refactor(game): 优化游戏组件并实现国际化支持
- 在AppBootResourceGate组件中集成react-i18next实现资源加载文本的国际化 - 修改DesktopAnimal组件中的loading dots key以提高渲染性能 - 在DesktopControl组件中添加useRef和useEffect钩子管理定时器清理逻辑 - 将DesktopTitle组件重构为MessageBroadcast组件并增强其响应式设计 - 更新DesktopSupportModal组件中的客户服务文本为国际化格式 - 在AuthSession模块中实现本地存储数据清理时保留关键偏好设置 - 调整多个游戏组件的样式类以改进移动端适配效果 - 移除未使用的桌面提取功能相关代码文件 - 更新GitNexus索引统计数据反映最新的代码变更
This commit is contained in:
@@ -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
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
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
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
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,17 +181,32 @@ export function MobileHeader() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => setModalOpen('desktopSupport', true)}
|
||||
whileTap={{
|
||||
scale: 0.95,
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<SmartImage
|
||||
className={'h-design-20 w-design-20 cursor-pointer'}
|
||||
alt={'chatImage'}
|
||||
src={chatImage}
|
||||
/>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className={'w-full px-design-10 '}>
|
||||
<div className="flex h-design-29 w-full items-center gap-design-5 my-design-5 rounded-sm border-[#0A353E] border-1">
|
||||
<div className="flex h-design-19 w-design-43 shrink-0 items-center justify-center gap-design-5 !rounded-[3px] !px-design-6 !py-0">
|
||||
<Wifi
|
||||
aria-hidden="true"
|
||||
color="currentColor"
|
||||
strokeWidth={2.4}
|
||||
className={`${signalPresentation.toneClassName} h-design-10 w-design-10 shrink-0`}
|
||||
/>
|
||||
<div className="flex h-design-19 w-design-43 shrink-0 items-center justify-center !rounded-[3px] !px-design-6 !py-0">
|
||||
<div className={signalPresentation.toneClassName}>
|
||||
<SignalBars
|
||||
activeBars={signalPresentation.activeBars}
|
||||
toneClassName={signalPresentation.toneClassName}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`${signalPresentation.toneClassName} whitespace-nowrap text-design-7 font-bold leading-none`}
|
||||
>
|
||||
@@ -178,7 +215,7 @@ export function MobileHeader() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-design-66 shrink-0 flex-col items-start justify-center border-r border-[rgba(128,223,231,0.32)] pr-design-7 text-design-7 leading-none">
|
||||
<div className="flex flex-col h-full w-design-66 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.32)] pr-design-7 text-design-7 leading-none">
|
||||
<div className="text-[#B4E4E9]">
|
||||
{t('gameDesktop.header.systemTime')}
|
||||
</div>
|
||||
@@ -188,7 +225,7 @@ export function MobileHeader() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenRules}
|
||||
className={`${actionButtonClassName} !px-design-10`}
|
||||
className={`${actionButtonClassName} !px-design-8`}
|
||||
>
|
||||
<Info className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
|
||||
<div className="min-w-0 truncate">
|
||||
@@ -199,7 +236,7 @@ export function MobileHeader() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenNotice}
|
||||
className={`${actionButtonClassName} !px-design-10`}
|
||||
className={`${actionButtonClassName} !px-design-8`}
|
||||
>
|
||||
<Mail className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
|
||||
<div className="min-w-0 truncate">
|
||||
@@ -210,7 +247,7 @@ export function MobileHeader() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSoundEnabled}
|
||||
className={`${actionButtonClassName} !px-design-10`}
|
||||
className={`${actionButtonClassName} !px-design-8`}
|
||||
>
|
||||
{isSoundEnabled ? (
|
||||
<Volume2
|
||||
@@ -227,21 +264,19 @@ export function MobileHeader() {
|
||||
{t('gameDesktop.header.bgm')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<motion.button
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen('desktopSupport', true)}
|
||||
whileTap={{
|
||||
scale: 0.95,
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
onClick={onOpenLanguage}
|
||||
className={`${actionButtonClassName} !px-design-8 justify-between`}
|
||||
>
|
||||
<SmartImage
|
||||
className={'h-design-20 w-design-20 cursor-pointer'}
|
||||
alt={'chatImage'}
|
||||
src={chatImage}
|
||||
src={currentLanguageOption.icon}
|
||||
alt={currentLanguageLabel}
|
||||
className="h-design-14 w-design-14 shrink-0 rounded-full"
|
||||
imgClassName="object-cover"
|
||||
/>
|
||||
</motion.button>
|
||||
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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
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
|
||||
|
||||
Reference in New Issue
Block a user