refactor(game): 重构项目结构,优化链路, 移动端适配
- 移除 useGameBoardVm 数据层实施说明文档 - 移除核心玩法与前端规则摘要文档 - 移除游戏模块数据与界面分层第一阶段实施稿文档 - 清理与数据层重构相关的技术方案说明 - 删除关于 PC 和 Mobile 界面分离的设计规划 - 移除 view-model hooks 架构设计相关内容
This commit is contained in:
@@ -6,11 +6,17 @@ import {
|
||||
} from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { AuthSessionInput } from '@/store/auth'
|
||||
import {
|
||||
mergeAuthUsers,
|
||||
normalizeAuthSession,
|
||||
normalizeAuthUserProfile,
|
||||
normalizeRefreshAuthSession,
|
||||
} from '@/lib/auth/auth-normalizers'
|
||||
import { getAuthDeviceId } from '@/store/auth'
|
||||
import type { ApiResponse } from '@/type'
|
||||
import type {
|
||||
ApiResponse,
|
||||
AuthSessionDto,
|
||||
AuthSessionInput,
|
||||
AuthUserProfileDto,
|
||||
LoginPayload,
|
||||
LoginRequestDto,
|
||||
@@ -24,13 +30,7 @@ import type {
|
||||
SendSmsCodePayload,
|
||||
SendSmsCodeRequestDto,
|
||||
SendSmsCodeResult,
|
||||
} from './types'
|
||||
import {
|
||||
mergeAuthUsers,
|
||||
normalizeAuthSession,
|
||||
normalizeAuthUserProfile,
|
||||
normalizeRefreshAuthSession,
|
||||
} from './types'
|
||||
} from '@/type'
|
||||
|
||||
const shouldLogAuthLifecycle =
|
||||
import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
} from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { ApiResponse } from '@/type'
|
||||
|
||||
import type {
|
||||
ApiResponse,
|
||||
DepositCreateRequestDto,
|
||||
DepositCreateResponseDto,
|
||||
DepositTierItem,
|
||||
@@ -28,7 +27,7 @@ import type {
|
||||
WalletRecordType,
|
||||
WithdrawCreateRequestDto,
|
||||
WithdrawCreateResponseDto,
|
||||
} from './finance-types'
|
||||
} from '@/type'
|
||||
|
||||
function unwrapFinanceEnvelope<T>(
|
||||
response: ApiResponse<T>,
|
||||
@@ -1,55 +1,53 @@
|
||||
import {
|
||||
API_SUCCESS_CODE,
|
||||
DEFAULT_GAME_CHIP_COLORS,
|
||||
DEFAULT_LIST_PAGE_SIZE,
|
||||
GAME_API_ENDPOINTS,
|
||||
GAME_GRID_COLUMNS,
|
||||
GAME_MAX_SELECTION_CELLS,
|
||||
} from '@/constants'
|
||||
import {
|
||||
createEmptyGameBootstrapSnapshot,
|
||||
deriveTrendEntries,
|
||||
} from '@/features/game/shared'
|
||||
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,
|
||||
ApiResponse,
|
||||
BetSelection,
|
||||
BetSelectionDto,
|
||||
ChipDto,
|
||||
ConnectionState,
|
||||
ConnectionStateDto,
|
||||
DashboardState,
|
||||
DashboardStateDto,
|
||||
GameAnnouncementsDto,
|
||||
GameBetOrdersDto,
|
||||
GameBootstrapDto,
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
GameCellDto,
|
||||
GameLobbyInitDto,
|
||||
GameLobbyInitResult,
|
||||
GameLobbyPeriodDto,
|
||||
GamePeriodTickDto,
|
||||
GamePlaceBetDto,
|
||||
GamePlaceBetRequestDto,
|
||||
GameRoundFeedDto,
|
||||
HistoryEntry,
|
||||
HistoryEntryDto,
|
||||
NoticeConfirmDto,
|
||||
NoticeDetailDto,
|
||||
NoticeListDto,
|
||||
RoundPhase,
|
||||
RoundSnapshot,
|
||||
RoundSnapshotDto,
|
||||
TrendEntry,
|
||||
TrendEntryDto,
|
||||
} from './types'
|
||||
} from '@/type'
|
||||
|
||||
function unwrapGameEnvelope<T>(
|
||||
response: ApiResponse<T>,
|
||||
@@ -86,13 +84,6 @@ function assertLobbyInitDto(
|
||||
}
|
||||
}
|
||||
|
||||
export interface GameLobbyInitResult {
|
||||
runtimeEnabled: boolean
|
||||
serverTime: number
|
||||
snapshot: GameBootstrapSnapshot
|
||||
userSnapshot: GameLobbyInitDto['user_snapshot']
|
||||
}
|
||||
|
||||
function normalizeGameCell(dto: GameCellDto) {
|
||||
return dto satisfies GameCell
|
||||
}
|
||||
4
src/api/index.ts
Normal file
4
src/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './auth-api'
|
||||
export * from './finance-api'
|
||||
export * from './game-api'
|
||||
export * from './period-history-api'
|
||||
@@ -1,13 +1,7 @@
|
||||
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
|
||||
}
|
||||
import type { ApiResponse, GamePeriodHistoryItemDto } from '@/type'
|
||||
|
||||
interface GamePeriodHistoryDto {
|
||||
list: GamePeriodHistoryItemDto[]
|
||||
BIN
src/assets/system/mobile-modal-header.webp
Normal file
BIN
src/assets/system/mobile-modal-header.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
170
src/components/mobile-center-modal.tsx
Normal file
170
src/components/mobile-center-modal.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { type ReactNode, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import mobileModalHeader from '@/assets/system/mobile-modal-header.webp'
|
||||
import modalClose from '@/assets/system/modal-close.webp'
|
||||
import { acquireBodyScrollLock } from '@/lib/dom/body-scroll-lock'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MobileCenterModalProps {
|
||||
open: boolean
|
||||
onClose?: () => void
|
||||
title?: ReactNode
|
||||
titleAlign?: 'left' | 'center'
|
||||
isShowClose?: boolean
|
||||
isNormalBg?: boolean
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
backdropClassName?: string
|
||||
}
|
||||
|
||||
const MOBILE_MODAL_HEADER_HEIGHT = 'calc(var(--design-unit)*82)'
|
||||
const MOBILE_MODAL_CONTENT_TOP = 'calc(var(--design-unit)*40)'
|
||||
|
||||
export function MobileCenterModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
titleAlign = 'left',
|
||||
isShowClose = true,
|
||||
children,
|
||||
className,
|
||||
backdropClassName,
|
||||
}: MobileCenterModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const onCloseRef = useRef(onClose)
|
||||
const handleClose = () => {
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const releaseBodyScrollLock = acquireBodyScrollLock()
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onCloseRef.current?.()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
releaseBodyScrollLock()
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open || typeof document === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 px-design-12 py-design-14',
|
||||
backdropClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={
|
||||
typeof title === 'string'
|
||||
? title
|
||||
: t('commonUi.modal.defaultAriaLabel')
|
||||
}
|
||||
className={cn(
|
||||
'relative flex min-h-0 w-full flex-col overflow-visible rounded-[calc(var(--design-unit)*12)] text-white shadow-[0_0_calc(var(--design-unit)*22)_rgba(21,213,232,0.18)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full min-h-0 w-full overflow-visible rounded-b-[calc(var(--design-unit)*10)] bg-[linear-gradient(180deg,rgba(5,37,47,0.98)_0%,rgba(2,21,31,0.98)_58%,rgba(1,12,20,0.99)_100%)] shadow-[inset_0_0_calc(var(--design-unit)*24)_rgba(30,206,222,0.18)]',
|
||||
title ? '' : 'pt-design-40',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-[1] rounded-b-[calc(var(--design-unit)*10)] border-x border-b border-[rgba(108,205,207,0.72)]"
|
||||
style={{
|
||||
top: MOBILE_MODAL_CONTENT_TOP,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-20 shrink-0"
|
||||
style={{
|
||||
top: 'calc(var(--design-unit)*-2)',
|
||||
left: 'calc(var(--design-unit)*-3)',
|
||||
right: 'calc(var(--design-unit)*-3)',
|
||||
height: 'calc(var(--design-unit)*86)',
|
||||
backgroundImage: `url(${mobileModalHeader})`,
|
||||
backgroundPosition: 'top center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: '100% 100%',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-30 shrink-0 px-design-22"
|
||||
style={{
|
||||
left: 'calc(var(--design-unit)*-8)',
|
||||
right: 'calc(var(--design-unit)*-8)',
|
||||
height: MOBILE_MODAL_HEADER_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{title ? (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-auto flex h-design-44 w-full items-center text-design-22 font-semibold tracking-[0.05em] text-cyan-50',
|
||||
titleAlign === 'center'
|
||||
? 'justify-center text-center'
|
||||
: 'justify-start text-left',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isShowClose && onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('commonUi.modal.close')}
|
||||
onClick={handleClose}
|
||||
className="pointer-events-auto absolute top-design-11 right-design-15 inline-flex h-design-24 w-design-34 cursor-pointer items-center justify-center rounded-full transition hover:scale-105 active:scale-95"
|
||||
>
|
||||
<img
|
||||
src={modalClose}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="modal-close-glow relative z-10 h-design-32 w-design-26 object-contain"
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="relative z-10 min-h-0 w-full overflow-hidden"
|
||||
style={{
|
||||
height: `calc(100% - ${MOBILE_MODAL_CONTENT_TOP})`,
|
||||
marginTop: MOBILE_MODAL_CONTENT_TOP,
|
||||
}}
|
||||
>
|
||||
<div className="h-full min-h-0 w-full overflow-y-auto overscroll-contain">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import controlLeft from '@/assets/game/control-left.webp'
|
||||
import controlMid from '@/assets/game/control-mid.webp'
|
||||
import controlRight from '@/assets/game/control-right.webp'
|
||||
import hallMusic from '@/assets/music/hall-music.mp3'
|
||||
import type { DepositWithdrawConfig } from '@/features/game/api/finance-types'
|
||||
import type { DepositWithdrawConfig } from '@/type'
|
||||
|
||||
/** @description 游戏棋盘行数。 */
|
||||
export const GAME_GRID_ROWS = 6
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
145
src/features/auth/components/mobile/mobile-login-form-view.tsx
Normal file
145
src/features/auth/components/mobile/mobile-login-form-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
src/features/auth/components/mobile/mobile-login-form.tsx
Normal file
43
src/features/auth/components/mobile/mobile-login-form.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
70
src/features/auth/components/mobile/mobile-register-form.tsx
Normal file
70
src/features/auth/components/mobile/mobile-register-form.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './finance-api'
|
||||
export * from './finance-types'
|
||||
export * from './game-api'
|
||||
export * from './types'
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
|
||||
186
src/features/game/components/mobile/mobile-topup.tsx
Normal file
186
src/features/game/components/mobile/mobile-topup.tsx
Normal 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
|
||||
640
src/features/game/components/mobile/mobile-withdraw.tsx
Normal file
640
src/features/game/components/mobile/mobile-withdraw.tsx
Normal 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
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './api'
|
||||
export * from './shared'
|
||||
@@ -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'
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export * from './constants'
|
||||
export * from './flower-assets'
|
||||
export * from './initial-state'
|
||||
export * from './selectors'
|
||||
export * from './types'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AUTH_ERROR_KEY_PREFIX } from '@/constants'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
|
||||
type AuthSubmitContext = 'login' | 'register'
|
||||
import type { AuthSubmitContext } from '@/type'
|
||||
|
||||
function isTranslationKey(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.startsWith(AUTH_ERROR_KEY_PREFIX)
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
import type { DesktopAnimalWarningType } from '@/type'
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
@@ -22,8 +23,6 @@ function parseBalance(value: string | number | null | undefined) {
|
||||
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
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { placeGameBet } from '@/features/game'
|
||||
import type { BetSelection } from '@/features/game/shared'
|
||||
import { placeGameBet } from '@/api'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import {
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
import type { BetSelection } from '@/type'
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getDepositTierList } from '@/api'
|
||||
import {
|
||||
DEFAULT_APP_LANGUAGE,
|
||||
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
|
||||
} from '@/constants'
|
||||
import { getDepositTierList } from '@/features/game/api'
|
||||
|
||||
export function useDepositTierList() {
|
||||
const { i18n } = useTranslation()
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getDepositWithdrawConfig } from '@/api'
|
||||
import {
|
||||
DEFAULT_APP_LANGUAGE,
|
||||
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
|
||||
} from '@/constants'
|
||||
import { getDepositWithdrawConfig } from '@/features/game/api'
|
||||
|
||||
export function useDepositWithdrawConfig() {
|
||||
const { i18n } = useTranslation()
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getDepositOrderList, getWithdrawOrderList } from '@/api'
|
||||
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
|
||||
import { getDepositOrderList, getWithdrawOrderList } from '@/features/game/api'
|
||||
|
||||
export type FinanceRecordType = 'deposit' | 'withdraw'
|
||||
import type { FinanceRecordType } from '@/type'
|
||||
|
||||
const FINANCE_RECORD_TYPE_OPTIONS: Array<{
|
||||
key: FinanceRecordType
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { placeGameBet } from '@/api'
|
||||
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 {
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
|
||||
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'limit' | 'submitting'
|
||||
import type { ConfirmState } from '@/type'
|
||||
|
||||
function formatChipDisplayValue(amount: number) {
|
||||
if (Number.isInteger(amount)) {
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getGameBetMyOrders } from '@/api'
|
||||
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'
|
||||
import type { HistoryResultState } from '@/type'
|
||||
|
||||
function formatCreatedTime(timestamp: number, locale: string) {
|
||||
const date = new Date(timestamp * 1000)
|
||||
@@ -32,8 +32,6 @@ function formatNumbers(numbers: number[]) {
|
||||
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)
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { getGameLobbyInit, normalizePeriodTickRound } from '@/api'
|
||||
import {
|
||||
FALLBACK_POLL_INTERVAL_MS,
|
||||
GAME_SOCKET_TOPIC_VALUES,
|
||||
@@ -18,29 +19,15 @@ import {
|
||||
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
|
||||
}
|
||||
PeriodEventData,
|
||||
UserStreakMessageData,
|
||||
WalletChangedData,
|
||||
} from '@/type'
|
||||
|
||||
let sharedSocketClient: GameSocketClient | null = null
|
||||
let sharedSocketKey: string | null = null
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
CONNECTION_LATENCY_GOOD_MS,
|
||||
CONNECTION_LATENCY_POOR_MS,
|
||||
} from '@/constants'
|
||||
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
|
||||
import { useAppLanguage } from '@/hooks/use-app-language'
|
||||
import {
|
||||
isDesktopFullscreen,
|
||||
subscribeDesktopFullscreenChange,
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { loginWithPassword } from '@/api'
|
||||
import i18n from '@/i18n'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { type LoginFormValues, loginFormSchema } from '@/schema/auth-schema'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { loginWithPassword } from '../api/auth-api'
|
||||
import { type LoginFormValues, loginFormSchema } from '../schema/auth-schema'
|
||||
import type { UseLoginFormOptions } from '@/type'
|
||||
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>({
|
||||
@@ -1,24 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import {
|
||||
type GamePeriodHistoryItemDto,
|
||||
getGamePeriodHistory,
|
||||
} from '@/features/game/api/period-history-api'
|
||||
import { type GamePeriodHistoryItemDto, getGamePeriodHistory } from '@/api'
|
||||
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
|
||||
import type { PeriodHistoryDisplayItem } from '@/type'
|
||||
|
||||
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('-')
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { registerWithPassword } from '@/api'
|
||||
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'
|
||||
} from '@/schema/auth-schema'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import type { UseRegisterFormOptions } from '@/type'
|
||||
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
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { sendSmsCode } from '@/api'
|
||||
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() {
|
||||
@@ -2,9 +2,8 @@ import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getWalletRecordList } from '@/api'
|
||||
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
|
||||
import { getWalletRecordList } from '@/features/game/api'
|
||||
|
||||
const WALLET_RECORD_TYPE = 'payout'
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
createWithdraw,
|
||||
type WithdrawCreateRequestDto,
|
||||
} from '@/features/game/api'
|
||||
import { createWithdraw, type WithdrawCreateRequestDto } from '@/api'
|
||||
import { notify } from '@/lib/notify'
|
||||
|
||||
export function useWithdrawSubmit() {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { DepositWithdrawConfig } from '@/api'
|
||||
import {
|
||||
DEFAULT_CURRENCY_CODE,
|
||||
DEFAULT_WITHDRAW_CONFIG,
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
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 { useDepositWithdrawConfig } from '@/hooks/use-deposit-withdraw-config'
|
||||
import { useAuthStore } from '@/store'
|
||||
|
||||
function formatNumber(locale: string, value: number) {
|
||||
@@ -8,7 +8,7 @@ import msMY from '@/locales/ms-MY'
|
||||
import zhCN from '@/locales/zh-CN'
|
||||
import { getStoredAppLanguage, setStoredAppLanguage } from '@/store/auth'
|
||||
|
||||
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
||||
export type { AppLanguage } from '@/type'
|
||||
|
||||
/** @description 判断给定语言是否在当前应用支持列表中。 */
|
||||
export function isSupportedLanguage(
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
HTTP_STATUS,
|
||||
REQUEST_HEADERS,
|
||||
} from '@/constants'
|
||||
import type { AuthTokenDto } from '@/features/auth/api/types'
|
||||
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
|
||||
import { ApiError } from '@/lib/api/api-error.ts'
|
||||
import {
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
getStoredAppLanguage,
|
||||
useAuthStore,
|
||||
} from '@/store/auth'
|
||||
import type { ApiResponse } from '@/type'
|
||||
import type { ApiResponse, AuthTokenDto } from '@/type'
|
||||
|
||||
type RequestOptions = Omit<Options, 'json'>
|
||||
type JsonRequestOptions<TBody> = RequestOptions & {
|
||||
|
||||
74
src/lib/auth/auth-normalizers.ts
Normal file
74
src/lib/auth/auth-normalizers.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
AuthSessionDto,
|
||||
AuthSessionInput,
|
||||
AuthUser,
|
||||
AuthUserDto,
|
||||
AuthUserProfileDto,
|
||||
RefreshTokenDto,
|
||||
} from '@/type'
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,16 @@ import { LOGIN_PROMPT_DEDUP_MS } from '@/constants'
|
||||
import i18n from '@/i18n'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { queryClient } from '@/lib/query/query-client'
|
||||
import type { AuthSessionInput, AuthUser } from '@/store/auth'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useModalStore } from '@/store/modal'
|
||||
|
||||
export type CurrentUserInitializer = () => Promise<AuthUser | null>
|
||||
export type RefreshSessionHandler = (
|
||||
refreshToken: string,
|
||||
) => Promise<AuthSessionInput | null>
|
||||
import type {
|
||||
AuthSessionInput,
|
||||
AuthUser,
|
||||
ClearAuthenticatedSessionOptions,
|
||||
CurrentUserInitializer,
|
||||
RefreshSessionHandler,
|
||||
UnauthorizedSessionOptions,
|
||||
} from '@/type'
|
||||
|
||||
let currentUserInitializer: CurrentUserInitializer | null = null
|
||||
let refreshSessionHandler: RefreshSessionHandler | null = null
|
||||
@@ -17,16 +19,6 @@ let authInitializationPromise: Promise<void> | null = null
|
||||
let refreshSessionPromise: Promise<boolean> | null = null
|
||||
let lastLoginPromptAt = 0
|
||||
|
||||
interface ClearAuthenticatedSessionOptions {
|
||||
clearBrowserStorage?: boolean
|
||||
clearQueryCache?: boolean
|
||||
}
|
||||
|
||||
interface UnauthorizedSessionOptions extends ClearAuthenticatedSessionOptions {
|
||||
openLoginModal?: boolean
|
||||
showLoginRequiredToast?: boolean
|
||||
}
|
||||
|
||||
function clearBrowserStorageData() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.clear()
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { redirect } from '@tanstack/react-router'
|
||||
|
||||
import type { AppLanguage } from '@/i18n'
|
||||
import { getPreferredLanguage } from '@/i18n'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import type { RequireAuthenticatedSessionOptions } from '@/type'
|
||||
|
||||
import { initializeAuthSession, isAuthenticated } from './auth-session'
|
||||
|
||||
interface RequireAuthenticatedSessionOptions {
|
||||
fallbackLanguage?: AppLanguage
|
||||
}
|
||||
|
||||
export async function requireAuthenticatedSession(
|
||||
options: RequireAuthenticatedSessionOptions = {},
|
||||
) {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { APP_DEFAULT_DESCRIPTION, APP_NAME } from '@/constants'
|
||||
|
||||
interface DocumentMetadata {
|
||||
description?: string
|
||||
robots?: string
|
||||
title?: string
|
||||
}
|
||||
import type { DocumentMetadata } from '@/type'
|
||||
|
||||
function upsertMetaTag(
|
||||
selector: string,
|
||||
|
||||
@@ -3,13 +3,7 @@ import {
|
||||
DEFAULT_ALERT_DURATION_MS,
|
||||
NOTIFICATION_EXIT_DURATION_MS,
|
||||
} from '@/constants'
|
||||
|
||||
type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading'
|
||||
|
||||
export interface NotifyOptions {
|
||||
description?: string
|
||||
duration?: number
|
||||
}
|
||||
import type { NotificationType, NotifyOptions } from '@/type'
|
||||
|
||||
interface NotificationDialog {
|
||||
description?: string
|
||||
|
||||
@@ -115,8 +115,8 @@ export default {
|
||||
label: 'Language',
|
||||
zhCN: '中文',
|
||||
enUS: 'English',
|
||||
msMY: 'Bahasa Melayu',
|
||||
idID: 'Bahasa Indonesia',
|
||||
msMY: 'Melayu',
|
||||
idID: 'Indonesia',
|
||||
},
|
||||
game: {
|
||||
metaTitle: 'Game Lobby',
|
||||
|
||||
@@ -114,8 +114,8 @@ export default {
|
||||
label: 'Bahasa',
|
||||
zhCN: '中文',
|
||||
enUS: 'English',
|
||||
msMY: 'Bahasa Melayu',
|
||||
idID: 'Bahasa Indonesia',
|
||||
msMY: 'Melayu',
|
||||
idID: 'Indonesia',
|
||||
},
|
||||
game: {
|
||||
metaTitle: 'Lobby Game',
|
||||
|
||||
@@ -117,8 +117,8 @@ export default {
|
||||
label: 'Bahasa',
|
||||
zhCN: '中文',
|
||||
enUS: 'English',
|
||||
msMY: 'Bahasa Melayu',
|
||||
idID: 'Bahasa Indonesia',
|
||||
msMY: 'Melayu',
|
||||
idID: 'Indonesia',
|
||||
},
|
||||
game: {
|
||||
metaTitle: 'Lobi Permainan',
|
||||
|
||||
@@ -114,8 +114,8 @@ export default {
|
||||
label: '语言',
|
||||
zhCN: '中文',
|
||||
enUS: 'English',
|
||||
msMY: 'Bahasa Melayu',
|
||||
idID: 'Bahasa Indonesia',
|
||||
msMY: 'Melayu',
|
||||
idID: 'Indonesia',
|
||||
},
|
||||
game: {
|
||||
metaTitle: '游戏大厅',
|
||||
|
||||
@@ -3,13 +3,10 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { getCurrentUserProfile, refreshAuthSession } from '@/api'
|
||||
import { AppBootResourceGate } from '@/components/app-boot-resource-gate'
|
||||
import { AppNotificationAlert } from '@/components/ui/notification-alert'
|
||||
import { APP_ROOT_ELEMENT_ID } from '@/constants'
|
||||
import {
|
||||
getCurrentUserProfile,
|
||||
refreshAuthSession,
|
||||
} from '@/features/auth/api/auth-api'
|
||||
import { GlobalAudioController } from '@/features/game/audio/global-audio-controller'
|
||||
import '@/i18n'
|
||||
import { prefetchAuthToken } from '@/lib/api/api-client'
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
import { startTransition, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getGameLobbyInit } from '@/api'
|
||||
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 {
|
||||
EntryNoticeGateModal,
|
||||
MobileEntryNoticeGateModal,
|
||||
} from '@/features/game/components/shared/entry-notice-gate-modal'
|
||||
import { useGameRealtimeSync } from '@/hooks/use-game-realtime-sync.ts'
|
||||
import { useDocumentMetadata } from '@/lib/head/document-metadata.ts'
|
||||
import { notify } from '@/lib/notify.ts'
|
||||
import { MobileEntry } from '@/main/mobile-entry.tsx'
|
||||
import { PcEntry } from '@/main/pc-entry.tsx'
|
||||
import DesktopAutoSettingModal from '@/modal/desktop/desktop-auto-setting-modal.tsx'
|
||||
import DesktopLanguageModal from '@/modal/desktop/desktop-language-modal.tsx'
|
||||
import DesktopLoginModal from '@/modal/desktop/desktop-login-modal.tsx'
|
||||
import DesktopNoticeModal from '@/modal/desktop/desktop-notice-modal.tsx'
|
||||
import { DesktopPeriodHistoryDrawer } from '@/modal/desktop/desktop-period-history-drawer.tsx'
|
||||
import DesktopProceduresModal from '@/modal/desktop/desktop-procedures-modal.tsx'
|
||||
import DesktopRegisterModal from '@/modal/desktop/desktop-register-modal.tsx'
|
||||
import DesktopRulesModal from '@/modal/desktop/desktop-rules-modal.tsx'
|
||||
import DesktopSupportModal from '@/modal/desktop/desktop-support-modal.tsx'
|
||||
import DesktopUserInfoModal from '@/modal/desktop/desktop-userInfo-modal.tsx'
|
||||
import DesktopWithdrawTopupModal from '@/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
||||
import MobileAutoSettingModal from '@/modal/mobile/mobile-auto-setting-modal.tsx'
|
||||
import MobileLanguageModal from '@/modal/mobile/mobile-language-modal.tsx'
|
||||
import MobileLoginModal from '@/modal/mobile/mobile-login-modal.tsx'
|
||||
import MobileNoticeModal from '@/modal/mobile/mobile-notice-modal.tsx'
|
||||
import { MobilePeriodHistoryDrawer } from '@/modal/mobile/mobile-period-history-drawer.tsx'
|
||||
import MobileProceduresModal from '@/modal/mobile/mobile-procedures-modal.tsx'
|
||||
import MobileRegisterModal from '@/modal/mobile/mobile-register-modal.tsx'
|
||||
import MobileRulesModal from '@/modal/mobile/mobile-rules-modal.tsx'
|
||||
import MobileSupportModal from '@/modal/mobile/mobile-support-modal.tsx'
|
||||
import MobileUserInfoModal from '@/modal/mobile/mobile-userInfo-modal.tsx'
|
||||
import MobileWithdrawTopupModal from '@/modal/mobile/mobile-withdraw-topup-modal.tsx'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
function EntryModalHost() {
|
||||
function DesktopModalHost() {
|
||||
return (
|
||||
<>
|
||||
{/* 桌面端登录弹窗:用于未登录用户进入登录流程 */}
|
||||
@@ -54,7 +67,38 @@ function EntryModalHost() {
|
||||
)
|
||||
}
|
||||
|
||||
export function EntryPage() {
|
||||
function MobileModalHost() {
|
||||
return (
|
||||
<>
|
||||
{/* 移动端登录弹窗:用于未登录用户进入登录流程 */}
|
||||
<MobileLoginModal />
|
||||
{/* 移动端注册弹窗:用于新用户注册账号 */}
|
||||
<MobileRegisterModal />
|
||||
{/* 移动端语言切换弹窗:用于选择当前站点展示语言 */}
|
||||
<MobileLanguageModal />
|
||||
{/* 移动端规则弹窗:展示当前游戏玩法、下注与结算规则 */}
|
||||
<MobileRulesModal />
|
||||
{/* 移动端用户信息弹窗:展示个人资料与站内消息 */}
|
||||
<MobileUserInfoModal />
|
||||
{/* 移动端公告弹窗:展示活动公告或运营通知内容 */}
|
||||
<MobileNoticeModal />
|
||||
{/* 移动端自动托管弹窗:配置自动托管相关条件 */}
|
||||
<MobileAutoSettingModal />
|
||||
{/* 移动端充值/提现前置选择弹窗:先选择进入充值还是提现 */}
|
||||
<MobileProceduresModal />
|
||||
{/* 移动端充值/提现业务弹窗:承载具体的充值或提现内容 */}
|
||||
<MobileWithdrawTopupModal />
|
||||
{/* 移动端客服弹窗:承载在线客服 iframe */}
|
||||
<MobileSupportModal />
|
||||
{/* 强制弹窗 */}
|
||||
<MobileEntryNoticeGateModal />
|
||||
{/* 历史开奖信息弹窗 */}
|
||||
<MobilePeriodHistoryDrawer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function MainEntryPage() {
|
||||
const { t } = useTranslation()
|
||||
useGameRealtimeSync()
|
||||
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
|
||||
@@ -213,7 +257,7 @@ export function EntryPage() {
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{isMobile ? <MobileEntry /> : <PcEntry />}
|
||||
<EntryModalHost />
|
||||
{isMobile ? <MobileModalHost /> : <DesktopModalHost />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
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'
|
||||
import { useAutoHostingRunner } from '@/hooks/use-auto-hosting-runner.ts'
|
||||
|
||||
export function MobileEntry() {
|
||||
useAutoHostingRunner()
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
|
||||
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
||||
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
|
||||
import { useAutoHostingRunner } from '@/hooks/use-auto-hosting-runner.ts'
|
||||
|
||||
export function PcEntry() {
|
||||
useAutoHostingRunner()
|
||||
@@ -3,7 +3,7 @@ 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 { useFinanceRecordsVm } from '@/hooks/use-finance-records-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { useAppLanguage } from '@/hooks/use-app-language'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { DesktopLoginForm } from '@/features/auth/components/desktop-login-form'
|
||||
import { DesktopLoginForm } from '@/features/auth/components/desktop/desktop-login-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopLoginModal() {
|
||||
@@ -3,11 +3,11 @@ import dayjs from 'dayjs'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getNoticeDetail, getNoticeList } from '@/api'
|
||||
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'
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DEFAULT_PERIOD_HISTORY_LIMIT,
|
||||
type PeriodHistoryDisplayItem,
|
||||
usePeriodHistoryVm,
|
||||
} from '@/features/game/hooks/use-period-history-vm'
|
||||
} from '@/hooks/use-period-history-vm'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
const OVERLAY_EASE = [0.16, 1, 0.3, 1] as const
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { DesktopRegisterForm } from '@/features/auth/components/desktop-register-form'
|
||||
import { DesktopRegisterForm } from '@/features/auth/components/desktop/desktop-register-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopRegisterModal() {
|
||||
@@ -10,18 +10,18 @@ import {
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { logoutWithPassword } from '@/api'
|
||||
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 DesktopFinanceRecordsTab from '@/modal/desktop/desktop-finance-records-tab'
|
||||
import DesktopWalletRecordsTab from '@/modal/desktop/desktop-wallet-records-tab'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
|
||||
type UserInfoTabKey = 'financeRecords' | 'profile' | 'walletRecords'
|
||||
@@ -3,7 +3,7 @@ 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'
|
||||
import { useWalletRecordsVm } from '@/hooks/use-wallet-records-vm'
|
||||
|
||||
function DesktopWalletRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
const vm = useWalletRecordsVm({ enabled })
|
||||
229
src/modal/mobile/mobile-auto-setting-modal.tsx
Normal file
229
src/modal/mobile/mobile-auto-setting-modal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import { MobileCenterModal } from '@/components/mobile-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 MobileAutoSettingModal() {
|
||||
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 (
|
||||
<MobileCenterModal
|
||||
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="!h-[min(calc(var(--design-unit)*500),calc(100dvh-var(--design-unit)*28))]"
|
||||
>
|
||||
<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>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileAutoSettingModal
|
||||
181
src/modal/mobile/mobile-finance-records-tab.tsx
Normal file
181
src/modal/mobile/mobile-finance-records-tab.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
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 '@/hooks/use-finance-records-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function maskOrderNo(value: string) {
|
||||
const text = value.trim()
|
||||
|
||||
if (text.length <= 12) {
|
||||
return text
|
||||
}
|
||||
|
||||
return `${text.slice(0, 6)}**${text.slice(-4)}`
|
||||
}
|
||||
|
||||
function MobileFinanceRecordsTab({ 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: () => 52,
|
||||
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 min-h-0 w-full flex-col p-design-4">
|
||||
<div className="mb-design-8 flex shrink-0 items-center justify-between gap-design-8 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-8 py-design-7">
|
||||
<div className="relative grid min-w-0 grid-cols-2 overflow-hidden rounded-md border border-[#3EAFC7]/30 bg-[#031B24]/75 p-design-3">
|
||||
{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-24 min-w-design-82 cursor-pointer rounded-md px-design-8 text-design-12 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 text-design-10">
|
||||
{recordType.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-design-11 text-[#7ECAD1]">
|
||||
{vm.pageLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-x-auto rounded-md">
|
||||
<div className="min-w-design-330">
|
||||
<div className="grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] gap-design-6 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-10 py-design-8 text-design-12 text-[#7ECAD1]">
|
||||
<div>{vm.headers.orderNo}</div>
|
||||
<div>{vm.headers.amount}</div>
|
||||
<div>{vm.headers.bonusAmount}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="mt-design-7 max-h-[calc(var(--design-unit)*340)] min-h-0 overflow-y-auto pr-design-2"
|
||||
>
|
||||
{vm.isLoading ? (
|
||||
<DataLoadingIndicator label={vm.loadingText} />
|
||||
) : vm.isError ? (
|
||||
<div className="py-design-24 text-center text-design-12 text-[#6CCDCF]">
|
||||
{vm.loadFailedText}
|
||||
</div>
|
||||
) : vm.items.length === 0 ? (
|
||||
<div className="py-design-24 text-center text-design-12 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-7"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{item ? (
|
||||
<motion.div
|
||||
className="grid h-design-45 grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-6 rounded-md bg-[#0A4252] px-design-10 py-design-8 text-design-12 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"
|
||||
title={item.orderNoLabel}
|
||||
>
|
||||
{maskOrderNo(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-design-45 rounded-md bg-[#0A4252]/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileFinanceRecordsTab
|
||||
100
src/modal/mobile/mobile-language-modal.tsx
Normal file
100
src/modal/mobile/mobile-language-modal.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { useAppLanguage } from '@/hooks/use-app-language'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function MobileLanguageModal() {
|
||||
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 (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-16">
|
||||
{t('language.label')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="!h-design-350"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col px-design-14 pt-design-5">
|
||||
<div className="w-full flex flex-wrap gap-design-10 overflow-y-auto">
|
||||
{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 w-[calc(50%-var(--design-unit)*5))] flex h-design-130 min-w-0 flex-col justify-between overflow-hidden rounded-[calc(var(--design-unit)*12)] border px-design-12 py-design-12 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_calc(var(--design-unit)*14)_rgba(128,223,231,0.52),0_0_calc(var(--design-unit)*18)_rgba(66,227,255,0.18)]'
|
||||
: 'border-[#62BFC8]/45 bg-[linear-gradient(180deg,rgba(10,30,43,0.92),rgba(4,13,21,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(128,223,231,0.16)] hover:border-[#86EFFF]/80 hover:shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(128,223,231,0.28),0_0_calc(var(--design-unit)*14)_rgba(66,227,255,0.1)]',
|
||||
)}
|
||||
>
|
||||
<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 min-w-0 items-start justify-between">
|
||||
<SmartImage
|
||||
src={option.icon}
|
||||
alt={t(option.labelKey)}
|
||||
className="h-design-26 w-design-26 shrink-0 rounded-[calc(var(--design-unit)*8)] object-cover shadow-[0_calc(var(--design-unit)*5)_calc(var(--design-unit)*12)_rgba(0,0,0,0.28)]"
|
||||
/>
|
||||
{isActive ? (
|
||||
<div className="rounded-full border border-[#8BF5FF]/55 bg-[#8BF5FF]/18 px-design-8 py-design-3 text-design-10 font-semibold uppercase text-[#C9FCFF] shadow-[0_0_calc(var(--design-unit)*10)_rgba(66,227,255,0.18)]">
|
||||
{t('gameDesktop.control.selected')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative min-w-0 w-full">
|
||||
<div className="w-full truncate text-design-17 font-semibold text-[#F3FFFF]">
|
||||
{t(option.labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative 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-8 flex min-w-0 items-center justify-between text-design-11 text-[#98D6DC]">
|
||||
<span className="min-w-0 truncate">
|
||||
{t('language.label')}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#D8FDFF]">{option.code}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileLanguageModal
|
||||
33
src/modal/mobile/mobile-login-modal.tsx
Normal file
33
src/modal/mobile/mobile-login-modal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { MobileLoginForm } from '@/features/auth/components/mobile/mobile-login-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function MobileLoginModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopLogin)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopLogin', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={() => setModalOpen('desktopLogin', false)}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-16">
|
||||
{t('game.modals.login.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="center"
|
||||
className="!h-design-360"
|
||||
backdropClassName="backdrop-blur-none"
|
||||
>
|
||||
<MobileLoginForm onSuccess={handleSubmit} />
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileLoginModal
|
||||
190
src/modal/mobile/mobile-notice-modal.tsx
Normal file
190
src/modal/mobile/mobile-notice-modal.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
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 { getNoticeDetail, getNoticeList } from '@/api'
|
||||
import blueBtnBg from '@/assets/system/blue-btn.webp'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
type NoticeViewState = 'detail' | 'list'
|
||||
|
||||
function MobileNoticeModal() {
|
||||
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 (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-14">
|
||||
{t('game.modals.userInfo.message.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="!h-design-330"
|
||||
>
|
||||
<div className="flex h-full min-h-0 w-full flex-col px-design-6 pb-design-8 pt-design-2">
|
||||
{noticeView === 'detail' ? (
|
||||
<div className="mb-design-8 flex shrink-0 items-center rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-8 py-design-7">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleReturnToList()
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-design-7 text-[#86DAE7] transition hover:text-white"
|
||||
>
|
||||
<span className="flex h-design-28 w-design-28 items-center justify-center rounded-full border border-[#4AC6DE]/45 bg-[#0B4454]">
|
||||
<ArrowLeft className="h-design-16 w-design-16" />
|
||||
</span>
|
||||
<span className="text-design-13 font-medium">
|
||||
{t('game.modals.userInfo.message.back')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-md">
|
||||
{noticeView === 'list' ? (
|
||||
<div className="flex h-full w-full flex-col gap-design-8 p-design-4">
|
||||
{noticeListQuery.isLoading ? (
|
||||
<DataLoadingIndicator
|
||||
label={t('game.modals.userInfo.message.loading')}
|
||||
/>
|
||||
) : noticeListQuery.isError ? (
|
||||
<div className="py-design-24 text-center text-design-13 text-[#6CCDCF]">
|
||||
{t('game.modals.userInfo.message.loadFailed')}
|
||||
</div>
|
||||
) : noticeItems.length === 0 ? (
|
||||
<div className="py-design-24 text-center text-design-13 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 min-h-design-62 cursor-pointer items-center gap-design-9 rounded-md bg-[#0A4252] px-design-9 py-design-8 text-left transition hover:bg-[#0E576D]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-design-6 text-design-11 text-[#BFEAEC]">
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-[calc(var(--design-unit)*4)] border px-design-5 py-design-2 text-center text-design-9 font-semibold leading-none',
|
||||
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>
|
||||
<span className="min-w-0 truncate">
|
||||
{dayjs(item.publish_time * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-design-3 flex items-center gap-design-8">
|
||||
<div className="truncate text-design-14 text-white">
|
||||
{item.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SmartBackground
|
||||
src={blueBtnBg}
|
||||
size="100% 100%"
|
||||
className="flex h-design-34 w-design-78 shrink-0 items-center justify-center text-design-12 font-bold"
|
||||
>
|
||||
{t('game.modals.userInfo.message.check')}
|
||||
</SmartBackground>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 w-full flex-col gap-design-10 p-design-4">
|
||||
{noticeDetailQuery.isLoading ? (
|
||||
<DataLoadingIndicator
|
||||
label={t('game.modals.userInfo.message.loading')}
|
||||
/>
|
||||
) : noticeDetailQuery.isError ? (
|
||||
<div className="py-design-24 text-center text-design-13 text-[#6CCDCF]">
|
||||
{t('game.modals.userInfo.message.loadFailed')}
|
||||
</div>
|
||||
) : noticeDetailQuery.data ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto 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-12 shadow-[0_0_calc(var(--design-unit)*18)_rgba(14,108,132,0.16)]">
|
||||
<div className="mb-design-10 inline-flex rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-10 py-design-4 text-design-11 text-[#9CE8F2]">
|
||||
{dayjs(noticeDetailQuery.data.publish_time * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)}
|
||||
</div>
|
||||
<div className="text-design-18 font-semibold leading-tight text-white">
|
||||
{noticeDetailQuery.data.title}
|
||||
</div>
|
||||
<div className="mt-design-12 whitespace-pre-wrap text-design-14 leading-[1.62] text-[#C4F2F7]">
|
||||
{noticeDetailQuery.data.content}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-design-24 text-center text-design-13 text-[#6CCDCF]">
|
||||
{t('game.modals.userInfo.message.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileNoticeModal
|
||||
181
src/modal/mobile/mobile-period-history-drawer.tsx
Normal file
181
src/modal/mobile/mobile-period-history-drawer.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
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 '@/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 MobilePeriodHistoryDrawerViewProps {
|
||||
isError: boolean
|
||||
isLoading: boolean
|
||||
items: PeriodHistoryDisplayItem[]
|
||||
labels: PeriodHistoryDrawerLabels
|
||||
onClose: () => void
|
||||
onRetry: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function MobilePeriodHistoryDrawer() {
|
||||
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 (
|
||||
<MobilePeriodHistoryDrawerView
|
||||
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 MobilePeriodHistoryDrawerView({
|
||||
isError,
|
||||
isLoading,
|
||||
items,
|
||||
labels,
|
||||
onClose,
|
||||
onRetry,
|
||||
open,
|
||||
}: MobilePeriodHistoryDrawerViewProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
101
src/modal/mobile/mobile-procedures-modal.tsx
Normal file
101
src/modal/mobile/mobile-procedures-modal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
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 { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
|
||||
function MobileProceduresModal() {
|
||||
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 (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-16'}>
|
||||
{t('game.modals.procedures.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="h-design-280"
|
||||
>
|
||||
<div className={'h-full flex flex-col px-design-5'}>
|
||||
<SmartBackground
|
||||
src={proceduresBg}
|
||||
repeat="no-repeat"
|
||||
size="cover"
|
||||
className={
|
||||
'flex-1 flex h-full min-h-0 w-full flex-col items-center justify-between overflow-hidden rounded-md px-design-14 py-design-18'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex items-center justify-center gap-design-14 pl-design-50 py-design-8'
|
||||
}
|
||||
>
|
||||
<SmartImage
|
||||
className={'w-design-40 mt-design-15 ml-design-10'}
|
||||
alt={'diamond'}
|
||||
src={diamond}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'modal-title-gold-glow mt-design-15 text-design-22 font-bold tracking-[0.06em] text-[#F7DC7A]'
|
||||
}
|
||||
>
|
||||
{currentUser?.coin || 0}
|
||||
</div>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
|
||||
<div
|
||||
className={
|
||||
'h-design-74 flex w-full items-center justify-center gap-design-30'
|
||||
}
|
||||
>
|
||||
<SmartBackground
|
||||
src={withdrawBtnBg}
|
||||
onClick={() => handleOpenWithdrawTopup('withdraw')}
|
||||
className={
|
||||
'flex h-design-74 w-design-120 cursor-pointer items-center justify-center pb-design-6 text-design-14 font-bold transition-[transform,filter] duration-150 hover:brightness-110 active:translate-y-[calc(var(--design-unit)*1)] active:scale-[0.98] active:brightness-95'
|
||||
}
|
||||
>
|
||||
{t('game.modals.procedures.withdraw')}
|
||||
</SmartBackground>
|
||||
<SmartBackground
|
||||
src={topupBtnBg}
|
||||
onClick={() => handleOpenWithdrawTopup('topup')}
|
||||
className={
|
||||
'flex h-design-74 w-design-120 cursor-pointer items-center justify-center pb-design-10 text-design-14 font-bold transition-[transform,filter] duration-150 hover:brightness-110 active:translate-y-[calc(var(--design-unit)*1)] active:scale-[0.98] active:brightness-95'
|
||||
}
|
||||
>
|
||||
{t('game.modals.procedures.topup')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileProceduresModal
|
||||
33
src/modal/mobile/mobile-register-modal.tsx
Normal file
33
src/modal/mobile/mobile-register-modal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { MobileRegisterForm } from '@/features/auth/components/mobile/mobile-register-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function MobileRegisterModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopRegister)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopRegister', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={() => setModalOpen('desktopRegister', false)}
|
||||
title={
|
||||
<div className={'modal-title-glow'}>
|
||||
{t('game.modals.register.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="center"
|
||||
className="!h-[min(calc(var(--design-unit)*520),calc(100dvh-var(--design-unit)*28))]"
|
||||
backdropClassName="backdrop-blur-none"
|
||||
>
|
||||
<MobileRegisterForm onSuccess={handleSubmit} />
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileRegisterModal
|
||||
53
src/modal/mobile/mobile-rules-modal.tsx
Normal file
53
src/modal/mobile/mobile-rules-modal.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function MobileRulesModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopRules)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopRules', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
isNormalBg={true}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-16">
|
||||
{t('game.modals.rules.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="left"
|
||||
className="!h-design-320"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col gap-design-12 px-design-14 pb-design-16 pt-design-4">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-[calc(var(--design-unit)*10)] bg-black/35 px-design-14 py-design-12 text-design-12 leading-[1.62] text-[#B9E7EA] whitespace-pre-line">
|
||||
{t('game.modals.rules.content')}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-center">
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 100%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
onClick={handleClose}
|
||||
className="modal-title-glow flex h-design-42 w-design-120 cursor-pointer items-center justify-center mt-design-1 pb-design-3 pl-design-5 text-design-14 font-bold"
|
||||
>
|
||||
{t('game.modals.rules.confirm')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileRulesModal
|
||||
78
src/modal/mobile/mobile-support-modal.tsx
Normal file
78
src/modal/mobile/mobile-support-modal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { MobileCenterModal } from '@/components/mobile-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 MobileSupportModal() {
|
||||
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 (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
isNormalBg={true}
|
||||
onClose={handleClose}
|
||||
titleAlign="left"
|
||||
title={<div className="modal-title-glow text-design-16">在线客服</div>}
|
||||
className="h-design-500"
|
||||
>
|
||||
<div className="h-full min-h-0 px-design-8 pb-design-10 pt-design-4">
|
||||
<div className="relative h-full min-h-0 overflow-hidden rounded-[calc(var(--design-unit)*7)] 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)*14)_rgba(88,205,218,0.13),0_0_calc(var(--design-unit)*10)_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="客服连线中"
|
||||
className="text-design-12"
|
||||
/>
|
||||
</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>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileSupportModal
|
||||
328
src/modal/mobile/mobile-userInfo-modal.tsx
Normal file
328
src/modal/mobile/mobile-userInfo-modal.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
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 { logoutWithPassword } from '@/api'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
import userInfoBg from '@/assets/system/userInfo-bg.webp'
|
||||
import { MobileCenterModal } from '@/components/mobile-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 { clearAuthenticatedSession } from '@/lib/auth/auth-session'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { cn } from '@/lib/utils'
|
||||
import MobileFinanceRecordsTab from '@/modal/mobile/mobile-finance-records-tab'
|
||||
import MobileWalletRecordsTab from '@/modal/mobile/mobile-wallet-records-tab'
|
||||
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 MobileUserInfoModal() {
|
||||
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 (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-16">
|
||||
{t('game.modals.userInfo.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="h-design-420"
|
||||
>
|
||||
<div className="relative flex h-full min-h-0 w-full flex-col px-design-8 pb-design-10 pt-design-2">
|
||||
<div className="relative mb-design-10 grid h-design-40 shrink-0 grid-cols-3 overflow-hidden rounded-[calc(var(--design-unit)*10)] border border-[#2B8CA3]/35 bg-[#031B24]/75 p-design-3">
|
||||
{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 min-w-0 cursor-pointer items-center justify-center gap-design-5 overflow-hidden rounded-[calc(var(--design-unit)*8)] px-design-5 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-0 rounded-[calc(var(--design-unit)*8)] bg-[linear-gradient(180deg,rgba(254,238,176,0.34)_0%,rgba(254,238,176,0.15)_100%)]"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 430,
|
||||
damping: 36,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="user-info-tab-active-indicator"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-design-10 bottom-0 h-[calc(var(--design-unit)*2)] rounded-full bg-[linear-gradient(90deg,rgba(255,248,214,0.2),rgba(254,238,176,0.95),rgba(255,248,214,0.2))] shadow-[0_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-17 w-design-17" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative z-10 min-w-0 truncate text-center text-design-11 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="min-h-0 flex-1 overflow-hidden">
|
||||
{activeTab === 'profile' ? (
|
||||
<SmartBackground
|
||||
src={userInfoBg}
|
||||
size="140% 100%"
|
||||
className={
|
||||
'relative flex h-design-250 min-h-0 w-full flex-col overflow-hidden bg-top bg-no-repeat px-design-14 py-design-14 text-[#6CCDCF]'
|
||||
}
|
||||
>
|
||||
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center gap-design-12">
|
||||
<SmartImage
|
||||
className="h-design-58 w-design-58 shrink-0"
|
||||
src={currentUser?.headImage || avatar}
|
||||
alt={'avatar'}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 text-design-14 leading-[1.55] text-[#6CCDCF]">
|
||||
<div className="truncate">
|
||||
{t('game.modals.userInfo.profile.name')} :
|
||||
{currentUser?.name ?? '--'}
|
||||
</div>
|
||||
<div className="truncate mt-design-5">
|
||||
{t('game.modals.userInfo.profile.tel')} :{' '}
|
||||
{currentUser?.phone ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-design-24 min-h-0 w-full flex-1 overflow-hidden px-design-12 py-design-10 text-design-13 leading-[1.6]">
|
||||
<div className="text-[#6CCDCF]">
|
||||
{t('game.modals.userInfo.profile.registeredAt')}:
|
||||
<span className="ml-design-8 text-design-12 text-[#599AA3]">
|
||||
{currentUser?.createTime
|
||||
? dayjs
|
||||
.unix(currentUser.createTime)
|
||||
.format('YYYY-MM-DD HH:mm:ss')
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-design-10 flex min-w-0 items-center text-[#6CCDCF]">
|
||||
<span className="shrink-0">
|
||||
{t('auth.register.fields.inviteCode.label')}
|
||||
</span>
|
||||
<span className="ml-design-8 min-w-0 flex-1 truncate text-design-12 text-[#599AA3]">
|
||||
{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-8 flex h-design-26 w-design-26 shrink-0 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-15 w-design-15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-design-30 flex w-full shrink-0 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleLogout()
|
||||
}}
|
||||
disabled={logoutMutation.isPending}
|
||||
className="inline-flex h-design-36 min-w-design-126 cursor-pointer items-center justify-center gap-design-7 rounded-md border border-[#8F4747] bg-[#3A1111]/80 px-design-12 text-design-13 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-15 w-design-15" />
|
||||
<span>
|
||||
{logoutMutation.isPending
|
||||
? t('game.modals.userInfo.profile.loggingOut')
|
||||
: t('game.modals.userInfo.profile.logout')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
) : activeTab === 'financeRecords' ? (
|
||||
<MobileFinanceRecordsTab
|
||||
enabled={open && activeTab === 'financeRecords'}
|
||||
/>
|
||||
) : (
|
||||
<MobileWalletRecordsTab
|
||||
enabled={open && activeTab === 'walletRecords'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileUserInfoModal
|
||||
140
src/modal/mobile/mobile-wallet-records-tab.tsx
Normal file
140
src/modal/mobile/mobile-wallet-records-tab.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
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 '@/hooks/use-wallet-records-vm'
|
||||
|
||||
function MobileWalletRecordsTab({ 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: () => 52,
|
||||
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 min-h-0 w-full flex-col p-design-4">
|
||||
<div className="mb-design-8 flex shrink-0 items-center justify-between gap-design-8 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-8 py-design-7">
|
||||
<div className="text-design-14 font-medium text-[#BFEAEC]">
|
||||
{vm.headers.type}
|
||||
</div>
|
||||
<div className="shrink-0 text-design-11 text-[#7ECAD1]">
|
||||
{vm.pageLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-x-auto rounded-md">
|
||||
<div className="min-w-design-520">
|
||||
<div className="grid grid-cols-[minmax(0,0.72fr)_minmax(0,0.78fr)_minmax(0,0.78fr)_minmax(0,1.18fr)_minmax(0,0.88fr)] gap-design-5 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-9 py-design-8 text-design-11 text-[#7ECAD1]">
|
||||
<div className="text-center">{vm.headers.amount}</div>
|
||||
<div>{vm.headers.balanceBefore}</div>
|
||||
<div>{vm.headers.balanceAfter}</div>
|
||||
<div>{vm.headers.time}</div>
|
||||
<div>{vm.headers.remark}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="mt-design-7 max-h-[calc(var(--design-unit)*340)] min-h-0 overflow-y-auto pr-design-2"
|
||||
>
|
||||
{vm.isLoading ? (
|
||||
<DataLoadingIndicator label={vm.loadingText} />
|
||||
) : vm.isError ? (
|
||||
<div className="py-design-24 text-center text-design-12 text-[#6CCDCF]">
|
||||
{vm.loadFailedText}
|
||||
</div>
|
||||
) : vm.items.length === 0 ? (
|
||||
<div className="py-design-24 text-center text-design-12 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-7"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{item ? (
|
||||
<motion.div
|
||||
className="grid h-design-45 grid-cols-[minmax(0,0.72fr)_minmax(0,0.78fr)_minmax(0,0.78fr)_minmax(0,1.18fr)_minmax(0,0.88fr)] items-center gap-design-5 rounded-md bg-[#0A4252] px-design-9 py-design-8 text-design-11 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-center 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="whitespace-nowrap text-[#BFEAEC]">
|
||||
{item.timeLabel}
|
||||
</div>
|
||||
<div
|
||||
className="truncate text-white"
|
||||
title={item.remarkLabel}
|
||||
>
|
||||
{item.remarkLabel}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<DataLoadingIndicator
|
||||
compact
|
||||
label={vm.loadingText}
|
||||
className="h-design-45 rounded-md bg-[#0A4252]/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileWalletRecordsTab
|
||||
39
src/modal/mobile/mobile-withdraw-topup-modal.tsx
Normal file
39
src/modal/mobile/mobile-withdraw-topup-modal.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import MobileTopup from '@/features/game/components/mobile/mobile-topup.tsx'
|
||||
import MobileWithdraw from '@/features/game/components/mobile/mobile-withdraw.tsx'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function MobileWithdrawTopupModal() {
|
||||
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 (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-16 uppercase'}>
|
||||
{type === 'withdraw'
|
||||
? t('game.modals.withdrawTopup.applyWithdraw')
|
||||
: t('game.modals.withdrawTopup.applyTopup')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="h-design-510"
|
||||
>
|
||||
<div className={'h-full min-h-0 w-full'}>
|
||||
{type === 'withdraw' ? <MobileWithdraw /> : <MobileTopup />}
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileWithdrawTopupModal
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { EntryPage } from '@/features/game/entry/entry-page.tsx'
|
||||
import { MainEntryPage } from '@/main/main-entry-page.tsx'
|
||||
|
||||
export const Route = createFileRoute('/$lang/')({
|
||||
component: EntryPage,
|
||||
component: MainEntryPage,
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PASSWORD_MAX_LENGTH,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
} from '@/constants'
|
||||
import type { LoginFormValues, RegisterFormValues } from '@/type'
|
||||
|
||||
const usernameSchema = z
|
||||
.string()
|
||||
@@ -42,6 +43,3 @@ export const registerFormSchema = z
|
||||
message: 'auth.validation.confirmPassword.mismatch',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
export type LoginFormValues = z.infer<typeof loginFormSchema>
|
||||
export type RegisterFormValues = z.infer<typeof registerFormSchema>
|
||||
@@ -2,14 +2,7 @@ import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
|
||||
import { AUDIO_PREFERENCES_STORAGE_KEY } from '@/constants'
|
||||
|
||||
interface AudioPreferenceState {
|
||||
hasUnlockedSoundPlayback: boolean
|
||||
markSoundPlaybackUnlocked: () => void
|
||||
isSoundEnabled: boolean
|
||||
setSoundEnabled: (enabled: boolean) => void
|
||||
toggleSoundEnabled: () => void
|
||||
}
|
||||
import type { AudioPreferenceState } from '@/type'
|
||||
|
||||
export const useAudioStore = create<AudioPreferenceState>()(
|
||||
persist(
|
||||
|
||||
@@ -2,37 +2,7 @@ import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
|
||||
import { APP_PREFERENCES_STORAGE_KEY, AUTH_STORAGE_KEY } from '@/constants'
|
||||
|
||||
/**@description 未登录 | 已登录 | 正在从存储恢复数据 */
|
||||
export type AuthStatus = 'anonymous' | 'authenticated' | 'restoring'
|
||||
|
||||
export interface AuthUser {
|
||||
createTime?: number
|
||||
channelId?: number
|
||||
coin?: string
|
||||
currentStreak?: number
|
||||
email?: string
|
||||
headImage?: string
|
||||
id: string
|
||||
isJackpot?: boolean
|
||||
lastBetPeriodNo?: string
|
||||
name?: string
|
||||
oddsFactor?: number
|
||||
phone?: string
|
||||
registerInviteCode?: string
|
||||
riskFlags?: number
|
||||
roles?: string[]
|
||||
streakLevel?: number
|
||||
username?: string
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
export interface AuthSessionInput {
|
||||
accessToken: string
|
||||
accessTokenExpiresAt?: number | null
|
||||
currentUser?: AuthUser | null
|
||||
refreshToken?: string | null
|
||||
}
|
||||
import type { AuthSessionInput, AuthStatus, AuthUser } from '@/type'
|
||||
|
||||
interface PersistedAuthState {
|
||||
accessToken: string | null
|
||||
|
||||
@@ -1,43 +1,12 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import { AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD } from '@/constants'
|
||||
import type { BetSelection } from '@/features/game/shared'
|
||||
|
||||
export interface AutoHostingStopRules {
|
||||
stopIfBalanceBelow: {
|
||||
amount: number
|
||||
enabled: boolean
|
||||
}
|
||||
stopIfSingleWinAbove: {
|
||||
amount: number
|
||||
enabled: boolean
|
||||
}
|
||||
stopOnJackpot: boolean
|
||||
}
|
||||
|
||||
interface StartAutoHostingInput {
|
||||
balanceAfterBet: number | null
|
||||
rules: AutoHostingStopRules
|
||||
selections: BetSelection[]
|
||||
}
|
||||
|
||||
export interface GameAutoHostingStoreState {
|
||||
balanceAfterBet: number | null
|
||||
completedRounds: number
|
||||
isHosting: boolean
|
||||
lastIsJackpot: boolean | null
|
||||
lastSingleWinAmount: number | null
|
||||
lastSubmittedRoundId: string | null
|
||||
rules: AutoHostingStopRules
|
||||
selections: BetSelection[]
|
||||
markRoundSubmitted: (roundId: string, balanceAfterBet: number | null) => void
|
||||
recordBetWin: (input: {
|
||||
isJackpot: boolean
|
||||
singleWinAmount: number | null
|
||||
}) => void
|
||||
startHosting: (input: StartAutoHostingInput) => void
|
||||
stopHosting: () => void
|
||||
}
|
||||
import type {
|
||||
AutoHostingStopRules,
|
||||
BetSelection,
|
||||
GameAutoHostingStoreState,
|
||||
StartAutoHostingInput,
|
||||
} from '@/type'
|
||||
|
||||
const DEFAULT_AUTO_HOSTING_RULES: AutoHostingStopRules = {
|
||||
stopIfBalanceBelow: {
|
||||
|
||||
@@ -1,54 +1,31 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import type {
|
||||
BetSelection,
|
||||
Chip,
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
HistoryEntry,
|
||||
RoundPhase,
|
||||
RoundSnapshot,
|
||||
TrendEntry,
|
||||
} from '@/features/game/shared'
|
||||
import { DEFAULT_ACTIVE_CHIP_ID } from '@/constants'
|
||||
import {
|
||||
buildGameCellViewModels,
|
||||
createEmptyGameBootstrapSnapshot,
|
||||
DEFAULT_ACTIVE_CHIP_ID,
|
||||
getChipById,
|
||||
getRecentWinningCellIds,
|
||||
getSelectionTotal,
|
||||
groupSelectionsByCell,
|
||||
} from '@/features/game/shared'
|
||||
|
||||
type GameRoundSlice = Pick<
|
||||
GameBootstrapSnapshot,
|
||||
| 'cells'
|
||||
| 'chips'
|
||||
| 'history'
|
||||
| 'maxSelectionCount'
|
||||
| 'round'
|
||||
| 'selections'
|
||||
| 'trends'
|
||||
>
|
||||
import type {
|
||||
BetSelection,
|
||||
Chip,
|
||||
GameCell,
|
||||
GameRoundSlice,
|
||||
GameRoundStoreData,
|
||||
GameRoundStoreState,
|
||||
HistoryEntry,
|
||||
RevealAnimationPhase,
|
||||
RevealAnimationState,
|
||||
RewardAnimationType,
|
||||
RoundPhase,
|
||||
RoundSnapshot,
|
||||
TrendEntry,
|
||||
} from '@/type'
|
||||
|
||||
const MIN_BET_QUANTITY = 1
|
||||
|
||||
export type RevealAnimationPhase = 'idle' | 'spinning' | 'stopping' | 'result'
|
||||
export type RewardAnimationType = 'none' | 'small' | 'big'
|
||||
|
||||
export interface RevealAnimationState {
|
||||
pendingRewardAmount: string | null
|
||||
pendingRewardKey: string | null
|
||||
pendingRewardRoundId: string | null
|
||||
pendingRewardType: RewardAnimationType
|
||||
phase: RevealAnimationPhase
|
||||
revealKey: string | null
|
||||
rewardAmount: string | null
|
||||
rewardType: RewardAnimationType
|
||||
roundId: string | null
|
||||
winningCellId: number | null
|
||||
}
|
||||
|
||||
function createIdleRevealAnimation(): RevealAnimationState {
|
||||
return {
|
||||
pendingRewardAmount: null,
|
||||
@@ -143,39 +120,6 @@ function resolveSelectionQuantity(
|
||||
return normalizeBetQuantity(firstSelection.amount / chip.amount)
|
||||
}
|
||||
|
||||
export interface GameRoundStoreState extends GameRoundSlice {
|
||||
activeChipId: string
|
||||
activeBetQuantity: number
|
||||
adjustBetQuantity: (delta: number) => void
|
||||
clearSelections: () => void
|
||||
clearRewardAnimation: () => void
|
||||
finishRevealAnimation: () => void
|
||||
hydrateRound: (snapshot: GameRoundSlice) => void
|
||||
placeBet: (cellId: number) => void
|
||||
playPreparedRevealAnimation: (roundId?: string | null) => void
|
||||
prepareRevealAnimation: (input: {
|
||||
revealKey: string
|
||||
roundId: string
|
||||
winningCellId: number
|
||||
}) => void
|
||||
recentSuccessfulSelections: BetSelection[]
|
||||
revealAnimation: RevealAnimationState
|
||||
removeSelectionsForCell: (cellId: number) => void
|
||||
restoreRecentSuccessfulSelections: () => boolean
|
||||
setRecentSuccessfulSelections: (selections: BetSelection[]) => void
|
||||
selectChip: (chipId: string) => void
|
||||
setPhase: (phase: RoundPhase) => void
|
||||
setPendingBetWinReward: (input: {
|
||||
isJackpot: boolean
|
||||
revealKey: string
|
||||
roundId?: string | null
|
||||
totalWin: string
|
||||
winningCellId?: number | null
|
||||
}) => void
|
||||
syncRound: (round: Partial<RoundSnapshot>) => void
|
||||
upsertSelections: (selections: BetSelection[]) => void
|
||||
}
|
||||
|
||||
function createInitialRoundState(): GameRoundSlice & {
|
||||
activeChipId: string
|
||||
activeBetQuantity: number
|
||||
@@ -596,15 +540,5 @@ export const selectSelectionsByCell = (state: GameRoundStoreState) =>
|
||||
groupSelectionsByCell(state.selections)
|
||||
|
||||
export type GameRoundStore = typeof useGameRoundStore
|
||||
export type GameRoundStoreData = Pick<
|
||||
GameRoundStoreState,
|
||||
| 'cells'
|
||||
| 'chips'
|
||||
| 'history'
|
||||
| 'maxSelectionCount'
|
||||
| 'round'
|
||||
| 'selections'
|
||||
| 'trends'
|
||||
>
|
||||
|
||||
export type { BetSelection, GameCell, HistoryEntry, RoundSnapshot, TrendEntry }
|
||||
|
||||
@@ -4,33 +4,25 @@ import {
|
||||
CONNECTION_LATENCY_FAIR_MS,
|
||||
MAX_JACKPOT_BROADCAST_COUNT,
|
||||
} from '@/constants'
|
||||
import {
|
||||
createEmptyGameBootstrapSnapshot,
|
||||
getUnreadAnnouncementCount,
|
||||
getVisibleAnnouncements,
|
||||
} from '@/features/game/shared'
|
||||
import type {
|
||||
AnnouncementState,
|
||||
ConnectionState,
|
||||
ConnectionStatus,
|
||||
DashboardState,
|
||||
GameBootstrapSnapshot,
|
||||
} from '@/features/game/shared'
|
||||
import {
|
||||
createEmptyGameBootstrapSnapshot,
|
||||
getUnreadAnnouncementCount,
|
||||
getVisibleAnnouncements,
|
||||
} from '@/features/game/shared'
|
||||
JackpotBroadcastItem,
|
||||
} from '@/type'
|
||||
|
||||
type GameSessionSlice = Pick<
|
||||
GameBootstrapSnapshot,
|
||||
'announcements' | 'connection' | 'dashboard'
|
||||
>
|
||||
|
||||
export interface JackpotBroadcastItem {
|
||||
id: string
|
||||
message: string
|
||||
nickname: string
|
||||
periodNo: string
|
||||
receivedAt: string
|
||||
totalWin: string
|
||||
}
|
||||
|
||||
type JackpotBroadcastInput = Omit<JackpotBroadcastItem, 'receivedAt'>
|
||||
|
||||
export interface GameSessionStoreState extends GameSessionSlice {
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import { create } from 'zustand'
|
||||
import { INITIAL_MODAL_VISIBILITY, MODAL_KEYS } from '@/constants'
|
||||
import type { WithdrawTopupType } from '@/type'
|
||||
import type { ModalKey, ModalStoreState } from '@/type'
|
||||
|
||||
export { MODAL_KEYS }
|
||||
|
||||
export type ModalKey = (typeof MODAL_KEYS)[number]
|
||||
|
||||
type ModalVisibilityMap = Record<ModalKey, boolean>
|
||||
|
||||
export interface ModalStoreState {
|
||||
modals: ModalVisibilityMap
|
||||
withdrawTopupType: WithdrawTopupType
|
||||
closeAllModals: () => void
|
||||
openExclusiveModal: (key: ModalKey) => void
|
||||
setModalOpen: (key: ModalKey, open: boolean) => void
|
||||
setWithdrawTopupType: (type: WithdrawTopupType) => void
|
||||
}
|
||||
|
||||
export const useModalStore = create<ModalStoreState>()((set) => ({
|
||||
modals: INITIAL_MODAL_VISIBILITY,
|
||||
withdrawTopupType: 'withdraw',
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
msg?: string
|
||||
data: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ApiErrorOptions {
|
||||
message: string
|
||||
status?: number
|
||||
data?: unknown
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface FinanceCurrencyConfigDto {
|
||||
code: string
|
||||
deposit_coins_per_fiat: string
|
||||
@@ -49,11 +63,7 @@ export interface DepositTierItemDto {
|
||||
amount?: number | string
|
||||
bonus_amount?: number | string
|
||||
bonus_coins?: number | string
|
||||
channels?: Array<{
|
||||
code?: string
|
||||
name?: string
|
||||
sort?: number | string
|
||||
}>
|
||||
channels?: Array<{ code?: string; name?: string; sort?: number | string }>
|
||||
coins?: number | string
|
||||
currency?: string
|
||||
desc?: string
|
||||
@@ -120,11 +130,7 @@ export interface DepositWithdrawConfig {
|
||||
export interface DepositTierItem {
|
||||
amount: number
|
||||
bonusAmount: number
|
||||
channels: Array<{
|
||||
code: string
|
||||
name: string
|
||||
sort: number
|
||||
}>
|
||||
channels: Array<{ code: string; name: string; sort: number }>
|
||||
coins: number
|
||||
currency: string | null
|
||||
desc: string
|
||||
@@ -162,18 +168,18 @@ export interface DepositCreateResponseDto {
|
||||
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 FinanceOrderItemDto {
|
||||
amount: number | string
|
||||
bonus_amount: number | string
|
||||
order_no: string
|
||||
}
|
||||
|
||||
export interface FinanceOrderListDto {
|
||||
list: FinanceOrderItemDto[]
|
||||
pagination: FinanceOrderPaginationDto
|
||||
@@ -1,5 +1,36 @@
|
||||
import type { SMS_SEND_EVENT_REGISTER } from '@/constants'
|
||||
import type { AuthSessionInput, AuthUser } from '@/store/auth'
|
||||
|
||||
export type AuthStatus = 'anonymous' | 'authenticated' | 'restoring'
|
||||
|
||||
export type AuthSubmitContext = 'login' | 'register'
|
||||
|
||||
export interface AuthUser {
|
||||
createTime?: number
|
||||
channelId?: number
|
||||
coin?: string
|
||||
currentStreak?: number
|
||||
email?: string
|
||||
headImage?: string
|
||||
id: string
|
||||
isJackpot?: boolean
|
||||
lastBetPeriodNo?: string
|
||||
name?: string
|
||||
oddsFactor?: number
|
||||
phone?: string
|
||||
registerInviteCode?: string
|
||||
riskFlags?: number
|
||||
roles?: string[]
|
||||
streakLevel?: number
|
||||
username?: string
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
export interface AuthSessionInput {
|
||||
accessToken: string
|
||||
accessTokenExpiresAt?: number | null
|
||||
currentUser?: AuthUser | null
|
||||
refreshToken?: string | null
|
||||
}
|
||||
|
||||
export interface AuthApiEnvelope<T> {
|
||||
code: number
|
||||
@@ -107,68 +138,37 @@ export interface SendSmsCodeResult {
|
||||
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 type LoginFormValues = { password: string; username: string }
|
||||
|
||||
export type RegisterFormValues = {
|
||||
captcha: string
|
||||
confirmPassword: string
|
||||
inviteCode: string
|
||||
mobile: string
|
||||
password: string
|
||||
}
|
||||
|
||||
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 interface UseLoginFormOptions {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
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 interface UseRegisterFormOptions {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
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 interface ClearAuthenticatedSessionOptions {
|
||||
clearBrowserStorage?: boolean
|
||||
clearQueryCache?: boolean
|
||||
}
|
||||
|
||||
export function normalizeRefreshAuthSession(
|
||||
dto: RefreshTokenDto,
|
||||
): AuthSessionInput {
|
||||
return {
|
||||
accessToken: dto['user-token'],
|
||||
accessTokenExpiresAt: Date.now() + dto.expires_in * 1000,
|
||||
refreshToken: dto.refresh_token ?? null,
|
||||
}
|
||||
export interface UnauthorizedSessionOptions
|
||||
extends ClearAuthenticatedSessionOptions {
|
||||
openLoginModal?: boolean
|
||||
showLoginRequiredToast?: boolean
|
||||
}
|
||||
|
||||
export type CurrentUserInitializer = () => Promise<AuthUser | null>
|
||||
|
||||
export type RefreshSessionHandler = (
|
||||
refreshToken: string,
|
||||
) => Promise<AuthSessionInput | null>
|
||||
638
src/type/game.type.ts
Normal file
638
src/type/game.type.ts
Normal file
@@ -0,0 +1,638 @@
|
||||
import type {
|
||||
ANNOUNCEMENT_TONES,
|
||||
BET_SOURCES,
|
||||
CELL_STATUSES,
|
||||
CONNECTION_STATUSES,
|
||||
CONNECTION_TRANSPORTS,
|
||||
ROUND_PHASES,
|
||||
TREND_DIRECTIONS,
|
||||
} from '@/constants'
|
||||
|
||||
// ─── Enum Union Types ───────────────────────────────────────────────
|
||||
|
||||
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 type GamePeriodStatus =
|
||||
| 'betting'
|
||||
| 'locked'
|
||||
| 'settling'
|
||||
| 'payouting'
|
||||
| 'finished'
|
||||
| 'void'
|
||||
| (string & {})
|
||||
export type RevealAnimationPhase = 'idle' | 'spinning' | 'stopping' | 'result'
|
||||
export type RewardAnimationType = 'none' | 'small' | 'big'
|
||||
export type FinanceRecordType = 'deposit' | 'withdraw'
|
||||
export type ConfirmState =
|
||||
| 'idle'
|
||||
| 'ready'
|
||||
| 'insufficient'
|
||||
| 'limit'
|
||||
| 'submitting'
|
||||
export type HistoryResultState = 'lost' | 'pending' | 'win'
|
||||
export type DesktopAnimalWarningType = 'balance' | 'betLimit' | 'limit'
|
||||
|
||||
// ─── Game Domain Models ─────────────────────────────────────────────
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface PeriodHistoryDisplayItem {
|
||||
displayPeriodNo: string
|
||||
displayResultNumber: string
|
||||
image: string
|
||||
isOdd: boolean
|
||||
openTime: number
|
||||
periodNo: string
|
||||
resultNumber: number
|
||||
}
|
||||
|
||||
export interface FlowerImageAsset {
|
||||
animalUrl: string
|
||||
id: number
|
||||
rewardUrl: string
|
||||
}
|
||||
|
||||
export interface RevealAnimationState {
|
||||
pendingRewardAmount: string | null
|
||||
pendingRewardKey: string | null
|
||||
pendingRewardRoundId: string | null
|
||||
pendingRewardType: RewardAnimationType
|
||||
phase: RevealAnimationPhase
|
||||
revealKey: string | null
|
||||
rewardAmount: string | null
|
||||
rewardType: RewardAnimationType
|
||||
roundId: string | null
|
||||
winningCellId: number | null
|
||||
}
|
||||
|
||||
export interface AutoHostingStopRules {
|
||||
stopIfBalanceBelow: { amount: number; enabled: boolean }
|
||||
stopIfSingleWinAbove: { amount: number; enabled: boolean }
|
||||
stopOnJackpot: boolean
|
||||
}
|
||||
|
||||
export interface JackpotBroadcastItem {
|
||||
id: string
|
||||
message: string
|
||||
nickname: string
|
||||
periodNo: string
|
||||
receivedAt: string
|
||||
totalWin: string
|
||||
}
|
||||
|
||||
export interface UserStreakMessageData {
|
||||
currentStreak: number
|
||||
oddsFactor?: number
|
||||
streakLevel?: number
|
||||
}
|
||||
|
||||
export interface PeriodEventData {
|
||||
openTime: number | null
|
||||
periodNo: string
|
||||
resultNumber: number | null
|
||||
}
|
||||
|
||||
export interface WalletChangedData {
|
||||
coin: string
|
||||
}
|
||||
|
||||
export type AudioAssetId = 'hall-bgm'
|
||||
|
||||
export interface AudioAssetDefinition {
|
||||
id: AudioAssetId
|
||||
loop?: boolean
|
||||
src: string
|
||||
volume?: number
|
||||
}
|
||||
|
||||
// ─── Game DTOs ──────────────────────────────────────────────────────
|
||||
|
||||
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 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 GameLobbyInitResult {
|
||||
runtimeEnabled: boolean
|
||||
serverTime: number
|
||||
snapshot: GameBootstrapSnapshot
|
||||
userSnapshot: GameLobbyInitDto['user_snapshot']
|
||||
}
|
||||
|
||||
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 interface GamePeriodHistoryItemDto {
|
||||
open_time: number
|
||||
period_no: string
|
||||
result_number: number
|
||||
}
|
||||
|
||||
// ─── Store State Types ─────────────────────────────────────────────
|
||||
|
||||
export type GameRoundSlice = Pick<
|
||||
GameBootstrapSnapshot,
|
||||
| 'cells'
|
||||
| 'chips'
|
||||
| 'history'
|
||||
| 'maxSelectionCount'
|
||||
| 'round'
|
||||
| 'selections'
|
||||
| 'trends'
|
||||
>
|
||||
|
||||
export interface GameRoundStoreState extends GameRoundSlice {
|
||||
activeChipId: string
|
||||
activeBetQuantity: number
|
||||
adjustBetQuantity: (delta: number) => void
|
||||
clearSelections: () => void
|
||||
clearRewardAnimation: () => void
|
||||
finishRevealAnimation: () => void
|
||||
hydrateRound: (snapshot: GameRoundSlice) => void
|
||||
placeBet: (cellId: number) => void
|
||||
playPreparedRevealAnimation: (roundId?: string | null) => void
|
||||
prepareRevealAnimation: (input: {
|
||||
revealKey: string
|
||||
roundId: string
|
||||
winningCellId: number
|
||||
}) => void
|
||||
recentSuccessfulSelections: BetSelection[]
|
||||
revealAnimation: RevealAnimationState
|
||||
removeSelectionsForCell: (cellId: number) => void
|
||||
restoreRecentSuccessfulSelections: () => boolean
|
||||
setRecentSuccessfulSelections: (selections: BetSelection[]) => void
|
||||
selectChip: (chipId: string) => void
|
||||
setPhase: (phase: RoundPhase) => void
|
||||
setPendingBetWinReward: (input: {
|
||||
isJackpot: boolean
|
||||
revealKey: string
|
||||
roundId?: string | null
|
||||
totalWin: string
|
||||
winningCellId?: number | null
|
||||
}) => void
|
||||
syncRound: (round: Partial<RoundSnapshot>) => void
|
||||
upsertSelections: (selections: BetSelection[]) => void
|
||||
}
|
||||
|
||||
export type GameRoundStoreData = Pick<
|
||||
GameRoundStoreState,
|
||||
| 'cells'
|
||||
| 'chips'
|
||||
| 'history'
|
||||
| 'maxSelectionCount'
|
||||
| 'round'
|
||||
| 'selections'
|
||||
| 'trends'
|
||||
>
|
||||
|
||||
export interface GameSessionStoreState {
|
||||
announcements: AnnouncementState
|
||||
connection: ConnectionState
|
||||
dashboard: DashboardState
|
||||
dismissAnnouncement: (announcementId: string) => void
|
||||
hydrateSession: (snapshot: {
|
||||
announcements: AnnouncementState
|
||||
connection: ConnectionState
|
||||
dashboard: DashboardState
|
||||
}) => void
|
||||
jackpotBroadcasts: JackpotBroadcastItem[]
|
||||
markAnnouncementRead: (announcementId: string) => void
|
||||
pushJackpotBroadcasts: (
|
||||
broadcasts: Omit<JackpotBroadcastItem, 'receivedAt'>[],
|
||||
) => void
|
||||
requestRealtimeConnection: () => void
|
||||
resetRealtimeConnectionRequest: () => void
|
||||
shouldConnectRealtime: boolean
|
||||
setConnectionLatency: (latencyMs: number | null) => void
|
||||
setConnectionStatus: (status: ConnectionStatus) => void
|
||||
syncConnection: (patch: Partial<ConnectionState>) => void
|
||||
syncDashboard: (patch: Partial<DashboardState>) => void
|
||||
}
|
||||
|
||||
export type GameSessionStoreData = Pick<
|
||||
GameSessionStoreState,
|
||||
'announcements' | 'connection' | 'dashboard' | 'jackpotBroadcasts'
|
||||
>
|
||||
|
||||
export interface StartAutoHostingInput {
|
||||
balanceAfterBet: number | null
|
||||
rules: AutoHostingStopRules
|
||||
selections: BetSelection[]
|
||||
}
|
||||
|
||||
export interface GameAutoHostingStoreState {
|
||||
balanceAfterBet: number | null
|
||||
completedRounds: number
|
||||
isHosting: boolean
|
||||
lastIsJackpot: boolean | null
|
||||
lastSingleWinAmount: number | null
|
||||
lastSubmittedRoundId: string | null
|
||||
rules: AutoHostingStopRules
|
||||
selections: BetSelection[]
|
||||
markRoundSubmitted: (roundId: string, balanceAfterBet: number | null) => void
|
||||
recordBetWin: (input: {
|
||||
isJackpot: boolean
|
||||
singleWinAmount: number | null
|
||||
}) => void
|
||||
startHosting: (input: StartAutoHostingInput) => void
|
||||
stopHosting: () => void
|
||||
}
|
||||
@@ -1,17 +1,4 @@
|
||||
export type WithdrawTopupType = 'withdraw' | 'topup'
|
||||
|
||||
/** @description 后端统一响应体结构。 */
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
msg?: string
|
||||
data: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** @description 后端统一错误响应体结构。 */
|
||||
export interface ApiErrorOptions {
|
||||
message: string
|
||||
status?: number
|
||||
data?: unknown
|
||||
url?: string
|
||||
}
|
||||
export * from './api.type'
|
||||
export * from './auth.type'
|
||||
export * from './game.type'
|
||||
export * from './system.type'
|
||||
|
||||
54
src/type/system.type.ts
Normal file
54
src/type/system.type.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { MODAL_KEYS, SUPPORTED_LANGUAGES } from '@/constants'
|
||||
|
||||
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
||||
|
||||
export type ModalKey = (typeof MODAL_KEYS)[number]
|
||||
|
||||
export type WithdrawTopupType = 'withdraw' | 'topup'
|
||||
|
||||
export type NotificationType =
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'info'
|
||||
| 'loading'
|
||||
|
||||
export interface AudioPreferenceState {
|
||||
hasUnlockedSoundPlayback: boolean
|
||||
markSoundPlaybackUnlocked: () => void
|
||||
isSoundEnabled: boolean
|
||||
setSoundEnabled: (enabled: boolean) => void
|
||||
toggleSoundEnabled: () => void
|
||||
}
|
||||
|
||||
export interface NotifyOptions {
|
||||
description?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface DocumentMetadata {
|
||||
description?: string
|
||||
robots?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface ImportMetaEnv {
|
||||
readonly VITE_APP_ENV: 'development' | 'production' | 'test'
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_WEBSOCKET_URL?: string
|
||||
readonly VITE_ENABLE_QUERY_DEVTOOLS: 'true' | 'false'
|
||||
readonly VITE_ENABLE_REQUEST_LOG: 'true' | 'false'
|
||||
}
|
||||
|
||||
export interface ModalStoreState {
|
||||
modals: Record<ModalKey, boolean>
|
||||
withdrawTopupType: WithdrawTopupType
|
||||
closeAllModals: () => void
|
||||
openExclusiveModal: (key: ModalKey) => void
|
||||
setModalOpen: (key: ModalKey, open: boolean) => void
|
||||
setWithdrawTopupType: (type: WithdrawTopupType) => void
|
||||
}
|
||||
|
||||
export interface RequireAuthenticatedSessionOptions {
|
||||
fallbackLanguage?: AppLanguage
|
||||
}
|
||||
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@@ -1,13 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_ENV: 'development' | 'production' | 'test'
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_WEBSOCKET_URL?: string
|
||||
readonly VITE_ENABLE_QUERY_DEVTOOLS: 'true' | 'false'
|
||||
readonly VITE_ENABLE_REQUEST_LOG: 'true' | 'false'
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user