feat(game): 更新马来西亚手机号验证和游戏界面优化
- 修改认证模块手机号验证规则适配马来西亚号码格式 - 添加新的投注限制提示文本支持多语言 - 重命名结算阶段标签为Drawing统一显示 - 新增桌面版动物游戏遮罩组件分离功能 - 添加回合开始投注提醒弹窗组件 - 优化开奖动画流程和视觉效果 - 添加奖励动画显示和投注汇总展示 - 新增多种投注限制和状态提示信息
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -1,12 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const mobilePhonePattern = /^1[3-9]\d{9}$/
|
||||
const malaysiaMobilePhonePattern = /^60\d{1,9}$/
|
||||
|
||||
const usernameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'auth.validation.username.required')
|
||||
.regex(mobilePhonePattern, 'auth.validation.username.invalidPhone')
|
||||
.regex(malaysiaMobilePhonePattern, 'auth.validation.username.invalidPhone')
|
||||
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
|
||||
500
src/features/game/components/desktop/desktop-animal-overlay.tsx
Normal file
500
src/features/game/components/desktop/desktop-animal-overlay.tsx
Normal file
@@ -0,0 +1,500 @@
|
||||
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 {
|
||||
type BetSelection,
|
||||
FLOWER_IMAGE_BY_ID,
|
||||
groupSelectionsByCell,
|
||||
} from '@/features/game/shared'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import {
|
||||
type RewardAnimationType,
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
} from '@/store/game'
|
||||
|
||||
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 DesktopAnimalOverlayProps {
|
||||
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
|
||||
}
|
||||
|
||||
const fractionDigits = normalizedAmount.includes('.')
|
||||
? (normalizedAmount.split('.')[1]?.length ?? 0)
|
||||
: 0
|
||||
|
||||
return {
|
||||
fractionDigits,
|
||||
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)*18)] 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-42 py-design-14 text-design-28 font-black tracking-[0.14em] text-[#E8FFFF] [text-shadow:0_0_calc(var(--design-unit)*12)_rgba(70,245,255,0.34)]">
|
||||
{noBetText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-h-design-194 max-w-design-850 flex-wrap items-center justify-center gap-design-16 overflow-hidden rounded-[calc(var(--design-unit)*18)] 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-16 py-design-14 shadow-[0_0_calc(var(--design-unit)*24)_rgba(34,214,255,0.2),0_calc(var(--design-unit)*12)_calc(var(--design-unit)*24)_rgba(0,0,0,0.34),inset_0_calc(var(--design-unit)*1)_0_rgba(255,255,255,0.08),inset_0_calc(var(--design-unit)*-1)_0_rgba(124,232,255,0.18)] ring-1 ring-inset ring-[rgba(3,12,18,0.7)] backdrop-blur-[1px]">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.cellId}
|
||||
className="flex h-design-90 min-w-design-230 items-center gap-design-10 overflow-hidden rounded-[calc(var(--design-unit)*12)] 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-8 shadow-[0_0_calc(var(--design-unit)*16)_rgba(51,231,255,0.16),inset_0_calc(var(--design-unit)*1)_0_rgba(255,255,255,0.08)]"
|
||||
>
|
||||
<div className="relative h-design-72 w-design-72 shrink-0 overflow-hidden rounded-[calc(var(--design-unit)*10)] bg-[rgba(1,8,13,0.84)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(86,247,255,0.18)]">
|
||||
{item.imageUrl ? (
|
||||
<SmartImage
|
||||
src={item.imageUrl}
|
||||
alt=""
|
||||
showSkeleton={false}
|
||||
className="h-full w-full"
|
||||
imgClassName="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex h-design-72 min-w-0 flex-1 flex-col items-stretch justify-center gap-design-7 leading-none">
|
||||
<div className="flex h-design-28 w-full items-center justify-center rounded-full bg-[rgba(4,31,31,0.9)] text-design-24 font-black leading-none text-[#78FF7F] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(120,255,127,0.08)] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(120,255,127,0.52)]">
|
||||
{String(item.cellId).padStart(2, '0')}
|
||||
</div>
|
||||
<div className="flex h-design-32 w-full min-w-0 items-center justify-center gap-design-4 rounded-full bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-8 shadow-[0_0_calc(var(--design-unit)*14)_rgba(70,245,255,0.16)]">
|
||||
<SmartImage
|
||||
src={diamondIcon}
|
||||
alt=""
|
||||
showSkeleton={false}
|
||||
className="h-design-20 w-design-20 shrink-0 object-contain"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-design-22 font-black leading-none tabular-nums text-[#FFE58A] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,222,130,0.46)]">
|
||||
{formatStopBetAmount(item.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DesktopAnimalOverlay({
|
||||
showStopOverlay,
|
||||
}: DesktopAnimalOverlayProps) {
|
||||
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-50 flex items-center justify-center overflow-hidden bg-[rgba(2,8,14,0.72)] px-design-24 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.92)_58%,rgba(2,4,10,0.98)_100%)]"
|
||||
/>
|
||||
<LottiePlayer
|
||||
key={playerKey}
|
||||
path={rewardSource.path}
|
||||
renderer={rewardSource.renderer ?? 'svg'}
|
||||
loop={rewardSource.loop ?? false}
|
||||
autoplay={rewardSource.autoplay ?? true}
|
||||
className="absolute inset-0 h-full w-full [&>svg]:h-full [&>svg]:w-full [&>svg]:object-contain [&_canvas]:h-full [&_canvas]:w-full"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-10 flex items-center justify-center pb-design-220 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)*18)] scale-[0.96] opacity-0 blur-[calc(var(--design-unit)*2)]',
|
||||
childrenStage === 'exiting' &&
|
||||
'translate-y-[calc(var(--design-unit)*-14)] scale-[0.97] opacity-0 blur-[calc(var(--design-unit)*1.5)]',
|
||||
)}
|
||||
>
|
||||
<SmartBackground
|
||||
className="flex h-design-175 w-design-900 items-center justify-center gap-design-24 pb-design-50"
|
||||
src={winBg}
|
||||
size="contain"
|
||||
>
|
||||
<SmartImage
|
||||
className="h-design-50 w-design-225 drop-shadow-[0_0_calc(var(--design-unit)*10)_rgba(255,218,122,0.72)]"
|
||||
alt="win"
|
||||
src={winLogo}
|
||||
/>
|
||||
<div className="h-design-50 min-w-design-150 animate-bounce text-center font-sans text-design-56 leading-[calc(var(--design-unit)*50)] font-black tabular-nums text-[#FFE89A] [animation-duration:900ms] [-webkit-text-stroke:calc(var(--design-unit)*1)_#8A3A08] [text-shadow:0_0_calc(var(--design-unit)*6)_rgba(255,236,154,0.95),0_calc(var(--design-unit)*3)_0_#7A2F05,0_0_calc(var(--design-unit)*18)_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-50 bg-[rgba(2,8,14,0.72)] backdrop-blur-[2px]"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (hostingFlag) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center gap-design-22 bg-[rgba(2,8,14,0.38)] px-design-24 backdrop-blur-[1px]">
|
||||
{showStopOverlay ? (
|
||||
<div className="relative h-design-170 w-design-520 max-w-[72%] 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)*22)_rgba(60,235,255,0.28)]"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<SmartBackground
|
||||
src={hostingBg}
|
||||
size="100% 100%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
className="h-design-350 w-design-930 flex flex-col items-center justify-center"
|
||||
>
|
||||
<div className={'flex flex-col gap-design-44 items-center'}>
|
||||
<div className={'flex items-center gap-design-20'}>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
animate={prefersReducedMotion ? undefined : { rotate: 360 }}
|
||||
transition={{
|
||||
duration: 1.4,
|
||||
ease: 'linear',
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
className="flex h-design-48 w-design-48 items-center justify-center"
|
||||
>
|
||||
<SmartImage
|
||||
src={refreshIcon}
|
||||
alt=""
|
||||
priority
|
||||
showSkeleton={false}
|
||||
className="h-design-40 w-design-40"
|
||||
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.36)]"
|
||||
/>
|
||||
</motion.span>
|
||||
<div
|
||||
className={
|
||||
'text-design-22 text-[#ffffff] font-bold [text-shadow:0_0_calc(var(--design-unit)*12)_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="h-design-70 w-design-220 flex cursor-pointer items-center justify-center pb-design-4 text-design-24 font-bold text-[#EFFFFF] [text-shadow:0_1px_0_rgba(255,255,255,0.18),0_0_calc(var(--design-unit)*10)_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-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]">
|
||||
<div className="flex max-w-[88%] flex-col items-center justify-center gap-design-18">
|
||||
<StopBetSummary
|
||||
items={stopBetItems}
|
||||
noBetText={t('gameDesktop.animal.noBet')}
|
||||
/>
|
||||
<SmartImage
|
||||
src={stopImageSrc}
|
||||
alt="stop betting"
|
||||
priority
|
||||
showSkeleton={false}
|
||||
className="h-design-220 w-design-560 max-w-full overflow-visible"
|
||||
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -3,21 +3,18 @@ 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 enStopImage from '@/assets/game/en-stop.webp'
|
||||
import hostingBg from '@/assets/game/hosting-bg.webp'
|
||||
import hostingBtn from '@/assets/game/hosting-btn.webp'
|
||||
import zhStopImage from '@/assets/game/zh-stop.webp'
|
||||
import diamondIcon from '@/assets/system/diamond.webp'
|
||||
import refreshIcon from '@/assets/system/refresh.webp'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image'
|
||||
import { DesktopAnimalOverlay } from '@/features/game/components/desktop/desktop-animal-overlay.tsx'
|
||||
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
|
||||
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
|
||||
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameAutoHostingStore, useGameRoundStore } from '@/store/game'
|
||||
import { useGameRoundStore } from '@/store/game'
|
||||
|
||||
const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 4_000
|
||||
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 = 90
|
||||
const SETTLEMENT_REVEAL_MAX_STEP_MS = 480
|
||||
@@ -64,7 +61,7 @@ export function DesktopAnimal({
|
||||
imageClassName,
|
||||
onSelect,
|
||||
}: DesktopAnimalProps) {
|
||||
const { i18n, t } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const animalIds = useMemo(() => FLOWER_IMAGE_LIST.map((item) => item.id), [])
|
||||
const containerRef = useRef<HTMLElement | null>(null)
|
||||
@@ -77,20 +74,19 @@ export function DesktopAnimal({
|
||||
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 completedAutoHostingRounds = useGameAutoHostingStore(
|
||||
(state) => state.completedRounds,
|
||||
)
|
||||
const hostingFlag = useGameAutoHostingStore((state) => state.isHosting)
|
||||
const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
|
||||
const finishRevealAnimation = useGameRoundStore(
|
||||
(state) => state.finishRevealAnimation,
|
||||
)
|
||||
@@ -109,31 +105,46 @@ export function DesktopAnimal({
|
||||
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'
|
||||
const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
|
||||
? zhStopImage
|
||||
: enStopImage
|
||||
|
||||
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(revealWinningCellId)
|
||||
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(() => {
|
||||
@@ -147,22 +158,28 @@ export function DesktopAnimal({
|
||||
|
||||
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)
|
||||
setIsRevealHoldingResult(true)
|
||||
timeoutId = window.setTimeout(() => {
|
||||
finishRevealAnimation()
|
||||
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
|
||||
setIsRevealSettlingResult(false)
|
||||
setIsRevealHoldingResult(true)
|
||||
timeoutId = window.setTimeout(() => {
|
||||
finishRevealAnimation()
|
||||
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
|
||||
}, SETTLEMENT_REVEAL_SETTLE_DURATION_MS)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,7 +198,13 @@ export function DesktopAnimal({
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [animalIds, finishRevealAnimation, revealPhase, revealWinningCellId])
|
||||
}, [
|
||||
animalIds,
|
||||
finishRevealAnimation,
|
||||
revealPhase,
|
||||
revealWinningCellId,
|
||||
shouldHideRevealHighlight,
|
||||
])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (revealCellId === null) {
|
||||
@@ -230,6 +253,7 @@ export function DesktopAnimal({
|
||||
const hasPlacedSelection = Boolean(selectionMeta)
|
||||
const isMarqueeActive = showStandbyState && item.id === marqueeId
|
||||
const isRevealWinner =
|
||||
!shouldHideRevealHighlight &&
|
||||
(isRevealResult || isRevealHoldingResult) &&
|
||||
revealWinningCellId === item.id
|
||||
const warningType =
|
||||
@@ -238,7 +262,9 @@ export function DesktopAnimal({
|
||||
const warningLabel =
|
||||
warningType === 'balance'
|
||||
? t('gameDesktop.animal.insufficientBalanceRecharge')
|
||||
: t('gameDesktop.animal.selectionLimitReached')
|
||||
: warningType === 'betLimit'
|
||||
? t('gameDesktop.animal.betLimitExceeded')
|
||||
: t('gameDesktop.animal.selectionLimitReached')
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
@@ -409,7 +435,14 @@ export function DesktopAnimal({
|
||||
{revealFrame ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-40 transition-[height,transform,width] duration-75 ease-linear"
|
||||
className={cn(
|
||||
'pointer-events-none absolute z-40 transition-[height,transform,width]',
|
||||
prefersReducedMotion
|
||||
? 'duration-0'
|
||||
: isRevealSettlingResult
|
||||
? 'duration-[800ms] ease-[cubic-bezier(0.16,1,0.3,1)]'
|
||||
: 'duration-75 ease-linear',
|
||||
)}
|
||||
style={{
|
||||
height: revealFrame.height,
|
||||
transform: `translate(${revealFrame.left}px, ${revealFrame.top}px)`,
|
||||
@@ -436,88 +469,8 @@ export function DesktopAnimal({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showStopOverlay && !hostingFlag ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]"
|
||||
>
|
||||
<SmartImage
|
||||
src={stopImageSrc}
|
||||
alt="stop betting"
|
||||
priority
|
||||
showSkeleton={false}
|
||||
className="h-design-220 w-design-560 max-w-[78%] overflow-visible"
|
||||
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hostingFlag ? (
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center gap-design-22 bg-[rgba(2,8,14,0.6)] px-design-24 backdrop-blur-[1px]">
|
||||
{showStopOverlay ? (
|
||||
<SmartImage
|
||||
src={stopImageSrc}
|
||||
alt="stop betting"
|
||||
priority
|
||||
showSkeleton={false}
|
||||
className="h-design-170 w-design-520 max-w-[72%] overflow-visible"
|
||||
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
|
||||
/>
|
||||
) : null}
|
||||
<SmartBackground
|
||||
src={hostingBg}
|
||||
size="100% 100%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
className="h-design-350 w-design-930 flex flex-col items-center justify-center"
|
||||
>
|
||||
<div className={'flex flex-col gap-design-44 items-center'}>
|
||||
<div className={'flex items-center gap-design-20'}>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
animate={prefersReducedMotion ? undefined : { rotate: 360 }}
|
||||
transition={{
|
||||
duration: 1.4,
|
||||
ease: 'linear',
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
className="flex h-design-48 w-design-48 items-center justify-center"
|
||||
>
|
||||
<SmartImage
|
||||
src={refreshIcon}
|
||||
alt=""
|
||||
priority
|
||||
showSkeleton={false}
|
||||
className="h-design-40 w-design-40"
|
||||
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.36)]"
|
||||
/>
|
||||
</motion.span>
|
||||
<div
|
||||
className={
|
||||
'text-design-22 text-[#ffffff] font-bold [text-shadow:0_0_calc(var(--design-unit)*12)_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="h-design-70 w-design-220 flex cursor-pointer items-center justify-center pb-design-4 text-design-24 font-bold text-[#EFFFFF] [text-shadow:0_1px_0_rgba(255,255,255,0.18),0_0_calc(var(--design-unit)*10)_rgba(46,220,255,0.5)] transition-transform hover:-translate-y-[1px] active:translate-y-0"
|
||||
>
|
||||
{t('game.actions.stopAuto')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
</div>
|
||||
) : null}
|
||||
<DesktopAnimalOverlay showStopOverlay={showStopOverlay} />
|
||||
<RoundBettingStartAlert />
|
||||
|
||||
{showStandbyState ? (
|
||||
<button
|
||||
@@ -526,29 +479,6 @@ export function DesktopAnimal({
|
||||
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)]"
|
||||
>
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 18,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'linear',
|
||||
}}
|
||||
className="pointer-events-none absolute inset-[12%] rounded-full bg-[conic-gradient(from_0deg,rgba(129,255,250,0)_0deg,rgba(129,255,250,0.26)_60deg,rgba(129,255,250,0)_120deg,rgba(255,255,255,0)_360deg)] opacity-70 blur-[18px]"
|
||||
/>
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
animate={{
|
||||
scale: [1, 1.03, 1],
|
||||
opacity: [0.42, 0.7, 0.42],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2.8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className="pointer-events-none absolute inset-[22%] rounded-full border border-[rgba(124,255,248,0.22)] shadow-[0_0_calc(var(--design-unit)*22)_rgba(74,245,255,0.12),inset_0_0_calc(var(--design-unit)*18)_rgba(122,255,250,0.14)]"
|
||||
/>
|
||||
<div className="relative flex min-w-design-260 flex-col items-center gap-design-10 rounded-[calc(var(--design-unit)*22)] border border-[rgba(111,255,247,0.56)] bg-[linear-gradient(180deg,rgba(8,30,42,0.94),rgba(4,14,20,0.96))] px-design-32 py-design-18 text-center shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.38),0_0_calc(var(--design-unit)*42)_rgba(19,210,232,0.22),inset_0_0_calc(var(--design-unit)*18)_rgba(120,255,249,0.12)] transition-[transform,box-shadow,border-color] duration-200 group-hover:-translate-y-[1px] group-hover:border-[rgba(141,255,250,0.82)] group-hover:shadow-[0_0_calc(var(--design-unit)*24)_rgba(88,247,255,0.5),0_0_calc(var(--design-unit)*52)_rgba(32,228,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(145,255,251,0.16)]">
|
||||
<span className="pointer-events-none absolute inset-[1px] rounded-[calc(var(--design-unit)*22)] border border-[rgba(226,255,255,0.1)]" />
|
||||
<span className="pointer-events-none absolute inset-x-design-14 top-design-10 h-design-18 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.16),rgba(255,255,255,0))] opacity-70" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { motion } from 'motion/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import add from '@/assets/game/add.webp'
|
||||
import reduce from '@/assets/game/add.webp'
|
||||
import arrow from '@/assets/game/arrow.webp'
|
||||
import chipBg from '@/assets/game/chip-bg.webp'
|
||||
import chipLineBg from '@/assets/game/chip-line-bg.webp'
|
||||
@@ -10,7 +10,7 @@ import confirmBg from '@/assets/game/confirm-bg.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 reduce from '@/assets/game/reduce.webp'
|
||||
import add from '@/assets/game/reduce.webp'
|
||||
import totalBg from '@/assets/game/total-bg.webp'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
@@ -45,6 +45,8 @@ export function DesktopControl() {
|
||||
const [clickedId, setClickedId] = useState<string | null>(null)
|
||||
const [hidingId, setHidingId] = useState<string | null>(null)
|
||||
const [confirmClicked, setConfirmClicked] = useState(false)
|
||||
const isConfirmWarning =
|
||||
confirmState === 'insufficient' || confirmState === 'limit'
|
||||
|
||||
const handleChipClick = (chipId: string) => {
|
||||
if (!acceptingBets) {
|
||||
@@ -348,7 +350,7 @@ export function DesktopControl() {
|
||||
>
|
||||
<div>
|
||||
{t('gameDesktop.control.selected')}:{' '}
|
||||
<span className={'text-red-500'}>{selectedCountLabel}</span> /{' '}
|
||||
<span className={'text-reduce-500'}>{selectedCountLabel}</span> /{' '}
|
||||
{maxSelectionCountLabel}
|
||||
</div>
|
||||
<div className={'flex'}>
|
||||
@@ -455,7 +457,7 @@ export function DesktopControl() {
|
||||
</SmartBackground>
|
||||
<SmartBackground
|
||||
as={motion.button}
|
||||
src={confirmState === 'insufficient' ? confirmRedBg : confirmBg}
|
||||
src={isConfirmWarning ? confirmRedBg : confirmBg}
|
||||
size="100% 100%"
|
||||
type="button"
|
||||
onClick={handleConfirmClick}
|
||||
@@ -505,7 +507,7 @@ export function DesktopControl() {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="pointer-events-none absolute inset-0 bg-center bg-no-repeat"
|
||||
src={confirmState === 'insufficient' ? confirmRedBg : confirmBg}
|
||||
src={isConfirmWarning ? confirmRedBg : confirmBg}
|
||||
size="100% 100%"
|
||||
/>
|
||||
)}
|
||||
@@ -524,10 +526,9 @@ export function DesktopControl() {
|
||||
: {
|
||||
opacity: confirmClicked ? 0 : 1,
|
||||
y: confirmClicked ? 2 : 0,
|
||||
textShadow:
|
||||
confirmState === 'insufficient'
|
||||
? '0 0 10px rgba(255,206,206,0.45)'
|
||||
: 'none',
|
||||
textShadow: isConfirmWarning
|
||||
? '0 0 10px rgba(255,206,206,0.45)'
|
||||
: 'none',
|
||||
}
|
||||
}
|
||||
transition={
|
||||
@@ -539,10 +540,7 @@ export function DesktopControl() {
|
||||
}
|
||||
: { duration: 0.15 }
|
||||
}
|
||||
className={cn(
|
||||
'relative',
|
||||
confirmState === 'insufficient' && 'text-[#FFF1F1]',
|
||||
)}
|
||||
className={cn('relative', isConfirmWarning && 'text-[#FFF1F1]')}
|
||||
>
|
||||
{confirmLabel}
|
||||
</motion.span>
|
||||
|
||||
@@ -90,7 +90,7 @@ export function DesktopGameHistory() {
|
||||
ref={parentRef}
|
||||
onScroll={handleScroll}
|
||||
className={
|
||||
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
|
||||
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-6 overflow-y-auto overflow-x-hidden px-design-14 py-design-14'
|
||||
}
|
||||
>
|
||||
{isInitialLoading ? (
|
||||
@@ -142,10 +142,10 @@ export function DesktopGameHistory() {
|
||||
: 'linear-gradient(180deg,rgba(31,111,54,0.38),rgba(6,28,20,0.92))'
|
||||
|
||||
return (
|
||||
<div key={item.id} className="w-full pb-design-12 last:pb-0">
|
||||
<div key={item.id} className="w-full pb-design-4 last:pb-0">
|
||||
<div
|
||||
className={
|
||||
'relative isolate flex w-full flex-col items-center overflow-hidden rounded-[calc(var(--design-unit)*14)] border bg-[linear-gradient(180deg,rgba(6,33,45,0.97),rgba(3,14,23,0.94))] text-[#FFE375] shadow-[0_0_calc(var(--design-unit)*16)_rgba(63,226,255,0.14),inset_0_1px_0_rgba(218,255,255,0.12)] transition-colors duration-200'
|
||||
'relative isolate flex w-full flex-col overflow-hidden rounded-[calc(var(--design-unit)*8)] border bg-[linear-gradient(180deg,rgba(6,33,45,0.95),rgba(3,14,23,0.92))] text-[#FFE375] shadow-[0_0_calc(var(--design-unit)*10)_rgba(63,226,255,0.1),inset_0_1px_0_rgba(218,255,255,0.1)] transition-colors duration-200'
|
||||
}
|
||||
style={{
|
||||
borderColor: statusBorderColor,
|
||||
@@ -153,28 +153,31 @@ export function DesktopGameHistory() {
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-design-10 top-0 h-px opacity-80"
|
||||
className="pointer-events-none absolute inset-x-design-8 top-0 h-px opacity-70"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, transparent, ${statusColor}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -right-design-28 -top-design-30 h-design-90 w-design-90 rounded-full blur-[28px]"
|
||||
className="pointer-events-none absolute -right-design-24 -top-design-28 h-design-72 w-design-72 rounded-full blur-[24px]"
|
||||
style={{
|
||||
background: statusColor,
|
||||
opacity: isWin ? 0.14 : 0.08,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 flex min-h-design-42 w-full items-center justify-center border-b px-design-12 py-design-7"
|
||||
className="relative z-10 grid min-h-design-32 w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-design-8 border-b px-design-9 py-design-4"
|
||||
style={{
|
||||
background: statusBg,
|
||||
borderColor: statusBorderColor,
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 truncate text-design-12 font-medium text-[#8DBCC2]">
|
||||
{item.createdAtLabel}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex min-w-design-118 items-center justify-center gap-design-8 px-design-14 py-design-4 text-design-18 font-bold"
|
||||
className="inline-flex min-w-design-72 items-center justify-center gap-design-5 text-design-15 font-bold"
|
||||
style={{
|
||||
borderColor: statusBorderColor,
|
||||
color: statusColor,
|
||||
@@ -183,41 +186,33 @@ export function DesktopGameHistory() {
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-design-8 w-design-8 rounded-full shadow-[0_0_calc(var(--design-unit)*10)_currentColor]"
|
||||
className="h-design-6 w-design-6 rounded-full shadow-[0_0_calc(var(--design-unit)*8)_currentColor]"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
/>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right text-design-12 font-medium text-[#C0E7EB]">
|
||||
{t('gameDesktop.history.roundId')}: {item.periodNo}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'relative z-10 flex w-full flex-col gap-design-7 px-design-10 py-design-10 text-design-16'
|
||||
'relative z-10 flex w-full flex-col gap-design-5 px-design-8 py-design-7 text-design-13'
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-design-34 items-center justify-between gap-design-10 rounded-[calc(var(--design-unit)*8)] border border-[rgba(94,212,230,0.14)] bg-[rgba(5,23,33,0.58)] px-design-10 py-design-6">
|
||||
<span className={'shrink-0 text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.roundId')}:{' '}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
'min-w-0 text-right text-design-15 font-medium text-[#C0E7EB]'
|
||||
}
|
||||
>
|
||||
{item.periodNo}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-design-42 items-start justify-between gap-design-10 rounded-[calc(var(--design-unit)*8)] border border-[rgba(94,212,230,0.14)] bg-[rgba(5,23,33,0.58)] px-design-10 py-design-6">
|
||||
<span className={'shrink-0 pt-design-7 text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.numbers')}:{' '}
|
||||
<div className="flex min-h-design-34 items-start justify-between gap-design-6 rounded-[calc(var(--design-unit)*6)] border border-[rgba(94,212,230,0.12)] bg-[rgba(5,23,33,0.52)] px-design-7 py-design-4">
|
||||
<span className={'shrink-0 pt-design-5 text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.numbers')}
|
||||
</span>
|
||||
{item.numbers.length === 0 ? (
|
||||
<span className="pt-design-7 text-right">
|
||||
<span className="pt-design-5 text-right">
|
||||
{item.numbersLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 flex-1 flex-wrap items-center justify-end gap-design-5 align-middle">
|
||||
<span className="inline-flex min-w-0 flex-1 flex-wrap items-center justify-end gap-design-3 align-middle">
|
||||
{item.numbers.map((number) => (
|
||||
<HistoryRewardNumber
|
||||
className="!h-design-28 !min-w-design-28 !w-design-28 !p-0"
|
||||
key={`${item.id}-${number}`}
|
||||
number={number}
|
||||
/>
|
||||
@@ -225,36 +220,36 @@ export function DesktopGameHistory() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-design-34 items-center justify-between gap-design-10 rounded-[calc(var(--design-unit)*8)] border border-[rgba(255,227,117,0.16)] bg-[linear-gradient(90deg,rgba(5,23,33,0.58),rgba(84,57,8,0.16))] px-design-10 py-design-6">
|
||||
<span className={'shrink-0 text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.totalPoolAmount')}:{' '}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
'text-right text-design-17 font-bold text-[#FFE375] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,227,117,0.22)]'
|
||||
}
|
||||
>
|
||||
{item.amountLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-design-42 items-center justify-between gap-design-10 rounded-[calc(var(--design-unit)*8)] border border-[rgba(255,117,117,0.18)] bg-[linear-gradient(90deg,rgba(5,23,33,0.58),rgba(88,20,28,0.16))] px-design-10 py-design-6">
|
||||
<span className={'shrink-0 text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.winningResult')}:{' '}
|
||||
</span>
|
||||
{item.resultNumber === null ? (
|
||||
<span
|
||||
className={
|
||||
'text-right font-bold text-[#FF7575] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,117,117,0.28)]'
|
||||
}
|
||||
>
|
||||
{item.resultNumberLabel}
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-design-5">
|
||||
<div className="flex min-h-design-28 items-center justify-between gap-design-6 rounded-[calc(var(--design-unit)*6)] 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-7 py-design-4">
|
||||
<span className="shrink-0 text-[#84A2A2]">
|
||||
{t('gameDesktop.history.payout')}
|
||||
</span>
|
||||
) : (
|
||||
<HistoryRewardNumber
|
||||
className="text-[#FF7575]"
|
||||
number={item.resultNumber}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate text-right text-design-14 font-bold text-[#FFE375] [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(255,227,117,0.2)]">
|
||||
{item.winAmountLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-design-28 items-center justify-between gap-design-6 rounded-[calc(var(--design-unit)*6)] 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-7 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] [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(255,117,117,0.24)]'
|
||||
}
|
||||
>
|
||||
{item.resultNumberLabel}
|
||||
</span>
|
||||
) : (
|
||||
<HistoryRewardNumber
|
||||
className="!h-design-28 !min-w-design-28 !w-design-28 !p-0 text-[#FF7575]"
|
||||
number={item.resultNumber}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,13 @@ import {
|
||||
Mail,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Plus,
|
||||
UserKey,
|
||||
UserRoundPlus,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import add from '@/assets/game/add.webp'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import logo from '@/assets/system/logo.webp'
|
||||
@@ -195,8 +195,7 @@ export function DesktopHeader() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenProcedures}
|
||||
className="group relative flex items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
|
||||
className="group relative flex items-center justify-center transition-transform duration-150"
|
||||
>
|
||||
<SmartImage
|
||||
src={diamond}
|
||||
@@ -206,19 +205,17 @@ export function DesktopHeader() {
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset text-design-16 !py-design-20 !pr-design-14 box-border flex h-design-36 w-design-180 items-center justify-end gap-design-8 transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
|
||||
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-design-180 items-center justify-end gap-design-12 transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
|
||||
}
|
||||
>
|
||||
<span className="truncate">{currentUser?.coin || '--'}</span>
|
||||
<div className={'common-neon-inset !p-design-3'}>
|
||||
<Plus
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
color="#57B8BF"
|
||||
size={18}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</div>
|
||||
<div className="truncate">{currentUser?.coin || '--'}</div>
|
||||
|
||||
<SmartImage
|
||||
onClick={onOpenProcedures}
|
||||
className={'w-design-24 h-design-24 cursor-pointer'}
|
||||
alt={'add'}
|
||||
src={add}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import winLogo from '@/assets/game/win.webp'
|
||||
import winBg from '@/assets/game/win-bg.webp'
|
||||
import { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx'
|
||||
import type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
|
||||
import { cn } from '@/lib/utils.ts'
|
||||
import { useGameRoundStore } from '@/store'
|
||||
|
||||
const smallRewardPath = new URL(
|
||||
'../../../../assets/lottie/pc-small-reward.json',
|
||||
import.meta.url,
|
||||
).href
|
||||
const bigRewardPath = new URL(
|
||||
'../../../../assets/lottie/pc-big-reward.json',
|
||||
import.meta.url,
|
||||
).href
|
||||
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'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const fractionDigits = normalizedAmount.includes('.')
|
||||
? (normalizedAmount.split('.')[1]?.length ?? 0)
|
||||
: 0
|
||||
|
||||
return {
|
||||
fractionDigits,
|
||||
numericAmount,
|
||||
}
|
||||
}
|
||||
|
||||
function formatRewardAmount(value: number, fractionDigits: number) {
|
||||
return value.toLocaleString('en-US', {
|
||||
maximumFractionDigits: fractionDigits,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
})
|
||||
}
|
||||
|
||||
function DesktopRewardOverlay() {
|
||||
const rewardType = useGameRoundStore(
|
||||
(state) => state.revealAnimation.rewardType,
|
||||
)
|
||||
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 [isFadingOut, setIsFadingOut] = useState(false)
|
||||
const [childrenStage, setChildrenStage] =
|
||||
useState<RewardChildrenStage>('hidden')
|
||||
const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
|
||||
const rewardAmountMeta = useMemo(
|
||||
() => getAmountMeta(rewardAmount),
|
||||
[rewardAmount],
|
||||
)
|
||||
const source = useMemo<FullscreenLottieSource | null>(() => {
|
||||
if (rewardType === 'small') {
|
||||
return {
|
||||
id: 'pc-small-reward',
|
||||
path: smallRewardPath,
|
||||
loop: false,
|
||||
autoplay: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (rewardType === 'big') {
|
||||
return {
|
||||
id: 'pc-big-reward',
|
||||
path: bigRewardPath,
|
||||
loop: false,
|
||||
autoplay: true,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [rewardType])
|
||||
|
||||
useEffect(() => {
|
||||
if (rewardType === 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
setIsFadingOut(false)
|
||||
|
||||
const fadeTimerId = window.setTimeout(() => {
|
||||
setIsFadingOut(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, rewardType])
|
||||
|
||||
const shouldRenderOverlay = rewardType !== 'none'
|
||||
const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`
|
||||
const childTimelineKey = shouldRenderOverlay ? overlayAnimationKey : 'closed'
|
||||
|
||||
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])
|
||||
|
||||
return (
|
||||
<FullscreenLottieOverlay
|
||||
open={shouldRenderOverlay}
|
||||
source={source}
|
||||
animationKey={overlayAnimationKey}
|
||||
zIndex={120}
|
||||
loop={false}
|
||||
autoplay
|
||||
lockBodyScroll={!isFadingOut}
|
||||
backdropClassName={cn(
|
||||
'bg-black/70 transition-opacity duration-300',
|
||||
isFadingOut && 'pointer-events-none opacity-0',
|
||||
)}
|
||||
viewportClassName="px-0 py-0"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center pb-design-220 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)*18)] scale-[0.96] opacity-0 blur-[calc(var(--design-unit)*2)]',
|
||||
childrenStage === 'exiting' &&
|
||||
'translate-y-[calc(var(--design-unit)*-14)] scale-[0.97] opacity-0 blur-[calc(var(--design-unit)*1.5)]',
|
||||
)}
|
||||
>
|
||||
<SmartBackground
|
||||
className="flex h-design-175 w-design-900 items-center justify-center gap-design-24 pb-design-50"
|
||||
src={winBg}
|
||||
size="contain"
|
||||
>
|
||||
<SmartImage
|
||||
className="h-design-50 w-design-225 drop-shadow-[0_0_calc(var(--design-unit)*10)_rgba(255,218,122,0.72)]"
|
||||
alt="win"
|
||||
src={winLogo}
|
||||
/>
|
||||
<div className="h-design-50 min-w-design-150 animate-bounce text-center font-sans text-design-56 leading-[calc(var(--design-unit)*50)] font-black tabular-nums text-[#FFE89A] [animation-duration:900ms] [-webkit-text-stroke:calc(var(--design-unit)*1)_#8A3A08] [text-shadow:0_0_calc(var(--design-unit)*6)_rgba(255,236,154,0.95),0_calc(var(--design-unit)*3)_0_#7A2F05,0_0_calc(var(--design-unit)*18)_rgba(255,151,15,0.72)]">
|
||||
{displayRewardAmount}
|
||||
</div>
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</FullscreenLottieOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopRewardOverlay
|
||||
@@ -1,3 +1,4 @@
|
||||
import { motion, useReducedMotion } from 'motion/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import streakBg from '@/assets/game/pc-streak.webp'
|
||||
@@ -17,6 +18,7 @@ import { cn } from '@/lib/utils.ts'
|
||||
|
||||
export function DesktopStatusLine() {
|
||||
const { t } = useTranslation()
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const {
|
||||
countdownMs,
|
||||
limitLabel,
|
||||
@@ -165,22 +167,63 @@ export function DesktopStatusLine() {
|
||||
className={countdownClassName}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={'flex-1 flex items-center justify-center gap-design-100'}
|
||||
>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div
|
||||
className={'w-design-20 h-design-20 bg-[#78FF7F] rounded-[50%]'}
|
||||
></div>
|
||||
<div className={cn(phaseToneClassName, 'font-bold')}>
|
||||
{phaseLabel}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center gap-design-88">
|
||||
<div className="flex items-center gap-design-9">
|
||||
<span className="relative flex h-design-24 w-design-24 items-center justify-center">
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="absolute h-design-24 w-design-24 rounded-full bg-[rgba(120,255,127,0.22)] blur-[calc(var(--design-unit)*3)]"
|
||||
animate={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: { opacity: [0.36, 0.88, 0.42], scale: [0.9, 1.28, 0.98] }
|
||||
}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: {
|
||||
duration: 1.25,
|
||||
ease: 'easeInOut',
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
}
|
||||
/>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="relative h-design-14 w-design-14 rounded-full bg-[#78FF7F] shadow-[0_0_calc(var(--design-unit)*8)_rgba(120,255,127,0.78),0_0_calc(var(--design-unit)*16)_rgba(120,255,127,0.34)]"
|
||||
animate={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: { opacity: [0.78, 1, 0.86], scale: [0.96, 1.12, 1] }
|
||||
}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: {
|
||||
duration: 1.25,
|
||||
ease: 'easeInOut',
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
phaseToneClassName,
|
||||
'font-black tracking-[0.08em] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(120,255,127,0.44)]',
|
||||
)}
|
||||
>
|
||||
{phaseLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'text-[#CBD3D5] font-bold'}>
|
||||
{t('gameDesktop.status.roundId')}:{roundId}
|
||||
<div className="flex items-baseline gap-design-7 font-bold leading-none">
|
||||
<span className="text-design-20 tracking-[0.06em] text-[#9FBAC0]">
|
||||
{t('gameDesktop.status.roundId')}:
|
||||
</span>
|
||||
<span className="text-design-24 font-black tracking-[0.06em] text-[#E7FFFF] [text-shadow:0_0_calc(var(--design-unit)*9)_rgba(75,255,254,0.38)]">
|
||||
{roundId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useGameRoundStore } from '@/store/game'
|
||||
|
||||
const BETTING_START_ALERT_DURATION_MS = 2000
|
||||
|
||||
interface RoundBettingStartAlertProps {
|
||||
className?: string
|
||||
placement?: 'absolute' | 'fixed'
|
||||
}
|
||||
|
||||
export function RoundBettingStartAlert({
|
||||
className,
|
||||
placement = 'absolute',
|
||||
}: RoundBettingStartAlertProps) {
|
||||
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={cn(
|
||||
'pointer-events-none left-1/2 top-1/2 z-50 w-[min(calc(var(--design-unit)*560),88vw)] -translate-x-1/2 -translate-y-1/2',
|
||||
placement === 'fixed' ? 'fixed' : 'absolute',
|
||||
className,
|
||||
)}
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, scale: 0.82, y: 'calc(var(--design-unit)*18)' }
|
||||
}
|
||||
animate={
|
||||
prefersReducedMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, scale: 1, y: 0 }
|
||||
}
|
||||
exit={
|
||||
prefersReducedMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, scale: 0.92, y: 'calc(var(--design-unit)*-12)' }
|
||||
}
|
||||
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<motion.div
|
||||
className="relative overflow-hidden rounded-[calc(var(--design-unit)*22)] border border-[rgba(126,255,248,0.76)] bg-[linear-gradient(180deg,rgba(7,33,43,0.96),rgba(4,16,24,0.98))] px-design-36 py-design-28 text-center shadow-[0_0_calc(var(--design-unit)*20)_rgba(70,245,255,0.4),0_0_calc(var(--design-unit)*52)_rgba(18,206,232,0.28),inset_0_0_calc(var(--design-unit)*22)_rgba(126,255,248,0.16)]"
|
||||
animate={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: {
|
||||
boxShadow: [
|
||||
'0 0 calc(var(--design-unit)*14) rgba(70,245,255,0.32), 0 0 calc(var(--design-unit)*34) rgba(18,206,232,0.2), inset 0 0 calc(var(--design-unit)*18) rgba(126,255,248,0.14)',
|
||||
'0 0 calc(var(--design-unit)*24) rgba(87,250,255,0.54), 0 0 calc(var(--design-unit)*58) rgba(25,223,255,0.34), inset 0 0 calc(var(--design-unit)*24) rgba(146,255,251,0.22)',
|
||||
'0 0 calc(var(--design-unit)*18) rgba(70,245,255,0.38), 0 0 calc(var(--design-unit)*46) rgba(18,206,232,0.26), inset 0 0 calc(var(--design-unit)*20) 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)*22)] border border-[rgba(229,255,255,0.12)]" />
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-design-20 top-design-10 h-design-24 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.18),rgba(255,255,255,0))]"
|
||||
animate={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: { opacity: [0.54, 0.9, 0.58], x: ['-7%', '7%', '-2%'] }
|
||||
}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: { duration: 1.1, ease: 'easeInOut' }
|
||||
}
|
||||
/>
|
||||
<div className="relative z-10 flex flex-col items-center gap-design-28">
|
||||
<motion.div
|
||||
className="text-design-22 font-semibold leading-tight tracking-[0.18em] text-[#78FF7F]"
|
||||
animate={
|
||||
prefersReducedMotion ? undefined : { opacity: [0.78, 1, 0.9] }
|
||||
}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: { duration: 0.72, ease: 'easeOut' }
|
||||
}
|
||||
>
|
||||
{t('game.roundBettingStart.title', {
|
||||
roundId: visibleRoundId,
|
||||
})}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative flex items-center justify-center px-design-12 text-design-46 font-bold leading-tight tracking-[0.14em] text-[#F2FFFF] [text-shadow:0_0_calc(var(--design-unit)*16)_rgba(92,249,255,0.66)]"
|
||||
animate={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: {
|
||||
opacity: [0.88, 1, 0.92],
|
||||
scale: [0.98, 1.07, 0.99],
|
||||
textShadow: [
|
||||
'0 0 calc(var(--design-unit)*12) rgba(92,249,255,0.52), 0 0 calc(var(--design-unit)*22) rgba(120,255,127,0.22)',
|
||||
'0 0 calc(var(--design-unit)*22) rgba(111,255,255,0.9), 0 0 calc(var(--design-unit)*42) rgba(120,255,127,0.44)',
|
||||
'0 0 calc(var(--design-unit)*15) rgba(92,249,255,0.62), 0 0 calc(var(--design-unit)*28) rgba(120,255,127,0.28)',
|
||||
],
|
||||
}
|
||||
}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: {
|
||||
duration: 1.05,
|
||||
ease: 'easeInOut',
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
}
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-1/2 h-design-30 -translate-y-1/2 rounded-full bg-[rgba(120,255,248,0.24)] blur-[calc(var(--design-unit)*10)]"
|
||||
animate={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: {
|
||||
opacity: [0.28, 0.72, 0.34],
|
||||
scaleX: [0.82, 1.12, 0.9],
|
||||
}
|
||||
}
|
||||
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 className="mt-design-8 flex items-center gap-design-7">
|
||||
{[0, 1, 2, 3, 4].map((index) => (
|
||||
<motion.span
|
||||
key={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
|
||||
? undefined
|
||||
: { opacity: [0.36, 1, 0.42], y: [0, -2, 0] }
|
||||
}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? undefined
|
||||
: {
|
||||
delay: index * 0.06,
|
||||
duration: 1.1,
|
||||
ease: 'easeInOut',
|
||||
repeat: 1,
|
||||
}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MobileHeader } from '@/features/game/components/mobile/mobile-header.tsx'
|
||||
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
|
||||
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
|
||||
|
||||
export function MobileEntry() {
|
||||
@@ -7,6 +8,7 @@ export function MobileEntry() {
|
||||
return (
|
||||
<>
|
||||
<MobileHeader />
|
||||
<RoundBettingStartAlert placement="fixed" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { DesktopHeader, EntryNoticeGateModal } from '@/features/game/components'
|
||||
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
|
||||
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
|
||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
||||
import DesktopRewardOverlay from '@/features/game/components/desktop/desktop-reward-overlay.tsx'
|
||||
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
||||
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
|
||||
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
|
||||
@@ -67,8 +66,6 @@ export function PcEntry() {
|
||||
<DesktopProceduresModal />
|
||||
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
|
||||
<DesktopWithdrawTopupModal />
|
||||
{/* 大奖/小奖动画展示 */}
|
||||
<DesktopRewardOverlay />
|
||||
{/* 强制弹窗 */}
|
||||
<EntryNoticeGateModal />
|
||||
</>
|
||||
|
||||
@@ -22,7 +22,7 @@ function parseBalance(value: string | number | null | undefined) {
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
export type DesktopAnimalWarningType = 'balance' | 'limit'
|
||||
export type DesktopAnimalWarningType = 'balance' | 'betLimit' | 'limit'
|
||||
|
||||
function getNextMarqueeId(ids: number[], currentId: number | null) {
|
||||
if (ids.length === 0) {
|
||||
@@ -60,6 +60,7 @@ export function useAnimalVm(
|
||||
const chips = useGameRoundStore((state) => state.chips)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const roundId = useGameRoundStore((state) => state.round.id)
|
||||
const roundPhase = useGameRoundStore((state) => state.round.phase)
|
||||
const maxSelectionCount = useGameRoundStore(
|
||||
(state) => state.maxSelectionCount,
|
||||
)
|
||||
@@ -70,6 +71,9 @@ export function useAnimalVm(
|
||||
const selections = useGameRoundStore((state) => state.selections)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
const connection = useGameSessionStore((state) => state.connection)
|
||||
const tableLimitMax = useGameSessionStore(
|
||||
(state) => state.dashboard.tableLimitMax,
|
||||
)
|
||||
const requestRealtimeConnection = useGameSessionStore(
|
||||
(state) => state.requestRealtimeConnection,
|
||||
)
|
||||
@@ -112,7 +116,8 @@ export function useAnimalVm(
|
||||
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
|
||||
const hasSubmittedCurrentRound =
|
||||
Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId
|
||||
const lockInteraction = showStandbyState || hasSubmittedCurrentRound
|
||||
const lockInteraction =
|
||||
showStandbyState || hasSubmittedCurrentRound || roundPhase !== 'betting'
|
||||
const selectedCellCount = Object.keys(selectionByCell).length
|
||||
|
||||
useEffect(() => {
|
||||
@@ -169,7 +174,7 @@ export function useAnimalVm(
|
||||
}
|
||||
|
||||
const handleSelect = (animalId: number) => {
|
||||
if (showStandbyState) {
|
||||
if (roundPhase !== 'betting' || lockInteraction) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -193,6 +198,14 @@ export function useAnimalVm(
|
||||
|
||||
const nextBetAmount = (activeChip?.amount ?? 0) * activeBetQuantity
|
||||
|
||||
if (tableLimitMax > 0 && totalBetAmount + nextBetAmount > tableLimitMax) {
|
||||
setCellWarning({
|
||||
cellId: animalId,
|
||||
type: 'betLimit',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBetAmount + nextBetAmount > balance) {
|
||||
setCellWarning({
|
||||
cellId: animalId,
|
||||
|
||||
@@ -5,7 +5,11 @@ import { placeGameBet } from '@/features/game'
|
||||
import type { BetSelection } from '@/features/game/shared'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameAutoHostingStore, useGameRoundStore } from '@/store/game'
|
||||
import {
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
@@ -87,6 +91,9 @@ export function useAutoHostingRunner() {
|
||||
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const tableLimitMax = useGameSessionStore(
|
||||
(state) => state.dashboard.tableLimitMax,
|
||||
)
|
||||
const lastSingleWinAmount = useGameAutoHostingStore(
|
||||
(state) => state.lastSingleWinAmount,
|
||||
)
|
||||
@@ -171,6 +178,12 @@ export function useAutoHostingRunner() {
|
||||
)
|
||||
const balance = parseBalance(currentUser.coin)
|
||||
|
||||
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
|
||||
stopHosting()
|
||||
notify.warning(t('commonUi.toast.autoHostingStoppedBetLimit'))
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBetAmount > balance) {
|
||||
stopHosting()
|
||||
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
|
||||
@@ -250,6 +263,7 @@ export function useAutoHostingRunner() {
|
||||
selections,
|
||||
setCurrentUser,
|
||||
stopHosting,
|
||||
tableLimitMax,
|
||||
t,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
|
||||
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'submitting'
|
||||
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'limit' | 'submitting'
|
||||
|
||||
function formatChipDisplayValue(amount: number) {
|
||||
if (Number.isInteger(amount)) {
|
||||
@@ -86,6 +86,9 @@ export function useGameControlVm() {
|
||||
const connectionStatus = useGameSessionStore(
|
||||
(state) => state.connection.status,
|
||||
)
|
||||
const tableLimitMax = useGameSessionStore(
|
||||
(state) => state.dashboard.tableLimitMax,
|
||||
)
|
||||
const shouldConnectRealtime = useGameSessionStore(
|
||||
(state) => state.shouldConnectRealtime,
|
||||
)
|
||||
@@ -122,14 +125,18 @@ export function useGameControlVm() {
|
||||
const hasSubmittedCurrentRound =
|
||||
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
|
||||
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
|
||||
const hasExceededBetLimit =
|
||||
hasSelections && tableLimitMax > 0 && totalBetAmount > tableLimitMax
|
||||
const confirmState: ConfirmState =
|
||||
isSubmitting || isAutoHosting
|
||||
? 'submitting'
|
||||
: !hasSelections
|
||||
? 'idle'
|
||||
: hasInsufficientBalance
|
||||
? 'insufficient'
|
||||
: 'ready'
|
||||
: hasExceededBetLimit
|
||||
? 'limit'
|
||||
: hasInsufficientBalance
|
||||
? 'insufficient'
|
||||
: 'ready'
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (confirmState === 'submitting' || !hasSelections) {
|
||||
@@ -142,6 +149,11 @@ export function useGameControlVm() {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasExceededBetLimit) {
|
||||
notify.warning(t('commonUi.toast.betLimitExceeded'))
|
||||
return
|
||||
}
|
||||
|
||||
if (hasInsufficientBalance) {
|
||||
notify.warning(t('commonUi.toast.insufficientBalance'))
|
||||
return
|
||||
@@ -214,6 +226,7 @@ export function useGameControlVm() {
|
||||
confirmState,
|
||||
currentUser,
|
||||
hasInsufficientBalance,
|
||||
hasExceededBetLimit,
|
||||
hasSelections,
|
||||
hasSubmittedCurrentRound,
|
||||
round.id,
|
||||
@@ -275,9 +288,11 @@ export function useGameControlVm() {
|
||||
? t('gameDesktop.control.selectNumbers')
|
||||
: confirmState === 'insufficient'
|
||||
? t('gameDesktop.control.insufficientBalance')
|
||||
: confirmState === 'submitting'
|
||||
? t('gameDesktop.control.submitting')
|
||||
: t('gameDesktop.control.confirm'),
|
||||
: confirmState === 'limit'
|
||||
? t('gameDesktop.control.betLimitExceeded')
|
||||
: confirmState === 'submitting'
|
||||
? t('gameDesktop.control.submitting')
|
||||
: t('gameDesktop.control.confirm'),
|
||||
confirmState,
|
||||
isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
|
||||
onChipSelect: selectChip,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
selectSelectionTotal,
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
|
||||
function parseAmount(value: string) {
|
||||
@@ -43,6 +44,9 @@ function DesktopAutoSettingModal() {
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const selections = useGameRoundStore((state) => state.selections)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
const tableLimitMax = useGameSessionStore(
|
||||
(state) => state.dashboard.tableLimitMax,
|
||||
)
|
||||
const startHosting = useGameAutoHostingStore((state) => state.startHosting)
|
||||
const [balanceLimitEnabled, setBalanceLimitEnabled] = useState(false)
|
||||
const [balanceLimitValue, setBalanceLimitValue] = useState('0')
|
||||
@@ -69,6 +73,11 @@ function DesktopAutoSettingModal() {
|
||||
|
||||
const balance = parseBalance(currentUser?.coin)
|
||||
|
||||
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
|
||||
notify.warning(t('commonUi.toast.betLimitExceeded'))
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBetAmount > balance) {
|
||||
notify.warning(t('commonUi.toast.insufficientBalance'))
|
||||
return
|
||||
|
||||
@@ -89,9 +89,12 @@ export default {
|
||||
phases: {
|
||||
betting: 'Betting',
|
||||
locked: 'Locked',
|
||||
revealing: 'Revealing',
|
||||
settled: 'Settled',
|
||||
},
|
||||
roundBettingStart: {
|
||||
title: 'Round {{roundId}}',
|
||||
action: 'Start Betting',
|
||||
},
|
||||
actions: {
|
||||
unifiedBetHint: 'Unified bet',
|
||||
totalBet: 'Total bet',
|
||||
@@ -253,6 +256,7 @@ export default {
|
||||
inviteLinkCopyFailed:
|
||||
'Failed to copy invite link. Please copy it manually.',
|
||||
insufficientBalance: 'Insufficient balance. Please adjust your bet.',
|
||||
betLimitExceeded: 'Single bet limit exceeded',
|
||||
betUnavailable: 'Betting is not available for this round',
|
||||
betPlaced: 'Bet placed successfully',
|
||||
noRecentSuccessfulBet:
|
||||
@@ -266,6 +270,8 @@ export default {
|
||||
autoHostingStopped: 'Auto spin stopped',
|
||||
autoHostingStoppedBalance:
|
||||
'Balance condition reached. Auto spin has stopped.',
|
||||
autoHostingStoppedBetLimit:
|
||||
'Single bet limit exceeded. Auto spin has stopped.',
|
||||
autoHostingStoppedWin:
|
||||
'Single-win condition reached. Auto spin has stopped.',
|
||||
autoHostingStoppedJackpot: 'Jackpot reached. Auto spin has stopped.',
|
||||
@@ -378,6 +384,7 @@ export default {
|
||||
confirm: 'Confirm',
|
||||
selectNumbers: 'Select Numbers',
|
||||
insufficientBalance: 'Insufficient Balance',
|
||||
betLimitExceeded: 'Limit Exceeded',
|
||||
submitting: 'Submitting...',
|
||||
actions: {
|
||||
clear: 'Clear',
|
||||
@@ -404,7 +411,7 @@ export default {
|
||||
description: '(Revealing Result)',
|
||||
},
|
||||
settled: {
|
||||
label: 'Settled',
|
||||
label: 'Drawing',
|
||||
description: '(Round Complete)',
|
||||
},
|
||||
waiting: {
|
||||
@@ -418,10 +425,12 @@ export default {
|
||||
},
|
||||
animal: {
|
||||
insufficientBalanceRecharge: 'Insufficient balance, please top up',
|
||||
betLimitExceeded: 'Single bet limit exceeded',
|
||||
loading: 'Loading',
|
||||
selectionLimitReached: 'Selection limit exceeded',
|
||||
tapToEnter: 'Tap To Enter',
|
||||
getStart: 'Get Start',
|
||||
noBet: 'No Bet',
|
||||
},
|
||||
history: {
|
||||
title: 'History',
|
||||
@@ -431,6 +440,7 @@ export default {
|
||||
orderNo: 'Order No.',
|
||||
roundId: 'Round ID',
|
||||
numbers: 'Bet Numbers',
|
||||
createdAt: 'Time',
|
||||
settledAt: 'Settled At',
|
||||
totalPoolAmount: 'Bet Amount',
|
||||
winningResult: 'Winning Result',
|
||||
|
||||
@@ -88,9 +88,12 @@ export default {
|
||||
phases: {
|
||||
betting: 'Betting',
|
||||
locked: 'Terkunci',
|
||||
revealing: 'Mengungkap',
|
||||
settled: 'Selesai',
|
||||
},
|
||||
roundBettingStart: {
|
||||
title: 'Ronde {{roundId}}',
|
||||
action: 'Mulai Bertaruh',
|
||||
},
|
||||
actions: {
|
||||
unifiedBetHint: 'Bet seragam',
|
||||
totalBet: 'Total bet',
|
||||
@@ -252,6 +255,7 @@ export default {
|
||||
inviteLinkCopyFailed:
|
||||
'Gagal menyalin tautan undangan. Silakan salin secara manual.',
|
||||
insufficientBalance: 'Saldo tidak cukup. Silakan sesuaikan taruhan.',
|
||||
betLimitExceeded: 'Melebihi batas taruhan tunggal',
|
||||
betUnavailable: 'Taruhan tidak tersedia untuk ronde ini',
|
||||
betPlaced: 'Taruhan berhasil dikirim',
|
||||
noRecentSuccessfulBet:
|
||||
@@ -265,6 +269,8 @@ export default {
|
||||
autoHostingStopped: 'Auto spin berhenti',
|
||||
autoHostingStoppedBalance:
|
||||
'Kondisi saldo tercapai. Auto spin telah berhenti.',
|
||||
autoHostingStoppedBetLimit:
|
||||
'Melebihi batas taruhan tunggal. Auto spin telah berhenti.',
|
||||
autoHostingStoppedWin:
|
||||
'Kondisi kemenangan tunggal tercapai. Auto spin telah berhenti.',
|
||||
autoHostingStoppedJackpot: 'Jackpot tercapai. Auto spin telah berhenti.',
|
||||
@@ -378,6 +384,7 @@ export default {
|
||||
confirm: 'Konfirmasi',
|
||||
selectNumbers: 'Pilih Nombor',
|
||||
insufficientBalance: 'Saldo Tidak Cukup',
|
||||
betLimitExceeded: 'Batas Terlampaui',
|
||||
submitting: 'Mengirim...',
|
||||
actions: {
|
||||
clear: 'Hapus',
|
||||
@@ -404,7 +411,7 @@ export default {
|
||||
description: '(Mengungkap Hasil)',
|
||||
},
|
||||
settled: {
|
||||
label: 'Selesai',
|
||||
label: 'Drawing',
|
||||
description: '(Ronde Selesai)',
|
||||
},
|
||||
waiting: {
|
||||
@@ -418,10 +425,12 @@ export default {
|
||||
},
|
||||
animal: {
|
||||
insufficientBalanceRecharge: 'Saldo tidak cukup, silakan isi ulang',
|
||||
betLimitExceeded: 'Melebihi batas taruhan tunggal',
|
||||
loading: 'Memuat',
|
||||
selectionLimitReached: 'Melebihi pilihan yang diizinkan',
|
||||
tapToEnter: 'Ketuk Untuk Masuk',
|
||||
getStart: 'Mulai',
|
||||
noBet: 'Belum Bertaruh',
|
||||
},
|
||||
history: {
|
||||
title: 'Riwayat',
|
||||
@@ -431,6 +440,7 @@ export default {
|
||||
orderNo: 'No. Order',
|
||||
roundId: 'ID Ronde',
|
||||
numbers: 'Nomor Taruhan',
|
||||
createdAt: 'Waktu',
|
||||
settledAt: 'Waktu Selesai',
|
||||
totalPoolAmount: 'Jumlah Taruhan',
|
||||
winningResult: 'Hasil Menang',
|
||||
|
||||
@@ -91,9 +91,12 @@ export default {
|
||||
phases: {
|
||||
betting: 'Taruhan',
|
||||
locked: 'Dikunci',
|
||||
revealing: 'Cabutan',
|
||||
settled: 'Selesai',
|
||||
},
|
||||
roundBettingStart: {
|
||||
title: 'Pusingan {{roundId}}',
|
||||
action: 'Mula Bertaruh',
|
||||
},
|
||||
actions: {
|
||||
unifiedBetHint: 'Taruhan seragam',
|
||||
totalBet: 'Jumlah taruhan',
|
||||
@@ -256,6 +259,7 @@ export default {
|
||||
inviteLinkCopyFailed:
|
||||
'Gagal menyalin pautan jemputan. Sila salin secara manual.',
|
||||
insufficientBalance: 'Baki tidak mencukupi. Sila laraskan taruhan.',
|
||||
betLimitExceeded: 'Melebihi had taruhan tunggal',
|
||||
betUnavailable: 'Taruhan tidak tersedia untuk pusingan ini',
|
||||
betPlaced: 'Taruhan berjaya dihantar',
|
||||
noRecentSuccessfulBet:
|
||||
@@ -269,6 +273,8 @@ export default {
|
||||
autoHostingStopped: 'Putaran auto dihentikan',
|
||||
autoHostingStoppedBalance:
|
||||
'Syarat baki dicapai. Putaran auto telah dihentikan.',
|
||||
autoHostingStoppedBetLimit:
|
||||
'Melebihi had taruhan tunggal. Putaran auto telah dihentikan.',
|
||||
autoHostingStoppedWin:
|
||||
'Syarat kemenangan tunggal dicapai. Putaran auto telah dihentikan.',
|
||||
autoHostingStoppedJackpot:
|
||||
@@ -383,6 +389,7 @@ export default {
|
||||
confirm: 'Sahkan',
|
||||
selectNumbers: 'Pilih Nombor',
|
||||
insufficientBalance: 'Baki Tidak Mencukupi',
|
||||
betLimitExceeded: 'Melebihi Had',
|
||||
submitting: 'Menghantar...',
|
||||
actions: {
|
||||
clear: 'Kosongkan',
|
||||
@@ -409,7 +416,7 @@ export default {
|
||||
description: '(Mendedahkan Hasil)',
|
||||
},
|
||||
settled: {
|
||||
label: 'Selesai',
|
||||
label: 'Cabutan',
|
||||
description: '(Pusingan Tamat)',
|
||||
},
|
||||
waiting: {
|
||||
@@ -423,10 +430,12 @@ export default {
|
||||
},
|
||||
animal: {
|
||||
insufficientBalanceRecharge: 'Baki tidak mencukupi, sila tambah nilai',
|
||||
betLimitExceeded: 'Melebihi had taruhan tunggal',
|
||||
loading: 'Memuatkan',
|
||||
selectionLimitReached: 'Melebihi pilihan aksara yang dibenarkan',
|
||||
tapToEnter: 'Ketik Untuk Masuk',
|
||||
getStart: 'Mula',
|
||||
noBet: 'Belum Bertaruh',
|
||||
},
|
||||
history: {
|
||||
title: 'Sejarah',
|
||||
@@ -436,6 +445,7 @@ export default {
|
||||
orderNo: 'No. Pesanan',
|
||||
roundId: 'ID Pusingan',
|
||||
numbers: 'Nombor Pertaruhan',
|
||||
createdAt: 'Masa',
|
||||
settledAt: 'Masa Selesai',
|
||||
totalPoolAmount: 'Jumlah Pertaruhan',
|
||||
winningResult: 'Keputusan Menang',
|
||||
|
||||
@@ -87,9 +87,12 @@ export default {
|
||||
phases: {
|
||||
betting: '下注中',
|
||||
locked: '已封盘',
|
||||
revealing: '开奖中',
|
||||
settled: '已结算',
|
||||
},
|
||||
roundBettingStart: {
|
||||
title: '{{roundId}}期',
|
||||
action: '开始下注',
|
||||
},
|
||||
actions: {
|
||||
unifiedBetHint: '统一下注额',
|
||||
totalBet: '总下注',
|
||||
@@ -246,6 +249,7 @@ export default {
|
||||
inviteLinkCopied: '邀请链接已复制',
|
||||
inviteLinkCopyFailed: '邀请链接复制失败,请手动复制',
|
||||
insufficientBalance: '余额不足,请调整下注金额',
|
||||
betLimitExceeded: '超过单次投注限额',
|
||||
betUnavailable: '当前期不可下注',
|
||||
betPlaced: '下注成功',
|
||||
noRecentSuccessfulBet: '暂无上一局成功下注记录',
|
||||
@@ -256,6 +260,7 @@ export default {
|
||||
autoHostingStarted: '自动托管已开始',
|
||||
autoHostingStopped: '自动托管已停止',
|
||||
autoHostingStoppedBalance: '余额低于条件,自动托管已停止',
|
||||
autoHostingStoppedBetLimit: '超过单次投注限额,自动托管已停止',
|
||||
autoHostingStoppedWin: '单次盈利达到条件,自动托管已停止',
|
||||
autoHostingStoppedJackpot: '出现 Jackpot 大奖,自动托管已停止',
|
||||
autoHostingSubmitFailed: '自动托管下注失败,已停止托管',
|
||||
@@ -365,6 +370,7 @@ export default {
|
||||
confirm: '确认',
|
||||
selectNumbers: '请选择号码',
|
||||
insufficientBalance: '余额不足',
|
||||
betLimitExceeded: '超过限额',
|
||||
submitting: '提交中...',
|
||||
actions: {
|
||||
clear: '清空',
|
||||
@@ -391,7 +397,7 @@ export default {
|
||||
description: '(正在开奖)',
|
||||
},
|
||||
settled: {
|
||||
label: '已结算',
|
||||
label: '开奖中',
|
||||
description: '(本轮结束)',
|
||||
},
|
||||
waiting: {
|
||||
@@ -405,10 +411,12 @@ export default {
|
||||
},
|
||||
animal: {
|
||||
insufficientBalanceRecharge: '余额不足,请充值',
|
||||
betLimitExceeded: '超过单次投注限额',
|
||||
loading: '加载中',
|
||||
selectionLimitReached: '超过可选择字花',
|
||||
tapToEnter: '点击进入',
|
||||
getStart: '开始游戏',
|
||||
noBet: '未下注',
|
||||
},
|
||||
history: {
|
||||
title: '历史记录',
|
||||
@@ -418,6 +426,7 @@ export default {
|
||||
orderNo: '订单号',
|
||||
roundId: '期号',
|
||||
numbers: '下注号码',
|
||||
createdAt: '时间',
|
||||
settledAt: '结算时间',
|
||||
totalPoolAmount: '下注金额',
|
||||
winningResult: '中奖字花',
|
||||
|
||||
Reference in New Issue
Block a user