feat: 优化整体项目ui
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { AUTH_ENDPOINTS, AUTH_SKIP_REFRESH_CONTEXT_KEY } from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { AuthSessionInput } from '@/store/auth'
|
||||
@@ -20,13 +21,6 @@ import {
|
||||
normalizeRefreshAuthSession,
|
||||
} from './types'
|
||||
|
||||
const AUTH_ENDPOINTS = {
|
||||
login: 'api/user/login',
|
||||
profile: 'api/user/profile',
|
||||
refreshToken: 'api/user/refreshToken',
|
||||
register: 'api/user/register',
|
||||
} as const
|
||||
|
||||
const shouldLogAuthLifecycle =
|
||||
import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
||||
|
||||
@@ -158,7 +152,7 @@ export async function refreshAuthSession(
|
||||
AUTH_ENDPOINTS.refreshToken,
|
||||
{
|
||||
context: {
|
||||
skipAuthRefresh: true,
|
||||
[AUTH_SKIP_REFRESH_CONTEXT_KEY]: true,
|
||||
},
|
||||
json: {
|
||||
refresh_token: refreshToken,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { AUTH_ERROR_KEY_PREFIX } from '@/constants'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
|
||||
type AuthSubmitContext = 'login' | 'register'
|
||||
|
||||
const AUTH_ERROR_KEY_PREFIX = 'auth.'
|
||||
|
||||
function isTranslationKey(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.startsWith(AUTH_ERROR_KEY_PREFIX)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FINANCE_API_ENDPOINTS } from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { ApiResponse } from '@/type'
|
||||
@@ -10,6 +11,9 @@ import type {
|
||||
DepositWithdrawConfig,
|
||||
DepositWithdrawConfigDto,
|
||||
FinanceCurrencyConfigDto,
|
||||
FinanceOrderItemDto,
|
||||
FinanceOrderList,
|
||||
FinanceOrderListDto,
|
||||
FinancePayChannelDto,
|
||||
FinanceRateConfigDto,
|
||||
FinanceWithdrawBankDto,
|
||||
@@ -18,14 +22,6 @@ import type {
|
||||
WithdrawCreateResponseDto,
|
||||
} from './finance-types'
|
||||
|
||||
export const FINANCE_API_ENDPOINTS = {
|
||||
depositCreate: 'api/finance/depositCreate',
|
||||
depositTierList: 'api/finance/depositTierList',
|
||||
depositWithdrawConfig: 'api/finance/depositWithdrawConfig',
|
||||
legacyCashierConfig: 'api/finance/cashierConfig',
|
||||
withdrawCreate: 'api/finance/withdrawCreate',
|
||||
} as const
|
||||
|
||||
function unwrapFinanceEnvelope<T>(
|
||||
response: ApiResponse<T>,
|
||||
fallbackMessage = 'Finance request failed',
|
||||
@@ -181,6 +177,25 @@ function normalizeDepositTierItem(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFinanceOrderItem(dto: FinanceOrderItemDto) {
|
||||
return {
|
||||
amount: String(dto.amount ?? ''),
|
||||
bonusAmount: String(dto.bonus_amount ?? ''),
|
||||
orderNo: dto.order_no,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFinanceOrderList(dto: FinanceOrderListDto): FinanceOrderList {
|
||||
return {
|
||||
list: (dto.list ?? []).map(normalizeFinanceOrderItem),
|
||||
pagination: {
|
||||
page: dto.pagination?.page ?? 1,
|
||||
page_size: dto.pagination?.page_size ?? 20,
|
||||
total: dto.pagination?.total ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDepositWithdrawConfig() {
|
||||
const response = await api.post<DepositWithdrawConfigDto>(
|
||||
FINANCE_API_ENDPOINTS.depositWithdrawConfig,
|
||||
@@ -230,6 +245,48 @@ export async function createDeposit(payload: DepositCreateRequestDto) {
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function getDepositOrderList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.get<FinanceOrderListDto>(
|
||||
FINANCE_API_ENDPOINTS.depositList,
|
||||
{
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? 20),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<FinanceOrderListDto>,
|
||||
'Failed to load deposit order list',
|
||||
)
|
||||
|
||||
return normalizeFinanceOrderList(dto)
|
||||
}
|
||||
|
||||
export async function getWithdrawOrderList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.get<FinanceOrderListDto>(
|
||||
FINANCE_API_ENDPOINTS.withdrawList,
|
||||
{
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? 20),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<FinanceOrderListDto>,
|
||||
'Failed to load withdraw order list',
|
||||
)
|
||||
|
||||
return normalizeFinanceOrderList(dto)
|
||||
}
|
||||
|
||||
export async function createWithdraw(payload: WithdrawCreateRequestDto) {
|
||||
const response = await api.post<
|
||||
WithdrawCreateResponseDto,
|
||||
|
||||
@@ -162,6 +162,34 @@ export interface DepositCreateResponseDto {
|
||||
total_amount: number
|
||||
}
|
||||
|
||||
export interface FinanceOrderItemDto {
|
||||
amount: number | string
|
||||
bonus_amount: number | string
|
||||
order_no: string
|
||||
}
|
||||
|
||||
export interface FinanceOrderPaginationDto {
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface FinanceOrderListDto {
|
||||
list: FinanceOrderItemDto[]
|
||||
pagination: FinanceOrderPaginationDto
|
||||
}
|
||||
|
||||
export interface FinanceOrderItem {
|
||||
amount: string
|
||||
bonusAmount: string
|
||||
orderNo: string
|
||||
}
|
||||
|
||||
export interface FinanceOrderList {
|
||||
list: FinanceOrderItem[]
|
||||
pagination: FinanceOrderPaginationDto
|
||||
}
|
||||
|
||||
export interface WithdrawCreateRequestDto {
|
||||
bank_code: string
|
||||
channel_code: string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GAME_API_ENDPOINTS } from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { ApiResponse } from '@/type'
|
||||
@@ -79,19 +80,6 @@ function assertLobbyInitDto(
|
||||
}
|
||||
}
|
||||
|
||||
export const GAME_API_ENDPOINTS = {
|
||||
announcements: 'game/announcements',
|
||||
betMyOrders: 'api/game/betMyOrders',
|
||||
betPlaceLegacy: 'api/game/betPlace',
|
||||
bootstrap: 'game/bootstrap',
|
||||
lobbyInit: 'api/game/lobbyInit',
|
||||
noticeConfirm: 'api/notice/noticeConfirm',
|
||||
noticeDetail: 'api/notice/noticeDetail',
|
||||
noticeList: 'api/notice/noticeList',
|
||||
placeBet: 'api/game/placeBet',
|
||||
roundFeed: 'game/round-feed',
|
||||
} as const
|
||||
|
||||
export interface GameLobbyInitResult {
|
||||
runtimeEnabled: boolean
|
||||
serverTime: number
|
||||
|
||||
@@ -125,9 +125,11 @@ export interface GameAnnouncementsDto {
|
||||
}
|
||||
|
||||
export interface NoticeListItemDto {
|
||||
content?: string
|
||||
is_read: boolean
|
||||
must_confirm?: boolean
|
||||
notice_id: number
|
||||
notice_type: 'silent' | 'popout'
|
||||
notice_type: 'silent' | 'popout' | (string & {})
|
||||
publish_time: number
|
||||
title: string
|
||||
}
|
||||
@@ -140,7 +142,7 @@ export interface NoticeDetailDto {
|
||||
content: string
|
||||
must_confirm: boolean
|
||||
notice_id: number
|
||||
notice_type: 'silent' | 'popout'
|
||||
notice_type: 'silent' | 'popout' | (string & {})
|
||||
publish_time: number
|
||||
title: string
|
||||
}
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import hallMusic from '@/assets/music/hall-music.mp3'
|
||||
|
||||
export type AudioAssetId = 'hall-bgm'
|
||||
|
||||
export type AudioAssetDefinition = {
|
||||
id: AudioAssetId
|
||||
loop?: boolean
|
||||
src: string
|
||||
volume?: number
|
||||
}
|
||||
|
||||
export const AUDIO_ASSET_DEFINITIONS: AudioAssetDefinition[] = [
|
||||
{
|
||||
id: 'hall-bgm',
|
||||
src: hallMusic,
|
||||
loop: true,
|
||||
volume: 1,
|
||||
},
|
||||
]
|
||||
export {
|
||||
AUDIO_ASSET_DEFINITIONS,
|
||||
type AudioAssetDefinition,
|
||||
type AudioAssetId,
|
||||
} from '@/constants/game'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
235
src/features/game/components/shared/entry-notice-gate-modal.tsx
Normal file
235
src/features/game/components/shared/entry-notice-gate-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { startTransition, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getGameLobbyInit, getVisibleAnnouncements } from '@/features/game'
|
||||
import { GameAnnouncementModal } from '@/features/game/components'
|
||||
import { getGameLobbyInit } from '@/features/game'
|
||||
import { EntryNoticeGateModal } from '@/features/game/components'
|
||||
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
|
||||
import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
|
||||
import { useGameRealtimeSync } from '@/features/game/hooks/use-game-realtime-sync.ts'
|
||||
@@ -11,21 +11,12 @@ import { notify } from '@/lib/notify'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
const ENABLE_ANNOUNCEMENT_MODAL = false
|
||||
|
||||
export function EntryPage() {
|
||||
const { t } = useTranslation()
|
||||
useGameRealtimeSync()
|
||||
const announcements = useGameSessionStore((state) => state.announcements)
|
||||
const dismissAnnouncement = useGameSessionStore(
|
||||
(state) => state.dismissAnnouncement,
|
||||
)
|
||||
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
|
||||
const selectChip = useGameRoundStore((state) => state.selectChip)
|
||||
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
|
||||
const markAnnouncementRead = useGameSessionStore(
|
||||
(state) => state.markAnnouncementRead,
|
||||
)
|
||||
const syncConnection = useGameSessionStore((state) => state.syncConnection)
|
||||
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
@@ -39,16 +30,6 @@ export function EntryPage() {
|
||||
return window.matchMedia('(max-width: 768px)').matches
|
||||
})
|
||||
|
||||
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'),
|
||||
@@ -167,41 +148,7 @@ export function EntryPage() {
|
||||
>
|
||||
{isMobile ? <MobileEntry /> : <PcEntry />}
|
||||
|
||||
<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>
|
||||
<EntryNoticeGateModal />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { DesktopHeader } from '@/features/game/components'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
|
||||
import {
|
||||
DesktopHeader,
|
||||
FullscreenLottieOverlay,
|
||||
type FullscreenLottieSource,
|
||||
} from '@/features/game/components'
|
||||
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
|
||||
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
|
||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
||||
@@ -12,6 +18,77 @@ import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register
|
||||
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
|
||||
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
|
||||
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
||||
import { useGameRoundStore } from '@/store/game'
|
||||
|
||||
const smallRewardPath = new URL(
|
||||
'../../../assets/lottie/pc-small-reward.json',
|
||||
import.meta.url,
|
||||
).href
|
||||
const bigRewardPath = new URL(
|
||||
'../../../assets/lottie/pc-big-reward.json',
|
||||
import.meta.url,
|
||||
).href
|
||||
|
||||
function DesktopRewardOverlay() {
|
||||
const rewardType = useGameRoundStore(
|
||||
(state) => state.revealAnimation.rewardType,
|
||||
)
|
||||
const revealKey = useGameRoundStore(
|
||||
(state) => state.revealAnimation.revealKey,
|
||||
)
|
||||
const roundId = useGameRoundStore((state) => state.revealAnimation.roundId)
|
||||
const clearRewardAnimation = useGameRoundStore(
|
||||
(state) => state.clearRewardAnimation,
|
||||
)
|
||||
const source = useMemo<FullscreenLottieSource | null>(() => {
|
||||
if (rewardType === 'small') {
|
||||
return {
|
||||
id: 'pc-small-reward',
|
||||
path: smallRewardPath,
|
||||
loop: false,
|
||||
autoplay: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (rewardType === 'big') {
|
||||
return {
|
||||
id: 'pc-big-reward',
|
||||
path: bigRewardPath,
|
||||
loop: false,
|
||||
autoplay: true,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [rewardType])
|
||||
|
||||
useEffect(() => {
|
||||
if (rewardType === 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
clearRewardAnimation()
|
||||
}, REWARD_OVERLAY_DURATION_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
}, [clearRewardAnimation, rewardType])
|
||||
|
||||
return (
|
||||
<FullscreenLottieOverlay
|
||||
open={rewardType !== 'none'}
|
||||
source={source}
|
||||
animationKey={`${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`}
|
||||
zIndex={120}
|
||||
loop={false}
|
||||
autoplay
|
||||
backdropClassName="bg-black/70"
|
||||
viewportClassName="px-0 py-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PcEntry() {
|
||||
return (
|
||||
@@ -19,13 +96,15 @@ export function PcEntry() {
|
||||
<DesktopHeader />
|
||||
<div
|
||||
className={
|
||||
'mx-auto mt-design-30 mb-design-60 w-[calc(100%-40*var(--design-unit))]'
|
||||
'mx-auto mt-design-20 mb-design-75 w-[calc(100%-40*var(--design-unit))]'
|
||||
}
|
||||
>
|
||||
<DesktopStatusLine />
|
||||
</div>
|
||||
|
||||
<div className={'mx-auto w-[calc(100%-72*var(--design-unit))]'}>
|
||||
<div
|
||||
className={'mx-auto w-[calc(100%-72*var(--design-unit))] mb-design-5'}
|
||||
>
|
||||
<div className={'flex w-full items-start gap-design-10'}>
|
||||
<div className={'flex-1'}>
|
||||
<DesktopAnimal />
|
||||
@@ -63,6 +142,7 @@ export function PcEntry() {
|
||||
<DesktopProceduresModal />
|
||||
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
|
||||
<DesktopWithdrawTopupModal />
|
||||
<DesktopRewardOverlay />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export function useAnimalVm(
|
||||
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||
const chips = useGameRoundStore((state) => state.chips)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const roundId = useGameRoundStore((state) => state.round.id)
|
||||
const maxSelectionCount = useGameRoundStore(
|
||||
(state) => state.maxSelectionCount,
|
||||
)
|
||||
@@ -106,7 +107,9 @@ export function useAnimalVm(
|
||||
shouldConnectRealtime &&
|
||||
(connection.status === 'connecting' || connection.status === 'reconnecting')
|
||||
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
|
||||
const lockInteraction = showStandbyState
|
||||
const hasSubmittedCurrentRound =
|
||||
Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId
|
||||
const lockInteraction = showStandbyState || hasSubmittedCurrentRound
|
||||
const selectedCellCount = Object.keys(selectionByCell).length
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useLocation } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LANGUAGE_OPTIONS } from '@/constants'
|
||||
import { type AppLanguage, supportedLanguages } from '@/i18n'
|
||||
import { LANGUAGE_OPTIONS, SUPPORTED_LANGUAGES } from '@/constants'
|
||||
import type { AppLanguage } from '@/i18n'
|
||||
|
||||
const languagePrefixPattern = new RegExp(
|
||||
`^/(${supportedLanguages.join('|')})(?=/|$)`,
|
||||
`^/(${SUPPORTED_LANGUAGES.join('|')})(?=/|$)`,
|
||||
)
|
||||
|
||||
function resolveNextPathname(pathname: string, language: AppLanguage) {
|
||||
|
||||
128
src/features/game/hooks/use-finance-records-vm.ts
Normal file
128
src/features/game/hooks/use-finance-records-vm.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getDepositOrderList, getWithdrawOrderList } from '@/features/game/api'
|
||||
|
||||
export type FinanceRecordType = 'deposit' | 'withdraw'
|
||||
|
||||
const FINANCE_RECORD_PAGE_SIZE = 20
|
||||
|
||||
const FINANCE_RECORD_TYPE_OPTIONS: Array<{
|
||||
key: FinanceRecordType
|
||||
labelKey: string
|
||||
}> = [
|
||||
{
|
||||
key: 'deposit',
|
||||
labelKey: 'game.modals.userInfo.financeRecords.deposit',
|
||||
},
|
||||
{
|
||||
key: 'withdraw',
|
||||
labelKey: 'game.modals.userInfo.financeRecords.withdraw',
|
||||
},
|
||||
]
|
||||
|
||||
function formatFinanceAmount(value: string, locale: string) {
|
||||
const numberValue = Number(value)
|
||||
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
return value || '--'
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(numberValue)
|
||||
}
|
||||
|
||||
export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
|
||||
const { i18n, t } = useTranslation()
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
|
||||
const [recordType, setRecordType] = useState<FinanceRecordType>('deposit')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['finance', 'user-info-order-list', recordType, page],
|
||||
queryFn: () =>
|
||||
recordType === 'deposit'
|
||||
? getDepositOrderList({
|
||||
page,
|
||||
pageSize: FINANCE_RECORD_PAGE_SIZE,
|
||||
})
|
||||
: getWithdrawOrderList({
|
||||
page,
|
||||
pageSize: FINANCE_RECORD_PAGE_SIZE,
|
||||
}),
|
||||
enabled,
|
||||
})
|
||||
|
||||
const pagination = query.data?.pagination
|
||||
const total = pagination?.total ?? 0
|
||||
|
||||
const recordTypes = useMemo(
|
||||
() =>
|
||||
FINANCE_RECORD_TYPE_OPTIONS.map((option) => ({
|
||||
key: option.key,
|
||||
label: t(option.labelKey),
|
||||
})),
|
||||
[t],
|
||||
)
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
(query.data?.list ?? []).map((item) => ({
|
||||
amountLabel: formatFinanceAmount(item.amount, locale),
|
||||
bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale),
|
||||
id: item.orderNo,
|
||||
orderNoLabel: item.orderNo || '--',
|
||||
})),
|
||||
[locale, query.data?.list],
|
||||
)
|
||||
|
||||
const selectRecordType = useCallback((type: FinanceRecordType) => {
|
||||
setRecordType(type)
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
const goPreviousPage = useCallback(() => {
|
||||
setPage((currentPage) => Math.max(1, currentPage - 1))
|
||||
}, [])
|
||||
|
||||
const goNextPage = useCallback(() => {
|
||||
setPage((currentPage) => currentPage + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setRecordType('deposit')
|
||||
setPage(1)
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return {
|
||||
canGoNextPage: page * FINANCE_RECORD_PAGE_SIZE < total,
|
||||
canGoPreviousPage: page > 1,
|
||||
emptyText: t('game.modals.userInfo.financeRecords.empty'),
|
||||
goNextPage,
|
||||
goPreviousPage,
|
||||
headers: {
|
||||
amount: t('game.modals.userInfo.financeRecords.amount'),
|
||||
bonusAmount: t('game.modals.userInfo.financeRecords.bonusAmount'),
|
||||
orderNo: t('game.modals.userInfo.financeRecords.orderNo'),
|
||||
},
|
||||
isError: query.isError,
|
||||
isFetching: query.isFetching,
|
||||
isLoading: query.isLoading,
|
||||
items,
|
||||
loadFailedText: t('game.modals.userInfo.financeRecords.loadFailed'),
|
||||
loadingText: t('game.modals.userInfo.financeRecords.loading'),
|
||||
pageLabel: t('game.modals.userInfo.financeRecords.page', {
|
||||
page: pagination?.page ?? page,
|
||||
total,
|
||||
}),
|
||||
nextText: t('game.modals.userInfo.financeRecords.next'),
|
||||
previousText: t('game.modals.userInfo.financeRecords.previous'),
|
||||
recordType,
|
||||
recordTypes,
|
||||
selectRecordType,
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,8 @@ export function useGameControlVm() {
|
||||
const hasSelections = selections.length > 0
|
||||
const hasEnteredGame =
|
||||
shouldConnectRealtime && connectionStatus === 'connected'
|
||||
const hasSubmittedCurrentRound =
|
||||
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
|
||||
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
|
||||
const confirmState: ConfirmState = isSubmitting
|
||||
? 'submitting'
|
||||
@@ -141,6 +143,11 @@ export function useGameControlVm() {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasSubmittedCurrentRound) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
const groupedSelections = selections.reduce<
|
||||
Map<string, { betId: number; numbers: number[] }>
|
||||
>((accumulator, selection) => {
|
||||
@@ -219,6 +226,7 @@ export function useGameControlVm() {
|
||||
currentUser,
|
||||
hasInsufficientBalance,
|
||||
hasSelections,
|
||||
hasSubmittedCurrentRound,
|
||||
round.id,
|
||||
round.phase,
|
||||
selections,
|
||||
@@ -229,7 +237,7 @@ export function useGameControlVm() {
|
||||
])
|
||||
|
||||
const handleRepeatSelections = useCallback(() => {
|
||||
if (round.phase !== 'betting') {
|
||||
if (round.phase !== 'betting' || hasSubmittedCurrentRound) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
@@ -242,16 +250,21 @@ export function useGameControlVm() {
|
||||
}
|
||||
|
||||
notify.success(t('commonUi.toast.repeatSelectionsRestored'))
|
||||
}, [restoreRecentSuccessfulSelections, round.phase, t])
|
||||
}, [
|
||||
hasSubmittedCurrentRound,
|
||||
restoreRecentSuccessfulSelections,
|
||||
round.phase,
|
||||
t,
|
||||
])
|
||||
|
||||
const handleOpenAutoSetting = useCallback(() => {
|
||||
setModalOpen('desktopAutoSetting', true)
|
||||
}, [setModalOpen])
|
||||
|
||||
return {
|
||||
acceptingBets: round.phase === 'betting',
|
||||
actionsEnabled: hasEnteredGame,
|
||||
canClear: selections.length > 0,
|
||||
acceptingBets: round.phase === 'betting' && !hasSubmittedCurrentRound,
|
||||
actionsEnabled: hasEnteredGame && !hasSubmittedCurrentRound,
|
||||
canClear: selections.length > 0 && !hasSubmittedCurrentRound,
|
||||
confirmLabel:
|
||||
confirmState === 'idle'
|
||||
? t('gameDesktop.control.selectNumbers')
|
||||
|
||||
@@ -2,12 +2,11 @@ import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { GAME_HISTORY_PAGE_SIZE } from '@/constants'
|
||||
import { getGameBetMyOrders } from '@/features/game/api/game-api'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore } from '@/store/game'
|
||||
|
||||
const GAME_HISTORY_PAGE_SIZE = 20
|
||||
|
||||
function formatCreatedTime(timestamp: number, locale: string) {
|
||||
const date = new Date(timestamp * 1000)
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
FALLBACK_POLL_INTERVAL_MS,
|
||||
GAME_SOCKET_TOPIC_VALUES,
|
||||
GAME_SOCKET_TOPICS,
|
||||
PLAYER_SOCKET_TOPICS,
|
||||
SOCKET_DISCONNECT_DELAY_MS,
|
||||
} from '@/constants'
|
||||
import i18n from '@/i18n'
|
||||
import { prefetchAuthToken } from '@/lib/api/api-client'
|
||||
import {
|
||||
@@ -16,51 +23,11 @@ type UserStreakMessageData = {
|
||||
streakLevel?: number
|
||||
}
|
||||
|
||||
const FALLBACK_POLL_INTERVAL_MS = 10_000
|
||||
const GAME_SOCKET_TOPICS = {
|
||||
// 对局状态心跳。每秒推送当前期号、状态、倒计时、runtime_enabled 等。
|
||||
periodTick: 'period.tick',
|
||||
// 本期封盘通知。用于前端立即停止下注。
|
||||
periodLocked: 'period.locked',
|
||||
// 本期开奖通知。用于同步开奖号码、所属期号等阶段结果。
|
||||
periodOpened: 'period.opened',
|
||||
// 本期派彩完成通知。用于结算阶段同步。
|
||||
periodPayout: 'period.payout',
|
||||
// 当前玩家连胜与赔率信息。通常在结算后或演示帧刷新。
|
||||
userStreak: 'user.streak',
|
||||
// 下注成功通知。仅当前用户可见,通常伴随扣款结果。
|
||||
betAccepted: 'bet.accepted',
|
||||
// 余额变化通知。充值、下注、派彩都会走这条流。
|
||||
walletChanged: 'wallet.changed',
|
||||
// 自动托管进度通知。包含托管开关、执行状态等。
|
||||
autoSpinProgress: 'auto.spin.progress',
|
||||
// 大奖命中通知。仅当本期存在中大奖用户时推送。
|
||||
jackpotHit: 'jackpot.hit',
|
||||
// 后台实时页全量快照。仅 admin live 页面使用,当前 H5 前台不订阅。
|
||||
adminLiveSnapshot: 'admin.live.snapshot',
|
||||
// 后台开奖结果通知。仅 admin live 页面使用,当前 H5 前台不订阅。
|
||||
adminLiveOpened: 'admin.live.opened',
|
||||
} as const
|
||||
|
||||
const GAME_SOCKET_TOPIC_VALUES = new Set<string>(
|
||||
Object.values(GAME_SOCKET_TOPICS),
|
||||
)
|
||||
|
||||
// 当前 H5 游戏页实际需要的用户侧事件。
|
||||
// 后台专用事件保持在 GAME_SOCKET_TOPICS 中做口径对齐,但不在这里订阅。
|
||||
const PLAYER_SOCKET_TOPICS = [
|
||||
GAME_SOCKET_TOPICS.periodTick,
|
||||
GAME_SOCKET_TOPICS.userStreak,
|
||||
GAME_SOCKET_TOPICS.periodOpened,
|
||||
GAME_SOCKET_TOPICS.periodLocked,
|
||||
GAME_SOCKET_TOPICS.periodPayout,
|
||||
GAME_SOCKET_TOPICS.betAccepted,
|
||||
GAME_SOCKET_TOPICS.walletChanged,
|
||||
GAME_SOCKET_TOPICS.autoSpinProgress,
|
||||
GAME_SOCKET_TOPICS.jackpotHit,
|
||||
] as const
|
||||
|
||||
const SOCKET_DISCONNECT_DELAY_MS = 150
|
||||
type PeriodEventData = {
|
||||
openTime: number | null
|
||||
periodNo: string
|
||||
resultNumber: number | null
|
||||
}
|
||||
|
||||
let sharedSocketClient: GameSocketClient | null = null
|
||||
let sharedSocketKey: string | null = null
|
||||
@@ -186,6 +153,37 @@ function extractPeriodTick(
|
||||
}
|
||||
}
|
||||
|
||||
function extractPeriodEventData(
|
||||
message: GameSocketMessage,
|
||||
): PeriodEventData | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const source = data ?? (message as Record<string, unknown>)
|
||||
const periodNo =
|
||||
typeof source.period_no === 'string'
|
||||
? source.period_no
|
||||
: typeof source.periodNo === 'string'
|
||||
? source.periodNo
|
||||
: null
|
||||
|
||||
if (!periodNo) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resultNumber = toOptionalNumber(
|
||||
source.result_number ?? source.resultNumber,
|
||||
)
|
||||
const openTime = toOptionalNumber(source.open_time ?? source.openTime)
|
||||
|
||||
return {
|
||||
openTime: openTime ?? null,
|
||||
periodNo,
|
||||
resultNumber:
|
||||
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
||||
? resultNumber
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function extractWalletCoin(message: GameSocketMessage) {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const source = data ?? (message as Record<string, unknown>)
|
||||
@@ -288,6 +286,90 @@ function applyPeriodPhase(phase: 'locked' | 'revealing' | 'settled') {
|
||||
useGameRoundStore.getState().setPhase(phase)
|
||||
}
|
||||
|
||||
function applyPeriodLockedMessage(
|
||||
message: GameSocketMessage,
|
||||
serverTime: number | null,
|
||||
) {
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
const period = extractPeriodEventData(message)
|
||||
const roundState = useGameRoundStore.getState()
|
||||
const roundId = period?.periodNo ?? roundState.round.id
|
||||
|
||||
if (roundId) {
|
||||
roundState.syncRound({
|
||||
id: roundId,
|
||||
phase: 'locked',
|
||||
})
|
||||
} else {
|
||||
roundState.setPhase('locked')
|
||||
}
|
||||
}
|
||||
|
||||
function applyPeriodOpenedMessage(
|
||||
message: GameSocketMessage,
|
||||
serverTime: number | null,
|
||||
) {
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
const period = extractPeriodEventData(message)
|
||||
|
||||
if (!period || period.resultNumber === null) {
|
||||
applyPeriodPhase('revealing')
|
||||
return
|
||||
}
|
||||
|
||||
const roundState = useGameRoundStore.getState()
|
||||
const openedAt = toIsoFromUnixSeconds(
|
||||
period.openTime ?? serverTime ?? Math.floor(Date.now() / 1000),
|
||||
)
|
||||
const hasSmallReward = roundState.selections.some(
|
||||
(selection) => selection.cellId === period.resultNumber,
|
||||
)
|
||||
const revealKey = `${period.periodNo}:${period.resultNumber}`
|
||||
|
||||
roundState.syncRound({
|
||||
id: period.periodNo,
|
||||
phase: 'revealing',
|
||||
revealingAt: openedAt,
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
useGameRoundStore.getState().prepareRevealAnimation({
|
||||
hasSmallReward,
|
||||
revealKey,
|
||||
roundId: period.periodNo,
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
}
|
||||
|
||||
function applyPeriodPayoutMessage(
|
||||
message: GameSocketMessage,
|
||||
serverTime: number | null,
|
||||
) {
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
const period = extractPeriodEventData(message)
|
||||
|
||||
if (period?.resultNumber !== null && period?.resultNumber !== undefined) {
|
||||
const roundState = useGameRoundStore.getState()
|
||||
const revealKey = `${period.periodNo}:${period.resultNumber}`
|
||||
|
||||
roundState.prepareRevealAnimation({
|
||||
hasSmallReward: roundState.selections.some(
|
||||
(selection) => selection.cellId === period.resultNumber,
|
||||
),
|
||||
revealKey,
|
||||
roundId: period.periodNo,
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
}
|
||||
|
||||
const roundId = period?.periodNo ?? useGameRoundStore.getState().round.id
|
||||
|
||||
applyPeriodPhase('settled')
|
||||
useGameRoundStore.getState().playPreparedRevealAnimation(roundId || null)
|
||||
}
|
||||
|
||||
function applyUserStreakMessage(message: GameSocketMessage) {
|
||||
const streakData = extractUserStreakMessageData(message)
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
@@ -320,6 +402,8 @@ function applyWalletChangedMessage(message: GameSocketMessage) {
|
||||
|
||||
function applyJackpotHitMessage(message: GameSocketMessage) {
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
const period = extractPeriodEventData(message)
|
||||
const isJackpot = extractJackpotStatus(message)
|
||||
|
||||
if (!currentUser) {
|
||||
return
|
||||
@@ -327,8 +411,12 @@ function applyJackpotHitMessage(message: GameSocketMessage) {
|
||||
|
||||
useAuthStore.getState().setCurrentUser({
|
||||
...currentUser,
|
||||
isJackpot: extractJackpotStatus(message),
|
||||
isJackpot,
|
||||
})
|
||||
|
||||
if (isJackpot) {
|
||||
useGameRoundStore.getState().showJackpotReward(period?.periodNo ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
function applyRealtimeMessage(message: GameSocketMessage) {
|
||||
@@ -336,20 +424,41 @@ function applyRealtimeMessage(message: GameSocketMessage) {
|
||||
const topic = getMessageTopic(message)
|
||||
|
||||
switch (topic) {
|
||||
case GAME_SOCKET_TOPICS.periodTick:
|
||||
case GAME_SOCKET_TOPICS.periodTick: {
|
||||
const period = extractPeriodTick(message)
|
||||
const resultNumber =
|
||||
typeof period?.result_number === 'number' ? period.result_number : null
|
||||
const shouldStartSettledReveal =
|
||||
period?.status === 'settled' && resultNumber !== null
|
||||
const hasSmallReward = shouldStartSettledReveal
|
||||
? useGameRoundStore
|
||||
.getState()
|
||||
.selections.some((selection) => selection.cellId === resultNumber)
|
||||
: false
|
||||
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
if (shouldStartSettledReveal) {
|
||||
useGameRoundStore.getState().prepareRevealAnimation({
|
||||
hasSmallReward,
|
||||
revealKey: `${period.period_no}:${resultNumber}`,
|
||||
roundId: period.period_no,
|
||||
winningCellId: resultNumber,
|
||||
})
|
||||
useGameRoundStore
|
||||
.getState()
|
||||
.playPreparedRevealAnimation(period.period_no)
|
||||
}
|
||||
break
|
||||
}
|
||||
case GAME_SOCKET_TOPICS.periodLocked:
|
||||
applyPeriodMessage(message, serverTime)
|
||||
applyPeriodPhase('locked')
|
||||
applyPeriodLockedMessage(message, serverTime)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.periodOpened:
|
||||
applyPeriodMessage(message, serverTime)
|
||||
applyPeriodPhase('revealing')
|
||||
applyPeriodOpenedMessage(message, serverTime)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.periodPayout:
|
||||
applyPeriodMessage(message, serverTime)
|
||||
applyPeriodPhase('settled')
|
||||
applyPeriodPayoutMessage(message, serverTime)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.userStreak:
|
||||
applyUserStreakMessage(message)
|
||||
|
||||
@@ -1,36 +1,9 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PHASE_META } from '@/constants'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
const PHASE_META = {
|
||||
betting: {
|
||||
descriptionKey: 'gameDesktop.status.phase.betting.description',
|
||||
labelKey: 'gameDesktop.status.phase.betting.label',
|
||||
toneClassName: 'text-[#78FF7F]',
|
||||
},
|
||||
locked: {
|
||||
descriptionKey: 'gameDesktop.status.phase.locked.description',
|
||||
labelKey: 'gameDesktop.status.phase.locked.label',
|
||||
toneClassName: 'text-[#FFE375]',
|
||||
},
|
||||
revealing: {
|
||||
descriptionKey: 'gameDesktop.status.phase.revealing.description',
|
||||
labelKey: 'gameDesktop.status.phase.revealing.label',
|
||||
toneClassName: 'text-[#57E8FF]',
|
||||
},
|
||||
settled: {
|
||||
descriptionKey: 'gameDesktop.status.phase.settled.description',
|
||||
labelKey: 'gameDesktop.status.phase.settled.label',
|
||||
toneClassName: 'text-[#FF9C6B]',
|
||||
},
|
||||
waiting: {
|
||||
descriptionKey: 'gameDesktop.status.phase.waiting.description',
|
||||
labelKey: 'gameDesktop.status.phase.waiting.label',
|
||||
toneClassName: 'text-[#A7B6C7]',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function useGameStatusVm() {
|
||||
const { t } = useTranslation()
|
||||
const cells = useGameRoundStore((state) => state.cells)
|
||||
@@ -62,6 +35,7 @@ export function useGameStatusVm() {
|
||||
phaseToneClassName: phaseMeta.toneClassName,
|
||||
roundId: round.id || '--',
|
||||
streakLabel: typeof streakValue === 'number' ? `X${streakValue}` : '--',
|
||||
streakValue,
|
||||
}
|
||||
}, [cells, currentUser, dashboard, round, t, trends])
|
||||
}
|
||||
|
||||
@@ -1,43 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DEFAULT_WITHDRAW_CONFIG, QUICK_FIAT_AMOUNTS } from '@/constants'
|
||||
import type { DepositWithdrawConfig } from '@/features/game/api'
|
||||
import { useDepositWithdrawConfig } from '@/features/game/hooks/use-deposit-withdraw-config'
|
||||
import { useAuthStore } from '@/store'
|
||||
|
||||
const QUICK_FIAT_AMOUNTS = [3, 30, 50, 100, 200, 500] as const
|
||||
|
||||
const DEFAULT_WITHDRAW_CONFIG: DepositWithdrawConfig = {
|
||||
currencies: [
|
||||
{
|
||||
code: 'MYR',
|
||||
depositCoinsPerFiat: '100',
|
||||
depositCoinsPerFiatValue: 100,
|
||||
label: 'MYR',
|
||||
withdrawCoinsPerFiat: '100',
|
||||
withdrawCoinsPerFiatValue: 100,
|
||||
},
|
||||
],
|
||||
payChannels: [],
|
||||
platformCoinLabel: '钻石',
|
||||
rates: [
|
||||
{
|
||||
currency: 'MYR',
|
||||
diamondsPerFiatUnit: '100',
|
||||
diamondsPerFiatUnitValue: 100,
|
||||
},
|
||||
],
|
||||
withdraw: {
|
||||
banks: [],
|
||||
feeNote: 'RM10 - RM99.99 之间的交易将收取最低RM 1的提现手续费',
|
||||
minBank: '10',
|
||||
minEwallet: '10',
|
||||
processingNote: '30s即可到账',
|
||||
rateHint: '汇率为参考价格,实际以提现时为准。',
|
||||
rateMode: 'fixed' as const,
|
||||
},
|
||||
}
|
||||
|
||||
function formatNumber(locale: string, value: number) {
|
||||
return new Intl.NumberFormat(locale).format(value)
|
||||
}
|
||||
|
||||
160
src/features/game/modal/desktop/desktop-finance-records-tab.tsx
Normal file
160
src/features/game/modal/desktop/desktop-finance-records-tab.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
|
||||
import { useFinanceRecordsVm } from '@/features/game/hooks/use-finance-records-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
const vm = useFinanceRecordsVm({ enabled })
|
||||
|
||||
return (
|
||||
<div className={'flex h-full w-full flex-col p-design-10'}>
|
||||
<div
|
||||
className={
|
||||
'mb-design-12 flex items-center justify-between gap-design-16 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'relative grid grid-cols-2 overflow-hidden rounded-md border border-[#3EAFC7]/30 bg-[#031B24]/75 p-design-4'
|
||||
}
|
||||
>
|
||||
{vm.recordTypes.map((recordType) => {
|
||||
const isActive = recordType.key === vm.recordType
|
||||
|
||||
return (
|
||||
<button
|
||||
key={recordType.key}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => vm.selectRecordType(recordType.key)}
|
||||
className={cn(
|
||||
'relative h-design-44 min-w-design-130 cursor-pointer rounded-md px-design-16 text-design-18 transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-white'
|
||||
: 'text-[#6CCDCF] hover:bg-[#0A4252] hover:text-white',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="finance-record-type-active"
|
||||
className={
|
||||
'absolute inset-0 rounded-md bg-[linear-gradient(180deg,#3DA5BD,#166477)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(62,175,199,0.26)]'
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 420,
|
||||
damping: 34,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className={'relative z-10'}>{recordType.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className={'min-h-0 flex-1 overflow-auto rounded-md'}>
|
||||
<div
|
||||
className={
|
||||
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] gap-design-10 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-16 py-design-12 text-design-16 text-[#7ECAD1]'
|
||||
}
|
||||
>
|
||||
<div>{vm.headers.orderNo}</div>
|
||||
<div>{vm.headers.amount}</div>
|
||||
<div>{vm.headers.bonusAmount}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'mt-design-10 flex min-h-[calc(var(--design-unit)*320)] flex-col gap-design-10'
|
||||
}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={vm.recordType}
|
||||
className={'flex flex-col gap-design-10'}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
x: vm.recordType === 'deposit' ? -18 : 18,
|
||||
}}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
x: vm.recordType === 'deposit' ? 18 : -18,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
{vm.isLoading ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadingText}
|
||||
</div>
|
||||
) : vm.isError ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadFailedText}
|
||||
</div>
|
||||
) : vm.items.length === 0 ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.emptyText}
|
||||
</div>
|
||||
) : (
|
||||
vm.items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
className={
|
||||
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-18 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
|
||||
}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
delay: Math.min(index, 6) * 0.025,
|
||||
duration: 0.16,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
<div className={'truncate font-medium text-white'}>
|
||||
{item.orderNoLabel}
|
||||
</div>
|
||||
<div className={'text-[#FEEEB0]'}>{item.amountLabel}</div>
|
||||
<div className={'text-[#7CFFCF]'}>
|
||||
{item.bonusAmountLabel}
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={'mt-design-12 flex items-center justify-end gap-design-10'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!vm.canGoPreviousPage || vm.isFetching}
|
||||
onClick={vm.goPreviousPage}
|
||||
className={
|
||||
'h-design-40 cursor-pointer rounded-md border border-[#3EAFC7]/35 bg-[#062E39] px-design-16 text-design-16 text-[#86DAE7] transition hover:bg-[#0A4252] disabled:cursor-not-allowed disabled:opacity-45'
|
||||
}
|
||||
>
|
||||
{vm.previousText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!vm.canGoNextPage || vm.isFetching}
|
||||
onClick={vm.goNextPage}
|
||||
className={
|
||||
'h-design-40 cursor-pointer rounded-md border border-[#3EAFC7]/35 bg-[#062E39] px-design-16 text-design-16 text-[#86DAE7] transition hover:bg-[#0A4252] disabled:cursor-not-allowed disabled:opacity-45'
|
||||
}
|
||||
>
|
||||
{vm.nextText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopFinanceRecordsTab
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { ArrowLeft, CircleUserRound, Mail } from 'lucide-react'
|
||||
import { ArrowLeft, CircleUserRound, Mail, ReceiptText } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
@@ -10,10 +11,11 @@ import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { getNoticeDetail, getNoticeList } from '@/features/game/api'
|
||||
import DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
|
||||
type UserInfoTabKey = 'profile' | 'message'
|
||||
type UserInfoTabKey = 'financeRecords' | 'message' | 'profile'
|
||||
type MessageViewState = 'list' | 'detail'
|
||||
|
||||
const USER_INFO_TABS: Array<{
|
||||
@@ -26,6 +28,11 @@ const USER_INFO_TABS: Array<{
|
||||
labelKey: 'game.modals.userInfo.tabs.profile',
|
||||
icon: CircleUserRound,
|
||||
},
|
||||
{
|
||||
key: 'financeRecords',
|
||||
labelKey: 'game.modals.userInfo.tabs.financeRecords',
|
||||
icon: ReceiptText,
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
labelKey: 'game.modals.userInfo.tabs.message',
|
||||
@@ -118,40 +125,64 @@ function DesktopUserInfoModal() {
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'relative flex h-design-150 w-full flex-col items-center justify-center gap-design-10 overflow-hidden transition',
|
||||
'relative flex h-design-150 w-full cursor-pointer flex-col items-center justify-center gap-design-8 overflow-hidden px-design-10 transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-[#FEEEB0]'
|
||||
: 'text-[#58ADAF] hover:text-[#BFEAEC]',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<span
|
||||
<motion.span
|
||||
layoutId="user-info-tab-active-bg"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-full bg-[linear-gradient(to_left,rgba(254,238,176,0.46)_0%,rgba(254,238,176,0.28)_42%,rgba(254,238,176,0.12)_68%,rgba(254,238,176,0)_100%)]"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 430,
|
||||
damping: 36,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isActive ? (
|
||||
<span
|
||||
<motion.span
|
||||
layoutId="user-info-tab-active-indicator"
|
||||
aria-hidden="true"
|
||||
className="absolute right-0 top-1/2 h-[72%] w-[calc(var(--design-unit)*3)] -translate-y-1/2 rounded-l-full bg-[linear-gradient(180deg,rgba(255,248,214,0.96)_0%,rgba(254,238,176,0.92)_48%,rgba(232,188,112,0.88)_100%)] shadow-[-2px_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.36)]"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 430,
|
||||
damping: 36,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Icon
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative z-10 transition h-design-50 w-design-50',
|
||||
'relative z-10 transition',
|
||||
isActive &&
|
||||
'drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.5)]',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
animate={{
|
||||
scale: isActive ? 1.06 : 1,
|
||||
y: isActive ? -2 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
<Icon className={'h-design-40 w-design-40'} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative z-10 text-design-24',
|
||||
'relative z-10 text-center text-design-20 leading-tight',
|
||||
isActive && 'modal-title-gold-glow',
|
||||
)}
|
||||
animate={{
|
||||
scale: isActive ? 1.04 : 1,
|
||||
y: isActive ? -1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -210,6 +241,10 @@ function DesktopUserInfoModal() {
|
||||
</div>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
) : activeTab === 'financeRecords' ? (
|
||||
<DesktopFinanceRecordsTab
|
||||
enabled={open && activeTab === 'financeRecords'}
|
||||
/>
|
||||
) : (
|
||||
<div className={'flex h-full w-full flex-col'}>
|
||||
{messageView === 'detail' ? (
|
||||
@@ -224,7 +259,7 @@ function DesktopUserInfoModal() {
|
||||
void handleReturnToList()
|
||||
}}
|
||||
className={
|
||||
'flex items-center gap-design-10 text-[#86DAE7] transition hover:text-white'
|
||||
'flex cursor-pointer items-center gap-design-10 text-[#86DAE7] transition hover:text-white'
|
||||
}
|
||||
>
|
||||
<span
|
||||
@@ -278,7 +313,7 @@ function DesktopUserInfoModal() {
|
||||
setMessageView('detail')
|
||||
}}
|
||||
className={
|
||||
'flex items-center gap-design-20 rounded-md bg-[#0A4252] px-design-15 py-design-15 text-left transition hover:bg-[#0E576D]'
|
||||
'flex cursor-pointer items-center gap-design-20 rounded-md bg-[#0A4252] px-design-15 py-design-15 text-left transition hover:bg-[#0E576D]'
|
||||
}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,59 +1,17 @@
|
||||
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_COLORS = [
|
||||
'#1D4ED8',
|
||||
'#0F766E',
|
||||
'#B45309',
|
||||
'#B91C1C',
|
||||
'#7C3AED',
|
||||
'#111827',
|
||||
] as const
|
||||
|
||||
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-5'
|
||||
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
|
||||
export const GAME_RECENT_HISTORY_LIMIT = 12
|
||||
export const GAME_MAX_SELECTION_CELLS = 5
|
||||
export {
|
||||
ANNOUNCEMENT_TONES,
|
||||
BET_SOURCES,
|
||||
CELL_STATUSES,
|
||||
CONNECTION_STATUSES,
|
||||
CONNECTION_TRANSPORTS,
|
||||
DEFAULT_ACTIVE_CHIP_ID,
|
||||
DEFAULT_ANNOUNCEMENT_TTL_MS,
|
||||
DEFAULT_GAME_CHIP_COLORS,
|
||||
GAME_GRID_COLUMNS,
|
||||
GAME_GRID_ROWS,
|
||||
GAME_MAX_SELECTION_CELLS,
|
||||
GAME_RECENT_HISTORY_LIMIT,
|
||||
GAME_TOTAL_CELLS,
|
||||
ROUND_PHASES,
|
||||
TREND_DIRECTIONS,
|
||||
} from '@/constants/game'
|
||||
|
||||
Reference in New Issue
Block a user