feat(auth): 集成认证授权功能并优化API客户端
- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取 - 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理 - 添加了WebSocket连接配置和API基础URL环境变量设置 - 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑 - 集成了MD5加密和认证令牌缓存机制,提升安全性 - 添加了多语言国际化支持,包括英语、中文、马来语和印尼语 - 实现了认证状态管理和本地存储持久化功能 - 添加了表单验证schema和错误处理机制,增强用户体验
@@ -1,4 +1,7 @@
|
|||||||
VITE_APP_ENV=development
|
VITE_APP_ENV=development
|
||||||
VITE_API_BASE_URL=http://localhost:3000
|
VITE_API_BASE_URL=/
|
||||||
|
VITE_WEBSOCKET_URL=wss://zihua-api.h55555game.top/ws/
|
||||||
VITE_ENABLE_QUERY_DEVTOOLS=true
|
VITE_ENABLE_QUERY_DEVTOOLS=true
|
||||||
VITE_ENABLE_REQUEST_LOG=true
|
VITE_ENABLE_REQUEST_LOG=true
|
||||||
|
# 客户端密钥
|
||||||
|
VITE_AUTH_TOKEN_SECRET=564d14asdasd113e46542asd6das1a2a
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
VITE_APP_ENV=development
|
VITE_APP_ENV=development
|
||||||
VITE_API_BASE_URL=http://localhost:3000
|
VITE_API_BASE_URL=https://zihua-api.h55555game.top/
|
||||||
|
VITE_WEBSOCKET_URL=wss://zihua-api.h55555game.top/ws/
|
||||||
VITE_ENABLE_QUERY_DEVTOOLS=true
|
VITE_ENABLE_QUERY_DEVTOOLS=true
|
||||||
VITE_ENABLE_REQUEST_LOG=true
|
VITE_ENABLE_REQUEST_LOG=true
|
||||||
|
# 客户端密钥
|
||||||
|
VITE_AUTH_TOKEN_SECRET=564d14asdasd113e46542asd6das1a2a
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
VITE_APP_ENV=production
|
VITE_APP_ENV=production
|
||||||
VITE_API_BASE_URL=https://api.example.com
|
VITE_API_BASE_URL=https://zihua-api.h55555game.top/
|
||||||
|
VITE_WEBSOCKET_URL=wss://zihua-api.h55555game.top/ws/
|
||||||
VITE_ENABLE_QUERY_DEVTOOLS=false
|
VITE_ENABLE_QUERY_DEVTOOLS=false
|
||||||
VITE_ENABLE_REQUEST_LOG=false
|
VITE_ENABLE_REQUEST_LOG=false
|
||||||
|
# 客户端密钥
|
||||||
|
VITE_AUTH_TOKEN_SECRET=564d14asdasd113e46542asd6das1a2a
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
"@tanstack/react-query": "^5.99.0",
|
"@tanstack/react-query": "^5.99.0",
|
||||||
"@tanstack/react-query-devtools": "^5.99.0",
|
"@tanstack/react-query-devtools": "^5.99.0",
|
||||||
"@tanstack/react-router": "^1.168.22",
|
"@tanstack/react-router": "^1.168.22",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
"@types/md5": "^2.3.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
@@ -37,10 +39,11 @@
|
|||||||
"ky": "^2.0.1",
|
"ky": "^2.0.1",
|
||||||
"lottie-web": "^5.13.0",
|
"lottie-web": "^5.13.0",
|
||||||
"lucide-react": "^1.9.0",
|
"lucide-react": "^1.9.0",
|
||||||
|
"md5": "^2.3.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "19.2.5",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "19.2.5",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "^7.75.0",
|
||||||
"react-i18next": "^17.0.3",
|
"react-i18next": "^17.0.3",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
|
|||||||
59
pnpm-lock.yaml
generated
@@ -23,6 +23,12 @@ importers:
|
|||||||
'@tanstack/react-router':
|
'@tanstack/react-router':
|
||||||
specifier: ^1.168.22
|
specifier: ^1.168.22
|
||||||
version: 1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: ^3.13.24
|
||||||
|
version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
|
'@types/md5':
|
||||||
|
specifier: ^2.3.6
|
||||||
|
version: 2.3.6
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -44,6 +50,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.9.0
|
specifier: ^1.9.0
|
||||||
version: 1.9.0(react@19.2.5)
|
version: 1.9.0(react@19.2.5)
|
||||||
|
md5:
|
||||||
|
specifier: ^2.3.0
|
||||||
|
version: 2.3.0
|
||||||
motion:
|
motion:
|
||||||
specifier: ^12.38.0
|
specifier: ^12.38.0
|
||||||
version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
@@ -51,10 +60,10 @@ importers:
|
|||||||
specifier: ^1.4.3
|
specifier: ^1.4.3
|
||||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.4
|
specifier: 19.2.5
|
||||||
version: 19.2.5
|
version: 19.2.5
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.4
|
specifier: 19.2.5
|
||||||
version: 19.2.5(react@19.2.5)
|
version: 19.2.5(react@19.2.5)
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.75.0
|
specifier: ^7.75.0
|
||||||
@@ -1686,6 +1695,12 @@ packages:
|
|||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.13.24':
|
||||||
|
resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
'@tanstack/router-cli@1.166.33':
|
'@tanstack/router-cli@1.166.33':
|
||||||
resolution: {integrity: sha512-gCWBbCVkfT2OzgxQVV275BjRYKvfh7SEKD73ATHWyLE8ifm8/O2700roObVHUy+Y0jJT91Am0UkjsES0O2jqzw==}
|
resolution: {integrity: sha512-gCWBbCVkfT2OzgxQVV275BjRYKvfh7SEKD73ATHWyLE8ifm8/O2700roObVHUy+Y0jJT91Am0UkjsES0O2jqzw==}
|
||||||
engines: {node: '>=20.19'}
|
engines: {node: '>=20.19'}
|
||||||
@@ -1729,6 +1744,9 @@ packages:
|
|||||||
'@tanstack/store@0.9.3':
|
'@tanstack/store@0.9.3':
|
||||||
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
|
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.14.0':
|
||||||
|
resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
|
||||||
|
|
||||||
'@tanstack/virtual-file-routes@1.161.7':
|
'@tanstack/virtual-file-routes@1.161.7':
|
||||||
resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==}
|
resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==}
|
||||||
engines: {node: '>=20.19'}
|
engines: {node: '>=20.19'}
|
||||||
@@ -1752,6 +1770,9 @@ packages:
|
|||||||
'@types/babel__traverse@7.28.0':
|
'@types/babel__traverse@7.28.0':
|
||||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||||
|
|
||||||
|
'@types/md5@2.3.6':
|
||||||
|
resolution: {integrity: sha512-WD69gNXtRBnpknfZcb4TRQ0XJQbUPZcai/Qdhmka3sxUR3Et8NrXoeAoknG/LghYHTf4ve795rInVYHBTQdNVA==}
|
||||||
|
|
||||||
'@types/node@24.12.2':
|
'@types/node@24.12.2':
|
||||||
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
|
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
|
||||||
|
|
||||||
@@ -1956,6 +1977,9 @@ packages:
|
|||||||
chardet@0.7.0:
|
chardet@0.7.0:
|
||||||
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
|
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
|
||||||
|
|
||||||
|
charenc@0.0.2:
|
||||||
|
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
@@ -2104,6 +2128,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
crypt@0.0.2:
|
||||||
|
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
|
||||||
|
|
||||||
cssesc@3.0.0:
|
cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -2604,6 +2631,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
is-buffer@1.1.6:
|
||||||
|
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
||||||
|
|
||||||
is-docker@3.0.0:
|
is-docker@3.0.0:
|
||||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
@@ -2902,6 +2932,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
md5@2.3.0:
|
||||||
|
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
||||||
|
|
||||||
media-typer@1.1.0:
|
media-typer@1.1.0:
|
||||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -5317,6 +5350,12 @@ snapshots:
|
|||||||
react-dom: 19.2.5(react@19.2.5)
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.14.0
|
||||||
|
react: 19.2.5
|
||||||
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
|
|
||||||
'@tanstack/router-cli@1.166.33':
|
'@tanstack/router-cli@1.166.33':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/router-generator': 1.166.32
|
'@tanstack/router-generator': 1.166.32
|
||||||
@@ -5382,6 +5421,8 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/store@0.9.3': {}
|
'@tanstack/store@0.9.3': {}
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.14.0': {}
|
||||||
|
|
||||||
'@tanstack/virtual-file-routes@1.161.7': {}
|
'@tanstack/virtual-file-routes@1.161.7': {}
|
||||||
|
|
||||||
'@ts-morph/common@0.27.0':
|
'@ts-morph/common@0.27.0':
|
||||||
@@ -5416,6 +5457,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@types/md5@2.3.6': {}
|
||||||
|
|
||||||
'@types/node@24.12.2':
|
'@types/node@24.12.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
@@ -5613,6 +5656,8 @@ snapshots:
|
|||||||
|
|
||||||
chardet@0.7.0: {}
|
chardet@0.7.0: {}
|
||||||
|
|
||||||
|
charenc@0.0.2: {}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
@@ -5761,6 +5806,8 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
crypt@0.0.2: {}
|
||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
@@ -6295,6 +6342,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
binary-extensions: 2.3.0
|
binary-extensions: 2.3.0
|
||||||
|
|
||||||
|
is-buffer@1.1.6: {}
|
||||||
|
|
||||||
is-docker@3.0.0: {}
|
is-docker@3.0.0: {}
|
||||||
|
|
||||||
is-extglob@2.1.1: {}
|
is-extglob@2.1.1: {}
|
||||||
@@ -6510,6 +6559,12 @@ snapshots:
|
|||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
|
md5@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
charenc: 0.0.2
|
||||||
|
crypt: 0.0.2
|
||||||
|
is-buffer: 1.1.6
|
||||||
|
|
||||||
media-typer@1.1.0: {}
|
media-typer@1.1.0: {}
|
||||||
|
|
||||||
meow@13.2.0: {}
|
meow@13.2.0: {}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -1,5 +1,6 @@
|
|||||||
import { type ReactNode, useEffect } from 'react'
|
import { type ReactNode, useEffect } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import modalBg from '@/assets/system/modal-bg.webp'
|
import modalBg from '@/assets/system/modal-bg.webp'
|
||||||
import modalClose from '@/assets/system/modal-close.webp'
|
import modalClose from '@/assets/system/modal-close.webp'
|
||||||
import modalNormalBg from '@/assets/system/modal-normal-bg.png'
|
import modalNormalBg from '@/assets/system/modal-normal-bg.png'
|
||||||
@@ -29,6 +30,7 @@ export function CenterModal({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: CenterModalProps) {
|
}: CenterModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose?.()
|
onClose?.()
|
||||||
}
|
}
|
||||||
@@ -63,7 +65,11 @@ export function CenterModal({
|
|||||||
<SmartBackground
|
<SmartBackground
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={typeof title === 'string' ? title : 'Modal'}
|
aria-label={
|
||||||
|
typeof title === 'string'
|
||||||
|
? title
|
||||||
|
: t('commonUi.modal.defaultAriaLabel')
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex h-design-640 w-design-720 flex-col overflow-hidden rounded-[calc(var(--design-unit)*28)] px-design-20 text-white',
|
'relative flex h-design-640 w-design-720 flex-col overflow-hidden rounded-[calc(var(--design-unit)*28)] px-design-20 text-white',
|
||||||
className,
|
className,
|
||||||
@@ -93,7 +99,7 @@ export function CenterModal({
|
|||||||
{isShowClose && onClose ? (
|
{isShowClose && onClose ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close modal"
|
aria-label={t('commonUi.modal.close')}
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-1/2 inline-flex h-design-60 w-design-60 -translate-y-1/2 items-center justify-center rounded-full transition hover:scale-105 active:scale-95',
|
'absolute top-1/2 inline-flex h-design-60 w-design-60 -translate-y-1/2 items-center justify-center rounded-full transition hover:scale-105 active:scale-95',
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { AppLanguage } from '@/i18n'
|
import { type AppLanguage, supportedLanguages } from '@/i18n'
|
||||||
|
|
||||||
|
const languagePrefixPattern = new RegExp(
|
||||||
|
`^/(${supportedLanguages.join('|')})(?=/|$)`,
|
||||||
|
)
|
||||||
|
|
||||||
interface LanguageLinkProps {
|
interface LanguageLinkProps {
|
||||||
currentPathname: string
|
currentPathname: string
|
||||||
@@ -14,7 +18,7 @@ export function LanguageLink({
|
|||||||
language,
|
language,
|
||||||
}: LanguageLinkProps) {
|
}: LanguageLinkProps) {
|
||||||
const nextPathname = currentPathname.replace(
|
const nextPathname = currentPathname.replace(
|
||||||
/^\/(zh-CN|en-US)(?=\/|$)/,
|
languagePrefixPattern,
|
||||||
`/${language}`,
|
`/${language}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
71
src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Info,
|
||||||
|
LoaderCircle,
|
||||||
|
TriangleAlert,
|
||||||
|
X,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { notify, useNotificationStore } from '@/lib/notify'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const TOAST_ICON_BY_TYPE = {
|
||||||
|
error: <XCircle className="h-4 w-4 shrink-0 text-[#FF8A9E]" />,
|
||||||
|
info: <Info className="h-4 w-4 shrink-0 text-[#7CE8FF]" />,
|
||||||
|
loading: (
|
||||||
|
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-[#7CE8FF]" />
|
||||||
|
),
|
||||||
|
success: <CheckCircle2 className="h-4 w-4 shrink-0 text-[#7CF0B8]" />,
|
||||||
|
warning: <TriangleAlert className="h-4 w-4 shrink-0 text-[#FFD66E]" />,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const TOAST_TONE_CLASS_BY_TYPE = {
|
||||||
|
error: 'game-toast-error',
|
||||||
|
info: 'game-toast-info',
|
||||||
|
loading: 'game-toast-loading',
|
||||||
|
success: 'game-toast-success',
|
||||||
|
warning: 'game-toast-warning',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function AppToaster() {
|
||||||
|
const toasts = useNotificationStore((state) => state.toasts)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-atomic="true"
|
||||||
|
aria-live="polite"
|
||||||
|
className="game-toaster pointer-events-none fixed top-[calc(var(--design-unit)*88)] left-1/2 z-[9999] flex w-full -translate-x-1/2 flex-col items-center gap-3 px-4 md:top-[calc(var(--design-unit)*88)]"
|
||||||
|
>
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
role="status"
|
||||||
|
className={cn(
|
||||||
|
'game-toast pointer-events-auto',
|
||||||
|
TOAST_TONE_CLASS_BY_TYPE[toast.type],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="game-toast-icon">
|
||||||
|
{TOAST_ICON_BY_TYPE[toast.type]}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="game-toast-content">
|
||||||
|
<div className="game-toast-title">{toast.message}</div>
|
||||||
|
{toast.description ? (
|
||||||
|
<div className="game-toast-description">{toast.description}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close notification"
|
||||||
|
onClick={() => notify.dismiss(toast.id)}
|
||||||
|
className="game-toast-close"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5 text-[#D5FBFF]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ export const APP_DEFAULT_DESCRIPTION =
|
|||||||
/** @description 认证状态持久化到浏览器时使用的存储键。 */
|
/** @description 认证状态持久化到浏览器时使用的存储键。 */
|
||||||
export const AUTH_STORAGE_KEY = 'auth-session'
|
export const AUTH_STORAGE_KEY = 'auth-session'
|
||||||
|
|
||||||
|
/** @description 应用偏好持久化到浏览器时使用的存储键。 */
|
||||||
|
export const APP_PREFERENCES_STORAGE_KEY = 'app-preferences'
|
||||||
|
|
||||||
/** @description 接口请求的默认超时时间,单位为毫秒。 */
|
/** @description 接口请求的默认超时时间,单位为毫秒。 */
|
||||||
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
@@ -48,23 +51,48 @@ export const QUERY_RETRYABLE_STATUS_CODES = [
|
|||||||
408, 429, 500, 502, 503, 504,
|
408, 429, 500, 502, 503, 504,
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** @description 国际化语言设置持久化到浏览器时使用的存储键。 */
|
|
||||||
export const I18N_LANGUAGE_STORAGE_KEY = 'app-language'
|
|
||||||
|
|
||||||
/** @description 桌面端布局切换起始断点。 */
|
/** @description 桌面端布局切换起始断点。 */
|
||||||
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
|
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
|
||||||
|
|
||||||
export const CHIP_OPTIONS = [
|
export const CHIP_IMAGE_OPTIONS = [
|
||||||
{ id: 'chip-1', value: 1, src: chip1 },
|
{ id: 'chip-1', src: chip1 },
|
||||||
{ id: 'chip-2', value: 5, src: chip2 },
|
{ id: 'chip-2', src: chip2 },
|
||||||
{ id: 'chip-3', value: 10, src: chip3 },
|
{ id: 'chip-3', src: chip3 },
|
||||||
{ id: 'chip-4', value: 25, src: chip4 },
|
{ id: 'chip-4', src: chip4 },
|
||||||
{ id: 'chip-5', value: 50, src: chip5 },
|
{ id: 'chip-5', src: chip5 },
|
||||||
{ id: 'chip-6', value: 100, src: chip6 },
|
{ id: 'chip-6', src: chip6 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const CHIP_IMAGE_MAP = new Map(
|
||||||
|
CHIP_IMAGE_OPTIONS.map((chip) => [chip.id, chip.src] as const),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const DEFAULT_CHIP_AMOUNTS = [
|
||||||
|
{ amount: 1, id: 'chip-1' },
|
||||||
|
{ amount: 5, id: 'chip-2' },
|
||||||
|
{ amount: 10, id: 'chip-3' },
|
||||||
|
{ amount: 25, id: 'chip-4' },
|
||||||
|
{ amount: 50, id: 'chip-5' },
|
||||||
|
{ amount: 100, id: 'chip-6' },
|
||||||
|
] as const
|
||||||
|
|
||||||
export const ACTION_OPTIONS = [
|
export const ACTION_OPTIONS = [
|
||||||
{ id: 'clear', label: 'Clear', Icon: Trash2, bg: controlLeft },
|
{
|
||||||
{ id: 'repeat', label: 'Repeat', Icon: Repeat2, bg: controlMid },
|
id: 'clear',
|
||||||
{ id: 'auto-spin', label: 'Auto-Spin', Icon: Settings, bg: controlRight },
|
labelKey: 'gameDesktop.control.actions.clear',
|
||||||
|
Icon: Trash2,
|
||||||
|
bg: controlLeft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'repeat',
|
||||||
|
labelKey: 'gameDesktop.control.actions.repeat',
|
||||||
|
Icon: Repeat2,
|
||||||
|
bg: controlMid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auto-spin',
|
||||||
|
labelKey: 'gameDesktop.control.actions.auto-spin',
|
||||||
|
Icon: Settings,
|
||||||
|
bg: controlRight,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
179
src/features/auth/api/auth-api.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { api } from '@/lib/api/api-client'
|
||||||
|
import { ApiError } from '@/lib/api/api-error'
|
||||||
|
import type { AuthSessionInput } from '@/store/auth'
|
||||||
|
import { getAuthDeviceId } from '@/store/auth'
|
||||||
|
import type { ApiResponse } from '@/type'
|
||||||
|
import type {
|
||||||
|
AuthSessionDto,
|
||||||
|
AuthUserProfileDto,
|
||||||
|
LoginPayload,
|
||||||
|
LoginRequestDto,
|
||||||
|
RefreshTokenDto,
|
||||||
|
RefreshTokenRequestDto,
|
||||||
|
RegisterPayload,
|
||||||
|
RegisterRequestDto,
|
||||||
|
} from './types'
|
||||||
|
import {
|
||||||
|
mergeAuthUsers,
|
||||||
|
normalizeAuthSession,
|
||||||
|
normalizeAuthUserProfile,
|
||||||
|
normalizeRefreshAuthSession,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const AUTH_ENDPOINTS = {
|
||||||
|
login: 'api/user/login',
|
||||||
|
profile: 'api/user/profile',
|
||||||
|
refreshToken: 'api/user/refreshToken',
|
||||||
|
register: 'api/user/register',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const shouldLogAuthLifecycle =
|
||||||
|
import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
||||||
|
|
||||||
|
function unwrapEnvelope<T>(
|
||||||
|
response: ApiResponse<T>,
|
||||||
|
fallbackErrorKey = 'auth.errors.requestFailed',
|
||||||
|
) {
|
||||||
|
if (response.code === 1) {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError({
|
||||||
|
data: response,
|
||||||
|
message: fallbackErrorKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAuthSessionExpiry(action: string, session: AuthSessionInput) {
|
||||||
|
if (!shouldLogAuthLifecycle || !session.accessTokenExpiresAt) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[auth] ${action} user-token expires at ${new Date(
|
||||||
|
session.accessTokenExpiresAt,
|
||||||
|
).toISOString()} (${session.accessTokenExpiresAt})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentUserProfileByToken(userToken: string) {
|
||||||
|
const response = await api.post<AuthUserProfileDto>(AUTH_ENDPOINTS.profile, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${userToken}`,
|
||||||
|
'user-token': userToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return normalizeAuthUserProfile(
|
||||||
|
unwrapEnvelope(
|
||||||
|
response as ApiResponse<AuthUserProfileDto>,
|
||||||
|
'auth.errors.requestFailed',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildEnrichedAuthSession(dto: AuthSessionDto) {
|
||||||
|
const session = normalizeAuthSession(dto)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileUser = await getCurrentUserProfileByToken(session.accessToken)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
currentUser: mergeAuthUsers(session.currentUser, profileUser),
|
||||||
|
} satisfies AuthSessionInput
|
||||||
|
} catch {
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithPassword(
|
||||||
|
payload: LoginPayload,
|
||||||
|
): Promise<AuthSessionInput> {
|
||||||
|
const response = await api.post<AuthSessionDto, LoginRequestDto>(
|
||||||
|
AUTH_ENDPOINTS.login,
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
device_id: getAuthDeviceId(),
|
||||||
|
password: payload.password,
|
||||||
|
username: payload.username,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const session = await buildEnrichedAuthSession(
|
||||||
|
unwrapEnvelope(
|
||||||
|
response as ApiResponse<AuthSessionDto>,
|
||||||
|
'auth.login.errors.submitFailed',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logAuthSessionExpiry('login', session)
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerWithPassword(
|
||||||
|
payload: RegisterPayload,
|
||||||
|
): Promise<AuthSessionInput> {
|
||||||
|
const response = await api.post<AuthSessionDto, RegisterRequestDto>(
|
||||||
|
AUTH_ENDPOINTS.register,
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
device_id: getAuthDeviceId(),
|
||||||
|
invite_code: payload.inviteCode,
|
||||||
|
password: payload.password,
|
||||||
|
username: payload.username,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const session = await buildEnrichedAuthSession(
|
||||||
|
unwrapEnvelope(
|
||||||
|
response as ApiResponse<AuthSessionDto>,
|
||||||
|
'auth.register.errors.submitFailed',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logAuthSessionExpiry('register', session)
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUserProfile() {
|
||||||
|
const response = await api.post<AuthUserProfileDto>(AUTH_ENDPOINTS.profile)
|
||||||
|
|
||||||
|
return normalizeAuthUserProfile(
|
||||||
|
unwrapEnvelope(
|
||||||
|
response as ApiResponse<AuthUserProfileDto>,
|
||||||
|
'auth.errors.requestFailed',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAuthSession(
|
||||||
|
refreshToken: string,
|
||||||
|
): Promise<AuthSessionInput | null> {
|
||||||
|
const response = await api.post<RefreshTokenDto, RefreshTokenRequestDto>(
|
||||||
|
AUTH_ENDPOINTS.refreshToken,
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
skipAuthRefresh: true,
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const session = normalizeRefreshAuthSession(
|
||||||
|
unwrapEnvelope(
|
||||||
|
response as ApiResponse<RefreshTokenDto>,
|
||||||
|
'auth.errors.requestFailed',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logAuthSessionExpiry('refresh', session)
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
140
src/features/auth/api/types.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import type { AuthSessionInput, AuthUser } from '@/store/auth'
|
||||||
|
|
||||||
|
export interface AuthApiEnvelope<T> {
|
||||||
|
code: number
|
||||||
|
data: T
|
||||||
|
message?: string
|
||||||
|
msg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthTokenDto {
|
||||||
|
auth_token: string
|
||||||
|
expires_in: number
|
||||||
|
server_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUserDto {
|
||||||
|
channel_id: number
|
||||||
|
coin: string
|
||||||
|
phone?: string
|
||||||
|
risk_flags: number
|
||||||
|
username: string
|
||||||
|
uuid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSessionDto {
|
||||||
|
expires_in: number
|
||||||
|
refresh_token?: string | null
|
||||||
|
user: AuthUserDto
|
||||||
|
'user-token': string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenDto {
|
||||||
|
expires_in: number
|
||||||
|
refresh_token?: string | null
|
||||||
|
'user-token': string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUserProfileDto {
|
||||||
|
channel_id: number
|
||||||
|
coin: string
|
||||||
|
create_time: number
|
||||||
|
current_streak: number
|
||||||
|
email: string
|
||||||
|
head_image: string
|
||||||
|
last_bet_period_no: string
|
||||||
|
phone: string
|
||||||
|
register_invite_code: string
|
||||||
|
risk_flags: number
|
||||||
|
username: string
|
||||||
|
uuid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequestDto {
|
||||||
|
device_id?: string
|
||||||
|
password: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequestDto extends LoginRequestDto {
|
||||||
|
invite_code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenRequestDto {
|
||||||
|
refresh_token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginPayload {
|
||||||
|
password: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterPayload extends LoginPayload {
|
||||||
|
inviteCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuthUser(dto: AuthUserDto): AuthUser {
|
||||||
|
return {
|
||||||
|
channelId: dto.channel_id,
|
||||||
|
coin: dto.coin,
|
||||||
|
id: dto.uuid,
|
||||||
|
name: dto.username,
|
||||||
|
phone: dto.phone,
|
||||||
|
riskFlags: dto.risk_flags,
|
||||||
|
username: dto.username,
|
||||||
|
uuid: dto.uuid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuthUserProfile(dto: AuthUserProfileDto): AuthUser {
|
||||||
|
return {
|
||||||
|
channelId: dto.channel_id,
|
||||||
|
coin: dto.coin,
|
||||||
|
createTime: dto.create_time,
|
||||||
|
currentStreak: dto.current_streak,
|
||||||
|
email: dto.email,
|
||||||
|
headImage: dto.head_image,
|
||||||
|
id: dto.uuid,
|
||||||
|
lastBetPeriodNo: dto.last_bet_period_no,
|
||||||
|
name: dto.username,
|
||||||
|
phone: dto.phone,
|
||||||
|
registerInviteCode: dto.register_invite_code,
|
||||||
|
riskFlags: dto.risk_flags,
|
||||||
|
username: dto.username,
|
||||||
|
uuid: dto.uuid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeAuthUsers(
|
||||||
|
baseUser: AuthUser | null | undefined,
|
||||||
|
profileUser: AuthUser | null | undefined,
|
||||||
|
): AuthUser | null {
|
||||||
|
if (!baseUser && !profileUser) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseUser,
|
||||||
|
...profileUser,
|
||||||
|
id: profileUser?.id ?? baseUser?.id ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuthSession(dto: AuthSessionDto): AuthSessionInput {
|
||||||
|
return {
|
||||||
|
accessToken: dto['user-token'],
|
||||||
|
accessTokenExpiresAt: Date.now() + dto.expires_in * 1000,
|
||||||
|
currentUser: normalizeAuthUser(dto.user),
|
||||||
|
refreshToken: dto.refresh_token ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRefreshAuthSession(
|
||||||
|
dto: RefreshTokenDto,
|
||||||
|
): AuthSessionInput {
|
||||||
|
return {
|
||||||
|
accessToken: dto['user-token'],
|
||||||
|
accessTokenExpiresAt: Date.now() + dto.expires_in * 1000,
|
||||||
|
refreshToken: dto.refresh_token ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/features/auth/components/desktop-auth-form-parts.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import rightImg from '@/assets/system/right.webp'
|
||||||
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function DesktopAuthFieldRow({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
labelClassName,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
label: string
|
||||||
|
labelClassName?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={'flex flex-col gap-design-10'}>
|
||||||
|
<div className={'flex items-start gap-design-16'}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-design-180 shrink-0 pt-design-10 text-left !text-design-24 text-[#58ADAF]',
|
||||||
|
labelClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className={'min-w-0 flex-1'}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopAuthInputError({ message }: { message?: string }) {
|
||||||
|
if (!message) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'pt-design-6 text-design-16 text-[#FF6A6A]'}>{message}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopAuthFooterLinks({
|
||||||
|
primaryLabel,
|
||||||
|
secondaryLabel,
|
||||||
|
}: {
|
||||||
|
primaryLabel: string
|
||||||
|
secondaryLabel: string
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex items-center justify-around'}>
|
||||||
|
{[primaryLabel, secondaryLabel].map((label) => (
|
||||||
|
<div key={label} className={'flex items-center gap-design-10'}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SmartImage alt={t('auth.common.arrowIconAlt')} src={rightImg} />
|
||||||
|
</div>
|
||||||
|
<div className={'text-[#549195]'}>{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopAuthSubmitError({
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
message?: string | null
|
||||||
|
}) {
|
||||||
|
if (!message) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-md border border-[#B93F44] bg-[rgba(78,17,23,0.35)] px-design-20 py-design-14 text-design-18 text-[#FFD2D2]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
src/features/auth/components/desktop-login-form-view.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import loginBg from '@/assets/system/login-bg.webp'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
|
import {
|
||||||
|
DesktopAuthFieldRow,
|
||||||
|
DesktopAuthFooterLinks,
|
||||||
|
DesktopAuthInputError,
|
||||||
|
DesktopAuthSubmitError,
|
||||||
|
} from './desktop-auth-form-parts'
|
||||||
|
|
||||||
|
interface DesktopLoginFormViewProps {
|
||||||
|
errors: {
|
||||||
|
password?: string
|
||||||
|
username?: string
|
||||||
|
}
|
||||||
|
isSubmitting: boolean
|
||||||
|
onPasswordChange: (value: string) => void
|
||||||
|
onSubmit: () => void
|
||||||
|
onUsernameChange: (value: string) => void
|
||||||
|
password: string
|
||||||
|
submitError?: string | null
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopLoginFormView({
|
||||||
|
errors,
|
||||||
|
isSubmitting,
|
||||||
|
onPasswordChange,
|
||||||
|
onSubmit,
|
||||||
|
onUsernameChange,
|
||||||
|
password,
|
||||||
|
submitError,
|
||||||
|
username,
|
||||||
|
}: DesktopLoginFormViewProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onSubmit()
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'flex flex-col items-center justify-between gap-design-20 px-design-20'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'h-design-375 flex flex-col gap-design-30 w-full bg-[#060B0F]/50 p-design-50'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DesktopAuthFieldRow label={t('auth.login.fields.username.label')}>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => onUsernameChange(event.target.value)}
|
||||||
|
placeholder={t('auth.login.fields.username.placeholder')}
|
||||||
|
aria-invalid={Boolean(errors.username)}
|
||||||
|
className={'h-design-58 text-left'}
|
||||||
|
/>
|
||||||
|
<DesktopAuthInputError
|
||||||
|
message={errors.username ? t(errors.username) : undefined}
|
||||||
|
/>
|
||||||
|
</DesktopAuthFieldRow>
|
||||||
|
|
||||||
|
<DesktopAuthFieldRow label={t('auth.login.fields.password.label')}>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => onPasswordChange(event.target.value)}
|
||||||
|
placeholder={t('auth.login.fields.password.placeholder')}
|
||||||
|
aria-invalid={Boolean(errors.password)}
|
||||||
|
className={'h-design-58 text-left'}
|
||||||
|
/>
|
||||||
|
<DesktopAuthInputError
|
||||||
|
message={errors.password ? t(errors.password) : undefined}
|
||||||
|
/>
|
||||||
|
</DesktopAuthFieldRow>
|
||||||
|
|
||||||
|
<DesktopAuthSubmitError
|
||||||
|
message={submitError ? t(submitError) : undefined}
|
||||||
|
/>
|
||||||
|
<DesktopAuthFooterLinks
|
||||||
|
primaryLabel={t('auth.login.footer.registerAccount')}
|
||||||
|
secondaryLabel={t('auth.login.footer.forgotPassword')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SmartBackground
|
||||||
|
as={motion.button}
|
||||||
|
type="submit"
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
src={loginBg}
|
||||||
|
size="100% 100%"
|
||||||
|
className={
|
||||||
|
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer disabled:pointer-events-none disabled:opacity-60'
|
||||||
|
}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? t('auth.common.actions.submitting')
|
||||||
|
: t('auth.login.actions.submit')}
|
||||||
|
</SmartBackground>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/features/auth/components/desktop-login-form.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useController } from 'react-hook-form'
|
||||||
|
import { useLoginForm } from '../hooks/use-login-form'
|
||||||
|
import { DesktopLoginFormView } from './desktop-login-form-view'
|
||||||
|
|
||||||
|
interface DesktopLoginFormProps {
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopLoginForm({ onSuccess }: DesktopLoginFormProps) {
|
||||||
|
const { form, isSubmitting, onSubmit, submitError } = useLoginForm({
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
const usernameField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'username',
|
||||||
|
})
|
||||||
|
const passwordField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'password',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DesktopLoginFormView
|
||||||
|
username={usernameField.field.value ?? ''}
|
||||||
|
password={passwordField.field.value ?? ''}
|
||||||
|
errors={{
|
||||||
|
password: form.formState.errors.password?.message,
|
||||||
|
username: form.formState.errors.username?.message,
|
||||||
|
}}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onPasswordChange={passwordField.field.onChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onUsernameChange={usernameField.field.onChange}
|
||||||
|
submitError={submitError}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
src/features/auth/components/desktop-register-form-view.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import loginBg from '@/assets/system/login-bg.webp'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
|
import {
|
||||||
|
DesktopAuthFieldRow,
|
||||||
|
DesktopAuthFooterLinks,
|
||||||
|
DesktopAuthInputError,
|
||||||
|
DesktopAuthSubmitError,
|
||||||
|
} from './desktop-auth-form-parts'
|
||||||
|
|
||||||
|
interface DesktopRegisterFormViewProps {
|
||||||
|
errors: {
|
||||||
|
confirmPassword?: string
|
||||||
|
inviteCode?: string
|
||||||
|
password?: string
|
||||||
|
username?: string
|
||||||
|
}
|
||||||
|
inviteCode: string
|
||||||
|
isSubmitting: boolean
|
||||||
|
onConfirmPasswordChange: (value: string) => void
|
||||||
|
onInviteCodeChange: (value: string) => void
|
||||||
|
onPasswordChange: (value: string) => void
|
||||||
|
onSubmit: () => void
|
||||||
|
onUsernameChange: (value: string) => void
|
||||||
|
password: string
|
||||||
|
confirmPassword: string
|
||||||
|
submitError?: string | null
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopRegisterFormView({
|
||||||
|
confirmPassword,
|
||||||
|
errors,
|
||||||
|
inviteCode,
|
||||||
|
isSubmitting,
|
||||||
|
onConfirmPasswordChange,
|
||||||
|
onInviteCodeChange,
|
||||||
|
onPasswordChange,
|
||||||
|
onSubmit,
|
||||||
|
onUsernameChange,
|
||||||
|
password,
|
||||||
|
submitError,
|
||||||
|
username,
|
||||||
|
}: DesktopRegisterFormViewProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onSubmit()
|
||||||
|
}}
|
||||||
|
className={'flex flex-col items-center justify-between px-design-20'}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'h-design-490 flex flex-col gap-design-26 w-full bg-[#060B0F]/50 p-design-50'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DesktopAuthFieldRow label={t('auth.register.fields.username.label')}>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => onUsernameChange(event.target.value)}
|
||||||
|
placeholder={t('auth.register.fields.username.placeholder')}
|
||||||
|
aria-invalid={Boolean(errors.username)}
|
||||||
|
className={'h-design-58 text-left'}
|
||||||
|
/>
|
||||||
|
<DesktopAuthInputError
|
||||||
|
message={errors.username ? t(errors.username) : undefined}
|
||||||
|
/>
|
||||||
|
</DesktopAuthFieldRow>
|
||||||
|
|
||||||
|
<DesktopAuthFieldRow label={t('auth.register.fields.password.label')}>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => onPasswordChange(event.target.value)}
|
||||||
|
placeholder={t('auth.register.fields.password.placeholder')}
|
||||||
|
aria-invalid={Boolean(errors.password)}
|
||||||
|
className={'h-design-58 text-left'}
|
||||||
|
/>
|
||||||
|
<DesktopAuthInputError
|
||||||
|
message={errors.password ? t(errors.password) : undefined}
|
||||||
|
/>
|
||||||
|
</DesktopAuthFieldRow>
|
||||||
|
|
||||||
|
<DesktopAuthFieldRow
|
||||||
|
label={t('auth.register.fields.confirmPassword.label')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => onConfirmPasswordChange(event.target.value)}
|
||||||
|
placeholder={t('auth.register.fields.confirmPassword.placeholder')}
|
||||||
|
aria-invalid={Boolean(errors.confirmPassword)}
|
||||||
|
className={'h-design-58 text-left'}
|
||||||
|
/>
|
||||||
|
<DesktopAuthInputError
|
||||||
|
message={
|
||||||
|
errors.confirmPassword ? t(errors.confirmPassword) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DesktopAuthFieldRow>
|
||||||
|
|
||||||
|
<DesktopAuthFieldRow
|
||||||
|
label={t('auth.register.fields.inviteCode.label')}
|
||||||
|
labelClassName="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={(event) => onInviteCodeChange(event.target.value)}
|
||||||
|
placeholder={t('auth.register.fields.inviteCode.placeholder')}
|
||||||
|
aria-invalid={Boolean(errors.inviteCode)}
|
||||||
|
className={'h-design-58 max-w-design-520 text-left'}
|
||||||
|
/>
|
||||||
|
<DesktopAuthInputError
|
||||||
|
message={errors.inviteCode ? t(errors.inviteCode) : undefined}
|
||||||
|
/>
|
||||||
|
</DesktopAuthFieldRow>
|
||||||
|
|
||||||
|
<DesktopAuthSubmitError
|
||||||
|
message={submitError ? t(submitError) : undefined}
|
||||||
|
/>
|
||||||
|
<DesktopAuthFooterLinks
|
||||||
|
primaryLabel={t('auth.register.footer.alreadyHaveAccount')}
|
||||||
|
secondaryLabel={t('auth.register.footer.needHelp')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SmartBackground
|
||||||
|
as={motion.button}
|
||||||
|
type="submit"
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
src={loginBg}
|
||||||
|
size="100% 100%"
|
||||||
|
className={
|
||||||
|
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer disabled:pointer-events-none disabled:opacity-60'
|
||||||
|
}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? t('auth.common.actions.submitting')
|
||||||
|
: t('auth.register.actions.submit')}
|
||||||
|
</SmartBackground>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/features/auth/components/desktop-register-form.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useController } from 'react-hook-form'
|
||||||
|
import { useRegisterForm } from '../hooks/use-register-form'
|
||||||
|
import { DesktopRegisterFormView } from './desktop-register-form-view'
|
||||||
|
|
||||||
|
interface DesktopRegisterFormProps {
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopRegisterForm({ onSuccess }: DesktopRegisterFormProps) {
|
||||||
|
const { form, isSubmitting, onSubmit, submitError } = useRegisterForm({
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
const usernameField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'username',
|
||||||
|
})
|
||||||
|
const passwordField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'password',
|
||||||
|
})
|
||||||
|
const confirmPasswordField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'confirmPassword',
|
||||||
|
})
|
||||||
|
const inviteCodeField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'inviteCode',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DesktopRegisterFormView
|
||||||
|
username={usernameField.field.value ?? ''}
|
||||||
|
password={passwordField.field.value ?? ''}
|
||||||
|
confirmPassword={confirmPasswordField.field.value ?? ''}
|
||||||
|
inviteCode={inviteCodeField.field.value ?? ''}
|
||||||
|
errors={{
|
||||||
|
confirmPassword: form.formState.errors.confirmPassword?.message,
|
||||||
|
inviteCode: form.formState.errors.inviteCode?.message,
|
||||||
|
password: form.formState.errors.password?.message,
|
||||||
|
username: form.formState.errors.username?.message,
|
||||||
|
}}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onConfirmPasswordChange={confirmPasswordField.field.onChange}
|
||||||
|
onInviteCodeChange={inviteCodeField.field.onChange}
|
||||||
|
onPasswordChange={passwordField.field.onChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onUsernameChange={usernameField.field.onChange}
|
||||||
|
submitError={submitError}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
src/features/auth/hooks/auth-error-key.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { ApiError } from '@/lib/api/api-error'
|
||||||
|
|
||||||
|
type AuthSubmitContext = 'login' | 'register'
|
||||||
|
|
||||||
|
const AUTH_ERROR_KEY_PREFIX = 'auth.'
|
||||||
|
|
||||||
|
function isTranslationKey(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && value.startsWith(AUTH_ERROR_KEY_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackKeyByContext(context: AuthSubmitContext) {
|
||||||
|
return context === 'login'
|
||||||
|
? 'auth.login.errors.submitFailed'
|
||||||
|
: 'auth.register.errors.submitFailed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAuthSubmitErrorKey(
|
||||||
|
error: unknown,
|
||||||
|
context: AuthSubmitContext,
|
||||||
|
) {
|
||||||
|
if (!error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackKey = fallbackKeyByContext(context)
|
||||||
|
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (isTranslationKey(error.message)) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.status === 408) {
|
||||||
|
return 'auth.errors.timeout'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.status === 401) {
|
||||||
|
return context === 'login'
|
||||||
|
? 'auth.login.errors.invalidCredentials'
|
||||||
|
: 'auth.register.errors.unauthorized'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error.status === 'number' && error.status >= 500) {
|
||||||
|
return 'auth.errors.serviceUnavailable'
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && isTranslationKey(error.message)) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackKey
|
||||||
|
}
|
||||||
11
src/features/auth/hooks/use-auth.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { type ModalKey, useModalStore } from '@/store'
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
|
const handleLogin = (modalKey: ModalKey, open: boolean) => {
|
||||||
|
setModalOpen(modalKey, open)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleLogin }
|
||||||
|
}
|
||||||
47
src/features/auth/hooks/use-login-form.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
import { notify } from '@/lib/notify'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { loginWithPassword } from '../api/auth-api'
|
||||||
|
import { type LoginFormValues, loginFormSchema } from '../schema/auth-schema'
|
||||||
|
import { toAuthSubmitErrorKey } from './auth-error-key'
|
||||||
|
import { createZodResolver } from './zod-form-resolver'
|
||||||
|
|
||||||
|
interface UseLoginFormOptions {
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoginForm({ onSuccess }: UseLoginFormOptions = {}) {
|
||||||
|
const startSession = useAuthStore((state) => state.startSession)
|
||||||
|
const form = useForm<LoginFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
password: '',
|
||||||
|
username: '',
|
||||||
|
},
|
||||||
|
mode: 'onBlur',
|
||||||
|
resolver: createZodResolver(loginFormSchema),
|
||||||
|
})
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: loginWithPassword,
|
||||||
|
onError: (error) => {
|
||||||
|
const errorKey = toAuthSubmitErrorKey(error, 'login')
|
||||||
|
|
||||||
|
if (errorKey) {
|
||||||
|
notify.error(i18n.t(errorKey))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (session) => {
|
||||||
|
startSession(session)
|
||||||
|
notify.success(i18n.t('commonUi.toast.loginSuccess'))
|
||||||
|
onSuccess?.()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
isSubmitting: mutation.isPending,
|
||||||
|
onSubmit: form.handleSubmit((values) => mutation.mutateAsync(values)),
|
||||||
|
submitError: toAuthSubmitErrorKey(mutation.error, 'login'),
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/features/auth/hooks/use-register-form.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
import { notify } from '@/lib/notify'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { registerWithPassword } from '../api/auth-api'
|
||||||
|
import {
|
||||||
|
type RegisterFormValues,
|
||||||
|
registerFormSchema,
|
||||||
|
} from '../schema/auth-schema'
|
||||||
|
import { toAuthSubmitErrorKey } from './auth-error-key'
|
||||||
|
import { createZodResolver } from './zod-form-resolver'
|
||||||
|
|
||||||
|
interface UseRegisterFormOptions {
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegisterForm({ onSuccess }: UseRegisterFormOptions = {}) {
|
||||||
|
const startSession = useAuthStore((state) => state.startSession)
|
||||||
|
const form = useForm<RegisterFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
confirmPassword: '',
|
||||||
|
inviteCode: '',
|
||||||
|
password: '',
|
||||||
|
username: '',
|
||||||
|
},
|
||||||
|
mode: 'onBlur',
|
||||||
|
resolver: createZodResolver(registerFormSchema),
|
||||||
|
})
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (values: RegisterFormValues) => {
|
||||||
|
const { confirmPassword: _confirmPassword, ...payload } = values
|
||||||
|
|
||||||
|
return registerWithPassword(payload)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const errorKey = toAuthSubmitErrorKey(error, 'register')
|
||||||
|
|
||||||
|
if (errorKey) {
|
||||||
|
notify.error(i18n.t(errorKey))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (session) => {
|
||||||
|
startSession(session)
|
||||||
|
notify.success(i18n.t('commonUi.toast.registerSuccess'))
|
||||||
|
onSuccess?.()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
isSubmitting: mutation.isPending,
|
||||||
|
onSubmit: form.handleSubmit((values) => mutation.mutateAsync(values)),
|
||||||
|
submitError: toAuthSubmitErrorKey(mutation.error, 'register'),
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/features/auth/hooks/zod-form-resolver.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type {
|
||||||
|
FieldErrors,
|
||||||
|
FieldValues,
|
||||||
|
Resolver,
|
||||||
|
ResolverResult,
|
||||||
|
} from 'react-hook-form'
|
||||||
|
import type { ZodType } from 'zod'
|
||||||
|
|
||||||
|
function setNestedError(
|
||||||
|
errors: FieldErrors,
|
||||||
|
path: Array<string | number>,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
const [head, ...rest] = path
|
||||||
|
|
||||||
|
if (head === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rest.length === 0) {
|
||||||
|
errors[String(head)] = {
|
||||||
|
message,
|
||||||
|
type: 'manual',
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = String(head)
|
||||||
|
const next = (errors[key] as FieldErrors | undefined) ?? {}
|
||||||
|
errors[key] = next
|
||||||
|
setNestedError(next, rest, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createZodResolver<TValues extends FieldValues>(
|
||||||
|
schema: ZodType<TValues>,
|
||||||
|
): Resolver<TValues> {
|
||||||
|
return async (values): Promise<ResolverResult<TValues>> => {
|
||||||
|
const result = await schema.safeParseAsync(values)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return {
|
||||||
|
errors: {},
|
||||||
|
values: result.data,
|
||||||
|
} satisfies ResolverResult<TValues>
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: FieldErrors = {}
|
||||||
|
|
||||||
|
for (const issue of result.error.issues) {
|
||||||
|
setNestedError(
|
||||||
|
errors,
|
||||||
|
issue.path.filter(
|
||||||
|
(segment): segment is string | number =>
|
||||||
|
typeof segment === 'string' || typeof segment === 'number',
|
||||||
|
),
|
||||||
|
issue.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
values: {} as TValues,
|
||||||
|
} as ResolverResult<TValues>
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/features/auth/schema/auth-schema.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const mobilePhonePattern = /^1[3-9]\d{9}$/
|
||||||
|
|
||||||
|
const usernameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, 'auth.validation.username.required')
|
||||||
|
.regex(mobilePhonePattern, 'auth.validation.username.invalidPhone')
|
||||||
|
|
||||||
|
const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(6, 'auth.validation.password.min')
|
||||||
|
.max(32, 'auth.validation.password.max')
|
||||||
|
|
||||||
|
export const loginFormSchema = z.object({
|
||||||
|
password: passwordSchema,
|
||||||
|
username: usernameSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const registerFormSchema = z
|
||||||
|
.object({
|
||||||
|
confirmPassword: passwordSchema,
|
||||||
|
inviteCode: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, 'auth.validation.inviteCode.required')
|
||||||
|
.max(32, 'auth.validation.inviteCode.max'),
|
||||||
|
password: passwordSchema,
|
||||||
|
username: usernameSchema,
|
||||||
|
})
|
||||||
|
.refine((value) => value.password === value.confirmPassword, {
|
||||||
|
message: 'auth.validation.confirmPassword.mismatch',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export type LoginFormValues = z.infer<typeof loginFormSchema>
|
||||||
|
export type RegisterFormValues = z.infer<typeof registerFormSchema>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { api } from '@/lib/api/api-client'
|
import { api } from '@/lib/api/api-client'
|
||||||
|
import { ApiError } from '@/lib/api/api-error'
|
||||||
|
import type { ApiResponse } from '@/type'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AnnouncementItem,
|
AnnouncementItem,
|
||||||
@@ -9,10 +11,17 @@ import type {
|
|||||||
GameBootstrapSnapshot,
|
GameBootstrapSnapshot,
|
||||||
GameCell,
|
GameCell,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
|
RoundPhase,
|
||||||
RoundSnapshot,
|
RoundSnapshot,
|
||||||
TrendEntry,
|
TrendEntry,
|
||||||
} from '../shared'
|
} from '../shared'
|
||||||
import { createMockGameBootstrapSnapshot } from '../shared'
|
import {
|
||||||
|
createMockGameBootstrapSnapshot,
|
||||||
|
DEFAULT_GAME_CHIP_COLORS,
|
||||||
|
deriveTrendEntries,
|
||||||
|
GAME_GRID_COLUMNS,
|
||||||
|
GAME_MAX_SELECTION_CELLS,
|
||||||
|
} from '../shared'
|
||||||
import type {
|
import type {
|
||||||
AnnouncementStateDto,
|
AnnouncementStateDto,
|
||||||
BetSelectionDto,
|
BetSelectionDto,
|
||||||
@@ -20,20 +29,72 @@ import type {
|
|||||||
ConnectionStateDto,
|
ConnectionStateDto,
|
||||||
DashboardStateDto,
|
DashboardStateDto,
|
||||||
GameAnnouncementsDto,
|
GameAnnouncementsDto,
|
||||||
|
GameBetOrdersDto,
|
||||||
GameBootstrapDto,
|
GameBootstrapDto,
|
||||||
GameCellDto,
|
GameCellDto,
|
||||||
|
GameLobbyInitDto,
|
||||||
|
GameLobbyPeriodDto,
|
||||||
|
GamePeriodTickDto,
|
||||||
GameRoundFeedDto,
|
GameRoundFeedDto,
|
||||||
HistoryEntryDto,
|
HistoryEntryDto,
|
||||||
|
NoticeConfirmDto,
|
||||||
|
NoticeDetailDto,
|
||||||
|
NoticeListDto,
|
||||||
RoundSnapshotDto,
|
RoundSnapshotDto,
|
||||||
TrendEntryDto,
|
TrendEntryDto,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
|
function unwrapGameEnvelope<T>(
|
||||||
|
response: ApiResponse<T>,
|
||||||
|
fallbackMessage = 'Game request failed',
|
||||||
|
) {
|
||||||
|
if (response.code === 1) {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError({
|
||||||
|
data: response,
|
||||||
|
message:
|
||||||
|
typeof response.msg === 'string' && response.msg.length > 0
|
||||||
|
? response.msg
|
||||||
|
: fallbackMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLobbyInitDto(
|
||||||
|
dto: GameLobbyInitDto,
|
||||||
|
): asserts dto is GameLobbyInitDto {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(dto.server_time) ||
|
||||||
|
!Array.isArray(dto.dictionary) ||
|
||||||
|
!dto.bet_config ||
|
||||||
|
!Number.isFinite(dto.bet_config.default_bet_chip_id)
|
||||||
|
) {
|
||||||
|
throw new ApiError({
|
||||||
|
data: dto,
|
||||||
|
message: 'Invalid game lobby init payload',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const GAME_API_ENDPOINTS = {
|
export const GAME_API_ENDPOINTS = {
|
||||||
announcements: 'game/announcements',
|
announcements: 'game/announcements',
|
||||||
|
betMyOrders: 'api/game/betMyOrders',
|
||||||
bootstrap: 'game/bootstrap',
|
bootstrap: 'game/bootstrap',
|
||||||
|
lobbyInit: 'api/game/lobbyInit',
|
||||||
|
noticeConfirm: 'api/notice/noticeConfirm',
|
||||||
|
noticeDetail: 'api/notice/noticeDetail',
|
||||||
|
noticeList: 'api/notice/noticeList',
|
||||||
roundFeed: 'game/round-feed',
|
roundFeed: 'game/round-feed',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
export interface GameLobbyInitResult {
|
||||||
|
runtimeEnabled: boolean
|
||||||
|
serverTime: number
|
||||||
|
snapshot: GameBootstrapSnapshot
|
||||||
|
userSnapshot: GameLobbyInitDto['user_snapshot']
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeGameCell(dto: GameCellDto) {
|
function normalizeGameCell(dto: GameCellDto) {
|
||||||
return dto satisfies GameCell
|
return dto satisfies GameCell
|
||||||
}
|
}
|
||||||
@@ -136,6 +197,193 @@ function normalizeConnectionState(dto: ConnectionStateDto) {
|
|||||||
} satisfies ConnectionState
|
} satisfies ConnectionState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toIsoFromUnixSeconds(seconds: number) {
|
||||||
|
const timestamp = Number(seconds)
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
|
||||||
|
if (!Number.isFinite(timestamp) || Number.isNaN(date.valueOf())) {
|
||||||
|
throw new ApiError({
|
||||||
|
data: { seconds },
|
||||||
|
message: 'Invalid unix timestamp',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLobbyRoundPhase(
|
||||||
|
status: GameLobbyPeriodDto['status'],
|
||||||
|
runtimeEnabled: boolean,
|
||||||
|
): RoundPhase {
|
||||||
|
if (!runtimeEnabled && status === 'betting') {
|
||||||
|
return 'locked'
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'betting':
|
||||||
|
return 'betting'
|
||||||
|
case 'locked':
|
||||||
|
return 'locked'
|
||||||
|
case 'settling':
|
||||||
|
return 'revealing'
|
||||||
|
case 'payouting':
|
||||||
|
case 'finished':
|
||||||
|
case 'void':
|
||||||
|
return 'settled'
|
||||||
|
default:
|
||||||
|
return 'waiting'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLobbyChips(
|
||||||
|
chips: Record<string, string>,
|
||||||
|
defaultBetChipId: number,
|
||||||
|
) {
|
||||||
|
return Object.entries(chips)
|
||||||
|
.sort(([leftId], [rightId]) => Number(leftId) - Number(rightId))
|
||||||
|
.map(([chipId, chipAmount], index) => {
|
||||||
|
const amount = Number(chipAmount)
|
||||||
|
|
||||||
|
return {
|
||||||
|
amount: Number.isFinite(amount) ? amount : 0,
|
||||||
|
color:
|
||||||
|
DEFAULT_GAME_CHIP_COLORS[index % DEFAULT_GAME_CHIP_COLORS.length] ??
|
||||||
|
DEFAULT_GAME_CHIP_COLORS[0],
|
||||||
|
id: `chip-${chipId}`,
|
||||||
|
isDefault: Number(chipId) === defaultBetChipId,
|
||||||
|
label: chipAmount,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLobbyCells(dictionary: GameLobbyInitDto['dictionary']) {
|
||||||
|
return [...dictionary]
|
||||||
|
.sort((left, right) => left.number - right.number)
|
||||||
|
.map(
|
||||||
|
(item, index) =>
|
||||||
|
({
|
||||||
|
column: (index % GAME_GRID_COLUMNS) + 1,
|
||||||
|
id: item.number,
|
||||||
|
label: item.name,
|
||||||
|
odds: 36,
|
||||||
|
row: Math.floor(index / GAME_GRID_COLUMNS) + 1,
|
||||||
|
}) satisfies GameCell,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLobbyRound(
|
||||||
|
lobbyInit: Pick<
|
||||||
|
GameLobbyInitDto,
|
||||||
|
'period' | 'runtime_enabled' | 'server_time'
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
if (!lobbyInit.period) {
|
||||||
|
return {
|
||||||
|
bettingClosesAt: toIsoFromUnixSeconds(lobbyInit.server_time),
|
||||||
|
id: '',
|
||||||
|
phase: 'waiting',
|
||||||
|
revealingAt: toIsoFromUnixSeconds(lobbyInit.server_time),
|
||||||
|
settledAt: null,
|
||||||
|
startedAt: toIsoFromUnixSeconds(lobbyInit.server_time),
|
||||||
|
winningCellId: null,
|
||||||
|
} satisfies RoundSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bettingClosesAt: toIsoFromUnixSeconds(lobbyInit.period.lock_at),
|
||||||
|
id: lobbyInit.period.period_no,
|
||||||
|
phase: normalizeLobbyRoundPhase(
|
||||||
|
lobbyInit.period.status,
|
||||||
|
lobbyInit.runtime_enabled,
|
||||||
|
),
|
||||||
|
revealingAt: toIsoFromUnixSeconds(lobbyInit.period.open_at),
|
||||||
|
settledAt: toIsoFromUnixSeconds(lobbyInit.period.open_at),
|
||||||
|
startedAt: toIsoFromUnixSeconds(lobbyInit.server_time),
|
||||||
|
winningCellId: null,
|
||||||
|
} satisfies RoundSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePeriodTickRound(
|
||||||
|
period: GamePeriodTickDto,
|
||||||
|
previousRound?: Pick<RoundSnapshot, 'id' | 'startedAt'> | null,
|
||||||
|
) {
|
||||||
|
const startedAt =
|
||||||
|
previousRound?.id === period.period_no
|
||||||
|
? previousRound.startedAt
|
||||||
|
: toIsoFromUnixSeconds(period.server_time)
|
||||||
|
const countdownSeconds = Math.max(0, period.countdown)
|
||||||
|
const betCloseSeconds = Math.max(0, period.bet_close_in)
|
||||||
|
const phase = normalizeLobbyRoundPhase(period.status, period.runtime_enabled)
|
||||||
|
const nextPhaseAt = toIsoFromUnixSeconds(
|
||||||
|
period.server_time + countdownSeconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
bettingClosesAt: toIsoFromUnixSeconds(period.server_time + betCloseSeconds),
|
||||||
|
id: period.period_no,
|
||||||
|
phase,
|
||||||
|
revealingAt: nextPhaseAt,
|
||||||
|
settledAt: nextPhaseAt,
|
||||||
|
startedAt,
|
||||||
|
winningCellId:
|
||||||
|
typeof period.result_number === 'number' ? period.result_number : null,
|
||||||
|
} satisfies RoundSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeGameLobbyInit(dto: GameLobbyInitDto) {
|
||||||
|
const baseIso = toIsoFromUnixSeconds(dto.server_time)
|
||||||
|
const template = createMockGameBootstrapSnapshot(baseIso)
|
||||||
|
const cells = normalizeLobbyCells(dto.dictionary)
|
||||||
|
const chips = normalizeLobbyChips(
|
||||||
|
dto.bet_config.chips,
|
||||||
|
dto.bet_config.default_bet_chip_id,
|
||||||
|
)
|
||||||
|
const round = normalizeLobbyRound({
|
||||||
|
period: null,
|
||||||
|
runtime_enabled: dto.runtime_enabled,
|
||||||
|
server_time: dto.server_time,
|
||||||
|
})
|
||||||
|
const trends = deriveTrendEntries([])
|
||||||
|
|
||||||
|
return {
|
||||||
|
announcements: {
|
||||||
|
activeAnnouncementId: null,
|
||||||
|
items: [],
|
||||||
|
lastUpdatedAt: null,
|
||||||
|
} satisfies AnnouncementState,
|
||||||
|
cells,
|
||||||
|
chips: chips.length > 0 ? chips : template.chips,
|
||||||
|
connection: {
|
||||||
|
...template.connection,
|
||||||
|
connectedAt: null,
|
||||||
|
lastError: null,
|
||||||
|
lastMessageAt: null,
|
||||||
|
latencyMs: null,
|
||||||
|
reconnectAttempt: 0,
|
||||||
|
status: 'idle',
|
||||||
|
transport: 'polling',
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
countdownMs: 0,
|
||||||
|
featuredCellId: null,
|
||||||
|
onlinePlayers: 0,
|
||||||
|
tableLimitMax: Number(dto.bet_config.max_bet_per_number) || 0,
|
||||||
|
tableLimitMin: Number(dto.bet_config.min_bet_per_number) || 0,
|
||||||
|
totalPoolAmount: 0,
|
||||||
|
updatedAt: baseIso,
|
||||||
|
} satisfies DashboardState,
|
||||||
|
history: [],
|
||||||
|
maxSelectionCount:
|
||||||
|
Number.isFinite(dto.bet_config.pick_max_number_count) &&
|
||||||
|
dto.bet_config.pick_max_number_count > 0
|
||||||
|
? Math.min(36, Math.floor(dto.bet_config.pick_max_number_count))
|
||||||
|
: GAME_MAX_SELECTION_CELLS,
|
||||||
|
round,
|
||||||
|
selections: [],
|
||||||
|
trends,
|
||||||
|
} satisfies GameBootstrapSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeGameBootstrap(dto: GameBootstrapDto) {
|
export function normalizeGameBootstrap(dto: GameBootstrapDto) {
|
||||||
return {
|
return {
|
||||||
announcements: normalizeAnnouncementState(dto.announcements),
|
announcements: normalizeAnnouncementState(dto.announcements),
|
||||||
@@ -144,6 +392,7 @@ export function normalizeGameBootstrap(dto: GameBootstrapDto) {
|
|||||||
connection: normalizeConnectionState(dto.connection),
|
connection: normalizeConnectionState(dto.connection),
|
||||||
dashboard: normalizeDashboardState(dto.dashboard),
|
dashboard: normalizeDashboardState(dto.dashboard),
|
||||||
history: dto.history.map(normalizeHistoryEntry),
|
history: dto.history.map(normalizeHistoryEntry),
|
||||||
|
maxSelectionCount: GAME_MAX_SELECTION_CELLS,
|
||||||
round: normalizeRoundSnapshot(dto.round),
|
round: normalizeRoundSnapshot(dto.round),
|
||||||
selections: dto.selections.map(normalizeBetSelection),
|
selections: dto.selections.map(normalizeBetSelection),
|
||||||
trends: dto.trends.map(normalizeTrendEntry),
|
trends: dto.trends.map(normalizeTrendEntry),
|
||||||
@@ -164,22 +413,125 @@ export function normalizeGameRoundFeed(dto: GameRoundFeedDto) {
|
|||||||
|
|
||||||
export async function getGameBootstrap() {
|
export async function getGameBootstrap() {
|
||||||
const response = await api.get<GameBootstrapDto>(GAME_API_ENDPOINTS.bootstrap)
|
const response = await api.get<GameBootstrapDto>(GAME_API_ENDPOINTS.bootstrap)
|
||||||
|
const dto = unwrapGameEnvelope(
|
||||||
|
response as ApiResponse<GameBootstrapDto>,
|
||||||
|
'Failed to load game bootstrap',
|
||||||
|
)
|
||||||
|
|
||||||
return normalizeGameBootstrap(response.data)
|
return normalizeGameBootstrap(dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGameRoundFeed() {
|
export async function getGameRoundFeed() {
|
||||||
const response = await api.get<GameRoundFeedDto>(GAME_API_ENDPOINTS.roundFeed)
|
const response = await api.get<GameRoundFeedDto>(GAME_API_ENDPOINTS.roundFeed)
|
||||||
|
const dto = unwrapGameEnvelope(
|
||||||
|
response as ApiResponse<GameRoundFeedDto>,
|
||||||
|
'Failed to load game round feed',
|
||||||
|
)
|
||||||
|
|
||||||
return normalizeGameRoundFeed(response.data)
|
return normalizeGameRoundFeed(dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGameAnnouncements() {
|
export async function getGameAnnouncements() {
|
||||||
const response = await api.get<GameAnnouncementsDto>(
|
const response = await api.get<GameAnnouncementsDto>(
|
||||||
GAME_API_ENDPOINTS.announcements,
|
GAME_API_ENDPOINTS.announcements,
|
||||||
)
|
)
|
||||||
|
const dto = unwrapGameEnvelope(
|
||||||
|
response as ApiResponse<GameAnnouncementsDto>,
|
||||||
|
'Failed to load game announcements',
|
||||||
|
)
|
||||||
|
|
||||||
return normalizeAnnouncementState(response.data.announcements)
|
return normalizeAnnouncementState(dto.announcements)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGameLobbyInit() {
|
||||||
|
const response = await api.post<GameLobbyInitDto>(
|
||||||
|
GAME_API_ENDPOINTS.lobbyInit,
|
||||||
|
)
|
||||||
|
const dto = unwrapGameEnvelope(
|
||||||
|
response as ApiResponse<GameLobbyInitDto>,
|
||||||
|
'Failed to load game lobby init',
|
||||||
|
)
|
||||||
|
assertLobbyInitDto(dto)
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtimeEnabled: dto.runtime_enabled,
|
||||||
|
serverTime: dto.server_time,
|
||||||
|
snapshot: normalizeGameLobbyInit(dto),
|
||||||
|
userSnapshot: dto.user_snapshot,
|
||||||
|
} satisfies GameLobbyInitResult
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNoticeList(params?: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}) {
|
||||||
|
const response = await api.get<NoticeListDto>(GAME_API_ENDPOINTS.noticeList, {
|
||||||
|
searchParams: {
|
||||||
|
page: String(params?.page ?? 1),
|
||||||
|
page_size: String(params?.pageSize ?? 20),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const dto = unwrapGameEnvelope(
|
||||||
|
response as ApiResponse<NoticeListDto>,
|
||||||
|
'Failed to load notice list',
|
||||||
|
)
|
||||||
|
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNoticeDetail(id: number) {
|
||||||
|
const response = await api.get<NoticeDetailDto>(
|
||||||
|
GAME_API_ENDPOINTS.noticeDetail,
|
||||||
|
{
|
||||||
|
searchParams: {
|
||||||
|
id: String(id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const dto = unwrapGameEnvelope(
|
||||||
|
response as ApiResponse<NoticeDetailDto>,
|
||||||
|
'Failed to load notice detail',
|
||||||
|
)
|
||||||
|
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmNotice(noticeId: number) {
|
||||||
|
const response = await api.get<NoticeConfirmDto>(
|
||||||
|
GAME_API_ENDPOINTS.noticeConfirm,
|
||||||
|
{
|
||||||
|
searchParams: {
|
||||||
|
notice_id: String(noticeId),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const dto = unwrapGameEnvelope(
|
||||||
|
response as ApiResponse<NoticeConfirmDto>,
|
||||||
|
'Failed to confirm notice',
|
||||||
|
)
|
||||||
|
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGameBetMyOrders(params: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}) {
|
||||||
|
const response = await api.post<GameBetOrdersDto>(
|
||||||
|
GAME_API_ENDPOINTS.betMyOrders,
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
page: params.page ?? 1,
|
||||||
|
page_size: params.pageSize ?? 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const dto = unwrapGameEnvelope(
|
||||||
|
response as ApiResponse<GameBetOrdersDto>,
|
||||||
|
'Failed to load bet orders',
|
||||||
|
)
|
||||||
|
|
||||||
|
return dto
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMockGameBootstrap(latencyMs = 120) {
|
export async function getMockGameBootstrap(latencyMs = 120) {
|
||||||
|
|||||||
@@ -123,6 +123,116 @@ export interface GameAnnouncementsDto {
|
|||||||
announcements: AnnouncementStateDto
|
announcements: AnnouncementStateDto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NoticeListItemDto {
|
||||||
|
is_read: boolean
|
||||||
|
notice_id: number
|
||||||
|
notice_type: 'silent' | 'popout'
|
||||||
|
publish_time: number
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeListDto {
|
||||||
|
list: NoticeListItemDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeDetailDto {
|
||||||
|
content: string
|
||||||
|
must_confirm: boolean
|
||||||
|
notice_id: number
|
||||||
|
notice_type: 'silent' | 'popout'
|
||||||
|
publish_time: number
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeConfirmDto {
|
||||||
|
confirm_time: number
|
||||||
|
confirmed: boolean
|
||||||
|
notice_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GamePeriodStatus =
|
||||||
|
| 'betting'
|
||||||
|
| 'locked'
|
||||||
|
| 'settling'
|
||||||
|
| 'payouting'
|
||||||
|
| 'finished'
|
||||||
|
| 'void'
|
||||||
|
| (string & {})
|
||||||
|
|
||||||
|
export interface GameLobbyPeriodDto {
|
||||||
|
countdown: number
|
||||||
|
lock_at: number
|
||||||
|
open_at: number
|
||||||
|
period_no: string
|
||||||
|
status: GamePeriodStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLobbyBetConfigDto {
|
||||||
|
chips: Record<string, string>
|
||||||
|
default_bet_chip_id: number
|
||||||
|
max_bet_per_number: string
|
||||||
|
min_bet_per_number: string
|
||||||
|
pick_max_number_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLobbyDictionaryItemDto {
|
||||||
|
category: string
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
number: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLobbyUserSnapshotDto {
|
||||||
|
coin: string
|
||||||
|
current_streak: number
|
||||||
|
is_jackpot?: boolean
|
||||||
|
odds_factor?: number
|
||||||
|
streak_level?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLobbyInitDto {
|
||||||
|
bet_config: GameLobbyBetConfigDto
|
||||||
|
dictionary: GameLobbyDictionaryItemDto[]
|
||||||
|
period?: GameLobbyPeriodDto | null
|
||||||
|
runtime_enabled: boolean
|
||||||
|
server_time: number
|
||||||
|
user_snapshot: GameLobbyUserSnapshotDto
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePeriodTickDto {
|
||||||
|
bet_close_in: number
|
||||||
|
countdown: number
|
||||||
|
period_id: number | null
|
||||||
|
period_no: string
|
||||||
|
result_number: number | null
|
||||||
|
runtime_enabled: boolean
|
||||||
|
server_time: number
|
||||||
|
status: GamePeriodStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameBetOrderDto {
|
||||||
|
bet_amount: string
|
||||||
|
create_time: number
|
||||||
|
numbers: number[]
|
||||||
|
order_no: string
|
||||||
|
period_no: string
|
||||||
|
result_number: number | null
|
||||||
|
status: string
|
||||||
|
total_amount: string
|
||||||
|
win_amount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameBetOrdersPaginationDto {
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameBetOrdersDto {
|
||||||
|
list: GameBetOrderDto[]
|
||||||
|
pagination: GameBetOrdersPaginationDto
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AnnouncementState,
|
AnnouncementState,
|
||||||
Chip,
|
Chip,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import diamondIcon from '@/assets/system/diamond.webp'
|
||||||
import { SmartImage } from '@/components/smart-image'
|
import { SmartImage } from '@/components/smart-image'
|
||||||
|
import { notify } from '@/lib/notify'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useAuthStore, useModalStore } from '@/store'
|
||||||
|
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||||
|
|
||||||
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
|
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
|
||||||
eager: true,
|
eager: true,
|
||||||
@@ -18,6 +24,37 @@ const animalImageList = Object.entries(animalModules)
|
|||||||
.filter((item) => item.id > 0)
|
.filter((item) => item.id > 0)
|
||||||
.sort((left, right) => left.id - right.id)
|
.sort((left, right) => left.id - right.id)
|
||||||
|
|
||||||
|
function getNextMarqueeId(currentId: number | null) {
|
||||||
|
if (animalImageList.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animalImageList.length === 1) {
|
||||||
|
return animalImageList[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = currentId
|
||||||
|
|
||||||
|
while (nextId === currentId) {
|
||||||
|
nextId =
|
||||||
|
animalImageList[Math.floor(Math.random() * animalImageList.length)]?.id ??
|
||||||
|
currentId
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextId
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSelectedLog(
|
||||||
|
selectionByCell: Record<number, { amount: number; count: number }>,
|
||||||
|
) {
|
||||||
|
return Object.entries(selectionByCell)
|
||||||
|
.map(([cellId, value]) => ({
|
||||||
|
字花: String(cellId).padStart(2, '0'),
|
||||||
|
筹码: value.amount,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => Number(left.字花) - Number(right.字花))
|
||||||
|
}
|
||||||
|
|
||||||
interface DesktopAnimalProps {
|
interface DesktopAnimalProps {
|
||||||
activeId?: number | null
|
activeId?: number | null
|
||||||
className?: string
|
className?: string
|
||||||
@@ -33,40 +70,220 @@ export function DesktopAnimal({
|
|||||||
imageClassName,
|
imageClassName,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: DesktopAnimalProps) {
|
}: DesktopAnimalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const authStatus = useAuthStore((state) => state.status)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||||
|
const chips = useGameRoundStore((state) => state.chips)
|
||||||
|
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||||
|
const maxSelectionCount = useGameRoundStore(
|
||||||
|
(state) => state.maxSelectionCount,
|
||||||
|
)
|
||||||
|
const placeBet = useGameRoundStore((state) => state.placeBet)
|
||||||
|
const removeSelectionsForCell = useGameRoundStore(
|
||||||
|
(state) => state.removeSelectionsForCell,
|
||||||
|
)
|
||||||
|
const selections = useGameRoundStore((state) => state.selections)
|
||||||
|
const connection = useGameSessionStore((state) => state.connection)
|
||||||
|
const requestRealtimeConnection = useGameSessionStore(
|
||||||
|
(state) => state.requestRealtimeConnection,
|
||||||
|
)
|
||||||
|
const shouldConnectRealtime = useGameSessionStore(
|
||||||
|
(state) => state.shouldConnectRealtime,
|
||||||
|
)
|
||||||
|
const [marqueeId, setMarqueeId] = useState<number | null>(() =>
|
||||||
|
getNextMarqueeId(null),
|
||||||
|
)
|
||||||
|
const activeChip = useMemo(
|
||||||
|
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
|
||||||
|
[activeChipId, chips],
|
||||||
|
)
|
||||||
|
const selectionByCell = useMemo(() => {
|
||||||
|
return selections.reduce<Record<number, { amount: number; count: number }>>(
|
||||||
|
(accumulator, selection) => {
|
||||||
|
const current = accumulator[selection.cellId] ?? { amount: 0, count: 0 }
|
||||||
|
|
||||||
|
accumulator[selection.cellId] = {
|
||||||
|
amount: current.amount + selection.amount,
|
||||||
|
count: current.count + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
}, [selections])
|
||||||
|
|
||||||
|
const isRealtimeConnected = connection.status === 'connected'
|
||||||
|
const isRealtimeConnecting =
|
||||||
|
shouldConnectRealtime &&
|
||||||
|
(connection.status === 'connecting' || connection.status === 'reconnecting')
|
||||||
|
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
|
||||||
|
const lockInteraction = showStandbyState
|
||||||
|
const isSelectedCell = (animalId: number) =>
|
||||||
|
Boolean(selectionByCell[animalId])
|
||||||
|
const selectedCellCount = Object.keys(selectionByCell).length
|
||||||
|
|
||||||
|
const handleStart = () => {
|
||||||
|
if (authStatus !== 'authenticated') {
|
||||||
|
notify.warning(t('commonUi.toast.loginRequired'))
|
||||||
|
setModalOpen('desktopLogin', true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelections()
|
||||||
|
requestRealtimeConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (animalId: number) => {
|
||||||
|
if (showStandbyState) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSelect) {
|
||||||
|
onSelect(animalId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelectedCell(animalId)) {
|
||||||
|
const nextSelectionByCell = { ...selectionByCell }
|
||||||
|
delete nextSelectionByCell[animalId]
|
||||||
|
console.log('已选', formatSelectedLog(nextSelectionByCell))
|
||||||
|
removeSelectionsForCell(animalId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCellCount >= maxSelectionCount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'已选',
|
||||||
|
formatSelectedLog({
|
||||||
|
...selectionByCell,
|
||||||
|
[animalId]: {
|
||||||
|
amount: activeChip?.amount ?? 0,
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
placeBet(animalId)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showStandbyState) {
|
||||||
|
setMarqueeId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMarqueeId((currentId) => getNextMarqueeId(currentId))
|
||||||
|
|
||||||
|
let timerId = 0
|
||||||
|
|
||||||
|
const loop = () => {
|
||||||
|
setMarqueeId((currentId) => getNextMarqueeId(currentId))
|
||||||
|
timerId = window.setTimeout(loop, 180 + Math.floor(Math.random() * 220))
|
||||||
|
}
|
||||||
|
|
||||||
|
timerId = window.setTimeout(loop, 220)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timerId)
|
||||||
|
}
|
||||||
|
}, [showStandbyState])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid w-full grid-cols-6 gap-design-5 common-neon-inset',
|
'relative grid w-full grid-cols-6 gap-design-5 overflow-hidden common-neon-inset',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{animalImageList.map((item) => {
|
{animalImageList.map((item) => {
|
||||||
const isActive = item.id === activeId
|
const selectionMeta = selectionByCell[item.id]
|
||||||
|
const hasPlacedSelection = Boolean(selectionMeta)
|
||||||
|
const isActive = item.id === activeId || hasPlacedSelection
|
||||||
|
const isMarqueeActive = showStandbyState && item.id === marqueeId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect?.(item.id)}
|
disabled={lockInteraction}
|
||||||
|
onClick={() => handleSelect(item.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col items-center transition',
|
'relative flex flex-col items-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
|
||||||
'cursor-pointer',
|
lockInteraction
|
||||||
|
? 'cursor-not-allowed opacity-90'
|
||||||
|
: 'cursor-pointer hover:-translate-y-[1px]',
|
||||||
|
isMarqueeActive &&
|
||||||
|
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
|
||||||
isActive &&
|
isActive &&
|
||||||
'border-[rgba(255,151,15,0.95)] shadow-[inset_0_0_16px_rgba(255,151,15,0.55)]',
|
'border-[rgba(255,187,61,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,175,52,0.82),0_0_calc(var(--design-unit)*30)_rgba(255,151,15,0.46),inset_0_0_calc(var(--design-unit)*20)_rgba(255,177,70,0.58)]',
|
||||||
|
!showStandbyState && !hasPlacedSelection && 'opacity-95',
|
||||||
itemClassName,
|
itemClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150',
|
||||||
|
isMarqueeActive &&
|
||||||
|
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
|
||||||
|
isActive &&
|
||||||
|
'bg-[radial-gradient(circle_at_center,rgba(255,207,116,0.42)_0%,rgba(255,181,61,0.16)_42%,transparent_74%)] opacity-100',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!showStandbyState && !hasPlacedSelection ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-[calc(var(--design-unit)*2)] z-20 rounded-[calc(var(--design-unit)*15)] bg-[rgba(4,16,24,0.52)] shadow-[inset_0_0_calc(var(--design-unit)*20)_rgba(3,9,14,0.56)]"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<SmartImage
|
<SmartImage
|
||||||
src={item.url}
|
src={item.url}
|
||||||
alt={`animal-${item.id}`}
|
alt={`animal-${item.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-design-112 w-design-223 rounded-2xl object-contain',
|
'relative z-10 h-design-112 w-design-223 rounded-2xl object-contain',
|
||||||
imageClassName,
|
imageClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{hasPlacedSelection ? (
|
||||||
|
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||||
|
<span className="flex min-w-design-96 items-center justify-center gap-design-4 rounded-full border border-[rgba(162,242,255,0.48)] bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-10 py-design-6 shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.18)]">
|
||||||
|
<SmartImage
|
||||||
|
src={diamondIcon}
|
||||||
|
alt="diamond"
|
||||||
|
className="h-design-24 w-design-24 shrink-0 object-contain"
|
||||||
|
/>
|
||||||
|
<span className="text-design-18 font-semibold leading-none tracking-[0.06em] text-[#D8FBFF]">
|
||||||
|
{selectionMeta.amount}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{showStandbyState ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleStart}
|
||||||
|
className="absolute inset-0 z-10 flex cursor-pointer items-center justify-center bg-[rgba(3,13,20,0.62)]"
|
||||||
|
>
|
||||||
|
<div className="relative flex flex-col items-center gap-design-8 rounded-[calc(var(--design-unit)*20)] border border-[rgba(111,255,247,0.54)] bg-[linear-gradient(180deg,rgba(6,28,38,0.92),rgba(4,14,20,0.94))] px-design-28 py-design-16 text-center shadow-[0_0_calc(var(--design-unit)*16)_rgba(70,245,255,0.34),0_0_calc(var(--design-unit)*34)_rgba(19,210,232,0.22)] transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-[1px] hover:border-[rgba(141,255,250,0.8)] hover:shadow-[0_0_calc(var(--design-unit)*22)_rgba(88,247,255,0.48),0_0_calc(var(--design-unit)*42)_rgba(32,228,255,0.3)]">
|
||||||
|
<span className="text-design-14 uppercase tracking-[0.42em] text-[rgba(111,255,247,0.76)]">
|
||||||
|
{isRealtimeConnecting ? '' : t('gameDesktop.animal.tapToEnter')}
|
||||||
|
</span>
|
||||||
|
<span className="text-design-28 font-semibold tracking-[0.18em] text-[#D2FFFF]">
|
||||||
|
{isRealtimeConnecting
|
||||||
|
? t('gameDesktop.animal.loading')
|
||||||
|
: t('gameDesktop.animal.getStart')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import add from '@/assets/game/add.webp'
|
import add from '@/assets/game/add.webp'
|
||||||
import arrow from '@/assets/game/arrow.webp'
|
import arrow from '@/assets/game/arrow.webp'
|
||||||
import chipBg from '@/assets/game/chip-bg.webp'
|
import chipBg from '@/assets/game/chip-bg.webp'
|
||||||
@@ -9,16 +10,18 @@ import controlBg from '@/assets/game/control-bg.png'
|
|||||||
import leftBottomBg from '@/assets/game/left-bg.webp'
|
import leftBottomBg from '@/assets/game/left-bg.webp'
|
||||||
import reduce from '@/assets/game/reduce.webp'
|
import reduce from '@/assets/game/reduce.webp'
|
||||||
import totalBg from '@/assets/game/total-bg.webp'
|
import totalBg from '@/assets/game/total-bg.webp'
|
||||||
|
import diamond from '@/assets/system/diamond.webp'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import { ACTION_OPTIONS } from '@/constants'
|
import { ACTION_OPTIONS } from '@/constants'
|
||||||
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
|
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function DesktopControl() {
|
export function DesktopControl() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const {
|
const {
|
||||||
canClear,
|
canClear,
|
||||||
chips,
|
chips,
|
||||||
|
maxSelectionCountLabel,
|
||||||
onChipSelect,
|
onChipSelect,
|
||||||
onClearSelections,
|
onClearSelections,
|
||||||
selectedChipAmountLabel,
|
selectedChipAmountLabel,
|
||||||
@@ -26,7 +29,6 @@ export function DesktopControl() {
|
|||||||
selectedCountLabel,
|
selectedCountLabel,
|
||||||
totalBetAmountLabel,
|
totalBetAmountLabel,
|
||||||
} = useGameControlVm()
|
} = useGameControlVm()
|
||||||
|
|
||||||
const [clickedId, setClickedId] = useState<string | null>(null)
|
const [clickedId, setClickedId] = useState<string | null>(null)
|
||||||
const [hidingId, setHidingId] = useState<string | null>(null)
|
const [hidingId, setHidingId] = useState<string | null>(null)
|
||||||
const [confirmClicked, setConfirmClicked] = useState(false)
|
const [confirmClicked, setConfirmClicked] = useState(false)
|
||||||
@@ -74,8 +76,8 @@ export function DesktopControl() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={'flex flex-col items-center justify-center'}>
|
<div className={'flex flex-col items-center justify-center'}>
|
||||||
<div>TREBD</div>
|
<div>{t('gameDesktop.control.trend')}</div>
|
||||||
<div>MAP</div>
|
<div>{t('gameDesktop.control.map')}</div>
|
||||||
</div>
|
</div>
|
||||||
<SmartImage
|
<SmartImage
|
||||||
src={arrow}
|
src={arrow}
|
||||||
@@ -110,10 +112,10 @@ export function DesktopControl() {
|
|||||||
transition={{
|
transition={{
|
||||||
layout: {
|
layout: {
|
||||||
type: 'spring',
|
type: 'spring',
|
||||||
stiffness: 420,
|
stiffness: 360,
|
||||||
damping: 32,
|
damping: 26,
|
||||||
},
|
},
|
||||||
duration: 0.18,
|
duration: 0.26,
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
'relative flex h-design-70 w-design-70 shrink-0 cursor-pointer items-center justify-center rounded-full'
|
'relative flex h-design-70 w-design-70 shrink-0 cursor-pointer items-center justify-center rounded-full'
|
||||||
@@ -178,15 +180,16 @@ export function DesktopControl() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
layout
|
||||||
animate={
|
animate={
|
||||||
isSelected
|
isSelected
|
||||||
? {
|
? {
|
||||||
y: [-1, -3, -1],
|
y: [-1, -4, -1],
|
||||||
scale: [1.02, 1.06, 1.02],
|
scale: [1.04, 1.1, 1.04],
|
||||||
filter: [
|
filter: [
|
||||||
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
|
'drop-shadow(0 8px 10px rgba(0,0,0,0.22))',
|
||||||
'drop-shadow(0 10px 14px rgba(245, 200, 107, 0.22))',
|
'drop-shadow(0 12px 16px rgba(245, 200, 107, 0.28))',
|
||||||
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
|
'drop-shadow(0 8px 10px rgba(0,0,0,0.22))',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -205,6 +208,27 @@ export function DesktopControl() {
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
className={'h-design-70 w-design-70 object-contain'}
|
className={'h-design-70 w-design-70 object-contain'}
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'pointer-events-none absolute inset-x-0 top-1/2 z-[8] -translate-y-[calc(50%-1*var(--design-unit))] text-center text-design-16 font-black leading-none tracking-[0.06em] text-[rgba(96,54,0,0.85)] blur-[1px]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{chip.valueLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-[calc(50%+1*var(--design-unit))] text-center text-design-16 font-black leading-none tracking-[0.06em] text-[rgba(66,28,0,0.72)]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{chip.valueLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'pointer-events-none absolute inset-x-0 top-1/2 z-[11] -translate-y-1/2 text-center text-design-16 font-black leading-none tracking-[0.06em] text-white [text-shadow:0_1px_0_rgba(255,255,255,0.6),0_2px_4px_rgba(0,0,0,0.72),0_0_10px_rgba(255,255,255,0.22)]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{chip.valueLabel}
|
||||||
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
)
|
)
|
||||||
@@ -237,11 +261,26 @@ export function DesktopControl() {
|
|||||||
src={totalBg}
|
src={totalBg}
|
||||||
size="100% 100%"
|
size="100% 100%"
|
||||||
className={
|
className={
|
||||||
'desktop-control-total relative flex flex-col items-center justify-center z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
|
'desktop-control-total relative flex items-center justify-center text-design-20 gap-design-40 z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>SELECTED:{selectedCountLabel}</div>
|
<div>
|
||||||
<div>Total Bet:{totalBetAmountLabel}</div>
|
{t('gameDesktop.control.selected')}:{' '}
|
||||||
|
<span className={'text-red-500'}>{selectedCountLabel}</span> /{' '}
|
||||||
|
{maxSelectionCountLabel}
|
||||||
|
</div>
|
||||||
|
<div className={'flex'}>
|
||||||
|
<div>{t('gameDesktop.control.totalBet')}:</div>
|
||||||
|
|
||||||
|
<div className={'flex items-center gap-design-10'}>
|
||||||
|
<SmartImage
|
||||||
|
className={'w-design-30 h-design-30'}
|
||||||
|
src={diamond}
|
||||||
|
alt={'diamond'}
|
||||||
|
/>
|
||||||
|
<div>{totalBetAmountLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
src={controlBg}
|
src={controlBg}
|
||||||
@@ -250,7 +289,7 @@ export function DesktopControl() {
|
|||||||
'desktop-control-actions relative z-10 flex h-full w-design-385 shrink-0 items-center bg-center bg-no-repeat pl-design-15',
|
'desktop-control-actions relative z-10 flex h-full w-design-385 shrink-0 items-center bg-center bg-no-repeat pl-design-15',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{ACTION_OPTIONS.map(({ id, label, Icon, bg }) => {
|
{ACTION_OPTIONS.map(({ id, labelKey, Icon, bg }) => {
|
||||||
const isClicked = clickedId === id
|
const isClicked = clickedId === id
|
||||||
const isHiding = hidingId === id
|
const isHiding = hidingId === id
|
||||||
const showBg = isClicked || isHiding
|
const showBg = isClicked || isHiding
|
||||||
@@ -315,7 +354,7 @@ export function DesktopControl() {
|
|||||||
className={showBg ? 'text-[#D9FEFF]' : 'text-[#37D5CB]'}
|
className={showBg ? 'text-[#D9FEFF]' : 'text-[#37D5CB]'}
|
||||||
/>
|
/>
|
||||||
<div className={'mt-design-6 text-design-14 leading-none'}>
|
<div className={'mt-design-6 text-design-14 leading-none'}>
|
||||||
{label}
|
{t(labelKey)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
@@ -351,7 +390,7 @@ export function DesktopControl() {
|
|||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
confirm
|
{t('gameDesktop.control.confirm')}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,54 @@
|
|||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import historyBg from '@/assets/system/history-bg.png'
|
import historyBg from '@/assets/system/history-bg.png'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts'
|
import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts'
|
||||||
|
|
||||||
export function DesktopGameHistory() {
|
export function DesktopGameHistory() {
|
||||||
const { emptyText, isEmpty, items } = useGameHistoryVm()
|
const { t } = useTranslation()
|
||||||
|
const {
|
||||||
|
emptyText,
|
||||||
|
endText,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isEmpty,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isInitialLoading,
|
||||||
|
items,
|
||||||
|
loadingText,
|
||||||
|
} = useGameHistoryVm()
|
||||||
|
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const rowCount = hasNextPage ? items.length + 1 : items.length
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rowCount,
|
||||||
|
estimateSize: () => 196,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
overscan: 4,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const virtualItems = virtualizer.getVirtualItems()
|
||||||
|
const lastItem = virtualItems[virtualItems.length - 1]
|
||||||
|
|
||||||
|
if (
|
||||||
|
!lastItem ||
|
||||||
|
!hasNextPage ||
|
||||||
|
isFetchingNextPage ||
|
||||||
|
lastItem.index < items.length - 1
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchNextPage()
|
||||||
|
}, [
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
items.length,
|
||||||
|
virtualizer,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
@@ -16,14 +61,23 @@ export function DesktopGameHistory() {
|
|||||||
'relative z-20 flex h-design-50 shrink-0 items-center justify-center text-design-30 text-[#D5FBFF]'
|
'relative z-20 flex h-design-50 shrink-0 items-center justify-center text-design-30 text-[#D5FBFF]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
History
|
{t('gameDesktop.history.title')}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
ref={parentRef}
|
||||||
className={
|
className={
|
||||||
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
|
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isEmpty ? (
|
{isInitialLoading ? (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loadingText}
|
||||||
|
</div>
|
||||||
|
) : isEmpty ? (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
|
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
|
||||||
@@ -32,56 +86,98 @@ export function DesktopGameHistory() {
|
|||||||
{emptyText}
|
{emptyText}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => {
|
<div
|
||||||
return (
|
className="relative w-full"
|
||||||
<div
|
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||||
key={item.id}
|
>
|
||||||
className={
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
const item = items[virtualRow.index]
|
||||||
}
|
|
||||||
>
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
key={item?.id ?? `loader-${virtualRow.index}`}
|
||||||
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
|
className="absolute left-0 top-0 w-full"
|
||||||
}
|
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||||
>
|
>
|
||||||
{item.statusLabel}
|
{item ? (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.statusLabel}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className={'text-[#84A2A2]'}>
|
||||||
|
{t('gameDesktop.history.orderNo')}:{' '}
|
||||||
|
</span>
|
||||||
|
<span className={'text-[#C0E7EB]'}>
|
||||||
|
{item.orderNo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={'text-[#84A2A2]'}>
|
||||||
|
{t('gameDesktop.history.roundId')}:{' '}
|
||||||
|
</span>
|
||||||
|
<span className={'text-[#C0E7EB]'}>
|
||||||
|
{item.periodNo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={'text-[#84A2A2]'}>
|
||||||
|
{t('gameDesktop.history.numbers')}:{' '}
|
||||||
|
</span>
|
||||||
|
<span>{item.numbersLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={'text-[#84A2A2]'}>
|
||||||
|
{t('gameDesktop.history.settledAt')}:{' '}
|
||||||
|
</span>
|
||||||
|
<span>{item.createdAtLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={'text-[#84A2A2]'}>
|
||||||
|
{t('gameDesktop.history.totalPoolAmount')}:{' '}
|
||||||
|
</span>
|
||||||
|
<span className={'text-[#FFE375]'}>
|
||||||
|
{item.amountLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={'text-[#84A2A2]'}>
|
||||||
|
{t('gameDesktop.history.winningResult')}:{' '}
|
||||||
|
</span>
|
||||||
|
<span className={'text-[#FF7575]'}>
|
||||||
|
{item.resultNumberLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={'text-[#84A2A2]'}>
|
||||||
|
{t('gameDesktop.history.payout')}:{' '}
|
||||||
|
</span>
|
||||||
|
<span>{item.winAmountLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[calc(var(--design-unit)*60)] items-center justify-center text-design-16 text-[#84A2A2]">
|
||||||
|
{isFetchingNextPage ? loadingText : endText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
)
|
||||||
className={
|
})}
|
||||||
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
|
</div>
|
||||||
}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span className={'text-[#84A2A2]'}>Round ID: </span>
|
|
||||||
<span className={'text-[#C0E7EB]'}>{item.roundId}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={'text-[#84A2A2]'}>Settled At: </span>
|
|
||||||
<span>{item.settledAtLabel}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={'text-[#84A2A2]'}>
|
|
||||||
Total Pool Amount:{' '}
|
|
||||||
</span>
|
|
||||||
<span className={'text-[#FFE375]'}>
|
|
||||||
{item.totalPoolAmountLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={'text-[#84A2A2]'}>Winning Result: </span>
|
|
||||||
<span className={'text-[#FF7575]'}>
|
|
||||||
{item.winningCellIdLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={'text-[#84A2A2]'}>Payout: </span>
|
|
||||||
<span>{item.payoutMultiplierLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
|
|||||||
@@ -1,10 +1,260 @@
|
|||||||
import { CircleAlert, Mail, Volume2 } from 'lucide-react'
|
import { CircleAlert, Mail, Maximize, Minimize, Volume2 } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import avatar from '@/assets/system/avatar.webp'
|
import avatar from '@/assets/system/avatar.webp'
|
||||||
|
import diamond from '@/assets/system/diamond.webp'
|
||||||
import logo from '@/assets/system/logo.webp'
|
import logo from '@/assets/system/logo.webp'
|
||||||
import wifi from '@/assets/system/wifi.webp'
|
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import {
|
||||||
|
isDesktopFullscreen,
|
||||||
|
subscribeDesktopFullscreenChange,
|
||||||
|
toggleDesktopFullscreen,
|
||||||
|
} from '@/lib/utils'
|
||||||
|
import { useAuthStore, useGameSessionStore, useModalStore } from '@/store'
|
||||||
|
|
||||||
|
type BrowserNetworkInformation = {
|
||||||
|
addEventListener?: (type: 'change', listener: () => void) => void
|
||||||
|
downlink?: number
|
||||||
|
effectiveType?: string
|
||||||
|
removeEventListener?: (type: 'change', listener: () => void) => void
|
||||||
|
rtt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignalPresentation = {
|
||||||
|
activeBars: number
|
||||||
|
latencyLabel: string
|
||||||
|
toneClassName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimezoneOffset(date: Date) {
|
||||||
|
const offsetMinutes = -date.getTimezoneOffset()
|
||||||
|
const sign = offsetMinutes >= 0 ? '+' : '-'
|
||||||
|
const absoluteMinutes = Math.abs(offsetMinutes)
|
||||||
|
const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0')
|
||||||
|
const minutes = String(absoluteMinutes % 60).padStart(2, '0')
|
||||||
|
|
||||||
|
return `GMT${sign}${hours}${minutes === '00' ? '' : `:${minutes}`}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeaderTime(date: Date) {
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${hours}:${minutes}:${seconds} ${formatTimezoneOffset(date)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBrowserNetworkInformation() {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (navigator as Navigator & { connection?: BrowserNetworkInformation })
|
||||||
|
.connection
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSignalPresentation(input: {
|
||||||
|
isOnline: boolean
|
||||||
|
latencyMs: number | null
|
||||||
|
status: string
|
||||||
|
}) {
|
||||||
|
if (!input.isOnline || input.status === 'disconnected') {
|
||||||
|
return {
|
||||||
|
activeBars: 0,
|
||||||
|
latencyLabel: '--',
|
||||||
|
toneClassName: 'text-[#FF6B6B]',
|
||||||
|
} satisfies SignalPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.latencyMs === null) {
|
||||||
|
return {
|
||||||
|
activeBars: input.status === 'connected' ? 2 : 1,
|
||||||
|
latencyLabel: '--',
|
||||||
|
toneClassName: 'text-[#7F8EA3]',
|
||||||
|
} satisfies SignalPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.latencyMs <= 80) {
|
||||||
|
return {
|
||||||
|
activeBars: 4,
|
||||||
|
latencyLabel: String(input.latencyMs),
|
||||||
|
toneClassName: 'text-[#74FF69]',
|
||||||
|
} satisfies SignalPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.latencyMs <= 150) {
|
||||||
|
return {
|
||||||
|
activeBars: 3,
|
||||||
|
latencyLabel: String(input.latencyMs),
|
||||||
|
toneClassName: 'text-[#B7FF6A]',
|
||||||
|
} satisfies SignalPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.latencyMs <= 300) {
|
||||||
|
return {
|
||||||
|
activeBars: 2,
|
||||||
|
latencyLabel: String(input.latencyMs),
|
||||||
|
toneClassName: 'text-[#FFD76A]',
|
||||||
|
} satisfies SignalPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeBars: 1,
|
||||||
|
latencyLabel: String(input.latencyMs),
|
||||||
|
toneClassName: 'text-[#FF8A6A]',
|
||||||
|
} satisfies SignalPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignalBars({
|
||||||
|
activeBars,
|
||||||
|
toneClassName,
|
||||||
|
}: {
|
||||||
|
activeBars: number
|
||||||
|
toneClassName: string
|
||||||
|
}) {
|
||||||
|
const barHeights = ['h-[6px]', 'h-[10px]', 'h-[14px]', 'h-[18px]'] as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-design-20 w-design-28 items-end gap-[2px]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{barHeights.map((heightClassName, index) => {
|
||||||
|
const isActive = index < activeBars
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={heightClassName}
|
||||||
|
className={[
|
||||||
|
'w-[5px] rounded-t-[2px] transition-colors',
|
||||||
|
heightClassName,
|
||||||
|
isActive ? `bg-current ${toneClassName}` : 'bg-white/18',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function DesktopHeader() {
|
export function DesktopHeader() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
const [clockNow, setClockNow] = useState(() => Date.now())
|
||||||
|
const [isOnline, setIsOnline] = useState(() =>
|
||||||
|
typeof navigator === 'undefined' ? true : navigator.onLine,
|
||||||
|
)
|
||||||
|
const [browserNetworkRttMs, setBrowserNetworkRttMs] = useState<number | null>(
|
||||||
|
() => {
|
||||||
|
const rtt = getBrowserNetworkInformation()?.rtt
|
||||||
|
|
||||||
|
return typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0
|
||||||
|
? rtt
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const currentUser = useAuthStore((state) => state.currentUser)
|
||||||
|
const authStatus = useAuthStore((state) => state.status)
|
||||||
|
const connection = useGameSessionStore((state) => state.connection)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
|
const serverClockOffsetMs = useMemo(() => {
|
||||||
|
if (
|
||||||
|
connection.status !== 'connected' ||
|
||||||
|
connection.transport !== 'websocket' ||
|
||||||
|
!connection.lastMessageAt
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverTimestamp = Date.parse(connection.lastMessageAt)
|
||||||
|
|
||||||
|
if (Number.isNaN(serverTimestamp)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverTimestamp - Date.now()
|
||||||
|
}, [connection.lastMessageAt, connection.status, connection.transport])
|
||||||
|
|
||||||
|
const systemTimeLabel = useMemo(() => {
|
||||||
|
const activeTimestamp =
|
||||||
|
serverClockOffsetMs === null ? clockNow : clockNow + serverClockOffsetMs
|
||||||
|
|
||||||
|
return formatHeaderTime(new Date(activeTimestamp))
|
||||||
|
}, [clockNow, serverClockOffsetMs])
|
||||||
|
|
||||||
|
const signalLatencyMs = useMemo(() => {
|
||||||
|
if (
|
||||||
|
typeof connection.latencyMs === 'number' &&
|
||||||
|
Number.isFinite(connection.latencyMs) &&
|
||||||
|
connection.latencyMs >= 0
|
||||||
|
) {
|
||||||
|
return connection.latencyMs
|
||||||
|
}
|
||||||
|
|
||||||
|
return browserNetworkRttMs
|
||||||
|
}, [browserNetworkRttMs, connection.latencyMs])
|
||||||
|
|
||||||
|
const signalPresentation = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveSignalPresentation({
|
||||||
|
isOnline,
|
||||||
|
latencyMs: signalLatencyMs,
|
||||||
|
status: connection.status,
|
||||||
|
}),
|
||||||
|
[connection.status, isOnline, signalLatencyMs],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const syncFullscreenState = () => {
|
||||||
|
setIsFullscreen(isDesktopFullscreen())
|
||||||
|
}
|
||||||
|
syncFullscreenState()
|
||||||
|
return subscribeDesktopFullscreenChange(syncFullscreenState)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setClockNow(Date.now())
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const syncBrowserNetworkState = () => {
|
||||||
|
setIsOnline(navigator.onLine)
|
||||||
|
|
||||||
|
const rtt = getBrowserNetworkInformation()?.rtt
|
||||||
|
|
||||||
|
setBrowserNetworkRttMs(
|
||||||
|
typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0 ? rtt : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const networkInformation = getBrowserNetworkInformation()
|
||||||
|
|
||||||
|
syncBrowserNetworkState()
|
||||||
|
window.addEventListener('online', syncBrowserNetworkState)
|
||||||
|
window.addEventListener('offline', syncBrowserNetworkState)
|
||||||
|
networkInformation?.addEventListener?.('change', syncBrowserNetworkState)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', syncBrowserNetworkState)
|
||||||
|
window.removeEventListener('offline', syncBrowserNetworkState)
|
||||||
|
networkInformation?.removeEventListener?.(
|
||||||
|
'change',
|
||||||
|
syncBrowserNetworkState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleFullscreenToggle = async () => {
|
||||||
|
await toggleDesktopFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
|
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
|
||||||
<div className="flex h-design-70 w-full items-center px-design-12">
|
<div className="flex h-design-70 w-full items-center px-design-12">
|
||||||
@@ -18,92 +268,124 @@ export function DesktopHeader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full w-design-130 items-center justify-center gap-design-10 border-r border-[rgba(128,223,231,0.65)]">
|
<div className="flex h-full w-design-130 items-center justify-center gap-design-10 border-r border-[rgba(128,223,231,0.65)]">
|
||||||
<SmartImage
|
<div className={signalPresentation.toneClassName}>
|
||||||
src={wifi}
|
<SignalBars
|
||||||
alt="wifi"
|
activeBars={signalPresentation.activeBars}
|
||||||
priority
|
toneClassName={signalPresentation.toneClassName}
|
||||||
className="h-design-20 w-design-28"
|
/>
|
||||||
/>
|
</div>
|
||||||
<div className={'text-[#74FF69] text-design-20'}>
|
<div className={`${signalPresentation.toneClassName} text-design-20`}>
|
||||||
24 <span className={'text-design-16'}>ms</span>
|
{signalPresentation.latencyLabel}{' '}
|
||||||
|
<span className={'text-design-16'}>ms</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full w-design-175 flex-col items-center justify-center gap-design-5 border-r border-[rgba(128,223,231,0.65)]">
|
<div className="flex h-full w-design-175 flex-col items-center justify-center gap-design-5 border-r border-[rgba(128,223,231,0.65)]">
|
||||||
<div>System Time</div>
|
<div>{t('gameDesktop.header.systemTime')}</div>
|
||||||
<div>20:05:12 GMT+08</div>
|
<div>{systemTimeLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full flex-1 items-center justify-around gap-design-10 px-design-40 text-[#D5FBFF] border-r border-[rgba(128,223,231,0.65)]">
|
<div className="flex h-full flex-1 items-center justify-around gap-design-10 border-r border-[rgba(128,223,231,0.65)] px-design-20">
|
||||||
<div
|
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
|
||||||
className={
|
|
||||||
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CircleAlert color={'#57B8BF'} size={16} />
|
<CircleAlert color={'#57B8BF'} size={16} />
|
||||||
<div>Rules & Ddds</div>
|
<div>{t('gameDesktop.header.rules')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
|
||||||
className={
|
|
||||||
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Mail color={'#57B8BF'} size={16} />
|
<Mail color={'#57B8BF'} size={16} />
|
||||||
<div>Pesan</div>
|
<div>{t('gameDesktop.header.message')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
|
||||||
className={
|
|
||||||
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Volume2 color={'#57B8BF'} size={16} />
|
<Volume2 color={'#57B8BF'} size={16} />
|
||||||
<div>BGM</div>
|
<div>{t('gameDesktop.header.bgm')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
|
||||||
|
<CircleAlert color={'#57B8BF'} size={16} />
|
||||||
|
<div>{t('gameDesktop.header.id')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFullscreenToggle}
|
||||||
|
className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85"
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<Minimize color={'#57B8BF'} size={16} />
|
||||||
|
) : (
|
||||||
|
<Maximize color={'#57B8BF'} size={16} />
|
||||||
|
)}
|
||||||
|
<div>{t('gameDesktop.header.fullscreen')}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authStatus === 'authenticated' ? (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
|
'flex items-center justify-center gap-design-30 pl-design-30 pr-design-10'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CircleAlert color={'#57B8BF'} size={16} />
|
<div className={'relative flex items-center justify-center'}>
|
||||||
<div>ID</div>
|
<SmartImage
|
||||||
</div>
|
src={avatar}
|
||||||
</div>
|
alt="avatar"
|
||||||
|
priority
|
||||||
|
className="absolute -left-5 z-20 h-design-50 w-design-50"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'common-neon-inset text-design-16 !py-design-20 flex h-design-36 w-design-180 items-center justify-end'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{currentUser?.username || '--'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={'flex items-center justify-center px-design-35'}>
|
<div className={'relative flex items-center justify-center'}>
|
||||||
<div className={'relative flex items-center justify-center'}>
|
<SmartImage
|
||||||
<SmartImage
|
src={diamond}
|
||||||
src={avatar}
|
alt="diamond"
|
||||||
alt="avatar"
|
priority
|
||||||
priority
|
className="absolute -left-5 z-20 h-design-50 w-design-50"
|
||||||
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
|
/>
|
||||||
/>
|
<div
|
||||||
<div
|
className={
|
||||||
className={
|
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-design-180 items-center justify-end'
|
||||||
'common-neon-inset !py-design-20 flex h-design-36 w-design-160 items-center justify-end'
|
}
|
||||||
}
|
>
|
||||||
>
|
{currentUser?.coin || '--'}
|
||||||
Biomond Balance
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={'relative flex items-center justify-center'}>
|
) : (
|
||||||
<SmartImage
|
<div
|
||||||
src={avatar}
|
className={
|
||||||
alt="avatar"
|
'flex items-center justify-center gap-design-30 pl-design-30 pr-design-10'
|
||||||
priority
|
}
|
||||||
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
|
>
|
||||||
/>
|
<button
|
||||||
<div
|
type="button"
|
||||||
className={
|
className={
|
||||||
'common-neon-inset !py-design-20 box-border flex h-design-36 w-design-160 items-center justify-end'
|
'min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85'
|
||||||
}
|
}
|
||||||
|
onClick={() => setModalOpen('desktopLogin', true)}
|
||||||
>
|
>
|
||||||
Biomond Balance
|
<CircleAlert color={'#57B8BF'} size={16} />
|
||||||
</div>
|
<div>{t('gameDesktop.header.login')}</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
'min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85'
|
||||||
|
}
|
||||||
|
onClick={() => setModalOpen('desktopRegister', true)}
|
||||||
|
>
|
||||||
|
<CircleAlert color={'#57B8BF'} size={16} />
|
||||||
|
<div>{t('gameDesktop.header.register')}</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import statusCenter from '@/assets/system/status-center.webp'
|
import statusCenter from '@/assets/system/status-center.webp'
|
||||||
import statusLine from '@/assets/system/status-line.webp'
|
import statusLine from '@/assets/system/status-line.webp'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
@@ -6,6 +7,7 @@ import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.t
|
|||||||
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
|
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
|
||||||
|
|
||||||
export function DesktopStatusLine() {
|
export function DesktopStatusLine() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const {
|
const {
|
||||||
countdownMs,
|
countdownMs,
|
||||||
limitLabel,
|
limitLabel,
|
||||||
@@ -27,9 +29,15 @@ export function DesktopStatusLine() {
|
|||||||
<div
|
<div
|
||||||
className={'flex-1 flex items-center justify-center gap-design-24'}
|
className={'flex-1 flex items-center justify-center gap-design-24'}
|
||||||
>
|
>
|
||||||
<div>Odds: {oddsLabel}</div>
|
<div>
|
||||||
<div>Streak: {streakLabel}</div>
|
{t('gameDesktop.status.odds')}: {oddsLabel}
|
||||||
<div>Limit: {limitLabel}</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('gameDesktop.status.streak')}: {streakLabel}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('gameDesktop.status.limit')}: {limitLabel}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
src={statusCenter}
|
src={statusCenter}
|
||||||
@@ -44,7 +52,9 @@ export function DesktopStatusLine() {
|
|||||||
/>
|
/>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
<div className={'flex-1 flex items-center justify-center gap-10'}>
|
<div className={'flex-1 flex items-center justify-center gap-10'}>
|
||||||
<div>Round ID:{roundId}</div>
|
<div>
|
||||||
|
{t('gameDesktop.status.roundId')}:{roundId}
|
||||||
|
</div>
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Megaphone } from 'lucide-react'
|
import { Megaphone } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
export function DesktopTitle() {
|
export function DesktopTitle() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="common-neon-inset text-design-16 w-full flex h-design-50 items-end gap-design-10 !px-design-20 text-[#FF970F]">
|
<section className="common-neon-inset text-design-16 w-full flex h-design-50 items-end gap-design-10 !px-design-20 text-[#FF970F]">
|
||||||
<Megaphone color={'#57B8BF'} />
|
<Megaphone color={'#57B8BF'} />
|
||||||
<div>
|
<div>{t('gameDesktop.title.announcement')}</div>
|
||||||
Selamat kepada pemain Wu Yanzu yang telah memenangkan hadiah utama
|
|
||||||
sebesar 5.000 yuan sebanyak lima kali berturut-turut!🎉🎉🎉
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
function DesktopTopup() {
|
function DesktopTopup() {
|
||||||
return <div>DesktopTopup</div>
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return <div>{t('gameDesktop.topup.placeholder')}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DesktopTopup
|
export default DesktopTopup
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Minus, Plus } from 'lucide-react'
|
import { Minus, Plus } from 'lucide-react'
|
||||||
import { type ReactNode, useState } from 'react'
|
import { type ReactNode, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
@@ -148,10 +149,12 @@ function WithdrawField({
|
|||||||
|
|
||||||
function AmountShell({
|
function AmountShell({
|
||||||
amount,
|
amount,
|
||||||
|
availableBalanceText,
|
||||||
onMinus,
|
onMinus,
|
||||||
onPlus,
|
onPlus,
|
||||||
}: {
|
}: {
|
||||||
amount: number
|
amount: number
|
||||||
|
availableBalanceText: string
|
||||||
onMinus: () => void
|
onMinus: () => void
|
||||||
onPlus: () => void
|
onPlus: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -180,7 +183,7 @@ function AmountShell({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
|
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
|
||||||
Saldo Tersedia: {formatNumber(AVAILABLE_BALANCE)}
|
{availableBalanceText}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -355,6 +358,7 @@ function PreviewRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DesktopWithdraw() {
|
function DesktopWithdraw() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [amount, setAmount] = useState(6626)
|
const [amount, setAmount] = useState(6626)
|
||||||
const [currency, setCurrency] =
|
const [currency, setCurrency] =
|
||||||
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
|
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
|
||||||
@@ -388,15 +392,24 @@ function DesktopWithdraw() {
|
|||||||
>
|
>
|
||||||
<div className="flex min-h-full min-w-0 flex-[1.7] flex-col px-design-16 py-design-14">
|
<div className="flex min-h-full min-w-0 flex-[1.7] flex-col px-design-16 py-design-14">
|
||||||
<div className="flex flex-col gap-design-12">
|
<div className="flex flex-col gap-design-12">
|
||||||
<WithdrawField label="Jumlah Penarikan Berlian">
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.diamondWithdrawalAmount')}
|
||||||
|
>
|
||||||
<AmountShell
|
<AmountShell
|
||||||
amount={amount}
|
amount={amount}
|
||||||
|
availableBalanceText={t(
|
||||||
|
'gameDesktop.withdraw.availableBalance',
|
||||||
|
{ amount: formatNumber(AVAILABLE_BALANCE) },
|
||||||
|
)}
|
||||||
onMinus={() => handleAmountChange(amount - 1)}
|
onMinus={() => handleAmountChange(amount - 1)}
|
||||||
onPlus={() => handleAmountChange(amount + 1)}
|
onPlus={() => handleAmountChange(amount + 1)}
|
||||||
/>
|
/>
|
||||||
</WithdrawField>
|
</WithdrawField>
|
||||||
|
|
||||||
<WithdrawField label="Jenis Mata Uang" alignStart={false}>
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.currencyType')}
|
||||||
|
alignStart={false}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
value={currency}
|
value={currency}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -405,9 +418,11 @@ function DesktopWithdraw() {
|
|||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="h-design-52 w-full rounded-[calc(var(--design-unit)*6)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-16 text-left text-design-20 font-semibold text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-52 [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
|
className="h-design-52 w-full rounded-[calc(var(--design-unit)*6)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-16 text-left text-design-20 font-semibold text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-52 [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
|
||||||
aria-label="Currency selection"
|
aria-label={t('gameDesktop.withdraw.currencySelection')}
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Select currency" />
|
<SelectValue
|
||||||
|
placeholder={t('gameDesktop.withdraw.selectCurrency')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
position="popper"
|
position="popper"
|
||||||
@@ -441,7 +456,9 @@ function DesktopWithdraw() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WithdrawField label="Saluran Pembayaran">
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.paymentChannel')}
|
||||||
|
>
|
||||||
<div className="flex flex-wrap gap-design-10">
|
<div className="flex flex-wrap gap-design-10">
|
||||||
{PAYMENT_CHANNELS.map((channel) => (
|
{PAYMENT_CHANNELS.map((channel) => (
|
||||||
<PaymentCard
|
<PaymentCard
|
||||||
@@ -455,7 +472,7 @@ function DesktopWithdraw() {
|
|||||||
</div>
|
</div>
|
||||||
</WithdrawField>
|
</WithdrawField>
|
||||||
|
|
||||||
<WithdrawField label="Kode Bank">
|
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
|
||||||
<div className="flex flex-col gap-design-10">
|
<div className="flex flex-col gap-design-10">
|
||||||
<div className="flex h-design-40 items-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(12,61,72,0.78),rgba(6,28,39,0.88))] px-design-12 text-design-15 uppercase tracking-[0.02em] text-[#A4EAF2] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.07)]">
|
<div className="flex h-design-40 items-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(12,61,72,0.78),rgba(6,28,39,0.88))] px-design-12 text-design-15 uppercase tracking-[0.02em] text-[#A4EAF2] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.07)]">
|
||||||
{`014${selectedBank?.label ?? 'BCA'} (${selectedBank?.subtitle ?? 'BANK CENTRAL ASIA'}): 014`}
|
{`014${selectedBank?.label ?? 'BCA'} (${selectedBank?.subtitle ?? 'BANK CENTRAL ASIA'}): 014`}
|
||||||
@@ -475,40 +492,62 @@ function DesktopWithdraw() {
|
|||||||
</div>
|
</div>
|
||||||
</WithdrawField>
|
</WithdrawField>
|
||||||
|
|
||||||
<WithdrawField label="Nama Pemegang Kartu">
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.cardHolderName')}
|
||||||
|
>
|
||||||
<InputShell
|
<InputShell
|
||||||
value={holderName}
|
value={holderName}
|
||||||
onChange={setHolderName}
|
onChange={setHolderName}
|
||||||
placeholder="Mohon masukkan nama pemegang kartu."
|
placeholder={t(
|
||||||
|
'gameDesktop.withdraw.placeholders.cardHolderName',
|
||||||
|
)}
|
||||||
error={holderNameError}
|
error={holderNameError}
|
||||||
errorMessage="Mohon masukkan nama pemegang kartu."
|
errorMessage={t(
|
||||||
|
'gameDesktop.withdraw.errors.cardHolderNameRequired',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</WithdrawField>
|
</WithdrawField>
|
||||||
|
|
||||||
<WithdrawField label="Nomor Rekening Bank">
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
|
||||||
|
>
|
||||||
<InputShell
|
<InputShell
|
||||||
value={bankAccount}
|
value={bankAccount}
|
||||||
onChange={setBankAccount}
|
onChange={setBankAccount}
|
||||||
placeholder="Silakan masukkan nomor rekening bank Anda."
|
placeholder={t(
|
||||||
|
'gameDesktop.withdraw.placeholders.bankAccountNumber',
|
||||||
|
)}
|
||||||
error={bankAccountError}
|
error={bankAccountError}
|
||||||
errorMessage="Silakan masukkan nomor rekening bank Anda."
|
errorMessage={t(
|
||||||
|
'gameDesktop.withdraw.errors.bankAccountRequired',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</WithdrawField>
|
</WithdrawField>
|
||||||
|
|
||||||
<WithdrawField label="Email Penerima" alignStart={false}>
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.receiverEmail')}
|
||||||
|
alignStart={false}
|
||||||
|
>
|
||||||
<InputShell
|
<InputShell
|
||||||
value={receiverEmail}
|
value={receiverEmail}
|
||||||
onChange={setReceiverEmail}
|
onChange={setReceiverEmail}
|
||||||
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
|
placeholder={t(
|
||||||
|
'gameDesktop.withdraw.placeholders.receiverEmail',
|
||||||
|
)}
|
||||||
uppercase={true}
|
uppercase={true}
|
||||||
/>
|
/>
|
||||||
</WithdrawField>
|
</WithdrawField>
|
||||||
|
|
||||||
<WithdrawField label="Nomor Ponsel Penerima" alignStart={false}>
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.receiverPhone')}
|
||||||
|
alignStart={false}
|
||||||
|
>
|
||||||
<InputShell
|
<InputShell
|
||||||
value={receiverPhone}
|
value={receiverPhone}
|
||||||
onChange={setReceiverPhone}
|
onChange={setReceiverPhone}
|
||||||
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
|
placeholder={t(
|
||||||
|
'gameDesktop.withdraw.placeholders.receiverPhone',
|
||||||
|
)}
|
||||||
uppercase={true}
|
uppercase={true}
|
||||||
/>
|
/>
|
||||||
</WithdrawField>
|
</WithdrawField>
|
||||||
@@ -519,67 +558,81 @@ function DesktopWithdraw() {
|
|||||||
|
|
||||||
<div className="flex min-h-full min-w-0 w-design-520 shrink-0 flex-col">
|
<div className="flex min-h-full min-w-0 w-design-520 shrink-0 flex-col">
|
||||||
<div className="flex h-design-44 items-center border-b border-[rgba(89,209,223,0.2)] bg-[linear-gradient(90deg,rgba(18,99,110,0.8),rgba(7,68,79,0.9))] px-design-12 text-design-20 font-semibold uppercase tracking-[0.04em] text-[#9AF5FB]">
|
<div className="flex h-design-44 items-center border-b border-[rgba(89,209,223,0.2)] bg-[linear-gradient(90deg,rgba(18,99,110,0.8),rgba(7,68,79,0.9))] px-design-12 text-design-20 font-semibold uppercase tracking-[0.04em] text-[#9AF5FB]">
|
||||||
Pratinjau Penukaran
|
{t('gameDesktop.withdraw.preview.title')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-design-12 px-design-10 py-design-10">
|
<div className="flex flex-1 flex-col gap-design-12 px-design-10 py-design-10">
|
||||||
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
|
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
|
||||||
<PreviewRow label="Jumlah Berlian" value={formatNumber(amount)} />
|
|
||||||
<PreviewRow
|
<PreviewRow
|
||||||
label="Kurs (MYR)"
|
label={t('gameDesktop.withdraw.preview.diamondAmount')}
|
||||||
value={`${100 * MYR_PER_100_DIAMONDS} BERLIAN = 1 MYR`}
|
value={formatNumber(amount)}
|
||||||
/>
|
/>
|
||||||
<PreviewRow
|
<PreviewRow
|
||||||
label="Dapat Ditukarkan MYR"
|
label={t('gameDesktop.withdraw.preview.rateMyr')}
|
||||||
|
value={t('gameDesktop.withdraw.preview.rateMyrValue', {
|
||||||
|
diamonds: 100 * MYR_PER_100_DIAMONDS,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<PreviewRow
|
||||||
|
label={t('gameDesktop.withdraw.preview.convertibleMyr')}
|
||||||
value={`RM ${formatFixedTwo(withdrawMyr)}`}
|
value={`RM ${formatFixedTwo(withdrawMyr)}`}
|
||||||
highlight={true}
|
highlight={true}
|
||||||
/>
|
/>
|
||||||
<PreviewRow
|
<PreviewRow
|
||||||
label="Nilai Tukar USDT/MYR"
|
label={t('gameDesktop.withdraw.preview.usdtMyrRate')}
|
||||||
value={`1 USDT = RM ${USDT_TO_MYR_RATE}`}
|
value={t('gameDesktop.withdraw.preview.usdtMyrRateValue', {
|
||||||
|
rate: USDT_TO_MYR_RATE,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<PreviewRow
|
<PreviewRow
|
||||||
label="Nilai Tukar (VND)"
|
label={t('gameDesktop.withdraw.preview.rateVnd')}
|
||||||
value={`${VND_PER_DIAMOND} BERLIAN = 1 VND`}
|
value={t('gameDesktop.withdraw.preview.rateVndValue', {
|
||||||
|
diamonds: VND_PER_DIAMOND,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<PreviewRow
|
<PreviewRow
|
||||||
label="Dapat Dikonversi ke VND"
|
label={t('gameDesktop.withdraw.preview.convertibleVnd')}
|
||||||
value={`${formatNumber(withdrawVnd)} VND`}
|
value={`${formatNumber(withdrawVnd)} VND`}
|
||||||
highlight={true}
|
highlight={true}
|
||||||
/>
|
/>
|
||||||
<PreviewRow
|
<PreviewRow
|
||||||
label="Dapat Ditukarkan dengan USDT"
|
label={t('gameDesktop.withdraw.preview.convertibleUsdt')}
|
||||||
value={`${formatFixedSix(withdrawUsdt)} USDT`}
|
value={`${formatFixedSix(withdrawUsdt)} USDT`}
|
||||||
highlight={true}
|
highlight={true}
|
||||||
/>
|
/>
|
||||||
<PreviewRow
|
<PreviewRow
|
||||||
label="Jumlah Berlian Nilai Tukar Tetap"
|
label={t(
|
||||||
|
'gameDesktop.withdraw.preview.fixedExchangeDiamondAmount',
|
||||||
|
)}
|
||||||
value="0-0-0 0:0:0"
|
value="0-0-0 0:0:0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-12 py-design-10 text-design-16 leading-[1.35] text-[#F0B44A]">
|
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-12 py-design-10 text-design-16 leading-[1.35] text-[#F0B44A]">
|
||||||
Nilai tukar berfungsi sebagai harga acuan; nilai tukar aktual yang
|
{t('gameDesktop.withdraw.exchangeRateNotice')}
|
||||||
berlaku ditentukan pada saat penarikan.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
|
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
|
||||||
<div>
|
<div>
|
||||||
Dompet Elektronik:{' '}
|
{t('gameDesktop.withdraw.wallet')}:{' '}
|
||||||
<span className="text-[#B9F4F8]">Minimal RM10</span>
|
<span className="text-[#B9F4F8]">
|
||||||
|
{t('gameDesktop.withdraw.minimumRm10')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Bank: <span className="text-[#B9F4F8]">Minimal RM10</span>
|
{t('gameDesktop.withdraw.bank')}:{' '}
|
||||||
|
<span className="text-[#B9F4F8]">
|
||||||
|
{t('gameDesktop.withdraw.minimumRm10')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Waktu Pengerjaan:{' '}
|
{t('gameDesktop.withdraw.processingTime')}:{' '}
|
||||||
<span className="text-[#77FF76]">
|
<span className="text-[#77FF76]">
|
||||||
Dana Tiba Hanya Dalam 9 Detik.
|
{t('gameDesktop.withdraw.fundsArrivalTime')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[#B9F4F8]">
|
<div className="text-[#B9F4F8]">
|
||||||
Melihat: Transaksi antara RM10 dan RM99,99 akan dikenakan biaya
|
{t('gameDesktop.withdraw.feeNotice')}
|
||||||
penarikan minimum sebesar RM1.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -591,7 +644,7 @@ function DesktopWithdraw() {
|
|||||||
size="100% 100%"
|
size="100% 100%"
|
||||||
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-18 font-bold uppercase tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-18 font-bold uppercase tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
Membatalkan
|
{t('gameDesktop.withdraw.cancel')}
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
as="button"
|
as="button"
|
||||||
@@ -600,9 +653,9 @@ function DesktopWithdraw() {
|
|||||||
size="100% 100%"
|
size="100% 100%"
|
||||||
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
Konfirmasi
|
{t('gameDesktop.withdraw.confirm')}
|
||||||
<br />
|
<br />
|
||||||
Penarikan
|
{t('gameDesktop.withdraw.withdrawal')}
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { getMockGameBootstrap, getVisibleAnnouncements } from '@/features/game'
|
import { getGameLobbyInit, getVisibleAnnouncements } from '@/features/game'
|
||||||
import { GameAnnouncementModal } from '@/features/game/components'
|
import { GameAnnouncementModal } from '@/features/game/components'
|
||||||
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
|
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
|
||||||
import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
|
import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
|
||||||
|
import { useGameRealtimeSync } from '@/features/game/hooks/use-game-realtime-sync.ts'
|
||||||
import { useDocumentMetadata } from '@/lib/head/document-metadata'
|
import { useDocumentMetadata } from '@/lib/head/document-metadata'
|
||||||
|
import { notify } from '@/lib/notify'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||||
|
|
||||||
const ENABLE_ANNOUNCEMENT_MODAL = false
|
const ENABLE_ANNOUNCEMENT_MODAL = false
|
||||||
|
|
||||||
export function EntryPage() {
|
export function EntryPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
useGameRealtimeSync()
|
||||||
const announcements = useGameSessionStore((state) => state.announcements)
|
const announcements = useGameSessionStore((state) => state.announcements)
|
||||||
const dismissAnnouncement = useGameSessionStore(
|
const dismissAnnouncement = useGameSessionStore(
|
||||||
(state) => state.dismissAnnouncement,
|
(state) => state.dismissAnnouncement,
|
||||||
)
|
)
|
||||||
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
|
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
|
||||||
|
const selectChip = useGameRoundStore((state) => state.selectChip)
|
||||||
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
|
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
|
||||||
const markAnnouncementRead = useGameSessionStore(
|
const markAnnouncementRead = useGameSessionStore(
|
||||||
(state) => state.markAnnouncementRead,
|
(state) => state.markAnnouncementRead,
|
||||||
)
|
)
|
||||||
|
const syncConnection = useGameSessionStore((state) => state.syncConnection)
|
||||||
|
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
||||||
|
const authStatus = useAuthStore((state) => state.status)
|
||||||
|
|
||||||
const [isHydrating, setIsHydrating] = useState(true)
|
const [isHydrating, setIsHydrating] = useState(true)
|
||||||
const [isMobile, setIsMobile] = useState(() => {
|
const [isMobile, setIsMobile] = useState(() => {
|
||||||
@@ -49,33 +57,89 @@ export function EntryPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
void getMockGameBootstrap().then((snapshot) => {
|
void getGameLobbyInit()
|
||||||
if (cancelled) {
|
.then((result) => {
|
||||||
return
|
if (cancelled) {
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
hydrateRound({
|
const snapshot = result.snapshot
|
||||||
cells: snapshot.cells,
|
|
||||||
chips: snapshot.chips,
|
hydrateRound({
|
||||||
history: snapshot.history,
|
cells: snapshot.cells,
|
||||||
round: snapshot.round,
|
chips: snapshot.chips,
|
||||||
selections: snapshot.selections,
|
history: snapshot.history,
|
||||||
trends: snapshot.trends,
|
maxSelectionCount: snapshot.maxSelectionCount,
|
||||||
|
round: snapshot.round,
|
||||||
|
selections: snapshot.selections,
|
||||||
|
trends: snapshot.trends,
|
||||||
|
})
|
||||||
|
const defaultChipId =
|
||||||
|
snapshot.chips.find((chip) => chip.isDefault)?.id ?? null
|
||||||
|
|
||||||
|
if (defaultChipId) {
|
||||||
|
selectChip(defaultChipId)
|
||||||
|
}
|
||||||
|
hydrateSession({
|
||||||
|
announcements: snapshot.announcements,
|
||||||
|
connection: snapshot.connection,
|
||||||
|
dashboard: snapshot.dashboard,
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentUser = useAuthStore.getState().currentUser
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
setCurrentUser({
|
||||||
|
...currentUser,
|
||||||
|
coin: result.userSnapshot.coin,
|
||||||
|
currentStreak: result.userSnapshot.current_streak,
|
||||||
|
isJackpot: result.userSnapshot.is_jackpot,
|
||||||
|
oddsFactor: result.userSnapshot.odds_factor,
|
||||||
|
streakLevel: result.userSnapshot.streak_level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsHydrating(false)
|
||||||
})
|
})
|
||||||
hydrateSession({
|
|
||||||
announcements: snapshot.announcements,
|
|
||||||
connection: snapshot.connection,
|
|
||||||
dashboard: snapshot.dashboard,
|
|
||||||
})
|
|
||||||
setIsHydrating(false)
|
|
||||||
})
|
})
|
||||||
})
|
.catch((error) => {
|
||||||
|
console.error('Failed to load game lobby init', error)
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
if (authStatus === 'authenticated') {
|
||||||
|
notify.error(t('commonUi.toast.lobbyInitFailed'), {
|
||||||
|
description: error instanceof Error ? error.message : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
syncConnection({
|
||||||
|
connectedAt: null,
|
||||||
|
lastError:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to load game lobby init',
|
||||||
|
lastMessageAt: null,
|
||||||
|
latencyMs: null,
|
||||||
|
status: 'disconnected',
|
||||||
|
transport: 'offline',
|
||||||
|
})
|
||||||
|
setIsHydrating(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [hydrateRound, hydrateSession])
|
}, [
|
||||||
|
authStatus,
|
||||||
|
hydrateRound,
|
||||||
|
hydrateSession,
|
||||||
|
selectChip,
|
||||||
|
setCurrentUser,
|
||||||
|
syncConnection,
|
||||||
|
t,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export function MobileEntry() {
|
export function MobileEntry() {
|
||||||
return <div>mobile component entry</div>
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return <div>{t('gameDesktop.mobile.placeholder')}</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { DesktopControl } from '@/features/game/components/desktop/desktop-contr
|
|||||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
||||||
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
||||||
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
|
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
|
||||||
|
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
|
||||||
|
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
|
||||||
|
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
|
||||||
|
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
|
||||||
|
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
|
||||||
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
||||||
|
|
||||||
export function PcEntry() {
|
export function PcEntry() {
|
||||||
@@ -38,20 +43,13 @@ export function PcEntry() {
|
|||||||
>
|
>
|
||||||
<DesktopControl />
|
<DesktopControl />
|
||||||
</div>
|
</div>
|
||||||
{/*登录弹窗*/}
|
<DesktopLoginModal />
|
||||||
{/*<DesktopLoginModal />*/}
|
<DesktopRegisterModal />
|
||||||
{/*注册弹窗 */}
|
<DesktopUserInfoModal />
|
||||||
{/*<DesktopRegisterModal />*/}
|
<DesktopNoticeModal />
|
||||||
{/* 用户信息弹窗 */}
|
|
||||||
{/*<DesktopUserInfoModal />*/}
|
|
||||||
{/*公告弹窗*/}
|
|
||||||
{/*<DesktopNoticeModal />*/}
|
|
||||||
{/*自动托管弹窗*/}
|
|
||||||
<DesktopAutoSettingModal />
|
<DesktopAutoSettingModal />
|
||||||
{/* 充值提现前置选择弹窗*/}
|
<DesktopProceduresModal />
|
||||||
{/*<DesktopProceduresModal />*/}
|
<DesktopWithdrawTopupModal />
|
||||||
{/* 充值和提现弹窗 */}
|
|
||||||
{/*<DesktopWithdrawTopupModal/>*/}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,43 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { CHIP_OPTIONS } from '@/constants'
|
import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants'
|
||||||
import { selectSelectionTotal, useGameRoundStore } from '@/store/game'
|
import { selectSelectionTotal, useGameRoundStore } from '@/store/game'
|
||||||
|
|
||||||
const CHIP_IMAGE_MAP = new Map(
|
function formatChipDisplayValue(amount: number) {
|
||||||
CHIP_OPTIONS.map((chip) => [chip.value, chip.src] as const),
|
if (Number.isInteger(amount)) {
|
||||||
)
|
return String(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return amount.toFixed(2).replace(/\.?0+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
export function useGameControlVm() {
|
export function useGameControlVm() {
|
||||||
const chips = useGameRoundStore((state) => state.chips)
|
const chips = useGameRoundStore((state) => state.chips)
|
||||||
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||||
|
const maxSelectionCount = useGameRoundStore(
|
||||||
|
(state) => state.maxSelectionCount,
|
||||||
|
)
|
||||||
const selections = useGameRoundStore((state) => state.selections)
|
const selections = useGameRoundStore((state) => state.selections)
|
||||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||||
const selectChip = useGameRoundStore((state) => state.selectChip)
|
const selectChip = useGameRoundStore((state) => state.selectChip)
|
||||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||||
|
|
||||||
const chipItems = useMemo(
|
const chipItems = useMemo(() => {
|
||||||
() =>
|
const items = chips.map((chip) => ({
|
||||||
chips.map((chip) => ({
|
amount: chip.amount,
|
||||||
amount: chip.amount,
|
id: chip.id,
|
||||||
id: chip.id,
|
isSelected: chip.id === activeChipId,
|
||||||
isSelected: chip.id === activeChipId,
|
src: CHIP_IMAGE_MAP.get(chip.id) ?? CHIP_IMAGE_OPTIONS[0]?.src ?? '',
|
||||||
src: CHIP_IMAGE_MAP.get(chip.amount) ?? CHIP_OPTIONS[0]?.src ?? '',
|
valueLabel: formatChipDisplayValue(chip.amount),
|
||||||
valueLabel: String(chip.amount),
|
}))
|
||||||
})),
|
|
||||||
[activeChipId, chips],
|
return items.sort((left, right) => {
|
||||||
)
|
if (left.isSelected === right.isSelected) {
|
||||||
|
return left.id.localeCompare(right.id, undefined, { numeric: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.isSelected ? 1 : -1
|
||||||
|
})
|
||||||
|
}, [activeChipId, chips])
|
||||||
|
|
||||||
const selectedChip =
|
const selectedChip =
|
||||||
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
|
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
|
||||||
@@ -33,10 +46,11 @@ export function useGameControlVm() {
|
|||||||
canClear: selections.length > 0,
|
canClear: selections.length > 0,
|
||||||
onChipSelect: selectChip,
|
onChipSelect: selectChip,
|
||||||
onClearSelections: clearSelections,
|
onClearSelections: clearSelections,
|
||||||
|
maxSelectionCountLabel: maxSelectionCount,
|
||||||
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
|
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
|
||||||
selectedChipId: activeChipId,
|
selectedChipId: activeChipId,
|
||||||
selectedCountLabel: `${selections.length}/5`,
|
selectedCountLabel: selections.length,
|
||||||
totalBetAmountLabel: String(totalBetAmount),
|
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
|
||||||
chips: chipItems,
|
chips: chipItems,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
|
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useGameRoundStore } from '@/store/game'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
function formatSettledTime(iso: string) {
|
import { getGameBetMyOrders } from '@/features/game/api/game-api'
|
||||||
const date = new Date(iso)
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
|
||||||
|
const GAME_HISTORY_PAGE_SIZE = 20
|
||||||
|
|
||||||
|
function formatCreatedTime(timestamp: number, locale: string) {
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
return '--'
|
return '--'
|
||||||
}
|
}
|
||||||
|
|
||||||
return date.toLocaleString('zh-CN', {
|
return date.toLocaleString(locale, {
|
||||||
hour12: false,
|
hour12: false,
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -18,26 +24,70 @@ function formatSettledTime(iso: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNumbers(numbers: number[]) {
|
||||||
|
if (numbers.length === 0) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
return numbers.map((number) => String(number).padStart(2, '0')).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
export function useGameHistoryVm() {
|
export function useGameHistoryVm() {
|
||||||
const history = useGameRoundStore((state) => state.history)
|
const { i18n, t } = useTranslation()
|
||||||
|
const accessToken = useAuthStore((state) => state.accessToken)
|
||||||
|
const authStatus = useAuthStore((state) => state.status)
|
||||||
|
|
||||||
|
const query = useInfiniteQuery({
|
||||||
|
queryKey: ['game', 'bet-my-orders', accessToken],
|
||||||
|
enabled: authStatus === 'authenticated' && Boolean(accessToken),
|
||||||
|
initialPageParam: 1,
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
|
getGameBetMyOrders({
|
||||||
|
page: pageParam,
|
||||||
|
pageSize: GAME_HISTORY_PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextPage = lastPage.pagination.page + 1
|
||||||
|
const loadedCount =
|
||||||
|
lastPage.pagination.page * lastPage.pagination.page_size
|
||||||
|
|
||||||
|
return loadedCount < lastPage.pagination.total ? nextPage : undefined
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() =>
|
() =>
|
||||||
history.map((entry) => ({
|
(query.data?.pages ?? []).flatMap((page) =>
|
||||||
id: entry.roundId,
|
page.list.map((entry) => ({
|
||||||
payoutMultiplierLabel: `${entry.payoutMultiplier}x`,
|
amountLabel: entry.total_amount,
|
||||||
roundId: entry.roundId,
|
createdAtLabel: formatCreatedTime(
|
||||||
settledAtLabel: formatSettledTime(entry.settledAt),
|
entry.create_time,
|
||||||
statusLabel: 'settled',
|
i18n.resolvedLanguage ?? 'en-US',
|
||||||
totalPoolAmountLabel: entry.totalPoolAmount.toFixed(2),
|
),
|
||||||
winningCellIdLabel: String(entry.winningCellId),
|
id: entry.order_no,
|
||||||
})),
|
numbersLabel: formatNumbers(entry.numbers),
|
||||||
[history],
|
orderNo: entry.order_no,
|
||||||
|
periodNo: entry.period_no,
|
||||||
|
resultNumberLabel:
|
||||||
|
entry.result_number === null
|
||||||
|
? '--'
|
||||||
|
: String(entry.result_number).padStart(2, '0'),
|
||||||
|
statusLabel: entry.status,
|
||||||
|
winAmountLabel: entry.win_amount,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
[i18n.resolvedLanguage, query.data?.pages],
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emptyText: 'No history yet',
|
emptyText: t('gameDesktop.history.empty'),
|
||||||
isEmpty: items.length === 0,
|
endText: t('gameDesktop.history.end'),
|
||||||
|
fetchNextPage: query.fetchNextPage,
|
||||||
|
hasNextPage: query.hasNextPage,
|
||||||
|
isEmpty: authStatus !== 'authenticated' || items.length === 0,
|
||||||
|
isFetchingNextPage: query.isFetchingNextPage,
|
||||||
|
isInitialLoading: query.isLoading,
|
||||||
items,
|
items,
|
||||||
|
loadingText: t('gameDesktop.history.loading'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
465
src/features/game/hooks/use-game-realtime-sync.ts
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
import { prefetchAuthToken } from '@/lib/api/api-client'
|
||||||
|
import {
|
||||||
|
GameSocketClient,
|
||||||
|
type GameSocketMessage,
|
||||||
|
} from '@/lib/ws/game-socket-client'
|
||||||
|
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
|
||||||
|
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||||
|
import { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api'
|
||||||
|
import type { GameLobbyUserSnapshotDto, GamePeriodTickDto } from '../api/types'
|
||||||
|
|
||||||
|
const FALLBACK_POLL_INTERVAL_MS = 10_000
|
||||||
|
const GAME_SOCKET_TOPICS = {
|
||||||
|
// 对局状态心跳。每秒推送当前期号、状态、倒计时、runtime_enabled 等。
|
||||||
|
periodTick: 'period.tick',
|
||||||
|
// 本期封盘通知。用于前端立即停止下注。
|
||||||
|
periodLocked: 'period.locked',
|
||||||
|
// 本期开奖通知。用于同步开奖号码、所属期号等阶段结果。
|
||||||
|
periodOpened: 'period.opened',
|
||||||
|
// 本期派彩完成通知。用于结算阶段同步。
|
||||||
|
periodPayout: 'period.payout',
|
||||||
|
// 当前玩家连胜与赔率信息。通常在结算后或演示帧刷新。
|
||||||
|
userStreak: 'user.streak',
|
||||||
|
// 下注成功通知。仅当前用户可见,通常伴随扣款结果。
|
||||||
|
betAccepted: 'bet.accepted',
|
||||||
|
// 余额变化通知。充值、下注、派彩都会走这条流。
|
||||||
|
walletChanged: 'wallet.changed',
|
||||||
|
// 自动托管进度通知。包含托管开关、执行状态等。
|
||||||
|
autoSpinProgress: 'auto.spin.progress',
|
||||||
|
// 大奖命中通知。仅当本期存在中大奖用户时推送。
|
||||||
|
jackpotHit: 'jackpot.hit',
|
||||||
|
// 后台实时页全量快照。仅 admin live 页面使用,当前 H5 前台不订阅。
|
||||||
|
adminLiveSnapshot: 'admin.live.snapshot',
|
||||||
|
// 后台开奖结果通知。仅 admin live 页面使用,当前 H5 前台不订阅。
|
||||||
|
adminLiveOpened: 'admin.live.opened',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// 当前 H5 游戏页实际需要的用户侧事件。
|
||||||
|
// 后台专用事件保持在 GAME_SOCKET_TOPICS 中做口径对齐,但不在这里订阅。
|
||||||
|
const PLAYER_SOCKET_TOPICS = [
|
||||||
|
GAME_SOCKET_TOPICS.periodTick,
|
||||||
|
GAME_SOCKET_TOPICS.userStreak,
|
||||||
|
GAME_SOCKET_TOPICS.periodOpened,
|
||||||
|
GAME_SOCKET_TOPICS.periodLocked,
|
||||||
|
GAME_SOCKET_TOPICS.periodPayout,
|
||||||
|
GAME_SOCKET_TOPICS.betAccepted,
|
||||||
|
GAME_SOCKET_TOPICS.walletChanged,
|
||||||
|
GAME_SOCKET_TOPICS.autoSpinProgress,
|
||||||
|
GAME_SOCKET_TOPICS.jackpotHit,
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const SOCKET_DISCONNECT_DELAY_MS = 150
|
||||||
|
|
||||||
|
let sharedSocketClient: GameSocketClient | null = null
|
||||||
|
let sharedSocketKey: string | null = null
|
||||||
|
let sharedSocketDisconnectTimerId: number | null = null
|
||||||
|
|
||||||
|
function toIsoFromUnixSeconds(seconds: number) {
|
||||||
|
return new Date(seconds * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSocketLang(language: string | null | undefined) {
|
||||||
|
return language?.startsWith('zh') ? 'zh' : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOptionalNumber(value: unknown) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value)
|
||||||
|
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNestedRecord(
|
||||||
|
value: unknown,
|
||||||
|
key: string,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = (value as Record<string, unknown>)[key]
|
||||||
|
|
||||||
|
return nested && typeof nested === 'object'
|
||||||
|
? (nested as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractServerTime(message: GameSocketMessage) {
|
||||||
|
const root = message as Record<string, unknown>
|
||||||
|
|
||||||
|
if (typeof root.server_time === 'number') {
|
||||||
|
return root.server_time
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = getNestedRecord(message, 'data')
|
||||||
|
|
||||||
|
return typeof data?.server_time === 'number' ? data.server_time : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUserSnapshot(
|
||||||
|
message: GameSocketMessage,
|
||||||
|
): GameLobbyUserSnapshotDto | null {
|
||||||
|
const direct = getNestedRecord(message, 'user_snapshot')
|
||||||
|
const nested = getNestedRecord(
|
||||||
|
getNestedRecord(message, 'data'),
|
||||||
|
'user_snapshot',
|
||||||
|
)
|
||||||
|
const source = direct ?? nested
|
||||||
|
|
||||||
|
if (
|
||||||
|
!source ||
|
||||||
|
typeof source.coin !== 'string' ||
|
||||||
|
typeof source.current_streak !== 'number'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
coin: source.coin,
|
||||||
|
current_streak: source.current_streak,
|
||||||
|
is_jackpot:
|
||||||
|
typeof source.is_jackpot === 'boolean' ? source.is_jackpot : undefined,
|
||||||
|
odds_factor: toOptionalNumber(source.odds_factor),
|
||||||
|
streak_level: toOptionalNumber(source.streak_level),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPeriodTick(
|
||||||
|
message: GameSocketMessage,
|
||||||
|
): GamePeriodTickDto | null {
|
||||||
|
const data = getNestedRecord(message, 'data')
|
||||||
|
const nested = getNestedRecord(data, 'period')
|
||||||
|
const source = nested ?? data
|
||||||
|
|
||||||
|
if (
|
||||||
|
!source ||
|
||||||
|
typeof source.period_no !== 'string' ||
|
||||||
|
typeof source.status !== 'string' ||
|
||||||
|
typeof source.countdown !== 'number' ||
|
||||||
|
typeof source.bet_close_in !== 'number'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bet_close_in: source.bet_close_in,
|
||||||
|
countdown: source.countdown,
|
||||||
|
period_id: typeof source.period_id === 'number' ? source.period_id : null,
|
||||||
|
period_no: source.period_no,
|
||||||
|
result_number:
|
||||||
|
typeof source.result_number === 'number' ? source.result_number : null,
|
||||||
|
runtime_enabled:
|
||||||
|
typeof source.runtime_enabled === 'boolean'
|
||||||
|
? source.runtime_enabled
|
||||||
|
: true,
|
||||||
|
server_time:
|
||||||
|
typeof source.server_time === 'number'
|
||||||
|
? source.server_time
|
||||||
|
: Math.floor(Date.now() / 1000),
|
||||||
|
status: source.status as GamePeriodTickDto['status'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
|
||||||
|
const currentRoundState = useGameRoundStore.getState()
|
||||||
|
const currentSessionState = useGameSessionStore.getState()
|
||||||
|
|
||||||
|
useGameRoundStore.getState().hydrateRound({
|
||||||
|
cells: result.snapshot.cells,
|
||||||
|
chips: result.snapshot.chips,
|
||||||
|
history: currentRoundState.history,
|
||||||
|
maxSelectionCount: result.snapshot.maxSelectionCount,
|
||||||
|
round: currentRoundState.round,
|
||||||
|
selections: currentRoundState.selections,
|
||||||
|
trends: currentRoundState.trends,
|
||||||
|
})
|
||||||
|
|
||||||
|
useGameSessionStore.getState().hydrateSession({
|
||||||
|
announcements: result.snapshot.announcements,
|
||||||
|
connection: {
|
||||||
|
...result.snapshot.connection,
|
||||||
|
status: 'connected',
|
||||||
|
transport: 'polling',
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
...currentSessionState.dashboard,
|
||||||
|
tableLimitMax: result.snapshot.dashboard.tableLimitMax,
|
||||||
|
tableLimitMin: result.snapshot.dashboard.tableLimitMin,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentUser = useAuthStore.getState().currentUser
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
useAuthStore.getState().setCurrentUser({
|
||||||
|
...currentUser,
|
||||||
|
coin: result.userSnapshot.coin,
|
||||||
|
currentStreak: result.userSnapshot.current_streak,
|
||||||
|
isJackpot: result.userSnapshot.is_jackpot,
|
||||||
|
oddsFactor: result.userSnapshot.odds_factor,
|
||||||
|
streakLevel: result.userSnapshot.streak_level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRealtimeMessage(message: GameSocketMessage) {
|
||||||
|
const serverTime = extractServerTime(message)
|
||||||
|
const period = extractPeriodTick(message)
|
||||||
|
const userSnapshot = extractUserSnapshot(message)
|
||||||
|
|
||||||
|
if (period) {
|
||||||
|
const previousRound = useGameRoundStore.getState().round
|
||||||
|
const round = normalizePeriodTickRound(
|
||||||
|
{
|
||||||
|
...period,
|
||||||
|
server_time: serverTime ?? period.server_time,
|
||||||
|
},
|
||||||
|
previousRound,
|
||||||
|
)
|
||||||
|
|
||||||
|
useGameRoundStore.getState().syncRound({
|
||||||
|
bettingClosesAt: round.bettingClosesAt,
|
||||||
|
id: round.id,
|
||||||
|
phase: round.phase,
|
||||||
|
revealingAt: round.revealingAt,
|
||||||
|
settledAt: round.settledAt,
|
||||||
|
startedAt: round.startedAt,
|
||||||
|
winningCellId: round.winningCellId,
|
||||||
|
})
|
||||||
|
useGameSessionStore.getState().syncDashboard({
|
||||||
|
countdownMs: period.countdown * 1000,
|
||||||
|
updatedAt:
|
||||||
|
serverTime !== null
|
||||||
|
? toIsoFromUnixSeconds(serverTime)
|
||||||
|
: toIsoFromUnixSeconds(period.server_time),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userSnapshot) {
|
||||||
|
const currentUser = useAuthStore.getState().currentUser
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
useAuthStore.getState().setCurrentUser({
|
||||||
|
...currentUser,
|
||||||
|
coin: userSnapshot.coin,
|
||||||
|
currentStreak: userSnapshot.current_streak,
|
||||||
|
isJackpot: userSnapshot.is_jackpot,
|
||||||
|
oddsFactor: userSnapshot.odds_factor,
|
||||||
|
streakLevel: userSnapshot.streak_level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useGameSessionStore.getState().syncConnection({
|
||||||
|
lastMessageAt:
|
||||||
|
serverTime !== null
|
||||||
|
? toIsoFromUnixSeconds(serverTime)
|
||||||
|
: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameRealtimeSync() {
|
||||||
|
const accessToken = useAuthStore((state) => state.accessToken)
|
||||||
|
const authStatus = useAuthStore((state) => state.status)
|
||||||
|
const shouldConnectRealtime = useGameSessionStore(
|
||||||
|
(state) => state.shouldConnectRealtime,
|
||||||
|
)
|
||||||
|
const socketClientRef = useRef<GameSocketClient | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sharedSocketDisconnectTimerId !== null) {
|
||||||
|
window.clearTimeout(sharedSocketDisconnectTimerId)
|
||||||
|
sharedSocketDisconnectTimerId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!shouldConnectRealtime ||
|
||||||
|
authStatus !== 'authenticated' ||
|
||||||
|
!accessToken
|
||||||
|
) {
|
||||||
|
sharedSocketDisconnectTimerId = window.setTimeout(() => {
|
||||||
|
sharedSocketClient?.disconnect()
|
||||||
|
sharedSocketClient = null
|
||||||
|
sharedSocketKey = null
|
||||||
|
sharedSocketDisconnectTimerId = null
|
||||||
|
}, SOCKET_DISCONNECT_DELAY_MS)
|
||||||
|
socketClientRef.current = sharedSocketClient
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const websocketUrl = import.meta.env.VITE_WEBSOCKET_URL?.trim() || null
|
||||||
|
const socketKey = `${websocketUrl ?? ''}::${accessToken}`
|
||||||
|
|
||||||
|
if (sharedSocketClient && sharedSocketKey === socketKey) {
|
||||||
|
socketClientRef.current = sharedSocketClient
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sharedSocketDisconnectTimerId = window.setTimeout(() => {
|
||||||
|
sharedSocketClient?.disconnect()
|
||||||
|
sharedSocketClient = null
|
||||||
|
sharedSocketKey = null
|
||||||
|
sharedSocketDisconnectTimerId = null
|
||||||
|
}, SOCKET_DISCONNECT_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedSocketClient?.disconnect()
|
||||||
|
|
||||||
|
const socketClient = new GameSocketClient({
|
||||||
|
getContext: async () => {
|
||||||
|
await prefetchAuthToken()
|
||||||
|
|
||||||
|
const authToken = useAuthStore.getState().apiAuthToken
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: accessToken,
|
||||||
|
authToken,
|
||||||
|
deviceId: getAuthDeviceId(),
|
||||||
|
lang: toSocketLang(i18n.resolvedLanguage),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getUrl: () => websocketUrl,
|
||||||
|
onError: (error) => {
|
||||||
|
useGameSessionStore.getState().syncConnection({
|
||||||
|
lastError:
|
||||||
|
'message' in error && typeof error.message === 'string'
|
||||||
|
? error.message
|
||||||
|
: 'WebSocket error',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onLatencyChange: (latencyMs) => {
|
||||||
|
useGameSessionStore.getState().syncConnection({
|
||||||
|
latencyMs,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onMessage: (message) => {
|
||||||
|
if (message.event === 'ws.connected') {
|
||||||
|
const serverTime = extractServerTime(message)
|
||||||
|
|
||||||
|
useGameSessionStore.getState().syncConnection({
|
||||||
|
connectedAt:
|
||||||
|
serverTime !== null
|
||||||
|
? toIsoFromUnixSeconds(serverTime)
|
||||||
|
: new Date().toISOString(),
|
||||||
|
lastError: null,
|
||||||
|
lastMessageAt:
|
||||||
|
serverTime !== null
|
||||||
|
? toIsoFromUnixSeconds(serverTime)
|
||||||
|
: new Date().toISOString(),
|
||||||
|
reconnectAttempt: 0,
|
||||||
|
status: 'connected',
|
||||||
|
transport: 'websocket',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRealtimeMessage(message)
|
||||||
|
},
|
||||||
|
onStatusChange: (status, reconnectAttempt) => {
|
||||||
|
const mappedStatus =
|
||||||
|
status === 'idle'
|
||||||
|
? 'idle'
|
||||||
|
: status === 'connected'
|
||||||
|
? 'connected'
|
||||||
|
: status
|
||||||
|
|
||||||
|
useGameSessionStore.getState().syncConnection({
|
||||||
|
latencyMs: mappedStatus === 'connected' ? undefined : null,
|
||||||
|
reconnectAttempt,
|
||||||
|
status: mappedStatus,
|
||||||
|
transport: websocketUrl ? 'websocket' : 'polling',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
sharedSocketClient = socketClient
|
||||||
|
sharedSocketKey = socketKey
|
||||||
|
socketClientRef.current = socketClient
|
||||||
|
socketClient.subscribe([...PLAYER_SOCKET_TOPICS])
|
||||||
|
void socketClient.connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sharedSocketDisconnectTimerId = window.setTimeout(() => {
|
||||||
|
if (sharedSocketClient === socketClient) {
|
||||||
|
socketClient.disconnect()
|
||||||
|
sharedSocketClient = null
|
||||||
|
sharedSocketKey = null
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedSocketDisconnectTimerId = null
|
||||||
|
}, SOCKET_DISCONNECT_DELAY_MS)
|
||||||
|
socketClientRef.current = sharedSocketClient
|
||||||
|
}
|
||||||
|
}, [accessToken, authStatus, shouldConnectRealtime])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldConnectRealtime || authStatus !== 'authenticated') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let intervalId = 0
|
||||||
|
|
||||||
|
const pollLobbyState = async () => {
|
||||||
|
const connection = useGameSessionStore.getState().connection
|
||||||
|
|
||||||
|
if (
|
||||||
|
connection.status === 'connected' &&
|
||||||
|
connection.transport === 'websocket'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getGameLobbyInit()
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLobbySync(result)
|
||||||
|
|
||||||
|
useGameSessionStore.getState().syncConnection({
|
||||||
|
lastError: null,
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
status: 'connected',
|
||||||
|
transport: 'polling',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
useGameSessionStore.getState().syncConnection({
|
||||||
|
lastError: error instanceof Error ? error.message : 'Polling failed',
|
||||||
|
status: 'reconnecting',
|
||||||
|
transport: 'polling',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalId = window.setInterval(() => {
|
||||||
|
void pollLobbyState()
|
||||||
|
}, FALLBACK_POLL_INTERVAL_MS)
|
||||||
|
void pollLobbyState()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearInterval(intervalId)
|
||||||
|
}
|
||||||
|
}, [authStatus, shouldConnectRealtime])
|
||||||
|
}
|
||||||
@@ -1,59 +1,67 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { getRoundCountdownMs } from '@/features/game/shared/selectors'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||||
|
|
||||||
const PHASE_META = {
|
const PHASE_META = {
|
||||||
betting: {
|
betting: {
|
||||||
description: '(Menerima Taruhan)',
|
descriptionKey: 'gameDesktop.status.phase.betting.description',
|
||||||
label: 'OPEN',
|
labelKey: 'gameDesktop.status.phase.betting.label',
|
||||||
toneClassName: 'text-[#78FF7F]',
|
toneClassName: 'text-[#78FF7F]',
|
||||||
},
|
},
|
||||||
locked: {
|
locked: {
|
||||||
description: '(Taruhan Ditutup)',
|
descriptionKey: 'gameDesktop.status.phase.locked.description',
|
||||||
label: 'LOCKED',
|
labelKey: 'gameDesktop.status.phase.locked.label',
|
||||||
toneClassName: 'text-[#FFE375]',
|
toneClassName: 'text-[#FFE375]',
|
||||||
},
|
},
|
||||||
revealing: {
|
revealing: {
|
||||||
description: '(Mengundi Hasil)',
|
descriptionKey: 'gameDesktop.status.phase.revealing.description',
|
||||||
label: 'DRAWING',
|
labelKey: 'gameDesktop.status.phase.revealing.label',
|
||||||
toneClassName: 'text-[#57E8FF]',
|
toneClassName: 'text-[#57E8FF]',
|
||||||
},
|
},
|
||||||
settled: {
|
settled: {
|
||||||
description: '(Putaran Selesai)',
|
descriptionKey: 'gameDesktop.status.phase.settled.description',
|
||||||
label: 'SETTLED',
|
labelKey: 'gameDesktop.status.phase.settled.label',
|
||||||
toneClassName: 'text-[#FF9C6B]',
|
toneClassName: 'text-[#FF9C6B]',
|
||||||
},
|
},
|
||||||
waiting: {
|
waiting: {
|
||||||
description: '(Menunggu Putaran Berikutnya)',
|
descriptionKey: 'gameDesktop.status.phase.waiting.description',
|
||||||
label: 'WAITING',
|
labelKey: 'gameDesktop.status.phase.waiting.label',
|
||||||
toneClassName: 'text-[#A7B6C7]',
|
toneClassName: 'text-[#A7B6C7]',
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function useGameStatusVm() {
|
export function useGameStatusVm() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const cells = useGameRoundStore((state) => state.cells)
|
const cells = useGameRoundStore((state) => state.cells)
|
||||||
const round = useGameRoundStore((state) => state.round)
|
const round = useGameRoundStore((state) => state.round)
|
||||||
const trends = useGameRoundStore((state) => state.trends)
|
const trends = useGameRoundStore((state) => state.trends)
|
||||||
const dashboard = useGameSessionStore((state) => state.dashboard)
|
const dashboard = useGameSessionStore((state) => state.dashboard)
|
||||||
|
const currentUser = useAuthStore((state) => state.currentUser)
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const oddsValue = cells[0]?.odds ?? '--'
|
const oddsValue =
|
||||||
|
typeof currentUser?.oddsFactor === 'number'
|
||||||
|
? currentUser.oddsFactor
|
||||||
|
: (cells[0]?.odds ?? '--')
|
||||||
const featuredTrend = trends.find(
|
const featuredTrend = trends.find(
|
||||||
(entry) => entry.cellId === dashboard.featuredCellId,
|
(entry) => entry.cellId === dashboard.featuredCellId,
|
||||||
)
|
)
|
||||||
const phaseMeta = PHASE_META[round.phase]
|
const phaseMeta = PHASE_META[round.phase]
|
||||||
|
const streakValue =
|
||||||
|
currentUser?.currentStreak ?? featuredTrend?.currentStreak ?? null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
acceptingBets: round.phase === 'betting',
|
acceptingBets: round.phase === 'betting',
|
||||||
countdownMs: getRoundCountdownMs(round),
|
countdownMs: dashboard.countdownMs,
|
||||||
limitLabel: `${dashboard.tableLimitMin}-${dashboard.tableLimitMax}`,
|
limitLabel: `${dashboard.tableLimitMin}-${dashboard.tableLimitMax}`,
|
||||||
oddsLabel: `1:${oddsValue}`,
|
oddsLabel: `1:${oddsValue}`,
|
||||||
phase: round.phase,
|
phase: round.phase,
|
||||||
phaseDescription: phaseMeta.description,
|
phaseDescription: t(phaseMeta.descriptionKey),
|
||||||
phaseLabel: phaseMeta.label,
|
phaseLabel: t(phaseMeta.labelKey),
|
||||||
phaseToneClassName: phaseMeta.toneClassName,
|
phaseToneClassName: phaseMeta.toneClassName,
|
||||||
roundId: round.id,
|
roundId: round.id || '--',
|
||||||
streakLabel: featuredTrend ? `X${featuredTrend.currentStreak}` : '--',
|
streakLabel: typeof streakValue === 'number' ? `X${streakValue}` : '--',
|
||||||
}
|
}
|
||||||
}, [cells, dashboard, round, trends])
|
}, [cells, currentUser, dashboard, round, t, trends])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
import { useState } from 'react'
|
import { useTranslation } from 'react-i18next'
|
||||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { Input } from '@/components/ui/input.tsx'
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
import { Switch } from '@/components/ui/switch.tsx'
|
import { Switch } from '@/components/ui/switch.tsx'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
const AUTO_STOP_ROWS = [
|
const AUTO_STOP_ROWS = [
|
||||||
{
|
{
|
||||||
label: 'Stop if balance lower than',
|
labelKey: 'game.modals.autoSetting.rows.stopIfBalanceLowerThan',
|
||||||
value: '0',
|
value: '0',
|
||||||
checked: false,
|
checked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Stop if single win exceeds',
|
labelKey: 'game.modals.autoSetting.rows.stopIfSingleWinExceeds',
|
||||||
value: '50000',
|
value: '50000',
|
||||||
checked: true,
|
checked: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Stop on any Jackpot',
|
labelKey: 'game.modals.autoSetting.rows.stopOnAnyJackpot',
|
||||||
// value: '50000',
|
// value: '50000',
|
||||||
checked: false,
|
checked: false,
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function DesktopAutoSettingModal() {
|
function DesktopAutoSettingModal() {
|
||||||
const [open, setOpen] = useState(true)
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopAutoSetting)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
setOpen(false)
|
setModalOpen('desktopAutoSetting', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +39,7 @@ function DesktopAutoSettingModal() {
|
|||||||
onClose={handleSubmit}
|
onClose={handleSubmit}
|
||||||
title={
|
title={
|
||||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||||
Biomond Balance
|
{t('game.modals.autoSetting.title')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
isNormalBg={true}
|
isNormalBg={true}
|
||||||
@@ -51,7 +54,7 @@ function DesktopAutoSettingModal() {
|
|||||||
<div className={'flex w-full flex-col gap-design-26'}>
|
<div className={'flex w-full flex-col gap-design-26'}>
|
||||||
{AUTO_STOP_ROWS.map((row) => (
|
{AUTO_STOP_ROWS.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={row.label}
|
key={row.labelKey}
|
||||||
className={'flex items-center justify-between gap-design-30'}
|
className={'flex items-center justify-between gap-design-30'}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -59,10 +62,10 @@ function DesktopAutoSettingModal() {
|
|||||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{row.label}
|
{t(row.labelKey)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{row.value ? (
|
{'value' in row ? (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||||
@@ -95,7 +98,7 @@ function DesktopAutoSettingModal() {
|
|||||||
'w-design-300 h-design-72 pb-design-4 flex items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF]'
|
'w-design-300 h-design-72 pb-design-4 flex items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
START AUTO-SPIN
|
{t('game.modals.autoSetting.startAutoSpin')}
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,100 +1,28 @@
|
|||||||
import { motion } from 'motion/react'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useState } from 'react'
|
|
||||||
import loginBg from '@/assets/system/login-bg.webp'
|
|
||||||
import rightImg from '@/assets/system/right.webp'
|
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { DesktopLoginForm } from '@/features/auth/components/desktop-login-form'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { useModalStore } from '@/store'
|
||||||
import { Input } from '@/components/ui/input.tsx'
|
|
||||||
|
|
||||||
function DesktopLoginModal() {
|
function DesktopLoginModal() {
|
||||||
const [open, setOpen] = useState(true)
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopLogin)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
setOpen(false)
|
setModalOpen('desktopLogin', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenterModal
|
<CenterModal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => {}}
|
onClose={() => setModalOpen('desktopLogin', false)}
|
||||||
title={<div className={'modal-title-glow'}>登录</div>}
|
title={
|
||||||
|
<div className={'modal-title-glow'}>{t('game.modals.login.title')}</div>
|
||||||
|
}
|
||||||
titleAlign="center"
|
titleAlign="center"
|
||||||
className={'w-design-980 h-design-540'}
|
className={'w-design-980 h-design-540'}
|
||||||
>
|
>
|
||||||
<div
|
<DesktopLoginForm onSuccess={handleSubmit} />
|
||||||
className={
|
|
||||||
'flex flex-col items-center justify-between gap-design-20 px-design-20'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'h-design-375 flex flex-col gap-design-45 w-full bg-[#060B0F]/50 p-design-50'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={'flex items-center'}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Akun/TEL:
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
className={'flex-1 text-left'}
|
|
||||||
placeholder={'Silakan masukkan akun atau nomor ponsel Anda.'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={'flex items-center'}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Kata Sandi:
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
className={'flex-1 text-left'}
|
|
||||||
placeholder={'Masukkan Kata Sandi'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={'flex items-center justify-around'}>
|
|
||||||
<div className={'flex items-center gap-design-10'}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SmartImage alt={'right'} src={rightImg} />
|
|
||||||
</div>
|
|
||||||
<div className={'text-[#549195]'}>Daftar Akun</div>
|
|
||||||
</div>
|
|
||||||
<div className={'flex items-center gap-design-10'}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SmartImage alt={'right'} src={rightImg} />
|
|
||||||
</div>
|
|
||||||
<div className={'text-[#549195]'}>Ingat Kata Sandi</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SmartBackground
|
|
||||||
as={motion.div}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
src={loginBg}
|
|
||||||
size="100% 100%"
|
|
||||||
className={
|
|
||||||
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
MASUK
|
|
||||||
</SmartBackground>
|
|
||||||
</div>
|
|
||||||
</CenterModal>
|
</CenterModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useState } from 'react'
|
import { useTranslation } from 'react-i18next'
|
||||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||||
import noticeBg from '@/assets/system/notice-bg.webp'
|
import noticeBg from '@/assets/system/notice-bg.webp'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
function DesktopNoticeModal() {
|
function DesktopNoticeModal() {
|
||||||
const [open, setOpen] = useState(true)
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopNotice)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
setOpen(false)
|
setModalOpen('desktopNotice', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,7 +22,7 @@ function DesktopNoticeModal() {
|
|||||||
onClose={handleSubmit}
|
onClose={handleSubmit}
|
||||||
title={
|
title={
|
||||||
<div className={'modal-title-glow text-design-26'}>
|
<div className={'modal-title-glow text-design-26'}>
|
||||||
PENGUMUMAN ACARA
|
{t('game.modals.notice.title')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
isNormalBg={true}
|
isNormalBg={true}
|
||||||
@@ -40,13 +43,7 @@ function DesktopNoticeModal() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={'text-[#74B3BA] text-design-18 leading-[1.6]'}>
|
<div className={'text-[#74B3BA] text-design-18 leading-[1.6]'}>
|
||||||
"Perjanjian Lisensi dan Layanan Game" (selanjutnya disebut sebagai
|
{t('game.modals.notice.content')}
|
||||||
"Perjanjian ini") disepakati secara bersama-sama oleh Anda dan
|
|
||||||
Penyedia Layanan Game; Perjanjian ini merupakan kontrak yang
|
|
||||||
mengikat secara hukum. Anda sangat dianjurkan untuk membaca dengan
|
|
||||||
saksama dan memahami s epenuhnya isi dari setiap klausul—khususnya
|
|
||||||
klausul-klausul yang membebaskan atau membatasi tanggung jawab
|
|
||||||
(selanjutnya disebut sebagai "Klausul Pembebasan"),
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={'w-full flex justify-around'}>
|
<div className={'w-full flex justify-around'}>
|
||||||
@@ -59,7 +56,7 @@ function DesktopNoticeModal() {
|
|||||||
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
|
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Memeriksa
|
{t('game.modals.notice.check')}
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
|
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
@@ -71,7 +68,7 @@ function DesktopNoticeModal() {
|
|||||||
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
|
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Memeriksa
|
{t('game.modals.notice.check')}
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { useState } from 'react'
|
import { useTranslation } from 'react-i18next'
|
||||||
import proceduresBg from '@/assets/system/procedures-bg.webp'
|
import proceduresBg from '@/assets/system/procedures-bg.webp'
|
||||||
import topupBtnBg from '@/assets/system/topup.webp'
|
import topupBtnBg from '@/assets/system/topup.webp'
|
||||||
import withdrawBtnBg from '@/assets/system/withdraw.webp'
|
import withdrawBtnBg from '@/assets/system/withdraw.webp'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
function DesktopProceduresModal() {
|
function DesktopProceduresModal() {
|
||||||
const [open, setOpen] = useState(true)
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopProcedures)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const setWithdrawTopupType = useModalStore(
|
||||||
|
(state) => state.setWithdrawTopupType,
|
||||||
|
)
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
setOpen(false)
|
setModalOpen('desktopProcedures', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenWithdrawTopup(type: 'withdraw' | 'topup') {
|
||||||
|
setModalOpen('desktopProcedures', false)
|
||||||
|
setWithdrawTopupType(type)
|
||||||
|
setModalOpen('desktopWithdrawTopup', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,7 +30,7 @@ function DesktopProceduresModal() {
|
|||||||
onClose={handleSubmit}
|
onClose={handleSubmit}
|
||||||
title={
|
title={
|
||||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||||
Biomond Balance
|
{t('game.modals.procedures.title')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
isNormalBg={true}
|
isNormalBg={true}
|
||||||
@@ -33,23 +45,27 @@ function DesktopProceduresModal() {
|
|||||||
'h-[95%] w-full rounded-md flex flex-col items-center justify-between'
|
'h-[95%] w-full rounded-md flex flex-col items-center justify-between'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={'mt-design-190'}>111</div>
|
<div className={'mt-design-190'}>
|
||||||
|
{t('game.modals.procedures.contentPlaceholder')}
|
||||||
|
</div>
|
||||||
<div className={'flex items-center ml-design-180'}>
|
<div className={'flex items-center ml-design-180'}>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
src={withdrawBtnBg}
|
src={withdrawBtnBg}
|
||||||
|
onClick={() => handleOpenWithdrawTopup('withdraw')}
|
||||||
className={
|
className={
|
||||||
'w-design-400 h-design-195 flex items-center justify-center pb-design-10 text-design-32 font-bold'
|
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-10 text-design-32 font-bold'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
提 现
|
{t('game.modals.procedures.withdraw')}
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
src={topupBtnBg}
|
src={topupBtnBg}
|
||||||
|
onClick={() => handleOpenWithdrawTopup('topup')}
|
||||||
className={
|
className={
|
||||||
'w-design-400 h-design-195 flex items-center justify-center pb-design-20 text-design-32 font-bold'
|
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-20 text-design-32 font-bold'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
充 值
|
{t('game.modals.procedures.topup')}
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
</div>
|
</div>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
|
|||||||
@@ -1,124 +1,30 @@
|
|||||||
import { motion } from 'motion/react'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useState } from 'react'
|
|
||||||
import loginBg from '@/assets/system/login-bg.webp'
|
|
||||||
import rightImg from '@/assets/system/right.webp'
|
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { DesktopRegisterForm } from '@/features/auth/components/desktop-register-form'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { useModalStore } from '@/store'
|
||||||
import { Input } from '@/components/ui/input.tsx'
|
|
||||||
|
|
||||||
function DesktopRegisterModal() {
|
function DesktopRegisterModal() {
|
||||||
const [open, setOpen] = useState(true)
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopRegister)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
setOpen(false)
|
setModalOpen('desktopRegister', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenterModal
|
<CenterModal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => {}}
|
onClose={() => setModalOpen('desktopRegister', false)}
|
||||||
title={<div className={'modal-title-glow'}>注册</div>}
|
title={
|
||||||
|
<div className={'modal-title-glow'}>
|
||||||
|
{t('game.modals.register.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
titleAlign="center"
|
titleAlign="center"
|
||||||
className={'w-design-980 h-design-740'}
|
className={'w-design-980 h-design-740'}
|
||||||
>
|
>
|
||||||
<div
|
<DesktopRegisterForm onSuccess={handleSubmit} />
|
||||||
className={'flex flex-col items-center justify-between px-design-20'}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'h-design-490 flex flex-col gap-design-30 w-full bg-[#060B0F]/50 p-design-50'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={'flex items-center'}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Akun/TEL:
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
className={'flex-1 text-left'}
|
|
||||||
placeholder={'Silakan masukkan akun atau nomor ponsel Anda.'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={'flex items-center'}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Kata Sandi:
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
className={'flex-1 text-left'}
|
|
||||||
placeholder={'Masukkan Kata Sandi'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={'flex items-center'}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Kata Sandi:
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
className={'flex-1 text-left'}
|
|
||||||
placeholder={'Masukkan Kata Sandi'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={'flex items-center'}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Kata Sandi:
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
className={'flex-1 text-left'}
|
|
||||||
placeholder={'Masukkan Kata Sandi'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={'flex items-center justify-around'}>
|
|
||||||
<div className={'flex items-center gap-design-10'}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SmartImage alt={'right'} src={rightImg} />
|
|
||||||
</div>
|
|
||||||
<div className={'text-[#549195]'}>Daftar Akun</div>
|
|
||||||
</div>
|
|
||||||
<div className={'flex items-center gap-design-10'}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SmartImage alt={'right'} src={rightImg} />
|
|
||||||
</div>
|
|
||||||
<div className={'text-[#549195]'}>Ingat Kata Sandi</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SmartBackground
|
|
||||||
as={motion.div}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
src={loginBg}
|
|
||||||
size="100% 100%"
|
|
||||||
className={
|
|
||||||
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
MASUK
|
|
||||||
</SmartBackground>
|
|
||||||
</div>
|
|
||||||
</CenterModal>
|
</CenterModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CircleUserRound, Mail } from 'lucide-react'
|
import { CircleUserRound, Mail } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import avatar from '@/assets/system/avatar.webp'
|
import avatar from '@/assets/system/avatar.webp'
|
||||||
import blueBtnBg from '@/assets/system/blue-btn.webp'
|
import blueBtnBg from '@/assets/system/blue-btn.webp'
|
||||||
import lengthBtnBg from '@/assets/system/length-blue-btn.webp'
|
import lengthBtnBg from '@/assets/system/length-blue-btn.webp'
|
||||||
@@ -8,32 +9,35 @@ import { CenterModal } from '@/components/center-modal.tsx'
|
|||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
type UserInfoTabKey = 'profile' | 'message'
|
type UserInfoTabKey = 'profile' | 'message'
|
||||||
|
|
||||||
const USER_INFO_TABS: Array<{
|
const USER_INFO_TABS: Array<{
|
||||||
key: UserInfoTabKey
|
key: UserInfoTabKey
|
||||||
label: string
|
labelKey: string
|
||||||
icon: typeof CircleUserRound
|
icon: typeof CircleUserRound
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
key: 'profile',
|
key: 'profile',
|
||||||
label: '个人信息',
|
labelKey: 'game.modals.userInfo.tabs.profile',
|
||||||
icon: CircleUserRound,
|
icon: CircleUserRound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'message',
|
key: 'message',
|
||||||
label: '站内消息',
|
labelKey: 'game.modals.userInfo.tabs.message',
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function DesktopUserInfoModal() {
|
function DesktopUserInfoModal() {
|
||||||
const [open, setOpen] = useState(true)
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopUserInfo)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
|
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
setOpen(false)
|
setModalOpen('desktopUserInfo', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +45,9 @@ function DesktopUserInfoModal() {
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={handleSubmit}
|
onClose={handleSubmit}
|
||||||
title={
|
title={
|
||||||
<div className={'modal-title-glow text-design-26'}>Biomond Balance</div>
|
<div className={'modal-title-glow text-design-26'}>
|
||||||
|
{t('game.modals.userInfo.title')}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
isNormalBg={true}
|
isNormalBg={true}
|
||||||
titleAlign="left"
|
titleAlign="left"
|
||||||
@@ -96,7 +102,7 @@ function DesktopUserInfoModal() {
|
|||||||
isActive && 'modal-title-gold-glow',
|
isActive && 'modal-title-gold-glow',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{t(tab.labelKey)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -119,8 +125,12 @@ function DesktopUserInfoModal() {
|
|||||||
alt={'avatar'}
|
alt={'avatar'}
|
||||||
/>
|
/>
|
||||||
<div className={'flex flex-col gap-design-30'}>
|
<div className={'flex flex-col gap-design-30'}>
|
||||||
<div>NAMA :Biomond Balance</div>
|
<div>
|
||||||
<div>TEL :12345678901</div>
|
{t('game.modals.userInfo.profile.name')} :Biomond Balance
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('game.modals.userInfo.profile.tel')} :12345678901
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,7 +138,7 @@ function DesktopUserInfoModal() {
|
|||||||
<div className={'flex flex-col gap-design-5'}>
|
<div className={'flex flex-col gap-design-5'}>
|
||||||
{[1, 2, 3, 4].map((item) => (
|
{[1, 2, 3, 4].map((item) => (
|
||||||
<div key={item}>
|
<div key={item}>
|
||||||
Tanggal Pendaftaran :
|
{t('game.modals.userInfo.profile.registeredAt')} :
|
||||||
<span className={'text-design-18 text-[#599AA3]'}>
|
<span className={'text-design-18 text-[#599AA3]'}>
|
||||||
2022-10-06 23:36
|
2022-10-06 23:36
|
||||||
</span>
|
</span>
|
||||||
@@ -140,8 +150,7 @@ function DesktopUserInfoModal() {
|
|||||||
'w-design-600 h-design-120 text-design-18 rounded-md bg-[#000000]/40 flex items-center justify-center'
|
'w-design-600 h-design-120 text-design-18 rounded-md bg-[#000000]/40 flex items-center justify-center'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Tanda tangan pribadi saya persis seperti jiwa saya—unik dan
|
{t('game.modals.userInfo.profile.signature')}
|
||||||
mus
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
@@ -166,10 +175,7 @@ function DesktopUserInfoModal() {
|
|||||||
<div className={'h-design-95 w-design-95 bg-black'}></div>
|
<div className={'h-design-95 w-design-95 bg-black'}></div>
|
||||||
<div className={'flex-1'}>
|
<div className={'flex-1'}>
|
||||||
<div>2026-10-10 08:32:56</div>
|
<div>2026-10-10 08:32:56</div>
|
||||||
<div>
|
<div>{t('game.modals.userInfo.message.eventBonus')}</div>
|
||||||
[Event Bonus Isi Ulang] Dari tanggal 1 hingga 7 Oktober
|
|
||||||
2026, dapatkan pengembalian ...
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
src={blueBtnBg}
|
src={blueBtnBg}
|
||||||
@@ -178,7 +184,7 @@ function DesktopUserInfoModal() {
|
|||||||
'w-design-150 h-design-64 flex items-center justify-center text-design-20 font-bold'
|
'w-design-150 h-design-64 flex items-center justify-center text-design-20 font-bold'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Memeriksa
|
{t('game.modals.userInfo.message.check')}
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -196,7 +202,7 @@ function DesktopUserInfoModal() {
|
|||||||
'w-design-275 h-design-65 flex items-center justify-center text-design-22 font-bold'
|
'w-design-275 h-design-65 flex items-center justify-center text-design-22 font-bold'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
删除记录
|
{t('game.modals.userInfo.message.deleteRecords')}
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useState } from 'react'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import DesktopTopup from '@/features/game/components/desktop/desktop-topup.tsx'
|
import DesktopTopup from '@/features/game/components/desktop/desktop-topup.tsx'
|
||||||
import DesktopWithdraw from '@/features/game/components/desktop/desktop-withdraw.tsx'
|
import DesktopWithdraw from '@/features/game/components/desktop/desktop-withdraw.tsx'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
type WithdrawType = 'withdraw' | 'topup'
|
|
||||||
|
|
||||||
function DesktopWithdrawTopupModal() {
|
function DesktopWithdrawTopupModal() {
|
||||||
const [open, setOpen] = useState(true)
|
const { t } = useTranslation()
|
||||||
const [type] = useState<WithdrawType>('withdraw')
|
const open = useModalStore((state) => state.modals.desktopWithdrawTopup)
|
||||||
|
const type = useModalStore((state) => state.withdrawTopupType)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
setOpen(false)
|
setModalOpen('desktopWithdrawTopup', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,7 +20,9 @@ function DesktopWithdrawTopupModal() {
|
|||||||
onClose={handleSubmit}
|
onClose={handleSubmit}
|
||||||
title={
|
title={
|
||||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||||
{type === 'withdraw' ? '申请提现' : '申请充值'}
|
{type === 'withdraw'
|
||||||
|
? t('game.modals.withdrawTopup.applyWithdraw')
|
||||||
|
: t('game.modals.withdrawTopup.applyTopup')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
isNormalBg={true}
|
isNormalBg={true}
|
||||||
|
|||||||
@@ -56,4 +56,4 @@ export const DEFAULT_GAME_CHIP_COLORS = [
|
|||||||
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-5'
|
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-5'
|
||||||
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
|
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
|
||||||
export const GAME_RECENT_HISTORY_LIMIT = 12
|
export const GAME_RECENT_HISTORY_LIMIT = 12
|
||||||
export const GAME_BOARD_COLUMNS = GAME_GRID_COLUMNS
|
export const GAME_MAX_SELECTION_CELLS = 5
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { CHIP_OPTIONS } from '@/constants'
|
import { DEFAULT_CHIP_AMOUNTS } from '@/constants'
|
||||||
import {
|
import {
|
||||||
DEFAULT_ACTIVE_CHIP_ID,
|
DEFAULT_ACTIVE_CHIP_ID,
|
||||||
DEFAULT_ANNOUNCEMENT_TTL_MS,
|
DEFAULT_ANNOUNCEMENT_TTL_MS,
|
||||||
DEFAULT_GAME_CHIP_COLORS,
|
DEFAULT_GAME_CHIP_COLORS,
|
||||||
GAME_GRID_COLUMNS,
|
GAME_GRID_COLUMNS,
|
||||||
|
GAME_MAX_SELECTION_CELLS,
|
||||||
GAME_TOTAL_CELLS,
|
GAME_TOTAL_CELLS,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
import { deriveTrendEntries, getRoundCountdownMs } from './selectors'
|
import { deriveTrendEntries, getRoundCountdownMs } from './selectors'
|
||||||
@@ -41,12 +42,12 @@ export function createGameCells() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultChips() {
|
export function createDefaultChips() {
|
||||||
return CHIP_OPTIONS.map((chip, index) => ({
|
return DEFAULT_CHIP_AMOUNTS.map((chip, index) => ({
|
||||||
amount: chip.value,
|
amount: chip.amount,
|
||||||
color: DEFAULT_GAME_CHIP_COLORS[index],
|
color: DEFAULT_GAME_CHIP_COLORS[index],
|
||||||
id: chip.id,
|
id: chip.id,
|
||||||
isDefault: chip.id === DEFAULT_ACTIVE_CHIP_ID,
|
isDefault: chip.id === DEFAULT_ACTIVE_CHIP_ID,
|
||||||
label: chip.value >= 100 ? `${chip.value / 100}x` : String(chip.value),
|
label: chip.amount >= 100 ? `${chip.amount / 100}x` : String(chip.amount),
|
||||||
})) satisfies Chip[]
|
})) satisfies Chip[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,36 +77,8 @@ export function createMockRoundSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
|
|||||||
} satisfies RoundSnapshot
|
} satisfies RoundSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMockBetSelections(chips = createDefaultChips()) {
|
export function createMockBetSelections() {
|
||||||
const defaultChip =
|
return [] satisfies BetSelection[]
|
||||||
chips.find((chip) => chip.id === DEFAULT_ACTIVE_CHIP_ID) ?? chips[0]
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
amount: defaultChip.amount,
|
|
||||||
cellId: 8,
|
|
||||||
chipId: defaultChip.id,
|
|
||||||
id: 'bet-local-1',
|
|
||||||
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 4_000),
|
|
||||||
source: 'local',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
amount: chips[1]?.amount ?? defaultChip.amount,
|
|
||||||
cellId: 12,
|
|
||||||
chipId: chips[1]?.id ?? defaultChip.id,
|
|
||||||
id: 'bet-server-2',
|
|
||||||
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 7_000),
|
|
||||||
source: 'server',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
amount: chips[3]?.amount ?? defaultChip.amount,
|
|
||||||
cellId: 17,
|
|
||||||
chipId: chips[3]?.id ?? defaultChip.id,
|
|
||||||
id: 'bet-local-3',
|
|
||||||
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 10_000),
|
|
||||||
source: 'local',
|
|
||||||
},
|
|
||||||
] satisfies BetSelection[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMockAnnouncementState(baseIso = MOCK_GAME_BASE_TIME) {
|
export function createMockAnnouncementState(baseIso = MOCK_GAME_BASE_TIME) {
|
||||||
@@ -177,8 +150,9 @@ export function createMockGameBootstrapSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
|
|||||||
connection: createMockConnectionState(baseIso),
|
connection: createMockConnectionState(baseIso),
|
||||||
dashboard: createMockDashboardState(baseIso, round, history),
|
dashboard: createMockDashboardState(baseIso, round, history),
|
||||||
history,
|
history,
|
||||||
|
maxSelectionCount: GAME_MAX_SELECTION_CELLS,
|
||||||
round,
|
round,
|
||||||
selections: createMockBetSelections(chips),
|
selections: createMockBetSelections(),
|
||||||
trends: deriveTrendEntries(history),
|
trends: deriveTrendEntries(history),
|
||||||
} satisfies GameBootstrapSnapshot
|
} satisfies GameBootstrapSnapshot
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export interface GameBootstrapSnapshot {
|
|||||||
connection: ConnectionState
|
connection: ConnectionState
|
||||||
dashboard: DashboardState
|
dashboard: DashboardState
|
||||||
history: HistoryEntry[]
|
history: HistoryEntry[]
|
||||||
|
maxSelectionCount: number
|
||||||
round: RoundSnapshot
|
round: RoundSnapshot
|
||||||
selections: BetSelection[]
|
selections: BetSelection[]
|
||||||
trends: TrendEntry[]
|
trends: TrendEntry[]
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import i18n from 'i18next'
|
import i18n from 'i18next'
|
||||||
import { initReactI18next } from 'react-i18next'
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
|
||||||
import { I18N_LANGUAGE_STORAGE_KEY } from '@/constants'
|
|
||||||
import enUSCommon from '@/locales/en-US/common'
|
import enUSCommon from '@/locales/en-US/common'
|
||||||
|
import idIDCommon from '@/locales/id-ID/common'
|
||||||
|
import msMYCommon from '@/locales/ms-MY/common'
|
||||||
import zhCNCommon from '@/locales/zh-CN/common'
|
import zhCNCommon from '@/locales/zh-CN/common'
|
||||||
|
import { getStoredAppLanguage, setStoredAppLanguage } from '@/store/auth'
|
||||||
|
|
||||||
export const supportedLanguages = ['zh-CN', 'en-US'] as const
|
export const supportedLanguages = ['zh-CN', 'en-US', 'ms-MY', 'id-ID'] as const
|
||||||
export type AppLanguage = (typeof supportedLanguages)[number]
|
export type AppLanguage = (typeof supportedLanguages)[number]
|
||||||
|
|
||||||
const defaultLanguage: AppLanguage = 'zh-CN'
|
const defaultLanguage: AppLanguage = 'zh-CN'
|
||||||
@@ -39,6 +41,14 @@ function detectBrowserLanguage() {
|
|||||||
if (normalizedLanguage.startsWith('en')) {
|
if (normalizedLanguage.startsWith('en')) {
|
||||||
return 'en-US'
|
return 'en-US'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedLanguage.startsWith('ms')) {
|
||||||
|
return 'ms-MY'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedLanguage.startsWith('id')) {
|
||||||
|
return 'id-ID'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaultLanguage
|
return defaultLanguage
|
||||||
@@ -46,13 +56,7 @@ function detectBrowserLanguage() {
|
|||||||
|
|
||||||
/** @description 获取应用启动时应使用的初始语言。 */
|
/** @description 获取应用启动时应使用的初始语言。 */
|
||||||
function getInitialLanguage() {
|
function getInitialLanguage() {
|
||||||
if (typeof window === 'undefined') {
|
const persistedLanguage = getStoredAppLanguage()
|
||||||
return defaultLanguage
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistedLanguage = window.localStorage.getItem(
|
|
||||||
I18N_LANGUAGE_STORAGE_KEY,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isSupportedLanguage(persistedLanguage)) {
|
if (isSupportedLanguage(persistedLanguage)) {
|
||||||
return persistedLanguage
|
return persistedLanguage
|
||||||
@@ -91,6 +95,12 @@ void i18n.use(initReactI18next).init({
|
|||||||
'en-US': {
|
'en-US': {
|
||||||
common: enUSCommon,
|
common: enUSCommon,
|
||||||
},
|
},
|
||||||
|
'ms-MY': {
|
||||||
|
common: msMYCommon,
|
||||||
|
},
|
||||||
|
'id-ID': {
|
||||||
|
common: idIDCommon,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
})
|
})
|
||||||
@@ -101,8 +111,8 @@ function syncLanguageState(language: string) {
|
|||||||
document.documentElement.lang = language
|
document.documentElement.lang = language
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && isSupportedLanguage(language)) {
|
if (isSupportedLanguage(language)) {
|
||||||
window.localStorage.setItem(I18N_LANGUAGE_STORAGE_KEY, language)
|
setStoredAppLanguage(language)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import {
|
|||||||
DEFAULT_REQUEST_ACCEPT_HEADER,
|
DEFAULT_REQUEST_ACCEPT_HEADER,
|
||||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
|
import type { AuthTokenDto } from '@/features/auth/api/types'
|
||||||
|
import { ApiError } from '@/lib/api/api-error.ts'
|
||||||
import {
|
import {
|
||||||
handleUnauthorizedSession,
|
handleUnauthorizedSession,
|
||||||
tryRefreshAuthSession,
|
tryRefreshAuthSession,
|
||||||
} from '@/lib/auth/auth-session'
|
} from '@/lib/auth/auth-session'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { md5 } from '@/lib/crypto/md5'
|
||||||
|
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
|
||||||
import { ApiError } from './api-error'
|
import type { ApiResponse } from '@/type'
|
||||||
import type { ApiResponse } from './types'
|
|
||||||
|
|
||||||
type RequestOptions = Omit<Options, 'json'>
|
type RequestOptions = Omit<Options, 'json'>
|
||||||
type JsonRequestOptions<TBody> = RequestOptions & {
|
type JsonRequestOptions<TBody> = RequestOptions & {
|
||||||
@@ -20,7 +21,12 @@ type JsonRequestOptions<TBody> = RequestOptions & {
|
|||||||
|
|
||||||
const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
|
const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
|
||||||
const AUTH_SKIP_REFRESH_CONTEXT_KEY = 'skipAuthRefresh'
|
const AUTH_SKIP_REFRESH_CONTEXT_KEY = 'skipAuthRefresh'
|
||||||
|
const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
|
||||||
|
const AUTH_REFRESH_ENDPOINT = 'api/user/refreshToken'
|
||||||
|
const ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000
|
||||||
|
const AUTH_TOKEN_CACHE_SKEW_MS = 30_000
|
||||||
const appEnv = import.meta.env.VITE_APP_ENV
|
const appEnv = import.meta.env.VITE_APP_ENV
|
||||||
|
const authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim()
|
||||||
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
||||||
|
|
||||||
function normalizeApiBaseUrl(baseUrl: string | undefined) {
|
function normalizeApiBaseUrl(baseUrl: string | undefined) {
|
||||||
@@ -96,6 +102,15 @@ async function toApiError(error: unknown) {
|
|||||||
|
|
||||||
export const apiBaseUrl = normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
|
export const apiBaseUrl = normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
|
||||||
|
|
||||||
|
const authTokenClient = ky.create({
|
||||||
|
prefix: apiBaseUrl,
|
||||||
|
retry: 0,
|
||||||
|
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
|
headers: {
|
||||||
|
Accept: DEFAULT_REQUEST_ACCEPT_HEADER,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const apiClient = ky.create({
|
const apiClient = ky.create({
|
||||||
prefix: apiBaseUrl,
|
prefix: apiBaseUrl,
|
||||||
retry: 0,
|
retry: 0,
|
||||||
@@ -109,6 +124,7 @@ const apiClient = ky.create({
|
|||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
request.headers.set('Authorization', `Bearer ${token}`)
|
request.headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
request.headers.set('user-token', token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldLogRequests) {
|
if (shouldLogRequests) {
|
||||||
@@ -128,9 +144,153 @@ const apiClient = ky.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function shouldAttachAuthToken(input: string) {
|
||||||
|
return input !== AUTH_TOKEN_ENDPOINT
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldTryRefreshAccessToken(input: string, options?: Options) {
|
||||||
|
if (
|
||||||
|
input === AUTH_REFRESH_ENDPOINT ||
|
||||||
|
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] === true
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const authState = useAuthStore.getState()
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
authState.accessToken &&
|
||||||
|
authState.accessTokenExpiresAt &&
|
||||||
|
authState.accessTokenExpiresAt <=
|
||||||
|
Date.now() + ACCESS_TOKEN_REFRESH_SKEW_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapEnvelopeData<T>(response: ApiResponse<T>) {
|
||||||
|
if (response.code === 1) {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError({
|
||||||
|
data: response,
|
||||||
|
message:
|
||||||
|
'msg' in response && typeof response.msg === 'string'
|
||||||
|
? response.msg
|
||||||
|
: 'message' in response && typeof response.message === 'string'
|
||||||
|
? response.message
|
||||||
|
: API_ERROR_MESSAGES.unexpected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuthToken() {
|
||||||
|
try {
|
||||||
|
const authState = useAuthStore.getState()
|
||||||
|
|
||||||
|
if (
|
||||||
|
authState.apiAuthToken &&
|
||||||
|
authState.apiAuthTokenExpiresAt &&
|
||||||
|
authState.apiAuthTokenExpiresAt > Date.now() + AUTH_TOKEN_CACHE_SKEW_MS
|
||||||
|
) {
|
||||||
|
return authState.apiAuthToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authSecret) {
|
||||||
|
throw new ApiError({
|
||||||
|
message: 'auth.errors.authTokenConfigMissing',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = getAuthDeviceId()
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000)
|
||||||
|
const signature = md5(
|
||||||
|
`device_id=${deviceId}&secret=${authSecret}×tamp=${timestamp}`,
|
||||||
|
).toUpperCase()
|
||||||
|
|
||||||
|
const response = await authTokenClient
|
||||||
|
.get(AUTH_TOKEN_ENDPOINT, {
|
||||||
|
searchParams: {
|
||||||
|
device_id: deviceId,
|
||||||
|
secret: authSecret,
|
||||||
|
signature,
|
||||||
|
timestamp: String(timestamp),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json<ApiResponse<AuthTokenDto>>()
|
||||||
|
|
||||||
|
const data = unwrapEnvelopeData(response)
|
||||||
|
const expiresAt = Date.now() + data.expires_in * 1000
|
||||||
|
|
||||||
|
useAuthStore.getState().setApiAuthToken({
|
||||||
|
expiresAt,
|
||||||
|
serverTime: data.server_time,
|
||||||
|
value: data.auth_token,
|
||||||
|
})
|
||||||
|
|
||||||
|
return data.auth_token
|
||||||
|
} catch (error) {
|
||||||
|
throw await toApiError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prefetchAuthToken() {
|
||||||
|
await fetchAuthToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHeaders(headersInit?: Options['headers']) {
|
||||||
|
const headers = new Headers()
|
||||||
|
|
||||||
|
if (!headersInit) {
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headersInit instanceof Headers) {
|
||||||
|
headersInit.forEach((value, key) => {
|
||||||
|
headers.set(key, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(headersInit)) {
|
||||||
|
for (const [key, value] of headersInit) {
|
||||||
|
headers.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(headersInit)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
headers.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRequestOptions(input: string, options?: Options) {
|
||||||
|
const headers = createHeaders(options?.headers)
|
||||||
|
|
||||||
|
if (shouldAttachAuthToken(input) && !headers.has('auth-token')) {
|
||||||
|
headers.set('auth-token', await fetchAuthToken())
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
} satisfies Options
|
||||||
|
}
|
||||||
|
|
||||||
async function request<TResponse>(input: string, options?: Options) {
|
async function request<TResponse>(input: string, options?: Options) {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient(input, options)
|
if (shouldTryRefreshAccessToken(input, options)) {
|
||||||
|
await tryRefreshAuthSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient(
|
||||||
|
input,
|
||||||
|
await buildRequestOptions(input, options),
|
||||||
|
)
|
||||||
const data = await parseResponseBody(response)
|
const data = await parseResponseBody(response)
|
||||||
|
|
||||||
return data as TResponse
|
return data as TResponse
|
||||||
@@ -138,6 +298,7 @@ async function request<TResponse>(input: string, options?: Options) {
|
|||||||
if (
|
if (
|
||||||
error instanceof HTTPError &&
|
error instanceof HTTPError &&
|
||||||
error.response.status === 401 &&
|
error.response.status === 401 &&
|
||||||
|
input !== AUTH_REFRESH_ENDPOINT &&
|
||||||
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] !== true &&
|
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] !== true &&
|
||||||
options?.context?.[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY] !== true
|
options?.context?.[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY] !== true
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
interface ApiErrorOptions {
|
import type { ApiErrorOptions } from '@/type'
|
||||||
message: string
|
|
||||||
status?: number
|
|
||||||
data?: unknown
|
|
||||||
url?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number | null
|
status: number | null
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
/** @description 后端统一响应体结构。 */
|
|
||||||
export interface ApiResponse<T> {
|
|
||||||
code: number
|
|
||||||
msg: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
@@ -61,6 +61,14 @@ export async function initializeAuthSession() {
|
|||||||
return authInitializationPromise
|
return authInitializationPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hydrateCurrentUser(initializer: CurrentUserInitializer) {
|
||||||
|
const currentUser = await initializer()
|
||||||
|
|
||||||
|
useAuthStore.getState().setCurrentUser(currentUser)
|
||||||
|
|
||||||
|
return currentUser
|
||||||
|
}
|
||||||
|
|
||||||
export async function tryRefreshAuthSession() {
|
export async function tryRefreshAuthSession() {
|
||||||
if (refreshSessionPromise) {
|
if (refreshSessionPromise) {
|
||||||
return refreshSessionPromise
|
return refreshSessionPromise
|
||||||
@@ -86,6 +94,8 @@ export async function tryRefreshAuthSession() {
|
|||||||
|
|
||||||
useAuthStore.getState().startSession({
|
useAuthStore.getState().startSession({
|
||||||
accessToken: nextSession.accessToken,
|
accessToken: nextSession.accessToken,
|
||||||
|
accessTokenExpiresAt:
|
||||||
|
nextSession.accessTokenExpiresAt ?? snapshot.accessTokenExpiresAt,
|
||||||
currentUser: nextSession.currentUser ?? snapshot.currentUser,
|
currentUser: nextSession.currentUser ?? snapshot.currentUser,
|
||||||
refreshToken: nextSession.refreshToken ?? snapshot.refreshToken,
|
refreshToken: nextSession.refreshToken ?? snapshot.refreshToken,
|
||||||
})
|
})
|
||||||
|
|||||||
5
src/lib/crypto/md5.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import md5Hash from 'md5'
|
||||||
|
|
||||||
|
export function md5(value: string) {
|
||||||
|
return md5Hash(value)
|
||||||
|
}
|
||||||
113
src/lib/notify.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
const DEFAULT_TOAST_DURATION_MS = 3200
|
||||||
|
|
||||||
|
type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading'
|
||||||
|
|
||||||
|
export interface NotifyOptions {
|
||||||
|
description?: string
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationToast {
|
||||||
|
description?: string
|
||||||
|
duration: number
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
type: NotificationType
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationStoreState {
|
||||||
|
dismissToast: (id: string) => void
|
||||||
|
pushToast: (toast: NotificationToast) => void
|
||||||
|
toasts: NotificationToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimers = new Map<string, number>()
|
||||||
|
|
||||||
|
export const useNotificationStore = create<NotificationStoreState>()((set) => ({
|
||||||
|
dismissToast: (id) => {
|
||||||
|
const timerId = toastTimers.get(id)
|
||||||
|
|
||||||
|
if (timerId) {
|
||||||
|
window.clearTimeout(timerId)
|
||||||
|
toastTimers.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
toasts: state.toasts.filter((toast) => toast.id !== id),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
pushToast: (toast) => {
|
||||||
|
set((state) => ({
|
||||||
|
toasts: [...state.toasts.filter((item) => item.id !== toast.id), toast],
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
toasts: [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createToastId() {
|
||||||
|
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(
|
||||||
|
type: NotificationType,
|
||||||
|
message: string,
|
||||||
|
options?: NotifyOptions,
|
||||||
|
) {
|
||||||
|
const id = createToastId()
|
||||||
|
const duration = options?.duration ?? DEFAULT_TOAST_DURATION_MS
|
||||||
|
|
||||||
|
useNotificationStore.getState().pushToast({
|
||||||
|
description: options?.description,
|
||||||
|
duration,
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
const timerId = window.setTimeout(() => {
|
||||||
|
useNotificationStore.getState().dismissToast(id)
|
||||||
|
}, duration)
|
||||||
|
|
||||||
|
toastTimers.set(id, timerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notify = {
|
||||||
|
dismiss(id?: string) {
|
||||||
|
if (id) {
|
||||||
|
useNotificationStore.getState().dismissToast(id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const { toasts } = useNotificationStore.getState()
|
||||||
|
|
||||||
|
for (const toast of toasts) {
|
||||||
|
useNotificationStore.getState().dismissToast(toast.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
error(message: string, options?: NotifyOptions) {
|
||||||
|
return showToast('error', message, options)
|
||||||
|
},
|
||||||
|
info(message: string, options?: NotifyOptions) {
|
||||||
|
return showToast('info', message, options)
|
||||||
|
},
|
||||||
|
loading(message: string, options?: NotifyOptions) {
|
||||||
|
return showToast('loading', message, options)
|
||||||
|
},
|
||||||
|
message(message: string, options?: NotifyOptions) {
|
||||||
|
return showToast('info', message, options)
|
||||||
|
},
|
||||||
|
success(message: string, options?: NotifyOptions) {
|
||||||
|
return showToast('success', message, options)
|
||||||
|
},
|
||||||
|
warning(message: string, options?: NotifyOptions) {
|
||||||
|
return showToast('warning', message, options)
|
||||||
|
},
|
||||||
|
}
|
||||||
103
src/lib/utils.ts
@@ -4,3 +4,106 @@ import { twMerge } from 'tailwind-merge'
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FullscreenCapableElement = HTMLElement & {
|
||||||
|
mozRequestFullScreen?: () => Promise<void> | void
|
||||||
|
msRequestFullscreen?: () => Promise<void> | void
|
||||||
|
webkitRequestFullscreen?: () => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FullscreenCapableDocument = Document & {
|
||||||
|
mozCancelFullScreen?: () => Promise<void> | void
|
||||||
|
mozFullScreenElement?: Element | null
|
||||||
|
msExitFullscreen?: () => Promise<void> | void
|
||||||
|
msFullscreenElement?: Element | null
|
||||||
|
webkitExitFullscreen?: () => Promise<void> | void
|
||||||
|
webkitFullscreenElement?: Element | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const FULLSCREEN_CHANGE_EVENTS = [
|
||||||
|
'fullscreenchange',
|
||||||
|
'webkitfullscreenchange',
|
||||||
|
'mozfullscreenchange',
|
||||||
|
'MSFullscreenChange',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function isDesktopFullscreen() {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullscreenDocument = document as FullscreenCapableDocument
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
document.fullscreenElement ||
|
||||||
|
fullscreenDocument.webkitFullscreenElement ||
|
||||||
|
fullscreenDocument.mozFullScreenElement ||
|
||||||
|
fullscreenDocument.msFullscreenElement,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exitDesktopFullscreen() {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullscreenDocument = document as FullscreenCapableDocument
|
||||||
|
|
||||||
|
await Promise.resolve(
|
||||||
|
document.exitFullscreen?.() ??
|
||||||
|
fullscreenDocument.webkitExitFullscreen?.() ??
|
||||||
|
fullscreenDocument.mozCancelFullScreen?.() ??
|
||||||
|
fullscreenDocument.msExitFullscreen?.(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return !isDesktopFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeDesktopFullscreenChange(listener: () => void) {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
|
||||||
|
document.addEventListener(eventName, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
|
||||||
|
document.removeEventListener(eventName, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestDesktopFullscreen(
|
||||||
|
target: HTMLElement = document.documentElement,
|
||||||
|
) {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDesktopFullscreen()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullscreenTarget = target as FullscreenCapableElement
|
||||||
|
|
||||||
|
await Promise.resolve(
|
||||||
|
fullscreenTarget.requestFullscreen?.() ??
|
||||||
|
fullscreenTarget.webkitRequestFullscreen?.() ??
|
||||||
|
fullscreenTarget.mozRequestFullScreen?.() ??
|
||||||
|
fullscreenTarget.msRequestFullscreen?.(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return isDesktopFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleDesktopFullscreen(
|
||||||
|
target: HTMLElement = document.documentElement,
|
||||||
|
) {
|
||||||
|
if (isDesktopFullscreen()) {
|
||||||
|
return exitDesktopFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestDesktopFullscreen(target)
|
||||||
|
}
|
||||||
|
|||||||
297
src/lib/ws/game-socket-client.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
type GameSocketContext = {
|
||||||
|
authToken: string
|
||||||
|
deviceId: string
|
||||||
|
lang: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameSocketConnectedMessage = {
|
||||||
|
connection_id?: string
|
||||||
|
event: 'ws.connected'
|
||||||
|
heartbeat_interval?: number
|
||||||
|
server_time?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameSocketErrorMessage = {
|
||||||
|
code?: number
|
||||||
|
event: 'ws.error'
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameSocketPongMessage = {
|
||||||
|
action?: 'pong'
|
||||||
|
event?: 'pong'
|
||||||
|
server_time?: number
|
||||||
|
topic?: 'pong'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameSocketMessage =
|
||||||
|
| GameSocketConnectedMessage
|
||||||
|
| GameSocketErrorMessage
|
||||||
|
| GameSocketPongMessage
|
||||||
|
| ({ event?: string } & Record<string, unknown>)
|
||||||
|
|
||||||
|
type GameSocketStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'connecting'
|
||||||
|
| 'connected'
|
||||||
|
| 'reconnecting'
|
||||||
|
| 'disconnected'
|
||||||
|
|
||||||
|
type GameSocketClientOptions = {
|
||||||
|
getContext: () => Promise<GameSocketContext | null>
|
||||||
|
getUrl: () => string | null
|
||||||
|
onError?: (message: GameSocketErrorMessage | Error) => void
|
||||||
|
onLatencyChange?: (latencyMs: number | null) => void
|
||||||
|
onMessage?: (message: GameSocketMessage) => void
|
||||||
|
onStatusChange?: (status: GameSocketStatus, reconnectAttempt: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RECONNECT_DELAY_MS = 10_000
|
||||||
|
const LATENCY_PROBE_INTERVAL_MS = 3_000
|
||||||
|
const LATENCY_PROBE_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
function toQueryString(context: GameSocketContext) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
token: context.token,
|
||||||
|
auth_token: context.authToken,
|
||||||
|
device_id: context.deviceId,
|
||||||
|
lang: context.lang,
|
||||||
|
})
|
||||||
|
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameSocketClient {
|
||||||
|
private heartbeatTimerId: number | null = null
|
||||||
|
private latencyProbeTimerId: number | null = null
|
||||||
|
private manualClose = false
|
||||||
|
private readonly options: GameSocketClientOptions
|
||||||
|
private pendingPingSentAt: number | null = null
|
||||||
|
private reconnectAttempt = 0
|
||||||
|
private reconnectTimerId: number | null = null
|
||||||
|
private socket: WebSocket | null = null
|
||||||
|
private readonly subscribedTopics = new Set<string>()
|
||||||
|
|
||||||
|
constructor(options: GameSocketClientOptions) {
|
||||||
|
this.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearReconnectTimer()
|
||||||
|
this.clearHeartbeatTimer()
|
||||||
|
this.clearLatencyProbeTimer()
|
||||||
|
|
||||||
|
const url = this.options.getUrl()
|
||||||
|
const context = await this.options.getContext()
|
||||||
|
|
||||||
|
if (!url || !context) {
|
||||||
|
this.setStatus('disconnected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.manualClose = false
|
||||||
|
this.setStatus(this.reconnectAttempt > 0 ? 'reconnecting' : 'connecting')
|
||||||
|
|
||||||
|
const socketUrl = new URL(url)
|
||||||
|
|
||||||
|
socketUrl.search = toQueryString(context)
|
||||||
|
|
||||||
|
const socket = new WebSocket(socketUrl.toString())
|
||||||
|
|
||||||
|
this.socket = socket
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
this.flushSubscriptions()
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('message', (event) => {
|
||||||
|
this.handleMessage(event.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('error', () => {
|
||||||
|
this.options.onError?.(new Error('WebSocket connection error'))
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('close', () => {
|
||||||
|
this.socket = null
|
||||||
|
this.clearHeartbeatTimer()
|
||||||
|
this.clearLatencyProbeTimer()
|
||||||
|
this.pendingPingSentAt = null
|
||||||
|
this.options.onLatencyChange?.(null)
|
||||||
|
|
||||||
|
if (this.manualClose) {
|
||||||
|
this.setStatus('disconnected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleReconnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.manualClose = true
|
||||||
|
this.clearReconnectTimer()
|
||||||
|
this.clearHeartbeatTimer()
|
||||||
|
this.clearLatencyProbeTimer()
|
||||||
|
this.pendingPingSentAt = null
|
||||||
|
this.options.onLatencyChange?.(null)
|
||||||
|
this.socket?.close()
|
||||||
|
this.socket = null
|
||||||
|
this.setStatus('disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topics are de-duplicated locally and re-sent automatically after reconnect.
|
||||||
|
subscribe(topics: string[]) {
|
||||||
|
for (const topic of topics) {
|
||||||
|
this.subscribedTopics.add(topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.send({
|
||||||
|
action: 'subscribe',
|
||||||
|
topics: [...this.subscribedTopics],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
send(payload: Record<string, unknown>) {
|
||||||
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socket.send(JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearHeartbeatTimer() {
|
||||||
|
if (this.heartbeatTimerId !== null) {
|
||||||
|
window.clearInterval(this.heartbeatTimerId)
|
||||||
|
this.heartbeatTimerId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearReconnectTimer() {
|
||||||
|
if (this.reconnectTimerId !== null) {
|
||||||
|
window.clearTimeout(this.reconnectTimerId)
|
||||||
|
this.reconnectTimerId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearLatencyProbeTimer() {
|
||||||
|
if (this.latencyProbeTimerId !== null) {
|
||||||
|
window.clearInterval(this.latencyProbeTimerId)
|
||||||
|
this.latencyProbeTimerId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushSubscriptions() {
|
||||||
|
if (this.subscribedTopics.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.send({
|
||||||
|
action: 'subscribe',
|
||||||
|
topics: [...this.subscribedTopics],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendPing() {
|
||||||
|
const now = performance.now()
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.pendingPingSentAt !== null &&
|
||||||
|
now - this.pendingPingSentAt < LATENCY_PROBE_TIMEOUT_MS
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingPingSentAt = performance.now()
|
||||||
|
this.send({ action: 'ping' })
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePongMessage() {
|
||||||
|
if (this.pendingPingSentAt === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const latencyMs = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round(performance.now() - this.pendingPingSentAt),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.pendingPingSentAt = null
|
||||||
|
this.options.onLatencyChange?.(latencyMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleConnectedMessage(message: GameSocketConnectedMessage) {
|
||||||
|
this.reconnectAttempt = 0
|
||||||
|
this.setStatus('connected')
|
||||||
|
this.options.onLatencyChange?.(null)
|
||||||
|
this.clearLatencyProbeTimer()
|
||||||
|
|
||||||
|
if (message.heartbeat_interval && message.heartbeat_interval > 0) {
|
||||||
|
this.clearHeartbeatTimer()
|
||||||
|
this.heartbeatTimerId = window.setInterval(() => {
|
||||||
|
this.sendPing()
|
||||||
|
}, message.heartbeat_interval * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.latencyProbeTimerId = window.setInterval(() => {
|
||||||
|
this.sendPing()
|
||||||
|
}, LATENCY_PROBE_INTERVAL_MS)
|
||||||
|
|
||||||
|
this.flushSubscriptions()
|
||||||
|
this.sendPing()
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage(raw: string) {
|
||||||
|
if (raw.trim() === 'pong') {
|
||||||
|
this.handlePongMessage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let message: GameSocketMessage
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = JSON.parse(raw) as GameSocketMessage
|
||||||
|
} catch {
|
||||||
|
this.options.onError?.(new Error('WebSocket message parse failed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.event === 'ws.connected') {
|
||||||
|
this.handleConnectedMessage(message as GameSocketConnectedMessage)
|
||||||
|
} else if (message.event === 'ws.error') {
|
||||||
|
this.options.onError?.(message as GameSocketErrorMessage)
|
||||||
|
} else if (
|
||||||
|
message.event === 'pong' ||
|
||||||
|
('action' in message && message.action === 'pong') ||
|
||||||
|
('topic' in message && message.topic === 'pong')
|
||||||
|
) {
|
||||||
|
this.handlePongMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.onMessage?.(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect() {
|
||||||
|
this.reconnectAttempt += 1
|
||||||
|
this.setStatus('reconnecting')
|
||||||
|
|
||||||
|
const delay = Math.min(
|
||||||
|
1000 * 2 ** Math.max(0, this.reconnectAttempt - 1),
|
||||||
|
MAX_RECONNECT_DELAY_MS,
|
||||||
|
)
|
||||||
|
|
||||||
|
this.clearReconnectTimer()
|
||||||
|
this.reconnectTimerId = window.setTimeout(() => {
|
||||||
|
void this.connect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(status: GameSocketStatus) {
|
||||||
|
this.options.onStatusChange?.(status, this.reconnectAttempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ export default {
|
|||||||
label: 'Language',
|
label: 'Language',
|
||||||
zhCN: '中文',
|
zhCN: '中文',
|
||||||
enUS: 'English',
|
enUS: 'English',
|
||||||
|
msMY: 'Bahasa Melayu',
|
||||||
|
idID: 'Bahasa Indonesia',
|
||||||
},
|
},
|
||||||
game: {
|
game: {
|
||||||
metaTitle: 'Game Lobby',
|
metaTitle: 'Game Lobby',
|
||||||
@@ -109,6 +111,59 @@ export default {
|
|||||||
'This will later connect to the real announcement body, confirmation checkbox, and persistence flow.',
|
'This will later connect to the real announcement body, confirmation checkbox, and persistence flow.',
|
||||||
line2: 'For now it validates the shared modal structure.',
|
line2: 'For now it validates the shared modal structure.',
|
||||||
},
|
},
|
||||||
|
modals: {
|
||||||
|
login: {
|
||||||
|
title: 'Login',
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
title: 'Register',
|
||||||
|
},
|
||||||
|
notice: {
|
||||||
|
title: 'Event Notice',
|
||||||
|
content:
|
||||||
|
'This area will later load the real event announcement body, rich media, and a longer scrollable message. The current version focuses on shared multilingual modal wiring.',
|
||||||
|
check: 'View',
|
||||||
|
},
|
||||||
|
procedures: {
|
||||||
|
title: 'Top Up / Withdraw',
|
||||||
|
contentPlaceholder: 'Choose the action you want to continue with',
|
||||||
|
withdraw: 'Withdraw',
|
||||||
|
topup: 'Top Up',
|
||||||
|
},
|
||||||
|
autoSetting: {
|
||||||
|
title: 'Auto Spin',
|
||||||
|
startAutoSpin: 'Start Auto Spin',
|
||||||
|
rows: {
|
||||||
|
stopIfBalanceLowerThan: 'Stop if balance is lower than',
|
||||||
|
stopIfSingleWinExceeds: 'Stop if a single win exceeds',
|
||||||
|
stopOnAnyJackpot: 'Stop on any jackpot',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
title: 'User Info',
|
||||||
|
tabs: {
|
||||||
|
profile: 'Profile',
|
||||||
|
message: 'Messages',
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
name: 'Name',
|
||||||
|
tel: 'Phone',
|
||||||
|
registeredAt: 'Registered at',
|
||||||
|
signature:
|
||||||
|
'My signature is as unique as my personality. This area will later display the real profile summary.',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
eventBonus:
|
||||||
|
'[Top-up Bonus Event] From October 1 to October 7, 2026, claim your rebate rewards...',
|
||||||
|
check: 'View',
|
||||||
|
deleteRecords: 'Delete records',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
withdrawTopup: {
|
||||||
|
applyWithdraw: 'Apply for Withdrawal',
|
||||||
|
applyTopup: 'Apply for Top Up',
|
||||||
|
},
|
||||||
|
},
|
||||||
autoSpin: {
|
autoSpin: {
|
||||||
eyebrow: 'Auto spin',
|
eyebrow: 'Auto spin',
|
||||||
title: 'Auto spin running',
|
title: 'Auto spin running',
|
||||||
@@ -128,4 +183,234 @@ export default {
|
|||||||
maxBet: 'Max bet',
|
maxBet: 'Max bet',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
commonUi: {
|
||||||
|
modal: {
|
||||||
|
close: 'Close modal',
|
||||||
|
defaultAriaLabel: 'Modal',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
lobbyInitFailed: 'Failed to load the game lobby',
|
||||||
|
loginRequired: 'Please log in before entering the game',
|
||||||
|
loginSuccess: 'Login successful',
|
||||||
|
registerSuccess: 'Registration successful',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
common: {
|
||||||
|
arrowIconAlt: 'Arrow',
|
||||||
|
actions: {
|
||||||
|
submitting: 'Submitting...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
actions: {
|
||||||
|
submit: 'Log In',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
username: {
|
||||||
|
label: 'Account / Phone:',
|
||||||
|
placeholder: 'Enter account or mobile number',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: 'Password:',
|
||||||
|
placeholder: 'Enter password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
registerAccount: 'Create account',
|
||||||
|
forgotPassword: 'Forgot password',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
submitFailed: 'Login failed. Please try again later.',
|
||||||
|
invalidCredentials: 'Incorrect account or password.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
actions: {
|
||||||
|
submit: 'Register',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
username: {
|
||||||
|
label: 'Account / Phone:',
|
||||||
|
placeholder: 'Enter account or mobile number',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: 'Password:',
|
||||||
|
placeholder: 'Enter password',
|
||||||
|
},
|
||||||
|
confirmPassword: {
|
||||||
|
label: 'Confirm Password:',
|
||||||
|
placeholder: 'Re-enter password',
|
||||||
|
},
|
||||||
|
inviteCode: {
|
||||||
|
label: 'Invite Code:',
|
||||||
|
placeholder: 'Enter invite code',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
alreadyHaveAccount: 'Already have an account',
|
||||||
|
needHelp: 'Need help',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
submitFailed: 'Registration failed. Please try again later.',
|
||||||
|
unauthorized: 'Registration is not authorized. Please try again later.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
username: {
|
||||||
|
required: 'Please enter your mobile number.',
|
||||||
|
invalidPhone: 'Please enter a valid mobile number.',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
min: 'Password must be at least 6 characters.',
|
||||||
|
max: 'Password must be at most 32 characters.',
|
||||||
|
},
|
||||||
|
inviteCode: {
|
||||||
|
required: 'Please enter the invite code.',
|
||||||
|
max: 'Invite code must be at most 32 characters.',
|
||||||
|
},
|
||||||
|
confirmPassword: {
|
||||||
|
mismatch: 'The two passwords do not match.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
requestFailed: 'Request failed. Please try again later.',
|
||||||
|
authTokenConfigMissing:
|
||||||
|
'Authentication configuration is missing. Please contact support.',
|
||||||
|
timeout: 'Request timed out. Please try again later.',
|
||||||
|
serviceUnavailable:
|
||||||
|
'Service is temporarily unavailable. Please try again later.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gameDesktop: {
|
||||||
|
header: {
|
||||||
|
systemTime: 'System Time',
|
||||||
|
rules: 'Rules',
|
||||||
|
message: 'Message',
|
||||||
|
bgm: 'BGM',
|
||||||
|
id: 'ID',
|
||||||
|
fullscreen: 'Full Screen',
|
||||||
|
login: 'Login',
|
||||||
|
register: 'Register',
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
trend: 'Trend',
|
||||||
|
map: 'Map',
|
||||||
|
selected: 'Selected',
|
||||||
|
totalBet: 'Total Bet',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
actions: {
|
||||||
|
clear: 'Clear',
|
||||||
|
repeat: 'Repeat',
|
||||||
|
'auto-spin': 'Auto Spin',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
odds: 'Odds',
|
||||||
|
streak: 'Streak',
|
||||||
|
limit: 'Limit',
|
||||||
|
roundId: 'Round',
|
||||||
|
phase: {
|
||||||
|
betting: {
|
||||||
|
label: 'Open',
|
||||||
|
description: '(Accepting Bets)',
|
||||||
|
},
|
||||||
|
locked: {
|
||||||
|
label: 'Locked',
|
||||||
|
description: '(Betting Closed)',
|
||||||
|
},
|
||||||
|
revealing: {
|
||||||
|
label: 'Drawing',
|
||||||
|
description: '(Revealing Result)',
|
||||||
|
},
|
||||||
|
settled: {
|
||||||
|
label: 'Settled',
|
||||||
|
description: '(Round Complete)',
|
||||||
|
},
|
||||||
|
waiting: {
|
||||||
|
label: 'Waiting',
|
||||||
|
description: '(Waiting for Next Round)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
announcement: 'Announcement',
|
||||||
|
},
|
||||||
|
animal: {
|
||||||
|
loading: 'Loading',
|
||||||
|
tapToEnter: 'Tap To Enter',
|
||||||
|
getStart: 'Get Start',
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
title: 'History',
|
||||||
|
orderNo: 'Order No.',
|
||||||
|
roundId: 'Round ID',
|
||||||
|
numbers: 'Bet Numbers',
|
||||||
|
settledAt: 'Settled At',
|
||||||
|
totalPoolAmount: 'Bet Amount',
|
||||||
|
winningResult: 'Winning Result',
|
||||||
|
payout: 'Win Amount',
|
||||||
|
empty: 'No history yet',
|
||||||
|
end: 'No more records',
|
||||||
|
loading: 'Loading...',
|
||||||
|
settled: 'Settled',
|
||||||
|
},
|
||||||
|
topup: {
|
||||||
|
placeholder: 'Top-up content is under construction',
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
placeholder: 'Mobile entry is under construction',
|
||||||
|
},
|
||||||
|
withdraw: {
|
||||||
|
availableBalance: 'Available balance: {{amount}}',
|
||||||
|
currencySelection: 'Currency selection',
|
||||||
|
selectCurrency: 'Select currency',
|
||||||
|
exchangeRateNotice:
|
||||||
|
'Exchange rates and final payout amounts follow the platform real-time settlement.',
|
||||||
|
wallet: 'Wallet',
|
||||||
|
bank: 'Bank',
|
||||||
|
minimumRm10: 'Minimum RM 10',
|
||||||
|
processingTime: 'Processing time',
|
||||||
|
fundsArrivalTime: 'Expected within 1-15 minutes',
|
||||||
|
feeNotice:
|
||||||
|
'Please confirm the receiving information carefully. It cannot be changed after submission.',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
withdrawal: 'Withdrawal',
|
||||||
|
fields: {
|
||||||
|
diamondWithdrawalAmount: 'Diamond Withdrawal Amount',
|
||||||
|
currencyType: 'Currency Type',
|
||||||
|
paymentChannel: 'Payment Channel',
|
||||||
|
bankCode: 'Bank Code',
|
||||||
|
cardHolderName: 'Card Holder Name',
|
||||||
|
bankAccountNumber: 'Bank Account Number',
|
||||||
|
receiverEmail: 'Receiver Email',
|
||||||
|
receiverPhone: 'Receiver Phone',
|
||||||
|
},
|
||||||
|
placeholders: {
|
||||||
|
cardHolderName: 'Enter card holder name',
|
||||||
|
bankAccountNumber: 'Enter bank account number',
|
||||||
|
receiverEmail: 'Enter receiver email',
|
||||||
|
receiverPhone: 'Enter receiver phone number',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
cardHolderNameRequired: 'Please enter the card holder name.',
|
||||||
|
bankAccountRequired: 'Please enter the bank account number.',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
title: 'Exchange Preview',
|
||||||
|
diamondAmount: 'Diamond Amount',
|
||||||
|
rateMyr: 'MYR Rate',
|
||||||
|
rateMyrValue: '{{diamonds}} diamonds = 1 MYR',
|
||||||
|
convertibleMyr: 'Convertible MYR',
|
||||||
|
usdtMyrRate: 'USDT / MYR Rate',
|
||||||
|
usdtMyrRateValue: '1 USDT = {{rate}} MYR',
|
||||||
|
rateVnd: 'VND Rate',
|
||||||
|
rateVndValue: '1 diamond = {{diamonds}} VND',
|
||||||
|
convertibleVnd: 'Convertible VND',
|
||||||
|
convertibleUsdt: 'Convertible USDT',
|
||||||
|
fixedExchangeDiamondAmount: 'Fixed Exchange Diamond Amount',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
415
src/locales/id-ID/common.ts
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
export default {
|
||||||
|
nav: {
|
||||||
|
home: 'Beranda',
|
||||||
|
game: 'Game',
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
eyebrow: '36 Character Flower',
|
||||||
|
subtitle: 'Frontend game undian real-time untuk mobile dan desktop',
|
||||||
|
},
|
||||||
|
notFound: {
|
||||||
|
eyebrow: '404',
|
||||||
|
title: 'Halaman yang kamu minta tidak ditemukan.',
|
||||||
|
description: 'Rute ini tidak ada. Kembali ke halaman utama scaffold.',
|
||||||
|
home: 'Kembali ke beranda',
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
eyebrow: 'Shell game sedang dibangun',
|
||||||
|
title: 'Framework game dual-device 36-character-flower sedang dibangun.',
|
||||||
|
description:
|
||||||
|
'Proyek ini sudah melewati tahap scaffold umum. Sekarang strukturnya dibangun dengan rute game bersama, state bersama, serta tampilan mobile dan desktop terpisah untuk pengalaman betting real-time.',
|
||||||
|
cards: {
|
||||||
|
routingMode: 'Routing',
|
||||||
|
dataLayer: 'Model state',
|
||||||
|
transport: 'Real-time',
|
||||||
|
auth: 'Produk',
|
||||||
|
metadata: 'Fokus saat ini',
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
routingMode: 'URL bersama + tampilan device terpisah',
|
||||||
|
dataLayer: 'Round / Bet / User / UI / Connection',
|
||||||
|
transport: 'HTTP + WebSocket',
|
||||||
|
auth: 'Gameplay live draw 36-grid',
|
||||||
|
metadata: 'Bangun struktur dulu sebelum polishing state machine',
|
||||||
|
},
|
||||||
|
footnote:
|
||||||
|
'Berikutnya: rute utama game, model bisnis bersama, dan shell halaman mobile serta desktop.',
|
||||||
|
primaryAction: 'Masuk lobby game',
|
||||||
|
secondaryAction: 'Lihat struktur proyek',
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
label: 'Bahasa',
|
||||||
|
zhCN: '中文',
|
||||||
|
enUS: 'English',
|
||||||
|
msMY: 'Bahasa Melayu',
|
||||||
|
idID: 'Bahasa Indonesia',
|
||||||
|
},
|
||||||
|
game: {
|
||||||
|
metaTitle: 'Lobby Game',
|
||||||
|
metaDescription: 'Lobby game live 36-character-flower.',
|
||||||
|
lobbyTitle: 'Lobby 36 Character Flower',
|
||||||
|
lobbySubtitle:
|
||||||
|
'Dalam satu rute bisnis bersama, mobile dan desktop memasang tampilan berbeda di atas data dan state game yang sama.',
|
||||||
|
status: {
|
||||||
|
roundState: 'Status ronde',
|
||||||
|
currentRound: 'Ronde saat ini {{id}}',
|
||||||
|
tablePool: 'Pool meja',
|
||||||
|
onlineCount: '{{count}} online',
|
||||||
|
activeChip: 'Chip aktif',
|
||||||
|
announcementsRead: '{{read}}/{{total}} pengumuman dibaca',
|
||||||
|
connection: 'Koneksi',
|
||||||
|
connectionHealthy: 'Sinkronisasi stabil',
|
||||||
|
connectionRecovering: 'Menunggu pemulihan',
|
||||||
|
synced: 'Tersinkron',
|
||||||
|
degraded: 'Menurun',
|
||||||
|
},
|
||||||
|
board: {
|
||||||
|
historyTitle: 'Riwayat ronde',
|
||||||
|
historySubtitle: 'Jejak undian dan payout terbaru',
|
||||||
|
trendTitle: 'Radar tren',
|
||||||
|
trendSubtitle: 'Ringkasan momentum dan miss streak',
|
||||||
|
stageTitle: 'Panggung undian',
|
||||||
|
stageSubtitle:
|
||||||
|
'Panggung ini menampung papan utama dan struktur kontrol sebelum integrasi penuh state machine dan animasi.',
|
||||||
|
currentPhase: 'Fase saat ini',
|
||||||
|
selectedBet: 'Bet {{amount}}',
|
||||||
|
hitCount: '{{count}} hit',
|
||||||
|
hitBadge: '{{count}}x',
|
||||||
|
badgeWin: 'Menang',
|
||||||
|
badgeBet: 'Bet',
|
||||||
|
cellLabel: 'Sel {{id}}',
|
||||||
|
winningCell: 'Sel pemenang {{id}}',
|
||||||
|
missedRounds: 'Miss {{count}} ronde',
|
||||||
|
rising: 'Naik',
|
||||||
|
falling: 'Turun',
|
||||||
|
steady: 'Stabil',
|
||||||
|
hitTotal: '{{count}} hit',
|
||||||
|
},
|
||||||
|
phases: {
|
||||||
|
betting: 'Betting',
|
||||||
|
locked: 'Terkunci',
|
||||||
|
revealing: 'Mengungkap',
|
||||||
|
settled: 'Selesai',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
unifiedBetHint: 'Bet seragam',
|
||||||
|
totalBet: 'Total bet',
|
||||||
|
canBet: 'Bisa bet',
|
||||||
|
yes: 'Ya',
|
||||||
|
no: 'Tidak',
|
||||||
|
quickBet: 'Quick bet 08',
|
||||||
|
clearPending: 'Hapus pending',
|
||||||
|
autoModeDemo: 'Demo mode auto',
|
||||||
|
stopAuto: 'Stop auto',
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
eyebrow: 'Pengumuman',
|
||||||
|
acknowledge: 'Saya paham',
|
||||||
|
later: 'Nanti',
|
||||||
|
line1:
|
||||||
|
'Ini nantinya akan terhubung ke konten pengumuman nyata, checkbox konfirmasi, dan alur penyimpanan status.',
|
||||||
|
line2: 'Untuk sekarang ini memvalidasi struktur modal bersama.',
|
||||||
|
},
|
||||||
|
modals: {
|
||||||
|
login: {
|
||||||
|
title: 'Masuk',
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
title: 'Daftar',
|
||||||
|
},
|
||||||
|
notice: {
|
||||||
|
title: 'Pengumuman Acara',
|
||||||
|
content:
|
||||||
|
'Bagian ini nantinya akan memuat konten pengumuman acara yang sebenarnya, materi visual, dan pesan panjang yang dapat digulir. Versi saat ini fokus pada sambungan modal multibahasa.',
|
||||||
|
check: 'Lihat',
|
||||||
|
},
|
||||||
|
procedures: {
|
||||||
|
title: 'Isi Ulang / Tarik Dana',
|
||||||
|
contentPlaceholder: 'Pilih tindakan yang ingin kamu lanjutkan',
|
||||||
|
withdraw: 'Tarik Dana',
|
||||||
|
topup: 'Isi Ulang',
|
||||||
|
},
|
||||||
|
autoSetting: {
|
||||||
|
title: 'Auto Spin',
|
||||||
|
startAutoSpin: 'Mulai Auto Spin',
|
||||||
|
rows: {
|
||||||
|
stopIfBalanceLowerThan: 'Berhenti jika saldo lebih rendah dari',
|
||||||
|
stopIfSingleWinExceeds: 'Berhenti jika kemenangan tunggal melebihi',
|
||||||
|
stopOnAnyJackpot: 'Berhenti pada jackpot apa pun',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
title: 'Info Pengguna',
|
||||||
|
tabs: {
|
||||||
|
profile: 'Profil',
|
||||||
|
message: 'Pesan',
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
name: 'Nama',
|
||||||
|
tel: 'Telepon',
|
||||||
|
registeredAt: 'Tanggal daftar',
|
||||||
|
signature:
|
||||||
|
'Tanda tanganku seunik diriku. Bagian ini nantinya akan menampilkan ringkasan profil yang sebenarnya.',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
eventBonus:
|
||||||
|
'[Event Bonus Isi Ulang] Dari 1 Oktober hingga 7 Oktober 2026, klaim hadiah rebate kamu...',
|
||||||
|
check: 'Lihat',
|
||||||
|
deleteRecords: 'Hapus riwayat',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
withdrawTopup: {
|
||||||
|
applyWithdraw: 'Ajukan Penarikan',
|
||||||
|
applyTopup: 'Ajukan Isi Ulang',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
autoSpin: {
|
||||||
|
eyebrow: 'Auto spin',
|
||||||
|
title: 'Auto spin berjalan',
|
||||||
|
description:
|
||||||
|
'Mode auto akan menutupi board sambil mempertahankan fokus sel target dan progres.',
|
||||||
|
trailingLabel: 'Input manual terkunci',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
implementationTitle: 'Implementasi saat ini',
|
||||||
|
implementationSubtitle:
|
||||||
|
'Iterasi ini memprioritaskan shell dual-device, model bersama, dan wiring bisnis.',
|
||||||
|
implementationBody:
|
||||||
|
'Langkah berikutnya adalah API nyata, WebSocket, UI store penuh, dan state machine siklus ronde.',
|
||||||
|
limitsTitle: 'Batas meja',
|
||||||
|
limitsSubtitle: 'Berasal dari data mock dashboard',
|
||||||
|
minBet: 'Bet minimum',
|
||||||
|
maxBet: 'Bet maksimum',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commonUi: {
|
||||||
|
modal: {
|
||||||
|
close: 'Tutup modal',
|
||||||
|
defaultAriaLabel: 'Modal',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
lobbyInitFailed: 'Gagal memuat lobby game',
|
||||||
|
loginRequired: 'Silakan masuk sebelum memasuki game',
|
||||||
|
loginSuccess: 'Berhasil masuk',
|
||||||
|
registerSuccess: 'Pendaftaran berhasil',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
common: {
|
||||||
|
arrowIconAlt: 'Panah',
|
||||||
|
actions: {
|
||||||
|
submitting: 'Mengirim...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
actions: {
|
||||||
|
submit: 'Masuk',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
username: {
|
||||||
|
label: 'Akun / Telepon:',
|
||||||
|
placeholder: 'Masukkan akun atau nomor ponsel',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: 'Kata Sandi:',
|
||||||
|
placeholder: 'Masukkan kata sandi',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
registerAccount: 'Daftar akun',
|
||||||
|
forgotPassword: 'Lupa kata sandi',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
submitFailed: 'Login gagal. Silakan coba lagi nanti.',
|
||||||
|
invalidCredentials: 'Akun atau kata sandi salah.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
actions: {
|
||||||
|
submit: 'Daftar',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
username: {
|
||||||
|
label: 'Akun / Telepon:',
|
||||||
|
placeholder: 'Masukkan akun atau nomor ponsel',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: 'Kata Sandi:',
|
||||||
|
placeholder: 'Masukkan kata sandi',
|
||||||
|
},
|
||||||
|
confirmPassword: {
|
||||||
|
label: 'Konfirmasi Kata Sandi:',
|
||||||
|
placeholder: 'Masukkan ulang kata sandi',
|
||||||
|
},
|
||||||
|
inviteCode: {
|
||||||
|
label: 'Kode Undangan:',
|
||||||
|
placeholder: 'Masukkan kode undangan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
alreadyHaveAccount: 'Sudah punya akun',
|
||||||
|
needHelp: 'Butuh bantuan',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
submitFailed: 'Pendaftaran gagal. Silakan coba lagi nanti.',
|
||||||
|
unauthorized: 'Pendaftaran tidak diizinkan. Silakan coba lagi nanti.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
username: {
|
||||||
|
required: 'Silakan masukkan nomor ponsel.',
|
||||||
|
invalidPhone: 'Silakan masukkan nomor ponsel yang valid.',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
min: 'Kata sandi minimal 6 karakter.',
|
||||||
|
max: 'Kata sandi maksimal 32 karakter.',
|
||||||
|
},
|
||||||
|
inviteCode: {
|
||||||
|
required: 'Silakan masukkan kode undangan.',
|
||||||
|
max: 'Kode undangan maksimal 32 karakter.',
|
||||||
|
},
|
||||||
|
confirmPassword: {
|
||||||
|
mismatch: 'Kedua kata sandi tidak sama.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
requestFailed: 'Permintaan gagal. Silakan coba lagi nanti.',
|
||||||
|
authTokenConfigMissing:
|
||||||
|
'Konfigurasi autentikasi tidak ada. Silakan hubungi dukungan.',
|
||||||
|
timeout: 'Permintaan habis waktu. Silakan coba lagi nanti.',
|
||||||
|
serviceUnavailable:
|
||||||
|
'Layanan sedang tidak tersedia. Silakan coba lagi nanti.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gameDesktop: {
|
||||||
|
header: {
|
||||||
|
systemTime: 'Waktu Sistem',
|
||||||
|
rules: 'Aturan',
|
||||||
|
message: 'Pesan',
|
||||||
|
bgm: 'BGM',
|
||||||
|
id: 'ID',
|
||||||
|
fullscreen: 'Layar Penuh',
|
||||||
|
login: 'Masuk',
|
||||||
|
register: 'Daftar',
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
trend: 'Tren',
|
||||||
|
map: 'Peta',
|
||||||
|
selected: 'Dipilih',
|
||||||
|
totalBet: 'Total Bet',
|
||||||
|
confirm: 'Konfirmasi',
|
||||||
|
actions: {
|
||||||
|
clear: 'Hapus',
|
||||||
|
repeat: 'Ulang',
|
||||||
|
'auto-spin': 'Auto Spin',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
odds: 'Odds',
|
||||||
|
streak: 'Streak',
|
||||||
|
limit: 'Batas',
|
||||||
|
roundId: 'Ronde',
|
||||||
|
phase: {
|
||||||
|
betting: {
|
||||||
|
label: 'Buka',
|
||||||
|
description: '(Menerima Bet)',
|
||||||
|
},
|
||||||
|
locked: {
|
||||||
|
label: 'Terkunci',
|
||||||
|
description: '(Bet Ditutup)',
|
||||||
|
},
|
||||||
|
revealing: {
|
||||||
|
label: 'Drawing',
|
||||||
|
description: '(Mengungkap Hasil)',
|
||||||
|
},
|
||||||
|
settled: {
|
||||||
|
label: 'Selesai',
|
||||||
|
description: '(Ronde Selesai)',
|
||||||
|
},
|
||||||
|
waiting: {
|
||||||
|
label: 'Menunggu',
|
||||||
|
description: '(Menunggu Ronde Berikutnya)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
announcement: 'Pengumuman',
|
||||||
|
},
|
||||||
|
animal: {
|
||||||
|
loading: 'Memuat',
|
||||||
|
tapToEnter: 'Ketuk Untuk Masuk',
|
||||||
|
getStart: 'Mulai',
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
title: 'Riwayat',
|
||||||
|
orderNo: 'No. Order',
|
||||||
|
roundId: 'ID Ronde',
|
||||||
|
numbers: 'Nomor Taruhan',
|
||||||
|
settledAt: 'Waktu Selesai',
|
||||||
|
totalPoolAmount: 'Jumlah Taruhan',
|
||||||
|
winningResult: 'Hasil Menang',
|
||||||
|
payout: 'Jumlah Menang',
|
||||||
|
empty: 'Belum ada riwayat',
|
||||||
|
end: 'Tidak ada catatan lagi',
|
||||||
|
loading: 'Memuat...',
|
||||||
|
settled: 'Selesai',
|
||||||
|
},
|
||||||
|
topup: {
|
||||||
|
placeholder: 'Konten isi ulang sedang dibangun',
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
placeholder: 'Halaman mobile sedang dibangun',
|
||||||
|
},
|
||||||
|
withdraw: {
|
||||||
|
availableBalance: 'Saldo tersedia: {{amount}}',
|
||||||
|
currencySelection: 'Pilihan mata uang',
|
||||||
|
selectCurrency: 'Pilih mata uang',
|
||||||
|
exchangeRateNotice:
|
||||||
|
'Kurs dan jumlah akhir mengikuti penyelesaian real-time platform.',
|
||||||
|
wallet: 'Dompet',
|
||||||
|
bank: 'Bank',
|
||||||
|
minimumRm10: 'Minimum RM 10',
|
||||||
|
processingTime: 'Waktu proses',
|
||||||
|
fundsArrivalTime: 'Diperkirakan masuk dalam 1-15 menit',
|
||||||
|
feeNotice:
|
||||||
|
'Pastikan informasi penerima benar. Data tidak dapat diubah setelah dikirim.',
|
||||||
|
cancel: 'Batal',
|
||||||
|
confirm: 'Konfirmasi',
|
||||||
|
withdrawal: 'Penarikan',
|
||||||
|
fields: {
|
||||||
|
diamondWithdrawalAmount: 'Jumlah Berlian Ditarik',
|
||||||
|
currencyType: 'Jenis Mata Uang',
|
||||||
|
paymentChannel: 'Saluran Pembayaran',
|
||||||
|
bankCode: 'Kode Bank',
|
||||||
|
cardHolderName: 'Nama Pemilik Rekening',
|
||||||
|
bankAccountNumber: 'Nomor Rekening Bank',
|
||||||
|
receiverEmail: 'Email Penerima',
|
||||||
|
receiverPhone: 'Telepon Penerima',
|
||||||
|
},
|
||||||
|
placeholders: {
|
||||||
|
cardHolderName: 'Masukkan nama pemilik rekening',
|
||||||
|
bankAccountNumber: 'Masukkan nomor rekening bank',
|
||||||
|
receiverEmail: 'Masukkan email penerima',
|
||||||
|
receiverPhone: 'Masukkan nomor telepon penerima',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
cardHolderNameRequired: 'Silakan masukkan nama pemilik rekening.',
|
||||||
|
bankAccountRequired: 'Silakan masukkan nomor rekening bank.',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
title: 'Pratinjau Penukaran',
|
||||||
|
diamondAmount: 'Jumlah Berlian',
|
||||||
|
rateMyr: 'Kurs MYR',
|
||||||
|
rateMyrValue: '{{diamonds}} berlian = 1 MYR',
|
||||||
|
convertibleMyr: 'Bisa Ditukar ke MYR',
|
||||||
|
usdtMyrRate: 'Kurs USDT / MYR',
|
||||||
|
usdtMyrRateValue: '1 USDT = {{rate}} MYR',
|
||||||
|
rateVnd: 'Kurs VND',
|
||||||
|
rateVndValue: '1 berlian = {{diamonds}} VND',
|
||||||
|
convertibleVnd: 'Bisa Ditukar ke VND',
|
||||||
|
convertibleUsdt: 'Bisa Ditukar ke USDT',
|
||||||
|
fixedExchangeDiamondAmount: 'Jumlah Berlian Tukar Tetap',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
418
src/locales/ms-MY/common.ts
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
export default {
|
||||||
|
nav: {
|
||||||
|
home: 'Laman Utama',
|
||||||
|
game: 'Permainan',
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
eyebrow: '36 Character Flower',
|
||||||
|
subtitle:
|
||||||
|
'Antara muka permainan cabutan masa nyata untuk mudah alih dan desktop',
|
||||||
|
},
|
||||||
|
notFound: {
|
||||||
|
eyebrow: '404',
|
||||||
|
title: 'Halaman yang anda minta tidak ditemui.',
|
||||||
|
description:
|
||||||
|
'Laluan ini tidak wujud. Kembali ke halaman utama rangka kerja.',
|
||||||
|
home: 'Kembali ke utama',
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
eyebrow: 'Rangka permainan sedang dibina',
|
||||||
|
title:
|
||||||
|
'Rangka permainan dwi-peranti 36-character-flower sedang dibangunkan.',
|
||||||
|
description:
|
||||||
|
'Projek ini telah melepasi peringkat rangka asas. Kini ia disusun dengan laluan permainan dikongsi, keadaan dikongsi, serta paparan berasingan untuk mudah alih dan desktop bagi pengalaman pertaruhan masa nyata.',
|
||||||
|
cards: {
|
||||||
|
routingMode: 'Laluan',
|
||||||
|
dataLayer: 'Model keadaan',
|
||||||
|
transport: 'Masa nyata',
|
||||||
|
auth: 'Produk',
|
||||||
|
metadata: 'Fokus semasa',
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
routingMode: 'URL dikongsi + paparan peranti berasingan',
|
||||||
|
dataLayer: 'Round / Bet / User / UI / Connection',
|
||||||
|
transport: 'HTTP + WebSocket',
|
||||||
|
auth: 'Permainan cabutan langsung grid 36',
|
||||||
|
metadata: 'Bina struktur dahulu sebelum kemasan state machine',
|
||||||
|
},
|
||||||
|
footnote:
|
||||||
|
'Seterusnya: laluan utama permainan, model perniagaan dikongsi, dan rangka halaman mudah alih serta desktop.',
|
||||||
|
primaryAction: 'Masuk lobi permainan',
|
||||||
|
secondaryAction: 'Lihat struktur projek',
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
label: 'Bahasa',
|
||||||
|
zhCN: '中文',
|
||||||
|
enUS: 'English',
|
||||||
|
msMY: 'Bahasa Melayu',
|
||||||
|
idID: 'Bahasa Indonesia',
|
||||||
|
},
|
||||||
|
game: {
|
||||||
|
metaTitle: 'Lobi Permainan',
|
||||||
|
metaDescription: 'Lobi permainan langsung 36-character-flower.',
|
||||||
|
lobbyTitle: 'Lobi 36 Character Flower',
|
||||||
|
lobbySubtitle:
|
||||||
|
'Di bawah satu laluan perniagaan yang dikongsi, mudah alih dan desktop memaparkan antara muka berbeza di atas data dan keadaan permainan yang sama.',
|
||||||
|
status: {
|
||||||
|
roundState: 'Keadaan pusingan',
|
||||||
|
currentRound: 'Pusingan semasa {{id}}',
|
||||||
|
tablePool: 'Dana meja',
|
||||||
|
onlineCount: '{{count}} dalam talian',
|
||||||
|
activeChip: 'Cip aktif',
|
||||||
|
announcementsRead: '{{read}}/{{total}} pengumuman dibaca',
|
||||||
|
connection: 'Sambungan',
|
||||||
|
connectionHealthy: 'Penyegerakan stabil',
|
||||||
|
connectionRecovering: 'Menunggu pemulihan',
|
||||||
|
synced: 'Disegerakkan',
|
||||||
|
degraded: 'Terganggu',
|
||||||
|
},
|
||||||
|
board: {
|
||||||
|
historyTitle: 'Sejarah pusingan',
|
||||||
|
historySubtitle: 'Rekod cabutan dan pembayaran terkini',
|
||||||
|
trendTitle: 'Radar trend',
|
||||||
|
trendSubtitle: 'Ringkasan momentum dan kekerapan miss',
|
||||||
|
stageTitle: 'Pentas cabutan',
|
||||||
|
stageSubtitle:
|
||||||
|
'Pentas ini memuatkan papan utama dan struktur kawalan sebelum integrasi penuh state machine serta animasi.',
|
||||||
|
currentPhase: 'Fasa semasa',
|
||||||
|
selectedBet: 'Pertaruhan {{amount}}',
|
||||||
|
hitCount: '{{count}} kena',
|
||||||
|
hitBadge: '{{count}}x',
|
||||||
|
badgeWin: 'Menang',
|
||||||
|
badgeBet: 'Taruhan',
|
||||||
|
cellLabel: 'Sel {{id}}',
|
||||||
|
winningCell: 'Sel menang {{id}}',
|
||||||
|
missedRounds: 'Terlepas {{count}} pusingan',
|
||||||
|
rising: 'Meningkat',
|
||||||
|
falling: 'Menurun',
|
||||||
|
steady: 'Stabil',
|
||||||
|
hitTotal: '{{count}} kena',
|
||||||
|
},
|
||||||
|
phases: {
|
||||||
|
betting: 'Taruhan',
|
||||||
|
locked: 'Dikunci',
|
||||||
|
revealing: 'Cabutan',
|
||||||
|
settled: 'Selesai',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
unifiedBetHint: 'Taruhan seragam',
|
||||||
|
totalBet: 'Jumlah taruhan',
|
||||||
|
canBet: 'Boleh taruhan',
|
||||||
|
yes: 'Ya',
|
||||||
|
no: 'Tidak',
|
||||||
|
quickBet: 'Taruhan cepat 08',
|
||||||
|
clearPending: 'Kosongkan belum sah',
|
||||||
|
autoModeDemo: 'Demo mod auto',
|
||||||
|
stopAuto: 'Henti auto',
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
eyebrow: 'Pengumuman',
|
||||||
|
acknowledge: 'Faham',
|
||||||
|
later: 'Nanti',
|
||||||
|
line1:
|
||||||
|
'Ini akan disambungkan kepada kandungan pengumuman sebenar, kotak pengesahan, dan aliran penyimpanan status.',
|
||||||
|
line2: 'Buat masa ini, ia mengesahkan struktur modal yang dikongsi.',
|
||||||
|
},
|
||||||
|
modals: {
|
||||||
|
login: {
|
||||||
|
title: 'Log Masuk',
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
title: 'Daftar',
|
||||||
|
},
|
||||||
|
notice: {
|
||||||
|
title: 'Notis Acara',
|
||||||
|
content:
|
||||||
|
'Bahagian ini akan memuatkan kandungan notis acara sebenar, bahan visual, dan mesej boleh skrol yang lebih panjang. Versi semasa memfokuskan sambungan modal pelbagai bahasa.',
|
||||||
|
check: 'Semak',
|
||||||
|
},
|
||||||
|
procedures: {
|
||||||
|
title: 'Tambah Nilai / Pengeluaran',
|
||||||
|
contentPlaceholder: 'Pilih tindakan yang ingin anda teruskan',
|
||||||
|
withdraw: 'Keluarkan',
|
||||||
|
topup: 'Tambah Nilai',
|
||||||
|
},
|
||||||
|
autoSetting: {
|
||||||
|
title: 'Putaran Auto',
|
||||||
|
startAutoSpin: 'Mula Putaran Auto',
|
||||||
|
rows: {
|
||||||
|
stopIfBalanceLowerThan: 'Henti jika baki lebih rendah daripada',
|
||||||
|
stopIfSingleWinExceeds: 'Henti jika kemenangan tunggal melebihi',
|
||||||
|
stopOnAnyJackpot: 'Henti pada sebarang jackpot',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
title: 'Maklumat Pengguna',
|
||||||
|
tabs: {
|
||||||
|
profile: 'Profil',
|
||||||
|
message: 'Mesej',
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
name: 'Nama',
|
||||||
|
tel: 'Telefon',
|
||||||
|
registeredAt: 'Tarikh daftar',
|
||||||
|
signature:
|
||||||
|
'Tandatangan saya unik seperti personaliti saya. Bahagian ini akan memaparkan ringkasan profil sebenar kemudian.',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
eventBonus:
|
||||||
|
'[Acara Bonus Tambah Nilai] Dari 1 Oktober hingga 7 Oktober 2026, tuntut ganjaran rebat anda...',
|
||||||
|
check: 'Semak',
|
||||||
|
deleteRecords: 'Padam rekod',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
withdrawTopup: {
|
||||||
|
applyWithdraw: 'Mohon Pengeluaran',
|
||||||
|
applyTopup: 'Mohon Tambah Nilai',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
autoSpin: {
|
||||||
|
eyebrow: 'Putaran auto',
|
||||||
|
title: 'Putaran auto sedang berjalan',
|
||||||
|
description:
|
||||||
|
'Mod auto akan menutup papan sambil mengekalkan fokus sel sasaran dan kemajuan.',
|
||||||
|
trailingLabel: 'Input manual dikunci',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
implementationTitle: 'Pelaksanaan semasa',
|
||||||
|
implementationSubtitle:
|
||||||
|
'Iterasi ini mengutamakan shell dwi-peranti, model dikongsi, dan sambungan logik perniagaan.',
|
||||||
|
implementationBody:
|
||||||
|
'Langkah seterusnya ialah API sebenar, WebSocket, UI store penuh, dan state machine kitaran pusingan.',
|
||||||
|
limitsTitle: 'Had meja',
|
||||||
|
limitsSubtitle: 'Diambil daripada data mock dashboard',
|
||||||
|
minBet: 'Taruhan minimum',
|
||||||
|
maxBet: 'Taruhan maksimum',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commonUi: {
|
||||||
|
modal: {
|
||||||
|
close: 'Tutup modal',
|
||||||
|
defaultAriaLabel: 'Modal',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
lobbyInitFailed: 'Gagal memuatkan lobi permainan',
|
||||||
|
loginRequired: 'Sila log masuk sebelum memasuki permainan',
|
||||||
|
loginSuccess: 'Log masuk berjaya',
|
||||||
|
registerSuccess: 'Pendaftaran berjaya',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
common: {
|
||||||
|
arrowIconAlt: 'Anak panah',
|
||||||
|
actions: {
|
||||||
|
submitting: 'Menghantar...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
actions: {
|
||||||
|
submit: 'Log Masuk',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
username: {
|
||||||
|
label: 'Akaun / Telefon:',
|
||||||
|
placeholder: 'Masukkan akaun atau nombor telefon',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: 'Kata Laluan:',
|
||||||
|
placeholder: 'Masukkan kata laluan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
registerAccount: 'Daftar akaun',
|
||||||
|
forgotPassword: 'Lupa kata laluan',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
submitFailed: 'Log masuk gagal. Sila cuba lagi kemudian.',
|
||||||
|
invalidCredentials: 'Akaun atau kata laluan tidak betul.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
actions: {
|
||||||
|
submit: 'Daftar',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
username: {
|
||||||
|
label: 'Akaun / Telefon:',
|
||||||
|
placeholder: 'Masukkan akaun atau nombor telefon',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: 'Kata Laluan:',
|
||||||
|
placeholder: 'Masukkan kata laluan',
|
||||||
|
},
|
||||||
|
confirmPassword: {
|
||||||
|
label: 'Sahkan Kata Laluan:',
|
||||||
|
placeholder: 'Masukkan semula kata laluan',
|
||||||
|
},
|
||||||
|
inviteCode: {
|
||||||
|
label: 'Kod Jemputan:',
|
||||||
|
placeholder: 'Masukkan kod jemputan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
alreadyHaveAccount: 'Sudah ada akaun',
|
||||||
|
needHelp: 'Perlukan bantuan',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
submitFailed: 'Pendaftaran gagal. Sila cuba lagi kemudian.',
|
||||||
|
unauthorized: 'Pendaftaran tidak dibenarkan. Sila cuba lagi kemudian.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
username: {
|
||||||
|
required: 'Sila masukkan nombor telefon anda.',
|
||||||
|
invalidPhone: 'Sila masukkan nombor telefon yang sah.',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
min: 'Kata laluan mesti sekurang-kurangnya 6 aksara.',
|
||||||
|
max: 'Kata laluan mesti maksimum 32 aksara.',
|
||||||
|
},
|
||||||
|
inviteCode: {
|
||||||
|
required: 'Sila masukkan kod jemputan.',
|
||||||
|
max: 'Kod jemputan mesti maksimum 32 aksara.',
|
||||||
|
},
|
||||||
|
confirmPassword: {
|
||||||
|
mismatch: 'Kedua-dua kata laluan tidak sepadan.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
requestFailed: 'Permintaan gagal. Sila cuba lagi kemudian.',
|
||||||
|
authTokenConfigMissing:
|
||||||
|
'Konfigurasi pengesahan tiada. Sila hubungi sokongan.',
|
||||||
|
timeout: 'Permintaan tamat masa. Sila cuba lagi kemudian.',
|
||||||
|
serviceUnavailable:
|
||||||
|
'Perkhidmatan tidak tersedia buat sementara waktu. Sila cuba lagi kemudian.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gameDesktop: {
|
||||||
|
header: {
|
||||||
|
systemTime: 'Masa Sistem',
|
||||||
|
rules: 'Peraturan',
|
||||||
|
message: 'Mesej',
|
||||||
|
bgm: 'BGM',
|
||||||
|
id: 'ID',
|
||||||
|
fullscreen: 'Skrin Penuh',
|
||||||
|
login: 'Log Masuk',
|
||||||
|
register: 'Daftar',
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
trend: 'Trend',
|
||||||
|
map: 'Peta',
|
||||||
|
selected: 'Dipilih',
|
||||||
|
totalBet: 'Jumlah Taruhan',
|
||||||
|
confirm: 'Sahkan',
|
||||||
|
actions: {
|
||||||
|
clear: 'Kosongkan',
|
||||||
|
repeat: 'Ulang',
|
||||||
|
'auto-spin': 'Putaran Auto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
odds: 'Peluang',
|
||||||
|
streak: 'Streak',
|
||||||
|
limit: 'Had',
|
||||||
|
roundId: 'Pusingan',
|
||||||
|
phase: {
|
||||||
|
betting: {
|
||||||
|
label: 'Buka',
|
||||||
|
description: '(Menerima Taruhan)',
|
||||||
|
},
|
||||||
|
locked: {
|
||||||
|
label: 'Dikunci',
|
||||||
|
description: '(Taruhan Ditutup)',
|
||||||
|
},
|
||||||
|
revealing: {
|
||||||
|
label: 'Cabutan',
|
||||||
|
description: '(Mendedahkan Hasil)',
|
||||||
|
},
|
||||||
|
settled: {
|
||||||
|
label: 'Selesai',
|
||||||
|
description: '(Pusingan Tamat)',
|
||||||
|
},
|
||||||
|
waiting: {
|
||||||
|
label: 'Menunggu',
|
||||||
|
description: '(Menunggu Pusingan Seterusnya)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
announcement: 'Pengumuman',
|
||||||
|
},
|
||||||
|
animal: {
|
||||||
|
loading: 'Memuatkan',
|
||||||
|
tapToEnter: 'Ketik Untuk Masuk',
|
||||||
|
getStart: 'Mula',
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
title: 'Sejarah',
|
||||||
|
orderNo: 'No. Pesanan',
|
||||||
|
roundId: 'ID Pusingan',
|
||||||
|
numbers: 'Nombor Pertaruhan',
|
||||||
|
settledAt: 'Masa Selesai',
|
||||||
|
totalPoolAmount: 'Jumlah Pertaruhan',
|
||||||
|
winningResult: 'Keputusan Menang',
|
||||||
|
payout: 'Jumlah Menang',
|
||||||
|
empty: 'Belum ada sejarah',
|
||||||
|
end: 'Tiada lagi rekod',
|
||||||
|
loading: 'Memuatkan...',
|
||||||
|
settled: 'Selesai',
|
||||||
|
},
|
||||||
|
topup: {
|
||||||
|
placeholder: 'Kandungan tambah nilai sedang dibina',
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
placeholder: 'Halaman mudah alih sedang dibina',
|
||||||
|
},
|
||||||
|
withdraw: {
|
||||||
|
availableBalance: 'Baki tersedia: {{amount}}',
|
||||||
|
currencySelection: 'Pilihan mata wang',
|
||||||
|
selectCurrency: 'Pilih mata wang',
|
||||||
|
exchangeRateNotice:
|
||||||
|
'Kadar pertukaran dan jumlah akhir tertakluk kepada penyelesaian masa nyata platform.',
|
||||||
|
wallet: 'Dompet',
|
||||||
|
bank: 'Bank',
|
||||||
|
minimumRm10: 'Minimum RM 10',
|
||||||
|
processingTime: 'Masa pemprosesan',
|
||||||
|
fundsArrivalTime: 'Dijangka tiba dalam 1-15 minit',
|
||||||
|
feeNotice:
|
||||||
|
'Sila pastikan maklumat penerima adalah tepat. Ia tidak boleh diubah selepas dihantar.',
|
||||||
|
cancel: 'Batal',
|
||||||
|
confirm: 'Sahkan',
|
||||||
|
withdrawal: 'Pengeluaran',
|
||||||
|
fields: {
|
||||||
|
diamondWithdrawalAmount: 'Jumlah Berlian Dikeluarkan',
|
||||||
|
currencyType: 'Jenis Mata Wang',
|
||||||
|
paymentChannel: 'Saluran Pembayaran',
|
||||||
|
bankCode: 'Kod Bank',
|
||||||
|
cardHolderName: 'Nama Pemegang Kad',
|
||||||
|
bankAccountNumber: 'Nombor Akaun Bank',
|
||||||
|
receiverEmail: 'E-mel Penerima',
|
||||||
|
receiverPhone: 'Telefon Penerima',
|
||||||
|
},
|
||||||
|
placeholders: {
|
||||||
|
cardHolderName: 'Masukkan nama pemegang kad',
|
||||||
|
bankAccountNumber: 'Masukkan nombor akaun bank',
|
||||||
|
receiverEmail: 'Masukkan e-mel penerima',
|
||||||
|
receiverPhone: 'Masukkan nombor telefon penerima',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
cardHolderNameRequired: 'Sila masukkan nama pemegang kad.',
|
||||||
|
bankAccountRequired: 'Sila masukkan nombor akaun bank.',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
title: 'Pratonton Pertukaran',
|
||||||
|
diamondAmount: 'Jumlah Berlian',
|
||||||
|
rateMyr: 'Kadar MYR',
|
||||||
|
rateMyrValue: '{{diamonds}} berlian = 1 MYR',
|
||||||
|
convertibleMyr: 'Boleh Tukar MYR',
|
||||||
|
usdtMyrRate: 'Kadar USDT / MYR',
|
||||||
|
usdtMyrRateValue: '1 USDT = {{rate}} MYR',
|
||||||
|
rateVnd: 'Kadar VND',
|
||||||
|
rateVndValue: '1 berlian = {{diamonds}} VND',
|
||||||
|
convertibleVnd: 'Boleh Tukar VND',
|
||||||
|
convertibleUsdt: 'Boleh Tukar USDT',
|
||||||
|
fixedExchangeDiamondAmount: 'Jumlah Berlian Tukaran Tetap',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -41,6 +41,8 @@ export default {
|
|||||||
label: '语言',
|
label: '语言',
|
||||||
zhCN: '中文',
|
zhCN: '中文',
|
||||||
enUS: 'English',
|
enUS: 'English',
|
||||||
|
msMY: 'Bahasa Melayu',
|
||||||
|
idID: 'Bahasa Indonesia',
|
||||||
},
|
},
|
||||||
game: {
|
game: {
|
||||||
metaTitle: '游戏大厅',
|
metaTitle: '游戏大厅',
|
||||||
@@ -106,6 +108,57 @@ export default {
|
|||||||
line1: '这里后续会接真实公告图文、勾选确认和已读状态。',
|
line1: '这里后续会接真实公告图文、勾选确认和已读状态。',
|
||||||
line2: '当前先用共享弹窗骨架验证结构。',
|
line2: '当前先用共享弹窗骨架验证结构。',
|
||||||
},
|
},
|
||||||
|
modals: {
|
||||||
|
login: {
|
||||||
|
title: '登录',
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
title: '注册',
|
||||||
|
},
|
||||||
|
notice: {
|
||||||
|
title: '活动公告',
|
||||||
|
content:
|
||||||
|
'这里后续将接入真实的活动公告内容、图文素材和滚动阅读区域。当前版本先用多语言结构完成桌面端公告弹窗能力。',
|
||||||
|
check: '查看',
|
||||||
|
},
|
||||||
|
procedures: {
|
||||||
|
title: '充值 / 提现',
|
||||||
|
contentPlaceholder: '请选择你要进行的操作',
|
||||||
|
withdraw: '提现',
|
||||||
|
topup: '充值',
|
||||||
|
},
|
||||||
|
autoSetting: {
|
||||||
|
title: '自动托管',
|
||||||
|
startAutoSpin: '开始自动托管',
|
||||||
|
rows: {
|
||||||
|
stopIfBalanceLowerThan: '余额低于时停止',
|
||||||
|
stopIfSingleWinExceeds: '单次盈利超过时停止',
|
||||||
|
stopOnAnyJackpot: '出现任意 Jackpot 时停止',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
title: '用户信息',
|
||||||
|
tabs: {
|
||||||
|
profile: '个人信息',
|
||||||
|
message: '站内消息',
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
name: '姓名',
|
||||||
|
tel: '电话',
|
||||||
|
registeredAt: '注册时间',
|
||||||
|
signature: '我的个性签名和灵魂一样独特,后续这里会接真实资料字段。',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
eventBonus: '[充值活动] 10 月 1 日至 10 月 7 日期间可获得返利奖励……',
|
||||||
|
check: '查看',
|
||||||
|
deleteRecords: '删除记录',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
withdrawTopup: {
|
||||||
|
applyWithdraw: '申请提现',
|
||||||
|
applyTopup: '申请充值',
|
||||||
|
},
|
||||||
|
},
|
||||||
autoSpin: {
|
autoSpin: {
|
||||||
eyebrow: '自动托管',
|
eyebrow: '自动托管',
|
||||||
title: '自动托管运行中',
|
title: '自动托管运行中',
|
||||||
@@ -124,4 +177,230 @@ export default {
|
|||||||
maxBet: '最高下注',
|
maxBet: '最高下注',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
commonUi: {
|
||||||
|
modal: {
|
||||||
|
close: '关闭弹窗',
|
||||||
|
defaultAriaLabel: '弹窗',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
lobbyInitFailed: '游戏大厅加载失败',
|
||||||
|
loginRequired: '请先登录后进入游戏',
|
||||||
|
loginSuccess: '登录成功',
|
||||||
|
registerSuccess: '注册成功',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
common: {
|
||||||
|
arrowIconAlt: '箭头',
|
||||||
|
actions: {
|
||||||
|
submitting: '提交中...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
actions: {
|
||||||
|
submit: '登录',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
username: {
|
||||||
|
label: '账号/电话:',
|
||||||
|
placeholder: '请输入账号或手机号',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: '密码:',
|
||||||
|
placeholder: '请输入密码',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
registerAccount: '注册账号',
|
||||||
|
forgotPassword: '忘记密码',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
submitFailed: '登录失败,请稍后重试',
|
||||||
|
invalidCredentials: '账号或密码错误',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
actions: {
|
||||||
|
submit: '注册',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
username: {
|
||||||
|
label: '账号/电话:',
|
||||||
|
placeholder: '请输入账号或手机号',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: '密码:',
|
||||||
|
placeholder: '请输入密码',
|
||||||
|
},
|
||||||
|
confirmPassword: {
|
||||||
|
label: '确认密码:',
|
||||||
|
placeholder: '请再次输入密码',
|
||||||
|
},
|
||||||
|
inviteCode: {
|
||||||
|
label: '邀请码:',
|
||||||
|
placeholder: '请输入邀请码',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
alreadyHaveAccount: '已有账号',
|
||||||
|
needHelp: '需要帮助',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
submitFailed: '注册失败,请稍后重试',
|
||||||
|
unauthorized: '注册未授权,请稍后重试',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
username: {
|
||||||
|
required: '请输入手机号',
|
||||||
|
invalidPhone: '请输入正确的手机号',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
min: '密码至少 6 位',
|
||||||
|
max: '密码最多 32 位',
|
||||||
|
},
|
||||||
|
inviteCode: {
|
||||||
|
required: '请输入邀请码',
|
||||||
|
max: '邀请码最多 32 位',
|
||||||
|
},
|
||||||
|
confirmPassword: {
|
||||||
|
mismatch: '两次输入的密码不一致',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
requestFailed: '请求失败,请稍后重试',
|
||||||
|
authTokenConfigMissing: '认证配置缺失,请联系管理员',
|
||||||
|
timeout: '请求超时,请稍后重试',
|
||||||
|
serviceUnavailable: '服务暂不可用,请稍后重试',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gameDesktop: {
|
||||||
|
header: {
|
||||||
|
systemTime: '系统时间',
|
||||||
|
rules: '规则',
|
||||||
|
message: '消息',
|
||||||
|
bgm: '音乐',
|
||||||
|
id: '编号',
|
||||||
|
fullscreen: '全屏',
|
||||||
|
login: '登录',
|
||||||
|
register: '注册',
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
trend: '走势',
|
||||||
|
map: '地图',
|
||||||
|
selected: '已选',
|
||||||
|
totalBet: '总下注',
|
||||||
|
confirm: '确认',
|
||||||
|
actions: {
|
||||||
|
clear: '清空',
|
||||||
|
repeat: '重复',
|
||||||
|
'auto-spin': '自动托管',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
odds: '赔率',
|
||||||
|
streak: '连中',
|
||||||
|
limit: '限额',
|
||||||
|
roundId: '期号',
|
||||||
|
phase: {
|
||||||
|
betting: {
|
||||||
|
label: '下注中',
|
||||||
|
description: '(接受下注)',
|
||||||
|
},
|
||||||
|
locked: {
|
||||||
|
label: '已封盘',
|
||||||
|
description: '(停止下注)',
|
||||||
|
},
|
||||||
|
revealing: {
|
||||||
|
label: '开奖中',
|
||||||
|
description: '(正在开奖)',
|
||||||
|
},
|
||||||
|
settled: {
|
||||||
|
label: '已结算',
|
||||||
|
description: '(本轮结束)',
|
||||||
|
},
|
||||||
|
waiting: {
|
||||||
|
label: '等待中',
|
||||||
|
description: '(等待下一轮)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
announcement: '公告栏',
|
||||||
|
},
|
||||||
|
animal: {
|
||||||
|
loading: '加载中',
|
||||||
|
tapToEnter: '点击进入',
|
||||||
|
getStart: '开始游戏',
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
title: '历史记录',
|
||||||
|
orderNo: '订单号',
|
||||||
|
roundId: '期号',
|
||||||
|
numbers: '下注号码',
|
||||||
|
settledAt: '结算时间',
|
||||||
|
totalPoolAmount: '下注金额',
|
||||||
|
winningResult: '中奖字花',
|
||||||
|
payout: '中奖金额',
|
||||||
|
empty: '暂无历史记录',
|
||||||
|
end: '没有更多记录了',
|
||||||
|
loading: '加载中...',
|
||||||
|
settled: '已结算',
|
||||||
|
},
|
||||||
|
topup: {
|
||||||
|
placeholder: '充值内容建设中',
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
placeholder: '移动端页面建设中',
|
||||||
|
},
|
||||||
|
withdraw: {
|
||||||
|
availableBalance: '可用余额:{{amount}}',
|
||||||
|
currencySelection: '币种选择',
|
||||||
|
selectCurrency: '请选择币种',
|
||||||
|
exchangeRateNotice: '汇率与到账金额以平台实时结算为准。',
|
||||||
|
wallet: '钱包',
|
||||||
|
bank: '银行卡',
|
||||||
|
minimumRm10: '最低 RM 10',
|
||||||
|
processingTime: '处理时间',
|
||||||
|
fundsArrivalTime: '预计 1-15 分钟到账',
|
||||||
|
feeNotice: '请确认收款信息准确无误,提交后不可修改。',
|
||||||
|
cancel: '取消',
|
||||||
|
confirm: '确认',
|
||||||
|
withdrawal: '提现',
|
||||||
|
fields: {
|
||||||
|
diamondWithdrawalAmount: '提取钻石数量',
|
||||||
|
currencyType: '币种类型',
|
||||||
|
paymentChannel: '付款渠道',
|
||||||
|
bankCode: '银行代码',
|
||||||
|
cardHolderName: '持卡人姓名',
|
||||||
|
bankAccountNumber: '银行账号',
|
||||||
|
receiverEmail: '收款邮箱',
|
||||||
|
receiverPhone: '收款手机',
|
||||||
|
},
|
||||||
|
placeholders: {
|
||||||
|
cardHolderName: '请输入持卡人姓名',
|
||||||
|
bankAccountNumber: '请输入银行账号',
|
||||||
|
receiverEmail: '请输入收款邮箱',
|
||||||
|
receiverPhone: '请输入收款手机号',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
cardHolderNameRequired: '请输入持卡人姓名',
|
||||||
|
bankAccountRequired: '请输入银行账号',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
title: '兑换预览',
|
||||||
|
diamondAmount: '钻石数量',
|
||||||
|
rateMyr: '马币汇率',
|
||||||
|
rateMyrValue: '{{diamonds}} 钻石 = 1 MYR',
|
||||||
|
convertibleMyr: '可兑换 MYR',
|
||||||
|
usdtMyrRate: 'USDT / MYR 汇率',
|
||||||
|
usdtMyrRateValue: '1 USDT = {{rate}} MYR',
|
||||||
|
rateVnd: '越南盾汇率',
|
||||||
|
rateVndValue: '1 钻石 = {{diamonds}} VND',
|
||||||
|
convertibleVnd: '可兑换 VND',
|
||||||
|
convertibleUsdt: '可兑换 USDT',
|
||||||
|
fixedExchangeDiamondAmount: '固定兑换钻石金额',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
24
src/main.tsx
@@ -3,9 +3,19 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
|||||||
import { RouterProvider } from '@tanstack/react-router'
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { AppToaster } from '@/components/ui/toaster'
|
||||||
import { APP_ROOT_ELEMENT_ID } from '@/constants'
|
import { APP_ROOT_ELEMENT_ID } from '@/constants'
|
||||||
|
import {
|
||||||
|
getCurrentUserProfile,
|
||||||
|
refreshAuthSession,
|
||||||
|
} from '@/features/auth/api/auth-api'
|
||||||
import '@/i18n'
|
import '@/i18n'
|
||||||
import { initializeAuthSession } from '@/lib/auth/auth-session'
|
import { prefetchAuthToken } from '@/lib/api/api-client'
|
||||||
|
import {
|
||||||
|
initializeAuthSession,
|
||||||
|
registerCurrentUserInitializer,
|
||||||
|
registerRefreshSessionHandler,
|
||||||
|
} from '@/lib/auth/auth-session'
|
||||||
import { queryClient } from '@/lib/query/query-client'
|
import { queryClient } from '@/lib/query/query-client'
|
||||||
import { router } from '@/router'
|
import { router } from '@/router'
|
||||||
import './style/index.css'
|
import './style/index.css'
|
||||||
@@ -19,12 +29,22 @@ if (!rootElement) {
|
|||||||
throw new Error('Root element not found')
|
throw new Error('Root element not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
void initializeAuthSession()
|
registerCurrentUserInitializer(getCurrentUserProfile)
|
||||||
|
registerRefreshSessionHandler(refreshAuthSession)
|
||||||
|
|
||||||
|
void initializeAuthSession().then(async () => {
|
||||||
|
try {
|
||||||
|
await prefetchAuthToken()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to prefetch auth token', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
<AppToaster />
|
||||||
{shouldShowQueryDevtools && <ReactQueryDevtools initialIsOpen={false} />}
|
{shouldShowQueryDevtools && <ReactQueryDevtools initialIsOpen={false} />}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as LangRouteRouteImport } from './routes/$lang/route'
|
import { Route as LangRouteRouteImport } from './routes/$lang/route'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as LangIndexRouteImport } from './routes/$lang/index'
|
import { Route as LangIndexRouteImport } from './routes/$lang/index'
|
||||||
|
import { Route as LangWsTestRouteImport } from './routes/$lang/ws-test'
|
||||||
|
|
||||||
const LangRouteRoute = LangRouteRouteImport.update({
|
const LangRouteRoute = LangRouteRouteImport.update({
|
||||||
id: '/$lang',
|
id: '/$lang',
|
||||||
@@ -28,28 +29,36 @@ const LangIndexRoute = LangIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => LangRouteRoute,
|
getParentRoute: () => LangRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LangWsTestRoute = LangWsTestRouteImport.update({
|
||||||
|
id: '/ws-test',
|
||||||
|
path: '/ws-test',
|
||||||
|
getParentRoute: () => LangRouteRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/$lang': typeof LangRouteRouteWithChildren
|
'/$lang': typeof LangRouteRouteWithChildren
|
||||||
|
'/$lang/ws-test': typeof LangWsTestRoute
|
||||||
'/$lang/': typeof LangIndexRoute
|
'/$lang/': typeof LangIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/$lang/ws-test': typeof LangWsTestRoute
|
||||||
'/$lang': typeof LangIndexRoute
|
'/$lang': typeof LangIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/$lang': typeof LangRouteRouteWithChildren
|
'/$lang': typeof LangRouteRouteWithChildren
|
||||||
|
'/$lang/ws-test': typeof LangWsTestRoute
|
||||||
'/$lang/': typeof LangIndexRoute
|
'/$lang/': typeof LangIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/$lang' | '/$lang/'
|
fullPaths: '/' | '/$lang' | '/$lang/ws-test' | '/$lang/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/$lang'
|
to: '/' | '/$lang/ws-test' | '/$lang'
|
||||||
id: '__root__' | '/' | '/$lang' | '/$lang/'
|
id: '__root__' | '/' | '/$lang' | '/$lang/ws-test' | '/$lang/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -80,14 +89,23 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LangIndexRouteImport
|
preLoaderRoute: typeof LangIndexRouteImport
|
||||||
parentRoute: typeof LangRouteRoute
|
parentRoute: typeof LangRouteRoute
|
||||||
}
|
}
|
||||||
|
'/$lang/ws-test': {
|
||||||
|
id: '/$lang/ws-test'
|
||||||
|
path: '/ws-test'
|
||||||
|
fullPath: '/$lang/ws-test'
|
||||||
|
preLoaderRoute: typeof LangWsTestRouteImport
|
||||||
|
parentRoute: typeof LangRouteRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LangRouteRouteChildren {
|
interface LangRouteRouteChildren {
|
||||||
|
LangWsTestRoute: typeof LangWsTestRoute
|
||||||
LangIndexRoute: typeof LangIndexRoute
|
LangIndexRoute: typeof LangIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const LangRouteRouteChildren: LangRouteRouteChildren = {
|
const LangRouteRouteChildren: LangRouteRouteChildren = {
|
||||||
|
LangWsTestRoute: LangWsTestRoute,
|
||||||
LangIndexRoute: LangIndexRoute,
|
LangIndexRoute: LangIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
src/routes/$lang/ws-test.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const TEST_WS_URL =
|
||||||
|
'wss://zihua-api.h55555game.top/ws/?token=d77371f4-d053-475a-9c53-bfa11eb921c2&auth_token=708df54d-c647-46fc-b6ee-4339298d1ed4&device_id=web_0bc09c22-9157-4398-b4b3-57584ece9da9&lang=zh'
|
||||||
|
|
||||||
|
const TEST_TOPICS = [
|
||||||
|
'period.tick',
|
||||||
|
'user.streak',
|
||||||
|
'period.opened',
|
||||||
|
'period.locked',
|
||||||
|
'period.payout',
|
||||||
|
'bet.accepted',
|
||||||
|
'wallet.changed',
|
||||||
|
'auto.spin.progress',
|
||||||
|
'admin.live.snapshot',
|
||||||
|
'admin.live.opened',
|
||||||
|
'jackpot.hit',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type WsTestLog = {
|
||||||
|
at: string
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/$lang/ws-test')({
|
||||||
|
component: WsTestPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function WsTestPage() {
|
||||||
|
const [logs, setLogs] = useState<WsTestLog[]>([])
|
||||||
|
const [status, setStatus] = useState('idle')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const appendLog = (message: string) => {
|
||||||
|
setLogs((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLog(`creating websocket: ${TEST_WS_URL}`)
|
||||||
|
setStatus('connecting')
|
||||||
|
|
||||||
|
const socket = new WebSocket(TEST_WS_URL)
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
appendLog('open')
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
action: 'subscribe',
|
||||||
|
topics: [...TEST_TOPICS],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
appendLog(`subscribe: ${TEST_TOPICS.join(', ')}`)
|
||||||
|
setStatus('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('message', (event) => {
|
||||||
|
appendLog(`message: ${String(event.data)}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('error', () => {
|
||||||
|
appendLog('error')
|
||||||
|
setStatus('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('close', (event) => {
|
||||||
|
appendLog(
|
||||||
|
`close code=${event.code} reason=${event.reason || '(empty)'} wasClean=${event.wasClean}`,
|
||||||
|
)
|
||||||
|
setStatus('closed')
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-6 px-6 py-10 text-white">
|
||||||
|
<h1 className="text-2xl font-semibold">WS Test</h1>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="text-sm text-white/70">Status</div>
|
||||||
|
<div className="mt-1 text-lg">{status}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/30 p-4">
|
||||||
|
<div className="mb-3 text-sm text-white/70">URL</div>
|
||||||
|
<div className="break-all font-mono text-sm text-cyan-200">
|
||||||
|
{TEST_WS_URL}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/30 p-4">
|
||||||
|
<div className="mb-3 text-sm text-white/70">Topics</div>
|
||||||
|
<div className="break-all font-mono text-sm text-cyan-200">
|
||||||
|
{TEST_TOPICS.join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/30 p-4">
|
||||||
|
<div className="mb-3 text-sm text-white/70">Logs</div>
|
||||||
|
<div className="flex flex-col gap-2 font-mono text-sm">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="text-white/50">No logs yet</div>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<div key={log.id} className="break-all text-white/85">
|
||||||
|
[{log.at}] {log.message}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,42 +1,77 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||||
|
|
||||||
import { AUTH_STORAGE_KEY } from '@/constants'
|
import { APP_PREFERENCES_STORAGE_KEY, AUTH_STORAGE_KEY } from '@/constants'
|
||||||
|
|
||||||
|
/**@description 未登录 | 已登录 | 正在从存储恢复数据 */
|
||||||
export type AuthStatus = 'anonymous' | 'authenticated' | 'restoring'
|
export type AuthStatus = 'anonymous' | 'authenticated' | 'restoring'
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
|
createTime?: number
|
||||||
|
channelId?: number
|
||||||
|
coin?: string
|
||||||
|
currentStreak?: number
|
||||||
email?: string
|
email?: string
|
||||||
|
headImage?: string
|
||||||
id: string
|
id: string
|
||||||
|
isJackpot?: boolean
|
||||||
|
lastBetPeriodNo?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
oddsFactor?: number
|
||||||
|
phone?: string
|
||||||
|
registerInviteCode?: string
|
||||||
|
riskFlags?: number
|
||||||
roles?: string[]
|
roles?: string[]
|
||||||
|
streakLevel?: number
|
||||||
|
username?: string
|
||||||
|
uuid?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthSessionInput {
|
export interface AuthSessionInput {
|
||||||
accessToken: string
|
accessToken: string
|
||||||
|
accessTokenExpiresAt?: number | null
|
||||||
currentUser?: AuthUser | null
|
currentUser?: AuthUser | null
|
||||||
refreshToken?: string | null
|
refreshToken?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PersistedAuthState {
|
interface PersistedAuthState {
|
||||||
accessToken: string | null
|
accessToken: string | null
|
||||||
|
/** @description 用户登录态 `user-token` 的绝对过期时间戳(毫秒)。 */
|
||||||
|
accessTokenExpiresAt: number | null
|
||||||
|
/** @description `/api/v1/authToken` 返回的服务端时间戳(秒),用于后续校时或服务端时间基准判断。 */
|
||||||
|
apiAuthServerTime: number | null
|
||||||
|
apiAuthToken: string | null
|
||||||
|
/** @description 接口鉴权 `auth-token` 的绝对过期时间戳(毫秒)。 */
|
||||||
|
apiAuthTokenExpiresAt: number | null
|
||||||
currentUser: AuthUser | null
|
currentUser: AuthUser | null
|
||||||
refreshToken: string | null
|
refreshToken: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PersistedAppPreferenceState {
|
||||||
|
appLanguage: string | null
|
||||||
|
deviceId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthState extends PersistedAuthState {
|
interface AuthState extends PersistedAuthState {
|
||||||
|
clearApiAuthToken: () => void
|
||||||
clearAccessToken: () => void
|
clearAccessToken: () => void
|
||||||
clearSession: () => void
|
clearSession: () => void
|
||||||
finishHydration: () => void
|
finishHydration: () => void
|
||||||
isHydrated: boolean
|
isHydrated: boolean
|
||||||
lastUnauthorizedAt: string | null
|
lastUnauthorizedAt: string | null
|
||||||
markUnauthorized: () => void
|
markUnauthorized: () => void
|
||||||
|
setApiAuthToken: (token: {
|
||||||
|
expiresAt: number
|
||||||
|
serverTime: number
|
||||||
|
value: string
|
||||||
|
}) => void
|
||||||
setAccessToken: (token: string) => void
|
setAccessToken: (token: string) => void
|
||||||
setCurrentUser: (user: AuthUser | null) => void
|
setCurrentUser: (user: AuthUser | null) => void
|
||||||
startSession: (session: AuthSessionInput) => void
|
startSession: (session: AuthSessionInput) => void
|
||||||
status: AuthStatus
|
status: AuthStatus
|
||||||
updateTokens: (tokens: {
|
updateTokens: (tokens: {
|
||||||
accessToken: string
|
accessToken: string
|
||||||
|
accessTokenExpiresAt?: number | null
|
||||||
refreshToken?: string | null
|
refreshToken?: string | null
|
||||||
}) => void
|
}) => void
|
||||||
}
|
}
|
||||||
@@ -47,10 +82,25 @@ function resolveAuthStatus(accessToken: string | null): AuthStatus {
|
|||||||
|
|
||||||
const initialPersistedState: PersistedAuthState = {
|
const initialPersistedState: PersistedAuthState = {
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
|
accessTokenExpiresAt: null,
|
||||||
|
apiAuthServerTime: null,
|
||||||
|
apiAuthToken: null,
|
||||||
|
apiAuthTokenExpiresAt: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateDeviceId() {
|
||||||
|
if (
|
||||||
|
typeof crypto !== 'undefined' &&
|
||||||
|
typeof crypto.randomUUID === 'function'
|
||||||
|
) {
|
||||||
|
return `web_${crypto.randomUUID()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `web_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
@@ -58,9 +108,24 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
status: 'restoring',
|
status: 'restoring',
|
||||||
isHydrated: false,
|
isHydrated: false,
|
||||||
lastUnauthorizedAt: null,
|
lastUnauthorizedAt: null,
|
||||||
|
setApiAuthToken: ({ expiresAt, serverTime, value }) => {
|
||||||
|
set({
|
||||||
|
apiAuthServerTime: serverTime,
|
||||||
|
apiAuthToken: value,
|
||||||
|
apiAuthTokenExpiresAt: expiresAt,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
clearApiAuthToken: () => {
|
||||||
|
set({
|
||||||
|
apiAuthServerTime: null,
|
||||||
|
apiAuthToken: null,
|
||||||
|
apiAuthTokenExpiresAt: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
setAccessToken: (token) => {
|
setAccessToken: (token) => {
|
||||||
set({
|
set({
|
||||||
accessToken: token,
|
accessToken: token,
|
||||||
|
accessTokenExpiresAt: null,
|
||||||
status: 'authenticated',
|
status: 'authenticated',
|
||||||
isHydrated: true,
|
isHydrated: true,
|
||||||
})
|
})
|
||||||
@@ -73,20 +138,24 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
startSession: ({
|
startSession: ({
|
||||||
accessToken,
|
accessToken,
|
||||||
|
accessTokenExpiresAt = null,
|
||||||
currentUser = null,
|
currentUser = null,
|
||||||
refreshToken = null,
|
refreshToken = null,
|
||||||
}) => {
|
}) => {
|
||||||
set({
|
set({
|
||||||
accessToken,
|
accessToken,
|
||||||
|
accessTokenExpiresAt,
|
||||||
currentUser,
|
currentUser,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
status: 'authenticated',
|
status: 'authenticated',
|
||||||
isHydrated: true,
|
isHydrated: true,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
updateTokens: ({ accessToken, refreshToken }) => {
|
updateTokens: ({ accessToken, accessTokenExpiresAt, refreshToken }) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
accessToken,
|
accessToken,
|
||||||
|
accessTokenExpiresAt:
|
||||||
|
accessTokenExpiresAt ?? state.accessTokenExpiresAt,
|
||||||
refreshToken: refreshToken ?? state.refreshToken,
|
refreshToken: refreshToken ?? state.refreshToken,
|
||||||
status: 'authenticated',
|
status: 'authenticated',
|
||||||
isHydrated: true,
|
isHydrated: true,
|
||||||
@@ -101,6 +170,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
clearAccessToken: () => {
|
clearAccessToken: () => {
|
||||||
set({
|
set({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
|
accessTokenExpiresAt: null,
|
||||||
status: 'anonymous',
|
status: 'anonymous',
|
||||||
isHydrated: true,
|
isHydrated: true,
|
||||||
})
|
})
|
||||||
@@ -126,6 +196,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
storage: createJSONStorage(() => sessionStorage),
|
storage: createJSONStorage(() => sessionStorage),
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
accessToken: state.accessToken,
|
accessToken: state.accessToken,
|
||||||
|
accessTokenExpiresAt: state.accessTokenExpiresAt,
|
||||||
|
apiAuthServerTime: state.apiAuthServerTime,
|
||||||
|
apiAuthToken: state.apiAuthToken,
|
||||||
|
apiAuthTokenExpiresAt: state.apiAuthTokenExpiresAt,
|
||||||
currentUser: state.currentUser,
|
currentUser: state.currentUser,
|
||||||
refreshToken: state.refreshToken,
|
refreshToken: state.refreshToken,
|
||||||
}),
|
}),
|
||||||
@@ -141,3 +215,53 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface AppPreferenceStoreState extends PersistedAppPreferenceState {
|
||||||
|
getOrCreateDeviceId: () => string
|
||||||
|
setAppLanguage: (language: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
appLanguage: null,
|
||||||
|
deviceId: null,
|
||||||
|
getOrCreateDeviceId: () => {
|
||||||
|
const deviceId = get().deviceId
|
||||||
|
|
||||||
|
if (deviceId) {
|
||||||
|
return deviceId
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDeviceId = generateDeviceId()
|
||||||
|
|
||||||
|
set({ deviceId: nextDeviceId })
|
||||||
|
|
||||||
|
return nextDeviceId
|
||||||
|
},
|
||||||
|
setAppLanguage: (language) => {
|
||||||
|
set({ appLanguage: language })
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: APP_PREFERENCES_STORAGE_KEY,
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
appLanguage: state.appLanguage,
|
||||||
|
deviceId: state.deviceId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function getAuthDeviceId() {
|
||||||
|
return useAppPreferenceStore.getState().getOrCreateDeviceId()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredAppLanguage() {
|
||||||
|
return useAppPreferenceStore.getState().appLanguage
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredAppLanguage(language: string) {
|
||||||
|
useAppPreferenceStore.getState().setAppLanguage(language)
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ import {
|
|||||||
|
|
||||||
type GameRoundSlice = Pick<
|
type GameRoundSlice = Pick<
|
||||||
GameBootstrapSnapshot,
|
GameBootstrapSnapshot,
|
||||||
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
|
| 'cells'
|
||||||
|
| 'chips'
|
||||||
|
| 'history'
|
||||||
|
| 'maxSelectionCount'
|
||||||
|
| 'round'
|
||||||
|
| 'selections'
|
||||||
|
| 'trends'
|
||||||
>
|
>
|
||||||
|
|
||||||
export interface GameRoundStoreState extends GameRoundSlice {
|
export interface GameRoundStoreState extends GameRoundSlice {
|
||||||
@@ -47,6 +53,7 @@ function createInitialRoundState(): GameRoundSlice & { activeChipId: string } {
|
|||||||
cells: snapshot.cells,
|
cells: snapshot.cells,
|
||||||
chips: snapshot.chips,
|
chips: snapshot.chips,
|
||||||
history: snapshot.history,
|
history: snapshot.history,
|
||||||
|
maxSelectionCount: snapshot.maxSelectionCount,
|
||||||
round: snapshot.round,
|
round: snapshot.round,
|
||||||
selections: snapshot.selections,
|
selections: snapshot.selections,
|
||||||
trends: snapshot.trends,
|
trends: snapshot.trends,
|
||||||
@@ -68,6 +75,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
|||||||
cells: snapshot.cells,
|
cells: snapshot.cells,
|
||||||
chips: snapshot.chips,
|
chips: snapshot.chips,
|
||||||
history: snapshot.history,
|
history: snapshot.history,
|
||||||
|
maxSelectionCount: snapshot.maxSelectionCount,
|
||||||
round: snapshot.round,
|
round: snapshot.round,
|
||||||
selections: snapshot.selections,
|
selections: snapshot.selections,
|
||||||
trends: snapshot.trends,
|
trends: snapshot.trends,
|
||||||
@@ -79,8 +87,19 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
|||||||
getChipById(state.chips, state.activeChipId) ??
|
getChipById(state.chips, state.activeChipId) ??
|
||||||
state.chips.find((chip) => chip.isDefault) ??
|
state.chips.find((chip) => chip.isDefault) ??
|
||||||
state.chips[0]
|
state.chips[0]
|
||||||
|
const hasExistingSelection = state.selections.some(
|
||||||
|
(selection) => selection.cellId === cellId,
|
||||||
|
)
|
||||||
|
const selectedCellCount = new Set(
|
||||||
|
state.selections.map((selection) => selection.cellId),
|
||||||
|
).size
|
||||||
|
|
||||||
if (!activeChip || state.round.phase !== 'betting') {
|
if (
|
||||||
|
!activeChip ||
|
||||||
|
state.round.phase !== 'betting' ||
|
||||||
|
hasExistingSelection ||
|
||||||
|
selectedCellCount >= state.maxSelectionCount
|
||||||
|
) {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +184,13 @@ export const selectSelectionsByCell = (state: GameRoundStoreState) =>
|
|||||||
export type GameRoundStore = typeof useGameRoundStore
|
export type GameRoundStore = typeof useGameRoundStore
|
||||||
export type GameRoundStoreData = Pick<
|
export type GameRoundStoreData = Pick<
|
||||||
GameRoundStoreState,
|
GameRoundStoreState,
|
||||||
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
|
| 'cells'
|
||||||
|
| 'chips'
|
||||||
|
| 'history'
|
||||||
|
| 'maxSelectionCount'
|
||||||
|
| 'round'
|
||||||
|
| 'selections'
|
||||||
|
| 'trends'
|
||||||
>
|
>
|
||||||
|
|
||||||
export type { BetSelection, GameCell, HistoryEntry, RoundSnapshot, TrendEntry }
|
export type { BetSelection, GameCell, HistoryEntry, RoundSnapshot, TrendEntry }
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export interface GameSessionStoreState extends GameSessionSlice {
|
|||||||
dismissAnnouncement: (announcementId: string) => void
|
dismissAnnouncement: (announcementId: string) => void
|
||||||
hydrateSession: (snapshot: GameSessionSlice) => void
|
hydrateSession: (snapshot: GameSessionSlice) => void
|
||||||
markAnnouncementRead: (announcementId: string) => void
|
markAnnouncementRead: (announcementId: string) => void
|
||||||
|
requestRealtimeConnection: () => void
|
||||||
|
resetRealtimeConnectionRequest: () => void
|
||||||
|
shouldConnectRealtime: boolean
|
||||||
setConnectionLatency: (latencyMs: number | null) => void
|
setConnectionLatency: (latencyMs: number | null) => void
|
||||||
setConnectionStatus: (status: ConnectionStatus) => void
|
setConnectionStatus: (status: ConnectionStatus) => void
|
||||||
syncConnection: (patch: Partial<ConnectionState>) => void
|
syncConnection: (patch: Partial<ConnectionState>) => void
|
||||||
@@ -40,6 +43,7 @@ function createInitialSessionState(): GameSessionSlice {
|
|||||||
|
|
||||||
export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
|
export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
|
||||||
...createInitialSessionState(),
|
...createInitialSessionState(),
|
||||||
|
shouldConnectRealtime: false,
|
||||||
dismissAnnouncement: (announcementId) => {
|
dismissAnnouncement: (announcementId) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
announcements: {
|
announcements: {
|
||||||
@@ -57,7 +61,10 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
hydrateSession: (snapshot) => {
|
hydrateSession: (snapshot) => {
|
||||||
set(snapshot)
|
set((state) => ({
|
||||||
|
...snapshot,
|
||||||
|
shouldConnectRealtime: state.shouldConnectRealtime,
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
markAnnouncementRead: (announcementId) => {
|
markAnnouncementRead: (announcementId) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -69,6 +76,12 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
requestRealtimeConnection: () => {
|
||||||
|
set({ shouldConnectRealtime: true })
|
||||||
|
},
|
||||||
|
resetRealtimeConnectionRequest: () => {
|
||||||
|
set({ shouldConnectRealtime: false })
|
||||||
|
},
|
||||||
setConnectionLatency: (latencyMs) => {
|
setConnectionLatency: (latencyMs) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
connection: {
|
connection: {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './auth'
|
export * from './auth'
|
||||||
export * from './game'
|
export * from './game'
|
||||||
|
export * from './modal'
|
||||||
|
|||||||
1
src/store/modal/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './modal-store'
|
||||||
60
src/store/modal/modal-store.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { WithdrawTopupType } from '@/type'
|
||||||
|
|
||||||
|
export const MODAL_KEYS = [
|
||||||
|
/**@description 桌面端登录弹窗*/
|
||||||
|
'desktopLogin',
|
||||||
|
/**@description 桌面端注册弹窗*/
|
||||||
|
'desktopRegister',
|
||||||
|
/**@description 桌面端用户信息弹窗*/
|
||||||
|
'desktopUserInfo',
|
||||||
|
/**@description 桌面端公告弹窗*/
|
||||||
|
'desktopNotice',
|
||||||
|
/**@description 桌面端自动托管弹窗*/
|
||||||
|
'desktopAutoSetting',
|
||||||
|
/**@description 桌面端充值提现前置选择弹窗*/
|
||||||
|
'desktopProcedures',
|
||||||
|
/**@description 桌面端充值/提现弹窗*/
|
||||||
|
'desktopWithdrawTopup',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ModalKey = (typeof MODAL_KEYS)[number]
|
||||||
|
|
||||||
|
type ModalVisibilityMap = Record<ModalKey, boolean>
|
||||||
|
|
||||||
|
const INITIAL_MODAL_VISIBILITY: ModalVisibilityMap = {
|
||||||
|
desktopLogin: false,
|
||||||
|
desktopRegister: false,
|
||||||
|
desktopUserInfo: false,
|
||||||
|
desktopNotice: false,
|
||||||
|
desktopAutoSetting: false,
|
||||||
|
desktopProcedures: false,
|
||||||
|
desktopWithdrawTopup: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalStoreState {
|
||||||
|
modals: ModalVisibilityMap
|
||||||
|
withdrawTopupType: WithdrawTopupType
|
||||||
|
closeAllModals: () => void
|
||||||
|
setModalOpen: (key: ModalKey, open: boolean) => void
|
||||||
|
setWithdrawTopupType: (type: WithdrawTopupType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useModalStore = create<ModalStoreState>()((set) => ({
|
||||||
|
modals: INITIAL_MODAL_VISIBILITY,
|
||||||
|
withdrawTopupType: 'withdraw',
|
||||||
|
closeAllModals: () => {
|
||||||
|
set({ modals: INITIAL_MODAL_VISIBILITY })
|
||||||
|
},
|
||||||
|
setModalOpen: (key, open) => {
|
||||||
|
set((state) => ({
|
||||||
|
modals: {
|
||||||
|
...state.modals,
|
||||||
|
[key]: open,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
setWithdrawTopupType: (type) => {
|
||||||
|
set({ withdrawTopupType: type })
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -198,6 +198,7 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: calc(var(--design-unit) * 8) calc(var(--design-unit) * 10);
|
padding: calc(var(--design-unit) * 8) calc(var(--design-unit) * 10);
|
||||||
box-shadow: inset 0 0 8px rgba(128, 223, 231, 0.65);
|
box-shadow: inset 0 0 8px rgba(128, 223, 231, 0.65);
|
||||||
|
color: #d5fbff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.common-neon-inset-glow {
|
.common-neon-inset-glow {
|
||||||
@@ -346,6 +347,173 @@
|
|||||||
height: 0;
|
height: 0;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-toaster {
|
||||||
|
--width: min(
|
||||||
|
calc(100vw - calc(var(--design-unit) * 32)),
|
||||||
|
calc(var(--design-unit) * 520)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast {
|
||||||
|
width: min(
|
||||||
|
calc(100vw - calc(var(--design-unit) * 32)),
|
||||||
|
calc(var(--design-unit) * 520)
|
||||||
|
);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
column-gap: calc(var(--design-unit) * 14);
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(var(--design-unit) * 60);
|
||||||
|
padding: calc(var(--design-unit) * 16) calc(var(--design-unit) * 20);
|
||||||
|
border-radius: calc(var(--design-unit) * 16);
|
||||||
|
border: 1px solid rgba(128, 223, 231, 0.68);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(15, 35, 49, 0.98), rgba(6, 16, 24, 0.98)),
|
||||||
|
radial-gradient(circle at top, rgba(124, 232, 255, 0.22), transparent 58%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 calc(var(--design-unit) * 16) rgba(128, 223, 231, 0.18),
|
||||||
|
0 0 calc(var(--design-unit) * 24) rgba(29, 190, 219, 0.26),
|
||||||
|
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
|
||||||
|
rgba(2, 8, 16, 0.42);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
border-top-color: rgba(215, 250, 255, 0.32);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-success {
|
||||||
|
border-color: rgba(79, 220, 155, 0.72);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 calc(var(--design-unit) * 16) rgba(79, 220, 155, 0.16),
|
||||||
|
0 0 calc(var(--design-unit) * 24) rgba(79, 220, 155, 0.24),
|
||||||
|
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
|
||||||
|
rgba(2, 8, 16, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-error {
|
||||||
|
border-color: rgba(255, 94, 122, 0.68);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 calc(var(--design-unit) * 16) rgba(255, 94, 122, 0.16),
|
||||||
|
0 0 calc(var(--design-unit) * 24) rgba(255, 94, 122, 0.24),
|
||||||
|
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
|
||||||
|
rgba(2, 8, 16, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-warning {
|
||||||
|
border-color: rgba(255, 214, 110, 0.72);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 calc(var(--design-unit) * 16) rgba(255, 214, 110, 0.16),
|
||||||
|
0 0 calc(var(--design-unit) * 24) rgba(255, 214, 110, 0.22),
|
||||||
|
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
|
||||||
|
rgba(2, 8, 16, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-info,
|
||||||
|
.game-toast-loading,
|
||||||
|
.game-toast-default {
|
||||||
|
border-color: rgba(128, 223, 231, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(var(--design-unit) * 30);
|
||||||
|
height: calc(var(--design-unit) * 30);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 calc(var(--design-unit) * 8) rgba(255, 255, 255, 0.04),
|
||||||
|
0 0 calc(var(--design-unit) * 12) rgba(124, 232, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-content {
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: calc(var(--design-unit) * 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-title {
|
||||||
|
font-size: calc(var(--design-unit) * 17);
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: #f2fdff;
|
||||||
|
text-shadow:
|
||||||
|
0 0 calc(var(--design-unit) * 8) rgba(124, 232, 255, 0.18),
|
||||||
|
0 0 calc(var(--design-unit) * 16) rgba(124, 232, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-description {
|
||||||
|
margin-top: calc(var(--design-unit) * 5);
|
||||||
|
font-size: calc(var(--design-unit) * 13);
|
||||||
|
line-height: 1.45;
|
||||||
|
color: rgba(213, 251, 255, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: calc(var(--design-unit) * 14);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(var(--design-unit) * 28);
|
||||||
|
height: calc(var(--design-unit) * 28);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(128, 223, 231, 0.34);
|
||||||
|
background: rgba(4, 18, 27, 0.82);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 calc(var(--design-unit) * 6) rgba(255, 255, 255, 0.04),
|
||||||
|
0 0 calc(var(--design-unit) * 10) rgba(124, 232, 255, 0.14);
|
||||||
|
transition:
|
||||||
|
border-color 180ms ease,
|
||||||
|
background-color 180ms ease,
|
||||||
|
transform 180ms ease,
|
||||||
|
box-shadow 180ms ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-close:hover {
|
||||||
|
transform: translateY(-50%) scale(1.04);
|
||||||
|
border-color: rgba(128, 223, 231, 0.58);
|
||||||
|
background: rgba(10, 28, 40, 0.96);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 calc(var(--design-unit) * 8) rgba(255, 255, 255, 0.06),
|
||||||
|
0 0 calc(var(--design-unit) * 14) rgba(124, 232, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-action,
|
||||||
|
.game-toast-cancel {
|
||||||
|
margin-top: calc(var(--design-unit) * 10);
|
||||||
|
border-radius: calc(var(--design-unit) * 999);
|
||||||
|
padding: calc(var(--design-unit) * 6) calc(var(--design-unit) * 12);
|
||||||
|
font-size: calc(var(--design-unit) * 12);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-action {
|
||||||
|
border: 1px solid rgba(128, 223, 231, 0.52);
|
||||||
|
background: rgba(10, 34, 47, 0.9);
|
||||||
|
color: #d5fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-toast-cancel {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(213, 251, 255, 0.74);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
17
src/type/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export type WithdrawTopupType = 'withdraw' | 'topup'
|
||||||
|
|
||||||
|
/** @description 后端统一响应体结构。 */
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number
|
||||||
|
msg?: string
|
||||||
|
data: T
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @description 后端统一错误响应体结构。 */
|
||||||
|
export interface ApiErrorOptions {
|
||||||
|
message: string
|
||||||
|
status?: number
|
||||||
|
data?: unknown
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
@@ -3,6 +3,7 @@
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_APP_ENV: 'development' | 'production' | 'test'
|
readonly VITE_APP_ENV: 'development' | 'production' | 'test'
|
||||||
readonly VITE_API_BASE_URL: string
|
readonly VITE_API_BASE_URL: string
|
||||||
|
readonly VITE_WEBSOCKET_URL?: string
|
||||||
readonly VITE_ENABLE_QUERY_DEVTOOLS: 'true' | 'false'
|
readonly VITE_ENABLE_QUERY_DEVTOOLS: 'true' | 'false'
|
||||||
readonly VITE_ENABLE_REQUEST_LOG: 'true' | 'false'
|
readonly VITE_ENABLE_REQUEST_LOG: 'true' | 'false'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export default defineConfig({
|
|||||||
port: 9999,
|
port: 9999,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
allowedHosts: ['darlena-nonexpiring-cathie.ngrok-free.dev'],
|
allowedHosts: ['darlena-nonexpiring-cathie.ngrok-free.dev'],
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://zihua-api.h55555game.top',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
tanstackRouter({
|
tanstackRouter({
|
||||||
|
|||||||