feat:新增多语言

This commit is contained in:
JiaJun
2026-04-14 17:27:52 +08:00
parent 8fcf0355b3
commit b2c7c8d362
31 changed files with 1952 additions and 378 deletions

View File

@@ -1,8 +1,12 @@
// import {useEffect} from 'react'
import {lazy, Suspense} from 'react'
import {lazy, Suspense, useEffect, useRef} from 'react'
import {useQueryClient} from '@tanstack/react-query'
import {useTranslation} from 'react-i18next'
import {BrowserRouter, Route, Routes} from 'react-router-dom'
import {AuthGuide} from '@/features/authGuide.tsx'
import {GlobalToast} from '@/features/notifications'
import i18n, {normalizeLanguage} from '@/lib/i18n'
import {useUserStore} from '@/store/user.ts'
// import type { HostContextMessage } from '@/types'
const HomePage = lazy(() => import('./views/home'))
@@ -11,6 +15,35 @@ const AccountPage = lazy(() => import('./views/account'))
const GoodsPage = lazy(() => import('./views/goods'))
function App() {
const {t} = useTranslation()
const queryClient = useQueryClient()
const language = useUserStore((state) => state.language)
const previousLanguageRef = useRef<string | null>(null)
useEffect(() => {
const normalizedLanguage = normalizeLanguage(language)
const previousLanguage = previousLanguageRef.current
void (async () => {
await i18n.changeLanguage(normalizedLanguage)
document.documentElement.lang = normalizedLanguage
if (previousLanguage && previousLanguage !== normalizedLanguage) {
await queryClient.cancelQueries()
await queryClient.invalidateQueries()
await queryClient.refetchQueries({type: 'active'})
}
previousLanguageRef.current = normalizedLanguage
})()
}, [language, queryClient])
// 开发/测试阶段如需跳过 iframe 传参,可设置:
// VITE_BYPASS_IFRAME_CONTEXT=true
// 项目会直接使用测试数据初始化:
// username: +60777777777
// language: zh
// useEffect(() => {
// const handleMessage = (event: MessageEvent<HostContextMessage>) => {
// const message = event.data
@@ -19,15 +52,14 @@ function App() {
// return
// }
//
// const {token, language} = message.payload
// const {language, username} = message.payload
//
// if (typeof username === 'string' && username.trim()) {
//
// if (typeof token === 'string' && token.trim()) {
// sessionStorage.setItem('host_token', token)
// }
//
// if (typeof language === 'string' && language.trim()) {
// sessionStorage.setItem('host_language', language)
// document.documentElement.lang = language
// // language 由 zustand 存储,供请求头直接读取
// }
// }
//
@@ -43,13 +75,13 @@ function App() {
// {
// type: 'IFRAME_CONTEXT',
// payload: {
// token: 'test-token-123',
// username: '+60777777777',
// language: 'zh-CN',
// },
// },
// window.location.origin
// )
// 父页面发送iframe
// 父页面推荐使用握手方式发送 iframe context
// <iframe
// id="palyx-frame"
// src="https://your-iframe-app.example.com"
@@ -60,18 +92,22 @@ function App() {
// const iframe = document.getElementById('palyx-frame')
// const IFRAME_ORIGIN = 'https://your-iframe-app.example.com'
//
// iframe.addEventListener('load', () => {
// iframe.contentWindow.postMessage(
// {
// type: 'IFRAME_CONTEXT',
// payload: {
// token: 'token',
// language: 'zh-CN',
// window.addEventListener('message', (event) => {
// if (event.source !== iframe.contentWindow || event.data?.type !== 'PLAYX_READY') {
// return
// }
//
// iframe.contentWindow.postMessage(
// {
// type: 'IFRAME_CONTEXT',
// payload: {
// username: '+60777777777',
// language: 'zh-CN',
// },
// },
// },
// window.location.origin
// )
// })
// IFRAME_ORIGIN
// )
// })
// </script>
return (
@@ -81,7 +117,7 @@ function App() {
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center text-[14px] text-white/68">
Loading page...
{t('app.loading')}
</div>
}
>

View File

@@ -1,5 +1,6 @@
import {Children} from 'react'
import i18n from '@/lib/i18n'
import { cn } from '@/lib'
import type { ModalProps } from '@/types'
@@ -30,7 +31,7 @@ export function Modal({
<div className="fixed inset-0 z-50 flex items-center justify-center p-[12px] sm:p-[16px]">
<button
type="button"
aria-label="Close modal"
aria-label={i18n.t('common.close')}
className="absolute inset-0 bg-[#050409]/65 backdrop-blur-[6px]"
onClick={handleOverlayClick}
/>

View File

@@ -8,25 +8,25 @@ import {
import type {goodsType} from '@/types/business.type.ts'
export type GoodCategoryMeta = {
name: string
ctaLabel: string
nameKey: string
ctaLabelKey: string
icon: LucideIcon
}
export const HOME_CATEGORY_META_MAP: Record<goodsType, GoodCategoryMeta> = {
WITHDRAW: {
name: 'Transfer to Platform',
ctaLabel: 'Transfer Now',
nameKey: 'goods.categories.WITHDRAW',
ctaLabelKey: 'goods.actions.WITHDRAW',
icon: ArrowRightLeft,
},
BONUS: {
name: 'Game Bonus',
ctaLabel: 'Redeem Bonus',
nameKey: 'goods.categories.BONUS',
ctaLabelKey: 'goods.actions.BONUS',
icon: Sparkles,
},
PHYSICAL: {
name: 'Physical Prizes',
ctaLabel: 'Claim Prize',
nameKey: 'goods.categories.PHYSICAL',
ctaLabelKey: 'goods.actions.PHYSICAL',
icon: Gift,
},
}

View File

@@ -1,37 +1,29 @@
import type {AddAddressForm} from '@/types'
import i18n from '@/lib/i18n'
type AddressValidationResult =
| { valid: true }
| { valid: false; message: string }
export function validateAddressFormSubmission(addressForm: AddAddressForm): AddressValidationResult {
const normalizedRegion = addressForm.region.filter((value) => value.trim())
if (!addressForm.name.trim()) {
return {
valid: false,
message: 'Please enter the receiver name.',
message: i18n.t('validation.pleaseEnterReceiverName'),
}
}
if (!addressForm.phone.trim()) {
return {
valid: false,
message: 'Please enter a reachable mobile number.',
}
}
if (normalizedRegion.length === 0) {
return {
valid: false,
message: 'Please select a region.',
message: i18n.t('validation.pleaseEnterReachablePhone'),
}
}
if (!addressForm.detailedAddress.trim()) {
return {
valid: false,
message: 'Please enter the detailed address.',
message: i18n.t('validation.pleaseEnterDetailedAddress'),
}
}

View File

@@ -16,7 +16,6 @@ type UseAddressBookOptions = {
const emptyAddressForm: AddAddressForm = {
name: '',
phone: '',
region: [],
detailedAddress: '',
isDefault: false,
}
@@ -40,7 +39,6 @@ export function mapAddressToForm(item: AddressListItem): AddAddressForm {
return {
name: item.receiver_name,
phone: item.phone,
region: item.region.map((part) => part.trim()).filter(Boolean).slice(0, 3),
detailedAddress: item.detail_address,
isDefault: item.default_setting === 1,
}
@@ -66,7 +64,7 @@ export function useAddressBook(options?: UseAddressBookOptions) {
session_id: sessionId!,
receiver_name: addressForm.name.trim(),
phone: addressForm.phone.trim(),
region: addressForm.region.join(', '),
region: editingAddress ? editingAddress.region.map((part) => part.trim()).filter(Boolean).join(', ') : '',
detail_address: addressForm.detailedAddress.trim(),
default_setting: addressForm.isDefault ? '1' : '0',
} as const

View File

@@ -1,21 +1,112 @@
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {type PropsWithChildren, useEffect} from 'react'
import {type PropsWithChildren, useEffect, useRef, useState} from 'react'
import {login, validateToken} from '@/api/auth.ts'
import {userAssets} from '@/api/user.ts'
import {useTranslation} from 'react-i18next'
import {queryKeys} from '@/lib/queryKeys.ts'
import {useUserStore} from '@/store/user.ts'
import type {HostContextMessage} from '@/types'
const HOST_READY_MESSAGE = 'PLAYX_READY'
const HOST_READY_INTERVAL = 500
const HOST_READY_RETRY_LIMIT = 20
const TEST_BOOTSTRAP_ENABLED = import.meta.env.VITE_BYPASS_IFRAME_CONTEXT === 'true'
const TEST_BOOTSTRAP_USERNAME = '+60777777777'
const TEST_BOOTSTRAP_LANGUAGE = 'zh'
export function AuthGuide({children}: PropsWithChildren) {
const {t} = useTranslation()
const queryClient = useQueryClient()
const [username, setUsername] = useState(TEST_BOOTSTRAP_ENABLED ? TEST_BOOTSTRAP_USERNAME : '')
const setUserInfo = useUserStore((state) => state.setUserInfo)
const setAuthInfo = useUserStore((state) => state.setAuthInfo)
const setAssetsInfo = useUserStore((state) => state.setAssetsInfo)
const setLanguage = useUserStore((state) => state.setLanguage)
const clearUserInfo = useUserStore((state) => state.clearUserInfo)
const hasHostContextRef = useRef(false)
const activeUsernameRef = useRef('')
useEffect(() => {
if (TEST_BOOTSTRAP_ENABLED) {
hasHostContextRef.current = true
activeUsernameRef.current = TEST_BOOTSTRAP_USERNAME
setLanguage(TEST_BOOTSTRAP_LANGUAGE)
return
}
const isEmbedded = window.parent !== window
const notifyParentReady = () => {
if (!isEmbedded || hasHostContextRef.current) {
return
}
window.parent.postMessage({type: HOST_READY_MESSAGE}, '*')
}
const handleMessage = (event: MessageEvent<HostContextMessage>) => {
if (isEmbedded && event.source !== window.parent) {
return
}
const message = event.data
if (!message || message.type !== 'IFRAME_CONTEXT' || !message.payload) {
return
}
const {language, username: nextUsername} = message.payload
if (typeof nextUsername === 'string' && nextUsername.trim()) {
const normalizedUsername = nextUsername.trim()
if (activeUsernameRef.current && activeUsernameRef.current !== normalizedUsername) {
clearUserInfo()
queryClient.removeQueries({queryKey: ['auth-bootstrap']})
}
hasHostContextRef.current = true
activeUsernameRef.current = normalizedUsername
setUsername(normalizedUsername)
}
if (typeof language === 'string' && language.trim()) {
setLanguage(language.trim())
}
}
window.addEventListener('message', handleMessage)
notifyParentReady()
const retryTimer = isEmbedded
? window.setInterval(() => {
notifyParentReady()
}, HOST_READY_INTERVAL)
: null
const stopRetryTimer = isEmbedded
? window.setTimeout(() => {
if (retryTimer != null) {
window.clearInterval(retryTimer)
}
}, HOST_READY_INTERVAL * HOST_READY_RETRY_LIMIT)
: null
return () => {
window.removeEventListener('message', handleMessage)
if (retryTimer != null) {
window.clearInterval(retryTimer)
}
if (stopRetryTimer != null) {
window.clearTimeout(stopRetryTimer)
}
}
}, [clearUserInfo, queryClient, setLanguage])
const authBootstrapQuery = useQuery({
queryKey: queryKeys.authBootstrap,
queryKey: queryKeys.authBootstrap(username),
enabled: Boolean(username),
queryFn: async () => {
const loginResponse = await login({username: '+60777777777'})
const loginResponse = await login({username})
const userInfo = loginResponse.data.userInfo
const validateResponse = await validateToken(userInfo.token)
const authInfo = validateResponse.data
@@ -50,10 +141,10 @@ export function AuthGuide({children}: PropsWithChildren) {
clearUserInfo()
}, [authBootstrapQuery.isError, clearUserInfo])
if (authBootstrapQuery.isPending) {
if (!username || authBootstrapQuery.isPending) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center text-[14px] text-white/68">
Loading account data...
{!username && !TEST_BOOTSTRAP_ENABLED ? t('auth.waitingForHostContext') : t('auth.loadingAccountData')}
</div>
)
}
@@ -62,8 +153,8 @@ export function AuthGuide({children}: PropsWithChildren) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center">
<div className="max-w-[420px] rounded-[14px] border border-white/10 bg-white/4 px-[18px] py-[16px]">
<div className="text-[16px] font-semibold text-white">Authentication failed</div>
<div className="mt-[8px] text-[13px] leading-[1.6] text-white/58">Please refresh and try again.</div>
<div className="text-[16px] font-semibold text-white">{t('auth.authenticationFailed')}</div>
<div className="mt-[8px] text-[13px] leading-[1.6] text-white/58">{t('auth.refreshAndTryAgain')}</div>
</div>
</div>
)

View File

@@ -1,4 +1,5 @@
import {useState} from 'react'
import {useTranslation} from 'react-i18next'
import {ChevronRight} from 'lucide-react'
@@ -20,6 +21,7 @@ function GoodsImage({
}: {
imageUrl?: string
}) {
const {t} = useTranslation()
const [hasError, setHasError] = useState(false)
const showFallback = !imageUrl || hasError
@@ -28,7 +30,7 @@ function GoodsImage({
{showFallback ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[radial-gradient(circle_at_top,rgba(250,106,0,0.18),transparent_58%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-[12px] text-center">
<div className="h-[30px] w-[30px] rounded-[10px] border border-white/10 bg-white/6"></div>
<div className="mt-[10px] text-[12px] tracking-[0.08em] text-white/42">NO IMAGE</div>
<div className="mt-[10px] text-[12px] tracking-[0.08em] text-white/42">{t('goods.noImage')}</div>
</div>
) : null}
@@ -47,16 +49,18 @@ function GoodsImage({
export function GoodsCategoryList({
categories,
loading = false,
emptyText = 'No goods available yet.',
emptyText,
showMore = false,
onMoreClick,
onRedeem,
}: GoodsCategoryListProps) {
const {t} = useTranslation()
const resolvedEmptyText = emptyText ?? t('goods.noGoodsAvailableYet')
if (loading) {
return (
<div className="pb-[24px] mt-[20px]">
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
Loading ...
{t('goods.loading')}
</div>
</div>
)
@@ -66,7 +70,7 @@ export function GoodsCategoryList({
return (
<div className="pb-[24px] mt-[20px]">
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{emptyText}
{resolvedEmptyText}
</div>
</div>
)
@@ -93,7 +97,7 @@ export function GoodsCategoryList({
className="flex shrink-0 items-center gap-[3px] text-[12px] font-light text-[#FA6A00] underline cursor-pointer"
onClick={() => onMoreClick(category.id)}
>
more
{t('common.more')}
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true"/>
</button>
) : null}

View File

@@ -2,13 +2,13 @@ import {
Check,
ChevronRight,
CirclePlus,
Gift,
MapPinHouse,
} from 'lucide-react'
import {useState} from 'react'
import {useTranslation} from 'react-i18next'
import Button from '@/components/button'
import Modal from '@/components/modal'
import {RegionPicker} from '@/features/goods/RegionPicker'
import type {
AddAddressForm,
AddressOption,
@@ -47,6 +47,38 @@ type GoodsRedeemModalProps = {
confirmText?: string
}
function SelectedProductImage({
imageUrl,
alt,
}: {
imageUrl?: string
alt: string
}) {
const {t} = useTranslation()
const [hasError, setHasError] = useState(false)
const showFallback = !imageUrl || hasError
return (
<div className="relative h-[54px] w-[54px] overflow-hidden rounded-[10px] shadow-[0_10px_18px_rgba(0,0,0,0.2)]">
{showFallback ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[radial-gradient(circle_at_top,rgba(250,106,0,0.18),transparent_58%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-[6px] text-center">
<div className="h-[14px] w-[14px] rounded-[4px] border border-white/10 bg-white/6"></div>
<div className="mt-[4px] text-[7px] tracking-[0.08em] text-white/42">{t('goods.noImage')}</div>
</div>
) : null}
{imageUrl ? (
<img
src={imageUrl}
alt={alt}
className={`h-full w-full object-cover ${showFallback ? 'hidden' : 'block'}`}
onError={() => setHasError(true)}
/>
) : null}
</div>
)
}
export function GoodsRedeemModal({
selectedProduct,
modalMode,
@@ -65,8 +97,9 @@ export function GoodsRedeemModal({
forceOpen = false,
formOnly = false,
titleOverride,
confirmText = 'Confirm',
confirmText,
}: GoodsRedeemModalProps) {
const {t} = useTranslation()
const selectedCategoryId = selectedProduct?.categoryId
const selectedProductData = selectedProduct?.product ?? null
const isPhysicalPrize = selectedCategoryId === 'PHYSICAL'
@@ -74,12 +107,12 @@ export function GoodsRedeemModal({
const isGameBonus = selectedCategoryId === 'BONUS'
const isPhysicalPrizeSelection = modalMode === 'select-address' && isPhysicalPrize
const modalTitle = modalMode === 'add-address'
? 'Add Shipping Address'
? t('goods.addShippingAddress')
: isTransferToPlatform
? 'Confirm Withdrawal'
? t('goods.confirmWithdrawal')
: isGameBonus
? 'Confirm Bonus Redemption'
: 'Confirm Physical Reward'
? t('goods.confirmBonusRedemption')
: t('goods.confirmPhysicalReward')
const modalMaxWidthClassName = isTransferToPlatform
? 'max-w-[620px]'
: isGameBonus
@@ -95,7 +128,7 @@ export function GoodsRedeemModal({
className="flex items-center justify-between gap-[10px] border-b border-white/10 px-[6px] pb-[16px]">
<div className="flex items-center gap-[8px] text-[18px] font-medium text-white">
<MapPinHouse className="h-[18px] w-[18px] text-[#FE9F00]" aria-hidden="true"/>
<span>Address Info</span>
<span>{t('goods.addressInfo')}</span>
</div>
<button
type="button"
@@ -113,7 +146,7 @@ export function GoodsRedeemModal({
}`}
></span>
</span>
<span>Default Address</span>
<span>{t('goods.defaultAddress')}</span>
</button>
</div>
@@ -121,47 +154,37 @@ export function GoodsRedeemModal({
<div
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Name<span className="text-[#FA6A00]">*</span>
{t('goods.name')}<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.name}
onChange={(event) => onChangeAddressForm('name', event.target.value)}
placeholder="Enter receiver's full name"
placeholder={t('goods.enterReceiverName')}
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
<div
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Phone Number<span className="text-[#FA6A00]">*</span>
{t('goods.phoneNumber')}<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.phone}
type="number"
onChange={(event) => onChangeAddressForm('phone', event.target.value)}
placeholder="Enter a reachable mobile number"
placeholder={t('goods.enterReachablePhone')}
className="input-no-spin bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
<div
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Region<span className="text-[#FA6A00]">*</span>
</label>
<RegionPicker
value={addressForm.region}
onChange={(value) => onChangeAddressForm('region', value)}
/>
</div>
<div
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Detailed Address<span className="text-[#FA6A00]">*</span>
{t('goods.detailedAddress')}<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.detailedAddress}
onChange={(event) => onChangeAddressForm('detailedAddress', event.target.value)}
placeholder="Enter detail address"
placeholder={t('goods.enterDetailAddress')}
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
@@ -187,7 +210,7 @@ export function GoodsRedeemModal({
onClick={modalMode === 'add-address' ? onBackToSelectAddress : onClose}
disabled={submitLoading}
>
Cancel
{t('common.cancel')}
</Button>
<Button
type="button"
@@ -197,7 +220,7 @@ export function GoodsRedeemModal({
onClick={onConfirm}
disabled={submitLoading || (modalMode === 'add-address' && !isAddAddressFormValid)}
>
{submitLoading ? 'Processing...' : confirmText}
{submitLoading ? t('common.processing') : (confirmText ?? t('common.confirm'))}
</Button>
</>
}
@@ -211,22 +234,22 @@ export function GoodsRedeemModal({
className="rounded-[14px] bg-[#1C1818]/80 px-[18px] py-[14px] shadow-[0_10px_30px_rgba(0,0,0,0.22)]">
<div className="divide-y divide-white/8">
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[18px]">Withdrawal Amount</div>
<div className="text-[18px]">{t('goods.withdrawalAmount')}</div>
<div className="text-[18px]">{getNumericValue(selectedProductData.title)}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[18px]">Points Required</div>
<div className="text-[18px]">{t('goods.pointsRequired')}</div>
<div className="text-[18px]">{getNumericValue(selectedProductData.score)}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[18px] underline decoration-[#1E90FF] underline-offset-[3px]">
Turnover Requirement
{t('goods.turnoverRequirement')}
</div>
<div className="text-[18px]">{getTurnoverRequirement(selectedProductData.subtitle)}</div>
</div>
</div>
<div className="pt-[10px] text-center text-[18px] text-white/45">
Submit withdrawal request?
{t('goods.submitWithdrawalRequest')}
</div>
</div>
) : isGameBonus ? (
@@ -234,15 +257,15 @@ export function GoodsRedeemModal({
className="rounded-[10px] bg-[#1C1818]/80 px-[16px] py-[8px] shadow-[0_10px_30px_rgba(0,0,0,0.22)]">
<div className="divide-y divide-white/8">
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[13px] text-white/78">Item</div>
<div className="text-[13px] text-white/78">{t('goods.item')}</div>
<div className="text-[14px]">{selectedProductData.title}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[13px] text-white/78">Points Required</div>
<div className="text-[13px] text-white/78">{t('goods.pointsRequired')}</div>
<div className="text-[14px]">{getNumericValue(selectedProductData.score)}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[13px] text-white/78">Turnover Requirement</div>
<div className="text-[13px] text-white/78">{t('goods.turnoverRequirement')}</div>
<div className="text-[14px]">{getTurnoverRequirement(selectedProductData.subtitle)}</div>
</div>
</div>
@@ -253,38 +276,29 @@ export function GoodsRedeemModal({
className="rounded-[12px] bg-[#1F1B1B]/82 px-[14px] py-[10px] shadow-[0_10px_28px_rgba(0,0,0,0.2)]">
<div
className="flex items-center justify-between gap-[12px] border-b border-white/8 py-[12px]">
<div className="text-[13px] text-white/82">Item</div>
<div className="text-[13px] text-white/82">{t('goods.item')}</div>
<div className="flex items-center gap-[10px]">
<div className="text-right text-[15px] text-white">{selectedProductData.title}</div>
<div className="h-[54px] w-[54px] overflow-hidden rounded-[10px] shadow-[0_10px_18px_rgba(0,0,0,0.2)]">
{selectedProductData.imageUrl ? (
<img
src={selectedProductData.imageUrl}
alt={selectedProductData.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Gift className="h-[24px] w-[24px] text-white" aria-hidden="true"/>
</div>
)}
</div>
<SelectedProductImage
imageUrl={selectedProductData.imageUrl}
alt={selectedProductData.title}
/>
</div>
</div>
<div className="flex items-center justify-between py-[12px]">
<div className="text-[13px] text-white/82">Points Cost</div>
<div className="text-[13px] text-white/82">{t('goods.pointsCost')}</div>
<div className="text-[15px] text-white">{getNumericValue(selectedProductData.score)}</div>
</div>
</div>
<div>
<div className="mb-[12px] text-[14px] text-white/40">
Please select the address information to fill in.
{t('goods.pleaseSelectAddressInfo')}
</div>
<div className="overflow-hidden rounded-[12px] bg-[#1F1B1B]/82 shadow-[0_10px_28px_rgba(0,0,0,0.2)]">
{addressLoading ? (
<div className="px-[14px] py-[16px] text-[14px] text-white/60">
Loading address list...
{t('goods.loadingAddressList')}
</div>
) : null}
{addressOptions.map((address) => {
@@ -316,7 +330,7 @@ export function GoodsRedeemModal({
{address.isDefault ? (
<div
className="rounded-[4px] bg-[#FF7F7F] px-[5px] py-[1px] text-[10px] leading-none text-white">
Default
{t('account.default')}
</div>
) : null}
</div>
@@ -339,7 +353,7 @@ export function GoodsRedeemModal({
className="flex h-[16px] w-[16px] items-center justify-center rounded-full border border-white/70 text-white">
<CirclePlus className="h-[12px] w-[12px]" aria-hidden="true"/>
</span>
<div className="text-[14px] text-white/82">Add Address</div>
<div className="text-[14px] text-white/82">{t('goods.addAddress')}</div>
</div>
<ChevronRight className="h-[16px] w-[16px] text-white/45" aria-hidden="true"/>
</button>

View File

@@ -1,4 +1,5 @@
import type {SelectedProductState} from '@/types'
import i18n from '@/lib/i18n'
type RedeemValidationParams = {
sessionId?: string
@@ -18,21 +19,21 @@ export function validateRedeemSubmission({
if (!sessionId) {
return {
valid: false,
message: 'Session expired. Please log in again.',
message: i18n.t('validation.sessionExpired'),
}
}
if (!selectedProduct) {
return {
valid: false,
message: 'No product selected.',
message: i18n.t('validation.noProductSelected'),
}
}
if (selectedProduct.categoryId === 'PHYSICAL' && !selectedAddressId) {
return {
valid: false,
message: 'Please select a shipping address.',
message: i18n.t('validation.pleaseSelectShippingAddress'),
}
}
@@ -53,7 +54,7 @@ export function validateAddAddressSubmission({
if (!isAddAddressFormValid) {
return {
valid: false,
message: 'Please complete all required address fields.',
message: i18n.t('validation.pleaseCompleteAddressFields'),
}
}

View File

@@ -1,6 +1,7 @@
import {useQuery} from '@tanstack/react-query'
import {goodList} from '@/api/business.ts'
import i18n from '@/lib/i18n'
import {
API_ORIGIN,
HOME_CATEGORY_META_MAP,
@@ -29,8 +30,8 @@ function mapGoodItemToProductItem(item: GoodsItem, type: goodsType): ProductItem
id: String(item.id),
title: item.title,
subtitle: item.description,
score: `${item.score} Points`,
ctaLabel: categoryMeta.ctaLabel,
score: `${item.score} ${i18n.t('common.points')}`,
ctaLabel: i18n.t(categoryMeta.ctaLabelKey),
imageUrl: getProductImageUrl(item.image),
}
}
@@ -38,7 +39,7 @@ function mapGoodItemToProductItem(item: GoodsItem, type: goodsType): ProductItem
function buildProductCategories(groups: Record<goodsType, GoodsItem[]>): ProductCategory[] {
return HOME_GOOD_TYPE_ORDER.map((type) => ({
id: type,
name: HOME_CATEGORY_META_MAP[type].name,
name: i18n.t(HOME_CATEGORY_META_MAP[type].nameKey),
items: groups[type].map((item) => mapGoodItemToProductItem(item, type)),
}))
}
@@ -75,12 +76,12 @@ export function useGoodsCatalog(options?: UseGoodsCatalogOptions) {
},
)
return buildProductCategories(groups)
return groups
},
})
return {
productCategories: goodsCatalogQuery.data ?? [],
productCategories: goodsCatalogQuery.data ? buildProductCategories(goodsCatalogQuery.data) : [],
loading: goodsCatalogQuery.isPending,
}
}

View File

@@ -4,6 +4,7 @@ import {useState} from 'react'
import {bonusRedeem, physicalRedeem, withdrawApply} from '@/api/business.ts'
import {useAddressBook} from '@/features/addressBook'
import {notifyError, notifySuccess} from '@/features/notifications'
import i18n from '@/lib/i18n'
import {queryKeys} from '@/lib/queryKeys.ts'
import {validateAddAddressSubmission, validateRedeemSubmission} from '@/features/goods/redeemValidation'
import type {
@@ -105,7 +106,7 @@ export function useGoodsRedeem() {
: null)
setSelectedAddressId(defaultOption?.id ?? '')
setModalMode('select-address')
notifySuccess(savedAddress.response, 'Address added successfully.')
notifySuccess(savedAddress.response, i18n.t('goods.addressAddedSuccessfully'))
}
return
}
@@ -129,7 +130,7 @@ export function useGoodsRedeem() {
? queryClient.invalidateQueries({queryKey: queryKeys.assets(addressBook.sessionId)})
: Promise.resolve(),
])
notifySuccess(response, 'Redeem request submitted successfully.')
notifySuccess(response, i18n.t('goods.redeemRequestSubmittedSuccessfully'))
closeRedeemModal()
} catch {
// mutation error is surfaced via redeemMutation.error

View File

@@ -1,14 +1,26 @@
import type {ValidateTokenData} from '@/types/auth.type.ts'
import type {UserAssetsData} from '@/types/user.type.ts'
import i18n from '@/lib/i18n'
type ClaimValidationResult =
| { valid: true }
| { valid: false; message: string }
export function validateClaimSubmission(authInfo: ValidateTokenData | null): ClaimValidationResult {
export function validateClaimSubmission(
authInfo: ValidateTokenData | null,
assetsInfo: UserAssetsData | null,
): ClaimValidationResult {
if (!authInfo?.session_id || !authInfo.user_id) {
return {
valid: false,
message: 'Session expired. Please log in again.',
message: i18n.t('validation.sessionExpired'),
}
}
if ((assetsInfo?.locked_points ?? 0) <= 0) {
return {
valid: false,
message: i18n.t('home.noClaimablePointsAvailable'),
}
}

View File

@@ -4,6 +4,18 @@ html,
body,
#root {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #08070e;
overscroll-behavior-y: none;
}
html {
scrollbar-width: none;
-ms-overflow-style: none;
}
body::-webkit-scrollbar,
*::-webkit-scrollbar {
display: none;
}
.input-no-spin::-webkit-outer-spin-button,

34
src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,34 @@
import i18n from 'i18next'
import {initReactI18next} from 'react-i18next'
import en from '@/message/en'
import zh from '@/message/zh'
import {useUserStore} from '@/store/user.ts'
export function normalizeLanguage(language?: string) {
const value = language?.trim().toLowerCase()
if (!value) {
return 'zh'
}
return value.startsWith('zh') ? 'zh' : 'en'
}
const initialLanguage = normalizeLanguage(useUserStore.getState().language)
void i18n
.use(initReactI18next)
.init({
resources: {
zh: {translation: zh},
en: {translation: en},
},
lng: initialLanguage,
fallbackLng: 'zh',
interpolation: {
escapeValue: false,
},
})
export default i18n

View File

@@ -1,7 +1,7 @@
import type {goodsType} from '@/types/business.type.ts'
export const queryKeys = {
authBootstrap: ['auth-bootstrap'] as const,
authBootstrap: (username: string) => ['auth-bootstrap', username] as const,
goodsCatalog: (types?: readonly goodsType[]) => ['goods-catalog', ...(types ?? ['all'])] as const,
assets: (sessionId: string) => ['assets', sessionId] as const,
addressList: (sessionId: string) => ['address-list', sessionId] as const,

View File

@@ -1,6 +1,7 @@
import ky, {HTTPError, type AfterResponseHook, type Input, type KyInstance, type Options} from 'ky'
import {notifyError, resolveToastMessage} from '@/features/notifications'
import i18n from '@/lib/i18n'
import {useUserStore} from '@/store/user.ts'
import type {ValidateTokenData} from '@/types/auth.type.ts'
import {objectToFormData} from './tool'
@@ -44,7 +45,17 @@ const isApiEnvelope = (value: unknown): value is ApiEnvelope =>
'code' in value &&
typeof (value as {code?: unknown}).code === 'number'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/'
const resolveApiBaseUrl = () => {
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL?.trim()
if (configuredBaseUrl) {
return `${configuredBaseUrl.replace(/\/+$/, '')}/api/`
}
return import.meta.env.PROD ? 'https://playx-api.cjdhr.top/api/' : '/api/'
}
const API_BASE_URL = resolveApiBaseUrl()
const REQUEST_TIMEOUT = 10_000
const AUTH_RETRY_HEADER = 'x-auth-retried'
const VERIFY_TOKEN_PATH = '/v1/mall/verifyToken'
@@ -56,12 +67,18 @@ export const setAccessTokenFormatter = (formatter?: TokenFormatter) => {
accessTokenFormatter = formatter ?? ((token) => `Bearer ${token}`)
}
const getRequestLanguage = () => useUserStore.getState().language?.trim() || 'zh'
const authRefreshClient = ky.create({
baseUrl: API_BASE_URL,
timeout: REQUEST_TIMEOUT,
retry: 0,
headers: {
lang: 'zh',
hooks: {
beforeRequest: [
({request}) => {
request.headers.set('lang', getRequestLanguage())
},
],
},
})
@@ -88,7 +105,7 @@ async function refreshAuthInfo() {
refreshAuthInfoPromise = (async () => {
const token = useUserStore.getState().userInfo?.token?.trim()
if (!token) {
throw new RequestError('Unauthorized')
throw new RequestError(i18n.t('errors.unauthorized'))
}
const response = await authRefreshClient.post('v1/mall/verifyToken', {
@@ -100,7 +117,7 @@ async function refreshAuthInfo() {
}>()
if (!response || (response.code !== 1 && response.code !== 200) || !response.data?.session_id) {
throw new RequestError(resolveToastMessage(response, 'Unauthorized'))
throw new RequestError(resolveToastMessage(response, i18n.t('errors.unauthorized')))
}
useUserStore.getState().setAuthInfo(response.data)
@@ -141,12 +158,11 @@ const requestClient = ky.create({
baseUrl: API_BASE_URL,
timeout: REQUEST_TIMEOUT,
retry: 0,
headers: {
lang: 'zh',
},
hooks: {
beforeRequest: [
({request}) => {
request.headers.set('lang', getRequestLanguage())
const token = useUserStore.getState().userInfo?.token
if (!token) {
return
@@ -165,7 +181,7 @@ const requestClient = ky.create({
error.data,
typeof error.data === 'string'
? error.data
: error.response.statusText || 'Request failed',
: error.response.statusText || i18n.t('errors.requestFailed'),
)
notifyError(message)
@@ -177,7 +193,7 @@ const requestClient = ky.create({
})
}
const message = error.message || 'Network request failed'
const message = error.message || i18n.t('errors.networkRequestFailed')
notifyError(message)
return new RequestError(message, {
cause: error,
@@ -219,7 +235,7 @@ async function parseResponse<T, R extends ResponseType>(
const payload = await response.json()
if (isApiEnvelope(payload) && payload.code !== 1 && payload.code !== 200) {
const message = payload.msg?.trim() || 'Request failed'
const message = payload.msg?.trim() || i18n.t('errors.requestFailed')
notifyError(message)
throw new RequestError(message, {
status: response.status,

View File

@@ -3,6 +3,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import '@/lib/i18n'
import { queryClient } from '@/lib/query.ts'
createRoot(document.getElementById('root')!).render(

154
src/message/en.ts Normal file
View File

@@ -0,0 +1,154 @@
const en = {
common: {
back: 'Back',
loading: 'Loading...',
loadingPage: 'Loading page...',
processing: 'Processing...',
cancel: 'Cancel',
confirm: 'Confirm',
close: 'Close',
noData: 'No Data',
points: 'Points',
more: 'more',
language: 'Language',
chinese: 'Chinese',
english: 'English',
},
auth: {
waitingForHostContext: 'Waiting for host context...',
loadingAccountData: 'Loading account data...',
authenticationFailed: 'Authentication failed',
refreshAndTryAgain: 'Please refresh and try again.',
},
nav: {
record: 'Record',
account: 'Account',
switchToChinese: '中文',
switchToEnglish: 'EN',
},
home: {
claimablePoints: 'Claimable Points',
claimDescription: "Yesterday's losses have been converted into points. Claim them to use in rewards.",
dailyClaimLimit: 'Daily Claim Limit',
claimed: 'Claimed',
availableForWithdrawal: 'Available for Withdrawal (Cash)',
cashUnit: 'CNY',
claimNow: 'Claim Now',
syncBalance: 'Sync Balance',
syncing: 'Syncing...',
confirmClaim: 'Confirm Claim',
confirmClaimDescription: 'After converting the points to be collected into usable points, they can be redeemed or withdrawn. Are you sure to claim it?',
balanceSyncedSuccessfully: 'Balance synced successfully.',
claimSubmittedSuccessfully: 'Claim submitted successfully.',
noClaimablePointsAvailable: 'No claimable points available.',
},
goods: {
categories: {
WITHDRAW: 'Transfer to Platform',
BONUS: 'Game Bonus',
PHYSICAL: 'Physical Prizes',
},
actions: {
WITHDRAW: 'Transfer Now',
BONUS: 'Redeem Bonus',
PHYSICAL: 'Claim Prize',
},
noImage: 'NO IMAGE',
loading: 'Loading...',
noGoodsAvailableYet: 'No goods available yet.',
noGoodsForCategory: 'No goods found for this category.',
confirmWithdrawal: 'Confirm Withdrawal',
confirmBonusRedemption: 'Confirm Bonus Redemption',
confirmPhysicalReward: 'Confirm Physical Reward',
addShippingAddress: 'Add Shipping Address',
editShippingAddress: 'Edit Shipping Address',
saveChanges: 'Save Changes',
addressInfo: 'Address Info',
defaultAddress: 'Default Address',
name: 'Name',
phoneNumber: 'Phone Number',
detailedAddress: 'Detailed Address',
enterReceiverName: "Enter receiver's full name",
enterReachablePhone: 'Enter a reachable mobile number',
enterDetailAddress: 'Enter detail address',
withdrawalAmount: 'Withdrawal Amount',
pointsRequired: 'Points Required',
turnoverRequirement: 'Turnover Requirement',
submitWithdrawalRequest: 'Submit withdrawal request?',
item: 'Item',
pointsCost: 'Points Cost',
pleaseSelectAddressInfo: 'Please select the address information to fill in.',
loadingAddressList: 'Loading address list...',
addAddress: 'Add Address',
selectShippingAddress: 'Please select a shipping address.',
addressAddedSuccessfully: 'Address added successfully.',
redeemRequestSubmittedSuccessfully: 'Redeem request submitted successfully.',
bonusRedeemSubmittedSuccessfully: 'Bonus redeem request submitted successfully.',
physicalPrize: 'Physical Reward',
},
account: {
title: 'Account',
myShippingAddress: 'My Shipping Address',
addAddress: 'Add Address',
loadingAddressList: 'Loading address list...',
noShippingAddressFound: 'No shipping address found. Add one.',
address: 'Address',
edit: 'Edit',
delete: 'Delete',
default: 'Default',
optional: 'Optional',
addressUpdatedSuccessfully: 'Address updated successfully.',
addressDeletedSuccessfully: 'Address deleted successfully.',
deleteAddress: 'Delete Address',
deleteAddressFor: 'Delete the address for {{name}}?',
},
record: {
title: 'Record',
myOrders: 'My Orders',
pointsRecord: 'Points Record',
loading: 'Loading...',
noData: 'No Data',
checkDetails: 'Check the details',
trackingNumber: 'Tracking Number',
orderDetails: 'Order Details',
orderNumber: 'Order Number',
orderTime: 'Order Time',
orderType: 'Order Type',
itemName: 'Item Name',
points: 'Points',
status: 'Status',
untitledOrder: 'Untitled Order',
pointsRecordFallback: 'Points Record',
categories: {
bonus: 'Game Bonus',
physical: 'Physical',
withdraw: 'Transfer to Platform',
order: 'Order',
},
statusLabel: {
pending: 'Pending',
completed: 'Completed',
shipped: 'Shipped',
rejected: 'Rejected',
},
},
validation: {
sessionExpired: 'Session expired. Please log in again.',
noProductSelected: 'No product selected.',
pleaseSelectShippingAddress: 'Please select a shipping address.',
pleaseCompleteAddressFields: 'Please complete all required address fields.',
pleaseEnterReceiverName: 'Please enter the receiver name.',
pleaseEnterReachablePhone: 'Please enter a reachable mobile number.',
pleaseEnterDetailedAddress: 'Please enter the detailed address.',
},
errors: {
unauthorized: 'Unauthorized',
requestFailed: 'Request failed',
networkRequestFailed: 'Network request failed',
},
app: {
loading: 'Loading...',
},
}
export default en

154
src/message/zh.ts Normal file
View File

@@ -0,0 +1,154 @@
const zh = {
common: {
back: '返回',
loading: '加载中...',
loadingPage: '页面加载中...',
processing: '处理中...',
cancel: '取消',
confirm: '确认',
close: '关闭',
noData: '暂无数据',
points: '积分',
more: '更多',
language: '语言',
chinese: '中文',
english: '英文',
},
auth: {
waitingForHostContext: '等待宿主上下文...',
loadingAccountData: '账户数据加载中...',
authenticationFailed: '鉴权失败',
refreshAndTryAgain: '请刷新后重试。',
},
nav: {
record: '记录',
account: '我的',
switchToChinese: '中文',
switchToEnglish: 'EN',
},
home: {
claimablePoints: '待领取积分',
claimDescription: '昨日亏损已转为积分,领取后即可兑换或提现。',
dailyClaimLimit: '今日可领取上限',
claimed: '已领取',
availableForWithdrawal: '目前可提现(现金)',
cashUnit: '元',
claimNow: '立即领取',
syncBalance: '同步额度',
syncing: '同步中...',
confirmClaim: '确认领取',
confirmClaimDescription: '待领取积分在转换为可用积分后,可用于兑换或提现。确认领取吗?',
balanceSyncedSuccessfully: '额度同步成功。',
claimSubmittedSuccessfully: '领取已提交成功。',
noClaimablePointsAvailable: '暂无可领取积分。',
},
goods: {
categories: {
WITHDRAW: '提现到平台',
BONUS: '游戏红利',
PHYSICAL: '实物大奖',
},
actions: {
WITHDRAW: '立即提现',
BONUS: '兑换红利',
PHYSICAL: '领取奖品',
},
noImage: '暂无图片',
loading: '加载中...',
noGoodsAvailableYet: '暂无可兑换商品。',
noGoodsForCategory: '该分类下暂无商品。',
confirmWithdrawal: '确认提现',
confirmBonusRedemption: '确认兑换红利',
confirmPhysicalReward: '确认实物奖励',
addShippingAddress: '新增收货地址',
editShippingAddress: '编辑收货地址',
saveChanges: '保存修改',
addressInfo: '地址信息',
defaultAddress: '默认地址',
name: '收货人',
phoneNumber: '电话',
detailedAddress: '详细地址',
enterReceiverName: '请输入收货人姓名',
enterReachablePhone: '请输入可联系手机号',
enterDetailAddress: '请输入详细地址',
withdrawalAmount: '提现金额',
pointsRequired: '所需积分',
turnoverRequirement: '流水要求',
submitWithdrawalRequest: '提交提现申请?',
item: '商品',
pointsCost: '积分消耗',
pleaseSelectAddressInfo: '请选择并填写地址信息。',
loadingAddressList: '地址列表加载中...',
addAddress: '新增地址',
selectShippingAddress: '请选择收货地址。',
addressAddedSuccessfully: '地址新增成功。',
redeemRequestSubmittedSuccessfully: '兑换申请提交成功。',
bonusRedeemSubmittedSuccessfully: '红利兑换申请已提交。',
physicalPrize: '实物奖励',
},
account: {
title: '我的',
myShippingAddress: '收货地址',
addAddress: '新增地址',
loadingAddressList: '地址列表加载中...',
noShippingAddressFound: '暂无收货地址,请先新增。',
address: '地址',
edit: '编辑',
delete: '删除',
default: '默认',
optional: '非默认',
addressUpdatedSuccessfully: '地址更新成功。',
addressDeletedSuccessfully: '地址删除成功。',
deleteAddress: '删除地址',
deleteAddressFor: '确认删除 {{name}} 的地址?',
},
record: {
title: '记录',
myOrders: '我的订单',
pointsRecord: '积分流水',
loading: '加载中...',
noData: '暂无数据',
checkDetails: '查看详情',
trackingNumber: '物流单号',
orderDetails: '订单详情',
orderNumber: '订单编号',
orderTime: '下单时间',
orderType: '订单类型',
itemName: '商品名称',
points: '积分',
status: '状态',
untitledOrder: '未命名订单',
pointsRecordFallback: '积分记录',
categories: {
bonus: '游戏红利',
physical: '实物奖励',
withdraw: '提现到平台',
order: '订单',
},
statusLabel: {
pending: '待处理',
completed: '已完成',
shipped: '已发货',
rejected: '已驳回',
},
},
validation: {
sessionExpired: '登录态已过期,请重新登录。',
noProductSelected: '未选择商品。',
pleaseSelectShippingAddress: '请选择收货地址。',
pleaseCompleteAddressFields: '请填写完整地址信息。',
pleaseEnterReceiverName: '请输入收货人姓名。',
pleaseEnterReachablePhone: '请输入可联系手机号。',
pleaseEnterDetailedAddress: '请输入详细地址。',
},
errors: {
unauthorized: '未授权',
requestFailed: '请求失败',
networkRequestFailed: '网络请求失败',
},
app: {
loading: '加载中...',
},
}
export default zh

670
src/origin.html Normal file
View File

@@ -0,0 +1,670 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PlayX 积分商城 - 原型</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f2f4f6; color: #1a1a1a; font-size: 16px; line-height: 1.5; }
a { color: #2563eb; text-decoration: none; cursor: pointer; }
a:hover { text-decoration: underline; }
a:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
main { margin: 0 auto; padding: 16px; padding-bottom: 100px; width: 100%; }
@media (min-width: 1024px) {
main { max-width: 960px; padding: 24px 32px; padding-bottom: 120px; }
}
@media (min-width: 1440px) {
main { max-width: 1120px; padding: 32px 40px; }
}
.page { display: none; }
.page.active { display: block; }
.card {
background: #fff; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.08);
padding: 16px; margin-bottom: 16px;
}
.card-title { font-size: 0.85rem; color: #6b7280; margin-bottom: 4px; }
.card-value { font-size: 1.5rem; font-weight: 700; color: #1a1a1a; }
.asset-grid { display: grid; gap: 16px; grid-template-columns: repeat(2, 1fr); }
@media (min-width: 1024px) { .asset-grid { grid-template-columns: repeat(2, 1fr); gap: 20px; } }
.progress-wrap { margin: 16px 0; }
.progress-bar { height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: #2563eb; border-radius: 4px; transition: width .3s; }
.progress-text { font-size: 0.85rem; color: #6b7280; margin-top: 4px; }
.btn {
display: inline-flex; align-items: center; justify-content: center;
padding: 10px 20px; border: none; border-radius: 8px; font-size: 0.95rem; cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.btn:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { background: #9ca3af; cursor: not-allowed; }
.btn-secondary { background: #f3f4f6; color: #374151; }
.btn-secondary:hover { background: #e5e7eb; }
.btn-block { width: 100%; }
.action-row { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px; }
.action-row .btn { flex: 1; min-width: 120px; }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid #e5e7eb; margin-bottom: 16px; overflow-x: auto; }
.tabs .tab { padding: 10px 16px; font-size: 0.9rem; color: #6b7280; cursor: pointer; white-space: nowrap; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.tabs .tab:hover { color: #374151; }
.tabs .tab.active { color: #2563eb; font-weight: 600; border-bottom-color: #2563eb; }
.product-grid { display: grid; gap: 16px; grid-template-columns: repeat(2, 1fr); }
.product-card {
background: #fff; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.08);
overflow: hidden; display: flex; flex-direction: column;
cursor: pointer; transition: box-shadow 0.2s;
}
.product-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.12); }
.product-card .thumb { height: 120px; background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%); display: flex; align-items: center; justify-content: center; font-size: 2rem; color: #4f46e5; }
.product-card .body { padding: 12px; flex: 1; }
.product-card .name { font-weight: 600; margin-bottom: 4px; }
.product-card .meta { font-size: 0.85rem; color: #6b7280; margin-bottom: 8px; }
.product-card .points { color: #2563eb; font-weight: 700; }
.product-card .btn { margin-top: auto; }
.order-list { display: flex; flex-direction: column; gap: 12px; }
.order-item {
background: #fff; border-radius: 8px; padding: 12px 16px; box-shadow: 0 1px 2px rgba(0,0,0,.06);
display: grid; gap: 8px; cursor: pointer;
}
.order-item .row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; }
.order-item .type { font-size: 0.8rem; color: #6b7280; }
.order-item .status { font-size: 0.85rem; padding: 2px 8px; border-radius: 4px; }
.order-item .order-detail-link { font-size: 0.85rem; color: #2563eb; margin-top: 6px; cursor: pointer; }
.order-item .order-detail-link:hover { text-decoration: underline; }
.status-pending { background: #fef3c7; color: #92400e; }
.status-done { background: #d1fae5; color: #065f46; }
.status-shipped { background: #dbeafe; color: #1e40af; }
.status-rejected { background: #fee2e2; color: #991b1b; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 0.9rem; margin-bottom: 4px; color: #374151; }
.form-group input, .form-group textarea { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.95rem; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 200; display: none; align-items: center; justify-content: center; padding: 16px; }
.modal-overlay.show { display: flex; }
.modal { background: #fff; border-radius: 12px; max-width: 400px; width: 100%; max-height: 90vh; overflow-y: auto; }
.modal .modal-head { padding: 16px; border-bottom: 1px solid #e5e7eb; font-weight: 600; }
.modal .modal-body { padding: 16px; }
.modal .modal-foot { padding: 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
.flow-list { display: flex; flex-direction: column; gap: 8px; }
.flow-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: #fff; border-radius: 8px; font-size: 0.9rem; }
.flow-item .desc { color: #374151; }
.flow-item .points { font-weight: 600; }
.flow-item .points.plus { color: #059669; }
.flow-item .points.minus { color: #dc2626; }
.flow-item .time { font-size: 0.8rem; color: #9ca3af; }
.confirm-summary { background: #f9fafb; border-radius: 8px; padding: 12px; margin-bottom: 16px; font-size: 0.9rem; }
.confirm-summary .row { display: flex; justify-content: space-between; margin-bottom: 4px; }
.order-detail-dl { margin: 0; font-size: 0.9rem; }
.order-detail-dl dt { color: #6b7280; margin-top: 10px; margin-bottom: 2px; }
.order-detail-dl dt:first-child { margin-top: 0; }
.order-detail-dl dd { margin: 0; }
.withdraw-hero {
background: linear-gradient(135deg, #1e40af 0%, #2563eb 100%);
color: #fff; border-radius: 12px; padding: 24px; margin-bottom: 20px;
text-align: center; box-shadow: 0 4px 14px rgba(37, 99, 235, .35);
}
.withdraw-hero .label { font-size: 0.95rem; opacity: .9; margin-bottom: 4px; }
.withdraw-hero .amount { font-size: 2.5rem; font-weight: 800; letter-spacing: 1px; }
.withdraw-hero .unit { font-size: 1rem; font-weight: 600; margin-left: 4px; opacity: .95; }
.home-top { display: block; }
@media (min-width: 1024px) {
.home-top { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: start; }
.home-top .asset-grid { grid-template-columns: 1fr; }
}
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding-left: 2px; }
.section-title { font-size: 1rem; font-weight: 600; color: #374151; margin: 0; }
.section-more { font-size: 0.9rem; color: #2563eb; cursor: pointer; }
.section-more:hover { text-decoration: underline; }
.section-more:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.home-nav { text-align: right; padding: 6px 0 10px; font-size: 0.8rem; }
.home-nav a { color: #9ca3af; margin-left: 12px; }
.home-nav a:hover { color: #6b7280; text-decoration: none; }
.home-nav a:first-child { margin-left: 0; }
.page-head { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.page-head .back { font-size: 0.9rem; color: #2563eb; cursor: pointer; }
.page-head .back:hover { text-decoration: underline; }
.addr-list { display: flex; flex-direction: column; gap: 12px; }
.addr-card { background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.addr-card .name { font-weight: 600; margin-bottom: 4px; }
.addr-card .detail { font-size: 0.9rem; color: #6b7280; }
.addr-card .btn-addr { margin-top: 8px; font-size: 0.85rem; color: #2563eb; cursor: pointer; }
</style>
</head>
<body>
<!-- 加载/鉴权态原型演示用1.5秒后自动进入首页) -->
<div id="page-auth" class="page active" style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;padding:24px">
<div class="card" style="text-align:center;max-width:320px">
<div class="card-title" style="margin-bottom:12px">连接中</div>
<p style="color:#6b7280;font-size:0.9rem">正在验证身份...</p>
<p style="margin-top:12px;font-size:0.85rem;color:#9ca3af" id="authHint">演示1.5 秒后自动进入</p>
<a href="#" id="authFailLink" style="display:none;margin-top:12px;font-size:0.9rem">连接超时,请重新登录游戏</a>
</div>
</div>
<main>
<!-- 首页(含资产 + 兑换,无 Tab -->
<section id="page-home" class="page">
<div class="home-nav" aria-label="辅助导航">
<a href="#" data-page="records">记录</a>
<a href="#" data-page="profile">我的</a>
</div>
<div class="home-top">
<div class="withdraw-hero">
<div class="label">目前可提现(现金)</div>
<div class="amount" id="withdrawAmount">0<span class="unit"></span></div>
</div>
<div class="asset-grid">
<div class="card">
<div class="card-title">待领取积分</div>
<div class="card-value" id="lockedPoints">0</div>
<div class="card-title" style="margin-top:6px">昨日亏损已转为积分,领取后即可使用</div>
</div>
<div class="card">
<div class="card-title">今日可领取上限</div>
<div class="card-value" id="todayLimit">0</div>
<div class="progress-wrap">
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
<div class="progress-text" id="progressText">已领取 0 / 0</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="action-row">
<button type="button" class="btn btn-primary btn-block" id="btnClaim">立即领取</button>
<button type="button" class="btn btn-secondary" id="btnSync">同步额度</button>
</div>
</div>
<div class="section-header">
<h2 class="section-title">提现到平台</h2>
<a href="#" class="section-more" data-page="product-list" data-category="withdraw">更多</a>
</div>
<div class="product-grid" id="listWithdraw"></div>
<div class="section-header">
<h2 class="section-title">游戏红利</h2>
<a href="#" class="section-more" data-page="product-list" data-category="bonus">更多</a>
</div>
<div class="product-grid" id="listBonus"></div>
<div class="section-header">
<h2 class="section-title">实物大奖</h2>
<a href="#" class="section-more" data-page="product-list" data-category="physical">更多</a>
</div>
<div class="product-grid" id="listPhysical"></div>
</section>
<!-- 兑换确认 -->
<section id="page-confirm" class="page">
<div class="card">
<div class="modal-head" id="confirmTitle">确认兑换</div>
<div class="confirm-summary" id="confirmSummary"></div>
<div id="confirmAddressForm" style="display:none">
<div class="form-group"><label>收货人</label><input type="text" id="inputName" placeholder="姓名"> </div>
<div class="form-group"><label>电话</label><input type="tel" id="inputPhone" placeholder="手机号"> </div>
<div class="form-group"><label>地址</label><textarea id="inputAddr" rows="2" placeholder="详细地址"></textarea> </div>
</div>
<div class="action-row">
<button type="button" class="btn btn-secondary" id="btnConfirmBack">返回</button>
<button type="button" class="btn btn-primary" id="btnConfirmSubmit">提交</button>
</div>
</div>
</section>
<!-- 记录 -->
<section id="page-records" class="page">
<div class="page-head"><a href="#" class="back" data-page="home">返回</a><span>记录</span></div>
<div class="tabs">
<span class="tab active" data-record-tab="orders">我的订单</span>
<span class="tab" data-record-tab="flow">积分流水</span>
</div>
<div id="record-orders">
<div class="order-list" id="orderList"></div>
</div>
<div id="record-flow" style="display:none">
<div class="flow-list" id="flowList"></div>
</div>
</section>
<!-- 可兑换产品列表 -->
<section id="page-product-list" class="page">
<div class="page-head">
<a href="#" class="back" data-page="home">返回</a>
<span id="productListTitle">可兑换产品</span>
</div>
<div class="product-grid" id="listProductList"></div>
</section>
<!-- 我的(收货地址等) -->
<section id="page-profile" class="page">
<div class="page-head"><a href="#" class="back" data-page="home">返回</a><span>我的</span></div>
<div class="card">
<div class="card-title" style="margin-bottom:8px">收货地址</div>
<div class="addr-list" id="addrList"></div>
<button type="button" class="btn btn-secondary" id="btnAddAddr" style="margin-top:12px">新增地址</button>
</div>
</section>
</main>
<div class="modal-overlay" id="modalConfirm">
<div class="modal">
<div class="modal-head">确认领取</div>
<div class="modal-body">将待领取积分划转为可用积分后,即可兑换或提现。确定领取?</div>
<div class="modal-foot">
<button type="button" class="btn btn-secondary" id="modalConfirmCancel">取消</button>
<button type="button" class="btn btn-primary" id="modalConfirmOk">确定</button>
</div>
</div>
</div>
<div class="modal-overlay" id="modalResult">
<div class="modal">
<div class="modal-head">提示</div>
<div class="modal-body" id="modalResultText">领取成功,积分已到账</div>
<div class="modal-foot">
<button type="button" class="btn btn-primary" id="modalResultOk">确定</button>
</div>
</div>
</div>
<div class="modal-overlay" id="modalOrderDetail">
<div class="modal">
<div class="modal-head">订单详情</div>
<div class="modal-body">
<dl class="order-detail-dl" id="orderDetailBody"></dl>
</div>
<div class="modal-foot">
<button type="button" class="btn btn-primary" id="modalOrderDetailClose">关闭</button>
</div>
</div>
</div>
<div class="modal-overlay" id="modalWithdrawConfirm">
<div class="modal">
<div class="modal-head">确认提现</div>
<div class="modal-body">
<div class="confirm-summary" id="withdrawConfirmSummary"></div>
<p style="margin:0;font-size:0.9rem;color:#6b7280">确定提交提现申请?</p>
</div>
<div class="modal-foot">
<button type="button" class="btn btn-secondary" id="modalWithdrawCancel">取消</button>
<button type="button" class="btn btn-primary" id="modalWithdrawOk">确定</button>
</div>
</div>
</div>
<script>
(function () {
var demo = {
user: { username: 'demo_user_01' },
assets: {
lockedPoints: 2880,
availablePoints: 1520,
todayLimit: 1500,
todayClaimed: 800
},
bonus: [
{ id: 'b1', name: '每日回馈 50', points: 500, amount: 50, multiplier: 1, desc: '1倍流水' },
{ id: 'b2', name: '周礼包 200', points: 1800, amount: 200, multiplier: 3, desc: '3倍流水' },
{ id: 'b3', name: '月礼包 500', points: 4000, amount: 500, multiplier: 5, desc: '5倍流水' }
],
physical: [
{ id: 'p1', name: '蓝牙耳机', points: 1200, stock: 30 },
{ id: 'p2', name: '运动手环', points: 2500, stock: 15 },
{ id: 'p3', name: '品牌背包', points: 3800, stock: 8 }
],
withdraw: [
{ id: 'w1', name: '提现 100', points: 1000, amount: 100, multiplier: 1 },
{ id: 'w2', name: '提现 500', points: 4500, amount: 500, multiplier: 2 }
],
orders: [
{ id: 'o1', orderNo: 'ORD202503040001', time: '2025-03-04 10:20', type: '红利', name: '每日回馈 50', points: 500, status: 'done', statusText: '已发放' },
{ id: 'o2', orderNo: 'ORD202503030002', time: '2025-03-03 14:00', type: '实物', name: '蓝牙耳机', points: 1200, status: 'shipped', statusText: '已发货', tracking: 'SF1234567890' },
{ id: 'o3', orderNo: 'ORD202503020003', time: '2025-03-02 09:15', type: '提现', name: '提现 100', points: 1000, status: 'done', statusText: '已发放' },
{ id: 'o4', orderNo: 'ORD202503010004', time: '2025-03-01 16:30', type: '红利', name: '周礼包 200', points: 1800, status: 'pending', statusText: '处理中' },
{ id: 'o5', orderNo: 'ORD202502280005', time: '2025-02-28 11:00', type: '实物', name: '运动手环', points: 2500, status: 'rejected', statusText: '已驳回', rejectReason: '收货地址有误,请核实后重新申请' }
],
flow: [
{ time: '2025-03-04 10:20', desc: '红利兑换 - 每日回馈 50', change: -500 },
{ time: '2025-03-04 09:00', desc: '领取昨日保障金', change: 800 },
{ time: '2025-03-03 14:00', desc: '实物兑换 - 蓝牙耳机', change: -1200 },
{ time: '2025-03-03 09:00', desc: '领取昨日保障金', change: 700 },
{ time: '2025-03-02 09:15', desc: '提现到平台 - 100', change: -1000 }
],
addresses: [
{ id: 'a1', name: '张三', phone: '138****8001', addr: '广东省深圳市南山区某某路 1 号' },
{ id: 'a2', name: '李四', phone: '139****9002', addr: '北京市朝阳区某某大厦 5 层' }
]
};
function renderAssets() {
var a = demo.assets;
var cashYuan = (a.availablePoints / 10).toFixed(0);
document.getElementById('withdrawAmount').innerHTML = Number(cashYuan).toLocaleString() + '<span class="unit">元</span>';
document.getElementById('lockedPoints').textContent = a.lockedPoints.toLocaleString();
document.getElementById('todayLimit').textContent = a.todayLimit.toLocaleString();
var pct = a.todayLimit ? Math.min(100, (a.todayClaimed / a.todayLimit) * 100) : 0;
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressText').textContent = '已领取 ' + a.todayClaimed + ' / ' + a.todayLimit;
}
function cardBonus(item) {
return '<div class="product-card">' +
'<div class="thumb">' + item.amount + '</div>' +
'<div class="body"><div class="name">' + item.name + '</div>' +
'<div class="meta">' + item.desc + ' | 金额 ' + item.amount + '</div>' +
'<div class="points">' + item.points + ' 积分</div>' +
'<button type="button" class="btn btn-primary btn-block btn-exchange" data-type="bonus" data-id="' + item.id + '">兑换</button></div></div>';
}
function cardPhysical(item) {
return '<div class="product-card">' +
'<div class="thumb">实物</div>' +
'<div class="body"><div class="name">' + item.name + '</div>' +
'<div class="meta">库存 ' + item.stock + '</div>' +
'<div class="points">' + item.points + ' 积分</div>' +
'<button type="button" class="btn btn-primary btn-block btn-exchange" data-type="physical" data-id="' + item.id + '">兑换</button></div></div>';
}
function cardWithdraw(item) {
return '<div class="product-card">' +
'<div class="thumb">提现</div>' +
'<div class="body"><div class="name">' + item.name + '</div>' +
'<div class="meta">' + item.multiplier + ' 倍流水</div>' +
'<div class="points">' + item.points + ' 积分</div>' +
'<button type="button" class="btn btn-primary btn-block btn-exchange" data-type="withdraw" data-id="' + item.id + '">提现</button></div></div>';
}
function renderProducts(context, category) {
var bonusList = category ? (category === 'bonus' ? demo.bonus : []) : demo.bonus;
var physicalList = category ? (category === 'physical' ? demo.physical : []) : demo.physical;
var withdrawList = category ? (category === 'withdraw' ? demo.withdraw : []) : demo.withdraw;
if (context === 'home') {
bonusList = demo.bonus.slice(0, 2);
physicalList = demo.physical.slice(0, 2);
withdrawList = demo.withdraw.slice(0, 2);
}
if (context === 'product-list') {
var all = [];
if (category === 'bonus') all = demo.bonus.map(function (i) { return { type: 'bonus', item: i }; });
else if (category === 'physical') all = demo.physical.map(function (i) { return { type: 'physical', item: i }; });
else if (category === 'withdraw') all = demo.withdraw.map(function (i) { return { type: 'withdraw', item: i }; });
else all = demo.bonus.map(function (i) { return { type: 'bonus', item: i }; }).concat(demo.physical.map(function (i) { return { type: 'physical', item: i }; })).concat(demo.withdraw.map(function (i) { return { type: 'withdraw', item: i }; }));
var html = all.map(function (x) {
if (x.type === 'bonus') return cardBonus(x.item);
if (x.type === 'physical') return cardPhysical(x.item);
return cardWithdraw(x.item);
}).join('');
document.getElementById('listProductList').innerHTML = html;
return;
}
document.getElementById('listBonus').innerHTML = bonusList.map(cardBonus).join('');
document.getElementById('listPhysical').innerHTML = physicalList.map(cardPhysical).join('');
document.getElementById('listWithdraw').innerHTML = withdrawList.map(cardWithdraw).join('');
}
function renderAddresses() {
var html = demo.addresses.map(function (a) {
return '<div class="addr-card"><div class="name">' + a.name + ' ' + a.phone + '</div><div class="detail">' + a.addr + '</div><button type="button" class="btn-addr btn-addr-del" data-id="' + a.id + '" aria-label="删除">删除</button></div>';
}).join('');
document.getElementById('addrList').innerHTML = html;
}
function renderOrders() {
function statusClass(s) {
if (s === 'pending') return 'status-pending';
if (s === 'done') return 'status-done';
if (s === 'shipped') return 'status-shipped';
if (s === 'rejected') return 'status-rejected';
return '';
}
var html = demo.orders.map(function (o) {
var extra = o.tracking ? '<div class="row"><span class="type">物流单号 ' + o.tracking + '</span></div>' : '';
return '<div class="order-item" data-order-id="' + o.id + '" tabindex="0" role="button">' +
'<div class="row"><span class="type">' + o.time + ' · ' + o.type + '</span><span class="status ' + statusClass(o.status) + '">' + o.statusText + '</span></div>' +
'<div class="row"><span>' + o.name + '</span><span class="points">-' + o.points + ' 积分</span></div>' + extra +
'<div class="order-detail-link">查看详情</div></div>';
}).join('');
document.getElementById('orderList').innerHTML = html;
}
function openOrderDetail(orderId) {
var o = demo.orders.find(function (x) { return x.id === orderId; });
if (!o) return;
var statusClass = o.status === 'pending' ? '处理中' : o.status === 'done' ? '已发放' : o.status === 'shipped' ? '已发货' : o.status === 'rejected' ? '已驳回' : o.statusText;
var html = '<dt>订单号</dt><dd>' + (o.orderNo || o.id) + '</dd>' +
'<dt>下单时间</dt><dd>' + o.time + '</dd>' +
'<dt>类型</dt><dd>' + o.type + '</dd>' +
'<dt>商品名称</dt><dd>' + o.name + '</dd>' +
'<dt>消耗积分</dt><dd>' + o.points + '</dd>' +
'<dt>状态</dt><dd>' + statusClass + '</dd>';
if (o.tracking) html += '<dt>物流单号</dt><dd>' + o.tracking + '</dd>';
if (o.rejectReason) html += '<dt>驳回原因</dt><dd>' + o.rejectReason + '</dd>';
document.getElementById('orderDetailBody').innerHTML = html;
document.getElementById('modalOrderDetail').classList.add('show');
}
function renderFlow() {
var html = demo.flow.map(function (f) {
var c = f.change >= 0 ? 'plus' : 'minus';
var sign = f.change >= 0 ? '+' : '';
return '<div class="flow-item">' +
'<div><div class="desc">' + f.desc + '</div><div class="time">' + f.time + '</div></div>' +
'<span class="points ' + c + '">' + sign + f.change + '</span></div>';
}).join('');
document.getElementById('flowList').innerHTML = html;
}
function showPage(id, category) {
document.querySelectorAll('.page').forEach(function (p) { p.classList.remove('active'); });
var el = document.getElementById('page-' + id);
if (el) el.classList.add('active');
if (id === 'home') { renderAssets(); renderProducts('home'); }
if (id === 'records') { renderOrders(); renderFlow(); }
if (id === 'product-list') {
window._productListCategory = category;
var title = '可兑换产品';
if (category === 'bonus') title = '游戏红利';
else if (category === 'physical') title = '实物大奖';
else if (category === 'withdraw') title = '提现到平台';
document.getElementById('productListTitle').textContent = title;
renderProducts('product-list', category);
}
if (id === 'profile') renderAddresses();
}
function openConfirm(type, id, fromList) {
var item, needAddress = false, title = '确认兑换';
if (type === 'bonus') {
item = demo.bonus.find(function (x) { return x.id === id; });
title = '确认兑换红利';
} else if (type === 'physical') {
item = demo.physical.find(function (x) { return x.id === id; });
title = '确认兑换实物';
needAddress = true;
} else {
item = demo.withdraw.find(function (x) { return x.id === id; });
title = '确认提现';
}
if (!item) return;
window._fromProductList = fromList || null;
window._confirmPayload = { type: type, id: id, item: item };
if (type === 'withdraw') {
var html = '<div class="row"><span>' + item.name + '</span></div>' +
'<div class="row"><span>消耗积分</span><span>' + item.points + '</span></div>' +
'<div class="row"><span>流水要求</span><span>' + item.multiplier + ' 倍</span></div>';
document.getElementById('withdrawConfirmSummary').innerHTML = html;
document.getElementById('modalWithdrawConfirm').classList.add('show');
return;
}
var html = '<div class="row"><span>商品</span><span>' + item.name + '</span></div>' +
'<div class="row"><span>消耗积分</span><span>' + item.points + '</span></div>';
if (item.multiplier) html += '<div class="row"><span>流水要求</span><span>' + item.multiplier + ' 倍</span></div>';
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmSummary').innerHTML = html;
document.getElementById('confirmAddressForm').style.display = needAddress ? 'block' : 'none';
showPage('confirm');
}
function navTo(e) {
e.preventDefault();
var t = e.target.closest('a[data-page]');
if (t) showPage(t.getAttribute('data-page'));
}
function moreTo(e) {
e.preventDefault();
var t = e.target.closest('a[data-page]');
if (t) showPage(t.getAttribute('data-page'), t.getAttribute('data-category') || '');
}
document.body.addEventListener('click', function (e) {
var b = e.target.closest('a.back[data-page], .back[data-page]');
if (b) { e.preventDefault(); showPage(b.getAttribute('data-page')); }
});
document.getElementById('page-home').addEventListener('click', function (e) {
if (e.target.closest('a.section-more')) { moreTo(e); return; }
if (e.target.closest('.home-nav a[data-page]')) { e.preventDefault(); showPage(e.target.closest('a[data-page]').getAttribute('data-page')); return; }
var btn = e.target.closest('.btn-exchange');
if (!btn) return;
openConfirm(btn.dataset.type, btn.dataset.id, false);
});
document.getElementById('page-product-list').addEventListener('click', function (e) {
var btn = e.target.closest('.btn-exchange');
if (!btn) return;
openConfirm(btn.dataset.type, btn.dataset.id, true);
});
document.querySelector('#page-records .tabs').addEventListener('click', function (e) {
var t = e.target.closest('.tab');
if (t && t.dataset.recordTab) {
document.querySelectorAll('#page-records .tabs .tab').forEach(function (x) { x.classList.toggle('active', x === t); });
document.getElementById('record-orders').style.display = t.dataset.recordTab === 'orders' ? 'block' : 'none';
document.getElementById('record-flow').style.display = t.dataset.recordTab === 'flow' ? 'block' : 'none';
}
});
document.getElementById('btnClaim').addEventListener('click', function () {
document.getElementById('modalConfirm').classList.add('show');
});
document.getElementById('modalConfirmCancel').addEventListener('click', function () {
document.getElementById('modalConfirm').classList.remove('show');
});
document.getElementById('modalConfirmOk').addEventListener('click', function () {
document.getElementById('modalConfirm').classList.remove('show');
var a = demo.assets;
var canClaim = Math.min(a.lockedPoints, a.todayLimit - a.todayClaimed);
if (canClaim > 0) {
a.lockedPoints -= canClaim;
a.availablePoints += canClaim;
a.todayClaimed += canClaim;
}
document.getElementById('modalResultText').textContent = '领取成功,积分已到账';
document.getElementById('modalResult').classList.add('show');
});
document.getElementById('modalResultOk').addEventListener('click', function () {
document.getElementById('modalResult').classList.remove('show');
renderAssets();
});
document.getElementById('orderList').addEventListener('click', function (e) {
var item = e.target.closest('.order-item');
if (!item) return;
e.preventDefault();
openOrderDetail(item.getAttribute('data-order-id'));
});
document.getElementById('modalOrderDetailClose').addEventListener('click', function () {
document.getElementById('modalOrderDetail').classList.remove('show');
});
document.getElementById('modalWithdrawCancel').addEventListener('click', function () {
document.getElementById('modalWithdrawConfirm').classList.remove('show');
});
document.getElementById('modalWithdrawOk').addEventListener('click', function () {
var p = window._confirmPayload;
if (!p || p.type !== 'withdraw') return;
if (demo.assets.availablePoints < p.item.points) {
document.getElementById('modalResultText').textContent = '积分不足';
document.getElementById('modalResult').classList.add('show');
document.getElementById('modalWithdrawConfirm').classList.remove('show');
return;
}
demo.assets.availablePoints -= p.item.points;
window._fromProductList = null;
document.getElementById('modalWithdrawConfirm').classList.remove('show');
document.getElementById('modalResultText').textContent = '提现申请已提交,预计 10 分钟内处理';
document.getElementById('modalResult').classList.add('show');
renderAssets();
});
document.getElementById('page-home').addEventListener('click', function (e) {
var btn = e.target.closest('.btn-exchange');
if (!btn) return;
openConfirm(btn.dataset.type, btn.dataset.id);
});
document.getElementById('btnConfirmBack').addEventListener('click', function () {
if (window._fromProductList) showPage('product-list', window._productListCategory);
else showPage('home');
});
document.getElementById('btnConfirmSubmit').addEventListener('click', function () {
var p = window._confirmPayload;
if (!p) return;
if (demo.assets.availablePoints < p.item.points) {
document.getElementById('modalResultText').textContent = '积分不足';
document.getElementById('modalResult').classList.add('show');
return;
}
demo.assets.availablePoints -= p.item.points;
window._fromProductList = null;
var msg = p.type === 'withdraw' ? '提现申请已提交,预计 10 分钟内处理' : '申请已提交,请稍后查看记录';
document.getElementById('modalResultText').textContent = msg;
document.getElementById('modalResult').classList.add('show');
showPage('home');
});
document.getElementById('modalResultOk').addEventListener('click', function () {
var open = document.getElementById('modalResult').classList.contains('show');
document.getElementById('modalResult').classList.remove('show');
if (open && window._confirmPayload) renderAssets();
});
document.getElementById('btnSync').addEventListener('click', function () {
document.getElementById('modalResultText').textContent = '已同步最新额度';
document.getElementById('modalResult').classList.add('show');
});
document.getElementById('btnAddAddr').addEventListener('click', function () {
demo.addresses.push({ id: 'a' + Date.now(), name: '新收货人', phone: '138****0000', addr: '请编辑地址' });
renderAddresses();
});
document.getElementById('addrList').addEventListener('click', function (e) {
var btn = e.target.closest('.btn-addr-del');
if (!btn) return;
var id = btn.getAttribute('data-id');
demo.addresses = demo.addresses.filter(function (a) { return a.id !== id; });
renderAddresses();
});
renderAssets();
renderProducts('home');
renderOrders();
renderFlow();
setTimeout(function () {
document.getElementById('page-auth').style.display = 'none';
document.body.classList.add('app-ready');
showPage('home');
}, 1500);
})();
</script>
</body>
</html>

View File

@@ -11,6 +11,8 @@ type UserState = {
setAuthInfo: (authInfo: ValidateTokenData) => void
assetsInfo: UserAssetsData | null
setAssetsInfo: (assetsInfo: UserAssetsData) => void
language: string
setLanguage: (language: string) => void
clearUserInfo: () => void
}
@@ -23,6 +25,8 @@ export const useUserStore = create<UserState>()(
setAuthInfo: (authInfo) => set({authInfo}),
assetsInfo: null,
setAssetsInfo: (assetsInfo) => set({assetsInfo}),
language: 'zh',
setLanguage: (language) => set({language}),
clearUserInfo: () => set({userInfo: null, authInfo: null, assetsInfo: null}),
}),
{
@@ -32,6 +36,7 @@ export const useUserStore = create<UserState>()(
userInfo: state.userInfo,
authInfo: state.authInfo,
assetsInfo: state.assetsInfo,
language: state.language,
}),
},
),

View File

@@ -9,6 +9,7 @@ export type HostContextMessage = {
payload?: {
token?: string
language?: string
username?: string
}
}
@@ -80,7 +81,6 @@ export type ModalMode = 'select-address' | 'add-address'
export type AddAddressForm = {
name: string
phone: string
region: string[]
detailedAddress: string
isDefault: boolean
}

View File

@@ -1,4 +1,5 @@
import {useState} from 'react'
import {useTranslation} from 'react-i18next'
import PageLayout from '@/components/layout'
import Modal from '@/components/modal'
@@ -11,6 +12,7 @@ import {notifySuccess} from '@/features/notifications'
import type {AddressListItem} from '@/types/address.type.ts'
function AccountPage() {
const {t} = useTranslation()
const addressBook = useAddressBook({autoLoad: true})
const [addressModalOpen, setAddressModalOpen] = useState(false)
const [editingAddress, setEditingAddress] = useState<AddressListItem | null>(null)
@@ -40,7 +42,7 @@ function AccountPage() {
const saved = await addressBook.saveAddress(editingAddress)
if (saved) {
handleCloseAddressModal()
notifySuccess(saved.response, editingAddress ? 'Address updated successfully.' : 'Address added successfully.')
notifySuccess(saved.response, editingAddress ? t('account.addressUpdatedSuccessfully') : t('goods.addressAddedSuccessfully'))
}
}
@@ -52,107 +54,116 @@ function AccountPage() {
const deleted = await addressBook.removeAddress(String(deleteTarget.id))
if (deleted) {
setDeleteTarget(null)
notifySuccess(deleted, 'Address deleted successfully.')
notifySuccess(deleted, t('account.addressDeletedSuccessfully'))
}
}
return (
<PageLayout contentClassName="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col px-4 pb-8 sm:px-6 lg:px-8">
<Link
to="/"
className="mt-[12px] flex h-[44px] items-center justify-between rounded-[12px] bg-[#08070E]/72 px-[14px] text-[#F56E10] transition-colors hover:bg-[#0D0A14]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E] sm:mt-[16px]"
>
<div className="flex items-center gap-[8px]">
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
<span className="text-[14px] font-medium text-white/92">Back</span>
</div>
<div className="text-[15px] font-semibold text-[#F56E10]">Account</div>
<div className="w-[52px]"></div>
</Link>
<div className="mx-auto mt-[20px] w-full max-w-[1000px]">
<div className="mb-[14px] flex flex-col gap-[12px] sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-[10px]">
<div className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/15 text-[#FE9F00]">
<MapPinHouse className="h-[18px] w-[18px]" aria-hidden="true" />
</div>
<div>
<div className="text-[16px] font-semibold text-white">My Shipping Address</div>
</div>
<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-[1120px]">
<Link
to="/"
className="mt-[12px] flex h-[44px] items-center justify-between rounded-[12px] bg-[#08070E]/72 px-[14px] text-[#F56E10] transition-colors hover:bg-[#0D0A14]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E] sm:mt-[16px]"
>
<div className="flex items-center gap-[8px]">
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
<span className="text-[14px] font-medium text-white/92">{t('common.back')}</span>
</div>
<div className="text-[15px] font-semibold text-[#F56E10]">{t('account.title')}</div>
<div className="w-[52px]"></div>
</Link>
</div>
<div className="flex min-h-0 flex-1 flex-col pt-[20px]">
<div className="mx-auto w-full max-w-[1000px]">
<div className="mb-[14px] flex flex-col gap-[12px] sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-[10px]">
<div className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/15 text-[#FE9F00]">
<MapPinHouse className="h-[18px] w-[18px]" aria-hidden="true" />
</div>
<div>
<div className="text-[16px] font-semibold text-white">{t('account.myShippingAddress')}</div>
</div>
</div>
<button
type="button"
className="liquid-glass-bg inline-flex h-[40px] items-center justify-center gap-[8px] px-[14px] text-sm text-white transition-colors hover:bg-white/28 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={handleOpenAddAddress}
>
<Plus className="h-[14px] w-[14px]" aria-hidden="true" />
{t('account.addAddress')}
</button>
</div>
<button
type="button"
className="liquid-glass-bg inline-flex h-[40px] items-center justify-center gap-[8px] px-[14px] text-sm text-white transition-colors hover:bg-white/28 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={handleOpenAddAddress}
>
<Plus className="h-[14px] w-[14px]" aria-hidden="true" />
Add Address
</button>
</div>
{addressBook.loading ? (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
Loading address list...
</div>
) : !addressBook.addresses.length ? (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
No shipping address found. Add one.
</div>
) : (
<div className="grid grid-cols-1 gap-[12px]">
{addressBook.addresses.map((address) => {
const addressId = String(address.id)
const addressText = addressBook.addressOptions.find((option) => option.id === addressId)?.address ?? ''
const isDefault = address.default_setting === 1
<div className="min-h-0 flex-1 overflow-y-auto pb-[24px]">
<div className="mx-auto w-full max-w-[1000px]">
{addressBook.loading ? (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{t('account.loadingAddressList')}
</div>
) : !addressBook.addresses.length ? (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{t('account.noShippingAddressFound')}
</div>
) : (
<div className="grid grid-cols-1 gap-[12px]">
{addressBook.addresses.map((address) => {
const addressId = String(address.id)
const addressText = addressBook.addressOptions.find((option) => option.id === addressId)?.address ?? ''
const isDefault = address.default_setting === 1
return (
<div key={addressId} className="liquid-glass-bg p-[14px]">
<div className="flex items-start justify-between gap-[12px]">
<div>
<div className="text-[16px] font-semibold text-white">{address.receiver_name}</div>
<div className="mt-[4px] text-[13px] text-white/62">{address.phone}</div>
</div>
<div
className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${
isDefault
? 'bg-[#FA6A00]/14 text-[#FFB36D]'
: 'bg-white/6 text-white/62'
}`}
>
{isDefault ? (
<span className="inline-flex items-center gap-[5px]">
<BadgeCheck className="h-[12px] w-[12px]" aria-hidden="true" />
Default
</span>
) : 'Optional'}
</div>
return (
<div key={addressId} className="liquid-glass-bg p-[14px]">
<div className="flex items-start justify-between gap-[12px]">
<div>
<div className="text-[16px] font-semibold text-white">{address.receiver_name}</div>
<div className="mt-[4px] text-[13px] text-white/62">{address.phone}</div>
</div>
<div
className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${
isDefault
? 'bg-[#FA6A00]/14 text-[#FFB36D]'
: 'bg-white/6 text-white/62'
}`}
>
{isDefault ? (
<span className="inline-flex items-center gap-[5px]">
<BadgeCheck className="h-[12px] w-[12px]" aria-hidden="true" />
{t('account.default')}
</span>
) : t('account.optional')}
</div>
</div>
<div className="mt-[12px] rounded-[10px] bg-black/12 p-[12px]">
<div className="text-[12px] uppercase tracking-[0.08em] text-white/44">{t('account.address')}</div>
<div className="mt-[6px] text-[13px] leading-[1.6] text-white/78">{addressText}</div>
</div>
<div className="mt-[12px] flex justify-end gap-[10px]">
<button
type="button"
className="inline-flex items-center gap-[6px] rounded-full bg-white/6 px-[10px] py-[6px] text-[12px] text-white/82 transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => handleOpenEditAddress(address)}
>
<PencilLine className="h-[12px] w-[12px]" aria-hidden="true" />
{t('account.edit')}
</button>
<button
type="button"
className="inline-flex items-center gap-[6px] rounded-full bg-[#4B1818]/70 px-[10px] py-[6px] text-[12px] text-[#FFB1B1] transition-colors hover:bg-[#612121]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => setDeleteTarget(address)}
>
<Trash2 className="h-[12px] w-[12px]" aria-hidden="true" />
{t('account.delete')}
</button>
</div>
</div>
)
})}
</div>
<div className="mt-[12px] rounded-[10px] bg-black/12 p-[12px]">
<div className="text-[12px] uppercase tracking-[0.08em] text-white/44">Address</div>
<div className="mt-[6px] text-[13px] leading-[1.6] text-white/78">{addressText}</div>
</div>
<div className="mt-[12px] flex justify-end gap-[10px]">
<button
type="button"
className="inline-flex items-center gap-[6px] rounded-full bg-white/6 px-[10px] py-[6px] text-[12px] text-white/82 transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => handleOpenEditAddress(address)}
>
<PencilLine className="h-[12px] w-[12px]" aria-hidden="true" />
Edit
</button>
<button
type="button"
className="inline-flex items-center gap-[6px] rounded-full bg-[#4B1818]/70 px-[10px] py-[6px] text-[12px] text-[#FFB1B1] transition-colors hover:bg-[#612121]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => setDeleteTarget(address)}
>
<Trash2 className="h-[12px] w-[12px]" aria-hidden="true" />
Delete
</button>
</div>
</div>
)})}
)}
</div>
)}
</div>
</div>
<GoodsRedeemModal
@@ -172,13 +183,13 @@ function AccountPage() {
onChangeAddressForm={addressBook.changeAddressForm}
forceOpen={addressModalOpen}
formOnly
titleOverride={editingAddress ? 'Edit Shipping Address' : 'Add Shipping Address'}
confirmText={editingAddress ? 'Save Changes' : 'Add Address'}
titleOverride={editingAddress ? t('goods.editShippingAddress') : t('goods.addShippingAddress')}
confirmText={editingAddress ? t('goods.saveChanges') : t('account.addAddress')}
/>
<Modal
open={Boolean(deleteTarget)}
title="Delete Address"
title={t('account.deleteAddress')}
onClose={() => setDeleteTarget(null)}
className="max-w-[420px]"
bodyClassName="space-y-[18px]"
@@ -191,7 +202,7 @@ function AccountPage() {
onClick={() => setDeleteTarget(null)}
disabled={addressBook.deleteLoading}
>
Cancel
{t('common.cancel')}
</Button>
<Button
type="button"
@@ -199,13 +210,13 @@ function AccountPage() {
onClick={handleConfirmDelete}
disabled={addressBook.deleteLoading}
>
{addressBook.deleteLoading ? 'Deleting...' : 'Delete'}
{addressBook.deleteLoading ? t('common.processing') : t('account.delete')}
</Button>
</>
}
>
<div className="rounded-[12px] bg-[#1C1818]/82 px-[14px] py-[18px] text-[15px] leading-[1.65] text-white/92 shadow-[0_10px_30px_rgba(0,0,0,0.2)]">
{deleteTarget ? `Delete the address for ${deleteTarget.receiver_name}? ` : ''}
{deleteTarget ? t('account.deleteAddressFor', {name: deleteTarget.receiver_name}) : ''}
</div>
</Modal>
</PageLayout>

View File

@@ -1,8 +1,9 @@
import {ArrowLeft} from 'lucide-react'
import {useTranslation} from 'react-i18next'
import {Link, useSearchParams} from 'react-router-dom'
import PageLayout from '@/components/layout'
import {HOME_GOOD_TYPE_ORDER} from '@/constant'
import {HOME_CATEGORY_META_MAP, HOME_GOOD_TYPE_ORDER} from '@/constant'
import {
GoodsCategoryList,
GoodsRedeemModal,
@@ -12,12 +13,14 @@ import {
} from '@/features/goods'
function GoodsPage() {
const {t} = useTranslation()
const [searchParams] = useSearchParams()
const queryType = searchParams.get('type')
const selectedType = isGoodsType(queryType) ? queryType : HOME_GOOD_TYPE_ORDER[0]
const {productCategories, loading} = useGoodsCatalog({types: [selectedType]})
const redeem = useGoodsRedeem()
const visibleCategories = productCategories.filter((category) => category.id === selectedType)
const pageTitle = t(HOME_CATEGORY_META_MAP[selectedType].nameKey)
return (
<PageLayout contentClassName="mx-auto flex min-h-screen w-full max-w-[1180px] flex-col px-4 pb-8 sm:px-6 lg:px-8">
@@ -27,9 +30,9 @@ function GoodsPage() {
>
<div className="flex items-center gap-[8px]">
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true"/>
<span className="text-[14px] font-medium text-white/92">Back</span>
<span className="text-[14px] font-medium text-white/92">{t('common.back')}</span>
</div>
<div className="text-[15px] font-semibold text-[#F56E10]">{queryType}</div>
<div className="text-[15px] font-semibold text-[#F56E10]">{pageTitle}</div>
<div className="w-[52px]"></div>
</Link>
@@ -39,7 +42,7 @@ function GoodsPage() {
<GoodsCategoryList
categories={visibleCategories}
loading={loading}
emptyText="No goods found for this category."
emptyText={t('goods.noGoodsForCategory')}
onRedeem={redeem.openRedeemModal}
/>
</div>

View File

@@ -1,6 +1,8 @@
import {useState} from 'react'
import {useMutation} from '@tanstack/react-query'
import {Languages} from 'lucide-react'
import {useTranslation} from 'react-i18next'
import PageLayout from '@/components/layout'
import Modal from '@/components/modal'
@@ -11,8 +13,8 @@ import {
Coins,
Gauge,
History,
UserRound,
Wallet,
UserRound,
} from 'lucide-react'
import type {
ProductCategory,
@@ -30,6 +32,7 @@ import {validateClaimSubmission} from '@/features/home/claimValidation'
import {claim} from '@/api/business.ts'
import {notifyError, notifySuccess} from '@/features/notifications'
import {useUserStore} from "@/store/user.ts";
import {normalizeLanguage} from '@/lib/i18n'
function QuickNavCard({icon: Icon, label, to}: QuickNavCardProps) {
return (
@@ -58,9 +61,12 @@ function getProgressPercent(current = 0, total = 0) {
}
function HomePage() {
const {t} = useTranslation()
const [claimModalOpen, setClaimModalOpen] = useState(false)
const navigate = useNavigate()
const authInfo = useUserStore(state => state.authInfo)
const language = useUserStore((state) => state.language)
const setLanguage = useUserStore((state) => state.setLanguage)
const {productCategories, loading} = useGoodsCatalog()
const {invalidateAssets} = useAssetsRefresh()
const redeem = useGoodsRedeem()
@@ -78,12 +84,18 @@ function HomePage() {
const {assetsInfo} = useAssetsQuery()
const claimProgress = getProgressPercent(assetsInfo?.today_claimed, assetsInfo?.today_limit)
const isClaimAvailable = (assetsInfo?.locked_points ?? 0) > 0
const previewCategories: ProductCategory[] = productCategories.map((category) => ({
...category,
items: category.items.slice(0, 4),
}))
const handleOpenClaimModal = () => {
if (!isClaimAvailable) {
notifyError(t('home.noClaimablePointsAvailable'))
return
}
setClaimModalOpen(true)
}
@@ -95,14 +107,14 @@ function HomePage() {
const handleSyncBalance = async () => {
try {
await syncBalanceMutation.mutateAsync()
notifySuccess('Balance synced successfully.')
notifySuccess(t('home.balanceSyncedSuccessfully'))
} catch {
// request interceptor handles interface error toast
}
}
const handleConfirmClaim = async () => {
const claimValidation = validateClaimSubmission(authInfo)
const claimValidation = validateClaimSubmission(authInfo, assetsInfo)
if (!claimValidation.valid) {
notifyError(claimValidation.message)
return
@@ -111,7 +123,7 @@ function HomePage() {
try {
const response = await claimMutation.mutateAsync(`${authInfo!.user_id}${Date.now()}`)
await invalidateAssets()
notifySuccess(response, 'Claim submitted successfully.')
notifySuccess(response, t('home.claimSubmittedSuccessfully'))
setClaimModalOpen(false)
} catch {
// request errors are surfaced by the shared request toast
@@ -122,47 +134,61 @@ function HomePage() {
navigate(`/goods?type=${type}`)
}
const handleToggleLanguage = () => {
const currentLanguage = normalizeLanguage(language)
setLanguage(currentLanguage === 'zh' ? 'en' : 'zh')
}
return (
<PageLayout>
<div
className="grid grid-cols-2 gap-2 py-[14px] sm:ml-auto sm:flex sm:w-auto sm:grid-cols-none sm:justify-end">
<QuickNavCard to="/record" icon={History} label="record"/>
<QuickNavCard to="/account" icon={UserRound} label="account"/>
className="grid grid-cols-3 gap-2 py-[14px] sm:ml-auto sm:flex sm:w-auto sm:grid-cols-none sm:justify-end">
<QuickNavCard to="/record" icon={History} label={t('nav.record')}/>
<QuickNavCard to="/account" icon={UserRound} label={t('nav.account')}/>
<button
type="button"
className="liquid-glass-bg flex items-center justify-between gap-[12px] rounded-[12px] px-[12px] py-[8px] text-[13px] text-white/88 transition-colors hover:bg-white/28 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={handleToggleLanguage}
>
<div className="flex min-w-0 items-center gap-[10px]">
<div className="flex h-[32px] w-[32px] items-center justify-center rounded-[10px] bg-linear-to-b from-[#FB8001] to-[#FCAA2C] text-white shadow-[0_8px_18px_rgba(250,109,2,0.24)]">
<Languages className="h-[16px] w-[16px] shrink-0" aria-hidden="true"/>
</div>
<div className="truncate font-medium">{normalizeLanguage(language) === 'zh' ? t('nav.switchToEnglish') : t('nav.switchToChinese')}</div>
</div>
<ChevronRight className="h-[16px] w-[16px] shrink-0 text-white/70" aria-hidden="true"/>
</button>
</div>
<div className="mt-[4px]">
<div className="flex flex-col gap-3 lg:flex-row lg:items-stretch">
<div className="grid grid-cols-2 gap-3 lg:w-[544px] lg:shrink-0">
<div className="liquid-glass-bg flex min-h-[167px] flex-col justify-between p-[14px]">
<div className="liquid-glass-bg flex min-h-[167px] flex-col justify-between p-[12px] sm:p-[14px]">
<div className="flex items-start justify-between gap-[12px]">
<div>
<div className="text-[13px] tracking-[0.16em] text-white/58">Claimable
Points
</div>
<div className="min-w-0 flex-1">
<div className="text-[12px] tracking-[0.1em] text-white/58 sm:text-[13px] sm:tracking-[0.16em]">{t('home.claimablePoints')}</div>
<div
className="mt-[10px] text-[34px] font-semibold leading-none text-white">{assetsInfo?.locked_points || 0}</div>
className="mt-[10px] min-w-0 break-all text-[clamp(1.25rem,8vw,1.625rem)] font-semibold leading-[1.05] text-white sm:text-[34px]">{assetsInfo?.locked_points || 0}</div>
</div>
<div
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
<Coins className="h-[18px] w-[18px]" aria-hidden="true"/>
className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[10px] bg-[#FA6A00]/16 text-[#FE9F00] sm:h-[38px] sm:w-[38px] sm:rounded-[12px]">
<Coins className="h-[16px] w-[16px] sm:h-[18px] sm:w-[18px]" aria-hidden="true"/>
</div>
</div>
<div className="max-w-[28ch] text-[13px] leading-[1.6] text-white/68">
Yesterday&apos;s losses have been converted into points. Claim them to use in rewards.
<div className="max-w-[20ch] text-[12px] leading-[1.5] text-white/68 sm:max-w-[28ch] sm:text-[13px] sm:leading-[1.6]">
{t('home.claimDescription')}
</div>
</div>
<div className="liquid-glass-bg flex min-h-[167px] flex-col justify-around p-[14px]">
<div className="liquid-glass-bg flex min-h-[167px] flex-col justify-around p-[12px] sm:p-[14px]">
<div className="flex items-start justify-between gap-[12px]">
<div>
<div className="text-[13px] tracking-[0.16em] text-white/58">Daily Claim
Limit
</div>
<div className="min-w-0 flex-1">
<div className="text-[12px] tracking-[0.1em] text-white/58 sm:text-[13px] sm:tracking-[0.16em]">{t('home.dailyClaimLimit')}</div>
<div
className="mt-[10px] text-[34px] font-semibold leading-none text-white">{assetsInfo?.locked_points}</div>
className="mt-[10px] min-w-0 break-all text-[clamp(1.25rem,8vw,1.625rem)] font-semibold leading-[1.05] text-white sm:text-[34px]">{assetsInfo?.today_limit || 0}</div>
</div>
<div
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
<Gauge className="h-[18px] w-[18px]" aria-hidden="true"/>
className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[10px] bg-[#FA6A00]/16 text-[#FE9F00] sm:h-[38px] sm:w-[38px] sm:rounded-[12px]">
<Gauge className="h-[16px] w-[16px] sm:h-[18px] sm:w-[18px]" aria-hidden="true"/>
</div>
</div>
<div
@@ -178,7 +204,7 @@ function HomePage() {
></div>
</div>
<div
className="mt-[10px] text-[13px] text-white/68">Claimed: <span
className="mt-[10px] text-[12px] leading-[1.5] text-white/68 sm:text-[13px]">{t('home.claimed')}: <span
className={'text-[#FE9C00]'}>{assetsInfo?.today_claimed || 0}</span> / {assetsInfo?.today_limit || 0}
</div>
</div>
@@ -189,13 +215,11 @@ function HomePage() {
className="liquid-glass-bg flex min-h-[100px] flex-col justify-between p-[14px] sm:p-[16px]">
<div className="flex items-center justify-between gap-[12px]">
<div>
<div className="text-[13px] tracking-[0.16em] text-white/58">Available for
Withdrawal (Cash)
</div>
<div className="text-[13px] tracking-[0.16em] text-white/58">{t('home.availableForWithdrawal')}</div>
<div
className="mt-[10px] text-[32px] font-semibold leading-none text-white">{assetsInfo?.withdrawable_cash || 0}
className="mt-[10px] min-w-0 break-all text-[clamp(1.25rem,8vw,2rem)] font-semibold leading-[1.05] text-white sm:text-[32px]">{assetsInfo?.withdrawable_cash || 0}
<span
className="text-[13px] tracking-[0.16em] text-white/58 ml-[10px]">CNY</span>
className="text-[13px] tracking-[0.16em] text-white/58 ml-[10px]">{t('home.cashUnit')}</span>
</div>
</div>
<div
@@ -205,8 +229,11 @@ function HomePage() {
</div>
</div>
<div className="liquid-glass-bg grid grid-cols-2 gap-[10px] p-[5px]">
<Button className="h-[44px] w-full text-[13px]" onClick={handleOpenClaimModal}>
Claim Now
<Button
className="h-[44px] w-full text-[13px]"
onClick={handleOpenClaimModal}
>
{t('home.claimNow')}
</Button>
<Button
variant={'gray'}
@@ -214,7 +241,7 @@ function HomePage() {
onClick={handleSyncBalance}
disabled={syncBalanceMutation.isPending}
>
{syncBalanceMutation.isPending ? 'Syncing...' : 'Sync Balance'}
{syncBalanceMutation.isPending ? t('home.syncing') : t('home.syncBalance')}
</Button>
</div>
</div>
@@ -224,7 +251,7 @@ function HomePage() {
<GoodsCategoryList
categories={previewCategories}
loading={loading}
emptyText="No goods available yet."
emptyText={t('goods.noGoodsAvailableYet')}
showMore
onMoreClick={handleMoreClick}
onRedeem={redeem.openRedeemModal}
@@ -249,7 +276,7 @@ function HomePage() {
<Modal
open={claimModalOpen}
title="Confirm Claim"
title={t('home.confirmClaim')}
onClose={handleCloseClaimModal}
className="max-w-[560px]"
bodyClassName="space-y-[18px]"
@@ -258,25 +285,24 @@ function HomePage() {
<Button type="button" variant={'gray'} className="h-[38px] w-full sm:w-auto sm:min-w-[130px]"
onClick={handleCloseClaimModal}
disabled={claimMutation.isPending}>
Cancel
{t('common.cancel')}
</Button>
<Button type="button" className="h-[38px] w-full sm:w-auto sm:min-w-[130px]"
onClick={handleConfirmClaim}
disabled={claimMutation.isPending}>
{claimMutation.isPending ? 'Processing...' : 'Confirm'}
disabled={claimMutation.isPending || !isClaimAvailable}>
{claimMutation.isPending ? t('common.processing') : t('common.confirm')}
</Button>
</>
}
>
<div
className="rounded-[12px] bg-[#1C1818]/82 px-[14px] py-[18px] text-[17px] leading-[1.65] text-white/92 shadow-[0_10px_30px_rgba(0,0,0,0.2)]">
After converting the points to be collected into usable points, they can be redeemed or withdrawn.
Are you sure to claim it?
{t('home.confirmClaimDescription')}
</div>
</Modal>
</PageLayout>
)
}
export default HomePage;
export default HomePage

View File

@@ -1,8 +1,10 @@
import {useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import {useTranslation} from 'react-i18next'
import PageLayout from '@/components/layout'
import {ORDER_STATUS} from '@/constant'
import i18n from '@/lib/i18n'
import { cn } from '@/lib'
import Modal from '@/components/modal'
import Button from '@/components/button'
@@ -63,13 +65,13 @@ function getOrderStatus(status?: string | number) {
if (matchedStatus) {
switch (matchedStatus) {
case 'PENDING':
return 'Pending'
return i18n.t('record.statusLabel.pending')
case 'COMPLETED':
return 'Completed'
return i18n.t('record.statusLabel.completed')
case 'SHIPPED':
return 'Shipped'
return i18n.t('record.statusLabel.shipped')
case 'REJECTED':
return 'Rejected'
return i18n.t('record.statusLabel.rejected')
}
}
}
@@ -81,20 +83,20 @@ function getOrderStatus(status?: string | number) {
if (typeof normalizedStatus === 'number') {
switch (normalizedStatus) {
case 0:
return 'Pending'
return i18n.t('record.statusLabel.pending')
case 1:
return 'Completed'
return i18n.t('record.statusLabel.completed')
case 2:
return 'Shipped'
return i18n.t('record.statusLabel.shipped')
case 3:
return 'Rejected'
return i18n.t('record.statusLabel.rejected')
default:
return String(normalizedStatus)
}
}
if (!normalizedStatus) {
return 'Pending'
return i18n.t('record.statusLabel.pending')
}
return toTitleCase(normalizedStatus)
@@ -102,7 +104,17 @@ function getOrderStatus(status?: string | number) {
function getOrderCategory(item: OrderItem) {
if (item.type?.trim()) {
return item.type.trim().toUpperCase()
const normalizedType = item.type.trim().toUpperCase()
switch (normalizedType) {
case 'BONUS':
return i18n.t('record.categories.bonus')
case 'PHYSICAL':
return i18n.t('record.categories.physical')
case 'WITHDRAW':
return i18n.t('record.categories.withdraw')
default:
return toTitleCase(normalizedType)
}
}
if (item.type_title) {
@@ -116,11 +128,11 @@ function getOrderCategory(item: OrderItem) {
if (item.type) {
switch (item.type) {
case 'BONUS':
return 'Bonus'
return i18n.t('record.categories.bonus')
case 'PHYSICAL':
return 'Physical'
return i18n.t('record.categories.physical')
case 'WITHDRAW':
return 'Transfer to Platform'
return i18n.t('record.categories.withdraw')
default:
return toTitleCase(item.type)
}
@@ -130,7 +142,7 @@ function getOrderCategory(item: OrderItem) {
return toTitleCase(item.category)
}
return 'Order'
return i18n.t('record.categories.order')
}
function getOrderPoints(item: OrderItem) {
@@ -143,11 +155,11 @@ function getOrderPoints(item: OrderItem) {
const numericValue = typeof rawValue === 'string' ? Number(rawValue) : rawValue
if (Number.isNaN(numericValue)) {
const textValue = String(rawValue)
return {display: `${textValue} points`}
return {display: `${textValue} ${i18n.t('common.points')}`}
}
return {
display: `${numericValue} points`,
display: `${numericValue} ${i18n.t('common.points')}`,
}
}
@@ -176,7 +188,7 @@ function mapOrderItemToRecord(item: OrderItem): OrderRecord {
date,
time,
category: getOrderCategory(item),
title: item.item_title ?? item.title ?? item.mallItem?.title ?? 'Untitled Order',
title: item.item_title ?? item.title ?? item.mallItem?.title ?? i18n.t('record.untitledOrder'),
trackingNumber: getTrackingNumber(item),
status: getOrderStatus(item.status),
points: points.display,
@@ -221,7 +233,7 @@ function getPointsRecordTitle(item: PointsLogItem) {
item.type,
item.description,
item.remark?.split('\n')[0],
].find((value) => typeof value === 'string' && value.trim())?.trim() ?? 'Points Record'
].find((value) => typeof value === 'string' && value.trim())?.trim() ?? i18n.t('record.pointsRecordFallback')
}
function mapPointsLogItemToRecord(item: PointsLogItem) {
@@ -239,18 +251,24 @@ function mapPointsLogItemToRecord(item: PointsLogItem) {
}
function getOrderStatusClassName(status: string) {
switch (status.toLowerCase()) {
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]'
default:
return 'bg-white/15 text-white/80'
const normalizedStatus = status.toLowerCase()
if (normalizedStatus === 'completed' || status === i18n.t('record.statusLabel.completed')) {
return 'bg-[#9BFFC0] text-[#176640]'
}
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) {
@@ -272,8 +290,9 @@ function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
}
function OrderCard({ record, onOpenDetails }: OrderCardProps) {
const {t} = useTranslation()
return (
<div className="overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="shrink-0 overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[14px] text-white">
{record.date} {record.time} {record.category}
</div>
@@ -282,7 +301,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
<div className="text-[16px] font-medium text-white">{record.title}</div>
{record.trackingNumber ? (
<div className="mt-[4px] text-[13px] text-white/45">
Tracking Number&nbsp;&nbsp;{record.trackingNumber}
{t('record.trackingNumber')}&nbsp;&nbsp;{record.trackingNumber}
</div>
) : null}
<button
@@ -290,7 +309,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
className="mt-[10px] inline-flex items-center gap-[5px] text-[13px] text-[#FA6A00] focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => onOpenDetails(record)}
>
Check the details
{t('record.checkDetails')}
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true" />
</button>
</div>
@@ -313,7 +332,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
function PointsCard({ record }: PointsCardProps) {
return (
<div className="overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="shrink-0 overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[15px] text-white">
{record.title}
</div>
@@ -341,6 +360,7 @@ function OrdersTabContent({
sessionId: string
onOpenDetails: (record: OrderRecord) => void
}) {
const {t} = useTranslation()
const ordersQuery = useQuery({
queryKey: queryKeys.orders(sessionId),
enabled: Boolean(sessionId),
@@ -358,7 +378,7 @@ function OrdersTabContent({
if (ordersQuery.isPending) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
Loading...
{t('record.loading')}
</div>
)
}
@@ -366,21 +386,22 @@ function OrdersTabContent({
if (!orderRecords.length) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
No Data
{t('record.noData')}
</div>
)
}
return (
<>
<div className="space-y-[12px] pb-[4px]">
{orderRecords.map((record) => (
<OrderCard key={record.id} record={record} onOpenDetails={onOpenDetails} />
))}
</>
</div>
)
}
function PointsTabContent({sessionId}: {sessionId: string}) {
const {t} = useTranslation()
const pointsLogsQuery = useQuery({
queryKey: queryKeys.pointsLogs(sessionId),
enabled: Boolean(sessionId),
@@ -398,7 +419,7 @@ function PointsTabContent({sessionId}: {sessionId: string}) {
if (pointsLogsQuery.isPending) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
Loading...
{t('record.loading')}
</div>
)
}
@@ -406,19 +427,20 @@ function PointsTabContent({sessionId}: {sessionId: string}) {
if (!pointsRecords.length) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
No Data
{t('record.noData')}
</div>
)
}
return (
<>
<div className="space-y-[12px] pb-[4px]">
{pointsRecords.map((record) => <PointsCard key={record.id} record={record} />)}
</>
</div>
)
}
function RecordPage() {
const {t} = useTranslation()
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const [tab, setTab] = useState<RecordButtonType>('order')
const [selectedOrder, setSelectedOrder] = useState<OrderRecord | null>(null)
@@ -428,78 +450,84 @@ function RecordPage() {
}
return (
<PageLayout contentClassName="mx-auto flex min-h-screen w-full max-w-[980px] flex-col px-4 pb-8 sm:px-6 lg:px-8">
<Link
to="/"
className="mt-[12px] flex h-[44px] items-center justify-between rounded-[12px] bg-[#08070E]/72 px-[14px] text-[#F56E10] transition-colors hover:bg-[#0D0A14]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E] sm:mt-[16px]"
>
<div className="flex items-center gap-[8px]">
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
<span className="text-[14px] font-medium text-white/92">Back</span>
</div>
<div className="text-[15px] font-semibold text-[#F56E10]">Record</div>
<div className="w-[52px]"></div>
</Link>
<div className="mx-auto w-full max-w-[860px] pt-[18px] pb-[24px]">
<div className="mb-[12px] flex justify-end">
<div className="flex gap-[8px]">
<TabButton
active={tab === 'order'}
icon={PackageSearch}
label="My Orders"
onClick={() => {
setSelectedOrder(null)
setTab('order')
}}
/>
<TabButton
active={tab === 'record'}
icon={Coins}
label="Points Record"
onClick={() => {
setSelectedOrder(null)
setTab('record')
}}
/>
<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]">
<Link
to="/"
className="mt-[12px] flex h-[44px] items-center justify-between rounded-[12px] bg-[#08070E]/72 px-[14px] text-[#F56E10] transition-colors hover:bg-[#0D0A14]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E] sm:mt-[16px]"
>
<div className="flex items-center gap-[8px]">
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
<span className="text-[14px] font-medium text-white/92">{t('common.back')}</span>
</div>
<div className="text-[15px] font-semibold text-[#F56E10]">{t('record.title')}</div>
<div className="w-[52px]"></div>
</Link>
</div>
<div className="flex min-h-0 flex-1 flex-col pt-[18px] pb-[24px]">
<div className="mx-auto w-full max-w-[860px]">
<div className="mb-[12px] flex justify-end">
<div className="flex gap-[8px]">
<TabButton
active={tab === 'order'}
icon={PackageSearch}
label={t('record.myOrders')}
onClick={() => {
setSelectedOrder(null)
setTab('order')
}}
/>
<TabButton
active={tab === 'record'}
icon={Coins}
label={t('record.pointsRecord')}
onClick={() => {
setSelectedOrder(null)
setTab('record')
}}
/>
</div>
</div>
<div className="h-px bg-white/16"></div>
</div>
<div className="h-px bg-white/16"></div>
<div className="mt-[14px] flex flex-col gap-[12px]">
{tab === 'order' ? (
<OrdersTabContent key="order" sessionId={sessionId} onOpenDetails={setSelectedOrder} />
) : (
<PointsTabContent key="record" sessionId={sessionId} />
)}
<div className="mt-[14px] min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[860px]">
{tab === 'order' ? (
<OrdersTabContent key="order" sessionId={sessionId} onOpenDetails={setSelectedOrder} />
) : (
<PointsTabContent key="record" sessionId={sessionId} />
)}
</div>
</div>
</div>
<Modal
open={Boolean(selectedOrder)}
title="Order Details"
title={t('record.orderDetails')}
onClose={handleCloseDetails}
className="max-w-[420px]"
bodyClassName="pt-[0px]"
footer={
<Button type="button" className="h-[36px] w-full sm:min-w-[94px] sm:w-auto" onClick={handleCloseDetails}>
Close
{t('common.close')}
</Button>
}
>
{selectedOrder ? (
<div className="mt-[10px] rounded-[10px] bg-[#1C1818]/78 px-[12px] py-[6px]">
{[
{ label: 'Order Number', value: selectedOrder.orderNumber ?? '--' },
{ label: 'Order Time', value: `${selectedOrder.date} ${selectedOrder.time}` },
{ label: 'Order Type', value: selectedOrder.category },
{ label: 'Item Name', value: selectedOrder.title },
{ label: t('record.orderNumber'), value: selectedOrder.orderNumber ?? '--' },
{ label: t('record.orderTime'), value: `${selectedOrder.date} ${selectedOrder.time}` },
{ label: t('record.orderType'), value: selectedOrder.category },
{ label: t('record.itemName'), value: selectedOrder.title },
...(selectedOrder.trackingNumber
? [{ label: 'Tracking Number', value: selectedOrder.trackingNumber }]
? [{ label: t('record.trackingNumber'), value: selectedOrder.trackingNumber }]
: []),
{ label: 'Points', value: selectedOrder.points.replace(/^-/, '') },
{ label: 'Status', value: selectedOrder.status },
{ label: t('record.points'), value: selectedOrder.points.replace(/^-/, '') },
{ label: t('record.status'), value: selectedOrder.status },
].map((item) => (
<div key={item.label} className="border-b border-white/8 py-[10px] last:border-b-0">
<div className="text-[13px] text-white/48">{item.label}</div>

11
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BYPASS_IFRAME_CONTEXT?: string
readonly VITE_API_BASE_URL?: string
readonly VITE_API_ORIGIN?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}