refactor(game): 重构项目结构,优化链路, 移动端适配

- 移除 useGameBoardVm 数据层实施说明文档
- 移除核心玩法与前端规则摘要文档
- 移除游戏模块数据与界面分层第一阶段实施稿文档
- 清理与数据层重构相关的技术方案说明
- 删除关于 PC 和 Mobile 界面分离的设计规划
- 移除 view-model hooks 架构设计相关内容
This commit is contained in:
JiaJun
2026-06-03 17:21:13 +08:00
parent 3efcb3bba6
commit bfb4b76611
129 changed files with 4534 additions and 4227 deletions

View File

@@ -1,233 +0,0 @@
import {
API_SUCCESS_CODE,
AUTH_ENDPOINTS,
AUTH_SKIP_REFRESH_CONTEXT_KEY,
SMS_SEND_EVENT_REGISTER,
} from '@/constants'
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,
LogoutPayload,
LogoutRequestDto,
RefreshTokenDto,
RefreshTokenRequestDto,
RegisterPayload,
RegisterRequestDto,
SendSmsCodeDto,
SendSmsCodePayload,
SendSmsCodeRequestDto,
SendSmsCodeResult,
} from './types'
import {
mergeAuthUsers,
normalizeAuthSession,
normalizeAuthUserProfile,
normalizeRefreshAuthSession,
} from './types'
const shouldLogAuthLifecycle =
import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
function unwrapEnvelope<T>(
response: ApiResponse<T>,
fallbackErrorKey = 'auth.errors.requestFailed',
) {
if (response.code === API_SUCCESS_CODE) {
return response.data
}
throw new ApiError({
data: response,
message:
typeof response.msg === 'string' && response.msg.length > 0
? response.msg
: typeof response.message === 'string' && response.message.length > 0
? 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 logoutWithPassword(
payload: LogoutPayload,
): Promise<void> {
const response = await api.post<null, LogoutRequestDto>(
AUTH_ENDPOINTS.logout,
{
json: {
password: payload.password,
username: payload.username,
},
},
)
unwrapEnvelope(
response as ApiResponse<null>,
'auth.logout.errors.submitFailed',
)
}
export async function registerWithPassword(
payload: RegisterPayload,
): Promise<AuthSessionInput> {
const response = await api.post<AuthSessionDto, RegisterRequestDto>(
AUTH_ENDPOINTS.register,
{
json: {
captcha: payload.captcha,
device_id: getAuthDeviceId(),
invite_code: payload.inviteCode,
password: payload.password,
username: payload.mobile,
},
},
)
const session = await buildEnrichedAuthSession(
unwrapEnvelope(
response as ApiResponse<AuthSessionDto>,
'auth.register.errors.submitFailed',
),
)
logAuthSessionExpiry('register', session)
return session
}
export async function sendSmsCode(
payload: SendSmsCodePayload,
): Promise<SendSmsCodeResult> {
const response = await api.post<SendSmsCodeDto, SendSmsCodeRequestDto>(
AUTH_ENDPOINTS.sendSmsCode,
{
json: {
event: SMS_SEND_EVENT_REGISTER,
mobile: payload.mobile,
},
},
)
const data = unwrapEnvelope(
response as ApiResponse<SendSmsCodeDto>,
'auth.register.sms.errors.submitFailed',
)
return {
expiresIn: data.expires_in,
messageId: data.message_id,
}
}
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: {
[AUTH_SKIP_REFRESH_CONTEXT_KEY]: true,
},
json: {
refresh_token: refreshToken,
},
},
)
const session = normalizeRefreshAuthSession(
unwrapEnvelope(
response as ApiResponse<RefreshTokenDto>,
'auth.errors.requestFailed',
),
)
logAuthSessionExpiry('refresh', session)
return session
}

View File

@@ -1,174 +0,0 @@
import type { SMS_SEND_EVENT_REGISTER } from '@/constants'
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 LogoutRequestDto {
password: string
username: string
}
export interface RegisterRequestDto {
captcha: string
device_id?: string
invite_code: string
password: string
username: string
}
export interface SendSmsCodeRequestDto {
event: typeof SMS_SEND_EVENT_REGISTER
mobile: string
}
export interface SendSmsCodeDto {
expires_in: number
message_id: string
}
export interface RefreshTokenRequestDto {
refresh_token: string
}
export interface LoginPayload {
password: string
username: string
}
export type LogoutPayload = LoginPayload
export interface RegisterPayload {
captcha: string
inviteCode: string
mobile: string
password: string
}
export interface SendSmsCodePayload {
mobile: string
}
export interface SendSmsCodeResult {
expiresIn: number
messageId: 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

@@ -1,8 +1,6 @@
import { Eye, EyeOff } from 'lucide-react'
import { type ComponentProps, type ReactNode, useState } from 'react'
import { useTranslation } from 'react-i18next'
import rightImg from '@/assets/system/right.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { Input } from '@/components/ui/input.tsx'
import { cn } from '@/lib/utils'
@@ -42,33 +40,6 @@ export function DesktopAuthInputError({ message }: { message?: string }) {
)
}
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>
)
}
type DesktopAuthPasswordInputProps = Omit<ComponentProps<'input'>, 'type'>
export function DesktopAuthPasswordInput({
@@ -104,23 +75,3 @@ export function DesktopAuthPasswordInput({
</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

@@ -1,6 +1,6 @@
import { useController } from 'react-hook-form'
import { useLoginForm } from '@/hooks/use-login-form'
import { useModalStore } from '@/store'
import { useLoginForm } from '../hooks/use-login-form'
import { DesktopLoginFormView } from './desktop-login-form-view'
interface DesktopLoginFormProps {

View File

@@ -1,7 +1,7 @@
import { useController } from 'react-hook-form'
import { useRegisterForm } from '@/hooks/use-register-form'
import { useSendSmsCode } from '@/hooks/use-send-sms-code'
import { useModalStore } from '@/store'
import { useRegisterForm } from '../hooks/use-register-form'
import { useSendSmsCode } from '../hooks/use-send-sms-code'
import { DesktopRegisterFormView } from './desktop-register-form-view'
interface DesktopRegisterFormProps {

View File

@@ -0,0 +1,68 @@
import { Eye, EyeOff } from 'lucide-react'
import { type ComponentProps, type ReactNode, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input.tsx'
import { cn } from '@/lib/utils'
export function MobileAuthFieldRow({
label,
children,
}: {
children: ReactNode
label: string
}) {
return (
<div className="flex min-w-0 flex-col gap-design-5">
<span className="text-design-12 font-medium leading-none text-[#67C9CE]">
{label}
</span>
{children}
</div>
)
}
export function MobileAuthInputError({ message }: { message?: string }) {
if (!message) {
return null
}
return (
<div className="text-design-10 leading-tight text-[#FF7777]">{message}</div>
)
}
type MobileAuthPasswordInputProps = Omit<ComponentProps<'input'>, 'type'>
export function MobileAuthPasswordInput({
className,
...props
}: MobileAuthPasswordInputProps) {
const { t } = useTranslation()
const [isVisible, setIsVisible] = useState(false)
return (
<div className="relative">
<Input
{...props}
type={isVisible ? 'text' : 'password'}
className={cn('pr-design-36', className)}
/>
<button
type="button"
aria-label={t(
isVisible
? 'auth.common.passwordVisibility.hide'
: 'auth.common.passwordVisibility.show',
)}
onClick={() => setIsVisible((value) => !value)}
className="absolute right-design-8 top-1/2 flex h-design-24 w-design-24 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-[#A9E8EA] transition-colors duration-200 ease-out hover:text-[#F2FFFF] focus-visible:outline-none focus-visible:ring-[calc(var(--design-unit)*1.5)] focus-visible:ring-[rgba(110,255,255,0.4)]"
>
{isVisible ? (
<EyeOff aria-hidden="true" className="h-design-15 w-design-15" />
) : (
<Eye aria-hidden="true" className="h-design-15 w-design-15" />
)}
</button>
</div>
)
}

View File

@@ -0,0 +1,145 @@
import { motion, useReducedMotion } 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 {
MobileAuthFieldRow,
MobileAuthInputError,
MobileAuthPasswordInput,
} from './mobile-auth-form-parts'
interface MobileLoginFormViewProps {
errors: {
password?: string
username?: string
}
isSubmitting: boolean
onPasswordChange: (value: string) => void
onSubmit: () => void
onSwitchToRegister: () => void
onUsernameChange: (value: string) => void
password: string
username: string
}
export function MobileLoginFormView({
errors,
isSubmitting,
onPasswordChange,
onSubmit,
onSwitchToRegister,
onUsernameChange,
password,
username,
}: MobileLoginFormViewProps) {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const inputClassName =
'h-design-36 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] px-design-10 py-0 text-left text-design-14 shadow-[inset_0_0_calc(var(--design-unit)*8)_rgba(129,239,243,0.05)]'
return (
<form
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
className="relative isolate flex h-full min-h-0 flex-col justify-between gap-design-8 px-design-14 pb-design-12 pt-design-2"
>
<div className="relative min-h-0 w-full flex-1 overflow-hidden rounded-[calc(var(--design-unit)*12)] border border-[#214B53] mt-design-10 bg-[linear-gradient(180deg,rgba(7,21,27,0.94)_0%,rgba(5,15,20,0.82)_100%)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(6,112,126,0.14),inset_0_0_0_calc(var(--design-unit)*1)_rgba(120,222,227,0.08)] backdrop-blur-[calc(var(--design-unit)*8)]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(93,211,218,0.16),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(63,109,137,0.22),transparent_34%)]" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-design-80 bg-[linear-gradient(180deg,rgba(87,196,201,0.12),transparent)]" />
<div className="relative flex h-full min-h-0 flex-col overflow-y-auto px-design-18 py-design-14">
<div className="flex items-center gap-design-10 mb-design-10">
<div className="h-design-6 w-design-6 rounded-full bg-[#6EE4E6] shadow-[0_0_calc(var(--design-unit)*10)_rgba(110,228,230,0.75)]" />
<div className="h-px flex-1 bg-[linear-gradient(90deg,rgba(110,228,230,0.5),rgba(110,228,230,0))]" />
</div>
<MobileAuthFieldRow label={t('auth.login.fields.username.label')}>
<Input
id="mobile-login-username"
name="username"
autoComplete="username"
spellCheck={false}
value={username}
onChange={(event) => onUsernameChange(event.target.value)}
placeholder={t('auth.login.fields.username.placeholder')}
aria-describedby="mobile-login-username-error"
aria-invalid={Boolean(errors.username)}
className={inputClassName}
/>
<div
id="mobile-login-username-error"
className="relative h-design-16 overflow-hidden"
>
<div className="absolute inset-x-0 top-0">
<MobileAuthInputError
message={errors.username ? t(errors.username) : undefined}
/>
</div>
</div>
</MobileAuthFieldRow>
<MobileAuthFieldRow label={t('auth.login.fields.password.label')}>
<MobileAuthPasswordInput
id="mobile-login-password"
name="password"
autoComplete="current-password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
placeholder={t('auth.login.fields.password.placeholder')}
aria-describedby="mobile-login-password-error"
aria-invalid={Boolean(errors.password)}
className={inputClassName}
/>
<div
id="mobile-login-password-error"
className="relative h-design-16 overflow-hidden"
>
<div className="absolute inset-x-0 top-0">
<MobileAuthInputError
message={errors.password ? t(errors.password) : undefined}
/>
</div>
</div>
</MobileAuthFieldRow>
<motion.div
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
className="mt-auto flex items-center justify-center gap-design-7 text-center text-design-12 text-[#6DB5B9]"
>
<div className="h-px w-design-42 bg-[linear-gradient(90deg,rgba(109,181,185,0),rgba(109,181,185,0.7))]" />
<button
type="button"
onClick={onSwitchToRegister}
className="cursor-pointer underline underline-offset-[calc(var(--design-unit)*3)] transition-colors duration-200 ease-out hover:text-[#90DBDE] focus-visible:outline-none focus-visible:text-[#90DBDE]"
>
{t('auth.login.footer.registerAccount')}
</button>
<div className="h-px w-design-42 bg-[linear-gradient(90deg,rgba(109,181,185,0.7),rgba(109,181,185,0))]" />
</motion.div>
</div>
</div>
<div className="relative flex w-full shrink-0 justify-center">
<div className="pointer-events-none absolute inset-x-[20%] top-1/2 h-design-22 -translate-y-1/2 rounded-full bg-[rgba(76,213,216,0.22)] blur-[calc(var(--design-unit)*14)]" />
<SmartBackground
as={motion.button}
type="submit"
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
src={loginBg}
size="100% 100%"
className="relative z-10 flex h-design-58 w-design-210 cursor-pointer items-center justify-center overflow-hidden pb-design-2 text-design-18 font-bold text-[#F2FFFF] duration-200 ease-out hover:brightness-110 disabled:pointer-events-none disabled:opacity-60"
disabled={isSubmitting}
>
<span className="modal-title-glow text-design-15">
{isSubmitting
? t('auth.common.actions.submitting')
: t('auth.login.actions.submit')}
</span>
</SmartBackground>
</div>
</form>
)
}

View File

@@ -0,0 +1,43 @@
import { useController } from 'react-hook-form'
import { useLoginForm } from '@/hooks/use-login-form'
import { useModalStore } from '@/store'
import { MobileLoginFormView } from './mobile-login-form-view'
interface MobileLoginFormProps {
onSuccess?: () => void
}
export function MobileLoginForm({ onSuccess }: MobileLoginFormProps) {
const { form, isSubmitting, onSubmit } = useLoginForm({
onSuccess,
})
const openExclusiveModal = useModalStore((state) => state.openExclusiveModal)
const usernameField = useController({
control: form.control,
name: 'username',
})
const passwordField = useController({
control: form.control,
name: 'password',
})
function handleSwitchToRegister() {
openExclusiveModal('desktopRegister')
}
return (
<MobileLoginFormView
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}
onSwitchToRegister={handleSwitchToRegister}
onUsernameChange={usernameField.field.onChange}
/>
)
}

View File

@@ -0,0 +1,276 @@
import { motion, useReducedMotion } 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 { cn } from '@/lib/utils'
import {
MobileAuthFieldRow,
MobileAuthInputError,
MobileAuthPasswordInput,
} from './mobile-auth-form-parts'
interface MobileRegisterFormViewProps {
captcha: string
confirmPassword: string
errors: {
captcha?: string
confirmPassword?: string
inviteCode?: string
mobile?: string
password?: string
}
inviteCode: string
isSendingSmsCode: boolean
isSubmitting: boolean
mobile: string
onCaptchaChange: (value: string) => void
onConfirmPasswordChange: (value: string) => void
onInviteCodeChange: (value: string) => void
onMobileChange: (value: string) => void
onPasswordChange: (value: string) => void
onSendSmsCode: () => Promise<unknown>
onSubmit: () => void
onSwitchToLogin: () => void
password: string
smsCodeCanSend: boolean
smsCodeRemainingSeconds: number
}
const errorSlotClassName = 'relative h-design-12 overflow-hidden'
export function MobileRegisterFormView({
captcha,
confirmPassword,
errors,
inviteCode,
isSendingSmsCode,
isSubmitting,
mobile,
onCaptchaChange,
onConfirmPasswordChange,
onInviteCodeChange,
onMobileChange,
onPasswordChange,
onSendSmsCode,
onSubmit,
onSwitchToLogin,
password,
smsCodeCanSend,
smsCodeRemainingSeconds,
}: MobileRegisterFormViewProps) {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const inputClassName =
'h-design-32 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] px-design-10 py-0 text-left text-design-13 shadow-[inset_0_0_calc(var(--design-unit)*8)_rgba(129,239,243,0.05)]'
return (
<form
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
className="relative isolate flex h-full min-h-0 flex-col justify-between gap-design-6 px-design-14 pb-design-10 pt-design-10"
>
<div className="relative min-h-0 w-full flex-1 overflow-hidden rounded-[calc(var(--design-unit)*12)] border border-[#214B53] bg-[linear-gradient(180deg,rgba(7,21,27,0.94)_0%,rgba(5,15,20,0.82)_100%)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(6,112,126,0.14),inset_0_0_0_calc(var(--design-unit)*1)_rgba(120,222,227,0.08)] backdrop-blur-[calc(var(--design-unit)*8)]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(93,211,218,0.16),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(63,109,137,0.22),transparent_34%)]" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-design-80 bg-[linear-gradient(180deg,rgba(87,196,201,0.12),transparent)]" />
<div className="relative flex h-full min-h-0 flex-col overflow-y-auto px-design-18 py-design-10">
<div className="flex items-center gap-design-10 mb-design-5">
<div className="h-design-6 w-design-6 rounded-full bg-[#6EE4E6] shadow-[0_0_calc(var(--design-unit)*10)_rgba(110,228,230,0.75)]" />
<div className="h-px flex-1 bg-[linear-gradient(90deg,rgba(110,228,230,0.5),rgba(110,228,230,0))]" />
</div>
<MobileAuthFieldRow label={t('auth.register.fields.mobile.label')}>
<Input
id="mobile-register-mobile"
name="mobile"
autoComplete="username"
inputMode="tel"
spellCheck={false}
value={mobile}
onChange={(event) => onMobileChange(event.target.value)}
placeholder={t('auth.register.fields.mobile.placeholder')}
aria-describedby="mobile-register-mobile-error"
aria-invalid={Boolean(errors.mobile)}
className={inputClassName}
/>
<div
id="mobile-register-mobile-error"
className={errorSlotClassName}
>
<div className="absolute inset-x-0 top-0">
<MobileAuthInputError
message={errors.mobile ? t(errors.mobile) : undefined}
/>
</div>
</div>
</MobileAuthFieldRow>
<MobileAuthFieldRow label={t('auth.register.fields.captcha.label')}>
<div className="flex min-w-0 gap-design-8">
<Input
id="mobile-register-captcha"
name="captcha"
autoComplete="one-time-code"
inputMode="numeric"
spellCheck={false}
value={captcha}
onChange={(event) => onCaptchaChange(event.target.value)}
placeholder={t('auth.register.fields.captcha.placeholder')}
aria-describedby="mobile-register-captcha-error"
aria-invalid={Boolean(errors.captcha)}
className={cn(inputClassName, 'min-w-0 flex-1')}
/>
<button
type="button"
onClick={() => void onSendSmsCode()}
disabled={!mobile.trim() || !smsCodeCanSend}
className={cn(
'h-design-32 w-design-94 shrink-0 cursor-pointer rounded-md border border-[#3F8E93] bg-[linear-gradient(180deg,rgba(37,116,122,0.9),rgba(16,75,82,0.9))] px-design-5 text-design-11 font-semibold leading-tight text-[#E8FFFF] transition duration-200 ease-out hover:brightness-110 focus-visible:outline-none focus-visible:ring-[calc(var(--design-unit)*1.5)] focus-visible:ring-[rgba(110,255,255,0.35)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-55',
smsCodeRemainingSeconds > 0 && 'text-[#9AC7CA]',
)}
>
{smsCodeRemainingSeconds > 0
? t('auth.register.sms.countdown', {
seconds: smsCodeRemainingSeconds,
})
: isSendingSmsCode
? t('auth.register.sms.sending')
: t('auth.register.sms.send')}
</button>
</div>
<div
id="mobile-register-captcha-error"
className={errorSlotClassName}
>
<div className="absolute inset-x-0 top-0">
<MobileAuthInputError
message={errors.captcha ? t(errors.captcha) : undefined}
/>
</div>
</div>
</MobileAuthFieldRow>
<MobileAuthFieldRow label={t('auth.register.fields.password.label')}>
<MobileAuthPasswordInput
id="mobile-register-password"
name="password"
autoComplete="new-password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
placeholder={t('auth.register.fields.password.placeholder')}
aria-describedby="mobile-register-password-error"
aria-invalid={Boolean(errors.password)}
className={inputClassName}
/>
<div
id="mobile-register-password-error"
className={errorSlotClassName}
>
<div className="absolute inset-x-0 top-0">
<MobileAuthInputError
message={errors.password ? t(errors.password) : undefined}
/>
</div>
</div>
</MobileAuthFieldRow>
<MobileAuthFieldRow
label={t('auth.register.fields.confirmPassword.label')}
>
<MobileAuthPasswordInput
id="mobile-register-confirm-password"
name="confirmPassword"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => onConfirmPasswordChange(event.target.value)}
placeholder={t(
'auth.register.fields.confirmPassword.placeholder',
)}
aria-describedby="mobile-register-confirm-password-error"
aria-invalid={Boolean(errors.confirmPassword)}
className={inputClassName}
/>
<div
id="mobile-register-confirm-password-error"
className={errorSlotClassName}
>
<div className="absolute inset-x-0 top-0">
<MobileAuthInputError
message={
errors.confirmPassword
? t(errors.confirmPassword)
: undefined
}
/>
</div>
</div>
</MobileAuthFieldRow>
<MobileAuthFieldRow
label={t('auth.register.fields.inviteCode.label')}
>
<Input
id="mobile-register-invite-code"
name="inviteCode"
autoComplete="off"
spellCheck={false}
value={inviteCode}
onChange={(event) => onInviteCodeChange(event.target.value)}
placeholder={t('auth.register.fields.inviteCode.placeholder')}
aria-describedby="mobile-register-invite-code-error"
aria-invalid={Boolean(errors.inviteCode)}
className={inputClassName}
/>
<div
id="mobile-register-invite-code-error"
className={errorSlotClassName}
>
<div className="absolute inset-x-0 top-0">
<MobileAuthInputError
message={errors.inviteCode ? t(errors.inviteCode) : undefined}
/>
</div>
</div>
</MobileAuthFieldRow>
<motion.div
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
className="flex items-center justify-center gap-design-6 text-center text-design-11 text-[#6DB5B9]"
>
<div className="h-px w-design-30 bg-[linear-gradient(90deg,rgba(109,181,185,0),rgba(109,181,185,0.7))]" />
<button
type="button"
onClick={onSwitchToLogin}
className="cursor-pointer underline underline-offset-[calc(var(--design-unit)*3)] transition-colors duration-200 ease-out hover:text-[#90DBDE] focus-visible:outline-none focus-visible:text-[#90DBDE]"
>
{t('auth.register.footer.alreadyHaveAccount')}
</button>
<div className="h-px w-design-30 bg-[linear-gradient(90deg,rgba(109,181,185,0.7),rgba(109,181,185,0))]" />
</motion.div>
</div>
</div>
<div className="relative flex w-full shrink-0 justify-center">
<div className="pointer-events-none absolute inset-x-[22%] top-1/2 h-design-20 -translate-y-1/2 rounded-full bg-[rgba(76,213,216,0.22)] blur-[calc(var(--design-unit)*12)]" />
<SmartBackground
as={motion.button}
type="submit"
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
src={loginBg}
size="100% 100%"
className="relative z-10 flex h-design-52 w-design-198 cursor-pointer items-center justify-center overflow-hidden pb-design-2 text-design-17 font-bold text-[#F2FFFF] duration-200 ease-out hover:brightness-110 disabled:pointer-events-none disabled:opacity-60"
disabled={isSubmitting}
>
<span className="modal-title-glow text-design-14">
{isSubmitting
? t('auth.common.actions.submitting')
: t('auth.register.actions.submit')}
</span>
</SmartBackground>
</div>
</form>
)
}

View File

@@ -0,0 +1,70 @@
import { useController } from 'react-hook-form'
import { useRegisterForm } from '@/hooks/use-register-form'
import { useSendSmsCode } from '@/hooks/use-send-sms-code'
import { useModalStore } from '@/store'
import { MobileRegisterFormView } from './mobile-register-form-view'
interface MobileRegisterFormProps {
onSuccess?: () => void
}
export function MobileRegisterForm({ onSuccess }: MobileRegisterFormProps) {
const { form, isSubmitting, onSubmit } = useRegisterForm({
onSuccess,
})
const smsCode = useSendSmsCode()
const openExclusiveModal = useModalStore((state) => state.openExclusiveModal)
const mobileField = useController({
control: form.control,
name: 'mobile',
})
const captchaField = useController({
control: form.control,
name: 'captcha',
})
const passwordField = useController({
control: form.control,
name: 'password',
})
const confirmPasswordField = useController({
control: form.control,
name: 'confirmPassword',
})
const inviteCodeField = useController({
control: form.control,
name: 'inviteCode',
})
function handleSwitchToLogin() {
openExclusiveModal('desktopLogin')
}
return (
<MobileRegisterFormView
mobile={mobileField.field.value ?? ''}
captcha={captchaField.field.value ?? ''}
password={passwordField.field.value ?? ''}
confirmPassword={confirmPasswordField.field.value ?? ''}
inviteCode={inviteCodeField.field.value ?? ''}
errors={{
captcha: form.formState.errors.captcha?.message,
confirmPassword: form.formState.errors.confirmPassword?.message,
inviteCode: form.formState.errors.inviteCode?.message,
mobile: form.formState.errors.mobile?.message,
password: form.formState.errors.password?.message,
}}
isSubmitting={isSubmitting}
isSendingSmsCode={smsCode.isSending}
onCaptchaChange={captchaField.field.onChange}
onConfirmPasswordChange={confirmPasswordField.field.onChange}
onInviteCodeChange={inviteCodeField.field.onChange}
onMobileChange={mobileField.field.onChange}
onPasswordChange={passwordField.field.onChange}
onSendSmsCode={() => smsCode.send(mobileField.field.value ?? '')}
onSubmit={onSubmit}
onSwitchToLogin={handleSwitchToLogin}
smsCodeCanSend={smsCode.canSend}
smsCodeRemainingSeconds={smsCode.remainingSeconds}
/>
)
}

View File

@@ -1,73 +0,0 @@
import { AUTH_ERROR_KEY_PREFIX } from '@/constants'
import { ApiError } from '@/lib/api/api-error'
type AuthSubmitContext = 'login' | 'register'
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'
}
function hasServerMessage(error: ApiError) {
if (!error.data || typeof error.data !== 'object') {
return false
}
const data = error.data
const serverMessage =
'msg' in data && typeof data.msg === 'string'
? data.msg
: 'message' in data && typeof data.message === 'string'
? data.message
: null
return Boolean(serverMessage && serverMessage === error.message)
}
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 (hasServerMessage(error)) {
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

@@ -1,11 +0,0 @@
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

@@ -1,47 +0,0 @@
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

@@ -1,73 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import {
DEFAULT_REGISTER_INVITE_CODE,
REGISTER_INVITE_CODE_QUERY_PARAM,
} from '@/constants'
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
}
function getInitialRegisterInviteCode() {
if (typeof window === 'undefined') {
return DEFAULT_REGISTER_INVITE_CODE
}
return (
new URLSearchParams(window.location.search)
.get(REGISTER_INVITE_CODE_QUERY_PARAM)
?.trim() || DEFAULT_REGISTER_INVITE_CODE
)
}
export function useRegisterForm({ onSuccess }: UseRegisterFormOptions = {}) {
const startSession = useAuthStore((state) => state.startSession)
const form = useForm<RegisterFormValues>({
defaultValues: {
captcha: '',
confirmPassword: '',
inviteCode: getInitialRegisterInviteCode(),
mobile: '',
password: '',
},
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

@@ -1,50 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { SMS_CODE_COOLDOWN_FALLBACK_SECONDS } from '@/constants'
import i18n from '@/i18n'
import { notify } from '@/lib/notify'
import { sendSmsCode } from '../api/auth-api'
import { toAuthSubmitErrorKey } from './auth-error-key'
export function useSendSmsCode() {
const [remainingSeconds, setRemainingSeconds] = useState(0)
const mutation = useMutation({
mutationFn: (mobile: string) => sendSmsCode({ mobile: mobile.trim() }),
onError: (error) => {
const errorKey = toAuthSubmitErrorKey(error, 'register')
notify.error(
errorKey
? i18n.t(errorKey)
: i18n.t('auth.register.sms.errors.submitFailed'),
)
},
onSuccess: (result) => {
setRemainingSeconds(
result.expiresIn > 0
? result.expiresIn
: SMS_CODE_COOLDOWN_FALLBACK_SECONDS,
)
notify.success(i18n.t('auth.register.sms.success'))
},
})
useEffect(() => {
if (remainingSeconds <= 0) {
return
}
const timer = window.setTimeout(() => {
setRemainingSeconds((value) => Math.max(0, value - 1))
}, 1000)
return () => window.clearTimeout(timer)
}, [remainingSeconds])
return {
canSend: !mutation.isPending && remainingSeconds <= 0,
isSending: mutation.isPending,
remainingSeconds,
send: mutation.mutateAsync,
}
}

View File

@@ -1,66 +0,0 @@
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

@@ -1,47 +0,0 @@
import { z } from 'zod'
import {
INVITE_CODE_MAX_LENGTH,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
} from '@/constants'
const usernameSchema = z
.string()
.trim()
.min(1, 'auth.validation.username.required')
const captchaSchema = z
.string()
.trim()
.min(1, 'auth.validation.captcha.required')
const passwordSchema = z
.string()
.min(PASSWORD_MIN_LENGTH, 'auth.validation.password.min')
.max(PASSWORD_MAX_LENGTH, 'auth.validation.password.max')
export const loginFormSchema = z.object({
password: passwordSchema,
username: usernameSchema,
})
export const registerFormSchema = z
.object({
captcha: captchaSchema,
confirmPassword: passwordSchema,
inviteCode: z
.string()
.trim()
.min(1, 'auth.validation.inviteCode.required')
.max(INVITE_CODE_MAX_LENGTH, 'auth.validation.inviteCode.max'),
password: passwordSchema,
mobile: 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,378 +0,0 @@
import {
API_SUCCESS_CODE,
DEFAULT_LIST_PAGE_SIZE,
FINANCE_API_ENDPOINTS,
} from '@/constants'
import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type'
import type {
DepositCreateRequestDto,
DepositCreateResponseDto,
DepositTierItem,
DepositTierItemDto,
DepositWithdrawConfig,
DepositWithdrawConfigDto,
FinanceCurrencyConfigDto,
FinanceOrderItemDto,
FinanceOrderList,
FinanceOrderListDto,
FinancePayChannelDto,
FinanceRateConfigDto,
FinanceWithdrawBankDto,
FinanceWithdrawConfigDto,
WalletRecordItemDto,
WalletRecordList,
WalletRecordListDto,
WalletRecordType,
WithdrawCreateRequestDto,
WithdrawCreateResponseDto,
} from './finance-types'
function unwrapFinanceEnvelope<T>(
response: ApiResponse<T>,
fallbackMessage = 'Finance request failed',
) {
if (response.code === API_SUCCESS_CODE) {
return response.data
}
const responseMessage =
typeof response.message === 'string' && response.message.length > 0
? response.message
: typeof response.msg === 'string' && response.msg.length > 0
? response.msg
: fallbackMessage
throw new ApiError({
data: response,
message: responseMessage,
})
}
function toFiniteNumber(value: string | number | null | undefined) {
const numericValue = Number(value)
return Number.isFinite(numericValue) ? numericValue : 0
}
function normalizeCurrency(dto: FinanceCurrencyConfigDto) {
return {
code: dto.code,
depositCoinsPerFiat: dto.deposit_coins_per_fiat,
depositCoinsPerFiatValue: toFiniteNumber(dto.deposit_coins_per_fiat),
label: dto.label,
withdrawCoinsPerFiat: dto.withdraw_coins_per_fiat,
withdrawCoinsPerFiatValue: toFiniteNumber(dto.withdraw_coins_per_fiat),
}
}
function normalizeRate(dto: FinanceRateConfigDto) {
return {
currency: dto.currency,
diamondsPerFiatUnit: dto.diamonds_per_fiat_unit,
diamondsPerFiatUnitValue: toFiniteNumber(dto.diamonds_per_fiat_unit),
}
}
function normalizePayChannel(dto: FinancePayChannelDto) {
return {
code: dto.code,
name: dto.name,
sort: Number.isFinite(dto.sort) ? dto.sort : 0,
status: dto.status,
tierIds: Array.isArray(dto.tier_ids) ? dto.tier_ids : [],
}
}
function normalizeWithdrawBank(dto: FinanceWithdrawBankDto, index: number) {
const code = dto.code ?? dto.id ?? dto.name ?? `bank-${index + 1}`
const label = dto.label ?? dto.name ?? code
return {
code,
label,
sort:
typeof dto.sort === 'number' && Number.isFinite(dto.sort)
? dto.sort
: index,
status: typeof dto.status === 'number' ? dto.status : 1,
}
}
function normalizeWithdrawConfig(dto: FinanceWithdrawConfigDto) {
return {
banks: (dto.banks ?? []).map(normalizeWithdrawBank),
feeNote: dto.fee_note,
minBank: dto.min_bank,
minEwallet: dto.min_ewallet,
processingNote: dto.processing_note,
rateHint: dto.rate_hint,
rateMode: dto.rate_mode,
}
}
function normalizeDepositWithdrawConfig(
dto: DepositWithdrawConfigDto,
): DepositWithdrawConfig {
return {
currencies: (dto.currencies ?? []).map(normalizeCurrency),
payChannels: (dto.pay_channels ?? []).map(normalizePayChannel),
platformCoinLabel: dto.platform_coin_label,
rates: (dto.rates ?? []).map(normalizeRate),
withdraw: normalizeWithdrawConfig(dto.withdraw),
}
}
function normalizeDepositTierItem(
dto: DepositTierItemDto,
index: number,
): DepositTierItem {
const id = String(dto.id ?? dto.tier_id ?? `tier-${index + 1}`)
const amount = toFiniteNumber(dto.amount)
const bonusAmount = toFiniteNumber(dto.bonus_amount ?? dto.bonus_coins)
const payAmount = toFiniteNumber(dto.pay_amount ?? dto.amount)
const totalAmount = toFiniteNumber(
dto.total_amount ?? dto.coins ?? amount + bonusAmount,
)
const coins = totalAmount
const currency =
typeof dto.currency === 'string' && dto.currency.length > 0
? dto.currency
: null
const payChannelCode =
typeof dto.pay_channel_code === 'string' && dto.pay_channel_code.length > 0
? dto.pay_channel_code
: null
const sort = toFiniteNumber(dto.sort ?? dto.pay_amount ?? dto.amount)
const status = toFiniteNumber(dto.status ?? 1)
const channels = (dto.channels ?? [])
.map((channel, channelIndex) => ({
code: channel.code ?? `channel-${channelIndex + 1}`,
name: channel.name ?? channel.code ?? '--',
sort: toFiniteNumber(channel.sort ?? channelIndex),
}))
.sort((left, right) => left.sort - right.sort)
return {
amount,
bonusAmount,
channels,
coins,
currency,
desc: dto.desc ?? '',
id,
name:
typeof dto.name === 'string' && dto.name.length > 0
? dto.name
: `${amount}`,
payAmount,
payChannelCode,
sort,
status,
tierKey:
typeof dto.tier_key === 'string' && dto.tier_key.length > 0
? dto.tier_key
: null,
totalAmount,
title:
typeof dto.title === 'string' && dto.title.length > 0
? dto.title
: typeof dto.name === 'string' && dto.name.length > 0
? dto.name
: `${amount}`,
}
}
function normalizeFinanceOrderItem(dto: FinanceOrderItemDto) {
return {
amount: String(dto.amount ?? ''),
bonusAmount: String(dto.bonus_amount ?? ''),
orderNo: dto.order_no,
}
}
function normalizeFinanceOrderList(dto: FinanceOrderListDto): FinanceOrderList {
return {
list: (dto.list ?? []).map(normalizeFinanceOrderItem),
pagination: {
page: dto.pagination?.page ?? 1,
page_size: dto.pagination?.page_size ?? DEFAULT_LIST_PAGE_SIZE,
total: dto.pagination?.total ?? 0,
},
}
}
function stringifyNullableValue(value: unknown) {
return value === null || value === undefined ? '' : String(value)
}
function normalizeWalletRecordItem(dto: WalletRecordItemDto, index: number) {
const createdAt = dto.created_at ?? dto.create_time ?? dto.time ?? null
const id =
dto.id ??
dto.record_id ??
dto.wallet_record_id ??
dto.order_no ??
`${createdAt ?? 'wallet-record'}-${index + 1}`
return {
amount: stringifyNullableValue(
dto.amount ?? dto.change_amount ?? dto.coin ?? '',
),
balanceAfter: stringifyNullableValue(
dto.balance_after ?? dto.after_balance ?? dto.balance ?? '',
),
balanceBefore: stringifyNullableValue(
dto.balance_before ?? dto.before_balance ?? '',
),
createdAt,
id: String(id),
remark: stringifyNullableValue(dto.remark ?? dto.description ?? dto.memo),
type: stringifyNullableValue(
dto.type ?? dto.change_type ?? dto.biz_type ?? dto.scene,
),
}
}
function normalizeWalletRecordList(dto: WalletRecordListDto): WalletRecordList {
return {
list: (dto.list ?? []).map(normalizeWalletRecordItem),
pagination: {
page: dto.pagination?.page ?? dto.page ?? 1,
page_size:
dto.pagination?.page_size ?? dto.page_size ?? DEFAULT_LIST_PAGE_SIZE,
total: dto.pagination?.total ?? dto.total ?? 0,
},
}
}
export async function getDepositWithdrawConfig() {
const response = await api.post<DepositWithdrawConfigDto>(
FINANCE_API_ENDPOINTS.depositWithdrawConfig,
)
const dto = unwrapFinanceEnvelope(
response as ApiResponse<DepositWithdrawConfigDto>,
'Failed to load deposit and withdrawal config',
)
return normalizeDepositWithdrawConfig(dto)
}
export async function getDepositTierList() {
const response = await api.post<
| DepositTierItemDto[]
| { items?: DepositTierItemDto[]; list?: DepositTierItemDto[] },
undefined
>(FINANCE_API_ENDPOINTS.depositTierList)
const dto = unwrapFinanceEnvelope(
response as ApiResponse<
| DepositTierItemDto[]
| { items?: DepositTierItemDto[]; list?: DepositTierItemDto[] }
>,
'Failed to load deposit tier list',
)
const tierItems = Array.isArray(dto) ? dto : (dto?.list ?? dto?.items ?? [])
return tierItems
.map(normalizeDepositTierItem)
.filter((item) => item.status === 1)
.sort((left, right) => left.sort - right.sort)
}
export async function createDeposit(payload: DepositCreateRequestDto) {
const response = await api.post<
DepositCreateResponseDto,
DepositCreateRequestDto
>(FINANCE_API_ENDPOINTS.depositCreate, {
json: payload,
})
const dto = unwrapFinanceEnvelope(
response as ApiResponse<DepositCreateResponseDto>,
'Failed to create deposit',
)
return dto
}
export async function getDepositOrderList(params?: {
page?: number
pageSize?: number
}) {
const response = await api.get<FinanceOrderListDto>(
FINANCE_API_ENDPOINTS.depositList,
{
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
},
},
)
const dto = unwrapFinanceEnvelope(
response as ApiResponse<FinanceOrderListDto>,
'Failed to load deposit order list',
)
return normalizeFinanceOrderList(dto)
}
export async function getWithdrawOrderList(params?: {
page?: number
pageSize?: number
}) {
const response = await api.get<FinanceOrderListDto>(
FINANCE_API_ENDPOINTS.withdrawList,
{
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
},
},
)
const dto = unwrapFinanceEnvelope(
response as ApiResponse<FinanceOrderListDto>,
'Failed to load withdraw order list',
)
return normalizeFinanceOrderList(dto)
}
export async function getWalletRecordList(params?: {
page?: number
pageSize?: number
type?: WalletRecordType
}) {
const response = await api.get<WalletRecordListDto>(
FINANCE_API_ENDPOINTS.walletRecordList,
{
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
type: params?.type ?? 'payout',
},
},
)
const dto = unwrapFinanceEnvelope(
response as ApiResponse<WalletRecordListDto>,
'Failed to load wallet record list',
)
return normalizeWalletRecordList(dto)
}
export async function createWithdraw(payload: WithdrawCreateRequestDto) {
const response = await api.post<
WithdrawCreateResponseDto,
WithdrawCreateRequestDto
>(FINANCE_API_ENDPOINTS.withdrawCreate, {
json: payload,
})
const dto = unwrapFinanceEnvelope(
response as ApiResponse<WithdrawCreateResponseDto>,
'Failed to create withdraw',
)
return dto
}

View File

@@ -1,261 +0,0 @@
export interface FinanceCurrencyConfigDto {
code: string
deposit_coins_per_fiat: string
label: string
withdraw_coins_per_fiat: string
}
export interface FinanceRateConfigDto {
currency: string
diamonds_per_fiat_unit: string
}
export interface FinancePayChannelDto {
code: string
name: string
sort: number
status: number
tier_ids: number[]
}
export interface FinanceWithdrawBankDto {
code?: string
id?: string
label?: string
name?: string
sort?: number
status?: number
}
export interface FinanceWithdrawConfigDto {
banks: FinanceWithdrawBankDto[]
fee_note: string
min_bank: string
min_ewallet: string
processing_note: string
rate_hint: string
rate_mode: 'fixed' | 'live' | (string & {})
}
export interface DepositWithdrawConfigDto {
currencies: FinanceCurrencyConfigDto[]
pay_channels: FinancePayChannelDto[]
platform_coin_label: string
rates: FinanceRateConfigDto[]
withdraw: FinanceWithdrawConfigDto
}
export interface DepositTierItemDto {
amount?: number | string
bonus_amount?: number | string
bonus_coins?: number | string
channels?: Array<{
code?: string
name?: string
sort?: number | string
}>
coins?: number | string
currency?: string
desc?: string
id?: number | string
name?: string
pay_amount?: number | string
pay_channel_code?: string
sort?: number | string
status?: number | string
tier_key?: string
tier_id?: number | string
total_amount?: number | string
title?: string
}
export interface FinanceCurrencyConfig {
code: string
depositCoinsPerFiat: string
depositCoinsPerFiatValue: number
label: string
withdrawCoinsPerFiat: string
withdrawCoinsPerFiatValue: number
}
export interface FinanceRateConfig {
currency: string
diamondsPerFiatUnit: string
diamondsPerFiatUnitValue: number
}
export interface FinancePayChannel {
code: string
name: string
sort: number
status: number
tierIds: number[]
}
export interface FinanceWithdrawBank {
code: string
label: string
sort: number
status: number
}
export interface FinanceWithdrawConfig {
banks: FinanceWithdrawBank[]
feeNote: string
minBank: string
minEwallet: string
processingNote: string
rateHint: string
rateMode: FinanceWithdrawConfigDto['rate_mode']
}
export interface DepositWithdrawConfig {
currencies: FinanceCurrencyConfig[]
payChannels: FinancePayChannel[]
platformCoinLabel: string
rates: FinanceRateConfig[]
withdraw: FinanceWithdrawConfig
}
export interface DepositTierItem {
amount: number
bonusAmount: number
channels: Array<{
code: string
name: string
sort: number
}>
coins: number
currency: string | null
desc: string
id: string
name: string
payAmount: number
payChannelCode: string | null
sort: number
status: number
tierKey: string | null
totalAmount: number
title: string
}
export interface DepositCreateRequestDto {
channel_code: string
idempotency_key: string
tier_id: string
}
export interface DepositCreateResponseDto {
amount: number
bonus_amount: number
create_time: number
expire_at: number
expire_seconds: number
order_no: string
paid: boolean
pay_channel: string
pay_time: number
pay_url: string
reject_reason: string | null
review_required: boolean
status: 'pending' | (string & {})
total_amount: number
}
export interface FinanceOrderItemDto {
amount: number | string
bonus_amount: number | string
order_no: string
}
export interface FinanceOrderPaginationDto {
page: number
page_size: number
total: number
}
export interface FinanceOrderListDto {
list: FinanceOrderItemDto[]
pagination: FinanceOrderPaginationDto
}
export interface FinanceOrderItem {
amount: string
bonusAmount: string
orderNo: string
}
export interface FinanceOrderList {
list: FinanceOrderItem[]
pagination: FinanceOrderPaginationDto
}
export type WalletRecordType = 'payout' | (string & {})
export interface WalletRecordItemDto {
after_balance?: number | string | null
amount?: number | string | null
balance?: number | string | null
balance_after?: number | string | null
balance_before?: number | string | null
before_balance?: number | string | null
biz_type?: string | null
change_amount?: number | string | null
change_type?: string | null
coin?: number | string | null
create_time?: number | string | null
created_at?: number | string | null
description?: string | null
id?: number | string | null
memo?: string | null
order_no?: string | null
record_id?: number | string | null
remark?: string | null
scene?: string | null
time?: number | string | null
type?: string | null
wallet_record_id?: number | string | null
}
export interface WalletRecordListDto {
list: WalletRecordItemDto[]
pagination?: FinanceOrderPaginationDto
page?: number
page_size?: number
total?: number
}
export interface WalletRecordItem {
amount: string
balanceAfter: string
balanceBefore: string
createdAt: number | string | null
id: string
remark: string
type: string
}
export interface WalletRecordList {
list: WalletRecordItem[]
pagination: FinanceOrderPaginationDto
}
export interface WithdrawCreateRequestDto {
bank_code: string
channel_code: string
idempotency_key: string
receive_account: string
receiver_email: string
receiver_mobile: string
receiver_name: string
receive_type: string
withdraw_coin: number
}
export interface WithdrawCreateResponseDto {
actual_arrival_coin: number
fee_coin: number
order_no: string
risk_review_required: boolean
status: 'pending_review' | (string & {})
}

View File

@@ -1,510 +0,0 @@
import {
API_SUCCESS_CODE,
DEFAULT_LIST_PAGE_SIZE,
GAME_API_ENDPOINTS,
} from '@/constants'
import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type'
import type {
AnnouncementItem,
AnnouncementState,
BetSelection,
ConnectionState,
DashboardState,
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
RoundPhase,
RoundSnapshot,
TrendEntry,
} from '../shared'
import {
createEmptyGameBootstrapSnapshot,
DEFAULT_GAME_CHIP_COLORS,
deriveTrendEntries,
GAME_GRID_COLUMNS,
GAME_MAX_SELECTION_CELLS,
} from '../shared'
import type {
AnnouncementStateDto,
BetSelectionDto,
ChipDto,
ConnectionStateDto,
DashboardStateDto,
GameAnnouncementsDto,
GameBetOrdersDto,
GameBootstrapDto,
GameCellDto,
GameLobbyInitDto,
GameLobbyPeriodDto,
GamePeriodTickDto,
GamePlaceBetDto,
GamePlaceBetRequestDto,
GameRoundFeedDto,
HistoryEntryDto,
NoticeConfirmDto,
NoticeDetailDto,
NoticeListDto,
RoundSnapshotDto,
TrendEntryDto,
} from './types'
function unwrapGameEnvelope<T>(
response: ApiResponse<T>,
fallbackMessage = 'Game request failed',
) {
if (response.code === API_SUCCESS_CODE) {
return response.data
}
throw new ApiError({
data: response,
message:
typeof response.msg === 'string' && response.msg.length > 0
? response.msg
: typeof response.message === 'string' && response.message.length > 0
? response.message
: 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 interface GameLobbyInitResult {
runtimeEnabled: boolean
serverTime: number
snapshot: GameBootstrapSnapshot
userSnapshot: GameLobbyInitDto['user_snapshot']
}
function normalizeGameCell(dto: GameCellDto) {
return dto satisfies GameCell
}
function normalizeChip(dto: ChipDto) {
return {
amount: dto.amount,
color: dto.color,
id: dto.id,
isDefault: dto.is_default,
label: dto.label,
}
}
function normalizeBetSelection(dto: BetSelectionDto) {
return {
amount: dto.amount,
cellId: dto.cell_id,
chipId: dto.chip_id,
id: dto.id,
placedAt: dto.placed_at,
source: dto.source,
} satisfies BetSelection
}
function normalizeRoundSnapshot(dto: RoundSnapshotDto) {
return {
bettingClosesAt: dto.betting_closes_at,
id: dto.id,
phase: dto.phase,
revealingAt: dto.revealing_at,
settledAt: dto.settled_at,
startedAt: dto.started_at,
winningCellId: dto.winning_cell_id,
} satisfies RoundSnapshot
}
function normalizeHistoryEntry(dto: HistoryEntryDto) {
return {
payoutMultiplier: dto.payout_multiplier,
roundId: dto.round_id,
settledAt: dto.settled_at,
totalPoolAmount: dto.total_pool_amount,
winningCellId: dto.winning_cell_id,
} satisfies HistoryEntry
}
function normalizeTrendEntry(dto: TrendEntryDto) {
return {
cellId: dto.cell_id,
currentStreak: dto.current_streak,
direction: dto.direction,
hitCount: dto.hit_count,
lastHitRoundId: dto.last_hit_round_id,
missCount: dto.miss_count,
} satisfies TrendEntry
}
function normalizeAnnouncementState(dto: AnnouncementStateDto) {
return {
activeAnnouncementId: dto.active_announcement_id,
items: dto.items.map(
(item) =>
({
createdAt: item.created_at,
expiresAt: item.expires_at,
id: item.id,
isPinned: item.is_pinned,
isRead: item.is_read,
message: item.message,
title: item.title,
tone: item.tone,
}) satisfies AnnouncementItem,
),
lastUpdatedAt: dto.last_updated_at,
} satisfies AnnouncementState
}
function normalizeDashboardState(dto: DashboardStateDto) {
return {
countdownMs: dto.countdown_ms,
featuredCellId: dto.featured_cell_id,
onlinePlayers: dto.online_players,
tableLimitMax: dto.table_limit_max,
tableLimitMin: dto.table_limit_min,
totalPoolAmount: dto.total_pool_amount,
updatedAt: dto.updated_at,
} satisfies DashboardState
}
function normalizeConnectionState(dto: ConnectionStateDto) {
return {
connectedAt: dto.connected_at,
lastError: dto.last_error,
lastMessageAt: dto.last_message_at,
latencyMs: dto.latency_ms,
reconnectAttempt: dto.reconnect_attempt,
status: dto.status,
transport: dto.transport,
} 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 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 = createEmptyGameBootstrapSnapshot(baseIso)
const cells = normalizeLobbyCells(dto.dictionary)
const chips = normalizeLobbyChips(
dto.bet_config.chips,
dto.bet_config.default_bet_chip_id,
)
const trends = deriveTrendEntries([])
return {
announcements: {
activeAnnouncementId: null,
items: [],
lastUpdatedAt: null,
} satisfies AnnouncementState,
cells,
chips: chips.length > 0 ? chips : template.chips,
connection: {
...template.connection,
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: template.round,
selections: [],
trends,
} satisfies GameBootstrapSnapshot
}
export function normalizeGameBootstrap(dto: GameBootstrapDto) {
return {
announcements: normalizeAnnouncementState(dto.announcements),
cells: dto.cells.map(normalizeGameCell),
chips: dto.chips.map(normalizeChip),
connection: normalizeConnectionState(dto.connection),
dashboard: normalizeDashboardState(dto.dashboard),
history: dto.history.map(normalizeHistoryEntry),
maxSelectionCount:
typeof dto.max_selection_count === 'number' &&
Number.isFinite(dto.max_selection_count) &&
dto.max_selection_count > 0
? Math.min(36, Math.floor(dto.max_selection_count))
: GAME_MAX_SELECTION_CELLS,
round: normalizeRoundSnapshot(dto.round),
selections: dto.selections.map(normalizeBetSelection),
trends: dto.trends.map(normalizeTrendEntry),
} satisfies GameBootstrapSnapshot
}
export function normalizeGameRoundFeed(dto: GameRoundFeedDto) {
return {
history: dto.history.map(normalizeHistoryEntry),
round: normalizeRoundSnapshot(dto.round),
selections: dto.selections.map(normalizeBetSelection),
trends: dto.trends.map(normalizeTrendEntry),
} satisfies Pick<
GameBootstrapSnapshot,
'history' | 'round' | 'selections' | 'trends'
>
}
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(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(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(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 ?? DEFAULT_LIST_PAGE_SIZE),
},
})
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: {
notice_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 ?? DEFAULT_LIST_PAGE_SIZE,
},
},
)
const dto = unwrapGameEnvelope(
response as ApiResponse<GameBetOrdersDto>,
'Failed to load bet orders',
)
return dto
}
export async function placeGameBet(payload: GamePlaceBetRequestDto) {
const response = await api.post<GamePlaceBetDto>(
GAME_API_ENDPOINTS.placeBet,
{
json: payload,
},
)
const dto = unwrapGameEnvelope(
response as ApiResponse<GamePlaceBetDto>,
'Failed to place game bet',
)
return dto
}

View File

@@ -1,4 +0,0 @@
export * from './finance-api'
export * from './finance-types'
export * from './game-api'
export * from './types'

View File

@@ -1,47 +0,0 @@
import { API_SUCCESS_CODE, GAME_API_ENDPOINTS } from '@/constants'
import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type'
export interface GamePeriodHistoryItemDto {
open_time: number
period_no: string
result_number: number
}
interface GamePeriodHistoryDto {
list: GamePeriodHistoryItemDto[]
}
function unwrapPeriodHistoryEnvelope(
response: ApiResponse<GamePeriodHistoryDto>,
) {
if (response.code === API_SUCCESS_CODE) {
return response.data
}
throw new ApiError({
data: response,
message:
typeof response.msg === 'string' && response.msg.length > 0
? response.msg
: typeof response.message === 'string' && response.message.length > 0
? response.message
: 'Failed to load period history',
})
}
export async function getGamePeriodHistory(params: { limit?: number } = {}) {
const response = await api.get<GamePeriodHistoryDto>(
GAME_API_ENDPOINTS.periodHistory,
{
searchParams: {
limit: String(params.limit ?? 30),
},
},
)
return unwrapPeriodHistoryEnvelope(
response as ApiResponse<GamePeriodHistoryDto>,
)
}

View File

@@ -1,316 +0,0 @@
import type {
AnnouncementState,
BetSelection,
Chip,
ConnectionState,
DashboardState,
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
RoundSnapshot,
TrendEntry,
} from '../shared'
export interface GameCellDto {
column: number
id: number
label: string
odds: number
row: number
}
export interface ChipDto {
amount: number
color: string
id: string
is_default?: boolean
label: string
}
export interface BetSelectionDto {
amount: number
cell_id: number
chip_id: string
id: string
placed_at: string
source: BetSelection['source']
}
export interface RoundSnapshotDto {
betting_closes_at: string
id: string
phase: RoundSnapshot['phase']
revealing_at: string
settled_at: string | null
started_at: string
winning_cell_id: number | null
}
export interface HistoryEntryDto {
payout_multiplier: number
round_id: string
settled_at: string
total_pool_amount: number
winning_cell_id: number
}
export interface TrendEntryDto {
cell_id: number
current_streak: number
direction: TrendEntry['direction']
hit_count: number
last_hit_round_id: string | null
miss_count: number
}
export interface AnnouncementItemDto {
created_at: string
expires_at: string | null
id: string
is_pinned?: boolean
is_read?: boolean
message: string
title: string
tone: 'info' | 'success' | 'warning' | 'critical'
}
export interface AnnouncementStateDto {
active_announcement_id: string | null
items: AnnouncementItemDto[]
last_updated_at: string | null
}
export interface DashboardStateDto {
countdown_ms: number
featured_cell_id: number | null
online_players: number
table_limit_max: number
table_limit_min: number
total_pool_amount: number
updated_at: string | null
}
export interface ConnectionStateDto {
connected_at: string | null
last_error: string | null
last_message_at: string | null
latency_ms: number | null
reconnect_attempt: number
status: ConnectionState['status']
transport: ConnectionState['transport']
}
export interface GameBootstrapDto {
announcements: AnnouncementStateDto
cells: GameCellDto[]
chips: ChipDto[]
connection: ConnectionStateDto
dashboard: DashboardStateDto
history: HistoryEntryDto[]
max_selection_count?: number
round: RoundSnapshotDto
selections: BetSelectionDto[]
trends: TrendEntryDto[]
}
export interface GameRoundFeedDto {
history: HistoryEntryDto[]
round: RoundSnapshotDto
selections: BetSelectionDto[]
trends: TrendEntryDto[]
}
export interface GameAnnouncementsDto {
announcements: AnnouncementStateDto
}
export interface NoticeListItemDto {
content?: string
is_read: boolean
must_confirm?: boolean
notice_id: number
notice_type: 'silent' | 'popout' | (string & {})
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' | (string & {})
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 JackpotHitItemDto {
nickname: string
period_no: string
result_number: number
total_win: string
}
export interface JackpotHitEventDataDto {
hits: JackpotHitItemDto[]
period_id: number | null
period_no: string
result_number: number | null
server_time: number
}
export interface JackpotHitEventDto {
data: JackpotHitEventDataDto
event: 'jackpot.hit'
server_time: number
topic?: 'jackpot.hit'
}
export interface BetWinItemDto {
bet_id: number
win_amount: string
}
export interface BetWinEventDataDto {
balance_after?: string
bets: BetWinItemDto[]
current_streak?: number
is_jackpot: boolean
is_win: boolean
odds_factor?: number
payout_pending_review: boolean
period_id?: number
period_no: string
result_number: number | null
server_time?: number
streak_level?: number
total_win: string
user_id?: number
}
export interface BetWinEventDto {
data: BetWinEventDataDto
event: 'bet.win'
server_time: number
topic?: 'bet.win'
}
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 interface GamePlaceBetRequestDto {
bet_amount?: string
bet_id: number
idempotency_key: string
numbers: string
period_no: string
single_bet_amount?: string
}
export interface GamePlaceBetDto {
balance_after: string
current_streak: number
locked_balance?: string
numbers_count: number
order_no: string
period_no: string
status: 'accepted' | 'rejected' | (string & {})
}
export type {
AnnouncementState,
Chip,
DashboardState,
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
}

View File

@@ -1,5 +1,2 @@
export {
AUDIO_ASSET_DEFINITIONS,
type AudioAssetDefinition,
type AudioAssetId,
} from '@/constants/game'
export { AUDIO_ASSET_DEFINITIONS } from '@/constants/game'
export type { AudioAssetDefinition, AudioAssetId } from '@/type'

View File

@@ -17,7 +17,6 @@ import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image'
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
import {
type BetSelection,
FLOWER_IMAGE_BY_ID,
groupSelectionsByCell,
} from '@/features/game/shared'
@@ -28,6 +27,7 @@ import {
useGameAutoHostingStore,
useGameRoundStore,
} from '@/store/game'
import type { BetSelection } from '@/type'
const REWARD_OVERLAY_FADE_OUT_MS = 300
const REWARD_CHILDREN_FADE_IN_MS = 2_000

View File

@@ -7,8 +7,8 @@ import diamondIcon from '@/assets/system/diamond.webp'
import { SmartImage } from '@/components/smart-image'
import { DesktopAnimalOverlay } from '@/features/game/components/desktop/desktop-animal-overlay.tsx'
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
import { useAnimalVm } from '@/hooks/use-animal-vm'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore } from '@/store/game'

View File

@@ -16,7 +16,7 @@ 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 { useGameControlVm } from '@/hooks/use-game-control-vm.ts'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'

View File

@@ -5,8 +5,8 @@ import historyBg from '@/assets/system/history-bg.png'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts'
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
import { useGameHistoryVm } from '@/hooks/use-game-history-vm.ts'
function HistoryRewardNumber({
className,

View File

@@ -16,10 +16,7 @@ import chatImage from '@/assets/system/chat.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import {
useHeaderClockLabel,
useHeaderVm,
} from '@/features/game/hooks/use-header-vm'
import { useHeaderClockLabel, useHeaderVm } from '@/hooks/use-header-vm'
import { useModalStore } from '@/store'
function HeaderClock() {

View File

@@ -13,7 +13,7 @@ import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
import { cn } from '@/lib/utils.ts'
export function DesktopStatusLine() {

View File

@@ -1,9 +1,9 @@
import { useMutation } from '@tanstack/react-query'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { createDeposit, type DepositTierItem } from '@/api'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { createDeposit, type DepositTierItem } from '@/features/game/api'
import { useDepositTierList } from '@/features/game/hooks/use-deposit-tier-list'
import { useDepositTierList } from '@/hooks/use-deposit-tier-list'
import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils'

View File

@@ -12,8 +12,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select.tsx'
import { useWithdrawSubmit } from '@/features/game/hooks/use-withdraw-submit'
import { useWithdrawVm } from '@/features/game/hooks/use-withdraw-vm'
import { useWithdrawSubmit } from '@/hooks/use-withdraw-submit'
import { useWithdrawVm } from '@/hooks/use-withdraw-vm'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'

View File

@@ -1,4 +0,0 @@
export { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx'
export type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
export { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
export { EntryNoticeGateModal } from '@/features/game/components/shared/entry-notice-gate-modal'

View File

@@ -7,15 +7,15 @@ import {
VolumeX,
Wifi,
} from 'lucide-react'
import { motion } from 'motion/react'
import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp'
import chatImage from '@/assets/system/chat.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import {
useHeaderClockLabel,
useHeaderVm,
} from '@/features/game/hooks/use-header-vm'
import { useHeaderClockLabel, useHeaderVm } from '@/hooks/use-header-vm'
import { useModalStore } from '@/store'
function MobileHeaderClock() {
const systemTimeLabel = useHeaderClockLabel()
@@ -29,6 +29,7 @@ function MobileHeaderClock() {
export function MobileHeader() {
const { t } = useTranslation()
const setModalOpen = useModalStore((state) => state.setModalOpen)
const {
authStatus,
currentLanguageLabel,
@@ -53,7 +54,7 @@ export function MobileHeader() {
return (
<header className="sticky top-0 z-30 h-design-62">
<div className="border-b-2 border-[#787553] bg-[#020B14]] bg-[#020B14] flex h-design-33 w-full items-center">
<div className="border-b-2 border-[#787553] bg-[#020B14] flex h-design-33 w-full items-center">
<div className="flex h-design-23 w-design-130 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.45)] px-design-10">
<SmartImage
src={logo}
@@ -64,6 +65,20 @@ export function MobileHeader() {
/>
</div>
<button
type="button"
onClick={onOpenLanguage}
className={`${actionButtonClassName} !px-design-10 justify-between`}
>
<SmartImage
src={currentLanguageOption.icon}
alt={currentLanguageLabel}
className="h-design-14 w-design-14 shrink-0 rounded-full"
imgClassName="object-cover"
/>
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
</button>
{authStatus === 'authenticated' ? (
<div className="flex h-full min-w-0 flex-1 items-center justify-end gap-design-7 px-design-9">
<button
@@ -213,19 +228,20 @@ export function MobileHeader() {
</div>
</button>
<button
<motion.button
type="button"
onClick={onOpenLanguage}
className={`${actionButtonClassName} !px-design-10 justify-between`}
onClick={() => setModalOpen('desktopSupport', true)}
whileTap={{
scale: 0.95,
}}
whileHover={{ scale: 1.05 }}
>
<SmartImage
src={currentLanguageOption.icon}
alt={currentLanguageLabel}
className="h-design-14 w-design-14 shrink-0 rounded-full"
imgClassName="object-cover"
className={'h-design-20 w-design-20 cursor-pointer'}
alt={'chatImage'}
src={chatImage}
/>
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
</button>
</motion.button>
</div>
</div>
</header>

View File

@@ -0,0 +1,186 @@
import { useMutation } from '@tanstack/react-query'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { createDeposit, type DepositTierItem } from '@/api'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { useDepositTierList } from '@/hooks/use-deposit-tier-list'
import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils'
const PANEL_CLASS =
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(88,225,238,0.08)]'
function formatNumber(value: number) {
return new Intl.NumberFormat('en-US').format(value)
}
function MobileTopup() {
const { t } = useTranslation()
const tierListQuery = useDepositTierList()
const tiers = tierListQuery.data ?? []
const createDepositInFlightRef = useRef(false)
const pendingPayWindowRef = useRef<Window | null>(null)
const createDepositMutation = useMutation({
mutationFn: ({
channelCode,
tierId,
}: {
channelCode: string
tierId: string
}) =>
createDeposit({
channel_code: channelCode,
idempotency_key: String(Date.now()),
tier_id: tierId,
}),
})
const handleCreateDeposit = async (tier: DepositTierItem) => {
if (createDepositInFlightRef.current || createDepositMutation.isPending) {
return
}
const channelCode = tier.payChannelCode ?? tier.channels[0]?.code ?? ''
if (!channelCode) {
notify.error(t('commonUi.toast.requestFailed'))
return
}
createDepositInFlightRef.current = true
const payWindow = window.open('', '_blank')
if (!payWindow) {
createDepositInFlightRef.current = false
notify.error(t('gameDesktop.topup.tier.openPayUrlFailed'))
return
}
payWindow.opener = null
pendingPayWindowRef.current = payWindow
try {
const result = await createDepositMutation.mutateAsync({
channelCode,
tierId: tier.id,
})
const payUrl = result.pay_url.trim()
if (!payUrl) {
payWindow.close()
notify.error(t('gameDesktop.topup.tier.missingPayUrl'))
return
}
payWindow.location.replace(payUrl)
notify.success(t('gameDesktop.topup.tier.createSuccess'))
} catch (error) {
payWindow.close()
notify.error(
error instanceof Error
? error.message
: t('commonUi.toast.requestFailed'),
)
} finally {
createDepositInFlightRef.current = false
if (pendingPayWindowRef.current === payWindow) {
pendingPayWindowRef.current = null
}
}
}
return (
<div className="flex h-full min-h-0 w-full px-design-8 pb-design-8 text-[#D9FFFF]">
<div
className={cn(
PANEL_CLASS,
'flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden px-design-10 py-design-10',
)}
>
<div className="mb-design-8 flex items-center border-b border-[rgba(89,209,223,0.2)] pb-design-8">
<div className="text-design-16 font-semibold text-[#9AF5FB]">
{t('gameDesktop.topup.tier.title')}
</div>
</div>
{tierListQuery.isLoading ? (
<DataLoadingIndicator
label={t('gameDesktop.topup.tier.loading')}
className="h-full min-h-0 rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.18)] bg-[rgba(6,24,35,0.52)]"
/>
) : tierListQuery.isError ? (
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(185,63,68,0.28)] bg-[rgba(34,13,16,0.42)] px-design-12 text-center text-design-14 text-[#F4A9AE]">
{t('gameDesktop.topup.tier.failed')}
</div>
) : tiers.length === 0 ? (
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.18)] bg-[rgba(6,24,35,0.52)] px-design-12 text-center text-design-14 text-[#8FDDE6]">
{t('gameDesktop.topup.tier.empty')}
</div>
) : (
<div className="grid min-h-0 min-w-0 grid-cols-2 gap-design-8 overflow-y-auto pr-design-1">
{tiers.map((tier) => (
<button
key={tier.id}
type="button"
onClick={() => {
void handleCreateDeposit(tier)
}}
className={cn(
'relative min-h-design-126 overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] px-design-8 py-design-8 text-left shadow-[0_0_calc(var(--design-unit)*8)_rgba(88,225,238,0.08)] transition-[border-color,box-shadow,filter] duration-150',
createDepositMutation.isPending
? 'cursor-wait opacity-80'
: 'cursor-pointer hover:border-[rgba(170,247,255,0.62)] hover:brightness-110 active:brightness-90',
)}
>
<div className="absolute right-design-6 top-design-6 max-w-design-48 truncate rounded-full border border-[rgba(121,219,229,0.28)] bg-[rgba(10,39,52,0.7)] px-design-5 py-[2px] text-design-8 leading-none text-[#7CDDE7]">
{tier.currency ?? 'FIAT'}
</div>
<div className="pr-design-46 text-design-10 uppercase leading-tight tracking-[0.04em] text-[#63AEB6]">
{tier.title}
</div>
<div className="pt-design-5 text-design-18 font-semibold leading-none text-[#FFE229]">
{formatNumber(tier.payAmount)}
</div>
<div className="pt-design-3 text-design-10 text-[#9FDCE3]">
{t('gameDesktop.topup.tier.coins')}:{' '}
{formatNumber(tier.totalAmount)}
</div>
<div className="mt-design-7 rounded-[calc(var(--design-unit)*5)] border border-[rgba(89,209,223,0.18)] bg-[rgba(4,19,28,0.58)] px-design-6 py-design-5">
<div className="flex items-center justify-between gap-design-6 text-design-10">
<span className="text-[#7CE3E8]">
{t('gameDesktop.topup.tier.bonus')}
</span>
<span className="text-[#FFF1C9]">
{formatNumber(tier.bonusAmount)}
</span>
</div>
<div className="mt-design-3 flex items-center justify-between gap-design-6 text-design-10">
<span className="text-[#7CE3E8]">Channels</span>
<span className="line-clamp-1 text-right text-[#6DFF83]">
{tier.channels.length > 0
? tier.channels
.map((channel) => channel.name)
.join(', ')
: '--'}
</span>
</div>
</div>
{tier.desc ? (
<div className="mt-design-5 line-clamp-2 text-design-9 leading-[1.25] text-[#6DAAB0]">
{tier.desc}
</div>
) : null}
</button>
))}
</div>
)}
</div>
</div>
)
}
export default MobileTopup

View File

@@ -0,0 +1,640 @@
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'
import { Input } from '@/components/ui/input.tsx'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select.tsx'
import { useWithdrawSubmit } from '@/hooks/use-withdraw-submit'
import { useWithdrawVm } from '@/hooks/use-withdraw-vm'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'
const PANEL_CLASS =
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(88,225,238,0.08)]'
function formatNumber(value: number) {
return new Intl.NumberFormat('en-US').format(value)
}
function getPaymentGlyph(code: string, name: string) {
if (code.toLowerCase().includes('alipay')) {
return '支'
}
return name.trim().slice(0, 1).toUpperCase() || code.slice(0, 1).toUpperCase()
}
function WithdrawField({
label,
children,
}: {
label: string
children: ReactNode
}) {
return (
<div className="flex flex-col gap-design-3">
<div className="text-design-10 font-medium uppercase leading-none text-[#6FD4DA]">
{label}
</div>
<div className="min-w-0">{children}</div>
</div>
)
}
function AmountShell({
amount,
availableBalanceText,
onAmountChange,
onMinus,
onPlus,
}: {
amount: number
availableBalanceText: string
onAmountChange: (value: number) => void
onMinus: () => void
onPlus: () => void
}) {
function handleInputChange(value: string) {
const nextValue = Number(value.replace(/[^\d]/g, ''))
onAmountChange(Number.isFinite(nextValue) ? nextValue : 0)
}
return (
<div className="flex flex-col gap-design-2">
<div className="flex h-design-34 items-center gap-design-6 rounded-[calc(var(--design-unit)*5)] border border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(14,64,74,0.82),rgba(8,36,47,0.78))] px-design-6 shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(93,239,255,0.08)]">
<button
type="button"
onClick={onMinus}
className="flex h-design-24 w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
>
<Minus className="h-design-12 w-design-12" />
</button>
<input
value={amount === 0 ? '' : String(amount)}
onChange={(event) => handleInputChange(event.target.value)}
inputMode="numeric"
pattern="[0-9]*"
className="h-full min-w-0 flex-1 bg-transparent text-center text-design-16 font-medium text-[#A1EBF3] outline-none placeholder:text-[rgba(109,170,176,0.55)]"
placeholder="0"
/>
<button
type="button"
onClick={onPlus}
className="flex h-design-24 w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
>
<Plus className="h-design-12 w-design-12" />
</button>
</div>
<div className="pl-design-2 text-design-10 text-[#6DAAB0]">
{availableBalanceText}
</div>
</div>
)
}
function QuickAmountCard({
amount,
preview,
active,
onClick,
}: {
amount: number
preview: string
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'group relative flex h-design-42 min-w-0 w-full cursor-pointer flex-col items-start justify-center overflow-hidden rounded-[calc(var(--design-unit)*6)] border px-design-6 text-left transition-[border-color,background-color,box-shadow,filter] duration-150',
active
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(88,54,28,0.96),rgba(56,33,18,0.92))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.2),inset_0_0_calc(var(--design-unit)*10)_rgba(255,217,120,0.08)]'
: 'border-[rgba(103,227,239,0.26)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] hover:border-[rgba(170,247,255,0.62)] hover:brightness-110',
)}
>
<span
className={cn(
'absolute right-design-6 top-design-6 h-design-5 w-design-5 rounded-full transition',
active
? 'bg-[#FFD15E] shadow-[0_0_8px_rgba(255,209,94,0.8)]'
: 'bg-[rgba(122,220,230,0.26)]',
)}
/>
<div className="max-w-full truncate text-design-13 font-semibold leading-none text-[#FFE229]">
{amount}
</div>
<div
className={cn(
'max-w-full truncate pt-design-3 text-design-10 leading-none',
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
)}
>
{preview}
</div>
</button>
)
}
function PaymentCard({
active,
label,
glyph,
onClick,
}: {
active: boolean
label: string
glyph: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'group relative flex h-design-42 min-w-0 cursor-pointer items-center gap-design-6 rounded-[calc(var(--design-unit)*6)] border px-design-6 text-left transition-[border-color,background-color,box-shadow,filter] duration-150',
active
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(88,54,28,0.96),rgba(56,33,18,0.92))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18),inset_0_0_calc(var(--design-unit)*10)_rgba(255,217,120,0.08)]'
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] hover:border-[rgba(170,247,255,0.62)] hover:brightness-110',
)}
>
<span
className={cn(
'absolute right-design-6 top-design-6 h-design-5 w-design-5 rounded-full transition',
active
? 'bg-[#FFD15E] shadow-[0_0_8px_rgba(255,209,94,0.8)]'
: 'bg-[rgba(122,220,230,0.26)]',
)}
/>
<div
className={cn(
'flex h-design-26 w-design-26 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] border text-design-12 font-semibold leading-none transition-colors',
active
? 'border-[rgba(255,218,132,0.45)] bg-[rgba(255,211,113,0.16)] text-[#FFD97A]'
: 'border-[rgba(121,219,229,0.28)] bg-[rgba(10,39,52,0.7)] text-[#8DE4EA]',
)}
>
{glyph}
</div>
<div className="min-w-0 flex-1 pr-design-7">
<div
className={cn(
'truncate !text-design-12 font-medium leading-none',
active ? 'text-[#FFF1C9]' : 'text-[#D7FBFF]',
)}
>
{label}
</div>
<div
className={cn(
'pt-design-2 !text-design-10 uppercase leading-none tracking-[0.03em]',
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
)}
>
Channel
</div>
</div>
</button>
)
}
function InputShell({
value,
onChange,
placeholder,
error,
errorMessage,
uppercase = false,
type = 'text',
}: {
value: string
onChange: (value: string) => void
placeholder: string
error?: boolean
errorMessage?: string
uppercase?: boolean
type?: 'text' | 'email' | 'tel'
}) {
return (
<div className="flex w-full flex-col gap-design-3">
<Input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className={cn(
'h-design-30 rounded-[calc(var(--design-unit)*5)] border px-design-8 text-design-12',
uppercase && 'uppercase',
error
? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]'
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(10,47,57,0.84),rgba(5,23,32,0.92))] text-[#ACF1F6]',
)}
/>
{error && errorMessage ? (
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
{errorMessage}
</div>
) : null}
</div>
)
}
function PreviewRow({
label,
value,
highlight = false,
}: {
label: string
value: ReactNode
highlight?: boolean
}) {
return (
<div className="flex border-b border-[rgba(89,209,223,0.2)] last:border-b-0">
<div className="flex w-[46%] shrink-0 items-center border-r border-[rgba(89,209,223,0.2)] px-design-6 py-design-7 text-design-10 font-medium uppercase leading-[1.15] text-[#7CE3E8]">
{label}
</div>
<div
className={cn(
'flex min-w-0 flex-1 items-center justify-end px-design-6 py-design-7 text-right text-design-10 text-[#E6FFFF]',
highlight && 'text-design-11 font-semibold text-[#6DFF83]',
)}
>
{value}
</div>
</div>
)
}
function MobileWithdraw() {
const { t } = useTranslation()
const vm = useWithdrawVm()
const withdrawSubmitMutation = useWithdrawSubmit()
const setModalOpen = useModalStore((state) => state.setModalOpen)
const [hasSubmitted, setHasSubmitted] = useState(false)
const [activeQuickAmountId, setActiveQuickAmountId] = useState<string | null>(
null,
)
function handleAmountChange(nextAmount: number) {
vm.setAmount(Math.max(0, nextAmount))
setActiveQuickAmountId(null)
}
function handleQuickAmountSelect(optionId: string, amount: number) {
vm.setAmount(Math.max(0, amount))
setActiveQuickAmountId(optionId)
}
function resetWithdrawFormState() {
setHasSubmitted(false)
setActiveQuickAmountId(null)
vm.resetForm()
}
function handleCloseWithdraw() {
resetWithdrawFormState()
setModalOpen('desktopWithdrawTopup', false)
}
function handleConfirmWithdraw() {
if (withdrawSubmitMutation.isPending) {
return
}
setHasSubmitted(true)
if (
vm.amountRequiredError ||
vm.amountExceedsBalance ||
vm.holderNameError ||
vm.bankAccountError ||
vm.paymentChannelCodeError ||
vm.bankCodeError ||
vm.receiverEmailError ||
vm.receiverPhoneError
) {
return
}
withdrawSubmitMutation.mutate(
{
bank_code: vm.bankCode,
channel_code: vm.paymentChannelCode,
idempotency_key: String(Date.now()),
receive_account: vm.bankAccount.trim(),
receiver_email: vm.receiverEmail.trim(),
receiver_mobile: vm.receiverPhone.trim(),
receiver_name: vm.holderName.trim(),
receive_type: 'bank',
withdraw_coin: vm.amount,
},
{
onSuccess: () => {
handleCloseWithdraw()
},
},
)
}
return (
<div className="flex h-full min-h-0 w-full px-design-6 pb-design-6 text-[#D9FFFF]">
<div
className={cn(
PANEL_CLASS,
'flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden',
)}
>
<div className="min-h-0 flex-1 overflow-y-auto px-design-8 py-design-7">
<div className="flex flex-col !gap-design-10">
<WithdrawField
label={t('gameDesktop.withdraw.fields.diamondAmount')}
>
<AmountShell
amount={vm.amount}
availableBalanceText={t(
'gameDesktop.withdraw.availableBalance',
{ amount: formatNumber(vm.availableBalance) },
)}
onAmountChange={handleAmountChange}
onMinus={() => handleAmountChange(vm.amount - 1)}
onPlus={() => handleAmountChange(vm.amount + 1)}
/>
{hasSubmitted && vm.amountRequiredError ? (
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
{t('gameDesktop.withdraw.errors.amountRequired')}
</div>
) : null}
{hasSubmitted && vm.amountExceedsBalance ? (
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
{t('gameDesktop.withdraw.errors.amountExceedsBalance')}
</div>
) : null}
</WithdrawField>
<div className="grid min-w-0 grid-cols-3 gap-design-5">
{vm.quickAmounts.map((option) => (
<QuickAmountCard
key={option.id}
amount={option.diamonds}
preview={option.preview}
active={option.id === activeQuickAmountId}
onClick={() =>
handleQuickAmountSelect(option.id, option.diamonds)
}
/>
))}
</div>
<WithdrawField
label={t('gameDesktop.withdraw.fields.currencyType')}
>
<Select
value={vm.currencyCode}
onValueChange={vm.setCurrencyCode}
>
<SelectTrigger
className="h-design-30 w-full rounded-[calc(var(--design-unit)*5)] 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-8 text-left !text-design-12 text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.08)] data-[size=default]:h-design-30 data-[placeholder]:text-[rgba(109,170,176,0.55)] [&_svg]:h-design-14 [&_svg]:w-design-14 [&_svg]:text-[#79DFEA]"
aria-label={t('gameDesktop.withdraw.currencySelection')}
>
<SelectValue
placeholder={t('gameDesktop.withdraw.selectCurrency')}
/>
</SelectTrigger>
<SelectContent>
{vm.config.currencies.map((option) => (
<SelectItem key={option.code} value={option.code}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.paymentChannel')}
>
<div className="flex w-full flex-col gap-design-3">
{vm.sortedPayChannels.length > 0 ? (
<div className="grid grid-cols-2 gap-design-5">
{vm.sortedPayChannels.map((channel) => (
<PaymentCard
key={channel.code}
active={channel.code === vm.paymentChannelCode}
label={channel.name}
glyph={getPaymentGlyph(channel.code, channel.name)}
onClick={() => vm.setPaymentChannelCode(channel.code)}
/>
))}
</div>
) : (
<div className="flex min-h-design-38 items-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(185,63,68,0.45)] bg-[rgba(34,13,16,0.6)] px-design-8 text-design-10 text-[#F4B1B1]">
{t('gameDesktop.withdraw.errors.paymentChannelUnavailable')}
</div>
)}
{hasSubmitted && vm.paymentChannelCodeError ? (
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
{t('gameDesktop.withdraw.errors.paymentChannelRequired')}
</div>
) : null}
</div>
</WithdrawField>
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
<div className="flex w-full flex-col gap-design-3">
<Select
value={vm.bankCode}
onValueChange={vm.setBankCode}
disabled={vm.sortedBanks.length === 0}
>
<SelectTrigger
className={cn(
'h-design-30 w-full rounded-[calc(var(--design-unit)*5)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-8 text-left !text-design-12 text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.08)] data-[size=default]:h-design-30 data-[placeholder]:text-[rgba(109,170,176,0.55)] [&_svg]:h-design-14 [&_svg]:w-design-14 [&_svg]:text-[#79DFEA]',
hasSubmitted && vm.bankCodeError
? 'border-[#B93F44]'
: 'border-[rgba(103,227,239,0.3)]',
)}
aria-label={t('gameDesktop.withdraw.fields.bankCode')}
>
<SelectValue
placeholder={t(
'gameDesktop.withdraw.placeholders.bankCode',
)}
/>
</SelectTrigger>
<SelectContent>
{vm.sortedBanks.map((bank) => (
<SelectItem key={bank.code} value={bank.code}>
{bank.label}
</SelectItem>
))}
</SelectContent>
</Select>
{vm.sortedBanks.length === 0 ? (
<div className="pl-design-2 text-design-10 text-[#F4B1B1]">
{t('gameDesktop.withdraw.errors.bankCodeUnavailable')}
</div>
) : null}
{hasSubmitted && vm.bankCodeError ? (
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
{t('gameDesktop.withdraw.errors.bankCodeRequired')}
</div>
) : null}
</div>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.cardHolderName')}
>
<InputShell
value={vm.holderName}
onChange={vm.setHolderName}
placeholder={t(
'gameDesktop.withdraw.placeholders.cardHolderName',
)}
error={hasSubmitted && vm.holderNameError}
errorMessage={t(
'gameDesktop.withdraw.errors.cardHolderNameRequired',
)}
/>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
>
<InputShell
value={vm.bankAccount}
onChange={vm.setBankAccount}
placeholder={t(
'gameDesktop.withdraw.placeholders.bankAccountNumber',
)}
error={hasSubmitted && vm.bankAccountError}
errorMessage={t(
'gameDesktop.withdraw.errors.bankAccountRequired',
)}
/>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverEmail')}
>
<InputShell
value={vm.receiverEmail}
onChange={vm.setReceiverEmail}
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverEmail',
)}
type="email"
error={hasSubmitted && vm.receiverEmailError}
errorMessage={t(
'gameDesktop.withdraw.errors.receiverEmailInvalid',
)}
/>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverPhone')}
>
<InputShell
value={vm.receiverPhone}
onChange={vm.setReceiverPhone}
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverPhone',
)}
type="tel"
error={hasSubmitted && vm.receiverPhoneError}
errorMessage={t(
'gameDesktop.withdraw.errors.receiverPhoneInvalid',
)}
/>
</WithdrawField>
<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={t('gameDesktop.withdraw.preview.diamondAmount')}
value={formatNumber(vm.amount)}
/>
<PreviewRow
label={vm.selectedCurrencyPreview.exchangeRateLabel}
value={vm.selectedCurrencyPreview.exchangeRateValue}
/>
<PreviewRow
label={vm.selectedCurrencyPreview.convertibleLabel}
value={vm.selectedCurrencyPreview.convertibleValue}
highlight={true}
/>
<PreviewRow
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-8 py-design-6 text-design-10 leading-[1.3] text-[#F0B44A]">
{vm.withdrawCopy.rateHint}
</div>
<div className="flex flex-col gap-design-3 px-design-1 text-design-10 uppercase leading-[1.3] text-[#7AD8E0]">
<div>
{vm.withdrawCopy.processingLabel}:{' '}
<span className="text-[#77FF76]">
{vm.withdrawCopy.processingValue}
</span>
</div>
<div>
{vm.withdrawCopy.noticeLabel}:{' '}
<span className="text-red-700">{vm.withdrawCopy.feeNote}</span>
</div>
</div>
</div>
</div>
<div className="flex shrink-0 items-center justify-between gap-design-6 border-t border-[rgba(89,209,223,0.22)] bg-[rgba(3,15,24,0.86)] px-design-8 py-design-5">
<SmartBackground
as="button"
type="button"
src={lengthGreenBtn}
size="100% 100%"
onClick={handleCloseWithdraw}
className="flex h-design-38 flex-1 cursor-pointer items-center justify-center pb-design-2 text-center !text-design-12 font-bold uppercase text-[#F0FFFF] transition hover:brightness-110 active:scale-[0.98]"
>
{t('gameDesktop.withdraw.cancel')}
</SmartBackground>
<SmartBackground
as="button"
type="button"
src={lengthBlueBtn}
size="100% 100%"
onClick={handleConfirmWithdraw}
disabled={withdrawSubmitMutation.isPending}
className={cn(
'flex h-design-38 flex-1 items-center justify-center whitespace-nowrap pb-design-2 text-center !text-design-12 font-bold uppercase leading-[1.05] text-[#F0FFFF] transition',
withdrawSubmitMutation.isPending
? 'cursor-not-allowed opacity-70'
: 'cursor-pointer hover:brightness-110 active:scale-[0.98]',
)}
>
{withdrawSubmitMutation.isPending
? t('commonUi.action.submitting')
: `${t('gameDesktop.withdraw.confirm')}`}
</SmartBackground>
</div>
</div>
</div>
)
}
export default MobileWithdraw

View File

@@ -3,9 +3,11 @@ import dayjs from 'dayjs'
import { RotateCw } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getNoticeList } from '@/api'
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
import checkIcon from '@/assets/system/right.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator.tsx'
@@ -13,7 +15,6 @@ import {
ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY,
} from '@/constants'
import { getNoticeList } from '@/features/game/api'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import { useModalStore } from '@/store/modal'
@@ -36,8 +37,15 @@ function setLastConfirmedAt(storageKey: string, timestamp: number) {
localStorage.setItem(storageKey, String(timestamp))
}
export function EntryNoticeGateModal() {
interface EntryNoticeGateModalProps {
variant?: 'desktop' | 'mobile'
}
export function EntryNoticeGateModal({
variant = 'desktop',
}: EntryNoticeGateModalProps = {}) {
const { t } = useTranslation()
const isMobile = variant === 'mobile'
const authStatus = useAuthStore((state) => state.status)
const authIsHydrated = useAuthStore((state) => state.isHydrated)
const accessToken = useAuthStore((state) => state.accessToken)
@@ -118,29 +126,64 @@ export function EntryNoticeGateModal() {
return null
}
const Modal = isMobile ? MobileCenterModal : CenterModal
return (
<CenterModal
<Modal
open={shouldShowModal}
isShowClose={false}
isNormalBg={true}
title={
<div className="modal-title-glow text-design-26">
<div
className={cn(
'modal-title-glow',
isMobile ? 'text-design-16' : 'text-design-26',
)}
>
{t('game.modals.entryNotice.title')}
</div>
}
titleAlign="left"
className="h-design-700 w-design-1000 max-h-[92vh] max-w-[92vw]"
className={
isMobile
? 'h-design-400'
: 'h-design-700 w-design-1000 max-h-[92vh] max-w-[92vw]'
}
>
<div className="flex h-full w-full flex-col gap-design-20 px-design-14 pb-design-30 pt-design-8">
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-[#2B8CA3]/45 bg-[#001B24]/70 p-design-18 shadow-[inset_0_0_calc(var(--design-unit)*18)_rgba(39,175,205,0.1)]">
<div
className={cn(
'flex h-full w-full flex-col',
isMobile
? 'gap-design-10 px-design-8 pb-design-8 pt-design-4'
: 'gap-design-20 px-design-14 pb-design-30 pt-design-8',
)}
>
<div
className={cn(
'min-h-0 flex-1 overflow-y-auto rounded-md border border-[#2B8CA3]/45 bg-[#001B24]/70 shadow-[inset_0_0_calc(var(--design-unit)*18)_rgba(39,175,205,0.1)]',
isMobile ? 'p-design-8' : 'p-design-18',
)}
>
{noticeListQuery.isPending ? (
<DataLoadingIndicator
label={t('game.modals.entryNotice.loading')}
className="h-full min-h-[calc(var(--design-unit)*320)]"
className={cn(
'h-full',
isMobile
? 'min-h-[calc(var(--design-unit)*220)] text-design-12'
: 'min-h-[calc(var(--design-unit)*320)]',
)}
/>
) : noticeListQuery.isError ? (
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] flex-col items-center justify-center gap-design-18 text-center text-[#9CE8F2]">
<div className="text-design-22">
<div
className={cn(
'flex h-full flex-col items-center justify-center text-center text-[#9CE8F2]',
isMobile
? 'min-h-[calc(var(--design-unit)*220)] gap-design-10'
: 'min-h-[calc(var(--design-unit)*320)] gap-design-18',
)}
>
<div className={isMobile ? 'text-design-13' : 'text-design-22'}>
{t('game.modals.entryNotice.loadFailed')}
</div>
<button
@@ -148,30 +191,75 @@ export function EntryNoticeGateModal() {
onClick={() => {
void noticeListQuery.refetch()
}}
className="inline-flex items-center gap-design-8 rounded-md border border-[#4AC6DE]/45 bg-[#0B4454] px-design-18 py-design-10 text-design-18 text-[#D7FFFF] transition hover:bg-[#0E576D]"
className={cn(
'inline-flex items-center rounded-md border border-[#4AC6DE]/45 bg-[#0B4454] text-[#D7FFFF] transition hover:bg-[#0E576D]',
isMobile
? 'gap-design-5 px-design-10 py-design-6 text-design-12'
: 'gap-design-8 px-design-18 py-design-10 text-design-18',
)}
>
<RotateCw className="h-design-18 w-design-18" />
<RotateCw
className={
isMobile
? 'h-design-13 w-design-13'
: 'h-design-18 w-design-18'
}
/>
{t('game.modals.entryNotice.retry')}
</button>
</div>
) : (
<div className="flex flex-col gap-design-16">
<div
className={cn(
'flex flex-col',
isMobile ? 'gap-design-8' : 'gap-design-16',
)}
>
{popoutNotices.map((notice, index) => (
<article
key={notice.notice_id}
className="rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-20"
className={cn(
'rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)]',
isMobile ? 'p-design-9' : 'p-design-20',
)}
>
<div className="mb-design-12 flex flex-wrap items-center justify-between gap-design-12">
<div className="min-w-0 flex-1 text-design-24 font-semibold leading-tight text-white">
<div
className={cn(
'flex flex-wrap items-center justify-between',
isMobile
? 'mb-design-6 gap-design-6'
: 'mb-design-12 gap-design-12',
)}
>
<div
className={cn(
'min-w-0 flex-1 font-semibold leading-tight text-white',
isMobile ? 'text-design-13' : 'text-design-24',
)}
>
{index + 1}. {notice.title}
</div>
<div className="rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-12 py-design-5 text-design-15 text-[#9CE8F2]">
<div
className={cn(
'rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 text-[#9CE8F2]',
isMobile
? 'px-design-7 py-design-3 text-design-9'
: 'px-design-12 py-design-5 text-design-15',
)}
>
{dayjs(notice.publish_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)}
</div>
</div>
<div className="whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]">
<div
className={cn(
'whitespace-pre-wrap text-[#C4F2F7]',
isMobile
? 'text-design-11 leading-[1.55]'
: 'text-design-18 leading-[1.8]',
)}
>
{notice.content ?? ''}
</div>
</article>
@@ -180,8 +268,20 @@ export function EntryNoticeGateModal() {
)}
</div>
<div className="flex shrink-0 flex-col items-center justify-center gap-design-20">
<label className="inline-flex cursor-pointer items-center justify-center gap-design-12 text-design-20 text-[#C4F2F7]">
<div
className={cn(
'flex shrink-0 flex-col items-center justify-center',
isMobile ? 'gap-design-8' : 'gap-design-20',
)}
>
<label
className={cn(
'inline-flex cursor-pointer items-center justify-center text-[#C4F2F7]',
isMobile
? 'gap-design-6 text-design-11'
: 'gap-design-12 text-design-20',
)}
>
<input
type="checkbox"
checked={hasAgreed}
@@ -192,7 +292,10 @@ export function EntryNoticeGateModal() {
<span
aria-hidden="true"
className={cn(
'flex h-design-32 w-design-32 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] border transition',
'flex shrink-0 items-center justify-center border transition',
isMobile
? 'h-design-20 w-design-20 rounded-[calc(var(--design-unit)*4)]'
: 'h-design-32 w-design-32 rounded-[calc(var(--design-unit)*5)]',
hasAgreed
? 'border-[#4AFF49]/80 bg-[#071F11]'
: 'border-[#6CCDCF]/70 bg-[#031D25]',
@@ -204,7 +307,12 @@ export function EntryNoticeGateModal() {
alt=""
priority={true}
showSkeleton={false}
className="h-design-34 w-design-38 overflow-visible"
className={cn(
'overflow-visible',
isMobile
? 'h-design-22 w-design-24'
: 'h-design-34 w-design-38',
)}
imgClassName="object-contain"
/>
) : null}
@@ -230,7 +338,10 @@ export function EntryNoticeGateModal() {
}
}}
className={cn(
'flex h-design-72 w-design-270 items-center justify-center rounded-md pb-design-5 text-design-22 font-bold transition',
'flex items-center justify-center rounded-md font-bold transition',
isMobile
? 'h-design-42 w-design-156 pb-design-3 text-design-14'
: 'h-design-72 w-design-270 pb-design-5 text-design-22',
canEnter
? 'modal-title-glow cursor-pointer text-white hover:brightness-110 active:brightness-95'
: 'cursor-not-allowed text-white opacity-80 grayscale',
@@ -248,6 +359,10 @@ export function EntryNoticeGateModal() {
</SmartBackground>
</div>
</div>
</CenterModal>
</Modal>
)
}
export function MobileEntryNoticeGateModal() {
return <EntryNoticeGateModal variant="mobile" />
}

View File

@@ -1,5 +1,5 @@
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import type { PeriodHistoryDisplayItem } from '@/features/game/hooks/use-period-history-vm'
import type { PeriodHistoryDisplayItem } from '@/hooks/use-period-history-vm'
import { cn } from '@/lib/utils'
interface PeriodHistoryListLabels {

View File

@@ -1,219 +0,0 @@
import { startTransition, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MOBILE_LAYOUT_BREAKPOINT_PX } from '@/constants'
import { getGameLobbyInit } from '@/features/game'
import { EntryNoticeGateModal } 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 DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
import DesktopLanguageModal from '@/features/game/modal/desktop/desktop-language-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 { DesktopPeriodHistoryDrawer } from '@/features/game/modal/desktop/desktop-period-history-drawer.tsx'
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
import DesktopSupportModal from '@/features/game/modal/desktop/desktop-support-modal.tsx'
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
import { useDocumentMetadata } from '@/lib/head/document-metadata'
import { notify } from '@/lib/notify'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
function EntryModalHost() {
return (
<>
{/* 桌面端登录弹窗:用于未登录用户进入登录流程 */}
<DesktopLoginModal />
{/* 桌面端注册弹窗:用于新用户注册账号 */}
<DesktopRegisterModal />
{/* 桌面端语言切换弹窗:用于选择当前站点展示语言 */}
<DesktopLanguageModal />
{/* 桌面端规则弹窗:展示当前游戏玩法、下注与结算规则 */}
<DesktopRulesModal />
{/* 桌面端用户信息弹窗:展示个人资料与站内消息 */}
<DesktopUserInfoModal />
{/* 桌面端公告弹窗:展示活动公告或运营通知内容 */}
<DesktopNoticeModal />
{/* 桌面端自动托管弹窗:配置自动托管相关条件 */}
<DesktopAutoSettingModal />
{/* 桌面端充值/提现前置选择弹窗:先选择进入充值还是提现 */}
<DesktopProceduresModal />
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
<DesktopWithdrawTopupModal />
{/* 桌面端客服弹窗:承载在线客服 iframe */}
<DesktopSupportModal />
{/* 强制弹窗 */}
<EntryNoticeGateModal />
{/* 历史开奖信息弹窗 */}
<DesktopPeriodHistoryDrawer />
</>
)
}
export function EntryPage() {
const { t } = useTranslation()
useGameRealtimeSync()
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
const selectChip = useGameRoundStore((state) => state.selectChip)
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
const syncConnection = useGameSessionStore((state) => state.syncConnection)
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const authStatus = useAuthStore((state) => state.status)
const authIsHydrated = useAuthStore((state) => state.isHydrated)
const accessToken = useAuthStore((state) => state.accessToken)
const lastUnauthorizedAt = useAuthStore((state) => state.lastUnauthorizedAt)
const isReloginRequired =
authStatus === 'anonymous' && Boolean(lastUnauthorizedAt)
const [isHydrating, setIsHydrating] = useState(true)
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') {
return false
}
return window.matchMedia(`(max-width: ${MOBILE_LAYOUT_BREAKPOINT_PX}px)`)
.matches
})
useDocumentMetadata({
title: t('game.metaTitle'),
description: t('game.metaDescription'),
})
useEffect(() => {
if (!authIsHydrated) {
setIsHydrating(true)
return
}
if (isReloginRequired || authStatus !== 'authenticated' || !accessToken) {
setIsHydrating(false)
return
}
let cancelled = false
void getGameLobbyInit()
.then((result) => {
if (cancelled) {
return
}
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)
})
})
.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
}
}, [
accessToken,
authIsHydrated,
authStatus,
hydrateRound,
hydrateSession,
isReloginRequired,
selectChip,
setCurrentUser,
syncConnection,
t,
])
useEffect(() => {
if (typeof window === 'undefined') {
return
}
const mediaQuery = window.matchMedia(
`(max-width: ${MOBILE_LAYOUT_BREAKPOINT_PX}px)`,
)
const syncLayout = (event?: MediaQueryListEvent) => {
setIsMobile(event?.matches ?? mediaQuery.matches)
}
syncLayout()
mediaQuery.addEventListener('change', syncLayout)
return () => {
mediaQuery.removeEventListener('change', syncLayout)
}
}, [])
return (
<section
aria-busy={isHydrating}
aria-label={t('game.lobbyTitle')}
className="flex min-h-0 flex-1 flex-col"
>
{isMobile ? <MobileEntry /> : <PcEntry />}
<EntryModalHost />
</section>
)
}

View File

@@ -1,14 +0,0 @@
import { MobileHeader } from '@/features/game/components/mobile/mobile-header.tsx'
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
export function MobileEntry() {
useAutoHostingRunner()
return (
<>
<MobileHeader />
<RoundBettingStartAlert placement="fixed" />
</>
)
}

View File

@@ -1,45 +0,0 @@
import { DesktopHeader } from '@/features/game/components'
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
export function PcEntry() {
useAutoHostingRunner()
return (
<>
<DesktopHeader />
<div
className={'mx-auto my-design-10 w-[calc(100%-40*var(--design-unit))]'}
>
<DesktopStatusLine />
</div>
<div
className={'mx-auto w-[calc(100%-72*var(--design-unit))] mb-design-5'}
>
<div className={'flex w-full items-start gap-design-10'}>
<div className={'flex-1'}>
<DesktopAnimal />
</div>
<div
className={
'flex h-[calc(var(--design-unit)*715)] min-h-0 w-design-370'
}
>
<DesktopGameHistory />
</div>
</div>
</div>
<div
className={'mx-auto mt-design-10 w-[calc(100%-40*var(--design-unit))]'}
>
<DesktopControl />
</div>
</>
)
}

View File

@@ -1,236 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { notify } from '@/lib/notify'
import { useAudioStore, useAuthStore, useModalStore } from '@/store'
import {
selectSelectionTotal,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
function parseBalance(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
if (typeof value !== 'string') {
return 0
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
export type DesktopAnimalWarningType = 'balance' | 'betLimit' | 'limit'
function getNextMarqueeId(ids: number[], currentId: number | null) {
if (ids.length === 0) {
return null
}
if (ids.length === 1) {
return ids[0] ?? null
}
let nextId = currentId
while (nextId === currentId) {
nextId = ids[Math.floor(Math.random() * ids.length)] ?? currentId
}
return nextId
}
export function useAnimalVm(
animalIds: number[],
onSelect?: (animalId: number) => void,
) {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
const currentUser = useAuthStore((state) => state.currentUser)
const markSoundPlaybackUnlocked = useAudioStore(
(state) => state.markSoundPlaybackUnlocked,
)
const isLoginModalOpen = useModalStore((state) => state.modals.desktopLogin)
const isRegisterModalOpen = useModalStore(
(state) => state.modals.desktopRegister,
)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const activeBetQuantity = useGameRoundStore(
(state) => state.activeBetQuantity,
)
const chips = useGameRoundStore((state) => state.chips)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const roundId = useGameRoundStore((state) => state.round.id)
const roundPhase = useGameRoundStore((state) => state.round.phase)
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 totalBetAmount = useGameRoundStore(selectSelectionTotal)
const connection = useGameSessionStore((state) => state.connection)
const tableLimitMax = useGameSessionStore(
(state) => state.dashboard.tableLimitMax,
)
const requestRealtimeConnection = useGameSessionStore(
(state) => state.requestRealtimeConnection,
)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
const [marqueeId, setMarqueeId] = useState<number | null>(() =>
getNextMarqueeId(animalIds, null),
)
const [cellWarning, setCellWarning] = useState<{
cellId: number
type: DesktopAnimalWarningType
} | null>(null)
const activeChip = useMemo(
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
[activeChipId, chips],
)
const balance = parseBalance(currentUser?.coin)
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 isAuthModalOpen = isLoginModalOpen || isRegisterModalOpen
const shouldAnimateStandby = showStandbyState && !isAuthModalOpen
const hasSubmittedCurrentRound =
Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId
const lockInteraction =
showStandbyState || hasSubmittedCurrentRound || roundPhase !== 'betting'
const selectedCellCount = Object.keys(selectionByCell).length
useEffect(() => {
if (cellWarning === null) {
return
}
const timerId = window.setTimeout(() => {
setCellWarning((currentWarning) =>
currentWarning?.cellId === cellWarning.cellId &&
currentWarning.type === cellWarning.type
? null
: currentWarning,
)
}, 1200)
return () => {
window.clearTimeout(timerId)
}
}, [cellWarning])
useEffect(() => {
if (!shouldAnimateStandby) {
setMarqueeId(null)
return
}
setMarqueeId((currentId) => getNextMarqueeId(animalIds, currentId))
let timerId = 0
const loop = () => {
setMarqueeId((currentId) => getNextMarqueeId(animalIds, currentId))
timerId = window.setTimeout(loop, 180 + Math.floor(Math.random() * 220))
}
timerId = window.setTimeout(loop, 220)
return () => {
window.clearTimeout(timerId)
}
}, [animalIds, shouldAnimateStandby])
const handleStart = () => {
if (authStatus !== 'authenticated') {
notify.warning(t('commonUi.toast.loginRequired'))
setModalOpen('desktopLogin', true)
return
}
clearSelections()
markSoundPlaybackUnlocked()
requestRealtimeConnection()
}
const handleSelect = (animalId: number) => {
if (roundPhase !== 'betting' || lockInteraction) {
return
}
if (onSelect) {
onSelect(animalId)
return
}
if (selectionByCell[animalId]) {
removeSelectionsForCell(animalId)
return
}
if (selectedCellCount >= maxSelectionCount) {
setCellWarning({
cellId: animalId,
type: 'limit',
})
return
}
const nextBetAmount = (activeChip?.amount ?? 0) * activeBetQuantity
if (tableLimitMax > 0 && totalBetAmount + nextBetAmount > tableLimitMax) {
setCellWarning({
cellId: animalId,
type: 'betLimit',
})
return
}
if (totalBetAmount + nextBetAmount > balance) {
setCellWarning({
cellId: animalId,
type: 'balance',
})
return
}
placeBet(animalId)
}
return {
cellWarning,
handleSelect,
handleStart,
isRealtimeConnecting,
lockInteraction,
marqueeId: shouldAnimateStandby ? marqueeId : null,
selectionByCell,
showStandbyState,
}
}

View File

@@ -1,60 +0,0 @@
import { useLocation } from '@tanstack/react-router'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
DEFAULT_APP_LANGUAGE,
LANGUAGE_OPTIONS,
SUPPORTED_LANGUAGES,
} from '@/constants'
import type { AppLanguage } from '@/i18n'
const languagePrefixPattern = new RegExp(
`^/(${SUPPORTED_LANGUAGES.join('|')})(?=/|$)`,
)
function resolveNextPathname(pathname: string, language: AppLanguage) {
if (languagePrefixPattern.test(pathname)) {
return pathname.replace(languagePrefixPattern, `/${language}`)
}
return `/${language}${pathname.startsWith('/') ? pathname : `/${pathname}`}`
}
export function useAppLanguage() {
const { i18n, t } = useTranslation()
const location = useLocation()
const currentLanguage = (i18n.resolvedLanguage ??
i18n.language ??
DEFAULT_APP_LANGUAGE) as AppLanguage
const currentLanguageOption = useMemo(
() =>
LANGUAGE_OPTIONS.find((option) => option.code === currentLanguage) ??
LANGUAGE_OPTIONS.find((option) => option.code === DEFAULT_APP_LANGUAGE) ??
LANGUAGE_OPTIONS[0],
[currentLanguage],
)
const selectLanguage = async (language: AppLanguage) => {
if (language === currentLanguage) {
return
}
await i18n.changeLanguage(language)
const nextPathname = resolveNextPathname(location.pathname, language)
window.location.assign(
`${nextPathname}${window.location.search}${window.location.hash}`,
)
}
return {
currentLanguage,
currentLanguageLabel: t(currentLanguageOption.labelKey),
currentLanguageOption,
languageOptions: LANGUAGE_OPTIONS,
selectLanguage,
}
}

View File

@@ -1,269 +0,0 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { placeGameBet } from '@/features/game'
import type { BetSelection } from '@/features/game/shared'
import { notify } from '@/lib/notify'
import { useAuthStore } from '@/store/auth'
import {
useGameAutoHostingStore,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
function parseBalance(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
if (typeof value !== 'string') {
return 0
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function createIdempotencyKey() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return `auto-bet-${crypto.randomUUID()}`
}
return `auto-bet-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function toBetId(chipId: string) {
const match = chipId.match(/^chip-(\d+)$/)
if (!match) {
return null
}
const betId = Number(match[1])
return Number.isInteger(betId) && betId >= 1 && betId <= 6 ? betId : null
}
function formatBetAmount(amount: number) {
if (Number.isInteger(amount)) {
return String(amount)
}
return amount.toFixed(2).replace(/\.?0+$/, '')
}
function groupSelections(selections: BetSelection[]) {
return selections.reduce<
Map<string, { amount: number; betId: number; numbers: number[] }>
>((accumulator, selection) => {
const betId = toBetId(selection.chipId)
if (betId === null) {
return accumulator
}
const groupKey = `${betId}:${selection.amount}`
const current = accumulator.get(groupKey)
if (current) {
current.numbers.push(selection.cellId)
return accumulator
}
accumulator.set(groupKey, {
amount: selection.amount,
betId,
numbers: [selection.cellId],
})
return accumulator
}, new Map())
}
export function useAutoHostingRunner() {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
const currentUser = useAuthStore((state) => state.currentUser)
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const round = useGameRoundStore((state) => state.round)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const tableLimitMax = useGameSessionStore(
(state) => state.dashboard.tableLimitMax,
)
const lastSingleWinAmount = useGameAutoHostingStore(
(state) => state.lastSingleWinAmount,
)
const lastIsJackpot = useGameAutoHostingStore((state) => state.lastIsJackpot)
const isHosting = useGameAutoHostingStore((state) => state.isHosting)
const lastSubmittedRoundId = useGameAutoHostingStore(
(state) => state.lastSubmittedRoundId,
)
const rules = useGameAutoHostingStore((state) => state.rules)
const selections = useGameAutoHostingStore((state) => state.selections)
const markRoundSubmitted = useGameAutoHostingStore(
(state) => state.markRoundSubmitted,
)
const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
const inFlightRoundIdRef = useRef<string | null>(null)
useEffect(() => {
if (!isHosting) {
return
}
const balance = parseBalance(currentUser?.coin)
if (
rules.stopIfBalanceBelow.enabled &&
balance < rules.stopIfBalanceBelow.amount
) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
return
}
if (
rules.stopIfSingleWinAbove.enabled &&
lastSingleWinAmount !== null &&
lastSingleWinAmount > rules.stopIfSingleWinAbove.amount
) {
stopHosting()
notify.success(t('commonUi.toast.autoHostingStoppedWin'))
return
}
if (rules.stopOnJackpot && lastIsJackpot === true) {
stopHosting()
notify.success(t('commonUi.toast.autoHostingStoppedJackpot'))
return
}
}, [
currentUser?.coin,
isHosting,
lastIsJackpot,
lastSingleWinAmount,
rules,
stopHosting,
t,
])
useEffect(() => {
if (
!isHosting ||
inFlightRoundIdRef.current !== null ||
authStatus !== 'authenticated' ||
!currentUser ||
round.phase !== 'betting' ||
!round.id ||
lastSubmittedRoundId === round.id
) {
return
}
const groupedSelections = groupSelections(selections)
if (groupedSelections.size === 0) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStopped'))
return
}
const totalBetAmount = selections.reduce(
(total, selection) => total + selection.amount,
0,
)
const balance = parseBalance(currentUser.coin)
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStoppedBetLimit'))
return
}
if (totalBetAmount > balance) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
return
}
const submittingRoundId = round.id
inFlightRoundIdRef.current = submittingRoundId
const submitAutoBet = async () => {
try {
let latestBalance = currentUser.coin ?? '0'
for (const group of groupedSelections.values()) {
const uniqueNumbers = [...new Set(group.numbers)].sort(
(left, right) => left - right,
)
const formattedSingleBetAmount = formatBetAmount(group.amount)
const result = await placeGameBet({
bet_amount: formattedSingleBetAmount,
bet_id: group.betId,
idempotency_key: createIdempotencyKey(),
numbers: uniqueNumbers.join(','),
period_no: round.id,
single_bet_amount: formattedSingleBetAmount,
})
if (result.status !== 'accepted') {
throw new Error(t('commonUi.toast.betRejected'))
}
latestBalance = result.balance_after
}
const latestHostingState = useGameAutoHostingStore.getState()
const latestUser = useAuthStore.getState().currentUser
if (
!latestHostingState.isHosting ||
latestHostingState.lastSubmittedRoundId === submittingRoundId ||
!latestUser
) {
return
}
setCurrentUser({
...latestUser,
coin: latestBalance,
lastBetPeriodNo: submittingRoundId,
})
markRoundSubmitted(submittingRoundId, parseBalance(latestBalance))
clearSelections()
} catch (error) {
if (useGameAutoHostingStore.getState().isHosting) {
stopHosting()
notify.error(t('commonUi.toast.autoHostingSubmitFailed'), {
description: error instanceof Error ? error.message : undefined,
})
}
} finally {
if (inFlightRoundIdRef.current === submittingRoundId) {
inFlightRoundIdRef.current = null
}
}
}
void submitAutoBet()
}, [
authStatus,
clearSelections,
currentUser,
isHosting,
lastSubmittedRoundId,
markRoundSubmitted,
round.id,
round.phase,
selections,
setCurrentUser,
stopHosting,
tableLimitMax,
t,
])
}

View File

@@ -1,20 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
DEFAULT_APP_LANGUAGE,
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
} from '@/constants'
import { getDepositTierList } from '@/features/game/api'
export function useDepositTierList() {
const { i18n } = useTranslation()
const language =
i18n.resolvedLanguage ?? i18n.language ?? DEFAULT_APP_LANGUAGE
return useQuery({
queryKey: ['finance', 'deposit-tier-list', language],
queryFn: () => getDepositTierList(),
staleTime: FINANCE_CONFIG_QUERY_STALE_TIME_MS,
})
}

View File

@@ -1,20 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
DEFAULT_APP_LANGUAGE,
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
} from '@/constants'
import { getDepositWithdrawConfig } from '@/features/game/api'
export function useDepositWithdrawConfig() {
const { i18n } = useTranslation()
const language =
i18n.resolvedLanguage ?? i18n.language ?? DEFAULT_APP_LANGUAGE
return useQuery({
queryKey: ['finance', 'deposit-withdraw-config', language],
queryFn: () => getDepositWithdrawConfig(),
staleTime: FINANCE_CONFIG_QUERY_STALE_TIME_MS,
})
}

View File

@@ -1,123 +0,0 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
import { getDepositOrderList, getWithdrawOrderList } from '@/features/game/api'
export type FinanceRecordType = 'deposit' | 'withdraw'
const FINANCE_RECORD_TYPE_OPTIONS: Array<{
key: FinanceRecordType
labelKey: string
}> = [
{
key: 'deposit',
labelKey: 'game.modals.userInfo.financeRecords.deposit',
},
{
key: 'withdraw',
labelKey: 'game.modals.userInfo.financeRecords.withdraw',
},
]
function formatFinanceAmount(value: string, locale: string) {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) {
return value || '--'
}
return new Intl.NumberFormat(locale, {
maximumFractionDigits: 4,
}).format(numberValue)
}
export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
const { i18n, t } = useTranslation()
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
const [recordType, setRecordType] = useState<FinanceRecordType>('deposit')
const query = useInfiniteQuery({
queryKey: ['finance', 'user-info-order-list', recordType],
initialPageParam: 1,
queryFn: ({ pageParam }) =>
recordType === 'deposit'
? getDepositOrderList({
page: pageParam,
pageSize: DEFAULT_LIST_PAGE_SIZE,
})
: getWithdrawOrderList({
page: pageParam,
pageSize: DEFAULT_LIST_PAGE_SIZE,
}),
enabled,
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 lastPage = query.data?.pages.at(-1)
const total = lastPage?.pagination.total ?? 0
const loadedPage = lastPage?.pagination.page ?? 1
const recordTypes = useMemo(
() =>
FINANCE_RECORD_TYPE_OPTIONS.map((option) => ({
key: option.key,
label: t(option.labelKey),
})),
[t],
)
const items = useMemo(
() =>
(query.data?.pages ?? []).flatMap((page) =>
page.list.map((item, index) => ({
amountLabel: formatFinanceAmount(item.amount, locale),
bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale),
id: item.orderNo || `${page.pagination.page}-${index}`,
orderNoLabel: item.orderNo || '--',
})),
),
[locale, query.data?.pages],
)
const selectRecordType = useCallback((type: FinanceRecordType) => {
setRecordType(type)
}, [])
useEffect(() => {
if (!enabled) {
setRecordType('deposit')
}
}, [enabled])
return {
emptyText: t('game.modals.userInfo.financeRecords.empty'),
fetchNextPage: query.fetchNextPage,
hasNextPage: query.hasNextPage,
headers: {
amount: t('game.modals.userInfo.financeRecords.amount'),
bonusAmount: t('game.modals.userInfo.financeRecords.bonusAmount'),
orderNo: t('game.modals.userInfo.financeRecords.orderNo'),
},
isError: query.isError,
isFetchingNextPage: query.isFetchingNextPage,
isLoading: query.isLoading,
items,
loadFailedText: t('game.modals.userInfo.financeRecords.loadFailed'),
loadingText: t('game.modals.userInfo.financeRecords.loading'),
pageLabel: t('game.modals.userInfo.financeRecords.page', {
page: loadedPage,
total,
}),
recordType,
recordTypes,
selectRecordType,
}
}

View File

@@ -1,312 +0,0 @@
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants'
import { placeGameBet } from '@/features/game'
import { notify } from '@/lib/notify'
import { useAuthStore, useModalStore } from '@/store'
import {
selectSelectionTotal,
useGameAutoHostingStore,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'limit' | 'submitting'
function formatChipDisplayValue(amount: number) {
if (Number.isInteger(amount)) {
return String(amount)
}
return amount.toFixed(2).replace(/\.?0+$/, '')
}
function parseBalance(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
if (typeof value !== 'string') {
return 0
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function createIdempotencyKey() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return `bet-${crypto.randomUUID()}`
}
return `bet-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function toBetId(chipId: string) {
const match = chipId.match(/^chip-(\d+)$/)
if (!match) {
return null
}
const betId = Number(match[1])
return Number.isInteger(betId) && betId >= 1 && betId <= 6 ? betId : null
}
export function useGameControlVm() {
const { t } = useTranslation()
const chips = useGameRoundStore((state) => state.chips)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const activeBetQuantity = useGameRoundStore(
(state) => state.activeBetQuantity,
)
const round = useGameRoundStore((state) => state.round)
const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount,
)
const adjustBetQuantity = useGameRoundStore(
(state) => state.adjustBetQuantity,
)
const selections = useGameRoundStore((state) => state.selections)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const restoreRecentSuccessfulSelections = useGameRoundStore(
(state) => state.restoreRecentSuccessfulSelections,
)
const setRecentSuccessfulSelections = useGameRoundStore(
(state) => state.setRecentSuccessfulSelections,
)
const isAutoHosting = useGameAutoHostingStore((state) => state.isHosting)
const selectChip = useGameRoundStore((state) => state.selectChip)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const connectionStatus = useGameSessionStore(
(state) => state.connection.status,
)
const tableLimitMax = useGameSessionStore(
(state) => state.dashboard.tableLimitMax,
)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
const authStatus = useAuthStore((state) => state.status)
const currentUser = useAuthStore((state) => state.currentUser)
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const [isSubmitting, setIsSubmitting] = useState(false)
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
const balance = parseBalance(currentUser?.coin)
const hasSelections = selections.length > 0
const hasEnteredGame =
shouldConnectRealtime && connectionStatus === 'connected'
const hasSubmittedCurrentRound =
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
const hasExceededBetLimit =
hasSelections && tableLimitMax > 0 && totalBetAmount > tableLimitMax
const confirmState: ConfirmState =
isSubmitting || isAutoHosting
? 'submitting'
: !hasSelections
? 'idle'
: hasExceededBetLimit
? 'limit'
: hasInsufficientBalance
? 'insufficient'
: 'ready'
const handleConfirm = useCallback(async () => {
if (confirmState === 'submitting' || !hasSelections) {
return
}
if (authStatus !== 'authenticated') {
notify.warning(t('commonUi.toast.loginRequired'))
setModalOpen('desktopLogin', true)
return
}
if (hasExceededBetLimit) {
notify.warning(t('commonUi.toast.betLimitExceeded'))
return
}
if (hasInsufficientBalance) {
notify.warning(t('commonUi.toast.insufficientBalance'))
return
}
if (round.phase !== 'betting' || !round.id) {
notify.warning(t('commonUi.toast.betUnavailable'))
return
}
if (hasSubmittedCurrentRound) {
notify.warning(t('commonUi.toast.betUnavailable'))
return
}
const betId = toBetId(selections[0]?.chipId ?? activeChipId)
const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0
if (betId === null || singleBetAmount <= 0) {
notify.warning(t('commonUi.toast.betUnavailable'))
return
}
setIsSubmitting(true)
try {
let latestBalance = currentUser?.coin ?? '0'
const uniqueNumbers = [
...new Set(selections.map((item) => item.cellId)),
].sort((left, right) => left - right)
const formattedSingleBetAmount = formatChipDisplayValue(singleBetAmount)
const result = await placeGameBet({
bet_amount: formattedSingleBetAmount,
bet_id: betId,
idempotency_key: createIdempotencyKey(),
numbers: uniqueNumbers.join(','),
period_no: round.id,
single_bet_amount: formattedSingleBetAmount,
})
if (result.status !== 'accepted') {
throw new Error(t('commonUi.toast.betRejected'))
}
latestBalance = result.balance_after
if (currentUser) {
setCurrentUser({
...currentUser,
coin: latestBalance,
lastBetPeriodNo: round.id,
})
}
setRecentSuccessfulSelections(selections)
clearSelections()
notify.success(t('commonUi.toast.betPlaced'))
} catch (error) {
notify.error(t('commonUi.toast.betPlaceFailed'), {
description: error instanceof Error ? error.message : undefined,
})
} finally {
setIsSubmitting(false)
}
}, [
activeChipId,
authStatus,
clearSelections,
confirmState,
currentUser,
hasInsufficientBalance,
hasExceededBetLimit,
hasSelections,
hasSubmittedCurrentRound,
round.id,
round.phase,
selections,
selectedChip?.amount,
setRecentSuccessfulSelections,
setCurrentUser,
setModalOpen,
t,
])
const handleRepeatSelections = useCallback(() => {
if (round.phase !== 'betting' || hasSubmittedCurrentRound) {
notify.warning(t('commonUi.toast.betUnavailable'))
return
}
const restored = restoreRecentSuccessfulSelections()
if (!restored) {
notify.warning(t('commonUi.toast.noRecentSuccessfulBet'))
return
}
notify.success(t('commonUi.toast.repeatSelectionsRestored'))
}, [
hasSubmittedCurrentRound,
restoreRecentSuccessfulSelections,
round.phase,
t,
])
const handleOpenAutoSetting = useCallback(() => {
if (!hasSelections) {
notify.warning(t('commonUi.toast.selectNumbersBeforeAutoHosting'))
return
}
setModalOpen('desktopAutoSetting', true)
}, [hasSelections, setModalOpen, t])
return {
acceptingBets:
round.phase === 'betting' && !hasSubmittedCurrentRound && !isAutoHosting,
actionsEnabled:
hasEnteredGame &&
round.phase === 'betting' &&
!hasSubmittedCurrentRound &&
!isAutoHosting,
canClear:
selections.length > 0 &&
round.phase === 'betting' &&
!hasSubmittedCurrentRound &&
!isAutoHosting,
canDecreaseBetQuantity: activeBetQuantity > 1,
confirmLabel:
confirmState === 'idle'
? t('gameDesktop.control.selectNumbers')
: confirmState === 'insufficient'
? t('gameDesktop.control.insufficientBalance')
: confirmState === 'limit'
? t('gameDesktop.control.betLimitExceeded')
: confirmState === 'submitting'
? t('gameDesktop.control.submitting')
: t('gameDesktop.control.confirm'),
confirmState,
isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
onChipSelect: selectChip,
onDecreaseBetQuantity: () => adjustBetQuantity(-1),
onIncreaseBetQuantity: () => adjustBetQuantity(1),
onConfirm: handleConfirm,
onClearSelections: clearSelections,
onOpenAutoSetting: handleOpenAutoSetting,
onRepeatSelections: handleRepeatSelections,
maxSelectionCountLabel: maxSelectionCount,
selectedBetQuantityLabel: activeBetQuantity,
selectedChipId: activeChipId,
selectedCountLabel: selections.length,
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
chips: chipItems,
}
}

View File

@@ -1,140 +0,0 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { GAME_HISTORY_PAGE_SIZE } from '@/constants'
import { getGameBetMyOrders } from '@/features/game/api/game-api'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore } from '@/store/game'
function formatCreatedTime(timestamp: number, locale: string) {
const date = new Date(timestamp * 1000)
if (Number.isNaN(date.getTime())) {
return '--'
}
return date.toLocaleString(locale, {
hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function formatNumbers(numbers: number[]) {
if (numbers.length === 0) {
return '--'
}
return numbers.map((number) => String(number).padStart(2, '0')).join(', ')
}
type HistoryResultState = 'lost' | 'pending' | 'win'
export function useGameHistoryVm() {
const { i18n, t } = useTranslation()
const accessToken = useAuthStore((state) => state.accessToken)
const authStatus = useAuthStore((state) => state.status)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealRoundId = useGameRoundStore(
(state) => state.revealAnimation.roundId,
)
const lastRevealedRoundRef = useRef<string | null>(null)
const query = useInfiniteQuery({
queryKey: ['game', 'bet-my-orders', accessToken],
enabled: authStatus === 'authenticated' && Boolean(accessToken),
initialPageParam: 1,
refetchOnMount: 'always',
staleTime: 0,
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(
() =>
(query.data?.pages ?? []).flatMap((page) =>
page.list.map((entry) => {
const shouldHideResult =
entry.period_no === revealRoundId && revealPhase !== 'result'
const resultNumber = shouldHideResult ? null : entry.result_number
return {
amountLabel: entry.total_amount,
createdAtLabel: formatCreatedTime(
entry.create_time,
i18n.resolvedLanguage ?? 'en-US',
),
id: entry.order_no,
resultState:
resultNumber === null
? ('pending' satisfies HistoryResultState)
: entry.numbers.includes(resultNumber)
? ('win' satisfies HistoryResultState)
: ('lost' satisfies HistoryResultState),
numbersLabel: formatNumbers(entry.numbers),
numbers: entry.numbers,
orderNo: entry.order_no,
periodNo: entry.period_no,
resultNumber,
resultNumberLabel:
resultNumber === null
? '--'
: String(resultNumber).padStart(2, '0'),
winAmountLabel: entry.win_amount,
}
}),
),
[i18n.resolvedLanguage, query.data?.pages, revealPhase, revealRoundId],
)
useEffect(() => {
if (revealPhase !== 'result' || !revealRoundId) {
return
}
if (lastRevealedRoundRef.current === revealRoundId) {
return
}
if (authStatus !== 'authenticated' || query.isFetching || query.isLoading) {
return
}
lastRevealedRoundRef.current = revealRoundId
void query.refetch()
}, [
authStatus,
query.isFetching,
query.isLoading,
query.refetch,
revealPhase,
revealRoundId,
])
return {
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

@@ -1,913 +0,0 @@
import { useEffect, useRef } from 'react'
import {
FALLBACK_POLL_INTERVAL_MS,
GAME_SOCKET_TOPIC_VALUES,
GAME_SOCKET_TOPICS,
PLAYER_SOCKET_TOPICS,
SOCKET_DISCONNECT_DELAY_MS,
} from '@/constants'
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 {
useGameAutoHostingStore,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
import { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api'
import type {
BetWinEventDataDto,
GamePeriodTickDto,
JackpotHitEventDataDto,
JackpotHitItemDto,
} from '../api/types'
type UserStreakMessageData = {
currentStreak: number
oddsFactor?: number
streakLevel?: number
}
type PeriodEventData = {
openTime: number | null
periodNo: string
resultNumber: number | null
}
type WalletChangedData = {
coin: string
}
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 toOptionalString(value: unknown) {
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return undefined
}
function toOptionalBoolean(value: unknown) {
if (typeof value === 'boolean') {
return value
}
if (value === 1 || value === '1' || value === 'true') {
return true
}
if (value === 0 || value === '0' || value === 'false') {
return false
}
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 getMessageTopic(message: GameSocketMessage) {
const root = message as Record<string, unknown>
const event = typeof root.event === 'string' ? root.event : null
const topic = typeof root.topic === 'string' ? root.topic : null
if (event && GAME_SOCKET_TOPIC_VALUES.has(event)) {
return event
}
if (topic && GAME_SOCKET_TOPIC_VALUES.has(topic)) {
return topic
}
return event ?? topic
}
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 extractUserStreakMessageData(
message: GameSocketMessage,
): UserStreakMessageData | null {
const data = getNestedRecord(message, 'data')
const direct = getNestedRecord(message, 'user_snapshot')
const nested = getNestedRecord(data, 'user_snapshot')
const source =
data && 'current_streak' in data ? data : (nested ?? direct ?? data)
if (!source || typeof source.current_streak !== 'number') {
return null
}
return {
currentStreak: source.current_streak,
oddsFactor: toOptionalNumber(source.odds_factor),
streakLevel: 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 extractPeriodEventData(
message: GameSocketMessage,
): PeriodEventData | null {
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
const periodNo =
typeof source.period_no === 'string'
? source.period_no
: typeof source.periodNo === 'string'
? source.periodNo
: null
if (!periodNo) {
return null
}
const resultNumber = toOptionalNumber(
source.result_number ?? source.resultNumber,
)
const openTime = toOptionalNumber(source.open_time ?? source.openTime)
return {
openTime: openTime ?? null,
periodNo,
resultNumber:
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
? resultNumber
: null,
}
}
function extractWalletChangedData(
message: GameSocketMessage,
): WalletChangedData | null {
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
const coin = source.coin ?? source.balance ?? source.balance_after
const normalizedCoin =
typeof coin === 'string'
? coin
: typeof coin === 'number' && Number.isFinite(coin)
? String(coin)
: null
if (normalizedCoin === null) {
return null
}
return {
coin: normalizedCoin,
}
}
function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null {
if (!value || typeof value !== 'object') {
return null
}
const source = value as Record<string, unknown>
if (
typeof source.nickname !== 'string' ||
typeof source.period_no !== 'string' ||
typeof source.total_win !== 'string'
) {
return null
}
const resultNumber = toOptionalNumber(source.result_number)
if (typeof resultNumber !== 'number' || !Number.isInteger(resultNumber)) {
return null
}
return {
nickname: source.nickname,
period_no: source.period_no,
result_number: resultNumber,
total_win: source.total_win,
}
}
function extractJackpotHitData(
message: GameSocketMessage,
): JackpotHitEventDataDto | null {
const data = getNestedRecord(message, 'data')
if (!data || typeof data.period_no !== 'string') {
return null
}
const nestedHits = data.hits
const sourceHits = Array.isArray(nestedHits)
? nestedHits
: nestedHits && typeof nestedHits === 'object'
? [nestedHits]
: [data]
const firstHitSource = sourceHits.find(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object',
)
const hits = sourceHits
.map((item) => extractJackpotHitItem(item))
.filter((item): item is JackpotHitItemDto => item !== null)
const root = message as Record<string, unknown>
const serverTime = toOptionalNumber(
data.server_time ?? firstHitSource?.server_time ?? root.server_time,
)
const resultNumber = toOptionalNumber(
data.result_number ?? data['result number'],
)
if (typeof serverTime !== 'number') {
return null
}
return {
hits,
period_id:
typeof data.period_id === 'number' && Number.isInteger(data.period_id)
? data.period_id
: null,
period_no: data.period_no,
result_number:
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
? resultNumber
: null,
server_time: serverTime,
}
}
function extractBetWinData(
message: GameSocketMessage,
): BetWinEventDataDto | null {
const data = getNestedRecord(message, 'data')
if (!data) {
return null
}
const root = message as Record<string, unknown>
const userId = toOptionalNumber(data.user_id)
const periodId = toOptionalNumber(data.period_id)
const resultNumber = toOptionalNumber(data.result_number)
const totalWin = toOptionalString(data.total_win)
const balanceAfter = toOptionalString(data.balance_after)
const serverTime = toOptionalNumber(data.server_time ?? root.server_time)
const currentStreak = toOptionalNumber(data.current_streak)
const streakLevel = toOptionalNumber(data.streak_level)
const oddsFactor = toOptionalNumber(data.odds_factor)
const isJackpot = toOptionalBoolean(data.is_jackpot)
if (
typeof totalWin !== 'string' ||
typeof data.period_no !== 'string' ||
typeof isJackpot !== 'boolean'
) {
return null
}
return {
balance_after: balanceAfter,
bets: Array.isArray(data.bets)
? data.bets
.map((item) => {
if (!item || typeof item !== 'object') {
return null
}
const bet = item as Record<string, unknown>
const betId = toOptionalNumber(bet.bet_id)
const winAmount = toOptionalString(bet.win_amount)
return typeof betId === 'number' && typeof winAmount === 'string'
? {
bet_id: betId,
win_amount: winAmount,
}
: null
})
.filter(
(item): item is BetWinEventDataDto['bets'][number] => item !== null,
)
: [],
current_streak: currentStreak,
is_jackpot: isJackpot,
is_win: toOptionalBoolean(data.is_win) ?? true,
odds_factor: typeof oddsFactor === 'number' ? oddsFactor : undefined,
payout_pending_review:
toOptionalBoolean(data.payout_pending_review) ?? false,
period_id: periodId,
period_no: data.period_no,
result_number:
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
? resultNumber
: null,
server_time: serverTime,
streak_level: typeof streakLevel === 'number' ? streakLevel : undefined,
total_win: totalWin,
user_id: userId,
}
}
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 applyPeriodMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
const period = extractPeriodTick(message)
if (!period) {
return
}
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: previousRound.winningCellId,
})
useGameSessionStore.getState().syncDashboard({
countdownMs: period.countdown * 1000,
updatedAt:
serverTime !== null
? toIsoFromUnixSeconds(serverTime)
: toIsoFromUnixSeconds(period.server_time),
})
}
function applyPeriodPhase(phase: 'locked' | 'revealing' | 'settled') {
useGameRoundStore.getState().setPhase(phase)
}
function applyPeriodLockedMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message)
const roundState = useGameRoundStore.getState()
const roundId = period?.periodNo ?? roundState.round.id
if (roundId) {
roundState.syncRound({
id: roundId,
phase: 'locked',
})
} else {
roundState.setPhase('locked')
}
}
function applyPeriodOpenedMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
console.log('%c[period.opened 开奖数据]', 'color: red;', message)
applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message)
if (!period || period.resultNumber === null) {
applyPeriodPhase('revealing')
return
}
const roundState = useGameRoundStore.getState()
const openedAt = toIsoFromUnixSeconds(
period.openTime ?? serverTime ?? Math.floor(Date.now() / 1000),
)
const revealKey = `${period.periodNo}:${period.resultNumber}`
roundState.syncRound({
id: period.periodNo,
phase: 'revealing',
revealingAt: openedAt,
winningCellId: period.resultNumber,
})
useGameRoundStore.getState().prepareRevealAnimation({
revealKey,
roundId: period.periodNo,
winningCellId: period.resultNumber,
})
}
function applyPeriodPayoutMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message)
if (period?.resultNumber !== null && period?.resultNumber !== undefined) {
const roundState = useGameRoundStore.getState()
const revealKey = `${period.periodNo}:${period.resultNumber}`
roundState.syncRound({
id: period.periodNo,
winningCellId: period.resultNumber,
})
roundState.prepareRevealAnimation({
revealKey,
roundId: period.periodNo,
winningCellId: period.resultNumber,
})
}
const roundId = period?.periodNo ?? useGameRoundStore.getState().round.id
applyPeriodPhase('settled')
useGameRoundStore.getState().playPreparedRevealAnimation(roundId || null)
}
function applyUserStreakMessage(message: GameSocketMessage) {
const streakData = extractUserStreakMessageData(message)
const currentUser = useAuthStore.getState().currentUser
if (!streakData || !currentUser) {
return
}
useAuthStore.getState().setCurrentUser({
...currentUser,
currentStreak: streakData.currentStreak,
oddsFactor: streakData.oddsFactor ?? currentUser.oddsFactor,
streakLevel: streakData.streakLevel ?? currentUser.streakLevel,
})
}
function applyWalletChangedMessage(message: GameSocketMessage) {
const walletData = extractWalletChangedData(message)
const currentUser = useAuthStore.getState().currentUser
if (!walletData || !currentUser) {
return
}
useAuthStore.getState().setCurrentUser({
...currentUser,
coin: walletData.coin,
})
}
function applyJackpotHitMessage(message: GameSocketMessage) {
console.log('%c[jackpot.hit 数据]', 'color: red;', message)
const jackpotHitData = extractJackpotHitData(message)
if (jackpotHitData?.hits.length) {
useGameSessionStore.getState().pushJackpotBroadcasts(
jackpotHitData.hits.map((hit) => ({
id: `${jackpotHitData.period_no}:${hit.result_number}:${hit.nickname}:${hit.total_win}`,
message: `恭喜${hit.nickname} 用户中奖,获得${hit.total_win}`,
nickname: hit.nickname,
periodNo: hit.period_no,
totalWin: hit.total_win,
})),
)
}
}
function applyBetWinMessage(message: GameSocketMessage) {
const betWinData = extractBetWinData(message)
const currentUser = useAuthStore.getState().currentUser
if (!betWinData) {
return
}
useGameRoundStore.getState().setPendingBetWinReward({
isJackpot: betWinData.is_jackpot,
revealKey: `${betWinData.period_no}:${betWinData.result_number ?? 'pending'}:${betWinData.total_win}`,
roundId: betWinData.period_no,
totalWin: betWinData.total_win,
winningCellId: betWinData.result_number,
})
useGameAutoHostingStore.getState().recordBetWin({
isJackpot: betWinData.is_jackpot,
singleWinAmount: toOptionalNumber(betWinData.total_win) ?? null,
})
if (!currentUser) {
return
}
useAuthStore.getState().setCurrentUser({
...currentUser,
coin: betWinData.balance_after ?? currentUser.coin,
currentStreak: betWinData.current_streak ?? currentUser.currentStreak,
isJackpot: betWinData.is_jackpot,
oddsFactor: betWinData.odds_factor ?? currentUser.oddsFactor,
streakLevel: betWinData.streak_level ?? currentUser.streakLevel,
})
}
function applyRealtimeMessage(message: GameSocketMessage) {
const serverTime = extractServerTime(message)
const topic = getMessageTopic(message)
switch (topic) {
case GAME_SOCKET_TOPICS.periodTick:
applyPeriodMessage(message, serverTime)
break
case GAME_SOCKET_TOPICS.periodLocked:
applyPeriodLockedMessage(message, serverTime)
break
case GAME_SOCKET_TOPICS.periodOpened:
applyPeriodOpenedMessage(message, serverTime)
break
case GAME_SOCKET_TOPICS.periodPayout:
applyPeriodPayoutMessage(message, serverTime)
break
case GAME_SOCKET_TOPICS.userStreak:
applyUserStreakMessage(message)
break
case GAME_SOCKET_TOPICS.walletChanged:
applyWalletChangedMessage(message)
break
case GAME_SOCKET_TOPICS.jackpotHit:
applyJackpotHitMessage(message)
break
case GAME_SOCKET_TOPICS.betWin:
applyBetWinMessage(message)
break
case GAME_SOCKET_TOPICS.betAccepted:
case GAME_SOCKET_TOPICS.autoSpinProgress:
break
}
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 lastUnauthorizedAt = useAuthStore((state) => state.lastUnauthorizedAt)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
const socketClientRef = useRef<GameSocketClient | null>(null)
const isReloginRequired =
authStatus === 'anonymous' && Boolean(lastUnauthorizedAt)
useEffect(() => {
if (sharedSocketDisconnectTimerId !== null) {
window.clearTimeout(sharedSocketDisconnectTimerId)
sharedSocketDisconnectTimerId = null
}
if (isReloginRequired) {
sharedSocketClient?.disconnect()
sharedSocketClient = null
sharedSocketKey = null
socketClientRef.current = null
const gameSession = useGameSessionStore.getState()
gameSession.resetRealtimeConnectionRequest()
gameSession.syncConnection({
lastError: null,
latencyMs: null,
reconnectAttempt: 0,
status: 'disconnected',
transport: 'offline',
})
return
}
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, isReloginRequired, shouldConnectRealtime])
useEffect(() => {
if (
isReloginRequired ||
!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, isReloginRequired, shouldConnectRealtime])
}

View File

@@ -1,41 +0,0 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { PHASE_META } from '@/constants'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
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 =
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: dashboard.countdownMs,
limitLabel: `${dashboard.tableLimitMin}-${dashboard.tableLimitMax}`,
oddsLabel: `1:${oddsValue}`,
phase: round.phase,
phaseDescription: t(phaseMeta.descriptionKey),
phaseLabel: t(phaseMeta.labelKey),
phaseToneClassName: phaseMeta.toneClassName,
roundId: round.id || '--',
streakLabel: typeof streakValue === 'number' ? `X${streakValue}` : '--',
streakValue,
}
}, [cells, currentUser, dashboard, round, t, trends])
}

View File

@@ -1,258 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import {
CONNECTION_LATENCY_FAIR_MS,
CONNECTION_LATENCY_GOOD_MS,
CONNECTION_LATENCY_POOR_MS,
} from '@/constants'
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
import {
isDesktopFullscreen,
subscribeDesktopFullscreenChange,
toggleDesktopFullscreen,
} from '@/lib/utils'
import {
useAudioStore,
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 <= CONNECTION_LATENCY_GOOD_MS) {
return {
activeBars: 4,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#74FF69]',
} satisfies SignalPresentation
}
if (input.latencyMs <= CONNECTION_LATENCY_FAIR_MS) {
return {
activeBars: 3,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#B7FF6A]',
} satisfies SignalPresentation
}
if (input.latencyMs <= CONNECTION_LATENCY_POOR_MS) {
return {
activeBars: 2,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#FFD76A]',
} satisfies SignalPresentation
}
return {
activeBars: 1,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#FF8A6A]',
} satisfies SignalPresentation
}
export function useHeaderVm() {
const [isFullscreen, setIsFullscreen] = useState(false)
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 isSoundEnabled = useAudioStore((state) => state.isSoundEnabled)
const toggleSoundEnabled = useAudioStore((state) => state.toggleSoundEnabled)
const connection = useGameSessionStore((state) => state.connection)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const { currentLanguageLabel, currentLanguageOption } = useAppLanguage()
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 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,
)
}
}, [])
return {
authStatus,
currentLanguageLabel,
currentLanguageOption,
currentUser,
handleFullscreenToggle: () => toggleDesktopFullscreen(),
isFullscreen,
isSoundEnabled,
onOpenLanguage: () => setModalOpen('desktopLanguage', true),
onOpenLogin: () => setModalOpen('desktopLogin', true),
onOpenNotice: () => setModalOpen('desktopNotice', true),
onOpenProcedures: () => setModalOpen('desktopProcedures', true),
onOpenRegister: () => setModalOpen('desktopRegister', true),
onOpenRules: () => setModalOpen('desktopRules', true),
onOpenUserInfo: () => setModalOpen('desktopUserInfo', true),
signalPresentation,
toggleSoundEnabled,
}
}
export function useHeaderClockLabel() {
const [clockNow, setClockNow] = useState(() => Date.now())
const lastMessageAt = useGameSessionStore(
(state) => state.connection.lastMessageAt,
)
const connectionStatus = useGameSessionStore(
(state) => state.connection.status,
)
const connectionTransport = useGameSessionStore(
(state) => state.connection.transport,
)
const serverClockOffsetMs = useMemo(() => {
if (
connectionStatus !== 'connected' ||
connectionTransport !== 'websocket' ||
!lastMessageAt
) {
return null
}
const serverTimestamp = Date.parse(lastMessageAt)
if (Number.isNaN(serverTimestamp)) {
return null
}
return serverTimestamp - Date.now()
}, [connectionStatus, connectionTransport, lastMessageAt])
useEffect(() => {
const timer = window.setInterval(() => {
setClockNow(Date.now())
}, 1000)
return () => {
window.clearInterval(timer)
}
}, [])
const activeTimestamp =
serverClockOffsetMs === null ? clockNow : clockNow + serverClockOffsetMs
return formatHeaderTime(new Date(activeTimestamp))
}

View File

@@ -1,73 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import {
type GamePeriodHistoryItemDto,
getGamePeriodHistory,
} from '@/features/game/api/period-history-api'
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
export const DEFAULT_PERIOD_HISTORY_LIMIT = 36
export interface PeriodHistoryDisplayItem {
displayPeriodNo: string
displayResultNumber: string
image: string
isOdd: boolean
openTime: number
periodNo: string
resultNumber: number
}
function formatPeriodNo(periodNo: string) {
const [, timeSegment] = periodNo.split('-')
return timeSegment && timeSegment.length <= 8 ? timeSegment : periodNo
}
function formatResultNumber(number: number) {
return String(number).padStart(2, '0')
}
export function toPeriodHistoryDisplayItem(
item: GamePeriodHistoryItemDto,
): PeriodHistoryDisplayItem {
return {
displayPeriodNo: formatPeriodNo(item.period_no),
displayResultNumber: formatResultNumber(item.result_number),
image: FLOWER_IMAGE_BY_ID[item.result_number]?.animalUrl ?? '',
isOdd: item.result_number % 2 === 1,
openTime: item.open_time,
periodNo: item.period_no,
resultNumber: item.result_number,
}
}
export function usePeriodHistoryVm({
enabled,
limit = DEFAULT_PERIOD_HISTORY_LIMIT,
}: {
enabled: boolean
limit?: number
}) {
const query = useQuery({
queryKey: ['game', 'period-history', limit],
enabled,
queryFn: () => getGamePeriodHistory({ limit }),
refetchOnMount: 'always',
staleTime: 0,
})
const items = useMemo(
() =>
(query.data?.list ?? []).map((item) => toPeriodHistoryDisplayItem(item)),
[query.data?.list],
)
return {
isError: query.isError,
isLoading: query.isLoading,
items,
refetch: query.refetch,
}
}

View File

@@ -1,3 +0,0 @@
export function useTopupVm() {
return {}
}

View File

@@ -1,106 +0,0 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
import { getWalletRecordList } from '@/features/game/api'
const WALLET_RECORD_TYPE = 'payout'
function formatWalletAmount(value: string, locale: string) {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) {
return value || '--'
}
return new Intl.NumberFormat(locale, {
maximumFractionDigits: 4,
}).format(numberValue)
}
function formatWalletRecordTime(value: number | string | null) {
if (value === null || value === '') {
return '--'
}
const numericValue = Number(value)
const timestamp =
Number.isFinite(numericValue) && numericValue > 0
? numericValue < 10_000_000_000
? numericValue * 1000
: numericValue
: value
const formatted = dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')
return formatted === 'Invalid Date' ? String(value) : formatted
}
export function useWalletRecordsVm({ enabled }: { enabled: boolean }) {
const { i18n, t } = useTranslation()
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
const query = useInfiniteQuery({
queryKey: ['finance', 'wallet-record-list', WALLET_RECORD_TYPE],
initialPageParam: 1,
queryFn: ({ pageParam }) =>
getWalletRecordList({
page: pageParam,
pageSize: DEFAULT_LIST_PAGE_SIZE,
type: WALLET_RECORD_TYPE,
}),
enabled,
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 lastPage = query.data?.pages.at(-1)
const total = lastPage?.pagination.total ?? 0
const loadedPage = lastPage?.pagination.page ?? 1
const items = useMemo(
() =>
(query.data?.pages ?? []).flatMap((page) =>
page.list.map((item, index) => ({
amountLabel: formatWalletAmount(item.amount, locale),
balanceAfterLabel: formatWalletAmount(item.balanceAfter, locale),
balanceBeforeLabel: formatWalletAmount(item.balanceBefore, locale),
id: item.id || `${page.pagination.page}-${index}`,
remarkLabel: item.remark || '--',
timeLabel: formatWalletRecordTime(item.createdAt),
typeLabel: item.type || WALLET_RECORD_TYPE,
})),
),
[locale, query.data?.pages],
)
return {
emptyText: t('game.modals.userInfo.walletRecords.empty'),
fetchNextPage: query.fetchNextPage,
hasNextPage: query.hasNextPage,
headers: {
amount: t('game.modals.userInfo.walletRecords.amount'),
balanceAfter: t('game.modals.userInfo.walletRecords.balanceAfter'),
balanceBefore: t('game.modals.userInfo.walletRecords.balanceBefore'),
remark: t('game.modals.userInfo.walletRecords.remark'),
time: t('game.modals.userInfo.walletRecords.time'),
type: t('game.modals.userInfo.walletRecords.type'),
},
isError: query.isError,
isFetchingNextPage: query.isFetchingNextPage,
isLoading: query.isLoading,
items,
loadFailedText: t('game.modals.userInfo.walletRecords.loadFailed'),
loadingText: t('game.modals.userInfo.walletRecords.loading'),
pageLabel: t('game.modals.userInfo.walletRecords.page', {
page: loadedPage,
total,
}),
}
}

View File

@@ -1,48 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
createWithdraw,
type WithdrawCreateRequestDto,
} from '@/features/game/api'
import { notify } from '@/lib/notify'
export function useWithdrawSubmit() {
const { i18n, t } = useTranslation()
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
return useMutation({
mutationFn: (payload: WithdrawCreateRequestDto) => createWithdraw(payload),
onError: (error) => {
notify.error(
error instanceof Error
? error.message
: t('commonUi.toast.requestFailed'),
)
},
onSuccess: (data) => {
const formatter = new Intl.NumberFormat(locale, {
maximumFractionDigits: 2,
})
notify.success(t('gameDesktop.withdraw.submitSuccess'), {
description: [
t('gameDesktop.withdraw.success.orderNo', {
orderNo: data.order_no,
}),
t('gameDesktop.withdraw.success.actualArrivalCoin', {
amount: formatter.format(data.actual_arrival_coin),
}),
t('gameDesktop.withdraw.success.feeCoin', {
amount: formatter.format(data.fee_coin),
}),
t('gameDesktop.withdraw.success.reviewRequired', {
value: data.risk_review_required
? t('commonUi.dialog.yes')
: t('commonUi.dialog.no'),
}),
].join('\n'),
})
},
})
}

View File

@@ -1,313 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
DEFAULT_CURRENCY_CODE,
DEFAULT_WITHDRAW_CONFIG,
QUICK_FIAT_AMOUNTS,
WITHDRAW_EMAIL_PATTERN,
WITHDRAW_PHONE_PATTERN,
} from '@/constants'
import type { DepositWithdrawConfig } from '@/features/game/api'
import { useDepositWithdrawConfig } from '@/features/game/hooks/use-deposit-withdraw-config'
import { useAuthStore } from '@/store'
function formatNumber(locale: string, value: number) {
return new Intl.NumberFormat(locale).format(value)
}
function getInitialWithdrawAmount(
selectedRate: number,
maxWithdrawAmount: number,
) {
if (maxWithdrawAmount <= 0) {
return 0
}
return Math.min(
maxWithdrawAmount,
Math.max(1, Math.round(selectedRate * QUICK_FIAT_AMOUNTS[0])),
)
}
function getActiveCurrencyCode(
currencies: DepositWithdrawConfig['currencies'],
selectedCurrencyCode: string,
) {
return (
currencies.find((item) => item.code === selectedCurrencyCode) ??
currencies[0] ??
DEFAULT_WITHDRAW_CONFIG.currencies[0]
)
}
function getNormalizedConfig(
config: DepositWithdrawConfig | undefined,
fallback: DepositWithdrawConfig,
) {
return config ?? fallback
}
function isValidEmail(value: string) {
if (value.trim().length === 0) {
return false
}
return WITHDRAW_EMAIL_PATTERN.test(value.trim())
}
function isValidPhone(value: string) {
const normalized = value.replace(/[^\d+]/g, '')
if (normalized.length === 0) {
return false
}
return WITHDRAW_PHONE_PATTERN.test(normalized)
}
export function useWithdrawVm() {
const { i18n, t } = useTranslation()
const currentUser = useAuthStore((state) => state.currentUser)
const withdrawConfigQuery = useDepositWithdrawConfig()
const config = useMemo(() => {
const baseConfig = getNormalizedConfig(
withdrawConfigQuery.data,
DEFAULT_WITHDRAW_CONFIG,
)
return {
...baseConfig,
currencies:
baseConfig.currencies.length > 0
? baseConfig.currencies
: DEFAULT_WITHDRAW_CONFIG.currencies,
payChannels: baseConfig.payChannels,
withdraw: {
...baseConfig.withdraw,
banks: baseConfig.withdraw.banks,
},
}
}, [withdrawConfigQuery.data])
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
const [amount, setAmountState] = useState(0)
const [hasInitializedAmount, setHasInitializedAmount] = useState(false)
const [currencyCode, setCurrencyCode] = useState(
config.currencies[0]?.code ?? DEFAULT_CURRENCY_CODE,
)
const [paymentChannelCode, setPaymentChannelCode] = useState('')
const [bankCode, setBankCode] = useState('')
const [holderName, setHolderName] = useState('')
const [bankAccount, setBankAccount] = useState('')
const [receiverEmail, setReceiverEmail] = useState('')
const [receiverPhone, setReceiverPhone] = useState('')
const selectedCurrency = getActiveCurrencyCode(
config.currencies,
currencyCode,
)
const selectedRate = selectedCurrency.withdrawCoinsPerFiatValue || 1
const sortedPayChannels = useMemo(
() =>
[...config.payChannels]
.filter((channel) => channel.status === 1)
.sort((left, right) => left.sort - right.sort),
[config.payChannels],
)
const sortedBanks = useMemo(
() =>
[...config.withdraw.banks]
.filter((bank) => bank.status === 1)
.sort((left, right) => left.sort - right.sort),
[config.withdraw.banks],
)
const availableBalance = Number(currentUser?.coin ?? 0)
const maxWithdrawAmount = Math.max(0, Math.floor(availableBalance))
const selectedPaymentChannel =
sortedPayChannels.find((channel) => channel.code === paymentChannelCode) ??
null
const setAmount = useCallback(
(nextAmount: number) => {
setAmountState(
Math.min(maxWithdrawAmount, Math.max(0, Math.floor(nextAmount))),
)
},
[maxWithdrawAmount],
)
useEffect(() => {
if (
selectedCurrency &&
selectedCurrency.code !== currencyCode &&
config.currencies.some((item) => item.code === currencyCode)
) {
return
}
if (selectedCurrency && selectedCurrency.code !== currencyCode) {
setCurrencyCode(selectedCurrency.code)
}
}, [config.currencies, currencyCode, selectedCurrency])
useEffect(() => {
const firstAvailablePayChannel = sortedPayChannels[0]
if (!firstAvailablePayChannel) {
if (paymentChannelCode) {
setPaymentChannelCode('')
}
return
}
const hasSelectedAvailablePayChannel = sortedPayChannels.some(
(channel) => channel.code === paymentChannelCode,
)
if (!hasSelectedAvailablePayChannel) {
setPaymentChannelCode(firstAvailablePayChannel.code)
}
}, [paymentChannelCode, sortedPayChannels])
useEffect(() => {
if (sortedBanks.length === 0) {
if (bankCode) {
setBankCode('')
}
return
}
const hasSelectedAvailableBank = sortedBanks.some(
(bank) => bank.code === bankCode,
)
if (!hasSelectedAvailableBank) {
setBankCode('')
}
}, [bankCode, sortedBanks])
useEffect(() => {
if (!hasInitializedAmount && selectedRate > 0) {
setAmount(getInitialWithdrawAmount(selectedRate, maxWithdrawAmount))
setHasInitializedAmount(true)
}
}, [hasInitializedAmount, maxWithdrawAmount, selectedRate, setAmount])
useEffect(() => {
if (amount > maxWithdrawAmount) {
setAmount(maxWithdrawAmount)
}
}, [amount, maxWithdrawAmount, setAmount])
const quickAmounts = useMemo(() => {
return QUICK_FIAT_AMOUNTS.map((fiatAmount) => ({
diamonds: Math.min(
maxWithdrawAmount,
Math.max(1, Math.round(selectedRate * fiatAmount)),
),
id: `quick-${selectedCurrency.code}-${fiatAmount}`,
preview: `${selectedCurrency.code} ${formatNumber(locale, fiatAmount)}`,
}))
}, [locale, maxWithdrawAmount, selectedCurrency.code, selectedRate])
const resetForm = useCallback(() => {
const nextCurrencyCode = config.currencies[0]?.code ?? DEFAULT_CURRENCY_CODE
const nextCurrency = getActiveCurrencyCode(
config.currencies,
nextCurrencyCode,
)
const nextRate = nextCurrency.withdrawCoinsPerFiatValue || 1
setAmountState(getInitialWithdrawAmount(nextRate, maxWithdrawAmount))
setHasInitializedAmount(true)
setCurrencyCode(nextCurrencyCode)
setPaymentChannelCode(sortedPayChannels[0]?.code ?? '')
setBankCode('')
setHolderName('')
setBankAccount('')
setReceiverEmail('')
setReceiverPhone('')
}, [config.currencies, maxWithdrawAmount, sortedPayChannels])
const selectedCurrencyPreview = useMemo(
() => ({
currencyCode: selectedCurrency.code,
currencyLabel: selectedCurrency.label,
exchangeRateLabel: t('gameDesktop.withdraw.preview.exchangeRate', {
currency: selectedCurrency.code,
}),
exchangeRateValue: t('gameDesktop.withdraw.preview.exchangeRateValue', {
coins: formatNumber(locale, selectedRate),
currency: selectedCurrency.code,
platformCoinLabel: config.platformCoinLabel,
}),
convertibleLabel: t('gameDesktop.withdraw.preview.convertible', {
currency: selectedCurrency.code,
}),
convertibleValue: `${formatNumber(
locale,
selectedRate > 0 ? amount / selectedRate : 0,
)} ${selectedCurrency.code}`,
}),
[
amount,
config.platformCoinLabel,
locale,
selectedCurrency.code,
selectedCurrency.label,
selectedRate,
t,
],
)
return {
amount,
amountExceedsBalance: amount > maxWithdrawAmount,
amountRequiredError: amount <= 0,
availableBalance,
bankAccount,
bankAccountError: bankAccount.trim().length === 0,
bankCode,
bankCodeError: bankCode.trim().length === 0,
config,
currencyCode,
holderName,
holderNameError: holderName.trim().length === 0,
isLoading: withdrawConfigQuery.isLoading,
isRefetching: withdrawConfigQuery.isFetching,
maxWithdrawAmount,
paymentChannelCode,
paymentChannelCodeError: paymentChannelCode.trim().length === 0,
quickAmounts,
receiverEmail,
receiverEmailError: !isValidEmail(receiverEmail),
receiverPhone,
receiverPhoneError: !isValidPhone(receiverPhone),
selectedCurrency,
selectedCurrencyPreview,
selectedPaymentChannel,
selectedRate,
resetForm,
setAmount,
setBankAccount,
setBankCode,
setCurrencyCode,
setHolderName,
setPaymentChannelCode,
setReceiverEmail,
setReceiverPhone,
sortedBanks,
sortedPayChannels,
withdrawCopy: {
bankLabel: t('gameDesktop.withdraw.bank'),
eWalletLabel: t('gameDesktop.withdraw.eWallet'),
feeNote: config.withdraw.feeNote,
noticeLabel: t('gameDesktop.withdraw.notice'),
processingLabel: t('gameDesktop.withdraw.processingTime'),
processingValue: config.withdraw.processingNote,
rateHint: config.withdraw.rateHint,
},
}
}

View File

@@ -1,2 +0,0 @@
export * from './api'
export * from './shared'

View File

@@ -1,229 +0,0 @@
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 { AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD } from '@/constants'
import { notify } from '@/lib/notify'
import { useModalStore } from '@/store'
import { useAuthStore } from '@/store/auth'
import {
type AutoHostingStopRules,
selectSelectionTotal,
useGameAutoHostingStore,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
function parseAmount(value: string) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}
function parseBalance(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
if (typeof value !== 'string') {
return 0
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function DesktopAutoSettingModal() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopAutoSetting)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const currentUser = useAuthStore((state) => state.currentUser)
const round = useGameRoundStore((state) => state.round)
const selections = useGameRoundStore((state) => state.selections)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const tableLimitMax = useGameSessionStore(
(state) => state.dashboard.tableLimitMax,
)
const startHosting = useGameAutoHostingStore((state) => state.startHosting)
const [balanceLimitEnabled, setBalanceLimitEnabled] = useState(false)
const [balanceLimitValue, setBalanceLimitValue] = useState('0')
const [singleWinLimitEnabled, setSingleWinLimitEnabled] = useState(false)
const [singleWinLimitValue, setSingleWinLimitValue] = useState(
String(AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD),
)
const [jackpotStopEnabled, setJackpotStopEnabled] = useState(false)
function handleClose() {
setModalOpen('desktopAutoSetting', false)
}
function handleSubmit() {
if (round.phase !== 'betting' || !round.id) {
notify.warning(t('commonUi.toast.betUnavailable'))
handleClose()
return
}
if (selections.length === 0) {
notify.warning(t('commonUi.toast.selectNumbersBeforeAutoHosting'))
handleClose()
return
}
const balance = parseBalance(currentUser?.coin)
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
notify.warning(t('commonUi.toast.betLimitExceeded'))
return
}
if (totalBetAmount > balance) {
notify.warning(t('commonUi.toast.insufficientBalance'))
return
}
const rules: AutoHostingStopRules = {
stopIfBalanceBelow: {
amount: parseAmount(balanceLimitValue),
enabled: balanceLimitEnabled,
},
stopIfSingleWinAbove: {
amount: parseAmount(singleWinLimitValue),
enabled: singleWinLimitEnabled,
},
stopOnJackpot: jackpotStopEnabled,
}
startHosting({
balanceAfterBet: balance,
rules,
selections,
})
notify.success(t('commonUi.toast.autoHostingStarted'))
handleClose()
}
return (
<CenterModal
open={open}
onClose={handleClose}
title={
<div className={'modal-title-glow text-design-26 uppercase'}>
{t('game.modals.autoSetting.title')}
</div>
}
isNormalBg={true}
titleAlign="left"
className={'w-design-835 !h-design-500'}
>
<div
className={
'flex h-full w-full flex-col justify-between px-design-18 pt-design-30 pb-design-60'
}
>
<div className={'flex w-full flex-col gap-design-26'}>
<div className={'flex items-center justify-between gap-design-30'}>
<div
className={
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
}
>
{t('game.modals.autoSetting.rows.stopIfBalanceLowerThan')}
</div>
<div
className={
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
}
>
<Input
value={balanceLimitValue}
inputMode="decimal"
onChange={(event) => setBalanceLimitValue(event.target.value)}
className={
'game-setting-input h-full w-design-280 text-design-18'
}
/>
<Switch
size={'sm'}
checked={balanceLimitEnabled}
onCheckedChange={setBalanceLimitEnabled}
/>
</div>
</div>
<div className={'flex items-center justify-between gap-design-30'}>
<div
className={
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
}
>
{t('game.modals.autoSetting.rows.stopIfSingleWinExceeds')}
</div>
<div
className={
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
}
>
<Input
value={singleWinLimitValue}
inputMode="decimal"
onChange={(event) => setSingleWinLimitValue(event.target.value)}
className={
'game-setting-input h-full w-design-280 text-design-18'
}
/>
<Switch
size={'sm'}
checked={singleWinLimitEnabled}
onCheckedChange={setSingleWinLimitEnabled}
/>
</div>
</div>
<div className={'flex items-center justify-between gap-design-30'}>
<div
className={
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
}
>
{t('game.modals.autoSetting.rows.stopOnAnyJackpot')}
</div>
<div className={'flex w-design-410 justify-end pr-design-2'}>
<Switch
size={'sm'}
checked={jackpotStopEnabled}
onCheckedChange={setJackpotStopEnabled}
/>
</div>
</div>
</div>
<div className={'flex w-full justify-center'}>
<SmartBackground
as="button"
src={lengthBlueBtn}
size="100% 100%"
repeat="no-repeat"
position="center"
type="button"
onClick={handleSubmit}
className={
'w-design-300 h-design-72 pb-design-4 flex cursor-pointer items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF] transition-transform hover:-translate-y-[1px] active:translate-y-0'
}
>
{t('game.modals.autoSetting.startAutoSpin')}
</SmartBackground>
</div>
</div>
</CenterModal>
)
}
export default DesktopAutoSettingModal

View File

@@ -1,178 +0,0 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import { motion } from 'motion/react'
import { useEffect, useRef } from 'react'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { useFinanceRecordsVm } from '@/features/game/hooks/use-finance-records-vm'
import { cn } from '@/lib/utils'
function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
const vm = useFinanceRecordsVm({ enabled })
const parentRef = useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: vm.items.length + (vm.hasNextPage ? 1 : 0),
estimateSize: () => 72,
getScrollElement: () => parentRef.current,
overscan: 6,
})
const virtualItems = rowVirtualizer.getVirtualItems()
useEffect(() => {
const lastItem = virtualItems.at(-1)
if (
!lastItem ||
lastItem.index < vm.items.length - 1 ||
!vm.hasNextPage ||
vm.isFetchingNextPage
) {
return
}
void vm.fetchNextPage()
}, [
virtualItems,
vm.fetchNextPage,
vm.hasNextPage,
vm.isFetchingNextPage,
vm.items.length,
])
return (
<div className={'flex h-full w-full flex-col p-design-10'}>
<div
className={
'mb-design-12 flex items-center justify-between gap-design-16 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
}
>
<div
className={
'relative grid grid-cols-2 overflow-hidden rounded-md border border-[#3EAFC7]/30 bg-[#031B24]/75 p-design-4'
}
>
{vm.recordTypes.map((recordType) => {
const isActive = recordType.key === vm.recordType
return (
<button
key={recordType.key}
type="button"
aria-pressed={isActive}
onClick={() => {
vm.selectRecordType(recordType.key)
rowVirtualizer.scrollToOffset(0)
}}
className={cn(
'relative h-design-44 min-w-design-130 cursor-pointer rounded-md px-design-16 text-design-18 transition-colors duration-200',
isActive
? 'text-white'
: 'text-[#6CCDCF] hover:bg-[#0A4252] hover:text-white',
)}
>
{isActive ? (
<motion.span
layoutId="finance-record-type-active"
className={
'absolute inset-0 rounded-md bg-[linear-gradient(180deg,#3DA5BD,#166477)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(62,175,199,0.26)]'
}
transition={{
type: 'spring',
stiffness: 420,
damping: 34,
}}
/>
) : null}
<span className={'relative z-10'}>{recordType.label}</span>
</button>
)
})}
</div>
<div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div>
</div>
<div className={'min-h-0 flex-1 rounded-md'}>
<div
className={
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] gap-design-10 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-16 py-design-12 text-design-16 text-[#7ECAD1]'
}
>
<div>{vm.headers.orderNo}</div>
<div>{vm.headers.amount}</div>
<div>{vm.headers.bonusAmount}</div>
</div>
<div
ref={parentRef}
className={
'mt-design-10 max-h-[calc(var(--design-unit)*320)] min-h-0 overflow-auto pr-design-4'
}
>
{vm.isLoading ? (
<DataLoadingIndicator label={vm.loadingText} />
) : vm.isError ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.loadFailedText}
</div>
) : vm.items.length === 0 ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.emptyText}
</div>
) : (
<div
className={'relative w-full'}
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{virtualItems.map((virtualRow) => {
const item = vm.items[virtualRow.index]
return (
<div
key={virtualRow.key}
className={'absolute left-0 top-0 w-full pb-design-10'}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{item ? (
<motion.div
className={
'grid h-[calc(var(--design-unit)*62)] grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-18 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.16,
ease: 'easeOut',
}}
>
<div className={'truncate font-medium text-white'}>
{item.orderNoLabel}
</div>
<div className={'truncate text-[#FEEEB0]'}>
{item.amountLabel}
</div>
<div className={'truncate text-[#7CFFCF]'}>
{item.bonusAmountLabel}
</div>
</motion.div>
) : (
<DataLoadingIndicator
compact
label={vm.loadingText}
className="h-[calc(var(--design-unit)*62)] rounded-md bg-[#0A4252]/60"
/>
)}
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}
export default DesktopFinanceRecordsTab

View File

@@ -1,101 +0,0 @@
import { useTranslation } from 'react-i18next'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'
function DesktopLanguageModal() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopLanguage)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const { currentLanguage, languageOptions, selectLanguage } = useAppLanguage()
const handleClose = () => {
setModalOpen('desktopLanguage', false)
}
const handleSelectLanguage = async (
language: (typeof languageOptions)[number]['code'],
) => {
await selectLanguage(language)
handleClose()
}
return (
<CenterModal
open={open}
onClose={handleClose}
title={
<div className={'modal-title-glow text-design-30'}>
{t('language.label')}
</div>
}
isNormalBg={true}
titleAlign="left"
className="h-design-560 w-design-620"
>
<div className="flex h-full flex-col px-design-24 pb-design-28 pt-design-10">
<div className="grid flex-1 grid-cols-2 gap-design-16">
{languageOptions.map((option: (typeof languageOptions)[number]) => {
const isActive = option.code === currentLanguage
return (
<button
key={option.code}
type="button"
onClick={() => void handleSelectLanguage(option.code)}
className={cn(
'group relative flex h-full min-h-design-150 w-full flex-col justify-between overflow-hidden rounded-[18px] border px-design-18 py-design-18 text-left transition-all duration-200',
isActive
? 'border-[#8BF5FF] bg-[linear-gradient(180deg,rgba(22,64,80,0.94),rgba(7,21,31,0.96))] shadow-[inset_0_0_18px_rgba(128,223,231,0.55),0_0_22px_rgba(66,227,255,0.2)]'
: 'border-[#62BFC8]/45 bg-[linear-gradient(180deg,rgba(10,30,43,0.92),rgba(4,13,21,0.94))] shadow-[inset_0_0_14px_rgba(128,223,231,0.18)] hover:border-[#86EFFF]/80 hover:shadow-[inset_0_0_18px_rgba(128,223,231,0.3),0_0_18px_rgba(66,227,255,0.12)]',
)}
>
<div
className={cn(
'pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-200',
isActive
? 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.22),transparent_42%)] opacity-100'
: 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.14),transparent_42%)] group-hover:opacity-100',
)}
/>
<div className="relative flex items-start justify-between gap-design-12">
<SmartImage
src={option.icon}
alt={t(option.labelKey)}
className="h-design-32 w-design-32 shrink-0 rounded-[10px] object-cover shadow-[0_8px_18px_rgba(0,0,0,0.28)]"
/>
{isActive ? (
<div className="rounded-full border border-[#8BF5FF]/55 bg-[#8BF5FF]/18 px-design-12 py-design-6 text-design-14 font-semibold uppercase tracking-[0.14em] text-[#C9FCFF] shadow-[0_0_14px_rgba(66,227,255,0.18)]">
{t('gameDesktop.control.selected')}
</div>
) : null}
</div>
<div className="relative mt-design-18">
<div className="text-design-24 font-semibold text-[#F3FFFF]">
{t(option.labelKey)}
</div>
<div className="mt-design-8 text-design-15 uppercase tracking-[0.2em] text-[#7EDAE3]">
{option.code}
</div>
</div>
<div className="relative mt-design-16 h-px w-full bg-[linear-gradient(90deg,rgba(128,223,231,0),rgba(128,223,231,0.65),rgba(128,223,231,0))]" />
<div className="relative mt-design-12 flex items-center justify-between text-design-15 text-[#98D6DC]">
<span>{t('language.label')}</span>
<span className="text-[#D8FDFF]">{option.code}</span>
</div>
</button>
)
})}
</div>
</div>
</CenterModal>
)
}
export default DesktopLanguageModal

View File

@@ -1,31 +0,0 @@
import { useTranslation } from 'react-i18next'
import { CenterModal } from '@/components/center-modal.tsx'
import { DesktopLoginForm } from '@/features/auth/components/desktop-login-form'
import { useModalStore } from '@/store'
function DesktopLoginModal() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopLogin)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setModalOpen('desktopLogin', false)
}
return (
<CenterModal
open={open}
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'}
backdropClassName="backdrop-blur-none"
>
<DesktopLoginForm onSuccess={handleSubmit} />
</CenterModal>
)
}
export default DesktopLoginModal

View File

@@ -1,240 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { ArrowLeft } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import blueBtnBg from '@/assets/system/blue-btn.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { getNoticeDetail, getNoticeList } from '@/features/game/api'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'
type NoticeViewState = 'detail' | 'list'
function DesktopNoticeModal() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopNotice)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const [noticeView, setNoticeView] = useState<NoticeViewState>('list')
const [selectedNoticeId, setSelectedNoticeId] = useState<number | null>(null)
const noticeListQuery = useQuery({
queryKey: ['game', 'notice-list'],
queryFn: () => getNoticeList(),
enabled: open && noticeView === 'list',
})
const noticeDetailQuery = useQuery({
queryKey: ['game', 'notice-detail', selectedNoticeId],
queryFn: () => getNoticeDetail(selectedNoticeId ?? 0),
enabled: open && noticeView === 'detail' && selectedNoticeId !== null,
})
const noticeItems = useMemo(
() => noticeListQuery.data?.list ?? [],
[noticeListQuery.data],
)
async function handleReturnToList() {
setNoticeView('list')
setSelectedNoticeId(null)
await noticeListQuery.refetch()
}
useEffect(() => {
if (!open) {
setNoticeView('list')
setSelectedNoticeId(null)
}
}, [open])
function handleSubmit() {
setModalOpen('desktopNotice', false)
}
return (
<CenterModal
open={open}
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26'}>
{t('game.modals.userInfo.message.title')}
</div>
}
isNormalBg={true}
titleAlign="left"
className={'w-design-980 h-design-690'}
>
<div className={'flex h-full w-full flex-col'}>
{noticeView === 'detail' ? (
<div
className={
'mb-design-12 flex items-center mx-design-10 my-design-10 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
}
>
<button
type="button"
onClick={() => {
void handleReturnToList()
}}
className={
'flex cursor-pointer items-center gap-design-10 text-[#86DAE7] transition hover:text-white'
}
>
<span
className={
'flex h-design-40 w-design-40 items-center justify-center rounded-full border border-[#4AC6DE]/45 bg-[#0B4454]'
}
>
<ArrowLeft className={'h-design-22 w-design-22'} />
</span>
<span className={'text-design-20 font-medium tracking-wide'}>
{t('game.modals.userInfo.message.back')}
</span>
</button>
</div>
) : null}
<div className={'h-full w-full overflow-auto rounded-md'}>
{noticeView === 'list' ? (
<div
className={
'flex h-full w-full flex-col gap-design-10 p-design-10'
}
>
{noticeListQuery.isLoading ? (
<DataLoadingIndicator
label={t('game.modals.userInfo.message.loading')}
/>
) : noticeListQuery.isError ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{t('game.modals.userInfo.message.loadFailed')}
</div>
) : noticeItems.length === 0 ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{t('game.modals.userInfo.message.empty')}
</div>
) : (
noticeItems.map((item) => (
<button
key={item.notice_id}
type="button"
onClick={() => {
setSelectedNoticeId(item.notice_id)
setNoticeView('detail')
}}
className={
'flex cursor-pointer items-center gap-design-20 rounded-md bg-[#0A4252] px-design-15 py-design-15 text-left transition hover:bg-[#0E576D]'
}
>
<div
className={cn(
'relative flex h-design-95 w-design-95 items-center justify-center rounded-md text-design-18 font-bold',
item.notice_type === 'popout'
? 'bg-[#203C49] text-[#FEEEB0]'
: 'bg-[#111111] text-[#6CCDCF]',
)}
>
<span
className={cn(
'absolute -right-design-8 top-design-8 z-10 min-w-design-50 -rotate-[8deg] rounded-[calc(var(--design-unit)*4)] border px-design-6 py-design-4 text-center text-design-11 font-semibold leading-none shadow-[0_0_calc(var(--design-unit)*8)_rgba(0,0,0,0.2)]',
item.is_read
? 'border-[#2D7384] bg-[linear-gradient(180deg,#20596A,#153A47)] text-[#B4E9F0]'
: 'border-[#9B6427] bg-[linear-gradient(180deg,#8A5320,#5E3616)] text-[#FFF0A8]',
)}
>
{item.is_read
? t('game.modals.userInfo.message.read')
: t('game.modals.userInfo.message.unread')}
</span>
{item.notice_type.toUpperCase()}
</div>
<div className={'min-w-0 flex-1'}>
<div className={'text-design-18 text-[#BFEAEC]'}>
{dayjs(item.publish_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)}
</div>
<div
className={
'mt-design-4 flex items-center gap-design-12'
}
>
<div className={'truncate text-design-20 text-white'}>
{item.title}
</div>
</div>
</div>
<SmartBackground
src={blueBtnBg}
size="100% 100%"
className={
'flex h-design-64 w-design-150 items-center justify-center text-design-20 font-bold'
}
>
{t('game.modals.userInfo.message.check')}
</SmartBackground>
</button>
))
)}
</div>
) : (
<div
className={
'flex h-full w-full flex-col gap-design-16 p-design-10'
}
>
{noticeDetailQuery.isLoading ? (
<DataLoadingIndicator
label={t('game.modals.userInfo.message.loading')}
/>
) : noticeDetailQuery.isError ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{t('game.modals.userInfo.message.loadFailed')}
</div>
) : noticeDetailQuery.data ? (
<div
className={
'rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-24 shadow-[0_0_24px_rgba(14,108,132,0.16)]'
}
>
<div
className={
'mb-design-14 inline-flex rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-14 py-design-6 text-design-16 text-[#9CE8F2]'
}
>
{dayjs(noticeDetailQuery.data.publish_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)}
</div>
<div
className={
'text-design-28 font-semibold leading-tight text-white'
}
>
{noticeDetailQuery.data.title}
</div>
<div
className={
'mt-design-18 whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]'
}
>
{noticeDetailQuery.data.content}
</div>
</div>
) : (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{t('game.modals.userInfo.message.empty')}
</div>
)}
</div>
)}
</div>
</div>
</CenterModal>
)
}
export default DesktopNoticeModal

View File

@@ -1,181 +0,0 @@
import { X } from 'lucide-react'
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PeriodHistoryList } from '@/features/game/components/shared/period-history-list'
import {
DEFAULT_PERIOD_HISTORY_LIMIT,
type PeriodHistoryDisplayItem,
usePeriodHistoryVm,
} from '@/features/game/hooks/use-period-history-vm'
import { useModalStore } from '@/store'
const OVERLAY_EASE = [0.16, 1, 0.3, 1] as const
const DRAWER_TRANSITION = {
type: 'tween',
duration: 0.34,
ease: OVERLAY_EASE,
} as const
interface PeriodHistoryDrawerLabels {
close: string
empty: string
failed: string
loading: string
retry: string
title: string
}
interface DesktopPeriodHistoryDrawerViewProps {
isError: boolean
isLoading: boolean
items: PeriodHistoryDisplayItem[]
labels: PeriodHistoryDrawerLabels
onClose: () => void
onRetry: () => void
open: boolean
}
export function DesktopPeriodHistoryDrawer() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopPeriodHistory)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const vm = usePeriodHistoryVm({
enabled: open,
limit: DEFAULT_PERIOD_HISTORY_LIMIT,
})
const handleClose = () => {
setModalOpen('desktopPeriodHistory', false)
}
return (
<DesktopPeriodHistoryDrawerView
open={open}
items={vm.items}
isLoading={vm.isLoading}
isError={vm.isError}
labels={{
close: t('gameDesktop.periodHistory.close'),
empty: t('gameDesktop.periodHistory.empty'),
failed: t('gameDesktop.periodHistory.failed'),
loading: t('gameDesktop.periodHistory.loading'),
retry: t('gameDesktop.periodHistory.retry'),
title: t('gameDesktop.periodHistory.title'),
}}
onClose={handleClose}
onRetry={() => void vm.refetch()}
/>
)
}
export function DesktopPeriodHistoryDrawerView({
isError,
isLoading,
items,
labels,
onClose,
onRetry,
open,
}: DesktopPeriodHistoryDrawerViewProps) {
const prefersReducedMotion = useReducedMotion()
const [isDrawerAnimating, setIsDrawerAnimating] = useState(false)
return (
<AnimatePresence>
{open && (
<>
<motion.button
type="button"
aria-label={labels.close}
className="fixed left-0 right-0 top-0 bottom-[calc(var(--design-unit)*150)] z-30 cursor-default bg-black/48"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: prefersReducedMotion ? 0.12 : 0.26,
ease: OVERLAY_EASE,
}}
onClick={onClose}
/>
<motion.aside
role="dialog"
aria-modal="true"
aria-label={labels.title}
className="fixed left-0 top-design-16 bottom-[calc(var(--design-unit)*150)] z-40 flex w-design-1120 max-w-[calc(100vw-var(--design-unit)*24)] origin-left flex-col overflow-hidden rounded-r-[calc(var(--design-unit)*10)] border border-[rgba(81,230,255,0.62)] bg-[linear-gradient(180deg,rgba(6,19,32,0.98),rgba(3,12,22,0.96))] text-[#D5FBFF] shadow-[0_0_calc(var(--design-unit)*18)_rgba(39,216,255,0.28),0_0_calc(var(--design-unit)*54)_rgba(39,216,255,0.16),inset_0_0_calc(var(--design-unit)*18)_rgba(74,224,255,0.16)]"
initial={
prefersReducedMotion
? { opacity: 0 }
: { x: '-100%', opacity: 0.98 }
}
animate={
prefersReducedMotion ? { opacity: 1 } : { x: 0, opacity: 1 }
}
exit={
prefersReducedMotion
? { opacity: 0 }
: { x: '-100%', opacity: 0.98 }
}
transition={
prefersReducedMotion ? { duration: 0.12 } : DRAWER_TRANSITION
}
onAnimationStart={() => setIsDrawerAnimating(true)}
onAnimationComplete={() => setIsDrawerAnimating(false)}
style={
isDrawerAnimating
? { willChange: 'transform, opacity' }
: undefined
}
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-design-8 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(80,241,255,0.96),transparent)]"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-0 left-0 h-design-28 w-design-28 border-b-2 border-l-2 border-[#28E6FF]"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-0 right-0 h-design-28 w-design-28 border-b-2 border-r-2 border-[#28E6FF]"
/>
<div className="relative flex h-design-78 shrink-0 items-center justify-between border-b border-[rgba(80,224,255,0.38)] px-design-42">
<h2 className="text-design-28 font-bold leading-none text-white [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(156,244,255,0.42)]">
{labels.title}
</h2>
<button
type="button"
aria-label={labels.close}
className="flex h-design-42 w-design-42 cursor-pointer items-center justify-center text-[#C8F7FF] transition-colors duration-200 hover:text-white focus-visible:ring-2 focus-visible:ring-[#4FEAFF]"
onClick={onClose}
>
<X size={32} strokeWidth={2.1} />
</button>
</div>
<motion.div
className="history-scroll-hidden min-h-0 flex-1 overflow-y-auto px-design-34 py-design-26"
initial={
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }
}
animate={
prefersReducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
}
transition={
prefersReducedMotion
? { duration: 0.12 }
: { duration: 0.22, delay: 0.08, ease: OVERLAY_EASE }
}
>
<PeriodHistoryList
items={items}
isLoading={isLoading}
isError={isError}
labels={labels}
onRetry={onRetry}
/>
</motion.div>
</motion.aside>
</>
)}
</AnimatePresence>
)
}

View File

@@ -1,90 +0,0 @@
import { useTranslation } from 'react-i18next'
import diamond from '@/assets/system/diamond.webp'
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 { SmartImage } from '@/components/smart-image.tsx'
import { useAuthStore, useModalStore } from '@/store'
function DesktopProceduresModal() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopProcedures)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const setWithdrawTopupType = useModalStore(
(state) => state.setWithdrawTopupType,
)
const currentUser = useAuthStore((state) => state.currentUser)
function handleSubmit() {
setModalOpen('desktopProcedures', false)
}
function handleOpenWithdrawTopup(type: 'withdraw' | 'topup') {
setModalOpen('desktopProcedures', false)
setWithdrawTopupType(type)
setModalOpen('desktopWithdrawTopup', true)
}
return (
<CenterModal
open={open}
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26 uppercase'}>
{t('game.modals.procedures.title')}
</div>
}
isNormalBg={true}
titleAlign="left"
className={'w-design-1000 h-design-610'}
>
<SmartBackground
src={proceduresBg}
repeat="no-repeat"
size="cover"
className={
'h-[95%] w-full rounded-md flex flex-col items-center justify-between'
}
>
<div
className={
'mt-design-170 ml-design-120 flex items-center gap-design-50'
}
>
<SmartImage className={'w-design-80'} alt={'diamond'} src={diamond} />
<div
className={
'modal-title-gold-glow text-[#F7DC7A] text-design-32 font-bold tracking-[0.08em]'
}
>
{currentUser?.coin || 0}
</div>
</div>
<div className={'flex items-center ml-design-180'}>
<SmartBackground
src={withdrawBtnBg}
onClick={() => handleOpenWithdrawTopup('withdraw')}
className={
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-10 text-design-32 font-bold transition-[transform,filter] duration-150 hover:scale-[1.02] hover:brightness-110 active:translate-y-[calc(var(--design-unit)*2)] active:scale-[0.97] active:brightness-95'
}
>
{t('game.modals.procedures.withdraw')}
</SmartBackground>
<SmartBackground
src={topupBtnBg}
onClick={() => handleOpenWithdrawTopup('topup')}
className={
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-20 text-design-32 font-bold transition-[transform,filter] duration-150 hover:scale-[1.02] hover:brightness-110 active:translate-y-[calc(var(--design-unit)*2)] active:scale-[0.97] active:brightness-95'
}
>
{t('game.modals.procedures.topup')}
</SmartBackground>
</div>
</SmartBackground>
</CenterModal>
)
}
export default DesktopProceduresModal

View File

@@ -1,33 +0,0 @@
import { useTranslation } from 'react-i18next'
import { CenterModal } from '@/components/center-modal.tsx'
import { DesktopRegisterForm } from '@/features/auth/components/desktop-register-form'
import { useModalStore } from '@/store'
function DesktopRegisterModal() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopRegister)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setModalOpen('desktopRegister', false)
}
return (
<CenterModal
open={open}
onClose={() => setModalOpen('desktopRegister', false)}
title={
<div className={'modal-title-glow'}>
{t('game.modals.register.title')}
</div>
}
titleAlign="center"
className={'w-design-980 h-design-840'}
backdropClassName="backdrop-blur-none"
>
<DesktopRegisterForm onSuccess={handleSubmit} />
</CenterModal>
)
}
export default DesktopRegisterModal

View File

@@ -1,53 +0,0 @@
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 { useModalStore } from '@/store'
function DesktopRulesModal() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopRules)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const handleClose = () => {
setModalOpen('desktopRules', false)
}
return (
<CenterModal
open={open}
isNormalBg={true}
onClose={handleClose}
title={
<div className={'modal-title-glow text-design-28 '}>
{t('game.modals.rules.title')}
</div>
}
titleAlign="left"
className={'w-design-1040 h-design-720'}
>
<div className="flex h-full flex-col gap-design-24 px-design-28 pb-design-30 pt-design-10">
<div className="flex-1 overflow-y-auto rounded-[12px] bg-black/35 p-design-20 text-design-18 leading-[1.8] text-[#B9E7EA] whitespace-pre-line">
{t('game.modals.rules.content')}
</div>
<div className="flex justify-center">
<SmartBackground
as="button"
type="button"
src={lengthBlueBtn}
size="100% 90%"
repeat="no-repeat"
position="center"
onClick={handleClose}
className="modal-title-glow flex h-design-85 w-design-270 items-center justify-center pb-design-5 text-design-20 font-bold"
>
{t('game.modals.rules.confirm')}
</SmartBackground>
</div>
</div>
</CenterModal>
)
}
export default DesktopRulesModal

View File

@@ -1,75 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { CenterModal } from '@/components/center-modal.tsx'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { useModalStore } from '@/store'
const SUPPORT_CHAT_URL =
'https://tawk.to/chat/6a1d23d9e29f411c2ce86772/1jq0t82lu'
const IFRAME_READY_DELAY_MS = 2_000
function DesktopSupportModal() {
const [isLoading, setIsLoading] = useState(true)
const readyTimerRef = useRef<number | null>(null)
const open = useModalStore((state) => state.modals.desktopSupport)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const clearReadyTimer = useCallback(() => {
if (readyTimerRef.current === null) {
return
}
window.clearTimeout(readyTimerRef.current)
readyTimerRef.current = null
}, [])
const handleClose = () => {
setModalOpen('desktopSupport', false)
}
const handleLoaded = () => {
clearReadyTimer()
readyTimerRef.current = window.setTimeout(() => {
setIsLoading(false)
readyTimerRef.current = null
}, IFRAME_READY_DELAY_MS)
}
useEffect(() => {
if (open) {
clearReadyTimer()
setIsLoading(true)
}
return clearReadyTimer
}, [clearReadyTimer, open])
return (
<CenterModal
open={open}
isNormalBg={true}
onClose={handleClose}
titleAlign="left"
title={<div className="modal-title-glow text-design-30">线</div>}
className="h-design-760 w-design-980"
>
<div className="h-full px-design-24 pb-design-40 pt-design-10">
<div className="relative h-full overflow-hidden rounded-[calc(var(--design-unit)*14)] border border-[#2A6D73] bg-[linear-gradient(180deg,rgba(5,22,31,0.98),rgba(2,10,17,0.98))] shadow-[inset_0_0_calc(var(--design-unit)*22)_rgba(88,205,218,0.13),0_0_calc(var(--design-unit)*18)_rgba(31,156,174,0.14)]">
{isLoading ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
<DataLoadingIndicator label="客服连线中" />
</div>
) : null}
<iframe
title="customer-service-chat"
src={SUPPORT_CHAT_URL}
onLoad={handleLoaded}
className="h-full w-full bg-[linear-gradient(180deg,#061923,#020A11)]"
allow="microphone; camera; clipboard-read; clipboard-write"
/>
</div>
</div>
</CenterModal>
)
}
export default DesktopSupportModal

View File

@@ -1,337 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import dayjs from 'dayjs'
import {
CircleUserRound,
ClipboardList,
LogOut,
ReceiptText,
WalletCards,
} from 'lucide-react'
import { motion } from 'motion/react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp'
import userInfoBg from '@/assets/system/userInfo-bg.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { REGISTER_INVITE_CODE_QUERY_PARAM } from '@/constants'
import { logoutWithPassword } from '@/features/auth/api/auth-api'
import DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab'
import DesktopWalletRecordsTab from '@/features/game/modal/desktop/desktop-wallet-records-tab'
import { clearAuthenticatedSession } from '@/lib/auth/auth-session'
import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils'
import { useAuthStore, useModalStore } from '@/store'
type UserInfoTabKey = 'financeRecords' | 'profile' | 'walletRecords'
const USER_INFO_TABS: Array<{
key: UserInfoTabKey
labelKey: string
icon: typeof CircleUserRound
}> = [
{
key: 'profile',
labelKey: 'game.modals.userInfo.tabs.profile',
icon: CircleUserRound,
},
{
key: 'financeRecords',
labelKey: 'game.modals.userInfo.tabs.financeRecords',
icon: ReceiptText,
},
{
key: 'walletRecords',
labelKey: 'game.modals.userInfo.tabs.walletRecords',
icon: WalletCards,
},
]
function createRegisterInviteUrl(inviteCode: string) {
const url = new URL(window.location.href)
url.searchParams.set(REGISTER_INVITE_CODE_QUERY_PARAM, inviteCode)
return url.toString()
}
async function copyTextToClipboard(text: string) {
if (navigator.clipboard?.writeText && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
const copied = document.execCommand('copy')
if (!copied) {
throw new Error('Copy command failed')
}
} finally {
document.body.removeChild(textarea)
}
}
function DesktopUserInfoModal() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopUserInfo)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
const currentUser = useAuthStore((state) => state.currentUser)
const inviteCode = currentUser?.registerInviteCode?.trim() ?? ''
const logoutUsername =
currentUser?.username ?? currentUser?.phone ?? currentUser?.name ?? ''
const logoutMutation = useMutation({
mutationFn: logoutWithPassword,
})
useEffect(() => {
if (!open) {
setActiveTab('profile')
}
}, [open])
function handleSubmit() {
setModalOpen('desktopUserInfo', false)
}
async function handleCopyInviteLink() {
if (!inviteCode) {
return
}
try {
await copyTextToClipboard(createRegisterInviteUrl(inviteCode))
notify.success(t('commonUi.toast.inviteLinkCopied'))
} catch {
notify.error(t('commonUi.toast.inviteLinkCopyFailed'))
}
}
async function handleLogout() {
if (logoutMutation.isPending) {
return
}
try {
await logoutMutation.mutateAsync({
password: '',
username: logoutUsername,
})
notify.success(t('commonUi.toast.logoutSuccess'))
} catch {
notify.warning(t('commonUi.toast.logoutLocalOnly'))
} finally {
clearAuthenticatedSession({ clearBrowserStorage: true })
setModalOpen('desktopUserInfo', false)
}
}
return (
<CenterModal
open={open}
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26'}>
{t('game.modals.userInfo.title')}
</div>
}
isNormalBg={true}
titleAlign="left"
className={'w-design-980 h-design-590'}
>
<div className={'relative flex h-[96%] w-full'}>
<div className={'relative w-design-230 shrink-0'}>
<div
aria-hidden="true"
className="absolute right-0 top-0 h-full w-[calc(var(--design-unit)*2)] bg-[linear-gradient(180deg,rgba(68,244,255,0)_0%,rgba(68,244,255,0.55)_12%,rgba(130,255,255,0.95)_50%,rgba(68,244,255,0.55)_88%,rgba(68,244,255,0)_100%)] shadow-[0_0_calc(var(--design-unit)*8)_rgba(49,208,255,0.45)]"
/>
{USER_INFO_TABS.map((tab) => {
const Icon = tab.icon
const isActive = tab.key === activeTab
return (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={cn(
'relative flex h-design-150 w-full cursor-pointer flex-col items-center justify-center gap-design-8 overflow-hidden px-design-10 transition-colors duration-200',
isActive
? 'text-[#FEEEB0]'
: 'text-[#58ADAF] hover:text-[#BFEAEC]',
)}
>
{isActive ? (
<motion.span
layoutId="user-info-tab-active-bg"
aria-hidden="true"
className="absolute inset-y-0 right-0 w-full bg-[linear-gradient(to_left,rgba(254,238,176,0.46)_0%,rgba(254,238,176,0.28)_42%,rgba(254,238,176,0.12)_68%,rgba(254,238,176,0)_100%)]"
transition={{
type: 'spring',
stiffness: 430,
damping: 36,
}}
/>
) : null}
{isActive ? (
<motion.span
layoutId="user-info-tab-active-indicator"
aria-hidden="true"
className="absolute right-0 top-1/2 h-[72%] w-[calc(var(--design-unit)*3)] -translate-y-1/2 rounded-l-full bg-[linear-gradient(180deg,rgba(255,248,214,0.96)_0%,rgba(254,238,176,0.92)_48%,rgba(232,188,112,0.88)_100%)] shadow-[-2px_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.36)]"
transition={{
type: 'spring',
stiffness: 430,
damping: 36,
}}
/>
) : null}
<motion.div
className={cn(
'relative z-10 transition',
isActive &&
'drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.5)]',
)}
animate={{
scale: isActive ? 1.06 : 1,
y: isActive ? -2 : 0,
}}
transition={{ duration: 0.18, ease: 'easeOut' }}
>
<Icon className={'h-design-40 w-design-40'} />
</motion.div>
<motion.div
className={cn(
'relative z-10 text-center text-design-20 leading-tight',
isActive && 'modal-title-gold-glow',
)}
animate={{
scale: isActive ? 1.04 : 1,
y: isActive ? -1 : 0,
}}
transition={{ duration: 0.18, ease: 'easeOut' }}
>
{t(tab.labelKey)}
</motion.div>
</button>
)
})}
</div>
<div className={'flex-1'}>
{activeTab === 'profile' ? (
<SmartBackground
src={userInfoBg}
size="120% 100%"
className={
'flex flex-col h-full w-full items-start justify-between bg-top bg-no-repeat px-design-40 py-design-32 text-[#6CCDCF] text-design-24 gap-design-80'
}
>
<div className={'flex items-center gap-design-30'}>
<SmartImage
className={'h-design-100 w-design-100'}
src={currentUser?.headImage || avatar}
alt={'avatar'}
/>
<div className={'flex flex-col gap-design-30 text-[#6CCDCF]'}>
<div>
{t('game.modals.userInfo.profile.name')}
{currentUser?.name ?? '--'}
</div>
<div>
{t('game.modals.userInfo.profile.tel')} {' '}
{currentUser?.phone ?? '--'}
</div>
</div>
</div>
<div
className={
'w-design-600 flex-1 text-design-18 rounded-md bg-[#000000]/40 flex flex-col gap-design-20 p-design-20'
}
>
<div className={'text-[#6CCDCF]'}>
{t('game.modals.userInfo.profile.registeredAt')}:
<span
className={'text-design-18 text-[#599AA3] ml-design-10'}
>
{currentUser?.createTime
? dayjs
.unix(currentUser.createTime)
.format('YYYY-MM-DD HH:mm:ss')
: '--'}
</span>
</div>
<div className={'flex items-center text-[#6CCDCF]'}>
<span>{t('auth.register.fields.inviteCode.label')}</span>
<span
className={'text-design-18 text-[#599AA3] ml-design-10'}
>
{inviteCode || '--'}
</span>
<button
type="button"
onClick={() => {
void handleCopyInviteLink()
}}
disabled={!inviteCode}
aria-label={t(
'game.modals.userInfo.profile.copyInviteLink',
)}
title={t('game.modals.userInfo.profile.copyInviteLink')}
className="ml-design-10 flex h-design-30 w-design-30 cursor-pointer items-center justify-center rounded-md border border-[#356E76] bg-[#0B2F35]/70 text-[#6CCDCF] transition-colors duration-200 hover:border-[#6CCDCF] hover:text-[#D9FFFF] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#6CCDCF] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45"
>
<ClipboardList className="h-design-18 w-design-18" />
</button>
</div>
</div>
<div className={'w-full flex justify-end'}>
<button
type="button"
onClick={() => {
void handleLogout()
}}
disabled={logoutMutation.isPending}
className="mt-auto inline-flex h-design-44 min-w-design-170 cursor-pointer items-center justify-center gap-design-10 rounded-md border border-[#8F4747] bg-[#3A1111]/80 px-design-18 text-design-18 text-[#FFD7D7] transition-colors duration-200 hover:border-[#FF8A8A] hover:bg-[#5A1818]/85 hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#FF8A8A] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60"
>
<LogOut className="h-design-20 w-design-20" />
<span>
{logoutMutation.isPending
? t('game.modals.userInfo.profile.loggingOut')
: t('game.modals.userInfo.profile.logout')}
</span>
</button>
</div>
</SmartBackground>
) : activeTab === 'financeRecords' ? (
<DesktopFinanceRecordsTab
enabled={open && activeTab === 'financeRecords'}
/>
) : (
<DesktopWalletRecordsTab
enabled={open && activeTab === 'walletRecords'}
/>
)}
</div>
</div>
</CenterModal>
)
}
export default DesktopUserInfoModal

View File

@@ -1,145 +0,0 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import { motion } from 'motion/react'
import { useEffect, useRef } from 'react'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { useWalletRecordsVm } from '@/features/game/hooks/use-wallet-records-vm'
function DesktopWalletRecordsTab({ enabled }: { enabled: boolean }) {
const vm = useWalletRecordsVm({ enabled })
const parentRef = useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: vm.items.length + (vm.hasNextPage ? 1 : 0),
estimateSize: () => 72,
getScrollElement: () => parentRef.current,
overscan: 6,
})
const virtualItems = rowVirtualizer.getVirtualItems()
useEffect(() => {
const lastItem = virtualItems.at(-1)
if (
!lastItem ||
lastItem.index < vm.items.length - 1 ||
!vm.hasNextPage ||
vm.isFetchingNextPage
) {
return
}
void vm.fetchNextPage()
}, [
virtualItems,
vm.fetchNextPage,
vm.hasNextPage,
vm.isFetchingNextPage,
vm.items.length,
])
return (
<div className={'flex h-full w-full flex-col p-design-10'}>
<div
className={
'mb-design-12 flex items-center justify-between gap-design-16 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
}
>
<div className={'text-design-20 font-medium text-[#BFEAEC]'}>
{vm.headers.type}
</div>
<div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div>
</div>
<div className={'min-h-0 flex-1 rounded-md'}>
<div
className={
'grid grid-cols-[minmax(0,1.1fr)_minmax(0,0.75fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,1fr)] gap-design-10 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-16 py-design-12 text-design-16 text-[#7ECAD1]'
}
>
<div>{vm.headers.time}</div>
<div>{vm.headers.amount}</div>
<div>{vm.headers.balanceBefore}</div>
<div>{vm.headers.balanceAfter}</div>
<div>{vm.headers.remark}</div>
</div>
<div
ref={parentRef}
className={
'mt-design-10 max-h-[calc(var(--design-unit)*320)] min-h-0 overflow-auto pr-design-4'
}
>
{vm.isLoading ? (
<DataLoadingIndicator label={vm.loadingText} />
) : vm.isError ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.loadFailedText}
</div>
) : vm.items.length === 0 ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.emptyText}
</div>
) : (
<div
className={'relative w-full'}
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{virtualItems.map((virtualRow) => {
const item = vm.items[virtualRow.index]
return (
<div
key={virtualRow.key}
className={'absolute left-0 top-0 w-full pb-design-10'}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{item ? (
<motion.div
className={
'grid h-[calc(var(--design-unit)*62)] grid-cols-[minmax(0,1.1fr)_minmax(0,0.75fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,1fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-17 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.16,
ease: 'easeOut',
}}
>
<div className={'truncate text-[#BFEAEC]'}>
{item.timeLabel}
</div>
<div className={'truncate font-medium text-[#FEEEB0]'}>
{item.amountLabel}
</div>
<div className={'truncate text-[#86DAE7]'}>
{item.balanceBeforeLabel}
</div>
<div className={'truncate text-[#7CFFCF]'}>
{item.balanceAfterLabel}
</div>
<div className={'truncate text-white'}>
{item.remarkLabel}
</div>
</motion.div>
) : (
<DataLoadingIndicator
compact
label={vm.loadingText}
className="h-[calc(var(--design-unit)*62)] rounded-md bg-[#0A4252]/60"
/>
)}
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}
export default DesktopWalletRecordsTab

View File

@@ -1,39 +0,0 @@
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'
import { useModalStore } from '@/store'
function DesktopWithdrawTopupModal() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopWithdrawTopup)
const type = useModalStore((state) => state.withdrawTopupType)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setModalOpen('desktopWithdrawTopup', false)
}
return (
<CenterModal
open={open}
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26 uppercase'}>
{type === 'withdraw'
? t('game.modals.withdrawTopup.applyWithdraw')
: t('game.modals.withdrawTopup.applyTopup')}
</div>
}
isNormalBg={true}
titleAlign="left"
className={'w-design-1200 h-design-700'}
>
<div className={'w-full h-[96%]'}>
{type === 'withdraw' ? <DesktopWithdraw /> : <DesktopTopup />}
</div>
</CenterModal>
)
}
export default DesktopWithdrawTopupModal

View File

@@ -1,17 +0,0 @@
export {
ANNOUNCEMENT_TONES,
BET_SOURCES,
CELL_STATUSES,
CONNECTION_STATUSES,
CONNECTION_TRANSPORTS,
DEFAULT_ACTIVE_CHIP_ID,
DEFAULT_ANNOUNCEMENT_TTL_MS,
DEFAULT_GAME_CHIP_COLORS,
GAME_GRID_COLUMNS,
GAME_GRID_ROWS,
GAME_MAX_SELECTION_CELLS,
GAME_RECENT_HISTORY_LIMIT,
GAME_TOTAL_CELLS,
ROUND_PHASES,
TREND_DIRECTIONS,
} from '@/constants/game'

View File

@@ -1,3 +1,5 @@
import type { FlowerImageAsset } from '@/type'
const animalModules = import.meta.glob('../../../assets/animal/*.webp', {
eager: true,
import: 'default',
@@ -8,12 +10,6 @@ const rewardModules = import.meta.glob('../../../assets/reward/*.webp', {
import: 'default',
}) as Record<string, string>
export interface FlowerImageAsset {
animalUrl: string
id: number
rewardUrl: string
}
export const FLOWER_IMAGE_LIST: FlowerImageAsset[] = Array.from(
{ length: 36 },
(_, index) => {

View File

@@ -1,5 +1,3 @@
export * from './constants'
export * from './flower-assets'
export * from './initial-state'
export * from './selectors'
export * from './types'

View File

@@ -1,5 +1,8 @@
import { DEFAULT_CHIP_AMOUNTS } from '@/constants'
import { DEFAULT_GAME_CHIP_COLORS, GAME_MAX_SELECTION_CELLS } from './constants'
import {
DEFAULT_CHIP_AMOUNTS,
DEFAULT_GAME_CHIP_COLORS,
GAME_MAX_SELECTION_CELLS,
} from '@/constants'
import type {
AnnouncementState,
Chip,
@@ -7,7 +10,7 @@ import type {
DashboardState,
GameBootstrapSnapshot,
RoundSnapshot,
} from './types'
} from '@/type'
function createEmptyRoundSnapshot(nowIso: string): RoundSnapshot {
return {

View File

@@ -1,4 +1,4 @@
import { GAME_RECENT_HISTORY_LIMIT, GAME_TOTAL_CELLS } from './constants'
import { GAME_RECENT_HISTORY_LIMIT, GAME_TOTAL_CELLS } from '@/constants'
import type {
AnnouncementState,
BetSelection,
@@ -9,7 +9,7 @@ import type {
RoundSnapshot,
TrendDirection,
TrendEntry,
} from './types'
} from '@/type'
export function getChipById(chips: Chip[], chipId: string) {
return chips.find((chip) => chip.id === chipId) ?? null

View File

@@ -1,135 +0,0 @@
import type {
ANNOUNCEMENT_TONES,
BET_SOURCES,
CELL_STATUSES,
CONNECTION_STATUSES,
CONNECTION_TRANSPORTS,
ROUND_PHASES,
TREND_DIRECTIONS,
} from './constants'
export type RoundPhase = (typeof ROUND_PHASES)[number]
export type CellStatus = (typeof CELL_STATUSES)[number]
export type ConnectionStatus = (typeof CONNECTION_STATUSES)[number]
export type ConnectionTransport = (typeof CONNECTION_TRANSPORTS)[number]
export type AnnouncementTone = (typeof ANNOUNCEMENT_TONES)[number]
export type BetSource = (typeof BET_SOURCES)[number]
export type TrendDirection = (typeof TREND_DIRECTIONS)[number]
export interface GameCell {
column: number
id: number
label: string
odds: number
row: number
}
export interface Chip {
amount: number
color: string
id: string
isDefault?: boolean
label: string
}
export interface BetSelection {
amount: number
cellId: number
chipId: string
id: string
placedAt: string
source: BetSource
}
export interface RoundSnapshot {
bettingClosesAt: string
id: string
phase: RoundPhase
revealingAt: string
settledAt: string | null
startedAt: string
winningCellId: number | null
}
export interface HistoryEntry {
payoutMultiplier: number
roundId: string
settledAt: string
totalPoolAmount: number
winningCellId: number
}
export interface TrendEntry {
cellId: number
currentStreak: number
direction: TrendDirection
hitCount: number
lastHitRoundId: string | null
missCount: number
}
export interface AnnouncementItem {
createdAt: string
expiresAt: string | null
id: string
isPinned?: boolean
isRead?: boolean
message: string
title: string
tone: AnnouncementTone
}
export interface AnnouncementState {
activeAnnouncementId: string | null
items: AnnouncementItem[]
lastUpdatedAt: string | null
}
export interface DashboardState {
countdownMs: number
featuredCellId: number | null
onlinePlayers: number
tableLimitMax: number
tableLimitMin: number
totalPoolAmount: number
updatedAt: string | null
}
export interface ConnectionState {
connectedAt: string | null
lastError: string | null
lastMessageAt: string | null
latencyMs: number | null
reconnectAttempt: number
status: ConnectionStatus
transport: ConnectionTransport
}
export interface GameBootstrapSnapshot {
announcements: AnnouncementState
cells: GameCell[]
chips: Chip[]
connection: ConnectionState
dashboard: DashboardState
history: HistoryEntry[]
maxSelectionCount: number
round: RoundSnapshot
selections: BetSelection[]
trends: TrendEntry[]
}
export interface GameCellViewModel extends GameCell {
currentStreak: number
hitCount: number
isSelected: boolean
isWinningCell: boolean
selectionAmount: number
selectionCount: number
status: CellStatus
}
export interface SelectionSummary {
amount: number
cellId: number
count: number
}