diff --git a/src/assets/game/add.webp b/src/assets/game/add.webp index 550c504..22b8858 100644 Binary files a/src/assets/game/add.webp and b/src/assets/game/add.webp differ diff --git a/src/assets/game/reduce.webp b/src/assets/game/reduce.webp index 22b8858..550c504 100644 Binary files a/src/assets/game/reduce.webp and b/src/assets/game/reduce.webp differ diff --git a/src/features/auth/schema/auth-schema.ts b/src/features/auth/schema/auth-schema.ts index baf136f..1260af1 100644 --- a/src/features/auth/schema/auth-schema.ts +++ b/src/features/auth/schema/auth-schema.ts @@ -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() diff --git a/src/features/game/components/desktop/desktop-animal-overlay.tsx b/src/features/game/components/desktop/desktop-animal-overlay.tsx new file mode 100644 index 0000000..62de547 --- /dev/null +++ b/src/features/game/components/desktop/desktop-animal-overlay.tsx @@ -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 ( +
+ {noBetText} +
+ ) + } + + return ( +
+ {items.map((item) => ( +
+
+ {item.imageUrl ? ( + + ) : null} +
+
+
+ {String(item.cellId).padStart(2, '0')} +
+
+ + + {formatStopBetAmount(item.amount)} + +
+
+
+ ))} +
+ ) +} + +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('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 ( +
+ + ) + } + + if (revealPhase === 'result' && hasRenderedReward) { + return ( + diff --git a/src/features/game/components/desktop/desktop-reward-overlay.tsx b/src/features/game/components/desktop/desktop-reward-overlay.tsx deleted file mode 100644 index 8660128..0000000 --- a/src/features/game/components/desktop/desktop-reward-overlay.tsx +++ /dev/null @@ -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('hidden') - const [displayRewardAmount, setDisplayRewardAmount] = useState('0') - const rewardAmountMeta = useMemo( - () => getAmountMeta(rewardAmount), - [rewardAmount], - ) - const source = useMemo(() => { - 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 ( - -
- - -
- {displayRewardAmount} -
-
-
-
- ) -} - -export default DesktopRewardOverlay diff --git a/src/features/game/components/desktop/desktop-status.tsx b/src/features/game/components/desktop/desktop-status.tsx index 52b37e6..e75d2f1 100644 --- a/src/features/game/components/desktop/desktop-status.tsx +++ b/src/features/game/components/desktop/desktop-status.tsx @@ -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} />
-
-
-
-
-
- {phaseLabel} -
+
+
+ + +
+ {phaseLabel}
-
- {t('gameDesktop.status.roundId')}:{roundId} +
+ + {t('gameDesktop.status.roundId')}: + + + {roundId} +
diff --git a/src/features/game/components/shared/round-betting-start-alert.tsx b/src/features/game/components/shared/round-betting-start-alert.tsx new file mode 100644 index 0000000..8ecfbd9 --- /dev/null +++ b/src/features/game/components/shared/round-betting-start-alert.tsx @@ -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(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 ? ( + + + + + + ) : null} + + ) +} diff --git a/src/features/game/entry/mobile-entry.tsx b/src/features/game/entry/mobile-entry.tsx index 0348e70..ec0936b 100644 --- a/src/features/game/entry/mobile-entry.tsx +++ b/src/features/game/entry/mobile-entry.tsx @@ -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 ( <> + ) } diff --git a/src/features/game/entry/pc-entry.tsx b/src/features/game/entry/pc-entry.tsx index fc34316..aea7873 100644 --- a/src/features/game/entry/pc-entry.tsx +++ b/src/features/game/entry/pc-entry.tsx @@ -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() { {/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */} - {/* 大奖/小奖动画展示 */} - {/* 强制弹窗 */} diff --git a/src/features/game/hooks/use-animal-vm.ts b/src/features/game/hooks/use-animal-vm.ts index 45ecefc..5c904a3 100644 --- a/src/features/game/hooks/use-animal-vm.ts +++ b/src/features/game/hooks/use-animal-vm.ts @@ -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, diff --git a/src/features/game/hooks/use-auto-hosting-runner.ts b/src/features/game/hooks/use-auto-hosting-runner.ts index b4f7fdb..d97469c 100644 --- a/src/features/game/hooks/use-auto-hosting-runner.ts +++ b/src/features/game/hooks/use-auto-hosting-runner.ts @@ -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, ]) } diff --git a/src/features/game/hooks/use-game-control-vm.ts b/src/features/game/hooks/use-game-control-vm.ts index 0f21b5d..8a32dde 100644 --- a/src/features/game/hooks/use-game-control-vm.ts +++ b/src/features/game/hooks/use-game-control-vm.ts @@ -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, diff --git a/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx b/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx index f367d36..817a274 100644 --- a/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx +++ b/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx @@ -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 diff --git a/src/locales/en-US/common.ts b/src/locales/en-US/common.ts index a505e3b..2644ffe 100644 --- a/src/locales/en-US/common.ts +++ b/src/locales/en-US/common.ts @@ -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', diff --git a/src/locales/id-ID/common.ts b/src/locales/id-ID/common.ts index 795507b..8418b8f 100644 --- a/src/locales/id-ID/common.ts +++ b/src/locales/id-ID/common.ts @@ -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', diff --git a/src/locales/ms-MY/common.ts b/src/locales/ms-MY/common.ts index 05d2db1..59bc99d 100644 --- a/src/locales/ms-MY/common.ts +++ b/src/locales/ms-MY/common.ts @@ -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', diff --git a/src/locales/zh-CN/common.ts b/src/locales/zh-CN/common.ts index 883a9a2..e21951e 100644 --- a/src/locales/zh-CN/common.ts +++ b/src/locales/zh-CN/common.ts @@ -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: '中奖字花',