feat: 优化整体项目ui

This commit is contained in:
JiaJun
2026-05-22 17:58:52 +08:00
parent 44c984d59e
commit 046f250ce3
56 changed files with 2149 additions and 700 deletions

View File

@@ -1,11 +1,22 @@
import { TriangleAlert } from 'lucide-react'
import { motion } from 'motion/react'
import { useMemo } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import animalBorderImage from '@/assets/game/animal-border.webp'
import enStopImage from '@/assets/game/en-stop.webp'
import zhStopImage from '@/assets/game/zh-stop.webp'
import diamondIcon from '@/assets/system/diamond.webp'
import { LottiePlayer } from '@/components/lottie-player'
import { SmartImage } from '@/components/smart-image'
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore } from '@/store/game'
const revealBorderPath = new URL(
'../../../../assets/lottie/test.json',
import.meta.url,
).href
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true,
@@ -24,8 +35,25 @@ const animalImageList = Object.entries(animalModules)
.filter((item) => item.id > 0)
.sort((left, right) => left.id - right.id)
function getRandomAnimalId(ids: number[], currentId: number | null) {
if (ids.length === 0) {
return null
}
if (ids.length === 1) {
return ids[0] ?? null
}
let nextId = currentId
while (nextId === currentId) {
nextId = ids[Math.floor(Math.random() * ids.length)] ?? currentId
}
return nextId
}
interface DesktopAnimalProps {
activeId?: number | null
className?: string
itemClassName?: string
imageClassName?: string
@@ -33,14 +61,34 @@ interface DesktopAnimalProps {
}
export function DesktopAnimal({
activeId,
className,
itemClassName,
imageClassName,
onSelect,
}: DesktopAnimalProps) {
const { t } = useTranslation()
const { i18n, t } = useTranslation()
const animalIds = useMemo(() => animalImageList.map((item) => item.id), [])
const containerRef = useRef<HTMLElement | null>(null)
const cellRefs = useRef(new Map<number, HTMLButtonElement>())
const [revealCellId, setRevealCellId] = useState<number | null>(null)
const [revealFrame, setRevealFrame] = useState<{
height: number
left: number
top: number
width: number
} | null>(null)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
)
const roundPhase = useGameRoundStore((state) => state.round.phase)
const roundId = useGameRoundStore((state) => state.round.id)
const lastBetPeriodNo = useAuthStore(
(state) => state.currentUser?.lastBetPeriodNo,
)
const finishRevealAnimation = useGameRoundStore(
(state) => state.finishRevealAnimation,
)
const {
cellWarning,
handleSelect,
@@ -51,9 +99,112 @@ export function DesktopAnimal({
selectionByCell,
showStandbyState,
} = useAnimalVm(animalIds, onSelect)
const isRevealRunning =
revealPhase === 'spinning' || revealPhase === 'stopping'
const isRevealResult = revealPhase === 'result'
const hasSubmittedCurrentRound =
roundPhase === 'betting' && Boolean(roundId) && lastBetPeriodNo === roundId
const showStopOverlay =
hasSubmittedCurrentRound ||
roundPhase === 'locked' ||
roundPhase === 'revealing'
const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
? zhStopImage
: enStopImage
useEffect(() => {
if (revealPhase === 'idle') {
setRevealCellId(null)
return
}
if (revealPhase === 'result') {
setRevealCellId(revealWinningCellId)
return
}
if (revealPhase === 'spinning') {
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const intervalId = window.setInterval(() => {
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
}, 70)
return () => {
window.clearInterval(intervalId)
}
}
if (revealWinningCellId === null) {
return
}
let elapsedMs = 0
const timeoutIds: number[] = []
for (let index = 0; index < 14; index += 1) {
elapsedMs += 42 + index * 13
timeoutIds.push(
window.setTimeout(() => {
setRevealCellId((currentId) =>
getRandomAnimalId(animalIds, currentId),
)
}, elapsedMs),
)
}
elapsedMs += 160
timeoutIds.push(
window.setTimeout(() => {
setRevealCellId(revealWinningCellId)
finishRevealAnimation()
}, elapsedMs),
)
return () => {
for (const timeoutId of timeoutIds) {
window.clearTimeout(timeoutId)
}
}
}, [animalIds, finishRevealAnimation, revealPhase, revealWinningCellId])
useLayoutEffect(() => {
if (revealCellId === null) {
setRevealFrame(null)
return
}
const syncRevealFrame = () => {
const container = containerRef.current
const cell = cellRefs.current.get(revealCellId)
if (!container || !cell) {
setRevealFrame(null)
return
}
const containerRect = container.getBoundingClientRect()
const cellRect = cell.getBoundingClientRect()
setRevealFrame({
height: cellRect.height,
left: cellRect.left - containerRect.left,
top: cellRect.top - containerRect.top,
width: cellRect.width,
})
}
syncRevealFrame()
window.addEventListener('resize', syncRevealFrame)
return () => {
window.removeEventListener('resize', syncRevealFrame)
}
}, [revealCellId])
return (
<section
ref={containerRef}
className={cn(
'relative grid w-full grid-cols-6 gap-design-5 overflow-hidden common-neon-inset',
className,
@@ -62,8 +213,8 @@ export function DesktopAnimal({
{animalImageList.map((item) => {
const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta)
const isActive = item.id === activeId || hasPlacedSelection
const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner = isRevealResult && revealWinningCellId === item.id
const warningType =
cellWarning?.cellId === item.id ? cellWarning.type : null
const showCellWarning = warningType !== null
@@ -75,8 +226,15 @@ export function DesktopAnimal({
return (
<motion.button
key={item.id}
ref={(node) => {
if (node) {
cellRefs.current.set(item.id, node)
} else {
cellRefs.current.delete(item.id)
}
}}
type="button"
disabled={lockInteraction}
disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)}
animate={
showCellWarning
@@ -100,20 +258,34 @@ export function DesktopAnimal({
: { duration: 0.16, ease: 'easeOut' }
}
className={cn(
'relative flex flex-col items-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
'relative flex h-design-112 flex-col items-center justify-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
lockInteraction
? 'cursor-not-allowed opacity-90'
: 'cursor-pointer hover:-translate-y-[1px]',
isMarqueeActive &&
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
isActive &&
'border-[rgba(255,187,61,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,175,52,0.82),0_0_calc(var(--design-unit)*30)_rgba(255,151,15,0.46),inset_0_0_calc(var(--design-unit)*20)_rgba(255,177,70,0.58)]',
isRevealRunning &&
'border-[rgba(104,255,249,0.9)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(68,244,255,0.68),0_0_calc(var(--design-unit)*26)_rgba(37,214,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(115,255,247,0.24)] brightness-125 saturate-150',
isRevealWinner &&
'shadow-[0_0_calc(var(--design-unit)*14)_rgba(81,248,255,0.72),0_0_calc(var(--design-unit)*24)_rgba(30,199,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.34)] brightness-125 saturate-150',
showCellWarning &&
'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]',
!showStandbyState && !hasPlacedSelection && 'opacity-95',
itemClassName,
)}
>
<SmartImage
src={animalBorderImage}
alt=""
aria-hidden="true"
priority
showSkeleton={false}
className="pointer-events-none absolute inset-0 z-20 h-full w-full"
imgClassName="object-fill"
/>
<span className="pointer-events-none absolute left-design-24 top-design-16 z-30 text-design-32 font-bold leading-none text-[#4BFFFE]">
{String(item.id).padStart(2, '0')}
</span>
<motion.span
aria-hidden="true"
animate={
@@ -135,8 +307,10 @@ export function DesktopAnimal({
'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150',
isMarqueeActive &&
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
isActive &&
'bg-[radial-gradient(circle_at_center,rgba(255,207,116,0.42)_0%,rgba(255,181,61,0.16)_42%,transparent_74%)] opacity-100',
isRevealRunning &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.5)_0%,rgba(77,244,255,0.24)_40%,rgba(27,183,255,0.1)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*16)_rgba(95,249,255,0.72),inset_0_0_calc(var(--design-unit)*22)_rgba(151,255,250,0.4)]',
isRevealWinner &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.5)_0%,rgba(67,226,255,0.24)_38%,rgba(25,131,255,0.1)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*14)_rgba(92,248,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(126,255,250,0.4)]',
showCellWarning &&
'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100',
)}
@@ -151,9 +325,14 @@ export function DesktopAnimal({
src={item.url}
alt={`animal-${item.id}`}
className={cn(
'relative z-10 h-design-112 w-design-223 rounded-2xl object-contain',
'absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*14)]',
isRevealRunning &&
'brightness-125 saturate-150 drop-shadow-[0_0_calc(var(--design-unit)*10)_rgba(101,250,255,0.62)]',
isRevealWinner &&
'brightness-140 saturate-150 drop-shadow-[0_0_calc(var(--design-unit)*12)_rgba(106,250,255,0.72)]',
imageClassName,
)}
imgClassName="object-fill"
/>
{showCellWarning ? (
<motion.span
@@ -210,21 +389,135 @@ export function DesktopAnimal({
)
})}
{revealFrame ? (
<div
aria-hidden="true"
className="pointer-events-none absolute z-40 transition-[height,transform,width] duration-75 ease-linear"
style={{
height: revealFrame.height + 16,
transform: `translate(${revealFrame.left - 8}px, ${revealFrame.top - 8}px)`,
width: revealFrame.width + 16,
}}
>
<LottiePlayer
path={revealBorderPath}
renderer="svg"
loop
autoplay
speed={1.8}
className="h-full w-full scale-[1.18] [&>svg]:h-full [&>svg]:w-full"
/>
</div>
) : null}
{showStopOverlay ? (
<div
aria-hidden="true"
className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]"
>
<SmartImage
src={stopImageSrc}
alt="stop betting"
priority
showSkeleton={false}
className="h-design-220 w-design-560 max-w-[78%] overflow-visible"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
/>
</div>
) : null}
{showStandbyState ? (
<button
type="button"
onClick={handleStart}
className="absolute inset-0 z-10 flex cursor-pointer items-center justify-center bg-[rgba(3,13,20,0.62)]"
aria-busy={isRealtimeConnecting}
className="group absolute inset-0 z-10 flex cursor-pointer items-center justify-center overflow-hidden bg-[rgba(3,13,20,0.66)]"
>
<div className="relative flex flex-col items-center gap-design-8 rounded-[calc(var(--design-unit)*20)] border border-[rgba(111,255,247,0.54)] bg-[linear-gradient(180deg,rgba(6,28,38,0.92),rgba(4,14,20,0.94))] px-design-28 py-design-16 text-center shadow-[0_0_calc(var(--design-unit)*16)_rgba(70,245,255,0.34),0_0_calc(var(--design-unit)*34)_rgba(19,210,232,0.22)] transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-[1px] hover:border-[rgba(141,255,250,0.8)] hover:shadow-[0_0_calc(var(--design-unit)*22)_rgba(88,247,255,0.48),0_0_calc(var(--design-unit)*42)_rgba(32,228,255,0.3)]">
<span className="text-design-14 uppercase tracking-[0.42em] text-[rgba(111,255,247,0.76)]">
{isRealtimeConnecting ? '' : t('gameDesktop.animal.tapToEnter')}
</span>
<span className="text-design-28 font-semibold tracking-[0.18em] text-[#D2FFFF]">
<motion.div
aria-hidden="true"
animate={{ rotate: 360 }}
transition={{
duration: 18,
repeat: Number.POSITIVE_INFINITY,
ease: 'linear',
}}
className="pointer-events-none absolute inset-[12%] rounded-full bg-[conic-gradient(from_0deg,rgba(129,255,250,0)_0deg,rgba(129,255,250,0.26)_60deg,rgba(129,255,250,0)_120deg,rgba(255,255,255,0)_360deg)] opacity-70 blur-[18px]"
/>
<motion.div
aria-hidden="true"
animate={{
scale: [1, 1.03, 1],
opacity: [0.42, 0.7, 0.42],
}}
transition={{
duration: 2.8,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}}
className="pointer-events-none absolute inset-[22%] rounded-full border border-[rgba(124,255,248,0.22)] shadow-[0_0_calc(var(--design-unit)*22)_rgba(74,245,255,0.12),inset_0_0_calc(var(--design-unit)*18)_rgba(122,255,250,0.14)]"
/>
<div className="relative flex min-w-design-260 flex-col items-center gap-design-10 rounded-[calc(var(--design-unit)*22)] border border-[rgba(111,255,247,0.56)] bg-[linear-gradient(180deg,rgba(8,30,42,0.94),rgba(4,14,20,0.96))] px-design-32 py-design-18 text-center shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.38),0_0_calc(var(--design-unit)*42)_rgba(19,210,232,0.22),inset_0_0_calc(var(--design-unit)*18)_rgba(120,255,249,0.12)] transition-[transform,box-shadow,border-color] duration-200 group-hover:-translate-y-[1px] group-hover:border-[rgba(141,255,250,0.82)] group-hover:shadow-[0_0_calc(var(--design-unit)*24)_rgba(88,247,255,0.5),0_0_calc(var(--design-unit)*52)_rgba(32,228,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(145,255,251,0.16)]">
<span className="pointer-events-none absolute inset-[1px] rounded-[calc(var(--design-unit)*22)] border border-[rgba(226,255,255,0.1)]" />
<span className="pointer-events-none absolute inset-x-design-14 top-design-10 h-design-18 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.16),rgba(255,255,255,0))] opacity-70" />
<span className="text-design-14 uppercase tracking-[0.44em] text-[rgba(132,255,248,0.72)]">
{isRealtimeConnecting
? t('gameDesktop.animal.loading')
: t('gameDesktop.animal.getStart')}
: t('gameDesktop.animal.tapToEnter')}
</span>
<div className="flex items-center gap-design-10">
{isRealtimeConnecting ? (
<span className="relative flex h-design-24 w-design-24 items-center justify-center">
<motion.span
animate={{ rotate: 360 }}
transition={{
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'linear',
}}
className="absolute inset-0 rounded-full border-2 border-[rgba(119,255,250,0.22)] border-t-[rgba(119,255,250,0.98)] border-r-[rgba(119,255,250,0.56)]"
/>
<motion.span
animate={{ scale: [0.85, 1, 0.85], opacity: [0.6, 1, 0.6] }}
transition={{
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}}
className="h-design-8 w-design-8 rounded-full bg-[#BFFFFD] shadow-[0_0_calc(var(--design-unit)*10)_rgba(114,255,249,0.72)]"
/>
</span>
) : (
<span className="h-design-10 w-design-10 rounded-full bg-[rgba(126,255,248,0.92)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(114,255,249,0.62)]" />
)}
<span className="text-design-28 font-semibold tracking-[0.18em] text-[#E0FFFF]">
{isRealtimeConnecting
? t('gameDesktop.animal.loading')
: t('gameDesktop.animal.getStart')}
</span>
</div>
<div className="flex items-center gap-design-4">
{[0, 1, 2].map((index) => (
<motion.span
key={index}
animate={
isRealtimeConnecting
? { opacity: [0.28, 1, 0.28], y: [0, -2, 0] }
: { opacity: 0.7 }
}
transition={
isRealtimeConnecting
? {
duration: 0.9,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
delay: index * 0.15,
}
: undefined
}
className="h-design-4 w-design-4 rounded-full bg-[rgba(145,255,249,0.86)] shadow-[0_0_calc(var(--design-unit)*8)_rgba(114,255,249,0.48)]"
/>
))}
</div>
</div>
</button>
) : null}

View File

@@ -93,8 +93,10 @@ export function DesktopHeader() {
</div>
<div className="flex h-full w-design-175 flex-col items-center justify-center gap-design-5 border-r border-[rgba(128,223,231,0.65)]">
<div>{t('gameDesktop.header.systemTime')}</div>
<div>{systemTimeLabel}</div>
<div className={'text-[#B4E4E9]'}>
{t('gameDesktop.header.systemTime')}
</div>
<div className={'text-[#D2FCFF] font-bold'}>{systemTimeLabel}</div>
</div>
<div className="flex h-full flex-1 items-center justify-around gap-design-10 border-r border-[rgba(128,223,231,0.65)] px-design-20">

View File

@@ -1,14 +1,18 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import down5Animation from '@/assets/lottie/down5.json'
import diamond from '@/assets/system/diamond.webp'
import fire from '@/assets/system/fire.webp'
import lock from '@/assets/system/lock.webp'
import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp'
import streakBg from '@/assets/system/streak.webp'
import { LottiePlayer } from '@/components/lottie-player.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
export function DesktopStatusLine() {
const { t } = useTranslation()
const {
@@ -36,22 +40,72 @@ export function DesktopStatusLine() {
<SmartBackground
src={statusLine}
size="100% 100%"
className="w-full h-design-60 bg-no-repeat bg-center flex items-center justify-center"
className="w-full h-design-75 bg-no-repeat bg-center flex items-center justify-center"
>
{/* 状态栏左侧 */}
<div
className={'flex-1 flex items-center justify-center gap-design-24'}
className={
'relative h-full flex-1 flex items-center justify-center gap-design-50'
}
>
<div>
{t('gameDesktop.status.odds')}: {oddsLabel}
{/*<div className={'flex-1 absolute z-10 -right-20 -top-6 w-full !h-design-105'} style={{*/}
{/* backgroundImage: `url(${streakBg})`,*/}
{/* backgroundSize: '100% 110%',*/}
{/* backgroundRepeat: 'no-repeat',*/}
{/*}} >*/}
{/*</div>*/}
<div className={'text-[#CBD3D5] font-bold'}>
{t('gameDesktop.status.odds')}:{' '}
<span className={'text-[#E3D171]'}>{oddsLabel}</span>
</div>
<div>
{t('gameDesktop.status.streak')}: {streakLabel}
<div
className={
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
}
>
<SmartImage
className={'w-design-37 h-design-47'}
alt={'fire'}
src={fire}
/>
<div>
{t('gameDesktop.status.streak')}:{' '}
<span
className={
'bg-gradient-to-b from-[#EBA661] to-[#FCC785] bg-clip-text text-transparent'
}
>
{streakLabel}
</span>
</div>
</div>
<div>
{t('gameDesktop.status.limit')}: {limitLabel}
<div
className={
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
}
>
<SmartImage
className={'w-design-25 h-design-33'}
alt={'lock'}
src={lock}
/>
<div className={'flex items-center gap-design-10'}>
<div>{t('gameDesktop.status.limit')}:</div>
<div className={'flex items-center gap-design-5'}>
<SmartImage
className={'w-design-35 h-design-35'}
alt={'diamond'}
src={diamond}
/>
<div>{limitLabel}</div>
</div>
</div>
</div>
</div>
<div className="relative flex h-[105px] w-design-360 items-center justify-center">
<div className="relative z-20 flex h-[105px] w-design-360 items-center justify-center">
<SmartBackground
src={statusCenter}
className="pointer-events-none absolute inset-0 z-0 bg-no-repeat bg-center bg-contain transition-opacity duration-500 ease-out"
@@ -100,7 +154,7 @@ export function DesktopStatusLine() {
</SmartBackground>
<div
className={
'absolute top-design-60 left-1/2 -translate-x-1/2 -z-10 w-full px-design-16'
'absolute top-design-75 left-1/2 -translate-x-1/2 -z-10 w-full px-design-16'
}
>
<DesktopTitle />

View File

@@ -1,12 +1,19 @@
import { Megaphone } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import broadcast from '@/assets/system/broadcast.webp'
import { SmartImage } from '@/components/smart-image.tsx'
export function DesktopTitle() {
const { t } = useTranslation()
return (
<section className="common-neon-inset text-design-16 w-full flex h-design-50 items-end gap-design-10 !px-design-20 text-[#FF970F]">
<Megaphone color={'#57B8BF'} />
<div>{t('gameDesktop.title.announcement')}</div>
<section className="common-neon-inset text-design-16 w-full flex h-design-65 items-center gap-design-10 !px-design-20 ">
<SmartImage
className={'w-design-24 h-design-24'}
alt={'broadcast'}
src={broadcast}
/>
<div className={'!text-[#FF970F]'}>
{t('gameDesktop.title.announcement')}
</div>
</section>
)
}

View File

@@ -1,4 +1,4 @@
export { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx'
export type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
export { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal'
export { EntryNoticeGateModal } from '@/features/game/components/shared/entry-notice-gate-modal'

View File

@@ -0,0 +1,235 @@
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { RotateCw } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
import checkIcon from '@/assets/system/right.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import {
ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY,
} from '@/constants'
import { getNoticeList } from '@/features/game/api'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
function getLastConfirmedAt(storageKey: string) {
if (typeof localStorage === 'undefined') {
return null
}
const value = Number(localStorage.getItem(storageKey))
return Number.isFinite(value) && value > 0 ? value : null
}
function setLastConfirmedAt(storageKey: string, timestamp: number) {
if (typeof localStorage === 'undefined') {
return
}
localStorage.setItem(storageKey, String(timestamp))
}
export function EntryNoticeGateModal() {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
const authIsHydrated = useAuthStore((state) => state.isHydrated)
const accessToken = useAuthStore((state) => state.accessToken)
const currentUserId = useAuthStore((state) => state.currentUser?.id)
const [hasEntered, setHasEntered] = useState(false)
const [hasAgreed, setHasAgreed] = useState(false)
const [shouldGateEntry, setShouldGateEntry] = useState(false)
const hasStoredLoginInfo =
authStatus === 'authenticated' && Boolean(accessToken)
const confirmedAtStorageKey = `${ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY}:${
currentUserId ?? 'authenticated'
}`
useEffect(() => {
if (!authIsHydrated) {
return
}
setHasEntered(false)
setHasAgreed(false)
if (!hasStoredLoginInfo) {
setShouldGateEntry(true)
return
}
const lastConfirmedAt = getLastConfirmedAt(confirmedAtStorageKey)
setShouldGateEntry(
!lastConfirmedAt ||
Date.now() - lastConfirmedAt >= ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
)
}, [authIsHydrated, confirmedAtStorageKey, hasStoredLoginInfo])
const noticeListQuery = useQuery({
queryKey: ['game', 'entry-notice-list'],
queryFn: () => getNoticeList({ page: 1, pageSize: 20 }),
enabled: authIsHydrated && shouldGateEntry,
staleTime: 0,
})
const popoutNotices = useMemo(
() =>
(noticeListQuery.data?.list ?? []).filter(
(notice) => notice.notice_type === 'popout',
),
[noticeListQuery.data],
)
const shouldShowModal =
authIsHydrated &&
shouldGateEntry &&
!hasEntered &&
(noticeListQuery.isPending ||
noticeListQuery.isError ||
popoutNotices.length > 0)
const canEnter =
hasAgreed && !noticeListQuery.isPending && popoutNotices.length > 0
if (!shouldShowModal) {
return null
}
return (
<CenterModal
open={shouldShowModal}
isShowClose={false}
isNormalBg={true}
title={
<div className="modal-title-glow text-design-26">
{t('game.modals.entryNotice.title')}
</div>
}
titleAlign="left"
className="h-design-700 w-design-1000 max-h-[92vh] max-w-[92vw]"
>
<div className="flex h-full w-full flex-col gap-design-20 px-design-14 pb-design-30 pt-design-8">
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-[#2B8CA3]/45 bg-[#001B24]/70 p-design-18 shadow-[inset_0_0_calc(var(--design-unit)*18)_rgba(39,175,205,0.1)]">
{noticeListQuery.isPending ? (
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] items-center justify-center text-design-22 text-[#9CE8F2]">
{t('game.modals.entryNotice.loading')}
</div>
) : noticeListQuery.isError ? (
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] flex-col items-center justify-center gap-design-18 text-center text-[#9CE8F2]">
<div className="text-design-22">
{t('game.modals.entryNotice.loadFailed')}
</div>
<button
type="button"
onClick={() => {
void noticeListQuery.refetch()
}}
className="inline-flex items-center gap-design-8 rounded-md border border-[#4AC6DE]/45 bg-[#0B4454] px-design-18 py-design-10 text-design-18 text-[#D7FFFF] transition hover:bg-[#0E576D]"
>
<RotateCw className="h-design-18 w-design-18" />
{t('game.modals.entryNotice.retry')}
</button>
</div>
) : (
<div className="flex flex-col gap-design-16">
{popoutNotices.map((notice, index) => (
<article
key={notice.notice_id}
className="rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-20"
>
<div className="mb-design-12 flex flex-wrap items-center justify-between gap-design-12">
<div className="min-w-0 flex-1 text-design-24 font-semibold leading-tight text-white">
{index + 1}. {notice.title}
</div>
<div className="rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-12 py-design-5 text-design-15 text-[#9CE8F2]">
{dayjs(notice.publish_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)}
</div>
</div>
<div className="whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]">
{notice.content ?? ''}
</div>
</article>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-center gap-design-28">
<label className="inline-flex cursor-pointer items-center gap-design-12 text-design-20 text-[#C4F2F7]">
<input
type="checkbox"
checked={hasAgreed}
disabled={noticeListQuery.isPending || noticeListQuery.isError}
onChange={(event) => setHasAgreed(event.target.checked)}
className="peer sr-only"
/>
<span
aria-hidden="true"
className={cn(
'flex h-design-32 w-design-32 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] border transition',
hasAgreed
? 'border-[#4AFF49]/80 bg-[#071F11]'
: 'border-[#6CCDCF]/70 bg-[#031D25]',
)}
>
{hasAgreed ? (
<SmartImage
src={checkIcon}
alt=""
priority={true}
showSkeleton={false}
className="h-design-34 w-design-38 overflow-visible"
imgClassName="object-contain"
/>
) : null}
</span>
<span>{t('game.modals.entryNotice.agreement')}</span>
</label>
<SmartBackground
as="button"
type="button"
src={lengthGreenBtn}
size="106% 108%"
repeat="no-repeat"
position="center"
disabled={!canEnter}
onClick={() => {
if (canEnter) {
if (hasStoredLoginInfo) {
setLastConfirmedAt(confirmedAtStorageKey, Date.now())
}
setHasEntered(true)
}
}}
className={cn(
'flex h-design-72 w-design-270 items-center justify-center rounded-md pb-design-5 text-design-22 font-bold transition',
canEnter
? 'modal-title-glow cursor-pointer text-white hover:brightness-110 active:brightness-95'
: 'cursor-not-allowed text-white opacity-80 grayscale',
)}
style={
canEnter
? undefined
: {
filter: 'grayscale(100%)',
WebkitFilter: 'grayscale(100%)',
}
}
>
{t('game.modals.entryNotice.enterGame')}
</SmartBackground>
</div>
</div>
</CenterModal>
)
}

View File

@@ -1,114 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
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={cn(
'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={cn(
'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>
)
}