feat: 新增首页和图片资源

This commit is contained in:
JiaJun
2026-04-24 18:02:42 +08:00
parent bd92f10b83
commit 9127a06d4a
179 changed files with 3424 additions and 101 deletions

BIN
src/assets/animal/1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
src/assets/animal/10.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
src/assets/animal/11.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
src/assets/animal/12.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
src/assets/animal/13.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
src/assets/animal/14.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
src/assets/animal/15.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src/assets/animal/16.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
src/assets/animal/17.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
src/assets/animal/18.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
src/assets/animal/19.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
src/assets/animal/2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
src/assets/animal/20.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
src/assets/animal/21.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
src/assets/animal/22.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
src/assets/animal/23.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
src/assets/animal/24.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
src/assets/animal/25.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
src/assets/animal/26.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
src/assets/animal/27.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
src/assets/animal/28.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
src/assets/animal/29.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
src/assets/animal/3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
src/assets/animal/30.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
src/assets/animal/31.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
src/assets/animal/32.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
src/assets/animal/33.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
src/assets/animal/34.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
src/assets/animal/35.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
src/assets/animal/36.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
src/assets/animal/4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
src/assets/animal/5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
src/assets/animal/6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
src/assets/animal/7.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
src/assets/animal/8.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
src/assets/animal/9.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
src/assets/slices.zip Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
src/assets/slices/1@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
src/assets/slices/2@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
src/assets/slices/3@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
src/assets/slices/4@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
src/assets/slices/5@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
src/assets/slices/6@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
src/assets/slices/7@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
src/assets/slices/8@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
src/assets/slices/9@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
src/assets/system/a.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
src/assets/system/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
src/assets/system/wifi.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View 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>
)
}

View 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]"
* />
* */

View File

@@ -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
View File

View 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()
}

View File

@@ -0,0 +1,2 @@
export * from './game-api'
export * from './types'

View 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,
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,2 @@
export { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal'

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,2 @@
export * from './api'
export * from './shared'

View 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

View File

@@ -0,0 +1,4 @@
export * from './constants'
export * from './mock-data'
export * from './selectors'
export * from './types'

View 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
}

View 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
})
}

View 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
}

View File

@@ -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'

View File

@@ -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 = (

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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 =

View File

@@ -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>
}

View File

@@ -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
View File

@@ -0,0 +1 @@
export * from './auth-store'

View 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 }

View 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
View File

@@ -0,0 +1,2 @@
export * from './game-round-store'
export * from './game-session-store'

2
src/store/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './auth'
export * from './game'

77
src/style/index.css Normal file
View 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);
}
}

View File

@@ -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;
}
}