diff --git a/.cloudbase-sites/preview.json b/.cloudbase-sites/preview.json new file mode 100644 index 0000000..f382071 --- /dev/null +++ b/.cloudbase-sites/preview.json @@ -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" +} \ No newline at end of file diff --git a/public/playx.html b/public/playx.html new file mode 100644 index 0000000..78db8db --- /dev/null +++ b/public/playx.html @@ -0,0 +1,105 @@ + + + + + + + PlayX + + + +
header
+
+ +
+
+ +
+ +
+
+ + + + diff --git a/public/test-host.html b/public/test-host.html index b748abe..c9b79f3 100644 --- a/public/test-host.html +++ b/public/test-host.html @@ -330,6 +330,20 @@ scheduleHeightLog(event.data) 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, + }) + } }) diff --git a/src/constant/index.ts b/src/constant/index.ts index bb1d4f7..4504d7d 100644 --- a/src/constant/index.ts +++ b/src/constant/index.ts @@ -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' -/**@description 待处理 / 已完成 / 已发货 / 已驳回 */ +/**@description 待处理 / 已通过 / 已发货 / 已拒绝 */ export const ORDER_STATUS = ['PENDING','COMPLETED','SHIPPED','REJECTED'] diff --git a/src/features/addressBook/useAddressBook.ts b/src/features/addressBook/useAddressBook.ts index c79a04b..3b85f6a 100644 --- a/src/features/addressBook/useAddressBook.ts +++ b/src/features/addressBook/useAddressBook.ts @@ -22,8 +22,7 @@ const emptyAddressForm: AddAddressForm = { } export function getAddressText(item: AddressListItem) { - const regionText = item.region_text || item.region.map((part) => part.trim()).join(', ') - return [regionText, item.detail_address].filter(Boolean).join(', ') + return item.detail_address.trim() } export function mapAddressToOption(item: AddressListItem): AddressOption { @@ -56,7 +55,7 @@ export function useAddressBook(options?: UseAddressBookOptions) { enabled: Boolean(sessionId) && Boolean(options?.autoLoad), queryFn: async () => { 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), queryFn: async () => { const response = await addressList({session_id: sessionId}) - return response.data.list + return response.data?.list ?? [] }, }) diff --git a/src/features/authGuide.tsx b/src/features/authGuide.tsx index b67cbbb..3e72cf9 100644 --- a/src/features/authGuide.tsx +++ b/src/features/authGuide.tsx @@ -6,10 +6,11 @@ import {userAssets} from '@/api/user.ts' import {normalizeLanguage} from '@/lib/i18n' import {queryKeys} from '@/lib/queryKeys.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_HEIGHT_MESSAGE = 'IFRAME_HEIGHT' +const HOST_WHEEL_MESSAGE = 'IFRAME_WHEEL' const HOST_READY_INTERVAL = 500 const HOST_READY_RETRY_LIMIT = 20 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} } diff --git a/src/locale/en.ts b/src/locale/en.ts index e7a76cf..f58177c 100644 --- a/src/locale/en.ts +++ b/src/locale/en.ts @@ -130,10 +130,17 @@ const en = { }, statusLabel: { pending: 'Pending', - completed: 'Completed', + completed: 'Approved', shipped: 'Shipped', 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: { sessionExpired: 'Session expired. Please log in again.', diff --git a/src/locale/ms.ts b/src/locale/ms.ts index 679b76e..08902cd 100644 --- a/src/locale/ms.ts +++ b/src/locale/ms.ts @@ -130,10 +130,17 @@ const ms = { }, statusLabel: { pending: 'Menunggu', - completed: 'Selesai', + completed: 'Diluluskan', shipped: 'Dihantar', rejected: 'Ditolak', }, + statusNotice: { + approvedTitle: 'Pesanan Diluluskan', + rejectedTitle: 'Pesanan Ditolak', + approvedDescription: 'Pesanan anda telah diluluskan.', + rejectedDescription: 'Pesanan anda telah ditolak.', + acknowledge: 'Faham', + }, }, validation: { sessionExpired: 'Sesi telah tamat. Sila log masuk semula.', diff --git a/src/locale/zh.ts b/src/locale/zh.ts index 17dbc5d..9db9cc7 100644 --- a/src/locale/zh.ts +++ b/src/locale/zh.ts @@ -130,9 +130,16 @@ const zh = { }, statusLabel: { pending: '待处理', - completed: '已完成', + completed: '已通过', shipped: '已发货', - rejected: '已驳回', + rejected: '已拒绝', + }, + statusNotice: { + approvedTitle: '订单已通过', + rejectedTitle: '订单已拒绝', + approvedDescription: '订单已经通过。', + rejectedDescription: '订单已拒绝。', + acknowledge: '知道了', }, }, validation: { diff --git a/src/types/address.type.ts b/src/types/address.type.ts index 0dbb963..68cb3ef 100644 --- a/src/types/address.type.ts +++ b/src/types/address.type.ts @@ -12,14 +12,12 @@ export interface AddressInfo { export type AddressListItem = { id: number phone: string - region: string[] detail_address: string default_setting: 0 | 1 create_time: number update_time: number playx_user_asset_id: number receiver_name: string - region_text: string } export type AddressListData = { diff --git a/src/types/index.ts b/src/types/index.ts index cf66cf6..ad6e89f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 ButtonProps = PropsWithChildren> & { @@ -94,6 +102,8 @@ export type AccountTableRow = { export type PointsRecordTone = 'positive' | 'negative' +export type OrderStatusCode = 'PENDING' | 'COMPLETED' | 'SHIPPED' | 'REJECTED' | 'UNKNOWN' + export type OrderRecord = { id: string orderNumber?: string @@ -103,6 +113,7 @@ export type OrderRecord = { title: string trackingNumber?: string status: string + statusCode: OrderStatusCode points: string } diff --git a/src/views/record/index.tsx b/src/views/record/index.tsx index ee762b9..071d391 100644 --- a/src/views/record/index.tsx +++ b/src/views/record/index.tsx @@ -1,4 +1,4 @@ -import {useState} from 'react' +import {useCallback, useEffect, useRef, useState} from 'react' import {useQuery} from '@tanstack/react-query' import {useTranslation} from 'react-i18next' @@ -9,7 +9,7 @@ import { cn } from '@/lib' import {MotionButton, MotionDiv, tapMotionPropsIcon, tapMotionPropsSoft} from '@/lib/motion' import Modal from '@/components/modal' 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 { ArrowLeft, ChevronRight, Coins, PackageSearch } from 'lucide-react' import {orders, pointsLogs} from '@/api/business.ts' @@ -22,6 +22,16 @@ const pointsRecordToneClassName = { negative: 'bg-[#FF9BA4] text-[#7B2634]', } as const +const ORDER_POLL_INTERVAL_MS = 10_000 +const EMPTY_ORDER_RECORDS: OrderRecord[] = [] + +type OrderStatusNotice = { + id: string + title: string + orderNumber?: string + statusCode: Extract +} + function formatDatePart(value: number) { return String(value).padStart(2, '0') } @@ -59,48 +69,53 @@ function toTitleCase(value: string) { .join(' ') } -function getOrderStatus(status?: string | number) { +function normalizeOrderStatus(status?: string | number): OrderStatusCode { if (typeof status === 'string') { const normalizedStatus = status.trim().toUpperCase() - const matchedStatus = ORDER_STATUS.find((item) => item === normalizedStatus) - if (matchedStatus) { - 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') - } + if (ORDER_STATUS.includes(normalizedStatus)) { + return normalizedStatus as OrderStatusCode } } - const normalizedStatus = typeof status === 'string' && /^\d+$/.test(status) - ? Number(status) + const normalizedStatus = typeof status === 'string' && /^\d+$/.test(status.trim()) + ? Number(status.trim()) : status if (typeof normalizedStatus === 'number') { switch (normalizedStatus) { case 0: - return i18n.t('record.statusLabel.pending') + return 'PENDING' case 1: - return i18n.t('record.statusLabel.completed') + return 'COMPLETED' case 2: - return i18n.t('record.statusLabel.shipped') + return 'SHIPPED' case 3: - return i18n.t('record.statusLabel.rejected') + return 'REJECTED' default: - return String(normalizedStatus) + return 'UNKNOWN' } } - if (!normalizedStatus) { - return i18n.t('record.statusLabel.pending') - } + return 'UNKNOWN' +} - 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) { @@ -175,6 +190,7 @@ function mapOrderItemToRecord(item: OrderItem): OrderRecord { 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 points = getOrderPoints(item) + const statusCode = normalizeOrderStatus(item.status) return { id: String(item.id), @@ -184,7 +200,8 @@ function mapOrderItemToRecord(item: OrderItem): OrderRecord { category: getOrderCategory(item), title: item.item_title ?? item.title ?? item.mallItem?.title ?? i18n.t('record.untitledOrder'), trackingNumber: getTrackingNumber(item), - status: getOrderStatus(item.status), + status: getOrderStatusLabel(statusCode, item.status), + statusCode, points: points.display, } } @@ -244,25 +261,19 @@ function mapPointsLogItemToRecord(item: PointsLogItem) { } } -function getOrderStatusClassName(status: string) { - const normalizedStatus = status.toLowerCase() - if (normalizedStatus === 'completed' || status === i18n.t('record.statusLabel.completed')) { - return 'bg-[#9BFFC0] text-[#176640]' +function getOrderStatusClassName(statusCode: OrderStatusCode) { + switch (statusCode) { + case 'COMPLETED': + 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) { @@ -314,7 +325,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
{record.status} @@ -352,24 +363,87 @@ function PointsCard({ record }: PointsCardProps) { function OrdersTabContent({ sessionId, onOpenDetails, + onOrdersChange, + onStatusNotices, }: { sessionId: string onOpenDetails: (record: OrderRecord) => void + onOrdersChange: (records: OrderRecord[]) => void + onStatusNotices: (notices: OrderStatusNotice[]) => void }) { const {t} = useTranslation() + const initializedRef = useRef(false) + const pendingOrderIdsRef = useRef>(new Set()) const ordersQuery = useQuery({ queryKey: queryKeys.orders(sessionId), enabled: Boolean(sessionId), gcTime: 0, + refetchInterval: sessionId ? ORDER_POLL_INTERVAL_MS : false, + refetchIntervalInBackground: true, queryFn: async () => { const response = await orders({ 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) { return ( @@ -456,11 +530,31 @@ function RecordPage() { const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '') const [tab, setTab] = useState('order') const [selectedOrder, setSelectedOrder] = useState(null) + const [orderStatusNoticeQueue, setOrderStatusNoticeQueue] = useState([]) + const currentOrderStatusNotice = orderStatusNoticeQueue[0] const handleCloseDetails = () => { 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 (
@@ -510,7 +604,13 @@ function RecordPage() {
{tab === 'order' ? ( - + ) : ( )} @@ -551,6 +651,38 @@ function RecordPage() {
) : null} + + + {t('record.statusNotice.acknowledge')} + + } + > + {currentOrderStatusNotice ? ( +
+
+ {currentOrderStatusNotice.statusCode === 'COMPLETED' + ? t('record.statusNotice.approvedDescription') + : t('record.statusNotice.rejectedDescription')} +
+
+ {currentOrderStatusNotice.orderNumber ?? currentOrderStatusNotice.title} +
+
+ ) : null} +
) }