refactor(game): 优化游戏组件并实现国际化支持
- 在AppBootResourceGate组件中集成react-i18next实现资源加载文本的国际化 - 修改DesktopAnimal组件中的loading dots key以提高渲染性能 - 在DesktopControl组件中添加useRef和useEffect钩子管理定时器清理逻辑 - 将DesktopTitle组件重构为MessageBroadcast组件并增强其响应式设计 - 更新DesktopSupportModal组件中的客户服务文本为国际化格式 - 在AuthSession模块中实现本地存储数据清理时保留关键偏好设置 - 调整多个游戏组件的样式类以改进移动端适配效果 - 移除未使用的桌面提取功能相关代码文件 - 更新GitNexus索引统计数据反映最新的代码变更
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 601 KiB |
BIN
src/assets/game/mobile-add-reduce-bg.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
src/assets/game/mobile-confirm-red-bg.webp
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
src/assets/game/mobile-contro-comfirm.webp
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
src/assets/game/mobile-control-actions-bg.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src/assets/game/mobile-control-number.webp
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
src/assets/system/mobile-add-reduce-bg.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
src/assets/system/mobile-contro-comfirm.webp
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
src/assets/system/mobile-control-number.webp
Normal file
|
After Width: | Height: | Size: 149 KiB |
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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] }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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
|
||||
490
src/features/game/components/mobile/mobile-animal-overlay.tsx
Normal 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
|
||||
}
|
||||
553
src/features/game/components/mobile/mobile-animal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
550
src/features/game/components/mobile/mobile-control.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
238
src/features/game/components/mobile/mobile-game-history.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,74 +181,6 @@ export function MobileHeader() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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={`${signalPresentation.toneClassName} whitespace-nowrap text-design-7 font-bold leading-none`}
|
||||
>
|
||||
{signalPresentation.latencyLabel}
|
||||
<span className="pl-[1px] text-design-6">ms</span>
|
||||
</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="text-[#B4E4E9]">
|
||||
{t('gameDesktop.header.systemTime')}
|
||||
</div>
|
||||
<MobileHeaderClock />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenRules}
|
||||
className={`${actionButtonClassName} !px-design-10`}
|
||||
>
|
||||
<Info className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
|
||||
<div className="min-w-0 truncate">
|
||||
{t('gameDesktop.header.rules')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenNotice}
|
||||
className={`${actionButtonClassName} !px-design-10`}
|
||||
>
|
||||
<Mail className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
|
||||
<div className="min-w-0 truncate">
|
||||
{t('gameDesktop.header.message')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSoundEnabled}
|
||||
className={`${actionButtonClassName} !px-design-10`}
|
||||
>
|
||||
{isSoundEnabled ? (
|
||||
<Volume2
|
||||
className="h-design-8 w-design-8 shrink-0"
|
||||
color="#57B8BF"
|
||||
/>
|
||||
) : (
|
||||
<VolumeX
|
||||
className="h-design-8 w-design-8 shrink-0"
|
||||
color="#57B8BF"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 truncate">
|
||||
{t('gameDesktop.header.bgm')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
@@ -243,6 +197,87 @@ export function MobileHeader() {
|
||||
/>
|
||||
</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 !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`}
|
||||
>
|
||||
{signalPresentation.latencyLabel}
|
||||
<span className="pl-[1px] text-design-6">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<MobileHeaderClock />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenRules}
|
||||
className={`${actionButtonClassName} !px-design-8`}
|
||||
>
|
||||
<Info className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
|
||||
<div className="min-w-0 truncate">
|
||||
{t('gameDesktop.header.rules')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenNotice}
|
||||
className={`${actionButtonClassName} !px-design-8`}
|
||||
>
|
||||
<Mail className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
|
||||
<div className="min-w-0 truncate">
|
||||
{t('gameDesktop.header.message')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSoundEnabled}
|
||||
className={`${actionButtonClassName} !px-design-8`}
|
||||
>
|
||||
{isSoundEnabled ? (
|
||||
<Volume2
|
||||
className="h-design-8 w-design-8 shrink-0"
|
||||
color="#57B8BF"
|
||||
/>
|
||||
) : (
|
||||
<VolumeX
|
||||
className="h-design-8 w-design-8 shrink-0"
|
||||
color="#57B8BF"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 truncate">
|
||||
{t('gameDesktop.header.bgm')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLanguage}
|
||||
className={`${actionButtonClassName} !px-design-8 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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
201
src/features/game/components/mobile/mobile-status.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
359
src/lib/ws/message-parsers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -312,6 +312,14 @@ export default {
|
||||
close: '关闭弹窗',
|
||||
defaultAriaLabel: '弹窗',
|
||||
},
|
||||
boot: {
|
||||
loading: '资源加载中',
|
||||
syncing: '正在同步字花图鉴与游戏界面',
|
||||
},
|
||||
support: {
|
||||
title: '在线客服',
|
||||
connecting: '客服连线中',
|
||||
},
|
||||
toast: {
|
||||
lobbyInitFailed: '游戏大厅加载失败',
|
||||
loginRequired: '请先登录后进入游戏',
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={'flex w-full flex-col gap-design-8'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
'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]'
|
||||
'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]'
|
||||
'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>
|
||||
|
||||
<div className={'flex w-design-410 justify-end pr-design-2'}>
|
||||
<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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
<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="pointer-events-none absolute bottom-0 left-0 h-design-28 w-design-28 border-b-2 border-l-2 border-[#28E6FF]"
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
) : null}
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="mobile-period-history-tab-active-indicator"
|
||||
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]"
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
) : 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
|
||||
|
||||
<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}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<MobileGameHistory />
|
||||
)}
|
||||
</div>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = []
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -194,7 +194,6 @@ export interface AutoHostingStopRules {
|
||||
|
||||
export interface JackpotBroadcastItem {
|
||||
id: string
|
||||
message: string
|
||||
nickname: string
|
||||
periodNo: string
|
||||
receivedAt: string
|
||||
|
||||