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",
|
"@tanstack/react-query": "^5.96.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"element-china-area-data": "^6.1.0",
|
"element-china-area-data": "^6.1.0",
|
||||||
|
"i18next": "^26.0.4",
|
||||||
"ky": "^2.0.0",
|
"ky": "^2.0.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"province-city-china": "^8.5.8",
|
"province-city-china": "^8.5.8",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-i18next": "^17.0.2",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
|||||||
91
pnpm-lock.yaml
generated
91
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
|||||||
element-china-area-data:
|
element-china-area-data:
|
||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
version: 6.1.0
|
version: 6.1.0
|
||||||
|
i18next:
|
||||||
|
specifier: ^26.0.4
|
||||||
|
version: 26.0.4(typescript@5.9.3)
|
||||||
ky:
|
ky:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
@@ -32,6 +35,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.4
|
specifier: ^19.2.4
|
||||||
version: 19.2.4(react@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:
|
react-router-dom:
|
||||||
specifier: ^7.13.1
|
specifier: ^7.13.1
|
||||||
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
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
|
version: 4.2.1
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.12
|
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:
|
devDependencies:
|
||||||
'@babel/core':
|
'@babel/core':
|
||||||
specifier: ^7.29.0
|
specifier: ^7.29.0
|
||||||
@@ -56,7 +62,7 @@ importers:
|
|||||||
version: 9.39.4
|
version: 9.39.4
|
||||||
'@rolldown/plugin-babel':
|
'@rolldown/plugin-babel':
|
||||||
specifier: ^0.2.0
|
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':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1(vite@8.0.0(@types/node@24.12.0)(jiti@2.6.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)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.0
|
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:
|
babel-plugin-react-compiler:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -157,6 +163,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.2':
|
||||||
|
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -947,6 +957,17 @@ packages:
|
|||||||
hermes-parser@0.25.1:
|
hermes-parser@0.25.1:
|
||||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
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:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -1323,6 +1344,22 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.4
|
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:
|
react-router-dom@7.13.1:
|
||||||
resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==}
|
resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -1502,6 +1539,11 @@ packages:
|
|||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
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:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@@ -1548,6 +1590,10 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
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:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1693,6 +1739,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.2': {}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
@@ -1970,12 +2018,13 @@ snapshots:
|
|||||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.9':
|
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.9':
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
rolldown: 1.0.0-rc.9
|
rolldown: 1.0.0-rc.9
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@babel/runtime': 7.29.2
|
||||||
vite: 8.0.0(@types/node@24.12.0)(jiti@2.6.1)
|
vite: 8.0.0(@types/node@24.12.0)(jiti@2.6.1)
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||||
@@ -2196,12 +2245,12 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.57.0
|
'@typescript-eslint/types': 8.57.0
|
||||||
eslint-visitor-keys: 5.0.1
|
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:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||||
vite: 8.0.0(@types/node@24.12.0)(jiti@2.6.1)
|
vite: 8.0.0(@types/node@24.12.0)(jiti@2.6.1)
|
||||||
optionalDependencies:
|
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
|
babel-plugin-react-compiler: 1.0.0
|
||||||
|
|
||||||
'@xmldom/xmldom@0.8.11': {}
|
'@xmldom/xmldom@0.8.11': {}
|
||||||
@@ -2495,6 +2544,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hermes-estree: 0.25.1
|
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@5.3.2: {}
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
@@ -2795,6 +2854,17 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
scheduler: 0.27.0
|
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):
|
react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
@@ -2963,6 +3033,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
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: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1):
|
vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1):
|
||||||
@@ -2978,6 +3052,8 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
|
|
||||||
|
void-elements@3.1.0: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
@@ -3016,7 +3092,8 @@ snapshots:
|
|||||||
|
|
||||||
zod@4.3.6: {}
|
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:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
react: 19.2.4
|
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 {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 {BrowserRouter, Route, Routes} from 'react-router-dom'
|
||||||
import {AuthGuide} from '@/features/authGuide.tsx'
|
import {AuthGuide} from '@/features/authGuide.tsx'
|
||||||
import {GlobalToast} from '@/features/notifications'
|
import {GlobalToast} from '@/features/notifications'
|
||||||
|
import i18n, {normalizeLanguage} from '@/lib/i18n'
|
||||||
|
import {useUserStore} from '@/store/user.ts'
|
||||||
// import type { HostContextMessage } from '@/types'
|
// import type { HostContextMessage } from '@/types'
|
||||||
|
|
||||||
const HomePage = lazy(() => import('./views/home'))
|
const HomePage = lazy(() => import('./views/home'))
|
||||||
@@ -11,6 +15,35 @@ const AccountPage = lazy(() => import('./views/account'))
|
|||||||
const GoodsPage = lazy(() => import('./views/goods'))
|
const GoodsPage = lazy(() => import('./views/goods'))
|
||||||
|
|
||||||
function App() {
|
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(() => {
|
// useEffect(() => {
|
||||||
// const handleMessage = (event: MessageEvent<HostContextMessage>) => {
|
// const handleMessage = (event: MessageEvent<HostContextMessage>) => {
|
||||||
// const message = event.data
|
// const message = event.data
|
||||||
@@ -19,15 +52,14 @@ function App() {
|
|||||||
// return
|
// 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()) {
|
// if (typeof language === 'string' && language.trim()) {
|
||||||
// sessionStorage.setItem('host_language', language)
|
// // language 由 zustand 存储,供请求头直接读取
|
||||||
// document.documentElement.lang = language
|
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
@@ -43,13 +75,13 @@ function App() {
|
|||||||
// {
|
// {
|
||||||
// type: 'IFRAME_CONTEXT',
|
// type: 'IFRAME_CONTEXT',
|
||||||
// payload: {
|
// payload: {
|
||||||
// token: 'test-token-123',
|
// username: '+60777777777',
|
||||||
// language: 'zh-CN',
|
// language: 'zh-CN',
|
||||||
// },
|
// },
|
||||||
// },
|
// },
|
||||||
// window.location.origin
|
// window.location.origin
|
||||||
// )
|
// )
|
||||||
// 父页面发送iframe
|
// 父页面推荐使用握手方式发送 iframe context
|
||||||
// <iframe
|
// <iframe
|
||||||
// id="palyx-frame"
|
// id="palyx-frame"
|
||||||
// src="https://your-iframe-app.example.com"
|
// src="https://your-iframe-app.example.com"
|
||||||
@@ -60,18 +92,22 @@ function App() {
|
|||||||
// const iframe = document.getElementById('palyx-frame')
|
// const iframe = document.getElementById('palyx-frame')
|
||||||
// const IFRAME_ORIGIN = 'https://your-iframe-app.example.com'
|
// const IFRAME_ORIGIN = 'https://your-iframe-app.example.com'
|
||||||
//
|
//
|
||||||
// iframe.addEventListener('load', () => {
|
// window.addEventListener('message', (event) => {
|
||||||
// iframe.contentWindow.postMessage(
|
// if (event.source !== iframe.contentWindow || event.data?.type !== 'PLAYX_READY') {
|
||||||
// {
|
// return
|
||||||
// type: 'IFRAME_CONTEXT',
|
// }
|
||||||
// payload: {
|
//
|
||||||
// token: 'token',
|
// iframe.contentWindow.postMessage(
|
||||||
// language: 'zh-CN',
|
// {
|
||||||
|
// type: 'IFRAME_CONTEXT',
|
||||||
|
// payload: {
|
||||||
|
// username: '+60777777777',
|
||||||
|
// language: 'zh-CN',
|
||||||
|
// },
|
||||||
// },
|
// },
|
||||||
// },
|
// IFRAME_ORIGIN
|
||||||
// window.location.origin
|
// )
|
||||||
// )
|
// })
|
||||||
// })
|
|
||||||
// </script>
|
// </script>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,7 +117,7 @@ function App() {
|
|||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center text-[14px] text-white/68">
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Children} from 'react'
|
import {Children} from 'react'
|
||||||
|
|
||||||
|
import i18n from '@/lib/i18n'
|
||||||
import { cn } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
import type { ModalProps } from '@/types'
|
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]">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-[12px] sm:p-[16px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close modal"
|
aria-label={i18n.t('common.close')}
|
||||||
className="absolute inset-0 bg-[#050409]/65 backdrop-blur-[6px]"
|
className="absolute inset-0 bg-[#050409]/65 backdrop-blur-[6px]"
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,25 +8,25 @@ import {
|
|||||||
import type {goodsType} from '@/types/business.type.ts'
|
import type {goodsType} from '@/types/business.type.ts'
|
||||||
|
|
||||||
export type GoodCategoryMeta = {
|
export type GoodCategoryMeta = {
|
||||||
name: string
|
nameKey: string
|
||||||
ctaLabel: string
|
ctaLabelKey: string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HOME_CATEGORY_META_MAP: Record<goodsType, GoodCategoryMeta> = {
|
export const HOME_CATEGORY_META_MAP: Record<goodsType, GoodCategoryMeta> = {
|
||||||
WITHDRAW: {
|
WITHDRAW: {
|
||||||
name: 'Transfer to Platform',
|
nameKey: 'goods.categories.WITHDRAW',
|
||||||
ctaLabel: 'Transfer Now',
|
ctaLabelKey: 'goods.actions.WITHDRAW',
|
||||||
icon: ArrowRightLeft,
|
icon: ArrowRightLeft,
|
||||||
},
|
},
|
||||||
BONUS: {
|
BONUS: {
|
||||||
name: 'Game Bonus',
|
nameKey: 'goods.categories.BONUS',
|
||||||
ctaLabel: 'Redeem Bonus',
|
ctaLabelKey: 'goods.actions.BONUS',
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
},
|
},
|
||||||
PHYSICAL: {
|
PHYSICAL: {
|
||||||
name: 'Physical Prizes',
|
nameKey: 'goods.categories.PHYSICAL',
|
||||||
ctaLabel: 'Claim Prize',
|
ctaLabelKey: 'goods.actions.PHYSICAL',
|
||||||
icon: Gift,
|
icon: Gift,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,29 @@
|
|||||||
import type {AddAddressForm} from '@/types'
|
import type {AddAddressForm} from '@/types'
|
||||||
|
import i18n from '@/lib/i18n'
|
||||||
|
|
||||||
type AddressValidationResult =
|
type AddressValidationResult =
|
||||||
| { valid: true }
|
| { valid: true }
|
||||||
| { valid: false; message: string }
|
| { valid: false; message: string }
|
||||||
|
|
||||||
export function validateAddressFormSubmission(addressForm: AddAddressForm): AddressValidationResult {
|
export function validateAddressFormSubmission(addressForm: AddAddressForm): AddressValidationResult {
|
||||||
const normalizedRegion = addressForm.region.filter((value) => value.trim())
|
|
||||||
|
|
||||||
if (!addressForm.name.trim()) {
|
if (!addressForm.name.trim()) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: 'Please enter the receiver name.',
|
message: i18n.t('validation.pleaseEnterReceiverName'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!addressForm.phone.trim()) {
|
if (!addressForm.phone.trim()) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: 'Please enter a reachable mobile number.',
|
message: i18n.t('validation.pleaseEnterReachablePhone'),
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedRegion.length === 0) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
message: 'Please select a region.',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!addressForm.detailedAddress.trim()) {
|
if (!addressForm.detailedAddress.trim()) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: 'Please enter the detailed address.',
|
message: i18n.t('validation.pleaseEnterDetailedAddress'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type UseAddressBookOptions = {
|
|||||||
const emptyAddressForm: AddAddressForm = {
|
const emptyAddressForm: AddAddressForm = {
|
||||||
name: '',
|
name: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
region: [],
|
|
||||||
detailedAddress: '',
|
detailedAddress: '',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
}
|
}
|
||||||
@@ -40,7 +39,6 @@ export function mapAddressToForm(item: AddressListItem): AddAddressForm {
|
|||||||
return {
|
return {
|
||||||
name: item.receiver_name,
|
name: item.receiver_name,
|
||||||
phone: item.phone,
|
phone: item.phone,
|
||||||
region: item.region.map((part) => part.trim()).filter(Boolean).slice(0, 3),
|
|
||||||
detailedAddress: item.detail_address,
|
detailedAddress: item.detail_address,
|
||||||
isDefault: item.default_setting === 1,
|
isDefault: item.default_setting === 1,
|
||||||
}
|
}
|
||||||
@@ -66,7 +64,7 @@ export function useAddressBook(options?: UseAddressBookOptions) {
|
|||||||
session_id: sessionId!,
|
session_id: sessionId!,
|
||||||
receiver_name: addressForm.name.trim(),
|
receiver_name: addressForm.name.trim(),
|
||||||
phone: addressForm.phone.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(),
|
detail_address: addressForm.detailedAddress.trim(),
|
||||||
default_setting: addressForm.isDefault ? '1' : '0',
|
default_setting: addressForm.isDefault ? '1' : '0',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,21 +1,112 @@
|
|||||||
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
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 {login, validateToken} from '@/api/auth.ts'
|
||||||
import {userAssets} from '@/api/user.ts'
|
import {userAssets} from '@/api/user.ts'
|
||||||
|
import {useTranslation} from 'react-i18next'
|
||||||
import {queryKeys} from '@/lib/queryKeys.ts'
|
import {queryKeys} from '@/lib/queryKeys.ts'
|
||||||
import {useUserStore} from '@/store/user.ts'
|
import {useUserStore} from '@/store/user.ts'
|
||||||
|
import type {HostContextMessage} from '@/types'
|
||||||
|
|
||||||
|
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) {
|
export function AuthGuide({children}: PropsWithChildren) {
|
||||||
|
const {t} = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const [username, setUsername] = useState(TEST_BOOTSTRAP_ENABLED ? TEST_BOOTSTRAP_USERNAME : '')
|
||||||
const setUserInfo = useUserStore((state) => state.setUserInfo)
|
const setUserInfo = useUserStore((state) => state.setUserInfo)
|
||||||
const setAuthInfo = useUserStore((state) => state.setAuthInfo)
|
const setAuthInfo = useUserStore((state) => state.setAuthInfo)
|
||||||
const setAssetsInfo = useUserStore((state) => state.setAssetsInfo)
|
const setAssetsInfo = useUserStore((state) => state.setAssetsInfo)
|
||||||
|
const setLanguage = useUserStore((state) => state.setLanguage)
|
||||||
const clearUserInfo = useUserStore((state) => state.clearUserInfo)
|
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({
|
const authBootstrapQuery = useQuery({
|
||||||
queryKey: queryKeys.authBootstrap,
|
queryKey: queryKeys.authBootstrap(username),
|
||||||
|
enabled: Boolean(username),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const loginResponse = await login({username: '+60777777777'})
|
const loginResponse = await login({username})
|
||||||
const userInfo = loginResponse.data.userInfo
|
const userInfo = loginResponse.data.userInfo
|
||||||
const validateResponse = await validateToken(userInfo.token)
|
const validateResponse = await validateToken(userInfo.token)
|
||||||
const authInfo = validateResponse.data
|
const authInfo = validateResponse.data
|
||||||
@@ -50,10 +141,10 @@ export function AuthGuide({children}: PropsWithChildren) {
|
|||||||
clearUserInfo()
|
clearUserInfo()
|
||||||
}, [authBootstrapQuery.isError, clearUserInfo])
|
}, [authBootstrapQuery.isError, clearUserInfo])
|
||||||
|
|
||||||
if (authBootstrapQuery.isPending) {
|
if (!username || authBootstrapQuery.isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center text-[14px] text-white/68">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -62,8 +153,8 @@ export function AuthGuide({children}: PropsWithChildren) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center">
|
<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="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="text-[16px] font-semibold text-white">{t('auth.authenticationFailed')}</div>
|
||||||
<div className="mt-[8px] text-[13px] leading-[1.6] text-white/58">Please refresh and try again.</div>
|
<div className="mt-[8px] text-[13px] leading-[1.6] text-white/58">{t('auth.refreshAndTryAgain')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {useState} from 'react'
|
import {useState} from 'react'
|
||||||
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import {ChevronRight} from 'lucide-react'
|
import {ChevronRight} from 'lucide-react'
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ function GoodsImage({
|
|||||||
}: {
|
}: {
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
}) {
|
}) {
|
||||||
|
const {t} = useTranslation()
|
||||||
const [hasError, setHasError] = useState(false)
|
const [hasError, setHasError] = useState(false)
|
||||||
const showFallback = !imageUrl || hasError
|
const showFallback = !imageUrl || hasError
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ function GoodsImage({
|
|||||||
{showFallback ? (
|
{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="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="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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -47,16 +49,18 @@ function GoodsImage({
|
|||||||
export function GoodsCategoryList({
|
export function GoodsCategoryList({
|
||||||
categories,
|
categories,
|
||||||
loading = false,
|
loading = false,
|
||||||
emptyText = 'No goods available yet.',
|
emptyText,
|
||||||
showMore = false,
|
showMore = false,
|
||||||
onMoreClick,
|
onMoreClick,
|
||||||
onRedeem,
|
onRedeem,
|
||||||
}: GoodsCategoryListProps) {
|
}: GoodsCategoryListProps) {
|
||||||
|
const {t} = useTranslation()
|
||||||
|
const resolvedEmptyText = emptyText ?? t('goods.noGoodsAvailableYet')
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="pb-[24px] mt-[20px]">
|
<div className="pb-[24px] mt-[20px]">
|
||||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||||
Loading ...
|
{t('goods.loading')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -66,7 +70,7 @@ export function GoodsCategoryList({
|
|||||||
return (
|
return (
|
||||||
<div className="pb-[24px] mt-[20px]">
|
<div className="pb-[24px] mt-[20px]">
|
||||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||||
{emptyText}
|
{resolvedEmptyText}
|
||||||
</div>
|
</div>
|
||||||
</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"
|
className="flex shrink-0 items-center gap-[3px] text-[12px] font-light text-[#FA6A00] underline cursor-pointer"
|
||||||
onClick={() => onMoreClick(category.id)}
|
onClick={() => onMoreClick(category.id)}
|
||||||
>
|
>
|
||||||
more
|
{t('common.more')}
|
||||||
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true"/>
|
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true"/>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CirclePlus,
|
CirclePlus,
|
||||||
Gift,
|
|
||||||
MapPinHouse,
|
MapPinHouse,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import {useState} from 'react'
|
||||||
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import Button from '@/components/button'
|
import Button from '@/components/button'
|
||||||
import Modal from '@/components/modal'
|
import Modal from '@/components/modal'
|
||||||
import {RegionPicker} from '@/features/goods/RegionPicker'
|
|
||||||
import type {
|
import type {
|
||||||
AddAddressForm,
|
AddAddressForm,
|
||||||
AddressOption,
|
AddressOption,
|
||||||
@@ -47,6 +47,38 @@ type GoodsRedeemModalProps = {
|
|||||||
confirmText?: string
|
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({
|
export function GoodsRedeemModal({
|
||||||
selectedProduct,
|
selectedProduct,
|
||||||
modalMode,
|
modalMode,
|
||||||
@@ -65,8 +97,9 @@ export function GoodsRedeemModal({
|
|||||||
forceOpen = false,
|
forceOpen = false,
|
||||||
formOnly = false,
|
formOnly = false,
|
||||||
titleOverride,
|
titleOverride,
|
||||||
confirmText = 'Confirm',
|
confirmText,
|
||||||
}: GoodsRedeemModalProps) {
|
}: GoodsRedeemModalProps) {
|
||||||
|
const {t} = useTranslation()
|
||||||
const selectedCategoryId = selectedProduct?.categoryId
|
const selectedCategoryId = selectedProduct?.categoryId
|
||||||
const selectedProductData = selectedProduct?.product ?? null
|
const selectedProductData = selectedProduct?.product ?? null
|
||||||
const isPhysicalPrize = selectedCategoryId === 'PHYSICAL'
|
const isPhysicalPrize = selectedCategoryId === 'PHYSICAL'
|
||||||
@@ -74,12 +107,12 @@ export function GoodsRedeemModal({
|
|||||||
const isGameBonus = selectedCategoryId === 'BONUS'
|
const isGameBonus = selectedCategoryId === 'BONUS'
|
||||||
const isPhysicalPrizeSelection = modalMode === 'select-address' && isPhysicalPrize
|
const isPhysicalPrizeSelection = modalMode === 'select-address' && isPhysicalPrize
|
||||||
const modalTitle = modalMode === 'add-address'
|
const modalTitle = modalMode === 'add-address'
|
||||||
? 'Add Shipping Address'
|
? t('goods.addShippingAddress')
|
||||||
: isTransferToPlatform
|
: isTransferToPlatform
|
||||||
? 'Confirm Withdrawal'
|
? t('goods.confirmWithdrawal')
|
||||||
: isGameBonus
|
: isGameBonus
|
||||||
? 'Confirm Bonus Redemption'
|
? t('goods.confirmBonusRedemption')
|
||||||
: 'Confirm Physical Reward'
|
: t('goods.confirmPhysicalReward')
|
||||||
const modalMaxWidthClassName = isTransferToPlatform
|
const modalMaxWidthClassName = isTransferToPlatform
|
||||||
? 'max-w-[620px]'
|
? 'max-w-[620px]'
|
||||||
: isGameBonus
|
: 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]">
|
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">
|
<div className="flex items-center gap-[8px] text-[18px] font-medium text-white">
|
||||||
<MapPinHouse className="h-[18px] w-[18px] text-[#FE9F00]" aria-hidden="true"/>
|
<MapPinHouse className="h-[18px] w-[18px] text-[#FE9F00]" aria-hidden="true"/>
|
||||||
<span>Address Info</span>
|
<span>{t('goods.addressInfo')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -113,7 +146,7 @@ export function GoodsRedeemModal({
|
|||||||
}`}
|
}`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
<span>Default Address</span>
|
<span>{t('goods.defaultAddress')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -121,47 +154,37 @@ export function GoodsRedeemModal({
|
|||||||
<div
|
<div
|
||||||
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
|
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">
|
<label className="text-[14px] text-white/92">
|
||||||
Name<span className="text-[#FA6A00]">*</span>
|
{t('goods.name')}<span className="text-[#FA6A00]">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={addressForm.name}
|
value={addressForm.name}
|
||||||
onChange={(event) => onChangeAddressForm('name', event.target.value)}
|
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"
|
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
|
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">
|
<label className="text-[14px] text-white/92">
|
||||||
Phone Number<span className="text-[#FA6A00]">*</span>
|
{t('goods.phoneNumber')}<span className="text-[#FA6A00]">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={addressForm.phone}
|
value={addressForm.phone}
|
||||||
type="number"
|
type="number"
|
||||||
onChange={(event) => onChangeAddressForm('phone', event.target.value)}
|
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"
|
className="input-no-spin bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
|
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">
|
<label className="text-[14px] text-white/92">
|
||||||
Region<span className="text-[#FA6A00]">*</span>
|
{t('goods.detailedAddress')}<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>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={addressForm.detailedAddress}
|
value={addressForm.detailedAddress}
|
||||||
onChange={(event) => onChangeAddressForm('detailedAddress', event.target.value)}
|
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"
|
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,7 +210,7 @@ export function GoodsRedeemModal({
|
|||||||
onClick={modalMode === 'add-address' ? onBackToSelectAddress : onClose}
|
onClick={modalMode === 'add-address' ? onBackToSelectAddress : onClose}
|
||||||
disabled={submitLoading}
|
disabled={submitLoading}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -197,7 +220,7 @@ export function GoodsRedeemModal({
|
|||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={submitLoading || (modalMode === 'add-address' && !isAddAddressFormValid)}
|
disabled={submitLoading || (modalMode === 'add-address' && !isAddAddressFormValid)}
|
||||||
>
|
>
|
||||||
{submitLoading ? 'Processing...' : confirmText}
|
{submitLoading ? t('common.processing') : (confirmText ?? t('common.confirm'))}
|
||||||
</Button>
|
</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)]">
|
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="divide-y divide-white/8">
|
||||||
<div className="flex items-center justify-between py-[14px] text-white">
|
<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 className="text-[18px]">{getNumericValue(selectedProductData.title)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-[14px] text-white">
|
<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 className="text-[18px]">{getNumericValue(selectedProductData.score)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-[14px] text-white">
|
<div className="flex items-center justify-between py-[14px] text-white">
|
||||||
<div className="text-[18px] underline decoration-[#1E90FF] underline-offset-[3px]">
|
<div className="text-[18px] underline decoration-[#1E90FF] underline-offset-[3px]">
|
||||||
Turnover Requirement
|
{t('goods.turnoverRequirement')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px]">{getTurnoverRequirement(selectedProductData.subtitle)}</div>
|
<div className="text-[18px]">{getTurnoverRequirement(selectedProductData.subtitle)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-[10px] text-center text-[18px] text-white/45">
|
<div className="pt-[10px] text-center text-[18px] text-white/45">
|
||||||
Submit withdrawal request?
|
{t('goods.submitWithdrawalRequest')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isGameBonus ? (
|
) : 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)]">
|
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="divide-y divide-white/8">
|
||||||
<div className="flex items-center justify-between py-[14px] text-white">
|
<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 className="text-[14px]">{selectedProductData.title}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-[14px] text-white">
|
<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 className="text-[14px]">{getNumericValue(selectedProductData.score)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-[14px] text-white">
|
<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 className="text-[14px]">{getTurnoverRequirement(selectedProductData.subtitle)}</div>
|
||||||
</div>
|
</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)]">
|
className="rounded-[12px] bg-[#1F1B1B]/82 px-[14px] py-[10px] shadow-[0_10px_28px_rgba(0,0,0,0.2)]">
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between gap-[12px] border-b border-white/8 py-[12px]">
|
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="flex items-center gap-[10px]">
|
||||||
<div className="text-right text-[15px] text-white">{selectedProductData.title}</div>
|
<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)]">
|
<SelectedProductImage
|
||||||
{selectedProductData.imageUrl ? (
|
imageUrl={selectedProductData.imageUrl}
|
||||||
<img
|
alt={selectedProductData.title}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-[12px]">
|
<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 className="text-[15px] text-white">{getNumericValue(selectedProductData.score)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-[12px] text-[14px] text-white/40">
|
<div className="mb-[12px] text-[14px] text-white/40">
|
||||||
Please select the address information to fill in.
|
{t('goods.pleaseSelectAddressInfo')}
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-[12px] bg-[#1F1B1B]/82 shadow-[0_10px_28px_rgba(0,0,0,0.2)]">
|
<div className="overflow-hidden rounded-[12px] bg-[#1F1B1B]/82 shadow-[0_10px_28px_rgba(0,0,0,0.2)]">
|
||||||
{addressLoading ? (
|
{addressLoading ? (
|
||||||
<div className="px-[14px] py-[16px] text-[14px] text-white/60">
|
<div className="px-[14px] py-[16px] text-[14px] text-white/60">
|
||||||
Loading address list...
|
{t('goods.loadingAddressList')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{addressOptions.map((address) => {
|
{addressOptions.map((address) => {
|
||||||
@@ -316,7 +330,7 @@ export function GoodsRedeemModal({
|
|||||||
{address.isDefault ? (
|
{address.isDefault ? (
|
||||||
<div
|
<div
|
||||||
className="rounded-[4px] bg-[#FF7F7F] px-[5px] py-[1px] text-[10px] leading-none text-white">
|
className="rounded-[4px] bg-[#FF7F7F] px-[5px] py-[1px] text-[10px] leading-none text-white">
|
||||||
Default
|
{t('account.default')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
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"/>
|
<CirclePlus className="h-[12px] w-[12px]" aria-hidden="true"/>
|
||||||
</span>
|
</span>
|
||||||
<div className="text-[14px] text-white/82">Add Address</div>
|
<div className="text-[14px] text-white/82">{t('goods.addAddress')}</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-[16px] w-[16px] text-white/45" aria-hidden="true"/>
|
<ChevronRight className="h-[16px] w-[16px] text-white/45" aria-hidden="true"/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {SelectedProductState} from '@/types'
|
import type {SelectedProductState} from '@/types'
|
||||||
|
import i18n from '@/lib/i18n'
|
||||||
|
|
||||||
type RedeemValidationParams = {
|
type RedeemValidationParams = {
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
@@ -18,21 +19,21 @@ export function validateRedeemSubmission({
|
|||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: 'Session expired. Please log in again.',
|
message: i18n.t('validation.sessionExpired'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedProduct) {
|
if (!selectedProduct) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: 'No product selected.',
|
message: i18n.t('validation.noProductSelected'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedProduct.categoryId === 'PHYSICAL' && !selectedAddressId) {
|
if (selectedProduct.categoryId === 'PHYSICAL' && !selectedAddressId) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: 'Please select a shipping address.',
|
message: i18n.t('validation.pleaseSelectShippingAddress'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export function validateAddAddressSubmission({
|
|||||||
if (!isAddAddressFormValid) {
|
if (!isAddAddressFormValid) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
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 {useQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {goodList} from '@/api/business.ts'
|
import {goodList} from '@/api/business.ts'
|
||||||
|
import i18n from '@/lib/i18n'
|
||||||
import {
|
import {
|
||||||
API_ORIGIN,
|
API_ORIGIN,
|
||||||
HOME_CATEGORY_META_MAP,
|
HOME_CATEGORY_META_MAP,
|
||||||
@@ -29,8 +30,8 @@ function mapGoodItemToProductItem(item: GoodsItem, type: goodsType): ProductItem
|
|||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.description,
|
subtitle: item.description,
|
||||||
score: `${item.score} Points`,
|
score: `${item.score} ${i18n.t('common.points')}`,
|
||||||
ctaLabel: categoryMeta.ctaLabel,
|
ctaLabel: i18n.t(categoryMeta.ctaLabelKey),
|
||||||
imageUrl: getProductImageUrl(item.image),
|
imageUrl: getProductImageUrl(item.image),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,7 +39,7 @@ function mapGoodItemToProductItem(item: GoodsItem, type: goodsType): ProductItem
|
|||||||
function buildProductCategories(groups: Record<goodsType, GoodsItem[]>): ProductCategory[] {
|
function buildProductCategories(groups: Record<goodsType, GoodsItem[]>): ProductCategory[] {
|
||||||
return HOME_GOOD_TYPE_ORDER.map((type) => ({
|
return HOME_GOOD_TYPE_ORDER.map((type) => ({
|
||||||
id: 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)),
|
items: groups[type].map((item) => mapGoodItemToProductItem(item, type)),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -75,12 +76,12 @@ export function useGoodsCatalog(options?: UseGoodsCatalogOptions) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return buildProductCategories(groups)
|
return groups
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
productCategories: goodsCatalogQuery.data ?? [],
|
productCategories: goodsCatalogQuery.data ? buildProductCategories(goodsCatalogQuery.data) : [],
|
||||||
loading: goodsCatalogQuery.isPending,
|
loading: goodsCatalogQuery.isPending,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {useState} from 'react'
|
|||||||
import {bonusRedeem, physicalRedeem, withdrawApply} from '@/api/business.ts'
|
import {bonusRedeem, physicalRedeem, withdrawApply} from '@/api/business.ts'
|
||||||
import {useAddressBook} from '@/features/addressBook'
|
import {useAddressBook} from '@/features/addressBook'
|
||||||
import {notifyError, notifySuccess} from '@/features/notifications'
|
import {notifyError, notifySuccess} from '@/features/notifications'
|
||||||
|
import i18n from '@/lib/i18n'
|
||||||
import {queryKeys} from '@/lib/queryKeys.ts'
|
import {queryKeys} from '@/lib/queryKeys.ts'
|
||||||
import {validateAddAddressSubmission, validateRedeemSubmission} from '@/features/goods/redeemValidation'
|
import {validateAddAddressSubmission, validateRedeemSubmission} from '@/features/goods/redeemValidation'
|
||||||
import type {
|
import type {
|
||||||
@@ -105,7 +106,7 @@ export function useGoodsRedeem() {
|
|||||||
: null)
|
: null)
|
||||||
setSelectedAddressId(defaultOption?.id ?? '')
|
setSelectedAddressId(defaultOption?.id ?? '')
|
||||||
setModalMode('select-address')
|
setModalMode('select-address')
|
||||||
notifySuccess(savedAddress.response, 'Address added successfully.')
|
notifySuccess(savedAddress.response, i18n.t('goods.addressAddedSuccessfully'))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -129,7 +130,7 @@ export function useGoodsRedeem() {
|
|||||||
? queryClient.invalidateQueries({queryKey: queryKeys.assets(addressBook.sessionId)})
|
? queryClient.invalidateQueries({queryKey: queryKeys.assets(addressBook.sessionId)})
|
||||||
: Promise.resolve(),
|
: Promise.resolve(),
|
||||||
])
|
])
|
||||||
notifySuccess(response, 'Redeem request submitted successfully.')
|
notifySuccess(response, i18n.t('goods.redeemRequestSubmittedSuccessfully'))
|
||||||
closeRedeemModal()
|
closeRedeemModal()
|
||||||
} catch {
|
} catch {
|
||||||
// mutation error is surfaced via redeemMutation.error
|
// mutation error is surfaced via redeemMutation.error
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import type {ValidateTokenData} from '@/types/auth.type.ts'
|
import type {ValidateTokenData} from '@/types/auth.type.ts'
|
||||||
|
import type {UserAssetsData} from '@/types/user.type.ts'
|
||||||
|
import i18n from '@/lib/i18n'
|
||||||
|
|
||||||
type ClaimValidationResult =
|
type ClaimValidationResult =
|
||||||
| { valid: true }
|
| { valid: true }
|
||||||
| { valid: false; message: string }
|
| { 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) {
|
if (!authInfo?.session_id || !authInfo.user_id) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
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,
|
body,
|
||||||
#root {
|
#root {
|
||||||
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
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,
|
.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'
|
import type {goodsType} from '@/types/business.type.ts'
|
||||||
|
|
||||||
export const queryKeys = {
|
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,
|
goodsCatalog: (types?: readonly goodsType[]) => ['goods-catalog', ...(types ?? ['all'])] as const,
|
||||||
assets: (sessionId: string) => ['assets', sessionId] as const,
|
assets: (sessionId: string) => ['assets', sessionId] as const,
|
||||||
addressList: (sessionId: string) => ['address-list', 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 ky, {HTTPError, type AfterResponseHook, type Input, type KyInstance, type Options} from 'ky'
|
||||||
|
|
||||||
import {notifyError, resolveToastMessage} from '@/features/notifications'
|
import {notifyError, resolveToastMessage} from '@/features/notifications'
|
||||||
|
import i18n from '@/lib/i18n'
|
||||||
import {useUserStore} from '@/store/user.ts'
|
import {useUserStore} from '@/store/user.ts'
|
||||||
import type {ValidateTokenData} from '@/types/auth.type.ts'
|
import type {ValidateTokenData} from '@/types/auth.type.ts'
|
||||||
import {objectToFormData} from './tool'
|
import {objectToFormData} from './tool'
|
||||||
@@ -44,7 +45,17 @@ const isApiEnvelope = (value: unknown): value is ApiEnvelope =>
|
|||||||
'code' in value &&
|
'code' in value &&
|
||||||
typeof (value as {code?: unknown}).code === 'number'
|
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 REQUEST_TIMEOUT = 10_000
|
||||||
const AUTH_RETRY_HEADER = 'x-auth-retried'
|
const AUTH_RETRY_HEADER = 'x-auth-retried'
|
||||||
const VERIFY_TOKEN_PATH = '/v1/mall/verifyToken'
|
const VERIFY_TOKEN_PATH = '/v1/mall/verifyToken'
|
||||||
@@ -56,12 +67,18 @@ export const setAccessTokenFormatter = (formatter?: TokenFormatter) => {
|
|||||||
accessTokenFormatter = formatter ?? ((token) => `Bearer ${token}`)
|
accessTokenFormatter = formatter ?? ((token) => `Bearer ${token}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRequestLanguage = () => useUserStore.getState().language?.trim() || 'zh'
|
||||||
|
|
||||||
const authRefreshClient = ky.create({
|
const authRefreshClient = ky.create({
|
||||||
baseUrl: API_BASE_URL,
|
baseUrl: API_BASE_URL,
|
||||||
timeout: REQUEST_TIMEOUT,
|
timeout: REQUEST_TIMEOUT,
|
||||||
retry: 0,
|
retry: 0,
|
||||||
headers: {
|
hooks: {
|
||||||
lang: 'zh',
|
beforeRequest: [
|
||||||
|
({request}) => {
|
||||||
|
request.headers.set('lang', getRequestLanguage())
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -88,7 +105,7 @@ async function refreshAuthInfo() {
|
|||||||
refreshAuthInfoPromise = (async () => {
|
refreshAuthInfoPromise = (async () => {
|
||||||
const token = useUserStore.getState().userInfo?.token?.trim()
|
const token = useUserStore.getState().userInfo?.token?.trim()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new RequestError('Unauthorized')
|
throw new RequestError(i18n.t('errors.unauthorized'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await authRefreshClient.post('v1/mall/verifyToken', {
|
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) {
|
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)
|
useUserStore.getState().setAuthInfo(response.data)
|
||||||
@@ -141,12 +158,11 @@ const requestClient = ky.create({
|
|||||||
baseUrl: API_BASE_URL,
|
baseUrl: API_BASE_URL,
|
||||||
timeout: REQUEST_TIMEOUT,
|
timeout: REQUEST_TIMEOUT,
|
||||||
retry: 0,
|
retry: 0,
|
||||||
headers: {
|
|
||||||
lang: 'zh',
|
|
||||||
},
|
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeRequest: [
|
beforeRequest: [
|
||||||
({request}) => {
|
({request}) => {
|
||||||
|
request.headers.set('lang', getRequestLanguage())
|
||||||
|
|
||||||
const token = useUserStore.getState().userInfo?.token
|
const token = useUserStore.getState().userInfo?.token
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return
|
return
|
||||||
@@ -165,7 +181,7 @@ const requestClient = ky.create({
|
|||||||
error.data,
|
error.data,
|
||||||
typeof error.data === 'string'
|
typeof error.data === 'string'
|
||||||
? error.data
|
? error.data
|
||||||
: error.response.statusText || 'Request failed',
|
: error.response.statusText || i18n.t('errors.requestFailed'),
|
||||||
)
|
)
|
||||||
|
|
||||||
notifyError(message)
|
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)
|
notifyError(message)
|
||||||
return new RequestError(message, {
|
return new RequestError(message, {
|
||||||
cause: error,
|
cause: error,
|
||||||
@@ -219,7 +235,7 @@ async function parseResponse<T, R extends ResponseType>(
|
|||||||
const payload = await response.json()
|
const payload = await response.json()
|
||||||
|
|
||||||
if (isApiEnvelope(payload) && payload.code !== 1 && payload.code !== 200) {
|
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)
|
notifyError(message)
|
||||||
throw new RequestError(message, {
|
throw new RequestError(message, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import '@/lib/i18n'
|
||||||
import { queryClient } from '@/lib/query.ts'
|
import { queryClient } from '@/lib/query.ts'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
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
|
setAuthInfo: (authInfo: ValidateTokenData) => void
|
||||||
assetsInfo: UserAssetsData | null
|
assetsInfo: UserAssetsData | null
|
||||||
setAssetsInfo: (assetsInfo: UserAssetsData) => void
|
setAssetsInfo: (assetsInfo: UserAssetsData) => void
|
||||||
|
language: string
|
||||||
|
setLanguage: (language: string) => void
|
||||||
clearUserInfo: () => void
|
clearUserInfo: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +25,8 @@ export const useUserStore = create<UserState>()(
|
|||||||
setAuthInfo: (authInfo) => set({authInfo}),
|
setAuthInfo: (authInfo) => set({authInfo}),
|
||||||
assetsInfo: null,
|
assetsInfo: null,
|
||||||
setAssetsInfo: (assetsInfo) => set({assetsInfo}),
|
setAssetsInfo: (assetsInfo) => set({assetsInfo}),
|
||||||
|
language: 'zh',
|
||||||
|
setLanguage: (language) => set({language}),
|
||||||
clearUserInfo: () => set({userInfo: null, authInfo: null, assetsInfo: null}),
|
clearUserInfo: () => set({userInfo: null, authInfo: null, assetsInfo: null}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -32,6 +36,7 @@ export const useUserStore = create<UserState>()(
|
|||||||
userInfo: state.userInfo,
|
userInfo: state.userInfo,
|
||||||
authInfo: state.authInfo,
|
authInfo: state.authInfo,
|
||||||
assetsInfo: state.assetsInfo,
|
assetsInfo: state.assetsInfo,
|
||||||
|
language: state.language,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type HostContextMessage = {
|
|||||||
payload?: {
|
payload?: {
|
||||||
token?: string
|
token?: string
|
||||||
language?: string
|
language?: string
|
||||||
|
username?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +81,6 @@ export type ModalMode = 'select-address' | 'add-address'
|
|||||||
export type AddAddressForm = {
|
export type AddAddressForm = {
|
||||||
name: string
|
name: string
|
||||||
phone: string
|
phone: string
|
||||||
region: string[]
|
|
||||||
detailedAddress: string
|
detailedAddress: string
|
||||||
isDefault: boolean
|
isDefault: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {useState} from 'react'
|
import {useState} from 'react'
|
||||||
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import PageLayout from '@/components/layout'
|
import PageLayout from '@/components/layout'
|
||||||
import Modal from '@/components/modal'
|
import Modal from '@/components/modal'
|
||||||
@@ -11,6 +12,7 @@ import {notifySuccess} from '@/features/notifications'
|
|||||||
import type {AddressListItem} from '@/types/address.type.ts'
|
import type {AddressListItem} from '@/types/address.type.ts'
|
||||||
|
|
||||||
function AccountPage() {
|
function AccountPage() {
|
||||||
|
const {t} = useTranslation()
|
||||||
const addressBook = useAddressBook({autoLoad: true})
|
const addressBook = useAddressBook({autoLoad: true})
|
||||||
const [addressModalOpen, setAddressModalOpen] = useState(false)
|
const [addressModalOpen, setAddressModalOpen] = useState(false)
|
||||||
const [editingAddress, setEditingAddress] = useState<AddressListItem | null>(null)
|
const [editingAddress, setEditingAddress] = useState<AddressListItem | null>(null)
|
||||||
@@ -40,7 +42,7 @@ function AccountPage() {
|
|||||||
const saved = await addressBook.saveAddress(editingAddress)
|
const saved = await addressBook.saveAddress(editingAddress)
|
||||||
if (saved) {
|
if (saved) {
|
||||||
handleCloseAddressModal()
|
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))
|
const deleted = await addressBook.removeAddress(String(deleteTarget.id))
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
notifySuccess(deleted, 'Address deleted successfully.')
|
notifySuccess(deleted, t('account.addressDeletedSuccessfully'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<PageLayout contentClassName="flex h-[100svh] w-full flex-col overflow-hidden px-4 pb-8 sm:px-6 lg:px-8">
|
||||||
<Link
|
<div className="mx-auto w-full max-w-[1120px]">
|
||||||
to="/"
|
<Link
|
||||||
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]"
|
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" />
|
<div className="flex items-center gap-[8px]">
|
||||||
<span className="text-[14px] font-medium text-white/92">Back</span>
|
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
|
||||||
</div>
|
<span className="text-[14px] font-medium text-white/92">{t('common.back')}</span>
|
||||||
<div className="text-[15px] font-semibold text-[#F56E10]">Account</div>
|
</div>
|
||||||
<div className="w-[52px]"></div>
|
<div className="text-[15px] font-semibold text-[#F56E10]">{t('account.title')}</div>
|
||||||
</Link>
|
<div className="w-[52px]"></div>
|
||||||
|
</Link>
|
||||||
<div className="mx-auto mt-[20px] w-full max-w-[1000px]">
|
</div>
|
||||||
<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 min-h-0 flex-1 flex-col pt-[20px]">
|
||||||
<div className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/15 text-[#FE9F00]">
|
<div className="mx-auto w-full max-w-[1000px]">
|
||||||
<MapPinHouse className="h-[18px] w-[18px]" aria-hidden="true" />
|
<div className="mb-[14px] flex flex-col gap-[12px] sm:flex-row sm:items-center sm:justify-between">
|
||||||
</div>
|
<div className="flex items-center gap-[10px]">
|
||||||
<div>
|
<div className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/15 text-[#FE9F00]">
|
||||||
<div className="text-[16px] font-semibold text-white">My Shipping Address</div>
|
<MapPinHouse className="h-[18px] w-[18px]" aria-hidden="true" />
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{addressBook.loading ? (
|
<div className="min-h-0 flex-1 overflow-y-auto pb-[24px]">
|
||||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
<div className="mx-auto w-full max-w-[1000px]">
|
||||||
Loading address list...
|
{addressBook.loading ? (
|
||||||
</div>
|
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||||
) : !addressBook.addresses.length ? (
|
{t('account.loadingAddressList')}
|
||||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
</div>
|
||||||
No shipping address found. Add one.
|
) : !addressBook.addresses.length ? (
|
||||||
</div>
|
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||||
) : (
|
{t('account.noShippingAddressFound')}
|
||||||
<div className="grid grid-cols-1 gap-[12px]">
|
</div>
|
||||||
{addressBook.addresses.map((address) => {
|
) : (
|
||||||
const addressId = String(address.id)
|
<div className="grid grid-cols-1 gap-[12px]">
|
||||||
const addressText = addressBook.addressOptions.find((option) => option.id === addressId)?.address ?? ''
|
{addressBook.addresses.map((address) => {
|
||||||
const isDefault = address.default_setting === 1
|
const addressId = String(address.id)
|
||||||
|
const addressText = addressBook.addressOptions.find((option) => option.id === addressId)?.address ?? ''
|
||||||
|
const isDefault = address.default_setting === 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={addressId} className="liquid-glass-bg p-[14px]">
|
<div key={addressId} className="liquid-glass-bg p-[14px]">
|
||||||
<div className="flex items-start justify-between gap-[12px]">
|
<div className="flex items-start justify-between gap-[12px]">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[16px] font-semibold text-white">{address.receiver_name}</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 className="mt-[4px] text-[13px] text-white/62">{address.phone}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${
|
className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${
|
||||||
isDefault
|
isDefault
|
||||||
? 'bg-[#FA6A00]/14 text-[#FFB36D]'
|
? 'bg-[#FA6A00]/14 text-[#FFB36D]'
|
||||||
: 'bg-white/6 text-white/62'
|
: 'bg-white/6 text-white/62'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isDefault ? (
|
{isDefault ? (
|
||||||
<span className="inline-flex items-center gap-[5px]">
|
<span className="inline-flex items-center gap-[5px]">
|
||||||
<BadgeCheck className="h-[12px] w-[12px]" aria-hidden="true" />
|
<BadgeCheck className="h-[12px] w-[12px]" aria-hidden="true" />
|
||||||
Default
|
{t('account.default')}
|
||||||
</span>
|
</span>
|
||||||
) : 'Optional'}
|
) : t('account.optional')}
|
||||||
</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">{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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GoodsRedeemModal
|
<GoodsRedeemModal
|
||||||
@@ -172,13 +183,13 @@ function AccountPage() {
|
|||||||
onChangeAddressForm={addressBook.changeAddressForm}
|
onChangeAddressForm={addressBook.changeAddressForm}
|
||||||
forceOpen={addressModalOpen}
|
forceOpen={addressModalOpen}
|
||||||
formOnly
|
formOnly
|
||||||
titleOverride={editingAddress ? 'Edit Shipping Address' : 'Add Shipping Address'}
|
titleOverride={editingAddress ? t('goods.editShippingAddress') : t('goods.addShippingAddress')}
|
||||||
confirmText={editingAddress ? 'Save Changes' : 'Add Address'}
|
confirmText={editingAddress ? t('goods.saveChanges') : t('account.addAddress')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
title="Delete Address"
|
title={t('account.deleteAddress')}
|
||||||
onClose={() => setDeleteTarget(null)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
className="max-w-[420px]"
|
className="max-w-[420px]"
|
||||||
bodyClassName="space-y-[18px]"
|
bodyClassName="space-y-[18px]"
|
||||||
@@ -191,7 +202,7 @@ function AccountPage() {
|
|||||||
onClick={() => setDeleteTarget(null)}
|
onClick={() => setDeleteTarget(null)}
|
||||||
disabled={addressBook.deleteLoading}
|
disabled={addressBook.deleteLoading}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -199,13 +210,13 @@ function AccountPage() {
|
|||||||
onClick={handleConfirmDelete}
|
onClick={handleConfirmDelete}
|
||||||
disabled={addressBook.deleteLoading}
|
disabled={addressBook.deleteLoading}
|
||||||
>
|
>
|
||||||
{addressBook.deleteLoading ? 'Deleting...' : 'Delete'}
|
{addressBook.deleteLoading ? t('common.processing') : t('account.delete')}
|
||||||
</Button>
|
</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)]">
|
<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>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {ArrowLeft} from 'lucide-react'
|
import {ArrowLeft} from 'lucide-react'
|
||||||
|
import {useTranslation} from 'react-i18next'
|
||||||
import {Link, useSearchParams} from 'react-router-dom'
|
import {Link, useSearchParams} from 'react-router-dom'
|
||||||
|
|
||||||
import PageLayout from '@/components/layout'
|
import PageLayout from '@/components/layout'
|
||||||
import {HOME_GOOD_TYPE_ORDER} from '@/constant'
|
import {HOME_CATEGORY_META_MAP, HOME_GOOD_TYPE_ORDER} from '@/constant'
|
||||||
import {
|
import {
|
||||||
GoodsCategoryList,
|
GoodsCategoryList,
|
||||||
GoodsRedeemModal,
|
GoodsRedeemModal,
|
||||||
@@ -12,12 +13,14 @@ import {
|
|||||||
} from '@/features/goods'
|
} from '@/features/goods'
|
||||||
|
|
||||||
function GoodsPage() {
|
function GoodsPage() {
|
||||||
|
const {t} = useTranslation()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const queryType = searchParams.get('type')
|
const queryType = searchParams.get('type')
|
||||||
const selectedType = isGoodsType(queryType) ? queryType : HOME_GOOD_TYPE_ORDER[0]
|
const selectedType = isGoodsType(queryType) ? queryType : HOME_GOOD_TYPE_ORDER[0]
|
||||||
const {productCategories, loading} = useGoodsCatalog({types: [selectedType]})
|
const {productCategories, loading} = useGoodsCatalog({types: [selectedType]})
|
||||||
const redeem = useGoodsRedeem()
|
const redeem = useGoodsRedeem()
|
||||||
const visibleCategories = productCategories.filter((category) => category.id === selectedType)
|
const visibleCategories = productCategories.filter((category) => category.id === selectedType)
|
||||||
|
const pageTitle = t(HOME_CATEGORY_META_MAP[selectedType].nameKey)
|
||||||
|
|
||||||
return (
|
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">
|
<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]">
|
<div className="flex items-center gap-[8px]">
|
||||||
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true"/>
|
<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>
|
||||||
<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>
|
<div className="w-[52px]"></div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -39,7 +42,7 @@ function GoodsPage() {
|
|||||||
<GoodsCategoryList
|
<GoodsCategoryList
|
||||||
categories={visibleCategories}
|
categories={visibleCategories}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyText="No goods found for this category."
|
emptyText={t('goods.noGoodsForCategory')}
|
||||||
onRedeem={redeem.openRedeemModal}
|
onRedeem={redeem.openRedeemModal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {useState} from 'react'
|
import {useState} from 'react'
|
||||||
|
|
||||||
import {useMutation} from '@tanstack/react-query'
|
import {useMutation} from '@tanstack/react-query'
|
||||||
|
import {Languages} from 'lucide-react'
|
||||||
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import PageLayout from '@/components/layout'
|
import PageLayout from '@/components/layout'
|
||||||
import Modal from '@/components/modal'
|
import Modal from '@/components/modal'
|
||||||
@@ -11,8 +13,8 @@ import {
|
|||||||
Coins,
|
Coins,
|
||||||
Gauge,
|
Gauge,
|
||||||
History,
|
History,
|
||||||
UserRound,
|
|
||||||
Wallet,
|
Wallet,
|
||||||
|
UserRound,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type {
|
import type {
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
@@ -30,6 +32,7 @@ import {validateClaimSubmission} from '@/features/home/claimValidation'
|
|||||||
import {claim} from '@/api/business.ts'
|
import {claim} from '@/api/business.ts'
|
||||||
import {notifyError, notifySuccess} from '@/features/notifications'
|
import {notifyError, notifySuccess} from '@/features/notifications'
|
||||||
import {useUserStore} from "@/store/user.ts";
|
import {useUserStore} from "@/store/user.ts";
|
||||||
|
import {normalizeLanguage} from '@/lib/i18n'
|
||||||
|
|
||||||
function QuickNavCard({icon: Icon, label, to}: QuickNavCardProps) {
|
function QuickNavCard({icon: Icon, label, to}: QuickNavCardProps) {
|
||||||
return (
|
return (
|
||||||
@@ -58,9 +61,12 @@ function getProgressPercent(current = 0, total = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
|
const {t} = useTranslation()
|
||||||
const [claimModalOpen, setClaimModalOpen] = useState(false)
|
const [claimModalOpen, setClaimModalOpen] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const authInfo = useUserStore(state => state.authInfo)
|
const authInfo = useUserStore(state => state.authInfo)
|
||||||
|
const language = useUserStore((state) => state.language)
|
||||||
|
const setLanguage = useUserStore((state) => state.setLanguage)
|
||||||
const {productCategories, loading} = useGoodsCatalog()
|
const {productCategories, loading} = useGoodsCatalog()
|
||||||
const {invalidateAssets} = useAssetsRefresh()
|
const {invalidateAssets} = useAssetsRefresh()
|
||||||
const redeem = useGoodsRedeem()
|
const redeem = useGoodsRedeem()
|
||||||
@@ -78,12 +84,18 @@ function HomePage() {
|
|||||||
|
|
||||||
const {assetsInfo} = useAssetsQuery()
|
const {assetsInfo} = useAssetsQuery()
|
||||||
const claimProgress = getProgressPercent(assetsInfo?.today_claimed, assetsInfo?.today_limit)
|
const claimProgress = getProgressPercent(assetsInfo?.today_claimed, assetsInfo?.today_limit)
|
||||||
|
const isClaimAvailable = (assetsInfo?.locked_points ?? 0) > 0
|
||||||
const previewCategories: ProductCategory[] = productCategories.map((category) => ({
|
const previewCategories: ProductCategory[] = productCategories.map((category) => ({
|
||||||
...category,
|
...category,
|
||||||
items: category.items.slice(0, 4),
|
items: category.items.slice(0, 4),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const handleOpenClaimModal = () => {
|
const handleOpenClaimModal = () => {
|
||||||
|
if (!isClaimAvailable) {
|
||||||
|
notifyError(t('home.noClaimablePointsAvailable'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setClaimModalOpen(true)
|
setClaimModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,14 +107,14 @@ function HomePage() {
|
|||||||
const handleSyncBalance = async () => {
|
const handleSyncBalance = async () => {
|
||||||
try {
|
try {
|
||||||
await syncBalanceMutation.mutateAsync()
|
await syncBalanceMutation.mutateAsync()
|
||||||
notifySuccess('Balance synced successfully.')
|
notifySuccess(t('home.balanceSyncedSuccessfully'))
|
||||||
} catch {
|
} catch {
|
||||||
// request interceptor handles interface error toast
|
// request interceptor handles interface error toast
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirmClaim = async () => {
|
const handleConfirmClaim = async () => {
|
||||||
const claimValidation = validateClaimSubmission(authInfo)
|
const claimValidation = validateClaimSubmission(authInfo, assetsInfo)
|
||||||
if (!claimValidation.valid) {
|
if (!claimValidation.valid) {
|
||||||
notifyError(claimValidation.message)
|
notifyError(claimValidation.message)
|
||||||
return
|
return
|
||||||
@@ -111,7 +123,7 @@ function HomePage() {
|
|||||||
try {
|
try {
|
||||||
const response = await claimMutation.mutateAsync(`${authInfo!.user_id}${Date.now()}`)
|
const response = await claimMutation.mutateAsync(`${authInfo!.user_id}${Date.now()}`)
|
||||||
await invalidateAssets()
|
await invalidateAssets()
|
||||||
notifySuccess(response, 'Claim submitted successfully.')
|
notifySuccess(response, t('home.claimSubmittedSuccessfully'))
|
||||||
setClaimModalOpen(false)
|
setClaimModalOpen(false)
|
||||||
} catch {
|
} catch {
|
||||||
// request errors are surfaced by the shared request toast
|
// request errors are surfaced by the shared request toast
|
||||||
@@ -122,47 +134,61 @@ function HomePage() {
|
|||||||
navigate(`/goods?type=${type}`)
|
navigate(`/goods?type=${type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleLanguage = () => {
|
||||||
|
const currentLanguage = normalizeLanguage(language)
|
||||||
|
setLanguage(currentLanguage === 'zh' ? 'en' : 'zh')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div
|
<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">
|
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="record"/>
|
<QuickNavCard to="/record" icon={History} label={t('nav.record')}/>
|
||||||
<QuickNavCard to="/account" icon={UserRound} label="account"/>
|
<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>
|
||||||
|
|
||||||
<div className="mt-[4px]">
|
<div className="mt-[4px]">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-stretch">
|
<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="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 className="flex items-start justify-between gap-[12px]">
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[13px] tracking-[0.16em] text-white/58">Claimable
|
<div className="text-[12px] tracking-[0.1em] text-white/58 sm:text-[13px] sm:tracking-[0.16em]">{t('home.claimablePoints')}</div>
|
||||||
Points
|
|
||||||
</div>
|
|
||||||
<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>
|
||||||
<div
|
<div
|
||||||
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
|
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-[18px] w-[18px]" aria-hidden="true"/>
|
<Coins className="h-[16px] w-[16px] sm:h-[18px] sm:w-[18px]" aria-hidden="true"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-[28ch] text-[13px] leading-[1.6] text-white/68">
|
<div className="max-w-[20ch] text-[12px] leading-[1.5] text-white/68 sm:max-w-[28ch] sm:text-[13px] sm:leading-[1.6]">
|
||||||
Yesterday's losses have been converted into points. Claim them to use in rewards.
|
{t('home.claimDescription')}
|
||||||
</div>
|
</div>
|
||||||
</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 className="flex items-start justify-between gap-[12px]">
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[13px] tracking-[0.16em] text-white/58">Daily Claim
|
<div className="text-[12px] tracking-[0.1em] text-white/58 sm:text-[13px] sm:tracking-[0.16em]">{t('home.dailyClaimLimit')}</div>
|
||||||
Limit
|
|
||||||
</div>
|
|
||||||
<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>
|
||||||
<div
|
<div
|
||||||
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
|
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-[18px] w-[18px]" aria-hidden="true"/>
|
<Gauge className="h-[16px] w-[16px] sm:h-[18px] sm:w-[18px]" aria-hidden="true"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -178,7 +204,7 @@ function HomePage() {
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</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}
|
className={'text-[#FE9C00]'}>{assetsInfo?.today_claimed || 0}</span> / {assetsInfo?.today_limit || 0}
|
||||||
</div>
|
</div>
|
||||||
</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]">
|
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 className="flex items-center justify-between gap-[12px]">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[13px] tracking-[0.16em] text-white/58">Available for
|
<div className="text-[13px] tracking-[0.16em] text-white/58">{t('home.availableForWithdrawal')}</div>
|
||||||
Withdrawal (Cash)
|
|
||||||
</div>
|
|
||||||
<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
|
<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>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -205,8 +229,11 @@ function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="liquid-glass-bg grid grid-cols-2 gap-[10px] p-[5px]">
|
<div className="liquid-glass-bg grid grid-cols-2 gap-[10px] p-[5px]">
|
||||||
<Button className="h-[44px] w-full text-[13px]" onClick={handleOpenClaimModal}>
|
<Button
|
||||||
Claim Now
|
className="h-[44px] w-full text-[13px]"
|
||||||
|
onClick={handleOpenClaimModal}
|
||||||
|
>
|
||||||
|
{t('home.claimNow')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={'gray'}
|
variant={'gray'}
|
||||||
@@ -214,7 +241,7 @@ function HomePage() {
|
|||||||
onClick={handleSyncBalance}
|
onClick={handleSyncBalance}
|
||||||
disabled={syncBalanceMutation.isPending}
|
disabled={syncBalanceMutation.isPending}
|
||||||
>
|
>
|
||||||
{syncBalanceMutation.isPending ? 'Syncing...' : 'Sync Balance'}
|
{syncBalanceMutation.isPending ? t('home.syncing') : t('home.syncBalance')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,7 +251,7 @@ function HomePage() {
|
|||||||
<GoodsCategoryList
|
<GoodsCategoryList
|
||||||
categories={previewCategories}
|
categories={previewCategories}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyText="No goods available yet."
|
emptyText={t('goods.noGoodsAvailableYet')}
|
||||||
showMore
|
showMore
|
||||||
onMoreClick={handleMoreClick}
|
onMoreClick={handleMoreClick}
|
||||||
onRedeem={redeem.openRedeemModal}
|
onRedeem={redeem.openRedeemModal}
|
||||||
@@ -249,7 +276,7 @@ function HomePage() {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={claimModalOpen}
|
open={claimModalOpen}
|
||||||
title="Confirm Claim"
|
title={t('home.confirmClaim')}
|
||||||
onClose={handleCloseClaimModal}
|
onClose={handleCloseClaimModal}
|
||||||
className="max-w-[560px]"
|
className="max-w-[560px]"
|
||||||
bodyClassName="space-y-[18px]"
|
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]"
|
<Button type="button" variant={'gray'} className="h-[38px] w-full sm:w-auto sm:min-w-[130px]"
|
||||||
onClick={handleCloseClaimModal}
|
onClick={handleCloseClaimModal}
|
||||||
disabled={claimMutation.isPending}>
|
disabled={claimMutation.isPending}>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" className="h-[38px] w-full sm:w-auto sm:min-w-[130px]"
|
<Button type="button" className="h-[38px] w-full sm:w-auto sm:min-w-[130px]"
|
||||||
onClick={handleConfirmClaim}
|
onClick={handleConfirmClaim}
|
||||||
disabled={claimMutation.isPending}>
|
disabled={claimMutation.isPending || !isClaimAvailable}>
|
||||||
{claimMutation.isPending ? 'Processing...' : 'Confirm'}
|
{claimMutation.isPending ? t('common.processing') : t('common.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<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)]">
|
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.
|
{t('home.confirmClaimDescription')}
|
||||||
Are you sure to claim it?
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default HomePage;
|
export default HomePage
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {useState} from 'react'
|
import {useState} from 'react'
|
||||||
import {useQuery} from '@tanstack/react-query'
|
import {useQuery} from '@tanstack/react-query'
|
||||||
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import PageLayout from '@/components/layout'
|
import PageLayout from '@/components/layout'
|
||||||
import {ORDER_STATUS} from '@/constant'
|
import {ORDER_STATUS} from '@/constant'
|
||||||
|
import i18n from '@/lib/i18n'
|
||||||
import { cn } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
import Modal from '@/components/modal'
|
import Modal from '@/components/modal'
|
||||||
import Button from '@/components/button'
|
import Button from '@/components/button'
|
||||||
@@ -63,13 +65,13 @@ function getOrderStatus(status?: string | number) {
|
|||||||
if (matchedStatus) {
|
if (matchedStatus) {
|
||||||
switch (matchedStatus) {
|
switch (matchedStatus) {
|
||||||
case 'PENDING':
|
case 'PENDING':
|
||||||
return 'Pending'
|
return i18n.t('record.statusLabel.pending')
|
||||||
case 'COMPLETED':
|
case 'COMPLETED':
|
||||||
return 'Completed'
|
return i18n.t('record.statusLabel.completed')
|
||||||
case 'SHIPPED':
|
case 'SHIPPED':
|
||||||
return 'Shipped'
|
return i18n.t('record.statusLabel.shipped')
|
||||||
case 'REJECTED':
|
case 'REJECTED':
|
||||||
return 'Rejected'
|
return i18n.t('record.statusLabel.rejected')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,20 +83,20 @@ function getOrderStatus(status?: string | number) {
|
|||||||
if (typeof normalizedStatus === 'number') {
|
if (typeof normalizedStatus === 'number') {
|
||||||
switch (normalizedStatus) {
|
switch (normalizedStatus) {
|
||||||
case 0:
|
case 0:
|
||||||
return 'Pending'
|
return i18n.t('record.statusLabel.pending')
|
||||||
case 1:
|
case 1:
|
||||||
return 'Completed'
|
return i18n.t('record.statusLabel.completed')
|
||||||
case 2:
|
case 2:
|
||||||
return 'Shipped'
|
return i18n.t('record.statusLabel.shipped')
|
||||||
case 3:
|
case 3:
|
||||||
return 'Rejected'
|
return i18n.t('record.statusLabel.rejected')
|
||||||
default:
|
default:
|
||||||
return String(normalizedStatus)
|
return String(normalizedStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalizedStatus) {
|
if (!normalizedStatus) {
|
||||||
return 'Pending'
|
return i18n.t('record.statusLabel.pending')
|
||||||
}
|
}
|
||||||
|
|
||||||
return toTitleCase(normalizedStatus)
|
return toTitleCase(normalizedStatus)
|
||||||
@@ -102,7 +104,17 @@ function getOrderStatus(status?: string | number) {
|
|||||||
|
|
||||||
function getOrderCategory(item: OrderItem) {
|
function getOrderCategory(item: OrderItem) {
|
||||||
if (item.type?.trim()) {
|
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) {
|
if (item.type_title) {
|
||||||
@@ -116,11 +128,11 @@ function getOrderCategory(item: OrderItem) {
|
|||||||
if (item.type) {
|
if (item.type) {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'BONUS':
|
case 'BONUS':
|
||||||
return 'Bonus'
|
return i18n.t('record.categories.bonus')
|
||||||
case 'PHYSICAL':
|
case 'PHYSICAL':
|
||||||
return 'Physical'
|
return i18n.t('record.categories.physical')
|
||||||
case 'WITHDRAW':
|
case 'WITHDRAW':
|
||||||
return 'Transfer to Platform'
|
return i18n.t('record.categories.withdraw')
|
||||||
default:
|
default:
|
||||||
return toTitleCase(item.type)
|
return toTitleCase(item.type)
|
||||||
}
|
}
|
||||||
@@ -130,7 +142,7 @@ function getOrderCategory(item: OrderItem) {
|
|||||||
return toTitleCase(item.category)
|
return toTitleCase(item.category)
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Order'
|
return i18n.t('record.categories.order')
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrderPoints(item: OrderItem) {
|
function getOrderPoints(item: OrderItem) {
|
||||||
@@ -143,11 +155,11 @@ function getOrderPoints(item: OrderItem) {
|
|||||||
const numericValue = typeof rawValue === 'string' ? Number(rawValue) : rawValue
|
const numericValue = typeof rawValue === 'string' ? Number(rawValue) : rawValue
|
||||||
if (Number.isNaN(numericValue)) {
|
if (Number.isNaN(numericValue)) {
|
||||||
const textValue = String(rawValue)
|
const textValue = String(rawValue)
|
||||||
return {display: `${textValue} points`}
|
return {display: `${textValue} ${i18n.t('common.points')}`}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
display: `${numericValue} points`,
|
display: `${numericValue} ${i18n.t('common.points')}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +188,7 @@ function mapOrderItemToRecord(item: OrderItem): OrderRecord {
|
|||||||
date,
|
date,
|
||||||
time,
|
time,
|
||||||
category: getOrderCategory(item),
|
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),
|
trackingNumber: getTrackingNumber(item),
|
||||||
status: getOrderStatus(item.status),
|
status: getOrderStatus(item.status),
|
||||||
points: points.display,
|
points: points.display,
|
||||||
@@ -221,7 +233,7 @@ function getPointsRecordTitle(item: PointsLogItem) {
|
|||||||
item.type,
|
item.type,
|
||||||
item.description,
|
item.description,
|
||||||
item.remark?.split('\n')[0],
|
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) {
|
function mapPointsLogItemToRecord(item: PointsLogItem) {
|
||||||
@@ -239,18 +251,24 @@ function mapPointsLogItemToRecord(item: PointsLogItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getOrderStatusClassName(status: string) {
|
function getOrderStatusClassName(status: string) {
|
||||||
switch (status.toLowerCase()) {
|
const normalizedStatus = status.toLowerCase()
|
||||||
case 'completed':
|
if (normalizedStatus === 'completed' || status === i18n.t('record.statusLabel.completed')) {
|
||||||
return 'bg-[#9BFFC0] text-[#176640]'
|
return 'bg-[#9BFFC0] text-[#176640]'
|
||||||
case 'shipped':
|
|
||||||
return 'bg-[#95F0FF] text-[#116A79]'
|
|
||||||
case 'pending':
|
|
||||||
return 'bg-[#FFF18C] text-[#7F6A0D]'
|
|
||||||
case 'rejected':
|
|
||||||
return 'bg-[#FFB1C0] text-[#7C2941]'
|
|
||||||
default:
|
|
||||||
return 'bg-white/15 text-white/80'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedStatus === 'shipped' || status === i18n.t('record.statusLabel.shipped')) {
|
||||||
|
return 'bg-[#95F0FF] text-[#116A79]'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedStatus === 'pending' || status === i18n.t('record.statusLabel.pending')) {
|
||||||
|
return 'bg-[#FFF18C] text-[#7F6A0D]'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedStatus === 'rejected' || status === i18n.t('record.statusLabel.rejected')) {
|
||||||
|
return 'bg-[#FFB1C0] text-[#7C2941]'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-white/15 text-white/80'
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
|
function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
|
||||||
@@ -272,8 +290,9 @@ function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OrderCard({ record, onOpenDetails }: OrderCardProps) {
|
function OrderCard({ record, onOpenDetails }: OrderCardProps) {
|
||||||
|
const {t} = useTranslation()
|
||||||
return (
|
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">
|
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[14px] text-white">
|
||||||
{record.date} {record.time} • {record.category}
|
{record.date} {record.time} • {record.category}
|
||||||
</div>
|
</div>
|
||||||
@@ -282,7 +301,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
|
|||||||
<div className="text-[16px] font-medium text-white">{record.title}</div>
|
<div className="text-[16px] font-medium text-white">{record.title}</div>
|
||||||
{record.trackingNumber ? (
|
{record.trackingNumber ? (
|
||||||
<div className="mt-[4px] text-[13px] text-white/45">
|
<div className="mt-[4px] text-[13px] text-white/45">
|
||||||
Tracking Number {record.trackingNumber}
|
{t('record.trackingNumber')} {record.trackingNumber}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<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]"
|
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)}
|
onClick={() => onOpenDetails(record)}
|
||||||
>
|
>
|
||||||
Check the details
|
{t('record.checkDetails')}
|
||||||
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true" />
|
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,7 +332,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
|
|||||||
|
|
||||||
function PointsCard({ record }: PointsCardProps) {
|
function PointsCard({ record }: PointsCardProps) {
|
||||||
return (
|
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">
|
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[15px] text-white">
|
||||||
{record.title}
|
{record.title}
|
||||||
</div>
|
</div>
|
||||||
@@ -341,6 +360,7 @@ function OrdersTabContent({
|
|||||||
sessionId: string
|
sessionId: string
|
||||||
onOpenDetails: (record: OrderRecord) => void
|
onOpenDetails: (record: OrderRecord) => void
|
||||||
}) {
|
}) {
|
||||||
|
const {t} = useTranslation()
|
||||||
const ordersQuery = useQuery({
|
const ordersQuery = useQuery({
|
||||||
queryKey: queryKeys.orders(sessionId),
|
queryKey: queryKeys.orders(sessionId),
|
||||||
enabled: Boolean(sessionId),
|
enabled: Boolean(sessionId),
|
||||||
@@ -358,7 +378,7 @@ function OrdersTabContent({
|
|||||||
if (ordersQuery.isPending) {
|
if (ordersQuery.isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||||
Loading...
|
{t('record.loading')}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -366,21 +386,22 @@ function OrdersTabContent({
|
|||||||
if (!orderRecords.length) {
|
if (!orderRecords.length) {
|
||||||
return (
|
return (
|
||||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||||
No Data
|
{t('record.noData')}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-[12px] pb-[4px]">
|
||||||
{orderRecords.map((record) => (
|
{orderRecords.map((record) => (
|
||||||
<OrderCard key={record.id} record={record} onOpenDetails={onOpenDetails} />
|
<OrderCard key={record.id} record={record} onOpenDetails={onOpenDetails} />
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PointsTabContent({sessionId}: {sessionId: string}) {
|
function PointsTabContent({sessionId}: {sessionId: string}) {
|
||||||
|
const {t} = useTranslation()
|
||||||
const pointsLogsQuery = useQuery({
|
const pointsLogsQuery = useQuery({
|
||||||
queryKey: queryKeys.pointsLogs(sessionId),
|
queryKey: queryKeys.pointsLogs(sessionId),
|
||||||
enabled: Boolean(sessionId),
|
enabled: Boolean(sessionId),
|
||||||
@@ -398,7 +419,7 @@ function PointsTabContent({sessionId}: {sessionId: string}) {
|
|||||||
if (pointsLogsQuery.isPending) {
|
if (pointsLogsQuery.isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||||
Loading...
|
{t('record.loading')}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -406,19 +427,20 @@ function PointsTabContent({sessionId}: {sessionId: string}) {
|
|||||||
if (!pointsRecords.length) {
|
if (!pointsRecords.length) {
|
||||||
return (
|
return (
|
||||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||||
No Data
|
{t('record.noData')}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-[12px] pb-[4px]">
|
||||||
{pointsRecords.map((record) => <PointsCard key={record.id} record={record} />)}
|
{pointsRecords.map((record) => <PointsCard key={record.id} record={record} />)}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordPage() {
|
function RecordPage() {
|
||||||
|
const {t} = useTranslation()
|
||||||
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
|
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
|
||||||
const [tab, setTab] = useState<RecordButtonType>('order')
|
const [tab, setTab] = useState<RecordButtonType>('order')
|
||||||
const [selectedOrder, setSelectedOrder] = useState<OrderRecord | null>(null)
|
const [selectedOrder, setSelectedOrder] = useState<OrderRecord | null>(null)
|
||||||
@@ -428,78 +450,84 @@ function RecordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<PageLayout contentClassName="flex h-[100svh] w-full flex-col overflow-hidden px-4 pb-8 sm:px-6 lg:px-8">
|
||||||
<Link
|
<div className="mx-auto w-full max-w-[980px]">
|
||||||
to="/"
|
<Link
|
||||||
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]"
|
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" />
|
<div className="flex items-center gap-[8px]">
|
||||||
<span className="text-[14px] font-medium text-white/92">Back</span>
|
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
|
||||||
</div>
|
<span className="text-[14px] font-medium text-white/92">{t('common.back')}</span>
|
||||||
<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')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="h-px bg-white/16"></div>
|
<div className="mt-[14px] min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<div className="mx-auto w-full max-w-[860px]">
|
||||||
<div className="mt-[14px] flex flex-col gap-[12px]">
|
{tab === 'order' ? (
|
||||||
{tab === 'order' ? (
|
<OrdersTabContent key="order" sessionId={sessionId} onOpenDetails={setSelectedOrder} />
|
||||||
<OrdersTabContent key="order" sessionId={sessionId} onOpenDetails={setSelectedOrder} />
|
) : (
|
||||||
) : (
|
<PointsTabContent key="record" sessionId={sessionId} />
|
||||||
<PointsTabContent key="record" sessionId={sessionId} />
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(selectedOrder)}
|
open={Boolean(selectedOrder)}
|
||||||
title="Order Details"
|
title={t('record.orderDetails')}
|
||||||
onClose={handleCloseDetails}
|
onClose={handleCloseDetails}
|
||||||
className="max-w-[420px]"
|
className="max-w-[420px]"
|
||||||
bodyClassName="pt-[0px]"
|
bodyClassName="pt-[0px]"
|
||||||
footer={
|
footer={
|
||||||
<Button type="button" className="h-[36px] w-full sm:min-w-[94px] sm:w-auto" onClick={handleCloseDetails}>
|
<Button type="button" className="h-[36px] w-full sm:min-w-[94px] sm:w-auto" onClick={handleCloseDetails}>
|
||||||
Close
|
{t('common.close')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{selectedOrder ? (
|
{selectedOrder ? (
|
||||||
<div className="mt-[10px] rounded-[10px] bg-[#1C1818]/78 px-[12px] py-[6px]">
|
<div className="mt-[10px] rounded-[10px] bg-[#1C1818]/78 px-[12px] py-[6px]">
|
||||||
{[
|
{[
|
||||||
{ label: 'Order Number', value: selectedOrder.orderNumber ?? '--' },
|
{ label: t('record.orderNumber'), value: selectedOrder.orderNumber ?? '--' },
|
||||||
{ label: 'Order Time', value: `${selectedOrder.date} ${selectedOrder.time}` },
|
{ label: t('record.orderTime'), value: `${selectedOrder.date} ${selectedOrder.time}` },
|
||||||
{ label: 'Order Type', value: selectedOrder.category },
|
{ label: t('record.orderType'), value: selectedOrder.category },
|
||||||
{ label: 'Item Name', value: selectedOrder.title },
|
{ label: t('record.itemName'), value: selectedOrder.title },
|
||||||
...(selectedOrder.trackingNumber
|
...(selectedOrder.trackingNumber
|
||||||
? [{ label: 'Tracking Number', value: selectedOrder.trackingNumber }]
|
? [{ label: t('record.trackingNumber'), value: selectedOrder.trackingNumber }]
|
||||||
: []),
|
: []),
|
||||||
{ label: 'Points', value: selectedOrder.points.replace(/^-/, '') },
|
{ label: t('record.points'), value: selectedOrder.points.replace(/^-/, '') },
|
||||||
{ label: 'Status', value: selectedOrder.status },
|
{ label: t('record.status'), value: selectedOrder.status },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.label} className="border-b border-white/8 py-[10px] last:border-b-0">
|
<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>
|
<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