feat(auth): 添加iframe轮播消息支持并优化地址簿和订单状态处理
- 添加HostWheelMessage类型定义和IFRAME_WHEEL消息常量 - 实现iframe轮播事件监听和消息发送功能 - 移除address.type.ts中的region和region_text字段 - 更新英文本地化文件中的completed状态为Approved - 添加订单状态通知相关的国际化文案 - 更新常量文件中的订单状态描述注释 - 在index.ts中添加HostWheelMessage和OrderStatusCode类型 - 修改record页面的订单状态处理逻辑 - 实现订单状态变更通知弹窗功能 - 优化地址簿获取地址文本的方法 - 修复地址列表API响应数据访问问题 - 添加playx.html测试页面和相关配置文件
This commit is contained in:
12
.cloudbase-sites/preview.json
Normal file
12
.cloudbase-sites/preview.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"pid": 62123,
|
||||||
|
"port": 17175,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"base": "/",
|
||||||
|
"framework": "vite-react",
|
||||||
|
"command": "/Users/jiaunun/.nvm/versions/node/v24.15.0/bin/node /Users/jiaunun/Desktop/JiaJun/palyX/node_modules/vite/bin/vite.js --host 0.0.0.0 --port 17175 --strictPort --base /",
|
||||||
|
"internalUrl": "http://127.0.0.1:17175/",
|
||||||
|
"logPath": "/Users/jiaunun/Desktop/JiaJun/palyX/.cloudbase-sites/logs/preview-1785130890070.log",
|
||||||
|
"cwd": "/Users/jiaunun/Desktop/JiaJun/palyX",
|
||||||
|
"startedAt": "2026-07-27T05:41:31.101Z"
|
||||||
|
}
|
||||||
105
public/playx.html
Normal file
105
public/playx.html
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
|
||||||
|
>
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
|
<title>PlayX</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100% - 80px);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background-color: red;
|
||||||
|
height: 80px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside {
|
||||||
|
background-color: blue;
|
||||||
|
height: 100%;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background-color: yellow;
|
||||||
|
height: 100%;
|
||||||
|
width: calc(100% - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>header</header>
|
||||||
|
<main>
|
||||||
|
<aside>aside</aside>
|
||||||
|
<section id="host-scroll-region">
|
||||||
|
<div id="iframe-container" style="height: 100%">
|
||||||
|
<iframe
|
||||||
|
id="playx-frame"
|
||||||
|
src="https://playx-mall.cjdhr.top"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<footer style="height: 80px; background-color: green;">footer</footer>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const frame = document.getElementById('playx-frame')
|
||||||
|
const iframeContainer = document.getElementById('iframe-container')
|
||||||
|
const hostScrollRegion = document.getElementById('host-scroll-region')
|
||||||
|
|
||||||
|
frame.addEventListener('load', () => {
|
||||||
|
iframeContainer.style.height = '100%'
|
||||||
|
hostScrollRegion.scrollTop = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
if (event.source !== frame.contentWindow) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === 'IFRAME_HEIGHT') {
|
||||||
|
const height = Number(event.data?.payload?.height)
|
||||||
|
|
||||||
|
if (!Number.isFinite(height) || height <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
iframeContainer.style.height = `${height}px`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === 'IFRAME_WHEEL') {
|
||||||
|
const downTotalY = Number(event.data?.payload?.downTotalY)
|
||||||
|
|
||||||
|
if (!Number.isFinite(downTotalY)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxScrollTop = Math.max(hostScrollRegion.scrollHeight - hostScrollRegion.clientHeight, 0)
|
||||||
|
hostScrollRegion.scrollTop = Math.min(Math.max(downTotalY, 0), maxScrollTop)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -330,6 +330,20 @@
|
|||||||
scheduleHeightLog(event.data)
|
scheduleHeightLog(event.data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.source === frame.contentWindow && event.data?.type === 'IFRAME_WHEEL') {
|
||||||
|
const downTotalY = Number(event.data?.payload?.downTotalY)
|
||||||
|
const upTotalY = Number(event.data?.payload?.upTotalY)
|
||||||
|
|
||||||
|
if (!Number.isFinite(downTotalY) || !Number.isFinite(upTotalY)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLog('Received IFRAME_WHEEL from iframe', {
|
||||||
|
downTotalY,
|
||||||
|
upTotalY,
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -35,5 +35,5 @@ export const HOME_GOOD_TYPE_ORDER = ['WITHDRAW', 'BONUS', 'PHYSICAL'] as const s
|
|||||||
|
|
||||||
export const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ?? 'https://playx-api.cjdhr.top'
|
export const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ?? 'https://playx-api.cjdhr.top'
|
||||||
|
|
||||||
/**@description 待处理 / 已完成 / 已发货 / 已驳回 */
|
/**@description 待处理 / 已通过 / 已发货 / 已拒绝 */
|
||||||
export const ORDER_STATUS = ['PENDING','COMPLETED','SHIPPED','REJECTED']
|
export const ORDER_STATUS = ['PENDING','COMPLETED','SHIPPED','REJECTED']
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ const emptyAddressForm: AddAddressForm = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAddressText(item: AddressListItem) {
|
export function getAddressText(item: AddressListItem) {
|
||||||
const regionText = item.region_text || item.region.map((part) => part.trim()).join(', ')
|
return item.detail_address.trim()
|
||||||
return [regionText, item.detail_address].filter(Boolean).join(', ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapAddressToOption(item: AddressListItem): AddressOption {
|
export function mapAddressToOption(item: AddressListItem): AddressOption {
|
||||||
@@ -56,7 +55,7 @@ export function useAddressBook(options?: UseAddressBookOptions) {
|
|||||||
enabled: Boolean(sessionId) && Boolean(options?.autoLoad),
|
enabled: Boolean(sessionId) && Boolean(options?.autoLoad),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await addressList({session_id: sessionId!})
|
const response = await addressList({session_id: sessionId!})
|
||||||
return response.data.list
|
return response.data?.list ?? []
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -108,7 +107,7 @@ export function useAddressBook(options?: UseAddressBookOptions) {
|
|||||||
queryKey: queryKeys.addressList(sessionId),
|
queryKey: queryKeys.addressList(sessionId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await addressList({session_id: sessionId})
|
const response = await addressList({session_id: sessionId})
|
||||||
return response.data.list
|
return response.data?.list ?? []
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import {userAssets} from '@/api/user.ts'
|
|||||||
import {normalizeLanguage} from '@/lib/i18n'
|
import {normalizeLanguage} from '@/lib/i18n'
|
||||||
import {queryKeys} from '@/lib/queryKeys.ts'
|
import {queryKeys} from '@/lib/queryKeys.ts'
|
||||||
import {useUserStore} from '@/store/user.ts'
|
import {useUserStore} from '@/store/user.ts'
|
||||||
import type {HostContextMessage} from '@/types'
|
import type {HostContextMessage, HostWheelMessage} from '@/types'
|
||||||
|
|
||||||
const HOST_READY_MESSAGE = 'IFRAME_READY'
|
const HOST_READY_MESSAGE = 'IFRAME_READY'
|
||||||
const HOST_HEIGHT_MESSAGE = 'IFRAME_HEIGHT'
|
const HOST_HEIGHT_MESSAGE = 'IFRAME_HEIGHT'
|
||||||
|
const HOST_WHEEL_MESSAGE = 'IFRAME_WHEEL'
|
||||||
const HOST_READY_INTERVAL = 500
|
const HOST_READY_INTERVAL = 500
|
||||||
const HOST_READY_RETRY_LIMIT = 20
|
const HOST_READY_RETRY_LIMIT = 20
|
||||||
const HOST_HEIGHT_REPORT_INTERVAL = 250
|
const HOST_HEIGHT_REPORT_INTERVAL = 250
|
||||||
@@ -240,5 +241,93 @@ export function AuthGuide({children}: PropsWithChildren) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.parent === window) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameId = 0
|
||||||
|
let hasPendingWheelUpdate = false
|
||||||
|
let virtualScrollY = 0
|
||||||
|
|
||||||
|
const measurePageHeight = () =>
|
||||||
|
Math.max(
|
||||||
|
document.body.scrollHeight,
|
||||||
|
document.documentElement.scrollHeight,
|
||||||
|
document.body.offsetHeight,
|
||||||
|
document.documentElement.offsetHeight,
|
||||||
|
)
|
||||||
|
|
||||||
|
const getPageHeight = () =>
|
||||||
|
Math.max(window.innerHeight, document.documentElement.clientHeight, 1)
|
||||||
|
|
||||||
|
const normalizeWheelDeltaY = (event: WheelEvent) => {
|
||||||
|
switch (event.deltaMode) {
|
||||||
|
case WheelEvent.DOM_DELTA_LINE:
|
||||||
|
return event.deltaY * 16
|
||||||
|
case WheelEvent.DOM_DELTA_PAGE:
|
||||||
|
return event.deltaY * getPageHeight()
|
||||||
|
case WheelEvent.DOM_DELTA_PIXEL:
|
||||||
|
default:
|
||||||
|
return event.deltaY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushWheelDelta = () => {
|
||||||
|
frameId = 0
|
||||||
|
|
||||||
|
if (!hasPendingWheelUpdate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const message: HostWheelMessage = {
|
||||||
|
type: HOST_WHEEL_MESSAGE,
|
||||||
|
payload: {
|
||||||
|
downTotalY: virtualScrollY,
|
||||||
|
upTotalY: Math.max(measurePageHeight() - virtualScrollY, 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[playx] posting wheel position to host:', message)
|
||||||
|
hasPendingWheelUpdate = false
|
||||||
|
window.parent.postMessage(message, '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleWheelPost = () => {
|
||||||
|
if (frameId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
frameId = window.requestAnimationFrame(flushWheelDelta)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
const deltaY = normalizeWheelDeltaY(event)
|
||||||
|
if (!Number.isFinite(deltaY) || deltaY === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageHeight = measurePageHeight()
|
||||||
|
const previousVirtualScrollY = virtualScrollY
|
||||||
|
virtualScrollY = Math.min(Math.max(virtualScrollY + deltaY, 0), pageHeight)
|
||||||
|
|
||||||
|
if (virtualScrollY === previousVirtualScrollY) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPendingWheelUpdate = true
|
||||||
|
scheduleWheelPost()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('wheel', handleWheel, {passive: true, capture: true})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('wheel', handleWheel, true)
|
||||||
|
if (frameId) {
|
||||||
|
window.cancelAnimationFrame(frameId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,10 +130,17 @@ const en = {
|
|||||||
},
|
},
|
||||||
statusLabel: {
|
statusLabel: {
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
completed: 'Completed',
|
completed: 'Approved',
|
||||||
shipped: 'Shipped',
|
shipped: 'Shipped',
|
||||||
rejected: 'Rejected',
|
rejected: 'Rejected',
|
||||||
},
|
},
|
||||||
|
statusNotice: {
|
||||||
|
approvedTitle: 'Order Approved',
|
||||||
|
rejectedTitle: 'Order Rejected',
|
||||||
|
approvedDescription: 'Your order has been approved.',
|
||||||
|
rejectedDescription: 'Your order has been rejected.',
|
||||||
|
acknowledge: 'Got it',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
sessionExpired: 'Session expired. Please log in again.',
|
sessionExpired: 'Session expired. Please log in again.',
|
||||||
|
|||||||
@@ -130,10 +130,17 @@ const ms = {
|
|||||||
},
|
},
|
||||||
statusLabel: {
|
statusLabel: {
|
||||||
pending: 'Menunggu',
|
pending: 'Menunggu',
|
||||||
completed: 'Selesai',
|
completed: 'Diluluskan',
|
||||||
shipped: 'Dihantar',
|
shipped: 'Dihantar',
|
||||||
rejected: 'Ditolak',
|
rejected: 'Ditolak',
|
||||||
},
|
},
|
||||||
|
statusNotice: {
|
||||||
|
approvedTitle: 'Pesanan Diluluskan',
|
||||||
|
rejectedTitle: 'Pesanan Ditolak',
|
||||||
|
approvedDescription: 'Pesanan anda telah diluluskan.',
|
||||||
|
rejectedDescription: 'Pesanan anda telah ditolak.',
|
||||||
|
acknowledge: 'Faham',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
sessionExpired: 'Sesi telah tamat. Sila log masuk semula.',
|
sessionExpired: 'Sesi telah tamat. Sila log masuk semula.',
|
||||||
|
|||||||
@@ -130,9 +130,16 @@ const zh = {
|
|||||||
},
|
},
|
||||||
statusLabel: {
|
statusLabel: {
|
||||||
pending: '待处理',
|
pending: '待处理',
|
||||||
completed: '已完成',
|
completed: '已通过',
|
||||||
shipped: '已发货',
|
shipped: '已发货',
|
||||||
rejected: '已驳回',
|
rejected: '已拒绝',
|
||||||
|
},
|
||||||
|
statusNotice: {
|
||||||
|
approvedTitle: '订单已通过',
|
||||||
|
rejectedTitle: '订单已拒绝',
|
||||||
|
approvedDescription: '订单已经通过。',
|
||||||
|
rejectedDescription: '订单已拒绝。',
|
||||||
|
acknowledge: '知道了',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
|
|||||||
@@ -12,14 +12,12 @@ export interface AddressInfo {
|
|||||||
export type AddressListItem = {
|
export type AddressListItem = {
|
||||||
id: number
|
id: number
|
||||||
phone: string
|
phone: string
|
||||||
region: string[]
|
|
||||||
detail_address: string
|
detail_address: string
|
||||||
default_setting: 0 | 1
|
default_setting: 0 | 1
|
||||||
create_time: number
|
create_time: number
|
||||||
update_time: number
|
update_time: number
|
||||||
playx_user_asset_id: number
|
playx_user_asset_id: number
|
||||||
receiver_name: string
|
receiver_name: string
|
||||||
region_text: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AddressListData = {
|
export type AddressListData = {
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ export type HostContextMessage = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HostWheelMessage = {
|
||||||
|
type: 'IFRAME_WHEEL'
|
||||||
|
payload: {
|
||||||
|
downTotalY: number
|
||||||
|
upTotalY: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ButtonVariant = 'orange' | 'gray'
|
export type ButtonVariant = 'orange' | 'gray'
|
||||||
|
|
||||||
export type ButtonProps = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>> & {
|
export type ButtonProps = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>> & {
|
||||||
@@ -94,6 +102,8 @@ export type AccountTableRow = {
|
|||||||
|
|
||||||
export type PointsRecordTone = 'positive' | 'negative'
|
export type PointsRecordTone = 'positive' | 'negative'
|
||||||
|
|
||||||
|
export type OrderStatusCode = 'PENDING' | 'COMPLETED' | 'SHIPPED' | 'REJECTED' | 'UNKNOWN'
|
||||||
|
|
||||||
export type OrderRecord = {
|
export type OrderRecord = {
|
||||||
id: string
|
id: string
|
||||||
orderNumber?: string
|
orderNumber?: string
|
||||||
@@ -103,6 +113,7 @@ export type OrderRecord = {
|
|||||||
title: string
|
title: string
|
||||||
trackingNumber?: string
|
trackingNumber?: string
|
||||||
status: string
|
status: string
|
||||||
|
statusCode: OrderStatusCode
|
||||||
points: string
|
points: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useState} from 'react'
|
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import {useQuery} from '@tanstack/react-query'
|
import {useQuery} from '@tanstack/react-query'
|
||||||
import {useTranslation} from 'react-i18next'
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import { cn } from '@/lib'
|
|||||||
import {MotionButton, MotionDiv, tapMotionPropsIcon, tapMotionPropsSoft} from '@/lib/motion'
|
import {MotionButton, MotionDiv, tapMotionPropsIcon, tapMotionPropsSoft} from '@/lib/motion'
|
||||||
import Modal from '@/components/modal'
|
import Modal from '@/components/modal'
|
||||||
import Button from '@/components/button'
|
import Button from '@/components/button'
|
||||||
import type { OrderCardProps, OrderRecord, PointsCardProps, RecordButtonType, TabButtonProps } from '@/types'
|
import type { OrderCardProps, OrderRecord, OrderStatusCode, PointsCardProps, RecordButtonType, TabButtonProps } from '@/types'
|
||||||
import {Link} from 'react-router-dom'
|
import {Link} from 'react-router-dom'
|
||||||
import { ArrowLeft, ChevronRight, Coins, PackageSearch } from 'lucide-react'
|
import { ArrowLeft, ChevronRight, Coins, PackageSearch } from 'lucide-react'
|
||||||
import {orders, pointsLogs} from '@/api/business.ts'
|
import {orders, pointsLogs} from '@/api/business.ts'
|
||||||
@@ -22,6 +22,16 @@ const pointsRecordToneClassName = {
|
|||||||
negative: 'bg-[#FF9BA4] text-[#7B2634]',
|
negative: 'bg-[#FF9BA4] text-[#7B2634]',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
const ORDER_POLL_INTERVAL_MS = 10_000
|
||||||
|
const EMPTY_ORDER_RECORDS: OrderRecord[] = []
|
||||||
|
|
||||||
|
type OrderStatusNotice = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
orderNumber?: string
|
||||||
|
statusCode: Extract<OrderStatusCode, 'COMPLETED' | 'REJECTED'>
|
||||||
|
}
|
||||||
|
|
||||||
function formatDatePart(value: number) {
|
function formatDatePart(value: number) {
|
||||||
return String(value).padStart(2, '0')
|
return String(value).padStart(2, '0')
|
||||||
}
|
}
|
||||||
@@ -59,48 +69,53 @@ function toTitleCase(value: string) {
|
|||||||
.join(' ')
|
.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrderStatus(status?: string | number) {
|
function normalizeOrderStatus(status?: string | number): OrderStatusCode {
|
||||||
if (typeof status === 'string') {
|
if (typeof status === 'string') {
|
||||||
const normalizedStatus = status.trim().toUpperCase()
|
const normalizedStatus = status.trim().toUpperCase()
|
||||||
const matchedStatus = ORDER_STATUS.find((item) => item === normalizedStatus)
|
if (ORDER_STATUS.includes(normalizedStatus)) {
|
||||||
if (matchedStatus) {
|
return normalizedStatus as OrderStatusCode
|
||||||
switch (matchedStatus) {
|
|
||||||
case 'PENDING':
|
|
||||||
return i18n.t('record.statusLabel.pending')
|
|
||||||
case 'COMPLETED':
|
|
||||||
return i18n.t('record.statusLabel.completed')
|
|
||||||
case 'SHIPPED':
|
|
||||||
return i18n.t('record.statusLabel.shipped')
|
|
||||||
case 'REJECTED':
|
|
||||||
return i18n.t('record.statusLabel.rejected')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedStatus = typeof status === 'string' && /^\d+$/.test(status)
|
const normalizedStatus = typeof status === 'string' && /^\d+$/.test(status.trim())
|
||||||
? Number(status)
|
? Number(status.trim())
|
||||||
: status
|
: status
|
||||||
|
|
||||||
if (typeof normalizedStatus === 'number') {
|
if (typeof normalizedStatus === 'number') {
|
||||||
switch (normalizedStatus) {
|
switch (normalizedStatus) {
|
||||||
case 0:
|
case 0:
|
||||||
return i18n.t('record.statusLabel.pending')
|
return 'PENDING'
|
||||||
case 1:
|
case 1:
|
||||||
return i18n.t('record.statusLabel.completed')
|
return 'COMPLETED'
|
||||||
case 2:
|
case 2:
|
||||||
return i18n.t('record.statusLabel.shipped')
|
return 'SHIPPED'
|
||||||
case 3:
|
case 3:
|
||||||
return i18n.t('record.statusLabel.rejected')
|
return 'REJECTED'
|
||||||
default:
|
default:
|
||||||
return String(normalizedStatus)
|
return 'UNKNOWN'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalizedStatus) {
|
return 'UNKNOWN'
|
||||||
return i18n.t('record.statusLabel.pending')
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return toTitleCase(normalizedStatus)
|
function getOrderStatusLabel(statusCode: OrderStatusCode, fallbackStatus?: string | number) {
|
||||||
|
switch (statusCode) {
|
||||||
|
case 'PENDING':
|
||||||
|
return i18n.t('record.statusLabel.pending')
|
||||||
|
case 'COMPLETED':
|
||||||
|
return i18n.t('record.statusLabel.completed')
|
||||||
|
case 'SHIPPED':
|
||||||
|
return i18n.t('record.statusLabel.shipped')
|
||||||
|
case 'REJECTED':
|
||||||
|
return i18n.t('record.statusLabel.rejected')
|
||||||
|
case 'UNKNOWN':
|
||||||
|
if (!fallbackStatus) {
|
||||||
|
return i18n.t('record.statusLabel.pending')
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof fallbackStatus === 'number' ? String(fallbackStatus) : toTitleCase(fallbackStatus)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrderCategory(item: OrderItem) {
|
function getOrderCategory(item: OrderItem) {
|
||||||
@@ -175,6 +190,7 @@ function mapOrderItemToRecord(item: OrderItem): OrderRecord {
|
|||||||
const { date, time } = getDateTimeParts(item.create_time ?? item.created_at)
|
const { date, time } = getDateTimeParts(item.create_time ?? item.created_at)
|
||||||
const orderNumber = item.external_transaction_id?.trim() || item.order_no ? String(item.external_transaction_id?.trim() || item.order_no) : undefined
|
const orderNumber = item.external_transaction_id?.trim() || item.order_no ? String(item.external_transaction_id?.trim() || item.order_no) : undefined
|
||||||
const points = getOrderPoints(item)
|
const points = getOrderPoints(item)
|
||||||
|
const statusCode = normalizeOrderStatus(item.status)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
@@ -184,7 +200,8 @@ function mapOrderItemToRecord(item: OrderItem): OrderRecord {
|
|||||||
category: getOrderCategory(item),
|
category: getOrderCategory(item),
|
||||||
title: item.item_title ?? item.title ?? item.mallItem?.title ?? i18n.t('record.untitledOrder'),
|
title: item.item_title ?? item.title ?? item.mallItem?.title ?? i18n.t('record.untitledOrder'),
|
||||||
trackingNumber: getTrackingNumber(item),
|
trackingNumber: getTrackingNumber(item),
|
||||||
status: getOrderStatus(item.status),
|
status: getOrderStatusLabel(statusCode, item.status),
|
||||||
|
statusCode,
|
||||||
points: points.display,
|
points: points.display,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,25 +261,19 @@ function mapPointsLogItemToRecord(item: PointsLogItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrderStatusClassName(status: string) {
|
function getOrderStatusClassName(statusCode: OrderStatusCode) {
|
||||||
const normalizedStatus = status.toLowerCase()
|
switch (statusCode) {
|
||||||
if (normalizedStatus === 'completed' || status === i18n.t('record.statusLabel.completed')) {
|
case 'COMPLETED':
|
||||||
return 'bg-[#9BFFC0] text-[#176640]'
|
return 'bg-[#9BFFC0] text-[#176640]'
|
||||||
|
case 'SHIPPED':
|
||||||
|
return 'bg-[#95F0FF] text-[#116A79]'
|
||||||
|
case 'PENDING':
|
||||||
|
return 'bg-[#FFF18C] text-[#7F6A0D]'
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'bg-[#FFB1C0] text-[#7C2941]'
|
||||||
|
case 'UNKNOWN':
|
||||||
|
return 'bg-white/15 text-white/80'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'shipped' || status === i18n.t('record.statusLabel.shipped')) {
|
|
||||||
return 'bg-[#95F0FF] text-[#116A79]'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedStatus === 'pending' || status === i18n.t('record.statusLabel.pending')) {
|
|
||||||
return 'bg-[#FFF18C] text-[#7F6A0D]'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedStatus === 'rejected' || status === i18n.t('record.statusLabel.rejected')) {
|
|
||||||
return 'bg-[#FFB1C0] text-[#7C2941]'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'bg-white/15 text-white/80'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
|
function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
|
||||||
@@ -314,7 +325,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-[6px] px-[8px] py-[3px] text-[11px] leading-none',
|
'rounded-[6px] px-[8px] py-[3px] text-[11px] leading-none',
|
||||||
getOrderStatusClassName(record.status),
|
getOrderStatusClassName(record.statusCode),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{record.status}
|
{record.status}
|
||||||
@@ -352,24 +363,87 @@ function PointsCard({ record }: PointsCardProps) {
|
|||||||
function OrdersTabContent({
|
function OrdersTabContent({
|
||||||
sessionId,
|
sessionId,
|
||||||
onOpenDetails,
|
onOpenDetails,
|
||||||
|
onOrdersChange,
|
||||||
|
onStatusNotices,
|
||||||
}: {
|
}: {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
onOpenDetails: (record: OrderRecord) => void
|
onOpenDetails: (record: OrderRecord) => void
|
||||||
|
onOrdersChange: (records: OrderRecord[]) => void
|
||||||
|
onStatusNotices: (notices: OrderStatusNotice[]) => void
|
||||||
}) {
|
}) {
|
||||||
const {t} = useTranslation()
|
const {t} = useTranslation()
|
||||||
|
const initializedRef = useRef(false)
|
||||||
|
const pendingOrderIdsRef = useRef<Set<string>>(new Set())
|
||||||
const ordersQuery = useQuery({
|
const ordersQuery = useQuery({
|
||||||
queryKey: queryKeys.orders(sessionId),
|
queryKey: queryKeys.orders(sessionId),
|
||||||
enabled: Boolean(sessionId),
|
enabled: Boolean(sessionId),
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
|
refetchInterval: sessionId ? ORDER_POLL_INTERVAL_MS : false,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await orders({
|
const response = await orders({
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (response.data.list ?? []).map(mapOrderItemToRecord)
|
return (response.data?.list ?? []).map(mapOrderItemToRecord)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const orderRecords = ordersQuery.data ?? []
|
const orderRecords = ordersQuery.data ?? EMPTY_ORDER_RECORDS
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initializedRef.current = false
|
||||||
|
pendingOrderIdsRef.current = new Set()
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ordersQuery.isSuccess) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onOrdersChange(orderRecords)
|
||||||
|
|
||||||
|
if (!initializedRef.current) {
|
||||||
|
pendingOrderIdsRef.current = new Set(
|
||||||
|
orderRecords
|
||||||
|
.filter((record) => record.statusCode === 'PENDING')
|
||||||
|
.map((record) => record.id),
|
||||||
|
)
|
||||||
|
initializedRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingOrderIds = pendingOrderIdsRef.current
|
||||||
|
const nextPendingOrderIds = new Set(pendingOrderIds)
|
||||||
|
const notices: OrderStatusNotice[] = []
|
||||||
|
|
||||||
|
orderRecords.forEach((record) => {
|
||||||
|
if (record.statusCode === 'PENDING') {
|
||||||
|
nextPendingOrderIds.add(record.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingOrderIds.has(record.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPendingOrderIds.delete(record.id)
|
||||||
|
|
||||||
|
if (record.statusCode === 'COMPLETED' || record.statusCode === 'REJECTED') {
|
||||||
|
notices.push({
|
||||||
|
id: record.id,
|
||||||
|
title: record.title,
|
||||||
|
orderNumber: record.orderNumber,
|
||||||
|
statusCode: record.statusCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
pendingOrderIdsRef.current = nextPendingOrderIds
|
||||||
|
|
||||||
|
if (notices.length) {
|
||||||
|
onStatusNotices(notices)
|
||||||
|
}
|
||||||
|
}, [orderRecords, ordersQuery.isSuccess, onOrdersChange, onStatusNotices])
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return (
|
return (
|
||||||
@@ -456,11 +530,31 @@ function RecordPage() {
|
|||||||
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
|
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
|
||||||
const [tab, setTab] = useState<RecordButtonType>('order')
|
const [tab, setTab] = useState<RecordButtonType>('order')
|
||||||
const [selectedOrder, setSelectedOrder] = useState<OrderRecord | null>(null)
|
const [selectedOrder, setSelectedOrder] = useState<OrderRecord | null>(null)
|
||||||
|
const [orderStatusNoticeQueue, setOrderStatusNoticeQueue] = useState<OrderStatusNotice[]>([])
|
||||||
|
const currentOrderStatusNotice = orderStatusNoticeQueue[0]
|
||||||
|
|
||||||
const handleCloseDetails = () => {
|
const handleCloseDetails = () => {
|
||||||
setSelectedOrder(null)
|
setSelectedOrder(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOrdersChange = useCallback((records: OrderRecord[]) => {
|
||||||
|
setSelectedOrder((currentOrder) => {
|
||||||
|
if (!currentOrder) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return records.find((record) => record.id === currentOrder.id) ?? currentOrder
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleStatusNotices = useCallback((notices: OrderStatusNotice[]) => {
|
||||||
|
setOrderStatusNoticeQueue((currentQueue) => [...currentQueue, ...notices])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCloseOrderStatusNotice = () => {
|
||||||
|
setOrderStatusNoticeQueue((currentQueue) => currentQueue.slice(1))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout contentClassName="flex h-[100svh] w-full flex-col overflow-hidden px-4 pb-8 sm:px-6 lg:px-8">
|
<PageLayout contentClassName="flex h-[100svh] w-full flex-col overflow-hidden px-4 pb-8 sm:px-6 lg:px-8">
|
||||||
<div className="mx-auto w-full max-w-[980px]">
|
<div className="mx-auto w-full max-w-[980px]">
|
||||||
@@ -510,7 +604,13 @@ function RecordPage() {
|
|||||||
<div className="mt-[14px] min-h-0 flex-1 overflow-y-auto">
|
<div className="mt-[14px] min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="mx-auto w-full max-w-[860px]">
|
<div className="mx-auto w-full max-w-[860px]">
|
||||||
{tab === 'order' ? (
|
{tab === 'order' ? (
|
||||||
<OrdersTabContent key="order" sessionId={sessionId} onOpenDetails={setSelectedOrder} />
|
<OrdersTabContent
|
||||||
|
key="order"
|
||||||
|
sessionId={sessionId}
|
||||||
|
onOpenDetails={setSelectedOrder}
|
||||||
|
onOrdersChange={handleOrdersChange}
|
||||||
|
onStatusNotices={handleStatusNotices}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PointsTabContent key="record" sessionId={sessionId} />
|
<PointsTabContent key="record" sessionId={sessionId} />
|
||||||
)}
|
)}
|
||||||
@@ -551,6 +651,38 @@ function RecordPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(currentOrderStatusNotice)}
|
||||||
|
title={currentOrderStatusNotice?.statusCode === 'COMPLETED'
|
||||||
|
? t('record.statusNotice.approvedTitle')
|
||||||
|
: t('record.statusNotice.rejectedTitle')}
|
||||||
|
onClose={handleCloseOrderStatusNotice}
|
||||||
|
className="max-w-[420px]"
|
||||||
|
bodyClassName="pt-[0px]"
|
||||||
|
footer={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="h-[36px] w-full sm:min-w-[94px] sm:w-auto"
|
||||||
|
onClick={handleCloseOrderStatusNotice}
|
||||||
|
>
|
||||||
|
{t('record.statusNotice.acknowledge')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{currentOrderStatusNotice ? (
|
||||||
|
<div className="mt-[10px] rounded-[10px] bg-[#1C1818]/78 px-[12px] py-[16px] text-[14px] leading-[1.65] text-white">
|
||||||
|
<div>
|
||||||
|
{currentOrderStatusNotice.statusCode === 'COMPLETED'
|
||||||
|
? t('record.statusNotice.approvedDescription')
|
||||||
|
: t('record.statusNotice.rejectedDescription')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[10px] text-white/58">
|
||||||
|
{currentOrderStatusNotice.orderNumber ?? currentOrderStatusNotice.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user