feat(auth): 集成认证授权功能并优化API客户端

- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取
- 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理
- 添加了WebSocket连接配置和API基础URL环境变量设置
- 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑
- 集成了MD5加密和认证令牌缓存机制,提升安全性
- 添加了多语言国际化支持,包括英语、中文、马来语和印尼语
- 实现了认证状态管理和本地存储持久化功能
- 添加了表单验证schema和错误处理机制,增强用户体验
This commit is contained in:
JiaJun
2026-05-16 09:03:55 +08:00
parent 6aaf90a6ac
commit 5dd4e31db4
81 changed files with 6086 additions and 627 deletions

View File

@@ -1,4 +1,6 @@
import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type'
import type {
AnnouncementItem,
@@ -9,10 +11,17 @@ import type {
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
RoundPhase,
RoundSnapshot,
TrendEntry,
} from '../shared'
import { createMockGameBootstrapSnapshot } from '../shared'
import {
createMockGameBootstrapSnapshot,
DEFAULT_GAME_CHIP_COLORS,
deriveTrendEntries,
GAME_GRID_COLUMNS,
GAME_MAX_SELECTION_CELLS,
} from '../shared'
import type {
AnnouncementStateDto,
BetSelectionDto,
@@ -20,20 +29,72 @@ import type {
ConnectionStateDto,
DashboardStateDto,
GameAnnouncementsDto,
GameBetOrdersDto,
GameBootstrapDto,
GameCellDto,
GameLobbyInitDto,
GameLobbyPeriodDto,
GamePeriodTickDto,
GameRoundFeedDto,
HistoryEntryDto,
NoticeConfirmDto,
NoticeDetailDto,
NoticeListDto,
RoundSnapshotDto,
TrendEntryDto,
} from './types'
function unwrapGameEnvelope<T>(
response: ApiResponse<T>,
fallbackMessage = 'Game request failed',
) {
if (response.code === 1) {
return response.data
}
throw new ApiError({
data: response,
message:
typeof response.msg === 'string' && response.msg.length > 0
? response.msg
: fallbackMessage,
})
}
function assertLobbyInitDto(
dto: GameLobbyInitDto,
): asserts dto is GameLobbyInitDto {
if (
!Number.isFinite(dto.server_time) ||
!Array.isArray(dto.dictionary) ||
!dto.bet_config ||
!Number.isFinite(dto.bet_config.default_bet_chip_id)
) {
throw new ApiError({
data: dto,
message: 'Invalid game lobby init payload',
})
}
}
export const GAME_API_ENDPOINTS = {
announcements: 'game/announcements',
betMyOrders: 'api/game/betMyOrders',
bootstrap: 'game/bootstrap',
lobbyInit: 'api/game/lobbyInit',
noticeConfirm: 'api/notice/noticeConfirm',
noticeDetail: 'api/notice/noticeDetail',
noticeList: 'api/notice/noticeList',
roundFeed: 'game/round-feed',
} as const
export interface GameLobbyInitResult {
runtimeEnabled: boolean
serverTime: number
snapshot: GameBootstrapSnapshot
userSnapshot: GameLobbyInitDto['user_snapshot']
}
function normalizeGameCell(dto: GameCellDto) {
return dto satisfies GameCell
}
@@ -136,6 +197,193 @@ function normalizeConnectionState(dto: ConnectionStateDto) {
} satisfies ConnectionState
}
function toIsoFromUnixSeconds(seconds: number) {
const timestamp = Number(seconds)
const date = new Date(timestamp * 1000)
if (!Number.isFinite(timestamp) || Number.isNaN(date.valueOf())) {
throw new ApiError({
data: { seconds },
message: 'Invalid unix timestamp',
})
}
return date.toISOString()
}
export function normalizeLobbyRoundPhase(
status: GameLobbyPeriodDto['status'],
runtimeEnabled: boolean,
): RoundPhase {
if (!runtimeEnabled && status === 'betting') {
return 'locked'
}
switch (status) {
case 'betting':
return 'betting'
case 'locked':
return 'locked'
case 'settling':
return 'revealing'
case 'payouting':
case 'finished':
case 'void':
return 'settled'
default:
return 'waiting'
}
}
function normalizeLobbyChips(
chips: Record<string, string>,
defaultBetChipId: number,
) {
return Object.entries(chips)
.sort(([leftId], [rightId]) => Number(leftId) - Number(rightId))
.map(([chipId, chipAmount], index) => {
const amount = Number(chipAmount)
return {
amount: Number.isFinite(amount) ? amount : 0,
color:
DEFAULT_GAME_CHIP_COLORS[index % DEFAULT_GAME_CHIP_COLORS.length] ??
DEFAULT_GAME_CHIP_COLORS[0],
id: `chip-${chipId}`,
isDefault: Number(chipId) === defaultBetChipId,
label: chipAmount,
}
})
}
function normalizeLobbyCells(dictionary: GameLobbyInitDto['dictionary']) {
return [...dictionary]
.sort((left, right) => left.number - right.number)
.map(
(item, index) =>
({
column: (index % GAME_GRID_COLUMNS) + 1,
id: item.number,
label: item.name,
odds: 36,
row: Math.floor(index / GAME_GRID_COLUMNS) + 1,
}) satisfies GameCell,
)
}
export function normalizeLobbyRound(
lobbyInit: Pick<
GameLobbyInitDto,
'period' | 'runtime_enabled' | 'server_time'
>,
) {
if (!lobbyInit.period) {
return {
bettingClosesAt: toIsoFromUnixSeconds(lobbyInit.server_time),
id: '',
phase: 'waiting',
revealingAt: toIsoFromUnixSeconds(lobbyInit.server_time),
settledAt: null,
startedAt: toIsoFromUnixSeconds(lobbyInit.server_time),
winningCellId: null,
} satisfies RoundSnapshot
}
return {
bettingClosesAt: toIsoFromUnixSeconds(lobbyInit.period.lock_at),
id: lobbyInit.period.period_no,
phase: normalizeLobbyRoundPhase(
lobbyInit.period.status,
lobbyInit.runtime_enabled,
),
revealingAt: toIsoFromUnixSeconds(lobbyInit.period.open_at),
settledAt: toIsoFromUnixSeconds(lobbyInit.period.open_at),
startedAt: toIsoFromUnixSeconds(lobbyInit.server_time),
winningCellId: null,
} satisfies RoundSnapshot
}
export function normalizePeriodTickRound(
period: GamePeriodTickDto,
previousRound?: Pick<RoundSnapshot, 'id' | 'startedAt'> | null,
) {
const startedAt =
previousRound?.id === period.period_no
? previousRound.startedAt
: toIsoFromUnixSeconds(period.server_time)
const countdownSeconds = Math.max(0, period.countdown)
const betCloseSeconds = Math.max(0, period.bet_close_in)
const phase = normalizeLobbyRoundPhase(period.status, period.runtime_enabled)
const nextPhaseAt = toIsoFromUnixSeconds(
period.server_time + countdownSeconds,
)
return {
bettingClosesAt: toIsoFromUnixSeconds(period.server_time + betCloseSeconds),
id: period.period_no,
phase,
revealingAt: nextPhaseAt,
settledAt: nextPhaseAt,
startedAt,
winningCellId:
typeof period.result_number === 'number' ? period.result_number : null,
} satisfies RoundSnapshot
}
export function normalizeGameLobbyInit(dto: GameLobbyInitDto) {
const baseIso = toIsoFromUnixSeconds(dto.server_time)
const template = createMockGameBootstrapSnapshot(baseIso)
const cells = normalizeLobbyCells(dto.dictionary)
const chips = normalizeLobbyChips(
dto.bet_config.chips,
dto.bet_config.default_bet_chip_id,
)
const round = normalizeLobbyRound({
period: null,
runtime_enabled: dto.runtime_enabled,
server_time: dto.server_time,
})
const trends = deriveTrendEntries([])
return {
announcements: {
activeAnnouncementId: null,
items: [],
lastUpdatedAt: null,
} satisfies AnnouncementState,
cells,
chips: chips.length > 0 ? chips : template.chips,
connection: {
...template.connection,
connectedAt: null,
lastError: null,
lastMessageAt: null,
latencyMs: null,
reconnectAttempt: 0,
status: 'idle',
transport: 'polling',
},
dashboard: {
countdownMs: 0,
featuredCellId: null,
onlinePlayers: 0,
tableLimitMax: Number(dto.bet_config.max_bet_per_number) || 0,
tableLimitMin: Number(dto.bet_config.min_bet_per_number) || 0,
totalPoolAmount: 0,
updatedAt: baseIso,
} satisfies DashboardState,
history: [],
maxSelectionCount:
Number.isFinite(dto.bet_config.pick_max_number_count) &&
dto.bet_config.pick_max_number_count > 0
? Math.min(36, Math.floor(dto.bet_config.pick_max_number_count))
: GAME_MAX_SELECTION_CELLS,
round,
selections: [],
trends,
} satisfies GameBootstrapSnapshot
}
export function normalizeGameBootstrap(dto: GameBootstrapDto) {
return {
announcements: normalizeAnnouncementState(dto.announcements),
@@ -144,6 +392,7 @@ export function normalizeGameBootstrap(dto: GameBootstrapDto) {
connection: normalizeConnectionState(dto.connection),
dashboard: normalizeDashboardState(dto.dashboard),
history: dto.history.map(normalizeHistoryEntry),
maxSelectionCount: GAME_MAX_SELECTION_CELLS,
round: normalizeRoundSnapshot(dto.round),
selections: dto.selections.map(normalizeBetSelection),
trends: dto.trends.map(normalizeTrendEntry),
@@ -164,22 +413,125 @@ export function normalizeGameRoundFeed(dto: GameRoundFeedDto) {
export async function getGameBootstrap() {
const response = await api.get<GameBootstrapDto>(GAME_API_ENDPOINTS.bootstrap)
const dto = unwrapGameEnvelope(
response as ApiResponse<GameBootstrapDto>,
'Failed to load game bootstrap',
)
return normalizeGameBootstrap(response.data)
return normalizeGameBootstrap(dto)
}
export async function getGameRoundFeed() {
const response = await api.get<GameRoundFeedDto>(GAME_API_ENDPOINTS.roundFeed)
const dto = unwrapGameEnvelope(
response as ApiResponse<GameRoundFeedDto>,
'Failed to load game round feed',
)
return normalizeGameRoundFeed(response.data)
return normalizeGameRoundFeed(dto)
}
export async function getGameAnnouncements() {
const response = await api.get<GameAnnouncementsDto>(
GAME_API_ENDPOINTS.announcements,
)
const dto = unwrapGameEnvelope(
response as ApiResponse<GameAnnouncementsDto>,
'Failed to load game announcements',
)
return normalizeAnnouncementState(response.data.announcements)
return normalizeAnnouncementState(dto.announcements)
}
export async function getGameLobbyInit() {
const response = await api.post<GameLobbyInitDto>(
GAME_API_ENDPOINTS.lobbyInit,
)
const dto = unwrapGameEnvelope(
response as ApiResponse<GameLobbyInitDto>,
'Failed to load game lobby init',
)
assertLobbyInitDto(dto)
return {
runtimeEnabled: dto.runtime_enabled,
serverTime: dto.server_time,
snapshot: normalizeGameLobbyInit(dto),
userSnapshot: dto.user_snapshot,
} satisfies GameLobbyInitResult
}
export async function getNoticeList(params?: {
page?: number
pageSize?: number
}) {
const response = await api.get<NoticeListDto>(GAME_API_ENDPOINTS.noticeList, {
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? 20),
},
})
const dto = unwrapGameEnvelope(
response as ApiResponse<NoticeListDto>,
'Failed to load notice list',
)
return dto
}
export async function getNoticeDetail(id: number) {
const response = await api.get<NoticeDetailDto>(
GAME_API_ENDPOINTS.noticeDetail,
{
searchParams: {
id: String(id),
},
},
)
const dto = unwrapGameEnvelope(
response as ApiResponse<NoticeDetailDto>,
'Failed to load notice detail',
)
return dto
}
export async function confirmNotice(noticeId: number) {
const response = await api.get<NoticeConfirmDto>(
GAME_API_ENDPOINTS.noticeConfirm,
{
searchParams: {
notice_id: String(noticeId),
},
},
)
const dto = unwrapGameEnvelope(
response as ApiResponse<NoticeConfirmDto>,
'Failed to confirm notice',
)
return dto
}
export async function getGameBetMyOrders(params: {
page?: number
pageSize?: number
}) {
const response = await api.post<GameBetOrdersDto>(
GAME_API_ENDPOINTS.betMyOrders,
{
json: {
page: params.page ?? 1,
page_size: params.pageSize ?? 20,
},
},
)
const dto = unwrapGameEnvelope(
response as ApiResponse<GameBetOrdersDto>,
'Failed to load bet orders',
)
return dto
}
export async function getMockGameBootstrap(latencyMs = 120) {

View File

@@ -123,6 +123,116 @@ export interface GameAnnouncementsDto {
announcements: AnnouncementStateDto
}
export interface NoticeListItemDto {
is_read: boolean
notice_id: number
notice_type: 'silent' | 'popout'
publish_time: number
title: string
}
export interface NoticeListDto {
list: NoticeListItemDto[]
}
export interface NoticeDetailDto {
content: string
must_confirm: boolean
notice_id: number
notice_type: 'silent' | 'popout'
publish_time: number
title: string
}
export interface NoticeConfirmDto {
confirm_time: number
confirmed: boolean
notice_id: number
}
export type GamePeriodStatus =
| 'betting'
| 'locked'
| 'settling'
| 'payouting'
| 'finished'
| 'void'
| (string & {})
export interface GameLobbyPeriodDto {
countdown: number
lock_at: number
open_at: number
period_no: string
status: GamePeriodStatus
}
export interface GameLobbyBetConfigDto {
chips: Record<string, string>
default_bet_chip_id: number
max_bet_per_number: string
min_bet_per_number: string
pick_max_number_count: number
}
export interface GameLobbyDictionaryItemDto {
category: string
icon: string
name: string
number: number
}
export interface GameLobbyUserSnapshotDto {
coin: string
current_streak: number
is_jackpot?: boolean
odds_factor?: number
streak_level?: number
}
export interface GameLobbyInitDto {
bet_config: GameLobbyBetConfigDto
dictionary: GameLobbyDictionaryItemDto[]
period?: GameLobbyPeriodDto | null
runtime_enabled: boolean
server_time: number
user_snapshot: GameLobbyUserSnapshotDto
}
export interface GamePeriodTickDto {
bet_close_in: number
countdown: number
period_id: number | null
period_no: string
result_number: number | null
runtime_enabled: boolean
server_time: number
status: GamePeriodStatus
}
export interface GameBetOrderDto {
bet_amount: string
create_time: number
numbers: number[]
order_no: string
period_no: string
result_number: number | null
status: string
total_amount: string
win_amount: string
}
export interface GameBetOrdersPaginationDto {
page: number
page_size: number
total: number
}
export interface GameBetOrdersDto {
list: GameBetOrderDto[]
pagination: GameBetOrdersPaginationDto
}
export type {
AnnouncementState,
Chip,

View File

@@ -1,5 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import diamondIcon from '@/assets/system/diamond.webp'
import { SmartImage } from '@/components/smart-image'
import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils'
import { useAuthStore, useModalStore } from '@/store'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true,
@@ -18,6 +24,37 @@ const animalImageList = Object.entries(animalModules)
.filter((item) => item.id > 0)
.sort((left, right) => left.id - right.id)
function getNextMarqueeId(currentId: number | null) {
if (animalImageList.length === 0) {
return null
}
if (animalImageList.length === 1) {
return animalImageList[0]?.id ?? null
}
let nextId = currentId
while (nextId === currentId) {
nextId =
animalImageList[Math.floor(Math.random() * animalImageList.length)]?.id ??
currentId
}
return nextId
}
function formatSelectedLog(
selectionByCell: Record<number, { amount: number; count: number }>,
) {
return Object.entries(selectionByCell)
.map(([cellId, value]) => ({
字花: String(cellId).padStart(2, '0'),
筹码: value.amount,
}))
.sort((left, right) => Number(left.) - Number(right.))
}
interface DesktopAnimalProps {
activeId?: number | null
className?: string
@@ -33,40 +70,220 @@ export function DesktopAnimal({
imageClassName,
onSelect,
}: DesktopAnimalProps) {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const chips = useGameRoundStore((state) => state.chips)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount,
)
const placeBet = useGameRoundStore((state) => state.placeBet)
const removeSelectionsForCell = useGameRoundStore(
(state) => state.removeSelectionsForCell,
)
const selections = useGameRoundStore((state) => state.selections)
const connection = useGameSessionStore((state) => state.connection)
const requestRealtimeConnection = useGameSessionStore(
(state) => state.requestRealtimeConnection,
)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
const [marqueeId, setMarqueeId] = useState<number | null>(() =>
getNextMarqueeId(null),
)
const activeChip = useMemo(
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
[activeChipId, chips],
)
const selectionByCell = useMemo(() => {
return selections.reduce<Record<number, { amount: number; count: number }>>(
(accumulator, selection) => {
const current = accumulator[selection.cellId] ?? { amount: 0, count: 0 }
accumulator[selection.cellId] = {
amount: current.amount + selection.amount,
count: current.count + 1,
}
return accumulator
},
{},
)
}, [selections])
const isRealtimeConnected = connection.status === 'connected'
const isRealtimeConnecting =
shouldConnectRealtime &&
(connection.status === 'connecting' || connection.status === 'reconnecting')
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
const lockInteraction = showStandbyState
const isSelectedCell = (animalId: number) =>
Boolean(selectionByCell[animalId])
const selectedCellCount = Object.keys(selectionByCell).length
const handleStart = () => {
if (authStatus !== 'authenticated') {
notify.warning(t('commonUi.toast.loginRequired'))
setModalOpen('desktopLogin', true)
return
}
clearSelections()
requestRealtimeConnection()
}
const handleSelect = (animalId: number) => {
if (showStandbyState) {
return
}
if (onSelect) {
onSelect(animalId)
return
}
if (isSelectedCell(animalId)) {
const nextSelectionByCell = { ...selectionByCell }
delete nextSelectionByCell[animalId]
console.log('已选', formatSelectedLog(nextSelectionByCell))
removeSelectionsForCell(animalId)
return
}
if (selectedCellCount >= maxSelectionCount) {
return
}
console.log(
'已选',
formatSelectedLog({
...selectionByCell,
[animalId]: {
amount: activeChip?.amount ?? 0,
count: 1,
},
}),
)
placeBet(animalId)
}
useEffect(() => {
if (!showStandbyState) {
setMarqueeId(null)
return
}
setMarqueeId((currentId) => getNextMarqueeId(currentId))
let timerId = 0
const loop = () => {
setMarqueeId((currentId) => getNextMarqueeId(currentId))
timerId = window.setTimeout(loop, 180 + Math.floor(Math.random() * 220))
}
timerId = window.setTimeout(loop, 220)
return () => {
window.clearTimeout(timerId)
}
}, [showStandbyState])
return (
<section
className={cn(
'grid w-full grid-cols-6 gap-design-5 common-neon-inset',
'relative grid w-full grid-cols-6 gap-design-5 overflow-hidden common-neon-inset',
className,
)}
>
{animalImageList.map((item) => {
const isActive = item.id === activeId
const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta)
const isActive = item.id === activeId || hasPlacedSelection
const isMarqueeActive = showStandbyState && item.id === marqueeId
return (
<button
key={item.id}
type="button"
onClick={() => onSelect?.(item.id)}
disabled={lockInteraction}
onClick={() => handleSelect(item.id)}
className={cn(
'flex flex-col items-center transition',
'cursor-pointer',
'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',
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,151,15,0.95)] shadow-[inset_0_0_16px_rgba(255,151,15,0.55)]',
'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)]',
!showStandbyState && !hasPlacedSelection && 'opacity-95',
itemClassName,
)}
>
<span
aria-hidden="true"
className={cn(
'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',
)}
/>
{!showStandbyState && !hasPlacedSelection ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[calc(var(--design-unit)*2)] z-20 rounded-[calc(var(--design-unit)*15)] bg-[rgba(4,16,24,0.52)] shadow-[inset_0_0_calc(var(--design-unit)*20)_rgba(3,9,14,0.56)]"
/>
) : null}
<SmartImage
src={item.url}
alt={`animal-${item.id}`}
className={cn(
'h-design-112 w-design-223 rounded-2xl object-contain',
'relative z-10 h-design-112 w-design-223 rounded-2xl object-contain',
imageClassName,
)}
/>
{hasPlacedSelection ? (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="flex min-w-design-96 items-center justify-center gap-design-4 rounded-full border border-[rgba(162,242,255,0.48)] bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-10 py-design-6 shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.18)]">
<SmartImage
src={diamondIcon}
alt="diamond"
className="h-design-24 w-design-24 shrink-0 object-contain"
/>
<span className="text-design-18 font-semibold leading-none tracking-[0.06em] text-[#D8FBFF]">
{selectionMeta.amount}
</span>
</span>
</span>
) : null}
</button>
)
})}
{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)]"
>
<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]">
{isRealtimeConnecting
? t('gameDesktop.animal.loading')
: t('gameDesktop.animal.getStart')}
</span>
</div>
</button>
) : null}
</section>
)
}

View File

@@ -1,5 +1,6 @@
import { motion } from 'motion/react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import add from '@/assets/game/add.webp'
import arrow from '@/assets/game/arrow.webp'
import chipBg from '@/assets/game/chip-bg.webp'
@@ -9,16 +10,18 @@ import controlBg from '@/assets/game/control-bg.png'
import leftBottomBg from '@/assets/game/left-bg.webp'
import reduce from '@/assets/game/reduce.webp'
import totalBg from '@/assets/game/total-bg.webp'
import diamond from '@/assets/system/diamond.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { ACTION_OPTIONS } from '@/constants'
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
import { cn } from '@/lib/utils'
export function DesktopControl() {
const { t } = useTranslation()
const {
canClear,
chips,
maxSelectionCountLabel,
onChipSelect,
onClearSelections,
selectedChipAmountLabel,
@@ -26,7 +29,6 @@ export function DesktopControl() {
selectedCountLabel,
totalBetAmountLabel,
} = useGameControlVm()
const [clickedId, setClickedId] = useState<string | null>(null)
const [hidingId, setHidingId] = useState<string | null>(null)
const [confirmClicked, setConfirmClicked] = useState(false)
@@ -74,8 +76,8 @@ export function DesktopControl() {
}
>
<div className={'flex flex-col items-center justify-center'}>
<div>TREBD</div>
<div>MAP</div>
<div>{t('gameDesktop.control.trend')}</div>
<div>{t('gameDesktop.control.map')}</div>
</div>
<SmartImage
src={arrow}
@@ -110,10 +112,10 @@ export function DesktopControl() {
transition={{
layout: {
type: 'spring',
stiffness: 420,
damping: 32,
stiffness: 360,
damping: 26,
},
duration: 0.18,
duration: 0.26,
}}
className={
'relative flex h-design-70 w-design-70 shrink-0 cursor-pointer items-center justify-center rounded-full'
@@ -178,15 +180,16 @@ export function DesktopControl() {
}
/>
<motion.div
layout
animate={
isSelected
? {
y: [-1, -3, -1],
scale: [1.02, 1.06, 1.02],
y: [-1, -4, -1],
scale: [1.04, 1.1, 1.04],
filter: [
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
'drop-shadow(0 10px 14px rgba(245, 200, 107, 0.22))',
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
'drop-shadow(0 8px 10px rgba(0,0,0,0.22))',
'drop-shadow(0 12px 16px rgba(245, 200, 107, 0.28))',
'drop-shadow(0 8px 10px rgba(0,0,0,0.22))',
],
}
: {
@@ -205,6 +208,27 @@ export function DesktopControl() {
draggable={false}
className={'h-design-70 w-design-70 object-contain'}
/>
<span
className={
'pointer-events-none absolute inset-x-0 top-1/2 z-[8] -translate-y-[calc(50%-1*var(--design-unit))] text-center text-design-16 font-black leading-none tracking-[0.06em] text-[rgba(96,54,0,0.85)] blur-[1px]'
}
>
{chip.valueLabel}
</span>
<span
className={
'pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-[calc(50%+1*var(--design-unit))] text-center text-design-16 font-black leading-none tracking-[0.06em] text-[rgba(66,28,0,0.72)]'
}
>
{chip.valueLabel}
</span>
<span
className={
'pointer-events-none absolute inset-x-0 top-1/2 z-[11] -translate-y-1/2 text-center text-design-16 font-black leading-none tracking-[0.06em] text-white [text-shadow:0_1px_0_rgba(255,255,255,0.6),0_2px_4px_rgba(0,0,0,0.72),0_0_10px_rgba(255,255,255,0.22)]'
}
>
{chip.valueLabel}
</span>
</motion.div>
</motion.button>
)
@@ -237,11 +261,26 @@ export function DesktopControl() {
src={totalBg}
size="100% 100%"
className={
'desktop-control-total relative flex flex-col items-center justify-center z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
'desktop-control-total relative flex items-center justify-center text-design-20 gap-design-40 z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
}
>
<div>SELECTED:{selectedCountLabel}</div>
<div>Total Bet{totalBetAmountLabel}</div>
<div>
{t('gameDesktop.control.selected')}:{' '}
<span className={'text-red-500'}>{selectedCountLabel}</span> /{' '}
{maxSelectionCountLabel}
</div>
<div className={'flex'}>
<div>{t('gameDesktop.control.totalBet')}</div>
<div className={'flex items-center gap-design-10'}>
<SmartImage
className={'w-design-30 h-design-30'}
src={diamond}
alt={'diamond'}
/>
<div>{totalBetAmountLabel}</div>
</div>
</div>
</SmartBackground>
<SmartBackground
src={controlBg}
@@ -250,7 +289,7 @@ export function DesktopControl() {
'desktop-control-actions relative z-10 flex h-full w-design-385 shrink-0 items-center bg-center bg-no-repeat pl-design-15',
)}
>
{ACTION_OPTIONS.map(({ id, label, Icon, bg }) => {
{ACTION_OPTIONS.map(({ id, labelKey, Icon, bg }) => {
const isClicked = clickedId === id
const isHiding = hidingId === id
const showBg = isClicked || isHiding
@@ -315,7 +354,7 @@ export function DesktopControl() {
className={showBg ? 'text-[#D9FEFF]' : 'text-[#37D5CB]'}
/>
<div className={'mt-design-6 text-design-14 leading-none'}>
{label}
{t(labelKey)}
</div>
</motion.div>
</motion.button>
@@ -351,7 +390,7 @@ export function DesktopControl() {
transition={{ duration: 0.15 }}
className="relative"
>
confirm
{t('gameDesktop.control.confirm')}
</motion.span>
</SmartBackground>
</div>

View File

@@ -1,9 +1,54 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import historyBg from '@/assets/system/history-bg.png'
import { SmartBackground } from '@/components/smart-background.tsx'
import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts'
export function DesktopGameHistory() {
const { emptyText, isEmpty, items } = useGameHistoryVm()
const { t } = useTranslation()
const {
emptyText,
endText,
fetchNextPage,
hasNextPage,
isEmpty,
isFetchingNextPage,
isInitialLoading,
items,
loadingText,
} = useGameHistoryVm()
const parentRef = useRef<HTMLDivElement | null>(null)
const rowCount = hasNextPage ? items.length + 1 : items.length
const virtualizer = useVirtualizer({
count: rowCount,
estimateSize: () => 196,
getScrollElement: () => parentRef.current,
overscan: 4,
})
useEffect(() => {
const virtualItems = virtualizer.getVirtualItems()
const lastItem = virtualItems[virtualItems.length - 1]
if (
!lastItem ||
!hasNextPage ||
isFetchingNextPage ||
lastItem.index < items.length - 1
) {
return
}
void fetchNextPage()
}, [
fetchNextPage,
hasNextPage,
isFetchingNextPage,
items.length,
virtualizer,
])
return (
<SmartBackground
@@ -16,14 +61,23 @@ export function DesktopGameHistory() {
'relative z-20 flex h-design-50 shrink-0 items-center justify-center text-design-30 text-[#D5FBFF]'
}
>
History
{t('gameDesktop.history.title')}
</div>
<div
ref={parentRef}
className={
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
}
>
{isEmpty ? (
{isInitialLoading ? (
<div
className={
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
}
>
{loadingText}
</div>
) : isEmpty ? (
<div
className={
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
@@ -32,56 +86,98 @@ export function DesktopGameHistory() {
{emptyText}
</div>
) : (
items.map((item) => {
return (
<div
key={item.id}
className={
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
}
>
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index]
return (
<div
className={
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
}
key={item?.id ?? `loader-${virtualRow.index}`}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{item.statusLabel}
{item ? (
<div
className={
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
}
>
<div
className={
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
}
>
{item.statusLabel}
</div>
<div
className={
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
}
>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.orderNo')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>
{item.orderNo}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.roundId')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>
{item.periodNo}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.numbers')}:{' '}
</span>
<span>{item.numbersLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.settledAt')}:{' '}
</span>
<span>{item.createdAtLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.totalPoolAmount')}:{' '}
</span>
<span className={'text-[#FFE375]'}>
{item.amountLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.winningResult')}:{' '}
</span>
<span className={'text-[#FF7575]'}>
{item.resultNumberLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.payout')}:{' '}
</span>
<span>{item.winAmountLabel}</span>
</div>
</div>
</div>
) : (
<div className="flex h-[calc(var(--design-unit)*60)] items-center justify-center text-design-16 text-[#84A2A2]">
{isFetchingNextPage ? loadingText : endText}
</div>
)}
</div>
<div
className={
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
}
>
<div>
<span className={'text-[#84A2A2]'}>Round ID: </span>
<span className={'text-[#C0E7EB]'}>{item.roundId}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>Settled At: </span>
<span>{item.settledAtLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
Total Pool Amount:{' '}
</span>
<span className={'text-[#FFE375]'}>
{item.totalPoolAmountLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>Winning Result: </span>
<span className={'text-[#FF7575]'}>
{item.winningCellIdLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>Payout: </span>
<span>{item.payoutMultiplierLabel}</span>
</div>
</div>
</div>
)
})
)
})}
</div>
)}
</div>
</SmartBackground>

View File

@@ -1,10 +1,260 @@
import { CircleAlert, Mail, Volume2 } from 'lucide-react'
import { CircleAlert, Mail, Maximize, Minimize, Volume2 } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import wifi from '@/assets/system/wifi.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import {
isDesktopFullscreen,
subscribeDesktopFullscreenChange,
toggleDesktopFullscreen,
} from '@/lib/utils'
import { useAuthStore, useGameSessionStore, useModalStore } from '@/store'
type BrowserNetworkInformation = {
addEventListener?: (type: 'change', listener: () => void) => void
downlink?: number
effectiveType?: string
removeEventListener?: (type: 'change', listener: () => void) => void
rtt?: number
}
type SignalPresentation = {
activeBars: number
latencyLabel: string
toneClassName: string
}
function formatTimezoneOffset(date: Date) {
const offsetMinutes = -date.getTimezoneOffset()
const sign = offsetMinutes >= 0 ? '+' : '-'
const absoluteMinutes = Math.abs(offsetMinutes)
const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0')
const minutes = String(absoluteMinutes % 60).padStart(2, '0')
return `GMT${sign}${hours}${minutes === '00' ? '' : `:${minutes}`}`
}
function formatHeaderTime(date: Date) {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${hours}:${minutes}:${seconds} ${formatTimezoneOffset(date)}`
}
function getBrowserNetworkInformation() {
if (typeof navigator === 'undefined') {
return null
}
return (navigator as Navigator & { connection?: BrowserNetworkInformation })
.connection
}
function resolveSignalPresentation(input: {
isOnline: boolean
latencyMs: number | null
status: string
}) {
if (!input.isOnline || input.status === 'disconnected') {
return {
activeBars: 0,
latencyLabel: '--',
toneClassName: 'text-[#FF6B6B]',
} satisfies SignalPresentation
}
if (input.latencyMs === null) {
return {
activeBars: input.status === 'connected' ? 2 : 1,
latencyLabel: '--',
toneClassName: 'text-[#7F8EA3]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 80) {
return {
activeBars: 4,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#74FF69]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 150) {
return {
activeBars: 3,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#B7FF6A]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 300) {
return {
activeBars: 2,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#FFD76A]',
} satisfies SignalPresentation
}
return {
activeBars: 1,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#FF8A6A]',
} satisfies SignalPresentation
}
function SignalBars({
activeBars,
toneClassName,
}: {
activeBars: number
toneClassName: string
}) {
const barHeights = ['h-[6px]', 'h-[10px]', 'h-[14px]', 'h-[18px]'] as const
return (
<div
className="flex h-design-20 w-design-28 items-end gap-[2px]"
aria-hidden="true"
>
{barHeights.map((heightClassName, index) => {
const isActive = index < activeBars
return (
<div
key={heightClassName}
className={[
'w-[5px] rounded-t-[2px] transition-colors',
heightClassName,
isActive ? `bg-current ${toneClassName}` : 'bg-white/18',
].join(' ')}
/>
)
})}
</div>
)
}
export function DesktopHeader() {
const { t } = useTranslation()
const [isFullscreen, setIsFullscreen] = useState(false)
const [clockNow, setClockNow] = useState(() => Date.now())
const [isOnline, setIsOnline] = useState(() =>
typeof navigator === 'undefined' ? true : navigator.onLine,
)
const [browserNetworkRttMs, setBrowserNetworkRttMs] = useState<number | null>(
() => {
const rtt = getBrowserNetworkInformation()?.rtt
return typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0
? rtt
: null
},
)
const currentUser = useAuthStore((state) => state.currentUser)
const authStatus = useAuthStore((state) => state.status)
const connection = useGameSessionStore((state) => state.connection)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const serverClockOffsetMs = useMemo(() => {
if (
connection.status !== 'connected' ||
connection.transport !== 'websocket' ||
!connection.lastMessageAt
) {
return null
}
const serverTimestamp = Date.parse(connection.lastMessageAt)
if (Number.isNaN(serverTimestamp)) {
return null
}
return serverTimestamp - Date.now()
}, [connection.lastMessageAt, connection.status, connection.transport])
const systemTimeLabel = useMemo(() => {
const activeTimestamp =
serverClockOffsetMs === null ? clockNow : clockNow + serverClockOffsetMs
return formatHeaderTime(new Date(activeTimestamp))
}, [clockNow, serverClockOffsetMs])
const signalLatencyMs = useMemo(() => {
if (
typeof connection.latencyMs === 'number' &&
Number.isFinite(connection.latencyMs) &&
connection.latencyMs >= 0
) {
return connection.latencyMs
}
return browserNetworkRttMs
}, [browserNetworkRttMs, connection.latencyMs])
const signalPresentation = useMemo(
() =>
resolveSignalPresentation({
isOnline,
latencyMs: signalLatencyMs,
status: connection.status,
}),
[connection.status, isOnline, signalLatencyMs],
)
useEffect(() => {
const syncFullscreenState = () => {
setIsFullscreen(isDesktopFullscreen())
}
syncFullscreenState()
return subscribeDesktopFullscreenChange(syncFullscreenState)
}, [])
useEffect(() => {
const timer = window.setInterval(() => {
setClockNow(Date.now())
}, 1000)
return () => {
window.clearInterval(timer)
}
}, [])
useEffect(() => {
const syncBrowserNetworkState = () => {
setIsOnline(navigator.onLine)
const rtt = getBrowserNetworkInformation()?.rtt
setBrowserNetworkRttMs(
typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0 ? rtt : null,
)
}
const networkInformation = getBrowserNetworkInformation()
syncBrowserNetworkState()
window.addEventListener('online', syncBrowserNetworkState)
window.addEventListener('offline', syncBrowserNetworkState)
networkInformation?.addEventListener?.('change', syncBrowserNetworkState)
return () => {
window.removeEventListener('online', syncBrowserNetworkState)
window.removeEventListener('offline', syncBrowserNetworkState)
networkInformation?.removeEventListener?.(
'change',
syncBrowserNetworkState,
)
}
}, [])
const handleFullscreenToggle = async () => {
await toggleDesktopFullscreen()
}
return (
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
<div className="flex h-design-70 w-full items-center px-design-12">
@@ -18,92 +268,124 @@ export function DesktopHeader() {
</div>
<div className="flex h-full w-design-130 items-center justify-center gap-design-10 border-r border-[rgba(128,223,231,0.65)]">
<SmartImage
src={wifi}
alt="wifi"
priority
className="h-design-20 w-design-28"
/>
<div className={'text-[#74FF69] text-design-20'}>
24 <span className={'text-design-16'}>ms</span>
<div className={signalPresentation.toneClassName}>
<SignalBars
activeBars={signalPresentation.activeBars}
toneClassName={signalPresentation.toneClassName}
/>
</div>
<div className={`${signalPresentation.toneClassName} text-design-20`}>
{signalPresentation.latencyLabel}{' '}
<span className={'text-design-16'}>ms</span>
</div>
</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>System Time</div>
<div>20:05:12 GMT+08</div>
<div>{t('gameDesktop.header.systemTime')}</div>
<div>{systemTimeLabel}</div>
</div>
<div className="flex h-full flex-1 items-center justify-around gap-design-10 px-design-40 text-[#D5FBFF] border-r border-[rgba(128,223,231,0.65)]">
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<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">
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<CircleAlert color={'#57B8BF'} size={16} />
<div>Rules & Ddds</div>
<div>{t('gameDesktop.header.rules')}</div>
</div>
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<Mail color={'#57B8BF'} size={16} />
<div>Pesan</div>
<div>{t('gameDesktop.header.message')}</div>
</div>
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<Volume2 color={'#57B8BF'} size={16} />
<div>BGM</div>
<div>{t('gameDesktop.header.bgm')}</div>
</div>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.id')}</div>
</div>
<button
type="button"
onClick={handleFullscreenToggle}
className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85"
>
{isFullscreen ? (
<Minimize color={'#57B8BF'} size={16} />
) : (
<Maximize color={'#57B8BF'} size={16} />
)}
<div>{t('gameDesktop.header.fullscreen')}</div>
</button>
</div>
{authStatus === 'authenticated' ? (
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
'flex items-center justify-center gap-design-30 pl-design-30 pr-design-10'
}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>ID</div>
</div>
</div>
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute -left-5 z-20 h-design-50 w-design-50"
/>
<div
className={
'common-neon-inset text-design-16 !py-design-20 flex h-design-36 w-design-180 items-center justify-end'
}
>
{currentUser?.username || '--'}
</div>
</div>
<div className={'flex items-center justify-center px-design-35'}>
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
/>
<div
className={
'common-neon-inset !py-design-20 flex h-design-36 w-design-160 items-center justify-end'
}
>
Biomond Balance
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={diamond}
alt="diamond"
priority
className="absolute -left-5 z-20 h-design-50 w-design-50"
/>
<div
className={
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-design-180 items-center justify-end'
}
>
{currentUser?.coin || '--'}
</div>
</div>
</div>
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
/>
<div
) : (
<div
className={
'flex items-center justify-center gap-design-30 pl-design-30 pr-design-10'
}
>
<button
type="button"
className={
'common-neon-inset !py-design-20 box-border flex h-design-36 w-design-160 items-center justify-end'
'min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85'
}
onClick={() => setModalOpen('desktopLogin', true)}
>
Biomond Balance
</div>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.login')}</div>
</button>
<button
type="button"
className={
'min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85'
}
onClick={() => setModalOpen('desktopRegister', true)}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.register')}</div>
</button>
</div>
</div>
)}
</div>
</header>
)

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
@@ -6,6 +7,7 @@ import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.t
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
export function DesktopStatusLine() {
const { t } = useTranslation()
const {
countdownMs,
limitLabel,
@@ -27,9 +29,15 @@ export function DesktopStatusLine() {
<div
className={'flex-1 flex items-center justify-center gap-design-24'}
>
<div>Odds: {oddsLabel}</div>
<div>Streak: {streakLabel}</div>
<div>Limit: {limitLabel}</div>
<div>
{t('gameDesktop.status.odds')}: {oddsLabel}
</div>
<div>
{t('gameDesktop.status.streak')}: {streakLabel}
</div>
<div>
{t('gameDesktop.status.limit')}: {limitLabel}
</div>
</div>
<SmartBackground
src={statusCenter}
@@ -44,7 +52,9 @@ export function DesktopStatusLine() {
/>
</SmartBackground>
<div className={'flex-1 flex items-center justify-center gap-10'}>
<div>Round ID:{roundId}</div>
<div>
{t('gameDesktop.status.roundId')}:{roundId}
</div>
<div className={'flex items-center gap-2'}>
<div className={'flex items-center gap-2'}>
<div

View File

@@ -1,12 +1,12 @@
import { Megaphone } from 'lucide-react'
import { useTranslation } from 'react-i18next'
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>
Selamat kepada pemain Wu Yanzu yang telah memenangkan hadiah utama
sebesar 5.000 yuan sebanyak lima kali berturut-turut!🎉🎉🎉
</div>
<div>{t('gameDesktop.title.announcement')}</div>
</section>
)
}

View File

@@ -1,5 +1,9 @@
import { useTranslation } from 'react-i18next'
function DesktopTopup() {
return <div>DesktopTopup</div>
const { t } = useTranslation()
return <div>{t('gameDesktop.topup.placeholder')}</div>
}
export default DesktopTopup

View File

@@ -1,5 +1,6 @@
import { Minus, Plus } from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { useTranslation } from 'react-i18next'
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
@@ -148,10 +149,12 @@ function WithdrawField({
function AmountShell({
amount,
availableBalanceText,
onMinus,
onPlus,
}: {
amount: number
availableBalanceText: string
onMinus: () => void
onPlus: () => void
}) {
@@ -180,7 +183,7 @@ function AmountShell({
</div>
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
Saldo Tersedia: {formatNumber(AVAILABLE_BALANCE)}
{availableBalanceText}
</div>
</div>
)
@@ -355,6 +358,7 @@ function PreviewRow({
}
function DesktopWithdraw() {
const { t } = useTranslation()
const [amount, setAmount] = useState(6626)
const [currency, setCurrency] =
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
@@ -388,15 +392,24 @@ function DesktopWithdraw() {
>
<div className="flex min-h-full min-w-0 flex-[1.7] flex-col px-design-16 py-design-14">
<div className="flex flex-col gap-design-12">
<WithdrawField label="Jumlah Penarikan Berlian">
<WithdrawField
label={t('gameDesktop.withdraw.fields.diamondWithdrawalAmount')}
>
<AmountShell
amount={amount}
availableBalanceText={t(
'gameDesktop.withdraw.availableBalance',
{ amount: formatNumber(AVAILABLE_BALANCE) },
)}
onMinus={() => handleAmountChange(amount - 1)}
onPlus={() => handleAmountChange(amount + 1)}
/>
</WithdrawField>
<WithdrawField label="Jenis Mata Uang" alignStart={false}>
<WithdrawField
label={t('gameDesktop.withdraw.fields.currencyType')}
alignStart={false}
>
<Select
value={currency}
onValueChange={(value) =>
@@ -405,9 +418,11 @@ function DesktopWithdraw() {
>
<SelectTrigger
className="h-design-52 w-full rounded-[calc(var(--design-unit)*6)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-16 text-left text-design-20 font-semibold text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-52 [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
aria-label="Currency selection"
aria-label={t('gameDesktop.withdraw.currencySelection')}
>
<SelectValue placeholder="Select currency" />
<SelectValue
placeholder={t('gameDesktop.withdraw.selectCurrency')}
/>
</SelectTrigger>
<SelectContent
position="popper"
@@ -441,7 +456,9 @@ function DesktopWithdraw() {
</div>
</div>
<WithdrawField label="Saluran Pembayaran">
<WithdrawField
label={t('gameDesktop.withdraw.fields.paymentChannel')}
>
<div className="flex flex-wrap gap-design-10">
{PAYMENT_CHANNELS.map((channel) => (
<PaymentCard
@@ -455,7 +472,7 @@ function DesktopWithdraw() {
</div>
</WithdrawField>
<WithdrawField label="Kode Bank">
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
<div className="flex flex-col gap-design-10">
<div className="flex h-design-40 items-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(12,61,72,0.78),rgba(6,28,39,0.88))] px-design-12 text-design-15 uppercase tracking-[0.02em] text-[#A4EAF2] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.07)]">
{`014${selectedBank?.label ?? 'BCA'} (${selectedBank?.subtitle ?? 'BANK CENTRAL ASIA'}): 014`}
@@ -475,40 +492,62 @@ function DesktopWithdraw() {
</div>
</WithdrawField>
<WithdrawField label="Nama Pemegang Kartu">
<WithdrawField
label={t('gameDesktop.withdraw.fields.cardHolderName')}
>
<InputShell
value={holderName}
onChange={setHolderName}
placeholder="Mohon masukkan nama pemegang kartu."
placeholder={t(
'gameDesktop.withdraw.placeholders.cardHolderName',
)}
error={holderNameError}
errorMessage="Mohon masukkan nama pemegang kartu."
errorMessage={t(
'gameDesktop.withdraw.errors.cardHolderNameRequired',
)}
/>
</WithdrawField>
<WithdrawField label="Nomor Rekening Bank">
<WithdrawField
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
>
<InputShell
value={bankAccount}
onChange={setBankAccount}
placeholder="Silakan masukkan nomor rekening bank Anda."
placeholder={t(
'gameDesktop.withdraw.placeholders.bankAccountNumber',
)}
error={bankAccountError}
errorMessage="Silakan masukkan nomor rekening bank Anda."
errorMessage={t(
'gameDesktop.withdraw.errors.bankAccountRequired',
)}
/>
</WithdrawField>
<WithdrawField label="Email Penerima" alignStart={false}>
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverEmail')}
alignStart={false}
>
<InputShell
value={receiverEmail}
onChange={setReceiverEmail}
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverEmail',
)}
uppercase={true}
/>
</WithdrawField>
<WithdrawField label="Nomor Ponsel Penerima" alignStart={false}>
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverPhone')}
alignStart={false}
>
<InputShell
value={receiverPhone}
onChange={setReceiverPhone}
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverPhone',
)}
uppercase={true}
/>
</WithdrawField>
@@ -519,67 +558,81 @@ function DesktopWithdraw() {
<div className="flex min-h-full min-w-0 w-design-520 shrink-0 flex-col">
<div className="flex h-design-44 items-center border-b border-[rgba(89,209,223,0.2)] bg-[linear-gradient(90deg,rgba(18,99,110,0.8),rgba(7,68,79,0.9))] px-design-12 text-design-20 font-semibold uppercase tracking-[0.04em] text-[#9AF5FB]">
Pratinjau Penukaran
{t('gameDesktop.withdraw.preview.title')}
</div>
<div className="flex flex-1 flex-col gap-design-12 px-design-10 py-design-10">
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
<PreviewRow label="Jumlah Berlian" value={formatNumber(amount)} />
<PreviewRow
label="Kurs (MYR)"
value={`${100 * MYR_PER_100_DIAMONDS} BERLIAN = 1 MYR`}
label={t('gameDesktop.withdraw.preview.diamondAmount')}
value={formatNumber(amount)}
/>
<PreviewRow
label="Dapat Ditukarkan MYR"
label={t('gameDesktop.withdraw.preview.rateMyr')}
value={t('gameDesktop.withdraw.preview.rateMyrValue', {
diamonds: 100 * MYR_PER_100_DIAMONDS,
})}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertibleMyr')}
value={`RM ${formatFixedTwo(withdrawMyr)}`}
highlight={true}
/>
<PreviewRow
label="Nilai Tukar USDT/MYR"
value={`1 USDT = RM ${USDT_TO_MYR_RATE}`}
label={t('gameDesktop.withdraw.preview.usdtMyrRate')}
value={t('gameDesktop.withdraw.preview.usdtMyrRateValue', {
rate: USDT_TO_MYR_RATE,
})}
/>
<PreviewRow
label="Nilai Tukar (VND)"
value={`${VND_PER_DIAMOND} BERLIAN = 1 VND`}
label={t('gameDesktop.withdraw.preview.rateVnd')}
value={t('gameDesktop.withdraw.preview.rateVndValue', {
diamonds: VND_PER_DIAMOND,
})}
/>
<PreviewRow
label="Dapat Dikonversi ke VND"
label={t('gameDesktop.withdraw.preview.convertibleVnd')}
value={`${formatNumber(withdrawVnd)} VND`}
highlight={true}
/>
<PreviewRow
label="Dapat Ditukarkan dengan USDT"
label={t('gameDesktop.withdraw.preview.convertibleUsdt')}
value={`${formatFixedSix(withdrawUsdt)} USDT`}
highlight={true}
/>
<PreviewRow
label="Jumlah Berlian Nilai Tukar Tetap"
label={t(
'gameDesktop.withdraw.preview.fixedExchangeDiamondAmount',
)}
value="0-0-0 0:0:0"
/>
</div>
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-12 py-design-10 text-design-16 leading-[1.35] text-[#F0B44A]">
Nilai tukar berfungsi sebagai harga acuan; nilai tukar aktual yang
berlaku ditentukan pada saat penarikan.
{t('gameDesktop.withdraw.exchangeRateNotice')}
</div>
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
<div>
Dompet Elektronik:{' '}
<span className="text-[#B9F4F8]">Minimal RM10</span>
{t('gameDesktop.withdraw.wallet')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumRm10')}
</span>
</div>
<div>
Bank: <span className="text-[#B9F4F8]">Minimal RM10</span>
{t('gameDesktop.withdraw.bank')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumRm10')}
</span>
</div>
<div>
Waktu Pengerjaan:{' '}
{t('gameDesktop.withdraw.processingTime')}:{' '}
<span className="text-[#77FF76]">
Dana Tiba Hanya Dalam 9 Detik.
{t('gameDesktop.withdraw.fundsArrivalTime')}
</span>
</div>
<div className="text-[#B9F4F8]">
Melihat: Transaksi antara RM10 dan RM99,99 akan dikenakan biaya
penarikan minimum sebesar RM1.
{t('gameDesktop.withdraw.feeNotice')}
</div>
</div>
@@ -591,7 +644,7 @@ function DesktopWithdraw() {
size="100% 100%"
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-18 font-bold uppercase tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
>
Membatalkan
{t('gameDesktop.withdraw.cancel')}
</SmartBackground>
<SmartBackground
as="button"
@@ -600,9 +653,9 @@ function DesktopWithdraw() {
size="100% 100%"
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
>
Konfirmasi
{t('gameDesktop.withdraw.confirm')}
<br />
Penarikan
{t('gameDesktop.withdraw.withdrawal')}
</SmartBackground>
</div>
</div>

View File

@@ -1,26 +1,34 @@
import { startTransition, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getMockGameBootstrap, getVisibleAnnouncements } from '@/features/game'
import { getGameLobbyInit, getVisibleAnnouncements } from '@/features/game'
import { GameAnnouncementModal } 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'
import { useDocumentMetadata } from '@/lib/head/document-metadata'
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)
const [isHydrating, setIsHydrating] = useState(true)
const [isMobile, setIsMobile] = useState(() => {
@@ -49,33 +57,89 @@ export function EntryPage() {
useEffect(() => {
let cancelled = false
void getMockGameBootstrap().then((snapshot) => {
if (cancelled) {
return
}
void getGameLobbyInit()
.then((result) => {
if (cancelled) {
return
}
startTransition(() => {
hydrateRound({
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
startTransition(() => {
const snapshot = result.snapshot
hydrateRound({
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
})
const defaultChipId =
snapshot.chips.find((chip) => chip.isDefault)?.id ?? null
if (defaultChipId) {
selectChip(defaultChipId)
}
hydrateSession({
announcements: snapshot.announcements,
connection: snapshot.connection,
dashboard: snapshot.dashboard,
})
const currentUser = useAuthStore.getState().currentUser
if (currentUser) {
setCurrentUser({
...currentUser,
coin: result.userSnapshot.coin,
currentStreak: result.userSnapshot.current_streak,
isJackpot: result.userSnapshot.is_jackpot,
oddsFactor: result.userSnapshot.odds_factor,
streakLevel: result.userSnapshot.streak_level,
})
}
setIsHydrating(false)
})
hydrateSession({
announcements: snapshot.announcements,
connection: snapshot.connection,
dashboard: snapshot.dashboard,
})
setIsHydrating(false)
})
})
.catch((error) => {
console.error('Failed to load game lobby init', error)
if (!cancelled) {
if (authStatus === 'authenticated') {
notify.error(t('commonUi.toast.lobbyInitFailed'), {
description: error instanceof Error ? error.message : undefined,
})
}
syncConnection({
connectedAt: null,
lastError:
error instanceof Error
? error.message
: 'Failed to load game lobby init',
lastMessageAt: null,
latencyMs: null,
status: 'disconnected',
transport: 'offline',
})
setIsHydrating(false)
}
})
return () => {
cancelled = true
}
}, [hydrateRound, hydrateSession])
}, [
authStatus,
hydrateRound,
hydrateSession,
selectChip,
setCurrentUser,
syncConnection,
t,
])
useEffect(() => {
if (typeof window === 'undefined') {

View File

@@ -1,3 +1,7 @@
import { useTranslation } from 'react-i18next'
export function MobileEntry() {
return <div>mobile component entry</div>
const { t } = useTranslation()
return <div>{t('gameDesktop.mobile.placeholder')}</div>
}

View File

@@ -4,6 +4,11 @@ import { DesktopControl } from '@/features/game/components/desktop/desktop-contr
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-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'
export function PcEntry() {
@@ -38,20 +43,13 @@ export function PcEntry() {
>
<DesktopControl />
</div>
{/*登录弹窗*/}
{/*<DesktopLoginModal />*/}
{/*注册弹窗 */}
{/*<DesktopRegisterModal />*/}
{/* 用户信息弹窗 */}
{/*<DesktopUserInfoModal />*/}
{/*公告弹窗*/}
{/*<DesktopNoticeModal />*/}
{/*自动托管弹窗*/}
<DesktopLoginModal />
<DesktopRegisterModal />
<DesktopUserInfoModal />
<DesktopNoticeModal />
<DesktopAutoSettingModal />
{/* 充值提现前置选择弹窗*/}
{/*<DesktopProceduresModal />*/}
{/* 充值和提现弹窗 */}
{/*<DesktopWithdrawTopupModal/>*/}
<DesktopProceduresModal />
<DesktopWithdrawTopupModal />
</>
)
}

View File

@@ -1,30 +1,43 @@
import { useMemo } from 'react'
import { CHIP_OPTIONS } from '@/constants'
import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants'
import { selectSelectionTotal, useGameRoundStore } from '@/store/game'
const CHIP_IMAGE_MAP = new Map(
CHIP_OPTIONS.map((chip) => [chip.value, chip.src] as const),
)
function formatChipDisplayValue(amount: number) {
if (Number.isInteger(amount)) {
return String(amount)
}
return amount.toFixed(2).replace(/\.?0+$/, '')
}
export function useGameControlVm() {
const chips = useGameRoundStore((state) => state.chips)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount,
)
const selections = useGameRoundStore((state) => state.selections)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const selectChip = useGameRoundStore((state) => state.selectChip)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const chipItems = useMemo(
() =>
chips.map((chip) => ({
amount: chip.amount,
id: chip.id,
isSelected: chip.id === activeChipId,
src: CHIP_IMAGE_MAP.get(chip.amount) ?? CHIP_OPTIONS[0]?.src ?? '',
valueLabel: String(chip.amount),
})),
[activeChipId, chips],
)
const chipItems = useMemo(() => {
const items = chips.map((chip) => ({
amount: chip.amount,
id: chip.id,
isSelected: chip.id === activeChipId,
src: CHIP_IMAGE_MAP.get(chip.id) ?? CHIP_IMAGE_OPTIONS[0]?.src ?? '',
valueLabel: formatChipDisplayValue(chip.amount),
}))
return items.sort((left, right) => {
if (left.isSelected === right.isSelected) {
return left.id.localeCompare(right.id, undefined, { numeric: true })
}
return left.isSelected ? 1 : -1
})
}, [activeChipId, chips])
const selectedChip =
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
@@ -33,10 +46,11 @@ export function useGameControlVm() {
canClear: selections.length > 0,
onChipSelect: selectChip,
onClearSelections: clearSelections,
maxSelectionCountLabel: maxSelectionCount,
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
selectedChipId: activeChipId,
selectedCountLabel: `${selections.length}/5`,
totalBetAmountLabel: String(totalBetAmount),
selectedCountLabel: selections.length,
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
chips: chipItems,
}
}

View File

@@ -1,14 +1,20 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useGameRoundStore } from '@/store/game'
import { useTranslation } from 'react-i18next'
function formatSettledTime(iso: string) {
const date = new Date(iso)
import { getGameBetMyOrders } from '@/features/game/api/game-api'
import { useAuthStore } from '@/store/auth'
const GAME_HISTORY_PAGE_SIZE = 20
function formatCreatedTime(timestamp: number, locale: string) {
const date = new Date(timestamp * 1000)
if (Number.isNaN(date.getTime())) {
return '--'
}
return date.toLocaleString('zh-CN', {
return date.toLocaleString(locale, {
hour12: false,
month: '2-digit',
day: '2-digit',
@@ -18,26 +24,70 @@ function formatSettledTime(iso: string) {
})
}
function formatNumbers(numbers: number[]) {
if (numbers.length === 0) {
return '--'
}
return numbers.map((number) => String(number).padStart(2, '0')).join(', ')
}
export function useGameHistoryVm() {
const history = useGameRoundStore((state) => state.history)
const { i18n, t } = useTranslation()
const accessToken = useAuthStore((state) => state.accessToken)
const authStatus = useAuthStore((state) => state.status)
const query = useInfiniteQuery({
queryKey: ['game', 'bet-my-orders', accessToken],
enabled: authStatus === 'authenticated' && Boolean(accessToken),
initialPageParam: 1,
queryFn: ({ pageParam }) =>
getGameBetMyOrders({
page: pageParam,
pageSize: GAME_HISTORY_PAGE_SIZE,
}),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.pagination.page + 1
const loadedCount =
lastPage.pagination.page * lastPage.pagination.page_size
return loadedCount < lastPage.pagination.total ? nextPage : undefined
},
})
const items = useMemo(
() =>
history.map((entry) => ({
id: entry.roundId,
payoutMultiplierLabel: `${entry.payoutMultiplier}x`,
roundId: entry.roundId,
settledAtLabel: formatSettledTime(entry.settledAt),
statusLabel: 'settled',
totalPoolAmountLabel: entry.totalPoolAmount.toFixed(2),
winningCellIdLabel: String(entry.winningCellId),
})),
[history],
(query.data?.pages ?? []).flatMap((page) =>
page.list.map((entry) => ({
amountLabel: entry.total_amount,
createdAtLabel: formatCreatedTime(
entry.create_time,
i18n.resolvedLanguage ?? 'en-US',
),
id: entry.order_no,
numbersLabel: formatNumbers(entry.numbers),
orderNo: entry.order_no,
periodNo: entry.period_no,
resultNumberLabel:
entry.result_number === null
? '--'
: String(entry.result_number).padStart(2, '0'),
statusLabel: entry.status,
winAmountLabel: entry.win_amount,
})),
),
[i18n.resolvedLanguage, query.data?.pages],
)
return {
emptyText: 'No history yet',
isEmpty: items.length === 0,
emptyText: t('gameDesktop.history.empty'),
endText: t('gameDesktop.history.end'),
fetchNextPage: query.fetchNextPage,
hasNextPage: query.hasNextPage,
isEmpty: authStatus !== 'authenticated' || items.length === 0,
isFetchingNextPage: query.isFetchingNextPage,
isInitialLoading: query.isLoading,
items,
loadingText: t('gameDesktop.history.loading'),
}
}

View File

@@ -0,0 +1,465 @@
import { useEffect, useRef } from 'react'
import i18n from '@/i18n'
import { prefetchAuthToken } from '@/lib/api/api-client'
import {
GameSocketClient,
type GameSocketMessage,
} from '@/lib/ws/game-socket-client'
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
import { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api'
import type { GameLobbyUserSnapshotDto, GamePeriodTickDto } from '../api/types'
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
// 当前 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
let sharedSocketClient: GameSocketClient | null = null
let sharedSocketKey: string | null = null
let sharedSocketDisconnectTimerId: number | null = null
function toIsoFromUnixSeconds(seconds: number) {
return new Date(seconds * 1000).toISOString()
}
function toSocketLang(language: string | null | undefined) {
return language?.startsWith('zh') ? 'zh' : 'en'
}
function toOptionalNumber(value: unknown) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
return undefined
}
function getNestedRecord(
value: unknown,
key: string,
): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null
}
const nested = (value as Record<string, unknown>)[key]
return nested && typeof nested === 'object'
? (nested as Record<string, unknown>)
: null
}
function extractServerTime(message: GameSocketMessage) {
const root = message as Record<string, unknown>
if (typeof root.server_time === 'number') {
return root.server_time
}
const data = getNestedRecord(message, 'data')
return typeof data?.server_time === 'number' ? data.server_time : null
}
function extractUserSnapshot(
message: GameSocketMessage,
): GameLobbyUserSnapshotDto | null {
const direct = getNestedRecord(message, 'user_snapshot')
const nested = getNestedRecord(
getNestedRecord(message, 'data'),
'user_snapshot',
)
const source = direct ?? nested
if (
!source ||
typeof source.coin !== 'string' ||
typeof source.current_streak !== 'number'
) {
return null
}
return {
coin: source.coin,
current_streak: source.current_streak,
is_jackpot:
typeof source.is_jackpot === 'boolean' ? source.is_jackpot : undefined,
odds_factor: toOptionalNumber(source.odds_factor),
streak_level: toOptionalNumber(source.streak_level),
}
}
function extractPeriodTick(
message: GameSocketMessage,
): GamePeriodTickDto | null {
const data = getNestedRecord(message, 'data')
const nested = getNestedRecord(data, 'period')
const source = nested ?? data
if (
!source ||
typeof source.period_no !== 'string' ||
typeof source.status !== 'string' ||
typeof source.countdown !== 'number' ||
typeof source.bet_close_in !== 'number'
) {
return null
}
return {
bet_close_in: source.bet_close_in,
countdown: source.countdown,
period_id: typeof source.period_id === 'number' ? source.period_id : null,
period_no: source.period_no,
result_number:
typeof source.result_number === 'number' ? source.result_number : null,
runtime_enabled:
typeof source.runtime_enabled === 'boolean'
? source.runtime_enabled
: true,
server_time:
typeof source.server_time === 'number'
? source.server_time
: Math.floor(Date.now() / 1000),
status: source.status as GamePeriodTickDto['status'],
}
}
function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
const currentRoundState = useGameRoundStore.getState()
const currentSessionState = useGameSessionStore.getState()
useGameRoundStore.getState().hydrateRound({
cells: result.snapshot.cells,
chips: result.snapshot.chips,
history: currentRoundState.history,
maxSelectionCount: result.snapshot.maxSelectionCount,
round: currentRoundState.round,
selections: currentRoundState.selections,
trends: currentRoundState.trends,
})
useGameSessionStore.getState().hydrateSession({
announcements: result.snapshot.announcements,
connection: {
...result.snapshot.connection,
status: 'connected',
transport: 'polling',
},
dashboard: {
...currentSessionState.dashboard,
tableLimitMax: result.snapshot.dashboard.tableLimitMax,
tableLimitMin: result.snapshot.dashboard.tableLimitMin,
},
})
const currentUser = useAuthStore.getState().currentUser
if (currentUser) {
useAuthStore.getState().setCurrentUser({
...currentUser,
coin: result.userSnapshot.coin,
currentStreak: result.userSnapshot.current_streak,
isJackpot: result.userSnapshot.is_jackpot,
oddsFactor: result.userSnapshot.odds_factor,
streakLevel: result.userSnapshot.streak_level,
})
}
}
function applyRealtimeMessage(message: GameSocketMessage) {
const serverTime = extractServerTime(message)
const period = extractPeriodTick(message)
const userSnapshot = extractUserSnapshot(message)
if (period) {
const previousRound = useGameRoundStore.getState().round
const round = normalizePeriodTickRound(
{
...period,
server_time: serverTime ?? period.server_time,
},
previousRound,
)
useGameRoundStore.getState().syncRound({
bettingClosesAt: round.bettingClosesAt,
id: round.id,
phase: round.phase,
revealingAt: round.revealingAt,
settledAt: round.settledAt,
startedAt: round.startedAt,
winningCellId: round.winningCellId,
})
useGameSessionStore.getState().syncDashboard({
countdownMs: period.countdown * 1000,
updatedAt:
serverTime !== null
? toIsoFromUnixSeconds(serverTime)
: toIsoFromUnixSeconds(period.server_time),
})
}
if (userSnapshot) {
const currentUser = useAuthStore.getState().currentUser
if (currentUser) {
useAuthStore.getState().setCurrentUser({
...currentUser,
coin: userSnapshot.coin,
currentStreak: userSnapshot.current_streak,
isJackpot: userSnapshot.is_jackpot,
oddsFactor: userSnapshot.odds_factor,
streakLevel: userSnapshot.streak_level,
})
}
}
useGameSessionStore.getState().syncConnection({
lastMessageAt:
serverTime !== null
? toIsoFromUnixSeconds(serverTime)
: new Date().toISOString(),
})
}
export function useGameRealtimeSync() {
const accessToken = useAuthStore((state) => state.accessToken)
const authStatus = useAuthStore((state) => state.status)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
const socketClientRef = useRef<GameSocketClient | null>(null)
useEffect(() => {
if (sharedSocketDisconnectTimerId !== null) {
window.clearTimeout(sharedSocketDisconnectTimerId)
sharedSocketDisconnectTimerId = null
}
if (
!shouldConnectRealtime ||
authStatus !== 'authenticated' ||
!accessToken
) {
sharedSocketDisconnectTimerId = window.setTimeout(() => {
sharedSocketClient?.disconnect()
sharedSocketClient = null
sharedSocketKey = null
sharedSocketDisconnectTimerId = null
}, SOCKET_DISCONNECT_DELAY_MS)
socketClientRef.current = sharedSocketClient
return
}
const websocketUrl = import.meta.env.VITE_WEBSOCKET_URL?.trim() || null
const socketKey = `${websocketUrl ?? ''}::${accessToken}`
if (sharedSocketClient && sharedSocketKey === socketKey) {
socketClientRef.current = sharedSocketClient
return () => {
sharedSocketDisconnectTimerId = window.setTimeout(() => {
sharedSocketClient?.disconnect()
sharedSocketClient = null
sharedSocketKey = null
sharedSocketDisconnectTimerId = null
}, SOCKET_DISCONNECT_DELAY_MS)
}
}
sharedSocketClient?.disconnect()
const socketClient = new GameSocketClient({
getContext: async () => {
await prefetchAuthToken()
const authToken = useAuthStore.getState().apiAuthToken
if (!authToken) {
return null
}
return {
token: accessToken,
authToken,
deviceId: getAuthDeviceId(),
lang: toSocketLang(i18n.resolvedLanguage),
}
},
getUrl: () => websocketUrl,
onError: (error) => {
useGameSessionStore.getState().syncConnection({
lastError:
'message' in error && typeof error.message === 'string'
? error.message
: 'WebSocket error',
})
},
onLatencyChange: (latencyMs) => {
useGameSessionStore.getState().syncConnection({
latencyMs,
})
},
onMessage: (message) => {
if (message.event === 'ws.connected') {
const serverTime = extractServerTime(message)
useGameSessionStore.getState().syncConnection({
connectedAt:
serverTime !== null
? toIsoFromUnixSeconds(serverTime)
: new Date().toISOString(),
lastError: null,
lastMessageAt:
serverTime !== null
? toIsoFromUnixSeconds(serverTime)
: new Date().toISOString(),
reconnectAttempt: 0,
status: 'connected',
transport: 'websocket',
})
}
applyRealtimeMessage(message)
},
onStatusChange: (status, reconnectAttempt) => {
const mappedStatus =
status === 'idle'
? 'idle'
: status === 'connected'
? 'connected'
: status
useGameSessionStore.getState().syncConnection({
latencyMs: mappedStatus === 'connected' ? undefined : null,
reconnectAttempt,
status: mappedStatus,
transport: websocketUrl ? 'websocket' : 'polling',
})
},
})
sharedSocketClient = socketClient
sharedSocketKey = socketKey
socketClientRef.current = socketClient
socketClient.subscribe([...PLAYER_SOCKET_TOPICS])
void socketClient.connect()
return () => {
sharedSocketDisconnectTimerId = window.setTimeout(() => {
if (sharedSocketClient === socketClient) {
socketClient.disconnect()
sharedSocketClient = null
sharedSocketKey = null
}
sharedSocketDisconnectTimerId = null
}, SOCKET_DISCONNECT_DELAY_MS)
socketClientRef.current = sharedSocketClient
}
}, [accessToken, authStatus, shouldConnectRealtime])
useEffect(() => {
if (!shouldConnectRealtime || authStatus !== 'authenticated') {
return
}
let cancelled = false
let intervalId = 0
const pollLobbyState = async () => {
const connection = useGameSessionStore.getState().connection
if (
connection.status === 'connected' &&
connection.transport === 'websocket'
) {
return
}
const startedAt = Date.now()
try {
const result = await getGameLobbyInit()
if (cancelled) {
return
}
applyLobbySync(result)
useGameSessionStore.getState().syncConnection({
lastError: null,
latencyMs: Date.now() - startedAt,
status: 'connected',
transport: 'polling',
})
} catch (error) {
if (cancelled) {
return
}
useGameSessionStore.getState().syncConnection({
lastError: error instanceof Error ? error.message : 'Polling failed',
status: 'reconnecting',
transport: 'polling',
})
}
}
intervalId = window.setInterval(() => {
void pollLobbyState()
}, FALLBACK_POLL_INTERVAL_MS)
void pollLobbyState()
return () => {
cancelled = true
window.clearInterval(intervalId)
}
}, [authStatus, shouldConnectRealtime])
}

View File

@@ -1,59 +1,67 @@
import { useMemo } from 'react'
import { getRoundCountdownMs } from '@/features/game/shared/selectors'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const PHASE_META = {
betting: {
description: '(Menerima Taruhan)',
label: 'OPEN',
descriptionKey: 'gameDesktop.status.phase.betting.description',
labelKey: 'gameDesktop.status.phase.betting.label',
toneClassName: 'text-[#78FF7F]',
},
locked: {
description: '(Taruhan Ditutup)',
label: 'LOCKED',
descriptionKey: 'gameDesktop.status.phase.locked.description',
labelKey: 'gameDesktop.status.phase.locked.label',
toneClassName: 'text-[#FFE375]',
},
revealing: {
description: '(Mengundi Hasil)',
label: 'DRAWING',
descriptionKey: 'gameDesktop.status.phase.revealing.description',
labelKey: 'gameDesktop.status.phase.revealing.label',
toneClassName: 'text-[#57E8FF]',
},
settled: {
description: '(Putaran Selesai)',
label: 'SETTLED',
descriptionKey: 'gameDesktop.status.phase.settled.description',
labelKey: 'gameDesktop.status.phase.settled.label',
toneClassName: 'text-[#FF9C6B]',
},
waiting: {
description: '(Menunggu Putaran Berikutnya)',
label: '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)
const round = useGameRoundStore((state) => state.round)
const trends = useGameRoundStore((state) => state.trends)
const dashboard = useGameSessionStore((state) => state.dashboard)
const currentUser = useAuthStore((state) => state.currentUser)
return useMemo(() => {
const oddsValue = cells[0]?.odds ?? '--'
const oddsValue =
typeof currentUser?.oddsFactor === 'number'
? currentUser.oddsFactor
: (cells[0]?.odds ?? '--')
const featuredTrend = trends.find(
(entry) => entry.cellId === dashboard.featuredCellId,
)
const phaseMeta = PHASE_META[round.phase]
const streakValue =
currentUser?.currentStreak ?? featuredTrend?.currentStreak ?? null
return {
acceptingBets: round.phase === 'betting',
countdownMs: getRoundCountdownMs(round),
countdownMs: dashboard.countdownMs,
limitLabel: `${dashboard.tableLimitMin}-${dashboard.tableLimitMax}`,
oddsLabel: `1:${oddsValue}`,
phase: round.phase,
phaseDescription: phaseMeta.description,
phaseLabel: phaseMeta.label,
phaseDescription: t(phaseMeta.descriptionKey),
phaseLabel: t(phaseMeta.labelKey),
phaseToneClassName: phaseMeta.toneClassName,
roundId: round.id,
streakLabel: featuredTrend ? `X${featuredTrend.currentStreak}` : '--',
roundId: round.id || '--',
streakLabel: typeof streakValue === 'number' ? `X${streakValue}` : '--',
}
}, [cells, dashboard, round, trends])
}, [cells, currentUser, dashboard, round, t, trends])
}

View File

@@ -1,33 +1,36 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { Input } from '@/components/ui/input.tsx'
import { Switch } from '@/components/ui/switch.tsx'
import { useModalStore } from '@/store'
const AUTO_STOP_ROWS = [
{
label: 'Stop if balance lower than',
labelKey: 'game.modals.autoSetting.rows.stopIfBalanceLowerThan',
value: '0',
checked: false,
},
{
label: 'Stop if single win exceeds',
labelKey: 'game.modals.autoSetting.rows.stopIfSingleWinExceeds',
value: '50000',
checked: true,
},
{
label: 'Stop on any Jackpot',
labelKey: 'game.modals.autoSetting.rows.stopOnAnyJackpot',
// value: '50000',
checked: false,
},
] as const
function DesktopAutoSettingModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopAutoSetting)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopAutoSetting', false)
}
return (
@@ -36,7 +39,7 @@ function DesktopAutoSettingModal() {
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26 uppercase'}>
Biomond Balance
{t('game.modals.autoSetting.title')}
</div>
}
isNormalBg={true}
@@ -51,7 +54,7 @@ function DesktopAutoSettingModal() {
<div className={'flex w-full flex-col gap-design-26'}>
{AUTO_STOP_ROWS.map((row) => (
<div
key={row.label}
key={row.labelKey}
className={'flex items-center justify-between gap-design-30'}
>
<div
@@ -59,10 +62,10 @@ function DesktopAutoSettingModal() {
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
}
>
{row.label}
{t(row.labelKey)}
</div>
{row.value ? (
{'value' in row ? (
<div
className={
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
@@ -95,7 +98,7 @@ function DesktopAutoSettingModal() {
'w-design-300 h-design-72 pb-design-4 flex items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF]'
}
>
START AUTO-SPIN
{t('game.modals.autoSetting.startAutoSpin')}
</SmartBackground>
</div>
</div>

View File

@@ -1,100 +1,28 @@
import { motion } from 'motion/react'
import { useState } from 'react'
import loginBg from '@/assets/system/login-bg.webp'
import rightImg from '@/assets/system/right.webp'
import { useTranslation } from 'react-i18next'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { Input } from '@/components/ui/input.tsx'
import { DesktopLoginForm } from '@/features/auth/components/desktop-login-form'
import { useModalStore } from '@/store'
function DesktopLoginModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopLogin)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopLogin', false)
}
return (
<CenterModal
open={open}
onClose={() => {}}
title={<div className={'modal-title-glow'}></div>}
onClose={() => setModalOpen('desktopLogin', false)}
title={
<div className={'modal-title-glow'}>{t('game.modals.login.title')}</div>
}
titleAlign="center"
className={'w-design-980 h-design-540'}
>
<div
className={
'flex flex-col items-center justify-between gap-design-20 px-design-20'
}
>
<div
className={
'h-design-375 flex flex-col gap-design-45 w-full bg-[#060B0F]/50 p-design-50'
}
>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Akun/TEL:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Silakan masukkan akun atau nomor ponsel Anda.'}
/>
</div>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Kata Sandi:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Masukkan Kata Sandi'}
/>
</div>
<div className={'flex items-center justify-around'}>
<div className={'flex items-center gap-design-10'}>
<div
className={
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
}
>
<SmartImage alt={'right'} src={rightImg} />
</div>
<div className={'text-[#549195]'}>Daftar Akun</div>
</div>
<div className={'flex items-center gap-design-10'}>
<div
className={
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
}
>
<SmartImage alt={'right'} src={rightImg} />
</div>
<div className={'text-[#549195]'}>Ingat Kata Sandi</div>
</div>
</div>
</div>
<SmartBackground
as={motion.div}
onClick={handleSubmit}
whileTap={{ scale: 0.95 }}
src={loginBg}
size="100% 100%"
className={
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer'
}
>
MASUK
</SmartBackground>
</div>
<DesktopLoginForm onSuccess={handleSubmit} />
</CenterModal>
)
}

View File

@@ -1,16 +1,19 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
import noticeBg from '@/assets/system/notice-bg.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { useModalStore } from '@/store'
function DesktopNoticeModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopNotice)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopNotice', false)
}
return (
@@ -19,7 +22,7 @@ function DesktopNoticeModal() {
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26'}>
PENGUMUMAN ACARA
{t('game.modals.notice.title')}
</div>
}
isNormalBg={true}
@@ -40,13 +43,7 @@ function DesktopNoticeModal() {
/>
<div className={'text-[#74B3BA] text-design-18 leading-[1.6]'}>
"Perjanjian Lisensi dan Layanan Game" (selanjutnya disebut sebagai
"Perjanjian ini") disepakati secara bersama-sama oleh Anda dan
Penyedia Layanan Game; Perjanjian ini merupakan kontrak yang
mengikat secara hukum. Anda sangat dianjurkan untuk membaca dengan
saksama dan memahami s epenuhnya isi dari setiap klausulkhususnya
klausul-klausul yang membebaskan atau membatasi tanggung jawab
(selanjutnya disebut sebagai "Klausul Pembebasan"),
{t('game.modals.notice.content')}
</div>
</div>
<div className={'w-full flex justify-around'}>
@@ -59,7 +56,7 @@ function DesktopNoticeModal() {
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
}
>
Memeriksa
{t('game.modals.notice.check')}
</SmartBackground>
<SmartBackground
@@ -71,7 +68,7 @@ function DesktopNoticeModal() {
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
}
>
Memeriksa
{t('game.modals.notice.check')}
</SmartBackground>
</div>
</div>

View File

@@ -1,15 +1,27 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import proceduresBg from '@/assets/system/procedures-bg.webp'
import topupBtnBg from '@/assets/system/topup.webp'
import withdrawBtnBg from '@/assets/system/withdraw.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { useModalStore } from '@/store'
function DesktopProceduresModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopProcedures)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const setWithdrawTopupType = useModalStore(
(state) => state.setWithdrawTopupType,
)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopProcedures', false)
}
function handleOpenWithdrawTopup(type: 'withdraw' | 'topup') {
setModalOpen('desktopProcedures', false)
setWithdrawTopupType(type)
setModalOpen('desktopWithdrawTopup', true)
}
return (
@@ -18,7 +30,7 @@ function DesktopProceduresModal() {
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26 uppercase'}>
Biomond Balance
{t('game.modals.procedures.title')}
</div>
}
isNormalBg={true}
@@ -33,23 +45,27 @@ function DesktopProceduresModal() {
'h-[95%] w-full rounded-md flex flex-col items-center justify-between'
}
>
<div className={'mt-design-190'}>111</div>
<div className={'mt-design-190'}>
{t('game.modals.procedures.contentPlaceholder')}
</div>
<div className={'flex items-center ml-design-180'}>
<SmartBackground
src={withdrawBtnBg}
onClick={() => handleOpenWithdrawTopup('withdraw')}
className={
'w-design-400 h-design-195 flex items-center justify-center pb-design-10 text-design-32 font-bold'
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-10 text-design-32 font-bold'
}
>
{t('game.modals.procedures.withdraw')}
</SmartBackground>
<SmartBackground
src={topupBtnBg}
onClick={() => handleOpenWithdrawTopup('topup')}
className={
'w-design-400 h-design-195 flex items-center justify-center pb-design-20 text-design-32 font-bold'
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-20 text-design-32 font-bold'
}
>
{t('game.modals.procedures.topup')}
</SmartBackground>
</div>
</SmartBackground>

View File

@@ -1,124 +1,30 @@
import { motion } from 'motion/react'
import { useState } from 'react'
import loginBg from '@/assets/system/login-bg.webp'
import rightImg from '@/assets/system/right.webp'
import { useTranslation } from 'react-i18next'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { Input } from '@/components/ui/input.tsx'
import { DesktopRegisterForm } from '@/features/auth/components/desktop-register-form'
import { useModalStore } from '@/store'
function DesktopRegisterModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopRegister)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopRegister', false)
}
return (
<CenterModal
open={open}
onClose={() => {}}
title={<div className={'modal-title-glow'}></div>}
onClose={() => setModalOpen('desktopRegister', false)}
title={
<div className={'modal-title-glow'}>
{t('game.modals.register.title')}
</div>
}
titleAlign="center"
className={'w-design-980 h-design-740'}
>
<div
className={'flex flex-col items-center justify-between px-design-20'}
>
<div
className={
'h-design-490 flex flex-col gap-design-30 w-full bg-[#060B0F]/50 p-design-50'
}
>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Akun/TEL:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Silakan masukkan akun atau nomor ponsel Anda.'}
/>
</div>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Kata Sandi:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Masukkan Kata Sandi'}
/>
</div>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Kata Sandi:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Masukkan Kata Sandi'}
/>
</div>
<div className={'flex items-center'}>
<div
className={
'w-design-160 shrink-0 text-left text-design-24 text-[#58ADAF]'
}
>
Kata Sandi:
</div>
<Input
className={'flex-1 text-left'}
placeholder={'Masukkan Kata Sandi'}
/>
</div>
<div className={'flex items-center justify-around'}>
<div className={'flex items-center gap-design-10'}>
<div
className={
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
}
>
<SmartImage alt={'right'} src={rightImg} />
</div>
<div className={'text-[#549195]'}>Daftar Akun</div>
</div>
<div className={'flex items-center gap-design-10'}>
<div
className={
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
}
>
<SmartImage alt={'right'} src={rightImg} />
</div>
<div className={'text-[#549195]'}>Ingat Kata Sandi</div>
</div>
</div>
</div>
<SmartBackground
as={motion.div}
onClick={handleSubmit}
whileTap={{ scale: 0.95 }}
src={loginBg}
size="100% 100%"
className={
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer'
}
>
MASUK
</SmartBackground>
</div>
<DesktopRegisterForm onSuccess={handleSubmit} />
</CenterModal>
)
}

View File

@@ -1,5 +1,6 @@
import { CircleUserRound, Mail } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp'
import blueBtnBg from '@/assets/system/blue-btn.webp'
import lengthBtnBg from '@/assets/system/length-blue-btn.webp'
@@ -8,32 +9,35 @@ import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'
type UserInfoTabKey = 'profile' | 'message'
const USER_INFO_TABS: Array<{
key: UserInfoTabKey
label: string
labelKey: string
icon: typeof CircleUserRound
}> = [
{
key: 'profile',
label: '个人信息',
labelKey: 'game.modals.userInfo.tabs.profile',
icon: CircleUserRound,
},
{
key: 'message',
label: '站内消息',
labelKey: 'game.modals.userInfo.tabs.message',
icon: Mail,
},
]
function DesktopUserInfoModal() {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopUserInfo)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
function handleSubmit() {
setOpen(false)
setModalOpen('desktopUserInfo', false)
}
return (
@@ -41,7 +45,9 @@ function DesktopUserInfoModal() {
open={open}
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26'}>Biomond Balance</div>
<div className={'modal-title-glow text-design-26'}>
{t('game.modals.userInfo.title')}
</div>
}
isNormalBg={true}
titleAlign="left"
@@ -96,7 +102,7 @@ function DesktopUserInfoModal() {
isActive && 'modal-title-gold-glow',
)}
>
{tab.label}
{t(tab.labelKey)}
</div>
</button>
)
@@ -119,8 +125,12 @@ function DesktopUserInfoModal() {
alt={'avatar'}
/>
<div className={'flex flex-col gap-design-30'}>
<div>NAMA Biomond Balance</div>
<div>TEL 12345678901</div>
<div>
{t('game.modals.userInfo.profile.name')} Biomond Balance
</div>
<div>
{t('game.modals.userInfo.profile.tel')} 12345678901
</div>
</div>
</div>
@@ -128,7 +138,7 @@ function DesktopUserInfoModal() {
<div className={'flex flex-col gap-design-5'}>
{[1, 2, 3, 4].map((item) => (
<div key={item}>
Tanggal Pendaftaran
{t('game.modals.userInfo.profile.registeredAt')}
<span className={'text-design-18 text-[#599AA3]'}>
2022-10-06 2336
</span>
@@ -140,8 +150,7 @@ function DesktopUserInfoModal() {
'w-design-600 h-design-120 text-design-18 rounded-md bg-[#000000]/40 flex items-center justify-center'
}
>
Tanda tangan pribadi saya persis seperti jiwa sayaunik dan
mus
{t('game.modals.userInfo.profile.signature')}
</div>
</div>
</SmartBackground>
@@ -166,10 +175,7 @@ function DesktopUserInfoModal() {
<div className={'h-design-95 w-design-95 bg-black'}></div>
<div className={'flex-1'}>
<div>2026-10-10 08:32:56</div>
<div>
[Event Bonus Isi Ulang] Dari tanggal 1 hingga 7 Oktober
2026, dapatkan pengembalian ...
</div>
<div>{t('game.modals.userInfo.message.eventBonus')}</div>
</div>
<SmartBackground
src={blueBtnBg}
@@ -178,7 +184,7 @@ function DesktopUserInfoModal() {
'w-design-150 h-design-64 flex items-center justify-center text-design-20 font-bold'
}
>
Memeriksa
{t('game.modals.userInfo.message.check')}
</SmartBackground>
</div>
))}
@@ -196,7 +202,7 @@ function DesktopUserInfoModal() {
'w-design-275 h-design-65 flex items-center justify-center text-design-22 font-bold'
}
>
{t('game.modals.userInfo.message.deleteRecords')}
</SmartBackground>
</div>
</div>

View File

@@ -1,15 +1,17 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CenterModal } from '@/components/center-modal.tsx'
import DesktopTopup from '@/features/game/components/desktop/desktop-topup.tsx'
import DesktopWithdraw from '@/features/game/components/desktop/desktop-withdraw.tsx'
type WithdrawType = 'withdraw' | 'topup'
import { useModalStore } from '@/store'
function DesktopWithdrawTopupModal() {
const [open, setOpen] = useState(true)
const [type] = useState<WithdrawType>('withdraw')
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopWithdrawTopup)
const type = useModalStore((state) => state.withdrawTopupType)
const setModalOpen = useModalStore((state) => state.setModalOpen)
function handleSubmit() {
setOpen(false)
setModalOpen('desktopWithdrawTopup', false)
}
return (
@@ -18,7 +20,9 @@ function DesktopWithdrawTopupModal() {
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26 uppercase'}>
{type === 'withdraw' ? '申请提现' : '申请充值'}
{type === 'withdraw'
? t('game.modals.withdrawTopup.applyWithdraw')
: t('game.modals.withdrawTopup.applyTopup')}
</div>
}
isNormalBg={true}

View File

@@ -56,4 +56,4 @@ export const DEFAULT_GAME_CHIP_COLORS = [
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_BOARD_COLUMNS = GAME_GRID_COLUMNS
export const GAME_MAX_SELECTION_CELLS = 5

View File

@@ -1,9 +1,10 @@
import { CHIP_OPTIONS } from '@/constants'
import { DEFAULT_CHIP_AMOUNTS } from '@/constants'
import {
DEFAULT_ACTIVE_CHIP_ID,
DEFAULT_ANNOUNCEMENT_TTL_MS,
DEFAULT_GAME_CHIP_COLORS,
GAME_GRID_COLUMNS,
GAME_MAX_SELECTION_CELLS,
GAME_TOTAL_CELLS,
} from './constants'
import { deriveTrendEntries, getRoundCountdownMs } from './selectors'
@@ -41,12 +42,12 @@ export function createGameCells() {
}
export function createDefaultChips() {
return CHIP_OPTIONS.map((chip, index) => ({
amount: chip.value,
return DEFAULT_CHIP_AMOUNTS.map((chip, index) => ({
amount: chip.amount,
color: DEFAULT_GAME_CHIP_COLORS[index],
id: chip.id,
isDefault: chip.id === DEFAULT_ACTIVE_CHIP_ID,
label: chip.value >= 100 ? `${chip.value / 100}x` : String(chip.value),
label: chip.amount >= 100 ? `${chip.amount / 100}x` : String(chip.amount),
})) satisfies Chip[]
}
@@ -76,36 +77,8 @@ export function createMockRoundSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
} satisfies RoundSnapshot
}
export function createMockBetSelections(chips = createDefaultChips()) {
const defaultChip =
chips.find((chip) => chip.id === DEFAULT_ACTIVE_CHIP_ID) ?? chips[0]
return [
{
amount: defaultChip.amount,
cellId: 8,
chipId: defaultChip.id,
id: 'bet-local-1',
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 4_000),
source: 'local',
},
{
amount: chips[1]?.amount ?? defaultChip.amount,
cellId: 12,
chipId: chips[1]?.id ?? defaultChip.id,
id: 'bet-server-2',
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 7_000),
source: 'server',
},
{
amount: chips[3]?.amount ?? defaultChip.amount,
cellId: 17,
chipId: chips[3]?.id ?? defaultChip.id,
id: 'bet-local-3',
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 10_000),
source: 'local',
},
] satisfies BetSelection[]
export function createMockBetSelections() {
return [] satisfies BetSelection[]
}
export function createMockAnnouncementState(baseIso = MOCK_GAME_BASE_TIME) {
@@ -177,8 +150,9 @@ export function createMockGameBootstrapSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
connection: createMockConnectionState(baseIso),
dashboard: createMockDashboardState(baseIso, round, history),
history,
maxSelectionCount: GAME_MAX_SELECTION_CELLS,
round,
selections: createMockBetSelections(chips),
selections: createMockBetSelections(),
trends: deriveTrendEntries(history),
} satisfies GameBootstrapSnapshot
}

View File

@@ -112,6 +112,7 @@ export interface GameBootstrapSnapshot {
connection: ConnectionState
dashboard: DashboardState
history: HistoryEntry[]
maxSelectionCount: number
round: RoundSnapshot
selections: BetSelection[]
trends: TrendEntry[]