+
{index + 1}. {notice.title}
-
+
{dayjs(notice.publish_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)}
-
+
{notice.content ?? ''}
@@ -180,8 +268,20 @@ export function EntryNoticeGateModal() {
)}
-
-
+
)
}
+
+export function MobileEntryNoticeGateModal() {
+ return
+}
diff --git a/src/features/game/components/shared/period-history-list.tsx b/src/features/game/components/shared/period-history-list.tsx
index 70376af..e555f40 100644
--- a/src/features/game/components/shared/period-history-list.tsx
+++ b/src/features/game/components/shared/period-history-list.tsx
@@ -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 {
diff --git a/src/features/game/index.ts b/src/features/game/index.ts
deleted file mode 100644
index 128378a..0000000
--- a/src/features/game/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './api'
-export * from './shared'
diff --git a/src/features/game/shared/constants.ts b/src/features/game/shared/constants.ts
deleted file mode 100644
index 91aa2e3..0000000
--- a/src/features/game/shared/constants.ts
+++ /dev/null
@@ -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'
diff --git a/src/features/game/shared/flower-assets.ts b/src/features/game/shared/flower-assets.ts
index 1338a2d..9f0f795 100644
--- a/src/features/game/shared/flower-assets.ts
+++ b/src/features/game/shared/flower-assets.ts
@@ -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
-export interface FlowerImageAsset {
- animalUrl: string
- id: number
- rewardUrl: string
-}
-
export const FLOWER_IMAGE_LIST: FlowerImageAsset[] = Array.from(
{ length: 36 },
(_, index) => {
diff --git a/src/features/game/shared/index.ts b/src/features/game/shared/index.ts
index c62886e..20e45a1 100644
--- a/src/features/game/shared/index.ts
+++ b/src/features/game/shared/index.ts
@@ -1,5 +1,3 @@
-export * from './constants'
export * from './flower-assets'
export * from './initial-state'
export * from './selectors'
-export * from './types'
diff --git a/src/features/game/shared/initial-state.ts b/src/features/game/shared/initial-state.ts
index 389a571..3502e1f 100644
--- a/src/features/game/shared/initial-state.ts
+++ b/src/features/game/shared/initial-state.ts
@@ -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 {
diff --git a/src/features/game/shared/selectors.ts b/src/features/game/shared/selectors.ts
index f8c84cf..f77c8d2 100644
--- a/src/features/game/shared/selectors.ts
+++ b/src/features/game/shared/selectors.ts
@@ -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
diff --git a/src/features/game/shared/types.ts b/src/features/game/shared/types.ts
deleted file mode 100644
index 260d4ad..0000000
--- a/src/features/game/shared/types.ts
+++ /dev/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
-}
diff --git a/src/features/auth/hooks/auth-error-key.ts b/src/hooks/auth-error-key.ts
similarity index 97%
rename from src/features/auth/hooks/auth-error-key.ts
rename to src/hooks/auth-error-key.ts
index feea738..14cb2e9 100644
--- a/src/features/auth/hooks/auth-error-key.ts
+++ b/src/hooks/auth-error-key.ts
@@ -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)
diff --git a/src/features/game/hooks/use-animal-vm.ts b/src/hooks/use-animal-vm.ts
similarity index 98%
rename from src/features/game/hooks/use-animal-vm.ts
rename to src/hooks/use-animal-vm.ts
index 8729d19..dddb845 100644
--- a/src/features/game/hooks/use-animal-vm.ts
+++ b/src/hooks/use-animal-vm.ts
@@ -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
diff --git a/src/features/game/hooks/use-app-language.ts b/src/hooks/use-app-language.ts
similarity index 100%
rename from src/features/game/hooks/use-app-language.ts
rename to src/hooks/use-app-language.ts
diff --git a/src/features/auth/hooks/use-auth.ts b/src/hooks/use-auth.ts
similarity index 100%
rename from src/features/auth/hooks/use-auth.ts
rename to src/hooks/use-auth.ts
diff --git a/src/features/game/hooks/use-auto-hosting-runner.ts b/src/hooks/use-auto-hosting-runner.ts
similarity index 98%
rename from src/features/game/hooks/use-auto-hosting-runner.ts
rename to src/hooks/use-auto-hosting-runner.ts
index d97469c..7cc7c79 100644
--- a/src/features/game/hooks/use-auto-hosting-runner.ts
+++ b/src/hooks/use-auto-hosting-runner.ts
@@ -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') {
diff --git a/src/features/game/hooks/use-deposit-tier-list.ts b/src/hooks/use-deposit-tier-list.ts
similarity index 90%
rename from src/features/game/hooks/use-deposit-tier-list.ts
rename to src/hooks/use-deposit-tier-list.ts
index 1745a8f..40e0b01 100644
--- a/src/features/game/hooks/use-deposit-tier-list.ts
+++ b/src/hooks/use-deposit-tier-list.ts
@@ -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()
diff --git a/src/features/game/hooks/use-deposit-withdraw-config.ts b/src/hooks/use-deposit-withdraw-config.ts
similarity index 89%
rename from src/features/game/hooks/use-deposit-withdraw-config.ts
rename to src/hooks/use-deposit-withdraw-config.ts
index 2cae161..5ff9461 100644
--- a/src/features/game/hooks/use-deposit-withdraw-config.ts
+++ b/src/hooks/use-deposit-withdraw-config.ts
@@ -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()
diff --git a/src/features/game/hooks/use-finance-records-vm.ts b/src/hooks/use-finance-records-vm.ts
similarity index 96%
rename from src/features/game/hooks/use-finance-records-vm.ts
rename to src/hooks/use-finance-records-vm.ts
index 72d4687..f3af98b 100644
--- a/src/features/game/hooks/use-finance-records-vm.ts
+++ b/src/hooks/use-finance-records-vm.ts
@@ -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
diff --git a/src/features/game/hooks/use-game-control-vm.ts b/src/hooks/use-game-control-vm.ts
similarity index 98%
rename from src/features/game/hooks/use-game-control-vm.ts
rename to src/hooks/use-game-control-vm.ts
index 8a32dde..ca6d467 100644
--- a/src/features/game/hooks/use-game-control-vm.ts
+++ b/src/hooks/use-game-control-vm.ts
@@ -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)) {
diff --git a/src/features/game/hooks/use-game-history-vm.ts b/src/hooks/use-game-history-vm.ts
similarity index 97%
rename from src/features/game/hooks/use-game-history-vm.ts
rename to src/hooks/use-game-history-vm.ts
index 2b72f8b..03690cf 100644
--- a/src/features/game/hooks/use-game-history-vm.ts
+++ b/src/hooks/use-game-history-vm.ts
@@ -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)
diff --git a/src/features/game/hooks/use-game-realtime-sync.ts b/src/hooks/use-game-realtime-sync.ts
similarity index 98%
rename from src/features/game/hooks/use-game-realtime-sync.ts
rename to src/hooks/use-game-realtime-sync.ts
index aea5b3a..8090ffa 100644
--- a/src/features/game/hooks/use-game-realtime-sync.ts
+++ b/src/hooks/use-game-realtime-sync.ts
@@ -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
diff --git a/src/features/game/hooks/use-game-status-vm.ts b/src/hooks/use-game-status-vm.ts
similarity index 100%
rename from src/features/game/hooks/use-game-status-vm.ts
rename to src/hooks/use-game-status-vm.ts
diff --git a/src/features/game/hooks/use-header-vm.ts b/src/hooks/use-header-vm.ts
similarity index 99%
rename from src/features/game/hooks/use-header-vm.ts
rename to src/hooks/use-header-vm.ts
index 934df6a..fa4a961 100644
--- a/src/features/game/hooks/use-header-vm.ts
+++ b/src/hooks/use-header-vm.ts
@@ -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,
diff --git a/src/features/auth/hooks/use-login-form.ts b/src/hooks/use-login-form.ts
similarity index 86%
rename from src/features/auth/hooks/use-login-form.ts
rename to src/hooks/use-login-form.ts
index a36ff2d..773d9c0 100644
--- a/src/features/auth/hooks/use-login-form.ts
+++ b/src/hooks/use-login-form.ts
@@ -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({
diff --git a/src/features/game/hooks/use-period-history-vm.ts b/src/hooks/use-period-history-vm.ts
similarity index 82%
rename from src/features/game/hooks/use-period-history-vm.ts
rename to src/hooks/use-period-history-vm.ts
index 4c537ad..d5e1bd4 100644
--- a/src/features/game/hooks/use-period-history-vm.ts
+++ b/src/hooks/use-period-history-vm.ts
@@ -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('-')
diff --git a/src/features/auth/hooks/use-register-form.ts b/src/hooks/use-register-form.ts
similarity index 92%
rename from src/features/auth/hooks/use-register-form.ts
rename to src/hooks/use-register-form.ts
index 84c0b69..6649f0d 100644
--- a/src/features/auth/hooks/use-register-form.ts
+++ b/src/hooks/use-register-form.ts
@@ -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
diff --git a/src/features/auth/hooks/use-send-sms-code.ts b/src/hooks/use-send-sms-code.ts
similarity index 96%
rename from src/features/auth/hooks/use-send-sms-code.ts
rename to src/hooks/use-send-sms-code.ts
index 60ef3c6..c556965 100644
--- a/src/features/auth/hooks/use-send-sms-code.ts
+++ b/src/hooks/use-send-sms-code.ts
@@ -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() {
diff --git a/src/features/game/hooks/use-topup-vm.ts b/src/hooks/use-topup-vm.ts
similarity index 100%
rename from src/features/game/hooks/use-topup-vm.ts
rename to src/hooks/use-topup-vm.ts
diff --git a/src/features/game/hooks/use-wallet-records-vm.ts b/src/hooks/use-wallet-records-vm.ts
similarity index 98%
rename from src/features/game/hooks/use-wallet-records-vm.ts
rename to src/hooks/use-wallet-records-vm.ts
index 6827002..bfda8b1 100644
--- a/src/features/game/hooks/use-wallet-records-vm.ts
+++ b/src/hooks/use-wallet-records-vm.ts
@@ -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'
diff --git a/src/features/game/hooks/use-withdraw-submit.ts b/src/hooks/use-withdraw-submit.ts
similarity index 93%
rename from src/features/game/hooks/use-withdraw-submit.ts
rename to src/hooks/use-withdraw-submit.ts
index 940a326..a2ed03d 100644
--- a/src/features/game/hooks/use-withdraw-submit.ts
+++ b/src/hooks/use-withdraw-submit.ts
@@ -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() {
diff --git a/src/features/game/hooks/use-withdraw-vm.ts b/src/hooks/use-withdraw-vm.ts
similarity index 98%
rename from src/features/game/hooks/use-withdraw-vm.ts
rename to src/hooks/use-withdraw-vm.ts
index 8b279ab..fe4fedf 100644
--- a/src/features/game/hooks/use-withdraw-vm.ts
+++ b/src/hooks/use-withdraw-vm.ts
@@ -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) {
diff --git a/src/features/auth/hooks/zod-form-resolver.ts b/src/hooks/zod-form-resolver.ts
similarity index 100%
rename from src/features/auth/hooks/zod-form-resolver.ts
rename to src/hooks/zod-form-resolver.ts
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 1587008..aef1d4c 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -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(
diff --git a/src/lib/api/api-client.ts b/src/lib/api/api-client.ts
index f270fd4..be92f31 100644
--- a/src/lib/api/api-client.ts
+++ b/src/lib/api/api-client.ts
@@ -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
type JsonRequestOptions = RequestOptions & {
diff --git a/src/lib/auth/auth-normalizers.ts b/src/lib/auth/auth-normalizers.ts
new file mode 100644
index 0000000..a1e90c2
--- /dev/null
+++ b/src/lib/auth/auth-normalizers.ts
@@ -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,
+ }
+}
diff --git a/src/lib/auth/auth-session.ts b/src/lib/auth/auth-session.ts
index 2e1eb58..27715fd 100644
--- a/src/lib/auth/auth-session.ts
+++ b/src/lib/auth/auth-session.ts
@@ -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
-export type RefreshSessionHandler = (
- refreshToken: string,
-) => Promise
+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 | null = null
let refreshSessionPromise: Promise | 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()
diff --git a/src/lib/auth/require-auth.ts b/src/lib/auth/require-auth.ts
index 4ba29b5..44ce908 100644
--- a/src/lib/auth/require-auth.ts
+++ b/src/lib/auth/require-auth.ts
@@ -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 = {},
) {
diff --git a/src/lib/head/document-metadata.ts b/src/lib/head/document-metadata.ts
index 427dec5..471f5c7 100644
--- a/src/lib/head/document-metadata.ts
+++ b/src/lib/head/document-metadata.ts
@@ -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,
diff --git a/src/lib/notify.ts b/src/lib/notify.ts
index 166ab84..541396f 100644
--- a/src/lib/notify.ts
+++ b/src/lib/notify.ts
@@ -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
diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts
index e61118c..82e25ca 100644
--- a/src/locales/en-US.ts
+++ b/src/locales/en-US.ts
@@ -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',
diff --git a/src/locales/id-ID.ts b/src/locales/id-ID.ts
index 212407c..33df3c2 100644
--- a/src/locales/id-ID.ts
+++ b/src/locales/id-ID.ts
@@ -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',
diff --git a/src/locales/ms-MY.ts b/src/locales/ms-MY.ts
index 3e14fc7..d87ddaa 100644
--- a/src/locales/ms-MY.ts
+++ b/src/locales/ms-MY.ts
@@ -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',
diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts
index 0d8a844..4d79a16 100644
--- a/src/locales/zh-CN.ts
+++ b/src/locales/zh-CN.ts
@@ -114,8 +114,8 @@ export default {
label: '语言',
zhCN: '中文',
enUS: 'English',
- msMY: 'Bahasa Melayu',
- idID: 'Bahasa Indonesia',
+ msMY: 'Melayu',
+ idID: 'Indonesia',
},
game: {
metaTitle: '游戏大厅',
diff --git a/src/main.tsx b/src/main.tsx
index 13e8328..4192843 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -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'
diff --git a/src/features/game/entry/entry-page.tsx b/src/main/main-entry-page.tsx
similarity index 63%
rename from src/features/game/entry/entry-page.tsx
rename to src/main/main-entry-page.tsx
index 64c5782..bdff4cf 100644
--- a/src/features/game/entry/entry-page.tsx
+++ b/src/main/main-entry-page.tsx
@@ -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 (
+ <>
+ {/* 移动端登录弹窗:用于未登录用户进入登录流程 */}
+
+ {/* 移动端注册弹窗:用于新用户注册账号 */}
+
+ {/* 移动端语言切换弹窗:用于选择当前站点展示语言 */}
+
+ {/* 移动端规则弹窗:展示当前游戏玩法、下注与结算规则 */}
+
+ {/* 移动端用户信息弹窗:展示个人资料与站内消息 */}
+
+ {/* 移动端公告弹窗:展示活动公告或运营通知内容 */}
+
+ {/* 移动端自动托管弹窗:配置自动托管相关条件 */}
+
+ {/* 移动端充值/提现前置选择弹窗:先选择进入充值还是提现 */}
+
+ {/* 移动端充值/提现业务弹窗:承载具体的充值或提现内容 */}
+
+ {/* 移动端客服弹窗:承载在线客服 iframe */}
+
+ {/* 强制弹窗 */}
+
+ {/* 历史开奖信息弹窗 */}
+
+ >
+ )
+}
+
+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 ? : }
-
+ {isMobile ? : }
)
}
diff --git a/src/features/game/entry/mobile-entry.tsx b/src/main/mobile-entry.tsx
similarity index 80%
rename from src/features/game/entry/mobile-entry.tsx
rename to src/main/mobile-entry.tsx
index ec0936b..63d693f 100644
--- a/src/features/game/entry/mobile-entry.tsx
+++ b/src/main/mobile-entry.tsx
@@ -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()
diff --git a/src/features/game/entry/pc-entry.tsx b/src/main/pc-entry.tsx
similarity index 88%
rename from src/features/game/entry/pc-entry.tsx
rename to src/main/pc-entry.tsx
index 5171c07..4d3ca5a 100644
--- a/src/features/game/entry/pc-entry.tsx
+++ b/src/main/pc-entry.tsx
@@ -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()
diff --git a/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx b/src/modal/desktop/desktop-auto-setting-modal.tsx
similarity index 100%
rename from src/features/game/modal/desktop/desktop-auto-setting-modal.tsx
rename to src/modal/desktop/desktop-auto-setting-modal.tsx
diff --git a/src/features/game/modal/desktop/desktop-finance-records-tab.tsx b/src/modal/desktop/desktop-finance-records-tab.tsx
similarity index 98%
rename from src/features/game/modal/desktop/desktop-finance-records-tab.tsx
rename to src/modal/desktop/desktop-finance-records-tab.tsx
index 90adf61..7a5e7c7 100644
--- a/src/features/game/modal/desktop/desktop-finance-records-tab.tsx
+++ b/src/modal/desktop/desktop-finance-records-tab.tsx
@@ -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 }) {
diff --git a/src/features/game/modal/desktop/desktop-language-modal.tsx b/src/modal/desktop/desktop-language-modal.tsx
similarity index 98%
rename from src/features/game/modal/desktop/desktop-language-modal.tsx
rename to src/modal/desktop/desktop-language-modal.tsx
index 91dd489..dd79257 100644
--- a/src/features/game/modal/desktop/desktop-language-modal.tsx
+++ b/src/modal/desktop/desktop-language-modal.tsx
@@ -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'
diff --git a/src/features/game/modal/desktop/desktop-login-modal.tsx b/src/modal/desktop/desktop-login-modal.tsx
similarity index 97%
rename from src/features/game/modal/desktop/desktop-login-modal.tsx
rename to src/modal/desktop/desktop-login-modal.tsx
index 885af14..151038b 100644
--- a/src/features/game/modal/desktop/desktop-login-modal.tsx
+++ b/src/modal/desktop/desktop-login-modal.tsx
@@ -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() {
diff --git a/src/features/game/modal/desktop/desktop-notice-modal.tsx b/src/modal/desktop/desktop-notice-modal.tsx
similarity index 99%
rename from src/features/game/modal/desktop/desktop-notice-modal.tsx
rename to src/modal/desktop/desktop-notice-modal.tsx
index 5f4491e..c8ea2e6 100644
--- a/src/features/game/modal/desktop/desktop-notice-modal.tsx
+++ b/src/modal/desktop/desktop-notice-modal.tsx
@@ -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'
diff --git a/src/features/game/modal/desktop/desktop-period-history-drawer.tsx b/src/modal/desktop/desktop-period-history-drawer.tsx
similarity index 99%
rename from src/features/game/modal/desktop/desktop-period-history-drawer.tsx
rename to src/modal/desktop/desktop-period-history-drawer.tsx
index 44f4bef..5bedcc7 100644
--- a/src/features/game/modal/desktop/desktop-period-history-drawer.tsx
+++ b/src/modal/desktop/desktop-period-history-drawer.tsx
@@ -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
diff --git a/src/features/game/modal/desktop/desktop-procedures-modal.tsx b/src/modal/desktop/desktop-procedures-modal.tsx
similarity index 100%
rename from src/features/game/modal/desktop/desktop-procedures-modal.tsx
rename to src/modal/desktop/desktop-procedures-modal.tsx
diff --git a/src/features/game/modal/desktop/desktop-register-modal.tsx b/src/modal/desktop/desktop-register-modal.tsx
similarity index 96%
rename from src/features/game/modal/desktop/desktop-register-modal.tsx
rename to src/modal/desktop/desktop-register-modal.tsx
index 8974d06..a15bb0a 100644
--- a/src/features/game/modal/desktop/desktop-register-modal.tsx
+++ b/src/modal/desktop/desktop-register-modal.tsx
@@ -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() {
diff --git a/src/features/game/modal/desktop/desktop-rules-modal.tsx b/src/modal/desktop/desktop-rules-modal.tsx
similarity index 100%
rename from src/features/game/modal/desktop/desktop-rules-modal.tsx
rename to src/modal/desktop/desktop-rules-modal.tsx
diff --git a/src/features/game/modal/desktop/desktop-support-modal.tsx b/src/modal/desktop/desktop-support-modal.tsx
similarity index 100%
rename from src/features/game/modal/desktop/desktop-support-modal.tsx
rename to src/modal/desktop/desktop-support-modal.tsx
diff --git a/src/features/game/modal/desktop/desktop-userInfo-modal.tsx b/src/modal/desktop/desktop-userInfo-modal.tsx
similarity index 97%
rename from src/features/game/modal/desktop/desktop-userInfo-modal.tsx
rename to src/modal/desktop/desktop-userInfo-modal.tsx
index 8953807..b365db2 100644
--- a/src/features/game/modal/desktop/desktop-userInfo-modal.tsx
+++ b/src/modal/desktop/desktop-userInfo-modal.tsx
@@ -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'
diff --git a/src/features/game/modal/desktop/desktop-wallet-records-tab.tsx b/src/modal/desktop/desktop-wallet-records-tab.tsx
similarity index 98%
rename from src/features/game/modal/desktop/desktop-wallet-records-tab.tsx
rename to src/modal/desktop/desktop-wallet-records-tab.tsx
index c6c4b6c..25cbc69 100644
--- a/src/features/game/modal/desktop/desktop-wallet-records-tab.tsx
+++ b/src/modal/desktop/desktop-wallet-records-tab.tsx
@@ -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 })
diff --git a/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx b/src/modal/desktop/desktop-withdraw-topup-modal.tsx
similarity index 100%
rename from src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx
rename to src/modal/desktop/desktop-withdraw-topup-modal.tsx
diff --git a/src/modal/mobile/mobile-auto-setting-modal.tsx b/src/modal/mobile/mobile-auto-setting-modal.tsx
new file mode 100644
index 0000000..6c08e55
--- /dev/null
+++ b/src/modal/mobile/mobile-auto-setting-modal.tsx
@@ -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 (
+
+ {t('game.modals.autoSetting.title')}
+
+ }
+ isNormalBg={true}
+ titleAlign="left"
+ className="!h-[min(calc(var(--design-unit)*500),calc(100dvh-var(--design-unit)*28))]"
+ >
+
+
+
+
+ {t('game.modals.autoSetting.rows.stopIfBalanceLowerThan')}
+
+
+
+ setBalanceLimitValue(event.target.value)}
+ className={
+ 'game-setting-input h-full w-design-280 text-design-18'
+ }
+ />
+
+
+
+
+
+
+ {t('game.modals.autoSetting.rows.stopIfSingleWinExceeds')}
+
+
+
+ setSingleWinLimitValue(event.target.value)}
+ className={
+ 'game-setting-input h-full w-design-280 text-design-18'
+ }
+ />
+
+
+
+
+
+
+ {t('game.modals.autoSetting.rows.stopOnAnyJackpot')}
+
+
+
+
+
+
+
+
+
+
+ {t('game.modals.autoSetting.startAutoSpin')}
+
+
+
+
+ )
+}
+
+export default MobileAutoSettingModal
diff --git a/src/modal/mobile/mobile-finance-records-tab.tsx b/src/modal/mobile/mobile-finance-records-tab.tsx
new file mode 100644
index 0000000..8aeb9e7
--- /dev/null
+++ b/src/modal/mobile/mobile-finance-records-tab.tsx
@@ -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(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 (
+
+
+
+ {vm.recordTypes.map((recordType) => {
+ const isActive = recordType.key === vm.recordType
+
+ return (
+
+ )
+ })}
+
+
+
+ {vm.pageLabel}
+
+
+
+
+
+
+
{vm.headers.orderNo}
+
{vm.headers.amount}
+
{vm.headers.bonusAmount}
+
+
+
+ {vm.isLoading ? (
+
+ ) : vm.isError ? (
+
+ {vm.loadFailedText}
+
+ ) : vm.items.length === 0 ? (
+
+ {vm.emptyText}
+
+ ) : (
+
+ {virtualItems.map((virtualRow) => {
+ const item = vm.items[virtualRow.index]
+
+ return (
+
+ {item ? (
+
+
+ {maskOrderNo(item.orderNoLabel)}
+
+
+ {item.amountLabel}
+
+
+ {item.bonusAmountLabel}
+
+
+ ) : (
+
+ )}
+
+ )
+ })}
+
+ )}
+
+
+
+
+ )
+}
+
+export default MobileFinanceRecordsTab
diff --git a/src/modal/mobile/mobile-language-modal.tsx b/src/modal/mobile/mobile-language-modal.tsx
new file mode 100644
index 0000000..a4c49e0
--- /dev/null
+++ b/src/modal/mobile/mobile-language-modal.tsx
@@ -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 (
+
+ {t('language.label')}
+
+ }
+ isNormalBg={true}
+ titleAlign="left"
+ className="!h-design-350"
+ >
+
+
+ {languageOptions.map((option: (typeof languageOptions)[number]) => {
+ const isActive = option.code === currentLanguage
+
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default MobileLanguageModal
diff --git a/src/modal/mobile/mobile-login-modal.tsx b/src/modal/mobile/mobile-login-modal.tsx
new file mode 100644
index 0000000..04f1c46
--- /dev/null
+++ b/src/modal/mobile/mobile-login-modal.tsx
@@ -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 (
+ setModalOpen('desktopLogin', false)}
+ title={
+
+ {t('game.modals.login.title')}
+
+ }
+ titleAlign="center"
+ className="!h-design-360"
+ backdropClassName="backdrop-blur-none"
+ >
+
+
+ )
+}
+
+export default MobileLoginModal
diff --git a/src/modal/mobile/mobile-notice-modal.tsx b/src/modal/mobile/mobile-notice-modal.tsx
new file mode 100644
index 0000000..a183a84
--- /dev/null
+++ b/src/modal/mobile/mobile-notice-modal.tsx
@@ -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('list')
+ const [selectedNoticeId, setSelectedNoticeId] = useState(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 (
+
+ {t('game.modals.userInfo.message.title')}
+
+ }
+ isNormalBg={true}
+ titleAlign="left"
+ className="!h-design-330"
+ >
+
+ {noticeView === 'detail' ? (
+
+
+
+ ) : null}
+
+
+ {noticeView === 'list' ? (
+
+ {noticeListQuery.isLoading ? (
+
+ ) : noticeListQuery.isError ? (
+
+ {t('game.modals.userInfo.message.loadFailed')}
+
+ ) : noticeItems.length === 0 ? (
+
+ {t('game.modals.userInfo.message.empty')}
+
+ ) : (
+ noticeItems.map((item) => (
+
+ ))
+ )}
+
+ ) : (
+
+ {noticeDetailQuery.isLoading ? (
+
+ ) : noticeDetailQuery.isError ? (
+
+ {t('game.modals.userInfo.message.loadFailed')}
+
+ ) : noticeDetailQuery.data ? (
+
+
+ {dayjs(noticeDetailQuery.data.publish_time * 1000).format(
+ 'YYYY-MM-DD HH:mm:ss',
+ )}
+
+
+ {noticeDetailQuery.data.title}
+
+
+ {noticeDetailQuery.data.content}
+
+
+ ) : (
+
+ {t('game.modals.userInfo.message.empty')}
+
+ )}
+
+ )}
+
+
+
+ )
+}
+
+export default MobileNoticeModal
diff --git a/src/modal/mobile/mobile-period-history-drawer.tsx b/src/modal/mobile/mobile-period-history-drawer.tsx
new file mode 100644
index 0000000..391a995
--- /dev/null
+++ b/src/modal/mobile/mobile-period-history-drawer.tsx
@@ -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 (
+ void vm.refetch()}
+ />
+ )
+}
+
+export function MobilePeriodHistoryDrawerView({
+ isError,
+ isLoading,
+ items,
+ labels,
+ onClose,
+ onRetry,
+ open,
+}: MobilePeriodHistoryDrawerViewProps) {
+ const prefersReducedMotion = useReducedMotion()
+ const [isDrawerAnimating, setIsDrawerAnimating] = useState(false)
+
+ return (
+
+ {open && (
+ <>
+
+ setIsDrawerAnimating(true)}
+ onAnimationComplete={() => setIsDrawerAnimating(false)}
+ style={
+ isDrawerAnimating
+ ? { willChange: 'transform, opacity' }
+ : undefined
+ }
+ >
+
+
+
+
+
+ {labels.title}
+
+
+
+
+
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/modal/mobile/mobile-procedures-modal.tsx b/src/modal/mobile/mobile-procedures-modal.tsx
new file mode 100644
index 0000000..d16afe9
--- /dev/null
+++ b/src/modal/mobile/mobile-procedures-modal.tsx
@@ -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 (
+
+ {t('game.modals.procedures.title')}
+
+ }
+ isNormalBg={true}
+ titleAlign="left"
+ className="h-design-280"
+ >
+
+
+
+
+
+ {currentUser?.coin || 0}
+
+
+
+
+
+ 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')}
+
+ 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')}
+
+
+
+
+ )
+}
+
+export default MobileProceduresModal
diff --git a/src/modal/mobile/mobile-register-modal.tsx b/src/modal/mobile/mobile-register-modal.tsx
new file mode 100644
index 0000000..dc43680
--- /dev/null
+++ b/src/modal/mobile/mobile-register-modal.tsx
@@ -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 (
+ setModalOpen('desktopRegister', false)}
+ title={
+
+ {t('game.modals.register.title')}
+
+ }
+ titleAlign="center"
+ className="!h-[min(calc(var(--design-unit)*520),calc(100dvh-var(--design-unit)*28))]"
+ backdropClassName="backdrop-blur-none"
+ >
+
+
+ )
+}
+
+export default MobileRegisterModal
diff --git a/src/modal/mobile/mobile-rules-modal.tsx b/src/modal/mobile/mobile-rules-modal.tsx
new file mode 100644
index 0000000..a6d6091
--- /dev/null
+++ b/src/modal/mobile/mobile-rules-modal.tsx
@@ -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 (
+
+ {t('game.modals.rules.title')}
+
+ }
+ titleAlign="left"
+ className="!h-design-320"
+ >
+
+
+ {t('game.modals.rules.content')}
+
+
+
+
+ {t('game.modals.rules.confirm')}
+
+
+
+
+ )
+}
+
+export default MobileRulesModal
diff --git a/src/modal/mobile/mobile-support-modal.tsx b/src/modal/mobile/mobile-support-modal.tsx
new file mode 100644
index 0000000..4efc178
--- /dev/null
+++ b/src/modal/mobile/mobile-support-modal.tsx
@@ -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(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 (
+ 在线客服}
+ className="h-design-500"
+ >
+
+
+ {isLoading ? (
+
+
+
+ ) : null}
+
+
+
+
+ )
+}
+
+export default MobileSupportModal
diff --git a/src/modal/mobile/mobile-userInfo-modal.tsx b/src/modal/mobile/mobile-userInfo-modal.tsx
new file mode 100644
index 0000000..381d52c
--- /dev/null
+++ b/src/modal/mobile/mobile-userInfo-modal.tsx
@@ -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('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 (
+
+ {t('game.modals.userInfo.title')}
+
+ }
+ isNormalBg={true}
+ titleAlign="left"
+ className="h-design-420"
+ >
+
+
+ {USER_INFO_TABS.map((tab) => {
+ const Icon = tab.icon
+ const isActive = tab.key === activeTab
+
+ return (
+
+ )
+ })}
+
+
+
+ {activeTab === 'profile' ? (
+
+
+
+
+
+
+ {t('game.modals.userInfo.profile.name')} :
+ {currentUser?.name ?? '--'}
+
+
+ {t('game.modals.userInfo.profile.tel')} :{' '}
+ {currentUser?.phone ?? '--'}
+
+
+
+
+
+
+ {t('game.modals.userInfo.profile.registeredAt')}:
+
+ {currentUser?.createTime
+ ? dayjs
+ .unix(currentUser.createTime)
+ .format('YYYY-MM-DD HH:mm:ss')
+ : '--'}
+
+
+
+
+
+ {t('auth.register.fields.inviteCode.label')}
+
+
+ {inviteCode || '--'}
+
+
+
+
+
+
+
+
+
+
+ ) : activeTab === 'financeRecords' ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+export default MobileUserInfoModal
diff --git a/src/modal/mobile/mobile-wallet-records-tab.tsx b/src/modal/mobile/mobile-wallet-records-tab.tsx
new file mode 100644
index 0000000..db32cfd
--- /dev/null
+++ b/src/modal/mobile/mobile-wallet-records-tab.tsx
@@ -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(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 (
+
+
+
+ {vm.headers.type}
+
+
+ {vm.pageLabel}
+
+
+
+
+
+
+
{vm.headers.amount}
+
{vm.headers.balanceBefore}
+
{vm.headers.balanceAfter}
+
{vm.headers.time}
+
{vm.headers.remark}
+
+
+
+ {vm.isLoading ? (
+
+ ) : vm.isError ? (
+
+ {vm.loadFailedText}
+
+ ) : vm.items.length === 0 ? (
+
+ {vm.emptyText}
+
+ ) : (
+
+ {virtualItems.map((virtualRow) => {
+ const item = vm.items[virtualRow.index]
+
+ return (
+
+ {item ? (
+
+
+ {item.amountLabel}
+
+
+ {item.balanceBeforeLabel}
+
+
+ {item.balanceAfterLabel}
+
+
+ {item.timeLabel}
+
+
+ {item.remarkLabel}
+
+
+ ) : (
+
+ )}
+
+ )
+ })}
+
+ )}
+
+
+
+
+ )
+}
+
+export default MobileWalletRecordsTab
diff --git a/src/modal/mobile/mobile-withdraw-topup-modal.tsx b/src/modal/mobile/mobile-withdraw-topup-modal.tsx
new file mode 100644
index 0000000..272b27a
--- /dev/null
+++ b/src/modal/mobile/mobile-withdraw-topup-modal.tsx
@@ -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 (
+
+ {type === 'withdraw'
+ ? t('game.modals.withdrawTopup.applyWithdraw')
+ : t('game.modals.withdrawTopup.applyTopup')}
+
+ }
+ isNormalBg={true}
+ titleAlign="left"
+ className="h-design-510"
+ >
+
+ {type === 'withdraw' ? : }
+
+
+ )
+}
+
+export default MobileWithdrawTopupModal
diff --git a/src/routes/$lang/index.tsx b/src/routes/$lang/index.tsx
index ef9ee88..57390d7 100644
--- a/src/routes/$lang/index.tsx
+++ b/src/routes/$lang/index.tsx
@@ -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,
})
diff --git a/src/features/auth/schema/auth-schema.ts b/src/schema/auth-schema.ts
similarity index 89%
rename from src/features/auth/schema/auth-schema.ts
rename to src/schema/auth-schema.ts
index d75693b..e7e69a7 100644
--- a/src/features/auth/schema/auth-schema.ts
+++ b/src/schema/auth-schema.ts
@@ -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
-export type RegisterFormValues = z.infer
diff --git a/src/store/audio/audio-store.ts b/src/store/audio/audio-store.ts
index 7d1528c..b665870 100644
--- a/src/store/audio/audio-store.ts
+++ b/src/store/audio/audio-store.ts
@@ -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()(
persist(
diff --git a/src/store/auth/auth-store.ts b/src/store/auth/auth-store.ts
index 17bf8ce..2707f73 100644
--- a/src/store/auth/auth-store.ts
+++ b/src/store/auth/auth-store.ts
@@ -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
diff --git a/src/store/game/game-auto-hosting-store.ts b/src/store/game/game-auto-hosting-store.ts
index 26f1b9f..78177c8 100644
--- a/src/store/game/game-auto-hosting-store.ts
+++ b/src/store/game/game-auto-hosting-store.ts
@@ -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: {
diff --git a/src/store/game/game-round-store.ts b/src/store/game/game-round-store.ts
index c2c6c84..a5a7a43 100644
--- a/src/store/game/game-round-store.ts
+++ b/src/store/game/game-round-store.ts
@@ -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) => 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 }
diff --git a/src/store/game/game-session-store.ts b/src/store/game/game-session-store.ts
index 7424451..e89515c 100644
--- a/src/store/game/game-session-store.ts
+++ b/src/store/game/game-session-store.ts
@@ -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
export interface GameSessionStoreState extends GameSessionSlice {
diff --git a/src/store/modal/modal-store.ts b/src/store/modal/modal-store.ts
index 41abe8d..97033a6 100644
--- a/src/store/modal/modal-store.ts
+++ b/src/store/modal/modal-store.ts
@@ -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
-
-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()((set) => ({
modals: INITIAL_MODAL_VISIBILITY,
withdrawTopupType: 'withdraw',
diff --git a/src/features/game/api/finance-types.ts b/src/type/api.type.ts
similarity index 94%
rename from src/features/game/api/finance-types.ts
rename to src/type/api.type.ts
index b6743db..7bc6e09 100644
--- a/src/features/game/api/finance-types.ts
+++ b/src/type/api.type.ts
@@ -1,3 +1,17 @@
+export interface ApiResponse {
+ 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
diff --git a/src/features/auth/api/types.ts b/src/type/auth.type.ts
similarity index 53%
rename from src/features/auth/api/types.ts
rename to src/type/auth.type.ts
index 496f1fe..4d03edd 100644
--- a/src/features/auth/api/types.ts
+++ b/src/type/auth.type.ts
@@ -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 {
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
+
+export type RefreshSessionHandler = (
+ refreshToken: string,
+) => Promise
diff --git a/src/type/game.type.ts b/src/type/game.type.ts
new file mode 100644
index 0000000..9c6016e
--- /dev/null
+++ b/src/type/game.type.ts
@@ -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
+ 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) => 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[],
+ ) => void
+ requestRealtimeConnection: () => void
+ resetRealtimeConnectionRequest: () => void
+ shouldConnectRealtime: boolean
+ setConnectionLatency: (latencyMs: number | null) => void
+ setConnectionStatus: (status: ConnectionStatus) => void
+ syncConnection: (patch: Partial) => void
+ syncDashboard: (patch: Partial) => 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
+}
diff --git a/src/type/index.ts b/src/type/index.ts
index 8e695bf..ddc188e 100644
--- a/src/type/index.ts
+++ b/src/type/index.ts
@@ -1,17 +1,4 @@
-export type WithdrawTopupType = 'withdraw' | 'topup'
-
-/** @description 后端统一响应体结构。 */
-export interface ApiResponse {
- code: number
- msg?: string
- data: T
- message?: string
-}
-
-/** @description 后端统一错误响应体结构。 */
-export interface ApiErrorOptions {
- message: string
- status?: number
- data?: unknown
- url?: string
-}
+export * from './api.type'
+export * from './auth.type'
+export * from './game.type'
+export * from './system.type'
diff --git a/src/type/system.type.ts b/src/type/system.type.ts
new file mode 100644
index 0000000..002914a
--- /dev/null
+++ b/src/type/system.type.ts
@@ -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
+ withdrawTopupType: WithdrawTopupType
+ closeAllModals: () => void
+ openExclusiveModal: (key: ModalKey) => void
+ setModalOpen: (key: ModalKey, open: boolean) => void
+ setWithdrawTopupType: (type: WithdrawTopupType) => void
+}
+
+export interface RequireAuthenticatedSessionOptions {
+ fallbackLanguage?: AppLanguage
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index e102625..e97f35d 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1,13 +1,5 @@
///
-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
}