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() {
)
}
+
+export function DesktopTitle(props: MessageBroadcastProps) {
+ return
+}
diff --git a/src/features/game/components/desktop/desktop-withdraw-copy.tsx b/src/features/game/components/desktop/desktop-withdraw-copy.tsx
deleted file mode 100644
index 340353a..0000000
--- a/src/features/game/components/desktop/desktop-withdraw-copy.tsx
+++ /dev/null
@@ -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 (
-
-
- {label}
- :
-
-
- {children}
-
-
- )
-}
-
-function AmountShell({
- amount,
- availableBalanceText,
- onMinus,
- onPlus,
-}: {
- amount: number
- availableBalanceText: string
- onMinus: () => void
- onPlus: () => void
-}) {
- return (
-
-
-
-
-
-
-
- {formatNumber(amount)}
-
-
-
-
-
-
-
-
- {availableBalanceText}
-
-
- )
-}
-
-function QuickAmountCard({
- amount,
- preview,
- active,
- onClick,
-}: {
- amount: number
- preview: string
- active: boolean
- onClick: () => void
-}) {
- return (
-
-
- {amount}
-
-
- {preview}
-
-
- )
-}
-
-function PaymentCard({
- active,
- label,
- glyph,
- onClick,
-}: {
- active: boolean
- label: string
- glyph: string
- onClick: () => void
-}) {
- return (
-
-
- {glyph}
-
- {label}
-
- )
-}
-
-function BankCard({
- active,
- brand,
- subtitle,
- surface,
- onClick,
-}: {
- active: boolean
- brand: string
- subtitle: string
- surface: string
- onClick: () => void
-}) {
- return (
-
-
- {brand}
-
- {subtitle}
-
- )
-}
-
-function InputShell({
- value,
- onChange,
- placeholder,
- error,
- errorMessage,
- uppercase = false,
-}: {
- value: string
- onChange: (value: string) => void
- placeholder: string
- error?: boolean
- errorMessage?: string
- uppercase?: boolean
-}) {
- return (
-
-
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 ? (
-
- {errorMessage}
-
- ) : null}
-
- )
-}
-
-function PreviewRow({
- label,
- value,
- highlight = false,
-}: {
- label: string
- value: ReactNode
- highlight?: boolean
-}) {
- return (
-
-
- {label}
-
-
- {value}
-
-
- )
-}
-
-function DesktopWithdraw() {
- const { t } = useTranslation()
- const [amount, setAmount] = useState(6626)
- const [currency, setCurrency] =
- useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
- const [paymentChannel, setPaymentChannel] =
- useState
('alipay-primary')
- const [bank, setBank] = useState('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 (
-
-
-
-
-
- handleAmountChange(amount - 1)}
- onPlus={() => handleAmountChange(amount + 1)}
- />
-
-
-
-
- setCurrency(value as (typeof CURRENCY_OPTIONS)[number])
- }
- >
-
-
-
-
- {CURRENCY_OPTIONS.map((option) => (
-
- {option}
-
- ))}
-
-
-
-
-
-
-
- {QUICK_AMOUNTS.map((option) => (
- handleAmountChange(option.diamonds)}
- />
- ))}
-
-
-
-
-
- {PAYMENT_CHANNELS.map((channel) => (
-
setPaymentChannel(channel.id)}
- />
- ))}
-
-
-
-
-
-
- {BANK_OPTIONS.map((option) => (
- setBank(option.id)}
- />
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t('gameDesktop.withdraw.preview.title')}
-
-
-
-
-
-
- {t('gameDesktop.withdraw.referenceRateNotice')}
-
-
-
-
- {t('gameDesktop.withdraw.eWallet')}:{' '}
-
- {t('gameDesktop.withdraw.minimumAmount', {
- amount: '10',
- currency: 'MYR',
- })}
-
-
-
- {t('gameDesktop.withdraw.bank')}:{' '}
-
- {t('gameDesktop.withdraw.minimumAmount', {
- amount: '10',
- currency: 'MYR',
- })}
-
-
-
- {t('gameDesktop.withdraw.processingTime')}:{' '}
-
- {t('gameDesktop.withdraw.arrivalTimeValue')}
-
-
-
- {t('gameDesktop.withdraw.notice')}:{' '}
-
- {t('gameDesktop.withdraw.feeNotice')}
-
-
-
-
-
-
- {t('gameDesktop.withdraw.cancel')}
-
-
- {t('gameDesktop.withdraw.confirm')}
-
- {t('gameDesktop.withdraw.withdrawal')}
-
-
-
-
-
-
- )
-}
-
-export default DesktopWithdraw
diff --git a/src/features/game/components/mobile/mobile-animal-overlay.tsx b/src/features/game/components/mobile/mobile-animal-overlay.tsx
new file mode 100644
index 0000000..e250ee7
--- /dev/null
+++ b/src/features/game/components/mobile/mobile-animal-overlay.tsx
@@ -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 (
+
+ {noBetText}
+
+ )
+ }
+
+ return (
+
+ {items.map((item) => (
+
+
+ {item.imageUrl ? (
+
+ ) : null}
+
+
+
+ {String(item.cellId).padStart(2, '0')}
+
+
+
+
+ {formatStopBetAmount(item.amount)}
+
+
+
+
+ ))}
+
+ )
+}
+
+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('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 (
+
+
+
+
+
+
+
+ {displayRewardAmount}
+
+
+
+
+ )
+ }
+
+ if (revealPhase === 'result' && hasRenderedReward) {
+ return (
+
+ )
+ }
+
+ if (hostingFlag) {
+ return (
+
+ {showStopOverlay ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+ {t('game.autoSpin.runningRounds', {
+ count: completedAutoHostingRounds,
+ })}
+
+
+
+ {t('game.actions.stopAuto')}
+
+
+
+
+ )
+ }
+
+ if (showStopOverlay) {
+ return (
+
+ )
+ }
+
+ return null
+}
diff --git a/src/features/game/components/mobile/mobile-animal.tsx b/src/features/game/components/mobile/mobile-animal.tsx
new file mode 100644
index 0000000..b3550df
--- /dev/null
+++ b/src/features/game/components/mobile/mobile-animal.tsx
@@ -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(null)
+ const cellRefs = useRef(new Map())
+ const [revealCellId, setRevealCellId] = useState(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 (
+
+
+
+
+ {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 (
+ {
+ 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,
+ )}
+ >
+
+
+ {String(item.id).padStart(2, '0')}
+
+
+ {!showStandbyState && !hasPlacedSelection ? (
+
+ ) : null}
+
+ {showCellWarning ? (
+
+
+
+
+
+ {warningLabel}
+
+
+ ) : null}
+ {hasPlacedSelection ? (
+
+
+
+
+ {selectionMeta.amount}
+
+
+
+ ) : null}
+
+ )
+ })}
+
+
+ {revealFrame ? (
+
+ ) : null}
+
+
+
+ {showStandbyState ? (
+
+
+
+
+
+ {isRealtimeConnecting
+ ? t('gameDesktop.animal.loading')
+ : t('gameDesktop.animal.tapToEnter')}
+
+
+ {isRealtimeConnecting ? (
+
+
+
+
+ ) : (
+
+ )}
+
+ {isRealtimeConnecting
+ ? t('gameDesktop.animal.loading')
+ : t('gameDesktop.animal.getStart')}
+
+
+
+ {[0, 1, 2].map((index) => (
+
+ ))}
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/game/components/mobile/mobile-betting-start-alert.tsx b/src/features/game/components/mobile/mobile-betting-start-alert.tsx
new file mode 100644
index 0000000..395cdcc
--- /dev/null
+++ b/src/features/game/components/mobile/mobile-betting-start-alert.tsx
@@ -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(null)
+ const [visibleRoundId, setVisibleRoundId] = useState(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 (
+
+ {visibleRoundId ? (
+
+
+
+
+
+
+ {t('game.roundBettingStart.title', {
+ roundId: visibleRoundId,
+ })}
+
+
+
+ {t('game.roundBettingStart.action')}
+
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/game/components/mobile/mobile-control.tsx b/src/features/game/components/mobile/mobile-control.tsx
new file mode 100644
index 0000000..523ecc7
--- /dev/null
+++ b/src/features/game/components/mobile/mobile-control.tsx
@@ -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(null)
+ const [hidingId, setHidingId] = useState(null)
+ const [confirmClicked, setConfirmClicked] = useState(false)
+ const clickResetTimerRef = useRef(null)
+ const hideResetTimerRef = useRef(null)
+ const confirmResetTimerRef = useRef(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 (
+
+
+
+
+ {chips.map((chip) => {
+ const isSelected = chip.id === selectedChipId
+ const showLockedState = !acceptingBets
+
+ return (
+ 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
+ }
+ >
+
+
+
+
+ {showLockedState && (
+
+ )}
+
+ {chip.valueLabel}
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+
+ {selectedBetQuantityLabel}
+
+
+
+
+
+
+
+
+
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')}
+
+
+
+
+
+ {t('gameDesktop.control.selected')}:
+ {selectedCountLabel} /
+ {maxSelectionCountLabel}
+
+
+ {t('gameDesktop.control.totalBet')}:
+
+ {totalBetAmountLabel}
+
+
+
+
+
+ {ACTION_OPTIONS.map(({ id, labelKey, Icon, bg }) => {
+ const isClicked = clickedId === id
+ const isHiding = hidingId === id
+ const showBg = actionsEnabled && (isClicked || isHiding)
+
+ return (
+ 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 && (
+
+ )}
+
+
+
+ {t(labelKey)}
+
+
+
+ )
+ })}
+
+
+
+
+ {confirmClicked && (
+
+ )}
+
+ {confirmLabel}
+
+
+
+ )
+}
diff --git a/src/features/game/components/mobile/mobile-game-history.tsx b/src/features/game/components/mobile/mobile-game-history.tsx
new file mode 100644
index 0000000..e916ad2
--- /dev/null
+++ b/src/features/game/components/mobile/mobile-game-history.tsx
@@ -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 (
+
+ {label}
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+function HistoryEmptyState({ label }: { label: string }) {
+ return (
+
+ )
+}
+
+export function MobileGameHistory() {
+ const { t } = useTranslation()
+ const {
+ emptyText,
+ endText,
+ fetchNextPage,
+ hasNextPage,
+ isEmpty,
+ isFetchingNextPage,
+ isInitialLoading,
+ items,
+ loadingText,
+ } = useGameHistoryVm()
+ const parentRef = useRef(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 (
+
+ {isInitialLoading ? (
+
+ ) : isEmpty ? (
+
+ ) : (
+ <>
+ {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 (
+
+
+
+
+ {item.createdAtLabel}
+
+
+
+ {statusLabel}
+
+
+ {t('gameDesktop.history.roundId')}: {item.periodNo}
+
+
+
+
+
+
+ {t('gameDesktop.history.numbers')}
+
+ {item.numbers.length === 0 ? (
+
+ {item.numbersLabel}
+
+ ) : (
+
+ {item.numbers.map((number) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ {t('gameDesktop.history.payout')}
+
+
+ {item.winAmountLabel}
+
+
+
+
+
+ {t('gameDesktop.history.winningResult')}
+
+
+ {item.resultNumber === null ? (
+
+ {item.resultNumberLabel}
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ )
+ })}
+
+
+ {isFetchingNextPage ? (
+
+ ) : hasNextPage ? (
+ ''
+ ) : (
+ endText
+ )}
+
+ >
+ )}
+
+ )
+}
diff --git a/src/features/game/components/mobile/mobile-header.tsx b/src/features/game/components/mobile/mobile-header.tsx
index 0410252..b824109 100644
--- a/src/features/game/components/mobile/mobile-header.tsx
+++ b/src/features/game/components/mobile/mobile-header.tsx
@@ -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 (
+
+ {barHeights.map((heightClassName, index) => {
+ const isActive = index < activeBars
+
+ return (
+
+ )
+ })}
+
+ )
+}
+
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 (
-