feat(auth): 集成认证授权功能并优化API客户端
- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取 - 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理 - 添加了WebSocket连接配置和API基础URL环境变量设置 - 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑 - 集成了MD5加密和认证令牌缓存机制,提升安全性 - 添加了多语言国际化支持,包括英语、中文、马来语和印尼语 - 实现了认证状态管理和本地存储持久化功能 - 添加了表单验证schema和错误处理机制,增强用户体验
@@ -1,4 +1,7 @@
|
||||
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_REQUEST_LOG=true
|
||||
# 客户端密钥
|
||||
VITE_AUTH_TOKEN_SECRET=564d14asdasd113e46542asd6das1a2a
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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_REQUEST_LOG=true
|
||||
# 客户端密钥
|
||||
VITE_AUTH_TOKEN_SECRET=564d14asdasd113e46542asd6das1a2a
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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_REQUEST_LOG=false
|
||||
# 客户端密钥
|
||||
VITE_AUTH_TOKEN_SECRET=564d14asdasd113e46542asd6das1a2a
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tanstack/react-query-devtools": "^5.99.0",
|
||||
"@tanstack/react-router": "^1.168.22",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@types/md5": "^2.3.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
@@ -37,10 +39,11 @@
|
||||
"ky": "^2.0.1",
|
||||
"lottie-web": "^5.13.0",
|
||||
"lucide-react": "^1.9.0",
|
||||
"md5": "^2.3.0",
|
||||
"motion": "^12.38.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-i18next": "^17.0.3",
|
||||
"shadcn": "^4.7.0",
|
||||
|
||||
59
pnpm-lock.yaml
generated
@@ -23,6 +23,12 @@ importers:
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.168.22
|
||||
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:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -44,6 +50,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0(react@19.2.5)
|
||||
md5:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
motion:
|
||||
specifier: ^12.38.0
|
||||
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
|
||||
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:
|
||||
specifier: ^19.2.4
|
||||
specifier: 19.2.5
|
||||
version: 19.2.5
|
||||
react-dom:
|
||||
specifier: ^19.2.4
|
||||
specifier: 19.2.5
|
||||
version: 19.2.5(react@19.2.5)
|
||||
react-hook-form:
|
||||
specifier: ^7.75.0
|
||||
@@ -1686,6 +1695,12 @@ packages:
|
||||
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/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':
|
||||
resolution: {integrity: sha512-gCWBbCVkfT2OzgxQVV275BjRYKvfh7SEKD73ATHWyLE8ifm8/O2700roObVHUy+Y0jJT91Am0UkjsES0O2jqzw==}
|
||||
engines: {node: '>=20.19'}
|
||||
@@ -1729,6 +1744,9 @@ packages:
|
||||
'@tanstack/store@0.9.3':
|
||||
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':
|
||||
resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==}
|
||||
engines: {node: '>=20.19'}
|
||||
@@ -1752,6 +1770,9 @@ packages:
|
||||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/md5@2.3.6':
|
||||
resolution: {integrity: sha512-WD69gNXtRBnpknfZcb4TRQ0XJQbUPZcai/Qdhmka3sxUR3Et8NrXoeAoknG/LghYHTf4ve795rInVYHBTQdNVA==}
|
||||
|
||||
'@types/node@24.12.2':
|
||||
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
|
||||
|
||||
@@ -1956,6 +1977,9 @@ packages:
|
||||
chardet@0.7.0:
|
||||
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
|
||||
|
||||
charenc@0.0.2:
|
||||
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
|
||||
|
||||
chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
@@ -2104,6 +2128,9 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
crypt@0.0.2:
|
||||
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -2604,6 +2631,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-buffer@1.1.6:
|
||||
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
||||
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -2902,6 +2932,9 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
md5@2.3.0:
|
||||
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
||||
|
||||
media-typer@1.1.0:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -5317,6 +5350,12 @@ snapshots:
|
||||
react-dom: 19.2.5(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':
|
||||
dependencies:
|
||||
'@tanstack/router-generator': 1.166.32
|
||||
@@ -5382,6 +5421,8 @@ snapshots:
|
||||
|
||||
'@tanstack/store@0.9.3': {}
|
||||
|
||||
'@tanstack/virtual-core@3.14.0': {}
|
||||
|
||||
'@tanstack/virtual-file-routes@1.161.7': {}
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
@@ -5416,6 +5457,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@types/md5@2.3.6': {}
|
||||
|
||||
'@types/node@24.12.2':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
@@ -5613,6 +5656,8 @@ snapshots:
|
||||
|
||||
chardet@0.7.0: {}
|
||||
|
||||
charenc@0.0.2: {}
|
||||
|
||||
chokidar@3.6.0:
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
@@ -5761,6 +5806,8 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
crypt@0.0.2: {}
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
@@ -6295,6 +6342,8 @@ snapshots:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
|
||||
is-buffer@1.1.6: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
@@ -6510,6 +6559,12 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
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 { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import modalBg from '@/assets/system/modal-bg.webp'
|
||||
import modalClose from '@/assets/system/modal-close.webp'
|
||||
import modalNormalBg from '@/assets/system/modal-normal-bg.png'
|
||||
@@ -29,6 +30,7 @@ export function CenterModal({
|
||||
children,
|
||||
className,
|
||||
}: CenterModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const handleClose = () => {
|
||||
onClose?.()
|
||||
}
|
||||
@@ -63,7 +65,11 @@ export function CenterModal({
|
||||
<SmartBackground
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={typeof title === 'string' ? title : 'Modal'}
|
||||
aria-label={
|
||||
typeof title === 'string'
|
||||
? title
|
||||
: t('commonUi.modal.defaultAriaLabel')
|
||||
}
|
||||
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',
|
||||
className,
|
||||
@@ -93,7 +99,7 @@ export function CenterModal({
|
||||
{isShowClose && onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
aria-label={t('commonUi.modal.close')}
|
||||
onClick={handleClose}
|
||||
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',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { AppLanguage } from '@/i18n'
|
||||
import { type AppLanguage, supportedLanguages } from '@/i18n'
|
||||
|
||||
const languagePrefixPattern = new RegExp(
|
||||
`^/(${supportedLanguages.join('|')})(?=/|$)`,
|
||||
)
|
||||
|
||||
interface LanguageLinkProps {
|
||||
currentPathname: string
|
||||
@@ -14,7 +18,7 @@ export function LanguageLink({
|
||||
language,
|
||||
}: LanguageLinkProps) {
|
||||
const nextPathname = currentPathname.replace(
|
||||
/^\/(zh-CN|en-US)(?=\/|$)/,
|
||||
languagePrefixPattern,
|
||||
`/${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 认证状态持久化到浏览器时使用的存储键。 */
|
||||
export const AUTH_STORAGE_KEY = 'auth-session'
|
||||
|
||||
/** @description 应用偏好持久化到浏览器时使用的存储键。 */
|
||||
export const APP_PREFERENCES_STORAGE_KEY = 'app-preferences'
|
||||
|
||||
/** @description 接口请求的默认超时时间,单位为毫秒。 */
|
||||
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
||||
|
||||
@@ -48,23 +51,48 @@ export const QUERY_RETRYABLE_STATUS_CODES = [
|
||||
408, 429, 500, 502, 503, 504,
|
||||
] as const
|
||||
|
||||
/** @description 国际化语言设置持久化到浏览器时使用的存储键。 */
|
||||
export const I18N_LANGUAGE_STORAGE_KEY = 'app-language'
|
||||
|
||||
/** @description 桌面端布局切换起始断点。 */
|
||||
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
|
||||
|
||||
export const CHIP_OPTIONS = [
|
||||
{ id: 'chip-1', value: 1, src: chip1 },
|
||||
{ id: 'chip-2', value: 5, src: chip2 },
|
||||
{ id: 'chip-3', value: 10, src: chip3 },
|
||||
{ id: 'chip-4', value: 25, src: chip4 },
|
||||
{ id: 'chip-5', value: 50, src: chip5 },
|
||||
{ id: 'chip-6', value: 100, src: chip6 },
|
||||
export const CHIP_IMAGE_OPTIONS = [
|
||||
{ id: 'chip-1', src: chip1 },
|
||||
{ id: 'chip-2', src: chip2 },
|
||||
{ id: 'chip-3', src: chip3 },
|
||||
{ id: 'chip-4', src: chip4 },
|
||||
{ id: 'chip-5', src: chip5 },
|
||||
{ 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 = [
|
||||
{ id: 'clear', label: 'Clear', Icon: Trash2, bg: controlLeft },
|
||||
{ id: 'repeat', label: 'Repeat', Icon: Repeat2, bg: controlMid },
|
||||
{ id: 'auto-spin', label: 'Auto-Spin', Icon: Settings, bg: controlRight },
|
||||
{
|
||||
id: 'clear',
|
||||
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 { ApiError } from '@/lib/api/api-error'
|
||||
import type { ApiResponse } from '@/type'
|
||||
|
||||
import type {
|
||||
AnnouncementItem,
|
||||
@@ -9,10 +11,17 @@ import type {
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
HistoryEntry,
|
||||
RoundPhase,
|
||||
RoundSnapshot,
|
||||
TrendEntry,
|
||||
} from '../shared'
|
||||
import { createMockGameBootstrapSnapshot } from '../shared'
|
||||
import {
|
||||
createMockGameBootstrapSnapshot,
|
||||
DEFAULT_GAME_CHIP_COLORS,
|
||||
deriveTrendEntries,
|
||||
GAME_GRID_COLUMNS,
|
||||
GAME_MAX_SELECTION_CELLS,
|
||||
} from '../shared'
|
||||
import type {
|
||||
AnnouncementStateDto,
|
||||
BetSelectionDto,
|
||||
@@ -20,20 +29,72 @@ import type {
|
||||
ConnectionStateDto,
|
||||
DashboardStateDto,
|
||||
GameAnnouncementsDto,
|
||||
GameBetOrdersDto,
|
||||
GameBootstrapDto,
|
||||
GameCellDto,
|
||||
GameLobbyInitDto,
|
||||
GameLobbyPeriodDto,
|
||||
GamePeriodTickDto,
|
||||
GameRoundFeedDto,
|
||||
HistoryEntryDto,
|
||||
NoticeConfirmDto,
|
||||
NoticeDetailDto,
|
||||
NoticeListDto,
|
||||
RoundSnapshotDto,
|
||||
TrendEntryDto,
|
||||
} 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 = {
|
||||
announcements: 'game/announcements',
|
||||
betMyOrders: 'api/game/betMyOrders',
|
||||
bootstrap: 'game/bootstrap',
|
||||
lobbyInit: 'api/game/lobbyInit',
|
||||
noticeConfirm: 'api/notice/noticeConfirm',
|
||||
noticeDetail: 'api/notice/noticeDetail',
|
||||
noticeList: 'api/notice/noticeList',
|
||||
roundFeed: 'game/round-feed',
|
||||
} as const
|
||||
|
||||
export interface GameLobbyInitResult {
|
||||
runtimeEnabled: boolean
|
||||
serverTime: number
|
||||
snapshot: GameBootstrapSnapshot
|
||||
userSnapshot: GameLobbyInitDto['user_snapshot']
|
||||
}
|
||||
|
||||
function normalizeGameCell(dto: GameCellDto) {
|
||||
return dto satisfies GameCell
|
||||
}
|
||||
@@ -136,6 +197,193 @@ function normalizeConnectionState(dto: ConnectionStateDto) {
|
||||
} 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) {
|
||||
return {
|
||||
announcements: normalizeAnnouncementState(dto.announcements),
|
||||
@@ -144,6 +392,7 @@ export function normalizeGameBootstrap(dto: GameBootstrapDto) {
|
||||
connection: normalizeConnectionState(dto.connection),
|
||||
dashboard: normalizeDashboardState(dto.dashboard),
|
||||
history: dto.history.map(normalizeHistoryEntry),
|
||||
maxSelectionCount: GAME_MAX_SELECTION_CELLS,
|
||||
round: normalizeRoundSnapshot(dto.round),
|
||||
selections: dto.selections.map(normalizeBetSelection),
|
||||
trends: dto.trends.map(normalizeTrendEntry),
|
||||
@@ -164,22 +413,125 @@ export function normalizeGameRoundFeed(dto: GameRoundFeedDto) {
|
||||
|
||||
export async function getGameBootstrap() {
|
||||
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() {
|
||||
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() {
|
||||
const response = await api.get<GameAnnouncementsDto>(
|
||||
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) {
|
||||
|
||||
@@ -123,6 +123,116 @@ export interface GameAnnouncementsDto {
|
||||
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 {
|
||||
AnnouncementState,
|
||||
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 { notify } from '@/lib/notify'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
|
||||
eager: true,
|
||||
@@ -18,6 +24,37 @@ const animalImageList = Object.entries(animalModules)
|
||||
.filter((item) => item.id > 0)
|
||||
.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 {
|
||||
activeId?: number | null
|
||||
className?: string
|
||||
@@ -33,40 +70,220 @@ export function DesktopAnimal({
|
||||
imageClassName,
|
||||
onSelect,
|
||||
}: 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 (
|
||||
<section
|
||||
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,
|
||||
)}
|
||||
>
|
||||
{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 (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(item.id)}
|
||||
disabled={lockInteraction}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
className={cn(
|
||||
'flex flex-col items-center transition',
|
||||
'cursor-pointer',
|
||||
'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',
|
||||
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 &&
|
||||
'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,
|
||||
)}
|
||||
>
|
||||
<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
|
||||
src={item.url}
|
||||
alt={`animal-${item.id}`}
|
||||
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,
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion } from 'motion/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import add from '@/assets/game/add.webp'
|
||||
import arrow from '@/assets/game/arrow.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 reduce from '@/assets/game/reduce.webp'
|
||||
import totalBg from '@/assets/game/total-bg.webp'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { ACTION_OPTIONS } from '@/constants'
|
||||
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function DesktopControl() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
canClear,
|
||||
chips,
|
||||
maxSelectionCountLabel,
|
||||
onChipSelect,
|
||||
onClearSelections,
|
||||
selectedChipAmountLabel,
|
||||
@@ -26,7 +29,6 @@ export function DesktopControl() {
|
||||
selectedCountLabel,
|
||||
totalBetAmountLabel,
|
||||
} = useGameControlVm()
|
||||
|
||||
const [clickedId, setClickedId] = useState<string | null>(null)
|
||||
const [hidingId, setHidingId] = useState<string | null>(null)
|
||||
const [confirmClicked, setConfirmClicked] = useState(false)
|
||||
@@ -74,8 +76,8 @@ export function DesktopControl() {
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col items-center justify-center'}>
|
||||
<div>TREBD</div>
|
||||
<div>MAP</div>
|
||||
<div>{t('gameDesktop.control.trend')}</div>
|
||||
<div>{t('gameDesktop.control.map')}</div>
|
||||
</div>
|
||||
<SmartImage
|
||||
src={arrow}
|
||||
@@ -110,10 +112,10 @@ export function DesktopControl() {
|
||||
transition={{
|
||||
layout: {
|
||||
type: 'spring',
|
||||
stiffness: 420,
|
||||
damping: 32,
|
||||
stiffness: 360,
|
||||
damping: 26,
|
||||
},
|
||||
duration: 0.18,
|
||||
duration: 0.26,
|
||||
}}
|
||||
className={
|
||||
'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
|
||||
layout
|
||||
animate={
|
||||
isSelected
|
||||
? {
|
||||
y: [-1, -3, -1],
|
||||
scale: [1.02, 1.06, 1.02],
|
||||
y: [-1, -4, -1],
|
||||
scale: [1.04, 1.1, 1.04],
|
||||
filter: [
|
||||
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
|
||||
'drop-shadow(0 10px 14px rgba(245, 200, 107, 0.22))',
|
||||
'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 12px 16px rgba(245, 200, 107, 0.28))',
|
||||
'drop-shadow(0 8px 10px rgba(0,0,0,0.22))',
|
||||
],
|
||||
}
|
||||
: {
|
||||
@@ -205,6 +208,27 @@ export function DesktopControl() {
|
||||
draggable={false}
|
||||
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.button>
|
||||
)
|
||||
@@ -237,11 +261,26 @@ export function DesktopControl() {
|
||||
src={totalBg}
|
||||
size="100% 100%"
|
||||
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>Total Bet:{totalBetAmountLabel}</div>
|
||||
<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
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{ACTION_OPTIONS.map(({ id, label, Icon, bg }) => {
|
||||
{ACTION_OPTIONS.map(({ id, labelKey, Icon, bg }) => {
|
||||
const isClicked = clickedId === id
|
||||
const isHiding = hidingId === id
|
||||
const showBg = isClicked || isHiding
|
||||
@@ -315,7 +354,7 @@ export function DesktopControl() {
|
||||
className={showBg ? 'text-[#D9FEFF]' : 'text-[#37D5CB]'}
|
||||
/>
|
||||
<div className={'mt-design-6 text-design-14 leading-none'}>
|
||||
{label}
|
||||
{t(labelKey)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
@@ -351,7 +390,7 @@ export function DesktopControl() {
|
||||
transition={{ duration: 0.15 }}
|
||||
className="relative"
|
||||
>
|
||||
confirm
|
||||
{t('gameDesktop.control.confirm')}
|
||||
</motion.span>
|
||||
</SmartBackground>
|
||||
</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 { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts'
|
||||
|
||||
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 (
|
||||
<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]'
|
||||
}
|
||||
>
|
||||
History
|
||||
{t('gameDesktop.history.title')}
|
||||
</div>
|
||||
<div
|
||||
ref={parentRef}
|
||||
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'
|
||||
}
|
||||
>
|
||||
{isEmpty ? (
|
||||
{isInitialLoading ? (
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
|
||||
}
|
||||
>
|
||||
{loadingText}
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
|
||||
@@ -32,56 +86,98 @@ export function DesktopGameHistory() {
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={
|
||||
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const item = items[virtualRow.index]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
|
||||
}
|
||||
key={item?.id ?? `loader-${virtualRow.index}`}
|
||||
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
|
||||
className={
|
||||
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
|
||||
}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -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 diamond from '@/assets/system/diamond.webp'
|
||||
import logo from '@/assets/system/logo.webp'
|
||||
import wifi from '@/assets/system/wifi.webp'
|
||||
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() {
|
||||
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 (
|
||||
<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">
|
||||
@@ -18,92 +268,124 @@ export function DesktopHeader() {
|
||||
</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)]">
|
||||
<SmartImage
|
||||
src={wifi}
|
||||
alt="wifi"
|
||||
priority
|
||||
className="h-design-20 w-design-28"
|
||||
/>
|
||||
<div className={'text-[#74FF69] text-design-20'}>
|
||||
24 <span className={'text-design-16'}>ms</span>
|
||||
<div className={signalPresentation.toneClassName}>
|
||||
<SignalBars
|
||||
activeBars={signalPresentation.activeBars}
|
||||
toneClassName={signalPresentation.toneClassName}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${signalPresentation.toneClassName} text-design-20`}>
|
||||
{signalPresentation.latencyLabel}{' '}
|
||||
<span className={'text-design-16'}>ms</span>
|
||||
</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>System Time</div>
|
||||
<div>20:05:12 GMT+08</div>
|
||||
<div>{t('gameDesktop.header.systemTime')}</div>
|
||||
<div>{systemTimeLabel}</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={
|
||||
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
|
||||
}
|
||||
>
|
||||
<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 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>Rules & Ddds</div>
|
||||
<div>{t('gameDesktop.header.rules')}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<Mail color={'#57B8BF'} size={16} />
|
||||
<div>Pesan</div>
|
||||
<div>{t('gameDesktop.header.message')}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<Volume2 color={'#57B8BF'} size={16} />
|
||||
<div>BGM</div>
|
||||
<div>{t('gameDesktop.header.bgm')}</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
|
||||
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>ID</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'relative flex items-center justify-center'}>
|
||||
<SmartImage
|
||||
src={avatar}
|
||||
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'}>
|
||||
<SmartImage
|
||||
src={avatar}
|
||||
alt="avatar"
|
||||
priority
|
||||
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset !py-design-20 flex h-design-36 w-design-160 items-center justify-end'
|
||||
}
|
||||
>
|
||||
Biomond Balance
|
||||
<div className={'relative flex items-center justify-center'}>
|
||||
<SmartImage
|
||||
src={diamond}
|
||||
alt="diamond"
|
||||
priority
|
||||
className="absolute -left-5 z-20 h-design-50 w-design-50"
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-design-180 items-center justify-end'
|
||||
}
|
||||
>
|
||||
{currentUser?.coin || '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'relative flex items-center justify-center'}>
|
||||
<SmartImage
|
||||
src={avatar}
|
||||
alt="avatar"
|
||||
priority
|
||||
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
|
||||
/>
|
||||
<div
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
'flex items-center justify-center gap-design-30 pl-design-30 pr-design-10'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</div>
|
||||
<CircleAlert color={'#57B8BF'} size={16} />
|
||||
<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>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import statusCenter from '@/assets/system/status-center.webp'
|
||||
import statusLine from '@/assets/system/status-line.webp'
|
||||
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'
|
||||
|
||||
export function DesktopStatusLine() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
countdownMs,
|
||||
limitLabel,
|
||||
@@ -27,9 +29,15 @@ export function DesktopStatusLine() {
|
||||
<div
|
||||
className={'flex-1 flex items-center justify-center gap-design-24'}
|
||||
>
|
||||
<div>Odds: {oddsLabel}</div>
|
||||
<div>Streak: {streakLabel}</div>
|
||||
<div>Limit: {limitLabel}</div>
|
||||
<div>
|
||||
{t('gameDesktop.status.odds')}: {oddsLabel}
|
||||
</div>
|
||||
<div>
|
||||
{t('gameDesktop.status.streak')}: {streakLabel}
|
||||
</div>
|
||||
<div>
|
||||
{t('gameDesktop.status.limit')}: {limitLabel}
|
||||
</div>
|
||||
</div>
|
||||
<SmartBackground
|
||||
src={statusCenter}
|
||||
@@ -44,7 +52,9 @@ export function DesktopStatusLine() {
|
||||
/>
|
||||
</SmartBackground>
|
||||
<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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Megaphone } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
export function DesktopTitle() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
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]">
|
||||
<Megaphone color={'#57B8BF'} />
|
||||
<div>
|
||||
Selamat kepada pemain Wu Yanzu yang telah memenangkan hadiah utama
|
||||
sebesar 5.000 yuan sebanyak lima kali berturut-turut!🎉🎉🎉
|
||||
</div>
|
||||
<div>{t('gameDesktop.title.announcement')}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function DesktopTopup() {
|
||||
return <div>DesktopTopup</div>
|
||||
const { t } = useTranslation()
|
||||
|
||||
return <div>{t('gameDesktop.topup.placeholder')}</div>
|
||||
}
|
||||
|
||||
export default DesktopTopup
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Minus, Plus } from 'lucide-react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
@@ -148,10 +149,12 @@ function WithdrawField({
|
||||
|
||||
function AmountShell({
|
||||
amount,
|
||||
availableBalanceText,
|
||||
onMinus,
|
||||
onPlus,
|
||||
}: {
|
||||
amount: number
|
||||
availableBalanceText: string
|
||||
onMinus: () => void
|
||||
onPlus: () => void
|
||||
}) {
|
||||
@@ -180,7 +183,7 @@ function AmountShell({
|
||||
</div>
|
||||
|
||||
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
|
||||
Saldo Tersedia: {formatNumber(AVAILABLE_BALANCE)}
|
||||
{availableBalanceText}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -355,6 +358,7 @@ function PreviewRow({
|
||||
}
|
||||
|
||||
function DesktopWithdraw() {
|
||||
const { t } = useTranslation()
|
||||
const [amount, setAmount] = useState(6626)
|
||||
const [currency, setCurrency] =
|
||||
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 flex-col gap-design-12">
|
||||
<WithdrawField label="Jumlah Penarikan Berlian">
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.diamondWithdrawalAmount')}
|
||||
>
|
||||
<AmountShell
|
||||
amount={amount}
|
||||
availableBalanceText={t(
|
||||
'gameDesktop.withdraw.availableBalance',
|
||||
{ amount: formatNumber(AVAILABLE_BALANCE) },
|
||||
)}
|
||||
onMinus={() => handleAmountChange(amount - 1)}
|
||||
onPlus={() => handleAmountChange(amount + 1)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Jenis Mata Uang" alignStart={false}>
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.currencyType')}
|
||||
alignStart={false}
|
||||
>
|
||||
<Select
|
||||
value={currency}
|
||||
onValueChange={(value) =>
|
||||
@@ -405,9 +418,11 @@ function DesktopWithdraw() {
|
||||
>
|
||||
<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]"
|
||||
aria-label="Currency selection"
|
||||
aria-label={t('gameDesktop.withdraw.currencySelection')}
|
||||
>
|
||||
<SelectValue placeholder="Select currency" />
|
||||
<SelectValue
|
||||
placeholder={t('gameDesktop.withdraw.selectCurrency')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
@@ -441,7 +456,9 @@ function DesktopWithdraw() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WithdrawField label="Saluran Pembayaran">
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.paymentChannel')}
|
||||
>
|
||||
<div className="flex flex-wrap gap-design-10">
|
||||
{PAYMENT_CHANNELS.map((channel) => (
|
||||
<PaymentCard
|
||||
@@ -455,7 +472,7 @@ function DesktopWithdraw() {
|
||||
</div>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Kode Bank">
|
||||
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
|
||||
<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)]">
|
||||
{`014${selectedBank?.label ?? 'BCA'} (${selectedBank?.subtitle ?? 'BANK CENTRAL ASIA'}): 014`}
|
||||
@@ -475,40 +492,62 @@ function DesktopWithdraw() {
|
||||
</div>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Nama Pemegang Kartu">
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.cardHolderName')}
|
||||
>
|
||||
<InputShell
|
||||
value={holderName}
|
||||
onChange={setHolderName}
|
||||
placeholder="Mohon masukkan nama pemegang kartu."
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.cardHolderName',
|
||||
)}
|
||||
error={holderNameError}
|
||||
errorMessage="Mohon masukkan nama pemegang kartu."
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.cardHolderNameRequired',
|
||||
)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Nomor Rekening Bank">
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
|
||||
>
|
||||
<InputShell
|
||||
value={bankAccount}
|
||||
onChange={setBankAccount}
|
||||
placeholder="Silakan masukkan nomor rekening bank Anda."
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.bankAccountNumber',
|
||||
)}
|
||||
error={bankAccountError}
|
||||
errorMessage="Silakan masukkan nomor rekening bank Anda."
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.bankAccountRequired',
|
||||
)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Email Penerima" alignStart={false}>
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.receiverEmail')}
|
||||
alignStart={false}
|
||||
>
|
||||
<InputShell
|
||||
value={receiverEmail}
|
||||
onChange={setReceiverEmail}
|
||||
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.receiverEmail',
|
||||
)}
|
||||
uppercase={true}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Nomor Ponsel Penerima" alignStart={false}>
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.receiverPhone')}
|
||||
alignStart={false}
|
||||
>
|
||||
<InputShell
|
||||
value={receiverPhone}
|
||||
onChange={setReceiverPhone}
|
||||
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.receiverPhone',
|
||||
)}
|
||||
uppercase={true}
|
||||
/>
|
||||
</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 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 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)]">
|
||||
<PreviewRow label="Jumlah Berlian" value={formatNumber(amount)} />
|
||||
<PreviewRow
|
||||
label="Kurs (MYR)"
|
||||
value={`${100 * MYR_PER_100_DIAMONDS} BERLIAN = 1 MYR`}
|
||||
label={t('gameDesktop.withdraw.preview.diamondAmount')}
|
||||
value={formatNumber(amount)}
|
||||
/>
|
||||
<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)}`}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label="Nilai Tukar USDT/MYR"
|
||||
value={`1 USDT = RM ${USDT_TO_MYR_RATE}`}
|
||||
label={t('gameDesktop.withdraw.preview.usdtMyrRate')}
|
||||
value={t('gameDesktop.withdraw.preview.usdtMyrRateValue', {
|
||||
rate: USDT_TO_MYR_RATE,
|
||||
})}
|
||||
/>
|
||||
<PreviewRow
|
||||
label="Nilai Tukar (VND)"
|
||||
value={`${VND_PER_DIAMOND} BERLIAN = 1 VND`}
|
||||
label={t('gameDesktop.withdraw.preview.rateVnd')}
|
||||
value={t('gameDesktop.withdraw.preview.rateVndValue', {
|
||||
diamonds: VND_PER_DIAMOND,
|
||||
})}
|
||||
/>
|
||||
<PreviewRow
|
||||
label="Dapat Dikonversi ke VND"
|
||||
label={t('gameDesktop.withdraw.preview.convertibleVnd')}
|
||||
value={`${formatNumber(withdrawVnd)} VND`}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label="Dapat Ditukarkan dengan USDT"
|
||||
label={t('gameDesktop.withdraw.preview.convertibleUsdt')}
|
||||
value={`${formatFixedSix(withdrawUsdt)} USDT`}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label="Jumlah Berlian Nilai Tukar Tetap"
|
||||
label={t(
|
||||
'gameDesktop.withdraw.preview.fixedExchangeDiamondAmount',
|
||||
)}
|
||||
value="0-0-0 0:0:0"
|
||||
/>
|
||||
</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]">
|
||||
Nilai tukar berfungsi sebagai harga acuan; nilai tukar aktual yang
|
||||
berlaku ditentukan pada saat penarikan.
|
||||
{t('gameDesktop.withdraw.exchangeRateNotice')}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
|
||||
<div>
|
||||
Dompet Elektronik:{' '}
|
||||
<span className="text-[#B9F4F8]">Minimal RM10</span>
|
||||
{t('gameDesktop.withdraw.wallet')}:{' '}
|
||||
<span className="text-[#B9F4F8]">
|
||||
{t('gameDesktop.withdraw.minimumRm10')}
|
||||
</span>
|
||||
</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>
|
||||
Waktu Pengerjaan:{' '}
|
||||
{t('gameDesktop.withdraw.processingTime')}:{' '}
|
||||
<span className="text-[#77FF76]">
|
||||
Dana Tiba Hanya Dalam 9 Detik.
|
||||
{t('gameDesktop.withdraw.fundsArrivalTime')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[#B9F4F8]">
|
||||
Melihat: Transaksi antara RM10 dan RM99,99 akan dikenakan biaya
|
||||
penarikan minimum sebesar RM1.
|
||||
{t('gameDesktop.withdraw.feeNotice')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -591,7 +644,7 @@ function DesktopWithdraw() {
|
||||
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]"
|
||||
>
|
||||
Membatalkan
|
||||
{t('gameDesktop.withdraw.cancel')}
|
||||
</SmartBackground>
|
||||
<SmartBackground
|
||||
as="button"
|
||||
@@ -600,9 +653,9 @@ function DesktopWithdraw() {
|
||||
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]"
|
||||
>
|
||||
Konfirmasi
|
||||
{t('gameDesktop.withdraw.confirm')}
|
||||
<br />
|
||||
Penarikan
|
||||
{t('gameDesktop.withdraw.withdrawal')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getMockGameBootstrap, getVisibleAnnouncements } from '@/features/game'
|
||||
import { getGameLobbyInit, getVisibleAnnouncements } from '@/features/game'
|
||||
import { GameAnnouncementModal } from '@/features/game/components'
|
||||
import { MobileEntry } from '@/features/game/entry/mobile-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 { notify } from '@/lib/notify'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
const ENABLE_ANNOUNCEMENT_MODAL = false
|
||||
|
||||
export function EntryPage() {
|
||||
const { t } = useTranslation()
|
||||
useGameRealtimeSync()
|
||||
const announcements = useGameSessionStore((state) => state.announcements)
|
||||
const dismissAnnouncement = useGameSessionStore(
|
||||
(state) => state.dismissAnnouncement,
|
||||
)
|
||||
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
|
||||
const selectChip = useGameRoundStore((state) => state.selectChip)
|
||||
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
|
||||
const markAnnouncementRead = useGameSessionStore(
|
||||
(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 [isMobile, setIsMobile] = useState(() => {
|
||||
@@ -49,33 +57,89 @@ export function EntryPage() {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void getMockGameBootstrap().then((snapshot) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
void getGameLobbyInit()
|
||||
.then((result) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRound({
|
||||
cells: snapshot.cells,
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
trends: snapshot.trends,
|
||||
startTransition(() => {
|
||||
const snapshot = result.snapshot
|
||||
|
||||
hydrateRound({
|
||||
cells: snapshot.cells,
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
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 () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hydrateRound, hydrateSession])
|
||||
}, [
|
||||
authStatus,
|
||||
hydrateRound,
|
||||
hydrateSession,
|
||||
selectChip,
|
||||
setCurrentUser,
|
||||
syncConnection,
|
||||
t,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
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 { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.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'
|
||||
|
||||
export function PcEntry() {
|
||||
@@ -38,20 +43,13 @@ export function PcEntry() {
|
||||
>
|
||||
<DesktopControl />
|
||||
</div>
|
||||
{/*登录弹窗*/}
|
||||
{/*<DesktopLoginModal />*/}
|
||||
{/*注册弹窗 */}
|
||||
{/*<DesktopRegisterModal />*/}
|
||||
{/* 用户信息弹窗 */}
|
||||
{/*<DesktopUserInfoModal />*/}
|
||||
{/*公告弹窗*/}
|
||||
{/*<DesktopNoticeModal />*/}
|
||||
{/*自动托管弹窗*/}
|
||||
<DesktopLoginModal />
|
||||
<DesktopRegisterModal />
|
||||
<DesktopUserInfoModal />
|
||||
<DesktopNoticeModal />
|
||||
<DesktopAutoSettingModal />
|
||||
{/* 充值提现前置选择弹窗*/}
|
||||
{/*<DesktopProceduresModal />*/}
|
||||
{/* 充值和提现弹窗 */}
|
||||
{/*<DesktopWithdrawTopupModal/>*/}
|
||||
<DesktopProceduresModal />
|
||||
<DesktopWithdrawTopupModal />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
import { useMemo } from 'react'
|
||||
import { CHIP_OPTIONS } from '@/constants'
|
||||
import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants'
|
||||
import { selectSelectionTotal, useGameRoundStore } from '@/store/game'
|
||||
|
||||
const CHIP_IMAGE_MAP = new Map(
|
||||
CHIP_OPTIONS.map((chip) => [chip.value, chip.src] as const),
|
||||
)
|
||||
function formatChipDisplayValue(amount: number) {
|
||||
if (Number.isInteger(amount)) {
|
||||
return String(amount)
|
||||
}
|
||||
|
||||
return amount.toFixed(2).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
export function useGameControlVm() {
|
||||
const chips = useGameRoundStore((state) => state.chips)
|
||||
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||
const maxSelectionCount = useGameRoundStore(
|
||||
(state) => state.maxSelectionCount,
|
||||
)
|
||||
const selections = useGameRoundStore((state) => state.selections)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const selectChip = useGameRoundStore((state) => state.selectChip)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
|
||||
const chipItems = useMemo(
|
||||
() =>
|
||||
chips.map((chip) => ({
|
||||
amount: chip.amount,
|
||||
id: chip.id,
|
||||
isSelected: chip.id === activeChipId,
|
||||
src: CHIP_IMAGE_MAP.get(chip.amount) ?? CHIP_OPTIONS[0]?.src ?? '',
|
||||
valueLabel: String(chip.amount),
|
||||
})),
|
||||
[activeChipId, chips],
|
||||
)
|
||||
const chipItems = useMemo(() => {
|
||||
const items = chips.map((chip) => ({
|
||||
amount: chip.amount,
|
||||
id: chip.id,
|
||||
isSelected: chip.id === activeChipId,
|
||||
src: CHIP_IMAGE_MAP.get(chip.id) ?? CHIP_IMAGE_OPTIONS[0]?.src ?? '',
|
||||
valueLabel: formatChipDisplayValue(chip.amount),
|
||||
}))
|
||||
|
||||
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 =
|
||||
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
|
||||
@@ -33,10 +46,11 @@ export function useGameControlVm() {
|
||||
canClear: selections.length > 0,
|
||||
onChipSelect: selectChip,
|
||||
onClearSelections: clearSelections,
|
||||
maxSelectionCountLabel: maxSelectionCount,
|
||||
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
|
||||
selectedChipId: activeChipId,
|
||||
selectedCountLabel: `${selections.length}/5`,
|
||||
totalBetAmountLabel: String(totalBetAmount),
|
||||
selectedCountLabel: selections.length,
|
||||
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
|
||||
chips: chipItems,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { useGameRoundStore } from '@/store/game'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function formatSettledTime(iso: string) {
|
||||
const date = new Date(iso)
|
||||
import { getGameBetMyOrders } from '@/features/game/api/game-api'
|
||||
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())) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
return date.toLocaleString(locale, {
|
||||
hour12: false,
|
||||
month: '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() {
|
||||
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(
|
||||
() =>
|
||||
history.map((entry) => ({
|
||||
id: entry.roundId,
|
||||
payoutMultiplierLabel: `${entry.payoutMultiplier}x`,
|
||||
roundId: entry.roundId,
|
||||
settledAtLabel: formatSettledTime(entry.settledAt),
|
||||
statusLabel: 'settled',
|
||||
totalPoolAmountLabel: entry.totalPoolAmount.toFixed(2),
|
||||
winningCellIdLabel: String(entry.winningCellId),
|
||||
})),
|
||||
[history],
|
||||
(query.data?.pages ?? []).flatMap((page) =>
|
||||
page.list.map((entry) => ({
|
||||
amountLabel: entry.total_amount,
|
||||
createdAtLabel: formatCreatedTime(
|
||||
entry.create_time,
|
||||
i18n.resolvedLanguage ?? 'en-US',
|
||||
),
|
||||
id: entry.order_no,
|
||||
numbersLabel: formatNumbers(entry.numbers),
|
||||
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 {
|
||||
emptyText: 'No history yet',
|
||||
isEmpty: items.length === 0,
|
||||
emptyText: t('gameDesktop.history.empty'),
|
||||
endText: t('gameDesktop.history.end'),
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
hasNextPage: query.hasNextPage,
|
||||
isEmpty: authStatus !== 'authenticated' || items.length === 0,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isInitialLoading: query.isLoading,
|
||||
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 { getRoundCountdownMs } from '@/features/game/shared/selectors'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
const PHASE_META = {
|
||||
betting: {
|
||||
description: '(Menerima Taruhan)',
|
||||
label: 'OPEN',
|
||||
descriptionKey: 'gameDesktop.status.phase.betting.description',
|
||||
labelKey: 'gameDesktop.status.phase.betting.label',
|
||||
toneClassName: 'text-[#78FF7F]',
|
||||
},
|
||||
locked: {
|
||||
description: '(Taruhan Ditutup)',
|
||||
label: 'LOCKED',
|
||||
descriptionKey: 'gameDesktop.status.phase.locked.description',
|
||||
labelKey: 'gameDesktop.status.phase.locked.label',
|
||||
toneClassName: 'text-[#FFE375]',
|
||||
},
|
||||
revealing: {
|
||||
description: '(Mengundi Hasil)',
|
||||
label: 'DRAWING',
|
||||
descriptionKey: 'gameDesktop.status.phase.revealing.description',
|
||||
labelKey: 'gameDesktop.status.phase.revealing.label',
|
||||
toneClassName: 'text-[#57E8FF]',
|
||||
},
|
||||
settled: {
|
||||
description: '(Putaran Selesai)',
|
||||
label: 'SETTLED',
|
||||
descriptionKey: 'gameDesktop.status.phase.settled.description',
|
||||
labelKey: 'gameDesktop.status.phase.settled.label',
|
||||
toneClassName: 'text-[#FF9C6B]',
|
||||
},
|
||||
waiting: {
|
||||
description: '(Menunggu Putaran Berikutnya)',
|
||||
label: 'WAITING',
|
||||
descriptionKey: 'gameDesktop.status.phase.waiting.description',
|
||||
labelKey: 'gameDesktop.status.phase.waiting.label',
|
||||
toneClassName: 'text-[#A7B6C7]',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function useGameStatusVm() {
|
||||
const { t } = useTranslation()
|
||||
const cells = useGameRoundStore((state) => state.cells)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const trends = useGameRoundStore((state) => state.trends)
|
||||
const dashboard = useGameSessionStore((state) => state.dashboard)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
|
||||
return useMemo(() => {
|
||||
const oddsValue = cells[0]?.odds ?? '--'
|
||||
const oddsValue =
|
||||
typeof currentUser?.oddsFactor === 'number'
|
||||
? currentUser.oddsFactor
|
||||
: (cells[0]?.odds ?? '--')
|
||||
const featuredTrend = trends.find(
|
||||
(entry) => entry.cellId === dashboard.featuredCellId,
|
||||
)
|
||||
const phaseMeta = PHASE_META[round.phase]
|
||||
const streakValue =
|
||||
currentUser?.currentStreak ?? featuredTrend?.currentStreak ?? null
|
||||
|
||||
return {
|
||||
acceptingBets: round.phase === 'betting',
|
||||
countdownMs: getRoundCountdownMs(round),
|
||||
countdownMs: dashboard.countdownMs,
|
||||
limitLabel: `${dashboard.tableLimitMin}-${dashboard.tableLimitMax}`,
|
||||
oddsLabel: `1:${oddsValue}`,
|
||||
phase: round.phase,
|
||||
phaseDescription: phaseMeta.description,
|
||||
phaseLabel: phaseMeta.label,
|
||||
phaseDescription: t(phaseMeta.descriptionKey),
|
||||
phaseLabel: t(phaseMeta.labelKey),
|
||||
phaseToneClassName: phaseMeta.toneClassName,
|
||||
roundId: round.id,
|
||||
streakLabel: featuredTrend ? `X${featuredTrend.currentStreak}` : '--',
|
||||
roundId: round.id || '--',
|
||||
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 { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { Switch } from '@/components/ui/switch.tsx'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
const AUTO_STOP_ROWS = [
|
||||
{
|
||||
label: 'Stop if balance lower than',
|
||||
labelKey: 'game.modals.autoSetting.rows.stopIfBalanceLowerThan',
|
||||
value: '0',
|
||||
checked: false,
|
||||
},
|
||||
{
|
||||
label: 'Stop if single win exceeds',
|
||||
labelKey: 'game.modals.autoSetting.rows.stopIfSingleWinExceeds',
|
||||
value: '50000',
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
label: 'Stop on any Jackpot',
|
||||
labelKey: 'game.modals.autoSetting.rows.stopOnAnyJackpot',
|
||||
// value: '50000',
|
||||
checked: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
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() {
|
||||
setOpen(false)
|
||||
setModalOpen('desktopAutoSetting', false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -36,7 +39,7 @@ function DesktopAutoSettingModal() {
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||
Biomond Balance
|
||||
{t('game.modals.autoSetting.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
@@ -51,7 +54,7 @@ function DesktopAutoSettingModal() {
|
||||
<div className={'flex w-full flex-col gap-design-26'}>
|
||||
{AUTO_STOP_ROWS.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
key={row.labelKey}
|
||||
className={'flex items-center justify-between gap-design-30'}
|
||||
>
|
||||
<div
|
||||
@@ -59,10 +62,10 @@ function DesktopAutoSettingModal() {
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{row.label}
|
||||
{t(row.labelKey)}
|
||||
</div>
|
||||
|
||||
{row.value ? (
|
||||
{'value' in row ? (
|
||||
<div
|
||||
className={
|
||||
'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]'
|
||||
}
|
||||
>
|
||||
START AUTO-SPIN
|
||||
{t('game.modals.autoSetting.startAutoSpin')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,100 +1,28 @@
|
||||
import { motion } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
import loginBg from '@/assets/system/login-bg.webp'
|
||||
import rightImg from '@/assets/system/right.webp'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { DesktopLoginForm } from '@/features/auth/components/desktop-login-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
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() {
|
||||
setOpen(false)
|
||||
setModalOpen('desktopLogin', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={() => {}}
|
||||
title={<div className={'modal-title-glow'}>登录</div>}
|
||||
onClose={() => setModalOpen('desktopLogin', false)}
|
||||
title={
|
||||
<div className={'modal-title-glow'}>{t('game.modals.login.title')}</div>
|
||||
}
|
||||
titleAlign="center"
|
||||
className={'w-design-980 h-design-540'}
|
||||
>
|
||||
<div
|
||||
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>
|
||||
<DesktopLoginForm onSuccess={handleSubmit} />
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||
import noticeBg from '@/assets/system/notice-bg.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
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() {
|
||||
setOpen(false)
|
||||
setModalOpen('desktopNotice', false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -19,7 +22,7 @@ function DesktopNoticeModal() {
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26'}>
|
||||
PENGUMUMAN ACARA
|
||||
{t('game.modals.notice.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
@@ -40,13 +43,7 @@ function DesktopNoticeModal() {
|
||||
/>
|
||||
|
||||
<div className={'text-[#74B3BA] text-design-18 leading-[1.6]'}>
|
||||
"Perjanjian Lisensi dan Layanan Game" (selanjutnya disebut sebagai
|
||||
"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"),
|
||||
{t('game.modals.notice.content')}
|
||||
</div>
|
||||
</div>
|
||||
<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'
|
||||
}
|
||||
>
|
||||
Memeriksa
|
||||
{t('game.modals.notice.check')}
|
||||
</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'
|
||||
}
|
||||
>
|
||||
Memeriksa
|
||||
{t('game.modals.notice.check')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import proceduresBg from '@/assets/system/procedures-bg.webp'
|
||||
import topupBtnBg from '@/assets/system/topup.webp'
|
||||
import withdrawBtnBg from '@/assets/system/withdraw.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
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() {
|
||||
setOpen(false)
|
||||
setModalOpen('desktopProcedures', false)
|
||||
}
|
||||
|
||||
function handleOpenWithdrawTopup(type: 'withdraw' | 'topup') {
|
||||
setModalOpen('desktopProcedures', false)
|
||||
setWithdrawTopupType(type)
|
||||
setModalOpen('desktopWithdrawTopup', true)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -18,7 +30,7 @@ function DesktopProceduresModal() {
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||
Biomond Balance
|
||||
{t('game.modals.procedures.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
@@ -33,23 +45,27 @@ function DesktopProceduresModal() {
|
||||
'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'}>
|
||||
<SmartBackground
|
||||
src={withdrawBtnBg}
|
||||
onClick={() => handleOpenWithdrawTopup('withdraw')}
|
||||
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
|
||||
src={topupBtnBg}
|
||||
onClick={() => handleOpenWithdrawTopup('topup')}
|
||||
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>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
|
||||
@@ -1,124 +1,30 @@
|
||||
import { motion } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
import loginBg from '@/assets/system/login-bg.webp'
|
||||
import rightImg from '@/assets/system/right.webp'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { DesktopRegisterForm } from '@/features/auth/components/desktop-register-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
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() {
|
||||
setOpen(false)
|
||||
setModalOpen('desktopRegister', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={() => {}}
|
||||
title={<div className={'modal-title-glow'}>注册</div>}
|
||||
onClose={() => setModalOpen('desktopRegister', false)}
|
||||
title={
|
||||
<div className={'modal-title-glow'}>
|
||||
{t('game.modals.register.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="center"
|
||||
className={'w-design-980 h-design-740'}
|
||||
>
|
||||
<div
|
||||
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>
|
||||
<DesktopRegisterForm onSuccess={handleSubmit} />
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CircleUserRound, Mail } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
import blueBtnBg from '@/assets/system/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 { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
type UserInfoTabKey = 'profile' | 'message'
|
||||
|
||||
const USER_INFO_TABS: Array<{
|
||||
key: UserInfoTabKey
|
||||
label: string
|
||||
labelKey: string
|
||||
icon: typeof CircleUserRound
|
||||
}> = [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '个人信息',
|
||||
labelKey: 'game.modals.userInfo.tabs.profile',
|
||||
icon: CircleUserRound,
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
label: '站内消息',
|
||||
labelKey: 'game.modals.userInfo.tabs.message',
|
||||
icon: Mail,
|
||||
},
|
||||
]
|
||||
|
||||
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')
|
||||
|
||||
function handleSubmit() {
|
||||
setOpen(false)
|
||||
setModalOpen('desktopUserInfo', false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -41,7 +45,9 @@ function DesktopUserInfoModal() {
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
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}
|
||||
titleAlign="left"
|
||||
@@ -96,7 +102,7 @@ function DesktopUserInfoModal() {
|
||||
isActive && 'modal-title-gold-glow',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{t(tab.labelKey)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -119,8 +125,12 @@ function DesktopUserInfoModal() {
|
||||
alt={'avatar'}
|
||||
/>
|
||||
<div className={'flex flex-col gap-design-30'}>
|
||||
<div>NAMA :Biomond Balance</div>
|
||||
<div>TEL :12345678901</div>
|
||||
<div>
|
||||
{t('game.modals.userInfo.profile.name')} :Biomond Balance
|
||||
</div>
|
||||
<div>
|
||||
{t('game.modals.userInfo.profile.tel')} :12345678901
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +138,7 @@ function DesktopUserInfoModal() {
|
||||
<div className={'flex flex-col gap-design-5'}>
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<div key={item}>
|
||||
Tanggal Pendaftaran :
|
||||
{t('game.modals.userInfo.profile.registeredAt')} :
|
||||
<span className={'text-design-18 text-[#599AA3]'}>
|
||||
2022-10-06 23:36
|
||||
</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'
|
||||
}
|
||||
>
|
||||
Tanda tangan pribadi saya persis seperti jiwa saya—unik dan
|
||||
mus
|
||||
{t('game.modals.userInfo.profile.signature')}
|
||||
</div>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
@@ -166,10 +175,7 @@ function DesktopUserInfoModal() {
|
||||
<div className={'h-design-95 w-design-95 bg-black'}></div>
|
||||
<div className={'flex-1'}>
|
||||
<div>2026-10-10 08:32:56</div>
|
||||
<div>
|
||||
[Event Bonus Isi Ulang] Dari tanggal 1 hingga 7 Oktober
|
||||
2026, dapatkan pengembalian ...
|
||||
</div>
|
||||
<div>{t('game.modals.userInfo.message.eventBonus')}</div>
|
||||
</div>
|
||||
<SmartBackground
|
||||
src={blueBtnBg}
|
||||
@@ -178,7 +184,7 @@ function DesktopUserInfoModal() {
|
||||
'w-design-150 h-design-64 flex items-center justify-center text-design-20 font-bold'
|
||||
}
|
||||
>
|
||||
Memeriksa
|
||||
{t('game.modals.userInfo.message.check')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
))}
|
||||
@@ -196,7 +202,7 @@ function DesktopUserInfoModal() {
|
||||
'w-design-275 h-design-65 flex items-center justify-center text-design-22 font-bold'
|
||||
}
|
||||
>
|
||||
删除记录
|
||||
{t('game.modals.userInfo.message.deleteRecords')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import DesktopTopup from '@/features/game/components/desktop/desktop-topup.tsx'
|
||||
import DesktopWithdraw from '@/features/game/components/desktop/desktop-withdraw.tsx'
|
||||
|
||||
type WithdrawType = 'withdraw' | 'topup'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopWithdrawTopupModal() {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [type] = useState<WithdrawType>('withdraw')
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopWithdrawTopup)
|
||||
const type = useModalStore((state) => state.withdrawTopupType)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setOpen(false)
|
||||
setModalOpen('desktopWithdrawTopup', false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -18,7 +20,9 @@ function DesktopWithdrawTopupModal() {
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||
{type === 'withdraw' ? '申请提现' : '申请充值'}
|
||||
{type === 'withdraw'
|
||||
? t('game.modals.withdrawTopup.applyWithdraw')
|
||||
: t('game.modals.withdrawTopup.applyTopup')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
|
||||
@@ -56,4 +56,4 @@ export const DEFAULT_GAME_CHIP_COLORS = [
|
||||
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-5'
|
||||
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
|
||||
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 {
|
||||
DEFAULT_ACTIVE_CHIP_ID,
|
||||
DEFAULT_ANNOUNCEMENT_TTL_MS,
|
||||
DEFAULT_GAME_CHIP_COLORS,
|
||||
GAME_GRID_COLUMNS,
|
||||
GAME_MAX_SELECTION_CELLS,
|
||||
GAME_TOTAL_CELLS,
|
||||
} from './constants'
|
||||
import { deriveTrendEntries, getRoundCountdownMs } from './selectors'
|
||||
@@ -41,12 +42,12 @@ export function createGameCells() {
|
||||
}
|
||||
|
||||
export function createDefaultChips() {
|
||||
return CHIP_OPTIONS.map((chip, index) => ({
|
||||
amount: chip.value,
|
||||
return DEFAULT_CHIP_AMOUNTS.map((chip, index) => ({
|
||||
amount: chip.amount,
|
||||
color: DEFAULT_GAME_CHIP_COLORS[index],
|
||||
id: 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[]
|
||||
}
|
||||
|
||||
@@ -76,36 +77,8 @@ export function createMockRoundSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
|
||||
} satisfies RoundSnapshot
|
||||
}
|
||||
|
||||
export function createMockBetSelections(chips = createDefaultChips()) {
|
||||
const defaultChip =
|
||||
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 createMockBetSelections() {
|
||||
return [] satisfies BetSelection[]
|
||||
}
|
||||
|
||||
export function createMockAnnouncementState(baseIso = MOCK_GAME_BASE_TIME) {
|
||||
@@ -177,8 +150,9 @@ export function createMockGameBootstrapSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
|
||||
connection: createMockConnectionState(baseIso),
|
||||
dashboard: createMockDashboardState(baseIso, round, history),
|
||||
history,
|
||||
maxSelectionCount: GAME_MAX_SELECTION_CELLS,
|
||||
round,
|
||||
selections: createMockBetSelections(chips),
|
||||
selections: createMockBetSelections(),
|
||||
trends: deriveTrendEntries(history),
|
||||
} satisfies GameBootstrapSnapshot
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface GameBootstrapSnapshot {
|
||||
connection: ConnectionState
|
||||
dashboard: DashboardState
|
||||
history: HistoryEntry[]
|
||||
maxSelectionCount: number
|
||||
round: RoundSnapshot
|
||||
selections: BetSelection[]
|
||||
trends: TrendEntry[]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import { I18N_LANGUAGE_STORAGE_KEY } from '@/constants'
|
||||
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 { 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]
|
||||
|
||||
const defaultLanguage: AppLanguage = 'zh-CN'
|
||||
@@ -39,6 +41,14 @@ function detectBrowserLanguage() {
|
||||
if (normalizedLanguage.startsWith('en')) {
|
||||
return 'en-US'
|
||||
}
|
||||
|
||||
if (normalizedLanguage.startsWith('ms')) {
|
||||
return 'ms-MY'
|
||||
}
|
||||
|
||||
if (normalizedLanguage.startsWith('id')) {
|
||||
return 'id-ID'
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLanguage
|
||||
@@ -46,13 +56,7 @@ function detectBrowserLanguage() {
|
||||
|
||||
/** @description 获取应用启动时应使用的初始语言。 */
|
||||
function getInitialLanguage() {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultLanguage
|
||||
}
|
||||
|
||||
const persistedLanguage = window.localStorage.getItem(
|
||||
I18N_LANGUAGE_STORAGE_KEY,
|
||||
)
|
||||
const persistedLanguage = getStoredAppLanguage()
|
||||
|
||||
if (isSupportedLanguage(persistedLanguage)) {
|
||||
return persistedLanguage
|
||||
@@ -91,6 +95,12 @@ void i18n.use(initReactI18next).init({
|
||||
'en-US': {
|
||||
common: enUSCommon,
|
||||
},
|
||||
'ms-MY': {
|
||||
common: msMYCommon,
|
||||
},
|
||||
'id-ID': {
|
||||
common: idIDCommon,
|
||||
},
|
||||
},
|
||||
defaultNS: 'common',
|
||||
})
|
||||
@@ -101,8 +111,8 @@ function syncLanguageState(language: string) {
|
||||
document.documentElement.lang = language
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && isSupportedLanguage(language)) {
|
||||
window.localStorage.setItem(I18N_LANGUAGE_STORAGE_KEY, language)
|
||||
if (isSupportedLanguage(language)) {
|
||||
setStoredAppLanguage(language)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ import {
|
||||
DEFAULT_REQUEST_ACCEPT_HEADER,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
} from '@/constants'
|
||||
import type { AuthTokenDto } from '@/features/auth/api/types'
|
||||
import { ApiError } from '@/lib/api/api-error.ts'
|
||||
import {
|
||||
handleUnauthorizedSession,
|
||||
tryRefreshAuthSession,
|
||||
} from '@/lib/auth/auth-session'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
import { ApiError } from './api-error'
|
||||
import type { ApiResponse } from './types'
|
||||
import { md5 } from '@/lib/crypto/md5'
|
||||
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
|
||||
import type { ApiResponse } from '@/type'
|
||||
|
||||
type RequestOptions = Omit<Options, 'json'>
|
||||
type JsonRequestOptions<TBody> = RequestOptions & {
|
||||
@@ -20,7 +21,12 @@ type JsonRequestOptions<TBody> = RequestOptions & {
|
||||
|
||||
const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
|
||||
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 authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim()
|
||||
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
||||
|
||||
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)
|
||||
|
||||
const authTokenClient = ky.create({
|
||||
prefix: apiBaseUrl,
|
||||
retry: 0,
|
||||
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
headers: {
|
||||
Accept: DEFAULT_REQUEST_ACCEPT_HEADER,
|
||||
},
|
||||
})
|
||||
|
||||
const apiClient = ky.create({
|
||||
prefix: apiBaseUrl,
|
||||
retry: 0,
|
||||
@@ -109,6 +124,7 @@ const apiClient = ky.create({
|
||||
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`)
|
||||
request.headers.set('user-token', token)
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
|
||||
return data as TResponse
|
||||
@@ -138,6 +298,7 @@ async function request<TResponse>(input: string, options?: Options) {
|
||||
if (
|
||||
error instanceof HTTPError &&
|
||||
error.response.status === 401 &&
|
||||
input !== AUTH_REFRESH_ENDPOINT &&
|
||||
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] !== true &&
|
||||
options?.context?.[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY] !== true
|
||||
) {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
interface ApiErrorOptions {
|
||||
message: string
|
||||
status?: number
|
||||
data?: unknown
|
||||
url?: string
|
||||
}
|
||||
import type { ApiErrorOptions } from '@/type'
|
||||
|
||||
export class ApiError extends Error {
|
||||
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
|
||||
}
|
||||
|
||||
export async function hydrateCurrentUser(initializer: CurrentUserInitializer) {
|
||||
const currentUser = await initializer()
|
||||
|
||||
useAuthStore.getState().setCurrentUser(currentUser)
|
||||
|
||||
return currentUser
|
||||
}
|
||||
|
||||
export async function tryRefreshAuthSession() {
|
||||
if (refreshSessionPromise) {
|
||||
return refreshSessionPromise
|
||||
@@ -86,6 +94,8 @@ export async function tryRefreshAuthSession() {
|
||||
|
||||
useAuthStore.getState().startSession({
|
||||
accessToken: nextSession.accessToken,
|
||||
accessTokenExpiresAt:
|
||||
nextSession.accessTokenExpiresAt ?? snapshot.accessTokenExpiresAt,
|
||||
currentUser: nextSession.currentUser ?? snapshot.currentUser,
|
||||
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[]) {
|
||||
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',
|
||||
zhCN: '中文',
|
||||
enUS: 'English',
|
||||
msMY: 'Bahasa Melayu',
|
||||
idID: 'Bahasa Indonesia',
|
||||
},
|
||||
game: {
|
||||
metaTitle: 'Game Lobby',
|
||||
@@ -109,6 +111,59 @@ export default {
|
||||
'This will later connect to the real announcement body, confirmation checkbox, and persistence flow.',
|
||||
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: {
|
||||
eyebrow: 'Auto spin',
|
||||
title: 'Auto spin running',
|
||||
@@ -128,4 +183,234 @@ export default {
|
||||
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
|
||||
|
||||
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: '语言',
|
||||
zhCN: '中文',
|
||||
enUS: 'English',
|
||||
msMY: 'Bahasa Melayu',
|
||||
idID: 'Bahasa Indonesia',
|
||||
},
|
||||
game: {
|
||||
metaTitle: '游戏大厅',
|
||||
@@ -106,6 +108,57 @@ export default {
|
||||
line1: '这里后续会接真实公告图文、勾选确认和已读状态。',
|
||||
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: {
|
||||
eyebrow: '自动托管',
|
||||
title: '自动托管运行中',
|
||||
@@ -124,4 +177,230 @@ export default {
|
||||
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
|
||||
|
||||
24
src/main.tsx
@@ -3,9 +3,19 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { AppToaster } from '@/components/ui/toaster'
|
||||
import { APP_ROOT_ELEMENT_ID } from '@/constants'
|
||||
import {
|
||||
getCurrentUserProfile,
|
||||
refreshAuthSession,
|
||||
} from '@/features/auth/api/auth-api'
|
||||
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 { router } from '@/router'
|
||||
import './style/index.css'
|
||||
@@ -19,12 +29,22 @@ if (!rootElement) {
|
||||
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(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<AppToaster />
|
||||
{shouldShowQueryDevtools && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as LangRouteRouteImport } from './routes/$lang/route'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as LangIndexRouteImport } from './routes/$lang/index'
|
||||
import { Route as LangWsTestRouteImport } from './routes/$lang/ws-test'
|
||||
|
||||
const LangRouteRoute = LangRouteRouteImport.update({
|
||||
id: '/$lang',
|
||||
@@ -28,28 +29,36 @@ const LangIndexRoute = LangIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => LangRouteRoute,
|
||||
} as any)
|
||||
const LangWsTestRoute = LangWsTestRouteImport.update({
|
||||
id: '/ws-test',
|
||||
path: '/ws-test',
|
||||
getParentRoute: () => LangRouteRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/$lang': typeof LangRouteRouteWithChildren
|
||||
'/$lang/ws-test': typeof LangWsTestRoute
|
||||
'/$lang/': typeof LangIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/$lang/ws-test': typeof LangWsTestRoute
|
||||
'/$lang': typeof LangIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/$lang': typeof LangRouteRouteWithChildren
|
||||
'/$lang/ws-test': typeof LangWsTestRoute
|
||||
'/$lang/': typeof LangIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/$lang' | '/$lang/'
|
||||
fullPaths: '/' | '/$lang' | '/$lang/ws-test' | '/$lang/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/$lang'
|
||||
id: '__root__' | '/' | '/$lang' | '/$lang/'
|
||||
to: '/' | '/$lang/ws-test' | '/$lang'
|
||||
id: '__root__' | '/' | '/$lang' | '/$lang/ws-test' | '/$lang/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -80,14 +89,23 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LangIndexRouteImport
|
||||
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 {
|
||||
LangWsTestRoute: typeof LangWsTestRoute
|
||||
LangIndexRoute: typeof LangIndexRoute
|
||||
}
|
||||
|
||||
const LangRouteRouteChildren: LangRouteRouteChildren = {
|
||||
LangWsTestRoute: LangWsTestRoute,
|
||||
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 { 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 interface AuthUser {
|
||||
createTime?: number
|
||||
channelId?: number
|
||||
coin?: string
|
||||
currentStreak?: number
|
||||
email?: string
|
||||
headImage?: string
|
||||
id: string
|
||||
isJackpot?: boolean
|
||||
lastBetPeriodNo?: string
|
||||
name?: string
|
||||
oddsFactor?: number
|
||||
phone?: string
|
||||
registerInviteCode?: string
|
||||
riskFlags?: number
|
||||
roles?: string[]
|
||||
streakLevel?: number
|
||||
username?: string
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
export interface AuthSessionInput {
|
||||
accessToken: string
|
||||
accessTokenExpiresAt?: number | null
|
||||
currentUser?: AuthUser | null
|
||||
refreshToken?: string | null
|
||||
}
|
||||
|
||||
interface PersistedAuthState {
|
||||
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
|
||||
refreshToken: string | null
|
||||
}
|
||||
|
||||
interface PersistedAppPreferenceState {
|
||||
appLanguage: string | null
|
||||
deviceId: string | null
|
||||
}
|
||||
|
||||
interface AuthState extends PersistedAuthState {
|
||||
clearApiAuthToken: () => void
|
||||
clearAccessToken: () => void
|
||||
clearSession: () => void
|
||||
finishHydration: () => void
|
||||
isHydrated: boolean
|
||||
lastUnauthorizedAt: string | null
|
||||
markUnauthorized: () => void
|
||||
setApiAuthToken: (token: {
|
||||
expiresAt: number
|
||||
serverTime: number
|
||||
value: string
|
||||
}) => void
|
||||
setAccessToken: (token: string) => void
|
||||
setCurrentUser: (user: AuthUser | null) => void
|
||||
startSession: (session: AuthSessionInput) => void
|
||||
status: AuthStatus
|
||||
updateTokens: (tokens: {
|
||||
accessToken: string
|
||||
accessTokenExpiresAt?: number | null
|
||||
refreshToken?: string | null
|
||||
}) => void
|
||||
}
|
||||
@@ -47,10 +82,25 @@ function resolveAuthStatus(accessToken: string | null): AuthStatus {
|
||||
|
||||
const initialPersistedState: PersistedAuthState = {
|
||||
accessToken: null,
|
||||
accessTokenExpiresAt: null,
|
||||
apiAuthServerTime: null,
|
||||
apiAuthToken: null,
|
||||
apiAuthTokenExpiresAt: null,
|
||||
refreshToken: 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>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
@@ -58,9 +108,24 @@ export const useAuthStore = create<AuthState>()(
|
||||
status: 'restoring',
|
||||
isHydrated: false,
|
||||
lastUnauthorizedAt: null,
|
||||
setApiAuthToken: ({ expiresAt, serverTime, value }) => {
|
||||
set({
|
||||
apiAuthServerTime: serverTime,
|
||||
apiAuthToken: value,
|
||||
apiAuthTokenExpiresAt: expiresAt,
|
||||
})
|
||||
},
|
||||
clearApiAuthToken: () => {
|
||||
set({
|
||||
apiAuthServerTime: null,
|
||||
apiAuthToken: null,
|
||||
apiAuthTokenExpiresAt: null,
|
||||
})
|
||||
},
|
||||
setAccessToken: (token) => {
|
||||
set({
|
||||
accessToken: token,
|
||||
accessTokenExpiresAt: null,
|
||||
status: 'authenticated',
|
||||
isHydrated: true,
|
||||
})
|
||||
@@ -73,20 +138,24 @@ export const useAuthStore = create<AuthState>()(
|
||||
},
|
||||
startSession: ({
|
||||
accessToken,
|
||||
accessTokenExpiresAt = null,
|
||||
currentUser = null,
|
||||
refreshToken = null,
|
||||
}) => {
|
||||
set({
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
currentUser,
|
||||
refreshToken,
|
||||
status: 'authenticated',
|
||||
isHydrated: true,
|
||||
})
|
||||
},
|
||||
updateTokens: ({ accessToken, refreshToken }) => {
|
||||
updateTokens: ({ accessToken, accessTokenExpiresAt, refreshToken }) => {
|
||||
set((state) => ({
|
||||
accessToken,
|
||||
accessTokenExpiresAt:
|
||||
accessTokenExpiresAt ?? state.accessTokenExpiresAt,
|
||||
refreshToken: refreshToken ?? state.refreshToken,
|
||||
status: 'authenticated',
|
||||
isHydrated: true,
|
||||
@@ -101,6 +170,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
clearAccessToken: () => {
|
||||
set({
|
||||
accessToken: null,
|
||||
accessTokenExpiresAt: null,
|
||||
status: 'anonymous',
|
||||
isHydrated: true,
|
||||
})
|
||||
@@ -126,6 +196,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
partialize: (state) => ({
|
||||
accessToken: state.accessToken,
|
||||
accessTokenExpiresAt: state.accessTokenExpiresAt,
|
||||
apiAuthServerTime: state.apiAuthServerTime,
|
||||
apiAuthToken: state.apiAuthToken,
|
||||
apiAuthTokenExpiresAt: state.apiAuthTokenExpiresAt,
|
||||
currentUser: state.currentUser,
|
||||
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<
|
||||
GameBootstrapSnapshot,
|
||||
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
|
||||
| 'cells'
|
||||
| 'chips'
|
||||
| 'history'
|
||||
| 'maxSelectionCount'
|
||||
| 'round'
|
||||
| 'selections'
|
||||
| 'trends'
|
||||
>
|
||||
|
||||
export interface GameRoundStoreState extends GameRoundSlice {
|
||||
@@ -47,6 +53,7 @@ function createInitialRoundState(): GameRoundSlice & { activeChipId: string } {
|
||||
cells: snapshot.cells,
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
maxSelectionCount: snapshot.maxSelectionCount,
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
trends: snapshot.trends,
|
||||
@@ -68,6 +75,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
cells: snapshot.cells,
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
maxSelectionCount: snapshot.maxSelectionCount,
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
trends: snapshot.trends,
|
||||
@@ -79,8 +87,19 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
getChipById(state.chips, state.activeChipId) ??
|
||||
state.chips.find((chip) => chip.isDefault) ??
|
||||
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
|
||||
}
|
||||
|
||||
@@ -165,7 +184,13 @@ export const selectSelectionsByCell = (state: GameRoundStoreState) =>
|
||||
export type GameRoundStore = typeof useGameRoundStore
|
||||
export type GameRoundStoreData = Pick<
|
||||
GameRoundStoreState,
|
||||
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
|
||||
| 'cells'
|
||||
| 'chips'
|
||||
| 'history'
|
||||
| 'maxSelectionCount'
|
||||
| 'round'
|
||||
| 'selections'
|
||||
| 'trends'
|
||||
>
|
||||
|
||||
export type { BetSelection, GameCell, HistoryEntry, RoundSnapshot, TrendEntry }
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface GameSessionStoreState extends GameSessionSlice {
|
||||
dismissAnnouncement: (announcementId: string) => void
|
||||
hydrateSession: (snapshot: GameSessionSlice) => void
|
||||
markAnnouncementRead: (announcementId: string) => void
|
||||
requestRealtimeConnection: () => void
|
||||
resetRealtimeConnectionRequest: () => void
|
||||
shouldConnectRealtime: boolean
|
||||
setConnectionLatency: (latencyMs: number | null) => void
|
||||
setConnectionStatus: (status: ConnectionStatus) => void
|
||||
syncConnection: (patch: Partial<ConnectionState>) => void
|
||||
@@ -40,6 +43,7 @@ function createInitialSessionState(): GameSessionSlice {
|
||||
|
||||
export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
|
||||
...createInitialSessionState(),
|
||||
shouldConnectRealtime: false,
|
||||
dismissAnnouncement: (announcementId) => {
|
||||
set((state) => ({
|
||||
announcements: {
|
||||
@@ -57,7 +61,10 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
|
||||
}))
|
||||
},
|
||||
hydrateSession: (snapshot) => {
|
||||
set(snapshot)
|
||||
set((state) => ({
|
||||
...snapshot,
|
||||
shouldConnectRealtime: state.shouldConnectRealtime,
|
||||
}))
|
||||
},
|
||||
markAnnouncementRead: (announcementId) => {
|
||||
set((state) => ({
|
||||
@@ -69,6 +76,12 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
|
||||
},
|
||||
}))
|
||||
},
|
||||
requestRealtimeConnection: () => {
|
||||
set({ shouldConnectRealtime: true })
|
||||
},
|
||||
resetRealtimeConnectionRequest: () => {
|
||||
set({ shouldConnectRealtime: false })
|
||||
},
|
||||
setConnectionLatency: (latencyMs) => {
|
||||
set((state) => ({
|
||||
connection: {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './auth'
|
||||
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;
|
||||
padding: calc(var(--design-unit) * 8) calc(var(--design-unit) * 10);
|
||||
box-shadow: inset 0 0 8px rgba(128, 223, 231, 0.65);
|
||||
color: #d5fbff;
|
||||
}
|
||||
|
||||
.common-neon-inset-glow {
|
||||
@@ -346,6 +347,173 @@
|
||||
height: 0;
|
||||
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 {
|
||||
|
||||
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 {
|
||||
readonly VITE_APP_ENV: 'development' | 'production' | 'test'
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_WEBSOCKET_URL?: string
|
||||
readonly VITE_ENABLE_QUERY_DEVTOOLS: 'true' | 'false'
|
||||
readonly VITE_ENABLE_REQUEST_LOG: 'true' | 'false'
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ export default defineConfig({
|
||||
port: 9999,
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['darlena-nonexpiring-cathie.ngrok-free.dev'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://zihua-api.h55555game.top',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
tanstackRouter({
|
||||
|
||||