feat: 项目接口联调

This commit is contained in:
JiaJun
2026-04-10 09:27:11 +08:00
parent 906fa63870
commit af3ed15ba2
62 changed files with 4307 additions and 982 deletions

View File

@@ -226,3 +226,4 @@ Before delivering UI code, verify these items:
- [ ] Form inputs have labels
- [ ] Color is not the only indicator
- [ ] `prefers-reduced-motion` respected

View File

@@ -137,16 +137,11 @@
### 5.2 当前已观察到的 `SVG` 使用情况
根据当前代码扫描,项目中已存在以下 `SVG` 来源
根据当前代码扫描,当前项目中保留的 `SVG` 资源如下
- `src/assets/account.svg`
- `src/assets/record.svg`
- `public/icons.svg`
- `public/favicon.svg`
当前页面层级观察到`SVG` 资源使用位置如下:
- `src/views/home/index.tsx`
当前页面层级的界面图标已迁移为 `lucide-react`,不再保留视图层级`SVG` 图标依赖。
### 5.3 迁移规则
@@ -331,7 +326,6 @@
- `src/views/account/index.tsx`
- `src/views/record/index.tsx`
- `src/components/modal/index.tsx`
- 其余与界面展示相关的 `SVG` 资源使用位置
以下文件或样式必须作为核心基线保留:

View File

@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://rsms.me" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>palyx</title>
</head>

View File

@@ -10,16 +10,22 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.96.2",
"clsx": "^2.1.1",
"element-china-area-data": "^6.1.0",
"ky": "^2.0.0",
"lucide-react": "^0.577.0",
"province-city-china": "^8.5.8",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1"
"tailwindcss": "^4.2.1",
"zustand": "^5.0.12"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@capacitor/cli": "^8.2.0",
"@eslint/js": "^9.39.4",
"@rolldown/plugin-babel": "^0.2.0",
"@tailwindcss/vite": "^4.2.1",

683
pnpm-lock.yaml generated
View File

@@ -8,12 +8,24 @@ importers:
.:
dependencies:
'@tanstack/react-query':
specifier: ^5.96.2
version: 5.96.2(react@19.2.4)
clsx:
specifier: ^2.1.1
version: 2.1.1
element-china-area-data:
specifier: ^6.1.0
version: 6.1.0
ky:
specifier: ^2.0.0
version: 2.0.0
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.4)
province-city-china:
specifier: ^8.5.8
version: 8.5.8
react:
specifier: ^19.2.4
version: 19.2.4
@@ -29,10 +41,16 @@ importers:
tailwindcss:
specifier: ^4.2.1
version: 4.2.1
zustand:
specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)
devDependencies:
'@babel/core':
specifier: ^7.29.0
version: 7.29.0
'@capacitor/cli':
specifier: ^8.2.0
version: 8.2.0
'@eslint/js':
specifier: ^9.39.4
version: 9.39.4
@@ -151,6 +169,11 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@capacitor/cli@8.2.0':
resolution: {integrity: sha512-1cMEk0d/I6tl1U+v/lnJR5Oylpx8ZBIHrvQxD5zK0MkjYOUyQAAGJgh97rkhGJqjAUvrGpa8H4BmyhNQN9a17A==}
engines: {node: '>=22.0.0'}
hasBin: true
'@emnapi/core@1.9.0':
resolution: {integrity: sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==}
@@ -214,6 +237,42 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@ionic/cli-framework-output@2.2.8':
resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
engines: {node: '>=16.0.0'}
'@ionic/utils-array@2.1.6':
resolution: {integrity: sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==}
engines: {node: '>=16.0.0'}
'@ionic/utils-fs@3.1.7':
resolution: {integrity: sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==}
engines: {node: '>=16.0.0'}
'@ionic/utils-object@2.1.6':
resolution: {integrity: sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==}
engines: {node: '>=16.0.0'}
'@ionic/utils-process@2.1.12':
resolution: {integrity: sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==}
engines: {node: '>=16.0.0'}
'@ionic/utils-stream@3.1.7':
resolution: {integrity: sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==}
engines: {node: '>=16.0.0'}
'@ionic/utils-subprocess@3.0.1':
resolution: {integrity: sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==}
engines: {node: '>=16.0.0'}
'@ionic/utils-terminal@2.3.5':
resolution: {integrity: sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==}
engines: {node: '>=16.0.0'}
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -240,6 +299,9 @@ packages:
'@oxc-project/types@0.115.0':
resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==}
'@province-city-china/types@8.5.8':
resolution: {integrity: sha512-KZ3NyM8HsaBVcn5BRhWaOeZRhqEvm18PfB6HfRDuZfwwWhJLoTxB81mTENrBlONr2g8fy/fSbjsh44gvOj+/Lw==}
'@rolldown/binding-android-arm64@1.0.0-rc.9':
resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -452,6 +514,14 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tanstack/query-core@5.96.2':
resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==}
'@tanstack/react-query@5.96.2':
resolution: {integrity: sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==}
peerDependencies:
react: ^18 || ^19
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -470,6 +540,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/fs-extra@8.1.5':
resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -484,6 +557,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/slice-ansi@4.0.0':
resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==}
'@typescript-eslint/eslint-plugin@8.57.0':
resolution: {integrity: sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -556,6 +632,10 @@ packages:
babel-plugin-react-compiler:
optional: true
'@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -569,6 +649,10 @@ packages:
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@@ -576,6 +660,14 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
astral-regex@2.0.0:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
engines: {node: '>=8'}
at-least-node@1.0.0:
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
engines: {node: '>= 4.0.0'}
babel-plugin-react-compiler@1.0.0:
resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==}
@@ -586,11 +678,22 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.8:
resolution: {integrity: sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==}
engines: {node: '>=6.0.0'}
hasBin: true
big-integer@1.6.52:
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
engines: {node: '>=0.6'}
bplist-parser@0.3.2:
resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==}
engines: {node: '>= 5.10.0'}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@@ -603,6 +706,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@@ -614,6 +720,13 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
china-division@2.7.0:
resolution: {integrity: sha512-4uUPAT+1WfqDh5jytq7omdCmHNk3j+k76zEG/2IqaGcYB90c2SwcixttcypdsZ3T/9tN1TTpBDoeZn+Yw/qBEA==}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -625,6 +738,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
commander@12.1.0:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -654,6 +771,10 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
define-lazy-prop@2.0.0:
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
engines: {node: '>=8'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -661,10 +782,24 @@ packages:
electron-to-chromium@1.5.313:
resolution: {integrity: sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==}
element-china-area-data@6.1.0:
resolution: {integrity: sha512-IkpcjwQv2A/2AxFiSoaISZ+oMw1rZCPUSOg5sOCwT5jKc96TaawmKZeY81xfxXsO0QbKxU5LLc6AirhG52hUmg==}
elementtree@0.1.7:
resolution: {integrity: sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==}
engines: {node: '>= 0.4.0'}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
enhanced-resolve@5.20.0:
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
engines: {node: '>=10.13.0'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -739,6 +874,9 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -763,6 +901,14 @@ packages:
flatted@3.4.1:
resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==}
fs-extra@11.3.4:
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'}
fs-extra@9.1.0:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -776,6 +922,10 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
@@ -813,14 +963,34 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@4.1.3:
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
hasBin: true
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -854,9 +1024,24 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
ky@2.0.0:
resolution: {integrity: sha512-KzI4Vz5AbZFAUFYGx28PCSfFWUo6/qj9Br/P6KRwDieE1xfdz0tIONepJcLw/1xLocN13GgvfJGasa+pfSkbHg==}
engines: {node: '>=22'}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -1016,6 +1201,10 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -1034,6 +1223,14 @@ packages:
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@3.1.0:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1042,12 +1239,21 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
native-run@2.0.3:
resolution: {integrity: sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==}
engines: {node: '>=16.0.0'}
hasBin: true
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
open@8.4.2:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -1060,6 +1266,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -1072,6 +1281,13 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1079,6 +1295,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
plist@3.1.0:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'}
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
@@ -1087,6 +1307,13 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
province-city-china@8.5.8:
resolution: {integrity: sha512-gUV5kSOWHVufemkq6lygb0ngNZ4snMcONmr3QzxHuj1MYOQPphiyjHplfmywcVGWdGQgim30RXia/mYB007eLg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1117,15 +1344,34 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
rimraf@6.1.3:
resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==}
engines: {node: 20 || >=22}
hasBin: true
rolldown@1.0.0-rc.9:
resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
sax@1.1.4:
resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -1149,10 +1395,35 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
slice-ansi@4.0.0:
resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
engines: {node: '>=10'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -1171,10 +1442,21 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tar@7.5.12:
resolution: {integrity: sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==}
engines: {node: '>=18'}
through2@4.0.2:
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
@@ -1203,6 +1485,14 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
untildify@4.0.0:
resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
engines: {node: '>=8'}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -1212,6 +1502,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite@8.0.0:
resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1264,9 +1557,32 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
xmlbuilder@15.1.1:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -1280,6 +1596,24 @@ packages:
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zustand@5.0.12:
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@babel/code-frame@7.29.0':
@@ -1382,6 +1716,28 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@capacitor/cli@8.2.0':
dependencies:
'@ionic/cli-framework-output': 2.2.8
'@ionic/utils-subprocess': 3.0.1
'@ionic/utils-terminal': 2.3.5
commander: 12.1.0
debug: 4.4.3
env-paths: 2.2.1
fs-extra: 11.3.4
kleur: 4.1.5
native-run: 2.0.3
open: 8.4.2
plist: 3.1.0
prompts: 2.4.2
rimraf: 6.1.3
semver: 7.7.4
tar: 7.5.12
tslib: 2.8.1
xml2js: 0.6.2
transitivePeerDependencies:
- supports-color
'@emnapi/core@1.9.0':
dependencies:
'@emnapi/wasi-threads': 1.2.0
@@ -1455,6 +1811,86 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@ionic/cli-framework-output@2.2.8':
dependencies:
'@ionic/utils-terminal': 2.3.5
debug: 4.4.3
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@ionic/utils-array@2.1.6':
dependencies:
debug: 4.4.3
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@ionic/utils-fs@3.1.7':
dependencies:
'@types/fs-extra': 8.1.5
debug: 4.4.3
fs-extra: 9.1.0
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@ionic/utils-object@2.1.6':
dependencies:
debug: 4.4.3
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@ionic/utils-process@2.1.12':
dependencies:
'@ionic/utils-object': 2.1.6
'@ionic/utils-terminal': 2.3.5
debug: 4.4.3
signal-exit: 3.0.7
tree-kill: 1.2.2
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@ionic/utils-stream@3.1.7':
dependencies:
debug: 4.4.3
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@ionic/utils-subprocess@3.0.1':
dependencies:
'@ionic/utils-array': 2.1.6
'@ionic/utils-fs': 3.1.7
'@ionic/utils-process': 2.1.12
'@ionic/utils-stream': 3.1.7
'@ionic/utils-terminal': 2.3.5
cross-spawn: 7.0.6
debug: 4.4.3
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@ionic/utils-terminal@2.3.5':
dependencies:
'@types/slice-ansi': 4.0.0
debug: 4.4.3
signal-exit: 3.0.7
slice-ansi: 4.0.0
string-width: 4.2.3
strip-ansi: 6.0.1
tslib: 2.8.1
untildify: 4.0.0
wrap-ansi: 7.0.0
transitivePeerDependencies:
- supports-color
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.3
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1485,6 +1921,8 @@ snapshots:
'@oxc-project/types@0.115.0': {}
'@province-city-china/types@8.5.8': {}
'@rolldown/binding-android-arm64@1.0.0-rc.9':
optional: true
@@ -1612,6 +2050,13 @@ snapshots:
tailwindcss: 4.2.1
vite: 8.0.0(@types/node@24.12.0)(jiti@2.6.1)
'@tanstack/query-core@5.96.2': {}
'@tanstack/react-query@5.96.2(react@19.2.4)':
dependencies:
'@tanstack/query-core': 5.96.2
react: 19.2.4
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -1640,6 +2085,10 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/fs-extra@8.1.5':
dependencies:
'@types/node': 24.12.0
'@types/json-schema@7.0.15': {}
'@types/node@24.12.0':
@@ -1654,6 +2103,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/slice-ansi@4.0.0': {}
'@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -1753,6 +2204,8 @@ snapshots:
'@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
'@xmldom/xmldom@0.8.11': {}
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -1766,12 +2219,18 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
argparse@2.0.1: {}
astral-regex@2.0.0: {}
at-least-node@1.0.0: {}
babel-plugin-react-compiler@1.0.0:
dependencies:
'@babel/types': 7.29.0
@@ -1780,8 +2239,16 @@ snapshots:
balanced-match@4.0.4: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.8: {}
big-integer@1.6.52: {}
bplist-parser@0.3.2:
dependencies:
big-integer: 1.6.52
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -1799,6 +2266,8 @@ snapshots:
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-crc32@0.2.13: {}
callsites@3.1.0: {}
caniuse-lite@1.0.30001779: {}
@@ -1808,6 +2277,10 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
china-division@2.7.0: {}
chownr@3.0.0: {}
clsx@2.1.1: {}
color-convert@2.0.1:
@@ -1816,6 +2289,8 @@ snapshots:
color-name@1.1.4: {}
commander@12.1.0: {}
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
@@ -1836,15 +2311,29 @@ snapshots:
deep-is@0.1.4: {}
define-lazy-prop@2.0.0: {}
detect-libc@2.1.2: {}
electron-to-chromium@1.5.313: {}
element-china-area-data@6.1.0:
dependencies:
china-division: 2.7.0
elementtree@0.1.7:
dependencies:
sax: 1.1.4
emoji-regex@8.0.0: {}
enhanced-resolve@5.20.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
env-paths@2.2.1: {}
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
@@ -1940,6 +2429,10 @@ snapshots:
fast-levenshtein@2.0.6: {}
fd-slicer@1.1.0:
dependencies:
pend: 1.2.0
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -1960,6 +2453,19 @@ snapshots:
flatted@3.4.1: {}
fs-extra@11.3.4:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.0
universalify: 2.0.1
fs-extra@9.1.0:
dependencies:
at-least-node: 1.0.0
graceful-fs: 4.2.11
jsonfile: 6.2.0
universalify: 2.0.1
fsevents@2.3.3:
optional: true
@@ -1969,6 +2475,12 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@13.0.6:
dependencies:
minimatch: 10.2.4
minipass: 7.1.3
path-scurry: 2.0.2
globals@14.0.0: {}
globals@17.4.0: {}
@@ -1994,12 +2506,24 @@ snapshots:
imurmurhash@0.1.4: {}
inherits@2.0.4: {}
ini@4.1.3: {}
is-docker@2.2.1: {}
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
isexe@2.0.0: {}
jiti@2.6.1: {}
@@ -2020,10 +2544,22 @@ snapshots:
json5@2.2.3: {}
jsonfile@6.2.0:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
kleur@3.0.3: {}
kleur@4.1.5: {}
ky@2.0.0: {}
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -2133,6 +2669,8 @@ snapshots:
lodash.merge@4.6.2: {}
lru-cache@11.2.7: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -2153,14 +2691,42 @@ snapshots:
dependencies:
brace-expansion: 1.1.12
minipass@7.1.3: {}
minizlib@3.1.0:
dependencies:
minipass: 7.1.3
ms@2.1.3: {}
nanoid@3.3.11: {}
native-run@2.0.3:
dependencies:
'@ionic/utils-fs': 3.1.7
'@ionic/utils-terminal': 2.3.5
bplist-parser: 0.3.2
debug: 4.4.3
elementtree: 0.1.7
ini: 4.1.3
plist: 3.1.0
split2: 4.2.0
through2: 4.0.2
tslib: 2.8.1
yauzl: 2.10.0
transitivePeerDependencies:
- supports-color
natural-compare@1.4.0: {}
node-releases@2.0.36: {}
open@8.4.2:
dependencies:
define-lazy-prop: 2.0.0
is-docker: 2.2.1
is-wsl: 2.2.0
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -2178,6 +2744,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
package-json-from-dist@1.0.1: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -2186,10 +2754,23 @@ snapshots:
path-key@3.1.1: {}
path-scurry@2.0.2:
dependencies:
lru-cache: 11.2.7
minipass: 7.1.3
pend@1.2.0: {}
picocolors@1.1.1: {}
picomatch@4.0.3: {}
plist@3.1.0:
dependencies:
'@xmldom/xmldom': 0.8.11
base64-js: 1.5.1
xmlbuilder: 15.1.1
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
@@ -2198,6 +2779,15 @@ snapshots:
prelude-ls@1.2.1: {}
prompts@2.4.2:
dependencies:
kleur: 3.0.3
sisteransi: 1.0.5
province-city-china@8.5.8:
dependencies:
'@province-city-china/types': 8.5.8
punycode@2.3.1: {}
react-dom@19.2.4(react@19.2.4):
@@ -2221,8 +2811,19 @@ snapshots:
react@19.2.4: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
resolve-from@4.0.0: {}
rimraf@6.1.3:
dependencies:
glob: 13.0.6
package-json-from-dist: 1.0.1
rolldown@1.0.0-rc.9:
dependencies:
'@oxc-project/types': 0.115.0
@@ -2244,6 +2845,12 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9
safe-buffer@5.2.1: {}
sax@1.1.4: {}
sax@1.6.0: {}
scheduler@0.27.0: {}
semver@6.3.1: {}
@@ -2258,8 +2865,34 @@ snapshots:
shebang-regex@3.0.0: {}
signal-exit@3.0.7: {}
sisteransi@1.0.5: {}
slice-ansi@4.0.0:
dependencies:
ansi-styles: 4.3.0
astral-regex: 2.0.0
is-fullwidth-code-point: 3.0.0
source-map-js@1.2.1: {}
split2@4.2.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-json-comments@3.1.1: {}
supports-color@7.2.0:
@@ -2272,17 +2905,30 @@ snapshots:
tapable@2.3.0: {}
tar@7.5.12:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.3
minizlib: 3.1.0
yallist: 5.0.0
through2@4.0.2:
dependencies:
readable-stream: 3.6.2
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tree-kill@1.2.2: {}
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
tslib@2.8.1:
optional: true
tslib@2.8.1: {}
type-check@0.4.0:
dependencies:
@@ -2303,6 +2949,10 @@ snapshots:
undici-types@7.16.0: {}
universalify@2.0.1: {}
untildify@4.0.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
browserslist: 4.28.1
@@ -2313,6 +2963,8 @@ snapshots:
dependencies:
punycode: 2.3.1
util-deprecate@1.0.2: {}
vite@8.0.0(@types/node@24.12.0)(jiti@2.6.1):
dependencies:
'@oxc-project/runtime': 0.115.0
@@ -2332,8 +2984,30 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
xml2js@0.6.2:
dependencies:
sax: 1.6.0
xmlbuilder: 11.0.1
xmlbuilder@11.0.1: {}
xmlbuilder@15.1.1: {}
yallist@3.1.1: {}
yallist@5.0.0: {}
yauzl@2.10.0:
dependencies:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
yocto-queue@0.1.0: {}
zod-validation-error@4.0.2(zod@4.3.6):
@@ -2341,3 +3015,8 @@ snapshots:
zod: 4.3.6
zod@4.3.6: {}
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4):
optionalDependencies:
'@types/react': 19.2.14
react: 19.2.4

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
[{"c":"110000","n":"北京市","p":"11"},{"c":"120000","n":"天津市","p":"12"},{"c":"130000","n":"河北省","p":"13"},{"c":"140000","n":"山西省","p":"14"},{"c":"150000","n":"内蒙古自治区","p":"15"},{"c":"210000","n":"辽宁省","p":"21"},{"c":"220000","n":"吉林省","p":"22"},{"c":"230000","n":"黑龙江省","p":"23"},{"c":"310000","n":"上海市","p":"31"},{"c":"320000","n":"江苏省","p":"32"},{"c":"330000","n":"浙江省","p":"33"},{"c":"340000","n":"安徽省","p":"34"},{"c":"350000","n":"福建省","p":"35"},{"c":"360000","n":"江西省","p":"36"},{"c":"370000","n":"山东省","p":"37"},{"c":"410000","n":"河南省","p":"41"},{"c":"420000","n":"湖北省","p":"42"},{"c":"430000","n":"湖南省","p":"43"},{"c":"440000","n":"广东省","p":"44"},{"c":"450000","n":"广西壮族自治区","p":"45"},{"c":"460000","n":"海南省","p":"46"},{"c":"500000","n":"重庆市","p":"50"},{"c":"510000","n":"四川省","p":"51"},{"c":"520000","n":"贵州省","p":"52"},{"c":"530000","n":"云南省","p":"53"},{"c":"540000","n":"西藏自治区","p":"54"},{"c":"610000","n":"陕西省","p":"61"},{"c":"620000","n":"甘肃省","p":"62"},{"c":"630000","n":"青海省","p":"63"},{"c":"640000","n":"宁夏回族自治区","p":"64"},{"c":"650000","n":"新疆维吾尔自治区","p":"65"},{"c":"710000","n":"台湾省","p":"71"},{"c":"810000","n":"香港特别行政区","p":"81"},{"c":"820000","n":"澳门特别行政区","p":"82"}]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 243 KiB

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -1,16 +1,100 @@
// import {useEffect} from 'react'
import {lazy, Suspense} from 'react'
import {BrowserRouter, Route, Routes} from 'react-router-dom'
import HomePage from './views/home'
import RecordPage from './views/record'
import AccountPage from './views/account'
import {AuthGuide} from '@/features/authGuide.tsx'
import {GlobalToast} from '@/features/notifications'
// import type { HostContextMessage } from '@/types'
const HomePage = lazy(() => import('./views/home'))
const RecordPage = lazy(() => import('./views/record'))
const AccountPage = lazy(() => import('./views/account'))
const GoodsPage = lazy(() => import('./views/goods'))
function App() {
// useEffect(() => {
// const handleMessage = (event: MessageEvent<HostContextMessage>) => {
// const message = event.data
//
// if (!message || message.type !== 'IFRAME_CONTEXT' || !message.payload) {
// return
// }
//
// const {token, language} = message.payload
//
// console.log('postMessage', token, language)
//
// if (typeof token === 'string' && token.trim()) {
// sessionStorage.setItem('host_token', token)
// }
//
// if (typeof language === 'string' && language.trim()) {
// sessionStorage.setItem('host_language', language)
// document.documentElement.lang = language
// }
// }
//
// window.addEventListener('message', handleMessage)
//
// return () => {
// window.removeEventListener('message', handleMessage)
// }
// }, [])
// 本机测试
// window.postMessage(
// {
// type: 'IFRAME_CONTEXT',
// payload: {
// token: 'test-token-123',
// language: 'zh-CN',
// },
// },
// window.location.origin
// )
// 父页面发送iframe
// <iframe
// id="palyx-frame"
// src="https://your-iframe-app.example.com"
// style="width: 100%; border: 0;"
// ></iframe>
//
// <script>
// const iframe = document.getElementById('palyx-frame')
// const IFRAME_ORIGIN = 'https://your-iframe-app.example.com'
//
// iframe.addEventListener('load', () => {
// iframe.contentWindow.postMessage(
// {
// type: 'IFRAME_CONTEXT',
// payload: {
// token: 'token',
// language: 'zh-CN',
// },
// },
// window.location.origin
// )
// })
// </script>
return (
<BrowserRouter>
<GlobalToast/>
<AuthGuide>
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center text-[14px] text-white/68">
Loading page...
</div>
}
>
<Routes>
<Route path="/" element={<HomePage/>}/>
<Route path="/goods" element={<GoodsPage/>}/>
<Route path="/record" element={<RecordPage/>}/>
<Route path="/account" element={<AccountPage/>}/>
</Routes>
</Suspense>
</AuthGuide>
</BrowserRouter>
)
}

64
src/api/address.ts Normal file
View File

@@ -0,0 +1,64 @@
import {http, objectToFormData} from "@/lib";
import type {
AddressAddResponse,
AddressInfo,
AddressListResponse,
} from "@/types/address.type.ts";
import type {GoodListResponse} from "@/types/business.type.ts";
/** @description 获取收货地址 */
export const addressList = (params: {
session_id: string
}): Promise<AddressListResponse> => {
return http.get<AddressListResponse>('v1/mall/addressList', {
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 添加地址*/
export const addressAdd = (params: {
session_id: string
} & AddressInfo): Promise<AddressAddResponse> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {session_id: sessionId, ...rest} = params
const formData = objectToFormData(rest)
return http.post<AddressAddResponse>('v1/mall/addressAdd', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 修改地址(含设为默认) */
export const addressEdit = (params: {
session_id: string
id: string
} & AddressInfo): Promise<GoodListResponse> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {session_id: sessionId, ...rest} = params
const formData = objectToFormData(rest)
return http.post<GoodListResponse>('v1/mall/addressEdit', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 删除地址*/
export const addressDelete = (params: {
id: string,
session_id: string
}): Promise<GoodListResponse> => {
const formData = objectToFormData({id:params.id})
return http.post<GoodListResponse>('v1/mall/addressDelete', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}

15
src/api/auth.ts Normal file
View File

@@ -0,0 +1,15 @@
import {http, objectToFormData} from '@/lib'
import type {LoginParams, LoginResponse, ValidateTokenResponse} from "@/types/auth.type.ts";
/** @description 登录获取token */
export const login = (params: LoginParams) => http.get<LoginResponse>('v1/temLogin', {
searchParams: params,
})
/** @description Token 验证 */
export const validateToken = (token: string): Promise<ValidateTokenResponse> => {
const formData = objectToFormData({token})
return http.post<ValidateTokenResponse>('v1/mall/verifyToken', {
body: formData,
})
}

105
src/api/business.ts Normal file
View File

@@ -0,0 +1,105 @@
import {http, objectToFormData} from "@/lib";
import type {goodsType, GoodListResponse, OrdersResponse, PointsLogsResponse} from "@/types/business.type.ts";
/** @description 商品列表 */
export const goodList = (params: {
type: goodsType
}): Promise<GoodListResponse> => http.get<GoodListResponse>('v1/mall/items', {
searchParams: params,
})
/** @description 领取Claim
* @param params claim_request_id 领取请求ID使用 user_id + 时间戳生成
* */
export const claim = (params: {
claim_request_id: string,
session_id: string
}): Promise<GoodListResponse> => {
const formData = objectToFormData({claim_request_id: params.claim_request_id})
return http.post<GoodListResponse>('v1/mall/claim', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 红利兑换Bonus Redeem
* @param params item_id 商品ID
* @param params session_id 会话ID
* */
export const bonusRedeem = (params: {
item_id: string,
session_id: string
}): Promise<GoodListResponse> => {
const formData = objectToFormData({item_id: params.item_id})
return http.post<GoodListResponse>('v1/mall/bonusRedeem', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 实物兑换Physical Redeem
* @param params item_id 商品id
* @param params session_id 会话ID
* @param params address_id 收货地址id
* */
export const physicalRedeem = (params: {
item_id: string,
session_id: string,
address_id: string,
}): Promise<GoodListResponse> => {
const formData = objectToFormData({item_id: params.item_id, address_id: params.address_id})
return http.post<GoodListResponse>('v1/mall/physicalRedeem', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 提现申请Withdraw Apply
* @param params item_id 商品id
* @param params session_id 会话ID
* */
export const withdrawApply = (params: {
item_id: string,
session_id: string
}): Promise<GoodListResponse> => {
const formData = objectToFormData({item_id: params.item_id})
return http.post<GoodListResponse>('v1/mall/withdrawApply', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 订单列表 */
export const orders = (params: {
session_id: string
}): Promise<OrdersResponse> => {
return http.get<OrdersResponse>('v1/mall/orders', {
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 积分流水 */
export const pointsLogs = (params: {
session_id: string
}): Promise<PointsLogsResponse> => {
return http.get<PointsLogsResponse>('v1/mall/pointsLogs', {
searchParams: {
session_id: params.session_id,
},
})
}

8
src/api/user.ts Normal file
View File

@@ -0,0 +1,8 @@
import type {UserAssetsParams, UserAssetsResponse} from "@/types/user.type.ts";
import {http} from "@/lib";
/** @description 用户资产*/
export const userAssets = (params: UserAssetsParams) => http.get<UserAssetsResponse>('v1/mall/assets', {
searchParams: params,
})

View File

@@ -1,9 +0,0 @@
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="34" height="34" fill="url(#pattern0_18_206)"/>
<defs>
<pattern id="pattern0_18_206" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_18_206" transform="scale(0.0294118)"/>
</pattern>
<image id="image0_18_206" width="34" height="34" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAMAAAANmfvwAAAAAXNSR0IB2cksfwAAAblQTFRF//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//XrKAqIygAAAJN0Uk5TADWgz+Tq3siCGCy6//eUCVngHlvEuNPsHyL5hjmi29koU284EfILsHrKWvUKQocC6LwZ/qoE664X/cUboftgfZOlSE/Wn1iQRumyXTAdP27UCMmoDLEgIcOpxirS7b82/FJyKaMjN5KcmZuWRxDvYsJli4yJiIoySkCEfmjVrVZDOjvnByfLzHF1jWoNAfCDTOLx5sxRPAAAAg1JREFUeJxjZEAGjDDwHVkQic3FyIhQ+hGbEoE/SMpZGd9hKhH+CaY4GBm/gWhuxhfoSiQ/Awk+xn/PGRhkGN8D2UKP0JWwswINYnwA4Si+BhJi91CVKL8EssXvILtLkvEWihL1p0ALbiCcq/mYgUHuGooSuXcM8oxXEEp07zMwKF1CVqLPeIdB9TySp40YbzJonEFWYsp4jUH7JJISC8bLDJyvUSxi4WDQO4akxPoiA4PBERQlYt8YDG+/QCixO8fAYHwQRYkD0F7T/Qgl4l8ZGMz2oSgBGcxgvpcBSYfFHgYUJQyux4GE1S4Ix/0okLDZgaaE3+owyAmQ5ALSL/iYAU0JQyAjxAj1m2BSYS0DhpIQxu1IntZ9/ISBIWyL2CvflXAlDj8vMyADnxUMkZtBDD+okmjGDRDbdv7zOn2XDSSzLH4tskW6xmtA9n+xOgqO/zCeHw8050p8QVLicwBERjDORrGKIQ2aHYDMXMZ5QDJ5IgN2AFJSMAdoifEshGAhI+N/EM3E+J9paQxQScUUkEntCBVVk9BNMQOmv/xWJDGxb2hKnE4xMAQsQRar2YscRIWMDHV9DAzFjSj6JDIYGM4Zgz37/+ksRobmTgYtvxocvgFb1Ap0p+9yTHFPG3DiNu0tYexsZmCoK0NXofYMSXU30BmKl9GV9DQgKekt7WEsxHTABMYpEEbuVQAqHIIU/MvtggAAAABJRU5ErkJggg=="/>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -1,9 +0,0 @@
<svg width="32" height="34" viewBox="0 0 32 34" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="32" height="34" fill="url(#pattern0_18_197)"/>
<defs>
<pattern id="pattern0_18_197" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_18_197" transform="scale(0.03125 0.0294118)"/>
</pattern>
<image id="image0_18_197" width="32" height="34" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAiCAMAAAAJbCvNAAAAAXNSR0IB2cksfwAAAVZQTFRF//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//XrBUhsGwAAAHJ0Uk5TPej/4SUA2Mfv5+Xccunb5LTQ2r/XNEhGRUcrdGwC+wGxnuJxkmvNKd0fOjk7NW39wAzBz69eBGINwgUHpAlzgX97NtHqBmp29Wdp9Atd1WNcCArUZFrZ02CrFfpmtyeaDsoqLC+mMdbueM5bmZaXfoOYXJMCGQAAAb5JREFUeJxjZGBkZMAJGIGA7Q9ueQYG1n+MHL/wKWD/w8j1g4EThyWMXxl4vjPyf2bg+4pdAc9HBoHPIAWCn7Ar4H9HVQUCjMjh8Z+J8TWqArHn6NqlXiIrkHiKYb7McwbGP3AFUoyP0BXIP2H4959B4THMDXJogcX4gIFB8Q4Dx298vlBifMTzHqZAhZEBagYj4w0UoyAKNBkvI8T0GK8ASQVeoM8vQBUYnkEx2/QcgzHjSRCL1RjqC6mTyAqEP5idhiYTK6gVFoiABIbiYWPu/UCW0z6EG1CBw0GQfpfvXLsZ3LAp0HnwEUi672Fw3cHgiUWB+3eQ+S77GTy2YrVCR3YLkPTdBpE3wlAA1++9CUgL2aIr8GVcDySDNjIErAXSIY/Ooilw514NMT94FZBm0b6C7oaQlUDCYzdcP7ojQ1cwMEQyrkTohyiIXgFTELmEgSF2GVQ/11IGqIJ4xh1Alifji53//zEwJP/8twzI5VG+AtbD6LIP7gAnpavHYezUn0shDMaMWQg/sv5j+w5hwfQDFWTORCiIXabx8QmIkfFtKUyMkTVtpwc4phn/MT/awiAa9of1z61/B+CaALF6szdHXa+5AAAAAElFTkSuQmCC"/>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,18 +1,19 @@
import type { ButtonHTMLAttributes, PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
type ButtonProps = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>;
import type { ButtonProps } from '@/types'
export function Button({
children,
className = "",
type = "button",
variant = "orange",
...props
}: ButtonProps) {
const baseClassName = variant === "gray" ? "button-play-gray" : "button-play";
return (
<button
type={type}
className={twMerge("button-play", className)}
className={twMerge(baseClassName, className)}
{...props}
>
{children}

View File

@@ -1,3 +0,0 @@
export function Card(){
return (<div className={'w-full h-[]'}>card</div>)
}

View File

@@ -1,13 +1,11 @@
import type { ReactNode } from 'react'
import type { PageLayoutProps } from '@/types'
type PageLayoutProps = {
children: ReactNode
contentClassName?: string
}
function PageLayout({ children, contentClassName = 'w-[90%] lg:w-[60%] h-full mx-auto flex flex-col' }: PageLayoutProps) {
function PageLayout({
children,
contentClassName = 'mx-auto flex min-h-screen w-full max-w-[1180px] flex-col px-4 pb-8 sm:px-6 lg:px-8',
}: PageLayoutProps) {
return (
<main className="text-white h-screen w-screen overflow-x-hidden bg-[url('/home-figma-frame.png')] bg-cover bg-center bg-no-repeat">
<main className="min-h-screen w-full overflow-x-hidden bg-[url('/home-figma-frame.png')] bg-cover bg-center bg-no-repeat text-white">
<div className={contentClassName}>{children}</div>
</main>
)

View File

@@ -1,17 +1,5 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib'
type ModalProps = {
open: boolean
title?: ReactNode
children: ReactNode
footer?: ReactNode
onClose?: () => void
closeOnOverlayClick?: boolean
className?: string
bodyClassName?: string
}
import type { ModalProps } from '@/types'
export function Modal({
open,
@@ -34,7 +22,7 @@ export function Modal({
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-[16px]">
<div className="fixed inset-0 z-50 flex items-center justify-center p-[12px] sm:p-[16px]">
<button
type="button"
aria-label="Close modal"
@@ -46,16 +34,16 @@ export function Modal({
role="dialog"
aria-modal="true"
className={cn(
'relative z-10 w-full max-w-[560px] overflow-hidden rounded-[14px] shadow-[0_18px_60px_rgba(0,0,0,0.45)]',
'relative z-10 flex max-h-[calc(100dvh-24px)] w-full max-w-[560px] flex-col overflow-hidden rounded-[14px] shadow-[0_18px_60px_rgba(0,0,0,0.45)] sm:max-h-[calc(100vh-32px)]',
className,
)}
>
<div className="flex min-h-[58px] items-center justify-between bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[18px] py-[14px] text-white">
<div className="text-[18px] leading-[1.2] font-bold">{title}</div>
<div className="flex min-h-[58px] shrink-0 items-center justify-between bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[16px] py-[14px] text-white sm:px-[18px]">
<div className="pr-[12px] text-[16px] leading-[1.2] font-bold sm:text-[18px]">{title}</div>
{onClose ? (
<button
type="button"
className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-black/15 text-[18px] leading-none text-white transition-colors hover:bg-black/25"
className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-black/15 text-[18px] leading-none text-white transition-colors hover:bg-black/25 focus-visible:ring-2 focus-visible:ring-white/85 focus-visible:ring-offset-2 focus-visible:ring-offset-[#F96C02]"
onClick={onClose}
>
×
@@ -63,12 +51,21 @@ export function Modal({
) : null}
</div>
<div className="liquid-glass-bg !rounded-t-none bg-[#08070E]/75">
<div className={cn('px-[20px] py-[20px] text-white', bodyClassName)}>{children}</div>
<div className="liquid-glass-bg !rounded-t-none flex min-h-0 flex-1 flex-col bg-[#08070E]/75">
<div
className={cn(
'min-h-0 flex-1 overflow-y-auto px-[16px] py-[16px] text-white sm:px-[20px] sm:py-[20px]',
bodyClassName,
)}
>
{children}
</div>
{footer ? (
<div className="flex flex-wrap items-center justify-center gap-[60px] px-[20px] pb-[20px]">
<div className="shrink-0 border-t border-white/8 px-[16px] py-[14px] sm:px-[20px] sm:py-[16px]">
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-6">
{footer}
</div>
</div>
) : null}
</div>
</div>

View File

@@ -1,29 +1,18 @@
import type { ReactNode } from 'react'
export type TableColumn<T extends Record<string, string>> = {
label: string
key: keyof T
render?: (value: T[keyof T], record: T, index: number) => ReactNode
}
type BorderlessTableProps<T extends Record<string, string>> = {
columns: TableColumn<T>[]
dataSource: T[]
}
import type { BorderlessTableProps } from '@/types'
function BorderlessTable<T extends Record<string, string>>({
columns,
dataSource,
}: BorderlessTableProps<T>) {
return (
<div className="overflow-x-auto rounded-[12px] bg-[#08070E]/55 p-[10px]">
<table className="w-full min-w-[960px] table-fixed border-collapse text-left text-[14px] text-white">
<div className="overflow-x-auto rounded-[14px] border border-white/8 bg-[#08070E]/58 p-[10px] shadow-[0_12px_32px_rgba(0,0,0,0.22)]">
<table className="w-full min-w-[860px] table-fixed border-collapse text-left text-[14px] text-white">
<thead>
<tr className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] text-white">
{columns.map((column) => (
<th
key={String(column.key)}
className="px-[16px] py-[14px] text-[13px] font-bold first:rounded-l-[10px] last:rounded-r-[10px]"
className="px-[16px] py-[14px] text-[12px] font-bold uppercase tracking-[0.08em] first:rounded-tl-[10px] last:rounded-tr-[10px]"
>
{column.label}
</th>
@@ -34,15 +23,22 @@ function BorderlessTable<T extends Record<string, string>>({
{dataSource.map((record, index) => (
<tr
key={`${record.name}-${index}`}
className="bg-white/4 transition-colors hover:bg-white/8"
className="border-b border-white/6 bg-white/4 transition-colors last:border-b-0 hover:bg-white/8"
>
{columns.map((column) => {
{columns.map((column, columnIndex) => {
const value = record[column.key]
const isLastRow = index === dataSource.length - 1
const isFirstColumn = columnIndex === 0
const isLastColumn = columnIndex === columns.length - 1
return (
<td
key={String(column.key)}
className="px-[16px] py-[16px] align-top text-[13px] text-white/90"
className={`px-[16px] py-[16px] align-top text-[13px] leading-[1.55] text-white/90 ${
isLastRow && isFirstColumn ? 'rounded-bl-[10px]' : ''
} ${
isLastRow && isLastColumn ? 'rounded-br-[10px]' : ''
}`}
>
{column.render ? column.render(value, record, index) : value}
</td>

39
src/constant/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import {
ArrowRightLeft,
Gift,
Sparkles,
type LucideIcon,
} from 'lucide-react'
import type {goodsType} from '@/types/business.type.ts'
export type GoodCategoryMeta = {
name: string
ctaLabel: string
icon: LucideIcon
}
export const HOME_CATEGORY_META_MAP: Record<goodsType, GoodCategoryMeta> = {
WITHDRAW: {
name: 'Transfer to Platform',
ctaLabel: 'Transfer Now',
icon: ArrowRightLeft,
},
BONUS: {
name: 'Game Bonus',
ctaLabel: 'Redeem Bonus',
icon: Sparkles,
},
PHYSICAL: {
name: 'Physical Prizes',
ctaLabel: 'Claim Prize',
icon: Gift,
},
}
export const HOME_GOOD_TYPE_ORDER = ['WITHDRAW', 'BONUS', 'PHYSICAL'] as const satisfies readonly goodsType[]
export const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ?? 'https://playx-api.cjdhr.top'
/**@description 待处理 / 已完成 / 已发货 / 已驳回 */
export const ORDER_STATUS = ['PENDING','COMPLETED','SHIPPED','REJECTED']

View File

@@ -0,0 +1,37 @@
import type {AddAddressForm} from '@/types'
type AddressValidationResult =
| { valid: true }
| { valid: false; message: string }
export function validateAddressFormSubmission(addressForm: AddAddressForm): AddressValidationResult {
if (!addressForm.name.trim()) {
return {
valid: false,
message: 'Please enter the receiver name.',
}
}
if (!addressForm.phone.trim()) {
return {
valid: false,
message: 'Please enter a reachable mobile number.',
}
}
if (addressForm.region.length !== 3) {
return {
valid: false,
message: 'Please select province, city and district.',
}
}
if (!addressForm.detailedAddress.trim()) {
return {
valid: false,
message: 'Please enter the detailed address.',
}
}
return {valid: true}
}

View File

@@ -0,0 +1,2 @@
export * from './useAddressBook'
export * from './addressValidation'

View File

@@ -0,0 +1,187 @@
import {useCallback, useState} from 'react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {addressAdd, addressDelete, addressEdit, addressList} from '@/api/address.ts'
import {validateAddressFormSubmission} from '@/features/addressBook/addressValidation'
import {notifyError} from '@/features/notifications'
import {queryKeys} from '@/lib/queryKeys.ts'
import {emptyAddressFormMock} from '@/mock'
import {useUserStore} from '@/store/user.ts'
import type {AddressListItem} from '@/types/address.type.ts'
import type {AddAddressForm, AddressOption} from '@/types'
type UseAddressBookOptions = {
autoLoad?: boolean
}
export function getAddressText(item: AddressListItem) {
const regionText = item.region_text || item.region.map((part) => part.trim()).join(', ')
return [regionText, item.detail_address].filter(Boolean).join(', ')
}
export function mapAddressToOption(item: AddressListItem): AddressOption {
return {
id: String(item.id),
name: item.receiver_name,
phone: item.phone,
address: getAddressText(item),
isDefault: item.default_setting === 1,
}
}
export function mapAddressToForm(item: AddressListItem): AddAddressForm {
return {
name: item.receiver_name,
phone: item.phone,
region: item.region.map((part) => part.trim()).filter(Boolean).slice(0, 3),
detailedAddress: item.detail_address,
isDefault: item.default_setting === 1,
}
}
export function useAddressBook(options?: UseAddressBookOptions) {
const queryClient = useQueryClient()
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const [addressForm, setAddressForm] = useState<AddAddressForm>(emptyAddressFormMock)
const addressListQuery = useQuery({
queryKey: queryKeys.addressList(sessionId),
enabled: Boolean(sessionId) && Boolean(options?.autoLoad),
queryFn: async () => {
const response = await addressList({session_id: sessionId!})
return response.data.list
},
})
const saveAddressMutation = useMutation({
mutationFn: async (editingAddress?: AddressListItem | null) => {
const payload = {
session_id: sessionId!,
receiver_name: addressForm.name.trim(),
phone: addressForm.phone.trim(),
region: addressForm.region.join(', '),
detail_address: addressForm.detailedAddress.trim(),
default_setting: addressForm.isDefault ? '1' : '0',
} as const
if (editingAddress) {
return await addressEdit({
...payload,
id: String(editingAddress.id),
})
}
return await addressAdd(payload)
},
})
const deleteAddressMutation = useMutation({
mutationFn: async (addressId: string) => {
return await addressDelete({
id: addressId,
session_id: sessionId!,
})
},
})
const addresses = addressListQuery.data ?? []
const addressOptions = addresses.map(mapAddressToOption)
const isAddressFormValid = validateAddressFormSubmission(addressForm).valid
const loadAddresses = useCallback(async () => {
if (!sessionId) {
queryClient.removeQueries({queryKey: queryKeys.addressList(sessionId)})
return []
}
try {
const result = await queryClient.fetchQuery({
queryKey: queryKeys.addressList(sessionId),
queryFn: async () => {
const response = await addressList({session_id: sessionId})
return response.data.list
},
})
return result
} catch {
return []
}
}, [queryClient, sessionId])
const resetAddressForm = () => {
setAddressForm(emptyAddressFormMock)
saveAddressMutation.reset()
}
const fillAddressForm = (address?: AddressListItem | null) => {
setAddressForm(address ? mapAddressToForm(address) : emptyAddressFormMock)
saveAddressMutation.reset()
}
const changeAddressForm = (field: keyof AddAddressForm, value: AddAddressForm[keyof AddAddressForm]) => {
setAddressForm((previous) => ({
...previous,
[field]: value,
}))
}
const saveAddress = async (editingAddress?: AddressListItem | null) => {
if (!sessionId) {
return null
}
const validation = validateAddressFormSubmission(addressForm)
if (!validation.valid) {
notifyError(validation.message)
return null
}
try {
const response = await saveAddressMutation.mutateAsync(editingAddress)
await queryClient.invalidateQueries({
queryKey: queryKeys.addressList(sessionId),
})
return {
addresses: await loadAddresses(),
response,
}
} catch {
return null
}
}
const removeAddress = async (addressId: string) => {
if (!sessionId) {
return null
}
try {
const response = await deleteAddressMutation.mutateAsync(addressId)
await queryClient.invalidateQueries({
queryKey: queryKeys.addressList(sessionId),
})
return response
} catch {
return null
}
}
return {
sessionId,
addresses,
addressOptions,
loading: addressListQuery.isPending,
addressForm,
isAddressFormValid,
submitLoading: saveAddressMutation.isPending,
deleteLoading: deleteAddressMutation.isPending,
loadAddresses,
resetAddressForm,
fillAddressForm,
changeAddressForm,
saveAddress,
removeAddress,
}
}

View File

@@ -0,0 +1,73 @@
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {type PropsWithChildren, useEffect} from 'react'
import {login, validateToken} from '@/api/auth.ts'
import {userAssets} from '@/api/user.ts'
import {queryKeys} from '@/lib/queryKeys.ts'
import {useUserStore} from '@/store/user.ts'
export function AuthGuide({children}: PropsWithChildren) {
const queryClient = useQueryClient()
const setUserInfo = useUserStore((state) => state.setUserInfo)
const setAuthInfo = useUserStore((state) => state.setAuthInfo)
const setAssetsInfo = useUserStore((state) => state.setAssetsInfo)
const clearUserInfo = useUserStore((state) => state.clearUserInfo)
const authBootstrapQuery = useQuery({
queryKey: queryKeys.authBootstrap,
queryFn: async () => {
const loginResponse = await login({username: '+60777777777'})
const userInfo = loginResponse.data.userInfo
const validateResponse = await validateToken(userInfo.token)
const authInfo = validateResponse.data
const assetsResponse = await userAssets({
session_id: authInfo.session_id,
})
return {
userInfo,
authInfo,
assetsInfo: assetsResponse.data,
}
},
})
useEffect(() => {
if (!authBootstrapQuery.data) {
return
}
setUserInfo(authBootstrapQuery.data.userInfo)
setAuthInfo(authBootstrapQuery.data.authInfo)
setAssetsInfo(authBootstrapQuery.data.assetsInfo)
queryClient.setQueryData(queryKeys.assets(authBootstrapQuery.data.authInfo.session_id), authBootstrapQuery.data.assetsInfo)
}, [authBootstrapQuery.data, queryClient, setAssetsInfo, setAuthInfo, setUserInfo])
useEffect(() => {
if (!authBootstrapQuery.isError) {
return
}
clearUserInfo()
}, [authBootstrapQuery.isError, clearUserInfo])
if (authBootstrapQuery.isPending) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center text-[14px] text-white/68">
Loading account data...
</div>
)
}
if (authBootstrapQuery.isError) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center">
<div className="max-w-[420px] rounded-[14px] border border-white/10 bg-white/4 px-[18px] py-[16px]">
<div className="text-[16px] font-semibold text-white">Authentication failed</div>
<div className="mt-[8px] text-[13px] leading-[1.6] text-white/58">Please refresh and try again.</div>
</div>
</div>
)
}
return <>{children}</>
}

View File

@@ -0,0 +1,131 @@
import {useState} from 'react'
import {ChevronRight} from 'lucide-react'
import Button from '@/components/button'
import {HOME_CATEGORY_META_MAP} from '@/constant'
import type {ProductCategory, ProductItem} from '@/types'
type GoodsCategoryListProps = {
categories: ProductCategory[]
loading?: boolean
emptyText?: string
showMore?: boolean
onMoreClick?: (categoryId: ProductCategory['id']) => void
onRedeem: (product: ProductItem, categoryId: ProductCategory['id']) => void
}
function GoodsImage({
imageUrl,
}: {
imageUrl?: string
}) {
const [hasError, setHasError] = useState(false)
const showFallback = !imageUrl || hasError
return (
<div className="relative h-[116px] w-full overflow-hidden rounded-t-[10px] sm:h-[128px]">
{showFallback ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[radial-gradient(circle_at_top,rgba(250,106,0,0.18),transparent_58%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-[12px] text-center">
<div className="h-[30px] w-[30px] rounded-[10px] border border-white/10 bg-white/6"></div>
<div className="mt-[10px] text-[12px] tracking-[0.08em] text-white/42">NO IMAGE</div>
</div>
) : null}
{imageUrl ? (
<img
src={imageUrl}
alt=""
className={`h-full w-full object-cover ${showFallback ? 'hidden' : 'block'}`}
onError={() => setHasError(true)}
/>
) : null}
</div>
)
}
export function GoodsCategoryList({
categories,
loading = false,
emptyText = 'No goods available yet.',
showMore = false,
onMoreClick,
onRedeem,
}: GoodsCategoryListProps) {
if (loading) {
return (
<div className="pb-[24px] mt-[20px]">
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
Loading ...
</div>
</div>
)
}
if (!categories.length) {
return (
<div className="pb-[24px]">
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{emptyText}
</div>
</div>
)
}
return (
<div className="pb-[24px]">
{categories.map((category) => {
const CategoryIcon = HOME_CATEGORY_META_MAP[category.id].icon
return (
<div key={category.id} className="mt-[20px]">
<div className="mb-[10px] flex items-center justify-between gap-[12px]">
<div className="flex min-w-0 items-center gap-[10px]">
<div
className="flex h-[36px] w-[36px] items-center justify-center rounded-[12px] bg-[#FA6A00]/15 text-[#FE9F00]">
<CategoryIcon className="h-[18px] w-[18px]" aria-hidden="true"/>
</div>
<div className="truncate text-[15px] font-semibold text-white">{category.name}</div>
</div>
{showMore && onMoreClick ? (
<button
type="button"
className="flex shrink-0 items-center gap-[3px] text-[12px] font-light text-[#FA6A00] underline cursor-pointer"
onClick={() => onMoreClick(category.id)}
>
more
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true"/>
</button>
) : null}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{category.items.map((product) => (
<div
key={product.id}
className="liquid-glass-bg flex min-h-[260px] w-full flex-col items-stretch justify-start overflow-hidden"
>
<GoodsImage imageUrl={product.imageUrl}/>
<div
className="flex flex-1 flex-col items-start justify-between gap-[12px] p-[12px] sm:p-[14px]"
>
<div className="space-y-[4px]">
<div className="text-[16px] font-medium text-white">{product.title}</div>
<div className="text-[13px] text-white/52">{product.subtitle}</div>
</div>
<div className="text-[16px] font-semibold text-[#FA6A00]">{product.score}</div>
<Button
className="h-[36px] w-full text-[13px]"
onClick={() => onRedeem(product, category.id)}
>
{product.ctaLabel}
</Button>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,354 @@
import {
Check,
ChevronRight,
CirclePlus,
Gift,
MapPinHouse,
} from 'lucide-react'
import Button from '@/components/button'
import Modal from '@/components/modal'
import {RegionPicker} from '@/features/goods/RegionPicker'
import type {
AddAddressForm,
AddressOption,
ModalMode,
SelectedProductState,
} from '@/types'
function getNumericValue(value: string) {
const matched = value.match(/\d+/)
return matched ? matched[0] : value
}
function getTurnoverRequirement(subtitle: string) {
const matched = subtitle.match(/\d+/)
return matched ? `${matched[0]}x` : subtitle
}
type GoodsRedeemModalProps = {
selectedProduct: SelectedProductState | null
modalMode: ModalMode
addressOptions: AddressOption[]
selectedAddressId: string
addressForm: AddAddressForm
addressLoading: boolean
isAddAddressFormValid: boolean
submitLoading: boolean
onClose: () => void
onConfirm: () => void
onOpenAddAddress: () => void
onBackToSelectAddress: () => void
onSelectAddress: (addressId: string) => void
onChangeAddressForm: (field: keyof AddAddressForm, value: AddAddressForm[keyof AddAddressForm]) => void
forceOpen?: boolean
formOnly?: boolean
titleOverride?: string
confirmText?: string
}
export function GoodsRedeemModal({
selectedProduct,
modalMode,
addressOptions,
selectedAddressId,
addressForm,
addressLoading,
isAddAddressFormValid,
submitLoading,
onClose,
onConfirm,
onOpenAddAddress,
onBackToSelectAddress,
onSelectAddress,
onChangeAddressForm,
forceOpen = false,
formOnly = false,
titleOverride,
confirmText = 'Confirm',
}: GoodsRedeemModalProps) {
const selectedCategoryId = selectedProduct?.categoryId
const selectedProductData = selectedProduct?.product ?? null
const isPhysicalPrize = selectedCategoryId === 'PHYSICAL'
const isTransferToPlatform = selectedCategoryId === 'WITHDRAW'
const isGameBonus = selectedCategoryId === 'BONUS'
const isPhysicalPrizeSelection = modalMode === 'select-address' && isPhysicalPrize
const modalTitle = modalMode === 'add-address'
? 'Add Shipping Address'
: isTransferToPlatform
? 'Confirm Withdrawal'
: isGameBonus
? 'Confirm Bonus Redemption'
: 'Confirm Physical Reward'
const modalMaxWidthClassName = isTransferToPlatform
? 'max-w-[620px]'
: isGameBonus
? 'max-w-[460px]'
: 'max-w-[680px]'
const resolvedTitle = titleOverride ?? modalTitle
const isAddressFormMode = formOnly || isPhysicalPrize
const addressFormContent = (
<div
className="rounded-[12px] bg-[#171313]/88 px-[14px] py-[10px] shadow-[0_10px_30px_rgba(0,0,0,0.25)]">
<div
className="flex flex-col gap-[10px] border-b border-white/10 px-[6px] pb-[16px] sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-[8px] text-[18px] font-medium text-white">
<MapPinHouse className="h-[18px] w-[18px] text-[#FE9F00]" aria-hidden="true"/>
<span>Address Info</span>
</div>
<button
type="button"
className="flex items-center gap-[8px] text-[14px] text-white/85"
onClick={() => onChangeAddressForm('isDefault', !addressForm.isDefault)}
>
<span
className={`flex h-[16px] w-[16px] items-center justify-center rounded-full border ${
addressForm.isDefault ? 'border-[#FA6A00]' : 'border-white/35'
}`}
>
<span
className={`h-[8px] w-[8px] rounded-full ${
addressForm.isDefault ? 'bg-[#FA6A00]' : 'bg-transparent'
}`}
></span>
</span>
<span>Default Address</span>
</button>
</div>
<div className="divide-y divide-white/10">
<div
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Name<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.name}
onChange={(event) => onChangeAddressForm('name', event.target.value)}
placeholder="Enter receiver's full name"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
<div
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Phone Number<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.phone}
type="number"
onChange={(event) => onChangeAddressForm('phone', event.target.value)}
placeholder="Enter a reachable mobile number"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
<div
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Region<span className="text-[#FA6A00]">*</span>
</label>
<RegionPicker
value={addressForm.region}
onChange={(value) => onChangeAddressForm('region', value)}
/>
</div>
<div
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Detailed Address<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.detailedAddress}
onChange={(event) => onChangeAddressForm('detailedAddress', event.target.value)}
placeholder="Street, building, unit and room number"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
</div>
</div>
)
return (
<Modal
open={forceOpen || Boolean(selectedProduct)}
title={resolvedTitle}
onClose={onClose}
className={formOnly ? 'max-w-[680px]' : modalMaxWidthClassName}
bodyClassName="space-y-[18px]"
footer={
<>
<Button
type="button"
variant="gray"
className={`h-[38px] text-[14px] ${
isPhysicalPrizeSelection ? 'w-[124px]' : 'w-full sm:w-auto'
}`}
onClick={modalMode === 'add-address' ? onBackToSelectAddress : onClose}
disabled={submitLoading}
>
Cancel
</Button>
<Button
type="button"
className={`h-[38px] ${
isPhysicalPrizeSelection ? 'w-[124px]' : 'w-full sm:w-auto'
} ${modalMode === 'add-address' && !isAddAddressFormValid ? 'opacity-50' : ''}`}
onClick={onConfirm}
disabled={submitLoading || (modalMode === 'add-address' && !isAddAddressFormValid)}
>
{submitLoading ? 'Processing...' : confirmText}
</Button>
</>
}
>
{formOnly ? (
addressFormContent
) : selectedProductData ? (
<>
{isTransferToPlatform ? (
<div
className="rounded-[14px] bg-[#1C1818]/80 px-[18px] py-[14px] shadow-[0_10px_30px_rgba(0,0,0,0.22)]">
<div className="divide-y divide-white/8">
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[18px]">Withdrawal Amount</div>
<div className="text-[18px]">{getNumericValue(selectedProductData.title)}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[18px]">Points Required</div>
<div className="text-[18px]">{getNumericValue(selectedProductData.score)}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[18px] underline decoration-[#1E90FF] underline-offset-[3px]">
Turnover Requirement
</div>
<div className="text-[18px]">{getTurnoverRequirement(selectedProductData.subtitle)}</div>
</div>
</div>
<div className="pt-[10px] text-center text-[18px] text-white/45">
Submit withdrawal request?
</div>
</div>
) : isGameBonus ? (
<div
className="rounded-[10px] bg-[#1C1818]/80 px-[16px] py-[8px] shadow-[0_10px_30px_rgba(0,0,0,0.22)]">
<div className="divide-y divide-white/8">
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[13px] text-white/78">Item</div>
<div className="text-[14px]">{selectedProductData.title}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[13px] text-white/78">Points Required</div>
<div className="text-[14px]">{getNumericValue(selectedProductData.score)}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[13px] text-white/78">Turnover Requirement</div>
<div className="text-[14px]">{getTurnoverRequirement(selectedProductData.subtitle)}</div>
</div>
</div>
</div>
) : modalMode === 'select-address' && isPhysicalPrize ? (
<>
<div
className="rounded-[12px] bg-[#1F1B1B]/82 px-[14px] py-[10px] shadow-[0_10px_28px_rgba(0,0,0,0.2)]">
<div
className="flex items-center justify-between gap-[12px] border-b border-white/8 py-[12px]">
<div className="text-[13px] text-white/82">Item</div>
<div className="flex items-center gap-[10px]">
<div className="text-right text-[15px] text-white">{selectedProductData.title}</div>
<div className="h-[54px] w-[54px] overflow-hidden rounded-[10px] shadow-[0_10px_18px_rgba(0,0,0,0.2)]">
{selectedProductData.imageUrl ? (
<img
src={selectedProductData.imageUrl}
alt={selectedProductData.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Gift className="h-[24px] w-[24px] text-white" aria-hidden="true"/>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between py-[12px]">
<div className="text-[13px] text-white/82">Points Cost</div>
<div className="text-[15px] text-white">{getNumericValue(selectedProductData.score)}</div>
</div>
</div>
<div>
<div className="mb-[12px] text-[14px] text-white/40">
Please select the address information to fill in.
</div>
<div className="overflow-hidden rounded-[12px] bg-[#1F1B1B]/82 shadow-[0_10px_28px_rgba(0,0,0,0.2)]">
{addressLoading ? (
<div className="px-[14px] py-[16px] text-[14px] text-white/60">
Loading address list...
</div>
) : null}
{addressOptions.map((address) => {
const isSelected = selectedAddressId === address.id
return (
<button
key={address.id}
type="button"
className={`flex w-full items-start gap-[12px] border-b border-white/6 px-[14px] py-[14px] text-left transition-colors last:border-b-0 hover:bg-white/4 ${
isSelected ? 'bg-white/[0.03]' : ''
}`}
onClick={() => onSelectAddress(address.id)}
>
<div
className="mt-[2px] flex h-[20px] w-[20px] shrink-0 items-center justify-center rounded-full">
{isSelected ? (
<span
className="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#FA8A00] text-white shadow-[0_0_12px_rgba(250,138,0,0.45)]">
<Check className="h-[12px] w-[12px]" aria-hidden="true"/>
</span>
) : (
<span className="h-[20px] w-[20px] rounded-full border border-white/40"></span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-[8px]">
<div className="text-[14px] font-bold text-white">{address.name}</div>
<div className="text-[12px] text-white/45">{address.phone}</div>
{address.isDefault ? (
<div
className="rounded-[4px] bg-[#FF7F7F] px-[5px] py-[1px] text-[10px] leading-none text-white">
Default
</div>
) : null}
</div>
<div className="mt-[6px] text-[13px] leading-[1.5] text-white/52">
{address.address}
</div>
</div>
</button>
)
})}
<button
type="button"
className="flex w-full items-center justify-between px-[14px] py-[16px] text-left transition-colors hover:bg-white/4 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={onOpenAddAddress}
disabled={addressLoading}
>
<div className="flex items-center gap-[10px]">
<span
className="flex h-[16px] w-[16px] items-center justify-center rounded-full border border-white/70 text-white">
<CirclePlus className="h-[12px] w-[12px]" aria-hidden="true"/>
</span>
<div className="text-[14px] text-white/82">Add Address</div>
</div>
<ChevronRight className="h-[16px] w-[16px] text-white/45" aria-hidden="true"/>
</button>
</div>
</div>
</>
) : isAddressFormMode ? addressFormContent : null}
</>
) : null}
</Modal>
)
}

View File

@@ -0,0 +1,158 @@
import {useEffect, useState} from 'react'
type ProvinceNode = {
c: string
n: string
p: string
}
type CityNode = {
c: string
n: string
p: string
y: string
}
type AreaNode = {
c: string
n: string
p: string
y: string
a: string
}
type RegionPickerProps = {
value: string[]
onChange: (value: string[]) => void
}
type RegionDataset = {
province: ProvinceNode[]
city: CityNode[]
area: AreaNode[]
}
function RegionSelect({
value,
placeholder,
options,
onChange,
disabled = false,
}: {
value: string
placeholder: string
options: Array<{c: string; n: string}>
onChange: (value: string) => void
disabled?: boolean
}) {
return (
<div className="relative">
<select
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
className="h-[42px] w-full appearance-none rounded-[10px] border border-white/10 bg-[#0E0B12] px-[12px] pr-[36px] text-[14px] text-white outline-none transition-colors focus:border-[#FA6A00] disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={option.c} value={option.n}>
{option.n}
</option>
))}
</select>
<div className="pointer-events-none absolute right-[12px] top-1/2 -translate-y-1/2 text-[12px] text-white/38">
v
</div>
</div>
)
}
export function RegionPicker({value, onChange}: RegionPickerProps) {
const [regionData, setRegionData] = useState<RegionDataset | null>(null)
const [loadFailed, setLoadFailed] = useState(false)
const [province = '', city = '', district = ''] = value
useEffect(() => {
let cancelled = false
const loadRegionData = async () => {
try {
const [provinceResponse, cityResponse, areaResponse] = await Promise.all([
fetch(`${import.meta.env.BASE_URL}china-data/province.min.json`),
fetch(`${import.meta.env.BASE_URL}china-data/city.min.json`),
fetch(`${import.meta.env.BASE_URL}china-data/area.min.json`),
])
if (!provinceResponse.ok || !cityResponse.ok || !areaResponse.ok) {
throw new Error('Failed to load region data')
}
const [provinceList, cityList, areaList] = await Promise.all([
provinceResponse.json() as Promise<ProvinceNode[]>,
cityResponse.json() as Promise<CityNode[]>,
areaResponse.json() as Promise<AreaNode[]>,
])
if (!cancelled) {
setRegionData({
province: provinceList,
city: cityList,
area: areaList,
})
setLoadFailed(false)
}
} catch {
if (!cancelled) {
setLoadFailed(true)
}
}
}
void loadRegionData()
return () => {
cancelled = true
}
}, [])
const provinceList = regionData?.province ?? []
const cityList = regionData?.city ?? []
const areaList = regionData?.area ?? []
const selectedProvince = provinceList.find((item) => item.n === province)
const selectedCity = cityList.find((item) => item.n === city && item.p === selectedProvince?.p)
const cities = selectedProvince
? cityList.filter((item) => item.p === selectedProvince.p)
: []
const districts = selectedCity
? areaList.filter((item) => item.p === selectedCity.p && item.y === selectedCity.y)
: []
return (
<div className="grid grid-cols-1 gap-[10px] sm:grid-cols-3">
<RegionSelect
value={province}
placeholder={loadFailed ? 'failed to load' : 'province'}
options={provinceList}
onChange={(nextProvince) => onChange(nextProvince ? [nextProvince] : [])}
disabled={loadFailed || !regionData}
/>
<RegionSelect
value={city}
placeholder="city"
options={cities}
disabled={loadFailed || !regionData || !province}
onChange={(nextCity) => onChange(nextCity ? [province, nextCity] : [province])}
/>
<RegionSelect
value={district}
placeholder="district"
options={districts}
disabled={loadFailed || !regionData || !city}
onChange={(nextDistrict) => onChange(nextDistrict ? [province, city, nextDistrict] : [province, city])}
/>
</div>
)
}

View File

@@ -0,0 +1,7 @@
export * from './GoodsCategoryList'
export * from './GoodsRedeemModal'
export * from './RegionPicker'
export * from './useAssetsRefresh'
export * from './useAssetsQuery'
export * from './useGoodsCatalog'
export * from './useGoodsRedeem'

View File

@@ -0,0 +1,61 @@
import type {SelectedProductState} from '@/types'
type RedeemValidationParams = {
sessionId?: string
selectedProduct: SelectedProductState | null
selectedAddressId: string
}
type RedeemValidationResult =
| { valid: true }
| { valid: false; message: string }
export function validateRedeemSubmission({
sessionId,
selectedProduct,
selectedAddressId,
}: RedeemValidationParams): RedeemValidationResult {
if (!sessionId) {
return {
valid: false,
message: 'Session expired. Please log in again.',
}
}
if (!selectedProduct) {
return {
valid: false,
message: 'No product selected.',
}
}
if (selectedProduct.categoryId === 'PHYSICAL' && !selectedAddressId) {
return {
valid: false,
message: 'Please select a shipping address.',
}
}
return {valid: true}
}
type AddAddressValidationParams = {
isAddAddressFormValid: boolean
}
type AddAddressValidationResult =
| { valid: true }
| { valid: false; message: string }
export function validateAddAddressSubmission({
isAddAddressFormValid,
}: AddAddressValidationParams): AddAddressValidationResult {
if (!isAddAddressFormValid) {
return {
valid: false,
message: 'Please complete all required address fields.',
}
}
return {valid: true}
}

View File

@@ -0,0 +1,39 @@
import {useEffect} from 'react'
import {useQuery} from '@tanstack/react-query'
import {userAssets} from '@/api/user.ts'
import {queryKeys} from '@/lib/queryKeys.ts'
import {useUserStore} from '@/store/user.ts'
export function useAssetsQuery() {
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const setAssetsInfo = useUserStore((state) => state.setAssetsInfo)
const fallbackAssetsInfo = useUserStore((state) => state.assetsInfo)
const assetsQuery = useQuery({
queryKey: queryKeys.assets(sessionId),
enabled: Boolean(sessionId),
queryFn: async () => {
const response = await userAssets({
session_id: sessionId,
})
return response.data
},
initialData: fallbackAssetsInfo ?? undefined,
})
useEffect(() => {
if (!assetsQuery.data) {
return
}
setAssetsInfo(assetsQuery.data)
}, [assetsQuery.data, setAssetsInfo])
return {
assetsInfo: assetsQuery.data ?? null,
assetsLoading: assetsQuery.isPending,
assetsError: assetsQuery.error instanceof Error ? assetsQuery.error.message : null,
}
}

View File

@@ -0,0 +1,23 @@
import {useQueryClient} from '@tanstack/react-query'
import {queryKeys} from '@/lib/queryKeys.ts'
import {useUserStore} from '@/store/user.ts'
export function useAssetsRefresh() {
const queryClient = useQueryClient()
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const invalidateAssets = async () => {
if (!sessionId) {
return
}
await queryClient.invalidateQueries({
queryKey: queryKeys.assets(sessionId),
})
}
return {
invalidateAssets,
}
}

View File

@@ -0,0 +1,89 @@
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {goodList} from '@/api/business.ts'
import {
API_ORIGIN,
HOME_CATEGORY_META_MAP,
HOME_GOOD_TYPE_ORDER,
} from '@/constant'
import {queryKeys} from '@/lib/queryKeys.ts'
import type {ProductCategory, ProductItem} from '@/types'
import type {GoodsItem, goodsType} from '@/types/business.type.ts'
function getProductImageUrl(image?: string) {
if (!image) {
return undefined
}
if (/^https?:\/\//.test(image)) {
return image
}
return new URL(image, API_ORIGIN).toString()
}
function mapGoodItemToProductItem(item: GoodsItem, type: goodsType): ProductItem {
const categoryMeta = HOME_CATEGORY_META_MAP[type]
return {
id: String(item.id),
title: item.title,
subtitle: item.description,
score: `${item.score} Points`,
ctaLabel: categoryMeta.ctaLabel,
imageUrl: getProductImageUrl(item.image),
}
}
function buildProductCategories(groups: Record<goodsType, GoodsItem[]>): ProductCategory[] {
return HOME_GOOD_TYPE_ORDER.map((type) => ({
id: type,
name: HOME_CATEGORY_META_MAP[type].name,
items: groups[type].map((item) => mapGoodItemToProductItem(item, type)),
}))
}
export function isGoodsType(value: string | null | undefined): value is goodsType {
return value === 'WITHDRAW' || value === 'BONUS' || value === 'PHYSICAL'
}
type UseGoodsCatalogOptions = {
types?: readonly goodsType[]
}
export function useGoodsCatalog(options?: UseGoodsCatalogOptions) {
const queryClient = useQueryClient()
const types = options?.types?.length ? [...options.types] : HOME_GOOD_TYPE_ORDER
const goodsCatalogQuery = useQuery({
queryKey: queryKeys.goodsCatalog(types),
queryFn: async () => {
const responses = await Promise.all(
types.map(async (type) => {
const response = await goodList({type})
return [type, response.data.list] as const
}),
)
const groups = responses.reduce<Record<goodsType, GoodsItem[]>>(
(result, [type, items]) => {
result[type] = items
return result
},
{
WITHDRAW: [],
BONUS: [],
PHYSICAL: [],
},
)
return buildProductCategories(groups)
},
})
return {
productCategories: goodsCatalogQuery.data ?? [],
loading: goodsCatalogQuery.isPending,
error: goodsCatalogQuery.error instanceof Error ? goodsCatalogQuery.error.message : null,
invalidateGoods: () => queryClient.invalidateQueries({queryKey: ['goods-catalog']}),
}
}

View File

@@ -0,0 +1,156 @@
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {useState} from 'react'
import {bonusRedeem, physicalRedeem, withdrawApply} from '@/api/business.ts'
import {useAddressBook} from '@/features/addressBook'
import {notifyError, notifySuccess} from '@/features/notifications'
import {queryKeys} from '@/lib/queryKeys.ts'
import {validateAddAddressSubmission, validateRedeemSubmission} from '@/features/goods/redeemValidation'
import type {
ModalMode,
ProductCategory,
ProductItem,
SelectedProductState,
} from '@/types'
export function useGoodsRedeem() {
const queryClient = useQueryClient()
const addressBook = useAddressBook()
const [selectedProduct, setSelectedProduct] = useState<SelectedProductState | null>(null)
const [modalMode, setModalMode] = useState<ModalMode>('select-address')
const [selectedAddressId, setSelectedAddressId] = useState<string>('')
const redeemMutation = useMutation({
mutationFn: async (selectedProductState: SelectedProductState) => {
if (selectedProductState.categoryId === 'BONUS') {
return await bonusRedeem({
item_id: selectedProductState.product.id,
session_id: addressBook.sessionId,
})
}
if (selectedProductState.categoryId === 'PHYSICAL') {
return await physicalRedeem({
item_id: selectedProductState.product.id,
session_id: addressBook.sessionId,
address_id: selectedAddressId,
})
}
return await withdrawApply({
item_id: selectedProductState.product.id,
session_id: addressBook.sessionId,
})
},
})
const openRedeemModal = async (product: ProductItem, categoryId: ProductCategory['id']) => {
setSelectedProduct({
product,
categoryId,
})
addressBook.resetAddressForm()
if (categoryId === 'PHYSICAL') {
const addresses = await addressBook.loadAddresses()
const defaultAddress = addresses.find((item) => item.default_setting === 1) ?? addresses[0]
setSelectedAddressId(defaultAddress ? String(defaultAddress.id) : '')
setModalMode('select-address')
return
}
setModalMode('select-address')
}
const closeRedeemModal = () => {
setSelectedProduct(null)
setModalMode('select-address')
addressBook.resetAddressForm()
redeemMutation.reset()
}
const openAddAddress = () => {
setModalMode('add-address')
}
const backToSelectAddress = () => {
setModalMode('select-address')
}
const isAddAddressFormValid = addressBook.isAddressFormValid
const confirmRedeem = async () => {
if (modalMode === 'add-address') {
const addAddressValidation = validateAddAddressSubmission({
isAddAddressFormValid,
})
if (!addAddressValidation.valid) {
notifyError(addAddressValidation.message)
return
}
const savedAddress = await addressBook.saveAddress()
if (savedAddress) {
const defaultOption = savedAddress.addresses
.map((item) => ({
id: String(item.id),
isDefault: item.default_setting === 1,
}))
.find((item) => item.isDefault)
?? (savedAddress.addresses[0]
? {
id: String(savedAddress.addresses[0].id),
isDefault: savedAddress.addresses[0].default_setting === 1,
}
: null)
setSelectedAddressId(defaultOption?.id ?? '')
setModalMode('select-address')
notifySuccess(savedAddress.response, 'Address added successfully.')
}
return
}
const redeemValidation = validateRedeemSubmission({
sessionId: addressBook.sessionId,
selectedProduct,
selectedAddressId,
})
if (!redeemValidation.valid) {
notifyError(redeemValidation.message)
return
}
try {
const response = await redeemMutation.mutateAsync(selectedProduct!)
await Promise.all([
queryClient.invalidateQueries({queryKey: ['goods-catalog']}),
addressBook.sessionId
? queryClient.invalidateQueries({queryKey: queryKeys.assets(addressBook.sessionId)})
: Promise.resolve(),
])
notifySuccess(response, 'Redeem request submitted successfully.')
closeRedeemModal()
} catch {
// mutation error is surfaced via redeemMutation.error
}
}
return {
selectedProduct,
modalMode,
addressOptions: addressBook.addressOptions,
selectedAddressId,
addressForm: addressBook.addressForm,
addressLoading: addressBook.loading,
isAddAddressFormValid,
submitLoading: modalMode === 'add-address' ? addressBook.submitLoading : redeemMutation.isPending,
openRedeemModal,
closeRedeemModal,
openAddAddress,
backToSelectAddress,
setSelectedAddressId,
changeAddressForm: addressBook.changeAddressForm,
confirmRedeem,
}
}

View File

@@ -0,0 +1,16 @@
import type {ValidateTokenData} from '@/types/auth.type.ts'
type ClaimValidationResult =
| { valid: true }
| { valid: false; message: string }
export function validateClaimSubmission(authInfo: ValidateTokenData | null): ClaimValidationResult {
if (!authInfo?.session_id || !authInfo.user_id) {
return {
valid: false,
message: 'Session expired. Please log in again.',
}
}
return {valid: true}
}

View File

@@ -0,0 +1,32 @@
import {CircleAlert, CircleCheckBig} from 'lucide-react'
import {useToastStore} from './store'
export function GlobalToast() {
const toast = useToastStore((state) => state.toast)
if (!toast?.visible) {
return null
}
const isError = toast.type === 'error'
return (
<div className="pointer-events-none fixed right-[16px] top-[16px] z-[60] max-w-[320px]">
<div
className={`flex items-start gap-[10px] rounded-[14px] border px-[14px] py-[12px] text-white shadow-[0_18px_40px_rgba(0,0,0,0.35)] backdrop-blur-[8px] ${
isError
? 'border-[#FF9BA4]/35 bg-[#3A1318]/94'
: 'border-[#9BFFC0]/35 bg-[#11311E]/94'
}`}
>
{isError ? (
<CircleAlert className="mt-[1px] h-[18px] w-[18px] shrink-0 text-[#FF9BA4]" aria-hidden="true"/>
) : (
<CircleCheckBig className="mt-[1px] h-[18px] w-[18px] shrink-0 text-[#9BFFC0]" aria-hidden="true"/>
)}
<div className="text-[13px] leading-[1.55] text-white/92">{toast.message}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export * from './GlobalToast'
export * from './store'

View File

@@ -0,0 +1,79 @@
import {create} from 'zustand'
export type ToastType = 'success' | 'error'
type ToastState = {
message: string
type: ToastType
visible: boolean
}
type ToastStore = {
toast: ToastState | null
showToast: (message: string, type?: ToastType) => void
clearToast: () => void
}
let toastTimer: ReturnType<typeof setTimeout> | null = null
export function resolveToastMessage(source: unknown, fallback = '') {
if (typeof source === 'string' && source.trim()) {
return source.trim()
}
if (typeof source === 'object' && source !== null && 'msg' in source) {
const message = (source as {msg?: unknown}).msg
if (typeof message === 'string' && message.trim()) {
return message.trim()
}
}
return fallback.trim()
}
export const useToastStore = create<ToastStore>((set) => ({
toast: null,
showToast: (message, type = 'success') => {
if (toastTimer) {
clearTimeout(toastTimer)
}
set({
toast: {
message,
type,
visible: true,
},
})
toastTimer = setTimeout(() => {
set({toast: null})
toastTimer = null
}, 2000)
},
clearToast: () => {
if (toastTimer) {
clearTimeout(toastTimer)
toastTimer = null
}
set({toast: null})
},
}))
export function notifySuccess(source: unknown, fallback = '') {
const message = resolveToastMessage(source, fallback)
if (!message) {
return
}
useToastStore.getState().showToast(message, 'success')
}
export function notifyError(source: unknown, fallback = '') {
const message = resolveToastMessage(source, fallback)
if (!message) {
return
}
useToastStore.getState().showToast(message, 'error')
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

View File

@@ -1,5 +1,11 @@
@import "tailwindcss";
html,
body,
#root {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.button-play {
align-items: center;
@@ -16,7 +22,7 @@
color: #fff;
cursor: pointer;
display: inline-flex;
font-family: "JetBrains Mono",monospace;
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
justify-content: center;
line-height: 1;
list-style: none;
@@ -59,6 +65,64 @@
transform: translateY(2px);
}
.button-play-gray {
align-items: center;
appearance: none;
background-image: radial-gradient(100% 100% at 100% 0, #77716d 0, #66615d 45%, #55524f 100%);
border: 0;
border-radius: 6px;
box-shadow:
rgba(85, 82, 79, .42) 0 0 18px,
rgba(52, 49, 47, .35) 0 2px 4px,
rgba(52, 49, 47, .28) 0 7px 13px -3px,
#474440 0 -3px 0 inset;
box-sizing: border-box;
color: #fff;
cursor: pointer;
display: inline-flex;
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
justify-content: center;
line-height: 1;
list-style: none;
overflow: hidden;
padding-left: 16px;
padding-right: 16px;
position: relative;
text-align: left;
text-decoration: none;
transition: box-shadow .15s,transform .15s;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
white-space: nowrap;
will-change: box-shadow,transform;
font-size: 14px;
font-weight: bold;
}
.button-play-gray:focus {
box-shadow:
#474440 0 0 0 1.5px inset,
rgba(113, 108, 104, .5) 0 0 22px,
rgba(52, 49, 47, .35) 0 2px 4px,
rgba(52, 49, 47, .28) 0 7px 13px -3px,
#474440 0 -3px 0 inset;
}
.button-play-gray:hover {
box-shadow:
rgba(113, 108, 104, .58) 0 0 24px,
rgba(52, 49, 47, .35) 0 4px 8px,
rgba(52, 49, 47, .28) 0 7px 13px -3px,
#474440 0 -3px 0 inset;
transform: translateY(-2px);
}
.button-play-gray:active {
box-shadow: #474440 0 3px 7px inset, rgba(113, 108, 104, .28) 0 0 12px;
transform: translateY(2px);
}
.liquid-glass-bg {
position: relative;
overflow: hidden;
@@ -71,25 +135,3 @@
box-sizing: border-box;
font-weight: normal;
}
.progress-bar {
width: 100%;
height: 10px;
margin: 10px 0 8px;
overflow: hidden;
border-radius: 999px;
background: linear-gradient(180deg, rgba(18, 14, 10, 0.92) 0%, rgba(34, 22, 14, 0.96) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45);
}
.progress-bar__fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #fe9c00 0%, #fa6d02 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 220, 160, 0.35),
0 0 8px rgba(254, 156, 0, 0.55),
0 0 16px rgba(250, 109, 2, 0.4),
0 0 24px rgba(250, 109, 2, 0.22);
}

View File

@@ -1,5 +1,9 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export * from './request'
export * from './tool'

15
src/lib/query.ts Normal file
View File

@@ -0,0 +1,15 @@
import {QueryClient} from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
gcTime: 0,
refetchOnWindowFocus: false,
},
mutations: {
retry: false,
},
},
})

10
src/lib/queryKeys.ts Normal file
View File

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

183
src/lib/request.ts Normal file
View File

@@ -0,0 +1,183 @@
import ky, {HTTPError, type Input, type KyInstance, type Options} from 'ky'
import {notifyError, resolveToastMessage} from '@/features/notifications'
import {useUserStore} from '@/store/user.ts'
type RequestMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
type ResponseType = 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'response'
type TokenFormatter = (token: string) => string
export type RequestResult<T, R extends ResponseType = 'json'> =
R extends 'text' ? string
: R extends 'blob' ? Blob
: R extends 'arrayBuffer' ? ArrayBuffer
: R extends 'formData' ? FormData
: R extends 'response' ? Response
: T
export type RequestConfig<R extends ResponseType = 'json'> = Omit<Options, 'hooks' | 'method'> & {
responseType?: R
}
type ApiEnvelope = {
code: number
msg?: string
}
export class RequestError<T = unknown> extends Error {
status?: number
data?: T
constructor(message: string, options?: {status?: number; data?: T; cause?: unknown}) {
super(message, {cause: options?.cause})
this.name = 'RequestError'
this.status = options?.status
this.data = options?.data
}
}
const isApiEnvelope = (value: unknown): value is ApiEnvelope =>
typeof value === 'object' &&
value !== null &&
'code' in value &&
typeof (value as {code?: unknown}).code === 'number'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/'
const REQUEST_TIMEOUT = 10_000
let accessTokenFormatter: TokenFormatter = (token) => `Bearer ${token}`
export const setAccessTokenFormatter = (formatter?: TokenFormatter) => {
accessTokenFormatter = formatter ?? ((token) => `Bearer ${token}`)
}
const requestClient = ky.create({
baseUrl: API_BASE_URL,
timeout: REQUEST_TIMEOUT,
retry: 0,
headers: {
lang: 'zh',
},
hooks: {
beforeRequest: [
({request}) => {
const token = useUserStore.getState().userInfo?.token
if (!token) {
return
}
request.headers.set('Authorization', accessTokenFormatter(token))
},
],
beforeError: [
async ({error}) => {
if (error instanceof HTTPError) {
const message = resolveToastMessage(
error.data,
typeof error.data === 'string'
? error.data
: error.response.statusText || 'Request failed',
)
notifyError(message)
return new RequestError(message, {
status: error.response.status,
data: error.data,
cause: error,
})
}
const message = error.message || 'Network request failed'
notifyError(message)
return new RequestError(message, {
cause: error,
})
},
],
},
})
const isEmptyResponse = (response: Response) =>
response.status === 204 ||
response.status === 205 ||
response.headers.get('content-length') === '0'
async function parseResponse<T, R extends ResponseType>(
response: Response,
responseType: R,
): Promise<RequestResult<T, R>> {
if (responseType === 'response') {
return response as RequestResult<T, R>
}
if (isEmptyResponse(response)) {
return undefined as RequestResult<T, R>
}
switch (responseType) {
case 'text':
return (await response.text()) as RequestResult<T, R>
case 'blob':
return (await response.blob()) as RequestResult<T, R>
case 'arrayBuffer':
return (await response.arrayBuffer()) as RequestResult<T, R>
case 'formData':
return (await response.formData()) as RequestResult<T, R>
case 'json':
default:
{
const payload = await response.json()
if (isApiEnvelope(payload) && payload.code !== 1 && payload.code !== 200) {
const message = payload.msg?.trim() || 'Request failed'
notifyError(message)
throw new RequestError(message, {
status: response.status,
data: payload,
})
}
return payload as RequestResult<T, R>
}
}
}
async function request<T = unknown, R extends ResponseType = 'json'>(
method: RequestMethod,
input: Input,
config?: RequestConfig<R>,
): Promise<RequestResult<T, R>> {
const responseType = (config?.responseType ?? 'json') as R
const options = config ? {...config} : {}
delete options.responseType
const response = await requestClient(input, {
...options,
method,
})
return parseResponse<T, R>(response, responseType)
}
export const http = {
get: <T = unknown, R extends ResponseType = 'json'>(input: Input, config?: RequestConfig<R>): Promise<RequestResult<T, R>> =>
request<T, R>('get', input, config),
post: <T = unknown, R extends ResponseType = 'json'>(input: Input, config?: RequestConfig<R>): Promise<RequestResult<T, R>> =>
request<T, R>('post', input, config),
put: <T = unknown, R extends ResponseType = 'json'>(input: Input, config?: RequestConfig<R>): Promise<RequestResult<T, R>> =>
request<T, R>('put', input, config),
patch: <T = unknown, R extends ResponseType = 'json'>(input: Input, config?: RequestConfig<R>): Promise<RequestResult<T, R>> =>
request<T, R>('patch', input, config),
delete: <T = unknown, R extends ResponseType = 'json'>(input: Input, config?: RequestConfig<R>): Promise<RequestResult<T, R>> =>
request<T, R>('delete', input, config),
request: <T = unknown, R extends ResponseType = 'json'>(
method: RequestMethod,
input: Input,
config?: RequestConfig<R>,
): Promise<RequestResult<T, R>> => request<T, R>(method, input, config),
create: (options?: Options): KyInstance => requestClient.extend(options ?? {}),
}
export {requestClient}
export default http

28
src/lib/tool.ts Normal file
View File

@@ -0,0 +1,28 @@
type FormDataValue = string | number | boolean | Blob
type FormDataRecordValue =
| FormDataValue
| null
| undefined
| FormDataValue[]
export function objectToFormData(data: Record<string, FormDataRecordValue>): FormData {
const formData = new FormData()
Object.entries(data).forEach(([key, value]) => {
if (value == null) {
return
}
if (Array.isArray(value)) {
value.forEach((item) => {
formData.append(key, item instanceof Blob ? item : String(item))
})
return
}
formData.append(key, value instanceof Blob ? value : String(value))
})
return formData
}

View File

@@ -1,10 +1,14 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { queryClient } from '@/lib/query.ts'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)

162
src/mock/index.ts Normal file
View File

@@ -0,0 +1,162 @@
import type {
AccountTableRow,
AddressOption,
AddAddressForm,
OrderRecord,
PointsRecord,
PointsRecordTone,
} from '@/types'
export const accountAddressRowsMock: AccountTableRow[] = [
{
name: 'Jia Jun',
phone: '+86 138 0000 1288',
address: 'No. 88 Century Avenue, Pudong New Area, Shanghai',
code: '200120',
action: 'Edit',
setting: 'Default',
},
{
name: 'Alicia Tan',
phone: '+65 9123 4567',
address: '18 Robinson Road, Singapore',
code: '048547',
action: 'Edit',
setting: 'Optional',
},
{
name: 'Marcus Lee',
phone: '+60 12 778 9911',
address: '27 Jalan Bukit Bintang, Kuala Lumpur',
code: '55100',
action: 'Edit',
setting: 'Optional',
},
]
export const initialAddressOptionsMock: AddressOption[] = [
{
id: 'address-shanghai',
name: 'Jia Jun',
phone: '+86 138 0000 1288',
address: 'No. 88 Century Avenue, Pudong New Area, Shanghai',
isDefault: true,
},
{
id: 'address-singapore',
name: 'Alicia Tan',
phone: '+65 9123 4567',
address: '18 Robinson Road, Singapore',
},
{
id: 'address-kuala-lumpur',
name: 'Marcus Lee',
phone: '+60 12 778 9911',
address: '27 Jalan Bukit Bintang, Kuala Lumpur',
},
]
export const emptyAddressFormMock: AddAddressForm = {
name: '',
phone: '',
region: [],
detailedAddress: '',
isDefault: false,
}
export const orderRecordsMock: OrderRecord[] = [
{
id: 'order-1',
date: '2025-03-04',
time: '10:20',
category: 'Bonus',
title: 'Daily Rebate 50',
status: 'Issued',
points: '-500 points',
},
{
id: 'order-2',
date: '2025-03-03',
time: '14:00',
category: 'Physical',
title: 'Weekly Bonus 200',
trackingNumber: 'SF1234567890',
status: 'Shipped',
points: '-1200 points',
},
{
id: 'order-3',
date: '2025-03-02',
time: '09:15',
category: 'Withdrawal',
title: 'Wireless Earbuds',
status: 'Issued',
points: '-1000 points',
},
{
id: 'order-4',
date: '2025-03-01',
time: '16:30',
category: 'Bonus',
title: 'Fitness Tracker',
status: 'Pending',
points: '-1800 points',
},
{
id: 'order-5',
date: '2025-02-28',
time: '11:00',
category: 'Physical',
title: 'Withdraw 100',
status: 'Rejected',
points: '-2500 points',
},
]
export const pointsRecordsMock: PointsRecord[] = [
{
id: 'points-1',
title: 'Bonus Redemption - Daily Rewards 50',
date: '2025-03-04',
time: '10:20',
amount: '-500',
tone: 'negative',
},
{
id: 'points-2',
title: "Claim Yesterday's Protection Funds",
date: '2025-03-04',
time: '09:20',
amount: '+800',
tone: 'positive',
},
{
id: 'points-3',
title: 'Physical Item Redemption - Bluetooth Headphones',
date: '2025-03-03',
time: '14:00',
amount: '-1200',
tone: 'negative',
},
{
id: 'points-4',
title: "Claim Yesterday's Protection Funds",
date: '2025-03-03',
time: '09:00',
amount: '+700',
tone: 'positive',
},
{
id: 'points-5',
title: 'Withdraw to Platform - 100',
date: '2025-03-02',
time: '09:15',
amount: '-1000',
tone: 'negative',
},
]
export const pointsRecordToneClassNameMock: Record<PointsRecordTone, string> = {
positive: 'bg-[#9BFFC0] text-[#176640]',
negative: 'bg-[#FF9BA4] text-[#7B2634]',
}

38
src/store/user.ts Normal file
View File

@@ -0,0 +1,38 @@
import {create} from 'zustand'
import {createJSONStorage, persist} from 'zustand/middleware'
import type {LoginUserInfo, ValidateTokenData} from '@/types/auth.type.ts'
import type {UserAssetsData} from '@/types/user.type.ts'
type UserState = {
userInfo: LoginUserInfo | null
setUserInfo: (userInfo: LoginUserInfo) => void
authInfo: ValidateTokenData | null
setAuthInfo: (authInfo: ValidateTokenData) => void
assetsInfo: UserAssetsData | null
setAssetsInfo: (assetsInfo: UserAssetsData) => void
clearUserInfo: () => void
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
userInfo: null,
setUserInfo: (userInfo) => set({userInfo}),
authInfo: null,
setAuthInfo: (authInfo) => set({authInfo}),
assetsInfo: null,
setAssetsInfo: (assetsInfo) => set({assetsInfo}),
clearUserInfo: () => set({userInfo: null, authInfo: null, assetsInfo: null}),
}),
{
name: 'playx-user',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
userInfo: state.userInfo,
authInfo: state.authInfo,
assetsInfo: state.assetsInfo,
}),
},
),
)

37
src/types/address.type.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface AddressInfo {
/**@description 收件人姓名 */
receiver_name: string,
/**@description 电话 */
phone: string,
/**@description 地区(数组或逗号分隔字符串) */
region: string,
/**@description 详细地址 */
detail_address: string,
/**@description 1 设为默认地址 */
default_setting: string,
}
export type AddressListItem = {
id: number
phone: string
region: string[]
detail_address: string
default_setting: 0 | 1
create_time: number
update_time: number
playx_user_asset_id: number
receiver_name: string
region_text: string
}
export type AddressListData = {
list: AddressListItem[]
}
export type AddressListResponse = ApiResponse<AddressListData>
export type AddressAddData = {
id: number
}
export type AddressAddResponse = ApiResponse<AddressAddData>

31
src/types/auth.type.ts Normal file
View File

@@ -0,0 +1,31 @@
export type LoginParams = {
username?: string
}
export type LoginUserInfo = {
id: number
username: string
nickname: string
playx_user_id: string
token: string
refresh_token: string
expires_in: number
}
export type LoginData = {
userInfo: LoginUserInfo
}
export type LoginResponse = ApiResponse<LoginData>
export interface ValidateTokenData {
session_id:string;
user_id:string;
name:string;
token_expire_at:string;
}
export type ValidateTokenResponse = ApiResponse<ValidateTokenData>

108
src/types/business.type.ts Normal file
View File

@@ -0,0 +1,108 @@
export type goodsType = 'BONUS' | 'PHYSICAL' | 'WITHDRAW'
export type GoodsItem = {
id: number
title: string
description: string
remark: string
score: number
amount: number
multiplier: number
category: string
category_title: string
type: string
image: string
stock: number | null
admin_id: number
sort: number
create_time: number
update_time: number
status: number
}
export type GoodsListData = {
list: GoodsItem[]
}
export type GoodListResponse = ApiResponse<GoodsListData>
export type OrderItem = {
id: number | string
user_id?: string
order_no?: string
external_transaction_id?: string
playx_transaction_id?: string
type?: goodsType | string
type_title?: string
category?: string
category_title?: string
title?: string
item_title?: string
mall_item_id?: number | string | null
mall_address_id?: number | string | null
points_cost?: number | string
amount?: number | string
multiplier?: number | string
tracking_no?: string | null
logistics_no?: string | null
shipping_company?: string | null
shipping_no?: string | null
receiver_name?: string | null
receiver_phone?: string | null
receiver_address?: string | null
fail_reason?: string | null
reject_reason?: string | null
grant_status?: string | null
retry_count?: number
status?: string | number
score?: number | string
points?: number | string
create_time?: number | string
created_at?: string
update_time?: number | string
start_time?: number | string | null
end_time?: number | string | null
mallItem?: Partial<GoodsItem> | null
}
export type OrdersData = {
list: OrderItem[]
}
export type OrdersResponse = ApiResponse<OrdersData>
export type PointsLogItem = {
id: number | string
biz_type?: string
direction?: 'IN' | 'OUT' | string
ts?: number | string
ref_id?: string
order_no?: string
order_status?: string
item_id?: number | string | null
item_type?: string
item_score?: number | string
cursor?: string
type?: string
title?: string
item_title?: string
description?: string
remark?: string
amount?: number | string
points?: number | string
score?: number | string
points_cost?: number | string
points_change?: number | string
change_points?: number | string
create_time?: number | string
created_at?: string
update_time?: number | string
mallItem?: Partial<GoodsItem> | null
}
export type PointsLogsData = {
list: PointsLogItem[]
next_cursor?: string | null
}
export type PointsLogsResponse = ApiResponse<PointsLogsData>

6
src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
type ApiResponse<T> = {
code: number
msg: string
time: number
data: T
}

View File

@@ -1 +1,139 @@
import type { ButtonHTMLAttributes, PropsWithChildren, ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import type {goodsType} from './business.type.ts'
export type RecordButtonType = 'order' | 'record'
export type HostContextMessage = {
type: 'IFRAME_CONTEXT'
payload?: {
token?: string
language?: string
}
}
export type ButtonVariant = 'orange' | 'gray'
export type ButtonProps = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>> & {
variant?: ButtonVariant
}
export type ModalProps = {
open: boolean
title?: ReactNode
children: ReactNode
footer?: ReactNode
onClose?: () => void
closeOnOverlayClick?: boolean
className?: string
bodyClassName?: string
}
export type PageLayoutProps = {
children: ReactNode
contentClassName?: string
}
export type TableColumn<T extends Record<string, string>> = {
label: string
key: keyof T
render?: (value: T[keyof T], record: T, index: number) => ReactNode
}
export type BorderlessTableProps<T extends Record<string, string>> = {
columns: TableColumn<T>[]
dataSource: T[]
}
export type QuickNavCardProps = {
icon: LucideIcon
label: string
to: string
}
export type ProductItem = {
id: string
title: string
subtitle: string
score: string
ctaLabel: string
imageClassName?: string
imageUrl?: string
}
export type ProductCategory = {
id: goodsType
name: string
items: ProductItem[]
}
export type SelectedProductState = {
categoryId: ProductCategory['id']
product: ProductItem
}
export type AddressOption = {
id: string
name: string
phone: string
address: string
isDefault?: boolean
}
export type ModalMode = 'select-address' | 'add-address'
export type AddAddressForm = {
name: string
phone: string
region: string[]
detailedAddress: string
isDefault: boolean
}
export type AccountTableRow = {
name: string
phone: string
address: string
code: string
action: string
setting: string
}
export type PointsRecordTone = 'positive' | 'negative'
export type OrderRecord = {
id: string
orderNumber?: string
date: string
time: string
category: string
title: string
trackingNumber?: string
status: string
points: string
}
export type PointsRecord = {
id: string
title: string
date: string
time: string
amount: string
tone: PointsRecordTone
}
export type TabButtonProps = {
active: boolean
label: string
icon: LucideIcon
onClick: () => void
}
export type OrderCardProps = {
record: OrderRecord
onOpenDetails: (record: OrderRecord) => void
}
export type PointsCardProps = {
record: PointsRecord
}

18
src/types/user.type.ts Normal file
View File

@@ -0,0 +1,18 @@
export type UserAssetsParams = {
session_id?: string
}
export interface UserAssetsData {
/**@description 可用积分 */
available_points: number;
/**@description 待领取积分 */
locked_points: number;
/**@description 今日已领取 */
today_claimed: number;
/**@description 今日可领取上限 */
today_limit: number;
/**@description 可提现现金(积分按配置比例换算) */
withdrawable_cash: number;
}
export type UserAssetsResponse = ApiResponse<UserAssetsData>

View File

@@ -1,98 +1,289 @@
import PageLayout from '@/components/layout'
import BorderlessTable, { type TableColumn } from '@/components/table'
import { Link } from 'react-router-dom'
import {useState} from 'react'
type AccountTableRow = {
import PageLayout from '@/components/layout'
import BorderlessTable from '@/components/table'
import Modal from '@/components/modal'
import Button from '@/components/button'
import {Link} from 'react-router-dom'
import {ArrowLeft, BadgeCheck, MapPinHouse, PencilLine, Plus, Trash2} from 'lucide-react'
import {useAddressBook} from '@/features/addressBook'
import {GoodsRedeemModal} from '@/features/goods'
import {notifySuccess} from '@/features/notifications'
import type {AddressListItem} from '@/types/address.type.ts'
import type {TableColumn} from '@/types'
type AddressTableRow = {
id: string
name: string
phone: string
address: string
code: string
action: string
setting: string
}
function AccountPage() {
const columns: TableColumn<AccountTableRow>[] = [
const addressBook = useAddressBook({autoLoad: true})
const [addressModalOpen, setAddressModalOpen] = useState(false)
const [editingAddress, setEditingAddress] = useState<AddressListItem | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AddressListItem | null>(null)
const rows: AddressTableRow[] = addressBook.addresses.map((item) => ({
id: String(item.id),
name: item.receiver_name,
phone: item.phone,
address: addressBook.addressOptions.find((option) => option.id === String(item.id))?.address ?? '',
action: 'Edit',
setting: item.default_setting === 1 ? 'Default' : 'Optional',
}))
const isAddressFormValid = addressBook.isAddressFormValid
const handleOpenAddAddress = () => {
setEditingAddress(null)
addressBook.resetAddressForm()
setAddressModalOpen(true)
}
const handleOpenEditAddress = (address: AddressListItem) => {
setEditingAddress(address)
addressBook.fillAddressForm(address)
setAddressModalOpen(true)
}
const handleCloseAddressModal = () => {
setAddressModalOpen(false)
setEditingAddress(null)
addressBook.resetAddressForm()
}
const handleSubmitAddress = async () => {
const saved = await addressBook.saveAddress(editingAddress)
if (saved) {
handleCloseAddressModal()
notifySuccess(saved.response, editingAddress ? 'Address updated successfully.' : 'Address added successfully.')
}
}
const handleConfirmDelete = async () => {
if (!deleteTarget) {
return
}
const deleted = await addressBook.removeAddress(String(deleteTarget.id))
if (deleted) {
setDeleteTarget(null)
notifySuccess(deleted, 'Address deleted successfully.')
}
}
const columns: TableColumn<AddressTableRow>[] = [
{
label: 'Name',
key: 'name',
render: (value: string) => <div>{value}</div>
render: (value: string) => <div className="font-medium text-white">{value}</div>,
},
{
label: 'Phone / Mobile',
key: 'phone',
render: (value: string) => <div>{value}</div>
render: (value: string) => <div className="text-white/72">{value}</div>,
},
{
label: 'Address',
key: 'address',
render: (value: string) => <div>{value}</div>
},
{
label: 'Postal Code',
key: 'code',
render: (value: string) => <div>{value}</div>
render: (value: string) => <div className="max-w-[280px] text-white/72">{value}</div>,
},
{
label: 'Action',
key: 'action',
render: (value: string) => <div>{value}</div>
render: (_value: string, _record: AddressTableRow, index: number) => (
<div className="flex items-center gap-[8px]">
<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(addressBook.addresses[index])}
>
<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(addressBook.addresses[index])}
>
<Trash2 className="h-[12px] w-[12px]" aria-hidden="true" />
Delete
</button>
</div>
),
},
{
label: 'Default Setting',
key: 'setting',
render: (value: string) => <div>{value}</div>
}
]
const dataSource: AccountTableRow[] = [
{
name: 'Jia Jun',
phone: '+86 138 0000 1288',
address: 'No. 88 Century Avenue, Pudong New Area, Shanghai',
code: '200120',
action: 'Edit',
setting: 'Default',
},
{
name: 'Alicia Tan',
phone: '+65 9123 4567',
address: '18 Robinson Road, Singapore',
code: '048547',
action: 'Edit',
setting: 'Optional',
},
{
name: 'Marcus Lee',
phone: '+60 12 778 9911',
address: '27 Jalan Bukit Bintang, Kuala Lumpur',
code: '55100',
action: 'Edit',
setting: 'Optional',
render: (value: string) => (
<div
className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${
value === 'Default'
? 'bg-[#FA6A00]/14 text-[#FFB36D]'
: 'bg-white/6 text-white/62'
}`}
>
{value}
</div>
),
},
]
return (
<PageLayout contentClassName="min-h-screen">
<PageLayout contentClassName="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col px-4 pb-8 sm:px-6 lg:px-8">
<Link
to="/"
className={'relative text-[#F56E10] flex h-[40px] w-full items-center justify-center bg-[#08070E]/70'}
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={'absolute left-[16px]'}> &lt; </div>
<div>Account</div>
<div className="flex items-center gap-[8px]">
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
<span className="text-[14px] font-medium text-white/92">Back</span>
</div>
<div className="text-[15px] font-semibold text-[#F56E10]">Account</div>
<div className="w-[52px]"></div>
</Link>
<div className={'mt-[20px] w-full flex items-center justify-center'}>
<div className={'w-[80%]'}>
<div className={'w-full mb-[10px] flex items-center justify-between'}>
<div>My Shipping Address</div>
<div className={'liquid-glass-bg px-[10px] py-[5px] text-sm'}>Add Address</div>
<div className="mx-auto mt-[20px] w-full max-w-[1000px]">
<div className="mb-[14px] flex flex-col gap-[12px] sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-[10px]">
<div className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/15 text-[#FE9F00]">
<MapPinHouse className="h-[18px] w-[18px]" aria-hidden="true" />
</div>
<div>
<div className="text-[16px] font-semibold text-white">My Shipping Address</div>
</div>
</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>
<BorderlessTable columns={columns} dataSource={dataSource} />
{addressBook.loading ? (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
Loading address list...
</div>
) : !addressBook.addresses.length ? (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
No shipping address found. Add one to start redeeming physical rewards.
</div>
) : (
<>
<div className="space-y-[12px] lg:hidden">
{rows.map((item, index) => (
<div key={item.id} className="liquid-glass-bg p-[14px]">
<div className="flex items-start justify-between gap-[12px]">
<div>
<div className="text-[16px] font-semibold text-white">{item.name}</div>
<div className="mt-[4px] text-[13px] text-white/62">{item.phone}</div>
</div>
<div
className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${
item.setting === 'Default'
? 'bg-[#FA6A00]/14 text-[#FFB36D]'
: 'bg-white/6 text-white/62'
}`}
>
{item.setting === 'Default' ? (
<span className="inline-flex items-center gap-[5px]">
<BadgeCheck className="h-[12px] w-[12px]" aria-hidden="true" />
{item.setting}
</span>
) : item.setting}
</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">{item.address}</div>
</div>
<button
type="button"
className="mt-[12px] 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(addressBook.addresses[index])}
>
<PencilLine className="h-[12px] w-[12px]" aria-hidden="true" />
Edit
</button>
<button
type="button"
className="mt-[10px] 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(addressBook.addresses[index])}
>
<Trash2 className="h-[12px] w-[12px]" aria-hidden="true" />
Delete
</button>
</div>
))}
</div>
<div className="hidden lg:block">
<BorderlessTable columns={columns} dataSource={rows} />
</div>
</>
)}
</div>
<GoodsRedeemModal
selectedProduct={null}
modalMode="add-address"
addressOptions={[]}
selectedAddressId=""
addressForm={addressBook.addressForm}
addressLoading={false}
isAddAddressFormValid={isAddressFormValid}
submitLoading={addressBook.submitLoading}
onClose={handleCloseAddressModal}
onConfirm={handleSubmitAddress}
onOpenAddAddress={handleOpenAddAddress}
onBackToSelectAddress={handleCloseAddressModal}
onSelectAddress={() => {}}
onChangeAddressForm={addressBook.changeAddressForm}
forceOpen={addressModalOpen}
formOnly
titleOverride={editingAddress ? 'Edit Shipping Address' : 'Add Shipping Address'}
confirmText={editingAddress ? 'Save Changes' : 'Add Address'}
/>
<Modal
open={Boolean(deleteTarget)}
title="Delete Address"
onClose={() => setDeleteTarget(null)}
className="max-w-[420px]"
bodyClassName="space-y-[18px]"
footer={
<>
<Button
type="button"
variant="gray"
className="h-[38px] w-full sm:w-auto sm:min-w-[120px]"
onClick={() => setDeleteTarget(null)}
disabled={addressBook.deleteLoading}
>
Cancel
</Button>
<Button
type="button"
className="h-[38px] w-full sm:w-auto sm:min-w-[120px]"
onClick={handleConfirmDelete}
disabled={addressBook.deleteLoading}
>
{addressBook.deleteLoading ? 'Deleting...' : 'Delete'}
</Button>
</>
}
>
<div className="rounded-[12px] bg-[#1C1818]/82 px-[14px] py-[18px] text-[15px] leading-[1.65] text-white/92 shadow-[0_10px_30px_rgba(0,0,0,0.2)]">
{deleteTarget ? `Delete the address for ${deleteTarget.receiver_name}? ` : ''}
</div>
</Modal>
</PageLayout>
)
}

68
src/views/goods/index.tsx Normal file
View File

@@ -0,0 +1,68 @@
import {ArrowLeft} from 'lucide-react'
import {Link, useSearchParams} from 'react-router-dom'
import PageLayout from '@/components/layout'
import {HOME_GOOD_TYPE_ORDER} from '@/constant'
import {
GoodsCategoryList,
GoodsRedeemModal,
isGoodsType,
useGoodsCatalog,
useGoodsRedeem,
} from '@/features/goods'
function GoodsPage() {
const [searchParams] = useSearchParams()
const queryType = searchParams.get('type')
const selectedType = isGoodsType(queryType) ? queryType : HOME_GOOD_TYPE_ORDER[0]
const {productCategories, loading} = useGoodsCatalog({types: [selectedType]})
const redeem = useGoodsRedeem()
const visibleCategories = productCategories.filter((category) => category.id === selectedType)
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">
<Link
to="/"
className="mt-[12px] flex h-[44px] items-center justify-between rounded-[12px] bg-[#08070E]/72 px-[14px] text-[#F56E10] transition-colors hover:bg-[#0D0A14]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E] sm:mt-[16px]"
>
<div className="flex items-center gap-[8px]">
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true"/>
<span className="text-[14px] font-medium text-white/92">Back</span>
</div>
<div className="text-[15px] font-semibold text-[#F56E10]">{queryType}</div>
<div className="w-[52px]"></div>
</Link>
<div className="mx-auto w-full max-w-[1120px] pt-[18px] pb-[24px]">
<div className="h-px bg-white/16"></div>
<div className="mt-[14px]">
<GoodsCategoryList
categories={visibleCategories}
loading={loading}
emptyText="No goods found for this category."
onRedeem={redeem.openRedeemModal}
/>
</div>
</div>
<GoodsRedeemModal
selectedProduct={redeem.selectedProduct}
modalMode={redeem.modalMode}
addressOptions={redeem.addressOptions}
selectedAddressId={redeem.selectedAddressId}
addressForm={redeem.addressForm}
addressLoading={redeem.addressLoading}
isAddAddressFormValid={redeem.isAddAddressFormValid}
submitLoading={redeem.submitLoading}
onClose={redeem.closeRedeemModal}
onConfirm={redeem.confirmRedeem}
onOpenAddAddress={redeem.openAddAddress}
onBackToSelectAddress={redeem.backToSelectAddress}
onSelectAddress={redeem.setSelectedAddressId}
onChangeAddressForm={redeem.changeAddressForm}
/>
</PageLayout>
)
}
export default GoodsPage

View File

@@ -1,258 +1,87 @@
import {useState} from 'react'
import recordSvg from '@/assets/record.svg'
import accountSvg from '@/assets/account.svg'
import {useMutation} from '@tanstack/react-query'
import PageLayout from '@/components/layout'
import Modal from '@/components/modal'
import { Link } from 'react-router-dom'
import Button from '@/components/button'
import {Link, useNavigate} from 'react-router-dom'
import {
ChevronRight,
Coins,
Gauge,
History,
UserRound,
Wallet,
} from 'lucide-react'
import type {
ProductCategory,
QuickNavCardProps,
} from '@/types'
import {
GoodsCategoryList,
GoodsRedeemModal,
useAssetsQuery,
useAssetsRefresh,
useGoodsCatalog,
useGoodsRedeem,
} from '@/features/goods'
import {validateClaimSubmission} from '@/features/home/claimValidation'
import {claim} from '@/api/business.ts'
import {notifyError, notifySuccess} from '@/features/notifications'
import {useUserStore} from "@/store/user.ts";
type QuickNavCardProps = {
icon: string
label: string
to: string
}
type ProductItem = {
id: string
title: string
subtitle: string
points: string
ctaLabel: string
imageClassName: string
}
type ProductCategory = {
id: string
name: string
items: ProductItem[]
}
type SelectedProductState = {
categoryId: ProductCategory['id']
product: ProductItem
}
type AddressOption = {
id: string
name: string
phone: string
address: string
postalCode: string
isDefault?: boolean
}
type ModalMode = 'select-address' | 'add-address'
type AddAddressForm = {
name: string
phone: string
region: string
detailedAddress: string
postalCode: string
isDefault: boolean
}
function QuickNavCard({ icon, label, to }: QuickNavCardProps) {
function QuickNavCard({icon: Icon, label, to}: QuickNavCardProps) {
return (
<Link
to={to}
className={'liquid-glass-bg rounded-[10px] flex items-center pr-[10px] cursor-pointer'}
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]"
>
<div className={'flex px-[10px] py-[5px] items-center gap-[5px]'}>
<div className={'p-[5px] rounded-[10px] bg-linear-to-b from-[#FB8001] to-[#FCAA2C]'}>
<img src={icon} className={'w-[16px] h-[16px]'} />
<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)]">
<Icon className="h-[16px] w-[16px] shrink-0" aria-hidden="true"/>
</div>
<div>{label}</div>
<div className="truncate font-medium capitalize">{label}</div>
</div>
<div>&gt;</div>
<ChevronRight className="h-[16px] w-[16px] shrink-0 text-white/70" aria-hidden="true"/>
</Link>
)
}
const productCategories: ProductCategory[] = [
{
id: 'transfer-to-platform',
name: 'Transfer to Platform',
items: [
{
id: 'transfer-100',
title: 'Transfer 100',
subtitle: '1 x Turnover',
points: '1000 Points',
ctaLabel: 'Transfer Now',
imageClassName: 'bg-[linear-gradient(135deg,_#5B1D00_0%,_#FA6A00_55%,_#FFBC6D_100%)]',
},
{
id: 'transfer-300',
title: 'Transfer 300',
subtitle: '2 x Turnover',
points: '2800 Points',
ctaLabel: 'Transfer Now',
imageClassName: 'bg-[linear-gradient(135deg,_#1C1A48_0%,_#5050D8_55%,_#8AB6FF_100%)]',
},
{
id: 'transfer-500',
title: 'Transfer 500',
subtitle: '3 x Turnover',
points: '4200 Points',
ctaLabel: 'Transfer Now',
imageClassName: 'bg-[linear-gradient(135deg,_#153A35_0%,_#1F9D8B_55%,_#8BF3D8_100%)]',
},
{
id: 'transfer-1000',
title: 'Transfer 1000',
subtitle: '5 x Turnover',
points: '7800 Points',
ctaLabel: 'Transfer Now',
imageClassName: 'bg-[linear-gradient(135deg,_#421515_0%,_#C93D3D_52%,_#FF9B9B_100%)]',
},
],
},
{
id: 'game-bonus',
name: 'Game Bonus',
items: [
{
id: 'bonus-spin',
title: 'Lucky Spin Pack',
subtitle: 'Bonus Voucher',
points: '850 Points',
ctaLabel: 'Redeem Bonus',
imageClassName: 'bg-[linear-gradient(135deg,_#28103D_0%,_#8E3DD1_50%,_#F1A8FF_100%)]',
},
{
id: 'bonus-chip',
title: 'Chip Booster',
subtitle: 'Casino Special',
points: '1600 Points',
ctaLabel: 'Redeem Bonus',
imageClassName: 'bg-[linear-gradient(135deg,_#2D2407_0%,_#C79200_52%,_#FFE58A_100%)]',
},
{
id: 'bonus-cashback',
title: 'Cashback Card',
subtitle: 'Weekly Reward',
points: '2400 Points',
ctaLabel: 'Redeem Bonus',
imageClassName: 'bg-[linear-gradient(135deg,_#062A2F_0%,_#1296A5_52%,_#6DE2F0_100%)]',
},
{
id: 'bonus-vip',
title: 'VIP Match Bonus',
subtitle: 'Limited Access',
points: '5000 Points',
ctaLabel: 'Redeem Bonus',
imageClassName: 'bg-[linear-gradient(135deg,_#35120A_0%,_#DD6C2F_52%,_#FFC09A_100%)]',
},
],
},
{
id: 'physical-prizes',
name: 'Physical Prizes',
items: [
{
id: 'prize-headset',
title: 'Gaming Headset',
subtitle: 'Physical Delivery',
points: '6800 Points',
ctaLabel: 'Claim Prize',
imageClassName: 'bg-[linear-gradient(135deg,_#111827_0%,_#374151_50%,_#A3AAB8_100%)]',
},
{
id: 'prize-mouse',
title: 'Wireless Mouse',
subtitle: 'Physical Delivery',
points: '4200 Points',
ctaLabel: 'Claim Prize',
imageClassName: 'bg-[linear-gradient(135deg,_#19212F_0%,_#3A5B84_50%,_#98B8E3_100%)]',
},
{
id: 'prize-speaker',
title: 'Bluetooth Speaker',
subtitle: 'Physical Delivery',
points: '7300 Points',
ctaLabel: 'Claim Prize',
imageClassName: 'bg-[linear-gradient(135deg,_#32120F_0%,_#A73F2F_50%,_#F0A28D_100%)]',
},
{
id: 'prize-keyboard',
title: 'Mechanical Keyboard',
subtitle: 'Physical Delivery',
points: '9500 Points',
ctaLabel: 'Claim Prize',
imageClassName: 'bg-[linear-gradient(135deg,_#142C17_0%,_#2B7A38_52%,_#8DE69B_100%)]',
},
],
},
]
const initialAddressOptions: AddressOption[] = [
{
id: 'address-shanghai',
name: 'Jia Jun',
phone: '+86 138 0000 1288',
address: 'No. 88 Century Avenue, Pudong New Area, Shanghai',
postalCode: '200120',
isDefault: true,
},
{
id: 'address-singapore',
name: 'Alicia Tan',
phone: '+65 9123 4567',
address: '18 Robinson Road, Singapore',
postalCode: '048547',
},
{
id: 'address-kuala-lumpur',
name: 'Marcus Lee',
phone: '+60 12 778 9911',
address: '27 Jalan Bukit Bintang, Kuala Lumpur',
postalCode: '55100',
},
]
const emptyAddressForm: AddAddressForm = {
name: '',
phone: '',
region: '',
detailedAddress: '',
postalCode: '',
isDefault: false,
function getProgressPercent(current = 0, total = 0) {
if (total <= 0) {
return 0
}
function getNumericValue(value: string) {
const matched = value.match(/\d+/)
return matched ? matched[0] : value
}
function getTurnoverRequirement(subtitle: string) {
const matched = subtitle.match(/\d+/)
return matched ? `${matched[0]}x` : subtitle
return Math.min((current / total) * 100, 100)
}
function HomePage() {
const [selectedProduct, setSelectedProduct] = useState<SelectedProductState | null>(null)
const [claimModalOpen, setClaimModalOpen] = useState(false)
const [modalMode, setModalMode] = useState<ModalMode>('select-address')
const [addressOptions, setAddressOptions] = useState<AddressOption[]>(initialAddressOptions)
const [selectedAddressId, setSelectedAddressId] = useState<string>(initialAddressOptions[0]?.id ?? '')
const [addressForm, setAddressForm] = useState<AddAddressForm>(emptyAddressForm)
const handleOpenRedeemModal = (product: ProductItem, categoryId: ProductCategory['id']) => {
setSelectedProduct({
product,
categoryId,
const navigate = useNavigate()
const authInfo = useUserStore(state => state.authInfo)
const {productCategories, loading} = useGoodsCatalog()
const {invalidateAssets} = useAssetsRefresh()
const redeem = useGoodsRedeem()
const claimMutation = useMutation({
mutationFn: async (claimRequestId: string) => {
return await claim({
claim_request_id: claimRequestId,
session_id: authInfo!.session_id,
})
},
})
const syncBalanceMutation = useMutation({
mutationFn: invalidateAssets,
})
setSelectedAddressId(addressOptions[0]?.id ?? '')
setModalMode('select-address')
setAddressForm(emptyAddressForm)
}
const handleCloseRedeemModal = () => {
setSelectedProduct(null)
setModalMode('select-address')
setAddressForm(emptyAddressForm)
}
const {assetsInfo} = useAssetsQuery()
const claimProgress = getProgressPercent(assetsInfo?.today_claimed, assetsInfo?.today_limit)
const previewCategories: ProductCategory[] = productCategories.map((category) => ({
...category,
items: category.items.slice(0, 4),
}))
const handleOpenClaimModal = () => {
setClaimModalOpen(true)
@@ -260,392 +89,158 @@ function HomePage() {
const handleCloseClaimModal = () => {
setClaimModalOpen(false)
claimMutation.reset()
}
const handleOpenAddAddress = () => {
setModalMode('add-address')
const handleSyncBalance = async () => {
try {
await syncBalanceMutation.mutateAsync()
notifySuccess('Balance synced successfully.')
} catch {
// request interceptor handles interface error toast
}
}
const handleChangeAddressForm = (field: keyof AddAddressForm, value: string | boolean) => {
setAddressForm((previous) => ({
...previous,
[field]: value,
}))
}
const isAddAddressFormValid = [
addressForm.name,
addressForm.phone,
addressForm.region,
addressForm.detailedAddress,
].every((value) => value.trim())
const handleConfirm = () => {
if (modalMode === 'add-address') {
if (!isAddAddressFormValid) {
const handleConfirmClaim = async () => {
const claimValidation = validateClaimSubmission(authInfo)
if (!claimValidation.valid) {
notifyError(claimValidation.message)
return
}
const newAddress: AddressOption = {
id: `address-${Date.now()}`,
name: addressForm.name.trim(),
phone: addressForm.phone.trim(),
address: `${addressForm.region.trim()}, ${addressForm.detailedAddress.trim()}`,
postalCode: addressForm.postalCode.trim() || 'N/A',
isDefault: addressForm.isDefault,
try {
const response = await claimMutation.mutateAsync(`${authInfo!.user_id}${Date.now()}`)
await invalidateAssets()
notifySuccess(response, 'Claim submitted successfully.')
setClaimModalOpen(false)
} catch {
// request errors are surfaced by the shared request toast
}
}
setAddressOptions((previous) => {
const normalizedPrevious = addressForm.isDefault
? previous.map((item) => ({ ...item, isDefault: false }))
: previous
return [...normalizedPrevious, newAddress]
})
setSelectedAddressId(newAddress.id)
setAddressForm(emptyAddressForm)
setModalMode('select-address')
return
const handleMoreClick = (type: ProductCategory['id']) => {
navigate(`/goods?type=${type}`)
}
handleCloseRedeemModal()
}
const selectedCategoryId = selectedProduct?.categoryId
const selectedProductData = selectedProduct?.product ?? null
const isPhysicalPrize = selectedCategoryId === 'physical-prizes'
const isTransferToPlatform = selectedCategoryId === 'transfer-to-platform'
const isGameBonus = selectedCategoryId === 'game-bonus'
const modalTitle = modalMode === 'add-address'
? 'Add Shipping Address'
: isTransferToPlatform
? 'Confirm Withdrawal'
: isGameBonus
? 'Confirm Bonus Redemption'
: 'Redeem Product'
const modalMaxWidthClassName = isTransferToPlatform
? 'max-w-[620px]'
: isGameBonus
? 'max-w-[460px]'
: 'max-w-[720px]'
return (
<PageLayout>
<div className={'flex justify-end gap-2 py-[10px]'}>
<QuickNavCard to="/record" icon={recordSvg} label="record" />
<QuickNavCard to="/account" icon={accountSvg} label="account" />
<div
className="grid grid-cols-2 gap-2 py-[14px] sm:ml-auto sm:flex sm:w-auto sm:grid-cols-none sm:justify-end">
<QuickNavCard to="/record" icon={History} label="record"/>
<QuickNavCard to="/account" icon={UserRound} label="account"/>
</div>
<div className="mt-[4px]">
<div className="flex flex-col gap-3 lg:flex-row lg:items-stretch">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:w-[544px] lg:shrink-0">
<div className="liquid-glass-bg flex min-h-[167px] flex-col justify-between p-[14px]">
<div className="flex items-start justify-between gap-[12px]">
<div>
<div className={'flex gap-[5px]'}>
<div className={'liquid-glass-bg h-[167px] w-[267px] p-[10px] flex flex-col justify-start'}>
<div>Claimable Points</div>
<div>2,880</div>
<div>Yesterday's losses
have been converted
to points.Claim to use.
<div className="text-[13px] uppercase tracking-[0.16em] text-white/58">Claimable
Points
</div>
</div>
<div className={'liquid-glass-bg h-[167px] w-[267px] p-[10px] flex flex-col justify-start'}>
<div>Daily Claim Limit</div>
<div>1,500</div>
<div
className={'progress-bar'}
className="mt-[10px] text-[34px] font-semibold leading-none text-white">{assetsInfo?.locked_points || 0}</div>
</div>
<div
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
<Coins className="h-[18px] w-[18px]" aria-hidden="true"/>
</div>
</div>
<div className="max-w-[28ch] text-[13px] leading-[1.6] text-white/68">
Yesterday&apos;s losses have been converted into points. Claim them to use in rewards.
</div>
</div>
<div className="liquid-glass-bg flex min-h-[167px] flex-col justify-around p-[14px]">
<div className="flex items-start justify-between gap-[12px]">
<div>
<div className="text-[13px] uppercase tracking-[0.16em] text-white/58">Daily Claim
Limit
</div>
<div
className="mt-[10px] text-[34px] font-semibold leading-none text-white">{assetsInfo?.locked_points}</div>
</div>
<div
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
<Gauge className="h-[18px] w-[18px]" aria-hidden="true"/>
</div>
</div>
<div
className="mt-[14px] h-[10px] w-full overflow-hidden rounded-full border border-white/8 bg-[linear-gradient(180deg,rgba(18,14,10,0.92)_0%,rgba(34,22,14,0.96)_100%)] shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]"
role="progressbar"
aria-valuemin={0}
aria-valuemax={1500}
aria-valuenow={800}
aria-valuemax={assetsInfo?.today_limit || 0}
aria-valuenow={assetsInfo?.today_claimed || 0}
>
<div className={'progress-bar__fill'} style={{ width: '53.33%' }}></div>
<div
className="h-full rounded-full bg-linear-to-r from-[#FE9C00] to-[#FA6D02] shadow-[inset_0_0_0_1px_rgba(255,220,160,0.35),0_0_8px_rgba(254,156,0,0.55),0_0_16px_rgba(250,109,2,0.4),0_0_24px_rgba(250,109,2,0.22)]"
style={{width: `${claimProgress}%`}}
></div>
</div>
<div
className="mt-[10px] text-[13px] text-white/68">Claimed: <span className={'text-[#FE9C00]'}>{assetsInfo?.today_claimed || 0}</span> / {assetsInfo?.today_limit || 0}</div>
</div>
<div>Claimed: 800 / 1500</div>
</div>
<div className={'flex flex-col gap-[5px]'}>
<div className={'liquid-glass-bg flex-1'}>
<div>Available for Withdrawal (Cash)</div>
<div>152 CNY</div>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div
className="liquid-glass-bg flex min-h-[109px] flex-col justify-between p-[14px] sm:p-[16px]">
<div className="flex items-start justify-between gap-[12px]">
<div>
<div className="text-[13px] uppercase tracking-[0.16em] text-white/58">Available for
Withdrawal
</div>
<div className={'h-[54px] w-[564px] liquid-glass-bg flex gap-[10px] p-[5px]'}>
<button className={'button-play flex-1'} onClick={handleOpenClaimModal}>
<div
className="mt-[10px] text-[32px] font-semibold leading-none text-white">{assetsInfo?.withdrawable_cash || 0} CNY</div>
</div>
<div
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
<Wallet className="h-[18px] w-[18px]" aria-hidden="true"/>
</div>
</div>
</div>
<div className="liquid-glass-bg grid grid-cols-2 gap-[10px] p-[5px]">
<Button className="h-[44px] w-full text-[13px]" onClick={handleOpenClaimModal}>
Claim Now
</button>
<button className={'button-play flex-1'}>Sync Balance</button>
</Button>
<Button
variant={'gray'}
className="h-[44px] w-full text-[13px]"
onClick={handleSyncBalance}
disabled={syncBalanceMutation.isPending}
>
{syncBalanceMutation.isPending ? 'Syncing...' : 'Sync Balance'}
</Button>
</div>
</div>
</div>
</div>
<div>
{
productCategories.map((category) => (<div key={category.id} className={'mt-[20px]'}>
<div className={'flex items-center justify-between mb-[5px]'}>
<div className={'font-bold text-[14px]'}>{category.name}</div>
<div
className={'text-[#FA6A00] text-[12px] font-light underline cursor-pointer'}>more
</div>
</div>
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4 xl:grid-cols-4'}>
{
category.items.map((product) => (
<div
key={product.id}
className={'liquid-glass-bg aspect-[215/260] w-full flex flex-col items-stretch justify-start'}>
<div className={`${product.imageClassName} w-full h-[40%] rounded-t-[10px]`}></div>
<div
className={'p-[10px] w-full flex-1 flex flex-col justify-around items-start'}>
<div>{product.title}</div>
<div className={'text-neutral-500'}>{product.subtitle}</div>
<div className={'text-[#FA6A00]'}>{product.points}</div>
<button
className={'button-play w-full h-[30px]'}
onClick={() => handleOpenRedeemModal(product, category.id)}
>
{product.ctaLabel}
</button>
</div>
</div>
))
}
</div>
</div>))
}
</div>
<Modal
open={Boolean(selectedProduct)}
title={modalTitle}
onClose={handleCloseRedeemModal}
className={modalMaxWidthClassName}
bodyClassName="space-y-[18px]"
footer={
<>
<button
type="button"
className="h-[38px] rounded-[8px] border border-white/15 bg-white/5 px-[18px] text-[14px] text-white transition-colors hover:bg-white/10"
onClick={modalMode === 'add-address' ? () => setModalMode('select-address') : handleCloseRedeemModal}
>
Cancel
</button>
<button
type="button"
className={`button-play h-[38px] ${modalMode === 'add-address' && !isAddAddressFormValid ? 'opacity-50' : ''}`}
onClick={handleConfirm}
disabled={modalMode === 'add-address' && !isAddAddressFormValid}
>
Confirm
</button>
</>
}
>
{selectedProductData ? (
<>
{isTransferToPlatform ? (
<div className="rounded-[14px] bg-[#1C1818]/80 px-[18px] py-[14px] shadow-[0_10px_30px_rgba(0,0,0,0.22)]">
<div className="divide-y divide-white/8">
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[18px]">Withdrawal Amount</div>
<div className="text-[18px]">{getNumericValue(selectedProductData.title)}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[18px]">Points Required</div>
<div className="text-[18px]">{getNumericValue(selectedProductData.points)}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[18px] underline decoration-[#1E90FF] underline-offset-[3px]">
Turnover Requirement
</div>
<div className="text-[18px]">{getTurnoverRequirement(selectedProductData.subtitle)}</div>
</div>
</div>
<div className="pt-[10px] text-center text-[18px] text-white/45">
Submit withdrawal request?
</div>
</div>
) : isGameBonus ? (
<div className="rounded-[10px] bg-[#1C1818]/80 px-[16px] py-[8px] shadow-[0_10px_30px_rgba(0,0,0,0.22)]">
<div className="divide-y divide-white/8">
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[13px] text-white/78">Item</div>
<div className="text-[14px]">{selectedProductData.title}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[13px] text-white/78">Points Required</div>
<div className="text-[14px]">{getNumericValue(selectedProductData.points)}</div>
</div>
<div className="flex items-center justify-between py-[14px] text-white">
<div className="text-[13px] text-white/78">Turnover Requirement</div>
<div className="text-[14px]">{getTurnoverRequirement(selectedProductData.subtitle)}</div>
</div>
</div>
</div>
) : modalMode === 'select-address' && isPhysicalPrize ? (
<>
<div className="flex gap-[14px] rounded-[12px] bg-white/5 p-[12px]">
<div
className={`${selectedProductData.imageClassName} h-[110px] w-[140px] shrink-0 rounded-[10px]`}
></div>
<div className="flex min-w-0 flex-1 flex-col justify-between">
<div>
<div className="text-[20px] font-bold text-white">{selectedProductData.title}</div>
<div className="mt-[6px] text-[13px] text-white/60">{selectedProductData.subtitle}</div>
</div>
<div className="text-[18px] font-bold text-[#FA6A00]">{selectedProductData.points}</div>
</div>
</div>
<div>
<div className="mb-[10px] text-[14px] font-bold text-white">Select Shipping Address</div>
<div className="space-y-[10px]">
{addressOptions.map((address) => {
const isSelected = selectedAddressId === address.id
return (
<button
key={address.id}
type="button"
className={`flex w-full items-start gap-[12px] rounded-[12px] border px-[14px] py-[14px] text-left transition-colors ${
isSelected
? 'border-[#FA6A00] bg-[#FA6A00]/12'
: 'border-white/10 bg-white/4 hover:bg-white/8'
}`}
onClick={() => setSelectedAddressId(address.id)}
>
<div
className={`mt-[3px] h-[16px] w-[16px] rounded-full border ${
isSelected
? 'border-[#FA6A00] bg-[#FA6A00] shadow-[0_0_12px_rgba(250,106,0,0.45)]'
: 'border-white/30'
}`}
>
<div
className={`m-auto mt-[3px] h-[6px] w-[6px] rounded-full bg-white ${
isSelected ? 'block' : 'hidden'
}`}
></div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-[8px]">
<div className="text-[14px] font-bold text-white">{address.name}</div>
<div className="text-[13px] text-white/60">{address.phone}</div>
{address.isDefault ? (
<div className="rounded-full bg-[#FA6A00]/18 px-[8px] py-[2px] text-[11px] text-[#FFB36D]">
Default
</div>
) : null}
</div>
<div className="mt-[6px] text-[13px] leading-[1.5] text-white/75">
{address.address}
</div>
<div className="mt-[4px] text-[12px] text-white/45">
Postal Code: {address.postalCode}
</div>
</div>
</button>
)
})}
<button
type="button"
className="flex w-full items-center gap-[12px] rounded-[12px] border border-dashed border-[#FA6A00]/55 bg-[#FA6A00]/8 px-[14px] py-[16px] text-left transition-colors hover:bg-[#FA6A00]/12"
onClick={handleOpenAddAddress}
>
<div className="flex h-[18px] w-[18px] items-center justify-center rounded-full bg-[#FA6A00] text-[14px] leading-none text-white">
+
</div>
<div>
<div className="text-[14px] font-bold text-white">Add New Address</div>
<div className="mt-[4px] text-[12px] text-white/55">
Create a shipping address for this redemption
</div>
</div>
</button>
</div>
</div>
</>
) : isPhysicalPrize ? (
<div className="rounded-[12px] bg-[#171313]/88 px-[14px] py-[10px] shadow-[0_10px_30px_rgba(0,0,0,0.25)]">
<div className="flex items-center justify-between border-b border-white/10 px-[6px] pb-[16px]">
<div className="text-[18px] font-medium text-white">Address Info</div>
<button
type="button"
className="flex items-center gap-[8px] text-[14px] text-white/85"
onClick={() => handleChangeAddressForm('isDefault', !addressForm.isDefault)}
>
<span
className={`flex h-[16px] w-[16px] items-center justify-center rounded-full border ${
addressForm.isDefault ? 'border-[#FA6A00]' : 'border-white/35'
}`}
>
<span
className={`h-[8px] w-[8px] rounded-full ${
addressForm.isDefault ? 'bg-[#FA6A00]' : 'bg-transparent'
}`}
></span>
</span>
<span>Default Address</span>
</button>
</div>
<div className="divide-y divide-white/10">
<div className="grid grid-cols-[170px_1fr] items-center gap-[10px] px-[6px] py-[16px]">
<label className="text-[14px] text-white/92">
Name<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.name}
onChange={(event) => handleChangeAddressForm('name', event.target.value)}
placeholder="Full Name"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
<GoodsCategoryList
categories={previewCategories}
loading={loading}
emptyText="No goods available yet."
showMore
onMoreClick={handleMoreClick}
onRedeem={redeem.openRedeemModal}
/>
</div>
<div className="grid grid-cols-[170px_1fr] items-center gap-[10px] px-[6px] py-[16px]">
<label className="text-[14px] text-white/92">
Phone Number<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.phone}
onChange={(event) => handleChangeAddressForm('phone', event.target.value)}
placeholder="Phone Number"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
<GoodsRedeemModal
selectedProduct={redeem.selectedProduct}
modalMode={redeem.modalMode}
addressOptions={redeem.addressOptions}
selectedAddressId={redeem.selectedAddressId}
addressForm={redeem.addressForm}
addressLoading={redeem.addressLoading}
isAddAddressFormValid={redeem.isAddAddressFormValid}
submitLoading={redeem.submitLoading}
onClose={redeem.closeRedeemModal}
onConfirm={redeem.confirmRedeem}
onOpenAddAddress={redeem.openAddAddress}
onBackToSelectAddress={redeem.backToSelectAddress}
onSelectAddress={redeem.setSelectedAddressId}
onChangeAddressForm={redeem.changeAddressForm}
/>
</div>
<div className="grid grid-cols-[170px_1fr] items-center gap-[10px] px-[6px] py-[16px]">
<label className="text-[14px] text-white/92">
Region<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.region}
onChange={(event) => handleChangeAddressForm('region', event.target.value)}
placeholder="State, City, District"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
<div className="grid grid-cols-[170px_1fr] items-center gap-[10px] px-[6px] py-[16px]">
<label className="text-[14px] text-white/92">
Detailed Address<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.detailedAddress}
onChange={(event) => handleChangeAddressForm('detailedAddress', event.target.value)}
placeholder="Apt, Suite, Bldg, etc"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
<div className="grid grid-cols-[170px_1fr] items-center gap-[10px] px-[6px] py-[16px]">
<label className="text-[14px] text-white/92">Postal Code</label>
<input
value={addressForm.postalCode}
onChange={(event) => handleChangeAddressForm('postalCode', event.target.value)}
placeholder="Postal Code"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
</div>
</div>
) : null}
</>
) : null}
</Modal>
<Modal
open={claimModalOpen}
@@ -655,22 +250,23 @@ function HomePage() {
bodyClassName="space-y-[18px]"
footer={
<>
<button
type="button"
className="h-[38px] min-w-[120px] rounded-[8px] border border-white/10 bg-white/10 px-[18px] text-[14px] text-white/75 transition-colors hover:bg-white/14"
<Button type="button" variant={'gray'} className="h-[38px] w-full sm:w-auto sm:min-w-[130px]"
onClick={handleCloseClaimModal}
>
disabled={claimMutation.isPending}>
Cancel
</button>
<button type="button" className="button-play h-[38px] min-w-[130px]" onClick={handleCloseClaimModal}>
Confirm
</button>
</Button>
<Button type="button" className="h-[38px] w-full sm:w-auto sm:min-w-[130px]"
onClick={handleConfirmClaim}
disabled={claimMutation.isPending}>
{claimMutation.isPending ? 'Processing...' : 'Confirm'}
</Button>
</>
}
>
<div className="rounded-[12px] bg-[#1C1818]/82 px-[14px] py-[18px] text-[17px] leading-[1.65] text-white/92 shadow-[0_10px_30px_rgba(0,0,0,0.2)]">
Once pending points are transferred to your available balance, they can be redeemed or withdrawn.
Confirm claim?
<div
className="rounded-[12px] bg-[#1C1818]/82 px-[14px] py-[18px] text-[17px] leading-[1.65] text-white/92 shadow-[0_10px_30px_rgba(0,0,0,0.2)]">
After converting the points to be collected into usable points, they can be redeemed or withdrawn. Are you sure to claim it?
</div>
</Modal>
</PageLayout>

View File

@@ -1,131 +1,246 @@
import { useState } from 'react'
import {useEffect, useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import PageLayout from '@/components/layout'
import {ORDER_STATUS} from '@/constant'
import { cn } from '@/lib'
import Modal from '@/components/modal'
import type { RecordButtonType } from '@/types'
import Button from '@/components/button'
import type { OrderCardProps, OrderRecord, PointsCardProps, RecordButtonType, TabButtonProps } from '@/types'
import { Link } from 'react-router-dom'
import { ArrowLeft, ChevronRight, Coins, PackageSearch } from 'lucide-react'
import {orders, pointsLogs} from '@/api/business.ts'
import {queryKeys} from '@/lib/queryKeys.ts'
import { useUserStore } from '@/store/user.ts'
import type {OrderItem, PointsLogItem} from '@/types/business.type.ts'
type OrderRecord = {
id: string
date: string
time: string
category: string
title: string
trackingNumber?: string
status: string
points: string
}
type PointsRecord = {
id: string
title: string
date: string
time: string
amount: string
tone: 'positive' | 'negative'
}
const orderRecords: OrderRecord[] = [
{
id: 'order-1',
date: '2025-03-04',
time: '10:20',
category: 'Bonus',
title: 'Daily Rebate 50',
status: 'Issued',
points: '-500 points',
},
{
id: 'order-2',
date: '2025-03-03',
time: '14:00',
category: 'Physical',
title: 'Weekly Bonus 200',
trackingNumber: 'SF1234567890',
status: 'Shipped',
points: '-1200 points',
},
{
id: 'order-3',
date: '2025-03-02',
time: '09:15',
category: 'Withdrawal',
title: 'Wireless Earbuds',
status: 'Issued',
points: '-1000 points',
},
{
id: 'order-4',
date: '2025-03-01',
time: '16:30',
category: 'Bonus',
title: 'Fitness Tracker',
status: 'Pending',
points: '-1800 points',
},
{
id: 'order-5',
date: '2025-02-28',
time: '11:00',
category: 'Physical',
title: 'Withdraw 100',
status: 'Rejected',
points: '-2500 points',
},
]
const pointsRecords: PointsRecord[] = [
{
id: 'points-1',
title: 'Bonus Redemption - Daily Rewards 50',
date: '2025-03-04',
time: '10:20',
amount: '-500',
tone: 'negative',
},
{
id: 'points-2',
title: "Claim Yesterday's Protection Funds",
date: '2025-03-04',
time: '09:20',
amount: '+800',
tone: 'positive',
},
{
id: 'points-3',
title: 'Physical Item Redemption - Bluetooth Headphones',
date: '2025-03-03',
time: '14:00',
amount: '-1200',
tone: 'negative',
},
{
id: 'points-4',
title: "Claim Yesterday's Protection Funds",
date: '2025-03-03',
time: '09:00',
amount: '+700',
tone: 'positive',
},
{
id: 'points-5',
title: 'Withdraw to Platform - 100',
date: '2025-03-02',
time: '09:15',
amount: '-1000',
tone: 'negative',
},
]
const amountToneClassName: Record<PointsRecord['tone'], string> = {
const pointsRecordToneClassName = {
positive: 'bg-[#9BFFC0] text-[#176640]',
negative: 'bg-[#FF9BA4] text-[#7B2634]',
} as const
function formatDatePart(value: number) {
return String(value).padStart(2, '0')
}
function getDateTimeParts(value?: number | string) {
if (value == null) {
return { date: '--', time: '--:--' }
}
const numericValue = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
const date = new Date(
typeof numericValue === 'number'
? String(numericValue).length === 13
? numericValue
: numericValue * 1000
: numericValue,
)
if (Number.isNaN(date.getTime())) {
return { date: '--', time: '--:--' }
}
return {
date: `${date.getFullYear()}-${formatDatePart(date.getMonth() + 1)}-${formatDatePart(date.getDate())}`,
time: `${formatDatePart(date.getHours())}:${formatDatePart(date.getMinutes())}`,
}
}
function toTitleCase(value: string) {
return value
.toLowerCase()
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join(' ')
}
function getOrderStatus(status?: string | number) {
if (typeof status === 'string') {
const normalizedStatus = status.trim().toUpperCase()
const matchedStatus = ORDER_STATUS.find((item) => item === normalizedStatus)
if (matchedStatus) {
switch (matchedStatus) {
case 'PENDING':
return 'Pending'
case 'COMPLETED':
return 'Completed'
case 'SHIPPED':
return 'Shipped'
case 'REJECTED':
return 'Rejected'
}
}
}
const normalizedStatus = typeof status === 'string' && /^\d+$/.test(status)
? Number(status)
: status
if (typeof normalizedStatus === 'number') {
switch (normalizedStatus) {
case 0:
return 'Pending'
case 1:
return 'Completed'
case 2:
return 'Shipped'
case 3:
return 'Rejected'
default:
return String(normalizedStatus)
}
}
if (!normalizedStatus) {
return 'Pending'
}
return toTitleCase(normalizedStatus)
}
function getOrderCategory(item: OrderItem) {
if (item.type?.trim()) {
return item.type.trim().toUpperCase()
}
if (item.type_title) {
return item.type_title
}
if (item.category_title) {
return item.category_title
}
if (item.type) {
switch (item.type) {
case 'BONUS':
return 'Bonus'
case 'PHYSICAL':
return 'Physical'
case 'WITHDRAW':
return 'Transfer to Platform'
default:
return toTitleCase(item.type)
}
}
if (item.category) {
return toTitleCase(item.category)
}
return 'Order'
}
function getOrderPoints(item: OrderItem) {
const rawValue = item.points_cost
if (rawValue == null || rawValue === '') {
return {display: '--'}
}
const numericValue = typeof rawValue === 'string' ? Number(rawValue) : rawValue
if (Number.isNaN(numericValue)) {
const textValue = String(rawValue)
return {display: `${textValue} points`}
}
return {
display: `${numericValue} points`,
}
}
function getTrackingNumber(item: OrderItem) {
const shippingNumber = item.shipping_no?.trim()
const logisticsNumber = item.logistics_no?.trim()
const trackingNumber = item.tracking_no?.trim()
const shippingCompany = item.shipping_company?.trim()
const resolvedNumber = shippingNumber || logisticsNumber || trackingNumber
if (!resolvedNumber) {
return undefined
}
return shippingCompany ? `${shippingCompany} ${resolvedNumber}` : resolvedNumber
}
function mapOrderItemToRecord(item: OrderItem): OrderRecord {
const { date, time } = getDateTimeParts(item.create_time ?? item.created_at)
const orderNumber = item.external_transaction_id?.trim() || item.order_no ? String(item.external_transaction_id?.trim() || item.order_no) : undefined
const points = getOrderPoints(item)
return {
id: String(item.id),
orderNumber,
date,
time,
category: getOrderCategory(item),
title: item.item_title ?? item.title ?? item.mallItem?.title ?? 'Untitled Order',
trackingNumber: getTrackingNumber(item),
status: getOrderStatus(item.status),
points: points.display,
}
}
function getPointsRecordAmount(item: PointsLogItem) {
const rawValue = item.points
if (rawValue == null || rawValue === '') {
return {
amount: '--',
tone: 'negative' as const,
}
}
const numericValue = typeof rawValue === 'string' ? Number(rawValue) : rawValue
if (Number.isNaN(numericValue)) {
const textValue = String(rawValue).trim()
return {
amount: item.direction === 'IN'
? textValue.startsWith('+') ? textValue : `+${textValue.replace(/^[+-]/, '')}`
: item.direction === 'OUT'
? textValue.startsWith('-') ? textValue : `-${textValue.replace(/^[+-]/, '')}`
: textValue,
tone: item.direction === 'IN' ? 'positive' as const : 'negative' as const,
}
}
return {
amount: `${item.direction === 'IN' ? '+' : item.direction === 'OUT' ? '-' : ''}${Math.abs(numericValue)}`,
tone: item.direction === 'IN' ? 'positive' as const : 'negative' as const,
}
}
function getPointsRecordTitle(item: PointsLogItem) {
return [
item.item_title,
item.title,
item.mallItem?.title,
item.biz_type,
item.type,
item.description,
item.remark?.split('\n')[0],
].find((value) => typeof value === 'string' && value.trim())?.trim() ?? 'Points Record'
}
function mapPointsLogItemToRecord(item: PointsLogItem) {
const {date, time} = getDateTimeParts(item.ts ?? item.create_time ?? item.created_at ?? item.update_time)
const amount = getPointsRecordAmount(item)
return {
id: String(item.id),
title: getPointsRecordTitle(item),
date,
time,
amount: amount.amount,
tone: amount.tone,
}
}
function getOrderStatusClassName(status: string) {
switch (status.toLowerCase()) {
case 'issued':
case 'completed':
return 'bg-[#9BFFC0] text-[#176640]'
case 'shipped':
return 'bg-[#95F0FF] text-[#116A79]'
@@ -138,29 +253,19 @@ function getOrderStatusClassName(status: string) {
}
}
type TabButtonProps = {
active: boolean
label: string
onClick: () => void
}
type OrderCardProps = {
record: OrderRecord
onOpenDetails: (record: OrderRecord) => void
}
function TabButton({ active, label, onClick }: TabButtonProps) {
function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
return (
<button
type="button"
className={cn(
'min-w-[92px] cursor-pointer rounded-[6px] border px-[14px] py-[7px] text-[13px] transition-colors',
'inline-flex min-w-[140px] cursor-pointer items-center justify-center gap-[8px] rounded-[10px] border px-[14px] py-[9px] text-[13px] transition-colors focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]',
active
? 'border-[#F99A0B] bg-linear-to-r from-[#F96C02] to-[#FE9F00] text-white shadow-[0_0_16px_rgba(249,108,2,0.22)]'
: 'border-white/35 bg-white/3 text-[#B8B1AA] hover:bg-white/6',
)}
onClick={onClick}
>
<Icon className="h-[15px] w-[15px]" aria-hidden="true" />
{label}
</button>
)
@@ -168,12 +273,12 @@ function TabButton({ active, label, onClick }: TabButtonProps) {
function OrderCard({ record, onOpenDetails }: OrderCardProps) {
return (
<div className="overflow-hidden rounded-[10px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[8px] text-[14px] text-white">
<div className="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-[13px] text-white sm:text-[14px]">
{record.date} {record.time} {record.category}
</div>
<div className="liquid-glass-bg !rounded-t-none flex items-center justify-between px-[12px] py-[12px]">
<div className="min-w-0 flex-1 pr-[16px]">
<div className="liquid-glass-bg !rounded-t-none flex flex-col gap-[14px] px-[12px] py-[14px] sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<div className="text-[16px] font-medium text-white">{record.title}</div>
{record.trackingNumber ? (
<div className="mt-[4px] text-[13px] text-white/45">
@@ -182,14 +287,15 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
) : null}
<button
type="button"
className="mt-[8px] text-[13px] text-[#FA6A00]"
className="mt-[10px] inline-flex items-center gap-[5px] text-[13px] text-[#FA6A00] focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => onOpenDetails(record)}
>
Check the details
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true" />
</button>
</div>
<div className="flex shrink-0 flex-col items-end gap-[10px]">
<div className="flex shrink-0 items-center justify-between gap-[10px] sm:flex-col sm:items-end">
<div
className={cn(
'rounded-[6px] px-[8px] py-[3px] text-[11px] leading-none',
@@ -198,27 +304,27 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
>
{record.status}
</div>
<div className="text-[13px] text-white/85">{record.points}</div>
<div className="text-[13px] text-white/85">{record.points.replace(/^-/, '')}</div>
</div>
</div>
</div>
)
}
function PointsCard({ record }: { record: PointsRecord }) {
function PointsCard({ record }: PointsCardProps) {
return (
<div className="overflow-hidden rounded-[10px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[8px] text-[15px] text-white">
<div className="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 sm:text-[15px]">
{record.title}
</div>
<div className="liquid-glass-bg !rounded-t-none flex items-center justify-between px-[12px] py-[22px]">
<div className="liquid-glass-bg !rounded-t-none flex flex-col gap-[10px] px-[12px] py-[18px] sm:flex-row sm:items-center sm:justify-between sm:py-[22px]">
<div className="text-[13px] text-white/40">
{record.date} &nbsp; {record.time}
</div>
<div
className={cn(
'rounded-[6px] px-[10px] py-[3px] text-[12px] leading-none',
amountToneClassName[record.tone],
'inline-flex w-fit rounded-[6px] px-[10px] py-[3px] text-[12px] leading-none',
pointsRecordToneClassName[record.tone],
)}
>
{record.amount}
@@ -228,7 +334,92 @@ function PointsCard({ record }: { record: PointsRecord }) {
)
}
function OrdersTabContent({
sessionId,
onOpenDetails,
}: {
sessionId: string
onOpenDetails: (record: OrderRecord) => void
}) {
const ordersQuery = useQuery({
queryKey: queryKeys.orders(sessionId),
enabled: Boolean(sessionId),
gcTime: 0,
queryFn: async () => {
const response = await orders({
session_id: sessionId,
})
return (response.data.list ?? []).map(mapOrderItemToRecord)
},
})
const orderRecords = ordersQuery.data ?? []
if (ordersQuery.isPending) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
Loading...
</div>
)
}
if (!orderRecords.length) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
No Data
</div>
)
}
return (
<>
{orderRecords.map((record) => (
<OrderCard key={record.id} record={record} onOpenDetails={onOpenDetails} />
))}
</>
)
}
function PointsTabContent({sessionId}: {sessionId: string}) {
const pointsLogsQuery = useQuery({
queryKey: queryKeys.pointsLogs(sessionId),
enabled: Boolean(sessionId),
gcTime: 0,
queryFn: async () => {
const response = await pointsLogs({
session_id: sessionId,
})
return (response.data.list ?? []).map(mapPointsLogItemToRecord)
},
})
const pointsRecords = pointsLogsQuery.data ?? []
if (pointsLogsQuery.isPending) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
Loading...
</div>
)
}
if (!pointsRecords.length) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
No Data
</div>
)
}
return (
<>
{pointsRecords.map((record) => <PointsCard key={record.id} record={record} />)}
</>
)
}
function RecordPage() {
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const [tab, setTab] = useState<RecordButtonType>('order')
const [selectedOrder, setSelectedOrder] = useState<OrderRecord | null>(null)
@@ -236,30 +427,50 @@ function RecordPage() {
setSelectedOrder(null)
}
useEffect(() => {
setSelectedOrder(null)
}, [tab])
return (
<PageLayout contentClassName="min-h-screen">
<PageLayout contentClassName="mx-auto flex min-h-screen w-full max-w-[980px] flex-col px-4 pb-8 sm:px-6 lg:px-8">
<Link
to="/"
className="relative flex h-[40px] w-full items-center justify-center bg-[#08070E]/70 text-[#F56E10]"
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="absolute left-[16px]">&lt;</div>
<div>Record</div>
<div className="flex items-center gap-[8px]">
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
<span className="text-[14px] font-medium text-white/92">Back</span>
</div>
<div className="text-[15px] font-semibold text-[#F56E10]">Record</div>
<div className="w-[52px]"></div>
</Link>
<div className="mx-auto w-[60%] pt-[14px] pb-[24px]">
<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'} label="My Orders" onClick={() => setTab('order')} />
<TabButton active={tab === 'record'} label="Points Record" onClick={() => setTab('record')} />
<TabButton
active={tab === 'order'}
icon={PackageSearch}
label="My Orders"
onClick={() => setTab('order')}
/>
<TabButton
active={tab === 'record'}
icon={Coins}
label="Points Record"
onClick={() => setTab('record')}
/>
</div>
</div>
<div className="mt-[10px] border-t border-white/20"></div>
<div className="h-px bg-white/16"></div>
<div className="mt-[12px] flex flex-col gap-[12px]">
{tab === 'order'
? orderRecords.map((record) => (
<OrderCard key={record.id} record={record} onOpenDetails={setSelectedOrder} />
))
: pointsRecords.map((record) => <PointsCard key={record.id} record={record} />)}
<div className="mt-[14px] flex flex-col gap-[12px]">
{tab === 'order' ? (
<OrdersTabContent key="order" sessionId={sessionId} onOpenDetails={setSelectedOrder} />
) : (
<PointsTabContent key="record" sessionId={sessionId} />
)}
</div>
</div>
@@ -267,22 +478,25 @@ function RecordPage() {
open={Boolean(selectedOrder)}
title="Order Details"
onClose={handleCloseDetails}
className="max-w-[380px]"
className="max-w-[420px]"
bodyClassName="pt-[0px]"
footer={
<button type="button" className="button-play h-[36px] min-w-[94px]" onClick={handleCloseDetails}>
<Button type="button" className="h-[36px] w-full sm:min-w-[94px] sm:w-auto" onClick={handleCloseDetails}>
Close
</button>
</Button>
}
>
{selectedOrder ? (
<div className="rounded-[8px] bg-[#1C1818]/78 px-[12px] py-[6px]">
<div className="rounded-[10px] bg-[#1C1818]/78 px-[12px] py-[6px]">
{[
{ label: 'Order Number', value: `ORD${selectedOrder.date.replaceAll('-', '')}${selectedOrder.time.replace(':', '')}001` },
{ label: 'Order Number', value: selectedOrder.orderNumber ?? '--' },
{ label: 'Order Time', value: `${selectedOrder.date} ${selectedOrder.time}` },
{ label: 'Order Type', value: selectedOrder.category },
{ label: 'Item Name', value: selectedOrder.title },
{ label: 'Points', value: selectedOrder.points.replace('-', '') },
...(selectedOrder.trackingNumber
? [{ label: 'Tracking Number', value: selectedOrder.trackingNumber }]
: []),
{ label: 'Points', value: selectedOrder.points.replace(/^-/, '') },
{ label: 'Status', value: selectedOrder.status },
].map((item) => (
<div key={item.label} className="border-b border-white/8 py-[10px] last:border-b-0">

View File

@@ -6,6 +6,45 @@ import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
base: './',
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) {
return
}
if (id.includes('/react-router-dom/')) {
return 'router'
}
if (id.includes('/@tanstack/react-query/') || id.includes('/zustand/') || id.includes('/ky/')) {
return 'data'
}
if (id.includes('/lucide-react/')) {
return 'icons'
}
if (id.includes('/react/') || id.includes('/react-dom/')) {
return 'react'
}
return 'vendor'
},
},
},
},
server: {
proxy: {
'/api': {
target: 'https://playx-api.cjdhr.top',
changeOrigin: true,
secure: true,
},
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),