feat: 优化整体项目ui
BIN
figma/img.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/game/animal-border.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src/assets/game/en-stop.webp
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
src/assets/game/pc-streak.gif
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
src/assets/game/zh-stop.webp
Normal file
|
After Width: | Height: | Size: 593 KiB |
1
src/assets/lottie/pc-streak.json
Normal file
BIN
src/assets/system/broadcast.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/assets/system/streak.webp
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -37,23 +37,28 @@ export function CenterModal({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !onClose || typeof document === 'undefined') {
|
||||
if (!open || typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
if (onClose) {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
if (onClose) {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type AppLanguage, supportedLanguages } from '@/i18n'
|
||||
import { SUPPORTED_LANGUAGES } from '@/constants'
|
||||
import type { AppLanguage } from '@/i18n'
|
||||
|
||||
const languagePrefixPattern = new RegExp(
|
||||
`^/(${supportedLanguages.join('|')})(?=/|$)`,
|
||||
`^/(${SUPPORTED_LANGUAGES.join('|')})(?=/|$)`,
|
||||
)
|
||||
|
||||
interface LanguageLinkProps {
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
import { motion } from 'motion/react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
NOTIFICATION_EXIT_DURATION_MS,
|
||||
useNotificationStore,
|
||||
} from '@/lib/notify'
|
||||
import { NOTIFICATION_EXIT_DURATION_MS } from '@/constants'
|
||||
import { useNotificationStore } from '@/lib/notify'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TONE_CLASS_BY_TYPE = {
|
||||
|
||||
31
src/constants/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/** @description 认证状态持久化到浏览器时使用的存储键。 */
|
||||
export const AUTH_STORAGE_KEY = 'auth-session'
|
||||
|
||||
/** @description 认证模块调用的后端接口地址集合。 */
|
||||
export const AUTH_ENDPOINTS = {
|
||||
login: 'api/user/login',
|
||||
profile: 'api/user/profile',
|
||||
refreshToken: 'api/user/refreshToken',
|
||||
register: 'api/user/register',
|
||||
} as const
|
||||
|
||||
/** @description 获取接口鉴权 auth-token 时使用的接口地址。 */
|
||||
export const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
|
||||
|
||||
/** @description 刷新用户登录态 user-token 时使用的接口地址。 */
|
||||
export const AUTH_REFRESH_ENDPOINT = AUTH_ENDPOINTS.refreshToken
|
||||
|
||||
/** @description 标记当前请求已经尝试过刷新登录态的上下文键。 */
|
||||
export const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
|
||||
|
||||
/** @description 标记当前请求跳过登录态刷新的上下文键。 */
|
||||
export const AUTH_SKIP_REFRESH_CONTEXT_KEY = 'skipAuthRefresh'
|
||||
|
||||
/** @description 登录态 token 距离过期多久以内提前刷新,单位为毫秒。 */
|
||||
export const ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000
|
||||
|
||||
/** @description 接口鉴权 token 距离过期多久以内不再复用缓存,单位为毫秒。 */
|
||||
export const AUTH_TOKEN_CACHE_SKEW_MS = 30_000
|
||||
|
||||
/** @description 认证错误翻译 key 的统一前缀。 */
|
||||
export const AUTH_ERROR_KEY_PREFIX = 'auth.'
|
||||
321
src/constants/game.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { Repeat2, Settings, Trash2 } from 'lucide-react'
|
||||
import chip1 from '@/assets/game/chip1.webp'
|
||||
import chip2 from '@/assets/game/chip2.webp'
|
||||
import chip3 from '@/assets/game/chip3.webp'
|
||||
import chip4 from '@/assets/game/chip4.webp'
|
||||
import chip5 from '@/assets/game/chip5.webp'
|
||||
import chip6 from '@/assets/game/chip6.webp'
|
||||
import controlLeft from '@/assets/game/control-left.webp'
|
||||
import controlMid from '@/assets/game/control-mid.webp'
|
||||
import controlRight from '@/assets/game/control-right.webp'
|
||||
import hallMusic from '@/assets/music/hall-music.mp3'
|
||||
import type { DepositWithdrawConfig } from '@/features/game/api/finance-types'
|
||||
|
||||
/** @description 游戏棋盘行数。 */
|
||||
export const GAME_GRID_ROWS = 6
|
||||
|
||||
/** @description 游戏棋盘列数。 */
|
||||
export const GAME_GRID_COLUMNS = 6
|
||||
|
||||
/** @description 游戏棋盘总格子数。 */
|
||||
export const GAME_TOTAL_CELLS = GAME_GRID_ROWS * GAME_GRID_COLUMNS
|
||||
|
||||
/** @description 游戏回合阶段枚举。 */
|
||||
export const ROUND_PHASES = [
|
||||
'waiting',
|
||||
'betting',
|
||||
'locked',
|
||||
'revealing',
|
||||
'settled',
|
||||
] as const
|
||||
|
||||
/** @description 游戏格子在前端视图中的状态枚举。 */
|
||||
export const CELL_STATUSES = [
|
||||
'idle',
|
||||
'betting',
|
||||
'selected',
|
||||
'locked',
|
||||
'won',
|
||||
'lost',
|
||||
] as const
|
||||
|
||||
/** @description 游戏实时连接状态枚举。 */
|
||||
export const CONNECTION_STATUSES = [
|
||||
'idle',
|
||||
'connecting',
|
||||
'connected',
|
||||
'reconnecting',
|
||||
'disconnected',
|
||||
] as const
|
||||
|
||||
/** @description 游戏实时连接传输方式枚举。 */
|
||||
export const CONNECTION_TRANSPORTS = [
|
||||
'websocket',
|
||||
'polling',
|
||||
'offline',
|
||||
] as const
|
||||
|
||||
/** @description 游戏公告视觉语义枚举。 */
|
||||
export const ANNOUNCEMENT_TONES = [
|
||||
'info',
|
||||
'success',
|
||||
'warning',
|
||||
'critical',
|
||||
] as const
|
||||
|
||||
/** @description 下注来源枚举,用于区分本地未提交与服务端已确认下注。 */
|
||||
export const BET_SOURCES = ['local', 'server'] as const
|
||||
|
||||
/** @description 走势方向枚举。 */
|
||||
export const TREND_DIRECTIONS = ['rising', 'steady', 'falling'] as const
|
||||
|
||||
/** @description 默认筹码颜色,用于后端未返回筹码颜色时兜底。 */
|
||||
export const DEFAULT_GAME_CHIP_COLORS = [
|
||||
'#1D4ED8',
|
||||
'#0F766E',
|
||||
'#B45309',
|
||||
'#B91C1C',
|
||||
'#7C3AED',
|
||||
'#111827',
|
||||
] as const
|
||||
|
||||
/** @description 默认选中的筹码 ID。 */
|
||||
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-5'
|
||||
|
||||
/** @description 游戏公告默认存活时间,单位为毫秒。 */
|
||||
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
|
||||
|
||||
/** @description 游戏最近开奖记录最大保留条数。 */
|
||||
export const GAME_RECENT_HISTORY_LIMIT = 12
|
||||
|
||||
/** @description 单轮下注最多允许选择的格子数。 */
|
||||
export const GAME_MAX_SELECTION_CELLS = 5
|
||||
|
||||
/** @description 筹码 ID 与图片资源的配置列表。 */
|
||||
export const CHIP_IMAGE_OPTIONS = [
|
||||
{ id: 'chip-1', src: chip1 },
|
||||
{ id: 'chip-2', src: chip2 },
|
||||
{ id: 'chip-3', src: chip3 },
|
||||
{ id: 'chip-4', src: chip4 },
|
||||
{ id: 'chip-5', src: chip5 },
|
||||
{ id: 'chip-6', src: chip6 },
|
||||
]
|
||||
|
||||
/** @description 按筹码 ID 快速取得筹码图片资源的映射表。 */
|
||||
export const CHIP_IMAGE_MAP = new Map(
|
||||
CHIP_IMAGE_OPTIONS.map((chip) => [chip.id, chip.src] as const),
|
||||
)
|
||||
|
||||
/** @description 默认筹码面额与筹码 ID 配置。 */
|
||||
export const DEFAULT_CHIP_AMOUNTS = [
|
||||
{ amount: 1, id: 'chip-1' },
|
||||
{ amount: 5, id: 'chip-2' },
|
||||
{ amount: 10, id: 'chip-3' },
|
||||
{ amount: 25, id: 'chip-4' },
|
||||
{ amount: 50, id: 'chip-5' },
|
||||
{ amount: 100, id: 'chip-6' },
|
||||
] as const
|
||||
|
||||
/** @description 游戏控制栏动作按钮配置。 */
|
||||
export const ACTION_OPTIONS = [
|
||||
{
|
||||
id: 'clear',
|
||||
labelKey: 'gameDesktop.control.actions.clear',
|
||||
Icon: Trash2,
|
||||
bg: controlLeft,
|
||||
},
|
||||
{
|
||||
id: 'repeat',
|
||||
labelKey: 'gameDesktop.control.actions.repeat',
|
||||
Icon: Repeat2,
|
||||
bg: controlMid,
|
||||
},
|
||||
{
|
||||
id: 'auto-spin',
|
||||
labelKey: 'gameDesktop.control.actions.auto-spin',
|
||||
Icon: Settings,
|
||||
bg: controlRight,
|
||||
},
|
||||
]
|
||||
|
||||
/** @description 游戏业务接口地址集合。 */
|
||||
export const GAME_API_ENDPOINTS = {
|
||||
announcements: 'game/announcements',
|
||||
betMyOrders: 'api/game/betMyOrders',
|
||||
betPlaceLegacy: 'api/game/betPlace',
|
||||
bootstrap: 'game/bootstrap',
|
||||
lobbyInit: 'api/game/lobbyInit',
|
||||
noticeConfirm: 'api/notice/noticeConfirm',
|
||||
noticeDetail: 'api/notice/noticeDetail',
|
||||
noticeList: 'api/notice/noticeList',
|
||||
placeBet: 'api/game/placeBet',
|
||||
roundFeed: 'game/round-feed',
|
||||
} as const
|
||||
|
||||
/** @description 充值提现业务接口地址集合。 */
|
||||
export const FINANCE_API_ENDPOINTS = {
|
||||
depositCreate: 'api/finance/depositCreate',
|
||||
depositList: 'api/finance/depositList',
|
||||
depositTierList: 'api/finance/depositTierList',
|
||||
depositWithdrawConfig: 'api/finance/depositWithdrawConfig',
|
||||
legacyCashierConfig: 'api/finance/cashierConfig',
|
||||
withdrawCreate: 'api/finance/withdrawCreate',
|
||||
withdrawList: 'api/finance/withdrawList',
|
||||
} as const
|
||||
|
||||
/** @description 游戏实时接口不可用时的兜底轮询间隔,单位为毫秒。 */
|
||||
export const FALLBACK_POLL_INTERVAL_MS = 10_000
|
||||
|
||||
/** @description 游戏实时通信主题集合。 */
|
||||
export const GAME_SOCKET_TOPICS = {
|
||||
// 对局状态心跳。每秒推送当前期号、状态、倒计时、runtime_enabled 等。
|
||||
periodTick: 'period.tick',
|
||||
// 本期封盘通知。用于前端立即停止下注。
|
||||
periodLocked: 'period.locked',
|
||||
// 本期开奖通知。用于同步开奖号码、所属期号等阶段结果。
|
||||
periodOpened: 'period.opened',
|
||||
// 本期派彩完成通知。用于结算阶段同步。
|
||||
periodPayout: 'period.payout',
|
||||
// 当前玩家连胜与赔率信息。通常在结算后或演示帧刷新。
|
||||
userStreak: 'user.streak',
|
||||
// 下注成功通知。仅当前用户可见,通常伴随扣款结果。
|
||||
betAccepted: 'bet.accepted',
|
||||
// 余额变化通知。充值、下注、派彩都会走这条流。
|
||||
walletChanged: 'wallet.changed',
|
||||
// 自动托管进度通知。包含托管开关、执行状态等。
|
||||
autoSpinProgress: 'auto.spin.progress',
|
||||
// 大奖命中通知。仅当本期存在中大奖用户时推送。
|
||||
jackpotHit: 'jackpot.hit',
|
||||
// 后台实时页全量快照。仅 admin live 页面使用,当前 H5 前台不订阅。
|
||||
adminLiveSnapshot: 'admin.live.snapshot',
|
||||
// 后台开奖结果通知。仅 admin live 页面使用,当前 H5 前台不订阅。
|
||||
adminLiveOpened: 'admin.live.opened',
|
||||
} as const
|
||||
|
||||
/** @description 游戏实时通信主题白名单。 */
|
||||
export const GAME_SOCKET_TOPIC_VALUES = new Set<string>(
|
||||
Object.values(GAME_SOCKET_TOPICS),
|
||||
)
|
||||
|
||||
/** @description 当前 H5 游戏页实际订阅的玩家侧实时主题。 */
|
||||
export const PLAYER_SOCKET_TOPICS = [
|
||||
GAME_SOCKET_TOPICS.periodTick,
|
||||
GAME_SOCKET_TOPICS.userStreak,
|
||||
GAME_SOCKET_TOPICS.periodOpened,
|
||||
GAME_SOCKET_TOPICS.periodLocked,
|
||||
GAME_SOCKET_TOPICS.periodPayout,
|
||||
GAME_SOCKET_TOPICS.betAccepted,
|
||||
GAME_SOCKET_TOPICS.walletChanged,
|
||||
GAME_SOCKET_TOPICS.autoSpinProgress,
|
||||
GAME_SOCKET_TOPICS.jackpotHit,
|
||||
] as const
|
||||
|
||||
/** @description 游戏实时连接延迟断开的等待时间,单位为毫秒。 */
|
||||
export const SOCKET_DISCONNECT_DELAY_MS = 150
|
||||
|
||||
/** @description 游戏实时连接重连退避的最大等待时间,单位为毫秒。 */
|
||||
export const MAX_RECONNECT_DELAY_MS = 10_000
|
||||
|
||||
/** @description 游戏实时连接延迟探测间隔,单位为毫秒。 */
|
||||
export const LATENCY_PROBE_INTERVAL_MS = 3_000
|
||||
|
||||
/** @description 游戏实时连接延迟探测超时时间,单位为毫秒。 */
|
||||
export const LATENCY_PROBE_TIMEOUT_MS = 10_000
|
||||
|
||||
/** @description 桌面端中奖动画遮罩展示时长,单位为毫秒。 */
|
||||
export const REWARD_OVERLAY_DURATION_MS = 5_000
|
||||
|
||||
/** @description 入场公告确认时间戳在 localStorage 中使用的基础存储键。 */
|
||||
export const ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY =
|
||||
'36-character-flower:entry-notice:last-confirmed-at'
|
||||
|
||||
/** @description 已登录用户重复展示入场公告的最小间隔,单位为毫秒。 */
|
||||
export const ENTRY_NOTICE_CONFIRM_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/** @description 游戏投注记录每页加载条数。 */
|
||||
export const GAME_HISTORY_PAGE_SIZE = 20
|
||||
|
||||
/** @description 提现页快捷法币金额选项。 */
|
||||
export const QUICK_FIAT_AMOUNTS = [3, 30, 50, 100, 200, 500] as const
|
||||
|
||||
/** @description 后端提现配置缺失时使用的默认提现配置。 */
|
||||
export const DEFAULT_WITHDRAW_CONFIG: DepositWithdrawConfig = {
|
||||
currencies: [
|
||||
{
|
||||
code: 'MYR',
|
||||
depositCoinsPerFiat: '100',
|
||||
depositCoinsPerFiatValue: 100,
|
||||
label: 'MYR',
|
||||
withdrawCoinsPerFiat: '100',
|
||||
withdrawCoinsPerFiatValue: 100,
|
||||
},
|
||||
],
|
||||
payChannels: [],
|
||||
platformCoinLabel: '钻石',
|
||||
rates: [
|
||||
{
|
||||
currency: 'MYR',
|
||||
diamondsPerFiatUnit: '100',
|
||||
diamondsPerFiatUnitValue: 100,
|
||||
},
|
||||
],
|
||||
withdraw: {
|
||||
banks: [],
|
||||
feeNote: 'RM10 - RM99.99 之间的交易将收取最低RM 1的提现手续费',
|
||||
minBank: '10',
|
||||
minEwallet: '10',
|
||||
processingNote: '30s即可到账',
|
||||
rateHint: '汇率为参考价格,实际以提现时为准。',
|
||||
rateMode: 'fixed',
|
||||
},
|
||||
}
|
||||
|
||||
/** @description 游戏状态栏各回合阶段的翻译 key 与颜色样式配置。 */
|
||||
export const PHASE_META = {
|
||||
betting: {
|
||||
descriptionKey: 'gameDesktop.status.phase.betting.description',
|
||||
labelKey: 'gameDesktop.status.phase.betting.label',
|
||||
toneClassName: 'text-[#78FF7F]',
|
||||
},
|
||||
locked: {
|
||||
descriptionKey: 'gameDesktop.status.phase.locked.description',
|
||||
labelKey: 'gameDesktop.status.phase.locked.label',
|
||||
toneClassName: 'text-[#FFE375]',
|
||||
},
|
||||
revealing: {
|
||||
descriptionKey: 'gameDesktop.status.phase.revealing.description',
|
||||
labelKey: 'gameDesktop.status.phase.revealing.label',
|
||||
toneClassName: 'text-[#57E8FF]',
|
||||
},
|
||||
settled: {
|
||||
descriptionKey: 'gameDesktop.status.phase.settled.description',
|
||||
labelKey: 'gameDesktop.status.phase.settled.label',
|
||||
toneClassName: 'text-[#FF9C6B]',
|
||||
},
|
||||
waiting: {
|
||||
descriptionKey: 'gameDesktop.status.phase.waiting.description',
|
||||
labelKey: 'gameDesktop.status.phase.waiting.label',
|
||||
toneClassName: 'text-[#A7B6C7]',
|
||||
},
|
||||
} as const
|
||||
|
||||
/** @description 游戏音频资源 ID 枚举。 */
|
||||
export type AudioAssetId = 'hall-bgm'
|
||||
|
||||
/** @description 游戏音频资源配置结构。 */
|
||||
export type AudioAssetDefinition = {
|
||||
id: AudioAssetId
|
||||
loop?: boolean
|
||||
src: string
|
||||
volume?: number
|
||||
}
|
||||
|
||||
/** @description 游戏全局音频资源配置列表。 */
|
||||
export const AUDIO_ASSET_DEFINITIONS: AudioAssetDefinition[] = [
|
||||
{
|
||||
id: 'hall-bgm',
|
||||
src: hallMusic,
|
||||
loop: true,
|
||||
volume: 1,
|
||||
},
|
||||
]
|
||||
@@ -1,137 +1,3 @@
|
||||
import { Repeat2, Settings, Trash2 } from 'lucide-react'
|
||||
import chip1 from '@/assets/game/chip1.webp'
|
||||
import chip2 from '@/assets/game/chip2.webp'
|
||||
import chip3 from '@/assets/game/chip3.webp'
|
||||
import chip4 from '@/assets/game/chip4.webp'
|
||||
import chip5 from '@/assets/game/chip5.webp'
|
||||
import chip6 from '@/assets/game/chip6.webp'
|
||||
import controlLeft from '@/assets/game/control-left.webp'
|
||||
import controlMid from '@/assets/game/control-mid.webp'
|
||||
import controlRight from '@/assets/game/control-right.webp'
|
||||
import enUS from '@/assets/system/en-US.png'
|
||||
import idID from '@/assets/system/id-ID.webp'
|
||||
import msMY from '@/assets/system/ms-MY.png'
|
||||
import zhCN from '@/assets/system/zh-CN.png'
|
||||
import type { AppLanguage } from '@/i18n'
|
||||
|
||||
/** @description 应用启动阶段使用的根节点常量。 */
|
||||
export const APP_ROOT_ELEMENT_ID = 'root'
|
||||
|
||||
/** @description 应用名称,用于文档标题和分享元信息。 */
|
||||
export const APP_NAME = '36字花'
|
||||
|
||||
/** @description 应用默认的页面描述,用于 SEO 和分享卡片。 */
|
||||
export const APP_DEFAULT_DESCRIPTION =
|
||||
'36-character-flower real-time game portal built with React, TanStack Router, TanStack Query, and Zustand.'
|
||||
|
||||
/** @description 认证状态持久化到浏览器时使用的存储键。 */
|
||||
export const AUTH_STORAGE_KEY = 'auth-session'
|
||||
|
||||
/** @description 应用偏好持久化到浏览器时使用的存储键。 */
|
||||
export const APP_PREFERENCES_STORAGE_KEY = 'app-preferences'
|
||||
|
||||
/** @description 音频偏好持久化到浏览器时使用的存储键。 */
|
||||
export const AUDIO_PREFERENCES_STORAGE_KEY = 'audio-preferences'
|
||||
|
||||
/** @description 接口请求的默认超时时间,单位为毫秒。 */
|
||||
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
||||
|
||||
/** @description 请求默认声明可接收的响应内容类型。 */
|
||||
export const DEFAULT_REQUEST_ACCEPT_HEADER = 'application/json'
|
||||
|
||||
/** @description 请求层统一使用的错误提示文案。 */
|
||||
export const API_ERROR_MESSAGES = {
|
||||
timeout: 'Request timed out',
|
||||
unexpected: 'Unexpected request error',
|
||||
} as const
|
||||
|
||||
/** @description TanStack Query 默认的缓存新鲜时间。 */
|
||||
export const QUERY_DEFAULT_STALE_TIME_MS = 30_000
|
||||
|
||||
/** @description TanStack Query 默认的缓存回收时间。 */
|
||||
export const QUERY_DEFAULT_GC_TIME_MS = 5 * 60_000
|
||||
|
||||
/** @description 查询请求默认允许的最大重试次数。 */
|
||||
export const QUERY_RETRY_LIMIT = 2
|
||||
|
||||
/** @description 可被视为瞬时失败并允许重试的状态码集合。 */
|
||||
export const QUERY_RETRYABLE_STATUS_CODES = [
|
||||
408, 429, 500, 502, 503, 504,
|
||||
] as const
|
||||
|
||||
/** @description 桌面端布局切换起始断点。 */
|
||||
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
|
||||
|
||||
export const CHIP_IMAGE_OPTIONS = [
|
||||
{ id: 'chip-1', src: chip1 },
|
||||
{ id: 'chip-2', src: chip2 },
|
||||
{ id: 'chip-3', src: chip3 },
|
||||
{ id: 'chip-4', src: chip4 },
|
||||
{ id: 'chip-5', src: chip5 },
|
||||
{ id: 'chip-6', src: chip6 },
|
||||
]
|
||||
|
||||
export const CHIP_IMAGE_MAP = new Map(
|
||||
CHIP_IMAGE_OPTIONS.map((chip) => [chip.id, chip.src] as const),
|
||||
)
|
||||
|
||||
export const DEFAULT_CHIP_AMOUNTS = [
|
||||
{ amount: 1, id: 'chip-1' },
|
||||
{ amount: 5, id: 'chip-2' },
|
||||
{ amount: 10, id: 'chip-3' },
|
||||
{ amount: 25, id: 'chip-4' },
|
||||
{ amount: 50, id: 'chip-5' },
|
||||
{ amount: 100, id: 'chip-6' },
|
||||
] as const
|
||||
|
||||
export const ACTION_OPTIONS = [
|
||||
{
|
||||
id: 'clear',
|
||||
labelKey: 'gameDesktop.control.actions.clear',
|
||||
Icon: Trash2,
|
||||
bg: controlLeft,
|
||||
},
|
||||
{
|
||||
id: 'repeat',
|
||||
labelKey: 'gameDesktop.control.actions.repeat',
|
||||
Icon: Repeat2,
|
||||
bg: controlMid,
|
||||
},
|
||||
{
|
||||
id: 'auto-spin',
|
||||
labelKey: 'gameDesktop.control.actions.auto-spin',
|
||||
Icon: Settings,
|
||||
bg: controlRight,
|
||||
},
|
||||
]
|
||||
|
||||
export const LANGUAGE_OPTIONS: Array<{
|
||||
code: AppLanguage
|
||||
icon: string
|
||||
labelKey: string
|
||||
}> = [
|
||||
{
|
||||
code: 'zh-CN',
|
||||
icon: zhCN,
|
||||
labelKey: 'language.zhCN',
|
||||
},
|
||||
{
|
||||
code: 'en-US',
|
||||
icon: enUS,
|
||||
labelKey: 'language.enUS',
|
||||
},
|
||||
{
|
||||
code: 'ms-MY',
|
||||
icon: msMY,
|
||||
labelKey: 'language.msMY',
|
||||
},
|
||||
{
|
||||
code: 'id-ID',
|
||||
icon: idID,
|
||||
labelKey: 'language.idID',
|
||||
},
|
||||
]
|
||||
|
||||
export const LANGUAGE_ICON_MAP = new Map(
|
||||
LANGUAGE_OPTIONS.map((language) => [language.code, language.icon] as const),
|
||||
)
|
||||
export * from './auth'
|
||||
export * from './game'
|
||||
export * from './system'
|
||||
|
||||
128
src/constants/system.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import enUS from '@/assets/system/en-US.png'
|
||||
import idID from '@/assets/system/id-ID.webp'
|
||||
import msMY from '@/assets/system/ms-MY.png'
|
||||
import zhCN from '@/assets/system/zh-CN.png'
|
||||
|
||||
/** @description 应用启动阶段使用的根节点 ID。 */
|
||||
export const APP_ROOT_ELEMENT_ID = 'root'
|
||||
|
||||
/** @description 应用名称,用于文档标题和分享元信息。 */
|
||||
export const APP_NAME = '36字花'
|
||||
|
||||
/** @description 应用默认页面描述,用于 SEO 和分享卡片。 */
|
||||
export const APP_DEFAULT_DESCRIPTION =
|
||||
'36-character-flower real-time game portal built with React, TanStack Router, TanStack Query, and Zustand.'
|
||||
|
||||
/** @description 应用偏好持久化到浏览器时使用的存储键。 */
|
||||
export const APP_PREFERENCES_STORAGE_KEY = 'app-preferences'
|
||||
|
||||
/** @description 音频偏好持久化到浏览器时使用的存储键。 */
|
||||
export const AUDIO_PREFERENCES_STORAGE_KEY = 'audio-preferences'
|
||||
|
||||
/** @description 接口请求默认超时时间,单位为毫秒。 */
|
||||
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
||||
|
||||
/** @description 请求默认声明可接收的响应内容类型。 */
|
||||
export const DEFAULT_REQUEST_ACCEPT_HEADER = 'application/json'
|
||||
|
||||
/** @description 请求层统一使用的错误提示文案。 */
|
||||
export const API_ERROR_MESSAGES = {
|
||||
timeout: 'Request timed out',
|
||||
unexpected: 'Unexpected request error',
|
||||
} as const
|
||||
|
||||
/** @description TanStack Query 默认缓存新鲜时间,单位为毫秒。 */
|
||||
export const QUERY_DEFAULT_STALE_TIME_MS = 30_000
|
||||
|
||||
/** @description TanStack Query 默认缓存回收时间,单位为毫秒。 */
|
||||
export const QUERY_DEFAULT_GC_TIME_MS = 5 * 60_000
|
||||
|
||||
/** @description 查询请求默认允许的最大重试次数。 */
|
||||
export const QUERY_RETRY_LIMIT = 2
|
||||
|
||||
/** @description 可被视为瞬时失败并允许重试的 HTTP 状态码集合。 */
|
||||
export const QUERY_RETRYABLE_STATUS_CODES = [
|
||||
408, 429, 500, 502, 503, 504,
|
||||
] as const
|
||||
|
||||
/** @description 桌面端布局切换起始断点,单位为像素。 */
|
||||
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
|
||||
|
||||
/** @description 应用支持的语言代码列表。 */
|
||||
export const SUPPORTED_LANGUAGES = ['zh-CN', 'en-US', 'ms-MY', 'id-ID'] as const
|
||||
|
||||
/** @description 应用无法解析用户语言时使用的默认语言。 */
|
||||
export const DEFAULT_APP_LANGUAGE = 'zh-CN'
|
||||
|
||||
/** @description 语言切换面板展示的语言选项配置。 */
|
||||
export const LANGUAGE_OPTIONS: Array<{
|
||||
code: (typeof SUPPORTED_LANGUAGES)[number]
|
||||
icon: string
|
||||
labelKey: string
|
||||
}> = [
|
||||
{
|
||||
code: 'zh-CN',
|
||||
icon: zhCN,
|
||||
labelKey: 'language.zhCN',
|
||||
},
|
||||
{
|
||||
code: 'en-US',
|
||||
icon: enUS,
|
||||
labelKey: 'language.enUS',
|
||||
},
|
||||
{
|
||||
code: 'ms-MY',
|
||||
icon: msMY,
|
||||
labelKey: 'language.msMY',
|
||||
},
|
||||
{
|
||||
code: 'id-ID',
|
||||
icon: idID,
|
||||
labelKey: 'language.idID',
|
||||
},
|
||||
]
|
||||
|
||||
/** @description 按语言代码快速取得国旗图标资源的映射表。 */
|
||||
export const LANGUAGE_ICON_MAP = new Map(
|
||||
LANGUAGE_OPTIONS.map((language) => [language.code, language.icon] as const),
|
||||
)
|
||||
|
||||
/** @description 浏览器全屏状态变更事件名,包含 WebKit/Firefox/IE 兼容事件。 */
|
||||
export const FULLSCREEN_CHANGE_EVENTS = [
|
||||
'fullscreenchange',
|
||||
'webkitfullscreenchange',
|
||||
'mozfullscreenchange',
|
||||
'MSFullscreenChange',
|
||||
] as const
|
||||
|
||||
/** @description 通知弹窗默认停留时间,单位为毫秒。 */
|
||||
export const DEFAULT_ALERT_DURATION_MS = 2600
|
||||
|
||||
/** @description 通知弹窗关闭动画持续时间,单位为毫秒。 */
|
||||
export const NOTIFICATION_EXIT_DURATION_MS = 220
|
||||
|
||||
/** @description 全局弹窗注册键列表,用于统一控制弹窗开关状态。 */
|
||||
export const MODAL_KEYS = [
|
||||
'desktopLogin',
|
||||
'desktopRegister',
|
||||
'desktopLanguage',
|
||||
'desktopRules',
|
||||
'desktopUserInfo',
|
||||
'desktopNotice',
|
||||
'desktopAutoSetting',
|
||||
'desktopProcedures',
|
||||
'desktopWithdrawTopup',
|
||||
] as const
|
||||
|
||||
/** @description 全局弹窗默认可见状态。 */
|
||||
export const INITIAL_MODAL_VISIBILITY = {
|
||||
desktopLogin: false,
|
||||
desktopRegister: false,
|
||||
desktopLanguage: false,
|
||||
desktopRules: false,
|
||||
desktopUserInfo: false,
|
||||
desktopNotice: false,
|
||||
desktopAutoSetting: false,
|
||||
desktopProcedures: false,
|
||||
desktopWithdrawTopup: false,
|
||||
} as const
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AUTH_ENDPOINTS, AUTH_SKIP_REFRESH_CONTEXT_KEY } from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { AuthSessionInput } from '@/store/auth'
|
||||
@@ -20,13 +21,6 @@ import {
|
||||
normalizeRefreshAuthSession,
|
||||
} from './types'
|
||||
|
||||
const AUTH_ENDPOINTS = {
|
||||
login: 'api/user/login',
|
||||
profile: 'api/user/profile',
|
||||
refreshToken: 'api/user/refreshToken',
|
||||
register: 'api/user/register',
|
||||
} as const
|
||||
|
||||
const shouldLogAuthLifecycle =
|
||||
import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
||||
|
||||
@@ -158,7 +152,7 @@ export async function refreshAuthSession(
|
||||
AUTH_ENDPOINTS.refreshToken,
|
||||
{
|
||||
context: {
|
||||
skipAuthRefresh: true,
|
||||
[AUTH_SKIP_REFRESH_CONTEXT_KEY]: true,
|
||||
},
|
||||
json: {
|
||||
refresh_token: refreshToken,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { AUTH_ERROR_KEY_PREFIX } from '@/constants'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
|
||||
type AuthSubmitContext = 'login' | 'register'
|
||||
|
||||
const AUTH_ERROR_KEY_PREFIX = 'auth.'
|
||||
|
||||
function isTranslationKey(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.startsWith(AUTH_ERROR_KEY_PREFIX)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FINANCE_API_ENDPOINTS } from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { ApiResponse } from '@/type'
|
||||
@@ -10,6 +11,9 @@ import type {
|
||||
DepositWithdrawConfig,
|
||||
DepositWithdrawConfigDto,
|
||||
FinanceCurrencyConfigDto,
|
||||
FinanceOrderItemDto,
|
||||
FinanceOrderList,
|
||||
FinanceOrderListDto,
|
||||
FinancePayChannelDto,
|
||||
FinanceRateConfigDto,
|
||||
FinanceWithdrawBankDto,
|
||||
@@ -18,14 +22,6 @@ import type {
|
||||
WithdrawCreateResponseDto,
|
||||
} from './finance-types'
|
||||
|
||||
export const FINANCE_API_ENDPOINTS = {
|
||||
depositCreate: 'api/finance/depositCreate',
|
||||
depositTierList: 'api/finance/depositTierList',
|
||||
depositWithdrawConfig: 'api/finance/depositWithdrawConfig',
|
||||
legacyCashierConfig: 'api/finance/cashierConfig',
|
||||
withdrawCreate: 'api/finance/withdrawCreate',
|
||||
} as const
|
||||
|
||||
function unwrapFinanceEnvelope<T>(
|
||||
response: ApiResponse<T>,
|
||||
fallbackMessage = 'Finance request failed',
|
||||
@@ -181,6 +177,25 @@ function normalizeDepositTierItem(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFinanceOrderItem(dto: FinanceOrderItemDto) {
|
||||
return {
|
||||
amount: String(dto.amount ?? ''),
|
||||
bonusAmount: String(dto.bonus_amount ?? ''),
|
||||
orderNo: dto.order_no,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFinanceOrderList(dto: FinanceOrderListDto): FinanceOrderList {
|
||||
return {
|
||||
list: (dto.list ?? []).map(normalizeFinanceOrderItem),
|
||||
pagination: {
|
||||
page: dto.pagination?.page ?? 1,
|
||||
page_size: dto.pagination?.page_size ?? 20,
|
||||
total: dto.pagination?.total ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDepositWithdrawConfig() {
|
||||
const response = await api.post<DepositWithdrawConfigDto>(
|
||||
FINANCE_API_ENDPOINTS.depositWithdrawConfig,
|
||||
@@ -230,6 +245,48 @@ export async function createDeposit(payload: DepositCreateRequestDto) {
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function getDepositOrderList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.get<FinanceOrderListDto>(
|
||||
FINANCE_API_ENDPOINTS.depositList,
|
||||
{
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? 20),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<FinanceOrderListDto>,
|
||||
'Failed to load deposit order list',
|
||||
)
|
||||
|
||||
return normalizeFinanceOrderList(dto)
|
||||
}
|
||||
|
||||
export async function getWithdrawOrderList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.get<FinanceOrderListDto>(
|
||||
FINANCE_API_ENDPOINTS.withdrawList,
|
||||
{
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? 20),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<FinanceOrderListDto>,
|
||||
'Failed to load withdraw order list',
|
||||
)
|
||||
|
||||
return normalizeFinanceOrderList(dto)
|
||||
}
|
||||
|
||||
export async function createWithdraw(payload: WithdrawCreateRequestDto) {
|
||||
const response = await api.post<
|
||||
WithdrawCreateResponseDto,
|
||||
|
||||
@@ -162,6 +162,34 @@ 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 FinanceOrderListDto {
|
||||
list: FinanceOrderItemDto[]
|
||||
pagination: FinanceOrderPaginationDto
|
||||
}
|
||||
|
||||
export interface FinanceOrderItem {
|
||||
amount: string
|
||||
bonusAmount: string
|
||||
orderNo: string
|
||||
}
|
||||
|
||||
export interface FinanceOrderList {
|
||||
list: FinanceOrderItem[]
|
||||
pagination: FinanceOrderPaginationDto
|
||||
}
|
||||
|
||||
export interface WithdrawCreateRequestDto {
|
||||
bank_code: string
|
||||
channel_code: string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GAME_API_ENDPOINTS } from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { ApiResponse } from '@/type'
|
||||
@@ -79,19 +80,6 @@ function assertLobbyInitDto(
|
||||
}
|
||||
}
|
||||
|
||||
export const GAME_API_ENDPOINTS = {
|
||||
announcements: 'game/announcements',
|
||||
betMyOrders: 'api/game/betMyOrders',
|
||||
betPlaceLegacy: 'api/game/betPlace',
|
||||
bootstrap: 'game/bootstrap',
|
||||
lobbyInit: 'api/game/lobbyInit',
|
||||
noticeConfirm: 'api/notice/noticeConfirm',
|
||||
noticeDetail: 'api/notice/noticeDetail',
|
||||
noticeList: 'api/notice/noticeList',
|
||||
placeBet: 'api/game/placeBet',
|
||||
roundFeed: 'game/round-feed',
|
||||
} as const
|
||||
|
||||
export interface GameLobbyInitResult {
|
||||
runtimeEnabled: boolean
|
||||
serverTime: number
|
||||
|
||||
@@ -125,9 +125,11 @@ export interface GameAnnouncementsDto {
|
||||
}
|
||||
|
||||
export interface NoticeListItemDto {
|
||||
content?: string
|
||||
is_read: boolean
|
||||
must_confirm?: boolean
|
||||
notice_id: number
|
||||
notice_type: 'silent' | 'popout'
|
||||
notice_type: 'silent' | 'popout' | (string & {})
|
||||
publish_time: number
|
||||
title: string
|
||||
}
|
||||
@@ -140,7 +142,7 @@ export interface NoticeDetailDto {
|
||||
content: string
|
||||
must_confirm: boolean
|
||||
notice_id: number
|
||||
notice_type: 'silent' | 'popout'
|
||||
notice_type: 'silent' | 'popout' | (string & {})
|
||||
publish_time: number
|
||||
title: string
|
||||
}
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import hallMusic from '@/assets/music/hall-music.mp3'
|
||||
|
||||
export type AudioAssetId = 'hall-bgm'
|
||||
|
||||
export type AudioAssetDefinition = {
|
||||
id: AudioAssetId
|
||||
loop?: boolean
|
||||
src: string
|
||||
volume?: number
|
||||
}
|
||||
|
||||
export const AUDIO_ASSET_DEFINITIONS: AudioAssetDefinition[] = [
|
||||
{
|
||||
id: 'hall-bgm',
|
||||
src: hallMusic,
|
||||
loop: true,
|
||||
volume: 1,
|
||||
},
|
||||
]
|
||||
export {
|
||||
AUDIO_ASSET_DEFINITIONS,
|
||||
type AudioAssetDefinition,
|
||||
type AudioAssetId,
|
||||
} from '@/constants/game'
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import animalBorderImage from '@/assets/game/animal-border.webp'
|
||||
import enStopImage from '@/assets/game/en-stop.webp'
|
||||
import zhStopImage from '@/assets/game/zh-stop.webp'
|
||||
import diamondIcon from '@/assets/system/diamond.webp'
|
||||
import { LottiePlayer } from '@/components/lottie-player'
|
||||
import { SmartImage } from '@/components/smart-image'
|
||||
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore } from '@/store/game'
|
||||
|
||||
const revealBorderPath = new URL(
|
||||
'../../../../assets/lottie/test.json',
|
||||
import.meta.url,
|
||||
).href
|
||||
|
||||
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
|
||||
eager: true,
|
||||
@@ -24,8 +35,25 @@ const animalImageList = Object.entries(animalModules)
|
||||
.filter((item) => item.id > 0)
|
||||
.sort((left, right) => left.id - right.id)
|
||||
|
||||
function getRandomAnimalId(ids: number[], currentId: number | null) {
|
||||
if (ids.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (ids.length === 1) {
|
||||
return ids[0] ?? null
|
||||
}
|
||||
|
||||
let nextId = currentId
|
||||
|
||||
while (nextId === currentId) {
|
||||
nextId = ids[Math.floor(Math.random() * ids.length)] ?? currentId
|
||||
}
|
||||
|
||||
return nextId
|
||||
}
|
||||
|
||||
interface DesktopAnimalProps {
|
||||
activeId?: number | null
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
imageClassName?: string
|
||||
@@ -33,14 +61,34 @@ interface DesktopAnimalProps {
|
||||
}
|
||||
|
||||
export function DesktopAnimal({
|
||||
activeId,
|
||||
className,
|
||||
itemClassName,
|
||||
imageClassName,
|
||||
onSelect,
|
||||
}: DesktopAnimalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { i18n, t } = useTranslation()
|
||||
const animalIds = useMemo(() => animalImageList.map((item) => item.id), [])
|
||||
const containerRef = useRef<HTMLElement | null>(null)
|
||||
const cellRefs = useRef(new Map<number, HTMLButtonElement>())
|
||||
const [revealCellId, setRevealCellId] = useState<number | null>(null)
|
||||
const [revealFrame, setRevealFrame] = useState<{
|
||||
height: number
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
} | null>(null)
|
||||
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
|
||||
const revealWinningCellId = useGameRoundStore(
|
||||
(state) => state.revealAnimation.winningCellId,
|
||||
)
|
||||
const roundPhase = useGameRoundStore((state) => state.round.phase)
|
||||
const roundId = useGameRoundStore((state) => state.round.id)
|
||||
const lastBetPeriodNo = useAuthStore(
|
||||
(state) => state.currentUser?.lastBetPeriodNo,
|
||||
)
|
||||
const finishRevealAnimation = useGameRoundStore(
|
||||
(state) => state.finishRevealAnimation,
|
||||
)
|
||||
const {
|
||||
cellWarning,
|
||||
handleSelect,
|
||||
@@ -51,9 +99,112 @@ export function DesktopAnimal({
|
||||
selectionByCell,
|
||||
showStandbyState,
|
||||
} = useAnimalVm(animalIds, onSelect)
|
||||
const isRevealRunning =
|
||||
revealPhase === 'spinning' || revealPhase === 'stopping'
|
||||
const isRevealResult = revealPhase === 'result'
|
||||
const hasSubmittedCurrentRound =
|
||||
roundPhase === 'betting' && Boolean(roundId) && lastBetPeriodNo === roundId
|
||||
const showStopOverlay =
|
||||
hasSubmittedCurrentRound ||
|
||||
roundPhase === 'locked' ||
|
||||
roundPhase === 'revealing'
|
||||
const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
|
||||
? zhStopImage
|
||||
: enStopImage
|
||||
|
||||
useEffect(() => {
|
||||
if (revealPhase === 'idle') {
|
||||
setRevealCellId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (revealPhase === 'result') {
|
||||
setRevealCellId(revealWinningCellId)
|
||||
return
|
||||
}
|
||||
|
||||
if (revealPhase === 'spinning') {
|
||||
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
|
||||
}, 70)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}
|
||||
|
||||
if (revealWinningCellId === null) {
|
||||
return
|
||||
}
|
||||
|
||||
let elapsedMs = 0
|
||||
const timeoutIds: number[] = []
|
||||
|
||||
for (let index = 0; index < 14; index += 1) {
|
||||
elapsedMs += 42 + index * 13
|
||||
timeoutIds.push(
|
||||
window.setTimeout(() => {
|
||||
setRevealCellId((currentId) =>
|
||||
getRandomAnimalId(animalIds, currentId),
|
||||
)
|
||||
}, elapsedMs),
|
||||
)
|
||||
}
|
||||
|
||||
elapsedMs += 160
|
||||
timeoutIds.push(
|
||||
window.setTimeout(() => {
|
||||
setRevealCellId(revealWinningCellId)
|
||||
finishRevealAnimation()
|
||||
}, elapsedMs),
|
||||
)
|
||||
|
||||
return () => {
|
||||
for (const timeoutId of timeoutIds) {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}, [animalIds, finishRevealAnimation, revealPhase, revealWinningCellId])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (revealCellId === null) {
|
||||
setRevealFrame(null)
|
||||
return
|
||||
}
|
||||
|
||||
const syncRevealFrame = () => {
|
||||
const container = containerRef.current
|
||||
const cell = cellRefs.current.get(revealCellId)
|
||||
|
||||
if (!container || !cell) {
|
||||
setRevealFrame(null)
|
||||
return
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const cellRect = cell.getBoundingClientRect()
|
||||
|
||||
setRevealFrame({
|
||||
height: cellRect.height,
|
||||
left: cellRect.left - containerRect.left,
|
||||
top: cellRect.top - containerRect.top,
|
||||
width: cellRect.width,
|
||||
})
|
||||
}
|
||||
|
||||
syncRevealFrame()
|
||||
window.addEventListener('resize', syncRevealFrame)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', syncRevealFrame)
|
||||
}
|
||||
}, [revealCellId])
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative grid w-full grid-cols-6 gap-design-5 overflow-hidden common-neon-inset',
|
||||
className,
|
||||
@@ -62,8 +213,8 @@ export function DesktopAnimal({
|
||||
{animalImageList.map((item) => {
|
||||
const selectionMeta = selectionByCell[item.id]
|
||||
const hasPlacedSelection = Boolean(selectionMeta)
|
||||
const isActive = item.id === activeId || hasPlacedSelection
|
||||
const isMarqueeActive = showStandbyState && item.id === marqueeId
|
||||
const isRevealWinner = isRevealResult && revealWinningCellId === item.id
|
||||
const warningType =
|
||||
cellWarning?.cellId === item.id ? cellWarning.type : null
|
||||
const showCellWarning = warningType !== null
|
||||
@@ -75,8 +226,15 @@ export function DesktopAnimal({
|
||||
return (
|
||||
<motion.button
|
||||
key={item.id}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
cellRefs.current.set(item.id, node)
|
||||
} else {
|
||||
cellRefs.current.delete(item.id)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
disabled={lockInteraction}
|
||||
disabled={lockInteraction || showStopOverlay}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
animate={
|
||||
showCellWarning
|
||||
@@ -100,20 +258,34 @@ export function DesktopAnimal({
|
||||
: { duration: 0.16, ease: 'easeOut' }
|
||||
}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
|
||||
'relative flex h-design-112 flex-col items-center justify-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
|
||||
lockInteraction
|
||||
? 'cursor-not-allowed opacity-90'
|
||||
: 'cursor-pointer hover:-translate-y-[1px]',
|
||||
isMarqueeActive &&
|
||||
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
|
||||
isActive &&
|
||||
'border-[rgba(255,187,61,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,175,52,0.82),0_0_calc(var(--design-unit)*30)_rgba(255,151,15,0.46),inset_0_0_calc(var(--design-unit)*20)_rgba(255,177,70,0.58)]',
|
||||
isRevealRunning &&
|
||||
'border-[rgba(104,255,249,0.9)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(68,244,255,0.68),0_0_calc(var(--design-unit)*26)_rgba(37,214,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(115,255,247,0.24)] brightness-125 saturate-150',
|
||||
isRevealWinner &&
|
||||
'shadow-[0_0_calc(var(--design-unit)*14)_rgba(81,248,255,0.72),0_0_calc(var(--design-unit)*24)_rgba(30,199,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.34)] brightness-125 saturate-150',
|
||||
showCellWarning &&
|
||||
'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]',
|
||||
!showStandbyState && !hasPlacedSelection && 'opacity-95',
|
||||
itemClassName,
|
||||
)}
|
||||
>
|
||||
<SmartImage
|
||||
src={animalBorderImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
priority
|
||||
showSkeleton={false}
|
||||
className="pointer-events-none absolute inset-0 z-20 h-full w-full"
|
||||
imgClassName="object-fill"
|
||||
/>
|
||||
<span className="pointer-events-none absolute left-design-24 top-design-16 z-30 text-design-32 font-bold leading-none text-[#4BFFFE]">
|
||||
{String(item.id).padStart(2, '0')}
|
||||
</span>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
animate={
|
||||
@@ -135,8 +307,10 @@ export function DesktopAnimal({
|
||||
'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150',
|
||||
isMarqueeActive &&
|
||||
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
|
||||
isActive &&
|
||||
'bg-[radial-gradient(circle_at_center,rgba(255,207,116,0.42)_0%,rgba(255,181,61,0.16)_42%,transparent_74%)] opacity-100',
|
||||
isRevealRunning &&
|
||||
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.5)_0%,rgba(77,244,255,0.24)_40%,rgba(27,183,255,0.1)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*16)_rgba(95,249,255,0.72),inset_0_0_calc(var(--design-unit)*22)_rgba(151,255,250,0.4)]',
|
||||
isRevealWinner &&
|
||||
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.5)_0%,rgba(67,226,255,0.24)_38%,rgba(25,131,255,0.1)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*14)_rgba(92,248,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(126,255,250,0.4)]',
|
||||
showCellWarning &&
|
||||
'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100',
|
||||
)}
|
||||
@@ -151,9 +325,14 @@ export function DesktopAnimal({
|
||||
src={item.url}
|
||||
alt={`animal-${item.id}`}
|
||||
className={cn(
|
||||
'relative z-10 h-design-112 w-design-223 rounded-2xl object-contain',
|
||||
'absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*14)]',
|
||||
isRevealRunning &&
|
||||
'brightness-125 saturate-150 drop-shadow-[0_0_calc(var(--design-unit)*10)_rgba(101,250,255,0.62)]',
|
||||
isRevealWinner &&
|
||||
'brightness-140 saturate-150 drop-shadow-[0_0_calc(var(--design-unit)*12)_rgba(106,250,255,0.72)]',
|
||||
imageClassName,
|
||||
)}
|
||||
imgClassName="object-fill"
|
||||
/>
|
||||
{showCellWarning ? (
|
||||
<motion.span
|
||||
@@ -210,21 +389,135 @@ export function DesktopAnimal({
|
||||
)
|
||||
})}
|
||||
|
||||
{revealFrame ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-40 transition-[height,transform,width] duration-75 ease-linear"
|
||||
style={{
|
||||
height: revealFrame.height + 16,
|
||||
transform: `translate(${revealFrame.left - 8}px, ${revealFrame.top - 8}px)`,
|
||||
width: revealFrame.width + 16,
|
||||
}}
|
||||
>
|
||||
<LottiePlayer
|
||||
path={revealBorderPath}
|
||||
renderer="svg"
|
||||
loop
|
||||
autoplay
|
||||
speed={1.8}
|
||||
className="h-full w-full scale-[1.18] [&>svg]:h-full [&>svg]:w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showStopOverlay ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]"
|
||||
>
|
||||
<SmartImage
|
||||
src={stopImageSrc}
|
||||
alt="stop betting"
|
||||
priority
|
||||
showSkeleton={false}
|
||||
className="h-design-220 w-design-560 max-w-[78%] overflow-visible"
|
||||
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showStandbyState ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStart}
|
||||
className="absolute inset-0 z-10 flex cursor-pointer items-center justify-center bg-[rgba(3,13,20,0.62)]"
|
||||
aria-busy={isRealtimeConnecting}
|
||||
className="group absolute inset-0 z-10 flex cursor-pointer items-center justify-center overflow-hidden bg-[rgba(3,13,20,0.66)]"
|
||||
>
|
||||
<div className="relative flex flex-col items-center gap-design-8 rounded-[calc(var(--design-unit)*20)] border border-[rgba(111,255,247,0.54)] bg-[linear-gradient(180deg,rgba(6,28,38,0.92),rgba(4,14,20,0.94))] px-design-28 py-design-16 text-center shadow-[0_0_calc(var(--design-unit)*16)_rgba(70,245,255,0.34),0_0_calc(var(--design-unit)*34)_rgba(19,210,232,0.22)] transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-[1px] hover:border-[rgba(141,255,250,0.8)] hover:shadow-[0_0_calc(var(--design-unit)*22)_rgba(88,247,255,0.48),0_0_calc(var(--design-unit)*42)_rgba(32,228,255,0.3)]">
|
||||
<span className="text-design-14 uppercase tracking-[0.42em] text-[rgba(111,255,247,0.76)]">
|
||||
{isRealtimeConnecting ? '' : t('gameDesktop.animal.tapToEnter')}
|
||||
</span>
|
||||
<span className="text-design-28 font-semibold tracking-[0.18em] text-[#D2FFFF]">
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 18,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'linear',
|
||||
}}
|
||||
className="pointer-events-none absolute inset-[12%] rounded-full bg-[conic-gradient(from_0deg,rgba(129,255,250,0)_0deg,rgba(129,255,250,0.26)_60deg,rgba(129,255,250,0)_120deg,rgba(255,255,255,0)_360deg)] opacity-70 blur-[18px]"
|
||||
/>
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
animate={{
|
||||
scale: [1, 1.03, 1],
|
||||
opacity: [0.42, 0.7, 0.42],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2.8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className="pointer-events-none absolute inset-[22%] rounded-full border border-[rgba(124,255,248,0.22)] shadow-[0_0_calc(var(--design-unit)*22)_rgba(74,245,255,0.12),inset_0_0_calc(var(--design-unit)*18)_rgba(122,255,250,0.14)]"
|
||||
/>
|
||||
<div className="relative flex min-w-design-260 flex-col items-center gap-design-10 rounded-[calc(var(--design-unit)*22)] border border-[rgba(111,255,247,0.56)] bg-[linear-gradient(180deg,rgba(8,30,42,0.94),rgba(4,14,20,0.96))] px-design-32 py-design-18 text-center shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.38),0_0_calc(var(--design-unit)*42)_rgba(19,210,232,0.22),inset_0_0_calc(var(--design-unit)*18)_rgba(120,255,249,0.12)] transition-[transform,box-shadow,border-color] duration-200 group-hover:-translate-y-[1px] group-hover:border-[rgba(141,255,250,0.82)] group-hover:shadow-[0_0_calc(var(--design-unit)*24)_rgba(88,247,255,0.5),0_0_calc(var(--design-unit)*52)_rgba(32,228,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(145,255,251,0.16)]">
|
||||
<span className="pointer-events-none absolute inset-[1px] rounded-[calc(var(--design-unit)*22)] border border-[rgba(226,255,255,0.1)]" />
|
||||
<span className="pointer-events-none absolute inset-x-design-14 top-design-10 h-design-18 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.16),rgba(255,255,255,0))] opacity-70" />
|
||||
<span className="text-design-14 uppercase tracking-[0.44em] text-[rgba(132,255,248,0.72)]">
|
||||
{isRealtimeConnecting
|
||||
? t('gameDesktop.animal.loading')
|
||||
: t('gameDesktop.animal.getStart')}
|
||||
: t('gameDesktop.animal.tapToEnter')}
|
||||
</span>
|
||||
<div className="flex items-center gap-design-10">
|
||||
{isRealtimeConnecting ? (
|
||||
<span className="relative flex h-design-24 w-design-24 items-center justify-center">
|
||||
<motion.span
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'linear',
|
||||
}}
|
||||
className="absolute inset-0 rounded-full border-2 border-[rgba(119,255,250,0.22)] border-t-[rgba(119,255,250,0.98)] border-r-[rgba(119,255,250,0.56)]"
|
||||
/>
|
||||
<motion.span
|
||||
animate={{ scale: [0.85, 1, 0.85], opacity: [0.6, 1, 0.6] }}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className="h-design-8 w-design-8 rounded-full bg-[#BFFFFD] shadow-[0_0_calc(var(--design-unit)*10)_rgba(114,255,249,0.72)]"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-design-10 w-design-10 rounded-full bg-[rgba(126,255,248,0.92)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(114,255,249,0.62)]" />
|
||||
)}
|
||||
<span className="text-design-28 font-semibold tracking-[0.18em] text-[#E0FFFF]">
|
||||
{isRealtimeConnecting
|
||||
? t('gameDesktop.animal.loading')
|
||||
: t('gameDesktop.animal.getStart')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-design-4">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
animate={
|
||||
isRealtimeConnecting
|
||||
? { opacity: [0.28, 1, 0.28], y: [0, -2, 0] }
|
||||
: { opacity: 0.7 }
|
||||
}
|
||||
transition={
|
||||
isRealtimeConnecting
|
||||
? {
|
||||
duration: 0.9,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'easeInOut',
|
||||
delay: index * 0.15,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className="h-design-4 w-design-4 rounded-full bg-[rgba(145,255,249,0.86)] shadow-[0_0_calc(var(--design-unit)*8)_rgba(114,255,249,0.48)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -93,8 +93,10 @@ export function DesktopHeader() {
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-design-175 flex-col items-center justify-center gap-design-5 border-r border-[rgba(128,223,231,0.65)]">
|
||||
<div>{t('gameDesktop.header.systemTime')}</div>
|
||||
<div>{systemTimeLabel}</div>
|
||||
<div className={'text-[#B4E4E9]'}>
|
||||
{t('gameDesktop.header.systemTime')}
|
||||
</div>
|
||||
<div className={'text-[#D2FCFF] font-bold'}>{systemTimeLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-1 items-center justify-around gap-design-10 border-r border-[rgba(128,223,231,0.65)] px-design-20">
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import down5Animation from '@/assets/lottie/down5.json'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import fire from '@/assets/system/fire.webp'
|
||||
import lock from '@/assets/system/lock.webp'
|
||||
import statusCenter from '@/assets/system/status-center.webp'
|
||||
import statusLine from '@/assets/system/status-line.webp'
|
||||
import streakBg from '@/assets/system/streak.webp'
|
||||
import { LottiePlayer } from '@/components/lottie-player.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
|
||||
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
|
||||
|
||||
export function DesktopStatusLine() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@@ -36,22 +40,72 @@ export function DesktopStatusLine() {
|
||||
<SmartBackground
|
||||
src={statusLine}
|
||||
size="100% 100%"
|
||||
className="w-full h-design-60 bg-no-repeat bg-center flex items-center justify-center"
|
||||
className="w-full h-design-75 bg-no-repeat bg-center flex items-center justify-center"
|
||||
>
|
||||
{/* 状态栏左侧 */}
|
||||
<div
|
||||
className={'flex-1 flex items-center justify-center gap-design-24'}
|
||||
className={
|
||||
'relative h-full flex-1 flex items-center justify-center gap-design-50'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{t('gameDesktop.status.odds')}: {oddsLabel}
|
||||
{/*<div className={'flex-1 absolute z-10 -right-20 -top-6 w-full !h-design-105'} style={{*/}
|
||||
{/* backgroundImage: `url(${streakBg})`,*/}
|
||||
{/* backgroundSize: '100% 110%',*/}
|
||||
{/* backgroundRepeat: 'no-repeat',*/}
|
||||
{/*}} >*/}
|
||||
{/*</div>*/}
|
||||
|
||||
<div className={'text-[#CBD3D5] font-bold'}>
|
||||
{t('gameDesktop.status.odds')}:{' '}
|
||||
<span className={'text-[#E3D171]'}>{oddsLabel}</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('gameDesktop.status.streak')}: {streakLabel}
|
||||
<div
|
||||
className={
|
||||
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
|
||||
}
|
||||
>
|
||||
<SmartImage
|
||||
className={'w-design-37 h-design-47'}
|
||||
alt={'fire'}
|
||||
src={fire}
|
||||
/>
|
||||
<div>
|
||||
{t('gameDesktop.status.streak')}:{' '}
|
||||
<span
|
||||
className={
|
||||
'bg-gradient-to-b from-[#EBA661] to-[#FCC785] bg-clip-text text-transparent'
|
||||
}
|
||||
>
|
||||
{streakLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{t('gameDesktop.status.limit')}: {limitLabel}
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
|
||||
}
|
||||
>
|
||||
<SmartImage
|
||||
className={'w-design-25 h-design-33'}
|
||||
alt={'lock'}
|
||||
src={lock}
|
||||
/>
|
||||
<div className={'flex items-center gap-design-10'}>
|
||||
<div>{t('gameDesktop.status.limit')}:</div>
|
||||
<div className={'flex items-center gap-design-5'}>
|
||||
<SmartImage
|
||||
className={'w-design-35 h-design-35'}
|
||||
alt={'diamond'}
|
||||
src={diamond}
|
||||
/>
|
||||
<div>{limitLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex h-[105px] w-design-360 items-center justify-center">
|
||||
|
||||
<div className="relative z-20 flex h-[105px] w-design-360 items-center justify-center">
|
||||
<SmartBackground
|
||||
src={statusCenter}
|
||||
className="pointer-events-none absolute inset-0 z-0 bg-no-repeat bg-center bg-contain transition-opacity duration-500 ease-out"
|
||||
@@ -100,7 +154,7 @@ export function DesktopStatusLine() {
|
||||
</SmartBackground>
|
||||
<div
|
||||
className={
|
||||
'absolute top-design-60 left-1/2 -translate-x-1/2 -z-10 w-full px-design-16'
|
||||
'absolute top-design-75 left-1/2 -translate-x-1/2 -z-10 w-full px-design-16'
|
||||
}
|
||||
>
|
||||
<DesktopTitle />
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { Megaphone } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import broadcast from '@/assets/system/broadcast.webp'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
export function DesktopTitle() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="common-neon-inset text-design-16 w-full flex h-design-50 items-end gap-design-10 !px-design-20 text-[#FF970F]">
|
||||
<Megaphone color={'#57B8BF'} />
|
||||
<div>{t('gameDesktop.title.announcement')}</div>
|
||||
<section className="common-neon-inset text-design-16 w-full flex h-design-65 items-center gap-design-10 !px-design-20 ">
|
||||
<SmartImage
|
||||
className={'w-design-24 h-design-24'}
|
||||
alt={'broadcast'}
|
||||
src={broadcast}
|
||||
/>
|
||||
<div className={'!text-[#FF970F]'}>
|
||||
{t('gameDesktop.title.announcement')}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx'
|
||||
export type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
|
||||
export { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
|
||||
export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal'
|
||||
export { EntryNoticeGateModal } from '@/features/game/components/shared/entry-notice-gate-modal'
|
||||
|
||||
235
src/features/game/components/shared/entry-notice-gate-modal.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { RotateCw } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||
import checkIcon from '@/assets/system/right.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import {
|
||||
ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
|
||||
ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY,
|
||||
} from '@/constants'
|
||||
import { getNoticeList } from '@/features/game/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
function getLastConfirmedAt(storageKey: string) {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = Number(localStorage.getItem(storageKey))
|
||||
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
function setLastConfirmedAt(storageKey: string, timestamp: number) {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(storageKey, String(timestamp))
|
||||
}
|
||||
|
||||
export function EntryNoticeGateModal() {
|
||||
const { t } = useTranslation()
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const authIsHydrated = useAuthStore((state) => state.isHydrated)
|
||||
const accessToken = useAuthStore((state) => state.accessToken)
|
||||
const currentUserId = useAuthStore((state) => state.currentUser?.id)
|
||||
const [hasEntered, setHasEntered] = useState(false)
|
||||
const [hasAgreed, setHasAgreed] = useState(false)
|
||||
const [shouldGateEntry, setShouldGateEntry] = useState(false)
|
||||
|
||||
const hasStoredLoginInfo =
|
||||
authStatus === 'authenticated' && Boolean(accessToken)
|
||||
const confirmedAtStorageKey = `${ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY}:${
|
||||
currentUserId ?? 'authenticated'
|
||||
}`
|
||||
|
||||
useEffect(() => {
|
||||
if (!authIsHydrated) {
|
||||
return
|
||||
}
|
||||
|
||||
setHasEntered(false)
|
||||
setHasAgreed(false)
|
||||
|
||||
if (!hasStoredLoginInfo) {
|
||||
setShouldGateEntry(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const lastConfirmedAt = getLastConfirmedAt(confirmedAtStorageKey)
|
||||
|
||||
setShouldGateEntry(
|
||||
!lastConfirmedAt ||
|
||||
Date.now() - lastConfirmedAt >= ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
|
||||
)
|
||||
}, [authIsHydrated, confirmedAtStorageKey, hasStoredLoginInfo])
|
||||
|
||||
const noticeListQuery = useQuery({
|
||||
queryKey: ['game', 'entry-notice-list'],
|
||||
queryFn: () => getNoticeList({ page: 1, pageSize: 20 }),
|
||||
enabled: authIsHydrated && shouldGateEntry,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const popoutNotices = useMemo(
|
||||
() =>
|
||||
(noticeListQuery.data?.list ?? []).filter(
|
||||
(notice) => notice.notice_type === 'popout',
|
||||
),
|
||||
[noticeListQuery.data],
|
||||
)
|
||||
|
||||
const shouldShowModal =
|
||||
authIsHydrated &&
|
||||
shouldGateEntry &&
|
||||
!hasEntered &&
|
||||
(noticeListQuery.isPending ||
|
||||
noticeListQuery.isError ||
|
||||
popoutNotices.length > 0)
|
||||
const canEnter =
|
||||
hasAgreed && !noticeListQuery.isPending && popoutNotices.length > 0
|
||||
|
||||
if (!shouldShowModal) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={shouldShowModal}
|
||||
isShowClose={false}
|
||||
isNormalBg={true}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-26">
|
||||
{t('game.modals.entryNotice.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="left"
|
||||
className="h-design-700 w-design-1000 max-h-[92vh] max-w-[92vw]"
|
||||
>
|
||||
<div className="flex h-full w-full flex-col gap-design-20 px-design-14 pb-design-30 pt-design-8">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-[#2B8CA3]/45 bg-[#001B24]/70 p-design-18 shadow-[inset_0_0_calc(var(--design-unit)*18)_rgba(39,175,205,0.1)]">
|
||||
{noticeListQuery.isPending ? (
|
||||
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] items-center justify-center text-design-22 text-[#9CE8F2]">
|
||||
{t('game.modals.entryNotice.loading')}
|
||||
</div>
|
||||
) : noticeListQuery.isError ? (
|
||||
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] flex-col items-center justify-center gap-design-18 text-center text-[#9CE8F2]">
|
||||
<div className="text-design-22">
|
||||
{t('game.modals.entryNotice.loadFailed')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void noticeListQuery.refetch()
|
||||
}}
|
||||
className="inline-flex items-center gap-design-8 rounded-md border border-[#4AC6DE]/45 bg-[#0B4454] px-design-18 py-design-10 text-design-18 text-[#D7FFFF] transition hover:bg-[#0E576D]"
|
||||
>
|
||||
<RotateCw className="h-design-18 w-design-18" />
|
||||
{t('game.modals.entryNotice.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-design-16">
|
||||
{popoutNotices.map((notice, index) => (
|
||||
<article
|
||||
key={notice.notice_id}
|
||||
className="rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-20"
|
||||
>
|
||||
<div className="mb-design-12 flex flex-wrap items-center justify-between gap-design-12">
|
||||
<div className="min-w-0 flex-1 text-design-24 font-semibold leading-tight text-white">
|
||||
{index + 1}. {notice.title}
|
||||
</div>
|
||||
<div className="rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-12 py-design-5 text-design-15 text-[#9CE8F2]">
|
||||
{dayjs(notice.publish_time * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]">
|
||||
{notice.content ?? ''}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-center gap-design-28">
|
||||
<label className="inline-flex cursor-pointer items-center gap-design-12 text-design-20 text-[#C4F2F7]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasAgreed}
|
||||
disabled={noticeListQuery.isPending || noticeListQuery.isError}
|
||||
onChange={(event) => setHasAgreed(event.target.checked)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex h-design-32 w-design-32 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] border transition',
|
||||
hasAgreed
|
||||
? 'border-[#4AFF49]/80 bg-[#071F11]'
|
||||
: 'border-[#6CCDCF]/70 bg-[#031D25]',
|
||||
)}
|
||||
>
|
||||
{hasAgreed ? (
|
||||
<SmartImage
|
||||
src={checkIcon}
|
||||
alt=""
|
||||
priority={true}
|
||||
showSkeleton={false}
|
||||
className="h-design-34 w-design-38 overflow-visible"
|
||||
imgClassName="object-contain"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span>{t('game.modals.entryNotice.agreement')}</span>
|
||||
</label>
|
||||
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthGreenBtn}
|
||||
size="106% 108%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
disabled={!canEnter}
|
||||
onClick={() => {
|
||||
if (canEnter) {
|
||||
if (hasStoredLoginInfo) {
|
||||
setLastConfirmedAt(confirmedAtStorageKey, Date.now())
|
||||
}
|
||||
|
||||
setHasEntered(true)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-design-72 w-design-270 items-center justify-center rounded-md pb-design-5 text-design-22 font-bold transition',
|
||||
canEnter
|
||||
? 'modal-title-glow cursor-pointer text-white hover:brightness-110 active:brightness-95'
|
||||
: 'cursor-not-allowed text-white opacity-80 grayscale',
|
||||
)}
|
||||
style={
|
||||
canEnter
|
||||
? undefined
|
||||
: {
|
||||
filter: 'grayscale(100%)',
|
||||
WebkitFilter: 'grayscale(100%)',
|
||||
}
|
||||
}
|
||||
>
|
||||
{t('game.modals.entryNotice.enterGame')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type GameTone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger'
|
||||
|
||||
interface GameOverlayAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
tone?: GameTone
|
||||
}
|
||||
|
||||
interface GameAnnouncementModalProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description?: ReactNode
|
||||
eyebrow?: string
|
||||
tone?: GameTone
|
||||
primaryAction?: GameOverlayAction
|
||||
secondaryAction?: GameOverlayAction
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const toneClasses: Record<GameTone, string> = {
|
||||
neutral: 'border-white/10 bg-slate-950/92',
|
||||
brand: 'border-cyan-300/25 bg-slate-950/94',
|
||||
success: 'border-emerald-300/25 bg-slate-950/94',
|
||||
warning: 'border-amber-300/25 bg-slate-950/94',
|
||||
danger: 'border-rose-300/25 bg-slate-950/94',
|
||||
}
|
||||
|
||||
const actionToneClasses: Record<GameTone, string> = {
|
||||
neutral: 'border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.1]',
|
||||
brand: 'border-cyan-300/25 bg-cyan-300/14 text-cyan-50 hover:bg-cyan-300/22',
|
||||
success:
|
||||
'border-emerald-300/25 bg-emerald-300/14 text-emerald-50 hover:bg-emerald-300/22',
|
||||
warning:
|
||||
'border-amber-300/25 bg-amber-300/14 text-amber-50 hover:bg-amber-300/22',
|
||||
danger: 'border-rose-300/25 bg-rose-300/14 text-rose-50 hover:bg-rose-300/22',
|
||||
}
|
||||
|
||||
function ModalAction({ label, onClick, tone = 'brand' }: GameOverlayAction) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex min-h-12 items-center justify-center rounded-full border px-5 text-sm font-semibold tracking-[0.18em] uppercase transition duration-200',
|
||||
actionToneClasses[tone],
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function GameAnnouncementModal({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
eyebrow = '',
|
||||
tone = 'brand',
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
children,
|
||||
}: GameAnnouncementModalProps) {
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/82 px-4 py-8 backdrop-blur-md">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="game-announcement-title"
|
||||
className={cn(
|
||||
'w-full max-w-xl rounded-[32px] border p-6 shadow-[0_40px_120px_-40px_rgba(15,23,42,0.95)] sm:p-7',
|
||||
toneClasses[tone],
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[0.68rem] font-semibold tracking-[0.24em] text-slate-300 uppercase">
|
||||
{eyebrow}
|
||||
</span>
|
||||
<h2
|
||||
id="game-announcement-title"
|
||||
className="text-2xl font-semibold tracking-tight text-white sm:text-[2rem]"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<div className="text-sm leading-7 text-slate-300">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children ? (
|
||||
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
{secondaryAction || primaryAction ? (
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
{secondaryAction ? <ModalAction {...secondaryAction} /> : null}
|
||||
{primaryAction ? <ModalAction {...primaryAction} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { startTransition, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getGameLobbyInit, getVisibleAnnouncements } from '@/features/game'
|
||||
import { GameAnnouncementModal } from '@/features/game/components'
|
||||
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'
|
||||
@@ -11,21 +11,12 @@ import { notify } from '@/lib/notify'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
const ENABLE_ANNOUNCEMENT_MODAL = false
|
||||
|
||||
export function EntryPage() {
|
||||
const { t } = useTranslation()
|
||||
useGameRealtimeSync()
|
||||
const announcements = useGameSessionStore((state) => state.announcements)
|
||||
const dismissAnnouncement = useGameSessionStore(
|
||||
(state) => state.dismissAnnouncement,
|
||||
)
|
||||
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
|
||||
const selectChip = useGameRoundStore((state) => state.selectChip)
|
||||
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
|
||||
const markAnnouncementRead = useGameSessionStore(
|
||||
(state) => state.markAnnouncementRead,
|
||||
)
|
||||
const syncConnection = useGameSessionStore((state) => state.syncConnection)
|
||||
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
@@ -39,16 +30,6 @@ export function EntryPage() {
|
||||
return window.matchMedia('(max-width: 768px)').matches
|
||||
})
|
||||
|
||||
const activeAnnouncement = useMemo(
|
||||
() =>
|
||||
announcements.items.find(
|
||||
(item) => item.id === announcements.activeAnnouncementId,
|
||||
) ??
|
||||
getVisibleAnnouncements(announcements)[0] ??
|
||||
null,
|
||||
[announcements],
|
||||
)
|
||||
|
||||
useDocumentMetadata({
|
||||
title: t('game.metaTitle'),
|
||||
description: t('game.metaDescription'),
|
||||
@@ -167,41 +148,7 @@ export function EntryPage() {
|
||||
>
|
||||
{isMobile ? <MobileEntry /> : <PcEntry />}
|
||||
|
||||
<GameAnnouncementModal
|
||||
open={ENABLE_ANNOUNCEMENT_MODAL && Boolean(activeAnnouncement)}
|
||||
eyebrow={t('game.modal.eyebrow')}
|
||||
title={activeAnnouncement?.title ?? ''}
|
||||
description={activeAnnouncement?.message}
|
||||
primaryAction={{
|
||||
label: t('game.modal.acknowledge'),
|
||||
onClick: () => {
|
||||
if (!activeAnnouncement) {
|
||||
return
|
||||
}
|
||||
|
||||
markAnnouncementRead(activeAnnouncement.id)
|
||||
dismissAnnouncement(activeAnnouncement.id)
|
||||
},
|
||||
tone: 'brand',
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: t('game.modal.later'),
|
||||
onClick: () => {
|
||||
if (!activeAnnouncement) {
|
||||
return
|
||||
}
|
||||
|
||||
dismissAnnouncement(activeAnnouncement.id)
|
||||
},
|
||||
tone: 'neutral',
|
||||
}}
|
||||
tone="warning"
|
||||
>
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<p>{t('game.modal.line1')}</p>
|
||||
<p>{t('game.modal.line2')}</p>
|
||||
</div>
|
||||
</GameAnnouncementModal>
|
||||
<EntryNoticeGateModal />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { DesktopHeader } from '@/features/game/components'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
|
||||
import {
|
||||
DesktopHeader,
|
||||
FullscreenLottieOverlay,
|
||||
type FullscreenLottieSource,
|
||||
} 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'
|
||||
@@ -12,6 +18,77 @@ import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register
|
||||
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-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 { useGameRoundStore } from '@/store/game'
|
||||
|
||||
const smallRewardPath = new URL(
|
||||
'../../../assets/lottie/pc-small-reward.json',
|
||||
import.meta.url,
|
||||
).href
|
||||
const bigRewardPath = new URL(
|
||||
'../../../assets/lottie/pc-big-reward.json',
|
||||
import.meta.url,
|
||||
).href
|
||||
|
||||
function DesktopRewardOverlay() {
|
||||
const rewardType = useGameRoundStore(
|
||||
(state) => state.revealAnimation.rewardType,
|
||||
)
|
||||
const revealKey = useGameRoundStore(
|
||||
(state) => state.revealAnimation.revealKey,
|
||||
)
|
||||
const roundId = useGameRoundStore((state) => state.revealAnimation.roundId)
|
||||
const clearRewardAnimation = useGameRoundStore(
|
||||
(state) => state.clearRewardAnimation,
|
||||
)
|
||||
const source = useMemo<FullscreenLottieSource | null>(() => {
|
||||
if (rewardType === 'small') {
|
||||
return {
|
||||
id: 'pc-small-reward',
|
||||
path: smallRewardPath,
|
||||
loop: false,
|
||||
autoplay: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (rewardType === 'big') {
|
||||
return {
|
||||
id: 'pc-big-reward',
|
||||
path: bigRewardPath,
|
||||
loop: false,
|
||||
autoplay: true,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [rewardType])
|
||||
|
||||
useEffect(() => {
|
||||
if (rewardType === 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
clearRewardAnimation()
|
||||
}, REWARD_OVERLAY_DURATION_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
}, [clearRewardAnimation, rewardType])
|
||||
|
||||
return (
|
||||
<FullscreenLottieOverlay
|
||||
open={rewardType !== 'none'}
|
||||
source={source}
|
||||
animationKey={`${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`}
|
||||
zIndex={120}
|
||||
loop={false}
|
||||
autoplay
|
||||
backdropClassName="bg-black/70"
|
||||
viewportClassName="px-0 py-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PcEntry() {
|
||||
return (
|
||||
@@ -19,13 +96,15 @@ export function PcEntry() {
|
||||
<DesktopHeader />
|
||||
<div
|
||||
className={
|
||||
'mx-auto mt-design-30 mb-design-60 w-[calc(100%-40*var(--design-unit))]'
|
||||
'mx-auto mt-design-20 mb-design-75 w-[calc(100%-40*var(--design-unit))]'
|
||||
}
|
||||
>
|
||||
<DesktopStatusLine />
|
||||
</div>
|
||||
|
||||
<div className={'mx-auto w-[calc(100%-72*var(--design-unit))]'}>
|
||||
<div
|
||||
className={'mx-auto w-[calc(100%-72*var(--design-unit))] mb-design-5'}
|
||||
>
|
||||
<div className={'flex w-full items-start gap-design-10'}>
|
||||
<div className={'flex-1'}>
|
||||
<DesktopAnimal />
|
||||
@@ -63,6 +142,7 @@ export function PcEntry() {
|
||||
<DesktopProceduresModal />
|
||||
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
|
||||
<DesktopWithdrawTopupModal />
|
||||
<DesktopRewardOverlay />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export function useAnimalVm(
|
||||
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||
const chips = useGameRoundStore((state) => state.chips)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const roundId = useGameRoundStore((state) => state.round.id)
|
||||
const maxSelectionCount = useGameRoundStore(
|
||||
(state) => state.maxSelectionCount,
|
||||
)
|
||||
@@ -106,7 +107,9 @@ export function useAnimalVm(
|
||||
shouldConnectRealtime &&
|
||||
(connection.status === 'connecting' || connection.status === 'reconnecting')
|
||||
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
|
||||
const lockInteraction = showStandbyState
|
||||
const hasSubmittedCurrentRound =
|
||||
Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId
|
||||
const lockInteraction = showStandbyState || hasSubmittedCurrentRound
|
||||
const selectedCellCount = Object.keys(selectionByCell).length
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useLocation } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LANGUAGE_OPTIONS } from '@/constants'
|
||||
import { type AppLanguage, supportedLanguages } from '@/i18n'
|
||||
import { LANGUAGE_OPTIONS, SUPPORTED_LANGUAGES } from '@/constants'
|
||||
import type { AppLanguage } from '@/i18n'
|
||||
|
||||
const languagePrefixPattern = new RegExp(
|
||||
`^/(${supportedLanguages.join('|')})(?=/|$)`,
|
||||
`^/(${SUPPORTED_LANGUAGES.join('|')})(?=/|$)`,
|
||||
)
|
||||
|
||||
function resolveNextPathname(pathname: string, language: AppLanguage) {
|
||||
|
||||
128
src/features/game/hooks/use-finance-records-vm.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getDepositOrderList, getWithdrawOrderList } from '@/features/game/api'
|
||||
|
||||
export type FinanceRecordType = 'deposit' | 'withdraw'
|
||||
|
||||
const FINANCE_RECORD_PAGE_SIZE = 20
|
||||
|
||||
const FINANCE_RECORD_TYPE_OPTIONS: Array<{
|
||||
key: FinanceRecordType
|
||||
labelKey: string
|
||||
}> = [
|
||||
{
|
||||
key: 'deposit',
|
||||
labelKey: 'game.modals.userInfo.financeRecords.deposit',
|
||||
},
|
||||
{
|
||||
key: 'withdraw',
|
||||
labelKey: 'game.modals.userInfo.financeRecords.withdraw',
|
||||
},
|
||||
]
|
||||
|
||||
function formatFinanceAmount(value: string, locale: string) {
|
||||
const numberValue = Number(value)
|
||||
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
return value || '--'
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(numberValue)
|
||||
}
|
||||
|
||||
export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
|
||||
const { i18n, t } = useTranslation()
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
|
||||
const [recordType, setRecordType] = useState<FinanceRecordType>('deposit')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['finance', 'user-info-order-list', recordType, page],
|
||||
queryFn: () =>
|
||||
recordType === 'deposit'
|
||||
? getDepositOrderList({
|
||||
page,
|
||||
pageSize: FINANCE_RECORD_PAGE_SIZE,
|
||||
})
|
||||
: getWithdrawOrderList({
|
||||
page,
|
||||
pageSize: FINANCE_RECORD_PAGE_SIZE,
|
||||
}),
|
||||
enabled,
|
||||
})
|
||||
|
||||
const pagination = query.data?.pagination
|
||||
const total = pagination?.total ?? 0
|
||||
|
||||
const recordTypes = useMemo(
|
||||
() =>
|
||||
FINANCE_RECORD_TYPE_OPTIONS.map((option) => ({
|
||||
key: option.key,
|
||||
label: t(option.labelKey),
|
||||
})),
|
||||
[t],
|
||||
)
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
(query.data?.list ?? []).map((item) => ({
|
||||
amountLabel: formatFinanceAmount(item.amount, locale),
|
||||
bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale),
|
||||
id: item.orderNo,
|
||||
orderNoLabel: item.orderNo || '--',
|
||||
})),
|
||||
[locale, query.data?.list],
|
||||
)
|
||||
|
||||
const selectRecordType = useCallback((type: FinanceRecordType) => {
|
||||
setRecordType(type)
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
const goPreviousPage = useCallback(() => {
|
||||
setPage((currentPage) => Math.max(1, currentPage - 1))
|
||||
}, [])
|
||||
|
||||
const goNextPage = useCallback(() => {
|
||||
setPage((currentPage) => currentPage + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setRecordType('deposit')
|
||||
setPage(1)
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return {
|
||||
canGoNextPage: page * FINANCE_RECORD_PAGE_SIZE < total,
|
||||
canGoPreviousPage: page > 1,
|
||||
emptyText: t('game.modals.userInfo.financeRecords.empty'),
|
||||
goNextPage,
|
||||
goPreviousPage,
|
||||
headers: {
|
||||
amount: t('game.modals.userInfo.financeRecords.amount'),
|
||||
bonusAmount: t('game.modals.userInfo.financeRecords.bonusAmount'),
|
||||
orderNo: t('game.modals.userInfo.financeRecords.orderNo'),
|
||||
},
|
||||
isError: query.isError,
|
||||
isFetching: query.isFetching,
|
||||
isLoading: query.isLoading,
|
||||
items,
|
||||
loadFailedText: t('game.modals.userInfo.financeRecords.loadFailed'),
|
||||
loadingText: t('game.modals.userInfo.financeRecords.loading'),
|
||||
pageLabel: t('game.modals.userInfo.financeRecords.page', {
|
||||
page: pagination?.page ?? page,
|
||||
total,
|
||||
}),
|
||||
nextText: t('game.modals.userInfo.financeRecords.next'),
|
||||
previousText: t('game.modals.userInfo.financeRecords.previous'),
|
||||
recordType,
|
||||
recordTypes,
|
||||
selectRecordType,
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,8 @@ export function useGameControlVm() {
|
||||
const hasSelections = selections.length > 0
|
||||
const hasEnteredGame =
|
||||
shouldConnectRealtime && connectionStatus === 'connected'
|
||||
const hasSubmittedCurrentRound =
|
||||
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
|
||||
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
|
||||
const confirmState: ConfirmState = isSubmitting
|
||||
? 'submitting'
|
||||
@@ -141,6 +143,11 @@ export function useGameControlVm() {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasSubmittedCurrentRound) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
const groupedSelections = selections.reduce<
|
||||
Map<string, { betId: number; numbers: number[] }>
|
||||
>((accumulator, selection) => {
|
||||
@@ -219,6 +226,7 @@ export function useGameControlVm() {
|
||||
currentUser,
|
||||
hasInsufficientBalance,
|
||||
hasSelections,
|
||||
hasSubmittedCurrentRound,
|
||||
round.id,
|
||||
round.phase,
|
||||
selections,
|
||||
@@ -229,7 +237,7 @@ export function useGameControlVm() {
|
||||
])
|
||||
|
||||
const handleRepeatSelections = useCallback(() => {
|
||||
if (round.phase !== 'betting') {
|
||||
if (round.phase !== 'betting' || hasSubmittedCurrentRound) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
@@ -242,16 +250,21 @@ export function useGameControlVm() {
|
||||
}
|
||||
|
||||
notify.success(t('commonUi.toast.repeatSelectionsRestored'))
|
||||
}, [restoreRecentSuccessfulSelections, round.phase, t])
|
||||
}, [
|
||||
hasSubmittedCurrentRound,
|
||||
restoreRecentSuccessfulSelections,
|
||||
round.phase,
|
||||
t,
|
||||
])
|
||||
|
||||
const handleOpenAutoSetting = useCallback(() => {
|
||||
setModalOpen('desktopAutoSetting', true)
|
||||
}, [setModalOpen])
|
||||
|
||||
return {
|
||||
acceptingBets: round.phase === 'betting',
|
||||
actionsEnabled: hasEnteredGame,
|
||||
canClear: selections.length > 0,
|
||||
acceptingBets: round.phase === 'betting' && !hasSubmittedCurrentRound,
|
||||
actionsEnabled: hasEnteredGame && !hasSubmittedCurrentRound,
|
||||
canClear: selections.length > 0 && !hasSubmittedCurrentRound,
|
||||
confirmLabel:
|
||||
confirmState === 'idle'
|
||||
? t('gameDesktop.control.selectNumbers')
|
||||
|
||||
@@ -2,12 +2,11 @@ import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { GAME_HISTORY_PAGE_SIZE } from '@/constants'
|
||||
import { getGameBetMyOrders } from '@/features/game/api/game-api'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore } from '@/store/game'
|
||||
|
||||
const GAME_HISTORY_PAGE_SIZE = 20
|
||||
|
||||
function formatCreatedTime(timestamp: number, locale: string) {
|
||||
const date = new Date(timestamp * 1000)
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
FALLBACK_POLL_INTERVAL_MS,
|
||||
GAME_SOCKET_TOPIC_VALUES,
|
||||
GAME_SOCKET_TOPICS,
|
||||
PLAYER_SOCKET_TOPICS,
|
||||
SOCKET_DISCONNECT_DELAY_MS,
|
||||
} from '@/constants'
|
||||
import i18n from '@/i18n'
|
||||
import { prefetchAuthToken } from '@/lib/api/api-client'
|
||||
import {
|
||||
@@ -16,51 +23,11 @@ type UserStreakMessageData = {
|
||||
streakLevel?: number
|
||||
}
|
||||
|
||||
const FALLBACK_POLL_INTERVAL_MS = 10_000
|
||||
const GAME_SOCKET_TOPICS = {
|
||||
// 对局状态心跳。每秒推送当前期号、状态、倒计时、runtime_enabled 等。
|
||||
periodTick: 'period.tick',
|
||||
// 本期封盘通知。用于前端立即停止下注。
|
||||
periodLocked: 'period.locked',
|
||||
// 本期开奖通知。用于同步开奖号码、所属期号等阶段结果。
|
||||
periodOpened: 'period.opened',
|
||||
// 本期派彩完成通知。用于结算阶段同步。
|
||||
periodPayout: 'period.payout',
|
||||
// 当前玩家连胜与赔率信息。通常在结算后或演示帧刷新。
|
||||
userStreak: 'user.streak',
|
||||
// 下注成功通知。仅当前用户可见,通常伴随扣款结果。
|
||||
betAccepted: 'bet.accepted',
|
||||
// 余额变化通知。充值、下注、派彩都会走这条流。
|
||||
walletChanged: 'wallet.changed',
|
||||
// 自动托管进度通知。包含托管开关、执行状态等。
|
||||
autoSpinProgress: 'auto.spin.progress',
|
||||
// 大奖命中通知。仅当本期存在中大奖用户时推送。
|
||||
jackpotHit: 'jackpot.hit',
|
||||
// 后台实时页全量快照。仅 admin live 页面使用,当前 H5 前台不订阅。
|
||||
adminLiveSnapshot: 'admin.live.snapshot',
|
||||
// 后台开奖结果通知。仅 admin live 页面使用,当前 H5 前台不订阅。
|
||||
adminLiveOpened: 'admin.live.opened',
|
||||
} as const
|
||||
|
||||
const GAME_SOCKET_TOPIC_VALUES = new Set<string>(
|
||||
Object.values(GAME_SOCKET_TOPICS),
|
||||
)
|
||||
|
||||
// 当前 H5 游戏页实际需要的用户侧事件。
|
||||
// 后台专用事件保持在 GAME_SOCKET_TOPICS 中做口径对齐,但不在这里订阅。
|
||||
const PLAYER_SOCKET_TOPICS = [
|
||||
GAME_SOCKET_TOPICS.periodTick,
|
||||
GAME_SOCKET_TOPICS.userStreak,
|
||||
GAME_SOCKET_TOPICS.periodOpened,
|
||||
GAME_SOCKET_TOPICS.periodLocked,
|
||||
GAME_SOCKET_TOPICS.periodPayout,
|
||||
GAME_SOCKET_TOPICS.betAccepted,
|
||||
GAME_SOCKET_TOPICS.walletChanged,
|
||||
GAME_SOCKET_TOPICS.autoSpinProgress,
|
||||
GAME_SOCKET_TOPICS.jackpotHit,
|
||||
] as const
|
||||
|
||||
const SOCKET_DISCONNECT_DELAY_MS = 150
|
||||
type PeriodEventData = {
|
||||
openTime: number | null
|
||||
periodNo: string
|
||||
resultNumber: number | null
|
||||
}
|
||||
|
||||
let sharedSocketClient: GameSocketClient | null = null
|
||||
let sharedSocketKey: string | null = null
|
||||
@@ -186,6 +153,37 @@ function extractPeriodTick(
|
||||
}
|
||||
}
|
||||
|
||||
function extractPeriodEventData(
|
||||
message: GameSocketMessage,
|
||||
): PeriodEventData | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const source = data ?? (message as Record<string, unknown>)
|
||||
const periodNo =
|
||||
typeof source.period_no === 'string'
|
||||
? source.period_no
|
||||
: typeof source.periodNo === 'string'
|
||||
? source.periodNo
|
||||
: null
|
||||
|
||||
if (!periodNo) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resultNumber = toOptionalNumber(
|
||||
source.result_number ?? source.resultNumber,
|
||||
)
|
||||
const openTime = toOptionalNumber(source.open_time ?? source.openTime)
|
||||
|
||||
return {
|
||||
openTime: openTime ?? null,
|
||||
periodNo,
|
||||
resultNumber:
|
||||
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
||||
? resultNumber
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function extractWalletCoin(message: GameSocketMessage) {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const source = data ?? (message as Record<string, unknown>)
|
||||
@@ -288,6 +286,90 @@ function applyPeriodPhase(phase: 'locked' | 'revealing' | 'settled') {
|
||||
useGameRoundStore.getState().setPhase(phase)
|
||||
}
|
||||
|
||||
function applyPeriodLockedMessage(
|
||||
message: GameSocketMessage,
|
||||
serverTime: number | null,
|
||||
) {
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
const period = extractPeriodEventData(message)
|
||||
const roundState = useGameRoundStore.getState()
|
||||
const roundId = period?.periodNo ?? roundState.round.id
|
||||
|
||||
if (roundId) {
|
||||
roundState.syncRound({
|
||||
id: roundId,
|
||||
phase: 'locked',
|
||||
})
|
||||
} else {
|
||||
roundState.setPhase('locked')
|
||||
}
|
||||
}
|
||||
|
||||
function applyPeriodOpenedMessage(
|
||||
message: GameSocketMessage,
|
||||
serverTime: number | null,
|
||||
) {
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
const period = extractPeriodEventData(message)
|
||||
|
||||
if (!period || period.resultNumber === null) {
|
||||
applyPeriodPhase('revealing')
|
||||
return
|
||||
}
|
||||
|
||||
const roundState = useGameRoundStore.getState()
|
||||
const openedAt = toIsoFromUnixSeconds(
|
||||
period.openTime ?? serverTime ?? Math.floor(Date.now() / 1000),
|
||||
)
|
||||
const hasSmallReward = roundState.selections.some(
|
||||
(selection) => selection.cellId === period.resultNumber,
|
||||
)
|
||||
const revealKey = `${period.periodNo}:${period.resultNumber}`
|
||||
|
||||
roundState.syncRound({
|
||||
id: period.periodNo,
|
||||
phase: 'revealing',
|
||||
revealingAt: openedAt,
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
useGameRoundStore.getState().prepareRevealAnimation({
|
||||
hasSmallReward,
|
||||
revealKey,
|
||||
roundId: period.periodNo,
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
}
|
||||
|
||||
function applyPeriodPayoutMessage(
|
||||
message: GameSocketMessage,
|
||||
serverTime: number | null,
|
||||
) {
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
const period = extractPeriodEventData(message)
|
||||
|
||||
if (period?.resultNumber !== null && period?.resultNumber !== undefined) {
|
||||
const roundState = useGameRoundStore.getState()
|
||||
const revealKey = `${period.periodNo}:${period.resultNumber}`
|
||||
|
||||
roundState.prepareRevealAnimation({
|
||||
hasSmallReward: roundState.selections.some(
|
||||
(selection) => selection.cellId === period.resultNumber,
|
||||
),
|
||||
revealKey,
|
||||
roundId: period.periodNo,
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
}
|
||||
|
||||
const roundId = period?.periodNo ?? useGameRoundStore.getState().round.id
|
||||
|
||||
applyPeriodPhase('settled')
|
||||
useGameRoundStore.getState().playPreparedRevealAnimation(roundId || null)
|
||||
}
|
||||
|
||||
function applyUserStreakMessage(message: GameSocketMessage) {
|
||||
const streakData = extractUserStreakMessageData(message)
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
@@ -320,6 +402,8 @@ function applyWalletChangedMessage(message: GameSocketMessage) {
|
||||
|
||||
function applyJackpotHitMessage(message: GameSocketMessage) {
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
const period = extractPeriodEventData(message)
|
||||
const isJackpot = extractJackpotStatus(message)
|
||||
|
||||
if (!currentUser) {
|
||||
return
|
||||
@@ -327,8 +411,12 @@ function applyJackpotHitMessage(message: GameSocketMessage) {
|
||||
|
||||
useAuthStore.getState().setCurrentUser({
|
||||
...currentUser,
|
||||
isJackpot: extractJackpotStatus(message),
|
||||
isJackpot,
|
||||
})
|
||||
|
||||
if (isJackpot) {
|
||||
useGameRoundStore.getState().showJackpotReward(period?.periodNo ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
function applyRealtimeMessage(message: GameSocketMessage) {
|
||||
@@ -336,20 +424,41 @@ function applyRealtimeMessage(message: GameSocketMessage) {
|
||||
const topic = getMessageTopic(message)
|
||||
|
||||
switch (topic) {
|
||||
case GAME_SOCKET_TOPICS.periodTick:
|
||||
case GAME_SOCKET_TOPICS.periodTick: {
|
||||
const period = extractPeriodTick(message)
|
||||
const resultNumber =
|
||||
typeof period?.result_number === 'number' ? period.result_number : null
|
||||
const shouldStartSettledReveal =
|
||||
period?.status === 'settled' && resultNumber !== null
|
||||
const hasSmallReward = shouldStartSettledReveal
|
||||
? useGameRoundStore
|
||||
.getState()
|
||||
.selections.some((selection) => selection.cellId === resultNumber)
|
||||
: false
|
||||
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
if (shouldStartSettledReveal) {
|
||||
useGameRoundStore.getState().prepareRevealAnimation({
|
||||
hasSmallReward,
|
||||
revealKey: `${period.period_no}:${resultNumber}`,
|
||||
roundId: period.period_no,
|
||||
winningCellId: resultNumber,
|
||||
})
|
||||
useGameRoundStore
|
||||
.getState()
|
||||
.playPreparedRevealAnimation(period.period_no)
|
||||
}
|
||||
break
|
||||
}
|
||||
case GAME_SOCKET_TOPICS.periodLocked:
|
||||
applyPeriodMessage(message, serverTime)
|
||||
applyPeriodPhase('locked')
|
||||
applyPeriodLockedMessage(message, serverTime)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.periodOpened:
|
||||
applyPeriodMessage(message, serverTime)
|
||||
applyPeriodPhase('revealing')
|
||||
applyPeriodOpenedMessage(message, serverTime)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.periodPayout:
|
||||
applyPeriodMessage(message, serverTime)
|
||||
applyPeriodPhase('settled')
|
||||
applyPeriodPayoutMessage(message, serverTime)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.userStreak:
|
||||
applyUserStreakMessage(message)
|
||||
|
||||
@@ -1,36 +1,9 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PHASE_META } from '@/constants'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
const PHASE_META = {
|
||||
betting: {
|
||||
descriptionKey: 'gameDesktop.status.phase.betting.description',
|
||||
labelKey: 'gameDesktop.status.phase.betting.label',
|
||||
toneClassName: 'text-[#78FF7F]',
|
||||
},
|
||||
locked: {
|
||||
descriptionKey: 'gameDesktop.status.phase.locked.description',
|
||||
labelKey: 'gameDesktop.status.phase.locked.label',
|
||||
toneClassName: 'text-[#FFE375]',
|
||||
},
|
||||
revealing: {
|
||||
descriptionKey: 'gameDesktop.status.phase.revealing.description',
|
||||
labelKey: 'gameDesktop.status.phase.revealing.label',
|
||||
toneClassName: 'text-[#57E8FF]',
|
||||
},
|
||||
settled: {
|
||||
descriptionKey: 'gameDesktop.status.phase.settled.description',
|
||||
labelKey: 'gameDesktop.status.phase.settled.label',
|
||||
toneClassName: 'text-[#FF9C6B]',
|
||||
},
|
||||
waiting: {
|
||||
descriptionKey: 'gameDesktop.status.phase.waiting.description',
|
||||
labelKey: 'gameDesktop.status.phase.waiting.label',
|
||||
toneClassName: 'text-[#A7B6C7]',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function useGameStatusVm() {
|
||||
const { t } = useTranslation()
|
||||
const cells = useGameRoundStore((state) => state.cells)
|
||||
@@ -62,6 +35,7 @@ export function useGameStatusVm() {
|
||||
phaseToneClassName: phaseMeta.toneClassName,
|
||||
roundId: round.id || '--',
|
||||
streakLabel: typeof streakValue === 'number' ? `X${streakValue}` : '--',
|
||||
streakValue,
|
||||
}
|
||||
}, [cells, currentUser, dashboard, round, t, trends])
|
||||
}
|
||||
|
||||
@@ -1,43 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DEFAULT_WITHDRAW_CONFIG, QUICK_FIAT_AMOUNTS } from '@/constants'
|
||||
import type { DepositWithdrawConfig } from '@/features/game/api'
|
||||
import { useDepositWithdrawConfig } from '@/features/game/hooks/use-deposit-withdraw-config'
|
||||
import { useAuthStore } from '@/store'
|
||||
|
||||
const QUICK_FIAT_AMOUNTS = [3, 30, 50, 100, 200, 500] as const
|
||||
|
||||
const DEFAULT_WITHDRAW_CONFIG: DepositWithdrawConfig = {
|
||||
currencies: [
|
||||
{
|
||||
code: 'MYR',
|
||||
depositCoinsPerFiat: '100',
|
||||
depositCoinsPerFiatValue: 100,
|
||||
label: 'MYR',
|
||||
withdrawCoinsPerFiat: '100',
|
||||
withdrawCoinsPerFiatValue: 100,
|
||||
},
|
||||
],
|
||||
payChannels: [],
|
||||
platformCoinLabel: '钻石',
|
||||
rates: [
|
||||
{
|
||||
currency: 'MYR',
|
||||
diamondsPerFiatUnit: '100',
|
||||
diamondsPerFiatUnitValue: 100,
|
||||
},
|
||||
],
|
||||
withdraw: {
|
||||
banks: [],
|
||||
feeNote: 'RM10 - RM99.99 之间的交易将收取最低RM 1的提现手续费',
|
||||
minBank: '10',
|
||||
minEwallet: '10',
|
||||
processingNote: '30s即可到账',
|
||||
rateHint: '汇率为参考价格,实际以提现时为准。',
|
||||
rateMode: 'fixed' as const,
|
||||
},
|
||||
}
|
||||
|
||||
function formatNumber(locale: string, value: number) {
|
||||
return new Intl.NumberFormat(locale).format(value)
|
||||
}
|
||||
|
||||
160
src/features/game/modal/desktop/desktop-finance-records-tab.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
|
||||
import { useFinanceRecordsVm } from '@/features/game/hooks/use-finance-records-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
const vm = useFinanceRecordsVm({ enabled })
|
||||
|
||||
return (
|
||||
<div className={'flex h-full w-full flex-col p-design-10'}>
|
||||
<div
|
||||
className={
|
||||
'mb-design-12 flex items-center justify-between gap-design-16 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'relative grid grid-cols-2 overflow-hidden rounded-md border border-[#3EAFC7]/30 bg-[#031B24]/75 p-design-4'
|
||||
}
|
||||
>
|
||||
{vm.recordTypes.map((recordType) => {
|
||||
const isActive = recordType.key === vm.recordType
|
||||
|
||||
return (
|
||||
<button
|
||||
key={recordType.key}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => vm.selectRecordType(recordType.key)}
|
||||
className={cn(
|
||||
'relative h-design-44 min-w-design-130 cursor-pointer rounded-md px-design-16 text-design-18 transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-white'
|
||||
: 'text-[#6CCDCF] hover:bg-[#0A4252] hover:text-white',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="finance-record-type-active"
|
||||
className={
|
||||
'absolute inset-0 rounded-md bg-[linear-gradient(180deg,#3DA5BD,#166477)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(62,175,199,0.26)]'
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 420,
|
||||
damping: 34,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className={'relative z-10'}>{recordType.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className={'min-h-0 flex-1 overflow-auto rounded-md'}>
|
||||
<div
|
||||
className={
|
||||
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] gap-design-10 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-16 py-design-12 text-design-16 text-[#7ECAD1]'
|
||||
}
|
||||
>
|
||||
<div>{vm.headers.orderNo}</div>
|
||||
<div>{vm.headers.amount}</div>
|
||||
<div>{vm.headers.bonusAmount}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'mt-design-10 flex min-h-[calc(var(--design-unit)*320)] flex-col gap-design-10'
|
||||
}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={vm.recordType}
|
||||
className={'flex flex-col gap-design-10'}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
x: vm.recordType === 'deposit' ? -18 : 18,
|
||||
}}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
x: vm.recordType === 'deposit' ? 18 : -18,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
{vm.isLoading ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadingText}
|
||||
</div>
|
||||
) : vm.isError ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadFailedText}
|
||||
</div>
|
||||
) : vm.items.length === 0 ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.emptyText}
|
||||
</div>
|
||||
) : (
|
||||
vm.items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
className={
|
||||
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-18 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
|
||||
}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
delay: Math.min(index, 6) * 0.025,
|
||||
duration: 0.16,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
<div className={'truncate font-medium text-white'}>
|
||||
{item.orderNoLabel}
|
||||
</div>
|
||||
<div className={'text-[#FEEEB0]'}>{item.amountLabel}</div>
|
||||
<div className={'text-[#7CFFCF]'}>
|
||||
{item.bonusAmountLabel}
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={'mt-design-12 flex items-center justify-end gap-design-10'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!vm.canGoPreviousPage || vm.isFetching}
|
||||
onClick={vm.goPreviousPage}
|
||||
className={
|
||||
'h-design-40 cursor-pointer rounded-md border border-[#3EAFC7]/35 bg-[#062E39] px-design-16 text-design-16 text-[#86DAE7] transition hover:bg-[#0A4252] disabled:cursor-not-allowed disabled:opacity-45'
|
||||
}
|
||||
>
|
||||
{vm.previousText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!vm.canGoNextPage || vm.isFetching}
|
||||
onClick={vm.goNextPage}
|
||||
className={
|
||||
'h-design-40 cursor-pointer rounded-md border border-[#3EAFC7]/35 bg-[#062E39] px-design-16 text-design-16 text-[#86DAE7] transition hover:bg-[#0A4252] disabled:cursor-not-allowed disabled:opacity-45'
|
||||
}
|
||||
>
|
||||
{vm.nextText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopFinanceRecordsTab
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { ArrowLeft, CircleUserRound, Mail } from 'lucide-react'
|
||||
import { ArrowLeft, CircleUserRound, Mail, ReceiptText } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
@@ -10,10 +11,11 @@ import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { getNoticeDetail, getNoticeList } from '@/features/game/api'
|
||||
import DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
|
||||
type UserInfoTabKey = 'profile' | 'message'
|
||||
type UserInfoTabKey = 'financeRecords' | 'message' | 'profile'
|
||||
type MessageViewState = 'list' | 'detail'
|
||||
|
||||
const USER_INFO_TABS: Array<{
|
||||
@@ -26,6 +28,11 @@ const USER_INFO_TABS: Array<{
|
||||
labelKey: 'game.modals.userInfo.tabs.profile',
|
||||
icon: CircleUserRound,
|
||||
},
|
||||
{
|
||||
key: 'financeRecords',
|
||||
labelKey: 'game.modals.userInfo.tabs.financeRecords',
|
||||
icon: ReceiptText,
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
labelKey: 'game.modals.userInfo.tabs.message',
|
||||
@@ -118,40 +125,64 @@ function DesktopUserInfoModal() {
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'relative flex h-design-150 w-full flex-col items-center justify-center gap-design-10 overflow-hidden transition',
|
||||
'relative flex h-design-150 w-full cursor-pointer flex-col items-center justify-center gap-design-8 overflow-hidden px-design-10 transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-[#FEEEB0]'
|
||||
: 'text-[#58ADAF] hover:text-[#BFEAEC]',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<span
|
||||
<motion.span
|
||||
layoutId="user-info-tab-active-bg"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-full bg-[linear-gradient(to_left,rgba(254,238,176,0.46)_0%,rgba(254,238,176,0.28)_42%,rgba(254,238,176,0.12)_68%,rgba(254,238,176,0)_100%)]"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 430,
|
||||
damping: 36,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isActive ? (
|
||||
<span
|
||||
<motion.span
|
||||
layoutId="user-info-tab-active-indicator"
|
||||
aria-hidden="true"
|
||||
className="absolute right-0 top-1/2 h-[72%] w-[calc(var(--design-unit)*3)] -translate-y-1/2 rounded-l-full bg-[linear-gradient(180deg,rgba(255,248,214,0.96)_0%,rgba(254,238,176,0.92)_48%,rgba(232,188,112,0.88)_100%)] shadow-[-2px_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.36)]"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 430,
|
||||
damping: 36,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Icon
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative z-10 transition h-design-50 w-design-50',
|
||||
'relative z-10 transition',
|
||||
isActive &&
|
||||
'drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.5)]',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
animate={{
|
||||
scale: isActive ? 1.06 : 1,
|
||||
y: isActive ? -2 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
<Icon className={'h-design-40 w-design-40'} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative z-10 text-design-24',
|
||||
'relative z-10 text-center text-design-20 leading-tight',
|
||||
isActive && 'modal-title-gold-glow',
|
||||
)}
|
||||
animate={{
|
||||
scale: isActive ? 1.04 : 1,
|
||||
y: isActive ? -1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -210,6 +241,10 @@ function DesktopUserInfoModal() {
|
||||
</div>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
) : activeTab === 'financeRecords' ? (
|
||||
<DesktopFinanceRecordsTab
|
||||
enabled={open && activeTab === 'financeRecords'}
|
||||
/>
|
||||
) : (
|
||||
<div className={'flex h-full w-full flex-col'}>
|
||||
{messageView === 'detail' ? (
|
||||
@@ -224,7 +259,7 @@ function DesktopUserInfoModal() {
|
||||
void handleReturnToList()
|
||||
}}
|
||||
className={
|
||||
'flex items-center gap-design-10 text-[#86DAE7] transition hover:text-white'
|
||||
'flex cursor-pointer items-center gap-design-10 text-[#86DAE7] transition hover:text-white'
|
||||
}
|
||||
>
|
||||
<span
|
||||
@@ -278,7 +313,7 @@ function DesktopUserInfoModal() {
|
||||
setMessageView('detail')
|
||||
}}
|
||||
className={
|
||||
'flex items-center gap-design-20 rounded-md bg-[#0A4252] px-design-15 py-design-15 text-left transition hover:bg-[#0E576D]'
|
||||
'flex cursor-pointer items-center gap-design-20 rounded-md bg-[#0A4252] px-design-15 py-design-15 text-left transition hover:bg-[#0E576D]'
|
||||
}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,59 +1,17 @@
|
||||
export const GAME_GRID_ROWS = 6
|
||||
export const GAME_GRID_COLUMNS = 6
|
||||
export const GAME_TOTAL_CELLS = GAME_GRID_ROWS * GAME_GRID_COLUMNS
|
||||
|
||||
export const ROUND_PHASES = [
|
||||
'waiting',
|
||||
'betting',
|
||||
'locked',
|
||||
'revealing',
|
||||
'settled',
|
||||
] as const
|
||||
|
||||
export const CELL_STATUSES = [
|
||||
'idle',
|
||||
'betting',
|
||||
'selected',
|
||||
'locked',
|
||||
'won',
|
||||
'lost',
|
||||
] as const
|
||||
|
||||
export const CONNECTION_STATUSES = [
|
||||
'idle',
|
||||
'connecting',
|
||||
'connected',
|
||||
'reconnecting',
|
||||
'disconnected',
|
||||
] as const
|
||||
|
||||
export const CONNECTION_TRANSPORTS = [
|
||||
'websocket',
|
||||
'polling',
|
||||
'offline',
|
||||
] as const
|
||||
|
||||
export const ANNOUNCEMENT_TONES = [
|
||||
'info',
|
||||
'success',
|
||||
'warning',
|
||||
'critical',
|
||||
] as const
|
||||
|
||||
export const BET_SOURCES = ['local', 'server'] as const
|
||||
|
||||
export const TREND_DIRECTIONS = ['rising', 'steady', 'falling'] as const
|
||||
|
||||
export const DEFAULT_GAME_CHIP_COLORS = [
|
||||
'#1D4ED8',
|
||||
'#0F766E',
|
||||
'#B45309',
|
||||
'#B91C1C',
|
||||
'#7C3AED',
|
||||
'#111827',
|
||||
] as const
|
||||
|
||||
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-5'
|
||||
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
|
||||
export const GAME_RECENT_HISTORY_LIMIT = 12
|
||||
export const GAME_MAX_SELECTION_CELLS = 5
|
||||
export {
|
||||
ANNOUNCEMENT_TONES,
|
||||
BET_SOURCES,
|
||||
CELL_STATUSES,
|
||||
CONNECTION_STATUSES,
|
||||
CONNECTION_TRANSPORTS,
|
||||
DEFAULT_ACTIVE_CHIP_ID,
|
||||
DEFAULT_ANNOUNCEMENT_TTL_MS,
|
||||
DEFAULT_GAME_CHIP_COLORS,
|
||||
GAME_GRID_COLUMNS,
|
||||
GAME_GRID_ROWS,
|
||||
GAME_MAX_SELECTION_CELLS,
|
||||
GAME_RECENT_HISTORY_LIMIT,
|
||||
GAME_TOTAL_CELLS,
|
||||
ROUND_PHASES,
|
||||
TREND_DIRECTIONS,
|
||||
} from '@/constants/game'
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import { DEFAULT_APP_LANGUAGE, SUPPORTED_LANGUAGES } from '@/constants/system'
|
||||
import enUSCommon from '@/locales/en-US/common'
|
||||
import idIDCommon from '@/locales/id-ID/common'
|
||||
import msMYCommon from '@/locales/ms-MY/common'
|
||||
import zhCNCommon from '@/locales/zh-CN/common'
|
||||
import { getStoredAppLanguage, setStoredAppLanguage } from '@/store/auth'
|
||||
|
||||
export const supportedLanguages = ['zh-CN', 'en-US', 'ms-MY', 'id-ID'] as const
|
||||
export type AppLanguage = (typeof supportedLanguages)[number]
|
||||
|
||||
const defaultLanguage: AppLanguage = 'zh-CN'
|
||||
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
||||
|
||||
/** @description 判断给定语言是否在当前应用支持列表中。 */
|
||||
export function isSupportedLanguage(
|
||||
value: string | null | undefined,
|
||||
): value is AppLanguage {
|
||||
return supportedLanguages.includes(value as AppLanguage)
|
||||
return SUPPORTED_LANGUAGES.includes(value as AppLanguage)
|
||||
}
|
||||
|
||||
/** @description 从浏览器设置中推断最匹配的语言。 */
|
||||
function detectBrowserLanguage() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return defaultLanguage
|
||||
return DEFAULT_APP_LANGUAGE
|
||||
}
|
||||
|
||||
const browserLanguages = [...navigator.languages, navigator.language]
|
||||
@@ -51,7 +49,7 @@ function detectBrowserLanguage() {
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLanguage
|
||||
return DEFAULT_APP_LANGUAGE
|
||||
}
|
||||
|
||||
/** @description 获取应用启动时应使用的初始语言。 */
|
||||
@@ -83,7 +81,7 @@ export function getLanguageFromPathname(pathname: string) {
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
lng: getInitialLanguage(),
|
||||
fallbackLng: defaultLanguage,
|
||||
fallbackLng: DEFAULT_APP_LANGUAGE,
|
||||
debug: false,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
@@ -116,7 +114,7 @@ function syncLanguageState(language: string) {
|
||||
}
|
||||
}
|
||||
|
||||
syncLanguageState(i18n.resolvedLanguage ?? defaultLanguage)
|
||||
syncLanguageState(i18n.resolvedLanguage ?? DEFAULT_APP_LANGUAGE)
|
||||
i18n.on('languageChanged', syncLanguageState)
|
||||
|
||||
export default i18n
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import ky, { HTTPError, type Options, TimeoutError } from 'ky'
|
||||
import {
|
||||
ACCESS_TOKEN_REFRESH_SKEW_MS,
|
||||
API_ERROR_MESSAGES,
|
||||
AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY,
|
||||
AUTH_REFRESH_ENDPOINT,
|
||||
AUTH_SKIP_REFRESH_CONTEXT_KEY,
|
||||
AUTH_TOKEN_CACHE_SKEW_MS,
|
||||
AUTH_TOKEN_ENDPOINT,
|
||||
DEFAULT_REQUEST_ACCEPT_HEADER,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
} from '@/constants'
|
||||
@@ -24,12 +30,6 @@ type JsonRequestOptions<TBody> = RequestOptions & {
|
||||
json?: TBody
|
||||
}
|
||||
|
||||
const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
|
||||
const AUTH_SKIP_REFRESH_CONTEXT_KEY = 'skipAuthRefresh'
|
||||
const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
|
||||
const AUTH_REFRESH_ENDPOINT = 'api/user/refreshToken'
|
||||
const ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000
|
||||
const AUTH_TOKEN_CACHE_SKEW_MS = 30_000
|
||||
const appEnv = import.meta.env.VITE_APP_ENV
|
||||
const authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim()
|
||||
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
DEFAULT_ALERT_DURATION_MS,
|
||||
NOTIFICATION_EXIT_DURATION_MS,
|
||||
} from '@/constants'
|
||||
|
||||
type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading'
|
||||
const DEFAULT_ALERT_DURATION_MS = 2600
|
||||
export const NOTIFICATION_EXIT_DURATION_MS = 220
|
||||
|
||||
export interface NotifyOptions {
|
||||
description?: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { FULLSCREEN_CHANGE_EVENTS } from '@/constants'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -20,13 +21,6 @@ type FullscreenCapableDocument = Document & {
|
||||
webkitFullscreenElement?: Element | null
|
||||
}
|
||||
|
||||
const FULLSCREEN_CHANGE_EVENTS = [
|
||||
'fullscreenchange',
|
||||
'webkitfullscreenchange',
|
||||
'mozfullscreenchange',
|
||||
'MSFullscreenChange',
|
||||
] as const
|
||||
|
||||
export function isDesktopFullscreen() {
|
||||
if (typeof document === 'undefined') {
|
||||
return false
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
LATENCY_PROBE_INTERVAL_MS,
|
||||
LATENCY_PROBE_TIMEOUT_MS,
|
||||
MAX_RECONNECT_DELAY_MS,
|
||||
} from '@/constants'
|
||||
|
||||
type GameSocketContext = {
|
||||
authToken: string
|
||||
deviceId: string
|
||||
@@ -47,10 +53,6 @@ type GameSocketClientOptions = {
|
||||
onStatusChange?: (status: GameSocketStatus, reconnectAttempt: number) => void
|
||||
}
|
||||
|
||||
const MAX_RECONNECT_DELAY_MS = 10_000
|
||||
const LATENCY_PROBE_INTERVAL_MS = 3_000
|
||||
const LATENCY_PROBE_TIMEOUT_MS = 10_000
|
||||
|
||||
function toQueryString(context: GameSocketContext) {
|
||||
const params = new URLSearchParams({
|
||||
token: context.token,
|
||||
|
||||
@@ -103,14 +103,6 @@ export default {
|
||||
autoModeDemo: 'Auto mode demo',
|
||||
stopAuto: 'Stop auto',
|
||||
},
|
||||
modal: {
|
||||
eyebrow: 'Announcement',
|
||||
acknowledge: 'Acknowledge',
|
||||
later: 'Later',
|
||||
line1:
|
||||
'This will later connect to the real announcement body, confirmation checkbox, and persistence flow.',
|
||||
line2: 'For now it validates the shared modal structure.',
|
||||
},
|
||||
modals: {
|
||||
login: {
|
||||
title: 'Login',
|
||||
@@ -124,6 +116,16 @@ export default {
|
||||
'This area will later load the real event announcement body, rich media, and a longer scrollable message. The current version focuses on shared multilingual modal wiring.',
|
||||
check: 'View',
|
||||
},
|
||||
entryNotice: {
|
||||
title: 'Site Notice',
|
||||
subtitle:
|
||||
'Please read the following notices and confirm before entering the game.',
|
||||
loading: 'Loading notices...',
|
||||
loadFailed: 'Failed to load notices. Please try again.',
|
||||
retry: 'Reload',
|
||||
agreement: 'I have read and agree.',
|
||||
enterGame: 'Enter Game',
|
||||
},
|
||||
rules: {
|
||||
title: 'Game Rules',
|
||||
content:
|
||||
@@ -149,6 +151,7 @@ export default {
|
||||
title: 'User Info',
|
||||
tabs: {
|
||||
profile: 'Profile',
|
||||
financeRecords: 'Top Up / Withdraw Records',
|
||||
message: 'Messages',
|
||||
},
|
||||
profile: {
|
||||
@@ -171,6 +174,19 @@ export default {
|
||||
check: 'View',
|
||||
deleteRecords: 'Delete records',
|
||||
},
|
||||
financeRecords: {
|
||||
deposit: 'Top-up Records',
|
||||
withdraw: 'Withdrawal Records',
|
||||
orderNo: 'Order No.',
|
||||
amount: 'Amount',
|
||||
bonusAmount: 'Bonus Amount',
|
||||
loading: 'Loading records...',
|
||||
loadFailed: 'Failed to load records. Please try again later.',
|
||||
empty: 'No records yet',
|
||||
page: 'Page {{page}} / {{total}} total',
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
},
|
||||
},
|
||||
withdrawTopup: {
|
||||
applyWithdraw: 'Apply for Withdrawal',
|
||||
|
||||
@@ -102,14 +102,6 @@ export default {
|
||||
autoModeDemo: 'Demo mode auto',
|
||||
stopAuto: 'Stop auto',
|
||||
},
|
||||
modal: {
|
||||
eyebrow: 'Pengumuman',
|
||||
acknowledge: 'Saya paham',
|
||||
later: 'Nanti',
|
||||
line1:
|
||||
'Ini nantinya akan terhubung ke konten pengumuman nyata, checkbox konfirmasi, dan alur penyimpanan status.',
|
||||
line2: 'Untuk sekarang ini memvalidasi struktur modal bersama.',
|
||||
},
|
||||
modals: {
|
||||
login: {
|
||||
title: 'Masuk',
|
||||
@@ -123,6 +115,16 @@ export default {
|
||||
'Bagian ini nantinya akan memuat konten pengumuman acara yang sebenarnya, materi visual, dan pesan panjang yang dapat digulir. Versi saat ini fokus pada sambungan modal multibahasa.',
|
||||
check: 'Lihat',
|
||||
},
|
||||
entryNotice: {
|
||||
title: 'Pengumuman Situs',
|
||||
subtitle:
|
||||
'Silakan baca pengumuman berikut dan konfirmasi sebelum masuk ke game.',
|
||||
loading: 'Memuat pengumuman...',
|
||||
loadFailed: 'Gagal memuat pengumuman. Silakan coba lagi.',
|
||||
retry: 'Muat Ulang',
|
||||
agreement: 'Saya telah membaca dan setuju.',
|
||||
enterGame: 'Masuk Game',
|
||||
},
|
||||
rules: {
|
||||
title: 'Aturan Permainan',
|
||||
content:
|
||||
@@ -148,6 +150,7 @@ export default {
|
||||
title: 'Info Pengguna',
|
||||
tabs: {
|
||||
profile: 'Profil',
|
||||
financeRecords: 'Riwayat Isi Ulang / Penarikan',
|
||||
message: 'Pesan',
|
||||
},
|
||||
profile: {
|
||||
@@ -170,6 +173,19 @@ export default {
|
||||
check: 'Lihat',
|
||||
deleteRecords: 'Hapus riwayat',
|
||||
},
|
||||
financeRecords: {
|
||||
deposit: 'Riwayat Isi Ulang',
|
||||
withdraw: 'Riwayat Penarikan',
|
||||
orderNo: 'No. Pesanan',
|
||||
amount: 'Jumlah',
|
||||
bonusAmount: 'Jumlah Bonus',
|
||||
loading: 'Memuat riwayat...',
|
||||
loadFailed: 'Gagal memuat riwayat. Silakan coba lagi nanti.',
|
||||
empty: 'Belum ada riwayat',
|
||||
page: 'Halaman {{page}} / total {{total}}',
|
||||
previous: 'Sebelumnya',
|
||||
next: 'Berikutnya',
|
||||
},
|
||||
},
|
||||
withdrawTopup: {
|
||||
applyWithdraw: 'Ajukan Penarikan',
|
||||
|
||||
@@ -105,14 +105,6 @@ export default {
|
||||
autoModeDemo: 'Demo mod auto',
|
||||
stopAuto: 'Henti auto',
|
||||
},
|
||||
modal: {
|
||||
eyebrow: 'Pengumuman',
|
||||
acknowledge: 'Faham',
|
||||
later: 'Nanti',
|
||||
line1:
|
||||
'Ini akan disambungkan kepada kandungan pengumuman sebenar, kotak pengesahan, dan aliran penyimpanan status.',
|
||||
line2: 'Buat masa ini, ia mengesahkan struktur modal yang dikongsi.',
|
||||
},
|
||||
modals: {
|
||||
login: {
|
||||
title: 'Log Masuk',
|
||||
@@ -126,6 +118,16 @@ export default {
|
||||
'Bahagian ini akan memuatkan kandungan notis acara sebenar, bahan visual, dan mesej boleh skrol yang lebih panjang. Versi semasa memfokuskan sambungan modal pelbagai bahasa.',
|
||||
check: 'Semak',
|
||||
},
|
||||
entryNotice: {
|
||||
title: 'Notis Laman',
|
||||
subtitle:
|
||||
'Sila baca notis berikut dan sahkan sebelum memasuki permainan.',
|
||||
loading: 'Memuatkan notis...',
|
||||
loadFailed: 'Gagal memuatkan notis. Sila cuba lagi.',
|
||||
retry: 'Muat semula',
|
||||
agreement: 'Saya telah membaca dan bersetuju.',
|
||||
enterGame: 'Masuk Permainan',
|
||||
},
|
||||
rules: {
|
||||
title: 'Peraturan Permainan',
|
||||
content:
|
||||
@@ -151,6 +153,7 @@ export default {
|
||||
title: 'Maklumat Pengguna',
|
||||
tabs: {
|
||||
profile: 'Profil',
|
||||
financeRecords: 'Rekod Tambah Nilai / Pengeluaran',
|
||||
message: 'Mesej',
|
||||
},
|
||||
profile: {
|
||||
@@ -173,6 +176,19 @@ export default {
|
||||
check: 'Semak',
|
||||
deleteRecords: 'Padam rekod',
|
||||
},
|
||||
financeRecords: {
|
||||
deposit: 'Rekod Tambah Nilai',
|
||||
withdraw: 'Rekod Pengeluaran',
|
||||
orderNo: 'No. Pesanan',
|
||||
amount: 'Jumlah',
|
||||
bonusAmount: 'Jumlah Bonus',
|
||||
loading: 'Memuatkan rekod...',
|
||||
loadFailed: 'Gagal memuatkan rekod. Sila cuba lagi kemudian.',
|
||||
empty: 'Belum ada rekod',
|
||||
page: 'Halaman {{page}} / jumlah {{total}}',
|
||||
previous: 'Sebelumnya',
|
||||
next: 'Seterusnya',
|
||||
},
|
||||
},
|
||||
withdrawTopup: {
|
||||
applyWithdraw: 'Mohon Pengeluaran',
|
||||
|
||||
@@ -101,13 +101,6 @@ export default {
|
||||
autoModeDemo: '自动托管演示',
|
||||
stopAuto: '停止托管',
|
||||
},
|
||||
modal: {
|
||||
eyebrow: '强制公告',
|
||||
acknowledge: '已读并进入',
|
||||
later: '稍后查看',
|
||||
line1: '这里后续会接真实公告图文、勾选确认和已读状态。',
|
||||
line2: '当前先用共享弹窗骨架验证结构。',
|
||||
},
|
||||
modals: {
|
||||
login: {
|
||||
title: '登录',
|
||||
@@ -121,6 +114,15 @@ export default {
|
||||
'这里后续将接入真实的活动公告内容、图文素材和滚动阅读区域。当前版本先用多语言结构完成桌面端公告弹窗能力。',
|
||||
check: '查看',
|
||||
},
|
||||
entryNotice: {
|
||||
title: '网站公告',
|
||||
subtitle: '请先阅读以下公告,勾选确认后即可进入游戏。',
|
||||
loading: '公告加载中...',
|
||||
loadFailed: '公告加载失败,请重试',
|
||||
retry: '重新加载',
|
||||
agreement: '我已阅读并同意。',
|
||||
enterGame: '进入游戏',
|
||||
},
|
||||
rules: {
|
||||
title: '玩法规则',
|
||||
content:
|
||||
@@ -146,6 +148,7 @@ export default {
|
||||
title: '用户信息',
|
||||
tabs: {
|
||||
profile: '个人信息',
|
||||
financeRecords: '充值/提现记录',
|
||||
message: '站内消息',
|
||||
},
|
||||
profile: {
|
||||
@@ -166,6 +169,19 @@ export default {
|
||||
check: '查看',
|
||||
deleteRecords: '删除记录',
|
||||
},
|
||||
financeRecords: {
|
||||
deposit: '充值记录',
|
||||
withdraw: '提现记录',
|
||||
orderNo: '订单号',
|
||||
amount: '金额',
|
||||
bonusAmount: '赠送金额',
|
||||
loading: '记录加载中...',
|
||||
loadFailed: '记录加载失败,请稍后重试',
|
||||
empty: '暂无记录',
|
||||
page: '第 {{page}} 页 / 共 {{total}} 条',
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
},
|
||||
},
|
||||
withdrawTopup: {
|
||||
applyWithdraw: '申请提现',
|
||||
|
||||
@@ -31,6 +31,29 @@ type GameRoundSlice = Pick<
|
||||
| 'trends'
|
||||
>
|
||||
|
||||
export type RevealAnimationPhase = 'idle' | 'spinning' | 'stopping' | 'result'
|
||||
export type RewardAnimationType = 'none' | 'small' | 'big'
|
||||
|
||||
export interface RevealAnimationState {
|
||||
pendingRewardType: RewardAnimationType
|
||||
phase: RevealAnimationPhase
|
||||
revealKey: string | null
|
||||
rewardType: RewardAnimationType
|
||||
roundId: string | null
|
||||
winningCellId: number | null
|
||||
}
|
||||
|
||||
function createIdleRevealAnimation(): RevealAnimationState {
|
||||
return {
|
||||
pendingRewardType: 'none',
|
||||
phase: 'idle',
|
||||
revealKey: null,
|
||||
rewardType: 'none',
|
||||
roundId: null,
|
||||
winningCellId: null,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRecentActiveChipId(
|
||||
chips: Chip[],
|
||||
selections: BetSelection[],
|
||||
@@ -54,14 +77,25 @@ function resolveRecentActiveChipId(
|
||||
export interface GameRoundStoreState extends GameRoundSlice {
|
||||
activeChipId: string
|
||||
clearSelections: () => void
|
||||
clearRewardAnimation: () => void
|
||||
finishRevealAnimation: () => void
|
||||
hydrateRound: (snapshot: GameRoundSlice) => void
|
||||
placeBet: (cellId: number) => void
|
||||
playPreparedRevealAnimation: (roundId?: string | null) => void
|
||||
prepareRevealAnimation: (input: {
|
||||
hasSmallReward: boolean
|
||||
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
|
||||
showJackpotReward: (roundId?: string | null) => void
|
||||
syncRound: (round: Partial<RoundSnapshot>) => void
|
||||
upsertSelections: (selections: BetSelection[]) => void
|
||||
}
|
||||
@@ -69,6 +103,7 @@ export interface GameRoundStoreState extends GameRoundSlice {
|
||||
function createInitialRoundState(): GameRoundSlice & {
|
||||
activeChipId: string
|
||||
recentSuccessfulSelections: BetSelection[]
|
||||
revealAnimation: RevealAnimationState
|
||||
} {
|
||||
const snapshot = createEmptyGameBootstrapSnapshot()
|
||||
|
||||
@@ -79,6 +114,7 @@ function createInitialRoundState(): GameRoundSlice & {
|
||||
history: snapshot.history,
|
||||
maxSelectionCount: snapshot.maxSelectionCount,
|
||||
recentSuccessfulSelections: [],
|
||||
revealAnimation: createIdleRevealAnimation(),
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
trends: snapshot.trends,
|
||||
@@ -90,6 +126,37 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
clearSelections: () => {
|
||||
set({ selections: [] })
|
||||
},
|
||||
clearRewardAnimation: () => {
|
||||
set((state) => ({
|
||||
revealAnimation: {
|
||||
...state.revealAnimation,
|
||||
rewardType: 'none',
|
||||
},
|
||||
}))
|
||||
},
|
||||
finishRevealAnimation: () => {
|
||||
set((state) => {
|
||||
if (
|
||||
state.revealAnimation.phase !== 'stopping' &&
|
||||
state.revealAnimation.phase !== 'spinning'
|
||||
) {
|
||||
return state
|
||||
}
|
||||
|
||||
const rewardType =
|
||||
state.revealAnimation.rewardType === 'big'
|
||||
? 'big'
|
||||
: state.revealAnimation.pendingRewardType
|
||||
|
||||
return {
|
||||
revealAnimation: {
|
||||
...state.revealAnimation,
|
||||
phase: 'result',
|
||||
rewardType,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
hydrateRound: (snapshot) => {
|
||||
set((state) => ({
|
||||
activeChipId: getChipById(snapshot.chips, state.activeChipId)
|
||||
@@ -101,6 +168,10 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
maxSelectionCount: snapshot.maxSelectionCount,
|
||||
revealAnimation:
|
||||
snapshot.round.phase === 'betting' || snapshot.round.phase === 'waiting'
|
||||
? createIdleRevealAnimation()
|
||||
: state.revealAnimation,
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
trends: snapshot.trends,
|
||||
@@ -143,6 +214,61 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
}
|
||||
})
|
||||
},
|
||||
playPreparedRevealAnimation: (roundId) => {
|
||||
set((state) => {
|
||||
const nextRoundId = roundId ?? state.revealAnimation.roundId
|
||||
|
||||
if (
|
||||
state.revealAnimation.winningCellId === null ||
|
||||
state.revealAnimation.revealKey === null ||
|
||||
state.revealAnimation.phase !== 'idle'
|
||||
) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
revealAnimation: {
|
||||
...state.revealAnimation,
|
||||
phase: 'stopping',
|
||||
roundId: nextRoundId,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
prepareRevealAnimation: ({
|
||||
hasSmallReward,
|
||||
revealKey,
|
||||
roundId,
|
||||
winningCellId,
|
||||
}) => {
|
||||
set((state) => {
|
||||
if (state.revealAnimation.revealKey === revealKey) {
|
||||
return state
|
||||
}
|
||||
|
||||
const pendingRewardType =
|
||||
state.revealAnimation.pendingRewardType === 'big'
|
||||
? 'big'
|
||||
: hasSmallReward
|
||||
? 'small'
|
||||
: 'none'
|
||||
|
||||
return {
|
||||
revealAnimation: {
|
||||
...state.revealAnimation,
|
||||
pendingRewardType,
|
||||
phase: 'idle',
|
||||
revealKey,
|
||||
rewardType:
|
||||
state.revealAnimation.rewardType === 'big'
|
||||
? 'big'
|
||||
: state.revealAnimation.rewardType,
|
||||
roundId,
|
||||
winningCellId,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
recentSuccessfulSelections: [],
|
||||
removeSelectionsForCell: (cellId) => {
|
||||
set((state) => ({
|
||||
@@ -215,13 +341,38 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
nextState.selections = []
|
||||
}
|
||||
|
||||
if (phase === 'locked' || phase === 'revealing') {
|
||||
nextState.selections = []
|
||||
}
|
||||
|
||||
if (phase === 'betting' || phase === 'waiting') {
|
||||
nextState.revealAnimation = createIdleRevealAnimation()
|
||||
}
|
||||
|
||||
return nextState
|
||||
})
|
||||
},
|
||||
showJackpotReward: (roundId) => {
|
||||
set((state) => {
|
||||
const nextRoundId =
|
||||
roundId ?? state.round.id ?? state.revealAnimation.roundId
|
||||
const isResultReady = state.revealAnimation.phase === 'result'
|
||||
|
||||
return {
|
||||
revealAnimation: {
|
||||
...state.revealAnimation,
|
||||
pendingRewardType: 'big',
|
||||
rewardType: isResultReady ? 'big' : state.revealAnimation.rewardType,
|
||||
roundId: nextRoundId,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
syncRound: (round) => {
|
||||
set((state) => {
|
||||
const previousRound = state.round
|
||||
const nextRound = {
|
||||
...state.round,
|
||||
...previousRound,
|
||||
...round,
|
||||
}
|
||||
|
||||
@@ -233,6 +384,17 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
nextState.selections = []
|
||||
}
|
||||
|
||||
if (nextRound.phase === 'locked' || nextRound.phase === 'revealing') {
|
||||
nextState.selections = []
|
||||
}
|
||||
|
||||
if (
|
||||
nextRound.id !== previousRound.id &&
|
||||
(nextRound.phase === 'betting' || nextRound.phase === 'waiting')
|
||||
) {
|
||||
nextState.revealAnimation = createIdleRevealAnimation()
|
||||
}
|
||||
|
||||
return nextState
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,43 +1,13 @@
|
||||
import { create } from 'zustand'
|
||||
import { INITIAL_MODAL_VISIBILITY, MODAL_KEYS } from '@/constants'
|
||||
import type { WithdrawTopupType } from '@/type'
|
||||
|
||||
export const MODAL_KEYS = [
|
||||
/**@description 桌面端登录弹窗*/
|
||||
'desktopLogin',
|
||||
/**@description 桌面端注册弹窗*/
|
||||
'desktopRegister',
|
||||
/**@description 桌面端多语言弹窗*/
|
||||
'desktopLanguage',
|
||||
/**@description 桌面端规则弹窗*/
|
||||
'desktopRules',
|
||||
/**@description 桌面端用户信息弹窗*/
|
||||
'desktopUserInfo',
|
||||
/**@description 桌面端公告弹窗*/
|
||||
'desktopNotice',
|
||||
/**@description 桌面端自动托管弹窗*/
|
||||
'desktopAutoSetting',
|
||||
/**@description 桌面端充值提现前置选择弹窗*/
|
||||
'desktopProcedures',
|
||||
/**@description 桌面端充值/提现弹窗*/
|
||||
'desktopWithdrawTopup',
|
||||
] as const
|
||||
export { MODAL_KEYS }
|
||||
|
||||
export type ModalKey = (typeof MODAL_KEYS)[number]
|
||||
|
||||
type ModalVisibilityMap = Record<ModalKey, boolean>
|
||||
|
||||
const INITIAL_MODAL_VISIBILITY: ModalVisibilityMap = {
|
||||
desktopLogin: false,
|
||||
desktopRegister: false,
|
||||
desktopLanguage: false,
|
||||
desktopRules: false,
|
||||
desktopUserInfo: false,
|
||||
desktopNotice: false,
|
||||
desktopAutoSetting: false,
|
||||
desktopProcedures: false,
|
||||
desktopWithdrawTopup: false,
|
||||
}
|
||||
|
||||
export interface ModalStoreState {
|
||||
modals: ModalVisibilityMap
|
||||
withdrawTopupType: WithdrawTopupType
|
||||
|
||||
@@ -139,7 +139,8 @@
|
||||
}
|
||||
|
||||
@utility my-design-* {
|
||||
margin-block: calc(var(--design-unit) * --value(integer));
|
||||
margin-top: calc(var(--design-unit) * --value(integer));
|
||||
margin-bottom: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility left-design-* {
|
||||
|
||||