feat(auth): 集成认证授权功能并优化API客户端
- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取 - 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理 - 添加了WebSocket连接配置和API基础URL环境变量设置 - 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑 - 集成了MD5加密和认证令牌缓存机制,提升安全性 - 添加了多语言国际化支持,包括英语、中文、马来语和印尼语 - 实现了认证状态管理和本地存储持久化功能 - 添加了表单验证schema和错误处理机制,增强用户体验
This commit is contained in:
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user