diff --git a/.env.development b/.env.development
index 49980cb..7fe86fb 100644
--- a/.env.development
+++ b/.env.development
@@ -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
diff --git a/.env.example b/.env.example
index 49980cb..44fef08 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/.env.production b/.env.production
index 32fb8d6..52b64b0 100644
--- a/.env.production
+++ b/.env.production
@@ -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
diff --git a/package.json b/package.json
index b9f4788..6ffcc25 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 30f73a8..c9374d7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: {}
diff --git a/src/assets/game/chip1.webp b/src/assets/game/chip1.webp
index 081eee4..9d6e83b 100644
Binary files a/src/assets/game/chip1.webp and b/src/assets/game/chip1.webp differ
diff --git a/src/assets/game/chip2.webp b/src/assets/game/chip2.webp
index e87df1e..40fc046 100644
Binary files a/src/assets/game/chip2.webp and b/src/assets/game/chip2.webp differ
diff --git a/src/assets/game/chip3.webp b/src/assets/game/chip3.webp
index c19478c..a0763a2 100644
Binary files a/src/assets/game/chip3.webp and b/src/assets/game/chip3.webp differ
diff --git a/src/assets/game/chip4.webp b/src/assets/game/chip4.webp
index 9967c80..e7953c2 100644
Binary files a/src/assets/game/chip4.webp and b/src/assets/game/chip4.webp differ
diff --git a/src/assets/game/chip5.webp b/src/assets/game/chip5.webp
index d07f254..fd21a56 100644
Binary files a/src/assets/game/chip5.webp and b/src/assets/game/chip5.webp differ
diff --git a/src/assets/game/chip6.webp b/src/assets/game/chip6.webp
index 444456c..aa773b0 100644
Binary files a/src/assets/game/chip6.webp and b/src/assets/game/chip6.webp differ
diff --git a/src/components/center-modal.tsx b/src/components/center-modal.tsx
index 83e0f97..18baf87 100644
--- a/src/components/center-modal.tsx
+++ b/src/components/center-modal.tsx
@@ -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({
,
+ info: ,
+ loading: (
+
+ ),
+ success: ,
+ warning: ,
+} 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 (
+
+ {toasts.map((toast) => (
+
+
+ {TOAST_ICON_BY_TYPE[toast.type]}
+
+
+
+
{toast.message}
+ {toast.description ? (
+
{toast.description}
+ ) : null}
+
+
+
notify.dismiss(toast.id)}
+ className="game-toast-close"
+ >
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/constants/index.ts b/src/constants/index.ts
index e9a32e5..f8c3c62 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -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,
+ },
]
diff --git a/src/features/auth/api/auth-api.ts b/src/features/auth/api/auth-api.ts
new file mode 100644
index 0000000..288dc54
--- /dev/null
+++ b/src/features/auth/api/auth-api.ts
@@ -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(
+ response: ApiResponse,
+ 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(AUTH_ENDPOINTS.profile, {
+ headers: {
+ Authorization: `Bearer ${userToken}`,
+ 'user-token': userToken,
+ },
+ })
+
+ return normalizeAuthUserProfile(
+ unwrapEnvelope(
+ response as ApiResponse,
+ '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 {
+ const response = await api.post(
+ AUTH_ENDPOINTS.login,
+ {
+ json: {
+ device_id: getAuthDeviceId(),
+ password: payload.password,
+ username: payload.username,
+ },
+ },
+ )
+
+ const session = await buildEnrichedAuthSession(
+ unwrapEnvelope(
+ response as ApiResponse,
+ 'auth.login.errors.submitFailed',
+ ),
+ )
+
+ logAuthSessionExpiry('login', session)
+
+ return session
+}
+
+export async function registerWithPassword(
+ payload: RegisterPayload,
+): Promise {
+ const response = await api.post(
+ 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,
+ 'auth.register.errors.submitFailed',
+ ),
+ )
+
+ logAuthSessionExpiry('register', session)
+
+ return session
+}
+
+export async function getCurrentUserProfile() {
+ const response = await api.post(AUTH_ENDPOINTS.profile)
+
+ return normalizeAuthUserProfile(
+ unwrapEnvelope(
+ response as ApiResponse,
+ 'auth.errors.requestFailed',
+ ),
+ )
+}
+
+export async function refreshAuthSession(
+ refreshToken: string,
+): Promise {
+ const response = await api.post(
+ AUTH_ENDPOINTS.refreshToken,
+ {
+ context: {
+ skipAuthRefresh: true,
+ },
+ json: {
+ refresh_token: refreshToken,
+ },
+ },
+ )
+
+ const session = normalizeRefreshAuthSession(
+ unwrapEnvelope(
+ response as ApiResponse,
+ 'auth.errors.requestFailed',
+ ),
+ )
+
+ logAuthSessionExpiry('refresh', session)
+
+ return session
+}
diff --git a/src/features/auth/api/types.ts b/src/features/auth/api/types.ts
new file mode 100644
index 0000000..e7a13ec
--- /dev/null
+++ b/src/features/auth/api/types.ts
@@ -0,0 +1,140 @@
+import type { AuthSessionInput, AuthUser } from '@/store/auth'
+
+export interface AuthApiEnvelope {
+ 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,
+ }
+}
diff --git a/src/features/auth/components/desktop-auth-form-parts.tsx b/src/features/auth/components/desktop-auth-form-parts.tsx
new file mode 100644
index 0000000..e309399
--- /dev/null
+++ b/src/features/auth/components/desktop-auth-form-parts.tsx
@@ -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 (
+
+
+
+ {label}
+
+
{children}
+
+
+ )
+}
+
+export function DesktopAuthInputError({ message }: { message?: string }) {
+ if (!message) {
+ return null
+ }
+
+ return (
+ {message}
+ )
+}
+
+export function DesktopAuthFooterLinks({
+ primaryLabel,
+ secondaryLabel,
+}: {
+ primaryLabel: string
+ secondaryLabel: string
+}) {
+ const { t } = useTranslation()
+
+ return (
+
+ {[primaryLabel, secondaryLabel].map((label) => (
+
+ ))}
+
+ )
+}
+
+export function DesktopAuthSubmitError({
+ message,
+}: {
+ message?: string | null
+}) {
+ if (!message) {
+ return null
+ }
+
+ return (
+
+ {message}
+
+ )
+}
diff --git a/src/features/auth/components/desktop-login-form-view.tsx b/src/features/auth/components/desktop-login-form-view.tsx
new file mode 100644
index 0000000..890d366
--- /dev/null
+++ b/src/features/auth/components/desktop-login-form-view.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/features/auth/components/desktop-login-form.tsx b/src/features/auth/components/desktop-login-form.tsx
new file mode 100644
index 0000000..4c41b6a
--- /dev/null
+++ b/src/features/auth/components/desktop-login-form.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/features/auth/components/desktop-register-form-view.tsx b/src/features/auth/components/desktop-register-form-view.tsx
new file mode 100644
index 0000000..d00091b
--- /dev/null
+++ b/src/features/auth/components/desktop-register-form-view.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/features/auth/components/desktop-register-form.tsx b/src/features/auth/components/desktop-register-form.tsx
new file mode 100644
index 0000000..4917574
--- /dev/null
+++ b/src/features/auth/components/desktop-register-form.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/features/auth/hooks/auth-error-key.ts b/src/features/auth/hooks/auth-error-key.ts
new file mode 100644
index 0000000..d0d8a44
--- /dev/null
+++ b/src/features/auth/hooks/auth-error-key.ts
@@ -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
+}
diff --git a/src/features/auth/hooks/use-auth.ts b/src/features/auth/hooks/use-auth.ts
new file mode 100644
index 0000000..b774590
--- /dev/null
+++ b/src/features/auth/hooks/use-auth.ts
@@ -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 }
+}
diff --git a/src/features/auth/hooks/use-login-form.ts b/src/features/auth/hooks/use-login-form.ts
new file mode 100644
index 0000000..a36ff2d
--- /dev/null
+++ b/src/features/auth/hooks/use-login-form.ts
@@ -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({
+ 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'),
+ }
+}
diff --git a/src/features/auth/hooks/use-register-form.ts b/src/features/auth/hooks/use-register-form.ts
new file mode 100644
index 0000000..ffc337a
--- /dev/null
+++ b/src/features/auth/hooks/use-register-form.ts
@@ -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({
+ 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'),
+ }
+}
diff --git a/src/features/auth/hooks/zod-form-resolver.ts b/src/features/auth/hooks/zod-form-resolver.ts
new file mode 100644
index 0000000..f9c9776
--- /dev/null
+++ b/src/features/auth/hooks/zod-form-resolver.ts
@@ -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,
+ 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(
+ schema: ZodType,
+): Resolver {
+ return async (values): Promise> => {
+ const result = await schema.safeParseAsync(values)
+
+ if (result.success) {
+ return {
+ errors: {},
+ values: result.data,
+ } satisfies ResolverResult
+ }
+
+ 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
+ }
+}
diff --git a/src/features/auth/schema/auth-schema.ts b/src/features/auth/schema/auth-schema.ts
new file mode 100644
index 0000000..baf136f
--- /dev/null
+++ b/src/features/auth/schema/auth-schema.ts
@@ -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
+export type RegisterFormValues = z.infer
diff --git a/src/features/game/api/game-api.ts b/src/features/game/api/game-api.ts
index 8cf4e6f..42e0bb1 100644
--- a/src/features/game/api/game-api.ts
+++ b/src/features/game/api/game-api.ts
@@ -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(
+ response: ApiResponse,
+ 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,
+ 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 | 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(GAME_API_ENDPOINTS.bootstrap)
+ const dto = unwrapGameEnvelope(
+ response as ApiResponse,
+ 'Failed to load game bootstrap',
+ )
- return normalizeGameBootstrap(response.data)
+ return normalizeGameBootstrap(dto)
}
export async function getGameRoundFeed() {
const response = await api.get(GAME_API_ENDPOINTS.roundFeed)
+ const dto = unwrapGameEnvelope(
+ response as ApiResponse,
+ 'Failed to load game round feed',
+ )
- return normalizeGameRoundFeed(response.data)
+ return normalizeGameRoundFeed(dto)
}
export async function getGameAnnouncements() {
const response = await api.get(
GAME_API_ENDPOINTS.announcements,
)
+ const dto = unwrapGameEnvelope(
+ response as ApiResponse,
+ 'Failed to load game announcements',
+ )
- return normalizeAnnouncementState(response.data.announcements)
+ return normalizeAnnouncementState(dto.announcements)
+}
+
+export async function getGameLobbyInit() {
+ const response = await api.post(
+ GAME_API_ENDPOINTS.lobbyInit,
+ )
+ const dto = unwrapGameEnvelope(
+ response as ApiResponse,
+ '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(GAME_API_ENDPOINTS.noticeList, {
+ searchParams: {
+ page: String(params?.page ?? 1),
+ page_size: String(params?.pageSize ?? 20),
+ },
+ })
+ const dto = unwrapGameEnvelope(
+ response as ApiResponse,
+ 'Failed to load notice list',
+ )
+
+ return dto
+}
+
+export async function getNoticeDetail(id: number) {
+ const response = await api.get(
+ GAME_API_ENDPOINTS.noticeDetail,
+ {
+ searchParams: {
+ id: String(id),
+ },
+ },
+ )
+ const dto = unwrapGameEnvelope(
+ response as ApiResponse,
+ 'Failed to load notice detail',
+ )
+
+ return dto
+}
+
+export async function confirmNotice(noticeId: number) {
+ const response = await api.get(
+ GAME_API_ENDPOINTS.noticeConfirm,
+ {
+ searchParams: {
+ notice_id: String(noticeId),
+ },
+ },
+ )
+ const dto = unwrapGameEnvelope(
+ response as ApiResponse,
+ 'Failed to confirm notice',
+ )
+
+ return dto
+}
+
+export async function getGameBetMyOrders(params: {
+ page?: number
+ pageSize?: number
+}) {
+ const response = await api.post(
+ GAME_API_ENDPOINTS.betMyOrders,
+ {
+ json: {
+ page: params.page ?? 1,
+ page_size: params.pageSize ?? 20,
+ },
+ },
+ )
+ const dto = unwrapGameEnvelope(
+ response as ApiResponse,
+ 'Failed to load bet orders',
+ )
+
+ return dto
}
export async function getMockGameBootstrap(latencyMs = 120) {
diff --git a/src/features/game/api/types.ts b/src/features/game/api/types.ts
index 270299f..831e787 100644
--- a/src/features/game/api/types.ts
+++ b/src/features/game/api/types.ts
@@ -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
+ 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,
diff --git a/src/features/game/components/desktop/desktop-animal.tsx b/src/features/game/components/desktop/desktop-animal.tsx
index b2f851b..8bd05ba 100644
--- a/src/features/game/components/desktop/desktop-animal.tsx
+++ b/src/features/game/components/desktop/desktop-animal.tsx
@@ -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,
+) {
+ 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(() =>
+ getNextMarqueeId(null),
+ )
+ const activeChip = useMemo(
+ () => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
+ [activeChipId, chips],
+ )
+ const selectionByCell = useMemo(() => {
+ return selections.reduce>(
+ (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 (
{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 (
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,
)}
>
+
+ {!showStandbyState && !hasPlacedSelection ? (
+
+ ) : null}
+ {hasPlacedSelection ? (
+
+
+
+
+ {selectionMeta.amount}
+
+
+
+ ) : null}
)
})}
+
+ {showStandbyState ? (
+
+
+
+ {isRealtimeConnecting ? '' : t('gameDesktop.animal.tapToEnter')}
+
+
+ {isRealtimeConnecting
+ ? t('gameDesktop.animal.loading')
+ : t('gameDesktop.animal.getStart')}
+
+
+
+ ) : null}
)
}
diff --git a/src/features/game/components/desktop/desktop-control.tsx b/src/features/game/components/desktop/desktop-control.tsx
index 44a7437..e7bdd3b 100644
--- a/src/features/game/components/desktop/desktop-control.tsx
+++ b/src/features/game/components/desktop/desktop-control.tsx
@@ -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(null)
const [hidingId, setHidingId] = useState(null)
const [confirmClicked, setConfirmClicked] = useState(false)
@@ -74,8 +76,8 @@ export function DesktopControl() {
}
>
-
TREBD
-
MAP
+
{t('gameDesktop.control.trend')}
+
{t('gameDesktop.control.map')}
+
+ {chip.valueLabel}
+
+
+ {chip.valueLabel}
+
+
+ {chip.valueLabel}
+
)
@@ -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'
}
>
- SELECTED:{selectedCountLabel}
- Total Bet:{totalBetAmountLabel}
+
+ {t('gameDesktop.control.selected')}:{' '}
+ {selectedCountLabel} /{' '}
+ {maxSelectionCountLabel}
+
+
+
{t('gameDesktop.control.totalBet')}:
+
+
+
+
{totalBetAmountLabel}
+
+
- {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]'}
/>
- {label}
+ {t(labelKey)}
@@ -351,7 +390,7 @@ export function DesktopControl() {
transition={{ duration: 0.15 }}
className="relative"
>
- confirm
+ {t('gameDesktop.control.confirm')}
diff --git a/src/features/game/components/desktop/desktop-game-history.tsx b/src/features/game/components/desktop/desktop-game-history.tsx
index c59562c..0bb262e 100644
--- a/src/features/game/components/desktop/desktop-game-history.tsx
+++ b/src/features/game/components/desktop/desktop-game-history.tsx
@@ -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(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 (
- History
+ {t('gameDesktop.history.title')}
- {isEmpty ? (
+ {isInitialLoading ? (
+
+ {loadingText}
+
+ ) : isEmpty ? (
) : (
- items.map((item) => {
- return (
-
+
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const item = items[virtualRow.index]
+
+ return (
- {item.statusLabel}
+ {item ? (
+
+
+ {item.statusLabel}
+
+
+
+
+ {t('gameDesktop.history.orderNo')}:{' '}
+
+
+ {item.orderNo}
+
+
+
+
+ {t('gameDesktop.history.roundId')}:{' '}
+
+
+ {item.periodNo}
+
+
+
+
+ {t('gameDesktop.history.numbers')}:{' '}
+
+ {item.numbersLabel}
+
+
+
+ {t('gameDesktop.history.settledAt')}:{' '}
+
+ {item.createdAtLabel}
+
+
+
+ {t('gameDesktop.history.totalPoolAmount')}:{' '}
+
+
+ {item.amountLabel}
+
+
+
+
+ {t('gameDesktop.history.winningResult')}:{' '}
+
+
+ {item.resultNumberLabel}
+
+
+
+
+ {t('gameDesktop.history.payout')}:{' '}
+
+ {item.winAmountLabel}
+
+
+
+ ) : (
+
+ {isFetchingNextPage ? loadingText : endText}
+
+ )}
-
-
- Round ID:
- {item.roundId}
-
-
- Settled At:
- {item.settledAtLabel}
-
-
-
- Total Pool Amount:{' '}
-
-
- {item.totalPoolAmountLabel}
-
-
-
- Winning Result:
-
- {item.winningCellIdLabel}
-
-
-
- Payout:
- {item.payoutMultiplierLabel}
-
-
-
- )
- })
+ )
+ })}
+
)}
diff --git a/src/features/game/components/desktop/desktop-header.tsx b/src/features/game/components/desktop/desktop-header.tsx
index fe02dab..b483b90 100644
--- a/src/features/game/components/desktop/desktop-header.tsx
+++ b/src/features/game/components/desktop/desktop-header.tsx
@@ -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 (
+
+ {barHeights.map((heightClassName, index) => {
+ const isActive = index < activeBars
+
+ return (
+
+ )
+ })}
+
+ )
+}
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
(
+ () => {
+ 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 (