refactor(game): 优化游戏组件并实现国际化支持
- 在AppBootResourceGate组件中集成react-i18next实现资源加载文本的国际化 - 修改DesktopAnimal组件中的loading dots key以提高渲染性能 - 在DesktopControl组件中添加useRef和useEffect钩子管理定时器清理逻辑 - 将DesktopTitle组件重构为MessageBroadcast组件并增强其响应式设计 - 更新DesktopSupportModal组件中的客户服务文本为国际化格式 - 在AuthSession模块中实现本地存储数据清理时保留关键偏好设置 - 调整多个游戏组件的样式类以改进移动端适配效果 - 移除未使用的桌面提取功能相关代码文件 - 更新GitNexus索引统计数据反映最新的代码变更
@@ -1,7 +1,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **36-character-flower** (3087 symbols, 5931 relationships, 265 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **36-character-flower** (2879 symbols, 5735 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **36-character-flower** (3087 symbols, 5931 relationships, 265 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **36-character-flower** (2879 symbols, 5735 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
BIN
figma/img.png
Normal file
|
After Width: | Height: | Size: 601 KiB |
BIN
src/assets/game/mobile-add-reduce-bg.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
src/assets/game/mobile-confirm-red-bg.webp
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
src/assets/game/mobile-contro-comfirm.webp
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
src/assets/game/mobile-control-actions-bg.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src/assets/game/mobile-control-number.webp
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
src/assets/system/mobile-add-reduce-bg.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
src/assets/system/mobile-contro-comfirm.webp
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
src/assets/system/mobile-control-number.webp
Normal file
|
After Width: | Height: | Size: 149 KiB |
@@ -1,4 +1,5 @@
|
|||||||
import { type PropsWithChildren, useEffect, useMemo, useState } from 'react'
|
import { type PropsWithChildren, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const bootImageModules = import.meta.glob(
|
const bootImageModules = import.meta.glob(
|
||||||
'../assets/**/*.{jpg,jpeg,png,svg,webp}',
|
'../assets/**/*.{jpg,jpeg,png,svg,webp}',
|
||||||
@@ -90,6 +91,8 @@ function useBootResourceLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppLoadingOverlay({ progress }: { progress: number }) {
|
function AppLoadingOverlay({ progress }: { progress: number }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
@@ -137,10 +140,10 @@ function AppLoadingOverlay({ progress }: { progress: number }) {
|
|||||||
|
|
||||||
<div className="flex flex-col items-center gap-design-8 text-center">
|
<div className="flex flex-col items-center gap-design-8 text-center">
|
||||||
<div className="text-design-24 font-bold text-[#E8FFFF] [text-shadow:0_0_calc(var(--design-unit)*14)_rgba(113,255,247,0.38)]">
|
<div className="text-design-24 font-bold text-[#E8FFFF] [text-shadow:0_0_calc(var(--design-unit)*14)_rgba(113,255,247,0.38)]">
|
||||||
资源加载中
|
{t('commonUi.boot.loading')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-design-14 text-[rgba(181,242,247,0.72)]">
|
<div className="text-design-14 text-[rgba(181,242,247,0.72)]">
|
||||||
正在同步字花图鉴与游戏界面
|
{t('commonUi.boot.syncing')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export function AppNotificationAlert() {
|
|||||||
|
|
||||||
const tone = TONE_CLASS_BY_TYPE[activeDialog.type]
|
const tone = TONE_CLASS_BY_TYPE[activeDialog.type]
|
||||||
const isClosing = closingDialogId === activeDialog.id
|
const isClosing = closingDialogId === activeDialog.id
|
||||||
|
const isMobileViewport =
|
||||||
|
typeof window !== 'undefined' ? window.innerWidth <= 768 : false
|
||||||
|
|
||||||
const motionState = isClosing ? 'closing' : 'visible'
|
const motionState = isClosing ? 'closing' : 'visible'
|
||||||
const motionVariants = {
|
const motionVariants = {
|
||||||
@@ -111,17 +113,31 @@ export function AppNotificationAlert() {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="pointer-events-none fixed inset-x-0 top-[calc(var(--design-unit)*52)] z-50 flex justify-center px-4">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none fixed inset-x-0 z-50 flex justify-center',
|
||||||
|
isMobileViewport
|
||||||
|
? 'top-[calc(var(--design-unit)*38)] px-design-8'
|
||||||
|
: 'top-[calc(var(--design-unit)*52)] px-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={activeDialog.id}
|
key={activeDialog.id}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate={motionState}
|
animate={motionState}
|
||||||
variants={motionVariants}
|
variants={motionVariants}
|
||||||
className="w-[min(92vw,calc(var(--design-unit)*468))] will-change-transform"
|
className={cn(
|
||||||
|
'will-change-transform',
|
||||||
|
isMobileViewport
|
||||||
|
? 'w-[min(94vw,calc(var(--design-unit)*280))]'
|
||||||
|
: 'w-[min(92vw,calc(var(--design-unit)*468))]',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Alert
|
<Alert
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative origin-top flex w-full max-w-none items-center gap-design-12 overflow-hidden rounded-[calc(var(--design-unit)*14)] border px-design-14 py-design-12 text-left backdrop-blur-xl',
|
isMobileViewport
|
||||||
|
? 'relative origin-top flex w-full max-w-none items-center gap-design-8 overflow-hidden rounded-[calc(var(--design-unit)*10)] border px-design-10 py-design-8 text-left backdrop-blur-xl'
|
||||||
|
: 'relative origin-top flex w-full max-w-none items-center gap-design-12 overflow-hidden rounded-[calc(var(--design-unit)*14)] border px-design-14 py-design-12 text-left backdrop-blur-xl',
|
||||||
tone.alert,
|
tone.alert,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -134,7 +150,9 @@ export function AppNotificationAlert() {
|
|||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-design-10 bottom-design-10 left-design-10 w-[calc(var(--design-unit)*3)] rounded-full opacity-90 shadow-[0_0_calc(var(--design-unit)*14)_currentColor]',
|
isMobileViewport
|
||||||
|
? 'absolute top-design-8 bottom-design-8 left-design-7 w-[calc(var(--design-unit)*2.5)] rounded-full opacity-90 shadow-[0_0_calc(var(--design-unit)*10)_currentColor]'
|
||||||
|
: 'absolute top-design-10 bottom-design-10 left-design-10 w-[calc(var(--design-unit)*3)] rounded-full opacity-90 shadow-[0_0_calc(var(--design-unit)*14)_currentColor]',
|
||||||
tone.line,
|
tone.line,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -148,24 +166,42 @@ export function AppNotificationAlert() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 flex h-design-38 w-design-38 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*11)] border',
|
isMobileViewport
|
||||||
|
? 'relative z-10 flex h-design-28 w-design-28 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*8)] border'
|
||||||
|
: 'relative z-10 flex h-design-38 w-design-38 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*11)] border',
|
||||||
tone.iconShell,
|
tone.iconShell,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tone.icon}
|
{tone.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 flex min-h-design-38 min-w-0 flex-1 flex-col justify-center">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 min-w-0 flex-1 flex-col justify-center',
|
||||||
|
isMobileViewport
|
||||||
|
? 'flex min-h-design-28'
|
||||||
|
: 'flex min-h-design-38',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<AlertTitle
|
<AlertTitle
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-design-15 font-semibold leading-[1.25] text-shadow-[0_1px_0_rgba(0,0,0,0.18)]',
|
isMobileViewport
|
||||||
|
? 'text-design-11 font-semibold leading-[1.25] text-shadow-[0_1px_0_rgba(0,0,0,0.18)]'
|
||||||
|
: 'text-design-15 font-semibold leading-[1.25] text-shadow-[0_1px_0_rgba(0,0,0,0.18)]',
|
||||||
tone.title,
|
tone.title,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{activeDialog.message}
|
{activeDialog.message}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
{activeDialog.description ? (
|
{activeDialog.description ? (
|
||||||
<AlertDescription className="pt-design-3 whitespace-pre-line text-design-12 leading-[1.45] text-white/62">
|
<AlertDescription
|
||||||
|
className={cn(
|
||||||
|
'whitespace-pre-line text-white/62',
|
||||||
|
isMobileViewport
|
||||||
|
? 'pt-design-2 text-design-9 leading-[1.35]'
|
||||||
|
: 'pt-design-3 text-design-12 leading-[1.45]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{activeDialog.description}
|
{activeDialog.description}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ export function DesktopAnimal({
|
|||||||
<div className="flex items-center gap-design-4">
|
<div className="flex items-center gap-design-4">
|
||||||
{[0, 1, 2].map((index) => (
|
{[0, 1, 2].map((index) => (
|
||||||
<motion.span
|
<motion.span
|
||||||
key={index}
|
key={`loading-dot-${index}`}
|
||||||
animate={
|
animate={
|
||||||
isRealtimeConnecting
|
isRealtimeConnecting
|
||||||
? { opacity: [0.28, 1, 0.28], y: [0, -2, 0] }
|
? { opacity: [0.28, 1, 0.28], y: [0, -2, 0] }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import reduce from '@/assets/game/add.webp'
|
import reduce from '@/assets/game/add.webp'
|
||||||
import arrow from '@/assets/game/arrow.webp'
|
import arrow from '@/assets/game/arrow.webp'
|
||||||
@@ -48,9 +48,28 @@ export function DesktopControl() {
|
|||||||
const [clickedId, setClickedId] = useState<string | null>(null)
|
const [clickedId, setClickedId] = useState<string | null>(null)
|
||||||
const [hidingId, setHidingId] = useState<string | null>(null)
|
const [hidingId, setHidingId] = useState<string | null>(null)
|
||||||
const [confirmClicked, setConfirmClicked] = useState(false)
|
const [confirmClicked, setConfirmClicked] = useState(false)
|
||||||
|
const clickResetTimerRef = useRef<number | null>(null)
|
||||||
|
const hideResetTimerRef = useRef<number | null>(null)
|
||||||
|
const confirmResetTimerRef = useRef<number | null>(null)
|
||||||
const isConfirmWarning =
|
const isConfirmWarning =
|
||||||
confirmState === 'insufficient' || confirmState === 'limit'
|
confirmState === 'insufficient' || confirmState === 'limit'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (clickResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(clickResetTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(hideResetTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(confirmResetTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleChipClick = (chipId: string) => {
|
const handleChipClick = (chipId: string) => {
|
||||||
if (!acceptingBets) {
|
if (!acceptingBets) {
|
||||||
return
|
return
|
||||||
@@ -78,10 +97,20 @@ export function DesktopControl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setClickedId(id)
|
setClickedId(id)
|
||||||
setTimeout(() => {
|
|
||||||
|
if (clickResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(clickResetTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
clickResetTimerRef.current = window.setTimeout(() => {
|
||||||
setClickedId(null)
|
setClickedId(null)
|
||||||
setHidingId(id)
|
setHidingId(id)
|
||||||
setTimeout(() => {
|
|
||||||
|
if (hideResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(hideResetTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
hideResetTimerRef.current = window.setTimeout(() => {
|
||||||
setHidingId(null)
|
setHidingId(null)
|
||||||
}, 180)
|
}, 180)
|
||||||
}, 200)
|
}, 200)
|
||||||
@@ -102,7 +131,12 @@ export function DesktopControl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setConfirmClicked(true)
|
setConfirmClicked(true)
|
||||||
setTimeout(() => {
|
|
||||||
|
if (confirmResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(confirmResetTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmResetTimerRef.current = window.setTimeout(() => {
|
||||||
setConfirmClicked(false)
|
setConfirmClicked(false)
|
||||||
}, 200)
|
}, 200)
|
||||||
void onConfirm()
|
void onConfirm()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { LottiePlayer } from '@/components/lottie-player.tsx'
|
|||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
|
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
|
||||||
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
import { MessageBroadcast } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||||
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
|
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
|
||||||
import { cn } from '@/lib/utils.ts'
|
import { cn } from '@/lib/utils.ts'
|
||||||
|
|
||||||
@@ -42,15 +42,8 @@ export function DesktopStatusLine() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'relative w-full flex flex-col text-design-22'}>
|
<div className={'relative w-full flex flex-col text-design-22'}>
|
||||||
{/*<div*/}
|
|
||||||
{/* className={*/}
|
|
||||||
{/* }*/}
|
|
||||||
{/*>*/}
|
|
||||||
{/* <DesktopTitle />*/}
|
|
||||||
{/*</div>*/}
|
|
||||||
|
|
||||||
<div className={'w-full px-design-16 mb-design-10'}>
|
<div className={'w-full px-design-16 mb-design-10'}>
|
||||||
<DesktopTitle />
|
<MessageBroadcast />
|
||||||
</div>
|
</div>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
src={statusLine}
|
src={statusLine}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import useEmblaCarousel from 'embla-carousel-react'
|
|||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import broadcast from '@/assets/system/broadcast.webp'
|
import broadcast from '@/assets/system/broadcast.webp'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { cn } from '@/lib/utils.ts'
|
||||||
import { useGameSessionStore } from '@/store/game'
|
import { useGameSessionStore } from '@/store/game'
|
||||||
|
|
||||||
const winAmountFormatter = new Intl.NumberFormat('en-US', {
|
const winAmountFormatter = new Intl.NumberFormat('en-US', {
|
||||||
@@ -30,7 +31,11 @@ function formatWinAmount(value: string) {
|
|||||||
return Number.isFinite(amount) ? winAmountFormatter.format(amount) : value
|
return Number.isFinite(amount) ? winAmountFormatter.format(amount) : value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DesktopTitle() {
|
type MessageBroadcastProps = {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBroadcast({ className }: MessageBroadcastProps) {
|
||||||
const jackpotBroadcasts = useGameSessionStore(
|
const jackpotBroadcasts = useGameSessionStore(
|
||||||
(state) => state.jackpotBroadcasts,
|
(state) => state.jackpotBroadcasts,
|
||||||
)
|
)
|
||||||
@@ -107,19 +112,27 @@ export function DesktopTitle() {
|
|||||||
}, [emblaApi, hasBroadcasts, carouselTitleCount])
|
}, [emblaApi, hasBroadcasts, carouselTitleCount])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="common-neon-inset text-design-16 w-full flex h-design-65 items-center gap-design-10 !px-design-20 overflow-hidden">
|
<section
|
||||||
|
aria-label="Jackpot broadcast"
|
||||||
|
className={cn(
|
||||||
|
'common-neon-inset flex w-full min-w-0 items-center overflow-hidden',
|
||||||
|
'h-design-24 gap-design-5 !rounded-[3px] !px-design-7 !py-0 text-design-8',
|
||||||
|
'md:h-design-65 md:gap-design-10 md:!rounded-[5px] md:!px-design-20 md:text-design-16',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<SmartImage
|
<SmartImage
|
||||||
className={'w-design-24 h-design-24'}
|
className="h-design-12 w-design-12 shrink-0 md:h-design-24 md:w-design-24"
|
||||||
alt={'broadcast'}
|
alt={'broadcast'}
|
||||||
src={broadcast}
|
src={broadcast}
|
||||||
/>
|
/>
|
||||||
<div className="relative h-design-28 min-w-0 flex-1 overflow-hidden">
|
<div className="relative h-design-16 min-w-0 flex-1 overflow-hidden md:h-design-28">
|
||||||
<div className="h-full overflow-hidden" ref={emblaRef}>
|
<div className="h-full overflow-hidden" ref={emblaRef}>
|
||||||
<div className="flex h-full items-center gap-design-80">
|
<div className="flex h-full items-center gap-design-36 md:gap-design-80">
|
||||||
{carouselTitles.map((title) => (
|
{carouselTitles.map((title) => (
|
||||||
<div
|
<div
|
||||||
aria-hidden={title.cycleIndex > 0}
|
aria-hidden={title.cycleIndex > 0}
|
||||||
className="flex h-design-28 min-w-0 shrink-0 items-center whitespace-nowrap !text-[#FF970F]"
|
className="flex h-design-16 min-w-max shrink-0 items-center whitespace-nowrap !text-[#FF970F] md:h-design-28"
|
||||||
key={title.id}
|
key={title.id}
|
||||||
>
|
>
|
||||||
{title.message}
|
{title.message}
|
||||||
@@ -131,3 +144,7 @@ export function DesktopTitle() {
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DesktopTitle(props: MessageBroadcastProps) {
|
||||||
|
return <MessageBroadcast {...props} />
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,671 +0,0 @@
|
|||||||
import { Minus, Plus } from 'lucide-react'
|
|
||||||
import { type ReactNode, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
|
||||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
|
||||||
import { Input } from '@/components/ui/input.tsx'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select.tsx'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const AVAILABLE_BALANCE = 6628
|
|
||||||
const MYR_PER_100_DIAMONDS = 1
|
|
||||||
const USDT_TO_MYR_RATE = 4.049
|
|
||||||
const VND_PER_DIAMOND = 10
|
|
||||||
|
|
||||||
const QUICK_AMOUNTS = [
|
|
||||||
{ diamonds: 210, preview: 'MYR 3' },
|
|
||||||
{ diamonds: 2250, preview: 'MYR 30' },
|
|
||||||
{ diamonds: 4000, preview: 'MYR 50' },
|
|
||||||
{ diamonds: 8000, preview: 'MYR 100' },
|
|
||||||
{ diamonds: 17000, preview: 'MYR 200' },
|
|
||||||
{ diamonds: 45000, preview: 'MYR 500' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const CURRENCY_OPTIONS = ['MYR'] as const
|
|
||||||
|
|
||||||
const PAYMENT_CHANNELS = [
|
|
||||||
{
|
|
||||||
id: 'alipay-primary',
|
|
||||||
label: 'Alipay',
|
|
||||||
glyph: '支',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alipay-secondary',
|
|
||||||
label: 'Alipay',
|
|
||||||
glyph: '支',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alipay-third',
|
|
||||||
label: 'Alipay',
|
|
||||||
glyph: '支',
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const BANK_OPTIONS = [
|
|
||||||
{
|
|
||||||
id: 'bca',
|
|
||||||
label: 'BCA',
|
|
||||||
brand: 'BCA',
|
|
||||||
subtitle: 'Bank Central Asia',
|
|
||||||
surface:
|
|
||||||
'bg-[linear-gradient(180deg,rgba(251,252,255,0.98),rgba(224,239,255,0.96))] text-[#1E53A4]',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mandiri',
|
|
||||||
label: 'Mandiri',
|
|
||||||
brand: 'mandiri',
|
|
||||||
subtitle: 'Mandiri',
|
|
||||||
surface:
|
|
||||||
'bg-[linear-gradient(180deg,rgba(26,53,93,0.98),rgba(9,22,43,0.96))] text-[#F5C247]',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bni',
|
|
||||||
label: 'BNI',
|
|
||||||
brand: 'BNI',
|
|
||||||
subtitle: 'BNI',
|
|
||||||
surface:
|
|
||||||
'bg-[linear-gradient(180deg,rgba(254,253,252,0.98),rgba(239,242,247,0.96))] text-[#E1742B]',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bri',
|
|
||||||
label: 'BRI',
|
|
||||||
brand: 'BRI',
|
|
||||||
subtitle: 'BRI',
|
|
||||||
surface:
|
|
||||||
'bg-[linear-gradient(180deg,rgba(253,254,255,0.98),rgba(234,243,255,0.96))] text-[#0E56A5]',
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type PaymentChannelId = (typeof PAYMENT_CHANNELS)[number]['id']
|
|
||||||
type BankId = (typeof BANK_OPTIONS)[number]['id']
|
|
||||||
|
|
||||||
const numberFormatter = new Intl.NumberFormat('en-US')
|
|
||||||
const fixedTwoFormatter = new Intl.NumberFormat('en-US', {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})
|
|
||||||
const fixedSixFormatter = new Intl.NumberFormat('en-US', {
|
|
||||||
minimumFractionDigits: 6,
|
|
||||||
maximumFractionDigits: 6,
|
|
||||||
})
|
|
||||||
|
|
||||||
const PANEL_CLASS =
|
|
||||||
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.08),0_0_calc(var(--design-unit)*10)_rgba(32,163,186,0.12)]'
|
|
||||||
|
|
||||||
const SELECTABLE_CARD_CLASS =
|
|
||||||
'flex shrink-0 cursor-pointer flex-col items-center justify-between rounded-[calc(var(--design-unit)*6)] border px-design-8 py-design-8 transition'
|
|
||||||
|
|
||||||
const SELECTABLE_CARD_ACTIVE_CLASS =
|
|
||||||
'border-[#D18A43] bg-[linear-gradient(180deg,rgba(65,45,28,0.92),rgba(39,26,16,0.9))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
|
|
||||||
|
|
||||||
const SELECTABLE_CARD_IDLE_CLASS =
|
|
||||||
'border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(8,34,48,0.92),rgba(5,19,29,0.94))] hover:border-[rgba(170,247,255,0.7)]'
|
|
||||||
|
|
||||||
function formatNumber(value: number) {
|
|
||||||
return numberFormatter.format(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFixedTwo(value: number) {
|
|
||||||
return fixedTwoFormatter.format(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFixedSix(value: number) {
|
|
||||||
return fixedSixFormatter.format(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WithdrawField({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
alignStart = true,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
children: ReactNode
|
|
||||||
alignStart?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-design-14">
|
|
||||||
<div className="flex w-design-108 shrink-0 items-center justify-end text-right text-design-16 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
|
|
||||||
<span>{label}</span>
|
|
||||||
<span className="pl-design-4">:</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'min-w-0 flex-1',
|
|
||||||
alignStart ? 'pt-design-2' : 'flex items-center',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AmountShell({
|
|
||||||
amount,
|
|
||||||
availableBalanceText,
|
|
||||||
onMinus,
|
|
||||||
onPlus,
|
|
||||||
}: {
|
|
||||||
amount: number
|
|
||||||
availableBalanceText: string
|
|
||||||
onMinus: () => void
|
|
||||||
onPlus: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-design-6">
|
|
||||||
<div className="flex h-design-52 items-center gap-design-10 rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(14,64,74,0.82),rgba(8,36,47,0.78))] px-design-10 shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(93,239,255,0.08)]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onMinus}
|
|
||||||
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
|
||||||
>
|
|
||||||
<Minus className="h-design-16 w-design-16" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-center text-design-24 font-medium tracking-[0.04em] text-[#A1EBF3]">
|
|
||||||
{formatNumber(amount)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onPlus}
|
|
||||||
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
|
||||||
>
|
|
||||||
<Plus className="h-design-16 w-design-16" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
|
|
||||||
{availableBalanceText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function QuickAmountCard({
|
|
||||||
amount,
|
|
||||||
preview,
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
amount: number
|
|
||||||
preview: string
|
|
||||||
active: boolean
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
'flex h-design-68 w-design-104 shrink-0 cursor-pointer flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border transition',
|
|
||||||
active
|
|
||||||
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(84,48,24,0.92),rgba(60,34,18,0.88))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
|
|
||||||
: 'border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(10,44,58,0.84),rgba(5,21,32,0.92))] hover:border-[rgba(170,247,255,0.7)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="text-design-24 font-semibold leading-none text-[#FFE229]">
|
|
||||||
{amount}
|
|
||||||
</div>
|
|
||||||
<div className="pt-design-6 text-design-12 uppercase leading-none tracking-[0.04em] text-[#63AEB6]">
|
|
||||||
{preview}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaymentCard({
|
|
||||||
active,
|
|
||||||
label,
|
|
||||||
glyph,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
active: boolean
|
|
||||||
label: string
|
|
||||||
glyph: string
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
SELECTABLE_CARD_CLASS,
|
|
||||||
'h-design-92 w-design-86',
|
|
||||||
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-design-58 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-42 font-semibold leading-none',
|
|
||||||
active
|
|
||||||
? 'bg-[linear-gradient(180deg,#1F9DE8,#0E6BCF)] text-white'
|
|
||||||
: 'bg-[linear-gradient(180deg,#1C96DF,#0B6ECF)] text-white',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{glyph}
|
|
||||||
</div>
|
|
||||||
<div className="text-design-14 text-[#AEE8EE]">{label}</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BankCard({
|
|
||||||
active,
|
|
||||||
brand,
|
|
||||||
subtitle,
|
|
||||||
surface,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
active: boolean
|
|
||||||
brand: string
|
|
||||||
subtitle: string
|
|
||||||
surface: string
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
SELECTABLE_CARD_CLASS,
|
|
||||||
'h-design-86 w-design-86',
|
|
||||||
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-design-52 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-20 font-bold uppercase',
|
|
||||||
surface,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{brand}
|
|
||||||
</div>
|
|
||||||
<div className="text-design-13 text-[#AEE8EE]">{subtitle}</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputShell({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
error,
|
|
||||||
errorMessage,
|
|
||||||
uppercase = false,
|
|
||||||
}: {
|
|
||||||
value: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
placeholder: string
|
|
||||||
error?: boolean
|
|
||||||
errorMessage?: string
|
|
||||||
uppercase?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-design-5">
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className={cn(
|
|
||||||
'h-design-42 rounded-[calc(var(--design-unit)*5)] border px-design-14 text-design-16',
|
|
||||||
uppercase && 'uppercase',
|
|
||||||
error
|
|
||||||
? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]'
|
|
||||||
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(10,47,57,0.84),rgba(5,23,32,0.92))] text-[#ACF1F6]',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{error && errorMessage ? (
|
|
||||||
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreviewRow({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
highlight = false,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: ReactNode
|
|
||||||
highlight?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex border-b border-[rgba(89,209,223,0.2)] last:border-b-0">
|
|
||||||
<div className="flex w-[44%] shrink-0 items-center border-r border-[rgba(89,209,223,0.2)] px-design-14 py-design-20 text-design-16 font-medium uppercase leading-[1.15] text-[#7CE3E8]">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex min-w-0 flex-1 items-center justify-end px-design-14 py-design-20 text-right text-design-16 text-[#E6FFFF]',
|
|
||||||
highlight && 'text-design-18 font-semibold text-[#6DFF83]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DesktopWithdraw() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const [amount, setAmount] = useState(6626)
|
|
||||||
const [currency, setCurrency] =
|
|
||||||
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
|
|
||||||
const [paymentChannel, setPaymentChannel] =
|
|
||||||
useState<PaymentChannelId>('alipay-primary')
|
|
||||||
const [bank, setBank] = useState<BankId>('bca')
|
|
||||||
const [holderName, setHolderName] = useState('')
|
|
||||||
const [bankAccount, setBankAccount] = useState('')
|
|
||||||
const [receiverEmail, setReceiverEmail] = useState('')
|
|
||||||
const [receiverPhone, setReceiverPhone] = useState('')
|
|
||||||
|
|
||||||
const withdrawMyr = amount / 100
|
|
||||||
const withdrawVnd = amount * VND_PER_DIAMOND
|
|
||||||
const withdrawUsdt = withdrawMyr / USDT_TO_MYR_RATE
|
|
||||||
|
|
||||||
const holderNameError = holderName.trim().length === 0
|
|
||||||
const bankAccountError = bankAccount.trim().length === 0
|
|
||||||
|
|
||||||
function handleAmountChange(nextAmount: number) {
|
|
||||||
setAmount(Math.max(0, nextAmount))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0 w-full px-design-12 pb-design-12 text-[#D9FFFF]">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
PANEL_CLASS,
|
|
||||||
'flex h-full min-h-0 w-full min-w-0 overflow-y-auto',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex min-h-full min-w-0 flex-[1.7] flex-col px-design-16 py-design-14">
|
|
||||||
<div className="flex flex-col gap-design-12">
|
|
||||||
<WithdrawField label={t('提现钻石数量')}>
|
|
||||||
<AmountShell
|
|
||||||
amount={amount}
|
|
||||||
availableBalanceText={t(
|
|
||||||
'gameDesktop.withdraw.availableBalance',
|
|
||||||
{ amount: formatNumber(AVAILABLE_BALANCE) },
|
|
||||||
)}
|
|
||||||
onMinus={() => handleAmountChange(amount - 1)}
|
|
||||||
onPlus={() => handleAmountChange(amount + 1)}
|
|
||||||
/>
|
|
||||||
</WithdrawField>
|
|
||||||
|
|
||||||
<WithdrawField label={t('货币类型')} alignStart={false}>
|
|
||||||
<Select
|
|
||||||
value={currency}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setCurrency(value as (typeof CURRENCY_OPTIONS)[number])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className="h-design-52 w-full rounded-[calc(var(--design-unit)*6)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-16 text-left text-design-20 font-semibold text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-52 [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
|
|
||||||
aria-label={t('gameDesktop.withdraw.currencySelection')}
|
|
||||||
>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t('gameDesktop.withdraw.selectCurrency')}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent
|
|
||||||
position="popper"
|
|
||||||
className="min-w-(--radix-select-trigger-width) rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(8,36,48,0.98),rgba(4,18,28,0.98))] text-[#CFFDFF] shadow-[0_0_calc(var(--design-unit)*16)_rgba(56,241,255,0.12)]"
|
|
||||||
>
|
|
||||||
{CURRENCY_OPTIONS.map((option) => (
|
|
||||||
<SelectItem
|
|
||||||
key={option}
|
|
||||||
value={option}
|
|
||||||
className="rounded-[calc(var(--design-unit)*4)] px-design-12 py-design-10 text-design-18 focus:bg-[rgba(53,154,171,0.2)] focus:text-white"
|
|
||||||
>
|
|
||||||
{option}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</WithdrawField>
|
|
||||||
|
|
||||||
<div className="flex gap-design-14">
|
|
||||||
<div className="w-design-108 shrink-0" />
|
|
||||||
<div className="flex min-w-0 flex-1 flex-wrap gap-design-10">
|
|
||||||
{QUICK_AMOUNTS.map((option) => (
|
|
||||||
<QuickAmountCard
|
|
||||||
key={option.diamonds}
|
|
||||||
amount={option.diamonds}
|
|
||||||
preview={option.preview}
|
|
||||||
active={option.diamonds === amount}
|
|
||||||
onClick={() => handleAmountChange(option.diamonds)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<WithdrawField label={t('支付渠道')}>
|
|
||||||
<div className="flex flex-wrap gap-design-10">
|
|
||||||
{PAYMENT_CHANNELS.map((channel) => (
|
|
||||||
<PaymentCard
|
|
||||||
key={channel.id}
|
|
||||||
active={channel.id === paymentChannel}
|
|
||||||
label={channel.label}
|
|
||||||
glyph={channel.glyph}
|
|
||||||
onClick={() => setPaymentChannel(channel.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</WithdrawField>
|
|
||||||
|
|
||||||
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
|
|
||||||
<div className="flex flex-col gap-design-10">
|
|
||||||
<div className="flex flex-wrap gap-design-10">
|
|
||||||
{BANK_OPTIONS.map((option) => (
|
|
||||||
<BankCard
|
|
||||||
key={option.id}
|
|
||||||
active={option.id === bank}
|
|
||||||
brand={option.brand}
|
|
||||||
subtitle={option.label}
|
|
||||||
surface={option.surface}
|
|
||||||
onClick={() => setBank(option.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</WithdrawField>
|
|
||||||
|
|
||||||
<WithdrawField
|
|
||||||
label={t('gameDesktop.withdraw.fields.cardHolderName')}
|
|
||||||
>
|
|
||||||
<InputShell
|
|
||||||
value={holderName}
|
|
||||||
onChange={setHolderName}
|
|
||||||
placeholder={t(
|
|
||||||
'gameDesktop.withdraw.placeholders.cardHolderName',
|
|
||||||
)}
|
|
||||||
error={holderNameError}
|
|
||||||
errorMessage={t(
|
|
||||||
'gameDesktop.withdraw.errors.cardHolderNameRequired',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</WithdrawField>
|
|
||||||
|
|
||||||
<WithdrawField
|
|
||||||
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
|
|
||||||
>
|
|
||||||
<InputShell
|
|
||||||
value={bankAccount}
|
|
||||||
onChange={setBankAccount}
|
|
||||||
placeholder={t(
|
|
||||||
'gameDesktop.withdraw.placeholders.bankAccountNumber',
|
|
||||||
)}
|
|
||||||
error={bankAccountError}
|
|
||||||
errorMessage={t(
|
|
||||||
'gameDesktop.withdraw.errors.bankAccountRequired',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</WithdrawField>
|
|
||||||
|
|
||||||
<WithdrawField label={t('收款人邮箱')} alignStart={false}>
|
|
||||||
<InputShell
|
|
||||||
value={receiverEmail}
|
|
||||||
onChange={setReceiverEmail}
|
|
||||||
placeholder={t(
|
|
||||||
'gameDesktop.withdraw.placeholders.receiverEmail',
|
|
||||||
)}
|
|
||||||
uppercase={true}
|
|
||||||
/>
|
|
||||||
</WithdrawField>
|
|
||||||
|
|
||||||
<WithdrawField
|
|
||||||
label={t('gameDesktop.withdraw.fields.receiverPhone')}
|
|
||||||
alignStart={false}
|
|
||||||
>
|
|
||||||
<InputShell
|
|
||||||
value={receiverPhone}
|
|
||||||
onChange={setReceiverPhone}
|
|
||||||
placeholder={t(
|
|
||||||
'gameDesktop.withdraw.placeholders.receiverPhone',
|
|
||||||
)}
|
|
||||||
uppercase={true}
|
|
||||||
/>
|
|
||||||
</WithdrawField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px shrink-0 bg-[linear-gradient(180deg,rgba(89,209,223,0)_0%,rgba(89,209,223,0.4)_12%,rgba(89,209,223,0.5)_88%,rgba(89,209,223,0)_100%)]" />
|
|
||||||
|
|
||||||
<div className="flex min-h-full min-w-0 w-design-520 shrink-0 flex-col">
|
|
||||||
<div className="flex h-design-44 items-center border-b border-[rgba(89,209,223,0.2)] bg-[linear-gradient(90deg,rgba(18,99,110,0.8),rgba(7,68,79,0.9))] px-design-12 text-design-20 font-semibold uppercase tracking-[0.04em] text-[#9AF5FB]">
|
|
||||||
{t('gameDesktop.withdraw.preview.title')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-design-12 px-design-10 py-design-10">
|
|
||||||
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
|
|
||||||
<PreviewRow
|
|
||||||
label={t('gameDesktop.withdraw.preview.diamondAmount')}
|
|
||||||
value={formatNumber(amount)}
|
|
||||||
/>
|
|
||||||
<PreviewRow
|
|
||||||
label={t('gameDesktop.withdraw.preview.exchangeRate', {
|
|
||||||
currency: 'MYR',
|
|
||||||
})}
|
|
||||||
value={t('gameDesktop.withdraw.preview.exchangeRateValue', {
|
|
||||||
coins: 100 * MYR_PER_100_DIAMONDS,
|
|
||||||
currency: 'MYR',
|
|
||||||
platformCoinLabel: '钻石',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<PreviewRow
|
|
||||||
label={t('gameDesktop.withdraw.preview.convertible', {
|
|
||||||
currency: 'MYR',
|
|
||||||
})}
|
|
||||||
value={`RM ${formatFixedTwo(withdrawMyr)}`}
|
|
||||||
highlight={true}
|
|
||||||
/>
|
|
||||||
<PreviewRow
|
|
||||||
label={t('gameDesktop.withdraw.preview.exchangeRate', {
|
|
||||||
currency: 'USDT',
|
|
||||||
})}
|
|
||||||
value={t('gameDesktop.withdraw.preview.exchangeRateValue', {
|
|
||||||
coins: formatFixedTwo(100 * USDT_TO_MYR_RATE),
|
|
||||||
currency: 'USDT',
|
|
||||||
platformCoinLabel: '钻石',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<PreviewRow
|
|
||||||
label={t('gameDesktop.withdraw.preview.convertible', {
|
|
||||||
currency: 'VND',
|
|
||||||
})}
|
|
||||||
value={`${formatNumber(withdrawVnd)} VND`}
|
|
||||||
highlight={true}
|
|
||||||
/>
|
|
||||||
<PreviewRow
|
|
||||||
label={t('gameDesktop.withdraw.preview.convertible', {
|
|
||||||
currency: 'USDT',
|
|
||||||
})}
|
|
||||||
value={`${formatFixedSix(withdrawUsdt)} USDT`}
|
|
||||||
highlight={true}
|
|
||||||
/>
|
|
||||||
<PreviewRow
|
|
||||||
label={t(
|
|
||||||
'gameDesktop.withdraw.preview.fixedExchangeDiamondAmount',
|
|
||||||
)}
|
|
||||||
value="0-0-0 0:0:0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-12 py-design-10 text-design-16 leading-[1.35] text-[#F0B44A]">
|
|
||||||
{t('gameDesktop.withdraw.referenceRateNotice')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
|
|
||||||
<div>
|
|
||||||
{t('gameDesktop.withdraw.eWallet')}:{' '}
|
|
||||||
<span className="text-[#B9F4F8]">
|
|
||||||
{t('gameDesktop.withdraw.minimumAmount', {
|
|
||||||
amount: '10',
|
|
||||||
currency: 'MYR',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{t('gameDesktop.withdraw.bank')}:{' '}
|
|
||||||
<span className="text-[#B9F4F8]">
|
|
||||||
{t('gameDesktop.withdraw.minimumAmount', {
|
|
||||||
amount: '10',
|
|
||||||
currency: 'MYR',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{t('gameDesktop.withdraw.processingTime')}:{' '}
|
|
||||||
<span className="text-[#77FF76]">
|
|
||||||
{t('gameDesktop.withdraw.arrivalTimeValue')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{t('gameDesktop.withdraw.notice')}:{' '}
|
|
||||||
<span className="text-red-700">
|
|
||||||
{t('gameDesktop.withdraw.feeNotice')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto flex items-end justify-between gap-design-10 pt-design-10">
|
|
||||||
<SmartBackground
|
|
||||||
as="button"
|
|
||||||
type="button"
|
|
||||||
src={lengthGreenBtn}
|
|
||||||
size="100% 100%"
|
|
||||||
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-18 font-bold uppercase tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
{t('gameDesktop.withdraw.cancel')}
|
|
||||||
</SmartBackground>
|
|
||||||
<SmartBackground
|
|
||||||
as="button"
|
|
||||||
type="button"
|
|
||||||
src={lengthBlueBtn}
|
|
||||||
size="100% 100%"
|
|
||||||
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
{t('gameDesktop.withdraw.confirm')}
|
|
||||||
<br />
|
|
||||||
{t('gameDesktop.withdraw.withdrawal')}
|
|
||||||
</SmartBackground>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DesktopWithdraw
|
|
||||||
490
src/features/game/components/mobile/mobile-animal-overlay.tsx
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
import { motion, useReducedMotion } from 'motion/react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import enStopImage from '@/assets/game/en-stop.webp'
|
||||||
|
import hostingBg from '@/assets/game/hosting-bg.webp'
|
||||||
|
import hostingBtn from '@/assets/game/hosting-btn.webp'
|
||||||
|
import winLogo from '@/assets/game/win.webp'
|
||||||
|
import winBg from '@/assets/game/win-bg.webp'
|
||||||
|
import zhStopImage from '@/assets/game/zh-stop.webp'
|
||||||
|
import bigRewardPath from '@/assets/lottie/pc-big-reward.json?url'
|
||||||
|
import smallRewardPath from '@/assets/lottie/pc-small-reward.json?url'
|
||||||
|
import diamondIcon from '@/assets/system/diamond.webp'
|
||||||
|
import refreshIcon from '@/assets/system/refresh.webp'
|
||||||
|
import type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
|
||||||
|
import { LottiePlayer } from '@/components/lottie-player.tsx'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { SmartImage } from '@/components/smart-image'
|
||||||
|
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
|
||||||
|
import {
|
||||||
|
FLOWER_IMAGE_BY_ID,
|
||||||
|
groupSelectionsByCell,
|
||||||
|
} from '@/features/game/shared'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useGameAutoHostingStore, useGameRoundStore } from '@/store/game'
|
||||||
|
import type { BetSelection, RewardAnimationType } from '@/type'
|
||||||
|
|
||||||
|
const REWARD_OVERLAY_FADE_OUT_MS = 300
|
||||||
|
const REWARD_CHILDREN_FADE_IN_MS = 2_000
|
||||||
|
const REWARD_CHILDREN_VISIBLE_MS = 1_000
|
||||||
|
|
||||||
|
type RewardChildrenStage = 'hidden' | 'visible' | 'exiting'
|
||||||
|
type StopBetItem = {
|
||||||
|
amount: number
|
||||||
|
cellId: number
|
||||||
|
imageUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileAnimalOverlayProps {
|
||||||
|
showStopOverlay: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeOutCubic(progress: number) {
|
||||||
|
return 1 - (1 - progress) ** 3
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAmountMeta(amount: string | null) {
|
||||||
|
if (!amount) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedAmount = amount.replace(/,/g, '')
|
||||||
|
const numericAmount = Number(normalizedAmount)
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericAmount)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fractionDigits: normalizedAmount.includes('.')
|
||||||
|
? (normalizedAmount.split('.')[1]?.length ?? 0)
|
||||||
|
: 0,
|
||||||
|
numericAmount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRewardAmount(value: number, fractionDigits: number) {
|
||||||
|
return value.toLocaleString('en-US', {
|
||||||
|
maximumFractionDigits: fractionDigits,
|
||||||
|
minimumFractionDigits: fractionDigits,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRewardSource(
|
||||||
|
rewardType: RewardAnimationType,
|
||||||
|
): FullscreenLottieSource | null {
|
||||||
|
if (rewardType === 'small') {
|
||||||
|
return {
|
||||||
|
id: 'pc-small-reward',
|
||||||
|
path: smallRewardPath,
|
||||||
|
loop: false,
|
||||||
|
autoplay: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rewardType === 'big') {
|
||||||
|
return {
|
||||||
|
id: 'pc-big-reward',
|
||||||
|
path: bigRewardPath,
|
||||||
|
loop: false,
|
||||||
|
autoplay: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStopBetAmount(amount: number) {
|
||||||
|
return amount.toLocaleString('en-US', {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStopBetItems(selections: BetSelection[]) {
|
||||||
|
const groupedSelections = groupSelectionsByCell(selections)
|
||||||
|
|
||||||
|
return Object.entries(groupedSelections)
|
||||||
|
.map(([cellId, meta]) => {
|
||||||
|
const normalizedCellId = Number(cellId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
amount: meta.amount,
|
||||||
|
cellId: normalizedCellId,
|
||||||
|
imageUrl: FLOWER_IMAGE_BY_ID[normalizedCellId]?.animalUrl ?? '',
|
||||||
|
} satisfies StopBetItem
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.cellId - right.cellId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StopBetSummary({
|
||||||
|
items,
|
||||||
|
noBetText,
|
||||||
|
}: {
|
||||||
|
items: StopBetItem[]
|
||||||
|
noBetText: string
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="game-chip-glow rounded-[calc(var(--design-unit)*10)] border border-[rgba(124,232,255,0.22)] bg-[linear-gradient(180deg,rgba(8,34,42,0.94),rgba(3,13,20,0.96))] px-design-20 py-design-8 text-design-16 font-black tracking-[0.1em] text-[#E8FFFF] [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(70,245,255,0.34)]">
|
||||||
|
{noBetText}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-h-design-116 max-w-design-318 flex-wrap items-center justify-center gap-design-6 overflow-hidden rounded-[calc(var(--design-unit)*10)] border border-[rgba(124,232,255,0.28)] bg-[linear-gradient(180deg,rgba(10,31,42,0.84),rgba(3,12,18,0.74))] px-design-7 py-design-6 shadow-[0_0_calc(var(--design-unit)*16)_rgba(34,214,255,0.2),0_calc(var(--design-unit)*8)_calc(var(--design-unit)*16)_rgba(0,0,0,0.34),inset_0_calc(var(--design-unit)*1)_0_rgba(255,255,255,0.08)]">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.cellId}
|
||||||
|
className="flex h-design-42 min-w-design-100 items-center gap-design-5 overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[rgba(124,232,255,0.2)] bg-[linear-gradient(90deg,rgba(7,42,38,0.94),rgba(5,23,34,0.96)_44%,rgba(18,25,25,0.94))] px-design-4"
|
||||||
|
>
|
||||||
|
<div className="relative h-design-32 w-design-32 shrink-0 overflow-hidden rounded-[calc(var(--design-unit)*5)] bg-[rgba(1,8,13,0.84)]">
|
||||||
|
{item.imageUrl ? (
|
||||||
|
<SmartImage
|
||||||
|
src={item.imageUrl}
|
||||||
|
alt=""
|
||||||
|
showSkeleton={false}
|
||||||
|
className="h-full w-full"
|
||||||
|
imgClassName="object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col items-stretch justify-center gap-design-3 leading-none">
|
||||||
|
<div className="flex h-design-13 w-full items-center justify-center rounded-full bg-[rgba(4,31,31,0.9)] text-design-11 font-black leading-none text-[#78FF7F] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(120,255,127,0.52)]">
|
||||||
|
{String(item.cellId).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="flex h-design-15 w-full min-w-0 items-center justify-center gap-design-2 rounded-full bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-3">
|
||||||
|
<SmartImage
|
||||||
|
src={diamondIcon}
|
||||||
|
alt=""
|
||||||
|
showSkeleton={false}
|
||||||
|
className="h-design-9 w-design-9 shrink-0 object-contain"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 truncate text-design-10 font-black leading-none tabular-nums text-[#FFE58A] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(255,222,130,0.46)]">
|
||||||
|
{formatStopBetAmount(item.amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileAnimalOverlay({
|
||||||
|
showStopOverlay,
|
||||||
|
}: MobileAnimalOverlayProps) {
|
||||||
|
const { i18n, t } = useTranslation()
|
||||||
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
const completedAutoHostingRounds = useGameAutoHostingStore(
|
||||||
|
(state) => state.completedRounds,
|
||||||
|
)
|
||||||
|
const currentRoundId = useGameRoundStore((state) => state.round.id)
|
||||||
|
const lastBetPeriodNo = useAuthStore(
|
||||||
|
(state) => state.currentUser?.lastBetPeriodNo,
|
||||||
|
)
|
||||||
|
const hostingFlag = useGameAutoHostingStore((state) => state.isHosting)
|
||||||
|
const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
|
||||||
|
const selections = useGameRoundStore((state) => state.selections)
|
||||||
|
const recentSuccessfulSelections = useGameRoundStore(
|
||||||
|
(state) => state.recentSuccessfulSelections,
|
||||||
|
)
|
||||||
|
const rewardType = useGameRoundStore(
|
||||||
|
(state) => state.revealAnimation.rewardType,
|
||||||
|
)
|
||||||
|
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
|
||||||
|
const rewardAmount = useGameRoundStore(
|
||||||
|
(state) => state.revealAnimation.rewardAmount,
|
||||||
|
)
|
||||||
|
const revealKey = useGameRoundStore(
|
||||||
|
(state) => state.revealAnimation.revealKey,
|
||||||
|
)
|
||||||
|
const roundId = useGameRoundStore((state) => state.revealAnimation.roundId)
|
||||||
|
const clearRewardAnimation = useGameRoundStore(
|
||||||
|
(state) => state.clearRewardAnimation,
|
||||||
|
)
|
||||||
|
const [isRewardFadingOut, setIsRewardFadingOut] = useState(false)
|
||||||
|
const [childrenStage, setChildrenStage] =
|
||||||
|
useState<RewardChildrenStage>('hidden')
|
||||||
|
const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
|
||||||
|
const [hasRenderedReward, setHasRenderedReward] = useState(false)
|
||||||
|
const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
|
||||||
|
? zhStopImage
|
||||||
|
: enStopImage
|
||||||
|
const stopBetItems = useMemo(
|
||||||
|
() =>
|
||||||
|
getStopBetItems(
|
||||||
|
selections.length > 0
|
||||||
|
? selections
|
||||||
|
: lastBetPeriodNo === currentRoundId
|
||||||
|
? recentSuccessfulSelections
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
[currentRoundId, lastBetPeriodNo, recentSuccessfulSelections, selections],
|
||||||
|
)
|
||||||
|
const rewardAmountMeta = useMemo(
|
||||||
|
() => getAmountMeta(rewardAmount),
|
||||||
|
[rewardAmount],
|
||||||
|
)
|
||||||
|
const rewardSource = useMemo(() => getRewardSource(rewardType), [rewardType])
|
||||||
|
const shouldRenderReward =
|
||||||
|
revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null
|
||||||
|
const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`
|
||||||
|
const childTimelineKey = shouldRenderReward ? overlayAnimationKey : 'closed'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (revealPhase !== 'result') {
|
||||||
|
setHasRenderedReward(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRenderReward) {
|
||||||
|
setHasRenderedReward(true)
|
||||||
|
}
|
||||||
|
}, [revealPhase, shouldRenderReward])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldRenderReward) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRewardFadingOut(false)
|
||||||
|
|
||||||
|
const fadeTimerId = window.setTimeout(() => {
|
||||||
|
setIsRewardFadingOut(true)
|
||||||
|
}, REWARD_OVERLAY_DURATION_MS)
|
||||||
|
const clearTimerId = window.setTimeout(() => {
|
||||||
|
clearRewardAnimation()
|
||||||
|
}, REWARD_OVERLAY_DURATION_MS + REWARD_OVERLAY_FADE_OUT_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(fadeTimerId)
|
||||||
|
window.clearTimeout(clearTimerId)
|
||||||
|
}
|
||||||
|
}, [clearRewardAnimation, shouldRenderReward])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (childTimelineKey === 'closed') {
|
||||||
|
setChildrenStage('hidden')
|
||||||
|
setDisplayRewardAmount('0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setChildrenStage('hidden')
|
||||||
|
setDisplayRewardAmount(
|
||||||
|
rewardAmountMeta
|
||||||
|
? formatRewardAmount(0, rewardAmountMeta.fractionDigits)
|
||||||
|
: (rewardAmount ?? '0'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const enterFrameId = window.requestAnimationFrame(() => {
|
||||||
|
setChildrenStage('visible')
|
||||||
|
})
|
||||||
|
const exitTimerId = window.setTimeout(() => {
|
||||||
|
setChildrenStage('exiting')
|
||||||
|
}, REWARD_CHILDREN_FADE_IN_MS + REWARD_CHILDREN_VISIBLE_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(enterFrameId)
|
||||||
|
window.clearTimeout(exitTimerId)
|
||||||
|
}
|
||||||
|
}, [childTimelineKey, rewardAmount, rewardAmountMeta])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (childTimelineKey === 'closed') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rewardAmountMeta) {
|
||||||
|
setDisplayRewardAmount(rewardAmount ?? '0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationFrameId = 0
|
||||||
|
const startedAt = performance.now()
|
||||||
|
|
||||||
|
const syncRewardAmount = (now: number) => {
|
||||||
|
const progress = Math.min(
|
||||||
|
(now - startedAt) / REWARD_CHILDREN_FADE_IN_MS,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
setDisplayRewardAmount(
|
||||||
|
progress >= 1
|
||||||
|
? formatRewardAmount(
|
||||||
|
rewardAmountMeta.numericAmount,
|
||||||
|
rewardAmountMeta.fractionDigits,
|
||||||
|
)
|
||||||
|
: formatRewardAmount(
|
||||||
|
rewardAmountMeta.numericAmount * easeOutCubic(progress),
|
||||||
|
rewardAmountMeta.fractionDigits,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
animationFrameId = window.requestAnimationFrame(syncRewardAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = window.requestAnimationFrame(syncRewardAmount)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrameId)
|
||||||
|
}
|
||||||
|
}, [childTimelineKey, rewardAmount, rewardAmountMeta])
|
||||||
|
|
||||||
|
if (shouldRenderReward && rewardSource) {
|
||||||
|
const playerKey = `${overlayAnimationKey}-${rewardSource.id}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 z-40 flex items-center justify-center overflow-hidden bg-[rgba(2,8,14,0.66)] px-design-6 backdrop-blur-[2px] transition-opacity duration-300',
|
||||||
|
isRewardFadingOut && 'pointer-events-none opacity-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(49,208,255,0.12),rgba(3,7,15,0.88)_62%,rgba(2,4,10,0.96)_100%)]"
|
||||||
|
/>
|
||||||
|
<LottiePlayer
|
||||||
|
key={playerKey}
|
||||||
|
path={rewardSource.path}
|
||||||
|
renderer={rewardSource.renderer ?? 'svg'}
|
||||||
|
loop={rewardSource.loop ?? false}
|
||||||
|
autoplay={rewardSource.autoplay ?? true}
|
||||||
|
className="absolute left-1/2 top-[48%] h-design-236 w-[126%] -translate-x-1/2 -translate-y-1/2 [&>svg]:h-full [&>svg]:w-full [&>svg]:object-cover [&_canvas]:h-full [&_canvas]:w-full"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute inset-0 z-10 flex items-start justify-center pt-design-76 transition-[opacity,transform,filter] ease-out',
|
||||||
|
childrenStage === 'visible' || childrenStage === 'exiting'
|
||||||
|
? 'duration-[2000ms]'
|
||||||
|
: 'duration-0',
|
||||||
|
childrenStage === 'visible' &&
|
||||||
|
'translate-y-0 scale-100 opacity-75 blur-none',
|
||||||
|
childrenStage === 'hidden' &&
|
||||||
|
'translate-y-[calc(var(--design-unit)*8)] scale-[0.96] opacity-0 blur-[calc(var(--design-unit)*1)]',
|
||||||
|
childrenStage === 'exiting' &&
|
||||||
|
'translate-y-[calc(var(--design-unit)*-6)] scale-[0.97] opacity-0 blur-[calc(var(--design-unit)*1)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SmartBackground
|
||||||
|
className="flex h-design-72 w-design-318 items-center justify-center gap-design-7 pb-design-19"
|
||||||
|
src={winBg}
|
||||||
|
size="contain"
|
||||||
|
>
|
||||||
|
<SmartImage
|
||||||
|
className="h-design-21 w-design-86 drop-shadow-[0_0_calc(var(--design-unit)*7)_rgba(255,218,122,0.72)]"
|
||||||
|
alt="win"
|
||||||
|
src={winLogo}
|
||||||
|
/>
|
||||||
|
<div className="h-design-22 min-w-design-68 animate-bounce text-center font-sans text-design-24 leading-[calc(var(--design-unit)*22)] font-black tabular-nums text-[#FFE89A] [animation-duration:900ms] [-webkit-text-stroke:calc(var(--design-unit)*0.6)_#8A3A08] [text-shadow:0_0_calc(var(--design-unit)*5)_rgba(255,236,154,0.95),0_calc(var(--design-unit)*2)_0_#7A2F05,0_0_calc(var(--design-unit)*12)_rgba(255,151,15,0.72)]">
|
||||||
|
{displayRewardAmount}
|
||||||
|
</div>
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revealPhase === 'result' && hasRenderedReward) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 z-40 bg-[rgba(2,8,14,0.72)] backdrop-blur-[2px]"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostingFlag) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center gap-design-10 bg-[rgba(2,8,14,0.38)] px-design-10 backdrop-blur-[1px]">
|
||||||
|
{showStopOverlay ? (
|
||||||
|
<div className="relative h-design-78 w-design-226 max-w-[80%] overflow-visible">
|
||||||
|
<SmartImage
|
||||||
|
src={stopImageSrc}
|
||||||
|
alt="stop betting"
|
||||||
|
priority
|
||||||
|
showSkeleton={false}
|
||||||
|
className="h-full w-full overflow-visible"
|
||||||
|
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*14)_rgba(60,235,255,0.28)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<SmartBackground
|
||||||
|
src={hostingBg}
|
||||||
|
size="100% 100%"
|
||||||
|
repeat="no-repeat"
|
||||||
|
position="center"
|
||||||
|
className="flex h-design-150 w-design-322 flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-design-18">
|
||||||
|
<div className="flex items-center gap-design-9">
|
||||||
|
<motion.span
|
||||||
|
aria-hidden="true"
|
||||||
|
animate={prefersReducedMotion ? undefined : { rotate: 360 }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.4,
|
||||||
|
ease: 'linear',
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
}}
|
||||||
|
className="flex h-design-24 w-design-24 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SmartImage
|
||||||
|
src={refreshIcon}
|
||||||
|
alt=""
|
||||||
|
priority
|
||||||
|
showSkeleton={false}
|
||||||
|
className="h-design-19 w-design-19"
|
||||||
|
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*12)_rgba(60,235,255,0.36)]"
|
||||||
|
/>
|
||||||
|
</motion.span>
|
||||||
|
<div className="text-design-13 font-bold text-white [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(76,236,255,0.45)]">
|
||||||
|
{t('game.autoSpin.runningRounds', {
|
||||||
|
count: completedAutoHostingRounds,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SmartBackground
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
onClick={stopHosting}
|
||||||
|
src={hostingBtn}
|
||||||
|
size="100% 100%"
|
||||||
|
repeat="no-repeat"
|
||||||
|
position="center"
|
||||||
|
className="flex h-design-36 w-design-112 cursor-pointer items-center justify-center pb-design-2 text-design-13 font-bold text-[#EFFFFF] [text-shadow:0_1px_0_rgba(255,255,255,0.18),0_0_calc(var(--design-unit)*7)_rgba(46,220,255,0.5)] transition-transform hover:-translate-y-[1px] active:translate-y-0"
|
||||||
|
>
|
||||||
|
{t('game.actions.stopAuto')}
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showStopOverlay) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-40 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-10 backdrop-blur-[2px]">
|
||||||
|
<div className="flex max-w-[92%] flex-col items-center justify-center gap-design-8">
|
||||||
|
<StopBetSummary
|
||||||
|
items={stopBetItems}
|
||||||
|
noBetText={t('gameDesktop.animal.noBet')}
|
||||||
|
/>
|
||||||
|
<SmartImage
|
||||||
|
src={stopImageSrc}
|
||||||
|
alt="stop betting"
|
||||||
|
priority
|
||||||
|
showSkeleton={false}
|
||||||
|
className="h-design-98 w-design-250 max-w-full overflow-visible"
|
||||||
|
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*14)_rgba(60,235,255,0.28)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
553
src/features/game/components/mobile/mobile-animal.tsx
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
import { TriangleAlert } from 'lucide-react'
|
||||||
|
import { motion, useReducedMotion } from 'motion/react'
|
||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import animalBorderImage from '@/assets/game/animal-border.webp'
|
||||||
|
import diamondIcon from '@/assets/system/diamond.webp'
|
||||||
|
import { SmartImage } from '@/components/smart-image'
|
||||||
|
import { MobileAnimalOverlay } from '@/features/game/components/mobile/mobile-animal-overlay.tsx'
|
||||||
|
import { MobileStatusLine } from '@/features/game/components/mobile/mobile-status.tsx'
|
||||||
|
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
|
||||||
|
import { useAnimalVm } from '@/hooks/use-animal-vm'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useGameRoundStore } from '@/store/game'
|
||||||
|
|
||||||
|
const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 3_200
|
||||||
|
const SETTLEMENT_REVEAL_SETTLE_DURATION_MS = 800
|
||||||
|
const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000
|
||||||
|
const SETTLEMENT_REVEAL_MIN_STEP_MS = 180
|
||||||
|
const SETTLEMENT_REVEAL_MAX_STEP_MS = 960
|
||||||
|
const MOBILE_REVEAL_FRAME_OFFSET_PX = 2
|
||||||
|
|
||||||
|
function getRandomAnimalId(ids: number[], currentId: number | null) {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length === 1) {
|
||||||
|
return ids[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = currentId
|
||||||
|
|
||||||
|
while (nextId === currentId) {
|
||||||
|
nextId = ids[Math.floor(Math.random() * ids.length)] ?? currentId
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextId
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettlementRevealStepDelay(progress: number) {
|
||||||
|
const clampedProgress = Math.min(Math.max(progress, 0), 1)
|
||||||
|
const easedProgress = clampedProgress ** 1.65
|
||||||
|
|
||||||
|
return (
|
||||||
|
SETTLEMENT_REVEAL_MIN_STEP_MS +
|
||||||
|
(SETTLEMENT_REVEAL_MAX_STEP_MS - SETTLEMENT_REVEAL_MIN_STEP_MS) *
|
||||||
|
easedProgress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileAnimalProps {
|
||||||
|
className?: string
|
||||||
|
itemClassName?: string
|
||||||
|
imageClassName?: string
|
||||||
|
onSelect?: (animalId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileAnimal({
|
||||||
|
className,
|
||||||
|
itemClassName,
|
||||||
|
imageClassName,
|
||||||
|
onSelect,
|
||||||
|
}: MobileAnimalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
const animalIds = useMemo(() => FLOWER_IMAGE_LIST.map((item) => item.id), [])
|
||||||
|
const containerRef = useRef<HTMLElement | null>(null)
|
||||||
|
const cellRefs = useRef(new Map<number, HTMLButtonElement>())
|
||||||
|
const [revealCellId, setRevealCellId] = useState<number | null>(null)
|
||||||
|
const [revealFrame, setRevealFrame] = useState<{
|
||||||
|
height: number
|
||||||
|
left: number
|
||||||
|
top: number
|
||||||
|
width: number
|
||||||
|
} | null>(null)
|
||||||
|
const [isRevealHoldingResult, setIsRevealHoldingResult] = useState(false)
|
||||||
|
const [isRevealSettlingResult, setIsRevealSettlingResult] = useState(false)
|
||||||
|
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
|
||||||
|
const revealWinningCellId = useGameRoundStore(
|
||||||
|
(state) => state.revealAnimation.winningCellId,
|
||||||
|
)
|
||||||
|
const revealRewardType = useGameRoundStore(
|
||||||
|
(state) => state.revealAnimation.rewardType,
|
||||||
|
)
|
||||||
|
const roundPhase = useGameRoundStore((state) => state.round.phase)
|
||||||
|
const roundId = useGameRoundStore((state) => state.round.id)
|
||||||
|
const lastBetPeriodNo = useAuthStore(
|
||||||
|
(state) => state.currentUser?.lastBetPeriodNo,
|
||||||
|
)
|
||||||
|
const finishRevealAnimation = useGameRoundStore(
|
||||||
|
(state) => state.finishRevealAnimation,
|
||||||
|
)
|
||||||
|
const {
|
||||||
|
cellWarning,
|
||||||
|
handleSelect,
|
||||||
|
handleStart,
|
||||||
|
isRealtimeConnecting,
|
||||||
|
lockInteraction,
|
||||||
|
marqueeId,
|
||||||
|
selectionByCell,
|
||||||
|
showStandbyState,
|
||||||
|
} = useAnimalVm(animalIds, onSelect)
|
||||||
|
|
||||||
|
const isRevealRunning =
|
||||||
|
revealPhase === 'spinning' ||
|
||||||
|
(revealPhase === 'stopping' && !isRevealHoldingResult)
|
||||||
|
const isRevealResult = revealPhase === 'result'
|
||||||
|
const [hasRewardOverlayStarted, setHasRewardOverlayStarted] = useState(false)
|
||||||
|
const shouldHideRevealHighlight =
|
||||||
|
revealPhase === 'result' &&
|
||||||
|
(revealRewardType !== 'none' || hasRewardOverlayStarted)
|
||||||
|
const hasSubmittedCurrentRound =
|
||||||
|
roundPhase === 'betting' && Boolean(roundId) && lastBetPeriodNo === roundId
|
||||||
|
const showStopOverlay =
|
||||||
|
hasSubmittedCurrentRound ||
|
||||||
|
roundPhase === 'locked' ||
|
||||||
|
roundPhase === 'revealing'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (revealPhase === 'idle') {
|
||||||
|
setHasRewardOverlayStarted(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revealPhase === 'result' && revealRewardType !== 'none') {
|
||||||
|
setHasRewardOverlayStarted(true)
|
||||||
|
}
|
||||||
|
}, [revealPhase, revealRewardType])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (revealPhase === 'idle') {
|
||||||
|
setRevealCellId(null)
|
||||||
|
setIsRevealHoldingResult(false)
|
||||||
|
setIsRevealSettlingResult(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revealPhase === 'result') {
|
||||||
|
setRevealCellId(shouldHideRevealHighlight ? null : revealWinningCellId)
|
||||||
|
setIsRevealHoldingResult(false)
|
||||||
|
setIsRevealSettlingResult(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revealPhase === 'spinning') {
|
||||||
|
setIsRevealHoldingResult(false)
|
||||||
|
setIsRevealSettlingResult(false)
|
||||||
|
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
|
||||||
|
}, 70)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revealWinningCellId === null) {
|
||||||
|
setIsRevealHoldingResult(false)
|
||||||
|
setIsRevealSettlingResult(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = performance.now()
|
||||||
|
let timeoutId = 0
|
||||||
|
setIsRevealHoldingResult(false)
|
||||||
|
setIsRevealSettlingResult(false)
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
const elapsedMs = performance.now() - startedAt
|
||||||
|
|
||||||
|
if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) {
|
||||||
|
setIsRevealSettlingResult(true)
|
||||||
|
setRevealCellId(revealWinningCellId)
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
setIsRevealSettlingResult(false)
|
||||||
|
setIsRevealHoldingResult(true)
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
finishRevealAnimation()
|
||||||
|
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
|
||||||
|
}, SETTLEMENT_REVEAL_SETTLE_DURATION_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
|
||||||
|
|
||||||
|
const progress = elapsedMs / SETTLEMENT_REVEAL_RANDOM_DURATION_MS
|
||||||
|
const nextDelayMs = getSettlementRevealStepDelay(progress)
|
||||||
|
const remainingMs = SETTLEMENT_REVEAL_RANDOM_DURATION_MS - elapsedMs
|
||||||
|
|
||||||
|
timeoutId = window.setTimeout(step, Math.min(nextDelayMs, remainingMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
|
||||||
|
timeoutId = window.setTimeout(step, SETTLEMENT_REVEAL_MIN_STEP_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
animalIds,
|
||||||
|
finishRevealAnimation,
|
||||||
|
revealPhase,
|
||||||
|
revealWinningCellId,
|
||||||
|
shouldHideRevealHighlight,
|
||||||
|
])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (revealCellId === null) {
|
||||||
|
setRevealFrame(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncRevealFrame = () => {
|
||||||
|
const container = containerRef.current
|
||||||
|
const cell = cellRefs.current.get(revealCellId)
|
||||||
|
|
||||||
|
if (!container || !cell) {
|
||||||
|
setRevealFrame(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
const cellRect = cell.getBoundingClientRect()
|
||||||
|
|
||||||
|
setRevealFrame({
|
||||||
|
height: cellRect.height,
|
||||||
|
left: cellRect.left - containerRect.left,
|
||||||
|
top: cellRect.top - containerRect.top,
|
||||||
|
width: cellRect.width,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
syncRevealFrame()
|
||||||
|
window.addEventListener('resize', syncRevealFrame)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', syncRevealFrame)
|
||||||
|
}
|
||||||
|
}, [revealCellId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
'common-neon-inset-glow relative flex w-full flex-col gap-design-5 overflow-hidden !rounded-[calc(var(--design-unit)*6)] !px-design-5 !py-design-5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MobileStatusLine />
|
||||||
|
|
||||||
|
<div className="relative z-10 grid w-full grid-cols-4 gap-design-3">
|
||||||
|
{FLOWER_IMAGE_LIST.map((item) => {
|
||||||
|
const selectionMeta = selectionByCell[item.id]
|
||||||
|
const hasPlacedSelection = Boolean(selectionMeta)
|
||||||
|
const isMarqueeActive = showStandbyState && item.id === marqueeId
|
||||||
|
const isRevealWinner =
|
||||||
|
!shouldHideRevealHighlight &&
|
||||||
|
(isRevealResult || isRevealHoldingResult) &&
|
||||||
|
revealWinningCellId === item.id
|
||||||
|
const warningType =
|
||||||
|
cellWarning?.cellId === item.id ? cellWarning.type : null
|
||||||
|
const showCellWarning = warningType !== null
|
||||||
|
const warningLabel =
|
||||||
|
warningType === 'balance'
|
||||||
|
? t('gameDesktop.animal.insufficientBalanceRecharge')
|
||||||
|
: warningType === 'betLimit'
|
||||||
|
? t('gameDesktop.animal.betLimitExceeded')
|
||||||
|
: t('gameDesktop.animal.selectionLimitReached')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={item.id}
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) {
|
||||||
|
cellRefs.current.set(item.id, node)
|
||||||
|
} else {
|
||||||
|
cellRefs.current.delete(item.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={lockInteraction || showStopOverlay}
|
||||||
|
onClick={() => handleSelect(item.id)}
|
||||||
|
animate={
|
||||||
|
showCellWarning
|
||||||
|
? {
|
||||||
|
rotate: [0, -1.8, 1.4, -1, 0.6, 0],
|
||||||
|
scale: [1, 1.015, 0.992, 1.01, 1],
|
||||||
|
x: [0, -2, 2, -1, 1, 0],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
rotate: 0,
|
||||||
|
scale: 1,
|
||||||
|
x: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
showCellWarning
|
||||||
|
? {
|
||||||
|
duration: 0.46,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}
|
||||||
|
: { duration: 0.16, ease: 'easeOut' }
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'relative flex h-design-36 flex-col items-center justify-center overflow-hidden rounded-[calc(var(--design-unit)*6)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
|
||||||
|
lockInteraction
|
||||||
|
? 'cursor-not-allowed opacity-90'
|
||||||
|
: 'cursor-pointer hover:-translate-y-[1px]',
|
||||||
|
isMarqueeActive &&
|
||||||
|
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
|
||||||
|
isRevealRunning &&
|
||||||
|
'border-[rgba(104,255,249,0.76)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(68,244,255,0.46),0_0_calc(var(--design-unit)*22)_rgba(37,214,255,0.28),inset_0_0_calc(var(--design-unit)*16)_rgba(115,255,247,0.18)] brightness-115 saturate-125',
|
||||||
|
isRevealWinner &&
|
||||||
|
'border-[rgba(121,255,250,0.72)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(81,248,255,0.54),0_0_calc(var(--design-unit)*22)_rgba(30,199,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.24)] brightness-110 saturate-120',
|
||||||
|
showCellWarning &&
|
||||||
|
'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]',
|
||||||
|
!showStandbyState && !hasPlacedSelection && 'opacity-95',
|
||||||
|
itemClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SmartImage
|
||||||
|
src={animalBorderImage}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
priority
|
||||||
|
showSkeleton={false}
|
||||||
|
className="pointer-events-none absolute inset-0 z-20 h-full w-full"
|
||||||
|
imgClassName="object-fill"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute left-design-6 top-design-4 z-30 text-design-10 font-bold leading-none text-[#4BFFFE]">
|
||||||
|
{String(item.id).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<motion.span
|
||||||
|
aria-hidden="true"
|
||||||
|
animate={
|
||||||
|
showCellWarning
|
||||||
|
? {
|
||||||
|
opacity: [0.45, 1, 0.6, 1, 0.82],
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
showCellWarning
|
||||||
|
? {
|
||||||
|
duration: 0.52,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute inset-[calc(var(--design-unit)*1)] rounded-[calc(var(--design-unit)*6)] opacity-0 transition-opacity duration-150',
|
||||||
|
isMarqueeActive &&
|
||||||
|
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
|
||||||
|
isRevealRunning &&
|
||||||
|
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.36)_0%,rgba(77,244,255,0.16)_40%,rgba(27,183,255,0.07)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(95,249,255,0.46),inset_0_0_calc(var(--design-unit)*18)_rgba(151,255,250,0.24)]',
|
||||||
|
isRevealWinner &&
|
||||||
|
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.34)_0%,rgba(67,226,255,0.16)_38%,rgba(25,131,255,0.07)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(92,248,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(126,255,250,0.24)]',
|
||||||
|
showCellWarning &&
|
||||||
|
'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!showStandbyState && !hasPlacedSelection ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-[calc(var(--design-unit)*1)] z-20 rounded-[calc(var(--design-unit)*6)] bg-[rgba(4,16,24,0.52)] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(3,9,14,0.56)]"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<SmartImage
|
||||||
|
src={item.animalUrl}
|
||||||
|
alt={`animal-${item.id}`}
|
||||||
|
className={cn(
|
||||||
|
'absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*6)]',
|
||||||
|
isRevealRunning &&
|
||||||
|
'brightness-115 saturate-125 drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(101,250,255,0.42)]',
|
||||||
|
isRevealWinner &&
|
||||||
|
'brightness-110 saturate-120 drop-shadow-[0_0_calc(var(--design-unit)*9)_rgba(106,250,255,0.44)]',
|
||||||
|
imageClassName,
|
||||||
|
)}
|
||||||
|
imgClassName="object-fill"
|
||||||
|
/>
|
||||||
|
{showCellWarning ? (
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0, scale: 0.94 }}
|
||||||
|
animate={{
|
||||||
|
opacity: [0.2, 1, 0.92],
|
||||||
|
scale: [0.96, 1.02, 1],
|
||||||
|
boxShadow: [
|
||||||
|
'inset 0 0 calc(var(--design-unit)*10) rgba(255,108,108,0.16), 0 0 calc(var(--design-unit)*8) rgba(255,60,60,0.12)',
|
||||||
|
'inset 0 0 calc(var(--design-unit)*20) rgba(255,108,108,0.28), 0 0 calc(var(--design-unit)*20) rgba(255,60,60,0.28)',
|
||||||
|
'inset 0 0 calc(var(--design-unit)*16) rgba(255,108,108,0.22), 0 0 calc(var(--design-unit)*18) rgba(255,60,60,0.2)',
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
|
className="pointer-events-none absolute inset-[calc(var(--design-unit)*2)] z-30 flex flex-col items-center justify-center gap-design-2 rounded-[calc(var(--design-unit)*6)] border border-[rgba(255,126,126,0.9)] bg-[rgba(61,0,0,0.58)] px-design-2 py-design-2 text-center"
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
animate={{
|
||||||
|
opacity: [0.72, 1, 0.92],
|
||||||
|
scale: [0.94, 1.08, 1],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.38, ease: 'easeOut' }}
|
||||||
|
className="flex h-design-10 w-design-10 items-center justify-center"
|
||||||
|
>
|
||||||
|
<TriangleAlert className="h-design-10 w-design-10 text-[#FFD0D0] drop-shadow-[0_0_calc(var(--design-unit)*5)_rgba(255,92,92,0.45)]" />
|
||||||
|
</motion.span>
|
||||||
|
<motion.span
|
||||||
|
animate={{
|
||||||
|
opacity: [0.7, 1, 0.96],
|
||||||
|
scale: [0.98, 1.03, 1],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.42, ease: 'easeOut' }}
|
||||||
|
className="text-design-6 font-bold leading-tight tracking-[0.02em] text-[#FFE0E0] [text-shadow:0_0_calc(var(--design-unit)*6)_rgba(255,132,132,0.42)]"
|
||||||
|
>
|
||||||
|
{warningLabel}
|
||||||
|
</motion.span>
|
||||||
|
</motion.span>
|
||||||
|
) : null}
|
||||||
|
{hasPlacedSelection ? (
|
||||||
|
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||||
|
<span className="flex min-w-design-36 items-center justify-center gap-design-1 rounded-full border border-[rgba(162,242,255,0.48)] bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-3 py-design-1 shadow-[0_0_calc(var(--design-unit)*8)_rgba(70,245,255,0.18)]">
|
||||||
|
<SmartImage
|
||||||
|
src={diamondIcon}
|
||||||
|
alt="diamond"
|
||||||
|
className="h-design-9 w-design-9 shrink-0 object-contain"
|
||||||
|
/>
|
||||||
|
<span className="text-design-8 font-semibold leading-none tracking-[0.03em] text-[#D8FBFF]">
|
||||||
|
{selectionMeta.amount}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{revealFrame ? (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute left-0 top-0 z-40 transform-gpu transition-[height,transform,width]',
|
||||||
|
prefersReducedMotion
|
||||||
|
? 'duration-0'
|
||||||
|
: isRevealSettlingResult
|
||||||
|
? 'duration-[800ms] ease-[cubic-bezier(0.16,1,0.3,1)]'
|
||||||
|
: 'duration-[160ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
height: revealFrame.height + MOBILE_REVEAL_FRAME_OFFSET_PX * 2,
|
||||||
|
transform: `translate(${revealFrame.left - MOBILE_REVEAL_FRAME_OFFSET_PX}px, ${revealFrame.top - MOBILE_REVEAL_FRAME_OFFSET_PX}px)`,
|
||||||
|
width: revealFrame.width + MOBILE_REVEAL_FRAME_OFFSET_PX * 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mobile-gold-reveal-glow rounded-[calc(var(--design-unit)*7)]"
|
||||||
|
style={
|
||||||
|
prefersReducedMotion
|
||||||
|
? {
|
||||||
|
animation: 'none',
|
||||||
|
opacity: 0.36,
|
||||||
|
transform: 'scale(1)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="mobile-gold-reveal-static-border rounded-[calc(var(--design-unit)*7)]" />
|
||||||
|
<div
|
||||||
|
className="mobile-gold-reveal-shell rounded-[calc(var(--design-unit)*7)]"
|
||||||
|
style={prefersReducedMotion ? { animation: 'none' } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MobileAnimalOverlay showStopOverlay={showStopOverlay} />
|
||||||
|
|
||||||
|
{showStandbyState ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleStart}
|
||||||
|
aria-busy={isRealtimeConnecting}
|
||||||
|
className="group absolute inset-0 z-10 flex cursor-pointer items-center justify-center overflow-hidden bg-[rgba(3,13,20,0.66)]"
|
||||||
|
>
|
||||||
|
<div className="relative flex min-w-design-176 flex-col items-center gap-design-7 rounded-[calc(var(--design-unit)*12)] border border-[rgba(111,255,247,0.56)] bg-[linear-gradient(180deg,rgba(8,30,42,0.94),rgba(4,14,20,0.96))] px-design-18 py-design-12 text-center shadow-[0_0_calc(var(--design-unit)*14)_rgba(70,245,255,0.38),0_0_calc(var(--design-unit)*28)_rgba(19,210,232,0.22),inset_0_0_calc(var(--design-unit)*14)_rgba(120,255,249,0.12)] transition-[transform,box-shadow,border-color] duration-200 group-hover:-translate-y-[1px] group-hover:border-[rgba(141,255,250,0.82)]">
|
||||||
|
<span className="pointer-events-none absolute inset-[1px] rounded-[calc(var(--design-unit)*12)] border border-[rgba(226,255,255,0.1)]" />
|
||||||
|
<span className="pointer-events-none absolute inset-x-design-10 top-design-7 h-design-10 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.16),rgba(255,255,255,0))] opacity-70" />
|
||||||
|
<span className="text-design-9 uppercase tracking-[0.28em] text-[rgba(132,255,248,0.72)]">
|
||||||
|
{isRealtimeConnecting
|
||||||
|
? t('gameDesktop.animal.loading')
|
||||||
|
: t('gameDesktop.animal.tapToEnter')}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-design-7">
|
||||||
|
{isRealtimeConnecting ? (
|
||||||
|
<span className="relative flex h-design-16 w-design-16 items-center justify-center">
|
||||||
|
<motion.span
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.2,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
ease: 'linear',
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 rounded-full border-2 border-[rgba(119,255,250,0.22)] border-t-[rgba(119,255,250,0.98)] border-r-[rgba(119,255,250,0.56)]"
|
||||||
|
/>
|
||||||
|
<motion.span
|
||||||
|
animate={{ scale: [0.85, 1, 0.85], opacity: [0.6, 1, 0.6] }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.2,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
className="h-design-5 w-design-5 rounded-full bg-[#BFFFFD] shadow-[0_0_calc(var(--design-unit)*7)_rgba(114,255,249,0.72)]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="h-design-6 w-design-6 rounded-full bg-[rgba(126,255,248,0.92)] shadow-[0_0_calc(var(--design-unit)*7)_rgba(114,255,249,0.62)]" />
|
||||||
|
)}
|
||||||
|
<span className="text-design-17 font-semibold tracking-[0.12em] text-[#E0FFFF]">
|
||||||
|
{isRealtimeConnecting
|
||||||
|
? t('gameDesktop.animal.loading')
|
||||||
|
: t('gameDesktop.animal.getStart')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-design-4">
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<motion.span
|
||||||
|
key={`loading-dot-${index}`}
|
||||||
|
animate={
|
||||||
|
isRealtimeConnecting
|
||||||
|
? { opacity: [0.28, 1, 0.28], y: [0, -2, 0] }
|
||||||
|
: { opacity: 0.7 }
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
isRealtimeConnecting
|
||||||
|
? {
|
||||||
|
duration: 0.9,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
delay: index * 0.15,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="h-design-4 w-design-4 rounded-full bg-[rgba(145,255,249,0.86)] shadow-[0_0_calc(var(--design-unit)*8)_rgba(114,255,249,0.48)]"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useGameRoundStore } from '@/store/game'
|
||||||
|
|
||||||
|
const BETTING_START_ALERT_DURATION_MS = 2000
|
||||||
|
|
||||||
|
export function MobileBettingStartAlert() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
const roundId = useGameRoundStore((state) => state.round.id)
|
||||||
|
const roundPhase = useGameRoundStore((state) => state.round.phase)
|
||||||
|
const lastShownRoundIdRef = useRef<string | null>(null)
|
||||||
|
const [visibleRoundId, setVisibleRoundId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (roundPhase !== 'betting' || !roundId) {
|
||||||
|
setVisibleRoundId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastShownRoundIdRef.current === roundId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastShownRoundIdRef.current = roundId
|
||||||
|
setVisibleRoundId(roundId)
|
||||||
|
|
||||||
|
const timerId = window.setTimeout(() => {
|
||||||
|
setVisibleRoundId((currentRoundId) =>
|
||||||
|
currentRoundId === roundId ? null : currentRoundId,
|
||||||
|
)
|
||||||
|
}, BETTING_START_ALERT_DURATION_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timerId)
|
||||||
|
}
|
||||||
|
}, [roundId, roundPhase])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{visibleRoundId ? (
|
||||||
|
<motion.div
|
||||||
|
aria-live="polite"
|
||||||
|
className="pointer-events-none fixed left-1/2 top-[calc(var(--design-unit)*258)] z-50 w-[calc(100%-44*var(--design-unit))] max-w-[calc(var(--design-unit)*318)] -translate-x-1/2"
|
||||||
|
initial={
|
||||||
|
prefersReducedMotion
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { opacity: 0, scale: 0.9, y: 'calc(var(--design-unit)*18)' }
|
||||||
|
}
|
||||||
|
animate={
|
||||||
|
prefersReducedMotion
|
||||||
|
? { opacity: 1 }
|
||||||
|
: { opacity: 1, scale: 1, y: 0 }
|
||||||
|
}
|
||||||
|
exit={
|
||||||
|
prefersReducedMotion
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { opacity: 0, scale: 0.94, y: 'calc(var(--design-unit)*-8)' }
|
||||||
|
}
|
||||||
|
transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="relative overflow-hidden rounded-[calc(var(--design-unit)*9)] border border-[rgba(126,255,248,0.78)] bg-[linear-gradient(180deg,rgba(4,38,49,0.96),rgba(2,18,28,0.98))] px-design-16 py-design-12 text-center shadow-[0_0_calc(var(--design-unit)*14)_rgba(70,245,255,0.42),0_0_calc(var(--design-unit)*34)_rgba(18,206,232,0.26),inset_0_0_calc(var(--design-unit)*16)_rgba(126,255,248,0.18)]"
|
||||||
|
animate={
|
||||||
|
prefersReducedMotion
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
boxShadow: [
|
||||||
|
'0 0 calc(var(--design-unit)*10) rgba(70,245,255,0.34), 0 0 calc(var(--design-unit)*22) rgba(18,206,232,0.2), inset 0 0 calc(var(--design-unit)*12) rgba(126,255,248,0.14)',
|
||||||
|
'0 0 calc(var(--design-unit)*18) rgba(87,250,255,0.54), 0 0 calc(var(--design-unit)*38) rgba(25,223,255,0.34), inset 0 0 calc(var(--design-unit)*18) rgba(146,255,251,0.22)',
|
||||||
|
'0 0 calc(var(--design-unit)*12) rgba(70,245,255,0.38), 0 0 calc(var(--design-unit)*28) rgba(18,206,232,0.26), inset 0 0 calc(var(--design-unit)*14) rgba(126,255,248,0.16)',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
prefersReducedMotion
|
||||||
|
? undefined
|
||||||
|
: { duration: 0.72, ease: 'easeInOut' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute inset-[1px] rounded-[calc(var(--design-unit)*9)] border border-[rgba(229,255,255,0.13)]" />
|
||||||
|
<motion.span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-x-design-14 top-design-5 h-design-12 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.18),rgba(255,255,255,0))]"
|
||||||
|
animate={
|
||||||
|
prefersReducedMotion
|
||||||
|
? undefined
|
||||||
|
: { opacity: [0.5, 0.88, 0.58], x: ['-6%', '6%', '-2%'] }
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
prefersReducedMotion
|
||||||
|
? undefined
|
||||||
|
: { duration: 1.1, ease: 'easeInOut' }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 flex flex-col items-center gap-design-8">
|
||||||
|
<div className="text-design-11 font-semibold leading-tight text-[#78FF7F]">
|
||||||
|
{t('game.roundBettingStart.title', {
|
||||||
|
roundId: visibleRoundId,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
className="relative flex items-center justify-center px-design-8 text-design-24 font-bold leading-tight text-[#F2FFFF] [text-shadow:0_0_calc(var(--design-unit)*12)_rgba(92,249,255,0.66)]"
|
||||||
|
animate={
|
||||||
|
prefersReducedMotion
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
opacity: [0.88, 1, 0.92],
|
||||||
|
scale: [0.98, 1.06, 0.99],
|
||||||
|
textShadow: [
|
||||||
|
'0 0 calc(var(--design-unit)*8) rgba(92,249,255,0.52), 0 0 calc(var(--design-unit)*16) rgba(120,255,127,0.22)',
|
||||||
|
'0 0 calc(var(--design-unit)*14) rgba(111,255,255,0.9), 0 0 calc(var(--design-unit)*26) rgba(120,255,127,0.44)',
|
||||||
|
'0 0 calc(var(--design-unit)*10) rgba(92,249,255,0.62), 0 0 calc(var(--design-unit)*18) rgba(120,255,127,0.28)',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
prefersReducedMotion
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
duration: 1.05,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="relative z-10">
|
||||||
|
{t('game.roundBettingStart.action')}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
550
src/features/game/components/mobile/mobile-control.tsx
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import reduce from '@/assets/game/add.webp'
|
||||||
|
import chipBg from '@/assets/game/chip-bg.webp'
|
||||||
|
import chipLineBg from '@/assets/game/chip-line-bg.webp'
|
||||||
|
import chipLock from '@/assets/game/chip-lock.webp'
|
||||||
|
import confirmRedBg from '@/assets/game/confirm-red-bg.png'
|
||||||
|
import controlBg from '@/assets/game/control-bg.png'
|
||||||
|
import leftBottomBg from '@/assets/game/left-bg.webp'
|
||||||
|
import mobileAddReduceBg from '@/assets/game/mobile-add-reduce-bg.webp'
|
||||||
|
import mobileConfirmBg from '@/assets/game/mobile-contro-comfirm.webp'
|
||||||
|
import mobileTotalBg from '@/assets/game/mobile-control-number.webp'
|
||||||
|
import add from '@/assets/game/reduce.webp'
|
||||||
|
import diamond from '@/assets/system/diamond.webp'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { ACTION_OPTIONS } from '@/constants'
|
||||||
|
import { useGameControlVm } from '@/hooks/use-game-control-vm.ts'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
export function MobileControl() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const {
|
||||||
|
acceptingBets,
|
||||||
|
actionsEnabled,
|
||||||
|
canClear,
|
||||||
|
canDecreaseBetQuantity,
|
||||||
|
chips,
|
||||||
|
confirmLabel,
|
||||||
|
confirmState,
|
||||||
|
isConfirmClickable,
|
||||||
|
maxSelectionCountLabel,
|
||||||
|
onChipSelect,
|
||||||
|
onConfirm,
|
||||||
|
onClearSelections,
|
||||||
|
onDecreaseBetQuantity,
|
||||||
|
onIncreaseBetQuantity,
|
||||||
|
onOpenAutoSetting,
|
||||||
|
onRepeatSelections,
|
||||||
|
selectedBetQuantityLabel,
|
||||||
|
selectedChipId,
|
||||||
|
selectedCountLabel,
|
||||||
|
totalBetAmountLabel,
|
||||||
|
} = useGameControlVm()
|
||||||
|
const [clickedId, setClickedId] = useState<string | null>(null)
|
||||||
|
const [hidingId, setHidingId] = useState<string | null>(null)
|
||||||
|
const [confirmClicked, setConfirmClicked] = useState(false)
|
||||||
|
const clickResetTimerRef = useRef<number | null>(null)
|
||||||
|
const hideResetTimerRef = useRef<number | null>(null)
|
||||||
|
const confirmResetTimerRef = useRef<number | null>(null)
|
||||||
|
const isConfirmWarning =
|
||||||
|
confirmState === 'insufficient' || confirmState === 'limit'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (clickResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(clickResetTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(hideResetTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(confirmResetTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleChipClick = (chipId: string) => {
|
||||||
|
if (!acceptingBets) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onChipSelect(chipId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActionClick = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (!actionsEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'clear' && canClear) {
|
||||||
|
onClearSelections()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'repeat') {
|
||||||
|
onRepeatSelections()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'auto-spin') {
|
||||||
|
onOpenAutoSetting()
|
||||||
|
}
|
||||||
|
|
||||||
|
setClickedId(id)
|
||||||
|
|
||||||
|
if (clickResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(clickResetTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
clickResetTimerRef.current = window.setTimeout(() => {
|
||||||
|
setClickedId(null)
|
||||||
|
setHidingId(id)
|
||||||
|
|
||||||
|
if (hideResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(hideResetTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
hideResetTimerRef.current = window.setTimeout(() => {
|
||||||
|
setHidingId(null)
|
||||||
|
}, 180)
|
||||||
|
}, 200)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
actionsEnabled,
|
||||||
|
canClear,
|
||||||
|
onClearSelections,
|
||||||
|
onOpenAutoSetting,
|
||||||
|
onRepeatSelections,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleConfirmClick = useCallback(() => {
|
||||||
|
if (!isConfirmClickable) {
|
||||||
|
void onConfirm()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfirmClicked(true)
|
||||||
|
|
||||||
|
if (confirmResetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(confirmResetTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmResetTimerRef.current = window.setTimeout(() => {
|
||||||
|
setConfirmClicked(false)
|
||||||
|
}, 200)
|
||||||
|
void onConfirm()
|
||||||
|
}, [isConfirmClickable, onConfirm])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-design-3 overflow-visible text-[#D5FBFF]">
|
||||||
|
<div className="flex h-design-42 w-full items-center gap-design-3">
|
||||||
|
<SmartBackground
|
||||||
|
src={chipBg}
|
||||||
|
size="100% 100%"
|
||||||
|
className="relative z-10 flex h-full min-w-0 flex-1 items-center bg-center bg-no-repeat px-design-8"
|
||||||
|
>
|
||||||
|
<SmartBackground
|
||||||
|
src={chipLineBg}
|
||||||
|
size="100% 100%"
|
||||||
|
className="flex h-design-32 min-w-0 flex-1 items-center justify-between gap-design-2 overflow-visible px-design-4"
|
||||||
|
>
|
||||||
|
{chips.map((chip) => {
|
||||||
|
const isSelected = chip.id === selectedChipId
|
||||||
|
const showLockedState = !acceptingBets
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={chip.id}
|
||||||
|
layout
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleChipClick(chip.id)}
|
||||||
|
disabled={showLockedState}
|
||||||
|
whileTap={showLockedState ? undefined : { scale: 0.94 }}
|
||||||
|
transition={{
|
||||||
|
layout: {
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 360,
|
||||||
|
damping: 26,
|
||||||
|
},
|
||||||
|
duration: 0.26,
|
||||||
|
}}
|
||||||
|
className="relative flex h-design-32 w-design-32 shrink-0 items-center justify-center rounded-full"
|
||||||
|
style={
|
||||||
|
showLockedState
|
||||||
|
? {
|
||||||
|
WebkitFilter: 'grayscale(100%)',
|
||||||
|
filter: 'grayscale(100%)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
animate={
|
||||||
|
isSelected && !showLockedState
|
||||||
|
? {
|
||||||
|
opacity: [0.22, 0.5, 0.22],
|
||||||
|
scaleX: [0.82, 1.02, 0.82],
|
||||||
|
scaleY: [0.42, 0.56, 0.42],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
opacity: 0.32,
|
||||||
|
scaleX: 0.88,
|
||||||
|
scaleY: 0.48,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
isSelected
|
||||||
|
? {
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}
|
||||||
|
: { duration: 0.2, ease: 'easeOut' }
|
||||||
|
}
|
||||||
|
className="pointer-events-none absolute bottom-[calc(var(--design-unit)*-2)] left-1/2 h-design-8 w-design-24 -translate-x-1/2 rounded-full bg-[rgba(0,0,0,0.48)] blur-[3px]"
|
||||||
|
/>
|
||||||
|
<motion.span
|
||||||
|
animate={
|
||||||
|
isSelected && !showLockedState
|
||||||
|
? {
|
||||||
|
opacity: [0.72, 1, 0.72],
|
||||||
|
scale: [0.96, 1.04, 0.96],
|
||||||
|
boxShadow: [
|
||||||
|
'0 0 0 1px rgba(245, 200, 107, 0.68), 0 0 6px rgba(245, 200, 107, 0.44), 0 0 12px rgba(245, 200, 107, 0.22), inset 0 0 6px rgba(255, 214, 110, 0.18)',
|
||||||
|
'0 0 0 1px rgba(245, 200, 107, 0.96), 0 0 10px rgba(245, 200, 107, 0.78), 0 0 18px rgba(245, 200, 107, 0.42), inset 0 0 8px rgba(255, 214, 110, 0.32)',
|
||||||
|
'0 0 0 1px rgba(245, 200, 107, 0.68), 0 0 6px rgba(245, 200, 107, 0.44), 0 0 12px rgba(245, 200, 107, 0.22), inset 0 0 6px rgba(255, 214, 110, 0.18)',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.92,
|
||||||
|
boxShadow: '0 0 0 0 rgba(0,0,0,0)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
isSelected
|
||||||
|
? {
|
||||||
|
duration: 1.6,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}
|
||||||
|
: { duration: 0.22, ease: 'easeOut' }
|
||||||
|
}
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-full"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
animate={
|
||||||
|
isSelected && !showLockedState
|
||||||
|
? {
|
||||||
|
y: [-1, -3, -1],
|
||||||
|
scale: [1.04, 1.1, 1.04],
|
||||||
|
filter: [
|
||||||
|
'drop-shadow(0 4px 6px rgba(0,0,0,0.22))',
|
||||||
|
'drop-shadow(0 7px 10px rgba(245, 200, 107, 0.28))',
|
||||||
|
'drop-shadow(0 4px 6px rgba(0,0,0,0.22))',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
filter: showLockedState
|
||||||
|
? 'none'
|
||||||
|
: 'drop-shadow(0 3px 5px rgba(0,0,0,0.34))',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 24 }}
|
||||||
|
className="relative z-10"
|
||||||
|
>
|
||||||
|
<motion.img
|
||||||
|
src={chip.src}
|
||||||
|
alt={`chip-${chip.amount}`}
|
||||||
|
draggable={false}
|
||||||
|
className="h-design-32 w-design-32 object-contain"
|
||||||
|
/>
|
||||||
|
{showLockedState && (
|
||||||
|
<img
|
||||||
|
src={chipLock}
|
||||||
|
alt="chip-locked"
|
||||||
|
draggable={false}
|
||||||
|
className="pointer-events-none absolute left-1/2 top-1/2 z-40 h-design-25 w-design-25 -translate-x-1/2 -translate-y-1/2 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="pointer-events-none absolute inset-x-0 top-1/2 z-30 -translate-y-1/2 text-center text-design-7 font-black leading-none tracking-[0.03em] text-white [text-shadow:0_1px_0_rgba(255,255,255,0.6),0_2px_3px_rgba(0,0,0,0.72),0_0_6px_rgba(255,255,255,0.22)]">
|
||||||
|
{chip.valueLabel}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SmartBackground>
|
||||||
|
</SmartBackground>
|
||||||
|
|
||||||
|
<SmartBackground
|
||||||
|
src={mobileAddReduceBg}
|
||||||
|
size="100% 100%"
|
||||||
|
className="relative z-20 -ml-design-6 flex h-full w-design-103 shrink-0 items-center justify-center gap-design-6 bg-center bg-no-repeat px-design-9 text-design-13 font-bold"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!acceptingBets || !canDecreaseBetQuantity}
|
||||||
|
onClick={onDecreaseBetQuantity}
|
||||||
|
className={cn(
|
||||||
|
'flex h-design-24 w-design-24 shrink-0 items-center justify-center',
|
||||||
|
acceptingBets && canDecreaseBetQuantity
|
||||||
|
? 'cursor-pointer'
|
||||||
|
: 'cursor-not-allowed opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SmartImage
|
||||||
|
src={add}
|
||||||
|
alt="decrease"
|
||||||
|
className="h-design-22 w-design-22"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex h-design-24 w-design-34 items-center justify-center rounded-sm bg-[#091118]">
|
||||||
|
{selectedBetQuantityLabel}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!acceptingBets}
|
||||||
|
onClick={onIncreaseBetQuantity}
|
||||||
|
className={cn(
|
||||||
|
'flex h-design-24 w-design-24 shrink-0 items-center justify-center',
|
||||||
|
acceptingBets
|
||||||
|
? 'cursor-pointer'
|
||||||
|
: 'cursor-not-allowed opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SmartImage
|
||||||
|
src={reduce}
|
||||||
|
alt="increase"
|
||||||
|
className="h-design-22 w-design-22"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-design-40 w-full items-center gap-design-3">
|
||||||
|
<SmartBackground
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
src={leftBottomBg}
|
||||||
|
size="100% 100%"
|
||||||
|
onClick={() => setModalOpen('desktopPeriodHistory', true)}
|
||||||
|
aria-label={t('gameDesktop.history.title')}
|
||||||
|
className="relative z-20 flex h-full w-design-60 shrink-0 items-center justify-center bg-center bg-no-repeat px-design-6 text-center !text-design-8 font-bold leading-[1.15] text-[#D5FBFF] transition-[filter] duration-200 hover:brightness-125 focus-visible:ring-2 focus-visible:ring-[#4FEAFF]"
|
||||||
|
>
|
||||||
|
{t('gameDesktop.history.title')}
|
||||||
|
</SmartBackground>
|
||||||
|
|
||||||
|
<SmartBackground
|
||||||
|
src={mobileTotalBg}
|
||||||
|
size="100% 100%"
|
||||||
|
className="relative z-10 flex h-full min-w-0 flex-[0.9] items-center justify-center gap-design-14 bg-center bg-no-repeat px-design-8 text-design-9 font-bold"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-design-3 leading-none">
|
||||||
|
<div>
|
||||||
|
{t('gameDesktop.control.selected')}:
|
||||||
|
<span className="text-[#FF5A5A]">{selectedCountLabel}</span>/
|
||||||
|
{maxSelectionCountLabel}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-design-4">
|
||||||
|
<span>{t('gameDesktop.control.totalBet')}:</span>
|
||||||
|
<SmartImage
|
||||||
|
className="h-design-13 w-design-13"
|
||||||
|
src={diamond}
|
||||||
|
alt="diamond"
|
||||||
|
/>
|
||||||
|
<span>{totalBetAmountLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SmartBackground>
|
||||||
|
|
||||||
|
<SmartBackground
|
||||||
|
src={controlBg}
|
||||||
|
size="100% 100%"
|
||||||
|
className={cn(
|
||||||
|
'desktop-control-actions !ml-[calc(var(--design-unit)*-12)] relative z-20 flex h-full min-w-0 flex-1 items-center overflow-hidden bg-center bg-no-repeat pl-design-4',
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
!actionsEnabled
|
||||||
|
? {
|
||||||
|
WebkitFilter: 'grayscale(100%)',
|
||||||
|
filter: 'grayscale(100%)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ACTION_OPTIONS.map(({ id, labelKey, Icon, bg }) => {
|
||||||
|
const isClicked = clickedId === id
|
||||||
|
const isHiding = hidingId === id
|
||||||
|
const showBg = actionsEnabled && (isClicked || isHiding)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
disabled={!actionsEnabled}
|
||||||
|
onClick={() => handleActionClick(id)}
|
||||||
|
whileHover={actionsEnabled ? { y: -1, scale: 1.01 } : undefined}
|
||||||
|
whileTap={actionsEnabled ? { scale: 0.96 } : undefined}
|
||||||
|
className={cn(
|
||||||
|
'relative flex h-full min-w-0 flex-1 items-center justify-center overflow-hidden',
|
||||||
|
actionsEnabled ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||||
|
id === 'auto-spin' &&
|
||||||
|
'-translate-x-[calc(var(--design-unit)*1.5)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showBg && (
|
||||||
|
<SmartBackground
|
||||||
|
as={motion.span}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={isClicked ? { opacity: 1 } : { opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: isClicked ? 0.15 : 0.18,
|
||||||
|
ease: 'easeOut',
|
||||||
|
}}
|
||||||
|
src={bg}
|
||||||
|
className="pointer-events-none absolute inset-0 h-full bg-center bg-no-repeat"
|
||||||
|
size={
|
||||||
|
id === 'clear'
|
||||||
|
? '110% 90%'
|
||||||
|
: id === 'repeat'
|
||||||
|
? '98% 80%'
|
||||||
|
: '96% 80%'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<motion.div
|
||||||
|
animate={
|
||||||
|
showBg
|
||||||
|
? {
|
||||||
|
y: -1,
|
||||||
|
scale: 1.01,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
duration: showBg ? 0.22 : 0.18,
|
||||||
|
ease: 'easeOut',
|
||||||
|
}}
|
||||||
|
className="relative z-10 flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={14}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
className={showBg ? 'text-[#D9FEFF]' : 'text-[#37D5CB]'}
|
||||||
|
/>
|
||||||
|
<div className="mt-design-4 text-design-8 leading-none">
|
||||||
|
{t(labelKey)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SmartBackground
|
||||||
|
as={motion.button}
|
||||||
|
src={isConfirmWarning ? confirmRedBg : mobileConfirmBg}
|
||||||
|
size="100% 100%"
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirmClick}
|
||||||
|
whileHover={isConfirmClickable ? { scale: 1.005 } : undefined}
|
||||||
|
whileTap={isConfirmClickable ? { scale: 0.97 } : undefined}
|
||||||
|
animate={
|
||||||
|
confirmState === 'ready'
|
||||||
|
? {
|
||||||
|
scale: [1, 1.018, 1],
|
||||||
|
filter: [
|
||||||
|
'drop-shadow(0 0 0 rgba(0,0,0,0))',
|
||||||
|
'drop-shadow(0 0 12px rgba(245,200,107,0.72))',
|
||||||
|
'drop-shadow(0 0 0 rgba(0,0,0,0))',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: confirmState === 'idle'
|
||||||
|
? { scale: 1, filter: 'grayscale(.95)' }
|
||||||
|
: { scale: 1, filter: 'none' }
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
confirmState === 'ready'
|
||||||
|
? {
|
||||||
|
duration: 1.2,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}
|
||||||
|
: { duration: 0.2, ease: 'easeOut' }
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
confirmState === 'idle'
|
||||||
|
? {
|
||||||
|
WebkitFilter: 'grayscale(.95)',
|
||||||
|
filter: 'grayscale(.95)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 flex h-design-39 w-full shrink-0 items-center justify-center overflow-hidden bg-center bg-no-repeat text-design-16 font-black tracking-[0.04em]',
|
||||||
|
isConfirmClickable ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{confirmClicked && (
|
||||||
|
<SmartBackground
|
||||||
|
as={motion.span}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="pointer-events-none absolute inset-0 bg-center bg-no-repeat"
|
||||||
|
src={isConfirmWarning ? confirmRedBg : mobileConfirmBg}
|
||||||
|
size="100% 100%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<motion.span
|
||||||
|
animate={
|
||||||
|
confirmState === 'ready'
|
||||||
|
? {
|
||||||
|
opacity: confirmClicked ? 0 : 1,
|
||||||
|
y: confirmClicked ? 2 : [0, -1, 0],
|
||||||
|
textShadow: [
|
||||||
|
'0 0 8px rgba(255,238,173,0.72), 0 0 16px rgba(255,214,96,0.4)',
|
||||||
|
'0 0 12px rgba(255,252,220,0.95), 0 0 24px rgba(255,214,96,0.8)',
|
||||||
|
'0 0 8px rgba(255,238,173,0.72), 0 0 16px rgba(255,214,96,0.4)',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
opacity: confirmClicked ? 0 : 1,
|
||||||
|
y: confirmClicked ? 2 : 0,
|
||||||
|
textShadow: isConfirmWarning
|
||||||
|
? '0 0 8px rgba(255,206,206,0.55)'
|
||||||
|
: '0 0 8px rgba(210,255,255,0.38)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
confirmState === 'ready'
|
||||||
|
? {
|
||||||
|
duration: 1.2,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}
|
||||||
|
: { duration: 0.15 }
|
||||||
|
}
|
||||||
|
className={cn('relative z-10', isConfirmWarning && 'text-[#FFF1F1]')}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</motion.span>
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
238
src/features/game/components/mobile/mobile-game-history.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { History } from 'lucide-react'
|
||||||
|
import { useCallback, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { SmartImage } from '@/components/smart-image'
|
||||||
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
|
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
|
||||||
|
import { useGameHistoryVm } from '@/hooks/use-game-history-vm.ts'
|
||||||
|
|
||||||
|
function HistoryRewardNumber({
|
||||||
|
className,
|
||||||
|
number,
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
number: number
|
||||||
|
}) {
|
||||||
|
const image = FLOWER_IMAGE_BY_ID[number]
|
||||||
|
const label = String(number).padStart(2, '0')
|
||||||
|
|
||||||
|
if (!image?.rewardUrl) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-design-24 min-w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(95,233,255,0.22)] bg-[rgba(6,28,39,0.7)] px-design-4 text-design-10 font-bold text-[#D5FBFF] align-middle ${className ?? ''}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-design-24 w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(95,233,255,0.16)] bg-[rgba(6,28,39,0.38)] p-design-1 align-middle ${className ?? ''}`}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<SmartImage
|
||||||
|
src={image.rewardUrl}
|
||||||
|
alt={label}
|
||||||
|
showSkeleton={false}
|
||||||
|
className="h-full w-full overflow-visible"
|
||||||
|
imgClassName="object-contain"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryEmptyState({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full w-full flex-1 items-center justify-center px-design-8 py-design-10">
|
||||||
|
<div className="flex w-fit max-w-full flex-col items-center rounded-[calc(var(--design-unit)*8)] border border-[rgba(94,212,230,0.14)] bg-[rgba(5,23,33,0.3)] px-design-12 py-design-10 text-center">
|
||||||
|
<div className="mb-design-6 flex h-design-32 w-design-32 items-center justify-center rounded-full border border-[#56EFFF]/18 bg-[#061D29]/55 shadow-[0_0_calc(var(--design-unit)*8)_rgba(86,239,255,0.08)]">
|
||||||
|
<History
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-design-14 w-design-14 text-[#A8EAF1]"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap text-design-11 font-semibold text-[#9CCFD4]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileGameHistory() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const {
|
||||||
|
emptyText,
|
||||||
|
endText,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isEmpty,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isInitialLoading,
|
||||||
|
items,
|
||||||
|
loadingText,
|
||||||
|
} = useGameHistoryVm()
|
||||||
|
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const element = parentRef.current
|
||||||
|
|
||||||
|
if (!element || !hasNextPage || isFetchingNextPage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceToBottom =
|
||||||
|
element.scrollHeight - element.scrollTop - element.clientHeight
|
||||||
|
|
||||||
|
if (distanceToBottom <= 90) {
|
||||||
|
void fetchNextPage()
|
||||||
|
}
|
||||||
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={parentRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="history-scroll-hidden flex h-full min-h-0 w-full flex-col gap-design-5 overflow-y-auto overflow-x-hidden"
|
||||||
|
>
|
||||||
|
{isInitialLoading ? (
|
||||||
|
<DataLoadingIndicator
|
||||||
|
compact
|
||||||
|
label={loadingText}
|
||||||
|
className="min-h-full flex-1 text-design-11"
|
||||||
|
/>
|
||||||
|
) : isEmpty ? (
|
||||||
|
<HistoryEmptyState label={emptyText} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{items.map((item) => {
|
||||||
|
const isWin = item.resultState === 'win'
|
||||||
|
const statusLabel =
|
||||||
|
item.resultState === 'pending'
|
||||||
|
? t('gameDesktop.history.pending')
|
||||||
|
: isWin
|
||||||
|
? t('gameDesktop.history.win')
|
||||||
|
: t('gameDesktop.history.lost')
|
||||||
|
const statusColor =
|
||||||
|
item.resultState === 'pending'
|
||||||
|
? '#D5FBFF'
|
||||||
|
: isWin
|
||||||
|
? '#FFE375'
|
||||||
|
: '#8DFF98'
|
||||||
|
const statusBorderColor =
|
||||||
|
item.resultState === 'pending'
|
||||||
|
? 'rgba(143,241,255,0.34)'
|
||||||
|
: isWin
|
||||||
|
? 'rgba(255,227,117,0.5)'
|
||||||
|
: 'rgba(141,255,152,0.36)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="w-full">
|
||||||
|
<div
|
||||||
|
className="relative isolate flex w-full flex-col overflow-hidden rounded-[calc(var(--design-unit)*7)] border bg-[linear-gradient(180deg,rgba(6,33,45,0.95),rgba(3,14,23,0.92))] shadow-[0_0_calc(var(--design-unit)*8)_rgba(63,226,255,0.08),inset_0_1px_0_rgba(218,255,255,0.08)]"
|
||||||
|
style={{
|
||||||
|
borderColor: statusBorderColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative z-10 flex min-h-design-24 items-center justify-between gap-design-5 border-b px-design-7 py-design-4"
|
||||||
|
style={{
|
||||||
|
borderColor: statusBorderColor,
|
||||||
|
background:
|
||||||
|
item.resultState === 'pending'
|
||||||
|
? 'linear-gradient(180deg,rgba(28,106,126,0.38),rgba(5,25,36,0.9))'
|
||||||
|
: isWin
|
||||||
|
? 'linear-gradient(180deg,rgba(127,92,14,0.5),rgba(29,22,8,0.92))'
|
||||||
|
: 'linear-gradient(180deg,rgba(31,111,54,0.38),rgba(6,28,20,0.92))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-design-9 text-[#8DBCC2]">
|
||||||
|
{item.createdAtLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex min-w-design-56 items-center justify-center gap-design-4 text-design-11 font-bold"
|
||||||
|
style={{ color: statusColor }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-design-4 w-design-4 rounded-full shadow-[0_0_calc(var(--design-unit)*5)_currentColor]"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
/>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate text-right text-design-9 text-[#C0E7EB]">
|
||||||
|
{t('gameDesktop.history.roundId')}: {item.periodNo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col gap-design-4 px-design-7 py-design-6 text-design-10">
|
||||||
|
<div className="flex items-start justify-between gap-design-6 rounded-[calc(var(--design-unit)*5)] border border-[rgba(94,212,230,0.12)] bg-[rgba(5,23,33,0.52)] px-design-6 py-design-4">
|
||||||
|
<span className="shrink-0 pt-design-3 text-[#84A2A2]">
|
||||||
|
{t('gameDesktop.history.numbers')}
|
||||||
|
</span>
|
||||||
|
{item.numbers.length === 0 ? (
|
||||||
|
<span className="pt-design-3 text-right">
|
||||||
|
{item.numbersLabel}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex min-w-0 flex-1 flex-wrap items-center justify-end gap-design-2 align-middle">
|
||||||
|
{item.numbers.map((number) => (
|
||||||
|
<HistoryRewardNumber
|
||||||
|
key={`${item.id}-${number}`}
|
||||||
|
number={number}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-design-4">
|
||||||
|
<div className="flex min-h-design-22 items-center justify-between gap-design-4 rounded-[calc(var(--design-unit)*5)] border border-[rgba(255,227,117,0.14)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(84,57,8,0.14))] px-design-6 py-design-4">
|
||||||
|
<span className="shrink-0 text-[#84A2A2]">
|
||||||
|
{t('gameDesktop.history.payout')}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate text-right text-design-11 font-bold text-[#FFE375]">
|
||||||
|
{item.winAmountLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-design-22 items-center justify-between gap-design-4 rounded-[calc(var(--design-unit)*5)] border border-[rgba(255,117,117,0.16)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(88,20,28,0.14))] px-design-6 py-design-4">
|
||||||
|
<span className="shrink-0 text-[#84A2A2]">
|
||||||
|
{t('gameDesktop.history.winningResult')}
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 items-center justify-end">
|
||||||
|
{item.resultNumber === null ? (
|
||||||
|
<span className="text-right font-bold text-[#FF7575]">
|
||||||
|
{item.resultNumberLabel}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<HistoryRewardNumber
|
||||||
|
className="text-[#FF7575]"
|
||||||
|
number={item.resultNumber}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex min-h-design-24 items-center justify-center text-design-10 text-[#84A2A2]">
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<DataLoadingIndicator compact label={loadingText} />
|
||||||
|
) : hasNextPage ? (
|
||||||
|
''
|
||||||
|
) : (
|
||||||
|
endText
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
UserRoundPlus,
|
UserRoundPlus,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
Wifi,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -14,6 +13,7 @@ import chatImage from '@/assets/system/chat.webp'
|
|||||||
import diamond from '@/assets/system/diamond.webp'
|
import diamond from '@/assets/system/diamond.webp'
|
||||||
import logo from '@/assets/system/logo.webp'
|
import logo from '@/assets/system/logo.webp'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { MessageBroadcast } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||||
import { useHeaderClockLabel, useHeaderVm } from '@/hooks/use-header-vm'
|
import { useHeaderClockLabel, useHeaderVm } from '@/hooks/use-header-vm'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
@@ -27,6 +27,43 @@ function MobileHeaderClock() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SignalBars({
|
||||||
|
activeBars,
|
||||||
|
toneClassName,
|
||||||
|
}: {
|
||||||
|
activeBars: number
|
||||||
|
toneClassName: string
|
||||||
|
}) {
|
||||||
|
const barHeights = [
|
||||||
|
'h-[calc(var(--design-unit)*4)]',
|
||||||
|
'h-[calc(var(--design-unit)*6)]',
|
||||||
|
'h-[calc(var(--design-unit)*8)]',
|
||||||
|
'h-[calc(var(--design-unit)*10)]',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-design-11 w-design-16 items-end gap-[1px]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{barHeights.map((heightClassName, index) => {
|
||||||
|
const isActive = index < activeBars
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={heightClassName}
|
||||||
|
className={[
|
||||||
|
'w-[calc(var(--design-unit)*2)] rounded-t-[1px] transition-colors',
|
||||||
|
heightClassName,
|
||||||
|
isActive ? `bg-current ${toneClassName}` : 'bg-white/18',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MobileHeader() {
|
export function MobileHeader() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
@@ -53,9 +90,9 @@ export function MobileHeader() {
|
|||||||
'common-neon-inset flex h-design-19 items-center justify-end !rounded-[3px] !py-0 text-design-7 leading-none transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
|
'common-neon-inset flex h-design-19 items-center justify-end !rounded-[3px] !py-0 text-design-7 leading-none transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 h-design-62">
|
<header className="relative z-30 h-design-72">
|
||||||
<div className="border-b-2 border-[#787553] bg-[#020B14] flex h-design-33 w-full items-center">
|
<div className="border-b-2 border-[#787553] bg-[#020B14] flex h-design-33 w-full items-center px-design-10">
|
||||||
<div className="flex h-design-23 w-design-130 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.45)] px-design-10">
|
<div className="flex h-design-23 w-design-130 pr-design-10 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.45)]">
|
||||||
<SmartImage
|
<SmartImage
|
||||||
src={logo}
|
src={logo}
|
||||||
alt="logo"
|
alt="logo"
|
||||||
@@ -64,21 +101,6 @@ export function MobileHeader() {
|
|||||||
imgClassName="object-contain"
|
imgClassName="object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenLanguage}
|
|
||||||
className={`${actionButtonClassName} !px-design-10 justify-between`}
|
|
||||||
>
|
|
||||||
<SmartImage
|
|
||||||
src={currentLanguageOption.icon}
|
|
||||||
alt={currentLanguageLabel}
|
|
||||||
className="h-design-14 w-design-14 shrink-0 rounded-full"
|
|
||||||
imgClassName="object-cover"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{authStatus === 'authenticated' ? (
|
{authStatus === 'authenticated' ? (
|
||||||
<div className="flex h-full min-w-0 flex-1 items-center justify-end gap-design-7 px-design-9">
|
<div className="flex h-full min-w-0 flex-1 items-center justify-end gap-design-7 px-design-9">
|
||||||
<button
|
<button
|
||||||
@@ -159,17 +181,32 @@ export function MobileHeader() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalOpen('desktopSupport', true)}
|
||||||
|
whileTap={{
|
||||||
|
scale: 0.95,
|
||||||
|
}}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
>
|
||||||
|
<SmartImage
|
||||||
|
className={'h-design-20 w-design-20 cursor-pointer'}
|
||||||
|
alt={'chatImage'}
|
||||||
|
src={chatImage}
|
||||||
|
/>
|
||||||
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={'w-full px-design-10 '}>
|
<div className={'w-full px-design-10 '}>
|
||||||
<div className="flex h-design-29 w-full items-center gap-design-5 my-design-5 rounded-sm border-[#0A353E] border-1">
|
<div className="flex h-design-29 w-full items-center gap-design-5 my-design-5 rounded-sm border-[#0A353E] border-1">
|
||||||
<div className="flex h-design-19 w-design-43 shrink-0 items-center justify-center gap-design-5 !rounded-[3px] !px-design-6 !py-0">
|
<div className="flex h-design-19 w-design-43 shrink-0 items-center justify-center !rounded-[3px] !px-design-6 !py-0">
|
||||||
<Wifi
|
<div className={signalPresentation.toneClassName}>
|
||||||
aria-hidden="true"
|
<SignalBars
|
||||||
color="currentColor"
|
activeBars={signalPresentation.activeBars}
|
||||||
strokeWidth={2.4}
|
toneClassName={signalPresentation.toneClassName}
|
||||||
className={`${signalPresentation.toneClassName} h-design-10 w-design-10 shrink-0`}
|
/>
|
||||||
/>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`${signalPresentation.toneClassName} whitespace-nowrap text-design-7 font-bold leading-none`}
|
className={`${signalPresentation.toneClassName} whitespace-nowrap text-design-7 font-bold leading-none`}
|
||||||
>
|
>
|
||||||
@@ -178,7 +215,7 @@ export function MobileHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full w-design-66 shrink-0 flex-col items-start justify-center border-r border-[rgba(128,223,231,0.32)] pr-design-7 text-design-7 leading-none">
|
<div className="flex flex-col h-full w-design-66 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.32)] pr-design-7 text-design-7 leading-none">
|
||||||
<div className="text-[#B4E4E9]">
|
<div className="text-[#B4E4E9]">
|
||||||
{t('gameDesktop.header.systemTime')}
|
{t('gameDesktop.header.systemTime')}
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +225,7 @@ export function MobileHeader() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenRules}
|
onClick={onOpenRules}
|
||||||
className={`${actionButtonClassName} !px-design-10`}
|
className={`${actionButtonClassName} !px-design-8`}
|
||||||
>
|
>
|
||||||
<Info className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
|
<Info className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
|
||||||
<div className="min-w-0 truncate">
|
<div className="min-w-0 truncate">
|
||||||
@@ -199,7 +236,7 @@ export function MobileHeader() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenNotice}
|
onClick={onOpenNotice}
|
||||||
className={`${actionButtonClassName} !px-design-10`}
|
className={`${actionButtonClassName} !px-design-8`}
|
||||||
>
|
>
|
||||||
<Mail className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
|
<Mail className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
|
||||||
<div className="min-w-0 truncate">
|
<div className="min-w-0 truncate">
|
||||||
@@ -210,7 +247,7 @@ export function MobileHeader() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleSoundEnabled}
|
onClick={toggleSoundEnabled}
|
||||||
className={`${actionButtonClassName} !px-design-10`}
|
className={`${actionButtonClassName} !px-design-8`}
|
||||||
>
|
>
|
||||||
{isSoundEnabled ? (
|
{isSoundEnabled ? (
|
||||||
<Volume2
|
<Volume2
|
||||||
@@ -227,21 +264,19 @@ export function MobileHeader() {
|
|||||||
{t('gameDesktop.header.bgm')}
|
{t('gameDesktop.header.bgm')}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
<motion.button
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setModalOpen('desktopSupport', true)}
|
onClick={onOpenLanguage}
|
||||||
whileTap={{
|
className={`${actionButtonClassName} !px-design-8 justify-between`}
|
||||||
scale: 0.95,
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
>
|
>
|
||||||
<SmartImage
|
<SmartImage
|
||||||
className={'h-design-20 w-design-20 cursor-pointer'}
|
src={currentLanguageOption.icon}
|
||||||
alt={'chatImage'}
|
alt={currentLanguageLabel}
|
||||||
src={chatImage}
|
className="h-design-14 w-design-14 shrink-0 rounded-full"
|
||||||
|
imgClassName="object-cover"
|
||||||
/>
|
/>
|
||||||
</motion.button>
|
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
|
import type { PeriodHistoryDisplayItem } from '@/hooks/use-period-history-vm'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface MobilePeriodHistoryListLabels {
|
||||||
|
empty: string
|
||||||
|
failed: string
|
||||||
|
loading: string
|
||||||
|
retry: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobilePeriodHistoryListProps {
|
||||||
|
isError: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
items: PeriodHistoryDisplayItem[]
|
||||||
|
labels: MobilePeriodHistoryListLabels
|
||||||
|
onRetry: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobilePeriodHistoryList({
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
items,
|
||||||
|
labels,
|
||||||
|
onRetry,
|
||||||
|
}: MobilePeriodHistoryListProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<DataLoadingIndicator
|
||||||
|
compact
|
||||||
|
label={labels.loading}
|
||||||
|
className="min-h-full flex-1 text-design-11"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-col items-center justify-center gap-design-10 px-design-8 text-design-11 text-[#8DBCC2]">
|
||||||
|
<span>{labels.failed}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer rounded-[calc(var(--design-unit)*5)] border border-[rgba(85,236,255,0.38)] px-design-12 py-design-5 text-design-11 font-semibold text-[#D5FBFF] transition-colors duration-200 hover:bg-[rgba(85,236,255,0.12)]"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
{labels.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full items-center justify-center px-design-8 text-design-11 text-[#8DBCC2]">
|
||||||
|
{labels.empty}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history-scroll-hidden grid h-full min-h-0 grid-cols-2 gap-design-4 overflow-y-auto overflow-x-hidden pr-design-2 content-start">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.periodNo}-${item.openTime}`}
|
||||||
|
className="relative flex h-design-72 min-w-0 flex-col overflow-hidden rounded-[calc(var(--design-unit)*6)] border border-[rgba(92,221,242,0.26)] bg-[linear-gradient(180deg,rgba(10,29,43,0.96),rgba(4,14,24,0.98))] shadow-[0_0_calc(var(--design-unit)*8)_rgba(53,212,255,0.06),inset_0_1px_0_rgba(225,255,255,0.06)]"
|
||||||
|
title={item.periodNo}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-x-design-5 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(80,241,255,0.72),transparent)]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex h-design-24 items-center border-b border-[rgba(92,221,242,0.14)] px-design-6">
|
||||||
|
<span className="min-w-0 truncate text-design-9 font-semibold tracking-[0.08em] text-[#D9FEFF]">
|
||||||
|
{item.displayPeriodNo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-[auto_minmax(0,1fr)] items-center gap-design-5 px-design-6">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'relative flex h-design-34 w-design-38 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] text-design-16 font-black leading-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-design-8 before:w-design-8 before:rounded-tl-[calc(var(--design-unit)*5)] before:border-l before:border-t after:pointer-events-none after:absolute after:right-0 after:top-0 after:h-design-8 after:w-design-8 after:rounded-tr-[calc(var(--design-unit)*5)] after:border-r after:border-t',
|
||||||
|
item.isOdd
|
||||||
|
? 'bg-[rgba(54,5,15,0.46)] text-[#FF526C] shadow-[0_0_calc(var(--design-unit)*8)_rgba(255,55,81,0.16),inset_0_0_calc(var(--design-unit)*8)_rgba(255,55,81,0.1)] before:border-[rgba(255,82,108,0.82)] after:border-[rgba(255,82,108,0.82)]'
|
||||||
|
: 'bg-[rgba(10,31,80,0.56)] text-[#63A7FF] shadow-[0_0_calc(var(--design-unit)*8)_rgba(69,137,255,0.16),inset_0_0_calc(var(--design-unit)*8)_rgba(69,137,255,0.1)] before:border-[rgba(99,167,255,0.82)] after:border-[rgba(99,167,255,0.82)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute bottom-0 left-0 h-design-8 w-design-8 rounded-bl-[calc(var(--design-unit)*5)] border-b border-l',
|
||||||
|
item.isOdd
|
||||||
|
? 'border-[rgba(255,82,108,0.82)]'
|
||||||
|
: 'border-[rgba(99,167,255,0.82)]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute bottom-0 right-0 h-design-8 w-design-8 rounded-br-[calc(var(--design-unit)*5)] border-b border-r',
|
||||||
|
item.isOdd
|
||||||
|
? 'border-[rgba(255,82,108,0.82)]'
|
||||||
|
: 'border-[rgba(99,167,255,0.82)]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{item.displayResultNumber}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex min-w-0 items-center justify-center overflow-hidden">
|
||||||
|
{item.image ? (
|
||||||
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt={item.displayResultNumber}
|
||||||
|
draggable={false}
|
||||||
|
className="h-design-34 w-auto max-w-full object-contain drop-shadow-[0_0_calc(var(--design-unit)*6)_rgba(99,243,255,0.12)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-design-9 text-[#668C92]">--</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
201
src/features/game/components/mobile/mobile-status.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { motion, useReducedMotion } from 'motion/react'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import streakBg from '@/assets/game/pc-streak.webp'
|
||||||
|
import down5Animation from '@/assets/lottie/down5.json'
|
||||||
|
import diamond from '@/assets/system/diamond.webp'
|
||||||
|
import fire from '@/assets/system/fire.webp'
|
||||||
|
import lock from '@/assets/system/lock.webp'
|
||||||
|
import statusCenter from '@/assets/system/status-center.webp'
|
||||||
|
import statusLine from '@/assets/system/status-line.webp'
|
||||||
|
import { LottiePlayer } from '@/components/lottie-player.tsx'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
|
||||||
|
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
|
||||||
|
import { cn } from '@/lib/utils.ts'
|
||||||
|
|
||||||
|
function isIosDevice() {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent
|
||||||
|
const platform = navigator.platform
|
||||||
|
|
||||||
|
return (
|
||||||
|
/iPhone|iPad|iPod/.test(userAgent) ||
|
||||||
|
(platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileStatusLine() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
const { countdownMs, phaseLabel, phaseToneClassName, roundId } =
|
||||||
|
useGameStatusVm()
|
||||||
|
const [remainingMs, setRemainingMs] = useState(countdownMs)
|
||||||
|
const showWarningCountdown = remainingMs <= 5000 && remainingMs > 0
|
||||||
|
const isIos = isIosDevice()
|
||||||
|
const shouldMountWarningLottie = !isIos || showWarningCountdown
|
||||||
|
const shouldAnimateStatusIndicator = !prefersReducedMotion && !isIos
|
||||||
|
const countdownClassName = useMemo(
|
||||||
|
() =>
|
||||||
|
showWarningCountdown
|
||||||
|
? 'text-design-28 scale-[1.1] text-[#FF5A5A] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(255,90,90,0.85),0_0_calc(var(--design-unit)*14)_rgba(255,90,90,0.32)]'
|
||||||
|
: 'text-design-28 scale-[1.1] text-[#4BFFFE] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(75,255,254,0.85),0_0_calc(var(--design-unit)*14)_rgba(75,255,254,0.32)]',
|
||||||
|
[showWarningCountdown],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-20 flex w-full flex-col overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(116,240,248,0.55)] bg-[linear-gradient(180deg,rgba(3,28,37,0.94),rgba(2,16,25,0.98))] text-design-8 leading-none shadow-[inset_0_0_calc(var(--design-unit)*11)_rgba(95,235,246,0.3)]">
|
||||||
|
<div className="relative flex h-design-48 w-full items-center justify-between overflow-visible px-design-11">
|
||||||
|
<div className="pointer-events-none absolute inset-x-design-4 top-design-3 h-design-12 rounded-full bg-[linear-gradient(180deg,rgba(217,255,255,0.14),rgba(217,255,255,0))]" />
|
||||||
|
<div className="relative z-10 flex w-design-86 items-center justify-center gap-design-4 text-center">
|
||||||
|
<span className="text-design-8 font-bold text-[#9FBAC0]">
|
||||||
|
{t('gameDesktop.status.roundId')}:
|
||||||
|
</span>
|
||||||
|
<span className="text-design-10 font-black text-[#E7FFFF] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(75,255,254,0.38)]">
|
||||||
|
{roundId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-20 flex h-design-46 w-design-126 items-center justify-center">
|
||||||
|
<SmartBackground
|
||||||
|
src={statusCenter}
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute inset-0 bg-center bg-contain bg-no-repeat',
|
||||||
|
isIos ? '' : 'transition-opacity duration-500 ease-out',
|
||||||
|
)}
|
||||||
|
size="contain"
|
||||||
|
style={{
|
||||||
|
opacity: showWarningCountdown ? 0.18 : 1,
|
||||||
|
transform: showWarningCountdown ? 'scale(0.985)' : 'scale(1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{shouldMountWarningLottie ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute inset-0 overflow-visible',
|
||||||
|
isIos ? '' : 'transition-all duration-500 ease-out',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
opacity: showWarningCountdown ? 1 : 0,
|
||||||
|
transform: showWarningCountdown
|
||||||
|
? 'scale(1.08) translateY(calc(var(--design-unit)*1))'
|
||||||
|
: 'scale(1.02) translateY(0)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LottiePlayer
|
||||||
|
animationData={down5Animation}
|
||||||
|
className="h-full w-full"
|
||||||
|
loop
|
||||||
|
autoplay
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DesktopCountdown
|
||||||
|
initialMs={countdownMs}
|
||||||
|
onRemainingMsChange={setRemainingMs}
|
||||||
|
className={countdownClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex w-design-86 items-center justify-center gap-design-4 text-center">
|
||||||
|
<span className="relative flex h-design-13 w-design-13 shrink-0 items-center justify-center">
|
||||||
|
<motion.span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'absolute h-design-13 w-design-13 rounded-full bg-[rgba(120,255,127,0.22)]',
|
||||||
|
isIos ? '' : 'blur-[calc(var(--design-unit)*2)]',
|
||||||
|
)}
|
||||||
|
animate={
|
||||||
|
shouldAnimateStatusIndicator
|
||||||
|
? { opacity: [0.36, 0.88, 0.42], scale: [0.9, 1.28, 0.98] }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
shouldAnimateStatusIndicator
|
||||||
|
? {
|
||||||
|
duration: 1.25,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<motion.span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="relative h-design-8 w-design-8 rounded-full bg-[#78FF7F] shadow-[0_0_calc(var(--design-unit)*6)_rgba(120,255,127,0.78)]"
|
||||||
|
animate={
|
||||||
|
shouldAnimateStatusIndicator
|
||||||
|
? { opacity: [0.78, 1, 0.86], scale: [0.96, 1.12, 1] }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
shouldAnimateStatusIndicator
|
||||||
|
? {
|
||||||
|
duration: 1.25,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
phaseToneClassName,
|
||||||
|
'shrink-0 font-black [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(120,255,127,0.44)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{phaseLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileStatusMetrics() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { limitLabel, oddsLabel, streakLabel } = useGameStatusVm()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SmartBackground
|
||||||
|
src={statusLine}
|
||||||
|
size="100% 100%"
|
||||||
|
className="relative flex h-design-26 w-full items-center overflow-visible bg-center bg-no-repeat px-design-10 font-bold leading-none text-[#CBD3D5]"
|
||||||
|
style={{ fontSize: 'calc(var(--design-unit) * 7)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-y-design-1 left-0 w-[68%] bg-center bg-no-repeat opacity-80"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${streakBg})`,
|
||||||
|
backgroundSize: '118% 155%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center">
|
||||||
|
{t('gameDesktop.status.odds')}:
|
||||||
|
<span className="ml-design-3 text-[#E3D171]">{oddsLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center gap-design-2">
|
||||||
|
<SmartImage className="h-design-11 w-design-9" alt="fire" src={fire} />
|
||||||
|
<span>{t('gameDesktop.status.streak')}:</span>
|
||||||
|
<span className="bg-gradient-to-b from-[#EBA661] to-[#FCC785] bg-clip-text text-transparent">
|
||||||
|
{streakLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center gap-design-2">
|
||||||
|
<SmartImage className="h-design-10 w-design-8" alt="lock" src={lock} />
|
||||||
|
<span>{t('gameDesktop.status.limit')}:</span>
|
||||||
|
<SmartImage
|
||||||
|
className="h-design-10 w-design-10"
|
||||||
|
alt="diamond"
|
||||||
|
src={diamond}
|
||||||
|
/>
|
||||||
|
<span>{limitLabel}</span>
|
||||||
|
</div>
|
||||||
|
</SmartBackground>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -179,7 +179,7 @@ export function RoundBettingStartAlert({
|
|||||||
<div className="mt-design-8 flex items-center gap-design-7">
|
<div className="mt-design-8 flex items-center gap-design-7">
|
||||||
{[0, 1, 2, 3, 4].map((index) => (
|
{[0, 1, 2, 3, 4].map((index) => (
|
||||||
<motion.span
|
<motion.span
|
||||||
key={index}
|
key={`betting-start-bar-${index}`}
|
||||||
className="h-design-5 w-design-22 rounded-full bg-[rgba(130,255,248,0.88)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(91,248,255,0.52)]"
|
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={
|
animate={
|
||||||
prefersReducedMotion
|
prefersReducedMotion
|
||||||
|
|||||||
@@ -196,11 +196,20 @@ export function useAutoHostingRunner() {
|
|||||||
const submitAutoBet = async () => {
|
const submitAutoBet = async () => {
|
||||||
try {
|
try {
|
||||||
let latestBalance = currentUser.coin ?? '0'
|
let latestBalance = currentUser.coin ?? '0'
|
||||||
|
let remainingBalance = parseBalance(latestBalance)
|
||||||
|
|
||||||
for (const group of groupedSelections.values()) {
|
for (const group of groupedSelections.values()) {
|
||||||
const uniqueNumbers = [...new Set(group.numbers)].sort(
|
const uniqueNumbers = [...new Set(group.numbers)].sort(
|
||||||
(left, right) => left - right,
|
(left, right) => left - right,
|
||||||
)
|
)
|
||||||
|
const groupCost = group.amount * uniqueNumbers.length
|
||||||
|
|
||||||
|
if (groupCost > remainingBalance) {
|
||||||
|
stopHosting()
|
||||||
|
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const formattedSingleBetAmount = formatBetAmount(group.amount)
|
const formattedSingleBetAmount = formatBetAmount(group.amount)
|
||||||
const result = await placeGameBet({
|
const result = await placeGameBet({
|
||||||
bet_amount: formattedSingleBetAmount,
|
bet_amount: formattedSingleBetAmount,
|
||||||
@@ -216,6 +225,7 @@ export function useAutoHostingRunner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
latestBalance = result.balance_after
|
latestBalance = result.balance_after
|
||||||
|
remainingBalance = parseBalance(latestBalance)
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestHostingState = useGameAutoHostingStore.getState()
|
const latestHostingState = useGameAutoHostingStore.getState()
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
|
|||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(query.data?.pages ?? []).flatMap((page) =>
|
(query.data?.pages ?? []).flatMap((page) =>
|
||||||
page.list.map((item, index) => ({
|
(page.list ?? []).map((item, index) => ({
|
||||||
amountLabel: formatFinanceAmount(item.amount, locale),
|
amountLabel: formatFinanceAmount(item.amount, locale),
|
||||||
bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale),
|
bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale),
|
||||||
id: item.orderNo || `${page.pagination.page}-${index}`,
|
id: item.orderNo || `${page.pagination.page}-${index}`,
|
||||||
|
|||||||
@@ -168,6 +168,15 @@ export function useGameControlVm() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const latestBalance = parseBalance(
|
||||||
|
useAuthStore.getState().currentUser?.coin,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (totalBetAmount > latestBalance) {
|
||||||
|
notify.warning(t('commonUi.toast.insufficientBalance'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const betId = toBetId(selections[0]?.chipId ?? activeChipId)
|
const betId = toBetId(selections[0]?.chipId ?? activeChipId)
|
||||||
const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0
|
const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0
|
||||||
|
|
||||||
@@ -236,6 +245,7 @@ export function useGameControlVm() {
|
|||||||
setCurrentUser,
|
setCurrentUser,
|
||||||
setModalOpen,
|
setModalOpen,
|
||||||
t,
|
t,
|
||||||
|
totalBetAmount,
|
||||||
])
|
])
|
||||||
|
|
||||||
const handleRepeatSelections = useCallback(() => {
|
const handleRepeatSelections = useCallback(() => {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function useGameHistoryVm() {
|
|||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(query.data?.pages ?? []).flatMap((page) =>
|
(query.data?.pages ?? []).flatMap((page) =>
|
||||||
page.list.map((entry) => {
|
(page.list ?? []).map((entry) => {
|
||||||
const shouldHideResult =
|
const shouldHideResult =
|
||||||
entry.period_no === revealRoundId && revealPhase !== 'result'
|
entry.period_no === revealRoundId && revealPhase !== 'result'
|
||||||
const resultNumber = shouldHideResult ? null : entry.result_number
|
const resultNumber = shouldHideResult ? null : entry.result_number
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
|
|||||||
import { getGameLobbyInit, normalizePeriodTickRound } from '@/api'
|
import { getGameLobbyInit, normalizePeriodTickRound } from '@/api'
|
||||||
import {
|
import {
|
||||||
FALLBACK_POLL_INTERVAL_MS,
|
FALLBACK_POLL_INTERVAL_MS,
|
||||||
GAME_SOCKET_TOPIC_VALUES,
|
|
||||||
GAME_SOCKET_TOPICS,
|
GAME_SOCKET_TOPICS,
|
||||||
PLAYER_SOCKET_TOPICS,
|
PLAYER_SOCKET_TOPICS,
|
||||||
SOCKET_DISCONNECT_DELAY_MS,
|
SOCKET_DISCONNECT_DELAY_MS,
|
||||||
@@ -13,378 +12,33 @@ import {
|
|||||||
GameSocketClient,
|
GameSocketClient,
|
||||||
type GameSocketMessage,
|
type GameSocketMessage,
|
||||||
} from '@/lib/ws/game-socket-client'
|
} from '@/lib/ws/game-socket-client'
|
||||||
|
import {
|
||||||
|
extractBetWinData,
|
||||||
|
extractJackpotHitData,
|
||||||
|
extractPeriodEventData,
|
||||||
|
extractPeriodTick,
|
||||||
|
extractServerTime,
|
||||||
|
extractUserStreakMessageData,
|
||||||
|
extractWalletChangedData,
|
||||||
|
getMessageTopic,
|
||||||
|
toIsoFromUnixSeconds,
|
||||||
|
toOptionalNumber,
|
||||||
|
} from '@/lib/ws/message-parsers'
|
||||||
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
|
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
|
||||||
import {
|
import {
|
||||||
useGameAutoHostingStore,
|
useGameAutoHostingStore,
|
||||||
useGameRoundStore,
|
useGameRoundStore,
|
||||||
useGameSessionStore,
|
useGameSessionStore,
|
||||||
} from '@/store/game'
|
} from '@/store/game'
|
||||||
import type {
|
|
||||||
BetWinEventDataDto,
|
|
||||||
GamePeriodTickDto,
|
|
||||||
JackpotHitEventDataDto,
|
|
||||||
JackpotHitItemDto,
|
|
||||||
PeriodEventData,
|
|
||||||
UserStreakMessageData,
|
|
||||||
WalletChangedData,
|
|
||||||
} from '@/type'
|
|
||||||
|
|
||||||
let sharedSocketClient: GameSocketClient | null = null
|
let sharedSocketClient: GameSocketClient | null = null
|
||||||
let sharedSocketKey: string | null = null
|
let sharedSocketKey: string | null = null
|
||||||
let sharedSocketDisconnectTimerId: number | null = null
|
let sharedSocketDisconnectTimerId: number | null = null
|
||||||
|
|
||||||
function toIsoFromUnixSeconds(seconds: number) {
|
|
||||||
return new Date(seconds * 1000).toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSocketLang(language: string | null | undefined) {
|
function toSocketLang(language: string | null | undefined) {
|
||||||
return language?.startsWith('zh') ? 'zh' : 'en'
|
return language?.startsWith('zh') ? 'zh' : 'en'
|
||||||
}
|
}
|
||||||
|
|
||||||
function toOptionalNumber(value: unknown) {
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return Number.isFinite(value) ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const parsed = Number(value)
|
|
||||||
|
|
||||||
return Number.isFinite(parsed) ? parsed : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOptionalString(value: unknown) {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOptionalBoolean(value: unknown) {
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === 1 || value === '1' || value === 'true') {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === 0 || value === '0' || value === 'false') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNestedRecord(
|
|
||||||
value: unknown,
|
|
||||||
key: string,
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const nested = (value as Record<string, unknown>)[key]
|
|
||||||
|
|
||||||
return nested && typeof nested === 'object'
|
|
||||||
? (nested as Record<string, unknown>)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMessageTopic(message: GameSocketMessage) {
|
|
||||||
const root = message as Record<string, unknown>
|
|
||||||
const event = typeof root.event === 'string' ? root.event : null
|
|
||||||
const topic = typeof root.topic === 'string' ? root.topic : null
|
|
||||||
|
|
||||||
if (event && GAME_SOCKET_TOPIC_VALUES.has(event)) {
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic && GAME_SOCKET_TOPIC_VALUES.has(topic)) {
|
|
||||||
return topic
|
|
||||||
}
|
|
||||||
|
|
||||||
return event ?? topic
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractServerTime(message: GameSocketMessage) {
|
|
||||||
const root = message as Record<string, unknown>
|
|
||||||
|
|
||||||
if (typeof root.server_time === 'number') {
|
|
||||||
return root.server_time
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = getNestedRecord(message, 'data')
|
|
||||||
|
|
||||||
return typeof data?.server_time === 'number' ? data.server_time : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractUserStreakMessageData(
|
|
||||||
message: GameSocketMessage,
|
|
||||||
): UserStreakMessageData | null {
|
|
||||||
const data = getNestedRecord(message, 'data')
|
|
||||||
const direct = getNestedRecord(message, 'user_snapshot')
|
|
||||||
const nested = getNestedRecord(data, 'user_snapshot')
|
|
||||||
const source =
|
|
||||||
data && 'current_streak' in data ? data : (nested ?? direct ?? data)
|
|
||||||
|
|
||||||
if (!source || typeof source.current_streak !== 'number') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentStreak: source.current_streak,
|
|
||||||
oddsFactor: toOptionalNumber(source.odds_factor),
|
|
||||||
streakLevel: toOptionalNumber(source.streak_level),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractPeriodTick(
|
|
||||||
message: GameSocketMessage,
|
|
||||||
): GamePeriodTickDto | null {
|
|
||||||
const data = getNestedRecord(message, 'data')
|
|
||||||
const nested = getNestedRecord(data, 'period')
|
|
||||||
const source = nested ?? data
|
|
||||||
|
|
||||||
if (
|
|
||||||
!source ||
|
|
||||||
typeof source.period_no !== 'string' ||
|
|
||||||
typeof source.status !== 'string' ||
|
|
||||||
typeof source.countdown !== 'number' ||
|
|
||||||
typeof source.bet_close_in !== 'number'
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
bet_close_in: source.bet_close_in,
|
|
||||||
countdown: source.countdown,
|
|
||||||
period_id: typeof source.period_id === 'number' ? source.period_id : null,
|
|
||||||
period_no: source.period_no,
|
|
||||||
result_number:
|
|
||||||
typeof source.result_number === 'number' ? source.result_number : null,
|
|
||||||
runtime_enabled:
|
|
||||||
typeof source.runtime_enabled === 'boolean'
|
|
||||||
? source.runtime_enabled
|
|
||||||
: true,
|
|
||||||
server_time:
|
|
||||||
typeof source.server_time === 'number'
|
|
||||||
? source.server_time
|
|
||||||
: Math.floor(Date.now() / 1000),
|
|
||||||
status: source.status as GamePeriodTickDto['status'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractPeriodEventData(
|
|
||||||
message: GameSocketMessage,
|
|
||||||
): PeriodEventData | null {
|
|
||||||
const data = getNestedRecord(message, 'data')
|
|
||||||
const source = data ?? (message as Record<string, unknown>)
|
|
||||||
const periodNo =
|
|
||||||
typeof source.period_no === 'string'
|
|
||||||
? source.period_no
|
|
||||||
: typeof source.periodNo === 'string'
|
|
||||||
? source.periodNo
|
|
||||||
: null
|
|
||||||
|
|
||||||
if (!periodNo) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultNumber = toOptionalNumber(
|
|
||||||
source.result_number ?? source.resultNumber,
|
|
||||||
)
|
|
||||||
const openTime = toOptionalNumber(source.open_time ?? source.openTime)
|
|
||||||
|
|
||||||
return {
|
|
||||||
openTime: openTime ?? null,
|
|
||||||
periodNo,
|
|
||||||
resultNumber:
|
|
||||||
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
|
||||||
? resultNumber
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractWalletChangedData(
|
|
||||||
message: GameSocketMessage,
|
|
||||||
): WalletChangedData | null {
|
|
||||||
const data = getNestedRecord(message, 'data')
|
|
||||||
const source = data ?? (message as Record<string, unknown>)
|
|
||||||
const coin = source.coin ?? source.balance ?? source.balance_after
|
|
||||||
const normalizedCoin =
|
|
||||||
typeof coin === 'string'
|
|
||||||
? coin
|
|
||||||
: typeof coin === 'number' && Number.isFinite(coin)
|
|
||||||
? String(coin)
|
|
||||||
: null
|
|
||||||
|
|
||||||
if (normalizedCoin === null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
coin: normalizedCoin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null {
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = value as Record<string, unknown>
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof source.nickname !== 'string' ||
|
|
||||||
typeof source.period_no !== 'string' ||
|
|
||||||
typeof source.total_win !== 'string'
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultNumber = toOptionalNumber(source.result_number)
|
|
||||||
|
|
||||||
if (typeof resultNumber !== 'number' || !Number.isInteger(resultNumber)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nickname: source.nickname,
|
|
||||||
period_no: source.period_no,
|
|
||||||
result_number: resultNumber,
|
|
||||||
total_win: source.total_win,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractJackpotHitData(
|
|
||||||
message: GameSocketMessage,
|
|
||||||
): JackpotHitEventDataDto | null {
|
|
||||||
const data = getNestedRecord(message, 'data')
|
|
||||||
|
|
||||||
if (!data || typeof data.period_no !== 'string') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const nestedHits = data.hits
|
|
||||||
const sourceHits = Array.isArray(nestedHits)
|
|
||||||
? nestedHits
|
|
||||||
: nestedHits && typeof nestedHits === 'object'
|
|
||||||
? [nestedHits]
|
|
||||||
: [data]
|
|
||||||
const firstHitSource = sourceHits.find(
|
|
||||||
(item): item is Record<string, unknown> =>
|
|
||||||
Boolean(item) && typeof item === 'object',
|
|
||||||
)
|
|
||||||
const hits = sourceHits
|
|
||||||
.map((item) => extractJackpotHitItem(item))
|
|
||||||
.filter((item): item is JackpotHitItemDto => item !== null)
|
|
||||||
const root = message as Record<string, unknown>
|
|
||||||
const serverTime = toOptionalNumber(
|
|
||||||
data.server_time ?? firstHitSource?.server_time ?? root.server_time,
|
|
||||||
)
|
|
||||||
const resultNumber = toOptionalNumber(
|
|
||||||
data.result_number ?? data['result number'],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (typeof serverTime !== 'number') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hits,
|
|
||||||
period_id:
|
|
||||||
typeof data.period_id === 'number' && Number.isInteger(data.period_id)
|
|
||||||
? data.period_id
|
|
||||||
: null,
|
|
||||||
period_no: data.period_no,
|
|
||||||
result_number:
|
|
||||||
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
|
||||||
? resultNumber
|
|
||||||
: null,
|
|
||||||
server_time: serverTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractBetWinData(
|
|
||||||
message: GameSocketMessage,
|
|
||||||
): BetWinEventDataDto | null {
|
|
||||||
const data = getNestedRecord(message, 'data')
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = message as Record<string, unknown>
|
|
||||||
const userId = toOptionalNumber(data.user_id)
|
|
||||||
const periodId = toOptionalNumber(data.period_id)
|
|
||||||
const resultNumber = toOptionalNumber(data.result_number)
|
|
||||||
const totalWin = toOptionalString(data.total_win)
|
|
||||||
const balanceAfter = toOptionalString(data.balance_after)
|
|
||||||
const serverTime = toOptionalNumber(data.server_time ?? root.server_time)
|
|
||||||
const currentStreak = toOptionalNumber(data.current_streak)
|
|
||||||
const streakLevel = toOptionalNumber(data.streak_level)
|
|
||||||
const oddsFactor = toOptionalNumber(data.odds_factor)
|
|
||||||
const isJackpot = toOptionalBoolean(data.is_jackpot)
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof totalWin !== 'string' ||
|
|
||||||
typeof data.period_no !== 'string' ||
|
|
||||||
typeof isJackpot !== 'boolean'
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
balance_after: balanceAfter,
|
|
||||||
bets: Array.isArray(data.bets)
|
|
||||||
? data.bets
|
|
||||||
.map((item) => {
|
|
||||||
if (!item || typeof item !== 'object') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const bet = item as Record<string, unknown>
|
|
||||||
const betId = toOptionalNumber(bet.bet_id)
|
|
||||||
const winAmount = toOptionalString(bet.win_amount)
|
|
||||||
|
|
||||||
return typeof betId === 'number' && typeof winAmount === 'string'
|
|
||||||
? {
|
|
||||||
bet_id: betId,
|
|
||||||
win_amount: winAmount,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
})
|
|
||||||
.filter(
|
|
||||||
(item): item is BetWinEventDataDto['bets'][number] => item !== null,
|
|
||||||
)
|
|
||||||
: [],
|
|
||||||
current_streak: currentStreak,
|
|
||||||
is_jackpot: isJackpot,
|
|
||||||
is_win: toOptionalBoolean(data.is_win) ?? true,
|
|
||||||
odds_factor: typeof oddsFactor === 'number' ? oddsFactor : undefined,
|
|
||||||
payout_pending_review:
|
|
||||||
toOptionalBoolean(data.payout_pending_review) ?? false,
|
|
||||||
period_id: periodId,
|
|
||||||
period_no: data.period_no,
|
|
||||||
result_number:
|
|
||||||
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
|
||||||
? resultNumber
|
|
||||||
: null,
|
|
||||||
server_time: serverTime,
|
|
||||||
streak_level: typeof streakLevel === 'number' ? streakLevel : undefined,
|
|
||||||
total_win: totalWin,
|
|
||||||
user_id: userId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
|
function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
|
||||||
const currentRoundState = useGameRoundStore.getState()
|
const currentRoundState = useGameRoundStore.getState()
|
||||||
const currentSessionState = useGameSessionStore.getState()
|
const currentSessionState = useGameSessionStore.getState()
|
||||||
@@ -492,8 +146,6 @@ function applyPeriodOpenedMessage(
|
|||||||
message: GameSocketMessage,
|
message: GameSocketMessage,
|
||||||
serverTime: number | null,
|
serverTime: number | null,
|
||||||
) {
|
) {
|
||||||
console.log('%c[period.opened 开奖数据]', 'color: red;', message)
|
|
||||||
|
|
||||||
applyPeriodMessage(message, serverTime)
|
applyPeriodMessage(message, serverTime)
|
||||||
|
|
||||||
const period = extractPeriodEventData(message)
|
const period = extractPeriodEventData(message)
|
||||||
@@ -582,15 +234,12 @@ function applyWalletChangedMessage(message: GameSocketMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyJackpotHitMessage(message: GameSocketMessage) {
|
function applyJackpotHitMessage(message: GameSocketMessage) {
|
||||||
console.log('%c[jackpot.hit 数据]', 'color: red;', message)
|
|
||||||
|
|
||||||
const jackpotHitData = extractJackpotHitData(message)
|
const jackpotHitData = extractJackpotHitData(message)
|
||||||
|
|
||||||
if (jackpotHitData?.hits.length) {
|
if (jackpotHitData?.hits.length) {
|
||||||
useGameSessionStore.getState().pushJackpotBroadcasts(
|
useGameSessionStore.getState().pushJackpotBroadcasts(
|
||||||
jackpotHitData.hits.map((hit) => ({
|
jackpotHitData.hits.map((hit) => ({
|
||||||
id: `${jackpotHitData.period_no}:${hit.result_number}:${hit.nickname}:${hit.total_win}`,
|
id: `${jackpotHitData.period_no}:${hit.result_number}:${hit.nickname}:${hit.total_win}`,
|
||||||
message: `恭喜${hit.nickname} 用户中奖,获得${hit.total_win}`,
|
|
||||||
nickname: hit.nickname,
|
nickname: hit.nickname,
|
||||||
periodNo: hit.period_no,
|
periodNo: hit.period_no,
|
||||||
totalWin: hit.total_win,
|
totalWin: hit.total_win,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { LOGIN_PROMPT_DEDUP_MS } from '@/constants'
|
import {
|
||||||
|
APP_PREFERENCES_STORAGE_KEY,
|
||||||
|
AUDIO_PREFERENCES_STORAGE_KEY,
|
||||||
|
LOGIN_PROMPT_DEDUP_MS,
|
||||||
|
} from '@/constants'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { notify } from '@/lib/notify'
|
import { notify } from '@/lib/notify'
|
||||||
import { queryClient } from '@/lib/query/query-client'
|
import { queryClient } from '@/lib/query/query-client'
|
||||||
@@ -19,9 +23,24 @@ let authInitializationPromise: Promise<void> | null = null
|
|||||||
let refreshSessionPromise: Promise<boolean> | null = null
|
let refreshSessionPromise: Promise<boolean> | null = null
|
||||||
let lastLoginPromptAt = 0
|
let lastLoginPromptAt = 0
|
||||||
|
|
||||||
|
const PRESERVED_LOCAL_STORAGE_KEYS = [
|
||||||
|
APP_PREFERENCES_STORAGE_KEY,
|
||||||
|
AUDIO_PREFERENCES_STORAGE_KEY,
|
||||||
|
]
|
||||||
|
|
||||||
function clearBrowserStorageData() {
|
function clearBrowserStorageData() {
|
||||||
if (typeof localStorage !== 'undefined') {
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
const preserved = PRESERVED_LOCAL_STORAGE_KEYS.map(
|
||||||
|
(key) => [key, localStorage.getItem(key)] as const,
|
||||||
|
)
|
||||||
|
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
|
||||||
|
for (const [key, value] of preserved) {
|
||||||
|
if (value !== null) {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof sessionStorage !== 'undefined') {
|
if (typeof sessionStorage !== 'undefined') {
|
||||||
|
|||||||
359
src/lib/ws/message-parsers.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { GAME_SOCKET_TOPIC_VALUES } from '@/constants'
|
||||||
|
import type { GameSocketMessage } from '@/lib/ws/game-socket-client'
|
||||||
|
import type {
|
||||||
|
BetWinEventDataDto,
|
||||||
|
GamePeriodTickDto,
|
||||||
|
JackpotHitEventDataDto,
|
||||||
|
JackpotHitItemDto,
|
||||||
|
PeriodEventData,
|
||||||
|
UserStreakMessageData,
|
||||||
|
WalletChangedData,
|
||||||
|
} from '@/type'
|
||||||
|
|
||||||
|
export function toIsoFromUnixSeconds(seconds: number) {
|
||||||
|
return new Date(seconds * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOptionalNumber(value: unknown) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value)
|
||||||
|
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOptionalString(value: unknown) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOptionalBoolean(value: unknown) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 1 || value === '1' || value === 'true') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 0 || value === '0' || value === 'false') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNestedRecord(
|
||||||
|
value: unknown,
|
||||||
|
key: string,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = (value as Record<string, unknown>)[key]
|
||||||
|
|
||||||
|
return nested && typeof nested === 'object'
|
||||||
|
? (nested as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageTopic(message: GameSocketMessage) {
|
||||||
|
const root = message as Record<string, unknown>
|
||||||
|
const event = typeof root.event === 'string' ? root.event : null
|
||||||
|
const topic = typeof root.topic === 'string' ? root.topic : null
|
||||||
|
|
||||||
|
if (event && GAME_SOCKET_TOPIC_VALUES.has(event)) {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic && GAME_SOCKET_TOPIC_VALUES.has(topic)) {
|
||||||
|
return topic
|
||||||
|
}
|
||||||
|
|
||||||
|
return event ?? topic
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractServerTime(message: GameSocketMessage) {
|
||||||
|
const root = message as Record<string, unknown>
|
||||||
|
|
||||||
|
if (typeof root.server_time === 'number') {
|
||||||
|
return root.server_time
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = getNestedRecord(message, 'data')
|
||||||
|
|
||||||
|
return typeof data?.server_time === 'number' ? data.server_time : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractUserStreakMessageData(
|
||||||
|
message: GameSocketMessage,
|
||||||
|
): UserStreakMessageData | null {
|
||||||
|
const data = getNestedRecord(message, 'data')
|
||||||
|
const direct = getNestedRecord(message, 'user_snapshot')
|
||||||
|
const nested = getNestedRecord(data, 'user_snapshot')
|
||||||
|
const source =
|
||||||
|
data && 'current_streak' in data ? data : (nested ?? direct ?? data)
|
||||||
|
|
||||||
|
if (!source || typeof source.current_streak !== 'number') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStreak: source.current_streak,
|
||||||
|
oddsFactor: toOptionalNumber(source.odds_factor),
|
||||||
|
streakLevel: toOptionalNumber(source.streak_level),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPeriodTick(
|
||||||
|
message: GameSocketMessage,
|
||||||
|
): GamePeriodTickDto | null {
|
||||||
|
const data = getNestedRecord(message, 'data')
|
||||||
|
const nested = getNestedRecord(data, 'period')
|
||||||
|
const source = nested ?? data
|
||||||
|
|
||||||
|
if (
|
||||||
|
!source ||
|
||||||
|
typeof source.period_no !== 'string' ||
|
||||||
|
typeof source.status !== 'string' ||
|
||||||
|
typeof source.countdown !== 'number' ||
|
||||||
|
typeof source.bet_close_in !== 'number'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bet_close_in: source.bet_close_in,
|
||||||
|
countdown: source.countdown,
|
||||||
|
period_id: typeof source.period_id === 'number' ? source.period_id : null,
|
||||||
|
period_no: source.period_no,
|
||||||
|
result_number:
|
||||||
|
typeof source.result_number === 'number' ? source.result_number : null,
|
||||||
|
runtime_enabled:
|
||||||
|
typeof source.runtime_enabled === 'boolean'
|
||||||
|
? source.runtime_enabled
|
||||||
|
: true,
|
||||||
|
server_time:
|
||||||
|
typeof source.server_time === 'number'
|
||||||
|
? source.server_time
|
||||||
|
: Math.floor(Date.now() / 1000),
|
||||||
|
status: source.status as GamePeriodTickDto['status'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPeriodEventData(
|
||||||
|
message: GameSocketMessage,
|
||||||
|
): PeriodEventData | null {
|
||||||
|
const data = getNestedRecord(message, 'data')
|
||||||
|
const source = data ?? (message as Record<string, unknown>)
|
||||||
|
const periodNo =
|
||||||
|
typeof source.period_no === 'string'
|
||||||
|
? source.period_no
|
||||||
|
: typeof source.periodNo === 'string'
|
||||||
|
? source.periodNo
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!periodNo) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultNumber = toOptionalNumber(
|
||||||
|
source.result_number ?? source.resultNumber,
|
||||||
|
)
|
||||||
|
const openTime = toOptionalNumber(source.open_time ?? source.openTime)
|
||||||
|
|
||||||
|
return {
|
||||||
|
openTime: openTime ?? null,
|
||||||
|
periodNo,
|
||||||
|
resultNumber:
|
||||||
|
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
||||||
|
? resultNumber
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractWalletChangedData(
|
||||||
|
message: GameSocketMessage,
|
||||||
|
): WalletChangedData | null {
|
||||||
|
const data = getNestedRecord(message, 'data')
|
||||||
|
const source = data ?? (message as Record<string, unknown>)
|
||||||
|
const coin = source.coin ?? source.balance ?? source.balance_after
|
||||||
|
const normalizedCoin =
|
||||||
|
typeof coin === 'string'
|
||||||
|
? coin
|
||||||
|
: typeof coin === 'number' && Number.isFinite(coin)
|
||||||
|
? String(coin)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (normalizedCoin === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
coin: normalizedCoin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = value as Record<string, unknown>
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof source.nickname !== 'string' ||
|
||||||
|
typeof source.period_no !== 'string' ||
|
||||||
|
typeof source.total_win !== 'string'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultNumber = toOptionalNumber(source.result_number)
|
||||||
|
|
||||||
|
if (typeof resultNumber !== 'number' || !Number.isInteger(resultNumber)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nickname: source.nickname,
|
||||||
|
period_no: source.period_no,
|
||||||
|
result_number: resultNumber,
|
||||||
|
total_win: source.total_win,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractJackpotHitData(
|
||||||
|
message: GameSocketMessage,
|
||||||
|
): JackpotHitEventDataDto | null {
|
||||||
|
const data = getNestedRecord(message, 'data')
|
||||||
|
|
||||||
|
if (!data || typeof data.period_no !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedHits = data.hits
|
||||||
|
const sourceHits = Array.isArray(nestedHits)
|
||||||
|
? nestedHits
|
||||||
|
: nestedHits && typeof nestedHits === 'object'
|
||||||
|
? [nestedHits]
|
||||||
|
: [data]
|
||||||
|
const firstHitSource = sourceHits.find(
|
||||||
|
(item): item is Record<string, unknown> =>
|
||||||
|
Boolean(item) && typeof item === 'object',
|
||||||
|
)
|
||||||
|
const hits = sourceHits
|
||||||
|
.map((item) => extractJackpotHitItem(item))
|
||||||
|
.filter((item): item is JackpotHitItemDto => item !== null)
|
||||||
|
const root = message as Record<string, unknown>
|
||||||
|
const serverTime = toOptionalNumber(
|
||||||
|
data.server_time ?? firstHitSource?.server_time ?? root.server_time,
|
||||||
|
)
|
||||||
|
const resultNumber = toOptionalNumber(
|
||||||
|
data.result_number ?? data['result number'],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (typeof serverTime !== 'number') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hits,
|
||||||
|
period_id:
|
||||||
|
typeof data.period_id === 'number' && Number.isInteger(data.period_id)
|
||||||
|
? data.period_id
|
||||||
|
: null,
|
||||||
|
period_no: data.period_no,
|
||||||
|
result_number:
|
||||||
|
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
||||||
|
? resultNumber
|
||||||
|
: null,
|
||||||
|
server_time: serverTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBetWinData(
|
||||||
|
message: GameSocketMessage,
|
||||||
|
): BetWinEventDataDto | null {
|
||||||
|
const data = getNestedRecord(message, 'data')
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = message as Record<string, unknown>
|
||||||
|
const userId = toOptionalNumber(data.user_id)
|
||||||
|
const periodId = toOptionalNumber(data.period_id)
|
||||||
|
const resultNumber = toOptionalNumber(data.result_number)
|
||||||
|
const totalWin = toOptionalString(data.total_win)
|
||||||
|
const balanceAfter = toOptionalString(data.balance_after)
|
||||||
|
const serverTime = toOptionalNumber(data.server_time ?? root.server_time)
|
||||||
|
const currentStreak = toOptionalNumber(data.current_streak)
|
||||||
|
const streakLevel = toOptionalNumber(data.streak_level)
|
||||||
|
const oddsFactor = toOptionalNumber(data.odds_factor)
|
||||||
|
const isJackpot = toOptionalBoolean(data.is_jackpot)
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof totalWin !== 'string' ||
|
||||||
|
typeof data.period_no !== 'string' ||
|
||||||
|
typeof isJackpot !== 'boolean'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance_after: balanceAfter,
|
||||||
|
bets: Array.isArray(data.bets)
|
||||||
|
? data.bets
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const bet = item as Record<string, unknown>
|
||||||
|
const betId = toOptionalNumber(bet.bet_id)
|
||||||
|
const winAmount = toOptionalString(bet.win_amount)
|
||||||
|
|
||||||
|
return typeof betId === 'number' && typeof winAmount === 'string'
|
||||||
|
? {
|
||||||
|
bet_id: betId,
|
||||||
|
win_amount: winAmount,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(item): item is BetWinEventDataDto['bets'][number] => item !== null,
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
current_streak: currentStreak,
|
||||||
|
is_jackpot: isJackpot,
|
||||||
|
is_win: toOptionalBoolean(data.is_win) ?? true,
|
||||||
|
odds_factor: typeof oddsFactor === 'number' ? oddsFactor : undefined,
|
||||||
|
payout_pending_review:
|
||||||
|
toOptionalBoolean(data.payout_pending_review) ?? false,
|
||||||
|
period_id: periodId,
|
||||||
|
period_no: data.period_no,
|
||||||
|
result_number:
|
||||||
|
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
||||||
|
? resultNumber
|
||||||
|
: null,
|
||||||
|
server_time: serverTime,
|
||||||
|
streak_level: typeof streakLevel === 'number' ? streakLevel : undefined,
|
||||||
|
total_win: totalWin,
|
||||||
|
user_id: userId,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -318,6 +318,14 @@ export default {
|
|||||||
close: 'Close modal',
|
close: 'Close modal',
|
||||||
defaultAriaLabel: 'Modal',
|
defaultAriaLabel: 'Modal',
|
||||||
},
|
},
|
||||||
|
boot: {
|
||||||
|
loading: 'Loading resources',
|
||||||
|
syncing: 'Syncing the flower deck and game interface',
|
||||||
|
},
|
||||||
|
support: {
|
||||||
|
title: 'Live Support',
|
||||||
|
connecting: 'Connecting to support',
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
lobbyInitFailed: 'Failed to load the game lobby',
|
lobbyInitFailed: 'Failed to load the game lobby',
|
||||||
loginRequired: 'Please log in before entering the game',
|
loginRequired: 'Please log in before entering the game',
|
||||||
|
|||||||
@@ -317,6 +317,14 @@ export default {
|
|||||||
close: 'Tutup modal',
|
close: 'Tutup modal',
|
||||||
defaultAriaLabel: 'Modal',
|
defaultAriaLabel: 'Modal',
|
||||||
},
|
},
|
||||||
|
boot: {
|
||||||
|
loading: 'Memuat sumber daya',
|
||||||
|
syncing: 'Menyinkronkan deck bunga dan antarmuka game',
|
||||||
|
},
|
||||||
|
support: {
|
||||||
|
title: 'Dukungan Langsung',
|
||||||
|
connecting: 'Menghubungkan ke dukungan',
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
lobbyInitFailed: 'Gagal memuat lobby game',
|
lobbyInitFailed: 'Gagal memuat lobby game',
|
||||||
loginRequired: 'Silakan masuk sebelum memasuki game',
|
loginRequired: 'Silakan masuk sebelum memasuki game',
|
||||||
|
|||||||
@@ -320,6 +320,14 @@ export default {
|
|||||||
close: 'Tutup modal',
|
close: 'Tutup modal',
|
||||||
defaultAriaLabel: 'Modal',
|
defaultAriaLabel: 'Modal',
|
||||||
},
|
},
|
||||||
|
boot: {
|
||||||
|
loading: 'Memuatkan sumber',
|
||||||
|
syncing: 'Menyegerakkan dek bunga dan antara muka permainan',
|
||||||
|
},
|
||||||
|
support: {
|
||||||
|
title: 'Sokongan Langsung',
|
||||||
|
connecting: 'Menyambung ke sokongan',
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
lobbyInitFailed: 'Gagal memuatkan lobi permainan',
|
lobbyInitFailed: 'Gagal memuatkan lobi permainan',
|
||||||
loginRequired: 'Sila log masuk sebelum memasuki permainan',
|
loginRequired: 'Sila log masuk sebelum memasuki permainan',
|
||||||
|
|||||||
@@ -312,6 +312,14 @@ export default {
|
|||||||
close: '关闭弹窗',
|
close: '关闭弹窗',
|
||||||
defaultAriaLabel: '弹窗',
|
defaultAriaLabel: '弹窗',
|
||||||
},
|
},
|
||||||
|
boot: {
|
||||||
|
loading: '资源加载中',
|
||||||
|
syncing: '正在同步字花图鉴与游戏界面',
|
||||||
|
},
|
||||||
|
support: {
|
||||||
|
title: '在线客服',
|
||||||
|
connecting: '客服连线中',
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
lobbyInitFailed: '游戏大厅加载失败',
|
lobbyInitFailed: '游戏大厅加载失败',
|
||||||
loginRequired: '请先登录后进入游戏',
|
loginRequired: '请先登录后进入游戏',
|
||||||
|
|||||||
@@ -254,7 +254,9 @@ export function MainEntryPage() {
|
|||||||
<section
|
<section
|
||||||
aria-busy={isHydrating}
|
aria-busy={isHydrating}
|
||||||
aria-label={t('game.lobbyTitle')}
|
aria-label={t('game.lobbyTitle')}
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
className={
|
||||||
|
isMobile ? 'flex w-full flex-col' : 'flex min-h-0 flex-1 flex-col'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isMobile ? <MobileEntry /> : <PcEntry />}
|
{isMobile ? <MobileEntry /> : <PcEntry />}
|
||||||
{isMobile ? <MobileModalHost /> : <DesktopModalHost />}
|
{isMobile ? <MobileModalHost /> : <DesktopModalHost />}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { MessageBroadcast } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||||
|
import { MobileAnimal } from '@/features/game/components/mobile/mobile-animal.tsx'
|
||||||
|
import { MobileBettingStartAlert } from '@/features/game/components/mobile/mobile-betting-start-alert.tsx'
|
||||||
|
import { MobileControl } from '@/features/game/components/mobile/mobile-control.tsx'
|
||||||
import { MobileHeader } from '@/features/game/components/mobile/mobile-header.tsx'
|
import { MobileHeader } from '@/features/game/components/mobile/mobile-header.tsx'
|
||||||
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
|
import { MobileStatusMetrics } from '@/features/game/components/mobile/mobile-status.tsx'
|
||||||
import { useAutoHostingRunner } from '@/hooks/use-auto-hosting-runner.ts'
|
import { useAutoHostingRunner } from '@/hooks/use-auto-hosting-runner.ts'
|
||||||
|
|
||||||
export function MobileEntry() {
|
export function MobileEntry() {
|
||||||
@@ -8,7 +12,18 @@ export function MobileEntry() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MobileHeader />
|
<MobileHeader />
|
||||||
<RoundBettingStartAlert placement="fixed" />
|
<div className="w-full px-design-10 mb-design-5">
|
||||||
|
<MessageBroadcast />
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto mb-design-5 w-[calc(100%-20*var(--design-unit))]">
|
||||||
|
<MobileStatusMetrics />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="mx-auto flex w-[calc(100%-20*var(--design-unit))] flex-col gap-design-5 pb-design-8">
|
||||||
|
<MobileAnimal />
|
||||||
|
<MobileControl />
|
||||||
|
</main>
|
||||||
|
<MobileBettingStartAlert />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
@@ -8,6 +9,7 @@ const SUPPORT_CHAT_URL =
|
|||||||
const IFRAME_READY_DELAY_MS = 2_000
|
const IFRAME_READY_DELAY_MS = 2_000
|
||||||
|
|
||||||
function DesktopSupportModal() {
|
function DesktopSupportModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const readyTimerRef = useRef<number | null>(null)
|
const readyTimerRef = useRef<number | null>(null)
|
||||||
const open = useModalStore((state) => state.modals.desktopSupport)
|
const open = useModalStore((state) => state.modals.desktopSupport)
|
||||||
@@ -49,14 +51,18 @@ function DesktopSupportModal() {
|
|||||||
isNormalBg={true}
|
isNormalBg={true}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
titleAlign="left"
|
titleAlign="left"
|
||||||
title={<div className="modal-title-glow text-design-30">在线客服</div>}
|
title={
|
||||||
|
<div className="modal-title-glow text-design-30">
|
||||||
|
{t('commonUi.support.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
className="h-design-760 w-design-980"
|
className="h-design-760 w-design-980"
|
||||||
>
|
>
|
||||||
<div className="h-full px-design-24 pb-design-40 pt-design-10">
|
<div className="h-full px-design-24 pb-design-40 pt-design-10">
|
||||||
<div className="relative h-full overflow-hidden rounded-[calc(var(--design-unit)*14)] border border-[#2A6D73] bg-[linear-gradient(180deg,rgba(5,22,31,0.98),rgba(2,10,17,0.98))] shadow-[inset_0_0_calc(var(--design-unit)*22)_rgba(88,205,218,0.13),0_0_calc(var(--design-unit)*18)_rgba(31,156,174,0.14)]">
|
<div className="relative h-full overflow-hidden rounded-[calc(var(--design-unit)*14)] border border-[#2A6D73] bg-[linear-gradient(180deg,rgba(5,22,31,0.98),rgba(2,10,17,0.98))] shadow-[inset_0_0_calc(var(--design-unit)*22)_rgba(88,205,218,0.13),0_0_calc(var(--design-unit)*18)_rgba(31,156,174,0.14)]">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
|
||||||
<DataLoadingIndicator label="客服连线中" />
|
<DataLoadingIndicator label={t('commonUi.support.connecting')} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<iframe
|
<iframe
|
||||||
|
|||||||
@@ -112,32 +112,32 @@ function MobileAutoSettingModal() {
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
title={
|
title={
|
||||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
<div className={'modal-title-glow text-design-16'}>
|
||||||
{t('game.modals.autoSetting.title')}
|
{t('game.modals.autoSetting.title')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
isNormalBg={true}
|
isNormalBg={true}
|
||||||
titleAlign="left"
|
titleAlign="left"
|
||||||
className="!h-[min(calc(var(--design-unit)*500),calc(100dvh-var(--design-unit)*28))]"
|
className="h-design-370"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'flex h-full w-full flex-col justify-between px-design-18 pt-design-30 pb-design-60'
|
'flex h-full w-full flex-col justify-between px-design-10 pt-design-10 pb-design-14'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={'flex w-full flex-col gap-design-26'}>
|
<div className={'flex w-full flex-col gap-design-8'}>
|
||||||
<div className={'flex items-center justify-between gap-design-30'}>
|
<div
|
||||||
<div
|
className={
|
||||||
className={
|
'rounded-[calc(var(--design-unit)*8)] border border-[rgba(104,214,222,0.3)] bg-[rgba(4,30,38,0.7)] px-design-8 py-design-8 shadow-[inset_0_0_calc(var(--design-unit)*8)_rgba(33,193,219,0.08)]'
|
||||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
}
|
||||||
}
|
>
|
||||||
>
|
<div className={'text-design-12 leading-[1.35] text-[#9CF7FF]'}>
|
||||||
{t('game.modals.autoSetting.rows.stopIfBalanceLowerThan')}
|
{t('game.modals.autoSetting.rows.stopIfBalanceLowerThan')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
'mt-design-6 flex items-center gap-design-6 rounded-[calc(var(--design-unit)*8)] border border-[rgba(111,235,245,0.16)] bg-[rgba(10,57,66,0.7)] px-design-8 py-design-6'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -145,29 +145,30 @@ function MobileAutoSettingModal() {
|
|||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
onChange={(event) => setBalanceLimitValue(event.target.value)}
|
onChange={(event) => setBalanceLimitValue(event.target.value)}
|
||||||
className={
|
className={
|
||||||
'game-setting-input h-full w-design-280 text-design-18'
|
'game-setting-input h-design-32 flex-1 bg-transparent px-design-6 py-design-6 text-design-12'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Switch
|
<Switch
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
|
className="scale-[0.82]"
|
||||||
checked={balanceLimitEnabled}
|
checked={balanceLimitEnabled}
|
||||||
onCheckedChange={setBalanceLimitEnabled}
|
onCheckedChange={setBalanceLimitEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={'flex items-center justify-between gap-design-30'}>
|
<div
|
||||||
<div
|
className={
|
||||||
className={
|
'rounded-[calc(var(--design-unit)*8)] border border-[rgba(104,214,222,0.3)] bg-[rgba(4,30,38,0.7)] px-design-8 py-design-8 shadow-[inset_0_0_calc(var(--design-unit)*8)_rgba(33,193,219,0.08)]'
|
||||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
}
|
||||||
}
|
>
|
||||||
>
|
<div className={'text-design-12 leading-[1.35] text-[#9CF7FF]'}>
|
||||||
{t('game.modals.autoSetting.rows.stopIfSingleWinExceeds')}
|
{t('game.modals.autoSetting.rows.stopIfSingleWinExceeds')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
'mt-design-6 flex items-center gap-design-6 rounded-[calc(var(--design-unit)*8)] border border-[rgba(111,235,245,0.16)] bg-[rgba(10,57,66,0.7)] px-design-8 py-design-6'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -175,29 +176,30 @@ function MobileAutoSettingModal() {
|
|||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
onChange={(event) => setSingleWinLimitValue(event.target.value)}
|
onChange={(event) => setSingleWinLimitValue(event.target.value)}
|
||||||
className={
|
className={
|
||||||
'game-setting-input h-full w-design-280 text-design-18'
|
'game-setting-input h-design-32 flex-1 bg-transparent px-design-6 py-design-6 text-design-12'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Switch
|
<Switch
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
|
className="scale-[0.82]"
|
||||||
checked={singleWinLimitEnabled}
|
checked={singleWinLimitEnabled}
|
||||||
onCheckedChange={setSingleWinLimitEnabled}
|
onCheckedChange={setSingleWinLimitEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={'flex items-center justify-between gap-design-30'}>
|
<div
|
||||||
<div
|
className={
|
||||||
className={
|
'rounded-[calc(var(--design-unit)*8)] border border-[rgba(104,214,222,0.3)] bg-[rgba(4,30,38,0.7)] px-design-8 py-design-8 shadow-[inset_0_0_calc(var(--design-unit)*8)_rgba(33,193,219,0.08)]'
|
||||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
}
|
||||||
}
|
>
|
||||||
>
|
<div className={'flex items-center justify-between gap-design-8'}>
|
||||||
{t('game.modals.autoSetting.rows.stopOnAnyJackpot')}
|
<div className={'text-design-12 leading-[1.35] text-[#9CF7FF]'}>
|
||||||
</div>
|
{t('game.modals.autoSetting.rows.stopOnAnyJackpot')}
|
||||||
|
</div>
|
||||||
<div className={'flex w-design-410 justify-end pr-design-2'}>
|
|
||||||
<Switch
|
<Switch
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
|
className="scale-[0.82]"
|
||||||
checked={jackpotStopEnabled}
|
checked={jackpotStopEnabled}
|
||||||
onCheckedChange={setJackpotStopEnabled}
|
onCheckedChange={setJackpotStopEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -205,7 +207,7 @@ function MobileAutoSettingModal() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={'flex w-full justify-center'}>
|
<div className={'flex w-full justify-center pt-design-10'}>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
as="button"
|
as="button"
|
||||||
src={lengthBlueBtn}
|
src={lengthBlueBtn}
|
||||||
@@ -215,7 +217,7 @@ function MobileAutoSettingModal() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className={
|
className={
|
||||||
'w-design-300 h-design-72 pb-design-4 flex cursor-pointer items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF] transition-transform hover:-translate-y-[1px] active:translate-y-0'
|
'flex h-design-45 w-full max-w-[calc(var(--design-unit)*160)] cursor-pointer items-center justify-center pb-design-2 !text-design-12 font-bold mt-design-5 tracking-wide text-[#E7FBFF] transition-transform hover:-translate-y-[1px] active:translate-y-0'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t('game.modals.autoSetting.startAutoSpin')}
|
{t('game.modals.autoSetting.startAutoSpin')}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function MobileLanguageModal() {
|
|||||||
}
|
}
|
||||||
isNormalBg={true}
|
isNormalBg={true}
|
||||||
titleAlign="left"
|
titleAlign="left"
|
||||||
className="!h-design-350"
|
className="!h-design-330"
|
||||||
>
|
>
|
||||||
<div className="flex h-full min-h-0 flex-col px-design-14 pt-design-5">
|
<div className="flex h-full min-h-0 flex-col px-design-14 pt-design-5">
|
||||||
<div className="w-full flex flex-wrap gap-design-10 overflow-y-auto">
|
<div className="w-full flex flex-wrap gap-design-10 overflow-y-auto">
|
||||||
|
|||||||
@@ -2,28 +2,34 @@ import { X } from 'lucide-react'
|
|||||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
|
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { PeriodHistoryList } from '@/features/game/components/shared/period-history-list'
|
import { MobileGameHistory } from '@/features/game/components/mobile/mobile-game-history.tsx'
|
||||||
|
import { MobilePeriodHistoryList } from '@/features/game/components/mobile/mobile-period-history-list'
|
||||||
import {
|
import {
|
||||||
DEFAULT_PERIOD_HISTORY_LIMIT,
|
DEFAULT_PERIOD_HISTORY_LIMIT,
|
||||||
type PeriodHistoryDisplayItem,
|
type PeriodHistoryDisplayItem,
|
||||||
usePeriodHistoryVm,
|
usePeriodHistoryVm,
|
||||||
} from '@/hooks/use-period-history-vm'
|
} from '@/hooks/use-period-history-vm'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
const OVERLAY_EASE = [0.16, 1, 0.3, 1] as const
|
const OVERLAY_EASE = [0.16, 1, 0.3, 1] as const
|
||||||
const DRAWER_TRANSITION = {
|
const DRAWER_TRANSITION = {
|
||||||
type: 'tween',
|
type: 'tween',
|
||||||
duration: 0.34,
|
duration: 0.3,
|
||||||
ease: OVERLAY_EASE,
|
ease: OVERLAY_EASE,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
type HistoryTab = 'period' | 'record'
|
||||||
|
|
||||||
interface PeriodHistoryDrawerLabels {
|
interface PeriodHistoryDrawerLabels {
|
||||||
close: string
|
close: string
|
||||||
empty: string
|
empty: string
|
||||||
failed: string
|
failed: string
|
||||||
loading: string
|
loading: string
|
||||||
|
recordTab: string
|
||||||
retry: string
|
retry: string
|
||||||
title: string
|
title: string
|
||||||
|
periodTab: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MobilePeriodHistoryDrawerViewProps {
|
interface MobilePeriodHistoryDrawerViewProps {
|
||||||
@@ -59,6 +65,8 @@ export function MobilePeriodHistoryDrawer() {
|
|||||||
empty: t('gameDesktop.periodHistory.empty'),
|
empty: t('gameDesktop.periodHistory.empty'),
|
||||||
failed: t('gameDesktop.periodHistory.failed'),
|
failed: t('gameDesktop.periodHistory.failed'),
|
||||||
loading: t('gameDesktop.periodHistory.loading'),
|
loading: t('gameDesktop.periodHistory.loading'),
|
||||||
|
periodTab: t('gameDesktop.periodHistory.title'),
|
||||||
|
recordTab: t('gameDesktop.history.title'),
|
||||||
retry: t('gameDesktop.periodHistory.retry'),
|
retry: t('gameDesktop.periodHistory.retry'),
|
||||||
title: t('gameDesktop.periodHistory.title'),
|
title: t('gameDesktop.periodHistory.title'),
|
||||||
}}
|
}}
|
||||||
@@ -78,104 +86,130 @@ export function MobilePeriodHistoryDrawerView({
|
|||||||
open,
|
open,
|
||||||
}: MobilePeriodHistoryDrawerViewProps) {
|
}: MobilePeriodHistoryDrawerViewProps) {
|
||||||
const prefersReducedMotion = useReducedMotion()
|
const prefersReducedMotion = useReducedMotion()
|
||||||
const [isDrawerAnimating, setIsDrawerAnimating] = useState(false)
|
const [activeTab, setActiveTab] = useState<HistoryTab>('period')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open ? (
|
||||||
<>
|
<>
|
||||||
<motion.button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={labels.close}
|
aria-label={labels.close}
|
||||||
className="fixed left-0 right-0 top-0 bottom-[calc(var(--design-unit)*150)] z-30 cursor-default bg-black/48"
|
className="fixed inset-0 z-30 cursor-default bg-black/58"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{
|
transition={{
|
||||||
duration: prefersReducedMotion ? 0.12 : 0.26,
|
duration: prefersReducedMotion ? 0.12 : 0.22,
|
||||||
ease: OVERLAY_EASE,
|
ease: OVERLAY_EASE,
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<motion.aside
|
<motion.aside
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={labels.title}
|
aria-label={labels.title}
|
||||||
className="fixed left-0 top-design-16 bottom-[calc(var(--design-unit)*150)] z-40 flex w-design-1120 max-w-[calc(100vw-var(--design-unit)*24)] origin-left flex-col overflow-hidden rounded-r-[calc(var(--design-unit)*10)] border border-[rgba(81,230,255,0.62)] bg-[linear-gradient(180deg,rgba(6,19,32,0.98),rgba(3,12,22,0.96))] text-[#D5FBFF] shadow-[0_0_calc(var(--design-unit)*18)_rgba(39,216,255,0.28),0_0_calc(var(--design-unit)*54)_rgba(39,216,255,0.16),inset_0_0_calc(var(--design-unit)*18)_rgba(74,224,255,0.16)]"
|
className="fixed inset-x-0 bottom-0 z-40 flex h-[min(calc(var(--design-unit)*620),calc(100dvh-var(--design-unit)*12))] flex-col overflow-hidden rounded-t-[calc(var(--design-unit)*10)] border border-[rgba(81,230,255,0.62)] bg-[linear-gradient(180deg,rgba(6,19,32,0.98),rgba(3,12,22,0.96))] text-[#D5FBFF] shadow-[0_0_calc(var(--design-unit)*16)_rgba(39,216,255,0.22),0_0_calc(var(--design-unit)*40)_rgba(39,216,255,0.1),inset_0_0_calc(var(--design-unit)*14)_rgba(74,224,255,0.12)]"
|
||||||
initial={
|
initial={
|
||||||
prefersReducedMotion
|
prefersReducedMotion
|
||||||
? { opacity: 0 }
|
? { opacity: 0 }
|
||||||
: { x: '-100%', opacity: 0.98 }
|
: { y: '100%', opacity: 0.98 }
|
||||||
}
|
}
|
||||||
animate={
|
animate={
|
||||||
prefersReducedMotion ? { opacity: 1 } : { x: 0, opacity: 1 }
|
prefersReducedMotion ? { opacity: 1 } : { y: 0, opacity: 1 }
|
||||||
}
|
}
|
||||||
exit={
|
exit={
|
||||||
prefersReducedMotion
|
prefersReducedMotion
|
||||||
? { opacity: 0 }
|
? { opacity: 0 }
|
||||||
: { x: '-100%', opacity: 0.98 }
|
: { y: '100%', opacity: 0.98 }
|
||||||
}
|
}
|
||||||
transition={
|
transition={
|
||||||
prefersReducedMotion ? { duration: 0.12 } : DRAWER_TRANSITION
|
prefersReducedMotion ? { duration: 0.12 } : DRAWER_TRANSITION
|
||||||
}
|
}
|
||||||
onAnimationStart={() => setIsDrawerAnimating(true)}
|
|
||||||
onAnimationComplete={() => setIsDrawerAnimating(false)}
|
|
||||||
style={
|
|
||||||
isDrawerAnimating
|
|
||||||
? { willChange: 'transform, opacity' }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute inset-x-design-8 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(80,241,255,0.96),transparent)]"
|
className="pointer-events-none absolute inset-x-design-8 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(80,241,255,0.96),transparent)]"
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
<div className="relative flex h-design-40 shrink-0 items-center justify-between gap-design-4 border-b border-[rgba(80,224,255,0.3)] px-design-8">
|
||||||
className="pointer-events-none absolute bottom-0 left-0 h-design-28 w-design-28 border-b-2 border-l-2 border-[#28E6FF]"
|
<div className="relative grid h-design-26 w-[calc(var(--design-unit)*170)] max-w-[calc(100%-var(--design-unit)*30)] shrink-0 grid-cols-2 overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[#2B8CA3]/24 bg-[#031B24]/68 p-[calc(var(--design-unit)*1.5)]">
|
||||||
/>
|
{(
|
||||||
<span
|
[
|
||||||
aria-hidden="true"
|
['period', labels.periodTab],
|
||||||
className="pointer-events-none absolute bottom-0 right-0 h-design-28 w-design-28 border-b-2 border-r-2 border-[#28E6FF]"
|
['record', labels.recordTab],
|
||||||
/>
|
] as const
|
||||||
<div className="relative flex h-design-78 shrink-0 items-center justify-between border-b border-[rgba(80,224,255,0.38)] px-design-42">
|
).map(([tab, label]) => {
|
||||||
<h2 className="text-design-28 font-bold leading-none text-white [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(156,244,255,0.42)]">
|
const isActive = activeTab === tab
|
||||||
{labels.title}
|
|
||||||
</h2>
|
return (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={cn(
|
||||||
|
'relative flex min-w-0 cursor-pointer items-center justify-center overflow-hidden rounded-[calc(var(--design-unit)*5)] px-design-3 text-[calc(var(--design-unit)*8.5)] font-semibold transition-colors duration-200',
|
||||||
|
isActive
|
||||||
|
? 'text-[#FEEEB0]'
|
||||||
|
: 'text-[#58ADAF] hover:text-[#BFEAEC]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<motion.span
|
||||||
|
layoutId="mobile-period-history-tab-active-bg"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 rounded-[calc(var(--design-unit)*5)] bg-[linear-gradient(180deg,rgba(254,238,176,0.28)_0%,rgba(254,238,176,0.1)_100%)]"
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 430,
|
||||||
|
damping: 36,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isActive ? (
|
||||||
|
<motion.span
|
||||||
|
layoutId="mobile-period-history-tab-active-indicator"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-design-5 bottom-0 h-[calc(var(--design-unit)*1.5)] rounded-full bg-[linear-gradient(90deg,rgba(255,248,214,0.14),rgba(254,238,176,0.9),rgba(255,248,214,0.14))] shadow-[0_0_calc(var(--design-unit)*5)_rgba(254,238,176,0.24)]"
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 430,
|
||||||
|
damping: 36,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0 truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={labels.close}
|
aria-label={labels.close}
|
||||||
className="flex h-design-42 w-design-42 cursor-pointer items-center justify-center text-[#C8F7FF] transition-colors duration-200 hover:text-white focus-visible:ring-2 focus-visible:ring-[#4FEAFF]"
|
className="flex h-design-22 w-design-22 cursor-pointer items-center justify-center text-[#C8F7FF] transition-colors duration-200 hover:text-white"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<X size={32} strokeWidth={2.1} />
|
<X size={14} strokeWidth={2.1} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<motion.div
|
|
||||||
className="history-scroll-hidden min-h-0 flex-1 overflow-y-auto px-design-34 py-design-26"
|
<div className="min-h-0 flex-1 px-design-8 py-design-8">
|
||||||
initial={
|
{activeTab === 'period' ? (
|
||||||
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }
|
<MobilePeriodHistoryList
|
||||||
}
|
items={items}
|
||||||
animate={
|
isLoading={isLoading}
|
||||||
prefersReducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
isError={isError}
|
||||||
}
|
labels={labels}
|
||||||
transition={
|
onRetry={onRetry}
|
||||||
prefersReducedMotion
|
/>
|
||||||
? { duration: 0.12 }
|
) : (
|
||||||
: { duration: 0.22, delay: 0.08, ease: OVERLAY_EASE }
|
<MobileGameHistory />
|
||||||
}
|
)}
|
||||||
>
|
</div>
|
||||||
<PeriodHistoryList
|
|
||||||
items={items}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isError={isError}
|
|
||||||
labels={labels}
|
|
||||||
onRetry={onRetry}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
@@ -8,6 +9,7 @@ const SUPPORT_CHAT_URL =
|
|||||||
const IFRAME_READY_DELAY_MS = 2_000
|
const IFRAME_READY_DELAY_MS = 2_000
|
||||||
|
|
||||||
function MobileSupportModal() {
|
function MobileSupportModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const readyTimerRef = useRef<number | null>(null)
|
const readyTimerRef = useRef<number | null>(null)
|
||||||
const open = useModalStore((state) => state.modals.desktopSupport)
|
const open = useModalStore((state) => state.modals.desktopSupport)
|
||||||
@@ -49,7 +51,11 @@ function MobileSupportModal() {
|
|||||||
isNormalBg={true}
|
isNormalBg={true}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
titleAlign="left"
|
titleAlign="left"
|
||||||
title={<div className="modal-title-glow text-design-16">在线客服</div>}
|
title={
|
||||||
|
<div className="modal-title-glow text-design-16">
|
||||||
|
{t('commonUi.support.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
className="h-design-500"
|
className="h-design-500"
|
||||||
>
|
>
|
||||||
<div className="h-full min-h-0 px-design-8 pb-design-10 pt-design-4">
|
<div className="h-full min-h-0 px-design-8 pb-design-10 pt-design-4">
|
||||||
@@ -57,7 +63,7 @@ function MobileSupportModal() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
|
||||||
<DataLoadingIndicator
|
<DataLoadingIndicator
|
||||||
label="客服连线中"
|
label={t('commonUi.support.connecting')}
|
||||||
className="text-design-12"
|
className="text-design-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -416,11 +416,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === 'settled') {
|
if (phase === 'settled' || phase === 'locked' || phase === 'revealing') {
|
||||||
nextState.selections = []
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase === 'locked' || phase === 'revealing') {
|
|
||||||
nextState.selections = []
|
nextState.selections = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,11 +484,11 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
|||||||
round: nextRound,
|
round: nextRound,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextRound.phase === 'settled') {
|
if (
|
||||||
nextState.selections = []
|
nextRound.phase === 'settled' ||
|
||||||
}
|
nextRound.phase === 'locked' ||
|
||||||
|
nextRound.phase === 'revealing'
|
||||||
if (nextRound.phase === 'locked' || nextRound.phase === 'revealing') {
|
) {
|
||||||
nextState.selections = []
|
nextState.selections = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -636,6 +636,93 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: gold-border-pulse 1.9s ease-in-out infinite;
|
animation: gold-border-pulse 1.9s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-gold-reveal-shell {
|
||||||
|
--gold-angle: 0deg;
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(var(--design-unit) * 0.2);
|
||||||
|
border-radius: calc(var(--design-unit) * 7);
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(0 round calc(var(--design-unit) * 7));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-gold-reveal-shell::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: calc(var(--design-unit) * 7);
|
||||||
|
padding: calc(var(--design-unit) * 2.5);
|
||||||
|
background: conic-gradient(
|
||||||
|
from var(--gold-angle),
|
||||||
|
#534217 10%,
|
||||||
|
#534217 20%,
|
||||||
|
#ffe226 45%,
|
||||||
|
#534217 60%,
|
||||||
|
#534217 85%,
|
||||||
|
#ffe226 95%,
|
||||||
|
#534217 100%
|
||||||
|
);
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#fff 0 0) content-box,
|
||||||
|
linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
animation: rotating-gold-border 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-gold-reveal-shell::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(var(--design-unit) * 0.5);
|
||||||
|
border-radius: calc(var(--design-unit) * 6.5);
|
||||||
|
padding: calc(var(--design-unit) * 1.7);
|
||||||
|
background: conic-gradient(
|
||||||
|
from calc(var(--gold-angle) + 180deg),
|
||||||
|
rgba(255, 247, 210, 0) 0%,
|
||||||
|
rgba(255, 247, 210, 0) 66%,
|
||||||
|
rgba(255, 247, 210, 0.14) 71%,
|
||||||
|
rgba(255, 254, 244, 0.98) 75%,
|
||||||
|
rgba(255, 230, 130, 0.96) 79%,
|
||||||
|
rgba(255, 247, 210, 0.18) 84%,
|
||||||
|
rgba(255, 247, 210, 0) 90%,
|
||||||
|
rgba(255, 247, 210, 0) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#fff 0 0) content-box,
|
||||||
|
linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
animation: rotating-gold-border 2.1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-gold-reveal-static-border {
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(var(--design-unit) * 0.7);
|
||||||
|
border-radius: calc(var(--design-unit) * 7);
|
||||||
|
border: calc(var(--design-unit) * 2.8) solid rgba(181, 138, 40, 0.98);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 calc(var(--design-unit) * 7) rgba(255, 241, 181, 0.38),
|
||||||
|
0 0 calc(var(--design-unit) * 9) rgba(255, 210, 102, 0.32);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-gold-reveal-glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: calc(var(--design-unit) * 8);
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at center,
|
||||||
|
rgba(255, 226, 92, 0.2) 0%,
|
||||||
|
rgba(219, 161, 42, 0.16) 42%,
|
||||||
|
rgba(127, 86, 13, 0.08) 62%,
|
||||||
|
transparent 82%
|
||||||
|
);
|
||||||
|
filter: blur(calc(var(--design-unit) * 1.4));
|
||||||
|
opacity: 0.42;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: gold-border-pulse 1.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
@@ -194,7 +194,6 @@ export interface AutoHostingStopRules {
|
|||||||
|
|
||||||
export interface JackpotBroadcastItem {
|
export interface JackpotBroadcastItem {
|
||||||
id: string
|
id: string
|
||||||
message: string
|
|
||||||
nickname: string
|
nickname: string
|
||||||
periodNo: string
|
periodNo: string
|
||||||
receivedAt: string
|
receivedAt: string
|
||||||
|
|||||||