refactor(game): 优化游戏组件并实现国际化支持

- 在AppBootResourceGate组件中集成react-i18next实现资源加载文本的国际化
- 修改DesktopAnimal组件中的loading dots key以提高渲染性能
- 在DesktopControl组件中添加useRef和useEffect钩子管理定时器清理逻辑
- 将DesktopTitle组件重构为MessageBroadcast组件并增强其响应式设计
- 更新DesktopSupportModal组件中的客户服务文本为国际化格式
- 在AuthSession模块中实现本地存储数据清理时保留关键偏好设置
- 调整多个游戏组件的样式类以改进移动端适配效果
- 移除未使用的桌面提取功能相关代码文件
- 更新GitNexus索引统计数据反映最新的代码变更
This commit is contained in:
JiaJun
2026-06-04 18:01:40 +08:00
parent bfb4b76611
commit a6b34660ad
48 changed files with 3185 additions and 1216 deletions

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **36-character-flower** (3087 symbols, 5931 relationships, 265 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **36-character-flower** (2879 symbols, 5735 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **36-character-flower** (3087 symbols, 5931 relationships, 265 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **36-character-flower** (2879 symbols, 5735 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

BIN
figma/img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

View File

@@ -1,4 +1,5 @@
import { type PropsWithChildren, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const bootImageModules = import.meta.glob(
'../assets/**/*.{jpg,jpeg,png,svg,webp}',
@@ -90,6 +91,8 @@ function useBootResourceLoader() {
}
function AppLoadingOverlay({ progress }: { progress: number }) {
const { t } = useTranslation()
return (
<div
role="status"
@@ -137,10 +140,10 @@ function AppLoadingOverlay({ progress }: { progress: number }) {
<div className="flex flex-col items-center gap-design-8 text-center">
<div className="text-design-24 font-bold text-[#E8FFFF] [text-shadow:0_0_calc(var(--design-unit)*14)_rgba(113,255,247,0.38)]">
{t('commonUi.boot.loading')}
</div>
<div className="text-design-14 text-[rgba(181,242,247,0.72)]">
{t('commonUi.boot.syncing')}
</div>
</div>
</div>

View File

@@ -77,6 +77,8 @@ export function AppNotificationAlert() {
const tone = TONE_CLASS_BY_TYPE[activeDialog.type]
const isClosing = closingDialogId === activeDialog.id
const isMobileViewport =
typeof window !== 'undefined' ? window.innerWidth <= 768 : false
const motionState = isClosing ? 'closing' : 'visible'
const motionVariants = {
@@ -111,17 +113,31 @@ export function AppNotificationAlert() {
} as const
return createPortal(
<div className="pointer-events-none fixed inset-x-0 top-[calc(var(--design-unit)*52)] z-50 flex justify-center px-4">
<div
className={cn(
'pointer-events-none fixed inset-x-0 z-50 flex justify-center',
isMobileViewport
? 'top-[calc(var(--design-unit)*38)] px-design-8'
: 'top-[calc(var(--design-unit)*52)] px-4',
)}
>
<motion.div
key={activeDialog.id}
initial="hidden"
animate={motionState}
variants={motionVariants}
className="w-[min(92vw,calc(var(--design-unit)*468))] will-change-transform"
className={cn(
'will-change-transform',
isMobileViewport
? 'w-[min(94vw,calc(var(--design-unit)*280))]'
: 'w-[min(92vw,calc(var(--design-unit)*468))]',
)}
>
<Alert
className={cn(
'relative origin-top flex w-full max-w-none items-center gap-design-12 overflow-hidden rounded-[calc(var(--design-unit)*14)] border px-design-14 py-design-12 text-left backdrop-blur-xl',
isMobileViewport
? 'relative origin-top flex w-full max-w-none items-center gap-design-8 overflow-hidden rounded-[calc(var(--design-unit)*10)] border px-design-10 py-design-8 text-left backdrop-blur-xl'
: 'relative origin-top flex w-full max-w-none items-center gap-design-12 overflow-hidden rounded-[calc(var(--design-unit)*14)] border px-design-14 py-design-12 text-left backdrop-blur-xl',
tone.alert,
)}
>
@@ -134,7 +150,9 @@ export function AppNotificationAlert() {
<div
aria-hidden="true"
className={cn(
'absolute top-design-10 bottom-design-10 left-design-10 w-[calc(var(--design-unit)*3)] rounded-full opacity-90 shadow-[0_0_calc(var(--design-unit)*14)_currentColor]',
isMobileViewport
? 'absolute top-design-8 bottom-design-8 left-design-7 w-[calc(var(--design-unit)*2.5)] rounded-full opacity-90 shadow-[0_0_calc(var(--design-unit)*10)_currentColor]'
: 'absolute top-design-10 bottom-design-10 left-design-10 w-[calc(var(--design-unit)*3)] rounded-full opacity-90 shadow-[0_0_calc(var(--design-unit)*14)_currentColor]',
tone.line,
)}
/>
@@ -148,24 +166,42 @@ export function AppNotificationAlert() {
<div
className={cn(
'relative z-10 flex h-design-38 w-design-38 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*11)] border',
isMobileViewport
? 'relative z-10 flex h-design-28 w-design-28 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*8)] border'
: 'relative z-10 flex h-design-38 w-design-38 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*11)] border',
tone.iconShell,
)}
>
{tone.icon}
</div>
<div className="relative z-10 flex min-h-design-38 min-w-0 flex-1 flex-col justify-center">
<div
className={cn(
'relative z-10 min-w-0 flex-1 flex-col justify-center',
isMobileViewport
? 'flex min-h-design-28'
: 'flex min-h-design-38',
)}
>
<AlertTitle
className={cn(
'text-design-15 font-semibold leading-[1.25] text-shadow-[0_1px_0_rgba(0,0,0,0.18)]',
isMobileViewport
? 'text-design-11 font-semibold leading-[1.25] text-shadow-[0_1px_0_rgba(0,0,0,0.18)]'
: 'text-design-15 font-semibold leading-[1.25] text-shadow-[0_1px_0_rgba(0,0,0,0.18)]',
tone.title,
)}
>
{activeDialog.message}
</AlertTitle>
{activeDialog.description ? (
<AlertDescription className="pt-design-3 whitespace-pre-line text-design-12 leading-[1.45] text-white/62">
<AlertDescription
className={cn(
'whitespace-pre-line text-white/62',
isMobileViewport
? 'pt-design-2 text-design-9 leading-[1.35]'
: 'pt-design-3 text-design-12 leading-[1.45]',
)}
>
{activeDialog.description}
</AlertDescription>
) : null}

View File

@@ -521,7 +521,7 @@ export function DesktopAnimal({
<div className="flex items-center gap-design-4">
{[0, 1, 2].map((index) => (
<motion.span
key={index}
key={`loading-dot-${index}`}
animate={
isRealtimeConnecting
? { opacity: [0.28, 1, 0.28], y: [0, -2, 0] }

View File

@@ -1,5 +1,5 @@
import { motion } from 'motion/react'
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import reduce from '@/assets/game/add.webp'
import arrow from '@/assets/game/arrow.webp'
@@ -48,9 +48,28 @@ export function DesktopControl() {
const [clickedId, setClickedId] = useState<string | null>(null)
const [hidingId, setHidingId] = useState<string | null>(null)
const [confirmClicked, setConfirmClicked] = useState(false)
const clickResetTimerRef = useRef<number | null>(null)
const hideResetTimerRef = useRef<number | null>(null)
const confirmResetTimerRef = useRef<number | null>(null)
const isConfirmWarning =
confirmState === 'insufficient' || confirmState === 'limit'
useEffect(() => {
return () => {
if (clickResetTimerRef.current !== null) {
window.clearTimeout(clickResetTimerRef.current)
}
if (hideResetTimerRef.current !== null) {
window.clearTimeout(hideResetTimerRef.current)
}
if (confirmResetTimerRef.current !== null) {
window.clearTimeout(confirmResetTimerRef.current)
}
}
}, [])
const handleChipClick = (chipId: string) => {
if (!acceptingBets) {
return
@@ -78,10 +97,20 @@ export function DesktopControl() {
}
setClickedId(id)
setTimeout(() => {
if (clickResetTimerRef.current !== null) {
window.clearTimeout(clickResetTimerRef.current)
}
clickResetTimerRef.current = window.setTimeout(() => {
setClickedId(null)
setHidingId(id)
setTimeout(() => {
if (hideResetTimerRef.current !== null) {
window.clearTimeout(hideResetTimerRef.current)
}
hideResetTimerRef.current = window.setTimeout(() => {
setHidingId(null)
}, 180)
}, 200)
@@ -102,7 +131,12 @@ export function DesktopControl() {
}
setConfirmClicked(true)
setTimeout(() => {
if (confirmResetTimerRef.current !== null) {
window.clearTimeout(confirmResetTimerRef.current)
}
confirmResetTimerRef.current = window.setTimeout(() => {
setConfirmClicked(false)
}, 200)
void onConfirm()

View File

@@ -12,7 +12,7 @@ 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 { MessageBroadcast } from '@/features/game/components/desktop/desktop-title.tsx'
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
import { cn } from '@/lib/utils.ts'
@@ -42,15 +42,8 @@ export function DesktopStatusLine() {
return (
<div className={'relative w-full flex flex-col text-design-22'}>
{/*<div*/}
{/* className={*/}
{/* }*/}
{/*>*/}
{/* <DesktopTitle />*/}
{/*</div>*/}
<div className={'w-full px-design-16 mb-design-10'}>
<DesktopTitle />
<MessageBroadcast />
</div>
<SmartBackground
src={statusLine}

View File

@@ -3,6 +3,7 @@ import useEmblaCarousel from 'embla-carousel-react'
import { useEffect, useMemo } from 'react'
import broadcast from '@/assets/system/broadcast.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { cn } from '@/lib/utils.ts'
import { useGameSessionStore } from '@/store/game'
const winAmountFormatter = new Intl.NumberFormat('en-US', {
@@ -30,7 +31,11 @@ function formatWinAmount(value: string) {
return Number.isFinite(amount) ? winAmountFormatter.format(amount) : value
}
export function DesktopTitle() {
type MessageBroadcastProps = {
className?: string
}
export function MessageBroadcast({ className }: MessageBroadcastProps) {
const jackpotBroadcasts = useGameSessionStore(
(state) => state.jackpotBroadcasts,
)
@@ -107,19 +112,27 @@ export function DesktopTitle() {
}, [emblaApi, hasBroadcasts, carouselTitleCount])
return (
<section className="common-neon-inset text-design-16 w-full flex h-design-65 items-center gap-design-10 !px-design-20 overflow-hidden">
<section
aria-label="Jackpot broadcast"
className={cn(
'common-neon-inset flex w-full min-w-0 items-center overflow-hidden',
'h-design-24 gap-design-5 !rounded-[3px] !px-design-7 !py-0 text-design-8',
'md:h-design-65 md:gap-design-10 md:!rounded-[5px] md:!px-design-20 md:text-design-16',
className,
)}
>
<SmartImage
className={'w-design-24 h-design-24'}
className="h-design-12 w-design-12 shrink-0 md:h-design-24 md:w-design-24"
alt={'broadcast'}
src={broadcast}
/>
<div className="relative h-design-28 min-w-0 flex-1 overflow-hidden">
<div className="relative h-design-16 min-w-0 flex-1 overflow-hidden md:h-design-28">
<div className="h-full overflow-hidden" ref={emblaRef}>
<div className="flex h-full items-center gap-design-80">
<div className="flex h-full items-center gap-design-36 md:gap-design-80">
{carouselTitles.map((title) => (
<div
aria-hidden={title.cycleIndex > 0}
className="flex h-design-28 min-w-0 shrink-0 items-center whitespace-nowrap !text-[#FF970F]"
className="flex h-design-16 min-w-max shrink-0 items-center whitespace-nowrap !text-[#FF970F] md:h-design-28"
key={title.id}
>
{title.message}
@@ -131,3 +144,7 @@ export function DesktopTitle() {
</section>
)
}
export function DesktopTitle(props: MessageBroadcastProps) {
return <MessageBroadcast {...props} />
}

View File

@@ -1,671 +0,0 @@
import { Minus, Plus } from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { useTranslation } from 'react-i18next'
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
import { Input } from '@/components/ui/input.tsx'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select.tsx'
import { cn } from '@/lib/utils'
const AVAILABLE_BALANCE = 6628
const MYR_PER_100_DIAMONDS = 1
const USDT_TO_MYR_RATE = 4.049
const VND_PER_DIAMOND = 10
const QUICK_AMOUNTS = [
{ diamonds: 210, preview: 'MYR 3' },
{ diamonds: 2250, preview: 'MYR 30' },
{ diamonds: 4000, preview: 'MYR 50' },
{ diamonds: 8000, preview: 'MYR 100' },
{ diamonds: 17000, preview: 'MYR 200' },
{ diamonds: 45000, preview: 'MYR 500' },
] as const
const CURRENCY_OPTIONS = ['MYR'] as const
const PAYMENT_CHANNELS = [
{
id: 'alipay-primary',
label: 'Alipay',
glyph: '支',
},
{
id: 'alipay-secondary',
label: 'Alipay',
glyph: '支',
},
{
id: 'alipay-third',
label: 'Alipay',
glyph: '支',
},
] as const
const BANK_OPTIONS = [
{
id: 'bca',
label: 'BCA',
brand: 'BCA',
subtitle: 'Bank Central Asia',
surface:
'bg-[linear-gradient(180deg,rgba(251,252,255,0.98),rgba(224,239,255,0.96))] text-[#1E53A4]',
},
{
id: 'mandiri',
label: 'Mandiri',
brand: 'mandiri',
subtitle: 'Mandiri',
surface:
'bg-[linear-gradient(180deg,rgba(26,53,93,0.98),rgba(9,22,43,0.96))] text-[#F5C247]',
},
{
id: 'bni',
label: 'BNI',
brand: 'BNI',
subtitle: 'BNI',
surface:
'bg-[linear-gradient(180deg,rgba(254,253,252,0.98),rgba(239,242,247,0.96))] text-[#E1742B]',
},
{
id: 'bri',
label: 'BRI',
brand: 'BRI',
subtitle: 'BRI',
surface:
'bg-[linear-gradient(180deg,rgba(253,254,255,0.98),rgba(234,243,255,0.96))] text-[#0E56A5]',
},
] as const
type PaymentChannelId = (typeof PAYMENT_CHANNELS)[number]['id']
type BankId = (typeof BANK_OPTIONS)[number]['id']
const numberFormatter = new Intl.NumberFormat('en-US')
const fixedTwoFormatter = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
const fixedSixFormatter = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 6,
maximumFractionDigits: 6,
})
const PANEL_CLASS =
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.08),0_0_calc(var(--design-unit)*10)_rgba(32,163,186,0.12)]'
const SELECTABLE_CARD_CLASS =
'flex shrink-0 cursor-pointer flex-col items-center justify-between rounded-[calc(var(--design-unit)*6)] border px-design-8 py-design-8 transition'
const SELECTABLE_CARD_ACTIVE_CLASS =
'border-[#D18A43] bg-[linear-gradient(180deg,rgba(65,45,28,0.92),rgba(39,26,16,0.9))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
const SELECTABLE_CARD_IDLE_CLASS =
'border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(8,34,48,0.92),rgba(5,19,29,0.94))] hover:border-[rgba(170,247,255,0.7)]'
function formatNumber(value: number) {
return numberFormatter.format(value)
}
function formatFixedTwo(value: number) {
return fixedTwoFormatter.format(value)
}
function formatFixedSix(value: number) {
return fixedSixFormatter.format(value)
}
function WithdrawField({
label,
children,
alignStart = true,
}: {
label: string
children: ReactNode
alignStart?: boolean
}) {
return (
<div className="flex gap-design-14">
<div className="flex w-design-108 shrink-0 items-center justify-end text-right text-design-16 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
<span>{label}</span>
<span className="pl-design-4">:</span>
</div>
<div
className={cn(
'min-w-0 flex-1',
alignStart ? 'pt-design-2' : 'flex items-center',
)}
>
{children}
</div>
</div>
)
}
function AmountShell({
amount,
availableBalanceText,
onMinus,
onPlus,
}: {
amount: number
availableBalanceText: string
onMinus: () => void
onPlus: () => void
}) {
return (
<div className="flex flex-col gap-design-6">
<div className="flex h-design-52 items-center gap-design-10 rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(14,64,74,0.82),rgba(8,36,47,0.78))] px-design-10 shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(93,239,255,0.08)]">
<button
type="button"
onClick={onMinus}
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
>
<Minus className="h-design-16 w-design-16" />
</button>
<div className="flex min-w-0 flex-1 items-center justify-center text-design-24 font-medium tracking-[0.04em] text-[#A1EBF3]">
{formatNumber(amount)}
</div>
<button
type="button"
onClick={onPlus}
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
>
<Plus className="h-design-16 w-design-16" />
</button>
</div>
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
{availableBalanceText}
</div>
</div>
)
}
function QuickAmountCard({
amount,
preview,
active,
onClick,
}: {
amount: number
preview: string
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex h-design-68 w-design-104 shrink-0 cursor-pointer flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border transition',
active
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(84,48,24,0.92),rgba(60,34,18,0.88))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
: 'border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(10,44,58,0.84),rgba(5,21,32,0.92))] hover:border-[rgba(170,247,255,0.7)]',
)}
>
<div className="text-design-24 font-semibold leading-none text-[#FFE229]">
{amount}
</div>
<div className="pt-design-6 text-design-12 uppercase leading-none tracking-[0.04em] text-[#63AEB6]">
{preview}
</div>
</button>
)
}
function PaymentCard({
active,
label,
glyph,
onClick,
}: {
active: boolean
label: string
glyph: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
SELECTABLE_CARD_CLASS,
'h-design-92 w-design-86',
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
)}
>
<div
className={cn(
'flex h-design-58 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-42 font-semibold leading-none',
active
? 'bg-[linear-gradient(180deg,#1F9DE8,#0E6BCF)] text-white'
: 'bg-[linear-gradient(180deg,#1C96DF,#0B6ECF)] text-white',
)}
>
{glyph}
</div>
<div className="text-design-14 text-[#AEE8EE]">{label}</div>
</button>
)
}
function BankCard({
active,
brand,
subtitle,
surface,
onClick,
}: {
active: boolean
brand: string
subtitle: string
surface: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
SELECTABLE_CARD_CLASS,
'h-design-86 w-design-86',
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
)}
>
<div
className={cn(
'flex h-design-52 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-20 font-bold uppercase',
surface,
)}
>
{brand}
</div>
<div className="text-design-13 text-[#AEE8EE]">{subtitle}</div>
</button>
)
}
function InputShell({
value,
onChange,
placeholder,
error,
errorMessage,
uppercase = false,
}: {
value: string
onChange: (value: string) => void
placeholder: string
error?: boolean
errorMessage?: string
uppercase?: boolean
}) {
return (
<div className="flex flex-col gap-design-5">
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className={cn(
'h-design-42 rounded-[calc(var(--design-unit)*5)] border px-design-14 text-design-16',
uppercase && 'uppercase',
error
? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]'
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(10,47,57,0.84),rgba(5,23,32,0.92))] text-[#ACF1F6]',
)}
/>
{error && errorMessage ? (
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
{errorMessage}
</div>
) : null}
</div>
)
}
function PreviewRow({
label,
value,
highlight = false,
}: {
label: string
value: ReactNode
highlight?: boolean
}) {
return (
<div className="flex border-b border-[rgba(89,209,223,0.2)] last:border-b-0">
<div className="flex w-[44%] shrink-0 items-center border-r border-[rgba(89,209,223,0.2)] px-design-14 py-design-20 text-design-16 font-medium uppercase leading-[1.15] text-[#7CE3E8]">
{label}
</div>
<div
className={cn(
'flex min-w-0 flex-1 items-center justify-end px-design-14 py-design-20 text-right text-design-16 text-[#E6FFFF]',
highlight && 'text-design-18 font-semibold text-[#6DFF83]',
)}
>
{value}
</div>
</div>
)
}
function DesktopWithdraw() {
const { t } = useTranslation()
const [amount, setAmount] = useState(6626)
const [currency, setCurrency] =
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
const [paymentChannel, setPaymentChannel] =
useState<PaymentChannelId>('alipay-primary')
const [bank, setBank] = useState<BankId>('bca')
const [holderName, setHolderName] = useState('')
const [bankAccount, setBankAccount] = useState('')
const [receiverEmail, setReceiverEmail] = useState('')
const [receiverPhone, setReceiverPhone] = useState('')
const withdrawMyr = amount / 100
const withdrawVnd = amount * VND_PER_DIAMOND
const withdrawUsdt = withdrawMyr / USDT_TO_MYR_RATE
const holderNameError = holderName.trim().length === 0
const bankAccountError = bankAccount.trim().length === 0
function handleAmountChange(nextAmount: number) {
setAmount(Math.max(0, nextAmount))
}
return (
<div className="flex h-full min-h-0 w-full px-design-12 pb-design-12 text-[#D9FFFF]">
<div
className={cn(
PANEL_CLASS,
'flex h-full min-h-0 w-full min-w-0 overflow-y-auto',
)}
>
<div className="flex min-h-full min-w-0 flex-[1.7] flex-col px-design-16 py-design-14">
<div className="flex flex-col gap-design-12">
<WithdrawField label={t('提现钻石数量')}>
<AmountShell
amount={amount}
availableBalanceText={t(
'gameDesktop.withdraw.availableBalance',
{ amount: formatNumber(AVAILABLE_BALANCE) },
)}
onMinus={() => handleAmountChange(amount - 1)}
onPlus={() => handleAmountChange(amount + 1)}
/>
</WithdrawField>
<WithdrawField label={t('货币类型')} alignStart={false}>
<Select
value={currency}
onValueChange={(value) =>
setCurrency(value as (typeof CURRENCY_OPTIONS)[number])
}
>
<SelectTrigger
className="h-design-52 w-full rounded-[calc(var(--design-unit)*6)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-16 text-left text-design-20 font-semibold text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-52 [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
aria-label={t('gameDesktop.withdraw.currencySelection')}
>
<SelectValue
placeholder={t('gameDesktop.withdraw.selectCurrency')}
/>
</SelectTrigger>
<SelectContent
position="popper"
className="min-w-(--radix-select-trigger-width) rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(8,36,48,0.98),rgba(4,18,28,0.98))] text-[#CFFDFF] shadow-[0_0_calc(var(--design-unit)*16)_rgba(56,241,255,0.12)]"
>
{CURRENCY_OPTIONS.map((option) => (
<SelectItem
key={option}
value={option}
className="rounded-[calc(var(--design-unit)*4)] px-design-12 py-design-10 text-design-18 focus:bg-[rgba(53,154,171,0.2)] focus:text-white"
>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</WithdrawField>
<div className="flex gap-design-14">
<div className="w-design-108 shrink-0" />
<div className="flex min-w-0 flex-1 flex-wrap gap-design-10">
{QUICK_AMOUNTS.map((option) => (
<QuickAmountCard
key={option.diamonds}
amount={option.diamonds}
preview={option.preview}
active={option.diamonds === amount}
onClick={() => handleAmountChange(option.diamonds)}
/>
))}
</div>
</div>
<WithdrawField label={t('支付渠道')}>
<div className="flex flex-wrap gap-design-10">
{PAYMENT_CHANNELS.map((channel) => (
<PaymentCard
key={channel.id}
active={channel.id === paymentChannel}
label={channel.label}
glyph={channel.glyph}
onClick={() => setPaymentChannel(channel.id)}
/>
))}
</div>
</WithdrawField>
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
<div className="flex flex-col gap-design-10">
<div className="flex flex-wrap gap-design-10">
{BANK_OPTIONS.map((option) => (
<BankCard
key={option.id}
active={option.id === bank}
brand={option.brand}
subtitle={option.label}
surface={option.surface}
onClick={() => setBank(option.id)}
/>
))}
</div>
</div>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.cardHolderName')}
>
<InputShell
value={holderName}
onChange={setHolderName}
placeholder={t(
'gameDesktop.withdraw.placeholders.cardHolderName',
)}
error={holderNameError}
errorMessage={t(
'gameDesktop.withdraw.errors.cardHolderNameRequired',
)}
/>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
>
<InputShell
value={bankAccount}
onChange={setBankAccount}
placeholder={t(
'gameDesktop.withdraw.placeholders.bankAccountNumber',
)}
error={bankAccountError}
errorMessage={t(
'gameDesktop.withdraw.errors.bankAccountRequired',
)}
/>
</WithdrawField>
<WithdrawField label={t('收款人邮箱')} alignStart={false}>
<InputShell
value={receiverEmail}
onChange={setReceiverEmail}
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverEmail',
)}
uppercase={true}
/>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverPhone')}
alignStart={false}
>
<InputShell
value={receiverPhone}
onChange={setReceiverPhone}
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverPhone',
)}
uppercase={true}
/>
</WithdrawField>
</div>
</div>
<div className="w-px shrink-0 bg-[linear-gradient(180deg,rgba(89,209,223,0)_0%,rgba(89,209,223,0.4)_12%,rgba(89,209,223,0.5)_88%,rgba(89,209,223,0)_100%)]" />
<div className="flex min-h-full min-w-0 w-design-520 shrink-0 flex-col">
<div className="flex h-design-44 items-center border-b border-[rgba(89,209,223,0.2)] bg-[linear-gradient(90deg,rgba(18,99,110,0.8),rgba(7,68,79,0.9))] px-design-12 text-design-20 font-semibold uppercase tracking-[0.04em] text-[#9AF5FB]">
{t('gameDesktop.withdraw.preview.title')}
</div>
<div className="flex flex-1 flex-col gap-design-12 px-design-10 py-design-10">
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
<PreviewRow
label={t('gameDesktop.withdraw.preview.diamondAmount')}
value={formatNumber(amount)}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.exchangeRate', {
currency: 'MYR',
})}
value={t('gameDesktop.withdraw.preview.exchangeRateValue', {
coins: 100 * MYR_PER_100_DIAMONDS,
currency: 'MYR',
platformCoinLabel: '钻石',
})}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertible', {
currency: 'MYR',
})}
value={`RM ${formatFixedTwo(withdrawMyr)}`}
highlight={true}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.exchangeRate', {
currency: 'USDT',
})}
value={t('gameDesktop.withdraw.preview.exchangeRateValue', {
coins: formatFixedTwo(100 * USDT_TO_MYR_RATE),
currency: 'USDT',
platformCoinLabel: '钻石',
})}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertible', {
currency: 'VND',
})}
value={`${formatNumber(withdrawVnd)} VND`}
highlight={true}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertible', {
currency: 'USDT',
})}
value={`${formatFixedSix(withdrawUsdt)} USDT`}
highlight={true}
/>
<PreviewRow
label={t(
'gameDesktop.withdraw.preview.fixedExchangeDiamondAmount',
)}
value="0-0-0 0:0:0"
/>
</div>
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-12 py-design-10 text-design-16 leading-[1.35] text-[#F0B44A]">
{t('gameDesktop.withdraw.referenceRateNotice')}
</div>
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
<div>
{t('gameDesktop.withdraw.eWallet')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumAmount', {
amount: '10',
currency: 'MYR',
})}
</span>
</div>
<div>
{t('gameDesktop.withdraw.bank')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumAmount', {
amount: '10',
currency: 'MYR',
})}
</span>
</div>
<div>
{t('gameDesktop.withdraw.processingTime')}:{' '}
<span className="text-[#77FF76]">
{t('gameDesktop.withdraw.arrivalTimeValue')}
</span>
</div>
<div>
{t('gameDesktop.withdraw.notice')}:{' '}
<span className="text-red-700">
{t('gameDesktop.withdraw.feeNotice')}
</span>
</div>
</div>
<div className="mt-auto flex items-end justify-between gap-design-10 pt-design-10">
<SmartBackground
as="button"
type="button"
src={lengthGreenBtn}
size="100% 100%"
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-18 font-bold uppercase tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
>
{t('gameDesktop.withdraw.cancel')}
</SmartBackground>
<SmartBackground
as="button"
type="button"
src={lengthBlueBtn}
size="100% 100%"
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
>
{t('gameDesktop.withdraw.confirm')}
<br />
{t('gameDesktop.withdraw.withdrawal')}
</SmartBackground>
</div>
</div>
</div>
</div>
</div>
)
}
export default DesktopWithdraw

View File

@@ -0,0 +1,490 @@
import { motion, useReducedMotion } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import enStopImage from '@/assets/game/en-stop.webp'
import hostingBg from '@/assets/game/hosting-bg.webp'
import hostingBtn from '@/assets/game/hosting-btn.webp'
import winLogo from '@/assets/game/win.webp'
import winBg from '@/assets/game/win-bg.webp'
import zhStopImage from '@/assets/game/zh-stop.webp'
import bigRewardPath from '@/assets/lottie/pc-big-reward.json?url'
import smallRewardPath from '@/assets/lottie/pc-small-reward.json?url'
import diamondIcon from '@/assets/system/diamond.webp'
import refreshIcon from '@/assets/system/refresh.webp'
import type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
import { LottiePlayer } from '@/components/lottie-player.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image'
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
import {
FLOWER_IMAGE_BY_ID,
groupSelectionsByCell,
} from '@/features/game/shared'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import { useGameAutoHostingStore, useGameRoundStore } from '@/store/game'
import type { BetSelection, RewardAnimationType } from '@/type'
const REWARD_OVERLAY_FADE_OUT_MS = 300
const REWARD_CHILDREN_FADE_IN_MS = 2_000
const REWARD_CHILDREN_VISIBLE_MS = 1_000
type RewardChildrenStage = 'hidden' | 'visible' | 'exiting'
type StopBetItem = {
amount: number
cellId: number
imageUrl: string
}
interface MobileAnimalOverlayProps {
showStopOverlay: boolean
}
function easeOutCubic(progress: number) {
return 1 - (1 - progress) ** 3
}
function getAmountMeta(amount: string | null) {
if (!amount) {
return null
}
const normalizedAmount = amount.replace(/,/g, '')
const numericAmount = Number(normalizedAmount)
if (!Number.isFinite(numericAmount)) {
return null
}
return {
fractionDigits: normalizedAmount.includes('.')
? (normalizedAmount.split('.')[1]?.length ?? 0)
: 0,
numericAmount,
}
}
function formatRewardAmount(value: number, fractionDigits: number) {
return value.toLocaleString('en-US', {
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
})
}
function getRewardSource(
rewardType: RewardAnimationType,
): 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
}
function formatStopBetAmount(amount: number) {
return amount.toLocaleString('en-US', {
maximumFractionDigits: 2,
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
})
}
function getStopBetItems(selections: BetSelection[]) {
const groupedSelections = groupSelectionsByCell(selections)
return Object.entries(groupedSelections)
.map(([cellId, meta]) => {
const normalizedCellId = Number(cellId)
return {
amount: meta.amount,
cellId: normalizedCellId,
imageUrl: FLOWER_IMAGE_BY_ID[normalizedCellId]?.animalUrl ?? '',
} satisfies StopBetItem
})
.sort((left, right) => left.cellId - right.cellId)
}
function StopBetSummary({
items,
noBetText,
}: {
items: StopBetItem[]
noBetText: string
}) {
if (items.length === 0) {
return (
<div className="game-chip-glow rounded-[calc(var(--design-unit)*10)] border border-[rgba(124,232,255,0.22)] bg-[linear-gradient(180deg,rgba(8,34,42,0.94),rgba(3,13,20,0.96))] px-design-20 py-design-8 text-design-16 font-black tracking-[0.1em] text-[#E8FFFF] [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(70,245,255,0.34)]">
{noBetText}
</div>
)
}
return (
<div className="flex max-h-design-116 max-w-design-318 flex-wrap items-center justify-center gap-design-6 overflow-hidden rounded-[calc(var(--design-unit)*10)] border border-[rgba(124,232,255,0.28)] bg-[linear-gradient(180deg,rgba(10,31,42,0.84),rgba(3,12,18,0.74))] px-design-7 py-design-6 shadow-[0_0_calc(var(--design-unit)*16)_rgba(34,214,255,0.2),0_calc(var(--design-unit)*8)_calc(var(--design-unit)*16)_rgba(0,0,0,0.34),inset_0_calc(var(--design-unit)*1)_0_rgba(255,255,255,0.08)]">
{items.map((item) => (
<div
key={item.cellId}
className="flex h-design-42 min-w-design-100 items-center gap-design-5 overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[rgba(124,232,255,0.2)] bg-[linear-gradient(90deg,rgba(7,42,38,0.94),rgba(5,23,34,0.96)_44%,rgba(18,25,25,0.94))] px-design-4"
>
<div className="relative h-design-32 w-design-32 shrink-0 overflow-hidden rounded-[calc(var(--design-unit)*5)] bg-[rgba(1,8,13,0.84)]">
{item.imageUrl ? (
<SmartImage
src={item.imageUrl}
alt=""
showSkeleton={false}
className="h-full w-full"
imgClassName="object-cover"
/>
) : null}
</div>
<div className="flex min-w-0 flex-1 flex-col items-stretch justify-center gap-design-3 leading-none">
<div className="flex h-design-13 w-full items-center justify-center rounded-full bg-[rgba(4,31,31,0.9)] text-design-11 font-black leading-none text-[#78FF7F] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(120,255,127,0.52)]">
{String(item.cellId).padStart(2, '0')}
</div>
<div className="flex h-design-15 w-full min-w-0 items-center justify-center gap-design-2 rounded-full bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-3">
<SmartImage
src={diamondIcon}
alt=""
showSkeleton={false}
className="h-design-9 w-design-9 shrink-0 object-contain"
/>
<span className="min-w-0 truncate text-design-10 font-black leading-none tabular-nums text-[#FFE58A] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(255,222,130,0.46)]">
{formatStopBetAmount(item.amount)}
</span>
</div>
</div>
</div>
))}
</div>
)
}
export function MobileAnimalOverlay({
showStopOverlay,
}: MobileAnimalOverlayProps) {
const { i18n, t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const completedAutoHostingRounds = useGameAutoHostingStore(
(state) => state.completedRounds,
)
const currentRoundId = useGameRoundStore((state) => state.round.id)
const lastBetPeriodNo = useAuthStore(
(state) => state.currentUser?.lastBetPeriodNo,
)
const hostingFlag = useGameAutoHostingStore((state) => state.isHosting)
const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
const selections = useGameRoundStore((state) => state.selections)
const recentSuccessfulSelections = useGameRoundStore(
(state) => state.recentSuccessfulSelections,
)
const rewardType = useGameRoundStore(
(state) => state.revealAnimation.rewardType,
)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const rewardAmount = useGameRoundStore(
(state) => state.revealAnimation.rewardAmount,
)
const revealKey = useGameRoundStore(
(state) => state.revealAnimation.revealKey,
)
const roundId = useGameRoundStore((state) => state.revealAnimation.roundId)
const clearRewardAnimation = useGameRoundStore(
(state) => state.clearRewardAnimation,
)
const [isRewardFadingOut, setIsRewardFadingOut] = useState(false)
const [childrenStage, setChildrenStage] =
useState<RewardChildrenStage>('hidden')
const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
const [hasRenderedReward, setHasRenderedReward] = useState(false)
const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
? zhStopImage
: enStopImage
const stopBetItems = useMemo(
() =>
getStopBetItems(
selections.length > 0
? selections
: lastBetPeriodNo === currentRoundId
? recentSuccessfulSelections
: [],
),
[currentRoundId, lastBetPeriodNo, recentSuccessfulSelections, selections],
)
const rewardAmountMeta = useMemo(
() => getAmountMeta(rewardAmount),
[rewardAmount],
)
const rewardSource = useMemo(() => getRewardSource(rewardType), [rewardType])
const shouldRenderReward =
revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null
const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`
const childTimelineKey = shouldRenderReward ? overlayAnimationKey : 'closed'
useEffect(() => {
if (revealPhase !== 'result') {
setHasRenderedReward(false)
return
}
if (shouldRenderReward) {
setHasRenderedReward(true)
}
}, [revealPhase, shouldRenderReward])
useEffect(() => {
if (!shouldRenderReward) {
return
}
setIsRewardFadingOut(false)
const fadeTimerId = window.setTimeout(() => {
setIsRewardFadingOut(true)
}, REWARD_OVERLAY_DURATION_MS)
const clearTimerId = window.setTimeout(() => {
clearRewardAnimation()
}, REWARD_OVERLAY_DURATION_MS + REWARD_OVERLAY_FADE_OUT_MS)
return () => {
window.clearTimeout(fadeTimerId)
window.clearTimeout(clearTimerId)
}
}, [clearRewardAnimation, shouldRenderReward])
useEffect(() => {
if (childTimelineKey === 'closed') {
setChildrenStage('hidden')
setDisplayRewardAmount('0')
return
}
setChildrenStage('hidden')
setDisplayRewardAmount(
rewardAmountMeta
? formatRewardAmount(0, rewardAmountMeta.fractionDigits)
: (rewardAmount ?? '0'),
)
const enterFrameId = window.requestAnimationFrame(() => {
setChildrenStage('visible')
})
const exitTimerId = window.setTimeout(() => {
setChildrenStage('exiting')
}, REWARD_CHILDREN_FADE_IN_MS + REWARD_CHILDREN_VISIBLE_MS)
return () => {
window.cancelAnimationFrame(enterFrameId)
window.clearTimeout(exitTimerId)
}
}, [childTimelineKey, rewardAmount, rewardAmountMeta])
useEffect(() => {
if (childTimelineKey === 'closed') {
return
}
if (!rewardAmountMeta) {
setDisplayRewardAmount(rewardAmount ?? '0')
return
}
let animationFrameId = 0
const startedAt = performance.now()
const syncRewardAmount = (now: number) => {
const progress = Math.min(
(now - startedAt) / REWARD_CHILDREN_FADE_IN_MS,
1,
)
setDisplayRewardAmount(
progress >= 1
? formatRewardAmount(
rewardAmountMeta.numericAmount,
rewardAmountMeta.fractionDigits,
)
: formatRewardAmount(
rewardAmountMeta.numericAmount * easeOutCubic(progress),
rewardAmountMeta.fractionDigits,
),
)
if (progress < 1) {
animationFrameId = window.requestAnimationFrame(syncRewardAmount)
}
}
animationFrameId = window.requestAnimationFrame(syncRewardAmount)
return () => {
window.cancelAnimationFrame(animationFrameId)
}
}, [childTimelineKey, rewardAmount, rewardAmountMeta])
if (shouldRenderReward && rewardSource) {
const playerKey = `${overlayAnimationKey}-${rewardSource.id}`
return (
<div
className={cn(
'absolute inset-0 z-40 flex items-center justify-center overflow-hidden bg-[rgba(2,8,14,0.66)] px-design-6 backdrop-blur-[2px] transition-opacity duration-300',
isRewardFadingOut && 'pointer-events-none opacity-0',
)}
>
<div
aria-hidden="true"
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(49,208,255,0.12),rgba(3,7,15,0.88)_62%,rgba(2,4,10,0.96)_100%)]"
/>
<LottiePlayer
key={playerKey}
path={rewardSource.path}
renderer={rewardSource.renderer ?? 'svg'}
loop={rewardSource.loop ?? false}
autoplay={rewardSource.autoplay ?? true}
className="absolute left-1/2 top-[48%] h-design-236 w-[126%] -translate-x-1/2 -translate-y-1/2 [&>svg]:h-full [&>svg]:w-full [&>svg]:object-cover [&_canvas]:h-full [&_canvas]:w-full"
/>
<div
className={cn(
'pointer-events-none absolute inset-0 z-10 flex items-start justify-center pt-design-76 transition-[opacity,transform,filter] ease-out',
childrenStage === 'visible' || childrenStage === 'exiting'
? 'duration-[2000ms]'
: 'duration-0',
childrenStage === 'visible' &&
'translate-y-0 scale-100 opacity-75 blur-none',
childrenStage === 'hidden' &&
'translate-y-[calc(var(--design-unit)*8)] scale-[0.96] opacity-0 blur-[calc(var(--design-unit)*1)]',
childrenStage === 'exiting' &&
'translate-y-[calc(var(--design-unit)*-6)] scale-[0.97] opacity-0 blur-[calc(var(--design-unit)*1)]',
)}
>
<SmartBackground
className="flex h-design-72 w-design-318 items-center justify-center gap-design-7 pb-design-19"
src={winBg}
size="contain"
>
<SmartImage
className="h-design-21 w-design-86 drop-shadow-[0_0_calc(var(--design-unit)*7)_rgba(255,218,122,0.72)]"
alt="win"
src={winLogo}
/>
<div className="h-design-22 min-w-design-68 animate-bounce text-center font-sans text-design-24 leading-[calc(var(--design-unit)*22)] font-black tabular-nums text-[#FFE89A] [animation-duration:900ms] [-webkit-text-stroke:calc(var(--design-unit)*0.6)_#8A3A08] [text-shadow:0_0_calc(var(--design-unit)*5)_rgba(255,236,154,0.95),0_calc(var(--design-unit)*2)_0_#7A2F05,0_0_calc(var(--design-unit)*12)_rgba(255,151,15,0.72)]">
{displayRewardAmount}
</div>
</SmartBackground>
</div>
</div>
)
}
if (revealPhase === 'result' && hasRenderedReward) {
return (
<div
aria-hidden="true"
className="absolute inset-0 z-40 bg-[rgba(2,8,14,0.72)] backdrop-blur-[2px]"
/>
)
}
if (hostingFlag) {
return (
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center gap-design-10 bg-[rgba(2,8,14,0.38)] px-design-10 backdrop-blur-[1px]">
{showStopOverlay ? (
<div className="relative h-design-78 w-design-226 max-w-[80%] overflow-visible">
<SmartImage
src={stopImageSrc}
alt="stop betting"
priority
showSkeleton={false}
className="h-full w-full overflow-visible"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*14)_rgba(60,235,255,0.28)]"
/>
</div>
) : null}
<SmartBackground
src={hostingBg}
size="100% 100%"
repeat="no-repeat"
position="center"
className="flex h-design-150 w-design-322 flex-col items-center justify-center"
>
<div className="flex flex-col items-center gap-design-18">
<div className="flex items-center gap-design-9">
<motion.span
aria-hidden="true"
animate={prefersReducedMotion ? undefined : { rotate: 360 }}
transition={{
duration: 1.4,
ease: 'linear',
repeat: Number.POSITIVE_INFINITY,
}}
className="flex h-design-24 w-design-24 items-center justify-center"
>
<SmartImage
src={refreshIcon}
alt=""
priority
showSkeleton={false}
className="h-design-19 w-design-19"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*12)_rgba(60,235,255,0.36)]"
/>
</motion.span>
<div className="text-design-13 font-bold text-white [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(76,236,255,0.45)]">
{t('game.autoSpin.runningRounds', {
count: completedAutoHostingRounds,
})}
</div>
</div>
<SmartBackground
as="button"
type="button"
onClick={stopHosting}
src={hostingBtn}
size="100% 100%"
repeat="no-repeat"
position="center"
className="flex h-design-36 w-design-112 cursor-pointer items-center justify-center pb-design-2 text-design-13 font-bold text-[#EFFFFF] [text-shadow:0_1px_0_rgba(255,255,255,0.18),0_0_calc(var(--design-unit)*7)_rgba(46,220,255,0.5)] transition-transform hover:-translate-y-[1px] active:translate-y-0"
>
{t('game.actions.stopAuto')}
</SmartBackground>
</div>
</SmartBackground>
</div>
)
}
if (showStopOverlay) {
return (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-10 backdrop-blur-[2px]">
<div className="flex max-w-[92%] flex-col items-center justify-center gap-design-8">
<StopBetSummary
items={stopBetItems}
noBetText={t('gameDesktop.animal.noBet')}
/>
<SmartImage
src={stopImageSrc}
alt="stop betting"
priority
showSkeleton={false}
className="h-design-98 w-design-250 max-w-full overflow-visible"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*14)_rgba(60,235,255,0.28)]"
/>
</div>
</div>
)
}
return null
}

View File

@@ -0,0 +1,553 @@
import { TriangleAlert } from 'lucide-react'
import { motion, useReducedMotion } from 'motion/react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import animalBorderImage from '@/assets/game/animal-border.webp'
import diamondIcon from '@/assets/system/diamond.webp'
import { SmartImage } from '@/components/smart-image'
import { MobileAnimalOverlay } from '@/features/game/components/mobile/mobile-animal-overlay.tsx'
import { MobileStatusLine } from '@/features/game/components/mobile/mobile-status.tsx'
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
import { useAnimalVm } from '@/hooks/use-animal-vm'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore } from '@/store/game'
const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 3_200
const SETTLEMENT_REVEAL_SETTLE_DURATION_MS = 800
const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000
const SETTLEMENT_REVEAL_MIN_STEP_MS = 180
const SETTLEMENT_REVEAL_MAX_STEP_MS = 960
const MOBILE_REVEAL_FRAME_OFFSET_PX = 2
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
}
function getSettlementRevealStepDelay(progress: number) {
const clampedProgress = Math.min(Math.max(progress, 0), 1)
const easedProgress = clampedProgress ** 1.65
return (
SETTLEMENT_REVEAL_MIN_STEP_MS +
(SETTLEMENT_REVEAL_MAX_STEP_MS - SETTLEMENT_REVEAL_MIN_STEP_MS) *
easedProgress
)
}
interface MobileAnimalProps {
className?: string
itemClassName?: string
imageClassName?: string
onSelect?: (animalId: number) => void
}
export function MobileAnimal({
className,
itemClassName,
imageClassName,
onSelect,
}: MobileAnimalProps) {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const animalIds = useMemo(() => FLOWER_IMAGE_LIST.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 [isRevealHoldingResult, setIsRevealHoldingResult] = useState(false)
const [isRevealSettlingResult, setIsRevealSettlingResult] = useState(false)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
)
const revealRewardType = useGameRoundStore(
(state) => state.revealAnimation.rewardType,
)
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,
handleStart,
isRealtimeConnecting,
lockInteraction,
marqueeId,
selectionByCell,
showStandbyState,
} = useAnimalVm(animalIds, onSelect)
const isRevealRunning =
revealPhase === 'spinning' ||
(revealPhase === 'stopping' && !isRevealHoldingResult)
const isRevealResult = revealPhase === 'result'
const [hasRewardOverlayStarted, setHasRewardOverlayStarted] = useState(false)
const shouldHideRevealHighlight =
revealPhase === 'result' &&
(revealRewardType !== 'none' || hasRewardOverlayStarted)
const hasSubmittedCurrentRound =
roundPhase === 'betting' && Boolean(roundId) && lastBetPeriodNo === roundId
const showStopOverlay =
hasSubmittedCurrentRound ||
roundPhase === 'locked' ||
roundPhase === 'revealing'
useEffect(() => {
if (revealPhase === 'idle') {
setHasRewardOverlayStarted(false)
return
}
if (revealPhase === 'result' && revealRewardType !== 'none') {
setHasRewardOverlayStarted(true)
}
}, [revealPhase, revealRewardType])
useEffect(() => {
if (revealPhase === 'idle') {
setRevealCellId(null)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
if (revealPhase === 'result') {
setRevealCellId(shouldHideRevealHighlight ? null : revealWinningCellId)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
if (revealPhase === 'spinning') {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const intervalId = window.setInterval(() => {
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
}, 70)
return () => {
window.clearInterval(intervalId)
}
}
if (revealWinningCellId === null) {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
const startedAt = performance.now()
let timeoutId = 0
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
const step = () => {
const elapsedMs = performance.now() - startedAt
if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) {
setIsRevealSettlingResult(true)
setRevealCellId(revealWinningCellId)
timeoutId = window.setTimeout(() => {
setIsRevealSettlingResult(false)
setIsRevealHoldingResult(true)
timeoutId = window.setTimeout(() => {
finishRevealAnimation()
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
}, SETTLEMENT_REVEAL_SETTLE_DURATION_MS)
return
}
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const progress = elapsedMs / SETTLEMENT_REVEAL_RANDOM_DURATION_MS
const nextDelayMs = getSettlementRevealStepDelay(progress)
const remainingMs = SETTLEMENT_REVEAL_RANDOM_DURATION_MS - elapsedMs
timeoutId = window.setTimeout(step, Math.min(nextDelayMs, remainingMs))
}
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
timeoutId = window.setTimeout(step, SETTLEMENT_REVEAL_MIN_STEP_MS)
return () => {
window.clearTimeout(timeoutId)
}
}, [
animalIds,
finishRevealAnimation,
revealPhase,
revealWinningCellId,
shouldHideRevealHighlight,
])
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(
'common-neon-inset-glow relative flex w-full flex-col gap-design-5 overflow-hidden !rounded-[calc(var(--design-unit)*6)] !px-design-5 !py-design-5',
className,
)}
>
<MobileStatusLine />
<div className="relative z-10 grid w-full grid-cols-4 gap-design-3">
{FLOWER_IMAGE_LIST.map((item) => {
const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta)
const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner =
!shouldHideRevealHighlight &&
(isRevealResult || isRevealHoldingResult) &&
revealWinningCellId === item.id
const warningType =
cellWarning?.cellId === item.id ? cellWarning.type : null
const showCellWarning = warningType !== null
const warningLabel =
warningType === 'balance'
? t('gameDesktop.animal.insufficientBalanceRecharge')
: warningType === 'betLimit'
? t('gameDesktop.animal.betLimitExceeded')
: t('gameDesktop.animal.selectionLimitReached')
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 || showStopOverlay}
onClick={() => handleSelect(item.id)}
animate={
showCellWarning
? {
rotate: [0, -1.8, 1.4, -1, 0.6, 0],
scale: [1, 1.015, 0.992, 1.01, 1],
x: [0, -2, 2, -1, 1, 0],
}
: {
rotate: 0,
scale: 1,
x: 0,
}
}
transition={
showCellWarning
? {
duration: 0.46,
ease: 'easeInOut',
}
: { duration: 0.16, ease: 'easeOut' }
}
className={cn(
'relative flex h-design-36 flex-col items-center justify-center overflow-hidden rounded-[calc(var(--design-unit)*6)] 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)]',
isRevealRunning &&
'border-[rgba(104,255,249,0.76)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(68,244,255,0.46),0_0_calc(var(--design-unit)*22)_rgba(37,214,255,0.28),inset_0_0_calc(var(--design-unit)*16)_rgba(115,255,247,0.18)] brightness-115 saturate-125',
isRevealWinner &&
'border-[rgba(121,255,250,0.72)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(81,248,255,0.54),0_0_calc(var(--design-unit)*22)_rgba(30,199,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.24)] brightness-110 saturate-120',
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-6 top-design-4 z-30 text-design-10 font-bold leading-none text-[#4BFFFE]">
{String(item.id).padStart(2, '0')}
</span>
<motion.span
aria-hidden="true"
animate={
showCellWarning
? {
opacity: [0.45, 1, 0.6, 1, 0.82],
}
: undefined
}
transition={
showCellWarning
? {
duration: 0.52,
ease: 'easeInOut',
}
: undefined
}
className={cn(
'pointer-events-none absolute inset-[calc(var(--design-unit)*1)] rounded-[calc(var(--design-unit)*6)] 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)]',
isRevealRunning &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.36)_0%,rgba(77,244,255,0.16)_40%,rgba(27,183,255,0.07)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(95,249,255,0.46),inset_0_0_calc(var(--design-unit)*18)_rgba(151,255,250,0.24)]',
isRevealWinner &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.34)_0%,rgba(67,226,255,0.16)_38%,rgba(25,131,255,0.07)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(92,248,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(126,255,250,0.24)]',
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',
)}
/>
{!showStandbyState && !hasPlacedSelection ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[calc(var(--design-unit)*1)] z-20 rounded-[calc(var(--design-unit)*6)] bg-[rgba(4,16,24,0.52)] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(3,9,14,0.56)]"
/>
) : null}
<SmartImage
src={item.animalUrl}
alt={`animal-${item.id}`}
className={cn(
'absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*6)]',
isRevealRunning &&
'brightness-115 saturate-125 drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(101,250,255,0.42)]',
isRevealWinner &&
'brightness-110 saturate-120 drop-shadow-[0_0_calc(var(--design-unit)*9)_rgba(106,250,255,0.44)]',
imageClassName,
)}
imgClassName="object-fill"
/>
{showCellWarning ? (
<motion.span
initial={{ opacity: 0, scale: 0.94 }}
animate={{
opacity: [0.2, 1, 0.92],
scale: [0.96, 1.02, 1],
boxShadow: [
'inset 0 0 calc(var(--design-unit)*10) rgba(255,108,108,0.16), 0 0 calc(var(--design-unit)*8) rgba(255,60,60,0.12)',
'inset 0 0 calc(var(--design-unit)*20) rgba(255,108,108,0.28), 0 0 calc(var(--design-unit)*20) rgba(255,60,60,0.28)',
'inset 0 0 calc(var(--design-unit)*16) rgba(255,108,108,0.22), 0 0 calc(var(--design-unit)*18) rgba(255,60,60,0.2)',
],
}}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="pointer-events-none absolute inset-[calc(var(--design-unit)*2)] z-30 flex flex-col items-center justify-center gap-design-2 rounded-[calc(var(--design-unit)*6)] border border-[rgba(255,126,126,0.9)] bg-[rgba(61,0,0,0.58)] px-design-2 py-design-2 text-center"
>
<motion.span
animate={{
opacity: [0.72, 1, 0.92],
scale: [0.94, 1.08, 1],
}}
transition={{ duration: 0.38, ease: 'easeOut' }}
className="flex h-design-10 w-design-10 items-center justify-center"
>
<TriangleAlert className="h-design-10 w-design-10 text-[#FFD0D0] drop-shadow-[0_0_calc(var(--design-unit)*5)_rgba(255,92,92,0.45)]" />
</motion.span>
<motion.span
animate={{
opacity: [0.7, 1, 0.96],
scale: [0.98, 1.03, 1],
}}
transition={{ duration: 0.42, ease: 'easeOut' }}
className="text-design-6 font-bold leading-tight tracking-[0.02em] text-[#FFE0E0] [text-shadow:0_0_calc(var(--design-unit)*6)_rgba(255,132,132,0.42)]"
>
{warningLabel}
</motion.span>
</motion.span>
) : null}
{hasPlacedSelection ? (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="flex min-w-design-36 items-center justify-center gap-design-1 rounded-full border border-[rgba(162,242,255,0.48)] bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-3 py-design-1 shadow-[0_0_calc(var(--design-unit)*8)_rgba(70,245,255,0.18)]">
<SmartImage
src={diamondIcon}
alt="diamond"
className="h-design-9 w-design-9 shrink-0 object-contain"
/>
<span className="text-design-8 font-semibold leading-none tracking-[0.03em] text-[#D8FBFF]">
{selectionMeta.amount}
</span>
</span>
</span>
) : null}
</motion.button>
)
})}
</div>
{revealFrame ? (
<div
aria-hidden="true"
className={cn(
'pointer-events-none absolute left-0 top-0 z-40 transform-gpu transition-[height,transform,width]',
prefersReducedMotion
? 'duration-0'
: isRevealSettlingResult
? 'duration-[800ms] ease-[cubic-bezier(0.16,1,0.3,1)]'
: 'duration-[160ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
)}
style={{
height: revealFrame.height + MOBILE_REVEAL_FRAME_OFFSET_PX * 2,
transform: `translate(${revealFrame.left - MOBILE_REVEAL_FRAME_OFFSET_PX}px, ${revealFrame.top - MOBILE_REVEAL_FRAME_OFFSET_PX}px)`,
width: revealFrame.width + MOBILE_REVEAL_FRAME_OFFSET_PX * 2,
}}
>
<div
className="mobile-gold-reveal-glow rounded-[calc(var(--design-unit)*7)]"
style={
prefersReducedMotion
? {
animation: 'none',
opacity: 0.36,
transform: 'scale(1)',
}
: undefined
}
/>
<div className="mobile-gold-reveal-static-border rounded-[calc(var(--design-unit)*7)]" />
<div
className="mobile-gold-reveal-shell rounded-[calc(var(--design-unit)*7)]"
style={prefersReducedMotion ? { animation: 'none' } : undefined}
/>
</div>
) : null}
<MobileAnimalOverlay showStopOverlay={showStopOverlay} />
{showStandbyState ? (
<button
type="button"
onClick={handleStart}
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 min-w-design-176 flex-col items-center gap-design-7 rounded-[calc(var(--design-unit)*12)] 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-18 py-design-12 text-center shadow-[0_0_calc(var(--design-unit)*14)_rgba(70,245,255,0.38),0_0_calc(var(--design-unit)*28)_rgba(19,210,232,0.22),inset_0_0_calc(var(--design-unit)*14)_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)]">
<span className="pointer-events-none absolute inset-[1px] rounded-[calc(var(--design-unit)*12)] border border-[rgba(226,255,255,0.1)]" />
<span className="pointer-events-none absolute inset-x-design-10 top-design-7 h-design-10 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.16),rgba(255,255,255,0))] opacity-70" />
<span className="text-design-9 uppercase tracking-[0.28em] text-[rgba(132,255,248,0.72)]">
{isRealtimeConnecting
? t('gameDesktop.animal.loading')
: t('gameDesktop.animal.tapToEnter')}
</span>
<div className="flex items-center gap-design-7">
{isRealtimeConnecting ? (
<span className="relative flex h-design-16 w-design-16 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-5 w-design-5 rounded-full bg-[#BFFFFD] shadow-[0_0_calc(var(--design-unit)*7)_rgba(114,255,249,0.72)]"
/>
</span>
) : (
<span className="h-design-6 w-design-6 rounded-full bg-[rgba(126,255,248,0.92)] shadow-[0_0_calc(var(--design-unit)*7)_rgba(114,255,249,0.62)]" />
)}
<span className="text-design-17 font-semibold tracking-[0.12em] 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={`loading-dot-${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}
</section>
)
}

View File

@@ -0,0 +1,138 @@
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useGameRoundStore } from '@/store/game'
const BETTING_START_ALERT_DURATION_MS = 2000
export function MobileBettingStartAlert() {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const roundId = useGameRoundStore((state) => state.round.id)
const roundPhase = useGameRoundStore((state) => state.round.phase)
const lastShownRoundIdRef = useRef<string | null>(null)
const [visibleRoundId, setVisibleRoundId] = useState<string | null>(null)
useEffect(() => {
if (roundPhase !== 'betting' || !roundId) {
setVisibleRoundId(null)
return
}
if (lastShownRoundIdRef.current === roundId) {
return
}
lastShownRoundIdRef.current = roundId
setVisibleRoundId(roundId)
const timerId = window.setTimeout(() => {
setVisibleRoundId((currentRoundId) =>
currentRoundId === roundId ? null : currentRoundId,
)
}, BETTING_START_ALERT_DURATION_MS)
return () => {
window.clearTimeout(timerId)
}
}, [roundId, roundPhase])
return (
<AnimatePresence>
{visibleRoundId ? (
<motion.div
aria-live="polite"
className="pointer-events-none fixed left-1/2 top-[calc(var(--design-unit)*258)] z-50 w-[calc(100%-44*var(--design-unit))] max-w-[calc(var(--design-unit)*318)] -translate-x-1/2"
initial={
prefersReducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.9, y: 'calc(var(--design-unit)*18)' }
}
animate={
prefersReducedMotion
? { opacity: 1 }
: { opacity: 1, scale: 1, y: 0 }
}
exit={
prefersReducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.94, y: 'calc(var(--design-unit)*-8)' }
}
transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
>
<motion.div
className="relative overflow-hidden rounded-[calc(var(--design-unit)*9)] border border-[rgba(126,255,248,0.78)] bg-[linear-gradient(180deg,rgba(4,38,49,0.96),rgba(2,18,28,0.98))] px-design-16 py-design-12 text-center shadow-[0_0_calc(var(--design-unit)*14)_rgba(70,245,255,0.42),0_0_calc(var(--design-unit)*34)_rgba(18,206,232,0.26),inset_0_0_calc(var(--design-unit)*16)_rgba(126,255,248,0.18)]"
animate={
prefersReducedMotion
? undefined
: {
boxShadow: [
'0 0 calc(var(--design-unit)*10) rgba(70,245,255,0.34), 0 0 calc(var(--design-unit)*22) rgba(18,206,232,0.2), inset 0 0 calc(var(--design-unit)*12) rgba(126,255,248,0.14)',
'0 0 calc(var(--design-unit)*18) rgba(87,250,255,0.54), 0 0 calc(var(--design-unit)*38) rgba(25,223,255,0.34), inset 0 0 calc(var(--design-unit)*18) rgba(146,255,251,0.22)',
'0 0 calc(var(--design-unit)*12) rgba(70,245,255,0.38), 0 0 calc(var(--design-unit)*28) rgba(18,206,232,0.26), inset 0 0 calc(var(--design-unit)*14) rgba(126,255,248,0.16)',
],
}
}
transition={
prefersReducedMotion
? undefined
: { duration: 0.72, ease: 'easeInOut' }
}
>
<span className="pointer-events-none absolute inset-[1px] rounded-[calc(var(--design-unit)*9)] border border-[rgba(229,255,255,0.13)]" />
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-x-design-14 top-design-5 h-design-12 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.18),rgba(255,255,255,0))]"
animate={
prefersReducedMotion
? undefined
: { opacity: [0.5, 0.88, 0.58], x: ['-6%', '6%', '-2%'] }
}
transition={
prefersReducedMotion
? undefined
: { duration: 1.1, ease: 'easeInOut' }
}
/>
<div className="relative z-10 flex flex-col items-center gap-design-8">
<div className="text-design-11 font-semibold leading-tight text-[#78FF7F]">
{t('game.roundBettingStart.title', {
roundId: visibleRoundId,
})}
</div>
<motion.div
className="relative flex items-center justify-center px-design-8 text-design-24 font-bold leading-tight text-[#F2FFFF] [text-shadow:0_0_calc(var(--design-unit)*12)_rgba(92,249,255,0.66)]"
animate={
prefersReducedMotion
? undefined
: {
opacity: [0.88, 1, 0.92],
scale: [0.98, 1.06, 0.99],
textShadow: [
'0 0 calc(var(--design-unit)*8) rgba(92,249,255,0.52), 0 0 calc(var(--design-unit)*16) rgba(120,255,127,0.22)',
'0 0 calc(var(--design-unit)*14) rgba(111,255,255,0.9), 0 0 calc(var(--design-unit)*26) rgba(120,255,127,0.44)',
'0 0 calc(var(--design-unit)*10) rgba(92,249,255,0.62), 0 0 calc(var(--design-unit)*18) rgba(120,255,127,0.28)',
],
}
}
transition={
prefersReducedMotion
? undefined
: {
duration: 1.05,
ease: 'easeInOut',
repeat: Number.POSITIVE_INFINITY,
}
}
>
<span className="relative z-10">
{t('game.roundBettingStart.action')}
</span>
</motion.div>
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,550 @@
import { motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import reduce from '@/assets/game/add.webp'
import chipBg from '@/assets/game/chip-bg.webp'
import chipLineBg from '@/assets/game/chip-line-bg.webp'
import chipLock from '@/assets/game/chip-lock.webp'
import confirmRedBg from '@/assets/game/confirm-red-bg.png'
import controlBg from '@/assets/game/control-bg.png'
import leftBottomBg from '@/assets/game/left-bg.webp'
import mobileAddReduceBg from '@/assets/game/mobile-add-reduce-bg.webp'
import mobileConfirmBg from '@/assets/game/mobile-contro-comfirm.webp'
import mobileTotalBg from '@/assets/game/mobile-control-number.webp'
import add from '@/assets/game/reduce.webp'
import diamond from '@/assets/system/diamond.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { ACTION_OPTIONS } from '@/constants'
import { useGameControlVm } from '@/hooks/use-game-control-vm.ts'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'
export function MobileControl() {
const { t } = useTranslation()
const setModalOpen = useModalStore((state) => state.setModalOpen)
const {
acceptingBets,
actionsEnabled,
canClear,
canDecreaseBetQuantity,
chips,
confirmLabel,
confirmState,
isConfirmClickable,
maxSelectionCountLabel,
onChipSelect,
onConfirm,
onClearSelections,
onDecreaseBetQuantity,
onIncreaseBetQuantity,
onOpenAutoSetting,
onRepeatSelections,
selectedBetQuantityLabel,
selectedChipId,
selectedCountLabel,
totalBetAmountLabel,
} = useGameControlVm()
const [clickedId, setClickedId] = useState<string | null>(null)
const [hidingId, setHidingId] = useState<string | null>(null)
const [confirmClicked, setConfirmClicked] = useState(false)
const clickResetTimerRef = useRef<number | null>(null)
const hideResetTimerRef = useRef<number | null>(null)
const confirmResetTimerRef = useRef<number | null>(null)
const isConfirmWarning =
confirmState === 'insufficient' || confirmState === 'limit'
useEffect(() => {
return () => {
if (clickResetTimerRef.current !== null) {
window.clearTimeout(clickResetTimerRef.current)
}
if (hideResetTimerRef.current !== null) {
window.clearTimeout(hideResetTimerRef.current)
}
if (confirmResetTimerRef.current !== null) {
window.clearTimeout(confirmResetTimerRef.current)
}
}
}, [])
const handleChipClick = (chipId: string) => {
if (!acceptingBets) {
return
}
onChipSelect(chipId)
}
const handleActionClick = useCallback(
(id: string) => {
if (!actionsEnabled) {
return
}
if (id === 'clear' && canClear) {
onClearSelections()
}
if (id === 'repeat') {
onRepeatSelections()
}
if (id === 'auto-spin') {
onOpenAutoSetting()
}
setClickedId(id)
if (clickResetTimerRef.current !== null) {
window.clearTimeout(clickResetTimerRef.current)
}
clickResetTimerRef.current = window.setTimeout(() => {
setClickedId(null)
setHidingId(id)
if (hideResetTimerRef.current !== null) {
window.clearTimeout(hideResetTimerRef.current)
}
hideResetTimerRef.current = window.setTimeout(() => {
setHidingId(null)
}, 180)
}, 200)
},
[
actionsEnabled,
canClear,
onClearSelections,
onOpenAutoSetting,
onRepeatSelections,
],
)
const handleConfirmClick = useCallback(() => {
if (!isConfirmClickable) {
void onConfirm()
return
}
setConfirmClicked(true)
if (confirmResetTimerRef.current !== null) {
window.clearTimeout(confirmResetTimerRef.current)
}
confirmResetTimerRef.current = window.setTimeout(() => {
setConfirmClicked(false)
}, 200)
void onConfirm()
}, [isConfirmClickable, onConfirm])
return (
<div className="flex w-full flex-col gap-design-3 overflow-visible text-[#D5FBFF]">
<div className="flex h-design-42 w-full items-center gap-design-3">
<SmartBackground
src={chipBg}
size="100% 100%"
className="relative z-10 flex h-full min-w-0 flex-1 items-center bg-center bg-no-repeat px-design-8"
>
<SmartBackground
src={chipLineBg}
size="100% 100%"
className="flex h-design-32 min-w-0 flex-1 items-center justify-between gap-design-2 overflow-visible px-design-4"
>
{chips.map((chip) => {
const isSelected = chip.id === selectedChipId
const showLockedState = !acceptingBets
return (
<motion.button
key={chip.id}
layout
type="button"
onClick={() => handleChipClick(chip.id)}
disabled={showLockedState}
whileTap={showLockedState ? undefined : { scale: 0.94 }}
transition={{
layout: {
type: 'spring',
stiffness: 360,
damping: 26,
},
duration: 0.26,
}}
className="relative flex h-design-32 w-design-32 shrink-0 items-center justify-center rounded-full"
style={
showLockedState
? {
WebkitFilter: 'grayscale(100%)',
filter: 'grayscale(100%)',
}
: undefined
}
>
<motion.span
animate={
isSelected && !showLockedState
? {
opacity: [0.22, 0.5, 0.22],
scaleX: [0.82, 1.02, 0.82],
scaleY: [0.42, 0.56, 0.42],
}
: {
opacity: 0.32,
scaleX: 0.88,
scaleY: 0.48,
}
}
transition={
isSelected
? {
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}
: { duration: 0.2, ease: 'easeOut' }
}
className="pointer-events-none absolute bottom-[calc(var(--design-unit)*-2)] left-1/2 h-design-8 w-design-24 -translate-x-1/2 rounded-full bg-[rgba(0,0,0,0.48)] blur-[3px]"
/>
<motion.span
animate={
isSelected && !showLockedState
? {
opacity: [0.72, 1, 0.72],
scale: [0.96, 1.04, 0.96],
boxShadow: [
'0 0 0 1px rgba(245, 200, 107, 0.68), 0 0 6px rgba(245, 200, 107, 0.44), 0 0 12px rgba(245, 200, 107, 0.22), inset 0 0 6px rgba(255, 214, 110, 0.18)',
'0 0 0 1px rgba(245, 200, 107, 0.96), 0 0 10px rgba(245, 200, 107, 0.78), 0 0 18px rgba(245, 200, 107, 0.42), inset 0 0 8px rgba(255, 214, 110, 0.32)',
'0 0 0 1px rgba(245, 200, 107, 0.68), 0 0 6px rgba(245, 200, 107, 0.44), 0 0 12px rgba(245, 200, 107, 0.22), inset 0 0 6px rgba(255, 214, 110, 0.18)',
],
}
: {
opacity: 0,
scale: 0.92,
boxShadow: '0 0 0 0 rgba(0,0,0,0)',
}
}
transition={
isSelected
? {
duration: 1.6,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}
: { duration: 0.22, ease: 'easeOut' }
}
className="pointer-events-none absolute inset-0 rounded-full"
/>
<motion.div
layout
animate={
isSelected && !showLockedState
? {
y: [-1, -3, -1],
scale: [1.04, 1.1, 1.04],
filter: [
'drop-shadow(0 4px 6px rgba(0,0,0,0.22))',
'drop-shadow(0 7px 10px rgba(245, 200, 107, 0.28))',
'drop-shadow(0 4px 6px rgba(0,0,0,0.22))',
],
}
: {
y: 0,
scale: 1,
filter: showLockedState
? 'none'
: 'drop-shadow(0 3px 5px rgba(0,0,0,0.34))',
}
}
transition={{ type: 'spring', stiffness: 380, damping: 24 }}
className="relative z-10"
>
<motion.img
src={chip.src}
alt={`chip-${chip.amount}`}
draggable={false}
className="h-design-32 w-design-32 object-contain"
/>
{showLockedState && (
<img
src={chipLock}
alt="chip-locked"
draggable={false}
className="pointer-events-none absolute left-1/2 top-1/2 z-40 h-design-25 w-design-25 -translate-x-1/2 -translate-y-1/2 object-contain"
/>
)}
<span className="pointer-events-none absolute inset-x-0 top-1/2 z-30 -translate-y-1/2 text-center text-design-7 font-black leading-none tracking-[0.03em] text-white [text-shadow:0_1px_0_rgba(255,255,255,0.6),0_2px_3px_rgba(0,0,0,0.72),0_0_6px_rgba(255,255,255,0.22)]">
{chip.valueLabel}
</span>
</motion.div>
</motion.button>
)
})}
</SmartBackground>
</SmartBackground>
<SmartBackground
src={mobileAddReduceBg}
size="100% 100%"
className="relative z-20 -ml-design-6 flex h-full w-design-103 shrink-0 items-center justify-center gap-design-6 bg-center bg-no-repeat px-design-9 text-design-13 font-bold"
>
<button
type="button"
disabled={!acceptingBets || !canDecreaseBetQuantity}
onClick={onDecreaseBetQuantity}
className={cn(
'flex h-design-24 w-design-24 shrink-0 items-center justify-center',
acceptingBets && canDecreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage
src={add}
alt="decrease"
className="h-design-22 w-design-22"
/>
</button>
<div className="flex h-design-24 w-design-34 items-center justify-center rounded-sm bg-[#091118]">
{selectedBetQuantityLabel}
</div>
<button
type="button"
disabled={!acceptingBets}
onClick={onIncreaseBetQuantity}
className={cn(
'flex h-design-24 w-design-24 shrink-0 items-center justify-center',
acceptingBets
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage
src={reduce}
alt="increase"
className="h-design-22 w-design-22"
/>
</button>
</SmartBackground>
</div>
<div className="flex h-design-40 w-full items-center gap-design-3">
<SmartBackground
as="button"
type="button"
src={leftBottomBg}
size="100% 100%"
onClick={() => setModalOpen('desktopPeriodHistory', true)}
aria-label={t('gameDesktop.history.title')}
className="relative z-20 flex h-full w-design-60 shrink-0 items-center justify-center bg-center bg-no-repeat px-design-6 text-center !text-design-8 font-bold leading-[1.15] text-[#D5FBFF] transition-[filter] duration-200 hover:brightness-125 focus-visible:ring-2 focus-visible:ring-[#4FEAFF]"
>
{t('gameDesktop.history.title')}
</SmartBackground>
<SmartBackground
src={mobileTotalBg}
size="100% 100%"
className="relative z-10 flex h-full min-w-0 flex-[0.9] items-center justify-center gap-design-14 bg-center bg-no-repeat px-design-8 text-design-9 font-bold"
>
<div className="flex flex-col items-center gap-design-3 leading-none">
<div>
{t('gameDesktop.control.selected')}:
<span className="text-[#FF5A5A]">{selectedCountLabel}</span>/
{maxSelectionCountLabel}
</div>
<div className="flex items-center gap-design-4">
<span>{t('gameDesktop.control.totalBet')}:</span>
<SmartImage
className="h-design-13 w-design-13"
src={diamond}
alt="diamond"
/>
<span>{totalBetAmountLabel}</span>
</div>
</div>
</SmartBackground>
<SmartBackground
src={controlBg}
size="100% 100%"
className={cn(
'desktop-control-actions !ml-[calc(var(--design-unit)*-12)] relative z-20 flex h-full min-w-0 flex-1 items-center overflow-hidden bg-center bg-no-repeat pl-design-4',
)}
style={
!actionsEnabled
? {
WebkitFilter: 'grayscale(100%)',
filter: 'grayscale(100%)',
}
: undefined
}
>
{ACTION_OPTIONS.map(({ id, labelKey, Icon, bg }) => {
const isClicked = clickedId === id
const isHiding = hidingId === id
const showBg = actionsEnabled && (isClicked || isHiding)
return (
<motion.button
key={id}
type="button"
disabled={!actionsEnabled}
onClick={() => handleActionClick(id)}
whileHover={actionsEnabled ? { y: -1, scale: 1.01 } : undefined}
whileTap={actionsEnabled ? { scale: 0.96 } : undefined}
className={cn(
'relative flex h-full min-w-0 flex-1 items-center justify-center overflow-hidden',
actionsEnabled ? 'cursor-pointer' : 'cursor-not-allowed',
id === 'auto-spin' &&
'-translate-x-[calc(var(--design-unit)*1.5)]',
)}
>
{showBg && (
<SmartBackground
as={motion.span}
initial={{ opacity: 0 }}
animate={isClicked ? { opacity: 1 } : { opacity: 0 }}
transition={{
duration: isClicked ? 0.15 : 0.18,
ease: 'easeOut',
}}
src={bg}
className="pointer-events-none absolute inset-0 h-full bg-center bg-no-repeat"
size={
id === 'clear'
? '110% 90%'
: id === 'repeat'
? '98% 80%'
: '96% 80%'
}
/>
)}
<motion.div
animate={
showBg
? {
y: -1,
scale: 1.01,
}
: {
y: 0,
scale: 1,
}
}
transition={{
duration: showBg ? 0.22 : 0.18,
ease: 'easeOut',
}}
className="relative z-10 flex flex-col items-center justify-center"
>
<Icon
size={14}
strokeWidth={2.2}
className={showBg ? 'text-[#D9FEFF]' : 'text-[#37D5CB]'}
/>
<div className="mt-design-4 text-design-8 leading-none">
{t(labelKey)}
</div>
</motion.div>
</motion.button>
)
})}
</SmartBackground>
</div>
<SmartBackground
as={motion.button}
src={isConfirmWarning ? confirmRedBg : mobileConfirmBg}
size="100% 100%"
type="button"
onClick={handleConfirmClick}
whileHover={isConfirmClickable ? { scale: 1.005 } : undefined}
whileTap={isConfirmClickable ? { scale: 0.97 } : undefined}
animate={
confirmState === 'ready'
? {
scale: [1, 1.018, 1],
filter: [
'drop-shadow(0 0 0 rgba(0,0,0,0))',
'drop-shadow(0 0 12px rgba(245,200,107,0.72))',
'drop-shadow(0 0 0 rgba(0,0,0,0))',
],
}
: confirmState === 'idle'
? { scale: 1, filter: 'grayscale(.95)' }
: { scale: 1, filter: 'none' }
}
transition={
confirmState === 'ready'
? {
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}
: { duration: 0.2, ease: 'easeOut' }
}
style={
confirmState === 'idle'
? {
WebkitFilter: 'grayscale(.95)',
filter: 'grayscale(.95)',
}
: undefined
}
className={cn(
'relative z-10 flex h-design-39 w-full shrink-0 items-center justify-center overflow-hidden bg-center bg-no-repeat text-design-16 font-black tracking-[0.04em]',
isConfirmClickable ? 'cursor-pointer' : 'cursor-not-allowed',
)}
>
{confirmClicked && (
<SmartBackground
as={motion.span}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15 }}
className="pointer-events-none absolute inset-0 bg-center bg-no-repeat"
src={isConfirmWarning ? confirmRedBg : mobileConfirmBg}
size="100% 100%"
/>
)}
<motion.span
animate={
confirmState === 'ready'
? {
opacity: confirmClicked ? 0 : 1,
y: confirmClicked ? 2 : [0, -1, 0],
textShadow: [
'0 0 8px rgba(255,238,173,0.72), 0 0 16px rgba(255,214,96,0.4)',
'0 0 12px rgba(255,252,220,0.95), 0 0 24px rgba(255,214,96,0.8)',
'0 0 8px rgba(255,238,173,0.72), 0 0 16px rgba(255,214,96,0.4)',
],
}
: {
opacity: confirmClicked ? 0 : 1,
y: confirmClicked ? 2 : 0,
textShadow: isConfirmWarning
? '0 0 8px rgba(255,206,206,0.55)'
: '0 0 8px rgba(210,255,255,0.38)',
}
}
transition={
confirmState === 'ready'
? {
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}
: { duration: 0.15 }
}
className={cn('relative z-10', isConfirmWarning && 'text-[#FFF1F1]')}
>
{confirmLabel}
</motion.span>
</SmartBackground>
</div>
)
}

View File

@@ -0,0 +1,238 @@
import { History } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { SmartImage } from '@/components/smart-image'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
import { useGameHistoryVm } from '@/hooks/use-game-history-vm.ts'
function HistoryRewardNumber({
className,
number,
}: {
className?: string
number: number
}) {
const image = FLOWER_IMAGE_BY_ID[number]
const label = String(number).padStart(2, '0')
if (!image?.rewardUrl) {
return (
<span
className={`inline-flex h-design-24 min-w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(95,233,255,0.22)] bg-[rgba(6,28,39,0.7)] px-design-4 text-design-10 font-bold text-[#D5FBFF] align-middle ${className ?? ''}`}
>
{label}
</span>
)
}
return (
<span
className={`inline-flex h-design-24 w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(95,233,255,0.16)] bg-[rgba(6,28,39,0.38)] p-design-1 align-middle ${className ?? ''}`}
title={label}
>
<SmartImage
src={image.rewardUrl}
alt={label}
showSkeleton={false}
className="h-full w-full overflow-visible"
imgClassName="object-contain"
/>
</span>
)
}
function HistoryEmptyState({ label }: { label: string }) {
return (
<div className="flex min-h-full w-full flex-1 items-center justify-center px-design-8 py-design-10">
<div className="flex w-fit max-w-full flex-col items-center rounded-[calc(var(--design-unit)*8)] border border-[rgba(94,212,230,0.14)] bg-[rgba(5,23,33,0.3)] px-design-12 py-design-10 text-center">
<div className="mb-design-6 flex h-design-32 w-design-32 items-center justify-center rounded-full border border-[#56EFFF]/18 bg-[#061D29]/55 shadow-[0_0_calc(var(--design-unit)*8)_rgba(86,239,255,0.08)]">
<History
aria-hidden="true"
className="h-design-14 w-design-14 text-[#A8EAF1]"
strokeWidth={1.8}
/>
</div>
<div className="whitespace-nowrap text-design-11 font-semibold text-[#9CCFD4]">
{label}
</div>
</div>
</div>
)
}
export function MobileGameHistory() {
const { t } = useTranslation()
const {
emptyText,
endText,
fetchNextPage,
hasNextPage,
isEmpty,
isFetchingNextPage,
isInitialLoading,
items,
loadingText,
} = useGameHistoryVm()
const parentRef = useRef<HTMLDivElement | null>(null)
const handleScroll = useCallback(() => {
const element = parentRef.current
if (!element || !hasNextPage || isFetchingNextPage) {
return
}
const distanceToBottom =
element.scrollHeight - element.scrollTop - element.clientHeight
if (distanceToBottom <= 90) {
void fetchNextPage()
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
return (
<div
ref={parentRef}
onScroll={handleScroll}
className="history-scroll-hidden flex h-full min-h-0 w-full flex-col gap-design-5 overflow-y-auto overflow-x-hidden"
>
{isInitialLoading ? (
<DataLoadingIndicator
compact
label={loadingText}
className="min-h-full flex-1 text-design-11"
/>
) : isEmpty ? (
<HistoryEmptyState label={emptyText} />
) : (
<>
{items.map((item) => {
const isWin = item.resultState === 'win'
const statusLabel =
item.resultState === 'pending'
? t('gameDesktop.history.pending')
: isWin
? t('gameDesktop.history.win')
: t('gameDesktop.history.lost')
const statusColor =
item.resultState === 'pending'
? '#D5FBFF'
: isWin
? '#FFE375'
: '#8DFF98'
const statusBorderColor =
item.resultState === 'pending'
? 'rgba(143,241,255,0.34)'
: isWin
? 'rgba(255,227,117,0.5)'
: 'rgba(141,255,152,0.36)'
return (
<div key={item.id} className="w-full">
<div
className="relative isolate flex w-full flex-col overflow-hidden rounded-[calc(var(--design-unit)*7)] border bg-[linear-gradient(180deg,rgba(6,33,45,0.95),rgba(3,14,23,0.92))] shadow-[0_0_calc(var(--design-unit)*8)_rgba(63,226,255,0.08),inset_0_1px_0_rgba(218,255,255,0.08)]"
style={{
borderColor: statusBorderColor,
}}
>
<div
className="relative z-10 flex min-h-design-24 items-center justify-between gap-design-5 border-b px-design-7 py-design-4"
style={{
borderColor: statusBorderColor,
background:
item.resultState === 'pending'
? 'linear-gradient(180deg,rgba(28,106,126,0.38),rgba(5,25,36,0.9))'
: isWin
? 'linear-gradient(180deg,rgba(127,92,14,0.5),rgba(29,22,8,0.92))'
: 'linear-gradient(180deg,rgba(31,111,54,0.38),rgba(6,28,20,0.92))',
}}
>
<span className="min-w-0 truncate text-design-9 text-[#8DBCC2]">
{item.createdAtLabel}
</span>
<span
className="inline-flex min-w-design-56 items-center justify-center gap-design-4 text-design-11 font-bold"
style={{ color: statusColor }}
>
<span
aria-hidden="true"
className="h-design-4 w-design-4 rounded-full shadow-[0_0_calc(var(--design-unit)*5)_currentColor]"
style={{ backgroundColor: statusColor }}
/>
{statusLabel}
</span>
<span className="min-w-0 truncate text-right text-design-9 text-[#C0E7EB]">
{t('gameDesktop.history.roundId')}: {item.periodNo}
</span>
</div>
<div className="relative z-10 flex flex-col gap-design-4 px-design-7 py-design-6 text-design-10">
<div className="flex items-start justify-between gap-design-6 rounded-[calc(var(--design-unit)*5)] border border-[rgba(94,212,230,0.12)] bg-[rgba(5,23,33,0.52)] px-design-6 py-design-4">
<span className="shrink-0 pt-design-3 text-[#84A2A2]">
{t('gameDesktop.history.numbers')}
</span>
{item.numbers.length === 0 ? (
<span className="pt-design-3 text-right">
{item.numbersLabel}
</span>
) : (
<span className="inline-flex min-w-0 flex-1 flex-wrap items-center justify-end gap-design-2 align-middle">
{item.numbers.map((number) => (
<HistoryRewardNumber
key={`${item.id}-${number}`}
number={number}
/>
))}
</span>
)}
</div>
<div className="grid grid-cols-2 gap-design-4">
<div className="flex min-h-design-22 items-center justify-between gap-design-4 rounded-[calc(var(--design-unit)*5)] border border-[rgba(255,227,117,0.14)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(84,57,8,0.14))] px-design-6 py-design-4">
<span className="shrink-0 text-[#84A2A2]">
{t('gameDesktop.history.payout')}
</span>
<span className="min-w-0 truncate text-right text-design-11 font-bold text-[#FFE375]">
{item.winAmountLabel}
</span>
</div>
<div className="flex min-h-design-22 items-center justify-between gap-design-4 rounded-[calc(var(--design-unit)*5)] border border-[rgba(255,117,117,0.16)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(88,20,28,0.14))] px-design-6 py-design-4">
<span className="shrink-0 text-[#84A2A2]">
{t('gameDesktop.history.winningResult')}
</span>
<span className="flex min-w-0 items-center justify-end">
{item.resultNumber === null ? (
<span className="text-right font-bold text-[#FF7575]">
{item.resultNumberLabel}
</span>
) : (
<HistoryRewardNumber
className="text-[#FF7575]"
number={item.resultNumber}
/>
)}
</span>
</div>
</div>
</div>
</div>
</div>
)
})}
<div className="flex min-h-design-24 items-center justify-center text-design-10 text-[#84A2A2]">
{isFetchingNextPage ? (
<DataLoadingIndicator compact label={loadingText} />
) : hasNextPage ? (
''
) : (
endText
)}
</div>
</>
)}
</div>
)
}

View File

@@ -5,7 +5,6 @@ import {
UserRoundPlus,
Volume2,
VolumeX,
Wifi,
} from 'lucide-react'
import { motion } from 'motion/react'
import { useTranslation } from 'react-i18next'
@@ -14,6 +13,7 @@ import chatImage from '@/assets/system/chat.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { MessageBroadcast } from '@/features/game/components/desktop/desktop-title.tsx'
import { useHeaderClockLabel, useHeaderVm } from '@/hooks/use-header-vm'
import { useModalStore } from '@/store'
@@ -27,6 +27,43 @@ function MobileHeaderClock() {
)
}
function SignalBars({
activeBars,
toneClassName,
}: {
activeBars: number
toneClassName: string
}) {
const barHeights = [
'h-[calc(var(--design-unit)*4)]',
'h-[calc(var(--design-unit)*6)]',
'h-[calc(var(--design-unit)*8)]',
'h-[calc(var(--design-unit)*10)]',
] as const
return (
<div
className="flex h-design-11 w-design-16 items-end gap-[1px]"
aria-hidden="true"
>
{barHeights.map((heightClassName, index) => {
const isActive = index < activeBars
return (
<div
key={heightClassName}
className={[
'w-[calc(var(--design-unit)*2)] rounded-t-[1px] transition-colors',
heightClassName,
isActive ? `bg-current ${toneClassName}` : 'bg-white/18',
].join(' ')}
/>
)
})}
</div>
)
}
export function MobileHeader() {
const { t } = useTranslation()
const setModalOpen = useModalStore((state) => state.setModalOpen)
@@ -53,9 +90,9 @@ export function MobileHeader() {
'common-neon-inset flex h-design-19 items-center justify-end !rounded-[3px] !py-0 text-design-7 leading-none transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
return (
<header className="sticky top-0 z-30 h-design-62">
<div className="border-b-2 border-[#787553] bg-[#020B14] flex h-design-33 w-full items-center">
<div className="flex h-design-23 w-design-130 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.45)] px-design-10">
<header className="relative z-30 h-design-72">
<div className="border-b-2 border-[#787553] bg-[#020B14] flex h-design-33 w-full items-center px-design-10">
<div className="flex h-design-23 w-design-130 pr-design-10 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.45)]">
<SmartImage
src={logo}
alt="logo"
@@ -64,21 +101,6 @@ export function MobileHeader() {
imgClassName="object-contain"
/>
</div>
<button
type="button"
onClick={onOpenLanguage}
className={`${actionButtonClassName} !px-design-10 justify-between`}
>
<SmartImage
src={currentLanguageOption.icon}
alt={currentLanguageLabel}
className="h-design-14 w-design-14 shrink-0 rounded-full"
imgClassName="object-cover"
/>
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
</button>
{authStatus === 'authenticated' ? (
<div className="flex h-full min-w-0 flex-1 items-center justify-end gap-design-7 px-design-9">
<button
@@ -159,17 +181,32 @@ export function MobileHeader() {
</button>
</div>
)}
<motion.button
type="button"
onClick={() => setModalOpen('desktopSupport', true)}
whileTap={{
scale: 0.95,
}}
whileHover={{ scale: 1.05 }}
>
<SmartImage
className={'h-design-20 w-design-20 cursor-pointer'}
alt={'chatImage'}
src={chatImage}
/>
</motion.button>
</div>
<div className={'w-full px-design-10 '}>
<div className="flex h-design-29 w-full items-center gap-design-5 my-design-5 rounded-sm border-[#0A353E] border-1">
<div className="flex h-design-19 w-design-43 shrink-0 items-center justify-center gap-design-5 !rounded-[3px] !px-design-6 !py-0">
<Wifi
aria-hidden="true"
color="currentColor"
strokeWidth={2.4}
className={`${signalPresentation.toneClassName} h-design-10 w-design-10 shrink-0`}
/>
<div className="flex h-design-19 w-design-43 shrink-0 items-center justify-center !rounded-[3px] !px-design-6 !py-0">
<div className={signalPresentation.toneClassName}>
<SignalBars
activeBars={signalPresentation.activeBars}
toneClassName={signalPresentation.toneClassName}
/>
</div>
<div
className={`${signalPresentation.toneClassName} whitespace-nowrap text-design-7 font-bold leading-none`}
>
@@ -178,7 +215,7 @@ export function MobileHeader() {
</div>
</div>
<div className="flex h-full w-design-66 shrink-0 flex-col items-start justify-center border-r border-[rgba(128,223,231,0.32)] pr-design-7 text-design-7 leading-none">
<div className="flex flex-col h-full w-design-66 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.32)] pr-design-7 text-design-7 leading-none">
<div className="text-[#B4E4E9]">
{t('gameDesktop.header.systemTime')}
</div>
@@ -188,7 +225,7 @@ export function MobileHeader() {
<button
type="button"
onClick={onOpenRules}
className={`${actionButtonClassName} !px-design-10`}
className={`${actionButtonClassName} !px-design-8`}
>
<Info className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
<div className="min-w-0 truncate">
@@ -199,7 +236,7 @@ export function MobileHeader() {
<button
type="button"
onClick={onOpenNotice}
className={`${actionButtonClassName} !px-design-10`}
className={`${actionButtonClassName} !px-design-8`}
>
<Mail className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
<div className="min-w-0 truncate">
@@ -210,7 +247,7 @@ export function MobileHeader() {
<button
type="button"
onClick={toggleSoundEnabled}
className={`${actionButtonClassName} !px-design-10`}
className={`${actionButtonClassName} !px-design-8`}
>
{isSoundEnabled ? (
<Volume2
@@ -227,21 +264,19 @@ export function MobileHeader() {
{t('gameDesktop.header.bgm')}
</div>
</button>
<motion.button
<button
type="button"
onClick={() => setModalOpen('desktopSupport', true)}
whileTap={{
scale: 0.95,
}}
whileHover={{ scale: 1.05 }}
onClick={onOpenLanguage}
className={`${actionButtonClassName} !px-design-8 justify-between`}
>
<SmartImage
className={'h-design-20 w-design-20 cursor-pointer'}
alt={'chatImage'}
src={chatImage}
src={currentLanguageOption.icon}
alt={currentLanguageLabel}
className="h-design-14 w-design-14 shrink-0 rounded-full"
imgClassName="object-cover"
/>
</motion.button>
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
</button>
</div>
</div>
</header>

View File

@@ -0,0 +1,126 @@
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import type { PeriodHistoryDisplayItem } from '@/hooks/use-period-history-vm'
import { cn } from '@/lib/utils'
interface MobilePeriodHistoryListLabels {
empty: string
failed: string
loading: string
retry: string
}
interface MobilePeriodHistoryListProps {
isError: boolean
isLoading: boolean
items: PeriodHistoryDisplayItem[]
labels: MobilePeriodHistoryListLabels
onRetry: () => void
}
export function MobilePeriodHistoryList({
isError,
isLoading,
items,
labels,
onRetry,
}: MobilePeriodHistoryListProps) {
if (isLoading) {
return (
<DataLoadingIndicator
compact
label={labels.loading}
className="min-h-full flex-1 text-design-11"
/>
)
}
if (isError) {
return (
<div className="flex min-h-full flex-col items-center justify-center gap-design-10 px-design-8 text-design-11 text-[#8DBCC2]">
<span>{labels.failed}</span>
<button
type="button"
className="cursor-pointer rounded-[calc(var(--design-unit)*5)] border border-[rgba(85,236,255,0.38)] px-design-12 py-design-5 text-design-11 font-semibold text-[#D5FBFF] transition-colors duration-200 hover:bg-[rgba(85,236,255,0.12)]"
onClick={onRetry}
>
{labels.retry}
</button>
</div>
)
}
if (items.length === 0) {
return (
<div className="flex min-h-full items-center justify-center px-design-8 text-design-11 text-[#8DBCC2]">
{labels.empty}
</div>
)
}
return (
<div className="history-scroll-hidden grid h-full min-h-0 grid-cols-2 gap-design-4 overflow-y-auto overflow-x-hidden pr-design-2 content-start">
{items.map((item) => (
<div
key={`${item.periodNo}-${item.openTime}`}
className="relative flex h-design-72 min-w-0 flex-col overflow-hidden rounded-[calc(var(--design-unit)*6)] border border-[rgba(92,221,242,0.26)] bg-[linear-gradient(180deg,rgba(10,29,43,0.96),rgba(4,14,24,0.98))] shadow-[0_0_calc(var(--design-unit)*8)_rgba(53,212,255,0.06),inset_0_1px_0_rgba(225,255,255,0.06)]"
title={item.periodNo}
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-design-5 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(80,241,255,0.72),transparent)]"
/>
<div className="flex h-design-24 items-center border-b border-[rgba(92,221,242,0.14)] px-design-6">
<span className="min-w-0 truncate text-design-9 font-semibold tracking-[0.08em] text-[#D9FEFF]">
{item.displayPeriodNo}
</span>
</div>
<div className="grid min-h-0 flex-1 grid-cols-[auto_minmax(0,1fr)] items-center gap-design-5 px-design-6">
<span
className={cn(
'relative flex h-design-34 w-design-38 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] text-design-16 font-black leading-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-design-8 before:w-design-8 before:rounded-tl-[calc(var(--design-unit)*5)] before:border-l before:border-t after:pointer-events-none after:absolute after:right-0 after:top-0 after:h-design-8 after:w-design-8 after:rounded-tr-[calc(var(--design-unit)*5)] after:border-r after:border-t',
item.isOdd
? 'bg-[rgba(54,5,15,0.46)] text-[#FF526C] shadow-[0_0_calc(var(--design-unit)*8)_rgba(255,55,81,0.16),inset_0_0_calc(var(--design-unit)*8)_rgba(255,55,81,0.1)] before:border-[rgba(255,82,108,0.82)] after:border-[rgba(255,82,108,0.82)]'
: 'bg-[rgba(10,31,80,0.56)] text-[#63A7FF] shadow-[0_0_calc(var(--design-unit)*8)_rgba(69,137,255,0.16),inset_0_0_calc(var(--design-unit)*8)_rgba(69,137,255,0.1)] before:border-[rgba(99,167,255,0.82)] after:border-[rgba(99,167,255,0.82)]',
)}
>
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute bottom-0 left-0 h-design-8 w-design-8 rounded-bl-[calc(var(--design-unit)*5)] border-b border-l',
item.isOdd
? 'border-[rgba(255,82,108,0.82)]'
: 'border-[rgba(99,167,255,0.82)]',
)}
/>
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute bottom-0 right-0 h-design-8 w-design-8 rounded-br-[calc(var(--design-unit)*5)] border-b border-r',
item.isOdd
? 'border-[rgba(255,82,108,0.82)]'
: 'border-[rgba(99,167,255,0.82)]',
)}
/>
{item.displayResultNumber}
</span>
<span className="flex min-w-0 items-center justify-center overflow-hidden">
{item.image ? (
<img
src={item.image}
alt={item.displayResultNumber}
draggable={false}
className="h-design-34 w-auto max-w-full object-contain drop-shadow-[0_0_calc(var(--design-unit)*6)_rgba(99,243,255,0.12)]"
/>
) : (
<span className="text-design-9 text-[#668C92]">--</span>
)}
</span>
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,201 @@
import { motion, useReducedMotion } from 'motion/react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import streakBg from '@/assets/game/pc-streak.webp'
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 { 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 { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
import { cn } from '@/lib/utils.ts'
function isIosDevice() {
if (typeof navigator === 'undefined') {
return false
}
const userAgent = navigator.userAgent
const platform = navigator.platform
return (
/iPhone|iPad|iPod/.test(userAgent) ||
(platform === 'MacIntel' && navigator.maxTouchPoints > 1)
)
}
export function MobileStatusLine() {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const { countdownMs, phaseLabel, phaseToneClassName, roundId } =
useGameStatusVm()
const [remainingMs, setRemainingMs] = useState(countdownMs)
const showWarningCountdown = remainingMs <= 5000 && remainingMs > 0
const isIos = isIosDevice()
const shouldMountWarningLottie = !isIos || showWarningCountdown
const shouldAnimateStatusIndicator = !prefersReducedMotion && !isIos
const countdownClassName = useMemo(
() =>
showWarningCountdown
? 'text-design-28 scale-[1.1] text-[#FF5A5A] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(255,90,90,0.85),0_0_calc(var(--design-unit)*14)_rgba(255,90,90,0.32)]'
: 'text-design-28 scale-[1.1] text-[#4BFFFE] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(75,255,254,0.85),0_0_calc(var(--design-unit)*14)_rgba(75,255,254,0.32)]',
[showWarningCountdown],
)
return (
<div className="relative z-20 flex w-full flex-col overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(116,240,248,0.55)] bg-[linear-gradient(180deg,rgba(3,28,37,0.94),rgba(2,16,25,0.98))] text-design-8 leading-none shadow-[inset_0_0_calc(var(--design-unit)*11)_rgba(95,235,246,0.3)]">
<div className="relative flex h-design-48 w-full items-center justify-between overflow-visible px-design-11">
<div className="pointer-events-none absolute inset-x-design-4 top-design-3 h-design-12 rounded-full bg-[linear-gradient(180deg,rgba(217,255,255,0.14),rgba(217,255,255,0))]" />
<div className="relative z-10 flex w-design-86 items-center justify-center gap-design-4 text-center">
<span className="text-design-8 font-bold text-[#9FBAC0]">
{t('gameDesktop.status.roundId')}:
</span>
<span className="text-design-10 font-black text-[#E7FFFF] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(75,255,254,0.38)]">
{roundId}
</span>
</div>
<div className="relative z-20 flex h-design-46 w-design-126 items-center justify-center">
<SmartBackground
src={statusCenter}
className={cn(
'pointer-events-none absolute inset-0 bg-center bg-contain bg-no-repeat',
isIos ? '' : 'transition-opacity duration-500 ease-out',
)}
size="contain"
style={{
opacity: showWarningCountdown ? 0.18 : 1,
transform: showWarningCountdown ? 'scale(0.985)' : 'scale(1)',
}}
/>
{shouldMountWarningLottie ? (
<div
className={cn(
'pointer-events-none absolute inset-0 overflow-visible',
isIos ? '' : 'transition-all duration-500 ease-out',
)}
style={{
opacity: showWarningCountdown ? 1 : 0,
transform: showWarningCountdown
? 'scale(1.08) translateY(calc(var(--design-unit)*1))'
: 'scale(1.02) translateY(0)',
}}
>
<LottiePlayer
animationData={down5Animation}
className="h-full w-full"
loop
autoplay
/>
</div>
) : null}
<DesktopCountdown
initialMs={countdownMs}
onRemainingMsChange={setRemainingMs}
className={countdownClassName}
/>
</div>
<div className="relative z-10 flex w-design-86 items-center justify-center gap-design-4 text-center">
<span className="relative flex h-design-13 w-design-13 shrink-0 items-center justify-center">
<motion.span
aria-hidden="true"
className={cn(
'absolute h-design-13 w-design-13 rounded-full bg-[rgba(120,255,127,0.22)]',
isIos ? '' : 'blur-[calc(var(--design-unit)*2)]',
)}
animate={
shouldAnimateStatusIndicator
? { opacity: [0.36, 0.88, 0.42], scale: [0.9, 1.28, 0.98] }
: undefined
}
transition={
shouldAnimateStatusIndicator
? {
duration: 1.25,
ease: 'easeInOut',
repeat: Number.POSITIVE_INFINITY,
}
: undefined
}
/>
<motion.span
aria-hidden="true"
className="relative h-design-8 w-design-8 rounded-full bg-[#78FF7F] shadow-[0_0_calc(var(--design-unit)*6)_rgba(120,255,127,0.78)]"
animate={
shouldAnimateStatusIndicator
? { opacity: [0.78, 1, 0.86], scale: [0.96, 1.12, 1] }
: undefined
}
transition={
shouldAnimateStatusIndicator
? {
duration: 1.25,
ease: 'easeInOut',
repeat: Number.POSITIVE_INFINITY,
}
: undefined
}
/>
</span>
<span
className={cn(
phaseToneClassName,
'shrink-0 font-black [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(120,255,127,0.44)]',
)}
>
{phaseLabel}
</span>
</div>
</div>
</div>
)
}
export function MobileStatusMetrics() {
const { t } = useTranslation()
const { limitLabel, oddsLabel, streakLabel } = useGameStatusVm()
return (
<SmartBackground
src={statusLine}
size="100% 100%"
className="relative flex h-design-26 w-full items-center overflow-visible bg-center bg-no-repeat px-design-10 font-bold leading-none text-[#CBD3D5]"
style={{ fontSize: 'calc(var(--design-unit) * 7)' }}
>
<div
className="pointer-events-none absolute inset-y-design-1 left-0 w-[68%] bg-center bg-no-repeat opacity-80"
style={{
backgroundImage: `url(${streakBg})`,
backgroundSize: '118% 155%',
}}
/>
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center">
{t('gameDesktop.status.odds')}:
<span className="ml-design-3 text-[#E3D171]">{oddsLabel}</span>
</div>
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center gap-design-2">
<SmartImage className="h-design-11 w-design-9" alt="fire" src={fire} />
<span>{t('gameDesktop.status.streak')}:</span>
<span className="bg-gradient-to-b from-[#EBA661] to-[#FCC785] bg-clip-text text-transparent">
{streakLabel}
</span>
</div>
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center gap-design-2">
<SmartImage className="h-design-10 w-design-8" alt="lock" src={lock} />
<span>{t('gameDesktop.status.limit')}:</span>
<SmartImage
className="h-design-10 w-design-10"
alt="diamond"
src={diamond}
/>
<span>{limitLabel}</span>
</div>
</SmartBackground>
)
}

View File

@@ -179,7 +179,7 @@ export function RoundBettingStartAlert({
<div className="mt-design-8 flex items-center gap-design-7">
{[0, 1, 2, 3, 4].map((index) => (
<motion.span
key={index}
key={`betting-start-bar-${index}`}
className="h-design-5 w-design-22 rounded-full bg-[rgba(130,255,248,0.88)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(91,248,255,0.52)]"
animate={
prefersReducedMotion

View File

@@ -196,11 +196,20 @@ export function useAutoHostingRunner() {
const submitAutoBet = async () => {
try {
let latestBalance = currentUser.coin ?? '0'
let remainingBalance = parseBalance(latestBalance)
for (const group of groupedSelections.values()) {
const uniqueNumbers = [...new Set(group.numbers)].sort(
(left, right) => left - right,
)
const groupCost = group.amount * uniqueNumbers.length
if (groupCost > remainingBalance) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
return
}
const formattedSingleBetAmount = formatBetAmount(group.amount)
const result = await placeGameBet({
bet_amount: formattedSingleBetAmount,
@@ -216,6 +225,7 @@ export function useAutoHostingRunner() {
}
latestBalance = result.balance_after
remainingBalance = parseBalance(latestBalance)
}
const latestHostingState = useGameAutoHostingStore.getState()

View File

@@ -75,7 +75,7 @@ export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
const items = useMemo(
() =>
(query.data?.pages ?? []).flatMap((page) =>
page.list.map((item, index) => ({
(page.list ?? []).map((item, index) => ({
amountLabel: formatFinanceAmount(item.amount, locale),
bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale),
id: item.orderNo || `${page.pagination.page}-${index}`,

View File

@@ -168,6 +168,15 @@ export function useGameControlVm() {
return
}
const latestBalance = parseBalance(
useAuthStore.getState().currentUser?.coin,
)
if (totalBetAmount > latestBalance) {
notify.warning(t('commonUi.toast.insufficientBalance'))
return
}
const betId = toBetId(selections[0]?.chipId ?? activeChipId)
const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0
@@ -236,6 +245,7 @@ export function useGameControlVm() {
setCurrentUser,
setModalOpen,
t,
totalBetAmount,
])
const handleRepeatSelections = useCallback(() => {

View File

@@ -65,7 +65,7 @@ export function useGameHistoryVm() {
const items = useMemo(
() =>
(query.data?.pages ?? []).flatMap((page) =>
page.list.map((entry) => {
(page.list ?? []).map((entry) => {
const shouldHideResult =
entry.period_no === revealRoundId && revealPhase !== 'result'
const resultNumber = shouldHideResult ? null : entry.result_number

View File

@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
import { getGameLobbyInit, normalizePeriodTickRound } from '@/api'
import {
FALLBACK_POLL_INTERVAL_MS,
GAME_SOCKET_TOPIC_VALUES,
GAME_SOCKET_TOPICS,
PLAYER_SOCKET_TOPICS,
SOCKET_DISCONNECT_DELAY_MS,
@@ -13,378 +12,33 @@ import {
GameSocketClient,
type GameSocketMessage,
} from '@/lib/ws/game-socket-client'
import {
extractBetWinData,
extractJackpotHitData,
extractPeriodEventData,
extractPeriodTick,
extractServerTime,
extractUserStreakMessageData,
extractWalletChangedData,
getMessageTopic,
toIsoFromUnixSeconds,
toOptionalNumber,
} from '@/lib/ws/message-parsers'
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
import {
useGameAutoHostingStore,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
import type {
BetWinEventDataDto,
GamePeriodTickDto,
JackpotHitEventDataDto,
JackpotHitItemDto,
PeriodEventData,
UserStreakMessageData,
WalletChangedData,
} from '@/type'
let sharedSocketClient: GameSocketClient | null = null
let sharedSocketKey: string | null = null
let sharedSocketDisconnectTimerId: number | null = null
function toIsoFromUnixSeconds(seconds: number) {
return new Date(seconds * 1000).toISOString()
}
function toSocketLang(language: string | null | undefined) {
return language?.startsWith('zh') ? 'zh' : 'en'
}
function toOptionalNumber(value: unknown) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
return undefined
}
function toOptionalString(value: unknown) {
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return undefined
}
function toOptionalBoolean(value: unknown) {
if (typeof value === 'boolean') {
return value
}
if (value === 1 || value === '1' || value === 'true') {
return true
}
if (value === 0 || value === '0' || value === 'false') {
return false
}
return undefined
}
function getNestedRecord(
value: unknown,
key: string,
): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null
}
const nested = (value as Record<string, unknown>)[key]
return nested && typeof nested === 'object'
? (nested as Record<string, unknown>)
: null
}
function getMessageTopic(message: GameSocketMessage) {
const root = message as Record<string, unknown>
const event = typeof root.event === 'string' ? root.event : null
const topic = typeof root.topic === 'string' ? root.topic : null
if (event && GAME_SOCKET_TOPIC_VALUES.has(event)) {
return event
}
if (topic && GAME_SOCKET_TOPIC_VALUES.has(topic)) {
return topic
}
return event ?? topic
}
function extractServerTime(message: GameSocketMessage) {
const root = message as Record<string, unknown>
if (typeof root.server_time === 'number') {
return root.server_time
}
const data = getNestedRecord(message, 'data')
return typeof data?.server_time === 'number' ? data.server_time : null
}
function extractUserStreakMessageData(
message: GameSocketMessage,
): UserStreakMessageData | null {
const data = getNestedRecord(message, 'data')
const direct = getNestedRecord(message, 'user_snapshot')
const nested = getNestedRecord(data, 'user_snapshot')
const source =
data && 'current_streak' in data ? data : (nested ?? direct ?? data)
if (!source || typeof source.current_streak !== 'number') {
return null
}
return {
currentStreak: source.current_streak,
oddsFactor: toOptionalNumber(source.odds_factor),
streakLevel: toOptionalNumber(source.streak_level),
}
}
function extractPeriodTick(
message: GameSocketMessage,
): GamePeriodTickDto | null {
const data = getNestedRecord(message, 'data')
const nested = getNestedRecord(data, 'period')
const source = nested ?? data
if (
!source ||
typeof source.period_no !== 'string' ||
typeof source.status !== 'string' ||
typeof source.countdown !== 'number' ||
typeof source.bet_close_in !== 'number'
) {
return null
}
return {
bet_close_in: source.bet_close_in,
countdown: source.countdown,
period_id: typeof source.period_id === 'number' ? source.period_id : null,
period_no: source.period_no,
result_number:
typeof source.result_number === 'number' ? source.result_number : null,
runtime_enabled:
typeof source.runtime_enabled === 'boolean'
? source.runtime_enabled
: true,
server_time:
typeof source.server_time === 'number'
? source.server_time
: Math.floor(Date.now() / 1000),
status: source.status as GamePeriodTickDto['status'],
}
}
function extractPeriodEventData(
message: GameSocketMessage,
): PeriodEventData | null {
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
const periodNo =
typeof source.period_no === 'string'
? source.period_no
: typeof source.periodNo === 'string'
? source.periodNo
: null
if (!periodNo) {
return null
}
const resultNumber = toOptionalNumber(
source.result_number ?? source.resultNumber,
)
const openTime = toOptionalNumber(source.open_time ?? source.openTime)
return {
openTime: openTime ?? null,
periodNo,
resultNumber:
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
? resultNumber
: null,
}
}
function extractWalletChangedData(
message: GameSocketMessage,
): WalletChangedData | null {
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
const coin = source.coin ?? source.balance ?? source.balance_after
const normalizedCoin =
typeof coin === 'string'
? coin
: typeof coin === 'number' && Number.isFinite(coin)
? String(coin)
: null
if (normalizedCoin === null) {
return null
}
return {
coin: normalizedCoin,
}
}
function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null {
if (!value || typeof value !== 'object') {
return null
}
const source = value as Record<string, unknown>
if (
typeof source.nickname !== 'string' ||
typeof source.period_no !== 'string' ||
typeof source.total_win !== 'string'
) {
return null
}
const resultNumber = toOptionalNumber(source.result_number)
if (typeof resultNumber !== 'number' || !Number.isInteger(resultNumber)) {
return null
}
return {
nickname: source.nickname,
period_no: source.period_no,
result_number: resultNumber,
total_win: source.total_win,
}
}
function extractJackpotHitData(
message: GameSocketMessage,
): JackpotHitEventDataDto | null {
const data = getNestedRecord(message, 'data')
if (!data || typeof data.period_no !== 'string') {
return null
}
const nestedHits = data.hits
const sourceHits = Array.isArray(nestedHits)
? nestedHits
: nestedHits && typeof nestedHits === 'object'
? [nestedHits]
: [data]
const firstHitSource = sourceHits.find(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object',
)
const hits = sourceHits
.map((item) => extractJackpotHitItem(item))
.filter((item): item is JackpotHitItemDto => item !== null)
const root = message as Record<string, unknown>
const serverTime = toOptionalNumber(
data.server_time ?? firstHitSource?.server_time ?? root.server_time,
)
const resultNumber = toOptionalNumber(
data.result_number ?? data['result number'],
)
if (typeof serverTime !== 'number') {
return null
}
return {
hits,
period_id:
typeof data.period_id === 'number' && Number.isInteger(data.period_id)
? data.period_id
: null,
period_no: data.period_no,
result_number:
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
? resultNumber
: null,
server_time: serverTime,
}
}
function extractBetWinData(
message: GameSocketMessage,
): BetWinEventDataDto | null {
const data = getNestedRecord(message, 'data')
if (!data) {
return null
}
const root = message as Record<string, unknown>
const userId = toOptionalNumber(data.user_id)
const periodId = toOptionalNumber(data.period_id)
const resultNumber = toOptionalNumber(data.result_number)
const totalWin = toOptionalString(data.total_win)
const balanceAfter = toOptionalString(data.balance_after)
const serverTime = toOptionalNumber(data.server_time ?? root.server_time)
const currentStreak = toOptionalNumber(data.current_streak)
const streakLevel = toOptionalNumber(data.streak_level)
const oddsFactor = toOptionalNumber(data.odds_factor)
const isJackpot = toOptionalBoolean(data.is_jackpot)
if (
typeof totalWin !== 'string' ||
typeof data.period_no !== 'string' ||
typeof isJackpot !== 'boolean'
) {
return null
}
return {
balance_after: balanceAfter,
bets: Array.isArray(data.bets)
? data.bets
.map((item) => {
if (!item || typeof item !== 'object') {
return null
}
const bet = item as Record<string, unknown>
const betId = toOptionalNumber(bet.bet_id)
const winAmount = toOptionalString(bet.win_amount)
return typeof betId === 'number' && typeof winAmount === 'string'
? {
bet_id: betId,
win_amount: winAmount,
}
: null
})
.filter(
(item): item is BetWinEventDataDto['bets'][number] => item !== null,
)
: [],
current_streak: currentStreak,
is_jackpot: isJackpot,
is_win: toOptionalBoolean(data.is_win) ?? true,
odds_factor: typeof oddsFactor === 'number' ? oddsFactor : undefined,
payout_pending_review:
toOptionalBoolean(data.payout_pending_review) ?? false,
period_id: periodId,
period_no: data.period_no,
result_number:
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
? resultNumber
: null,
server_time: serverTime,
streak_level: typeof streakLevel === 'number' ? streakLevel : undefined,
total_win: totalWin,
user_id: userId,
}
}
function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
const currentRoundState = useGameRoundStore.getState()
const currentSessionState = useGameSessionStore.getState()
@@ -492,8 +146,6 @@ function applyPeriodOpenedMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
console.log('%c[period.opened 开奖数据]', 'color: red;', message)
applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message)
@@ -582,15 +234,12 @@ function applyWalletChangedMessage(message: GameSocketMessage) {
}
function applyJackpotHitMessage(message: GameSocketMessage) {
console.log('%c[jackpot.hit 数据]', 'color: red;', message)
const jackpotHitData = extractJackpotHitData(message)
if (jackpotHitData?.hits.length) {
useGameSessionStore.getState().pushJackpotBroadcasts(
jackpotHitData.hits.map((hit) => ({
id: `${jackpotHitData.period_no}:${hit.result_number}:${hit.nickname}:${hit.total_win}`,
message: `恭喜${hit.nickname} 用户中奖,获得${hit.total_win}`,
nickname: hit.nickname,
periodNo: hit.period_no,
totalWin: hit.total_win,

View File

@@ -1,4 +1,8 @@
import { LOGIN_PROMPT_DEDUP_MS } from '@/constants'
import {
APP_PREFERENCES_STORAGE_KEY,
AUDIO_PREFERENCES_STORAGE_KEY,
LOGIN_PROMPT_DEDUP_MS,
} from '@/constants'
import i18n from '@/i18n'
import { notify } from '@/lib/notify'
import { queryClient } from '@/lib/query/query-client'
@@ -19,9 +23,24 @@ let authInitializationPromise: Promise<void> | null = null
let refreshSessionPromise: Promise<boolean> | null = null
let lastLoginPromptAt = 0
const PRESERVED_LOCAL_STORAGE_KEYS = [
APP_PREFERENCES_STORAGE_KEY,
AUDIO_PREFERENCES_STORAGE_KEY,
]
function clearBrowserStorageData() {
if (typeof localStorage !== 'undefined') {
const preserved = PRESERVED_LOCAL_STORAGE_KEYS.map(
(key) => [key, localStorage.getItem(key)] as const,
)
localStorage.clear()
for (const [key, value] of preserved) {
if (value !== null) {
localStorage.setItem(key, value)
}
}
}
if (typeof sessionStorage !== 'undefined') {

View File

@@ -0,0 +1,359 @@
import { GAME_SOCKET_TOPIC_VALUES } from '@/constants'
import type { GameSocketMessage } from '@/lib/ws/game-socket-client'
import type {
BetWinEventDataDto,
GamePeriodTickDto,
JackpotHitEventDataDto,
JackpotHitItemDto,
PeriodEventData,
UserStreakMessageData,
WalletChangedData,
} from '@/type'
export function toIsoFromUnixSeconds(seconds: number) {
return new Date(seconds * 1000).toISOString()
}
export function toOptionalNumber(value: unknown) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
return undefined
}
export function toOptionalString(value: unknown) {
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return undefined
}
export function toOptionalBoolean(value: unknown) {
if (typeof value === 'boolean') {
return value
}
if (value === 1 || value === '1' || value === 'true') {
return true
}
if (value === 0 || value === '0' || value === 'false') {
return false
}
return undefined
}
function getNestedRecord(
value: unknown,
key: string,
): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null
}
const nested = (value as Record<string, unknown>)[key]
return nested && typeof nested === 'object'
? (nested as Record<string, unknown>)
: null
}
export function getMessageTopic(message: GameSocketMessage) {
const root = message as Record<string, unknown>
const event = typeof root.event === 'string' ? root.event : null
const topic = typeof root.topic === 'string' ? root.topic : null
if (event && GAME_SOCKET_TOPIC_VALUES.has(event)) {
return event
}
if (topic && GAME_SOCKET_TOPIC_VALUES.has(topic)) {
return topic
}
return event ?? topic
}
export function extractServerTime(message: GameSocketMessage) {
const root = message as Record<string, unknown>
if (typeof root.server_time === 'number') {
return root.server_time
}
const data = getNestedRecord(message, 'data')
return typeof data?.server_time === 'number' ? data.server_time : null
}
export function extractUserStreakMessageData(
message: GameSocketMessage,
): UserStreakMessageData | null {
const data = getNestedRecord(message, 'data')
const direct = getNestedRecord(message, 'user_snapshot')
const nested = getNestedRecord(data, 'user_snapshot')
const source =
data && 'current_streak' in data ? data : (nested ?? direct ?? data)
if (!source || typeof source.current_streak !== 'number') {
return null
}
return {
currentStreak: source.current_streak,
oddsFactor: toOptionalNumber(source.odds_factor),
streakLevel: toOptionalNumber(source.streak_level),
}
}
export function extractPeriodTick(
message: GameSocketMessage,
): GamePeriodTickDto | null {
const data = getNestedRecord(message, 'data')
const nested = getNestedRecord(data, 'period')
const source = nested ?? data
if (
!source ||
typeof source.period_no !== 'string' ||
typeof source.status !== 'string' ||
typeof source.countdown !== 'number' ||
typeof source.bet_close_in !== 'number'
) {
return null
}
return {
bet_close_in: source.bet_close_in,
countdown: source.countdown,
period_id: typeof source.period_id === 'number' ? source.period_id : null,
period_no: source.period_no,
result_number:
typeof source.result_number === 'number' ? source.result_number : null,
runtime_enabled:
typeof source.runtime_enabled === 'boolean'
? source.runtime_enabled
: true,
server_time:
typeof source.server_time === 'number'
? source.server_time
: Math.floor(Date.now() / 1000),
status: source.status as GamePeriodTickDto['status'],
}
}
export 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,
}
}
export function extractWalletChangedData(
message: GameSocketMessage,
): WalletChangedData | null {
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
const coin = source.coin ?? source.balance ?? source.balance_after
const normalizedCoin =
typeof coin === 'string'
? coin
: typeof coin === 'number' && Number.isFinite(coin)
? String(coin)
: null
if (normalizedCoin === null) {
return null
}
return {
coin: normalizedCoin,
}
}
function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null {
if (!value || typeof value !== 'object') {
return null
}
const source = value as Record<string, unknown>
if (
typeof source.nickname !== 'string' ||
typeof source.period_no !== 'string' ||
typeof source.total_win !== 'string'
) {
return null
}
const resultNumber = toOptionalNumber(source.result_number)
if (typeof resultNumber !== 'number' || !Number.isInteger(resultNumber)) {
return null
}
return {
nickname: source.nickname,
period_no: source.period_no,
result_number: resultNumber,
total_win: source.total_win,
}
}
export function extractJackpotHitData(
message: GameSocketMessage,
): JackpotHitEventDataDto | null {
const data = getNestedRecord(message, 'data')
if (!data || typeof data.period_no !== 'string') {
return null
}
const nestedHits = data.hits
const sourceHits = Array.isArray(nestedHits)
? nestedHits
: nestedHits && typeof nestedHits === 'object'
? [nestedHits]
: [data]
const firstHitSource = sourceHits.find(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object',
)
const hits = sourceHits
.map((item) => extractJackpotHitItem(item))
.filter((item): item is JackpotHitItemDto => item !== null)
const root = message as Record<string, unknown>
const serverTime = toOptionalNumber(
data.server_time ?? firstHitSource?.server_time ?? root.server_time,
)
const resultNumber = toOptionalNumber(
data.result_number ?? data['result number'],
)
if (typeof serverTime !== 'number') {
return null
}
return {
hits,
period_id:
typeof data.period_id === 'number' && Number.isInteger(data.period_id)
? data.period_id
: null,
period_no: data.period_no,
result_number:
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
? resultNumber
: null,
server_time: serverTime,
}
}
export function extractBetWinData(
message: GameSocketMessage,
): BetWinEventDataDto | null {
const data = getNestedRecord(message, 'data')
if (!data) {
return null
}
const root = message as Record<string, unknown>
const userId = toOptionalNumber(data.user_id)
const periodId = toOptionalNumber(data.period_id)
const resultNumber = toOptionalNumber(data.result_number)
const totalWin = toOptionalString(data.total_win)
const balanceAfter = toOptionalString(data.balance_after)
const serverTime = toOptionalNumber(data.server_time ?? root.server_time)
const currentStreak = toOptionalNumber(data.current_streak)
const streakLevel = toOptionalNumber(data.streak_level)
const oddsFactor = toOptionalNumber(data.odds_factor)
const isJackpot = toOptionalBoolean(data.is_jackpot)
if (
typeof totalWin !== 'string' ||
typeof data.period_no !== 'string' ||
typeof isJackpot !== 'boolean'
) {
return null
}
return {
balance_after: balanceAfter,
bets: Array.isArray(data.bets)
? data.bets
.map((item) => {
if (!item || typeof item !== 'object') {
return null
}
const bet = item as Record<string, unknown>
const betId = toOptionalNumber(bet.bet_id)
const winAmount = toOptionalString(bet.win_amount)
return typeof betId === 'number' && typeof winAmount === 'string'
? {
bet_id: betId,
win_amount: winAmount,
}
: null
})
.filter(
(item): item is BetWinEventDataDto['bets'][number] => item !== null,
)
: [],
current_streak: currentStreak,
is_jackpot: isJackpot,
is_win: toOptionalBoolean(data.is_win) ?? true,
odds_factor: typeof oddsFactor === 'number' ? oddsFactor : undefined,
payout_pending_review:
toOptionalBoolean(data.payout_pending_review) ?? false,
period_id: periodId,
period_no: data.period_no,
result_number:
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
? resultNumber
: null,
server_time: serverTime,
streak_level: typeof streakLevel === 'number' ? streakLevel : undefined,
total_win: totalWin,
user_id: userId,
}
}

View File

@@ -318,6 +318,14 @@ export default {
close: 'Close modal',
defaultAriaLabel: 'Modal',
},
boot: {
loading: 'Loading resources',
syncing: 'Syncing the flower deck and game interface',
},
support: {
title: 'Live Support',
connecting: 'Connecting to support',
},
toast: {
lobbyInitFailed: 'Failed to load the game lobby',
loginRequired: 'Please log in before entering the game',

View File

@@ -317,6 +317,14 @@ export default {
close: 'Tutup modal',
defaultAriaLabel: 'Modal',
},
boot: {
loading: 'Memuat sumber daya',
syncing: 'Menyinkronkan deck bunga dan antarmuka game',
},
support: {
title: 'Dukungan Langsung',
connecting: 'Menghubungkan ke dukungan',
},
toast: {
lobbyInitFailed: 'Gagal memuat lobby game',
loginRequired: 'Silakan masuk sebelum memasuki game',

View File

@@ -320,6 +320,14 @@ export default {
close: 'Tutup modal',
defaultAriaLabel: 'Modal',
},
boot: {
loading: 'Memuatkan sumber',
syncing: 'Menyegerakkan dek bunga dan antara muka permainan',
},
support: {
title: 'Sokongan Langsung',
connecting: 'Menyambung ke sokongan',
},
toast: {
lobbyInitFailed: 'Gagal memuatkan lobi permainan',
loginRequired: 'Sila log masuk sebelum memasuki permainan',

View File

@@ -312,6 +312,14 @@ export default {
close: '关闭弹窗',
defaultAriaLabel: '弹窗',
},
boot: {
loading: '资源加载中',
syncing: '正在同步字花图鉴与游戏界面',
},
support: {
title: '在线客服',
connecting: '客服连线中',
},
toast: {
lobbyInitFailed: '游戏大厅加载失败',
loginRequired: '请先登录后进入游戏',

View File

@@ -254,7 +254,9 @@ export function MainEntryPage() {
<section
aria-busy={isHydrating}
aria-label={t('game.lobbyTitle')}
className="flex min-h-0 flex-1 flex-col"
className={
isMobile ? 'flex w-full flex-col' : 'flex min-h-0 flex-1 flex-col'
}
>
{isMobile ? <MobileEntry /> : <PcEntry />}
{isMobile ? <MobileModalHost /> : <DesktopModalHost />}

View File

@@ -1,5 +1,9 @@
import { MessageBroadcast } from '@/features/game/components/desktop/desktop-title.tsx'
import { MobileAnimal } from '@/features/game/components/mobile/mobile-animal.tsx'
import { MobileBettingStartAlert } from '@/features/game/components/mobile/mobile-betting-start-alert.tsx'
import { MobileControl } from '@/features/game/components/mobile/mobile-control.tsx'
import { MobileHeader } from '@/features/game/components/mobile/mobile-header.tsx'
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
import { MobileStatusMetrics } from '@/features/game/components/mobile/mobile-status.tsx'
import { useAutoHostingRunner } from '@/hooks/use-auto-hosting-runner.ts'
export function MobileEntry() {
@@ -8,7 +12,18 @@ export function MobileEntry() {
return (
<>
<MobileHeader />
<RoundBettingStartAlert placement="fixed" />
<div className="w-full px-design-10 mb-design-5">
<MessageBroadcast />
</div>
<div className="mx-auto mb-design-5 w-[calc(100%-20*var(--design-unit))]">
<MobileStatusMetrics />
</div>
<main className="mx-auto flex w-[calc(100%-20*var(--design-unit))] flex-col gap-design-5 pb-design-8">
<MobileAnimal />
<MobileControl />
</main>
<MobileBettingStartAlert />
</>
)
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CenterModal } from '@/components/center-modal.tsx'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { useModalStore } from '@/store'
@@ -8,6 +9,7 @@ const SUPPORT_CHAT_URL =
const IFRAME_READY_DELAY_MS = 2_000
function DesktopSupportModal() {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(true)
const readyTimerRef = useRef<number | null>(null)
const open = useModalStore((state) => state.modals.desktopSupport)
@@ -49,14 +51,18 @@ function DesktopSupportModal() {
isNormalBg={true}
onClose={handleClose}
titleAlign="left"
title={<div className="modal-title-glow text-design-30">线</div>}
title={
<div className="modal-title-glow text-design-30">
{t('commonUi.support.title')}
</div>
}
className="h-design-760 w-design-980"
>
<div className="h-full px-design-24 pb-design-40 pt-design-10">
<div className="relative h-full overflow-hidden rounded-[calc(var(--design-unit)*14)] border border-[#2A6D73] bg-[linear-gradient(180deg,rgba(5,22,31,0.98),rgba(2,10,17,0.98))] shadow-[inset_0_0_calc(var(--design-unit)*22)_rgba(88,205,218,0.13),0_0_calc(var(--design-unit)*18)_rgba(31,156,174,0.14)]">
{isLoading ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
<DataLoadingIndicator label="客服连线中" />
<DataLoadingIndicator label={t('commonUi.support.connecting')} />
</div>
) : null}
<iframe

View File

@@ -112,32 +112,32 @@ function MobileAutoSettingModal() {
open={open}
onClose={handleClose}
title={
<div className={'modal-title-glow text-design-26 uppercase'}>
<div className={'modal-title-glow text-design-16'}>
{t('game.modals.autoSetting.title')}
</div>
}
isNormalBg={true}
titleAlign="left"
className="!h-[min(calc(var(--design-unit)*500),calc(100dvh-var(--design-unit)*28))]"
className="h-design-370"
>
<div
className={
'flex h-full w-full flex-col justify-between px-design-18 pt-design-30 pb-design-60'
'flex h-full w-full flex-col justify-between px-design-10 pt-design-10 pb-design-14'
}
>
<div className={'flex w-full flex-col gap-design-26'}>
<div className={'flex items-center justify-between gap-design-30'}>
<div
className={
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
}
>
<div className={'flex w-full flex-col gap-design-8'}>
<div
className={
'rounded-[calc(var(--design-unit)*8)] border border-[rgba(104,214,222,0.3)] bg-[rgba(4,30,38,0.7)] px-design-8 py-design-8 shadow-[inset_0_0_calc(var(--design-unit)*8)_rgba(33,193,219,0.08)]'
}
>
<div className={'text-design-12 leading-[1.35] text-[#9CF7FF]'}>
{t('game.modals.autoSetting.rows.stopIfBalanceLowerThan')}
</div>
<div
className={
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
'mt-design-6 flex items-center gap-design-6 rounded-[calc(var(--design-unit)*8)] border border-[rgba(111,235,245,0.16)] bg-[rgba(10,57,66,0.7)] px-design-8 py-design-6'
}
>
<Input
@@ -145,29 +145,30 @@ function MobileAutoSettingModal() {
inputMode="decimal"
onChange={(event) => setBalanceLimitValue(event.target.value)}
className={
'game-setting-input h-full w-design-280 text-design-18'
'game-setting-input h-design-32 flex-1 bg-transparent px-design-6 py-design-6 text-design-12'
}
/>
<Switch
size={'sm'}
className="scale-[0.82]"
checked={balanceLimitEnabled}
onCheckedChange={setBalanceLimitEnabled}
/>
</div>
</div>
<div className={'flex items-center justify-between gap-design-30'}>
<div
className={
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
}
>
<div
className={
'rounded-[calc(var(--design-unit)*8)] border border-[rgba(104,214,222,0.3)] bg-[rgba(4,30,38,0.7)] px-design-8 py-design-8 shadow-[inset_0_0_calc(var(--design-unit)*8)_rgba(33,193,219,0.08)]'
}
>
<div className={'text-design-12 leading-[1.35] text-[#9CF7FF]'}>
{t('game.modals.autoSetting.rows.stopIfSingleWinExceeds')}
</div>
<div
className={
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
'mt-design-6 flex items-center gap-design-6 rounded-[calc(var(--design-unit)*8)] border border-[rgba(111,235,245,0.16)] bg-[rgba(10,57,66,0.7)] px-design-8 py-design-6'
}
>
<Input
@@ -175,29 +176,30 @@ function MobileAutoSettingModal() {
inputMode="decimal"
onChange={(event) => setSingleWinLimitValue(event.target.value)}
className={
'game-setting-input h-full w-design-280 text-design-18'
'game-setting-input h-design-32 flex-1 bg-transparent px-design-6 py-design-6 text-design-12'
}
/>
<Switch
size={'sm'}
className="scale-[0.82]"
checked={singleWinLimitEnabled}
onCheckedChange={setSingleWinLimitEnabled}
/>
</div>
</div>
<div className={'flex items-center justify-between gap-design-30'}>
<div
className={
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
}
>
{t('game.modals.autoSetting.rows.stopOnAnyJackpot')}
</div>
<div className={'flex w-design-410 justify-end pr-design-2'}>
<div
className={
'rounded-[calc(var(--design-unit)*8)] border border-[rgba(104,214,222,0.3)] bg-[rgba(4,30,38,0.7)] px-design-8 py-design-8 shadow-[inset_0_0_calc(var(--design-unit)*8)_rgba(33,193,219,0.08)]'
}
>
<div className={'flex items-center justify-between gap-design-8'}>
<div className={'text-design-12 leading-[1.35] text-[#9CF7FF]'}>
{t('game.modals.autoSetting.rows.stopOnAnyJackpot')}
</div>
<Switch
size={'sm'}
className="scale-[0.82]"
checked={jackpotStopEnabled}
onCheckedChange={setJackpotStopEnabled}
/>
@@ -205,7 +207,7 @@ function MobileAutoSettingModal() {
</div>
</div>
<div className={'flex w-full justify-center'}>
<div className={'flex w-full justify-center pt-design-10'}>
<SmartBackground
as="button"
src={lengthBlueBtn}
@@ -215,7 +217,7 @@ function MobileAutoSettingModal() {
type="button"
onClick={handleSubmit}
className={
'w-design-300 h-design-72 pb-design-4 flex cursor-pointer items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF] transition-transform hover:-translate-y-[1px] active:translate-y-0'
'flex h-design-45 w-full max-w-[calc(var(--design-unit)*160)] cursor-pointer items-center justify-center pb-design-2 !text-design-12 font-bold mt-design-5 tracking-wide text-[#E7FBFF] transition-transform hover:-translate-y-[1px] active:translate-y-0'
}
>
{t('game.modals.autoSetting.startAutoSpin')}

View File

@@ -33,7 +33,7 @@ function MobileLanguageModal() {
}
isNormalBg={true}
titleAlign="left"
className="!h-design-350"
className="!h-design-330"
>
<div className="flex h-full min-h-0 flex-col px-design-14 pt-design-5">
<div className="w-full flex flex-wrap gap-design-10 overflow-y-auto">

View File

@@ -2,28 +2,34 @@ import { X } from 'lucide-react'
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PeriodHistoryList } from '@/features/game/components/shared/period-history-list'
import { MobileGameHistory } from '@/features/game/components/mobile/mobile-game-history.tsx'
import { MobilePeriodHistoryList } from '@/features/game/components/mobile/mobile-period-history-list'
import {
DEFAULT_PERIOD_HISTORY_LIMIT,
type PeriodHistoryDisplayItem,
usePeriodHistoryVm,
} from '@/hooks/use-period-history-vm'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'
const OVERLAY_EASE = [0.16, 1, 0.3, 1] as const
const DRAWER_TRANSITION = {
type: 'tween',
duration: 0.34,
duration: 0.3,
ease: OVERLAY_EASE,
} as const
type HistoryTab = 'period' | 'record'
interface PeriodHistoryDrawerLabels {
close: string
empty: string
failed: string
loading: string
recordTab: string
retry: string
title: string
periodTab: string
}
interface MobilePeriodHistoryDrawerViewProps {
@@ -59,6 +65,8 @@ export function MobilePeriodHistoryDrawer() {
empty: t('gameDesktop.periodHistory.empty'),
failed: t('gameDesktop.periodHistory.failed'),
loading: t('gameDesktop.periodHistory.loading'),
periodTab: t('gameDesktop.periodHistory.title'),
recordTab: t('gameDesktop.history.title'),
retry: t('gameDesktop.periodHistory.retry'),
title: t('gameDesktop.periodHistory.title'),
}}
@@ -78,104 +86,130 @@ export function MobilePeriodHistoryDrawerView({
open,
}: MobilePeriodHistoryDrawerViewProps) {
const prefersReducedMotion = useReducedMotion()
const [isDrawerAnimating, setIsDrawerAnimating] = useState(false)
const [activeTab, setActiveTab] = useState<HistoryTab>('period')
return (
<AnimatePresence>
{open && (
{open ? (
<>
<motion.button
type="button"
aria-label={labels.close}
className="fixed left-0 right-0 top-0 bottom-[calc(var(--design-unit)*150)] z-30 cursor-default bg-black/48"
className="fixed inset-0 z-30 cursor-default bg-black/58"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: prefersReducedMotion ? 0.12 : 0.26,
duration: prefersReducedMotion ? 0.12 : 0.22,
ease: OVERLAY_EASE,
}}
onClick={onClose}
/>
<motion.aside
role="dialog"
aria-modal="true"
aria-label={labels.title}
className="fixed left-0 top-design-16 bottom-[calc(var(--design-unit)*150)] z-40 flex w-design-1120 max-w-[calc(100vw-var(--design-unit)*24)] origin-left flex-col overflow-hidden rounded-r-[calc(var(--design-unit)*10)] border border-[rgba(81,230,255,0.62)] bg-[linear-gradient(180deg,rgba(6,19,32,0.98),rgba(3,12,22,0.96))] text-[#D5FBFF] shadow-[0_0_calc(var(--design-unit)*18)_rgba(39,216,255,0.28),0_0_calc(var(--design-unit)*54)_rgba(39,216,255,0.16),inset_0_0_calc(var(--design-unit)*18)_rgba(74,224,255,0.16)]"
className="fixed inset-x-0 bottom-0 z-40 flex h-[min(calc(var(--design-unit)*620),calc(100dvh-var(--design-unit)*12))] flex-col overflow-hidden rounded-t-[calc(var(--design-unit)*10)] border border-[rgba(81,230,255,0.62)] bg-[linear-gradient(180deg,rgba(6,19,32,0.98),rgba(3,12,22,0.96))] text-[#D5FBFF] shadow-[0_0_calc(var(--design-unit)*16)_rgba(39,216,255,0.22),0_0_calc(var(--design-unit)*40)_rgba(39,216,255,0.1),inset_0_0_calc(var(--design-unit)*14)_rgba(74,224,255,0.12)]"
initial={
prefersReducedMotion
? { opacity: 0 }
: { x: '-100%', opacity: 0.98 }
: { y: '100%', opacity: 0.98 }
}
animate={
prefersReducedMotion ? { opacity: 1 } : { x: 0, opacity: 1 }
prefersReducedMotion ? { opacity: 1 } : { y: 0, opacity: 1 }
}
exit={
prefersReducedMotion
? { opacity: 0 }
: { x: '-100%', opacity: 0.98 }
: { y: '100%', opacity: 0.98 }
}
transition={
prefersReducedMotion ? { duration: 0.12 } : DRAWER_TRANSITION
}
onAnimationStart={() => setIsDrawerAnimating(true)}
onAnimationComplete={() => setIsDrawerAnimating(false)}
style={
isDrawerAnimating
? { willChange: 'transform, opacity' }
: undefined
}
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-design-8 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(80,241,255,0.96),transparent)]"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-0 left-0 h-design-28 w-design-28 border-b-2 border-l-2 border-[#28E6FF]"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-0 right-0 h-design-28 w-design-28 border-b-2 border-r-2 border-[#28E6FF]"
/>
<div className="relative flex h-design-78 shrink-0 items-center justify-between border-b border-[rgba(80,224,255,0.38)] px-design-42">
<h2 className="text-design-28 font-bold leading-none text-white [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(156,244,255,0.42)]">
{labels.title}
</h2>
<div className="relative flex h-design-40 shrink-0 items-center justify-between gap-design-4 border-b border-[rgba(80,224,255,0.3)] px-design-8">
<div className="relative grid h-design-26 w-[calc(var(--design-unit)*170)] max-w-[calc(100%-var(--design-unit)*30)] shrink-0 grid-cols-2 overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[#2B8CA3]/24 bg-[#031B24]/68 p-[calc(var(--design-unit)*1.5)]">
{(
[
['period', labels.periodTab],
['record', labels.recordTab],
] as const
).map(([tab, label]) => {
const isActive = activeTab === tab
return (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
'relative flex min-w-0 cursor-pointer items-center justify-center overflow-hidden rounded-[calc(var(--design-unit)*5)] px-design-3 text-[calc(var(--design-unit)*8.5)] font-semibold transition-colors duration-200',
isActive
? 'text-[#FEEEB0]'
: 'text-[#58ADAF] hover:text-[#BFEAEC]',
)}
>
{isActive ? (
<motion.span
layoutId="mobile-period-history-tab-active-bg"
aria-hidden="true"
className="absolute inset-0 rounded-[calc(var(--design-unit)*5)] bg-[linear-gradient(180deg,rgba(254,238,176,0.28)_0%,rgba(254,238,176,0.1)_100%)]"
transition={{
type: 'spring',
stiffness: 430,
damping: 36,
}}
/>
) : null}
{isActive ? (
<motion.span
layoutId="mobile-period-history-tab-active-indicator"
aria-hidden="true"
className="absolute inset-x-design-5 bottom-0 h-[calc(var(--design-unit)*1.5)] rounded-full bg-[linear-gradient(90deg,rgba(255,248,214,0.14),rgba(254,238,176,0.9),rgba(255,248,214,0.14))] shadow-[0_0_calc(var(--design-unit)*5)_rgba(254,238,176,0.24)]"
transition={{
type: 'spring',
stiffness: 430,
damping: 36,
}}
/>
) : null}
<span className="min-w-0 truncate">{label}</span>
</button>
)
})}
</div>
<button
type="button"
aria-label={labels.close}
className="flex h-design-42 w-design-42 cursor-pointer items-center justify-center text-[#C8F7FF] transition-colors duration-200 hover:text-white focus-visible:ring-2 focus-visible:ring-[#4FEAFF]"
className="flex h-design-22 w-design-22 cursor-pointer items-center justify-center text-[#C8F7FF] transition-colors duration-200 hover:text-white"
onClick={onClose}
>
<X size={32} strokeWidth={2.1} />
<X size={14} strokeWidth={2.1} />
</button>
</div>
<motion.div
className="history-scroll-hidden min-h-0 flex-1 overflow-y-auto px-design-34 py-design-26"
initial={
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }
}
animate={
prefersReducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
}
transition={
prefersReducedMotion
? { duration: 0.12 }
: { duration: 0.22, delay: 0.08, ease: OVERLAY_EASE }
}
>
<PeriodHistoryList
items={items}
isLoading={isLoading}
isError={isError}
labels={labels}
onRetry={onRetry}
/>
</motion.div>
<div className="min-h-0 flex-1 px-design-8 py-design-8">
{activeTab === 'period' ? (
<MobilePeriodHistoryList
items={items}
isLoading={isLoading}
isError={isError}
labels={labels}
onRetry={onRetry}
/>
) : (
<MobileGameHistory />
)}
</div>
</motion.aside>
</>
)}
) : null}
</AnimatePresence>
)
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { useModalStore } from '@/store'
@@ -8,6 +9,7 @@ const SUPPORT_CHAT_URL =
const IFRAME_READY_DELAY_MS = 2_000
function MobileSupportModal() {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(true)
const readyTimerRef = useRef<number | null>(null)
const open = useModalStore((state) => state.modals.desktopSupport)
@@ -49,7 +51,11 @@ function MobileSupportModal() {
isNormalBg={true}
onClose={handleClose}
titleAlign="left"
title={<div className="modal-title-glow text-design-16">线</div>}
title={
<div className="modal-title-glow text-design-16">
{t('commonUi.support.title')}
</div>
}
className="h-design-500"
>
<div className="h-full min-h-0 px-design-8 pb-design-10 pt-design-4">
@@ -57,7 +63,7 @@ function MobileSupportModal() {
{isLoading ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
<DataLoadingIndicator
label="客服连线中"
label={t('commonUi.support.connecting')}
className="text-design-12"
/>
</div>

View File

@@ -416,11 +416,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
},
}
if (phase === 'settled') {
nextState.selections = []
}
if (phase === 'locked' || phase === 'revealing') {
if (phase === 'settled' || phase === 'locked' || phase === 'revealing') {
nextState.selections = []
}
@@ -488,11 +484,11 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
round: nextRound,
}
if (nextRound.phase === 'settled') {
nextState.selections = []
}
if (nextRound.phase === 'locked' || nextRound.phase === 'revealing') {
if (
nextRound.phase === 'settled' ||
nextRound.phase === 'locked' ||
nextRound.phase === 'revealing'
) {
nextState.selections = []
}

View File

@@ -636,6 +636,93 @@
pointer-events: none;
animation: gold-border-pulse 1.9s ease-in-out infinite;
}
.mobile-gold-reveal-shell {
--gold-angle: 0deg;
position: absolute;
inset: calc(var(--design-unit) * 0.2);
border-radius: calc(var(--design-unit) * 7);
overflow: hidden;
clip-path: inset(0 round calc(var(--design-unit) * 7));
pointer-events: none;
}
.mobile-gold-reveal-shell::before {
content: "";
position: absolute;
inset: 0;
border-radius: calc(var(--design-unit) * 7);
padding: calc(var(--design-unit) * 2.5);
background: conic-gradient(
from var(--gold-angle),
#534217 10%,
#534217 20%,
#ffe226 45%,
#534217 60%,
#534217 85%,
#ffe226 95%,
#534217 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: rotating-gold-border 8s linear infinite;
}
.mobile-gold-reveal-shell::after {
content: "";
position: absolute;
inset: calc(var(--design-unit) * 0.5);
border-radius: calc(var(--design-unit) * 6.5);
padding: calc(var(--design-unit) * 1.7);
background: conic-gradient(
from calc(var(--gold-angle) + 180deg),
rgba(255, 247, 210, 0) 0%,
rgba(255, 247, 210, 0) 66%,
rgba(255, 247, 210, 0.14) 71%,
rgba(255, 254, 244, 0.98) 75%,
rgba(255, 230, 130, 0.96) 79%,
rgba(255, 247, 210, 0.18) 84%,
rgba(255, 247, 210, 0) 90%,
rgba(255, 247, 210, 0) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: rotating-gold-border 2.1s linear infinite;
}
.mobile-gold-reveal-static-border {
position: absolute;
inset: calc(var(--design-unit) * 0.7);
border-radius: calc(var(--design-unit) * 7);
border: calc(var(--design-unit) * 2.8) solid rgba(181, 138, 40, 0.98);
box-shadow:
inset 0 0 calc(var(--design-unit) * 7) rgba(255, 241, 181, 0.38),
0 0 calc(var(--design-unit) * 9) rgba(255, 210, 102, 0.32);
pointer-events: none;
}
.mobile-gold-reveal-glow {
position: absolute;
inset: 0;
border-radius: calc(var(--design-unit) * 8);
background: radial-gradient(
circle at center,
rgba(255, 226, 92, 0.2) 0%,
rgba(219, 161, 42, 0.16) 42%,
rgba(127, 86, 13, 0.08) 62%,
transparent 82%
);
filter: blur(calc(var(--design-unit) * 1.4));
opacity: 0.42;
pointer-events: none;
animation: gold-border-pulse 1.9s ease-in-out infinite;
}
}
@theme inline {

View File

@@ -194,7 +194,6 @@ export interface AutoHostingStopRules {
export interface JackpotBroadcastItem {
id: string
message: string
nickname: string
periodNo: string
receivedAt: string