feat:新增多语言
This commit is contained in:
2
.env.production
Normal file
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=https://playx-api.cjdhr.top
|
||||
VITE_API_ORIGIN=https://playx-api.cjdhr.top
|
||||
@@ -13,11 +13,13 @@
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"clsx": "^2.1.1",
|
||||
"element-china-area-data": "^6.1.0",
|
||||
"i18next": "^26.0.4",
|
||||
"ky": "^2.0.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"province-city-china": "^8.5.8",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
|
||||
91
pnpm-lock.yaml
generated
91
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
element-china-area-data:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
i18next:
|
||||
specifier: ^26.0.4
|
||||
version: 26.0.4(typescript@5.9.3)
|
||||
ky:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
@@ -32,6 +35,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.4(react@19.2.4)
|
||||
react-i18next:
|
||||
specifier: ^17.0.2
|
||||
version: 17.0.2(i18next@26.0.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
react-router-dom:
|
||||
specifier: ^7.13.1
|
||||
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -43,7 +49,7 @@ importers:
|
||||
version: 4.2.1
|
||||
zustand:
|
||||
specifier: ^5.0.12
|
||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)
|
||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||
devDependencies:
|
||||
'@babel/core':
|
||||
specifier: ^7.29.0
|
||||
@@ -56,7 +62,7 @@ importers:
|
||||
version: 9.39.4
|
||||
'@rolldown/plugin-babel':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.1(@babel/core@7.29.0)(rolldown@1.0.0-rc.9)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1))
|
||||
version: 0.2.1(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.9)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1))
|
||||
@@ -74,7 +80,7 @@ importers:
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.1(@rolldown/plugin-babel@0.2.1(@babel/core@7.29.0)(rolldown@1.0.0-rc.9)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1))
|
||||
version: 6.0.1(@rolldown/plugin-babel@0.2.1(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.9)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1))
|
||||
babel-plugin-react-compiler:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
@@ -157,6 +163,10 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/runtime@7.29.2':
|
||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -947,6 +957,17 @@ packages:
|
||||
hermes-parser@0.25.1:
|
||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
i18next@26.0.4:
|
||||
resolution: {integrity: sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA==}
|
||||
peerDependencies:
|
||||
typescript: ^5 || ^6
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -1323,6 +1344,22 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.4
|
||||
|
||||
react-i18next@17.0.2:
|
||||
resolution: {integrity: sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==}
|
||||
peerDependencies:
|
||||
i18next: '>= 26.0.1'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
typescript: ^5 || ^6
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
react-router-dom@7.13.1:
|
||||
resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -1502,6 +1539,11 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
@@ -1548,6 +1590,10 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1693,6 +1739,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/runtime@7.29.2': {}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
@@ -1970,12 +2018,13 @@ snapshots:
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.9':
|
||||
optional: true
|
||||
|
||||
'@rolldown/plugin-babel@0.2.1(@babel/core@7.29.0)(rolldown@1.0.0-rc.9)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1))':
|
||||
'@rolldown/plugin-babel@0.2.1(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.9)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
picomatch: 4.0.3
|
||||
rolldown: 1.0.0-rc.9
|
||||
optionalDependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
vite: 8.0.0(@types/node@24.12.0)(jiti@2.6.1)
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||
@@ -2196,12 +2245,12 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.57.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@vitejs/plugin-react@6.0.1(@rolldown/plugin-babel@0.2.1(@babel/core@7.29.0)(rolldown@1.0.0-rc.9)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1))':
|
||||
'@vitejs/plugin-react@6.0.1(@rolldown/plugin-babel@0.2.1(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.9)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||
vite: 8.0.0(@types/node@24.12.0)(jiti@2.6.1)
|
||||
optionalDependencies:
|
||||
'@rolldown/plugin-babel': 0.2.1(@babel/core@7.29.0)(rolldown@1.0.0-rc.9)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1))
|
||||
'@rolldown/plugin-babel': 0.2.1(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.9)(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1))
|
||||
babel-plugin-react-compiler: 1.0.0
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
@@ -2495,6 +2544,16 @@ snapshots:
|
||||
dependencies:
|
||||
hermes-estree: 0.25.1
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
|
||||
i18next@26.0.4(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
@@ -2795,6 +2854,17 @@ snapshots:
|
||||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-i18next@17.0.2(i18next@26.0.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 26.0.4(typescript@5.9.3)
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
typescript: 5.9.3
|
||||
|
||||
react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -2963,6 +3033,10 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1):
|
||||
@@ -2978,6 +3052,8 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -3016,7 +3092,8 @@ snapshots:
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4):
|
||||
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
||||
217
public/test-host.html
Normal file
217
public/test-host.html
Normal file
@@ -0,0 +1,217 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PlayX Iframe Host Test</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f4f6fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 24px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.preview {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 20px;
|
||||
line-height: 1.6;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.log {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: pre-wrap;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: calc(100vh - 48px);
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
iframe {
|
||||
height: 70vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="panel">
|
||||
<h1>PlayX Host Test</h1>
|
||||
<p>Use this page to test iframe embedding and the <code>PLAYX_READY</code> / <code>IFRAME_CONTEXT</code> handshake flow.</p>
|
||||
|
||||
<label>
|
||||
Username
|
||||
<input id="username" value="+60777777777" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Language
|
||||
<input id="language" value="zh-CN" />
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button id="send" class="primary" type="button">Send Context</button>
|
||||
<button id="reload" class="secondary" type="button">Reload Iframe</button>
|
||||
</div>
|
||||
|
||||
<div id="log" class="log"></div>
|
||||
</aside>
|
||||
|
||||
<main class="preview">
|
||||
<iframe id="playx-frame" title="PlayX App" src="/"></iframe>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const frame = document.getElementById('playx-frame')
|
||||
const usernameInput = document.getElementById('username')
|
||||
const languageInput = document.getElementById('language')
|
||||
const sendButton = document.getElementById('send')
|
||||
const reloadButton = document.getElementById('reload')
|
||||
const logNode = document.getElementById('log')
|
||||
const iframeOrigin = window.location.origin
|
||||
|
||||
function appendLog(message, data) {
|
||||
const stamp = new Date().toLocaleTimeString()
|
||||
const payload = data ? ` ${JSON.stringify(data, null, 2)}` : ''
|
||||
logNode.textContent = `[${stamp}] ${message}${payload}\n${logNode.textContent}`
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
type: 'IFRAME_CONTEXT',
|
||||
payload: {
|
||||
username: usernameInput.value.trim(),
|
||||
language: languageInput.value.trim(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sendContext() {
|
||||
const message = buildPayload()
|
||||
frame.contentWindow?.postMessage(message, iframeOrigin)
|
||||
appendLog('Sent IFRAME_CONTEXT to iframe', message)
|
||||
}
|
||||
|
||||
frame.addEventListener('load', () => {
|
||||
appendLog('Iframe loaded', {src: frame.getAttribute('src')})
|
||||
})
|
||||
|
||||
sendButton.addEventListener('click', () => {
|
||||
sendContext()
|
||||
})
|
||||
|
||||
reloadButton.addEventListener('click', () => {
|
||||
frame.contentWindow?.location.reload()
|
||||
appendLog('Requested iframe reload')
|
||||
})
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source === frame.contentWindow && event.data?.type === 'PLAYX_READY') {
|
||||
appendLog('Received PLAYX_READY from iframe', {
|
||||
origin: event.origin,
|
||||
data: event.data,
|
||||
})
|
||||
sendContext()
|
||||
return
|
||||
}
|
||||
|
||||
appendLog('Received postMessage on host page', {
|
||||
origin: event.origin,
|
||||
data: event.data,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
76
src/App.tsx
76
src/App.tsx
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
34
src/lib/i18n.ts
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
154
src/message/en.ts
Normal 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
154
src/message/zh.ts
Normal 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
670
src/origin.html
Normal 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>
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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 {record.trackingNumber}
|
||||
{t('record.trackingNumber')} {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
11
src/vite-env.d.ts
vendored
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user