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.message}
+ {toast.description ? ( +
{toast.description}
+ ) : null} +
+ + +
+ ))} +
+ ) +} 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) => ( +
+
+ +
+
{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 ( +
{ + event.preventDefault() + onSubmit() + }} + className={ + 'flex flex-col items-center justify-between gap-design-20 px-design-20' + } + > +
+ + onUsernameChange(event.target.value)} + placeholder={t('auth.login.fields.username.placeholder')} + aria-invalid={Boolean(errors.username)} + className={'h-design-58 text-left'} + /> + + + + + onPasswordChange(event.target.value)} + placeholder={t('auth.login.fields.password.placeholder')} + aria-invalid={Boolean(errors.password)} + className={'h-design-58 text-left'} + /> + + + + + +
+ + + {isSubmitting + ? t('auth.common.actions.submitting') + : t('auth.login.actions.submit')} + +
+ ) +} 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 ( +
{ + event.preventDefault() + onSubmit() + }} + className={'flex flex-col items-center justify-between px-design-20'} + > +
+ + onUsernameChange(event.target.value)} + placeholder={t('auth.register.fields.username.placeholder')} + aria-invalid={Boolean(errors.username)} + className={'h-design-58 text-left'} + /> + + + + + onPasswordChange(event.target.value)} + placeholder={t('auth.register.fields.password.placeholder')} + aria-invalid={Boolean(errors.password)} + className={'h-design-58 text-left'} + /> + + + + + onConfirmPasswordChange(event.target.value)} + placeholder={t('auth.register.fields.confirmPassword.placeholder')} + aria-invalid={Boolean(errors.confirmPassword)} + className={'h-design-58 text-left'} + /> + + + + + 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'} + /> + + + + + +
+ + + {isSubmitting + ? t('auth.common.actions.submitting') + : t('auth.register.actions.submit')} + +
+ ) +} 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 ( ) })} + + {showStandbyState ? ( + + ) : 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 ( + diff --git a/src/features/game/modal/desktop/desktop-procedures-modal.tsx b/src/features/game/modal/desktop/desktop-procedures-modal.tsx index 10a47e1..6dc49a8 100644 --- a/src/features/game/modal/desktop/desktop-procedures-modal.tsx +++ b/src/features/game/modal/desktop/desktop-procedures-modal.tsx @@ -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={
- Biomond Balance + {t('game.modals.procedures.title')}
} isNormalBg={true} @@ -33,23 +45,27 @@ function DesktopProceduresModal() { 'h-[95%] w-full rounded-md flex flex-col items-center justify-between' } > -
111
+
+ {t('game.modals.procedures.contentPlaceholder')} +
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')} 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')}
diff --git a/src/features/game/modal/desktop/desktop-register-modal.tsx b/src/features/game/modal/desktop/desktop-register-modal.tsx index 78e9b7d..260f1dd 100644 --- a/src/features/game/modal/desktop/desktop-register-modal.tsx +++ b/src/features/game/modal/desktop/desktop-register-modal.tsx @@ -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 ( {}} - title={
注册
} + onClose={() => setModalOpen('desktopRegister', false)} + title={ +
+ {t('game.modals.register.title')} +
+ } titleAlign="center" className={'w-design-980 h-design-740'} > -
-
-
-
- Akun/TEL: -
- -
-
-
- Kata Sandi: -
- -
-
-
- Kata Sandi: -
- -
-
-
- Kata Sandi: -
- -
-
-
-
- -
-
Daftar Akun
-
-
-
- -
-
Ingat Kata Sandi
-
-
-
- - - MASUK - -
+
) } diff --git a/src/features/game/modal/desktop/desktop-userInfo-modal.tsx b/src/features/game/modal/desktop/desktop-userInfo-modal.tsx index ec1b0f0..33ffc77 100644 --- a/src/features/game/modal/desktop/desktop-userInfo-modal.tsx +++ b/src/features/game/modal/desktop/desktop-userInfo-modal.tsx @@ -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('profile') function handleSubmit() { - setOpen(false) + setModalOpen('desktopUserInfo', false) } return ( @@ -41,7 +45,9 @@ function DesktopUserInfoModal() { open={open} onClose={handleSubmit} title={ -
Biomond Balance
+
+ {t('game.modals.userInfo.title')} +
} isNormalBg={true} titleAlign="left" @@ -96,7 +102,7 @@ function DesktopUserInfoModal() { isActive && 'modal-title-gold-glow', )} > - {tab.label} + {t(tab.labelKey)}
) @@ -119,8 +125,12 @@ function DesktopUserInfoModal() { alt={'avatar'} />
-
NAMA :Biomond Balance
-
TEL :12345678901
+
+ {t('game.modals.userInfo.profile.name')} :Biomond Balance +
+
+ {t('game.modals.userInfo.profile.tel')} :12345678901 +
@@ -128,7 +138,7 @@ function DesktopUserInfoModal() {
{[1, 2, 3, 4].map((item) => (
- Tanggal Pendaftaran : + {t('game.modals.userInfo.profile.registeredAt')} : 2022-10-06 23:36 @@ -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')}
@@ -166,10 +175,7 @@ function DesktopUserInfoModal() {
2026-10-10 08:32:56
-
- [Event Bonus Isi Ulang] Dari tanggal 1 hingga 7 Oktober - 2026, dapatkan pengembalian ... -
+
{t('game.modals.userInfo.message.eventBonus')}
- Memeriksa + {t('game.modals.userInfo.message.check')} ))} @@ -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')} diff --git a/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx b/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx index 1af504e..9dcc161 100644 --- a/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx +++ b/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx @@ -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('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={
- {type === 'withdraw' ? '申请提现' : '申请充值'} + {type === 'withdraw' + ? t('game.modals.withdrawTopup.applyWithdraw') + : t('game.modals.withdrawTopup.applyTopup')}
} isNormalBg={true} diff --git a/src/features/game/shared/constants.ts b/src/features/game/shared/constants.ts index d2ad1fc..a549321 100644 --- a/src/features/game/shared/constants.ts +++ b/src/features/game/shared/constants.ts @@ -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 diff --git a/src/features/game/shared/mock-data.ts b/src/features/game/shared/mock-data.ts index f396036..f3052de 100644 --- a/src/features/game/shared/mock-data.ts +++ b/src/features/game/shared/mock-data.ts @@ -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 } diff --git a/src/features/game/shared/types.ts b/src/features/game/shared/types.ts index 7a1f815..260d4ad 100644 --- a/src/features/game/shared/types.ts +++ b/src/features/game/shared/types.ts @@ -112,6 +112,7 @@ export interface GameBootstrapSnapshot { connection: ConnectionState dashboard: DashboardState history: HistoryEntry[] + maxSelectionCount: number round: RoundSnapshot selections: BetSelection[] trends: TrendEntry[] diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 61e0cb6..c304b9c 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -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) } } diff --git a/src/lib/api/api-client.ts b/src/lib/api/api-client.ts index 13366c6..6d8cabb 100644 --- a/src/lib/api/api-client.ts +++ b/src/lib/api/api-client.ts @@ -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 type JsonRequestOptions = RequestOptions & { @@ -20,7 +21,12 @@ type JsonRequestOptions = 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(response: ApiResponse) { + 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>() + + 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(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(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 ) { diff --git a/src/lib/api/api-error.ts b/src/lib/api/api-error.ts index e2fddd6..e4b4273 100644 --- a/src/lib/api/api-error.ts +++ b/src/lib/api/api-error.ts @@ -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 diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts deleted file mode 100644 index b2ead25..0000000 --- a/src/lib/api/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** @description 后端统一响应体结构。 */ -export interface ApiResponse { - code: number - msg: string - data: T -} diff --git a/src/lib/auth/auth-session.ts b/src/lib/auth/auth-session.ts index 735c722..3cadaef 100644 --- a/src/lib/auth/auth-session.ts +++ b/src/lib/auth/auth-session.ts @@ -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, }) diff --git a/src/lib/crypto/md5.ts b/src/lib/crypto/md5.ts new file mode 100644 index 0000000..f045f5d --- /dev/null +++ b/src/lib/crypto/md5.ts @@ -0,0 +1,5 @@ +import md5Hash from 'md5' + +export function md5(value: string) { + return md5Hash(value) +} diff --git a/src/lib/notify.ts b/src/lib/notify.ts new file mode 100644 index 0000000..e490f1f --- /dev/null +++ b/src/lib/notify.ts @@ -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() + +export const useNotificationStore = create()((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) + }, +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d32b0fe..c4cddf1 100644 --- a/src/lib/utils.ts +++ b/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 + msRequestFullscreen?: () => Promise | void + webkitRequestFullscreen?: () => Promise | void +} + +type FullscreenCapableDocument = Document & { + mozCancelFullScreen?: () => Promise | void + mozFullScreenElement?: Element | null + msExitFullscreen?: () => Promise | void + msFullscreenElement?: Element | null + webkitExitFullscreen?: () => Promise | 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) +} diff --git a/src/lib/ws/game-socket-client.ts b/src/lib/ws/game-socket-client.ts new file mode 100644 index 0000000..2390013 --- /dev/null +++ b/src/lib/ws/game-socket-client.ts @@ -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) + +type GameSocketStatus = + | 'idle' + | 'connecting' + | 'connected' + | 'reconnecting' + | 'disconnected' + +type GameSocketClientOptions = { + getContext: () => Promise + 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() + + 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) { + 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) + } +} diff --git a/src/locales/en-US/common.ts b/src/locales/en-US/common.ts index 22e7a94..106c2a1 100644 --- a/src/locales/en-US/common.ts +++ b/src/locales/en-US/common.ts @@ -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 diff --git a/src/locales/id-ID/common.ts b/src/locales/id-ID/common.ts new file mode 100644 index 0000000..c0b8824 --- /dev/null +++ b/src/locales/id-ID/common.ts @@ -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 diff --git a/src/locales/ms-MY/common.ts b/src/locales/ms-MY/common.ts new file mode 100644 index 0000000..947d628 --- /dev/null +++ b/src/locales/ms-MY/common.ts @@ -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 diff --git a/src/locales/zh-CN/common.ts b/src/locales/zh-CN/common.ts index 657dacc..c5eb2da 100644 --- a/src/locales/zh-CN/common.ts +++ b/src/locales/zh-CN/common.ts @@ -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 diff --git a/src/main.tsx b/src/main.tsx index 3e6a43c..72839f9 100644 --- a/src/main.tsx +++ b/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( + {shouldShowQueryDevtools && } , diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ec7bbd6..5cd285c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, } diff --git a/src/routes/$lang/ws-test.tsx b/src/routes/$lang/ws-test.tsx new file mode 100644 index 0000000..cb2173a --- /dev/null +++ b/src/routes/$lang/ws-test.tsx @@ -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([]) + 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 ( +
+

WS Test

+
+
Status
+
{status}
+
+
+
URL
+
+ {TEST_WS_URL} +
+
+
+
Topics
+
+ {TEST_TOPICS.join(', ')} +
+
+
+
Logs
+
+ {logs.length === 0 ? ( +
No logs yet
+ ) : ( + logs.map((log) => ( +
+ [{log.at}] {log.message} +
+ )) + )} +
+
+
+ ) +} diff --git a/src/store/auth/auth-store.ts b/src/store/auth/auth-store.ts index 6f34b31..ee3ef42 100644 --- a/src/store/auth/auth-store.ts +++ b/src/store/auth/auth-store.ts @@ -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()( persist( (set) => ({ @@ -58,9 +108,24 @@ export const useAuthStore = create()( 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()( }, 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()( clearAccessToken: () => { set({ accessToken: null, + accessTokenExpiresAt: null, status: 'anonymous', isHydrated: true, }) @@ -126,6 +196,10 @@ export const useAuthStore = create()( 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()( }, ), ) + +interface AppPreferenceStoreState extends PersistedAppPreferenceState { + getOrCreateDeviceId: () => string + setAppLanguage: (language: string) => void +} + +export const useAppPreferenceStore = create()( + 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) +} diff --git a/src/store/game/game-round-store.ts b/src/store/game/game-round-store.ts index 214341e..d339df6 100644 --- a/src/store/game/game-round-store.ts +++ b/src/store/game/game-round-store.ts @@ -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()((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()((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 } diff --git a/src/store/game/game-session-store.ts b/src/store/game/game-session-store.ts index 1b27495..844c4c3 100644 --- a/src/store/game/game-session-store.ts +++ b/src/store/game/game-session-store.ts @@ -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) => void @@ -40,6 +43,7 @@ function createInitialSessionState(): GameSessionSlice { export const useGameSessionStore = create()((set) => ({ ...createInitialSessionState(), + shouldConnectRealtime: false, dismissAnnouncement: (announcementId) => { set((state) => ({ announcements: { @@ -57,7 +61,10 @@ export const useGameSessionStore = create()((set) => ({ })) }, hydrateSession: (snapshot) => { - set(snapshot) + set((state) => ({ + ...snapshot, + shouldConnectRealtime: state.shouldConnectRealtime, + })) }, markAnnouncementRead: (announcementId) => { set((state) => ({ @@ -69,6 +76,12 @@ export const useGameSessionStore = create()((set) => ({ }, })) }, + requestRealtimeConnection: () => { + set({ shouldConnectRealtime: true }) + }, + resetRealtimeConnectionRequest: () => { + set({ shouldConnectRealtime: false }) + }, setConnectionLatency: (latencyMs) => { set((state) => ({ connection: { diff --git a/src/store/index.ts b/src/store/index.ts index 6f9fed7..1a1232f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,2 +1,3 @@ export * from './auth' export * from './game' +export * from './modal' diff --git a/src/store/modal/index.ts b/src/store/modal/index.ts new file mode 100644 index 0000000..1a4ebe2 --- /dev/null +++ b/src/store/modal/index.ts @@ -0,0 +1 @@ +export * from './modal-store' diff --git a/src/store/modal/modal-store.ts b/src/store/modal/modal-store.ts new file mode 100644 index 0000000..94346ef --- /dev/null +++ b/src/store/modal/modal-store.ts @@ -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 + +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()((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 }) + }, +})) diff --git a/src/style/index.css b/src/style/index.css index 470ebe0..e567c18 100644 --- a/src/style/index.css +++ b/src/style/index.css @@ -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 { diff --git a/src/type/index.ts b/src/type/index.ts new file mode 100644 index 0000000..8e695bf --- /dev/null +++ b/src/type/index.ts @@ -0,0 +1,17 @@ +export type WithdrawTopupType = 'withdraw' | 'topup' + +/** @description 后端统一响应体结构。 */ +export interface ApiResponse { + code: number + msg?: string + data: T + message?: string +} + +/** @description 后端统一错误响应体结构。 */ +export interface ApiErrorOptions { + message: string + status?: number + data?: unknown + url?: string +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 89854c8..e102625 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -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' } diff --git a/vite.config.ts b/vite.config.ts index 61bc0b8..2b05666 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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({