feat(auth): 集成认证授权功能并优化API客户端

- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取
- 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理
- 添加了WebSocket连接配置和API基础URL环境变量设置
- 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑
- 集成了MD5加密和认证令牌缓存机制,提升安全性
- 添加了多语言国际化支持,包括英语、中文、马来语和印尼语
- 实现了认证状态管理和本地存储持久化功能
- 添加了表单验证schema和错误处理机制,增强用户体验
This commit is contained in:
JiaJun
2026-05-16 09:03:55 +08:00
parent 6aaf90a6ac
commit 5dd4e31db4
81 changed files with 6086 additions and 627 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,5 +1,6 @@
import { type ReactNode, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import modalBg from '@/assets/system/modal-bg.webp'
import modalClose from '@/assets/system/modal-close.webp'
import modalNormalBg from '@/assets/system/modal-normal-bg.png'
@@ -29,6 +30,7 @@ export function CenterModal({
children,
className,
}: CenterModalProps) {
const { t } = useTranslation()
const handleClose = () => {
onClose?.()
}
@@ -63,7 +65,11 @@ export function CenterModal({
<SmartBackground
role="dialog"
aria-modal="true"
aria-label={typeof title === 'string' ? title : 'Modal'}
aria-label={
typeof title === 'string'
? title
: t('commonUi.modal.defaultAriaLabel')
}
className={cn(
'relative flex h-design-640 w-design-720 flex-col overflow-hidden rounded-[calc(var(--design-unit)*28)] px-design-20 text-white',
className,
@@ -93,7 +99,7 @@ export function CenterModal({
{isShowClose && onClose ? (
<button
type="button"
aria-label="Close modal"
aria-label={t('commonUi.modal.close')}
onClick={handleClose}
className={cn(
'absolute top-1/2 inline-flex h-design-60 w-design-60 -translate-y-1/2 items-center justify-center rounded-full transition hover:scale-105 active:scale-95',

View File

@@ -1,4 +1,8 @@
import type { AppLanguage } from '@/i18n'
import { type AppLanguage, supportedLanguages } from '@/i18n'
const languagePrefixPattern = new RegExp(
`^/(${supportedLanguages.join('|')})(?=/|$)`,
)
interface LanguageLinkProps {
currentPathname: string
@@ -14,7 +18,7 @@ export function LanguageLink({
language,
}: LanguageLinkProps) {
const nextPathname = currentPathname.replace(
/^\/(zh-CN|en-US)(?=\/|$)/,
languagePrefixPattern,
`/${language}`,
)

View File

@@ -0,0 +1,71 @@
import {
CheckCircle2,
Info,
LoaderCircle,
TriangleAlert,
X,
XCircle,
} from 'lucide-react'
import { notify, useNotificationStore } from '@/lib/notify'
import { cn } from '@/lib/utils'
const TOAST_ICON_BY_TYPE = {
error: <XCircle className="h-4 w-4 shrink-0 text-[#FF8A9E]" />,
info: <Info className="h-4 w-4 shrink-0 text-[#7CE8FF]" />,
loading: (
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-[#7CE8FF]" />
),
success: <CheckCircle2 className="h-4 w-4 shrink-0 text-[#7CF0B8]" />,
warning: <TriangleAlert className="h-4 w-4 shrink-0 text-[#FFD66E]" />,
} as const
const TOAST_TONE_CLASS_BY_TYPE = {
error: 'game-toast-error',
info: 'game-toast-info',
loading: 'game-toast-loading',
success: 'game-toast-success',
warning: 'game-toast-warning',
} as const
export function AppToaster() {
const toasts = useNotificationStore((state) => state.toasts)
return (
<div
aria-atomic="true"
aria-live="polite"
className="game-toaster pointer-events-none fixed top-[calc(var(--design-unit)*88)] left-1/2 z-[9999] flex w-full -translate-x-1/2 flex-col items-center gap-3 px-4 md:top-[calc(var(--design-unit)*88)]"
>
{toasts.map((toast) => (
<div
key={toast.id}
role="status"
className={cn(
'game-toast pointer-events-auto',
TOAST_TONE_CLASS_BY_TYPE[toast.type],
)}
>
<span aria-hidden="true" className="game-toast-icon">
{TOAST_ICON_BY_TYPE[toast.type]}
</span>
<div className="game-toast-content">
<div className="game-toast-title">{toast.message}</div>
{toast.description ? (
<div className="game-toast-description">{toast.description}</div>
) : null}
</div>
<button
type="button"
aria-label="Close notification"
onClick={() => notify.dismiss(toast.id)}
className="game-toast-close"
>
<X className="h-3.5 w-3.5 text-[#D5FBFF]" />
</button>
</div>
))}
</div>
)
}

View File

@@ -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,
},
]

View File

@@ -0,0 +1,179 @@
import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error'
import type { AuthSessionInput } from '@/store/auth'
import { getAuthDeviceId } from '@/store/auth'
import type { ApiResponse } from '@/type'
import type {
AuthSessionDto,
AuthUserProfileDto,
LoginPayload,
LoginRequestDto,
RefreshTokenDto,
RefreshTokenRequestDto,
RegisterPayload,
RegisterRequestDto,
} from './types'
import {
mergeAuthUsers,
normalizeAuthSession,
normalizeAuthUserProfile,
normalizeRefreshAuthSession,
} from './types'
const AUTH_ENDPOINTS = {
login: 'api/user/login',
profile: 'api/user/profile',
refreshToken: 'api/user/refreshToken',
register: 'api/user/register',
} as const
const shouldLogAuthLifecycle =
import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
function unwrapEnvelope<T>(
response: ApiResponse<T>,
fallbackErrorKey = 'auth.errors.requestFailed',
) {
if (response.code === 1) {
return response.data
}
throw new ApiError({
data: response,
message: fallbackErrorKey,
})
}
function logAuthSessionExpiry(action: string, session: AuthSessionInput) {
if (!shouldLogAuthLifecycle || !session.accessTokenExpiresAt) {
return
}
console.info(
`[auth] ${action} user-token expires at ${new Date(
session.accessTokenExpiresAt,
).toISOString()} (${session.accessTokenExpiresAt})`,
)
}
async function getCurrentUserProfileByToken(userToken: string) {
const response = await api.post<AuthUserProfileDto>(AUTH_ENDPOINTS.profile, {
headers: {
Authorization: `Bearer ${userToken}`,
'user-token': userToken,
},
})
return normalizeAuthUserProfile(
unwrapEnvelope(
response as ApiResponse<AuthUserProfileDto>,
'auth.errors.requestFailed',
),
)
}
async function buildEnrichedAuthSession(dto: AuthSessionDto) {
const session = normalizeAuthSession(dto)
try {
const profileUser = await getCurrentUserProfileByToken(session.accessToken)
return {
...session,
currentUser: mergeAuthUsers(session.currentUser, profileUser),
} satisfies AuthSessionInput
} catch {
return session
}
}
export async function loginWithPassword(
payload: LoginPayload,
): Promise<AuthSessionInput> {
const response = await api.post<AuthSessionDto, LoginRequestDto>(
AUTH_ENDPOINTS.login,
{
json: {
device_id: getAuthDeviceId(),
password: payload.password,
username: payload.username,
},
},
)
const session = await buildEnrichedAuthSession(
unwrapEnvelope(
response as ApiResponse<AuthSessionDto>,
'auth.login.errors.submitFailed',
),
)
logAuthSessionExpiry('login', session)
return session
}
export async function registerWithPassword(
payload: RegisterPayload,
): Promise<AuthSessionInput> {
const response = await api.post<AuthSessionDto, RegisterRequestDto>(
AUTH_ENDPOINTS.register,
{
json: {
device_id: getAuthDeviceId(),
invite_code: payload.inviteCode,
password: payload.password,
username: payload.username,
},
},
)
const session = await buildEnrichedAuthSession(
unwrapEnvelope(
response as ApiResponse<AuthSessionDto>,
'auth.register.errors.submitFailed',
),
)
logAuthSessionExpiry('register', session)
return session
}
export async function getCurrentUserProfile() {
const response = await api.post<AuthUserProfileDto>(AUTH_ENDPOINTS.profile)
return normalizeAuthUserProfile(
unwrapEnvelope(
response as ApiResponse<AuthUserProfileDto>,
'auth.errors.requestFailed',
),
)
}
export async function refreshAuthSession(
refreshToken: string,
): Promise<AuthSessionInput | null> {
const response = await api.post<RefreshTokenDto, RefreshTokenRequestDto>(
AUTH_ENDPOINTS.refreshToken,
{
context: {
skipAuthRefresh: true,
},
json: {
refresh_token: refreshToken,
},
},
)
const session = normalizeRefreshAuthSession(
unwrapEnvelope(
response as ApiResponse<RefreshTokenDto>,
'auth.errors.requestFailed',
),
)
logAuthSessionExpiry('refresh', session)
return session
}

View File

@@ -0,0 +1,140 @@
import type { AuthSessionInput, AuthUser } from '@/store/auth'
export interface AuthApiEnvelope<T> {
code: number
data: T
message?: string
msg?: string
}
export interface AuthTokenDto {
auth_token: string
expires_in: number
server_time: number
}
export interface AuthUserDto {
channel_id: number
coin: string
phone?: string
risk_flags: number
username: string
uuid: string
}
export interface AuthSessionDto {
expires_in: number
refresh_token?: string | null
user: AuthUserDto
'user-token': string
}
export interface RefreshTokenDto {
expires_in: number
refresh_token?: string | null
'user-token': string
}
export interface AuthUserProfileDto {
channel_id: number
coin: string
create_time: number
current_streak: number
email: string
head_image: string
last_bet_period_no: string
phone: string
register_invite_code: string
risk_flags: number
username: string
uuid: string
}
export interface LoginRequestDto {
device_id?: string
password: string
username: string
}
export interface RegisterRequestDto extends LoginRequestDto {
invite_code: string
}
export interface RefreshTokenRequestDto {
refresh_token: string
}
export interface LoginPayload {
password: string
username: string
}
export interface RegisterPayload extends LoginPayload {
inviteCode: string
}
export function normalizeAuthUser(dto: AuthUserDto): AuthUser {
return {
channelId: dto.channel_id,
coin: dto.coin,
id: dto.uuid,
name: dto.username,
phone: dto.phone,
riskFlags: dto.risk_flags,
username: dto.username,
uuid: dto.uuid,
}
}
export function normalizeAuthUserProfile(dto: AuthUserProfileDto): AuthUser {
return {
channelId: dto.channel_id,
coin: dto.coin,
createTime: dto.create_time,
currentStreak: dto.current_streak,
email: dto.email,
headImage: dto.head_image,
id: dto.uuid,
lastBetPeriodNo: dto.last_bet_period_no,
name: dto.username,
phone: dto.phone,
registerInviteCode: dto.register_invite_code,
riskFlags: dto.risk_flags,
username: dto.username,
uuid: dto.uuid,
}
}
export function mergeAuthUsers(
baseUser: AuthUser | null | undefined,
profileUser: AuthUser | null | undefined,
): AuthUser | null {
if (!baseUser && !profileUser) {
return null
}
return {
...baseUser,
...profileUser,
id: profileUser?.id ?? baseUser?.id ?? '',
}
}
export function normalizeAuthSession(dto: AuthSessionDto): AuthSessionInput {
return {
accessToken: dto['user-token'],
accessTokenExpiresAt: Date.now() + dto.expires_in * 1000,
currentUser: normalizeAuthUser(dto.user),
refreshToken: dto.refresh_token ?? null,
}
}
export function normalizeRefreshAuthSession(
dto: RefreshTokenDto,
): AuthSessionInput {
return {
accessToken: dto['user-token'],
accessTokenExpiresAt: Date.now() + dto.expires_in * 1000,
refreshToken: dto.refresh_token ?? null,
}
}

View File

@@ -0,0 +1,88 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import rightImg from '@/assets/system/right.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { cn } from '@/lib/utils'
export function DesktopAuthFieldRow({
label,
children,
labelClassName,
}: {
children: ReactNode
label: string
labelClassName?: string
}) {
return (
<div className={'flex flex-col gap-design-10'}>
<div className={'flex items-start gap-design-16'}>
<div
className={cn(
'w-design-180 shrink-0 pt-design-10 text-left !text-design-24 text-[#58ADAF]',
labelClassName,
)}
>
{label}
</div>
<div className={'min-w-0 flex-1'}>{children}</div>
</div>
</div>
)
}
export function DesktopAuthInputError({ message }: { message?: string }) {
if (!message) {
return null
}
return (
<div className={'pt-design-6 text-design-16 text-[#FF6A6A]'}>{message}</div>
)
}
export function DesktopAuthFooterLinks({
primaryLabel,
secondaryLabel,
}: {
primaryLabel: string
secondaryLabel: string
}) {
const { t } = useTranslation()
return (
<div className={'flex items-center justify-around'}>
{[primaryLabel, secondaryLabel].map((label) => (
<div key={label} className={'flex items-center gap-design-10'}>
<div
className={
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
}
>
<SmartImage alt={t('auth.common.arrowIconAlt')} src={rightImg} />
</div>
<div className={'text-[#549195]'}>{label}</div>
</div>
))}
</div>
)
}
export function DesktopAuthSubmitError({
message,
}: {
message?: string | null
}) {
if (!message) {
return null
}
return (
<div
className={cn(
'w-full rounded-md border border-[#B93F44] bg-[rgba(78,17,23,0.35)] px-design-20 py-design-14 text-design-18 text-[#FFD2D2]',
)}
>
{message}
</div>
)
}

View File

@@ -0,0 +1,107 @@
import { motion } from 'motion/react'
import { useTranslation } from 'react-i18next'
import loginBg from '@/assets/system/login-bg.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
import { Input } from '@/components/ui/input.tsx'
import {
DesktopAuthFieldRow,
DesktopAuthFooterLinks,
DesktopAuthInputError,
DesktopAuthSubmitError,
} from './desktop-auth-form-parts'
interface DesktopLoginFormViewProps {
errors: {
password?: string
username?: string
}
isSubmitting: boolean
onPasswordChange: (value: string) => void
onSubmit: () => void
onUsernameChange: (value: string) => void
password: string
submitError?: string | null
username: string
}
export function DesktopLoginFormView({
errors,
isSubmitting,
onPasswordChange,
onSubmit,
onUsernameChange,
password,
submitError,
username,
}: DesktopLoginFormViewProps) {
const { t } = useTranslation()
return (
<form
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
className={
'flex flex-col items-center justify-between gap-design-20 px-design-20'
}
>
<div
className={
'h-design-375 flex flex-col gap-design-30 w-full bg-[#060B0F]/50 p-design-50'
}
>
<DesktopAuthFieldRow label={t('auth.login.fields.username.label')}>
<Input
value={username}
onChange={(event) => onUsernameChange(event.target.value)}
placeholder={t('auth.login.fields.username.placeholder')}
aria-invalid={Boolean(errors.username)}
className={'h-design-58 text-left'}
/>
<DesktopAuthInputError
message={errors.username ? t(errors.username) : undefined}
/>
</DesktopAuthFieldRow>
<DesktopAuthFieldRow label={t('auth.login.fields.password.label')}>
<Input
type="password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
placeholder={t('auth.login.fields.password.placeholder')}
aria-invalid={Boolean(errors.password)}
className={'h-design-58 text-left'}
/>
<DesktopAuthInputError
message={errors.password ? t(errors.password) : undefined}
/>
</DesktopAuthFieldRow>
<DesktopAuthSubmitError
message={submitError ? t(submitError) : undefined}
/>
<DesktopAuthFooterLinks
primaryLabel={t('auth.login.footer.registerAccount')}
secondaryLabel={t('auth.login.footer.forgotPassword')}
/>
</div>
<SmartBackground
as={motion.button}
type="submit"
whileTap={{ scale: 0.95 }}
src={loginBg}
size="100% 100%"
className={
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer disabled:pointer-events-none disabled:opacity-60'
}
disabled={isSubmitting}
>
{isSubmitting
? t('auth.common.actions.submitting')
: t('auth.login.actions.submit')}
</SmartBackground>
</form>
)
}

View File

@@ -0,0 +1,37 @@
import { useController } from 'react-hook-form'
import { useLoginForm } from '../hooks/use-login-form'
import { DesktopLoginFormView } from './desktop-login-form-view'
interface DesktopLoginFormProps {
onSuccess?: () => void
}
export function DesktopLoginForm({ onSuccess }: DesktopLoginFormProps) {
const { form, isSubmitting, onSubmit, submitError } = useLoginForm({
onSuccess,
})
const usernameField = useController({
control: form.control,
name: 'username',
})
const passwordField = useController({
control: form.control,
name: 'password',
})
return (
<DesktopLoginFormView
username={usernameField.field.value ?? ''}
password={passwordField.field.value ?? ''}
errors={{
password: form.formState.errors.password?.message,
username: form.formState.errors.username?.message,
}}
isSubmitting={isSubmitting}
onPasswordChange={passwordField.field.onChange}
onSubmit={onSubmit}
onUsernameChange={usernameField.field.onChange}
submitError={submitError}
/>
)
}

View File

@@ -0,0 +1,149 @@
import { motion } from 'motion/react'
import { useTranslation } from 'react-i18next'
import loginBg from '@/assets/system/login-bg.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
import { Input } from '@/components/ui/input.tsx'
import {
DesktopAuthFieldRow,
DesktopAuthFooterLinks,
DesktopAuthInputError,
DesktopAuthSubmitError,
} from './desktop-auth-form-parts'
interface DesktopRegisterFormViewProps {
errors: {
confirmPassword?: string
inviteCode?: string
password?: string
username?: string
}
inviteCode: string
isSubmitting: boolean
onConfirmPasswordChange: (value: string) => void
onInviteCodeChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: () => void
onUsernameChange: (value: string) => void
password: string
confirmPassword: string
submitError?: string | null
username: string
}
export function DesktopRegisterFormView({
confirmPassword,
errors,
inviteCode,
isSubmitting,
onConfirmPasswordChange,
onInviteCodeChange,
onPasswordChange,
onSubmit,
onUsernameChange,
password,
submitError,
username,
}: DesktopRegisterFormViewProps) {
const { t } = useTranslation()
return (
<form
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
className={'flex flex-col items-center justify-between px-design-20'}
>
<div
className={
'h-design-490 flex flex-col gap-design-26 w-full bg-[#060B0F]/50 p-design-50'
}
>
<DesktopAuthFieldRow label={t('auth.register.fields.username.label')}>
<Input
value={username}
onChange={(event) => onUsernameChange(event.target.value)}
placeholder={t('auth.register.fields.username.placeholder')}
aria-invalid={Boolean(errors.username)}
className={'h-design-58 text-left'}
/>
<DesktopAuthInputError
message={errors.username ? t(errors.username) : undefined}
/>
</DesktopAuthFieldRow>
<DesktopAuthFieldRow label={t('auth.register.fields.password.label')}>
<Input
type="password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
placeholder={t('auth.register.fields.password.placeholder')}
aria-invalid={Boolean(errors.password)}
className={'h-design-58 text-left'}
/>
<DesktopAuthInputError
message={errors.password ? t(errors.password) : undefined}
/>
</DesktopAuthFieldRow>
<DesktopAuthFieldRow
label={t('auth.register.fields.confirmPassword.label')}
>
<Input
type="password"
value={confirmPassword}
onChange={(event) => onConfirmPasswordChange(event.target.value)}
placeholder={t('auth.register.fields.confirmPassword.placeholder')}
aria-invalid={Boolean(errors.confirmPassword)}
className={'h-design-58 text-left'}
/>
<DesktopAuthInputError
message={
errors.confirmPassword ? t(errors.confirmPassword) : undefined
}
/>
</DesktopAuthFieldRow>
<DesktopAuthFieldRow
label={t('auth.register.fields.inviteCode.label')}
labelClassName="whitespace-nowrap"
>
<Input
value={inviteCode}
onChange={(event) => onInviteCodeChange(event.target.value)}
placeholder={t('auth.register.fields.inviteCode.placeholder')}
aria-invalid={Boolean(errors.inviteCode)}
className={'h-design-58 max-w-design-520 text-left'}
/>
<DesktopAuthInputError
message={errors.inviteCode ? t(errors.inviteCode) : undefined}
/>
</DesktopAuthFieldRow>
<DesktopAuthSubmitError
message={submitError ? t(submitError) : undefined}
/>
<DesktopAuthFooterLinks
primaryLabel={t('auth.register.footer.alreadyHaveAccount')}
secondaryLabel={t('auth.register.footer.needHelp')}
/>
</div>
<SmartBackground
as={motion.button}
type="submit"
whileTap={{ scale: 0.95 }}
src={loginBg}
size="100% 100%"
className={
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer disabled:pointer-events-none disabled:opacity-60'
}
disabled={isSubmitting}
>
{isSubmitting
? t('auth.common.actions.submitting')
: t('auth.register.actions.submit')}
</SmartBackground>
</form>
)
}

View File

@@ -0,0 +1,51 @@
import { useController } from 'react-hook-form'
import { useRegisterForm } from '../hooks/use-register-form'
import { DesktopRegisterFormView } from './desktop-register-form-view'
interface DesktopRegisterFormProps {
onSuccess?: () => void
}
export function DesktopRegisterForm({ onSuccess }: DesktopRegisterFormProps) {
const { form, isSubmitting, onSubmit, submitError } = useRegisterForm({
onSuccess,
})
const usernameField = useController({
control: form.control,
name: 'username',
})
const passwordField = useController({
control: form.control,
name: 'password',
})
const confirmPasswordField = useController({
control: form.control,
name: 'confirmPassword',
})
const inviteCodeField = useController({
control: form.control,
name: 'inviteCode',
})
return (
<DesktopRegisterFormView
username={usernameField.field.value ?? ''}
password={passwordField.field.value ?? ''}
confirmPassword={confirmPasswordField.field.value ?? ''}
inviteCode={inviteCodeField.field.value ?? ''}
errors={{
confirmPassword: form.formState.errors.confirmPassword?.message,
inviteCode: form.formState.errors.inviteCode?.message,
password: form.formState.errors.password?.message,
username: form.formState.errors.username?.message,
}}
isSubmitting={isSubmitting}
onConfirmPasswordChange={confirmPasswordField.field.onChange}
onInviteCodeChange={inviteCodeField.field.onChange}
onPasswordChange={passwordField.field.onChange}
onSubmit={onSubmit}
onUsernameChange={usernameField.field.onChange}
submitError={submitError}
/>
)
}

View File

@@ -0,0 +1,54 @@
import { ApiError } from '@/lib/api/api-error'
type AuthSubmitContext = 'login' | 'register'
const AUTH_ERROR_KEY_PREFIX = 'auth.'
function isTranslationKey(value: unknown): value is string {
return typeof value === 'string' && value.startsWith(AUTH_ERROR_KEY_PREFIX)
}
function fallbackKeyByContext(context: AuthSubmitContext) {
return context === 'login'
? 'auth.login.errors.submitFailed'
: 'auth.register.errors.submitFailed'
}
export function toAuthSubmitErrorKey(
error: unknown,
context: AuthSubmitContext,
) {
if (!error) {
return null
}
const fallbackKey = fallbackKeyByContext(context)
if (error instanceof ApiError) {
if (isTranslationKey(error.message)) {
return error.message
}
if (error.status === 408) {
return 'auth.errors.timeout'
}
if (error.status === 401) {
return context === 'login'
? 'auth.login.errors.invalidCredentials'
: 'auth.register.errors.unauthorized'
}
if (typeof error.status === 'number' && error.status >= 500) {
return 'auth.errors.serviceUnavailable'
}
return fallbackKey
}
if (error instanceof Error && isTranslationKey(error.message)) {
return error.message
}
return fallbackKey
}

View File

@@ -0,0 +1,11 @@
import { type ModalKey, useModalStore } from '@/store'
export function useAuth() {
const setModalOpen = useModalStore((state) => state.setModalOpen)
const handleLogin = (modalKey: ModalKey, open: boolean) => {
setModalOpen(modalKey, open)
}
return { handleLogin }
}

View File

@@ -0,0 +1,47 @@
import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import i18n from '@/i18n'
import { notify } from '@/lib/notify'
import { useAuthStore } from '@/store/auth'
import { loginWithPassword } from '../api/auth-api'
import { type LoginFormValues, loginFormSchema } from '../schema/auth-schema'
import { toAuthSubmitErrorKey } from './auth-error-key'
import { createZodResolver } from './zod-form-resolver'
interface UseLoginFormOptions {
onSuccess?: () => void
}
export function useLoginForm({ onSuccess }: UseLoginFormOptions = {}) {
const startSession = useAuthStore((state) => state.startSession)
const form = useForm<LoginFormValues>({
defaultValues: {
password: '',
username: '',
},
mode: 'onBlur',
resolver: createZodResolver(loginFormSchema),
})
const mutation = useMutation({
mutationFn: loginWithPassword,
onError: (error) => {
const errorKey = toAuthSubmitErrorKey(error, 'login')
if (errorKey) {
notify.error(i18n.t(errorKey))
}
},
onSuccess: (session) => {
startSession(session)
notify.success(i18n.t('commonUi.toast.loginSuccess'))
onSuccess?.()
},
})
return {
form,
isSubmitting: mutation.isPending,
onSubmit: form.handleSubmit((values) => mutation.mutateAsync(values)),
submitError: toAuthSubmitErrorKey(mutation.error, 'login'),
}
}

View File

@@ -0,0 +1,56 @@
import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import i18n from '@/i18n'
import { notify } from '@/lib/notify'
import { useAuthStore } from '@/store/auth'
import { registerWithPassword } from '../api/auth-api'
import {
type RegisterFormValues,
registerFormSchema,
} from '../schema/auth-schema'
import { toAuthSubmitErrorKey } from './auth-error-key'
import { createZodResolver } from './zod-form-resolver'
interface UseRegisterFormOptions {
onSuccess?: () => void
}
export function useRegisterForm({ onSuccess }: UseRegisterFormOptions = {}) {
const startSession = useAuthStore((state) => state.startSession)
const form = useForm<RegisterFormValues>({
defaultValues: {
confirmPassword: '',
inviteCode: '',
password: '',
username: '',
},
mode: 'onBlur',
resolver: createZodResolver(registerFormSchema),
})
const mutation = useMutation({
mutationFn: (values: RegisterFormValues) => {
const { confirmPassword: _confirmPassword, ...payload } = values
return registerWithPassword(payload)
},
onError: (error) => {
const errorKey = toAuthSubmitErrorKey(error, 'register')
if (errorKey) {
notify.error(i18n.t(errorKey))
}
},
onSuccess: (session) => {
startSession(session)
notify.success(i18n.t('commonUi.toast.registerSuccess'))
onSuccess?.()
},
})
return {
form,
isSubmitting: mutation.isPending,
onSubmit: form.handleSubmit((values) => mutation.mutateAsync(values)),
submitError: toAuthSubmitErrorKey(mutation.error, 'register'),
}
}

View File

@@ -0,0 +1,66 @@
import type {
FieldErrors,
FieldValues,
Resolver,
ResolverResult,
} from 'react-hook-form'
import type { ZodType } from 'zod'
function setNestedError(
errors: FieldErrors,
path: Array<string | number>,
message: string,
) {
const [head, ...rest] = path
if (head === undefined) {
return
}
if (rest.length === 0) {
errors[String(head)] = {
message,
type: 'manual',
}
return
}
const key = String(head)
const next = (errors[key] as FieldErrors | undefined) ?? {}
errors[key] = next
setNestedError(next, rest, message)
}
export function createZodResolver<TValues extends FieldValues>(
schema: ZodType<TValues>,
): Resolver<TValues> {
return async (values): Promise<ResolverResult<TValues>> => {
const result = await schema.safeParseAsync(values)
if (result.success) {
return {
errors: {},
values: result.data,
} satisfies ResolverResult<TValues>
}
const errors: FieldErrors = {}
for (const issue of result.error.issues) {
setNestedError(
errors,
issue.path.filter(
(segment): segment is string | number =>
typeof segment === 'string' || typeof segment === 'number',
),
issue.message,
)
}
return {
errors,
values: {} as TValues,
} as ResolverResult<TValues>
}
}

View File

@@ -0,0 +1,38 @@
import { z } from 'zod'
const mobilePhonePattern = /^1[3-9]\d{9}$/
const usernameSchema = z
.string()
.trim()
.min(1, 'auth.validation.username.required')
.regex(mobilePhonePattern, 'auth.validation.username.invalidPhone')
const passwordSchema = z
.string()
.min(6, 'auth.validation.password.min')
.max(32, 'auth.validation.password.max')
export const loginFormSchema = z.object({
password: passwordSchema,
username: usernameSchema,
})
export const registerFormSchema = z
.object({
confirmPassword: passwordSchema,
inviteCode: z
.string()
.trim()
.min(1, 'auth.validation.inviteCode.required')
.max(32, 'auth.validation.inviteCode.max'),
password: passwordSchema,
username: usernameSchema,
})
.refine((value) => value.password === value.confirmPassword, {
message: 'auth.validation.confirmPassword.mismatch',
path: ['confirmPassword'],
})
export type LoginFormValues = z.infer<typeof loginFormSchema>
export type RegisterFormValues = z.infer<typeof registerFormSchema>

View File

@@ -1,4 +1,6 @@
import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type'
import type {
AnnouncementItem,
@@ -9,10 +11,17 @@ import type {
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
RoundPhase,
RoundSnapshot,
TrendEntry,
} from '../shared'
import { createMockGameBootstrapSnapshot } from '../shared'
import {
createMockGameBootstrapSnapshot,
DEFAULT_GAME_CHIP_COLORS,
deriveTrendEntries,
GAME_GRID_COLUMNS,
GAME_MAX_SELECTION_CELLS,
} from '../shared'
import type {
AnnouncementStateDto,
BetSelectionDto,
@@ -20,20 +29,72 @@ import type {
ConnectionStateDto,
DashboardStateDto,
GameAnnouncementsDto,
GameBetOrdersDto,
GameBootstrapDto,
GameCellDto,
GameLobbyInitDto,
GameLobbyPeriodDto,
GamePeriodTickDto,
GameRoundFeedDto,
HistoryEntryDto,
NoticeConfirmDto,
NoticeDetailDto,
NoticeListDto,
RoundSnapshotDto,
TrendEntryDto,
} from './types'
function unwrapGameEnvelope<T>(
response: ApiResponse<T>,
fallbackMessage = 'Game request failed',
) {
if (response.code === 1) {
return response.data
}
throw new ApiError({
data: response,
message:
typeof response.msg === 'string' && response.msg.length > 0
? response.msg
: fallbackMessage,
})
}
function assertLobbyInitDto(
dto: GameLobbyInitDto,
): asserts dto is GameLobbyInitDto {
if (
!Number.isFinite(dto.server_time) ||
!Array.isArray(dto.dictionary) ||
!dto.bet_config ||
!Number.isFinite(dto.bet_config.default_bet_chip_id)
) {
throw new ApiError({
data: dto,
message: 'Invalid game lobby init payload',
})
}
}
export const GAME_API_ENDPOINTS = {
announcements: 'game/announcements',
betMyOrders: 'api/game/betMyOrders',
bootstrap: 'game/bootstrap',
lobbyInit: 'api/game/lobbyInit',
noticeConfirm: 'api/notice/noticeConfirm',
noticeDetail: 'api/notice/noticeDetail',
noticeList: 'api/notice/noticeList',
roundFeed: 'game/round-feed',
} as const
export interface GameLobbyInitResult {
runtimeEnabled: boolean
serverTime: number
snapshot: GameBootstrapSnapshot
userSnapshot: GameLobbyInitDto['user_snapshot']
}
function normalizeGameCell(dto: GameCellDto) {
return dto satisfies GameCell
}
@@ -136,6 +197,193 @@ function normalizeConnectionState(dto: ConnectionStateDto) {
} satisfies ConnectionState
}
function toIsoFromUnixSeconds(seconds: number) {
const timestamp = Number(seconds)
const date = new Date(timestamp * 1000)
if (!Number.isFinite(timestamp) || Number.isNaN(date.valueOf())) {
throw new ApiError({
data: { seconds },
message: 'Invalid unix timestamp',
})
}
return date.toISOString()
}
export function normalizeLobbyRoundPhase(
status: GameLobbyPeriodDto['status'],
runtimeEnabled: boolean,
): RoundPhase {
if (!runtimeEnabled && status === 'betting') {
return 'locked'
}
switch (status) {
case 'betting':
return 'betting'
case 'locked':
return 'locked'
case 'settling':
return 'revealing'
case 'payouting':
case 'finished':
case 'void':
return 'settled'
default:
return 'waiting'
}
}
function normalizeLobbyChips(
chips: Record<string, string>,
defaultBetChipId: number,
) {
return Object.entries(chips)
.sort(([leftId], [rightId]) => Number(leftId) - Number(rightId))
.map(([chipId, chipAmount], index) => {
const amount = Number(chipAmount)
return {
amount: Number.isFinite(amount) ? amount : 0,
color:
DEFAULT_GAME_CHIP_COLORS[index % DEFAULT_GAME_CHIP_COLORS.length] ??
DEFAULT_GAME_CHIP_COLORS[0],
id: `chip-${chipId}`,
isDefault: Number(chipId) === defaultBetChipId,
label: chipAmount,
}
})
}
function normalizeLobbyCells(dictionary: GameLobbyInitDto['dictionary']) {
return [...dictionary]
.sort((left, right) => left.number - right.number)
.map(
(item, index) =>
({
column: (index % GAME_GRID_COLUMNS) + 1,
id: item.number,
label: item.name,
odds: 36,
row: Math.floor(index / GAME_GRID_COLUMNS) + 1,
}) satisfies GameCell,
)
}
export function normalizeLobbyRound(
lobbyInit: Pick<
GameLobbyInitDto,
'period' | 'runtime_enabled' | 'server_time'
>,
) {
if (!lobbyInit.period) {
return {
bettingClosesAt: toIsoFromUnixSeconds(lobbyInit.server_time),
id: '',
phase: 'waiting',
revealingAt: toIsoFromUnixSeconds(lobbyInit.server_time),
settledAt: null,
startedAt: toIsoFromUnixSeconds(lobbyInit.server_time),
winningCellId: null,
} satisfies RoundSnapshot
}
return {
bettingClosesAt: toIsoFromUnixSeconds(lobbyInit.period.lock_at),
id: lobbyInit.period.period_no,
phase: normalizeLobbyRoundPhase(
lobbyInit.period.status,
lobbyInit.runtime_enabled,
),
revealingAt: toIsoFromUnixSeconds(lobbyInit.period.open_at),
settledAt: toIsoFromUnixSeconds(lobbyInit.period.open_at),
startedAt: toIsoFromUnixSeconds(lobbyInit.server_time),
winningCellId: null,
} satisfies RoundSnapshot
}
export function normalizePeriodTickRound(
period: GamePeriodTickDto,
previousRound?: Pick<RoundSnapshot, 'id' | 'startedAt'> | null,
) {
const startedAt =
previousRound?.id === period.period_no
? previousRound.startedAt
: toIsoFromUnixSeconds(period.server_time)
const countdownSeconds = Math.max(0, period.countdown)
const betCloseSeconds = Math.max(0, period.bet_close_in)
const phase = normalizeLobbyRoundPhase(period.status, period.runtime_enabled)
const nextPhaseAt = toIsoFromUnixSeconds(
period.server_time + countdownSeconds,
)
return {
bettingClosesAt: toIsoFromUnixSeconds(period.server_time + betCloseSeconds),
id: period.period_no,
phase,
revealingAt: nextPhaseAt,
settledAt: nextPhaseAt,
startedAt,
winningCellId:
typeof period.result_number === 'number' ? period.result_number : null,
} satisfies RoundSnapshot
}
export function normalizeGameLobbyInit(dto: GameLobbyInitDto) {
const baseIso = toIsoFromUnixSeconds(dto.server_time)
const template = createMockGameBootstrapSnapshot(baseIso)
const cells = normalizeLobbyCells(dto.dictionary)
const chips = normalizeLobbyChips(
dto.bet_config.chips,
dto.bet_config.default_bet_chip_id,
)
const round = normalizeLobbyRound({
period: null,
runtime_enabled: dto.runtime_enabled,
server_time: dto.server_time,
})
const trends = deriveTrendEntries([])
return {
announcements: {
activeAnnouncementId: null,
items: [],
lastUpdatedAt: null,
} satisfies AnnouncementState,
cells,
chips: chips.length > 0 ? chips : template.chips,
connection: {
...template.connection,
connectedAt: null,
lastError: null,
lastMessageAt: null,
latencyMs: null,
reconnectAttempt: 0,
status: 'idle',
transport: 'polling',
},
dashboard: {
countdownMs: 0,
featuredCellId: null,
onlinePlayers: 0,
tableLimitMax: Number(dto.bet_config.max_bet_per_number) || 0,
tableLimitMin: Number(dto.bet_config.min_bet_per_number) || 0,
totalPoolAmount: 0,
updatedAt: baseIso,
} satisfies DashboardState,
history: [],
maxSelectionCount:
Number.isFinite(dto.bet_config.pick_max_number_count) &&
dto.bet_config.pick_max_number_count > 0
? Math.min(36, Math.floor(dto.bet_config.pick_max_number_count))
: GAME_MAX_SELECTION_CELLS,
round,
selections: [],
trends,
} satisfies GameBootstrapSnapshot
}
export function normalizeGameBootstrap(dto: GameBootstrapDto) {
return {
announcements: normalizeAnnouncementState(dto.announcements),
@@ -144,6 +392,7 @@ export function normalizeGameBootstrap(dto: GameBootstrapDto) {
connection: normalizeConnectionState(dto.connection),
dashboard: normalizeDashboardState(dto.dashboard),
history: dto.history.map(normalizeHistoryEntry),
maxSelectionCount: GAME_MAX_SELECTION_CELLS,
round: normalizeRoundSnapshot(dto.round),
selections: dto.selections.map(normalizeBetSelection),
trends: dto.trends.map(normalizeTrendEntry),
@@ -164,22 +413,125 @@ export function normalizeGameRoundFeed(dto: GameRoundFeedDto) {
export async function getGameBootstrap() {
const response = await api.get<GameBootstrapDto>(GAME_API_ENDPOINTS.bootstrap)
const dto = unwrapGameEnvelope(
response as ApiResponse<GameBootstrapDto>,
'Failed to load game bootstrap',
)
return normalizeGameBootstrap(response.data)
return normalizeGameBootstrap(dto)
}
export async function getGameRoundFeed() {
const response = await api.get<GameRoundFeedDto>(GAME_API_ENDPOINTS.roundFeed)
const dto = unwrapGameEnvelope(
response as ApiResponse<GameRoundFeedDto>,
'Failed to load game round feed',
)
return normalizeGameRoundFeed(response.data)
return normalizeGameRoundFeed(dto)
}
export async function getGameAnnouncements() {
const response = await api.get<GameAnnouncementsDto>(
GAME_API_ENDPOINTS.announcements,
)
const dto = unwrapGameEnvelope(
response as ApiResponse<GameAnnouncementsDto>,
'Failed to load game announcements',
)
return normalizeAnnouncementState(response.data.announcements)
return normalizeAnnouncementState(dto.announcements)
}
export async function getGameLobbyInit() {
const response = await api.post<GameLobbyInitDto>(
GAME_API_ENDPOINTS.lobbyInit,
)
const dto = unwrapGameEnvelope(
response as ApiResponse<GameLobbyInitDto>,
'Failed to load game lobby init',
)
assertLobbyInitDto(dto)
return {
runtimeEnabled: dto.runtime_enabled,
serverTime: dto.server_time,
snapshot: normalizeGameLobbyInit(dto),
userSnapshot: dto.user_snapshot,
} satisfies GameLobbyInitResult
}
export async function getNoticeList(params?: {
page?: number
pageSize?: number
}) {
const response = await api.get<NoticeListDto>(GAME_API_ENDPOINTS.noticeList, {
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? 20),
},
})
const dto = unwrapGameEnvelope(
response as ApiResponse<NoticeListDto>,
'Failed to load notice list',
)
return dto
}
export async function getNoticeDetail(id: number) {
const response = await api.get<NoticeDetailDto>(
GAME_API_ENDPOINTS.noticeDetail,
{
searchParams: {
id: String(id),
},
},
)
const dto = unwrapGameEnvelope(
response as ApiResponse<NoticeDetailDto>,
'Failed to load notice detail',
)
return dto
}
export async function confirmNotice(noticeId: number) {
const response = await api.get<NoticeConfirmDto>(
GAME_API_ENDPOINTS.noticeConfirm,
{
searchParams: {
notice_id: String(noticeId),
},
},
)
const dto = unwrapGameEnvelope(
response as ApiResponse<NoticeConfirmDto>,
'Failed to confirm notice',
)
return dto
}
export async function getGameBetMyOrders(params: {
page?: number
pageSize?: number
}) {
const response = await api.post<GameBetOrdersDto>(
GAME_API_ENDPOINTS.betMyOrders,
{
json: {
page: params.page ?? 1,
page_size: params.pageSize ?? 20,
},
},
)
const dto = unwrapGameEnvelope(
response as ApiResponse<GameBetOrdersDto>,
'Failed to load bet orders',
)
return dto
}
export async function getMockGameBootstrap(latencyMs = 120) {

View File

@@ -123,6 +123,116 @@ export interface GameAnnouncementsDto {
announcements: AnnouncementStateDto
}
export interface NoticeListItemDto {
is_read: boolean
notice_id: number
notice_type: 'silent' | 'popout'
publish_time: number
title: string
}
export interface NoticeListDto {
list: NoticeListItemDto[]
}
export interface NoticeDetailDto {
content: string
must_confirm: boolean
notice_id: number
notice_type: 'silent' | 'popout'
publish_time: number
title: string
}
export interface NoticeConfirmDto {
confirm_time: number
confirmed: boolean
notice_id: number
}
export type GamePeriodStatus =
| 'betting'
| 'locked'
| 'settling'
| 'payouting'
| 'finished'
| 'void'
| (string & {})
export interface GameLobbyPeriodDto {
countdown: number
lock_at: number
open_at: number
period_no: string
status: GamePeriodStatus
}
export interface GameLobbyBetConfigDto {
chips: Record<string, string>
default_bet_chip_id: number
max_bet_per_number: string
min_bet_per_number: string
pick_max_number_count: number
}
export interface GameLobbyDictionaryItemDto {
category: string
icon: string
name: string
number: number
}
export interface GameLobbyUserSnapshotDto {
coin: string
current_streak: number
is_jackpot?: boolean
odds_factor?: number
streak_level?: number
}
export interface GameLobbyInitDto {
bet_config: GameLobbyBetConfigDto
dictionary: GameLobbyDictionaryItemDto[]
period?: GameLobbyPeriodDto | null
runtime_enabled: boolean
server_time: number
user_snapshot: GameLobbyUserSnapshotDto
}
export interface GamePeriodTickDto {
bet_close_in: number
countdown: number
period_id: number | null
period_no: string
result_number: number | null
runtime_enabled: boolean
server_time: number
status: GamePeriodStatus
}
export interface GameBetOrderDto {
bet_amount: string
create_time: number
numbers: number[]
order_no: string
period_no: string
result_number: number | null
status: string
total_amount: string
win_amount: string
}
export interface GameBetOrdersPaginationDto {
page: number
page_size: number
total: number
}
export interface GameBetOrdersDto {
list: GameBetOrderDto[]
pagination: GameBetOrdersPaginationDto
}
export type {
AnnouncementState,
Chip,

View File

@@ -1,5 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import diamondIcon from '@/assets/system/diamond.webp'
import { SmartImage } from '@/components/smart-image'
import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils'
import { useAuthStore, useModalStore } from '@/store'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true,
@@ -18,6 +24,37 @@ const animalImageList = Object.entries(animalModules)
.filter((item) => item.id > 0)
.sort((left, right) => left.id - right.id)
function getNextMarqueeId(currentId: number | null) {
if (animalImageList.length === 0) {
return null
}
if (animalImageList.length === 1) {
return animalImageList[0]?.id ?? null
}
let nextId = currentId
while (nextId === currentId) {
nextId =
animalImageList[Math.floor(Math.random() * animalImageList.length)]?.id ??
currentId
}
return nextId
}
function formatSelectedLog(
selectionByCell: Record<number, { amount: number; count: number }>,
) {
return Object.entries(selectionByCell)
.map(([cellId, value]) => ({
字花: String(cellId).padStart(2, '0'),
筹码: value.amount,
}))
.sort((left, right) => Number(left.) - Number(right.))
}
interface DesktopAnimalProps {
activeId?: number | null
className?: string
@@ -33,40 +70,220 @@ export function DesktopAnimal({
imageClassName,
onSelect,
}: DesktopAnimalProps) {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const chips = useGameRoundStore((state) => state.chips)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount,
)
const placeBet = useGameRoundStore((state) => state.placeBet)
const removeSelectionsForCell = useGameRoundStore(
(state) => state.removeSelectionsForCell,
)
const selections = useGameRoundStore((state) => state.selections)
const connection = useGameSessionStore((state) => state.connection)
const requestRealtimeConnection = useGameSessionStore(
(state) => state.requestRealtimeConnection,
)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
const [marqueeId, setMarqueeId] = useState<number | null>(() =>
getNextMarqueeId(null),
)
const activeChip = useMemo(
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
[activeChipId, chips],
)
const selectionByCell = useMemo(() => {
return selections.reduce<Record<number, { amount: number; count: number }>>(
(accumulator, selection) => {
const current = accumulator[selection.cellId] ?? { amount: 0, count: 0 }
accumulator[selection.cellId] = {
amount: current.amount + selection.amount,
count: current.count + 1,
}
return accumulator
},
{},
)
}, [selections])
const isRealtimeConnected = connection.status === 'connected'
const isRealtimeConnecting =
shouldConnectRealtime &&
(connection.status === 'connecting' || connection.status === 'reconnecting')
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
const lockInteraction = showStandbyState
const isSelectedCell = (animalId: number) =>
Boolean(selectionByCell[animalId])
const selectedCellCount = Object.keys(selectionByCell).length
const handleStart = () => {
if (authStatus !== 'authenticated') {
notify.warning(t('commonUi.toast.loginRequired'))
setModalOpen('desktopLogin', true)
return
}
clearSelections()
requestRealtimeConnection()
}
const handleSelect = (animalId: number) => {
if (showStandbyState) {
return
}
if (onSelect) {
onSelect(animalId)
return
}
if (isSelectedCell(animalId)) {
const nextSelectionByCell = { ...selectionByCell }
delete nextSelectionByCell[animalId]
console.log('已选', formatSelectedLog(nextSelectionByCell))
removeSelectionsForCell(animalId)
return
}
if (selectedCellCount >= maxSelectionCount) {
return
}
console.log(
'已选',
formatSelectedLog({
...selectionByCell,
[animalId]: {
amount: activeChip?.amount ?? 0,
count: 1,
},
}),
)
placeBet(animalId)
}
useEffect(() => {
if (!showStandbyState) {
setMarqueeId(null)
return
}
setMarqueeId((currentId) => getNextMarqueeId(currentId))
let timerId = 0
const loop = () => {
setMarqueeId((currentId) => getNextMarqueeId(currentId))
timerId = window.setTimeout(loop, 180 + Math.floor(Math.random() * 220))
}
timerId = window.setTimeout(loop, 220)
return () => {
window.clearTimeout(timerId)
}
}, [showStandbyState])
return (
<section
className={cn(
'grid w-full grid-cols-6 gap-design-5 common-neon-inset',
'relative grid w-full grid-cols-6 gap-design-5 overflow-hidden common-neon-inset',
className,
)}
>
{animalImageList.map((item) => {
const isActive = item.id === activeId
const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta)
const isActive = item.id === activeId || hasPlacedSelection
const isMarqueeActive = showStandbyState && item.id === marqueeId
return (
<button
key={item.id}
type="button"
onClick={() => onSelect?.(item.id)}
disabled={lockInteraction}
onClick={() => handleSelect(item.id)}
className={cn(
'flex flex-col items-center transition',
'cursor-pointer',
'relative flex flex-col items-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
lockInteraction
? 'cursor-not-allowed opacity-90'
: 'cursor-pointer hover:-translate-y-[1px]',
isMarqueeActive &&
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
isActive &&
'border-[rgba(255,151,15,0.95)] shadow-[inset_0_0_16px_rgba(255,151,15,0.55)]',
'border-[rgba(255,187,61,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,175,52,0.82),0_0_calc(var(--design-unit)*30)_rgba(255,151,15,0.46),inset_0_0_calc(var(--design-unit)*20)_rgba(255,177,70,0.58)]',
!showStandbyState && !hasPlacedSelection && 'opacity-95',
itemClassName,
)}
>
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150',
isMarqueeActive &&
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
isActive &&
'bg-[radial-gradient(circle_at_center,rgba(255,207,116,0.42)_0%,rgba(255,181,61,0.16)_42%,transparent_74%)] opacity-100',
)}
/>
{!showStandbyState && !hasPlacedSelection ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[calc(var(--design-unit)*2)] z-20 rounded-[calc(var(--design-unit)*15)] bg-[rgba(4,16,24,0.52)] shadow-[inset_0_0_calc(var(--design-unit)*20)_rgba(3,9,14,0.56)]"
/>
) : null}
<SmartImage
src={item.url}
alt={`animal-${item.id}`}
className={cn(
'h-design-112 w-design-223 rounded-2xl object-contain',
'relative z-10 h-design-112 w-design-223 rounded-2xl object-contain',
imageClassName,
)}
/>
{hasPlacedSelection ? (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="flex min-w-design-96 items-center justify-center gap-design-4 rounded-full border border-[rgba(162,242,255,0.48)] bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-10 py-design-6 shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.18)]">
<SmartImage
src={diamondIcon}
alt="diamond"
className="h-design-24 w-design-24 shrink-0 object-contain"
/>
<span className="text-design-18 font-semibold leading-none tracking-[0.06em] text-[#D8FBFF]">
{selectionMeta.amount}
</span>
</span>
</span>
) : null}
</button>
)
})}
{showStandbyState ? (
<button
type="button"
onClick={handleStart}
className="absolute inset-0 z-10 flex cursor-pointer items-center justify-center bg-[rgba(3,13,20,0.62)]"
>
<div className="relative flex flex-col items-center gap-design-8 rounded-[calc(var(--design-unit)*20)] border border-[rgba(111,255,247,0.54)] bg-[linear-gradient(180deg,rgba(6,28,38,0.92),rgba(4,14,20,0.94))] px-design-28 py-design-16 text-center shadow-[0_0_calc(var(--design-unit)*16)_rgba(70,245,255,0.34),0_0_calc(var(--design-unit)*34)_rgba(19,210,232,0.22)] transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-[1px] hover:border-[rgba(141,255,250,0.8)] hover:shadow-[0_0_calc(var(--design-unit)*22)_rgba(88,247,255,0.48),0_0_calc(var(--design-unit)*42)_rgba(32,228,255,0.3)]">
<span className="text-design-14 uppercase tracking-[0.42em] text-[rgba(111,255,247,0.76)]">
{isRealtimeConnecting ? '' : t('gameDesktop.animal.tapToEnter')}
</span>
<span className="text-design-28 font-semibold tracking-[0.18em] text-[#D2FFFF]">
{isRealtimeConnecting
? t('gameDesktop.animal.loading')
: t('gameDesktop.animal.getStart')}
</span>
</div>
</button>
) : null}
</section>
)
}

View File

@@ -1,5 +1,6 @@
import { motion } from 'motion/react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import add from '@/assets/game/add.webp'
import arrow from '@/assets/game/arrow.webp'
import chipBg from '@/assets/game/chip-bg.webp'
@@ -9,16 +10,18 @@ import controlBg from '@/assets/game/control-bg.png'
import leftBottomBg from '@/assets/game/left-bg.webp'
import reduce from '@/assets/game/reduce.webp'
import totalBg from '@/assets/game/total-bg.webp'
import diamond from '@/assets/system/diamond.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { ACTION_OPTIONS } from '@/constants'
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
import { cn } from '@/lib/utils'
export function DesktopControl() {
const { t } = useTranslation()
const {
canClear,
chips,
maxSelectionCountLabel,
onChipSelect,
onClearSelections,
selectedChipAmountLabel,
@@ -26,7 +29,6 @@ export function DesktopControl() {
selectedCountLabel,
totalBetAmountLabel,
} = useGameControlVm()
const [clickedId, setClickedId] = useState<string | null>(null)
const [hidingId, setHidingId] = useState<string | null>(null)
const [confirmClicked, setConfirmClicked] = useState(false)
@@ -74,8 +76,8 @@ export function DesktopControl() {
}
>
<div className={'flex flex-col items-center justify-center'}>
<div>TREBD</div>
<div>MAP</div>
<div>{t('gameDesktop.control.trend')}</div>
<div>{t('gameDesktop.control.map')}</div>
</div>
<SmartImage
src={arrow}
@@ -110,10 +112,10 @@ export function DesktopControl() {
transition={{
layout: {
type: 'spring',
stiffness: 420,
damping: 32,
stiffness: 360,
damping: 26,
},
duration: 0.18,
duration: 0.26,
}}
className={
'relative flex h-design-70 w-design-70 shrink-0 cursor-pointer items-center justify-center rounded-full'
@@ -178,15 +180,16 @@ export function DesktopControl() {
}
/>
<motion.div
layout
animate={
isSelected
? {
y: [-1, -3, -1],
scale: [1.02, 1.06, 1.02],
y: [-1, -4, -1],
scale: [1.04, 1.1, 1.04],
filter: [
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
'drop-shadow(0 10px 14px rgba(245, 200, 107, 0.22))',
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
'drop-shadow(0 8px 10px rgba(0,0,0,0.22))',
'drop-shadow(0 12px 16px rgba(245, 200, 107, 0.28))',
'drop-shadow(0 8px 10px rgba(0,0,0,0.22))',
],
}
: {
@@ -205,6 +208,27 @@ export function DesktopControl() {
draggable={false}
className={'h-design-70 w-design-70 object-contain'}
/>
<span
className={
'pointer-events-none absolute inset-x-0 top-1/2 z-[8] -translate-y-[calc(50%-1*var(--design-unit))] text-center text-design-16 font-black leading-none tracking-[0.06em] text-[rgba(96,54,0,0.85)] blur-[1px]'
}
>
{chip.valueLabel}
</span>
<span
className={
'pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-[calc(50%+1*var(--design-unit))] text-center text-design-16 font-black leading-none tracking-[0.06em] text-[rgba(66,28,0,0.72)]'
}
>
{chip.valueLabel}
</span>
<span
className={
'pointer-events-none absolute inset-x-0 top-1/2 z-[11] -translate-y-1/2 text-center text-design-16 font-black leading-none tracking-[0.06em] text-white [text-shadow:0_1px_0_rgba(255,255,255,0.6),0_2px_4px_rgba(0,0,0,0.72),0_0_10px_rgba(255,255,255,0.22)]'
}
>
{chip.valueLabel}
</span>
</motion.div>
</motion.button>
)
@@ -237,11 +261,26 @@ export function DesktopControl() {
src={totalBg}
size="100% 100%"
className={
'desktop-control-total relative flex flex-col items-center justify-center z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
'desktop-control-total relative flex items-center justify-center text-design-20 gap-design-40 z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
}
>
<div>SELECTED:{selectedCountLabel}</div>
<div>Total Bet{totalBetAmountLabel}</div>
<div>
{t('gameDesktop.control.selected')}:{' '}
<span className={'text-red-500'}>{selectedCountLabel}</span> /{' '}
{maxSelectionCountLabel}
</div>
<div className={'flex'}>
<div>{t('gameDesktop.control.totalBet')}</div>
<div className={'flex items-center gap-design-10'}>
<SmartImage
className={'w-design-30 h-design-30'}
src={diamond}
alt={'diamond'}
/>
<div>{totalBetAmountLabel}</div>
</div>
</div>
</SmartBackground>
<SmartBackground
src={controlBg}
@@ -250,7 +289,7 @@ export function DesktopControl() {
'desktop-control-actions relative z-10 flex h-full w-design-385 shrink-0 items-center bg-center bg-no-repeat pl-design-15',
)}
>
{ACTION_OPTIONS.map(({ id, label, Icon, bg }) => {
{ACTION_OPTIONS.map(({ id, labelKey, Icon, bg }) => {
const isClicked = clickedId === id
const isHiding = hidingId === id
const showBg = isClicked || isHiding
@@ -315,7 +354,7 @@ export function DesktopControl() {
className={showBg ? 'text-[#D9FEFF]' : 'text-[#37D5CB]'}
/>
<div className={'mt-design-6 text-design-14 leading-none'}>
{label}
{t(labelKey)}
</div>
</motion.div>
</motion.button>
@@ -351,7 +390,7 @@ export function DesktopControl() {
transition={{ duration: 0.15 }}
className="relative"
>
confirm
{t('gameDesktop.control.confirm')}
</motion.span>
</SmartBackground>
</div>

View File

@@ -1,9 +1,54 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import historyBg from '@/assets/system/history-bg.png'
import { SmartBackground } from '@/components/smart-background.tsx'
import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts'
export function DesktopGameHistory() {
const { emptyText, isEmpty, items } = useGameHistoryVm()
const { t } = useTranslation()
const {
emptyText,
endText,
fetchNextPage,
hasNextPage,
isEmpty,
isFetchingNextPage,
isInitialLoading,
items,
loadingText,
} = useGameHistoryVm()
const parentRef = useRef<HTMLDivElement | null>(null)
const rowCount = hasNextPage ? items.length + 1 : items.length
const virtualizer = useVirtualizer({
count: rowCount,
estimateSize: () => 196,
getScrollElement: () => parentRef.current,
overscan: 4,
})
useEffect(() => {
const virtualItems = virtualizer.getVirtualItems()
const lastItem = virtualItems[virtualItems.length - 1]
if (
!lastItem ||
!hasNextPage ||
isFetchingNextPage ||
lastItem.index < items.length - 1
) {
return
}
void fetchNextPage()
}, [
fetchNextPage,
hasNextPage,
isFetchingNextPage,
items.length,
virtualizer,
])
return (
<SmartBackground
@@ -16,14 +61,23 @@ export function DesktopGameHistory() {
'relative z-20 flex h-design-50 shrink-0 items-center justify-center text-design-30 text-[#D5FBFF]'
}
>
History
{t('gameDesktop.history.title')}
</div>
<div
ref={parentRef}
className={
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
}
>
{isEmpty ? (
{isInitialLoading ? (
<div
className={
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
}
>
{loadingText}
</div>
) : isEmpty ? (
<div
className={
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
@@ -32,56 +86,98 @@ export function DesktopGameHistory() {
{emptyText}
</div>
) : (
items.map((item) => {
return (
<div
key={item.id}
className={
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
}
>
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index]
return (
<div
className={
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
}
key={item?.id ?? `loader-${virtualRow.index}`}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{item.statusLabel}
{item ? (
<div
className={
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
}
>
<div
className={
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
}
>
{item.statusLabel}
</div>
<div
className={
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
}
>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.orderNo')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>
{item.orderNo}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.roundId')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>
{item.periodNo}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.numbers')}:{' '}
</span>
<span>{item.numbersLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.settledAt')}:{' '}
</span>
<span>{item.createdAtLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.totalPoolAmount')}:{' '}
</span>
<span className={'text-[#FFE375]'}>
{item.amountLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.winningResult')}:{' '}
</span>
<span className={'text-[#FF7575]'}>
{item.resultNumberLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.payout')}:{' '}
</span>
<span>{item.winAmountLabel}</span>
</div>
</div>
</div>
) : (
<div className="flex h-[calc(var(--design-unit)*60)] items-center justify-center text-design-16 text-[#84A2A2]">
{isFetchingNextPage ? loadingText : endText}
</div>
)}
</div>
<div
className={
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
}
>
<div>
<span className={'text-[#84A2A2]'}>Round ID: </span>
<span className={'text-[#C0E7EB]'}>{item.roundId}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>Settled At: </span>
<span>{item.settledAtLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
Total Pool Amount:{' '}
</span>
<span className={'text-[#FFE375]'}>
{item.totalPoolAmountLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>Winning Result: </span>
<span className={'text-[#FF7575]'}>
{item.winningCellIdLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>Payout: </span>
<span>{item.payoutMultiplierLabel}</span>
</div>
</div>
</div>
)
})
)
})}
</div>
)}
</div>
</SmartBackground>

View File

@@ -1,10 +1,260 @@
import { CircleAlert, Mail, Volume2 } from 'lucide-react'
import { CircleAlert, Mail, Maximize, Minimize, Volume2 } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import wifi from '@/assets/system/wifi.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import {
isDesktopFullscreen,
subscribeDesktopFullscreenChange,
toggleDesktopFullscreen,
} from '@/lib/utils'
import { useAuthStore, useGameSessionStore, useModalStore } from '@/store'
type BrowserNetworkInformation = {
addEventListener?: (type: 'change', listener: () => void) => void
downlink?: number
effectiveType?: string
removeEventListener?: (type: 'change', listener: () => void) => void
rtt?: number
}
type SignalPresentation = {
activeBars: number
latencyLabel: string
toneClassName: string
}
function formatTimezoneOffset(date: Date) {
const offsetMinutes = -date.getTimezoneOffset()
const sign = offsetMinutes >= 0 ? '+' : '-'
const absoluteMinutes = Math.abs(offsetMinutes)
const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0')
const minutes = String(absoluteMinutes % 60).padStart(2, '0')
return `GMT${sign}${hours}${minutes === '00' ? '' : `:${minutes}`}`
}
function formatHeaderTime(date: Date) {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${hours}:${minutes}:${seconds} ${formatTimezoneOffset(date)}`
}
function getBrowserNetworkInformation() {
if (typeof navigator === 'undefined') {
return null
}
return (navigator as Navigator & { connection?: BrowserNetworkInformation })
.connection
}
function resolveSignalPresentation(input: {
isOnline: boolean
latencyMs: number | null
status: string
}) {
if (!input.isOnline || input.status === 'disconnected') {
return {
activeBars: 0,
latencyLabel: '--',
toneClassName: 'text-[#FF6B6B]',
} satisfies SignalPresentation
}
if (input.latencyMs === null) {
return {
activeBars: input.status === 'connected' ? 2 : 1,
latencyLabel: '--',
toneClassName: 'text-[#7F8EA3]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 80) {
return {
activeBars: 4,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#74FF69]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 150) {
return {
activeBars: 3,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#B7FF6A]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 300) {
return {
activeBars: 2,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#FFD76A]',
} satisfies SignalPresentation
}
return {
activeBars: 1,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#FF8A6A]',
} satisfies SignalPresentation
}
function SignalBars({
activeBars,
toneClassName,
}: {
activeBars: number
toneClassName: string
}) {
const barHeights = ['h-[6px]', 'h-[10px]', 'h-[14px]', 'h-[18px]'] as const
return (
<div
className="flex h-design-20 w-design-28 items-end gap-[2px]"
aria-hidden="true"
>
{barHeights.map((heightClassName, index) => {
const isActive = index < activeBars
return (
<div
key={heightClassName}
className={[
'w-[5px] rounded-t-[2px] transition-colors',
heightClassName,
isActive ? `bg-current ${toneClassName}` : 'bg-white/18',
].join(' ')}
/>
)
})}
</div>
)
}
export function DesktopHeader() {
const { t } = useTranslation()
const [isFullscreen, setIsFullscreen] = useState(false)
const [clockNow, setClockNow] = useState(() => Date.now())
const [isOnline, setIsOnline] = useState(() =>
typeof navigator === 'undefined' ? true : navigator.onLine,
)
const [browserNetworkRttMs, setBrowserNetworkRttMs] = useState<number | null>(
() => {
const rtt = getBrowserNetworkInformation()?.rtt
return typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0
? rtt
: null
},
)
const currentUser = useAuthStore((state) => state.currentUser)
const authStatus = useAuthStore((state) => state.status)
const connection = useGameSessionStore((state) => state.connection)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const serverClockOffsetMs = useMemo(() => {
if (
connection.status !== 'connected' ||
connection.transport !== 'websocket' ||
!connection.lastMessageAt
) {
return null
}
const serverTimestamp = Date.parse(connection.lastMessageAt)
if (Number.isNaN(serverTimestamp)) {
return null
}
return serverTimestamp - Date.now()
}, [connection.lastMessageAt, connection.status, connection.transport])
const systemTimeLabel = useMemo(() => {
const activeTimestamp =
serverClockOffsetMs === null ? clockNow : clockNow + serverClockOffsetMs
return formatHeaderTime(new Date(activeTimestamp))
}, [clockNow, serverClockOffsetMs])
const signalLatencyMs = useMemo(() => {
if (
typeof connection.latencyMs === 'number' &&
Number.isFinite(connection.latencyMs) &&
connection.latencyMs >= 0
) {
return connection.latencyMs
}
return browserNetworkRttMs
}, [browserNetworkRttMs, connection.latencyMs])
const signalPresentation = useMemo(
() =>
resolveSignalPresentation({
isOnline,
latencyMs: signalLatencyMs,
status: connection.status,
}),
[connection.status, isOnline, signalLatencyMs],
)
useEffect(() => {
const syncFullscreenState = () => {
setIsFullscreen(isDesktopFullscreen())
}
syncFullscreenState()
return subscribeDesktopFullscreenChange(syncFullscreenState)
}, [])
useEffect(() => {
const timer = window.setInterval(() => {
setClockNow(Date.now())
}, 1000)
return () => {
window.clearInterval(timer)
}
}, [])
useEffect(() => {
const syncBrowserNetworkState = () => {
setIsOnline(navigator.onLine)
const rtt = getBrowserNetworkInformation()?.rtt
setBrowserNetworkRttMs(
typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0 ? rtt : null,
)
}
const networkInformation = getBrowserNetworkInformation()
syncBrowserNetworkState()
window.addEventListener('online', syncBrowserNetworkState)
window.addEventListener('offline', syncBrowserNetworkState)
networkInformation?.addEventListener?.('change', syncBrowserNetworkState)
return () => {
window.removeEventListener('online', syncBrowserNetworkState)
window.removeEventListener('offline', syncBrowserNetworkState)
networkInformation?.removeEventListener?.(
'change',
syncBrowserNetworkState,
)
}
}, [])
const handleFullscreenToggle = async () => {
await toggleDesktopFullscreen()
}
return (
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
<div className="flex h-design-70 w-full items-center px-design-12">
@@ -18,92 +268,124 @@ export function DesktopHeader() {
</div>
<div className="flex h-full w-design-130 items-center justify-center gap-design-10 border-r border-[rgba(128,223,231,0.65)]">
<SmartImage
src={wifi}
alt="wifi"
priority
className="h-design-20 w-design-28"
/>
<div className={'text-[#74FF69] text-design-20'}>
24 <span className={'text-design-16'}>ms</span>
<div className={signalPresentation.toneClassName}>
<SignalBars
activeBars={signalPresentation.activeBars}
toneClassName={signalPresentation.toneClassName}
/>
</div>
<div className={`${signalPresentation.toneClassName} text-design-20`}>
{signalPresentation.latencyLabel}{' '}
<span className={'text-design-16'}>ms</span>
</div>
</div>
<div className="flex h-full w-design-175 flex-col items-center justify-center gap-design-5 border-r border-[rgba(128,223,231,0.65)]">
<div>System Time</div>
<div>20:05:12 GMT+08</div>
<div>{t('gameDesktop.header.systemTime')}</div>
<div>{systemTimeLabel}</div>
</div>
<div className="flex h-full flex-1 items-center justify-around gap-design-10 px-design-40 text-[#D5FBFF] border-r border-[rgba(128,223,231,0.65)]">
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<div className="flex h-full flex-1 items-center justify-around gap-design-10 border-r border-[rgba(128,223,231,0.65)] px-design-20">
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<CircleAlert color={'#57B8BF'} size={16} />
<div>Rules & Ddds</div>
<div>{t('gameDesktop.header.rules')}</div>
</div>
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<Mail color={'#57B8BF'} size={16} />
<div>Pesan</div>
<div>{t('gameDesktop.header.message')}</div>
</div>
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<Volume2 color={'#57B8BF'} size={16} />
<div>BGM</div>
<div>{t('gameDesktop.header.bgm')}</div>
</div>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.id')}</div>
</div>
<button
type="button"
onClick={handleFullscreenToggle}
className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85"
>
{isFullscreen ? (
<Minimize color={'#57B8BF'} size={16} />
) : (
<Maximize color={'#57B8BF'} size={16} />
)}
<div>{t('gameDesktop.header.fullscreen')}</div>
</button>
</div>
{authStatus === 'authenticated' ? (
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
'flex items-center justify-center gap-design-30 pl-design-30 pr-design-10'
}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>ID</div>
</div>
</div>
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute -left-5 z-20 h-design-50 w-design-50"
/>
<div
className={
'common-neon-inset text-design-16 !py-design-20 flex h-design-36 w-design-180 items-center justify-end'
}
>
{currentUser?.username || '--'}
</div>
</div>
<div className={'flex items-center justify-center px-design-35'}>
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
/>
<div
className={
'common-neon-inset !py-design-20 flex h-design-36 w-design-160 items-center justify-end'
}
>
Biomond Balance
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={diamond}
alt="diamond"
priority
className="absolute -left-5 z-20 h-design-50 w-design-50"
/>
<div
className={
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-design-180 items-center justify-end'
}
>
{currentUser?.coin || '--'}
</div>
</div>
</div>
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
/>
<div
) : (
<div
className={
'flex items-center justify-center gap-design-30 pl-design-30 pr-design-10'
}
>
<button
type="button"
className={
'common-neon-inset !py-design-20 box-border flex h-design-36 w-design-160 items-center justify-end'
'min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85'
}
onClick={() => setModalOpen('desktopLogin', true)}
>
Biomond Balance
</div>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.login')}</div>
</button>
<button
type="button"
className={
'min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85'
}
onClick={() => setModalOpen('desktopRegister', true)}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.register')}</div>
</button>
</div>
</div>
)}
</div>
</header>
)

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
@@ -6,6 +7,7 @@ import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.t
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
export function DesktopStatusLine() {
const { t } = useTranslation()
const {
countdownMs,
limitLabel,
@@ -27,9 +29,15 @@ export function DesktopStatusLine() {
<div
className={'flex-1 flex items-center justify-center gap-design-24'}
>
<div>Odds: {oddsLabel}</div>
<div>Streak: {streakLabel}</div>
<div>Limit: {limitLabel}</div>
<div>
{t('gameDesktop.status.odds')}: {oddsLabel}
</div>
<div>
{t('gameDesktop.status.streak')}: {streakLabel}
</div>
<div>
{t('gameDesktop.status.limit')}: {limitLabel}
</div>
</div>
<SmartBackground
src={statusCenter}
@@ -44,7 +52,9 @@ export function DesktopStatusLine() {
/>
</SmartBackground>
<div className={'flex-1 flex items-center justify-center gap-10'}>
<div>Round ID:{roundId}</div>
<div>
{t('gameDesktop.status.roundId')}:{roundId}
</div>
<div className={'flex items-center gap-2'}>
<div className={'flex items-center gap-2'}>
<div

View File

@@ -1,12 +1,12 @@
import { Megaphone } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export function DesktopTitle() {
const { t } = useTranslation()
return (
<section className="common-neon-inset text-design-16 w-full flex h-design-50 items-end gap-design-10 !px-design-20 text-[#FF970F]">
<Megaphone color={'#57B8BF'} />
<div>
Selamat kepada pemain Wu Yanzu yang telah memenangkan hadiah utama
sebesar 5.000 yuan sebanyak lima kali berturut-turut!🎉🎉🎉
</div>
<div>{t('gameDesktop.title.announcement')}</div>
</section>
)
}

View File

@@ -1,5 +1,9 @@
import { useTranslation } from 'react-i18next'
function DesktopTopup() {
return <div>DesktopTopup</div>
const { t } = useTranslation()
return <div>{t('gameDesktop.topup.placeholder')}</div>
}
export default DesktopTopup

View File

@@ -1,5 +1,6 @@
import { Minus, Plus } from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { useTranslation } from 'react-i18next'
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
@@ -148,10 +149,12 @@ function WithdrawField({
function AmountShell({
amount,
availableBalanceText,
onMinus,
onPlus,
}: {
amount: number
availableBalanceText: string
onMinus: () => void
onPlus: () => void
}) {
@@ -180,7 +183,7 @@ function AmountShell({
</div>
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
Saldo Tersedia: {formatNumber(AVAILABLE_BALANCE)}
{availableBalanceText}
</div>
</div>
)
@@ -355,6 +358,7 @@ function PreviewRow({
}
function DesktopWithdraw() {
const { t } = useTranslation()
const [amount, setAmount] = useState(6626)
const [currency, setCurrency] =
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
@@ -388,15 +392,24 @@ function DesktopWithdraw() {
>
<div className="flex min-h-full min-w-0 flex-[1.7] flex-col px-design-16 py-design-14">
<div className="flex flex-col gap-design-12">
<WithdrawField label="Jumlah Penarikan Berlian">
<WithdrawField
label={t('gameDesktop.withdraw.fields.diamondWithdrawalAmount')}
>
<AmountShell
amount={amount}
availableBalanceText={t(
'gameDesktop.withdraw.availableBalance',
{ amount: formatNumber(AVAILABLE_BALANCE) },
)}
onMinus={() => handleAmountChange(amount - 1)}
onPlus={() => handleAmountChange(amount + 1)}
/>
</WithdrawField>
<WithdrawField label="Jenis Mata Uang" alignStart={false}>
<WithdrawField
label={t('gameDesktop.withdraw.fields.currencyType')}
alignStart={false}
>
<Select
value={currency}
onValueChange={(value) =>
@@ -405,9 +418,11 @@ function DesktopWithdraw() {
>
<SelectTrigger
className="h-design-52 w-full rounded-[calc(var(--design-unit)*6)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-16 text-left text-design-20 font-semibold text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-52 [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
aria-label="Currency selection"
aria-label={t('gameDesktop.withdraw.currencySelection')}
>
<SelectValue placeholder="Select currency" />
<SelectValue
placeholder={t('gameDesktop.withdraw.selectCurrency')}
/>
</SelectTrigger>
<SelectContent
position="popper"
@@ -441,7 +456,9 @@ function DesktopWithdraw() {
</div>
</div>
<WithdrawField label="Saluran Pembayaran">
<WithdrawField
label={t('gameDesktop.withdraw.fields.paymentChannel')}
>
<div className="flex flex-wrap gap-design-10">
{PAYMENT_CHANNELS.map((channel) => (
<PaymentCard
@@ -455,7 +472,7 @@ function DesktopWithdraw() {
</div>
</WithdrawField>
<WithdrawField label="Kode Bank">
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
<div className="flex flex-col gap-design-10">
<div className="flex h-design-40 items-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(12,61,72,0.78),rgba(6,28,39,0.88))] px-design-12 text-design-15 uppercase tracking-[0.02em] text-[#A4EAF2] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.07)]">
{`014${selectedBank?.label ?? 'BCA'} (${selectedBank?.subtitle ?? 'BANK CENTRAL ASIA'}): 014`}
@@ -475,40 +492,62 @@ function DesktopWithdraw() {
</div>
</WithdrawField>
<WithdrawField label="Nama Pemegang Kartu">
<WithdrawField
label={t('gameDesktop.withdraw.fields.cardHolderName')}
>
<InputShell
value={holderName}
onChange={setHolderName}
placeholder="Mohon masukkan nama pemegang kartu."
placeholder={t(
'gameDesktop.withdraw.placeholders.cardHolderName',
)}
error={holderNameError}
errorMessage="Mohon masukkan nama pemegang kartu."
errorMessage={t(
'gameDesktop.withdraw.errors.cardHolderNameRequired',
)}
/>
</WithdrawField>
<WithdrawField label="Nomor Rekening Bank">
<WithdrawField
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
>
<InputShell
value={bankAccount}
onChange={setBankAccount}
placeholder="Silakan masukkan nomor rekening bank Anda."
placeholder={t(
'gameDesktop.withdraw.placeholders.bankAccountNumber',
)}
error={bankAccountError}
errorMessage="Silakan masukkan nomor rekening bank Anda."
errorMessage={t(
'gameDesktop.withdraw.errors.bankAccountRequired',
)}
/>
</WithdrawField>
<WithdrawField label="Email Penerima" alignStart={false}>
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverEmail')}
alignStart={false}
>
<InputShell
value={receiverEmail}
onChange={setReceiverEmail}
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverEmail',
)}
uppercase={true}
/>
</WithdrawField>
<WithdrawField label="Nomor Ponsel Penerima" alignStart={false}>
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverPhone')}
alignStart={false}
>
<InputShell
value={receiverPhone}
onChange={setReceiverPhone}
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverPhone',
)}
uppercase={true}
/>
</WithdrawField>
@@ -519,67 +558,81 @@ function DesktopWithdraw() {
<div className="flex min-h-full min-w-0 w-design-520 shrink-0 flex-col">
<div className="flex h-design-44 items-center border-b border-[rgba(89,209,223,0.2)] bg-[linear-gradient(90deg,rgba(18,99,110,0.8),rgba(7,68,79,0.9))] px-design-12 text-design-20 font-semibold uppercase tracking-[0.04em] text-[#9AF5FB]">
Pratinjau Penukaran
{t('gameDesktop.withdraw.preview.title')}
</div>
<div className="flex flex-1 flex-col gap-design-12 px-design-10 py-design-10">
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
<PreviewRow label="Jumlah Berlian" value={formatNumber(amount)} />
<PreviewRow
label="Kurs (MYR)"
value={`${100 * MYR_PER_100_DIAMONDS} BERLIAN = 1 MYR`}
label={t('gameDesktop.withdraw.preview.diamondAmount')}
value={formatNumber(amount)}
/>
<PreviewRow
label="Dapat Ditukarkan MYR"
label={t('gameDesktop.withdraw.preview.rateMyr')}
value={t('gameDesktop.withdraw.preview.rateMyrValue', {
diamonds: 100 * MYR_PER_100_DIAMONDS,
})}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertibleMyr')}
value={`RM ${formatFixedTwo(withdrawMyr)}`}
highlight={true}
/>
<PreviewRow
label="Nilai Tukar USDT/MYR"
value={`1 USDT = RM ${USDT_TO_MYR_RATE}`}
label={t('gameDesktop.withdraw.preview.usdtMyrRate')}
value={t('gameDesktop.withdraw.preview.usdtMyrRateValue', {
rate: USDT_TO_MYR_RATE,
})}
/>
<PreviewRow
label="Nilai Tukar (VND)"
value={`${VND_PER_DIAMOND} BERLIAN = 1 VND`}
label={t('gameDesktop.withdraw.preview.rateVnd')}
value={t('gameDesktop.withdraw.preview.rateVndValue', {
diamonds: VND_PER_DIAMOND,
})}
/>
<PreviewRow
label="Dapat Dikonversi ke VND"
label={t('gameDesktop.withdraw.preview.convertibleVnd')}
value={`${formatNumber(withdrawVnd)} VND`}
highlight={true}
/>
<PreviewRow
label="Dapat Ditukarkan dengan USDT"
label={t('gameDesktop.withdraw.preview.convertibleUsdt')}
value={`${formatFixedSix(withdrawUsdt)} USDT`}
highlight={true}
/>
<PreviewRow
label="Jumlah Berlian Nilai Tukar Tetap"
label={t(
'gameDesktop.withdraw.preview.fixedExchangeDiamondAmount',
)}
value="0-0-0 0:0:0"
/>
</div>
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-12 py-design-10 text-design-16 leading-[1.35] text-[#F0B44A]">
Nilai tukar berfungsi sebagai harga acuan; nilai tukar aktual yang
berlaku ditentukan pada saat penarikan.
{t('gameDesktop.withdraw.exchangeRateNotice')}
</div>
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
<div>
Dompet Elektronik:{' '}
<span className="text-[#B9F4F8]">Minimal RM10</span>
{t('gameDesktop.withdraw.wallet')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumRm10')}
</span>
</div>
<div>
Bank: <span className="text-[#B9F4F8]">Minimal RM10</span>
{t('gameDesktop.withdraw.bank')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumRm10')}
</span>
</div>
<div>
Waktu Pengerjaan:{' '}
{t('gameDesktop.withdraw.processingTime')}:{' '}
<span className="text-[#77FF76]">
Dana Tiba Hanya Dalam 9 Detik.
{t('gameDesktop.withdraw.fundsArrivalTime')}
</span>
</div>
<div className="text-[#B9F4F8]">
Melihat: Transaksi antara RM10 dan RM99,99 akan dikenakan biaya
penarikan minimum sebesar RM1.
{t('gameDesktop.withdraw.feeNotice')}
</div>
</div>
@@ -591,7 +644,7 @@ function DesktopWithdraw() {
size="100% 100%"
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-18 font-bold uppercase tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
>
Membatalkan
{t('gameDesktop.withdraw.cancel')}
</SmartBackground>
<SmartBackground
as="button"
@@ -600,9 +653,9 @@ function DesktopWithdraw() {
size="100% 100%"
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
>
Konfirmasi
{t('gameDesktop.withdraw.confirm')}
<br />
Penarikan
{t('gameDesktop.withdraw.withdrawal')}
</SmartBackground>
</div>
</div>

View File

@@ -1,26 +1,34 @@
import { startTransition, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getMockGameBootstrap, getVisibleAnnouncements } from '@/features/game'
import { getGameLobbyInit, getVisibleAnnouncements } from '@/features/game'
import { GameAnnouncementModal } from '@/features/game/components'
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
import { useGameRealtimeSync } from '@/features/game/hooks/use-game-realtime-sync.ts'
import { useDocumentMetadata } from '@/lib/head/document-metadata'
import { notify } from '@/lib/notify'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const ENABLE_ANNOUNCEMENT_MODAL = false
export function EntryPage() {
const { t } = useTranslation()
useGameRealtimeSync()
const announcements = useGameSessionStore((state) => state.announcements)
const dismissAnnouncement = useGameSessionStore(
(state) => state.dismissAnnouncement,
)
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
const selectChip = useGameRoundStore((state) => state.selectChip)
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
const markAnnouncementRead = useGameSessionStore(
(state) => state.markAnnouncementRead,
)
const syncConnection = useGameSessionStore((state) => state.syncConnection)
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const authStatus = useAuthStore((state) => state.status)
const [isHydrating, setIsHydrating] = useState(true)
const [isMobile, setIsMobile] = useState(() => {
@@ -49,33 +57,89 @@ export function EntryPage() {
useEffect(() => {
let cancelled = false
void getMockGameBootstrap().then((snapshot) => {
if (cancelled) {
return
}
void getGameLobbyInit()
.then((result) => {
if (cancelled) {
return
}
startTransition(() => {
hydrateRound({
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
startTransition(() => {
const snapshot = result.snapshot
hydrateRound({
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
})
const defaultChipId =
snapshot.chips.find((chip) => chip.isDefault)?.id ?? null
if (defaultChipId) {
selectChip(defaultChipId)
}
hydrateSession({
announcements: snapshot.announcements,
connection: snapshot.connection,
dashboard: snapshot.dashboard,
})
const currentUser = useAuthStore.getState().currentUser
if (currentUser) {
setCurrentUser({
...currentUser,
coin: result.userSnapshot.coin,
currentStreak: result.userSnapshot.current_streak,
isJackpot: result.userSnapshot.is_jackpot,
oddsFactor: result.userSnapshot.odds_factor,
streakLevel: result.userSnapshot.streak_level,
})
}
setIsHydrating(false)
})
hydrateSession({
announcements: snapshot.announcements,
connection: snapshot.connection,
dashboard: snapshot.dashboard,
})
setIsHydrating(false)
})
})
.catch((error) => {
console.error('Failed to load game lobby init', error)
if (!cancelled) {
if (authStatus === 'authenticated') {
notify.error(t('commonUi.toast.lobbyInitFailed'), {
description: error instanceof Error ? error.message : undefined,
})
}
syncConnection({
connectedAt: null,
lastError:
error instanceof Error
? error.message
: 'Failed to load game lobby init',
lastMessageAt: null,
latencyMs: null,
status: 'disconnected',
transport: 'offline',
})
setIsHydrating(false)
}
})
return () => {
cancelled = true
}
}, [hydrateRound, hydrateSession])
}, [
authStatus,
hydrateRound,
hydrateSession,
selectChip,
setCurrentUser,
syncConnection,
t,
])
useEffect(() => {
if (typeof window === 'undefined') {

View File

@@ -1,3 +1,7 @@
import { useTranslation } from 'react-i18next'
export function MobileEntry() {
return <div>mobile component entry</div>
const { t } = useTranslation()
return <div>{t('gameDesktop.mobile.placeholder')}</div>
}

View File

@@ -4,6 +4,11 @@ import { DesktopControl } from '@/features/game/components/desktop/desktop-contr
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
export function PcEntry() {
@@ -38,20 +43,13 @@ export function PcEntry() {
>
<DesktopControl />
</div>
{/*登录弹窗*/}
{/*<DesktopLoginModal />*/}
{/*注册弹窗 */}
{/*<DesktopRegisterModal />*/}
{/* 用户信息弹窗 */}
{/*<DesktopUserInfoModal />*/}
{/*公告弹窗*/}
{/*<DesktopNoticeModal />*/}
{/*自动托管弹窗*/}
<DesktopLoginModal />
<DesktopRegisterModal />
<DesktopUserInfoModal />
<DesktopNoticeModal />
<DesktopAutoSettingModal />
{/* 充值提现前置选择弹窗*/}
{/*<DesktopProceduresModal />*/}
{/* 充值和提现弹窗 */}
{/*<DesktopWithdrawTopupModal/>*/}
<DesktopProceduresModal />
<DesktopWithdrawTopupModal />
</>
)
}

View File

@@ -1,30 +1,43 @@
import { useMemo } from 'react'
import { CHIP_OPTIONS } from '@/constants'
import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants'
import { selectSelectionTotal, useGameRoundStore } from '@/store/game'
const CHIP_IMAGE_MAP = new Map(
CHIP_OPTIONS.map((chip) => [chip.value, chip.src] as const),
)
function formatChipDisplayValue(amount: number) {
if (Number.isInteger(amount)) {
return String(amount)
}
return amount.toFixed(2).replace(/\.?0+$/, '')
}
export function useGameControlVm() {
const chips = useGameRoundStore((state) => state.chips)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount,
)
const selections = useGameRoundStore((state) => state.selections)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const selectChip = useGameRoundStore((state) => state.selectChip)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const chipItems = useMemo(
() =>
chips.map((chip) => ({
amount: chip.amount,
id: chip.id,
isSelected: chip.id === activeChipId,
src: CHIP_IMAGE_MAP.get(chip.amount) ?? CHIP_OPTIONS[0]?.src ?? '',
valueLabel: String(chip.amount),
})),
[activeChipId, chips],
)
const chipItems = useMemo(() => {
const items = chips.map((chip) => ({
amount: chip.amount,
id: chip.id,
isSelected: chip.id === activeChipId,
src: CHIP_IMAGE_MAP.get(chip.id) ?? CHIP_IMAGE_OPTIONS[0]?.src ?? '',
valueLabel: formatChipDisplayValue(chip.amount),
}))
return items.sort((left, right) => {
if (left.isSelected === right.isSelected) {
return left.id.localeCompare(right.id, undefined, { numeric: true })
}
return left.isSelected ? 1 : -1
})
}, [activeChipId, chips])
const selectedChip =
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
@@ -33,10 +46,11 @@ export function useGameControlVm() {
canClear: selections.length > 0,
onChipSelect: selectChip,
onClearSelections: clearSelections,
maxSelectionCountLabel: maxSelectionCount,
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
selectedChipId: activeChipId,
selectedCountLabel: `${selections.length}/5`,
totalBetAmountLabel: String(totalBetAmount),
selectedCountLabel: selections.length,
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
chips: chipItems,
}
}

View File

@@ -1,14 +1,20 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useGameRoundStore } from '@/store/game'
import { useTranslation } from 'react-i18next'
function formatSettledTime(iso: string) {
const date = new Date(iso)
import { getGameBetMyOrders } from '@/features/game/api/game-api'
import { useAuthStore } from '@/store/auth'
const GAME_HISTORY_PAGE_SIZE = 20
function formatCreatedTime(timestamp: number, locale: string) {
const date = new Date(timestamp * 1000)
if (Number.isNaN(date.getTime())) {
return '--'
}
return date.toLocaleString('zh-CN', {
return date.toLocaleString(locale, {
hour12: false,
month: '2-digit',
day: '2-digit',
@@ -18,26 +24,70 @@ function formatSettledTime(iso: string) {
})
}
function formatNumbers(numbers: number[]) {
if (numbers.length === 0) {
return '--'
}
return numbers.map((number) => String(number).padStart(2, '0')).join(', ')
}
export function useGameHistoryVm() {
const history = useGameRoundStore((state) => state.history)
const { i18n, t } = useTranslation()
const accessToken = useAuthStore((state) => state.accessToken)
const authStatus = useAuthStore((state) => state.status)
const query = useInfiniteQuery({
queryKey: ['game', 'bet-my-orders', accessToken],
enabled: authStatus === 'authenticated' && Boolean(accessToken),
initialPageParam: 1,
queryFn: ({ pageParam }) =>
getGameBetMyOrders({
page: pageParam,
pageSize: GAME_HISTORY_PAGE_SIZE,
}),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.pagination.page + 1
const loadedCount =
lastPage.pagination.page * lastPage.pagination.page_size
return loadedCount < lastPage.pagination.total ? nextPage : undefined
},
})
const items = useMemo(
() =>
history.map((entry) => ({
id: entry.roundId,
payoutMultiplierLabel: `${entry.payoutMultiplier}x`,
roundId: entry.roundId,
settledAtLabel: formatSettledTime(entry.settledAt),
statusLabel: 'settled',
totalPoolAmountLabel: entry.totalPoolAmount.toFixed(2),
winningCellIdLabel: String(entry.winningCellId),
})),
[history],
(query.data?.pages ?? []).flatMap((page) =>
page.list.map((entry) => ({
amountLabel: entry.total_amount,
createdAtLabel: formatCreatedTime(
entry.create_time,
i18n.resolvedLanguage ?? 'en-US',
),
id: entry.order_no,
numbersLabel: formatNumbers(entry.numbers),
orderNo: entry.order_no,
periodNo: entry.period_no,
resultNumberLabel:
entry.result_number === null
? '--'
: String(entry.result_number).padStart(2, '0'),
statusLabel: entry.status,
winAmountLabel: entry.win_amount,
})),
),
[i18n.resolvedLanguage, query.data?.pages],
)
return {
emptyText: 'No history yet',
isEmpty: items.length === 0,
emptyText: t('gameDesktop.history.empty'),
endText: t('gameDesktop.history.end'),
fetchNextPage: query.fetchNextPage,
hasNextPage: query.hasNextPage,
isEmpty: authStatus !== 'authenticated' || items.length === 0,
isFetchingNextPage: query.isFetchingNextPage,
isInitialLoading: query.isLoading,
items,
loadingText: t('gameDesktop.history.loading'),
}
}

View File

@@ -0,0 +1,465 @@
import { useEffect, useRef } from 'react'
import i18n from '@/i18n'
import { prefetchAuthToken } from '@/lib/api/api-client'
import {
GameSocketClient,
type GameSocketMessage,
} from '@/lib/ws/game-socket-client'
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
import { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api'
import type { GameLobbyUserSnapshotDto, GamePeriodTickDto } from '../api/types'
const FALLBACK_POLL_INTERVAL_MS = 10_000
const GAME_SOCKET_TOPICS = {
// 对局状态心跳。每秒推送当前期号、状态、倒计时、runtime_enabled 等。
periodTick: 'period.tick',
// 本期封盘通知。用于前端立即停止下注。
periodLocked: 'period.locked',
// 本期开奖通知。用于同步开奖号码、所属期号等阶段结果。
periodOpened: 'period.opened',
// 本期派彩完成通知。用于结算阶段同步。
periodPayout: 'period.payout',
// 当前玩家连胜与赔率信息。通常在结算后或演示帧刷新。
userStreak: 'user.streak',
// 下注成功通知。仅当前用户可见,通常伴随扣款结果。
betAccepted: 'bet.accepted',
// 余额变化通知。充值、下注、派彩都会走这条流。
walletChanged: 'wallet.changed',
// 自动托管进度通知。包含托管开关、执行状态等。
autoSpinProgress: 'auto.spin.progress',
// 大奖命中通知。仅当本期存在中大奖用户时推送。
jackpotHit: 'jackpot.hit',
// 后台实时页全量快照。仅 admin live 页面使用,当前 H5 前台不订阅。
adminLiveSnapshot: 'admin.live.snapshot',
// 后台开奖结果通知。仅 admin live 页面使用,当前 H5 前台不订阅。
adminLiveOpened: 'admin.live.opened',
} as const
// 当前 H5 游戏页实际需要的用户侧事件。
// 后台专用事件保持在 GAME_SOCKET_TOPICS 中做口径对齐,但不在这里订阅。
const PLAYER_SOCKET_TOPICS = [
GAME_SOCKET_TOPICS.periodTick,
GAME_SOCKET_TOPICS.userStreak,
GAME_SOCKET_TOPICS.periodOpened,
GAME_SOCKET_TOPICS.periodLocked,
GAME_SOCKET_TOPICS.periodPayout,
GAME_SOCKET_TOPICS.betAccepted,
GAME_SOCKET_TOPICS.walletChanged,
GAME_SOCKET_TOPICS.autoSpinProgress,
GAME_SOCKET_TOPICS.jackpotHit,
] as const
const SOCKET_DISCONNECT_DELAY_MS = 150
let sharedSocketClient: GameSocketClient | null = null
let sharedSocketKey: string | null = null
let sharedSocketDisconnectTimerId: number | null = null
function toIsoFromUnixSeconds(seconds: number) {
return new Date(seconds * 1000).toISOString()
}
function toSocketLang(language: string | null | undefined) {
return language?.startsWith('zh') ? 'zh' : 'en'
}
function toOptionalNumber(value: unknown) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
return undefined
}
function getNestedRecord(
value: unknown,
key: string,
): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null
}
const nested = (value as Record<string, unknown>)[key]
return nested && typeof nested === 'object'
? (nested as Record<string, unknown>)
: null
}
function extractServerTime(message: GameSocketMessage) {
const root = message as Record<string, unknown>
if (typeof root.server_time === 'number') {
return root.server_time
}
const data = getNestedRecord(message, 'data')
return typeof data?.server_time === 'number' ? data.server_time : null
}
function extractUserSnapshot(
message: GameSocketMessage,
): GameLobbyUserSnapshotDto | null {
const direct = getNestedRecord(message, 'user_snapshot')
const nested = getNestedRecord(
getNestedRecord(message, 'data'),
'user_snapshot',
)
const source = direct ?? nested
if (
!source ||
typeof source.coin !== 'string' ||
typeof source.current_streak !== 'number'
) {
return null
}
return {
coin: source.coin,
current_streak: source.current_streak,
is_jackpot:
typeof source.is_jackpot === 'boolean' ? source.is_jackpot : undefined,
odds_factor: toOptionalNumber(source.odds_factor),
streak_level: toOptionalNumber(source.streak_level),
}
}
function extractPeriodTick(
message: GameSocketMessage,
): GamePeriodTickDto | null {
const data = getNestedRecord(message, 'data')
const nested = getNestedRecord(data, 'period')
const source = nested ?? data
if (
!source ||
typeof source.period_no !== 'string' ||
typeof source.status !== 'string' ||
typeof source.countdown !== 'number' ||
typeof source.bet_close_in !== 'number'
) {
return null
}
return {
bet_close_in: source.bet_close_in,
countdown: source.countdown,
period_id: typeof source.period_id === 'number' ? source.period_id : null,
period_no: source.period_no,
result_number:
typeof source.result_number === 'number' ? source.result_number : null,
runtime_enabled:
typeof source.runtime_enabled === 'boolean'
? source.runtime_enabled
: true,
server_time:
typeof source.server_time === 'number'
? source.server_time
: Math.floor(Date.now() / 1000),
status: source.status as GamePeriodTickDto['status'],
}
}
function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
const currentRoundState = useGameRoundStore.getState()
const currentSessionState = useGameSessionStore.getState()
useGameRoundStore.getState().hydrateRound({
cells: result.snapshot.cells,
chips: result.snapshot.chips,
history: currentRoundState.history,
maxSelectionCount: result.snapshot.maxSelectionCount,
round: currentRoundState.round,
selections: currentRoundState.selections,
trends: currentRoundState.trends,
})
useGameSessionStore.getState().hydrateSession({
announcements: result.snapshot.announcements,
connection: {
...result.snapshot.connection,
status: 'connected',
transport: 'polling',
},
dashboard: {
...currentSessionState.dashboard,
tableLimitMax: result.snapshot.dashboard.tableLimitMax,
tableLimitMin: result.snapshot.dashboard.tableLimitMin,
},
})
const currentUser = useAuthStore.getState().currentUser
if (currentUser) {
useAuthStore.getState().setCurrentUser({
...currentUser,
coin: result.userSnapshot.coin,
currentStreak: result.userSnapshot.current_streak,
isJackpot: result.userSnapshot.is_jackpot,
oddsFactor: result.userSnapshot.odds_factor,
streakLevel: result.userSnapshot.streak_level,
})
}
}
function applyRealtimeMessage(message: GameSocketMessage) {
const serverTime = extractServerTime(message)
const period = extractPeriodTick(message)
const userSnapshot = extractUserSnapshot(message)
if (period) {
const previousRound = useGameRoundStore.getState().round
const round = normalizePeriodTickRound(
{
...period,
server_time: serverTime ?? period.server_time,
},
previousRound,
)
useGameRoundStore.getState().syncRound({
bettingClosesAt: round.bettingClosesAt,
id: round.id,
phase: round.phase,
revealingAt: round.revealingAt,
settledAt: round.settledAt,
startedAt: round.startedAt,
winningCellId: round.winningCellId,
})
useGameSessionStore.getState().syncDashboard({
countdownMs: period.countdown * 1000,
updatedAt:
serverTime !== null
? toIsoFromUnixSeconds(serverTime)
: toIsoFromUnixSeconds(period.server_time),
})
}
if (userSnapshot) {
const currentUser = useAuthStore.getState().currentUser
if (currentUser) {
useAuthStore.getState().setCurrentUser({
...currentUser,
coin: userSnapshot.coin,
currentStreak: userSnapshot.current_streak,
isJackpot: userSnapshot.is_jackpot,
oddsFactor: userSnapshot.odds_factor,
streakLevel: userSnapshot.streak_level,
})
}
}
useGameSessionStore.getState().syncConnection({
lastMessageAt:
serverTime !== null
? toIsoFromUnixSeconds(serverTime)
: new Date().toISOString(),
})
}
export function useGameRealtimeSync() {
const accessToken = useAuthStore((state) => state.accessToken)
const authStatus = useAuthStore((state) => state.status)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
const socketClientRef = useRef<GameSocketClient | null>(null)
useEffect(() => {
if (sharedSocketDisconnectTimerId !== null) {
window.clearTimeout(sharedSocketDisconnectTimerId)
sharedSocketDisconnectTimerId = null
}
if (
!shouldConnectRealtime ||
authStatus !== 'authenticated' ||
!accessToken
) {
sharedSocketDisconnectTimerId = window.setTimeout(() => {
sharedSocketClient?.disconnect()
sharedSocketClient = null
sharedSocketKey = null
sharedSocketDisconnectTimerId = null
}, SOCKET_DISCONNECT_DELAY_MS)
socketClientRef.current = sharedSocketClient
return
}
const websocketUrl = import.meta.env.VITE_WEBSOCKET_URL?.trim() || null
const socketKey = `${websocketUrl ?? ''}::${accessToken}`
if (sharedSocketClient && sharedSocketKey === socketKey) {
socketClientRef.current = sharedSocketClient
return () => {
sharedSocketDisconnectTimerId = window.setTimeout(() => {
sharedSocketClient?.disconnect()
sharedSocketClient = null
sharedSocketKey = null
sharedSocketDisconnectTimerId = null
}, SOCKET_DISCONNECT_DELAY_MS)
}
}
sharedSocketClient?.disconnect()
const socketClient = new GameSocketClient({
getContext: async () => {
await prefetchAuthToken()
const authToken = useAuthStore.getState().apiAuthToken
if (!authToken) {
return null
}
return {
token: accessToken,
authToken,
deviceId: getAuthDeviceId(),
lang: toSocketLang(i18n.resolvedLanguage),
}
},
getUrl: () => websocketUrl,
onError: (error) => {
useGameSessionStore.getState().syncConnection({
lastError:
'message' in error && typeof error.message === 'string'
? error.message
: 'WebSocket error',
})
},
onLatencyChange: (latencyMs) => {
useGameSessionStore.getState().syncConnection({
latencyMs,
})
},
onMessage: (message) => {
if (message.event === 'ws.connected') {
const serverTime = extractServerTime(message)
useGameSessionStore.getState().syncConnection({
connectedAt:
serverTime !== null
? toIsoFromUnixSeconds(serverTime)
: new Date().toISOString(),
lastError: null,
lastMessageAt:
serverTime !== null
? toIsoFromUnixSeconds(serverTime)
: new Date().toISOString(),
reconnectAttempt: 0,
status: 'connected',
transport: 'websocket',
})
}
applyRealtimeMessage(message)
},
onStatusChange: (status, reconnectAttempt) => {
const mappedStatus =
status === 'idle'
? 'idle'
: status === 'connected'
? 'connected'
: status
useGameSessionStore.getState().syncConnection({
latencyMs: mappedStatus === 'connected' ? undefined : null,
reconnectAttempt,
status: mappedStatus,
transport: websocketUrl ? 'websocket' : 'polling',
})
},
})
sharedSocketClient = socketClient
sharedSocketKey = socketKey
socketClientRef.current = socketClient
socketClient.subscribe([...PLAYER_SOCKET_TOPICS])
void socketClient.connect()
return () => {
sharedSocketDisconnectTimerId = window.setTimeout(() => {
if (sharedSocketClient === socketClient) {
socketClient.disconnect()
sharedSocketClient = null
sharedSocketKey = null
}
sharedSocketDisconnectTimerId = null
}, SOCKET_DISCONNECT_DELAY_MS)
socketClientRef.current = sharedSocketClient
}
}, [accessToken, authStatus, shouldConnectRealtime])
useEffect(() => {
if (!shouldConnectRealtime || authStatus !== 'authenticated') {
return
}
let cancelled = false
let intervalId = 0
const pollLobbyState = async () => {
const connection = useGameSessionStore.getState().connection
if (
connection.status === 'connected' &&
connection.transport === 'websocket'
) {
return
}
const startedAt = Date.now()
try {
const result = await getGameLobbyInit()
if (cancelled) {
return
}
applyLobbySync(result)
useGameSessionStore.getState().syncConnection({
lastError: null,
latencyMs: Date.now() - startedAt,
status: 'connected',
transport: 'polling',
})
} catch (error) {
if (cancelled) {
return
}
useGameSessionStore.getState().syncConnection({
lastError: error instanceof Error ? error.message : 'Polling failed',
status: 'reconnecting',
transport: 'polling',
})
}
}
intervalId = window.setInterval(() => {
void pollLobbyState()
}, FALLBACK_POLL_INTERVAL_MS)
void pollLobbyState()
return () => {
cancelled = true
window.clearInterval(intervalId)
}
}, [authStatus, shouldConnectRealtime])
}

View File

@@ -1,59 +1,67 @@
import { useMemo } from 'react'
import { getRoundCountdownMs } from '@/features/game/shared/selectors'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const PHASE_META = {
betting: {
description: '(Menerima Taruhan)',
label: 'OPEN',
descriptionKey: 'gameDesktop.status.phase.betting.description',
labelKey: 'gameDesktop.status.phase.betting.label',
toneClassName: 'text-[#78FF7F]',
},
locked: {
description: '(Taruhan Ditutup)',
label: 'LOCKED',
descriptionKey: 'gameDesktop.status.phase.locked.description',
labelKey: 'gameDesktop.status.phase.locked.label',
toneClassName: 'text-[#FFE375]',
},
revealing: {
description: '(Mengundi Hasil)',
label: 'DRAWING',
descriptionKey: 'gameDesktop.status.phase.revealing.description',
labelKey: 'gameDesktop.status.phase.revealing.label',
toneClassName: 'text-[#57E8FF]',
},
settled: {
description: '(Putaran Selesai)',
label: 'SETTLED',
descriptionKey: 'gameDesktop.status.phase.settled.description',
labelKey: 'gameDesktop.status.phase.settled.label',
toneClassName: 'text-[#FF9C6B]',
},
waiting: {
description: '(Menunggu Putaran Berikutnya)',
label: 'WAITING',
descriptionKey: 'gameDesktop.status.phase.waiting.description',
labelKey: 'gameDesktop.status.phase.waiting.label',
toneClassName: 'text-[#A7B6C7]',
},
} as const
export function useGameStatusVm() {
const { t } = useTranslation()
const cells = useGameRoundStore((state) => state.cells)
const round = useGameRoundStore((state) => state.round)
const trends = useGameRoundStore((state) => state.trends)
const dashboard = useGameSessionStore((state) => state.dashboard)
const currentUser = useAuthStore((state) => state.currentUser)
return useMemo(() => {
const oddsValue = cells[0]?.odds ?? '--'
const oddsValue =
typeof currentUser?.oddsFactor === 'number'
? currentUser.oddsFactor
: (cells[0]?.odds ?? '--')
const featuredTrend = trends.find(
(entry) => entry.cellId === dashboard.featuredCellId,
)
const phaseMeta = PHASE_META[round.phase]
const streakValue =
currentUser?.currentStreak ?? featuredTrend?.currentStreak ?? null
return {
acceptingBets: round.phase === 'betting',
countdownMs: getRoundCountdownMs(round),
countdownMs: dashboard.countdownMs,
limitLabel: `${dashboard.tableLimitMin}-${dashboard.tableLimitMax}`,
oddsLabel: `1:${oddsValue}`,
phase: round.phase,
phaseDescription: phaseMeta.description,
phaseLabel: phaseMeta.label,
phaseDescription: t(phaseMeta.descriptionKey),
phaseLabel: t(phaseMeta.labelKey),
phaseToneClassName: phaseMeta.toneClassName,
roundId: round.id,
streakLabel: featuredTrend ? `X${featuredTrend.currentStreak}` : '--',
roundId: round.id || '--',
streakLabel: typeof streakValue === 'number' ? `X${streakValue}` : '--',
}
}, [cells, dashboard, round, trends])
}, [cells, currentUser, dashboard, round, t, trends])
}

View File

@@ -1,33 +1,36 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { Input } from '@/components/ui/input.tsx'
import { Switch } from '@/components/ui/switch.tsx'
import { useModalStore } from '@/store'
const AUTO_STOP_ROWS = [
{
label: 'Stop if balance lower than',
labelKey: 'game.modals.autoSetting.rows.stopIfBalanceLowerThan',
value: '0',
checked: false,
},
{
label: 'Stop if single win exceeds',
labelKey: 'game.modals.autoSetting.rows.stopIfSingleWinExceeds',
value: '50000',
checked: true,
},
{
label: 'Stop on any Jackpot',
labelKey: 'game.modals.autoSetting.rows.stopOnAnyJackpot',
// value: '50000',
checked: false,
},
] as const
function DesktopAutoSettingModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopAutoSetting)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopAutoSetting', false)
}
return (
@@ -36,7 +39,7 @@ function DesktopAutoSettingModal() {
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26 uppercase'}>
Biomond Balance
{t('game.modals.autoSetting.title')}
</div>
}
isNormalBg={true}
@@ -51,7 +54,7 @@ function DesktopAutoSettingModal() {
<div className={'flex w-full flex-col gap-design-26'}>
{AUTO_STOP_ROWS.map((row) => (
<div
key={row.label}
key={row.labelKey}
className={'flex items-center justify-between gap-design-30'}
>
<div
@@ -59,10 +62,10 @@ function DesktopAutoSettingModal() {
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
}
>
{row.label}
{t(row.labelKey)}
</div>
{row.value ? (
{'value' in row ? (
<div
className={
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
@@ -95,7 +98,7 @@ function DesktopAutoSettingModal() {
'w-design-300 h-design-72 pb-design-4 flex items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF]'
}
>
START AUTO-SPIN
{t('game.modals.autoSetting.startAutoSpin')}
</SmartBackground>
</div>
</div>

View File

@@ -1,100 +1,28 @@
import { motion } from 'motion/react'
import { useState } from 'react'
import loginBg from '@/assets/system/login-bg.webp'
import rightImg from '@/assets/system/right.webp'
import { useTranslation } from 'react-i18next'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { Input } from '@/components/ui/input.tsx'
import { DesktopLoginForm } from '@/features/auth/components/desktop-login-form'
import { useModalStore } from '@/store'
function DesktopLoginModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopLogin)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopLogin', false)
}
return (
<CenterModal
open={open}
onClose={() => {}}
title={<div className={'modal-title-glow'}></div>}
onClose={() => setModalOpen('desktopLogin', false)}
title={
<div className={'modal-title-glow'}>{t('game.modals.login.title')}</div>
}
titleAlign="center"
className={'w-design-980 h-design-540'}
>
<div
className={
'flex flex-col items-center justify-between gap-design-20 px-design-20'
}
>
<div
className={
'h-design-375 flex flex-col gap-design-45 w-full bg-[#060B0F]/50 p-design-50'
}
>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Akun/TEL:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Silakan masukkan akun atau nomor ponsel Anda.'}
/>
</div>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Kata Sandi:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Masukkan Kata Sandi'}
/>
</div>
<div className={'flex items-center justify-around'}>
<div className={'flex items-center gap-design-10'}>
<div
className={
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
}
>
<SmartImage alt={'right'} src={rightImg} />
</div>
<div className={'text-[#549195]'}>Daftar Akun</div>
</div>
<div className={'flex items-center gap-design-10'}>
<div
className={
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
}
>
<SmartImage alt={'right'} src={rightImg} />
</div>
<div className={'text-[#549195]'}>Ingat Kata Sandi</div>
</div>
</div>
</div>
<SmartBackground
as={motion.div}
onClick={handleSubmit}
whileTap={{ scale: 0.95 }}
src={loginBg}
size="100% 100%"
className={
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer'
}
>
MASUK
</SmartBackground>
</div>
<DesktopLoginForm onSuccess={handleSubmit} />
</CenterModal>
)
}

View File

@@ -1,16 +1,19 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
import noticeBg from '@/assets/system/notice-bg.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { useModalStore } from '@/store'
function DesktopNoticeModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopNotice)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopNotice', false)
}
return (
@@ -19,7 +22,7 @@ function DesktopNoticeModal() {
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26'}>
PENGUMUMAN ACARA
{t('game.modals.notice.title')}
</div>
}
isNormalBg={true}
@@ -40,13 +43,7 @@ function DesktopNoticeModal() {
/>
<div className={'text-[#74B3BA] text-design-18 leading-[1.6]'}>
"Perjanjian Lisensi dan Layanan Game" (selanjutnya disebut sebagai
"Perjanjian ini") disepakati secara bersama-sama oleh Anda dan
Penyedia Layanan Game; Perjanjian ini merupakan kontrak yang
mengikat secara hukum. Anda sangat dianjurkan untuk membaca dengan
saksama dan memahami s epenuhnya isi dari setiap klausulkhususnya
klausul-klausul yang membebaskan atau membatasi tanggung jawab
(selanjutnya disebut sebagai "Klausul Pembebasan"),
{t('game.modals.notice.content')}
</div>
</div>
<div className={'w-full flex justify-around'}>
@@ -59,7 +56,7 @@ function DesktopNoticeModal() {
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
}
>
Memeriksa
{t('game.modals.notice.check')}
</SmartBackground>
<SmartBackground
@@ -71,7 +68,7 @@ function DesktopNoticeModal() {
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
}
>
Memeriksa
{t('game.modals.notice.check')}
</SmartBackground>
</div>
</div>

View File

@@ -1,15 +1,27 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import proceduresBg from '@/assets/system/procedures-bg.webp'
import topupBtnBg from '@/assets/system/topup.webp'
import withdrawBtnBg from '@/assets/system/withdraw.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { useModalStore } from '@/store'
function DesktopProceduresModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopProcedures)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const setWithdrawTopupType = useModalStore(
(state) => state.setWithdrawTopupType,
)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopProcedures', false)
}
function handleOpenWithdrawTopup(type: 'withdraw' | 'topup') {
setModalOpen('desktopProcedures', false)
setWithdrawTopupType(type)
setModalOpen('desktopWithdrawTopup', true)
}
return (
@@ -18,7 +30,7 @@ function DesktopProceduresModal() {
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26 uppercase'}>
Biomond Balance
{t('game.modals.procedures.title')}
</div>
}
isNormalBg={true}
@@ -33,23 +45,27 @@ function DesktopProceduresModal() {
'h-[95%] w-full rounded-md flex flex-col items-center justify-between'
}
>
<div className={'mt-design-190'}>111</div>
<div className={'mt-design-190'}>
{t('game.modals.procedures.contentPlaceholder')}
</div>
<div className={'flex items-center ml-design-180'}>
<SmartBackground
src={withdrawBtnBg}
onClick={() => handleOpenWithdrawTopup('withdraw')}
className={
'w-design-400 h-design-195 flex items-center justify-center pb-design-10 text-design-32 font-bold'
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-10 text-design-32 font-bold'
}
>
{t('game.modals.procedures.withdraw')}
</SmartBackground>
<SmartBackground
src={topupBtnBg}
onClick={() => handleOpenWithdrawTopup('topup')}
className={
'w-design-400 h-design-195 flex items-center justify-center pb-design-20 text-design-32 font-bold'
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-20 text-design-32 font-bold'
}
>
{t('game.modals.procedures.topup')}
</SmartBackground>
</div>
</SmartBackground>

View File

@@ -1,124 +1,30 @@
import { motion } from 'motion/react'
import { useState } from 'react'
import loginBg from '@/assets/system/login-bg.webp'
import rightImg from '@/assets/system/right.webp'
import { useTranslation } from 'react-i18next'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { Input } from '@/components/ui/input.tsx'
import { DesktopRegisterForm } from '@/features/auth/components/desktop-register-form'
import { useModalStore } from '@/store'
function DesktopRegisterModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopRegister)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopRegister', false)
}
return (
<CenterModal
open={open}
onClose={() => {}}
title={<div className={'modal-title-glow'}></div>}
onClose={() => setModalOpen('desktopRegister', false)}
title={
<div className={'modal-title-glow'}>
{t('game.modals.register.title')}
</div>
}
titleAlign="center"
className={'w-design-980 h-design-740'}
>
<div
className={'flex flex-col items-center justify-between px-design-20'}
>
<div
className={
'h-design-490 flex flex-col gap-design-30 w-full bg-[#060B0F]/50 p-design-50'
}
>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Akun/TEL:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Silakan masukkan akun atau nomor ponsel Anda.'}
/>
</div>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Kata Sandi:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Masukkan Kata Sandi'}
/>
</div>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Kata Sandi:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Masukkan Kata Sandi'}
/>
</div>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Kata Sandi:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Masukkan Kata Sandi'}
/>
</div>
<div className={'flex items-center justify-around'}>
<div className={'flex items-center gap-design-10'}>
<div
className={
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
}
>
<SmartImage alt={'right'} src={rightImg} />
</div>
<div className={'text-[#549195]'}>Daftar Akun</div>
</div>
<div className={'flex items-center gap-design-10'}>
<div
className={
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
}
>
<SmartImage alt={'right'} src={rightImg} />
</div>
<div className={'text-[#549195]'}>Ingat Kata Sandi</div>
</div>
</div>
</div>
<SmartBackground
as={motion.div}
onClick={handleSubmit}
whileTap={{ scale: 0.95 }}
src={loginBg}
size="100% 100%"
className={
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer'
}
>
MASUK
</SmartBackground>
</div>
<DesktopRegisterForm onSuccess={handleSubmit} />
</CenterModal>
)
}

View File

@@ -1,5 +1,6 @@
import { CircleUserRound, Mail } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp'
import blueBtnBg from '@/assets/system/blue-btn.webp'
import lengthBtnBg from '@/assets/system/length-blue-btn.webp'
@@ -8,32 +9,35 @@ import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'
type UserInfoTabKey = 'profile' | 'message'
const USER_INFO_TABS: Array<{
key: UserInfoTabKey
label: string
labelKey: string
icon: typeof CircleUserRound
}> = [
{
key: 'profile',
label: '个人信息',
labelKey: 'game.modals.userInfo.tabs.profile',
icon: CircleUserRound,
},
{
key: 'message',
label: '站内消息',
labelKey: 'game.modals.userInfo.tabs.message',
icon: Mail,
},
]
function DesktopUserInfoModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopUserInfo)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
function handleSubmit() {
setOpen(false)
setModalOpen('desktopUserInfo', false)
}
return (
@@ -41,7 +45,9 @@ function DesktopUserInfoModal() {
open={open}
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26'}>Biomond Balance</div>
<div className={'modal-title-glow text-design-26'}>
{t('game.modals.userInfo.title')}
</div>
}
isNormalBg={true}
titleAlign="left"
@@ -96,7 +102,7 @@ function DesktopUserInfoModal() {
isActive && 'modal-title-gold-glow',
)}
>
{tab.label}
{t(tab.labelKey)}
</div>
</button>
)
@@ -119,8 +125,12 @@ function DesktopUserInfoModal() {
alt={'avatar'}
/>
<div className={'flex flex-col gap-design-30'}>
<div>NAMA Biomond Balance</div>
<div>TEL 12345678901</div>
<div>
{t('game.modals.userInfo.profile.name')} Biomond Balance
</div>
<div>
{t('game.modals.userInfo.profile.tel')} 12345678901
</div>
</div>
</div>
@@ -128,7 +138,7 @@ function DesktopUserInfoModal() {
<div className={'flex flex-col gap-design-5'}>
{[1, 2, 3, 4].map((item) => (
<div key={item}>
Tanggal Pendaftaran
{t('game.modals.userInfo.profile.registeredAt')}
<span className={'text-design-18 text-[#599AA3]'}>
2022-10-06 2336
</span>
@@ -140,8 +150,7 @@ function DesktopUserInfoModal() {
'w-design-600 h-design-120 text-design-18 rounded-md bg-[#000000]/40 flex items-center justify-center'
}
>
Tanda tangan pribadi saya persis seperti jiwa sayaunik dan
mus
{t('game.modals.userInfo.profile.signature')}
</div>
</div>
</SmartBackground>
@@ -166,10 +175,7 @@ function DesktopUserInfoModal() {
<div className={'h-design-95 w-design-95 bg-black'}></div>
<div className={'flex-1'}>
<div>2026-10-10 08:32:56</div>
<div>
[Event Bonus Isi Ulang] Dari tanggal 1 hingga 7 Oktober
2026, dapatkan pengembalian ...
</div>
<div>{t('game.modals.userInfo.message.eventBonus')}</div>
</div>
<SmartBackground
src={blueBtnBg}
@@ -178,7 +184,7 @@ function DesktopUserInfoModal() {
'w-design-150 h-design-64 flex items-center justify-center text-design-20 font-bold'
}
>
Memeriksa
{t('game.modals.userInfo.message.check')}
</SmartBackground>
</div>
))}
@@ -196,7 +202,7 @@ function DesktopUserInfoModal() {
'w-design-275 h-design-65 flex items-center justify-center text-design-22 font-bold'
}
>
{t('game.modals.userInfo.message.deleteRecords')}
</SmartBackground>
</div>
</div>

View File

@@ -1,15 +1,17 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CenterModal } from '@/components/center-modal.tsx'
import DesktopTopup from '@/features/game/components/desktop/desktop-topup.tsx'
import DesktopWithdraw from '@/features/game/components/desktop/desktop-withdraw.tsx'
type WithdrawType = 'withdraw' | 'topup'
import { useModalStore } from '@/store'
function DesktopWithdrawTopupModal() {
const [open, setOpen] = useState(true)
const [type] = useState<WithdrawType>('withdraw')
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopWithdrawTopup)
const type = useModalStore((state) => state.withdrawTopupType)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopWithdrawTopup', false)
}
return (
@@ -18,7 +20,9 @@ function DesktopWithdrawTopupModal() {
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26 uppercase'}>
{type === 'withdraw' ? '申请提现' : '申请充值'}
{type === 'withdraw'
? t('game.modals.withdrawTopup.applyWithdraw')
: t('game.modals.withdrawTopup.applyTopup')}
</div>
}
isNormalBg={true}

View File

@@ -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

View File

@@ -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
}

View File

@@ -112,6 +112,7 @@ export interface GameBootstrapSnapshot {
connection: ConnectionState
dashboard: DashboardState
history: HistoryEntry[]
maxSelectionCount: number
round: RoundSnapshot
selections: BetSelection[]
trends: TrendEntry[]

View File

@@ -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)
}
}

View File

@@ -4,14 +4,15 @@ import {
DEFAULT_REQUEST_ACCEPT_HEADER,
DEFAULT_REQUEST_TIMEOUT_MS,
} from '@/constants'
import type { AuthTokenDto } from '@/features/auth/api/types'
import { ApiError } from '@/lib/api/api-error.ts'
import {
handleUnauthorizedSession,
tryRefreshAuthSession,
} from '@/lib/auth/auth-session'
import { useAuthStore } from '@/store/auth'
import { ApiError } from './api-error'
import type { ApiResponse } from './types'
import { md5 } from '@/lib/crypto/md5'
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
import type { ApiResponse } from '@/type'
type RequestOptions = Omit<Options, 'json'>
type JsonRequestOptions<TBody> = RequestOptions & {
@@ -20,7 +21,12 @@ type JsonRequestOptions<TBody> = RequestOptions & {
const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
const AUTH_SKIP_REFRESH_CONTEXT_KEY = 'skipAuthRefresh'
const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
const AUTH_REFRESH_ENDPOINT = 'api/user/refreshToken'
const ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000
const AUTH_TOKEN_CACHE_SKEW_MS = 30_000
const appEnv = import.meta.env.VITE_APP_ENV
const authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim()
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
function normalizeApiBaseUrl(baseUrl: string | undefined) {
@@ -96,6 +102,15 @@ async function toApiError(error: unknown) {
export const apiBaseUrl = normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
const authTokenClient = ky.create({
prefix: apiBaseUrl,
retry: 0,
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
headers: {
Accept: DEFAULT_REQUEST_ACCEPT_HEADER,
},
})
const apiClient = ky.create({
prefix: apiBaseUrl,
retry: 0,
@@ -109,6 +124,7 @@ const apiClient = ky.create({
if (token) {
request.headers.set('Authorization', `Bearer ${token}`)
request.headers.set('user-token', token)
}
if (shouldLogRequests) {
@@ -128,9 +144,153 @@ const apiClient = ky.create({
},
})
function shouldAttachAuthToken(input: string) {
return input !== AUTH_TOKEN_ENDPOINT
}
function shouldTryRefreshAccessToken(input: string, options?: Options) {
if (
input === AUTH_REFRESH_ENDPOINT ||
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] === true
) {
return false
}
const authState = useAuthStore.getState()
return Boolean(
authState.accessToken &&
authState.accessTokenExpiresAt &&
authState.accessTokenExpiresAt <=
Date.now() + ACCESS_TOKEN_REFRESH_SKEW_MS,
)
}
function unwrapEnvelopeData<T>(response: ApiResponse<T>) {
if (response.code === 1) {
return response.data
}
throw new ApiError({
data: response,
message:
'msg' in response && typeof response.msg === 'string'
? response.msg
: 'message' in response && typeof response.message === 'string'
? response.message
: API_ERROR_MESSAGES.unexpected,
})
}
async function fetchAuthToken() {
try {
const authState = useAuthStore.getState()
if (
authState.apiAuthToken &&
authState.apiAuthTokenExpiresAt &&
authState.apiAuthTokenExpiresAt > Date.now() + AUTH_TOKEN_CACHE_SKEW_MS
) {
return authState.apiAuthToken
}
if (!authSecret) {
throw new ApiError({
message: 'auth.errors.authTokenConfigMissing',
})
}
const deviceId = getAuthDeviceId()
const timestamp = Math.floor(Date.now() / 1000)
const signature = md5(
`device_id=${deviceId}&secret=${authSecret}&timestamp=${timestamp}`,
).toUpperCase()
const response = await authTokenClient
.get(AUTH_TOKEN_ENDPOINT, {
searchParams: {
device_id: deviceId,
secret: authSecret,
signature,
timestamp: String(timestamp),
},
})
.json<ApiResponse<AuthTokenDto>>()
const data = unwrapEnvelopeData(response)
const expiresAt = Date.now() + data.expires_in * 1000
useAuthStore.getState().setApiAuthToken({
expiresAt,
serverTime: data.server_time,
value: data.auth_token,
})
return data.auth_token
} catch (error) {
throw await toApiError(error)
}
}
export async function prefetchAuthToken() {
await fetchAuthToken()
}
function createHeaders(headersInit?: Options['headers']) {
const headers = new Headers()
if (!headersInit) {
return headers
}
if (headersInit instanceof Headers) {
headersInit.forEach((value, key) => {
headers.set(key, value)
})
return headers
}
if (Array.isArray(headersInit)) {
for (const [key, value] of headersInit) {
headers.set(key, value)
}
return headers
}
for (const [key, value] of Object.entries(headersInit)) {
if (typeof value === 'string') {
headers.set(key, value)
}
}
return headers
}
async function buildRequestOptions(input: string, options?: Options) {
const headers = createHeaders(options?.headers)
if (shouldAttachAuthToken(input) && !headers.has('auth-token')) {
headers.set('auth-token', await fetchAuthToken())
}
return {
...options,
headers,
} satisfies Options
}
async function request<TResponse>(input: string, options?: Options) {
try {
const response = await apiClient(input, options)
if (shouldTryRefreshAccessToken(input, options)) {
await tryRefreshAuthSession()
}
const response = await apiClient(
input,
await buildRequestOptions(input, options),
)
const data = await parseResponseBody(response)
return data as TResponse
@@ -138,6 +298,7 @@ async function request<TResponse>(input: string, options?: Options) {
if (
error instanceof HTTPError &&
error.response.status === 401 &&
input !== AUTH_REFRESH_ENDPOINT &&
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] !== true &&
options?.context?.[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY] !== true
) {

View File

@@ -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

View File

@@ -1,6 +0,0 @@
/** @description 后端统一响应体结构。 */
export interface ApiResponse<T> {
code: number
msg: string
data: T
}

View File

@@ -61,6 +61,14 @@ export async function initializeAuthSession() {
return authInitializationPromise
}
export async function hydrateCurrentUser(initializer: CurrentUserInitializer) {
const currentUser = await initializer()
useAuthStore.getState().setCurrentUser(currentUser)
return currentUser
}
export async function tryRefreshAuthSession() {
if (refreshSessionPromise) {
return refreshSessionPromise
@@ -86,6 +94,8 @@ export async function tryRefreshAuthSession() {
useAuthStore.getState().startSession({
accessToken: nextSession.accessToken,
accessTokenExpiresAt:
nextSession.accessTokenExpiresAt ?? snapshot.accessTokenExpiresAt,
currentUser: nextSession.currentUser ?? snapshot.currentUser,
refreshToken: nextSession.refreshToken ?? snapshot.refreshToken,
})

5
src/lib/crypto/md5.ts Normal file
View File

@@ -0,0 +1,5 @@
import md5Hash from 'md5'
export function md5(value: string) {
return md5Hash(value)
}

113
src/lib/notify.ts Normal file
View File

@@ -0,0 +1,113 @@
import { create } from 'zustand'
const DEFAULT_TOAST_DURATION_MS = 3200
type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading'
export interface NotifyOptions {
description?: string
duration?: number
}
interface NotificationToast {
description?: string
duration: number
id: string
message: string
type: NotificationType
}
interface NotificationStoreState {
dismissToast: (id: string) => void
pushToast: (toast: NotificationToast) => void
toasts: NotificationToast[]
}
const toastTimers = new Map<string, number>()
export const useNotificationStore = create<NotificationStoreState>()((set) => ({
dismissToast: (id) => {
const timerId = toastTimers.get(id)
if (timerId) {
window.clearTimeout(timerId)
toastTimers.delete(id)
}
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
}))
},
pushToast: (toast) => {
set((state) => ({
toasts: [...state.toasts.filter((item) => item.id !== toast.id), toast],
}))
},
toasts: [],
}))
function createToastId() {
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function showToast(
type: NotificationType,
message: string,
options?: NotifyOptions,
) {
const id = createToastId()
const duration = options?.duration ?? DEFAULT_TOAST_DURATION_MS
useNotificationStore.getState().pushToast({
description: options?.description,
duration,
id,
message,
type,
})
if (duration > 0) {
const timerId = window.setTimeout(() => {
useNotificationStore.getState().dismissToast(id)
}, duration)
toastTimers.set(id, timerId)
}
return id
}
export const notify = {
dismiss(id?: string) {
if (id) {
useNotificationStore.getState().dismissToast(id)
return id
}
const { toasts } = useNotificationStore.getState()
for (const toast of toasts) {
useNotificationStore.getState().dismissToast(toast.id)
}
return null
},
error(message: string, options?: NotifyOptions) {
return showToast('error', message, options)
},
info(message: string, options?: NotifyOptions) {
return showToast('info', message, options)
},
loading(message: string, options?: NotifyOptions) {
return showToast('loading', message, options)
},
message(message: string, options?: NotifyOptions) {
return showToast('info', message, options)
},
success(message: string, options?: NotifyOptions) {
return showToast('success', message, options)
},
warning(message: string, options?: NotifyOptions) {
return showToast('warning', message, options)
},
}

View File

@@ -4,3 +4,106 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
type FullscreenCapableElement = HTMLElement & {
mozRequestFullScreen?: () => Promise<void> | void
msRequestFullscreen?: () => Promise<void> | void
webkitRequestFullscreen?: () => Promise<void> | void
}
type FullscreenCapableDocument = Document & {
mozCancelFullScreen?: () => Promise<void> | void
mozFullScreenElement?: Element | null
msExitFullscreen?: () => Promise<void> | void
msFullscreenElement?: Element | null
webkitExitFullscreen?: () => Promise<void> | void
webkitFullscreenElement?: Element | null
}
const FULLSCREEN_CHANGE_EVENTS = [
'fullscreenchange',
'webkitfullscreenchange',
'mozfullscreenchange',
'MSFullscreenChange',
] as const
export function isDesktopFullscreen() {
if (typeof document === 'undefined') {
return false
}
const fullscreenDocument = document as FullscreenCapableDocument
return Boolean(
document.fullscreenElement ||
fullscreenDocument.webkitFullscreenElement ||
fullscreenDocument.mozFullScreenElement ||
fullscreenDocument.msFullscreenElement,
)
}
export async function exitDesktopFullscreen() {
if (typeof document === 'undefined') {
return false
}
const fullscreenDocument = document as FullscreenCapableDocument
await Promise.resolve(
document.exitFullscreen?.() ??
fullscreenDocument.webkitExitFullscreen?.() ??
fullscreenDocument.mozCancelFullScreen?.() ??
fullscreenDocument.msExitFullscreen?.(),
)
return !isDesktopFullscreen()
}
export function subscribeDesktopFullscreenChange(listener: () => void) {
if (typeof document === 'undefined') {
return () => {}
}
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
document.addEventListener(eventName, listener)
}
return () => {
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
document.removeEventListener(eventName, listener)
}
}
}
export async function requestDesktopFullscreen(
target: HTMLElement = document.documentElement,
) {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return false
}
if (isDesktopFullscreen()) {
return true
}
const fullscreenTarget = target as FullscreenCapableElement
await Promise.resolve(
fullscreenTarget.requestFullscreen?.() ??
fullscreenTarget.webkitRequestFullscreen?.() ??
fullscreenTarget.mozRequestFullScreen?.() ??
fullscreenTarget.msRequestFullscreen?.(),
)
return isDesktopFullscreen()
}
export async function toggleDesktopFullscreen(
target: HTMLElement = document.documentElement,
) {
if (isDesktopFullscreen()) {
return exitDesktopFullscreen()
}
return requestDesktopFullscreen(target)
}

View File

@@ -0,0 +1,297 @@
type GameSocketContext = {
authToken: string
deviceId: string
lang: string
token: string
}
type GameSocketConnectedMessage = {
connection_id?: string
event: 'ws.connected'
heartbeat_interval?: number
server_time?: number
}
type GameSocketErrorMessage = {
code?: number
event: 'ws.error'
message?: string
}
type GameSocketPongMessage = {
action?: 'pong'
event?: 'pong'
server_time?: number
topic?: 'pong'
}
export type GameSocketMessage =
| GameSocketConnectedMessage
| GameSocketErrorMessage
| GameSocketPongMessage
| ({ event?: string } & Record<string, unknown>)
type GameSocketStatus =
| 'idle'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'disconnected'
type GameSocketClientOptions = {
getContext: () => Promise<GameSocketContext | null>
getUrl: () => string | null
onError?: (message: GameSocketErrorMessage | Error) => void
onLatencyChange?: (latencyMs: number | null) => void
onMessage?: (message: GameSocketMessage) => void
onStatusChange?: (status: GameSocketStatus, reconnectAttempt: number) => void
}
const MAX_RECONNECT_DELAY_MS = 10_000
const LATENCY_PROBE_INTERVAL_MS = 3_000
const LATENCY_PROBE_TIMEOUT_MS = 10_000
function toQueryString(context: GameSocketContext) {
const params = new URLSearchParams({
token: context.token,
auth_token: context.authToken,
device_id: context.deviceId,
lang: context.lang,
})
return params.toString()
}
export class GameSocketClient {
private heartbeatTimerId: number | null = null
private latencyProbeTimerId: number | null = null
private manualClose = false
private readonly options: GameSocketClientOptions
private pendingPingSentAt: number | null = null
private reconnectAttempt = 0
private reconnectTimerId: number | null = null
private socket: WebSocket | null = null
private readonly subscribedTopics = new Set<string>()
constructor(options: GameSocketClientOptions) {
this.options = options
}
async connect() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
return
}
this.clearReconnectTimer()
this.clearHeartbeatTimer()
this.clearLatencyProbeTimer()
const url = this.options.getUrl()
const context = await this.options.getContext()
if (!url || !context) {
this.setStatus('disconnected')
return
}
this.manualClose = false
this.setStatus(this.reconnectAttempt > 0 ? 'reconnecting' : 'connecting')
const socketUrl = new URL(url)
socketUrl.search = toQueryString(context)
const socket = new WebSocket(socketUrl.toString())
this.socket = socket
socket.addEventListener('open', () => {
this.flushSubscriptions()
})
socket.addEventListener('message', (event) => {
this.handleMessage(event.data)
})
socket.addEventListener('error', () => {
this.options.onError?.(new Error('WebSocket connection error'))
})
socket.addEventListener('close', () => {
this.socket = null
this.clearHeartbeatTimer()
this.clearLatencyProbeTimer()
this.pendingPingSentAt = null
this.options.onLatencyChange?.(null)
if (this.manualClose) {
this.setStatus('disconnected')
return
}
this.scheduleReconnect()
})
}
disconnect() {
this.manualClose = true
this.clearReconnectTimer()
this.clearHeartbeatTimer()
this.clearLatencyProbeTimer()
this.pendingPingSentAt = null
this.options.onLatencyChange?.(null)
this.socket?.close()
this.socket = null
this.setStatus('disconnected')
}
// Topics are de-duplicated locally and re-sent automatically after reconnect.
subscribe(topics: string[]) {
for (const topic of topics) {
this.subscribedTopics.add(topic)
}
this.send({
action: 'subscribe',
topics: [...this.subscribedTopics],
})
}
send(payload: Record<string, unknown>) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return
}
this.socket.send(JSON.stringify(payload))
}
private clearHeartbeatTimer() {
if (this.heartbeatTimerId !== null) {
window.clearInterval(this.heartbeatTimerId)
this.heartbeatTimerId = null
}
}
private clearReconnectTimer() {
if (this.reconnectTimerId !== null) {
window.clearTimeout(this.reconnectTimerId)
this.reconnectTimerId = null
}
}
private clearLatencyProbeTimer() {
if (this.latencyProbeTimerId !== null) {
window.clearInterval(this.latencyProbeTimerId)
this.latencyProbeTimerId = null
}
}
private flushSubscriptions() {
if (this.subscribedTopics.size === 0) {
return
}
this.send({
action: 'subscribe',
topics: [...this.subscribedTopics],
})
}
private sendPing() {
const now = performance.now()
if (
this.pendingPingSentAt !== null &&
now - this.pendingPingSentAt < LATENCY_PROBE_TIMEOUT_MS
) {
return
}
this.pendingPingSentAt = performance.now()
this.send({ action: 'ping' })
}
private handlePongMessage() {
if (this.pendingPingSentAt === null) {
return
}
const latencyMs = Math.max(
0,
Math.round(performance.now() - this.pendingPingSentAt),
)
this.pendingPingSentAt = null
this.options.onLatencyChange?.(latencyMs)
}
private handleConnectedMessage(message: GameSocketConnectedMessage) {
this.reconnectAttempt = 0
this.setStatus('connected')
this.options.onLatencyChange?.(null)
this.clearLatencyProbeTimer()
if (message.heartbeat_interval && message.heartbeat_interval > 0) {
this.clearHeartbeatTimer()
this.heartbeatTimerId = window.setInterval(() => {
this.sendPing()
}, message.heartbeat_interval * 1000)
}
this.latencyProbeTimerId = window.setInterval(() => {
this.sendPing()
}, LATENCY_PROBE_INTERVAL_MS)
this.flushSubscriptions()
this.sendPing()
}
private handleMessage(raw: string) {
if (raw.trim() === 'pong') {
this.handlePongMessage()
return
}
let message: GameSocketMessage
try {
message = JSON.parse(raw) as GameSocketMessage
} catch {
this.options.onError?.(new Error('WebSocket message parse failed'))
return
}
if (message.event === 'ws.connected') {
this.handleConnectedMessage(message as GameSocketConnectedMessage)
} else if (message.event === 'ws.error') {
this.options.onError?.(message as GameSocketErrorMessage)
} else if (
message.event === 'pong' ||
('action' in message && message.action === 'pong') ||
('topic' in message && message.topic === 'pong')
) {
this.handlePongMessage()
}
this.options.onMessage?.(message)
}
private scheduleReconnect() {
this.reconnectAttempt += 1
this.setStatus('reconnecting')
const delay = Math.min(
1000 * 2 ** Math.max(0, this.reconnectAttempt - 1),
MAX_RECONNECT_DELAY_MS,
)
this.clearReconnectTimer()
this.reconnectTimerId = window.setTimeout(() => {
void this.connect()
}, delay)
}
private setStatus(status: GameSocketStatus) {
this.options.onStatusChange?.(status, this.reconnectAttempt)
}
}

View File

@@ -42,6 +42,8 @@ export default {
label: 'Language',
zhCN: '中文',
enUS: 'English',
msMY: 'Bahasa Melayu',
idID: 'Bahasa Indonesia',
},
game: {
metaTitle: 'Game Lobby',
@@ -109,6 +111,59 @@ export default {
'This will later connect to the real announcement body, confirmation checkbox, and persistence flow.',
line2: 'For now it validates the shared modal structure.',
},
modals: {
login: {
title: 'Login',
},
register: {
title: 'Register',
},
notice: {
title: 'Event Notice',
content:
'This area will later load the real event announcement body, rich media, and a longer scrollable message. The current version focuses on shared multilingual modal wiring.',
check: 'View',
},
procedures: {
title: 'Top Up / Withdraw',
contentPlaceholder: 'Choose the action you want to continue with',
withdraw: 'Withdraw',
topup: 'Top Up',
},
autoSetting: {
title: 'Auto Spin',
startAutoSpin: 'Start Auto Spin',
rows: {
stopIfBalanceLowerThan: 'Stop if balance is lower than',
stopIfSingleWinExceeds: 'Stop if a single win exceeds',
stopOnAnyJackpot: 'Stop on any jackpot',
},
},
userInfo: {
title: 'User Info',
tabs: {
profile: 'Profile',
message: 'Messages',
},
profile: {
name: 'Name',
tel: 'Phone',
registeredAt: 'Registered at',
signature:
'My signature is as unique as my personality. This area will later display the real profile summary.',
},
message: {
eventBonus:
'[Top-up Bonus Event] From October 1 to October 7, 2026, claim your rebate rewards...',
check: 'View',
deleteRecords: 'Delete records',
},
},
withdrawTopup: {
applyWithdraw: 'Apply for Withdrawal',
applyTopup: 'Apply for Top Up',
},
},
autoSpin: {
eyebrow: 'Auto spin',
title: 'Auto spin running',
@@ -128,4 +183,234 @@ export default {
maxBet: 'Max bet',
},
},
commonUi: {
modal: {
close: 'Close modal',
defaultAriaLabel: 'Modal',
},
toast: {
lobbyInitFailed: 'Failed to load the game lobby',
loginRequired: 'Please log in before entering the game',
loginSuccess: 'Login successful',
registerSuccess: 'Registration successful',
},
},
auth: {
common: {
arrowIconAlt: 'Arrow',
actions: {
submitting: 'Submitting...',
},
},
login: {
actions: {
submit: 'Log In',
},
fields: {
username: {
label: 'Account / Phone:',
placeholder: 'Enter account or mobile number',
},
password: {
label: 'Password:',
placeholder: 'Enter password',
},
},
footer: {
registerAccount: 'Create account',
forgotPassword: 'Forgot password',
},
errors: {
submitFailed: 'Login failed. Please try again later.',
invalidCredentials: 'Incorrect account or password.',
},
},
register: {
actions: {
submit: 'Register',
},
fields: {
username: {
label: 'Account / Phone:',
placeholder: 'Enter account or mobile number',
},
password: {
label: 'Password:',
placeholder: 'Enter password',
},
confirmPassword: {
label: 'Confirm Password:',
placeholder: 'Re-enter password',
},
inviteCode: {
label: 'Invite Code:',
placeholder: 'Enter invite code',
},
},
footer: {
alreadyHaveAccount: 'Already have an account',
needHelp: 'Need help',
},
errors: {
submitFailed: 'Registration failed. Please try again later.',
unauthorized: 'Registration is not authorized. Please try again later.',
},
},
validation: {
username: {
required: 'Please enter your mobile number.',
invalidPhone: 'Please enter a valid mobile number.',
},
password: {
min: 'Password must be at least 6 characters.',
max: 'Password must be at most 32 characters.',
},
inviteCode: {
required: 'Please enter the invite code.',
max: 'Invite code must be at most 32 characters.',
},
confirmPassword: {
mismatch: 'The two passwords do not match.',
},
},
errors: {
requestFailed: 'Request failed. Please try again later.',
authTokenConfigMissing:
'Authentication configuration is missing. Please contact support.',
timeout: 'Request timed out. Please try again later.',
serviceUnavailable:
'Service is temporarily unavailable. Please try again later.',
},
},
gameDesktop: {
header: {
systemTime: 'System Time',
rules: 'Rules',
message: 'Message',
bgm: 'BGM',
id: 'ID',
fullscreen: 'Full Screen',
login: 'Login',
register: 'Register',
},
control: {
trend: 'Trend',
map: 'Map',
selected: 'Selected',
totalBet: 'Total Bet',
confirm: 'Confirm',
actions: {
clear: 'Clear',
repeat: 'Repeat',
'auto-spin': 'Auto Spin',
},
},
status: {
odds: 'Odds',
streak: 'Streak',
limit: 'Limit',
roundId: 'Round',
phase: {
betting: {
label: 'Open',
description: '(Accepting Bets)',
},
locked: {
label: 'Locked',
description: '(Betting Closed)',
},
revealing: {
label: 'Drawing',
description: '(Revealing Result)',
},
settled: {
label: 'Settled',
description: '(Round Complete)',
},
waiting: {
label: 'Waiting',
description: '(Waiting for Next Round)',
},
},
},
title: {
announcement: 'Announcement',
},
animal: {
loading: 'Loading',
tapToEnter: 'Tap To Enter',
getStart: 'Get Start',
},
history: {
title: 'History',
orderNo: 'Order No.',
roundId: 'Round ID',
numbers: 'Bet Numbers',
settledAt: 'Settled At',
totalPoolAmount: 'Bet Amount',
winningResult: 'Winning Result',
payout: 'Win Amount',
empty: 'No history yet',
end: 'No more records',
loading: 'Loading...',
settled: 'Settled',
},
topup: {
placeholder: 'Top-up content is under construction',
},
mobile: {
placeholder: 'Mobile entry is under construction',
},
withdraw: {
availableBalance: 'Available balance: {{amount}}',
currencySelection: 'Currency selection',
selectCurrency: 'Select currency',
exchangeRateNotice:
'Exchange rates and final payout amounts follow the platform real-time settlement.',
wallet: 'Wallet',
bank: 'Bank',
minimumRm10: 'Minimum RM 10',
processingTime: 'Processing time',
fundsArrivalTime: 'Expected within 1-15 minutes',
feeNotice:
'Please confirm the receiving information carefully. It cannot be changed after submission.',
cancel: 'Cancel',
confirm: 'Confirm',
withdrawal: 'Withdrawal',
fields: {
diamondWithdrawalAmount: 'Diamond Withdrawal Amount',
currencyType: 'Currency Type',
paymentChannel: 'Payment Channel',
bankCode: 'Bank Code',
cardHolderName: 'Card Holder Name',
bankAccountNumber: 'Bank Account Number',
receiverEmail: 'Receiver Email',
receiverPhone: 'Receiver Phone',
},
placeholders: {
cardHolderName: 'Enter card holder name',
bankAccountNumber: 'Enter bank account number',
receiverEmail: 'Enter receiver email',
receiverPhone: 'Enter receiver phone number',
},
errors: {
cardHolderNameRequired: 'Please enter the card holder name.',
bankAccountRequired: 'Please enter the bank account number.',
},
preview: {
title: 'Exchange Preview',
diamondAmount: 'Diamond Amount',
rateMyr: 'MYR Rate',
rateMyrValue: '{{diamonds}} diamonds = 1 MYR',
convertibleMyr: 'Convertible MYR',
usdtMyrRate: 'USDT / MYR Rate',
usdtMyrRateValue: '1 USDT = {{rate}} MYR',
rateVnd: 'VND Rate',
rateVndValue: '1 diamond = {{diamonds}} VND',
convertibleVnd: 'Convertible VND',
convertibleUsdt: 'Convertible USDT',
fixedExchangeDiamondAmount: 'Fixed Exchange Diamond Amount',
},
},
},
} as const

415
src/locales/id-ID/common.ts Normal file
View File

@@ -0,0 +1,415 @@
export default {
nav: {
home: 'Beranda',
game: 'Game',
},
shell: {
eyebrow: '36 Character Flower',
subtitle: 'Frontend game undian real-time untuk mobile dan desktop',
},
notFound: {
eyebrow: '404',
title: 'Halaman yang kamu minta tidak ditemukan.',
description: 'Rute ini tidak ada. Kembali ke halaman utama scaffold.',
home: 'Kembali ke beranda',
},
home: {
eyebrow: 'Shell game sedang dibangun',
title: 'Framework game dual-device 36-character-flower sedang dibangun.',
description:
'Proyek ini sudah melewati tahap scaffold umum. Sekarang strukturnya dibangun dengan rute game bersama, state bersama, serta tampilan mobile dan desktop terpisah untuk pengalaman betting real-time.',
cards: {
routingMode: 'Routing',
dataLayer: 'Model state',
transport: 'Real-time',
auth: 'Produk',
metadata: 'Fokus saat ini',
},
values: {
routingMode: 'URL bersama + tampilan device terpisah',
dataLayer: 'Round / Bet / User / UI / Connection',
transport: 'HTTP + WebSocket',
auth: 'Gameplay live draw 36-grid',
metadata: 'Bangun struktur dulu sebelum polishing state machine',
},
footnote:
'Berikutnya: rute utama game, model bisnis bersama, dan shell halaman mobile serta desktop.',
primaryAction: 'Masuk lobby game',
secondaryAction: 'Lihat struktur proyek',
},
language: {
label: 'Bahasa',
zhCN: '中文',
enUS: 'English',
msMY: 'Bahasa Melayu',
idID: 'Bahasa Indonesia',
},
game: {
metaTitle: 'Lobby Game',
metaDescription: 'Lobby game live 36-character-flower.',
lobbyTitle: 'Lobby 36 Character Flower',
lobbySubtitle:
'Dalam satu rute bisnis bersama, mobile dan desktop memasang tampilan berbeda di atas data dan state game yang sama.',
status: {
roundState: 'Status ronde',
currentRound: 'Ronde saat ini {{id}}',
tablePool: 'Pool meja',
onlineCount: '{{count}} online',
activeChip: 'Chip aktif',
announcementsRead: '{{read}}/{{total}} pengumuman dibaca',
connection: 'Koneksi',
connectionHealthy: 'Sinkronisasi stabil',
connectionRecovering: 'Menunggu pemulihan',
synced: 'Tersinkron',
degraded: 'Menurun',
},
board: {
historyTitle: 'Riwayat ronde',
historySubtitle: 'Jejak undian dan payout terbaru',
trendTitle: 'Radar tren',
trendSubtitle: 'Ringkasan momentum dan miss streak',
stageTitle: 'Panggung undian',
stageSubtitle:
'Panggung ini menampung papan utama dan struktur kontrol sebelum integrasi penuh state machine dan animasi.',
currentPhase: 'Fase saat ini',
selectedBet: 'Bet {{amount}}',
hitCount: '{{count}} hit',
hitBadge: '{{count}}x',
badgeWin: 'Menang',
badgeBet: 'Bet',
cellLabel: 'Sel {{id}}',
winningCell: 'Sel pemenang {{id}}',
missedRounds: 'Miss {{count}} ronde',
rising: 'Naik',
falling: 'Turun',
steady: 'Stabil',
hitTotal: '{{count}} hit',
},
phases: {
betting: 'Betting',
locked: 'Terkunci',
revealing: 'Mengungkap',
settled: 'Selesai',
},
actions: {
unifiedBetHint: 'Bet seragam',
totalBet: 'Total bet',
canBet: 'Bisa bet',
yes: 'Ya',
no: 'Tidak',
quickBet: 'Quick bet 08',
clearPending: 'Hapus pending',
autoModeDemo: 'Demo mode auto',
stopAuto: 'Stop auto',
},
modal: {
eyebrow: 'Pengumuman',
acknowledge: 'Saya paham',
later: 'Nanti',
line1:
'Ini nantinya akan terhubung ke konten pengumuman nyata, checkbox konfirmasi, dan alur penyimpanan status.',
line2: 'Untuk sekarang ini memvalidasi struktur modal bersama.',
},
modals: {
login: {
title: 'Masuk',
},
register: {
title: 'Daftar',
},
notice: {
title: 'Pengumuman Acara',
content:
'Bagian ini nantinya akan memuat konten pengumuman acara yang sebenarnya, materi visual, dan pesan panjang yang dapat digulir. Versi saat ini fokus pada sambungan modal multibahasa.',
check: 'Lihat',
},
procedures: {
title: 'Isi Ulang / Tarik Dana',
contentPlaceholder: 'Pilih tindakan yang ingin kamu lanjutkan',
withdraw: 'Tarik Dana',
topup: 'Isi Ulang',
},
autoSetting: {
title: 'Auto Spin',
startAutoSpin: 'Mulai Auto Spin',
rows: {
stopIfBalanceLowerThan: 'Berhenti jika saldo lebih rendah dari',
stopIfSingleWinExceeds: 'Berhenti jika kemenangan tunggal melebihi',
stopOnAnyJackpot: 'Berhenti pada jackpot apa pun',
},
},
userInfo: {
title: 'Info Pengguna',
tabs: {
profile: 'Profil',
message: 'Pesan',
},
profile: {
name: 'Nama',
tel: 'Telepon',
registeredAt: 'Tanggal daftar',
signature:
'Tanda tanganku seunik diriku. Bagian ini nantinya akan menampilkan ringkasan profil yang sebenarnya.',
},
message: {
eventBonus:
'[Event Bonus Isi Ulang] Dari 1 Oktober hingga 7 Oktober 2026, klaim hadiah rebate kamu...',
check: 'Lihat',
deleteRecords: 'Hapus riwayat',
},
},
withdrawTopup: {
applyWithdraw: 'Ajukan Penarikan',
applyTopup: 'Ajukan Isi Ulang',
},
},
autoSpin: {
eyebrow: 'Auto spin',
title: 'Auto spin berjalan',
description:
'Mode auto akan menutupi board sambil mempertahankan fokus sel target dan progres.',
trailingLabel: 'Input manual terkunci',
},
footer: {
implementationTitle: 'Implementasi saat ini',
implementationSubtitle:
'Iterasi ini memprioritaskan shell dual-device, model bersama, dan wiring bisnis.',
implementationBody:
'Langkah berikutnya adalah API nyata, WebSocket, UI store penuh, dan state machine siklus ronde.',
limitsTitle: 'Batas meja',
limitsSubtitle: 'Berasal dari data mock dashboard',
minBet: 'Bet minimum',
maxBet: 'Bet maksimum',
},
},
commonUi: {
modal: {
close: 'Tutup modal',
defaultAriaLabel: 'Modal',
},
toast: {
lobbyInitFailed: 'Gagal memuat lobby game',
loginRequired: 'Silakan masuk sebelum memasuki game',
loginSuccess: 'Berhasil masuk',
registerSuccess: 'Pendaftaran berhasil',
},
},
auth: {
common: {
arrowIconAlt: 'Panah',
actions: {
submitting: 'Mengirim...',
},
},
login: {
actions: {
submit: 'Masuk',
},
fields: {
username: {
label: 'Akun / Telepon:',
placeholder: 'Masukkan akun atau nomor ponsel',
},
password: {
label: 'Kata Sandi:',
placeholder: 'Masukkan kata sandi',
},
},
footer: {
registerAccount: 'Daftar akun',
forgotPassword: 'Lupa kata sandi',
},
errors: {
submitFailed: 'Login gagal. Silakan coba lagi nanti.',
invalidCredentials: 'Akun atau kata sandi salah.',
},
},
register: {
actions: {
submit: 'Daftar',
},
fields: {
username: {
label: 'Akun / Telepon:',
placeholder: 'Masukkan akun atau nomor ponsel',
},
password: {
label: 'Kata Sandi:',
placeholder: 'Masukkan kata sandi',
},
confirmPassword: {
label: 'Konfirmasi Kata Sandi:',
placeholder: 'Masukkan ulang kata sandi',
},
inviteCode: {
label: 'Kode Undangan:',
placeholder: 'Masukkan kode undangan',
},
},
footer: {
alreadyHaveAccount: 'Sudah punya akun',
needHelp: 'Butuh bantuan',
},
errors: {
submitFailed: 'Pendaftaran gagal. Silakan coba lagi nanti.',
unauthorized: 'Pendaftaran tidak diizinkan. Silakan coba lagi nanti.',
},
},
validation: {
username: {
required: 'Silakan masukkan nomor ponsel.',
invalidPhone: 'Silakan masukkan nomor ponsel yang valid.',
},
password: {
min: 'Kata sandi minimal 6 karakter.',
max: 'Kata sandi maksimal 32 karakter.',
},
inviteCode: {
required: 'Silakan masukkan kode undangan.',
max: 'Kode undangan maksimal 32 karakter.',
},
confirmPassword: {
mismatch: 'Kedua kata sandi tidak sama.',
},
},
errors: {
requestFailed: 'Permintaan gagal. Silakan coba lagi nanti.',
authTokenConfigMissing:
'Konfigurasi autentikasi tidak ada. Silakan hubungi dukungan.',
timeout: 'Permintaan habis waktu. Silakan coba lagi nanti.',
serviceUnavailable:
'Layanan sedang tidak tersedia. Silakan coba lagi nanti.',
},
},
gameDesktop: {
header: {
systemTime: 'Waktu Sistem',
rules: 'Aturan',
message: 'Pesan',
bgm: 'BGM',
id: 'ID',
fullscreen: 'Layar Penuh',
login: 'Masuk',
register: 'Daftar',
},
control: {
trend: 'Tren',
map: 'Peta',
selected: 'Dipilih',
totalBet: 'Total Bet',
confirm: 'Konfirmasi',
actions: {
clear: 'Hapus',
repeat: 'Ulang',
'auto-spin': 'Auto Spin',
},
},
status: {
odds: 'Odds',
streak: 'Streak',
limit: 'Batas',
roundId: 'Ronde',
phase: {
betting: {
label: 'Buka',
description: '(Menerima Bet)',
},
locked: {
label: 'Terkunci',
description: '(Bet Ditutup)',
},
revealing: {
label: 'Drawing',
description: '(Mengungkap Hasil)',
},
settled: {
label: 'Selesai',
description: '(Ronde Selesai)',
},
waiting: {
label: 'Menunggu',
description: '(Menunggu Ronde Berikutnya)',
},
},
},
title: {
announcement: 'Pengumuman',
},
animal: {
loading: 'Memuat',
tapToEnter: 'Ketuk Untuk Masuk',
getStart: 'Mulai',
},
history: {
title: 'Riwayat',
orderNo: 'No. Order',
roundId: 'ID Ronde',
numbers: 'Nomor Taruhan',
settledAt: 'Waktu Selesai',
totalPoolAmount: 'Jumlah Taruhan',
winningResult: 'Hasil Menang',
payout: 'Jumlah Menang',
empty: 'Belum ada riwayat',
end: 'Tidak ada catatan lagi',
loading: 'Memuat...',
settled: 'Selesai',
},
topup: {
placeholder: 'Konten isi ulang sedang dibangun',
},
mobile: {
placeholder: 'Halaman mobile sedang dibangun',
},
withdraw: {
availableBalance: 'Saldo tersedia: {{amount}}',
currencySelection: 'Pilihan mata uang',
selectCurrency: 'Pilih mata uang',
exchangeRateNotice:
'Kurs dan jumlah akhir mengikuti penyelesaian real-time platform.',
wallet: 'Dompet',
bank: 'Bank',
minimumRm10: 'Minimum RM 10',
processingTime: 'Waktu proses',
fundsArrivalTime: 'Diperkirakan masuk dalam 1-15 menit',
feeNotice:
'Pastikan informasi penerima benar. Data tidak dapat diubah setelah dikirim.',
cancel: 'Batal',
confirm: 'Konfirmasi',
withdrawal: 'Penarikan',
fields: {
diamondWithdrawalAmount: 'Jumlah Berlian Ditarik',
currencyType: 'Jenis Mata Uang',
paymentChannel: 'Saluran Pembayaran',
bankCode: 'Kode Bank',
cardHolderName: 'Nama Pemilik Rekening',
bankAccountNumber: 'Nomor Rekening Bank',
receiverEmail: 'Email Penerima',
receiverPhone: 'Telepon Penerima',
},
placeholders: {
cardHolderName: 'Masukkan nama pemilik rekening',
bankAccountNumber: 'Masukkan nomor rekening bank',
receiverEmail: 'Masukkan email penerima',
receiverPhone: 'Masukkan nomor telepon penerima',
},
errors: {
cardHolderNameRequired: 'Silakan masukkan nama pemilik rekening.',
bankAccountRequired: 'Silakan masukkan nomor rekening bank.',
},
preview: {
title: 'Pratinjau Penukaran',
diamondAmount: 'Jumlah Berlian',
rateMyr: 'Kurs MYR',
rateMyrValue: '{{diamonds}} berlian = 1 MYR',
convertibleMyr: 'Bisa Ditukar ke MYR',
usdtMyrRate: 'Kurs USDT / MYR',
usdtMyrRateValue: '1 USDT = {{rate}} MYR',
rateVnd: 'Kurs VND',
rateVndValue: '1 berlian = {{diamonds}} VND',
convertibleVnd: 'Bisa Ditukar ke VND',
convertibleUsdt: 'Bisa Ditukar ke USDT',
fixedExchangeDiamondAmount: 'Jumlah Berlian Tukar Tetap',
},
},
},
} as const

418
src/locales/ms-MY/common.ts Normal file
View File

@@ -0,0 +1,418 @@
export default {
nav: {
home: 'Laman Utama',
game: 'Permainan',
},
shell: {
eyebrow: '36 Character Flower',
subtitle:
'Antara muka permainan cabutan masa nyata untuk mudah alih dan desktop',
},
notFound: {
eyebrow: '404',
title: 'Halaman yang anda minta tidak ditemui.',
description:
'Laluan ini tidak wujud. Kembali ke halaman utama rangka kerja.',
home: 'Kembali ke utama',
},
home: {
eyebrow: 'Rangka permainan sedang dibina',
title:
'Rangka permainan dwi-peranti 36-character-flower sedang dibangunkan.',
description:
'Projek ini telah melepasi peringkat rangka asas. Kini ia disusun dengan laluan permainan dikongsi, keadaan dikongsi, serta paparan berasingan untuk mudah alih dan desktop bagi pengalaman pertaruhan masa nyata.',
cards: {
routingMode: 'Laluan',
dataLayer: 'Model keadaan',
transport: 'Masa nyata',
auth: 'Produk',
metadata: 'Fokus semasa',
},
values: {
routingMode: 'URL dikongsi + paparan peranti berasingan',
dataLayer: 'Round / Bet / User / UI / Connection',
transport: 'HTTP + WebSocket',
auth: 'Permainan cabutan langsung grid 36',
metadata: 'Bina struktur dahulu sebelum kemasan state machine',
},
footnote:
'Seterusnya: laluan utama permainan, model perniagaan dikongsi, dan rangka halaman mudah alih serta desktop.',
primaryAction: 'Masuk lobi permainan',
secondaryAction: 'Lihat struktur projek',
},
language: {
label: 'Bahasa',
zhCN: '中文',
enUS: 'English',
msMY: 'Bahasa Melayu',
idID: 'Bahasa Indonesia',
},
game: {
metaTitle: 'Lobi Permainan',
metaDescription: 'Lobi permainan langsung 36-character-flower.',
lobbyTitle: 'Lobi 36 Character Flower',
lobbySubtitle:
'Di bawah satu laluan perniagaan yang dikongsi, mudah alih dan desktop memaparkan antara muka berbeza di atas data dan keadaan permainan yang sama.',
status: {
roundState: 'Keadaan pusingan',
currentRound: 'Pusingan semasa {{id}}',
tablePool: 'Dana meja',
onlineCount: '{{count}} dalam talian',
activeChip: 'Cip aktif',
announcementsRead: '{{read}}/{{total}} pengumuman dibaca',
connection: 'Sambungan',
connectionHealthy: 'Penyegerakan stabil',
connectionRecovering: 'Menunggu pemulihan',
synced: 'Disegerakkan',
degraded: 'Terganggu',
},
board: {
historyTitle: 'Sejarah pusingan',
historySubtitle: 'Rekod cabutan dan pembayaran terkini',
trendTitle: 'Radar trend',
trendSubtitle: 'Ringkasan momentum dan kekerapan miss',
stageTitle: 'Pentas cabutan',
stageSubtitle:
'Pentas ini memuatkan papan utama dan struktur kawalan sebelum integrasi penuh state machine serta animasi.',
currentPhase: 'Fasa semasa',
selectedBet: 'Pertaruhan {{amount}}',
hitCount: '{{count}} kena',
hitBadge: '{{count}}x',
badgeWin: 'Menang',
badgeBet: 'Taruhan',
cellLabel: 'Sel {{id}}',
winningCell: 'Sel menang {{id}}',
missedRounds: 'Terlepas {{count}} pusingan',
rising: 'Meningkat',
falling: 'Menurun',
steady: 'Stabil',
hitTotal: '{{count}} kena',
},
phases: {
betting: 'Taruhan',
locked: 'Dikunci',
revealing: 'Cabutan',
settled: 'Selesai',
},
actions: {
unifiedBetHint: 'Taruhan seragam',
totalBet: 'Jumlah taruhan',
canBet: 'Boleh taruhan',
yes: 'Ya',
no: 'Tidak',
quickBet: 'Taruhan cepat 08',
clearPending: 'Kosongkan belum sah',
autoModeDemo: 'Demo mod auto',
stopAuto: 'Henti auto',
},
modal: {
eyebrow: 'Pengumuman',
acknowledge: 'Faham',
later: 'Nanti',
line1:
'Ini akan disambungkan kepada kandungan pengumuman sebenar, kotak pengesahan, dan aliran penyimpanan status.',
line2: 'Buat masa ini, ia mengesahkan struktur modal yang dikongsi.',
},
modals: {
login: {
title: 'Log Masuk',
},
register: {
title: 'Daftar',
},
notice: {
title: 'Notis Acara',
content:
'Bahagian ini akan memuatkan kandungan notis acara sebenar, bahan visual, dan mesej boleh skrol yang lebih panjang. Versi semasa memfokuskan sambungan modal pelbagai bahasa.',
check: 'Semak',
},
procedures: {
title: 'Tambah Nilai / Pengeluaran',
contentPlaceholder: 'Pilih tindakan yang ingin anda teruskan',
withdraw: 'Keluarkan',
topup: 'Tambah Nilai',
},
autoSetting: {
title: 'Putaran Auto',
startAutoSpin: 'Mula Putaran Auto',
rows: {
stopIfBalanceLowerThan: 'Henti jika baki lebih rendah daripada',
stopIfSingleWinExceeds: 'Henti jika kemenangan tunggal melebihi',
stopOnAnyJackpot: 'Henti pada sebarang jackpot',
},
},
userInfo: {
title: 'Maklumat Pengguna',
tabs: {
profile: 'Profil',
message: 'Mesej',
},
profile: {
name: 'Nama',
tel: 'Telefon',
registeredAt: 'Tarikh daftar',
signature:
'Tandatangan saya unik seperti personaliti saya. Bahagian ini akan memaparkan ringkasan profil sebenar kemudian.',
},
message: {
eventBonus:
'[Acara Bonus Tambah Nilai] Dari 1 Oktober hingga 7 Oktober 2026, tuntut ganjaran rebat anda...',
check: 'Semak',
deleteRecords: 'Padam rekod',
},
},
withdrawTopup: {
applyWithdraw: 'Mohon Pengeluaran',
applyTopup: 'Mohon Tambah Nilai',
},
},
autoSpin: {
eyebrow: 'Putaran auto',
title: 'Putaran auto sedang berjalan',
description:
'Mod auto akan menutup papan sambil mengekalkan fokus sel sasaran dan kemajuan.',
trailingLabel: 'Input manual dikunci',
},
footer: {
implementationTitle: 'Pelaksanaan semasa',
implementationSubtitle:
'Iterasi ini mengutamakan shell dwi-peranti, model dikongsi, dan sambungan logik perniagaan.',
implementationBody:
'Langkah seterusnya ialah API sebenar, WebSocket, UI store penuh, dan state machine kitaran pusingan.',
limitsTitle: 'Had meja',
limitsSubtitle: 'Diambil daripada data mock dashboard',
minBet: 'Taruhan minimum',
maxBet: 'Taruhan maksimum',
},
},
commonUi: {
modal: {
close: 'Tutup modal',
defaultAriaLabel: 'Modal',
},
toast: {
lobbyInitFailed: 'Gagal memuatkan lobi permainan',
loginRequired: 'Sila log masuk sebelum memasuki permainan',
loginSuccess: 'Log masuk berjaya',
registerSuccess: 'Pendaftaran berjaya',
},
},
auth: {
common: {
arrowIconAlt: 'Anak panah',
actions: {
submitting: 'Menghantar...',
},
},
login: {
actions: {
submit: 'Log Masuk',
},
fields: {
username: {
label: 'Akaun / Telefon:',
placeholder: 'Masukkan akaun atau nombor telefon',
},
password: {
label: 'Kata Laluan:',
placeholder: 'Masukkan kata laluan',
},
},
footer: {
registerAccount: 'Daftar akaun',
forgotPassword: 'Lupa kata laluan',
},
errors: {
submitFailed: 'Log masuk gagal. Sila cuba lagi kemudian.',
invalidCredentials: 'Akaun atau kata laluan tidak betul.',
},
},
register: {
actions: {
submit: 'Daftar',
},
fields: {
username: {
label: 'Akaun / Telefon:',
placeholder: 'Masukkan akaun atau nombor telefon',
},
password: {
label: 'Kata Laluan:',
placeholder: 'Masukkan kata laluan',
},
confirmPassword: {
label: 'Sahkan Kata Laluan:',
placeholder: 'Masukkan semula kata laluan',
},
inviteCode: {
label: 'Kod Jemputan:',
placeholder: 'Masukkan kod jemputan',
},
},
footer: {
alreadyHaveAccount: 'Sudah ada akaun',
needHelp: 'Perlukan bantuan',
},
errors: {
submitFailed: 'Pendaftaran gagal. Sila cuba lagi kemudian.',
unauthorized: 'Pendaftaran tidak dibenarkan. Sila cuba lagi kemudian.',
},
},
validation: {
username: {
required: 'Sila masukkan nombor telefon anda.',
invalidPhone: 'Sila masukkan nombor telefon yang sah.',
},
password: {
min: 'Kata laluan mesti sekurang-kurangnya 6 aksara.',
max: 'Kata laluan mesti maksimum 32 aksara.',
},
inviteCode: {
required: 'Sila masukkan kod jemputan.',
max: 'Kod jemputan mesti maksimum 32 aksara.',
},
confirmPassword: {
mismatch: 'Kedua-dua kata laluan tidak sepadan.',
},
},
errors: {
requestFailed: 'Permintaan gagal. Sila cuba lagi kemudian.',
authTokenConfigMissing:
'Konfigurasi pengesahan tiada. Sila hubungi sokongan.',
timeout: 'Permintaan tamat masa. Sila cuba lagi kemudian.',
serviceUnavailable:
'Perkhidmatan tidak tersedia buat sementara waktu. Sila cuba lagi kemudian.',
},
},
gameDesktop: {
header: {
systemTime: 'Masa Sistem',
rules: 'Peraturan',
message: 'Mesej',
bgm: 'BGM',
id: 'ID',
fullscreen: 'Skrin Penuh',
login: 'Log Masuk',
register: 'Daftar',
},
control: {
trend: 'Trend',
map: 'Peta',
selected: 'Dipilih',
totalBet: 'Jumlah Taruhan',
confirm: 'Sahkan',
actions: {
clear: 'Kosongkan',
repeat: 'Ulang',
'auto-spin': 'Putaran Auto',
},
},
status: {
odds: 'Peluang',
streak: 'Streak',
limit: 'Had',
roundId: 'Pusingan',
phase: {
betting: {
label: 'Buka',
description: '(Menerima Taruhan)',
},
locked: {
label: 'Dikunci',
description: '(Taruhan Ditutup)',
},
revealing: {
label: 'Cabutan',
description: '(Mendedahkan Hasil)',
},
settled: {
label: 'Selesai',
description: '(Pusingan Tamat)',
},
waiting: {
label: 'Menunggu',
description: '(Menunggu Pusingan Seterusnya)',
},
},
},
title: {
announcement: 'Pengumuman',
},
animal: {
loading: 'Memuatkan',
tapToEnter: 'Ketik Untuk Masuk',
getStart: 'Mula',
},
history: {
title: 'Sejarah',
orderNo: 'No. Pesanan',
roundId: 'ID Pusingan',
numbers: 'Nombor Pertaruhan',
settledAt: 'Masa Selesai',
totalPoolAmount: 'Jumlah Pertaruhan',
winningResult: 'Keputusan Menang',
payout: 'Jumlah Menang',
empty: 'Belum ada sejarah',
end: 'Tiada lagi rekod',
loading: 'Memuatkan...',
settled: 'Selesai',
},
topup: {
placeholder: 'Kandungan tambah nilai sedang dibina',
},
mobile: {
placeholder: 'Halaman mudah alih sedang dibina',
},
withdraw: {
availableBalance: 'Baki tersedia: {{amount}}',
currencySelection: 'Pilihan mata wang',
selectCurrency: 'Pilih mata wang',
exchangeRateNotice:
'Kadar pertukaran dan jumlah akhir tertakluk kepada penyelesaian masa nyata platform.',
wallet: 'Dompet',
bank: 'Bank',
minimumRm10: 'Minimum RM 10',
processingTime: 'Masa pemprosesan',
fundsArrivalTime: 'Dijangka tiba dalam 1-15 minit',
feeNotice:
'Sila pastikan maklumat penerima adalah tepat. Ia tidak boleh diubah selepas dihantar.',
cancel: 'Batal',
confirm: 'Sahkan',
withdrawal: 'Pengeluaran',
fields: {
diamondWithdrawalAmount: 'Jumlah Berlian Dikeluarkan',
currencyType: 'Jenis Mata Wang',
paymentChannel: 'Saluran Pembayaran',
bankCode: 'Kod Bank',
cardHolderName: 'Nama Pemegang Kad',
bankAccountNumber: 'Nombor Akaun Bank',
receiverEmail: 'E-mel Penerima',
receiverPhone: 'Telefon Penerima',
},
placeholders: {
cardHolderName: 'Masukkan nama pemegang kad',
bankAccountNumber: 'Masukkan nombor akaun bank',
receiverEmail: 'Masukkan e-mel penerima',
receiverPhone: 'Masukkan nombor telefon penerima',
},
errors: {
cardHolderNameRequired: 'Sila masukkan nama pemegang kad.',
bankAccountRequired: 'Sila masukkan nombor akaun bank.',
},
preview: {
title: 'Pratonton Pertukaran',
diamondAmount: 'Jumlah Berlian',
rateMyr: 'Kadar MYR',
rateMyrValue: '{{diamonds}} berlian = 1 MYR',
convertibleMyr: 'Boleh Tukar MYR',
usdtMyrRate: 'Kadar USDT / MYR',
usdtMyrRateValue: '1 USDT = {{rate}} MYR',
rateVnd: 'Kadar VND',
rateVndValue: '1 berlian = {{diamonds}} VND',
convertibleVnd: 'Boleh Tukar VND',
convertibleUsdt: 'Boleh Tukar USDT',
fixedExchangeDiamondAmount: 'Jumlah Berlian Tukaran Tetap',
},
},
},
} as const

View File

@@ -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

View File

@@ -3,9 +3,19 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { RouterProvider } from '@tanstack/react-router'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { AppToaster } from '@/components/ui/toaster'
import { APP_ROOT_ELEMENT_ID } from '@/constants'
import {
getCurrentUserProfile,
refreshAuthSession,
} from '@/features/auth/api/auth-api'
import '@/i18n'
import { initializeAuthSession } from '@/lib/auth/auth-session'
import { prefetchAuthToken } from '@/lib/api/api-client'
import {
initializeAuthSession,
registerCurrentUserInitializer,
registerRefreshSessionHandler,
} from '@/lib/auth/auth-session'
import { queryClient } from '@/lib/query/query-client'
import { router } from '@/router'
import './style/index.css'
@@ -19,12 +29,22 @@ if (!rootElement) {
throw new Error('Root element not found')
}
void initializeAuthSession()
registerCurrentUserInitializer(getCurrentUserProfile)
registerRefreshSessionHandler(refreshAuthSession)
void initializeAuthSession().then(async () => {
try {
await prefetchAuthToken()
} catch (error) {
console.error('Failed to prefetch auth token', error)
}
})
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<AppToaster />
{shouldShowQueryDevtools && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</StrictMode>,

View File

@@ -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,
}

View File

@@ -0,0 +1,120 @@
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
const TEST_WS_URL =
'wss://zihua-api.h55555game.top/ws/?token=d77371f4-d053-475a-9c53-bfa11eb921c2&auth_token=708df54d-c647-46fc-b6ee-4339298d1ed4&device_id=web_0bc09c22-9157-4398-b4b3-57584ece9da9&lang=zh'
const TEST_TOPICS = [
'period.tick',
'user.streak',
'period.opened',
'period.locked',
'period.payout',
'bet.accepted',
'wallet.changed',
'auto.spin.progress',
'admin.live.snapshot',
'admin.live.opened',
'jackpot.hit',
] as const
type WsTestLog = {
at: string
id: string
message: string
}
export const Route = createFileRoute('/$lang/ws-test')({
component: WsTestPage,
})
function WsTestPage() {
const [logs, setLogs] = useState<WsTestLog[]>([])
const [status, setStatus] = useState('idle')
useEffect(() => {
const appendLog = (message: string) => {
setLogs((current) => [
...current,
{
at: new Date().toISOString(),
id: crypto.randomUUID(),
message,
},
])
}
appendLog(`creating websocket: ${TEST_WS_URL}`)
setStatus('connecting')
const socket = new WebSocket(TEST_WS_URL)
socket.addEventListener('open', () => {
appendLog('open')
socket.send(
JSON.stringify({
action: 'subscribe',
topics: [...TEST_TOPICS],
}),
)
appendLog(`subscribe: ${TEST_TOPICS.join(', ')}`)
setStatus('open')
})
socket.addEventListener('message', (event) => {
appendLog(`message: ${String(event.data)}`)
})
socket.addEventListener('error', () => {
appendLog('error')
setStatus('error')
})
socket.addEventListener('close', (event) => {
appendLog(
`close code=${event.code} reason=${event.reason || '(empty)'} wasClean=${event.wasClean}`,
)
setStatus('closed')
})
return () => {
socket.close()
}
}, [])
return (
<main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-6 px-6 py-10 text-white">
<h1 className="text-2xl font-semibold">WS Test</h1>
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-sm text-white/70">Status</div>
<div className="mt-1 text-lg">{status}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/30 p-4">
<div className="mb-3 text-sm text-white/70">URL</div>
<div className="break-all font-mono text-sm text-cyan-200">
{TEST_WS_URL}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/30 p-4">
<div className="mb-3 text-sm text-white/70">Topics</div>
<div className="break-all font-mono text-sm text-cyan-200">
{TEST_TOPICS.join(', ')}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/30 p-4">
<div className="mb-3 text-sm text-white/70">Logs</div>
<div className="flex flex-col gap-2 font-mono text-sm">
{logs.length === 0 ? (
<div className="text-white/50">No logs yet</div>
) : (
logs.map((log) => (
<div key={log.id} className="break-all text-white/85">
[{log.at}] {log.message}
</div>
))
)}
</div>
</div>
</main>
)
}

View File

@@ -1,42 +1,77 @@
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import { AUTH_STORAGE_KEY } from '@/constants'
import { APP_PREFERENCES_STORAGE_KEY, AUTH_STORAGE_KEY } from '@/constants'
/**@description 未登录 | 已登录 | 正在从存储恢复数据 */
export type AuthStatus = 'anonymous' | 'authenticated' | 'restoring'
export interface AuthUser {
createTime?: number
channelId?: number
coin?: string
currentStreak?: number
email?: string
headImage?: string
id: string
isJackpot?: boolean
lastBetPeriodNo?: string
name?: string
oddsFactor?: number
phone?: string
registerInviteCode?: string
riskFlags?: number
roles?: string[]
streakLevel?: number
username?: string
uuid?: string
}
export interface AuthSessionInput {
accessToken: string
accessTokenExpiresAt?: number | null
currentUser?: AuthUser | null
refreshToken?: string | null
}
interface PersistedAuthState {
accessToken: string | null
/** @description 用户登录态 `user-token` 的绝对过期时间戳(毫秒)。 */
accessTokenExpiresAt: number | null
/** @description `/api/v1/authToken` 返回的服务端时间戳(秒),用于后续校时或服务端时间基准判断。 */
apiAuthServerTime: number | null
apiAuthToken: string | null
/** @description 接口鉴权 `auth-token` 的绝对过期时间戳(毫秒)。 */
apiAuthTokenExpiresAt: number | null
currentUser: AuthUser | null
refreshToken: string | null
}
interface PersistedAppPreferenceState {
appLanguage: string | null
deviceId: string | null
}
interface AuthState extends PersistedAuthState {
clearApiAuthToken: () => void
clearAccessToken: () => void
clearSession: () => void
finishHydration: () => void
isHydrated: boolean
lastUnauthorizedAt: string | null
markUnauthorized: () => void
setApiAuthToken: (token: {
expiresAt: number
serverTime: number
value: string
}) => void
setAccessToken: (token: string) => void
setCurrentUser: (user: AuthUser | null) => void
startSession: (session: AuthSessionInput) => void
status: AuthStatus
updateTokens: (tokens: {
accessToken: string
accessTokenExpiresAt?: number | null
refreshToken?: string | null
}) => void
}
@@ -47,10 +82,25 @@ function resolveAuthStatus(accessToken: string | null): AuthStatus {
const initialPersistedState: PersistedAuthState = {
accessToken: null,
accessTokenExpiresAt: null,
apiAuthServerTime: null,
apiAuthToken: null,
apiAuthTokenExpiresAt: null,
refreshToken: null,
currentUser: null,
}
function generateDeviceId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return `web_${crypto.randomUUID()}`
}
return `web_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
@@ -58,9 +108,24 @@ export const useAuthStore = create<AuthState>()(
status: 'restoring',
isHydrated: false,
lastUnauthorizedAt: null,
setApiAuthToken: ({ expiresAt, serverTime, value }) => {
set({
apiAuthServerTime: serverTime,
apiAuthToken: value,
apiAuthTokenExpiresAt: expiresAt,
})
},
clearApiAuthToken: () => {
set({
apiAuthServerTime: null,
apiAuthToken: null,
apiAuthTokenExpiresAt: null,
})
},
setAccessToken: (token) => {
set({
accessToken: token,
accessTokenExpiresAt: null,
status: 'authenticated',
isHydrated: true,
})
@@ -73,20 +138,24 @@ export const useAuthStore = create<AuthState>()(
},
startSession: ({
accessToken,
accessTokenExpiresAt = null,
currentUser = null,
refreshToken = null,
}) => {
set({
accessToken,
accessTokenExpiresAt,
currentUser,
refreshToken,
status: 'authenticated',
isHydrated: true,
})
},
updateTokens: ({ accessToken, refreshToken }) => {
updateTokens: ({ accessToken, accessTokenExpiresAt, refreshToken }) => {
set((state) => ({
accessToken,
accessTokenExpiresAt:
accessTokenExpiresAt ?? state.accessTokenExpiresAt,
refreshToken: refreshToken ?? state.refreshToken,
status: 'authenticated',
isHydrated: true,
@@ -101,6 +170,7 @@ export const useAuthStore = create<AuthState>()(
clearAccessToken: () => {
set({
accessToken: null,
accessTokenExpiresAt: null,
status: 'anonymous',
isHydrated: true,
})
@@ -126,6 +196,10 @@ export const useAuthStore = create<AuthState>()(
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
accessToken: state.accessToken,
accessTokenExpiresAt: state.accessTokenExpiresAt,
apiAuthServerTime: state.apiAuthServerTime,
apiAuthToken: state.apiAuthToken,
apiAuthTokenExpiresAt: state.apiAuthTokenExpiresAt,
currentUser: state.currentUser,
refreshToken: state.refreshToken,
}),
@@ -141,3 +215,53 @@ export const useAuthStore = create<AuthState>()(
},
),
)
interface AppPreferenceStoreState extends PersistedAppPreferenceState {
getOrCreateDeviceId: () => string
setAppLanguage: (language: string) => void
}
export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
persist(
(set, get) => ({
appLanguage: null,
deviceId: null,
getOrCreateDeviceId: () => {
const deviceId = get().deviceId
if (deviceId) {
return deviceId
}
const nextDeviceId = generateDeviceId()
set({ deviceId: nextDeviceId })
return nextDeviceId
},
setAppLanguage: (language) => {
set({ appLanguage: language })
},
}),
{
name: APP_PREFERENCES_STORAGE_KEY,
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
appLanguage: state.appLanguage,
deviceId: state.deviceId,
}),
},
),
)
export function getAuthDeviceId() {
return useAppPreferenceStore.getState().getOrCreateDeviceId()
}
export function getStoredAppLanguage() {
return useAppPreferenceStore.getState().appLanguage
}
export function setStoredAppLanguage(language: string) {
useAppPreferenceStore.getState().setAppLanguage(language)
}

View File

@@ -22,7 +22,13 @@ import {
type GameRoundSlice = Pick<
GameBootstrapSnapshot,
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
| 'cells'
| 'chips'
| 'history'
| 'maxSelectionCount'
| 'round'
| 'selections'
| 'trends'
>
export interface GameRoundStoreState extends GameRoundSlice {
@@ -47,6 +53,7 @@ function createInitialRoundState(): GameRoundSlice & { activeChipId: string } {
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
@@ -68,6 +75,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
@@ -79,8 +87,19 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
getChipById(state.chips, state.activeChipId) ??
state.chips.find((chip) => chip.isDefault) ??
state.chips[0]
const hasExistingSelection = state.selections.some(
(selection) => selection.cellId === cellId,
)
const selectedCellCount = new Set(
state.selections.map((selection) => selection.cellId),
).size
if (!activeChip || state.round.phase !== 'betting') {
if (
!activeChip ||
state.round.phase !== 'betting' ||
hasExistingSelection ||
selectedCellCount >= state.maxSelectionCount
) {
return state
}
@@ -165,7 +184,13 @@ export const selectSelectionsByCell = (state: GameRoundStoreState) =>
export type GameRoundStore = typeof useGameRoundStore
export type GameRoundStoreData = Pick<
GameRoundStoreState,
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
| 'cells'
| 'chips'
| 'history'
| 'maxSelectionCount'
| 'round'
| 'selections'
| 'trends'
>
export type { BetSelection, GameCell, HistoryEntry, RoundSnapshot, TrendEntry }

View File

@@ -22,6 +22,9 @@ export interface GameSessionStoreState extends GameSessionSlice {
dismissAnnouncement: (announcementId: string) => void
hydrateSession: (snapshot: GameSessionSlice) => void
markAnnouncementRead: (announcementId: string) => void
requestRealtimeConnection: () => void
resetRealtimeConnectionRequest: () => void
shouldConnectRealtime: boolean
setConnectionLatency: (latencyMs: number | null) => void
setConnectionStatus: (status: ConnectionStatus) => void
syncConnection: (patch: Partial<ConnectionState>) => void
@@ -40,6 +43,7 @@ function createInitialSessionState(): GameSessionSlice {
export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
...createInitialSessionState(),
shouldConnectRealtime: false,
dismissAnnouncement: (announcementId) => {
set((state) => ({
announcements: {
@@ -57,7 +61,10 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
}))
},
hydrateSession: (snapshot) => {
set(snapshot)
set((state) => ({
...snapshot,
shouldConnectRealtime: state.shouldConnectRealtime,
}))
},
markAnnouncementRead: (announcementId) => {
set((state) => ({
@@ -69,6 +76,12 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
},
}))
},
requestRealtimeConnection: () => {
set({ shouldConnectRealtime: true })
},
resetRealtimeConnectionRequest: () => {
set({ shouldConnectRealtime: false })
},
setConnectionLatency: (latencyMs) => {
set((state) => ({
connection: {

View File

@@ -1,2 +1,3 @@
export * from './auth'
export * from './game'
export * from './modal'

1
src/store/modal/index.ts Normal file
View File

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

View File

@@ -0,0 +1,60 @@
import { create } from 'zustand'
import type { WithdrawTopupType } from '@/type'
export const MODAL_KEYS = [
/**@description 桌面端登录弹窗*/
'desktopLogin',
/**@description 桌面端注册弹窗*/
'desktopRegister',
/**@description 桌面端用户信息弹窗*/
'desktopUserInfo',
/**@description 桌面端公告弹窗*/
'desktopNotice',
/**@description 桌面端自动托管弹窗*/
'desktopAutoSetting',
/**@description 桌面端充值提现前置选择弹窗*/
'desktopProcedures',
/**@description 桌面端充值/提现弹窗*/
'desktopWithdrawTopup',
] as const
export type ModalKey = (typeof MODAL_KEYS)[number]
type ModalVisibilityMap = Record<ModalKey, boolean>
const INITIAL_MODAL_VISIBILITY: ModalVisibilityMap = {
desktopLogin: false,
desktopRegister: false,
desktopUserInfo: false,
desktopNotice: false,
desktopAutoSetting: false,
desktopProcedures: false,
desktopWithdrawTopup: false,
}
export interface ModalStoreState {
modals: ModalVisibilityMap
withdrawTopupType: WithdrawTopupType
closeAllModals: () => void
setModalOpen: (key: ModalKey, open: boolean) => void
setWithdrawTopupType: (type: WithdrawTopupType) => void
}
export const useModalStore = create<ModalStoreState>()((set) => ({
modals: INITIAL_MODAL_VISIBILITY,
withdrawTopupType: 'withdraw',
closeAllModals: () => {
set({ modals: INITIAL_MODAL_VISIBILITY })
},
setModalOpen: (key, open) => {
set((state) => ({
modals: {
...state.modals,
[key]: open,
},
}))
},
setWithdrawTopupType: (type) => {
set({ withdrawTopupType: type })
},
}))

View File

@@ -198,6 +198,7 @@
border-radius: 5px;
padding: calc(var(--design-unit) * 8) calc(var(--design-unit) * 10);
box-shadow: inset 0 0 8px rgba(128, 223, 231, 0.65);
color: #d5fbff;
}
.common-neon-inset-glow {
@@ -346,6 +347,173 @@
height: 0;
display: none;
}
.game-toaster {
--width: min(
calc(100vw - calc(var(--design-unit) * 32)),
calc(var(--design-unit) * 520)
);
}
.game-toast {
width: min(
calc(100vw - calc(var(--design-unit) * 32)),
calc(var(--design-unit) * 520)
);
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: calc(var(--design-unit) * 14);
align-items: center;
min-height: calc(var(--design-unit) * 60);
padding: calc(var(--design-unit) * 16) calc(var(--design-unit) * 20);
border-radius: calc(var(--design-unit) * 16);
border: 1px solid rgba(128, 223, 231, 0.68);
background:
linear-gradient(180deg, rgba(15, 35, 49, 0.98), rgba(6, 16, 24, 0.98)),
radial-gradient(circle at top, rgba(124, 232, 255, 0.22), transparent 58%);
box-shadow:
inset 0 0 calc(var(--design-unit) * 16) rgba(128, 223, 231, 0.18),
0 0 calc(var(--design-unit) * 24) rgba(29, 190, 219, 0.26),
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
rgba(2, 8, 16, 0.42);
backdrop-filter: blur(14px);
position: relative;
overflow: hidden;
}
.game-toast::before {
content: "";
position: absolute;
inset: 1px;
border-radius: inherit;
border: 1px solid rgba(255, 255, 255, 0.04);
border-top-color: rgba(215, 250, 255, 0.32);
pointer-events: none;
}
.game-toast-success {
border-color: rgba(79, 220, 155, 0.72);
box-shadow:
inset 0 0 calc(var(--design-unit) * 16) rgba(79, 220, 155, 0.16),
0 0 calc(var(--design-unit) * 24) rgba(79, 220, 155, 0.24),
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
rgba(2, 8, 16, 0.42);
}
.game-toast-error {
border-color: rgba(255, 94, 122, 0.68);
box-shadow:
inset 0 0 calc(var(--design-unit) * 16) rgba(255, 94, 122, 0.16),
0 0 calc(var(--design-unit) * 24) rgba(255, 94, 122, 0.24),
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
rgba(2, 8, 16, 0.42);
}
.game-toast-warning {
border-color: rgba(255, 214, 110, 0.72);
box-shadow:
inset 0 0 calc(var(--design-unit) * 16) rgba(255, 214, 110, 0.16),
0 0 calc(var(--design-unit) * 24) rgba(255, 214, 110, 0.22),
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
rgba(2, 8, 16, 0.42);
}
.game-toast-info,
.game-toast-loading,
.game-toast-default {
border-color: rgba(128, 223, 231, 0.52);
}
.game-toast-icon {
display: flex;
align-items: center;
justify-content: center;
width: calc(var(--design-unit) * 30);
height: calc(var(--design-unit) * 30);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
box-shadow:
inset 0 0 calc(var(--design-unit) * 8) rgba(255, 255, 255, 0.04),
0 0 calc(var(--design-unit) * 12) rgba(124, 232, 255, 0.12);
}
.game-toast-content {
min-width: 0;
padding-right: calc(var(--design-unit) * 22);
}
.game-toast-title {
font-size: calc(var(--design-unit) * 17);
line-height: 1.3;
font-weight: 800;
letter-spacing: 0.03em;
color: #f2fdff;
text-shadow:
0 0 calc(var(--design-unit) * 8) rgba(124, 232, 255, 0.18),
0 0 calc(var(--design-unit) * 16) rgba(124, 232, 255, 0.08);
}
.game-toast-description {
margin-top: calc(var(--design-unit) * 5);
font-size: calc(var(--design-unit) * 13);
line-height: 1.45;
color: rgba(213, 251, 255, 0.84);
}
.game-toast-close {
position: absolute;
top: 50%;
right: calc(var(--design-unit) * 14);
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: calc(var(--design-unit) * 28);
height: calc(var(--design-unit) * 28);
border-radius: 999px;
border: 1px solid rgba(128, 223, 231, 0.34);
background: rgba(4, 18, 27, 0.82);
box-shadow:
inset 0 0 calc(var(--design-unit) * 6) rgba(255, 255, 255, 0.04),
0 0 calc(var(--design-unit) * 10) rgba(124, 232, 255, 0.14);
transition:
border-color 180ms ease,
background-color 180ms ease,
transform 180ms ease,
box-shadow 180ms ease;
cursor: pointer;
}
.game-toast-close:hover {
transform: translateY(-50%) scale(1.04);
border-color: rgba(128, 223, 231, 0.58);
background: rgba(10, 28, 40, 0.96);
box-shadow:
inset 0 0 calc(var(--design-unit) * 8) rgba(255, 255, 255, 0.06),
0 0 calc(var(--design-unit) * 14) rgba(124, 232, 255, 0.22);
}
.game-toast-action,
.game-toast-cancel {
margin-top: calc(var(--design-unit) * 10);
border-radius: calc(var(--design-unit) * 999);
padding: calc(var(--design-unit) * 6) calc(var(--design-unit) * 12);
font-size: calc(var(--design-unit) * 12);
font-weight: 600;
cursor: pointer;
}
.game-toast-action {
border: 1px solid rgba(128, 223, 231, 0.52);
background: rgba(10, 34, 47, 0.9);
color: #d5fbff;
}
.game-toast-cancel {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(213, 251, 255, 0.74);
}
}
@theme inline {

17
src/type/index.ts Normal file
View File

@@ -0,0 +1,17 @@
export type WithdrawTopupType = 'withdraw' | 'topup'
/** @description 后端统一响应体结构。 */
export interface ApiResponse<T> {
code: number
msg?: string
data: T
message?: string
}
/** @description 后端统一错误响应体结构。 */
export interface ApiErrorOptions {
message: string
status?: number
data?: unknown
url?: string
}

1
src/vite-env.d.ts vendored
View File

@@ -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'
}