feat: 新增首页和图片资源
BIN
src/assets/animal/1.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
src/assets/animal/10.webp
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
src/assets/animal/11.webp
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
src/assets/animal/12.webp
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
src/assets/animal/13.webp
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
src/assets/animal/14.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
src/assets/animal/15.webp
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src/assets/animal/16.webp
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
src/assets/animal/17.webp
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
src/assets/animal/18.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
src/assets/animal/19.webp
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
src/assets/animal/2.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/assets/animal/20.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
src/assets/animal/21.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
src/assets/animal/22.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
src/assets/animal/23.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
src/assets/animal/24.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/assets/animal/25.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/assets/animal/26.webp
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
src/assets/animal/27.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
src/assets/animal/28.webp
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
src/assets/animal/29.webp
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
src/assets/animal/3.webp
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
src/assets/animal/30.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
src/assets/animal/31.webp
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
src/assets/animal/32.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
src/assets/animal/33.webp
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
src/assets/animal/34.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/assets/animal/35.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
src/assets/animal/36.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/assets/animal/4.webp
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
src/assets/animal/5.webp
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
src/assets/animal/6.webp
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
src/assets/animal/7.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
src/assets/animal/8.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
src/assets/animal/9.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
src/assets/slices.zip
Normal file
BIN
src/assets/slices/10@2x.webp
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
src/assets/slices/11@2x.webp
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
src/assets/slices/12@2x.webp
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
src/assets/slices/13@2x.webp
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
src/assets/slices/14@2x.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
src/assets/slices/15@2x.webp
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src/assets/slices/16@2x.webp
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
src/assets/slices/17@2x.webp
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
src/assets/slices/18@2x.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
src/assets/slices/19@2x.webp
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
src/assets/slices/1@2x.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
src/assets/slices/20@2x.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
src/assets/slices/21@2x.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
src/assets/slices/22@2x.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
src/assets/slices/23@2x.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
src/assets/slices/24@2x.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/assets/slices/25@2x.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/assets/slices/26@2x.webp
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
src/assets/slices/27@2x.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
src/assets/slices/28@2x.webp
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
src/assets/slices/29@2x.webp
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
src/assets/slices/2@2x.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/assets/slices/30@2x.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
src/assets/slices/31@2x.webp
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
src/assets/slices/32@2x.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
src/assets/slices/33@2x.webp
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
src/assets/slices/34@2x.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/assets/slices/35@2x.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
src/assets/slices/36@2x.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/assets/slices/3@2x.webp
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
src/assets/slices/4@2x.webp
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
src/assets/slices/5@2x.webp
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
src/assets/slices/6@2x.webp
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
src/assets/slices/7@2x.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
src/assets/slices/8@2x.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
src/assets/slices/9@2x.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
src/assets/slices/Auto-Spin亮@2x.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/slices/Auto-Spin暗@2x.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/slices/LOGO@2x.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src/assets/slices/clear亮@2x.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src/assets/slices/clear暗@2x.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/slices/confirm按钮@2x.webp
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
src/assets/slices/repeat亮@2x.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/slices/repeat暗@2x.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/slices/trebd map按钮@2x.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src/assets/slices/上栏@2x.webp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/assets/slices/下拉@2x.webp
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/assets/slices/中奖变亮遮罩@2x.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/slices/中奖记录框@2x.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/slices/信息 (1)@2x.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/assets/slices/倒计时下部框@2x.webp
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
src/assets/slices/倒计时框@2x.webp
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
src/assets/slices/充值按钮@2x.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
src/assets/slices/光效@2x.webp
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
src/assets/slices/公告@2x.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/assets/slices/公告栏@2x.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/assets/slices/分割线1@2x.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/slices/删除@2x.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/slices/卡片边框@2x.webp
Normal file
|
After Width: | Height: | Size: 745 KiB |
BIN
src/assets/slices/卡片边框素材@2x.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src/assets/slices/国旗@2x.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/slices/声音关@2x.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/assets/slices/声音开@2x.webp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/assets/slices/头像@2x.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/slices/头像框@2x.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/slices/底中框@2x.webp
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
src/assets/slices/循环 (1)@2x.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/assets/slices/按钮底框@2x.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
src/assets/slices/框内可滑动@2x.webp
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
src/assets/slices/椭圆 3@2x.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/slices/火@2x.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/slices/矩形 3 拷贝 2@2x(1).webp
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
src/assets/slices/矩形 3 拷贝 2@2x(2).webp
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
src/assets/slices/矩形 3 拷贝 2@2x.webp
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
src/assets/slices/矩形 3 拷贝 3@2x.webp
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src/assets/slices/矩形 4 拷贝@2x.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/slices/矩形 510@2x.webp
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
src/assets/slices/筹码100@2x.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src/assets/slices/筹码10@2x.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/slices/筹码1@2x.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src/assets/slices/筹码25@2x.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
src/assets/slices/筹码50@2x.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
src/assets/slices/筹码5@2x.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/slices/筹码底@2x.webp
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
src/assets/slices/筹码选中特效@2x.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/slices/网络信号@2x.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/slices/自选筹码+@2x.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src/assets/slices/自选筹码-@2x.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/slices/自选筹码底@2x.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/slices/规则@2x.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/assets/slices/设置@2x.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src/assets/slices/邮件@2x.webp
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
src/assets/slices/金边@2x.webp
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
src/assets/slices/钻石@2x(1).webp
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
src/assets/slices/钻石@2x(2).webp
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
src/assets/slices/钻石@2x.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src/assets/slices/锁@2x.webp
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
src/assets/system/a.webp
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
src/assets/system/avatar.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/system/diamond.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src/assets/system/logo.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src/assets/system/wifi.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
35
src/components/language-link.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { AppLanguage } from '@/i18n'
|
||||
|
||||
interface LanguageLinkProps {
|
||||
currentPathname: string
|
||||
isActive: boolean
|
||||
label: string
|
||||
language: AppLanguage
|
||||
}
|
||||
|
||||
export function LanguageLink({
|
||||
currentPathname,
|
||||
isActive,
|
||||
label,
|
||||
language,
|
||||
}: LanguageLinkProps) {
|
||||
const nextPathname = currentPathname.replace(
|
||||
/^\/(zh-CN|en-US)(?=\/|$)/,
|
||||
`/${language}`,
|
||||
)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={nextPathname}
|
||||
className={`inline-flex min-h-10 items-center rounded-full border px-3 text-xs font-medium transition ${
|
||||
isActive
|
||||
? 'border-amber-300/30 bg-amber-300/14 text-amber-100'
|
||||
: 'border-white/10 bg-white/5 text-slate-300 hover:border-cyan-300/20 hover:text-white'
|
||||
}`}
|
||||
hrefLang={language}
|
||||
lang={language}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
197
src/components/smart-image.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
forwardRef,
|
||||
type ImgHTMLAttributes,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const cx = (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(' ')
|
||||
|
||||
export interface SmartImageProps
|
||||
extends Omit<
|
||||
ImgHTMLAttributes<HTMLImageElement>,
|
||||
'alt' | 'children' | 'className' | 'loading' | 'src'
|
||||
> {
|
||||
src?: string | null
|
||||
alt: string
|
||||
className?: string
|
||||
imgClassName?: string
|
||||
skeletonClassName?: string
|
||||
fallbackClassName?: string
|
||||
fallback?: ReactNode
|
||||
fallbackSrc?: string
|
||||
placeholderSrc?: string
|
||||
loading?: 'eager' | 'lazy'
|
||||
priority?: boolean
|
||||
showSkeleton?: boolean
|
||||
aspectRatio?: CSSProperties['aspectRatio']
|
||||
}
|
||||
|
||||
export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
|
||||
function SmartImage(
|
||||
{
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
imgClassName,
|
||||
skeletonClassName,
|
||||
fallbackClassName,
|
||||
fallback,
|
||||
fallbackSrc,
|
||||
placeholderSrc,
|
||||
loading,
|
||||
priority = false,
|
||||
showSkeleton = true,
|
||||
aspectRatio,
|
||||
draggable = false,
|
||||
decoding,
|
||||
fetchPriority,
|
||||
onError,
|
||||
onLoad,
|
||||
style,
|
||||
...imgProps
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const normalizedSrc = src ?? ''
|
||||
const initialState = useMemo(
|
||||
() => ({
|
||||
currentSrc: normalizedSrc,
|
||||
hasError: normalizedSrc.length === 0,
|
||||
isLoaded: false,
|
||||
retriedWithFallback: false,
|
||||
}),
|
||||
[normalizedSrc],
|
||||
)
|
||||
const [currentSrc, setCurrentSrc] = useState(initialState.currentSrc)
|
||||
const [isLoaded, setIsLoaded] = useState(initialState.isLoaded)
|
||||
const [hasError, setHasError] = useState(initialState.hasError)
|
||||
const [retriedWithFallback, setRetriedWithFallback] = useState(
|
||||
initialState.retriedWithFallback,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSrc(initialState.currentSrc)
|
||||
setIsLoaded(initialState.isLoaded)
|
||||
setHasError(initialState.hasError)
|
||||
setRetriedWithFallback(initialState.retriedWithFallback)
|
||||
}, [initialState])
|
||||
|
||||
const resolvedLoading = priority ? 'eager' : (loading ?? 'lazy')
|
||||
const resolvedFetchPriority = priority ? 'high' : (fetchPriority ?? 'auto')
|
||||
|
||||
const shouldShowVisualPlaceholder = !isLoaded && !hasError
|
||||
const showFallback = hasError && !currentSrc
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('relative overflow-hidden', className)}
|
||||
style={aspectRatio ? { aspectRatio } : undefined}
|
||||
>
|
||||
{placeholderSrc ? (
|
||||
<img
|
||||
src={placeholderSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'pointer-events-none absolute inset-0 h-full w-full object-cover transition duration-300',
|
||||
shouldShowVisualPlaceholder
|
||||
? 'scale-105 opacity-100 blur-xl'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showSkeleton && shouldShowVisualPlaceholder ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'absolute inset-0 animate-pulse bg-gradient-to-br from-slate-800 via-slate-700/70 to-slate-800',
|
||||
skeletonClassName,
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{currentSrc ? (
|
||||
<img
|
||||
ref={ref}
|
||||
src={currentSrc}
|
||||
alt={alt}
|
||||
loading={resolvedLoading}
|
||||
decoding={decoding ?? 'async'}
|
||||
draggable={draggable}
|
||||
fetchPriority={resolvedFetchPriority}
|
||||
className={cx(
|
||||
'relative z-[1] h-full w-full object-cover transition duration-300',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
imgClassName,
|
||||
)}
|
||||
style={style}
|
||||
onLoad={(event) => {
|
||||
setIsLoaded(true)
|
||||
setHasError(false)
|
||||
onLoad?.(event)
|
||||
}}
|
||||
onError={(event) => {
|
||||
onError?.(event)
|
||||
|
||||
if (
|
||||
fallbackSrc &&
|
||||
!retriedWithFallback &&
|
||||
currentSrc !== fallbackSrc
|
||||
) {
|
||||
setCurrentSrc(fallbackSrc)
|
||||
setIsLoaded(false)
|
||||
setHasError(false)
|
||||
setRetriedWithFallback(true)
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentSrc('')
|
||||
setIsLoaded(false)
|
||||
setHasError(true)
|
||||
}}
|
||||
{...imgProps}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showFallback
|
||||
? (fallback ?? (
|
||||
<div
|
||||
className={cx(
|
||||
'absolute inset-0 flex items-center justify-center bg-slate-950/80 px-4 text-center text-xs font-medium tracking-[0.12em] text-slate-300 uppercase',
|
||||
fallbackClassName,
|
||||
)}
|
||||
>
|
||||
Image unavailable
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* <SmartImage
|
||||
* src={bannerUrl}
|
||||
* alt="banner"
|
||||
* className="h-48 w-full rounded-3xl"
|
||||
* imgClassName="object-cover"
|
||||
* placeholderSrc={bannerBlurUrl}
|
||||
* fallbackSrc="/images/banner-fallback.jpg"
|
||||
* />
|
||||
*
|
||||
* 如果是首屏主视觉图:
|
||||
*
|
||||
* <SmartImage
|
||||
* src={heroUrl}
|
||||
* alt="hero"
|
||||
* priority
|
||||
* className="aspect-[16/9] w-full rounded-[32px]"
|
||||
* />
|
||||
* */
|
||||
@@ -2,11 +2,11 @@
|
||||
export const APP_ROOT_ELEMENT_ID = 'root'
|
||||
|
||||
/** @description 应用名称,用于文档标题和分享元信息。 */
|
||||
export const APP_NAME = 'React SPA Template'
|
||||
export const APP_NAME = '36字花'
|
||||
|
||||
/** @description 应用默认的页面描述,用于 SEO 和分享卡片。 */
|
||||
export const APP_DEFAULT_DESCRIPTION =
|
||||
'A frontend scaffold with Vite, React, TanStack Router, TanStack Query, Zustand, and Biome.'
|
||||
'36-character-flower real-time game portal built with React, TanStack Router, TanStack Query, and Zustand.'
|
||||
|
||||
/** @description 认证状态持久化到浏览器时使用的存储键。 */
|
||||
export const AUTH_STORAGE_KEY = 'auth-session'
|
||||
@@ -39,3 +39,6 @@ export const QUERY_RETRYABLE_STATUS_CODES = [
|
||||
|
||||
/** @description 国际化语言设置持久化到浏览器时使用的存储键。 */
|
||||
export const I18N_LANGUAGE_STORAGE_KEY = 'app-language'
|
||||
|
||||
/** @description 桌面端布局切换起始断点。 */
|
||||
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
|
||||
|
||||
0
src/data/animal.ts
Normal file
191
src/features/game/api/game-api.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { api } from '@/lib/api/api-client'
|
||||
|
||||
import type {
|
||||
AnnouncementItem,
|
||||
AnnouncementState,
|
||||
BetSelection,
|
||||
ConnectionState,
|
||||
DashboardState,
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
HistoryEntry,
|
||||
RoundSnapshot,
|
||||
TrendEntry,
|
||||
} from '../shared'
|
||||
import { createMockGameBootstrapSnapshot } from '../shared'
|
||||
import type {
|
||||
AnnouncementStateDto,
|
||||
BetSelectionDto,
|
||||
ChipDto,
|
||||
ConnectionStateDto,
|
||||
DashboardStateDto,
|
||||
GameAnnouncementsDto,
|
||||
GameBootstrapDto,
|
||||
GameCellDto,
|
||||
GameRoundFeedDto,
|
||||
HistoryEntryDto,
|
||||
RoundSnapshotDto,
|
||||
TrendEntryDto,
|
||||
} from './types'
|
||||
|
||||
export const GAME_API_ENDPOINTS = {
|
||||
announcements: 'game/announcements',
|
||||
bootstrap: 'game/bootstrap',
|
||||
roundFeed: 'game/round-feed',
|
||||
} as const
|
||||
|
||||
function normalizeGameCell(dto: GameCellDto) {
|
||||
return dto satisfies GameCell
|
||||
}
|
||||
|
||||
function normalizeChip(dto: ChipDto) {
|
||||
return {
|
||||
amount: dto.amount,
|
||||
color: dto.color,
|
||||
id: dto.id,
|
||||
isDefault: dto.is_default,
|
||||
label: dto.label,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBetSelection(dto: BetSelectionDto) {
|
||||
return {
|
||||
amount: dto.amount,
|
||||
cellId: dto.cell_id,
|
||||
chipId: dto.chip_id,
|
||||
id: dto.id,
|
||||
placedAt: dto.placed_at,
|
||||
source: dto.source,
|
||||
} satisfies BetSelection
|
||||
}
|
||||
|
||||
function normalizeRoundSnapshot(dto: RoundSnapshotDto) {
|
||||
return {
|
||||
bettingClosesAt: dto.betting_closes_at,
|
||||
id: dto.id,
|
||||
phase: dto.phase,
|
||||
revealingAt: dto.revealing_at,
|
||||
settledAt: dto.settled_at,
|
||||
startedAt: dto.started_at,
|
||||
winningCellId: dto.winning_cell_id,
|
||||
} satisfies RoundSnapshot
|
||||
}
|
||||
|
||||
function normalizeHistoryEntry(dto: HistoryEntryDto) {
|
||||
return {
|
||||
payoutMultiplier: dto.payout_multiplier,
|
||||
roundId: dto.round_id,
|
||||
settledAt: dto.settled_at,
|
||||
totalPoolAmount: dto.total_pool_amount,
|
||||
winningCellId: dto.winning_cell_id,
|
||||
} satisfies HistoryEntry
|
||||
}
|
||||
|
||||
function normalizeTrendEntry(dto: TrendEntryDto) {
|
||||
return {
|
||||
cellId: dto.cell_id,
|
||||
currentStreak: dto.current_streak,
|
||||
direction: dto.direction,
|
||||
hitCount: dto.hit_count,
|
||||
lastHitRoundId: dto.last_hit_round_id,
|
||||
missCount: dto.miss_count,
|
||||
} satisfies TrendEntry
|
||||
}
|
||||
|
||||
function normalizeAnnouncementState(dto: AnnouncementStateDto) {
|
||||
return {
|
||||
activeAnnouncementId: dto.active_announcement_id,
|
||||
items: dto.items.map(
|
||||
(item) =>
|
||||
({
|
||||
createdAt: item.created_at,
|
||||
expiresAt: item.expires_at,
|
||||
id: item.id,
|
||||
isPinned: item.is_pinned,
|
||||
isRead: item.is_read,
|
||||
message: item.message,
|
||||
title: item.title,
|
||||
tone: item.tone,
|
||||
}) satisfies AnnouncementItem,
|
||||
),
|
||||
lastUpdatedAt: dto.last_updated_at,
|
||||
} satisfies AnnouncementState
|
||||
}
|
||||
|
||||
function normalizeDashboardState(dto: DashboardStateDto) {
|
||||
return {
|
||||
countdownMs: dto.countdown_ms,
|
||||
featuredCellId: dto.featured_cell_id,
|
||||
onlinePlayers: dto.online_players,
|
||||
tableLimitMax: dto.table_limit_max,
|
||||
tableLimitMin: dto.table_limit_min,
|
||||
totalPoolAmount: dto.total_pool_amount,
|
||||
updatedAt: dto.updated_at,
|
||||
} satisfies DashboardState
|
||||
}
|
||||
|
||||
function normalizeConnectionState(dto: ConnectionStateDto) {
|
||||
return {
|
||||
connectedAt: dto.connected_at,
|
||||
lastError: dto.last_error,
|
||||
lastMessageAt: dto.last_message_at,
|
||||
latencyMs: dto.latency_ms,
|
||||
reconnectAttempt: dto.reconnect_attempt,
|
||||
status: dto.status,
|
||||
transport: dto.transport,
|
||||
} satisfies ConnectionState
|
||||
}
|
||||
|
||||
export function normalizeGameBootstrap(dto: GameBootstrapDto) {
|
||||
return {
|
||||
announcements: normalizeAnnouncementState(dto.announcements),
|
||||
cells: dto.cells.map(normalizeGameCell),
|
||||
chips: dto.chips.map(normalizeChip),
|
||||
connection: normalizeConnectionState(dto.connection),
|
||||
dashboard: normalizeDashboardState(dto.dashboard),
|
||||
history: dto.history.map(normalizeHistoryEntry),
|
||||
round: normalizeRoundSnapshot(dto.round),
|
||||
selections: dto.selections.map(normalizeBetSelection),
|
||||
trends: dto.trends.map(normalizeTrendEntry),
|
||||
} satisfies GameBootstrapSnapshot
|
||||
}
|
||||
|
||||
export function normalizeGameRoundFeed(dto: GameRoundFeedDto) {
|
||||
return {
|
||||
history: dto.history.map(normalizeHistoryEntry),
|
||||
round: normalizeRoundSnapshot(dto.round),
|
||||
selections: dto.selections.map(normalizeBetSelection),
|
||||
trends: dto.trends.map(normalizeTrendEntry),
|
||||
} satisfies Pick<
|
||||
GameBootstrapSnapshot,
|
||||
'history' | 'round' | 'selections' | 'trends'
|
||||
>
|
||||
}
|
||||
|
||||
export async function getGameBootstrap() {
|
||||
const response = await api.get<GameBootstrapDto>(GAME_API_ENDPOINTS.bootstrap)
|
||||
|
||||
return normalizeGameBootstrap(response.data)
|
||||
}
|
||||
|
||||
export async function getGameRoundFeed() {
|
||||
const response = await api.get<GameRoundFeedDto>(GAME_API_ENDPOINTS.roundFeed)
|
||||
|
||||
return normalizeGameRoundFeed(response.data)
|
||||
}
|
||||
|
||||
export async function getGameAnnouncements() {
|
||||
const response = await api.get<GameAnnouncementsDto>(
|
||||
GAME_API_ENDPOINTS.announcements,
|
||||
)
|
||||
|
||||
return normalizeAnnouncementState(response.data.announcements)
|
||||
}
|
||||
|
||||
export async function getMockGameBootstrap(latencyMs = 120) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, latencyMs)
|
||||
})
|
||||
|
||||
return createMockGameBootstrapSnapshot()
|
||||
}
|
||||
2
src/features/game/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './game-api'
|
||||
export * from './types'
|
||||
133
src/features/game/api/types.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
AnnouncementState,
|
||||
BetSelection,
|
||||
Chip,
|
||||
ConnectionState,
|
||||
DashboardState,
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
HistoryEntry,
|
||||
RoundSnapshot,
|
||||
TrendEntry,
|
||||
} from '../shared'
|
||||
|
||||
export interface GameCellDto {
|
||||
column: number
|
||||
id: number
|
||||
label: string
|
||||
odds: number
|
||||
row: number
|
||||
}
|
||||
|
||||
export interface ChipDto {
|
||||
amount: number
|
||||
color: string
|
||||
id: string
|
||||
is_default?: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface BetSelectionDto {
|
||||
amount: number
|
||||
cell_id: number
|
||||
chip_id: string
|
||||
id: string
|
||||
placed_at: string
|
||||
source: BetSelection['source']
|
||||
}
|
||||
|
||||
export interface RoundSnapshotDto {
|
||||
betting_closes_at: string
|
||||
id: string
|
||||
phase: RoundSnapshot['phase']
|
||||
revealing_at: string
|
||||
settled_at: string | null
|
||||
started_at: string
|
||||
winning_cell_id: number | null
|
||||
}
|
||||
|
||||
export interface HistoryEntryDto {
|
||||
payout_multiplier: number
|
||||
round_id: string
|
||||
settled_at: string
|
||||
total_pool_amount: number
|
||||
winning_cell_id: number
|
||||
}
|
||||
|
||||
export interface TrendEntryDto {
|
||||
cell_id: number
|
||||
current_streak: number
|
||||
direction: TrendEntry['direction']
|
||||
hit_count: number
|
||||
last_hit_round_id: string | null
|
||||
miss_count: number
|
||||
}
|
||||
|
||||
export interface AnnouncementItemDto {
|
||||
created_at: string
|
||||
expires_at: string | null
|
||||
id: string
|
||||
is_pinned?: boolean
|
||||
is_read?: boolean
|
||||
message: string
|
||||
title: string
|
||||
tone: 'info' | 'success' | 'warning' | 'critical'
|
||||
}
|
||||
|
||||
export interface AnnouncementStateDto {
|
||||
active_announcement_id: string | null
|
||||
items: AnnouncementItemDto[]
|
||||
last_updated_at: string | null
|
||||
}
|
||||
|
||||
export interface DashboardStateDto {
|
||||
countdown_ms: number
|
||||
featured_cell_id: number | null
|
||||
online_players: number
|
||||
table_limit_max: number
|
||||
table_limit_min: number
|
||||
total_pool_amount: number
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface ConnectionStateDto {
|
||||
connected_at: string | null
|
||||
last_error: string | null
|
||||
last_message_at: string | null
|
||||
latency_ms: number | null
|
||||
reconnect_attempt: number
|
||||
status: ConnectionState['status']
|
||||
transport: ConnectionState['transport']
|
||||
}
|
||||
|
||||
export interface GameBootstrapDto {
|
||||
announcements: AnnouncementStateDto
|
||||
cells: GameCellDto[]
|
||||
chips: ChipDto[]
|
||||
connection: ConnectionStateDto
|
||||
dashboard: DashboardStateDto
|
||||
history: HistoryEntryDto[]
|
||||
round: RoundSnapshotDto
|
||||
selections: BetSelectionDto[]
|
||||
trends: TrendEntryDto[]
|
||||
}
|
||||
|
||||
export interface GameRoundFeedDto {
|
||||
history: HistoryEntryDto[]
|
||||
round: RoundSnapshotDto
|
||||
selections: BetSelectionDto[]
|
||||
trends: TrendEntryDto[]
|
||||
}
|
||||
|
||||
export interface GameAnnouncementsDto {
|
||||
announcements: AnnouncementStateDto
|
||||
}
|
||||
|
||||
export type {
|
||||
AnnouncementState,
|
||||
Chip,
|
||||
DashboardState,
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
HistoryEntry,
|
||||
}
|
||||
66
src/features/game/components/desktop/desktop-animal.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { SmartImage } from '@/components/smart-image'
|
||||
|
||||
const cx = (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(' ')
|
||||
|
||||
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
}) as Record<string, string>
|
||||
|
||||
const animalImageList = Object.entries(animalModules)
|
||||
.map(([path, url]) => {
|
||||
const match = path.match(/\/(\d+)\.webp$/)
|
||||
|
||||
return {
|
||||
id: Number(match?.[1] ?? 0),
|
||||
url,
|
||||
}
|
||||
})
|
||||
.filter((item) => item.id > 0)
|
||||
.sort((left, right) => left.id - right.id)
|
||||
|
||||
interface DesktopAnimalProps {
|
||||
activeId?: number | null
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
imageClassName?: string
|
||||
onSelect?: (animalId: number) => void
|
||||
}
|
||||
|
||||
export function DesktopAnimal({
|
||||
activeId,
|
||||
className,
|
||||
itemClassName,
|
||||
imageClassName,
|
||||
onSelect,
|
||||
}: DesktopAnimalProps) {
|
||||
return (
|
||||
<section className={cx('grid grid-cols-6', className)}>
|
||||
{animalImageList.map((item) => {
|
||||
const isActive = item.id === activeId
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(item.id)}
|
||||
className={cx(
|
||||
'flex flex-col items-center transition',
|
||||
'cursor-pointer',
|
||||
isActive &&
|
||||
'border-[rgba(255,151,15,0.95)] shadow-[inset_0_0_16px_rgba(255,151,15,0.55)]',
|
||||
itemClassName,
|
||||
)}
|
||||
>
|
||||
<SmartImage
|
||||
src={item.url}
|
||||
alt={`animal-${item.id}`}
|
||||
className={cx('h-[110px] w-[220px]', imageClassName)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
97
src/features/game/components/desktop/desktop-header.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { CircleAlert, Mail, Plus, Volume2 } from 'lucide-react'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import logo from '@/assets/system/logo.webp'
|
||||
import wifi from '@/assets/system/wifi.webp'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
|
||||
export function DesktopHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
|
||||
<div className="h-[70px] w-full flex items-center px-[12px]">
|
||||
<div className="w-[400px] flex justify-center items-center h-full border-r border-[rgba(128,223,231,0.65)]">
|
||||
<SmartImage
|
||||
src={logo}
|
||||
alt="logo"
|
||||
priority
|
||||
className="w-[320px] h-[40px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[148px] flex justify-center items-center gap-[10px] h-full px-[20px] border-r border-[rgba(128,223,231,0.65)]">
|
||||
<SmartImage
|
||||
src={wifi}
|
||||
alt="wifi"
|
||||
priority
|
||||
className="w-[28px] h-[20px]"
|
||||
/>
|
||||
<div className={'text-[#74FF69] text-[20px]'}>
|
||||
24 <span className={'text-[16px]'}>ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[175px] flex flex-col justify-center items-center gap-[5px] h-full px-[20px] border-r border-[rgba(128,223,231,0.65)]">
|
||||
<div>System Time</div>
|
||||
<div>20:05:12 GMT+08</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-around gap-[10px] h-full px-[20px] text-[#D5FBFF] border-r border-[rgba(128,223,231,0.65)]">
|
||||
<div className={'flex gap-[10px] common-neon-inset'}>
|
||||
<CircleAlert color={'#57B8BF'} />
|
||||
<div>Rules & Ddds</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex gap-[10px] common-neon-inset'}>
|
||||
<Mail color={'#57B8BF'} />
|
||||
<div>Pesan</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex gap-[10px] common-neon-inset'}>
|
||||
<Volume2 color={'#57B8BF'} />
|
||||
<div>BGM</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex gap-[10px] common-neon-inset'}>
|
||||
<CircleAlert color={'#57B8BF'} />
|
||||
<div>ID</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center h-full text-[#D5FBFF]">
|
||||
<div className={'relative flex items-center justify-center'}>
|
||||
<SmartImage
|
||||
src={avatar}
|
||||
alt="avatar"
|
||||
priority
|
||||
className="absolute left-[20px] top-0 z-20 w-[50px] h-[50px]"
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset !py-[20px] flex items-center justify-center box-border w-[175px] h-[36px]'
|
||||
}
|
||||
>
|
||||
Biomond Balance
|
||||
</div>
|
||||
</div>
|
||||
<div className={'relative flex items-center justify-center h-full'}>
|
||||
<SmartImage
|
||||
src={diamond}
|
||||
alt="diamond"
|
||||
priority
|
||||
className="absolute left-[30px] top-0 z-20 w-[50px] h-[50px]"
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset !py-[20px] flex items-center justify-end gap-[10px] w-[175px] h-[36px]'
|
||||
}
|
||||
>
|
||||
<div>5994469974</div>
|
||||
<div className={'bg-[#2D4559] rounded-xs cursor-pointer p-[5px]'}>
|
||||
<Plus size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
12
src/features/game/components/desktop/desktop-title.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Megaphone } from 'lucide-react'
|
||||
export function DesktopTitle() {
|
||||
return (
|
||||
<section className="common-neon-inset flex items-center text-[#FF970F] flex gap-[10px] !px-[20px] h-[50px]">
|
||||
<Megaphone color={'#57B8BF'} />
|
||||
<div>
|
||||
Selamat kepada pemain Wu Yanzu yang telah memenangkan hadiah utama
|
||||
sebesar 5.000 yuan sebanyak lima kali berturut-turut!🎉🎉🎉
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
2
src/features/game/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
|
||||
export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal'
|
||||
115
src/features/game/components/shared/game-announcement-modal.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const cx = (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(' ')
|
||||
|
||||
type GameTone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger'
|
||||
|
||||
interface GameOverlayAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
tone?: GameTone
|
||||
}
|
||||
|
||||
interface GameAnnouncementModalProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description?: ReactNode
|
||||
eyebrow?: string
|
||||
tone?: GameTone
|
||||
primaryAction?: GameOverlayAction
|
||||
secondaryAction?: GameOverlayAction
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const toneClasses: Record<GameTone, string> = {
|
||||
neutral: 'border-white/10 bg-slate-950/92',
|
||||
brand: 'border-cyan-300/25 bg-slate-950/94',
|
||||
success: 'border-emerald-300/25 bg-slate-950/94',
|
||||
warning: 'border-amber-300/25 bg-slate-950/94',
|
||||
danger: 'border-rose-300/25 bg-slate-950/94',
|
||||
}
|
||||
|
||||
const actionToneClasses: Record<GameTone, string> = {
|
||||
neutral: 'border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.1]',
|
||||
brand: 'border-cyan-300/25 bg-cyan-300/14 text-cyan-50 hover:bg-cyan-300/22',
|
||||
success:
|
||||
'border-emerald-300/25 bg-emerald-300/14 text-emerald-50 hover:bg-emerald-300/22',
|
||||
warning:
|
||||
'border-amber-300/25 bg-amber-300/14 text-amber-50 hover:bg-amber-300/22',
|
||||
danger: 'border-rose-300/25 bg-rose-300/14 text-rose-50 hover:bg-rose-300/22',
|
||||
}
|
||||
|
||||
function ModalAction({ label, onClick, tone = 'brand' }: GameOverlayAction) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cx(
|
||||
'inline-flex min-h-12 items-center justify-center rounded-full border px-5 text-sm font-semibold tracking-[0.18em] uppercase transition duration-200',
|
||||
actionToneClasses[tone],
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function GameAnnouncementModal({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
eyebrow = '',
|
||||
tone = 'brand',
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
children,
|
||||
}: GameAnnouncementModalProps) {
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/82 px-4 py-8 backdrop-blur-md">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="game-announcement-title"
|
||||
className={cx(
|
||||
'w-full max-w-xl rounded-[32px] border p-6 shadow-[0_40px_120px_-40px_rgba(15,23,42,0.95)] sm:p-7',
|
||||
toneClasses[tone],
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[0.68rem] font-semibold tracking-[0.24em] text-slate-300 uppercase">
|
||||
{eyebrow}
|
||||
</span>
|
||||
<h2
|
||||
id="game-announcement-title"
|
||||
className="text-2xl font-semibold tracking-tight text-white sm:text-[2rem]"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<div className="text-sm leading-7 text-slate-300">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children ? (
|
||||
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
{secondaryAction || primaryAction ? (
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
{secondaryAction ? <ModalAction {...secondaryAction} /> : null}
|
||||
{primaryAction ? <ModalAction {...primaryAction} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
src/features/game/entry/game-route-page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getMockGameBootstrap, getVisibleAnnouncements } from '@/features/game'
|
||||
import { GameAnnouncementModal } from '@/features/game/components'
|
||||
import { useDocumentMetadata } from '@/lib/head/document-metadata'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
const ENABLE_ANNOUNCEMENT_MODAL = false
|
||||
|
||||
export function GameRoutePage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const announcements = useGameSessionStore((state) => state.announcements)
|
||||
const dismissAnnouncement = useGameSessionStore(
|
||||
(state) => state.dismissAnnouncement,
|
||||
)
|
||||
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
|
||||
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
|
||||
const markAnnouncementRead = useGameSessionStore(
|
||||
(state) => state.markAnnouncementRead,
|
||||
)
|
||||
|
||||
const [isHydrating, setIsHydrating] = useState(true)
|
||||
|
||||
const activeAnnouncement = useMemo(
|
||||
() =>
|
||||
announcements.items.find(
|
||||
(item) => item.id === announcements.activeAnnouncementId,
|
||||
) ??
|
||||
getVisibleAnnouncements(announcements)[0] ??
|
||||
null,
|
||||
[announcements],
|
||||
)
|
||||
|
||||
useDocumentMetadata({
|
||||
title: t('game.metaTitle'),
|
||||
description: t('game.metaDescription'),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void getMockGameBootstrap().then((snapshot) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRound({
|
||||
cells: snapshot.cells,
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
trends: snapshot.trends,
|
||||
})
|
||||
hydrateSession({
|
||||
announcements: snapshot.announcements,
|
||||
connection: snapshot.connection,
|
||||
dashboard: snapshot.dashboard,
|
||||
})
|
||||
setIsHydrating(false)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hydrateRound, hydrateSession])
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-busy={isHydrating}
|
||||
aria-label={t('game.lobbyTitle')}
|
||||
className="flex min-h-0 flex-1"
|
||||
>
|
||||
<GameAnnouncementModal
|
||||
open={ENABLE_ANNOUNCEMENT_MODAL && Boolean(activeAnnouncement)}
|
||||
eyebrow={t('game.modal.eyebrow')}
|
||||
title={activeAnnouncement?.title ?? ''}
|
||||
description={activeAnnouncement?.message}
|
||||
primaryAction={{
|
||||
label: t('game.modal.acknowledge'),
|
||||
onClick: () => {
|
||||
if (!activeAnnouncement) {
|
||||
return
|
||||
}
|
||||
|
||||
markAnnouncementRead(activeAnnouncement.id)
|
||||
dismissAnnouncement(activeAnnouncement.id)
|
||||
},
|
||||
tone: 'brand',
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: t('game.modal.later'),
|
||||
onClick: () => {
|
||||
if (!activeAnnouncement) {
|
||||
return
|
||||
}
|
||||
|
||||
dismissAnnouncement(activeAnnouncement.id)
|
||||
},
|
||||
tone: 'neutral',
|
||||
}}
|
||||
tone="warning"
|
||||
>
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<p>{t('game.modal.line1')}</p>
|
||||
<p>{t('game.modal.line2')}</p>
|
||||
</div>
|
||||
</GameAnnouncementModal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
2
src/features/game/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './api'
|
||||
export * from './shared'
|
||||
61
src/features/game/shared/constants.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export const GAME_GRID_ROWS = 6
|
||||
export const GAME_GRID_COLUMNS = 6
|
||||
export const GAME_TOTAL_CELLS = GAME_GRID_ROWS * GAME_GRID_COLUMNS
|
||||
|
||||
export const ROUND_PHASES = [
|
||||
'waiting',
|
||||
'betting',
|
||||
'locked',
|
||||
'revealing',
|
||||
'settled',
|
||||
] as const
|
||||
|
||||
export const CELL_STATUSES = [
|
||||
'idle',
|
||||
'betting',
|
||||
'selected',
|
||||
'locked',
|
||||
'won',
|
||||
'lost',
|
||||
] as const
|
||||
|
||||
export const CONNECTION_STATUSES = [
|
||||
'idle',
|
||||
'connecting',
|
||||
'connected',
|
||||
'reconnecting',
|
||||
'disconnected',
|
||||
] as const
|
||||
|
||||
export const CONNECTION_TRANSPORTS = [
|
||||
'websocket',
|
||||
'polling',
|
||||
'offline',
|
||||
] as const
|
||||
|
||||
export const ANNOUNCEMENT_TONES = [
|
||||
'info',
|
||||
'success',
|
||||
'warning',
|
||||
'critical',
|
||||
] as const
|
||||
|
||||
export const BET_SOURCES = ['local', 'server'] as const
|
||||
|
||||
export const TREND_DIRECTIONS = ['rising', 'steady', 'falling'] as const
|
||||
|
||||
export const DEFAULT_GAME_CHIP_AMOUNTS = [10, 25, 50, 100, 200, 500] as const
|
||||
|
||||
export const DEFAULT_GAME_CHIP_COLORS = [
|
||||
'#1D4ED8',
|
||||
'#0F766E',
|
||||
'#B45309',
|
||||
'#B91C1C',
|
||||
'#7C3AED',
|
||||
'#111827',
|
||||
] as const
|
||||
|
||||
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-50'
|
||||
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
|
||||
export const GAME_RECENT_HISTORY_LIMIT = 12
|
||||
export const GAME_BOARD_COLUMNS = GAME_GRID_COLUMNS
|
||||
4
src/features/game/shared/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './constants'
|
||||
export * from './mock-data'
|
||||
export * from './selectors'
|
||||
export * from './types'
|
||||
184
src/features/game/shared/mock-data.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
DEFAULT_ACTIVE_CHIP_ID,
|
||||
DEFAULT_ANNOUNCEMENT_TTL_MS,
|
||||
DEFAULT_GAME_CHIP_AMOUNTS,
|
||||
DEFAULT_GAME_CHIP_COLORS,
|
||||
GAME_GRID_COLUMNS,
|
||||
GAME_TOTAL_CELLS,
|
||||
} from './constants'
|
||||
import { deriveTrendEntries, getRoundCountdownMs } from './selectors'
|
||||
import type {
|
||||
AnnouncementState,
|
||||
BetSelection,
|
||||
Chip,
|
||||
ConnectionState,
|
||||
DashboardState,
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
HistoryEntry,
|
||||
RoundSnapshot,
|
||||
} from './types'
|
||||
|
||||
const MOCK_GAME_BASE_TIME = '2026-04-23T12:00:00.000Z'
|
||||
const MOCK_HISTORY_RESULTS = [8, 12, 12, 4, 31, 9, 17, 22, 17, 5, 28, 13]
|
||||
|
||||
function offsetIso(baseIso: string, offsetMs: number) {
|
||||
return new Date(Date.parse(baseIso) + offsetMs).toISOString()
|
||||
}
|
||||
|
||||
export function createGameCells() {
|
||||
return Array.from({ length: GAME_TOTAL_CELLS }, (_, index) => {
|
||||
const id = index + 1
|
||||
|
||||
return {
|
||||
column: (index % GAME_GRID_COLUMNS) + 1,
|
||||
id,
|
||||
label: String(id).padStart(2, '0'),
|
||||
odds: 36,
|
||||
row: Math.floor(index / GAME_GRID_COLUMNS) + 1,
|
||||
} satisfies GameCell
|
||||
})
|
||||
}
|
||||
|
||||
export function createDefaultChips() {
|
||||
return DEFAULT_GAME_CHIP_AMOUNTS.map((amount, index) => ({
|
||||
amount,
|
||||
color: DEFAULT_GAME_CHIP_COLORS[index],
|
||||
id: `chip-${amount}`,
|
||||
isDefault: `chip-${amount}` === DEFAULT_ACTIVE_CHIP_ID,
|
||||
label: amount >= 100 ? `${amount / 100}x` : String(amount),
|
||||
})) satisfies Chip[]
|
||||
}
|
||||
|
||||
export function createMockHistoryEntries(baseIso = MOCK_GAME_BASE_TIME) {
|
||||
return MOCK_HISTORY_RESULTS.map((winningCellId, index) => {
|
||||
const settledAt = offsetIso(baseIso, -(index + 1) * 30_000)
|
||||
|
||||
return {
|
||||
payoutMultiplier: 36,
|
||||
roundId: `round-${6200 - index}`,
|
||||
settledAt,
|
||||
totalPoolAmount: 12_000 + index * 850,
|
||||
winningCellId,
|
||||
} satisfies HistoryEntry
|
||||
})
|
||||
}
|
||||
|
||||
export function createMockRoundSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
|
||||
return {
|
||||
bettingClosesAt: offsetIso(baseIso, 18_000),
|
||||
id: 'round-6201',
|
||||
phase: 'betting',
|
||||
revealingAt: offsetIso(baseIso, 24_000),
|
||||
settledAt: offsetIso(baseIso, 30_000),
|
||||
startedAt: baseIso,
|
||||
winningCellId: null,
|
||||
} satisfies RoundSnapshot
|
||||
}
|
||||
|
||||
export function createMockBetSelections(chips = createDefaultChips()) {
|
||||
const defaultChip =
|
||||
chips.find((chip) => chip.id === DEFAULT_ACTIVE_CHIP_ID) ?? chips[0]
|
||||
|
||||
return [
|
||||
{
|
||||
amount: defaultChip.amount,
|
||||
cellId: 8,
|
||||
chipId: defaultChip.id,
|
||||
id: 'bet-local-1',
|
||||
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 4_000),
|
||||
source: 'local',
|
||||
},
|
||||
{
|
||||
amount: chips[1]?.amount ?? defaultChip.amount,
|
||||
cellId: 12,
|
||||
chipId: chips[1]?.id ?? defaultChip.id,
|
||||
id: 'bet-server-2',
|
||||
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 7_000),
|
||||
source: 'server',
|
||||
},
|
||||
{
|
||||
amount: chips[3]?.amount ?? defaultChip.amount,
|
||||
cellId: 17,
|
||||
chipId: chips[3]?.id ?? defaultChip.id,
|
||||
id: 'bet-local-3',
|
||||
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 10_000),
|
||||
source: 'local',
|
||||
},
|
||||
] satisfies BetSelection[]
|
||||
}
|
||||
|
||||
export function createMockAnnouncementState(baseIso = MOCK_GAME_BASE_TIME) {
|
||||
return {
|
||||
activeAnnouncementId: 'announcement-maintenance',
|
||||
items: [
|
||||
{
|
||||
createdAt: offsetIso(baseIso, -20_000),
|
||||
expiresAt: offsetIso(baseIso, DEFAULT_ANNOUNCEMENT_TTL_MS),
|
||||
id: 'announcement-maintenance',
|
||||
isPinned: true,
|
||||
isRead: false,
|
||||
message: 'Realtime sync upgrades finish after the current cycle.',
|
||||
title: 'Table maintenance',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
createdAt: offsetIso(baseIso, -55_000),
|
||||
expiresAt: null,
|
||||
id: 'announcement-promo',
|
||||
isRead: true,
|
||||
message: 'Warm-up round rebates are credited every 5 settled rounds.',
|
||||
title: 'Reward window live',
|
||||
tone: 'success',
|
||||
},
|
||||
],
|
||||
lastUpdatedAt: offsetIso(baseIso, -10_000),
|
||||
} satisfies AnnouncementState
|
||||
}
|
||||
|
||||
export function createMockDashboardState(
|
||||
baseIso = MOCK_GAME_BASE_TIME,
|
||||
round = createMockRoundSnapshot(baseIso),
|
||||
history = createMockHistoryEntries(baseIso),
|
||||
) {
|
||||
return {
|
||||
countdownMs: getRoundCountdownMs(round, baseIso),
|
||||
featuredCellId: history[0]?.winningCellId ?? null,
|
||||
onlinePlayers: 1_284,
|
||||
tableLimitMax: 5_000,
|
||||
tableLimitMin: 10,
|
||||
totalPoolAmount: 84_300,
|
||||
updatedAt: baseIso,
|
||||
} satisfies DashboardState
|
||||
}
|
||||
|
||||
export function createMockConnectionState(baseIso = MOCK_GAME_BASE_TIME) {
|
||||
return {
|
||||
connectedAt: offsetIso(baseIso, -180_000),
|
||||
lastError: null,
|
||||
lastMessageAt: offsetIso(baseIso, -500),
|
||||
latencyMs: 48,
|
||||
reconnectAttempt: 0,
|
||||
status: 'connected',
|
||||
transport: 'websocket',
|
||||
} satisfies ConnectionState
|
||||
}
|
||||
|
||||
export function createMockGameBootstrapSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
|
||||
const cells = createGameCells()
|
||||
const chips = createDefaultChips()
|
||||
const history = createMockHistoryEntries(baseIso)
|
||||
const round = createMockRoundSnapshot(baseIso)
|
||||
|
||||
return {
|
||||
announcements: createMockAnnouncementState(baseIso),
|
||||
cells,
|
||||
chips,
|
||||
connection: createMockConnectionState(baseIso),
|
||||
dashboard: createMockDashboardState(baseIso, round, history),
|
||||
history,
|
||||
round,
|
||||
selections: createMockBetSelections(chips),
|
||||
trends: deriveTrendEntries(history),
|
||||
} satisfies GameBootstrapSnapshot
|
||||
}
|
||||
184
src/features/game/shared/selectors.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { GAME_RECENT_HISTORY_LIMIT, GAME_TOTAL_CELLS } from './constants'
|
||||
import type {
|
||||
AnnouncementState,
|
||||
BetSelection,
|
||||
Chip,
|
||||
GameCell,
|
||||
GameCellViewModel,
|
||||
HistoryEntry,
|
||||
RoundSnapshot,
|
||||
TrendDirection,
|
||||
TrendEntry,
|
||||
} from './types'
|
||||
|
||||
export function getChipById(chips: Chip[], chipId: string) {
|
||||
return chips.find((chip) => chip.id === chipId) ?? null
|
||||
}
|
||||
|
||||
export function getSelectionTotal(selections: BetSelection[]) {
|
||||
return selections.reduce((total, selection) => total + selection.amount, 0)
|
||||
}
|
||||
|
||||
export function groupSelectionsByCell(selections: BetSelection[]) {
|
||||
return selections.reduce<Record<number, { amount: number; count: number }>>(
|
||||
(accumulator, selection) => {
|
||||
const current = accumulator[selection.cellId] ?? {
|
||||
amount: 0,
|
||||
count: 0,
|
||||
}
|
||||
|
||||
accumulator[selection.cellId] = {
|
||||
amount: current.amount + selection.amount,
|
||||
count: current.count + 1,
|
||||
}
|
||||
|
||||
return accumulator
|
||||
},
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function getRecentWinningCellIds(
|
||||
history: HistoryEntry[],
|
||||
limit = GAME_RECENT_HISTORY_LIMIT,
|
||||
) {
|
||||
return history.slice(0, limit).map((entry) => entry.winningCellId)
|
||||
}
|
||||
|
||||
export function getUnreadAnnouncementCount(announcements: AnnouncementState) {
|
||||
return announcements.items.filter((item) => !item.isRead).length
|
||||
}
|
||||
|
||||
export function getVisibleAnnouncements(
|
||||
announcements: AnnouncementState,
|
||||
nowIso = new Date().toISOString(),
|
||||
) {
|
||||
const now = Date.parse(nowIso)
|
||||
|
||||
return announcements.items.filter((item) => {
|
||||
if (item.expiresAt === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Date.parse(item.expiresAt) > now
|
||||
})
|
||||
}
|
||||
|
||||
export function getRoundCountdownMs(
|
||||
round: RoundSnapshot,
|
||||
nowIso = new Date().toISOString(),
|
||||
) {
|
||||
const now = Date.parse(nowIso)
|
||||
|
||||
if (round.phase === 'waiting' || round.phase === 'betting') {
|
||||
return Math.max(0, Date.parse(round.bettingClosesAt) - now)
|
||||
}
|
||||
|
||||
if (round.phase === 'locked' || round.phase === 'revealing') {
|
||||
return Math.max(0, Date.parse(round.revealingAt) - now)
|
||||
}
|
||||
|
||||
if (round.settledAt) {
|
||||
return Math.max(0, Date.parse(round.settledAt) - now)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export function deriveTrendEntries(
|
||||
history: HistoryEntry[],
|
||||
cellCount = GAME_TOTAL_CELLS,
|
||||
) {
|
||||
const entries = Array.from({ length: cellCount }, (_, index) => {
|
||||
const cellId = index + 1
|
||||
const hitRounds = history.filter((entry) => entry.winningCellId === cellId)
|
||||
const recentSample = history.slice(0, 6)
|
||||
const previousSample = history.slice(6, 12)
|
||||
const recentHits = recentSample.filter(
|
||||
(entry) => entry.winningCellId === cellId,
|
||||
).length
|
||||
const previousHits = previousSample.filter(
|
||||
(entry) => entry.winningCellId === cellId,
|
||||
).length
|
||||
|
||||
let direction: TrendDirection = 'steady'
|
||||
|
||||
if (recentHits > previousHits) {
|
||||
direction = 'rising'
|
||||
} else if (recentHits < previousHits) {
|
||||
direction = 'falling'
|
||||
}
|
||||
|
||||
let currentStreak = 0
|
||||
|
||||
for (const entry of history) {
|
||||
if (entry.winningCellId !== cellId) {
|
||||
break
|
||||
}
|
||||
|
||||
currentStreak += 1
|
||||
}
|
||||
|
||||
return {
|
||||
cellId,
|
||||
currentStreak,
|
||||
direction,
|
||||
hitCount: hitRounds.length,
|
||||
lastHitRoundId: hitRounds[0]?.roundId ?? null,
|
||||
missCount: history.length - hitRounds.length,
|
||||
} satisfies TrendEntry
|
||||
})
|
||||
|
||||
return entries.sort((left, right) => {
|
||||
if (right.hitCount !== left.hitCount) {
|
||||
return right.hitCount - left.hitCount
|
||||
}
|
||||
|
||||
return left.cellId - right.cellId
|
||||
})
|
||||
}
|
||||
|
||||
export function buildGameCellViewModels(input: {
|
||||
cells: GameCell[]
|
||||
round: RoundSnapshot
|
||||
selections: BetSelection[]
|
||||
trends: TrendEntry[]
|
||||
}) {
|
||||
const groupedSelections = groupSelectionsByCell(input.selections)
|
||||
const trendByCell = new Map(
|
||||
input.trends.map((entry) => [entry.cellId, entry]),
|
||||
)
|
||||
|
||||
return input.cells.map((cell) => {
|
||||
const groupedSelection = groupedSelections[cell.id]
|
||||
const trend = trendByCell.get(cell.id)
|
||||
const isSelected = Boolean(groupedSelection)
|
||||
const isWinningCell = input.round.winningCellId === cell.id
|
||||
|
||||
let status: GameCellViewModel['status'] = 'idle'
|
||||
|
||||
if (input.round.phase === 'betting') {
|
||||
status = isSelected ? 'selected' : 'betting'
|
||||
} else if (
|
||||
input.round.phase === 'locked' ||
|
||||
input.round.phase === 'revealing'
|
||||
) {
|
||||
status = isSelected ? 'locked' : 'idle'
|
||||
} else if (input.round.phase === 'settled' && isWinningCell) {
|
||||
status = 'won'
|
||||
} else if (input.round.phase === 'settled' && isSelected) {
|
||||
status = 'lost'
|
||||
}
|
||||
|
||||
return {
|
||||
...cell,
|
||||
currentStreak: trend?.currentStreak ?? 0,
|
||||
hitCount: trend?.hitCount ?? 0,
|
||||
isSelected,
|
||||
isWinningCell,
|
||||
selectionAmount: groupedSelection?.amount ?? 0,
|
||||
selectionCount: groupedSelection?.count ?? 0,
|
||||
status,
|
||||
} satisfies GameCellViewModel
|
||||
})
|
||||
}
|
||||
134
src/features/game/shared/types.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
ANNOUNCEMENT_TONES,
|
||||
BET_SOURCES,
|
||||
CELL_STATUSES,
|
||||
CONNECTION_STATUSES,
|
||||
CONNECTION_TRANSPORTS,
|
||||
ROUND_PHASES,
|
||||
TREND_DIRECTIONS,
|
||||
} from './constants'
|
||||
|
||||
export type RoundPhase = (typeof ROUND_PHASES)[number]
|
||||
export type CellStatus = (typeof CELL_STATUSES)[number]
|
||||
export type ConnectionStatus = (typeof CONNECTION_STATUSES)[number]
|
||||
export type ConnectionTransport = (typeof CONNECTION_TRANSPORTS)[number]
|
||||
export type AnnouncementTone = (typeof ANNOUNCEMENT_TONES)[number]
|
||||
export type BetSource = (typeof BET_SOURCES)[number]
|
||||
export type TrendDirection = (typeof TREND_DIRECTIONS)[number]
|
||||
|
||||
export interface GameCell {
|
||||
column: number
|
||||
id: number
|
||||
label: string
|
||||
odds: number
|
||||
row: number
|
||||
}
|
||||
|
||||
export interface Chip {
|
||||
amount: number
|
||||
color: string
|
||||
id: string
|
||||
isDefault?: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface BetSelection {
|
||||
amount: number
|
||||
cellId: number
|
||||
chipId: string
|
||||
id: string
|
||||
placedAt: string
|
||||
source: BetSource
|
||||
}
|
||||
|
||||
export interface RoundSnapshot {
|
||||
bettingClosesAt: string
|
||||
id: string
|
||||
phase: RoundPhase
|
||||
revealingAt: string
|
||||
settledAt: string | null
|
||||
startedAt: string
|
||||
winningCellId: number | null
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
payoutMultiplier: number
|
||||
roundId: string
|
||||
settledAt: string
|
||||
totalPoolAmount: number
|
||||
winningCellId: number
|
||||
}
|
||||
|
||||
export interface TrendEntry {
|
||||
cellId: number
|
||||
currentStreak: number
|
||||
direction: TrendDirection
|
||||
hitCount: number
|
||||
lastHitRoundId: string | null
|
||||
missCount: number
|
||||
}
|
||||
|
||||
export interface AnnouncementItem {
|
||||
createdAt: string
|
||||
expiresAt: string | null
|
||||
id: string
|
||||
isPinned?: boolean
|
||||
isRead?: boolean
|
||||
message: string
|
||||
title: string
|
||||
tone: AnnouncementTone
|
||||
}
|
||||
|
||||
export interface AnnouncementState {
|
||||
activeAnnouncementId: string | null
|
||||
items: AnnouncementItem[]
|
||||
lastUpdatedAt: string | null
|
||||
}
|
||||
|
||||
export interface DashboardState {
|
||||
countdownMs: number
|
||||
featuredCellId: number | null
|
||||
onlinePlayers: number
|
||||
tableLimitMax: number
|
||||
tableLimitMin: number
|
||||
totalPoolAmount: number
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface ConnectionState {
|
||||
connectedAt: string | null
|
||||
lastError: string | null
|
||||
lastMessageAt: string | null
|
||||
latencyMs: number | null
|
||||
reconnectAttempt: number
|
||||
status: ConnectionStatus
|
||||
transport: ConnectionTransport
|
||||
}
|
||||
|
||||
export interface GameBootstrapSnapshot {
|
||||
announcements: AnnouncementState
|
||||
cells: GameCell[]
|
||||
chips: Chip[]
|
||||
connection: ConnectionState
|
||||
dashboard: DashboardState
|
||||
history: HistoryEntry[]
|
||||
round: RoundSnapshot
|
||||
selections: BetSelection[]
|
||||
trends: TrendEntry[]
|
||||
}
|
||||
|
||||
export interface GameCellViewModel extends GameCell {
|
||||
currentStreak: number
|
||||
hitCount: number
|
||||
isSelected: boolean
|
||||
isWinningCell: boolean
|
||||
selectionAmount: number
|
||||
selectionCount: number
|
||||
status: CellStatus
|
||||
}
|
||||
|
||||
export interface SelectionSummary {
|
||||
amount: number
|
||||
cellId: number
|
||||
count: number
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
handleUnauthorizedSession,
|
||||
tryRefreshAuthSession,
|
||||
} from '@/lib/auth/auth-session'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
import { ApiError } from './api-error'
|
||||
import type { ApiResponse } from './types'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AuthSessionInput, AuthUser } from '@/store/auth-store'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import type { AuthSessionInput, AuthUser } from '@/store/auth'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
export type CurrentUserInitializer = () => Promise<AuthUser | null>
|
||||
export type RefreshSessionHandler = (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from '@tanstack/react-router'
|
||||
|
||||
import type { AppLanguage } from '@/i18n'
|
||||
import { getPreferredLanguage } from '@/i18n'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
import { initializeAuthSession, isAuthenticated } from './auth-session'
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export default {
|
||||
nav: {
|
||||
home: 'Home',
|
||||
game: 'Game',
|
||||
},
|
||||
shell: {
|
||||
eyebrow: 'React SPA scaffold',
|
||||
subtitle: 'TanStack Router + Query + Auth + i18n',
|
||||
eyebrow: '36 Character Flower',
|
||||
subtitle: 'Real-time draw game frontend for mobile and desktop',
|
||||
},
|
||||
notFound: {
|
||||
eyebrow: '404',
|
||||
@@ -13,30 +14,118 @@ export default {
|
||||
home: 'Back home',
|
||||
},
|
||||
home: {
|
||||
eyebrow: 'Blank scaffold ready',
|
||||
title: 'Start building your frontend project from here.',
|
||||
eyebrow: 'Game shell in progress',
|
||||
title:
|
||||
'The 36-character-flower dual-device game framework is now being built.',
|
||||
description:
|
||||
'The template already includes routing, data fetching, session state, head metadata, and i18n. After removing the examples, this page becomes your clean starting point.',
|
||||
'The project has moved past the generic scaffold stage. It is now being structured around a shared game route, shared state, and split mobile and desktop views for the real-time betting experience.',
|
||||
cards: {
|
||||
routingMode: 'Routing mode',
|
||||
dataLayer: 'Data layer',
|
||||
transport: 'Transport',
|
||||
auth: 'Auth layer',
|
||||
metadata: 'Page metadata',
|
||||
routingMode: 'Routing',
|
||||
dataLayer: 'State model',
|
||||
transport: 'Realtime',
|
||||
auth: 'Product',
|
||||
metadata: 'Current focus',
|
||||
},
|
||||
values: {
|
||||
routingMode: 'SPA + file routes',
|
||||
dataLayer: 'TanStack Query',
|
||||
transport: 'ky',
|
||||
auth: 'Zustand session scaffold',
|
||||
metadata: 'Dynamic title / meta',
|
||||
routingMode: 'Shared URL + split device views',
|
||||
dataLayer: 'Round / Bet / User / UI / Connection',
|
||||
transport: 'HTTP + WebSocket',
|
||||
auth: '36-grid live draw gameplay',
|
||||
metadata: 'Scaffold the structure before state machine polish',
|
||||
},
|
||||
footnote:
|
||||
'A practical place to start is replacing src/routes/$lang/index.tsx, src/lib/api/api-client.ts, and src/store/auth-store.ts with your project-specific structure.',
|
||||
'Next up: the main game route, shared business model, and dedicated mobile and desktop page shells.',
|
||||
primaryAction: 'Enter game lobby',
|
||||
secondaryAction: 'View project structure',
|
||||
},
|
||||
language: {
|
||||
label: 'Language',
|
||||
zhCN: '中文',
|
||||
enUS: 'English',
|
||||
},
|
||||
game: {
|
||||
metaTitle: 'Game Lobby',
|
||||
metaDescription: '36-character-flower live game lobby.',
|
||||
lobbyTitle: '36 Character Flower Lobby',
|
||||
lobbySubtitle:
|
||||
'Under one shared business route, mobile and desktop mount different views on top of the same game data and state.',
|
||||
status: {
|
||||
roundState: 'Round state',
|
||||
currentRound: 'Current round {{id}}',
|
||||
tablePool: 'Table pool',
|
||||
onlineCount: '{{count}} online',
|
||||
activeChip: 'Active chip',
|
||||
announcementsRead: '{{read}}/{{total}} announcements read',
|
||||
connection: 'Connection',
|
||||
connectionHealthy: 'Live sync stable',
|
||||
connectionRecovering: 'Waiting to recover',
|
||||
synced: 'Synced',
|
||||
degraded: 'Degraded',
|
||||
},
|
||||
board: {
|
||||
historyTitle: 'Round history',
|
||||
historySubtitle: 'Recent draw and payout trace',
|
||||
trendTitle: 'Trend radar',
|
||||
trendSubtitle: 'Momentum and miss streak snapshot',
|
||||
stageTitle: 'Draw stage',
|
||||
stageSubtitle:
|
||||
'This stage holds the main board and control structure before the full state machine and animation pass.',
|
||||
currentPhase: 'Current phase',
|
||||
selectedBet: 'Bet {{amount}}',
|
||||
hitCount: '{{count}} hits',
|
||||
hitBadge: '{{count}}x',
|
||||
badgeWin: 'Win',
|
||||
badgeBet: 'Bet',
|
||||
cellLabel: 'Cell {{id}}',
|
||||
winningCell: 'Winning cell {{id}}',
|
||||
missedRounds: 'Missed {{count}} rounds',
|
||||
rising: 'Rising',
|
||||
falling: 'Falling',
|
||||
steady: 'Steady',
|
||||
hitTotal: '{{count}} hits',
|
||||
},
|
||||
phases: {
|
||||
betting: 'Betting',
|
||||
locked: 'Locked',
|
||||
revealing: 'Revealing',
|
||||
settled: 'Settled',
|
||||
},
|
||||
actions: {
|
||||
unifiedBetHint: 'Unified bet',
|
||||
totalBet: 'Total bet',
|
||||
canBet: 'Can bet',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
quickBet: 'Quick bet 08',
|
||||
clearPending: 'Clear pending',
|
||||
autoModeDemo: 'Auto mode demo',
|
||||
stopAuto: 'Stop auto',
|
||||
},
|
||||
modal: {
|
||||
eyebrow: 'Announcement',
|
||||
acknowledge: 'Acknowledge',
|
||||
later: 'Later',
|
||||
line1:
|
||||
'This will later connect to the real announcement body, confirmation checkbox, and persistence flow.',
|
||||
line2: 'For now it validates the shared modal structure.',
|
||||
},
|
||||
autoSpin: {
|
||||
eyebrow: 'Auto spin',
|
||||
title: 'Auto spin running',
|
||||
description:
|
||||
'Auto mode will cover the board while preserving target cell focus and progress.',
|
||||
trailingLabel: 'Manual input locked',
|
||||
},
|
||||
footer: {
|
||||
implementationTitle: 'Current implementation',
|
||||
implementationSubtitle:
|
||||
'This iteration prioritizes the dual-device shell, shared model, and business wiring.',
|
||||
implementationBody:
|
||||
'Next steps are the real API, WebSocket, full UI store, and round lifecycle state machine.',
|
||||
limitsTitle: 'Table limits',
|
||||
limitsSubtitle: 'Derived from dashboard mock data',
|
||||
minBet: 'Min bet',
|
||||
maxBet: 'Max bet',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export default {
|
||||
nav: {
|
||||
home: '首页',
|
||||
game: '游戏大厅',
|
||||
},
|
||||
shell: {
|
||||
eyebrow: 'React SPA 脚手架',
|
||||
subtitle: 'TanStack Router + Query + Auth + i18n',
|
||||
eyebrow: '36字花',
|
||||
subtitle: '实时开奖 · 双端适配 · 多语言游戏前端',
|
||||
},
|
||||
notFound: {
|
||||
eyebrow: '404',
|
||||
@@ -13,30 +14,114 @@ export default {
|
||||
home: '返回首页',
|
||||
},
|
||||
home: {
|
||||
eyebrow: '空白脚手架已就绪',
|
||||
title: '从这里开始搭建你的前端项目。',
|
||||
eyebrow: '游戏前台骨架已启动',
|
||||
title: '36 字花双端游戏框架正在落地。',
|
||||
description:
|
||||
'模板已经接好路由、请求层、会话状态、head metadata 和多语言。删除示例后,这一页就是你的干净起点。',
|
||||
'当前版本已经切入真实业务方向:统一路由、共享状态、移动端与桌面端分视图,后续会在这个基础上继续接入实时状态机、下注流程和开奖动画。',
|
||||
cards: {
|
||||
routingMode: '路由模式',
|
||||
dataLayer: '数据层',
|
||||
transport: '请求层',
|
||||
auth: '认证层',
|
||||
metadata: '页面元信息',
|
||||
routingMode: '路由策略',
|
||||
dataLayer: '状态规划',
|
||||
transport: '联机能力',
|
||||
auth: '业务目标',
|
||||
metadata: '当前重点',
|
||||
},
|
||||
values: {
|
||||
routingMode: 'SPA + 文件路由',
|
||||
dataLayer: 'TanStack Query',
|
||||
transport: 'ky',
|
||||
auth: 'Zustand 会话基座',
|
||||
metadata: '动态 title / meta',
|
||||
routingMode: '统一 URL + 双端分视图',
|
||||
dataLayer: 'Round / Bet / User / UI / Connection',
|
||||
transport: 'HTTP + WebSocket',
|
||||
auth: '36 宫格实时开奖玩法',
|
||||
metadata: '先落结构,再接状态机与动效',
|
||||
},
|
||||
footnote:
|
||||
'建议先从 src/routes/$lang/index.tsx、src/lib/api/api-client.ts 和 src/store/auth-store.ts 开始替换成你的项目结构。',
|
||||
'下一步会优先完成游戏主路由、共享业务模型和移动端 / 桌面端页面骨架。',
|
||||
primaryAction: '进入游戏大厅',
|
||||
secondaryAction: '查看项目结构',
|
||||
},
|
||||
language: {
|
||||
label: '语言',
|
||||
zhCN: '中文',
|
||||
enUS: 'English',
|
||||
},
|
||||
game: {
|
||||
metaTitle: '游戏大厅',
|
||||
metaDescription: '36字花实时开奖游戏大厅。',
|
||||
lobbyTitle: '36字花游戏大厅',
|
||||
lobbySubtitle:
|
||||
'统一业务路由下,移动端与桌面端分别挂载不同视图,但共用一套游戏数据和状态。',
|
||||
status: {
|
||||
roundState: '回合状态',
|
||||
currentRound: '当前期号 {{id}}',
|
||||
tablePool: '桌面资金池',
|
||||
onlineCount: '{{count}} 在线',
|
||||
activeChip: '当前筹码',
|
||||
announcementsRead: '已读公告 {{read}}/{{total}}',
|
||||
connection: '连接质量',
|
||||
connectionHealthy: '连接稳定',
|
||||
connectionRecovering: '等待重连',
|
||||
synced: '已同步',
|
||||
degraded: '异常',
|
||||
},
|
||||
board: {
|
||||
historyTitle: '开奖历史',
|
||||
historySubtitle: '最近开奖与派奖记录',
|
||||
trendTitle: '冷热走势',
|
||||
trendSubtitle: '热度与遗漏参考',
|
||||
stageTitle: '开奖舞台',
|
||||
stageSubtitle: '这一层先挂载主盘面与中控结构,后续再接真实状态机和动画。',
|
||||
currentPhase: '当前阶段',
|
||||
selectedBet: '下注 {{amount}}',
|
||||
hitCount: '命中 {{count}} 次',
|
||||
hitBadge: '{{count}}x',
|
||||
badgeWin: '中奖',
|
||||
badgeBet: '下注',
|
||||
cellLabel: '字花 {{id}}',
|
||||
winningCell: '中奖格 {{id}}',
|
||||
missedRounds: '遗漏 {{count}} 局',
|
||||
rising: '热度上升',
|
||||
falling: '热度回落',
|
||||
steady: '稳定',
|
||||
hitTotal: '{{count}} 命中',
|
||||
},
|
||||
phases: {
|
||||
betting: '下注中',
|
||||
locked: '已封盘',
|
||||
revealing: '开奖中',
|
||||
settled: '已结算',
|
||||
},
|
||||
actions: {
|
||||
unifiedBetHint: '统一下注额',
|
||||
totalBet: '总下注',
|
||||
canBet: '可下注',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
quickBet: '快速选中 08',
|
||||
clearPending: '清空未确认',
|
||||
autoModeDemo: '自动托管演示',
|
||||
stopAuto: '停止托管',
|
||||
},
|
||||
modal: {
|
||||
eyebrow: '强制公告',
|
||||
acknowledge: '已读并进入',
|
||||
later: '稍后查看',
|
||||
line1: '这里后续会接真实公告图文、勾选确认和已读状态。',
|
||||
line2: '当前先用共享弹窗骨架验证结构。',
|
||||
},
|
||||
autoSpin: {
|
||||
eyebrow: '自动托管',
|
||||
title: '自动托管运行中',
|
||||
description: '托管态会覆盖主盘面,但目标格子和进度信息仍然保留可见。',
|
||||
trailingLabel: '手动输入已锁定',
|
||||
},
|
||||
footer: {
|
||||
implementationTitle: '当前实现说明',
|
||||
implementationSubtitle:
|
||||
'这一版优先把双端壳层、共享模型和业务接线落到代码里。',
|
||||
implementationBody:
|
||||
'下一步会继续接入真实 API、WebSocket、完整 UI Store 和回合状态机。',
|
||||
limitsTitle: '桌限信息',
|
||||
limitsSubtitle: '来自 dashboard mock 数据',
|
||||
minBet: '最低下注',
|
||||
maxBet: '最高下注',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -8,7 +8,7 @@ import '@/i18n'
|
||||
import { initializeAuthSession } from '@/lib/auth/auth-session'
|
||||
import { queryClient } from '@/lib/query/query-client'
|
||||
import { router } from '@/router'
|
||||
import './styles.css'
|
||||
import './style/index.css'
|
||||
|
||||
const rootElement = document.getElementById(APP_ROOT_ELEMENT_ID)
|
||||
const shouldShowQueryDevtools =
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useDocumentMetadata } from '@/lib/head/document-metadata'
|
||||
import { GameRoutePage } from '@/features/game/entry/game-route-page'
|
||||
|
||||
export const Route = createFileRoute('/$lang/')({
|
||||
component: HomePage,
|
||||
component: GameRoutePage,
|
||||
})
|
||||
|
||||
function HomePage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useDocumentMetadata({
|
||||
title: t('home.title'),
|
||||
description: t('home.description'),
|
||||
})
|
||||
|
||||
return <div>111</div>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createFileRoute, Navigate, Outlet } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
|
||||
import { DesktopHeader } from '@/features/game/components/desktop/desktop-header.tsx'
|
||||
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
|
||||
|
||||
export const Route = createFileRoute('/$lang')({
|
||||
@@ -23,24 +25,18 @@ function LanguageLayout() {
|
||||
if (!isValidLanguage) {
|
||||
return <Navigate to="/$lang" params={{ lang: preferredLanguage }} replace />
|
||||
}
|
||||
|
||||
// function changeLanguage(nextLanguage: AppLanguage) {
|
||||
// const nextPathname = location.pathname.replace(
|
||||
// /^\/(zh-CN|en-US)(?=\/|$)/,
|
||||
// `/${nextLanguage}`,
|
||||
// )
|
||||
// void i18n.changeLanguage(nextLanguage)
|
||||
// void navigate({
|
||||
// to: nextPathname,
|
||||
// search: location.search,
|
||||
// hash: location.hash,
|
||||
// replace: true,
|
||||
// })
|
||||
// }
|
||||
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
</>
|
||||
<div className="flex min-h-full flex-col">
|
||||
<DesktopHeader />
|
||||
<div className={'mx-auto w-[95%] my-[10px]'}>
|
||||
<DesktopTitle />
|
||||
</div>
|
||||
<div className={''}>
|
||||
<DesktopAnimal />
|
||||
</div>
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
1
src/store/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './auth-store'
|
||||
171
src/store/game/game-round-store.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import type {
|
||||
BetSelection,
|
||||
Chip,
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
HistoryEntry,
|
||||
RoundPhase,
|
||||
RoundSnapshot,
|
||||
TrendEntry,
|
||||
} from '@/features/game/shared'
|
||||
import {
|
||||
buildGameCellViewModels,
|
||||
createMockGameBootstrapSnapshot,
|
||||
DEFAULT_ACTIVE_CHIP_ID,
|
||||
getChipById,
|
||||
getRecentWinningCellIds,
|
||||
getSelectionTotal,
|
||||
groupSelectionsByCell,
|
||||
} from '@/features/game/shared'
|
||||
|
||||
type GameRoundSlice = Pick<
|
||||
GameBootstrapSnapshot,
|
||||
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
|
||||
>
|
||||
|
||||
export interface GameRoundStoreState extends GameRoundSlice {
|
||||
activeChipId: string
|
||||
clearSelections: () => void
|
||||
hydrateRound: (snapshot: GameRoundSlice) => void
|
||||
placeBet: (cellId: number) => void
|
||||
removeSelectionsForCell: (cellId: number) => void
|
||||
selectChip: (chipId: string) => void
|
||||
setPhase: (phase: RoundPhase) => void
|
||||
syncRound: (round: Partial<RoundSnapshot>) => void
|
||||
upsertSelections: (selections: BetSelection[]) => void
|
||||
}
|
||||
|
||||
function createInitialRoundState(): GameRoundSlice & { activeChipId: string } {
|
||||
const snapshot = createMockGameBootstrapSnapshot()
|
||||
|
||||
return {
|
||||
activeChipId:
|
||||
snapshot.chips.find((chip) => chip.isDefault)?.id ??
|
||||
DEFAULT_ACTIVE_CHIP_ID,
|
||||
cells: snapshot.cells,
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
trends: snapshot.trends,
|
||||
}
|
||||
}
|
||||
|
||||
export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
...createInitialRoundState(),
|
||||
clearSelections: () => {
|
||||
set({ selections: [] })
|
||||
},
|
||||
hydrateRound: (snapshot) => {
|
||||
set((state) => ({
|
||||
activeChipId: getChipById(snapshot.chips, state.activeChipId)
|
||||
? state.activeChipId
|
||||
: (snapshot.chips.find((chip) => chip.isDefault)?.id ??
|
||||
snapshot.chips[0]?.id ??
|
||||
DEFAULT_ACTIVE_CHIP_ID),
|
||||
cells: snapshot.cells,
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
trends: snapshot.trends,
|
||||
}))
|
||||
},
|
||||
placeBet: (cellId) => {
|
||||
set((state) => {
|
||||
const activeChip =
|
||||
getChipById(state.chips, state.activeChipId) ??
|
||||
state.chips.find((chip) => chip.isDefault) ??
|
||||
state.chips[0]
|
||||
|
||||
if (!activeChip || state.round.phase !== 'betting') {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
selections: [
|
||||
...state.selections,
|
||||
{
|
||||
amount: activeChip.amount,
|
||||
cellId,
|
||||
chipId: activeChip.id,
|
||||
id: `bet-${cellId}-${state.selections.length + 1}-${Date.now()}`,
|
||||
placedAt: new Date().toISOString(),
|
||||
source: 'local',
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
},
|
||||
removeSelectionsForCell: (cellId) => {
|
||||
set((state) => ({
|
||||
selections: state.selections.filter(
|
||||
(selection) => selection.cellId !== cellId,
|
||||
),
|
||||
}))
|
||||
},
|
||||
selectChip: (chipId) => {
|
||||
set((state) => {
|
||||
if (!getChipById(state.chips, chipId)) {
|
||||
return state
|
||||
}
|
||||
|
||||
return { activeChipId: chipId }
|
||||
})
|
||||
},
|
||||
setPhase: (phase) => {
|
||||
set((state) => ({
|
||||
round: {
|
||||
...state.round,
|
||||
phase,
|
||||
},
|
||||
}))
|
||||
},
|
||||
syncRound: (round) => {
|
||||
set((state) => ({
|
||||
round: {
|
||||
...state.round,
|
||||
...round,
|
||||
},
|
||||
}))
|
||||
},
|
||||
upsertSelections: (selections) => {
|
||||
set({ selections })
|
||||
},
|
||||
}))
|
||||
|
||||
export const selectActiveChip = (state: GameRoundStoreState): Chip | null =>
|
||||
getChipById(state.chips, state.activeChipId) ??
|
||||
state.chips.find((chip) => chip.isDefault) ??
|
||||
state.chips[0] ??
|
||||
null
|
||||
|
||||
export const selectBoardCells = (state: GameRoundStoreState) =>
|
||||
buildGameCellViewModels({
|
||||
cells: state.cells,
|
||||
round: state.round,
|
||||
selections: state.selections,
|
||||
trends: state.trends,
|
||||
})
|
||||
|
||||
export const selectCanPlaceBets = (state: GameRoundStoreState) =>
|
||||
state.round.phase === 'betting'
|
||||
|
||||
export const selectRecentResults = (state: GameRoundStoreState) =>
|
||||
getRecentWinningCellIds(state.history)
|
||||
|
||||
export const selectSelectionTotal = (state: GameRoundStoreState) =>
|
||||
getSelectionTotal(state.selections)
|
||||
|
||||
export const selectSelectionsByCell = (state: GameRoundStoreState) =>
|
||||
groupSelectionsByCell(state.selections)
|
||||
|
||||
export type GameRoundStore = typeof useGameRoundStore
|
||||
export type GameRoundStoreData = Pick<
|
||||
GameRoundStoreState,
|
||||
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
|
||||
>
|
||||
|
||||
export type { BetSelection, GameCell, HistoryEntry, RoundSnapshot, TrendEntry }
|
||||
131
src/store/game/game-session-store.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import type {
|
||||
AnnouncementState,
|
||||
ConnectionState,
|
||||
ConnectionStatus,
|
||||
DashboardState,
|
||||
GameBootstrapSnapshot,
|
||||
} from '@/features/game/shared'
|
||||
import {
|
||||
createMockGameBootstrapSnapshot,
|
||||
getUnreadAnnouncementCount,
|
||||
getVisibleAnnouncements,
|
||||
} from '@/features/game/shared'
|
||||
|
||||
type GameSessionSlice = Pick<
|
||||
GameBootstrapSnapshot,
|
||||
'announcements' | 'connection' | 'dashboard'
|
||||
>
|
||||
|
||||
export interface GameSessionStoreState extends GameSessionSlice {
|
||||
dismissAnnouncement: (announcementId: string) => void
|
||||
hydrateSession: (snapshot: GameSessionSlice) => void
|
||||
markAnnouncementRead: (announcementId: string) => void
|
||||
setConnectionLatency: (latencyMs: number | null) => void
|
||||
setConnectionStatus: (status: ConnectionStatus) => void
|
||||
syncConnection: (patch: Partial<ConnectionState>) => void
|
||||
syncDashboard: (patch: Partial<DashboardState>) => void
|
||||
}
|
||||
|
||||
function createInitialSessionState(): GameSessionSlice {
|
||||
const snapshot = createMockGameBootstrapSnapshot()
|
||||
|
||||
return {
|
||||
announcements: snapshot.announcements,
|
||||
connection: snapshot.connection,
|
||||
dashboard: snapshot.dashboard,
|
||||
}
|
||||
}
|
||||
|
||||
export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
|
||||
...createInitialSessionState(),
|
||||
dismissAnnouncement: (announcementId) => {
|
||||
set((state) => ({
|
||||
announcements: {
|
||||
...state.announcements,
|
||||
activeAnnouncementId:
|
||||
state.announcements.activeAnnouncementId === announcementId
|
||||
? (state.announcements.items.find(
|
||||
(item) => item.id !== announcementId,
|
||||
)?.id ?? null)
|
||||
: state.announcements.activeAnnouncementId,
|
||||
items: state.announcements.items.filter(
|
||||
(item) => item.id !== announcementId,
|
||||
),
|
||||
},
|
||||
}))
|
||||
},
|
||||
hydrateSession: (snapshot) => {
|
||||
set(snapshot)
|
||||
},
|
||||
markAnnouncementRead: (announcementId) => {
|
||||
set((state) => ({
|
||||
announcements: {
|
||||
...state.announcements,
|
||||
items: state.announcements.items.map((item) =>
|
||||
item.id === announcementId ? { ...item, isRead: true } : item,
|
||||
),
|
||||
},
|
||||
}))
|
||||
},
|
||||
setConnectionLatency: (latencyMs) => {
|
||||
set((state) => ({
|
||||
connection: {
|
||||
...state.connection,
|
||||
latencyMs,
|
||||
},
|
||||
}))
|
||||
},
|
||||
setConnectionStatus: (status) => {
|
||||
set((state) => ({
|
||||
connection: {
|
||||
...state.connection,
|
||||
connectedAt:
|
||||
status === 'connected'
|
||||
? (state.connection.connectedAt ?? new Date().toISOString())
|
||||
: state.connection.connectedAt,
|
||||
status,
|
||||
},
|
||||
}))
|
||||
},
|
||||
syncConnection: (patch) => {
|
||||
set((state) => ({
|
||||
connection: {
|
||||
...state.connection,
|
||||
...patch,
|
||||
},
|
||||
}))
|
||||
},
|
||||
syncDashboard: (patch) => {
|
||||
set((state) => ({
|
||||
dashboard: {
|
||||
...state.dashboard,
|
||||
...patch,
|
||||
},
|
||||
}))
|
||||
},
|
||||
}))
|
||||
|
||||
export const selectActiveAnnouncement = (state: GameSessionStoreState) =>
|
||||
state.announcements.items.find(
|
||||
(item) => item.id === state.announcements.activeAnnouncementId,
|
||||
) ?? null
|
||||
|
||||
export const selectIsConnectionHealthy = (state: GameSessionStoreState) =>
|
||||
state.connection.status === 'connected' &&
|
||||
(state.connection.latencyMs === null || state.connection.latencyMs < 150)
|
||||
|
||||
export const selectUnreadAnnouncementCount = (state: GameSessionStoreState) =>
|
||||
getUnreadAnnouncementCount(state.announcements)
|
||||
|
||||
export const selectVisibleAnnouncements = (state: GameSessionStoreState) =>
|
||||
getVisibleAnnouncements(state.announcements)
|
||||
|
||||
export type GameSessionStore = typeof useGameSessionStore
|
||||
export type GameSessionStoreData = Pick<
|
||||
GameSessionStoreState,
|
||||
'announcements' | 'connection' | 'dashboard'
|
||||
>
|
||||
|
||||
export type { AnnouncementState, ConnectionState, DashboardState }
|
||||
2
src/store/game/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './game-round-store'
|
||||
export * from './game-session-store'
|
||||
2
src/store/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './auth'
|
||||
export * from './game'
|
||||
77
src/style/index.css
Normal file
@@ -0,0 +1,77 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
"Inter", "SF Pro Display", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
--font-mono:
|
||||
"JetBrains Mono", "SFMono-Regular", "SF Mono", Consolas, monospace;
|
||||
--color-game-bg: #07111f;
|
||||
--color-game-surface: #0d1b2d;
|
||||
--color-game-surface-strong: #12253d;
|
||||
--color-game-accent: #31d0ff;
|
||||
--color-game-accent-soft: #7ce8ff;
|
||||
--color-game-gold: #f5c86b;
|
||||
--color-game-gold-strong: #ffd66e;
|
||||
--color-game-danger: #ff5e7a;
|
||||
--color-game-success: #4fdc9b;
|
||||
--shadow-game-panel:
|
||||
0 20px 60px rgba(2, 6, 23, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply m-0 h-full w-full;
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(49, 208, 255, 0.12), transparent 28%),
|
||||
radial-gradient(
|
||||
circle at bottom right,
|
||||
rgba(245, 200, 107, 0.1),
|
||||
transparent 24%
|
||||
),
|
||||
linear-gradient(180deg, #07111f 0%, #040812 100%);
|
||||
color: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.common-neon-inset {
|
||||
border: 1px solid rgba(128, 223, 231, 0.65);
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
box-shadow: inset 0 0 8px rgba(128, 223, 231, 0.65);
|
||||
}
|
||||
|
||||
.game-panel {
|
||||
border: 1px solid rgba(124, 232, 255, 0.12);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(18, 37, 61, 0.88),
|
||||
rgba(7, 17, 31, 0.78)
|
||||
);
|
||||
box-shadow: var(--shadow-game-panel);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.game-chip-glow {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(49, 208, 255, 0.16),
|
||||
0 16px 36px rgba(49, 208, 255, 0.16);
|
||||
}
|
||||
|
||||
.game-gold-glow {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(245, 200, 107, 0.2),
|
||||
0 18px 40px rgba(245, 200, 107, 0.14);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
"Inter", "SF Pro Display", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
--font-mono:
|
||||
"JetBrains Mono", "SFMono-Regular", "SF Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply m-0 h-full w-full;
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
}
|
||||