feat(auth): 集成认证授权功能并优化API客户端
- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取 - 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理 - 添加了WebSocket连接配置和API基础URL环境变量设置 - 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑 - 集成了MD5加密和认证令牌缓存机制,提升安全性 - 添加了多语言国际化支持,包括英语、中文、马来语和印尼语 - 实现了认证状态管理和本地存储持久化功能 - 添加了表单验证schema和错误处理机制,增强用户体验
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
465
src/features/game/hooks/use-game-realtime-sync.ts
Normal file
465
src/features/game/hooks/use-game-realtime-sync.ts
Normal 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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 klausul—khususnya
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 23:36
|
||||
</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 saya—unik 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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface GameBootstrapSnapshot {
|
||||
connection: ConnectionState
|
||||
dashboard: DashboardState
|
||||
history: HistoryEntry[]
|
||||
maxSelectionCount: number
|
||||
round: RoundSnapshot
|
||||
selections: BetSelection[]
|
||||
trends: TrendEntry[]
|
||||
|
||||
Reference in New Issue
Block a user