+
{t('gameDesktop.history.winningResult')}
diff --git a/src/hooks/use-game-history-vm.ts b/src/hooks/use-game-history-vm.ts
index a66991c..5d84c79 100644
--- a/src/hooks/use-game-history-vm.ts
+++ b/src/hooks/use-game-history-vm.ts
@@ -1,11 +1,25 @@
import { useInfiniteQuery } from '@tanstack/react-query'
-import { useEffect, useMemo, useRef } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getGameBetMyOrders } from '@/api'
import { GAME_HISTORY_PAGE_SIZE } from '@/constants'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore } from '@/store/game'
-import type { HistoryResultState } from '@/type'
+import type { HistoryResultState, PeriodPayoutHistoryEntry } from '@/type'
+
+interface GameHistoryDisplayItem {
+ betAmountLabel: string
+ createdAtLabel: string
+ id: string
+ resultState: HistoryResultState
+ numbersLabel: string
+ numbers: number[]
+ orderNo: string
+ periodNo: string
+ resultNumber: number | null
+ resultNumberLabel: string
+ winAmountLabel: string
+}
function formatCreatedTime(timestamp: number, locale: string) {
const date = new Date(timestamp * 1000)
@@ -32,15 +46,45 @@ function formatNumbers(numbers: number[]) {
return numbers.map((number) => String(number).padStart(2, '0')).join(', ')
}
+function toPayoutHistoryDisplayItem(
+ entry: PeriodPayoutHistoryEntry,
+ locale: string,
+): GameHistoryDisplayItem {
+ return {
+ betAmountLabel: entry.betAmount,
+ createdAtLabel: formatCreatedTime(entry.serverTime, locale),
+ id: entry.id,
+ resultState: entry.betNumbers.includes(entry.resultNumber)
+ ? ('win' satisfies HistoryResultState)
+ : ('lost' satisfies HistoryResultState),
+ numbersLabel: formatNumbers(entry.betNumbers),
+ numbers: entry.betNumbers,
+ orderNo: entry.id,
+ periodNo: entry.periodNo,
+ resultNumber: entry.resultNumber,
+ resultNumberLabel: String(entry.resultNumber).padStart(2, '0'),
+ winAmountLabel: entry.winAmount,
+ }
+}
+
export function useGameHistoryVm() {
const { i18n, t } = useTranslation()
+ const locale = i18n.resolvedLanguage ?? 'en-US'
const accessToken = useAuthStore((state) => state.accessToken)
const authStatus = useAuthStore((state) => state.status)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealRoundId = useGameRoundStore(
(state) => state.revealAnimation.roundId,
)
- const lastRevealedRoundRef = useRef(null)
+ const pendingPayoutHistoryEntryId = useGameRoundStore(
+ (state) => state.pendingPayoutHistoryEntry?.id ?? null,
+ )
+ const consumePendingPayoutHistoryEntry = useGameRoundStore(
+ (state) => state.consumePendingPayoutHistoryEntry,
+ )
+ const [realtimeEntries, setRealtimeEntries] = useState<
+ PeriodPayoutHistoryEntry[]
+ >([])
const query = useInfiniteQuery({
queryKey: ['game', 'bet-my-orders', accessToken],
@@ -62,7 +106,7 @@ export function useGameHistoryVm() {
},
})
- const items = useMemo(
+ const apiItems = useMemo(
() =>
(query.data?.pages ?? []).flatMap((page) =>
(page.list ?? []).map((entry) => {
@@ -71,11 +115,8 @@ export function useGameHistoryVm() {
const resultNumber = shouldHideResult ? null : entry.result_number
return {
- amountLabel: entry.total_amount,
- createdAtLabel: formatCreatedTime(
- entry.create_time,
- i18n.resolvedLanguage ?? 'en-US',
- ),
+ betAmountLabel: entry.bet_amount,
+ createdAtLabel: formatCreatedTime(entry.create_time, locale),
id: entry.order_no,
resultState:
resultNumber === null
@@ -93,33 +134,52 @@ export function useGameHistoryVm() {
? '--'
: String(resultNumber).padStart(2, '0'),
winAmountLabel: entry.win_amount,
- }
+ } satisfies GameHistoryDisplayItem
}),
),
- [i18n.resolvedLanguage, query.data?.pages, revealPhase, revealRoundId],
+ [locale, query.data?.pages, revealPhase, revealRoundId],
+ )
+
+ const items = useMemo(
+ () => [
+ ...realtimeEntries.map((entry) =>
+ toPayoutHistoryDisplayItem(entry, locale),
+ ),
+ ...apiItems.filter(
+ (item) =>
+ !realtimeEntries.some((entry) => entry.periodNo === item.periodNo),
+ ),
+ ],
+ [apiItems, locale, realtimeEntries],
)
useEffect(() => {
- if (revealPhase !== 'result' || !revealRoundId) {
+ if (
+ revealPhase !== 'result' ||
+ !revealRoundId ||
+ !pendingPayoutHistoryEntryId
+ ) {
return
}
- if (lastRevealedRoundRef.current === revealRoundId) {
+ if (authStatus !== 'authenticated') {
return
}
- if (authStatus !== 'authenticated' || query.isFetching || query.isLoading) {
+ const entry = consumePendingPayoutHistoryEntry(revealRoundId)
+
+ if (!entry) {
return
}
- lastRevealedRoundRef.current = revealRoundId
-
- void query.refetch()
+ setRealtimeEntries((entries) => [
+ entry,
+ ...entries.filter((item) => item.periodNo !== entry.periodNo),
+ ])
}, [
authStatus,
- query.isFetching,
- query.isLoading,
- query.refetch,
+ consumePendingPayoutHistoryEntry,
+ pendingPayoutHistoryEntryId,
revealPhase,
revealRoundId,
])
diff --git a/src/hooks/use-game-realtime-sync.ts b/src/hooks/use-game-realtime-sync.ts
index 99998da..2060857 100644
--- a/src/hooks/use-game-realtime-sync.ts
+++ b/src/hooks/use-game-realtime-sync.ts
@@ -16,6 +16,7 @@ import {
extractBetWinData,
extractJackpotHitData,
extractPeriodEventData,
+ extractPeriodPayoutData,
extractPeriodTick,
extractServerTime,
extractUserStreakMessageData,
@@ -180,7 +181,8 @@ function applyPeriodPayoutMessage(
) {
applyPeriodMessage(message, serverTime)
- const period = extractPeriodEventData(message)
+ const payout = extractPeriodPayoutData(message)
+ const period = payout ?? extractPeriodEventData(message)
if (period?.resultNumber !== null && period?.resultNumber !== undefined) {
const roundState = useGameRoundStore.getState()
@@ -195,6 +197,21 @@ function applyPeriodPayoutMessage(
roundId: period.periodNo,
winningCellId: period.resultNumber,
})
+
+ if (payout && payout.betNumbers.length > 0) {
+ roundState.setPendingPayoutHistoryEntry({
+ betAmount: payout.betAmount ?? '0.00',
+ betFlowerNames: payout.betFlowerNames,
+ betNumbers: payout.betNumbers,
+ id: `period-payout:${payout.periodNo}`,
+ periodNo: payout.periodNo,
+ resultFlowerName: payout.resultFlowerName,
+ resultNumber: period.resultNumber,
+ serverTime:
+ payout.serverTime ?? serverTime ?? Math.floor(Date.now() / 1000),
+ winAmount: payout.winAmount ?? '0.00',
+ })
+ }
}
const roundId = period?.periodNo ?? useGameRoundStore.getState().round.id
diff --git a/src/hooks/use-register-form.ts b/src/hooks/use-register-form.ts
index 9f5d948..e918a79 100644
--- a/src/hooks/use-register-form.ts
+++ b/src/hooks/use-register-form.ts
@@ -1,28 +1,17 @@
import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { registerWithPassword } from '@/api'
-import {
- DEFAULT_REGISTER_INVITE_CODE,
- REGISTER_INVITE_CODE_QUERY_PARAM,
-} from '@/constants'
import i18n from '@/i18n'
import { notify } from '@/lib/notify'
import { registerFormSchema } from '@/schema/auth-schema'
import { useAuthStore } from '@/store/auth'
import type { RegisterFormValues, UseRegisterFormOptions } from '@/type'
import { toAuthSubmitErrorKey } from './auth-error-key'
+import { getRegisterInviteCodeFromSearch } from './use-register-invite-code'
import { createZodResolver } from './zod-form-resolver'
function getInitialRegisterInviteCode() {
- if (typeof window === 'undefined') {
- return DEFAULT_REGISTER_INVITE_CODE
- }
-
- return (
- new URLSearchParams(window.location.search)
- .get(REGISTER_INVITE_CODE_QUERY_PARAM)
- ?.trim() || DEFAULT_REGISTER_INVITE_CODE
- )
+ return getRegisterInviteCodeFromSearch()
}
export function useRegisterForm({ onSuccess }: UseRegisterFormOptions = {}) {
diff --git a/src/hooks/use-register-invite-code.ts b/src/hooks/use-register-invite-code.ts
new file mode 100644
index 0000000..c670d77
--- /dev/null
+++ b/src/hooks/use-register-invite-code.ts
@@ -0,0 +1,41 @@
+import { useEffect, useMemo, useRef } from 'react'
+import { REGISTER_INVITE_CODE_QUERY_PARAM } from '@/constants'
+import { useAuthStore } from '@/store/auth'
+import { useModalStore } from '@/store/modal'
+
+export function getRegisterInviteCodeFromSearch(search?: string) {
+ const sourceSearch =
+ search ?? (typeof window === 'undefined' ? '' : window.location.search)
+
+ return (
+ new URLSearchParams(sourceSearch)
+ .get(REGISTER_INVITE_CODE_QUERY_PARAM)
+ ?.trim() ?? ''
+ )
+}
+
+export function useAutoOpenRegisterByInviteCode() {
+ const handledInviteCodeRef = useRef(null)
+ const authIsHydrated = useAuthStore((state) => state.isHydrated)
+ const authStatus = useAuthStore((state) => state.status)
+ const openExclusiveModal = useModalStore((state) => state.openExclusiveModal)
+ const inviteCode = useMemo(() => getRegisterInviteCodeFromSearch(), [])
+
+ useEffect(() => {
+ if (
+ !authIsHydrated ||
+ !inviteCode ||
+ handledInviteCodeRef.current === inviteCode
+ ) {
+ return
+ }
+
+ handledInviteCodeRef.current = inviteCode
+
+ if (authStatus === 'authenticated') {
+ return
+ }
+
+ openExclusiveModal('desktopRegister')
+ }, [authIsHydrated, authStatus, inviteCode, openExclusiveModal])
+}
diff --git a/src/lib/ws/message-parsers.ts b/src/lib/ws/message-parsers.ts
index 3f84942..252d5ee 100644
--- a/src/lib/ws/message-parsers.ts
+++ b/src/lib/ws/message-parsers.ts
@@ -6,6 +6,7 @@ import type {
JackpotHitEventDataDto,
JackpotHitItemDto,
PeriodEventData,
+ PeriodPayoutEventData,
UserStreakMessageData,
WalletChangedData,
} from '@/type'
@@ -187,6 +188,58 @@ export function extractPeriodEventData(
}
}
+function toNumberArray(value: unknown) {
+ if (!Array.isArray(value)) {
+ return []
+ }
+
+ return value
+ .map((item) => toOptionalNumber(item))
+ .filter(
+ (item): item is number =>
+ typeof item === 'number' && Number.isInteger(item),
+ )
+}
+
+function toStringArray(value: unknown) {
+ if (!Array.isArray(value)) {
+ return []
+ }
+
+ return value
+ .map((item) => toOptionalString(item))
+ .filter((item): item is string => typeof item === 'string')
+}
+
+export function extractPeriodPayoutData(
+ message: GameSocketMessage,
+): PeriodPayoutEventData | null {
+ const period = extractPeriodEventData(message)
+
+ if (!period) {
+ return null
+ }
+
+ const data = getNestedRecord(message, 'data')
+ const source = data ?? (message as Record)
+ const root = message as Record
+
+ return {
+ ...period,
+ betAmount: toOptionalString(source.bet_amount) ?? null,
+ betFlowerNames: toStringArray(source.bet_flower_names),
+ betNumbers: toNumberArray(source.bet_numbers),
+ payoutRemainingSeconds:
+ toOptionalNumber(source.payout_remaining_seconds) ?? null,
+ payoutSeconds: toOptionalNumber(source.payout_seconds) ?? null,
+ payoutUntil: toOptionalNumber(source.payout_until) ?? null,
+ resultFlowerName: toOptionalString(source.result_flower_name) ?? null,
+ serverTime:
+ toOptionalNumber(source.server_time ?? root.server_time) ?? null,
+ winAmount: toOptionalString(source.win_amount) ?? null,
+ }
+}
+
export function extractWalletChangedData(
message: GameSocketMessage,
): WalletChangedData | null {
diff --git a/src/main/main-entry-page.tsx b/src/main/main-entry-page.tsx
index da98962..bec055f 100644
--- a/src/main/main-entry-page.tsx
+++ b/src/main/main-entry-page.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { getGameLobbyInit } from '@/api'
import { MOBILE_LAYOUT_BREAKPOINT_PX } from '@/constants'
import { useGameRealtimeSync } from '@/hooks/use-game-realtime-sync.ts'
+import { useAutoOpenRegisterByInviteCode } from '@/hooks/use-register-invite-code'
import { useDocumentMetadata } from '@/lib/head/document-metadata.ts'
import { notify } from '@/lib/notify.ts'
import { useAuthStore } from '@/store/auth'
@@ -24,6 +25,7 @@ const PcEntry = lazy(async () => {
export function MainEntryPage() {
const { t } = useTranslation()
useGameRealtimeSync()
+ useAutoOpenRegisterByInviteCode()
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
const selectChip = useGameRoundStore((state) => state.selectChip)
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
diff --git a/src/store/game/game-round-store.ts b/src/store/game/game-round-store.ts
index 068282c..0313ef5 100644
--- a/src/store/game/game-round-store.ts
+++ b/src/store/game/game-round-store.ts
@@ -15,6 +15,7 @@ import type {
GameRoundSlice,
GameRoundStoreState,
HistoryEntry,
+ PeriodPayoutHistoryEntry,
RevealAnimationState,
RoundSnapshot,
TrendEntry,
@@ -119,6 +120,7 @@ function resolveSelectionQuantity(
function createInitialRoundState(): GameRoundSlice & {
activeChipId: string
activeBetQuantity: number
+ pendingPayoutHistoryEntry: PeriodPayoutHistoryEntry | null
recentSuccessfulSelections: BetSelection[]
revealAnimation: RevealAnimationState
} {
@@ -131,6 +133,7 @@ function createInitialRoundState(): GameRoundSlice & {
chips: snapshot.chips,
history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount,
+ pendingPayoutHistoryEntry: null,
recentSuccessfulSelections: [],
revealAnimation: createIdleRevealAnimation(),
round: snapshot.round,
@@ -139,7 +142,7 @@ function createInitialRoundState(): GameRoundSlice & {
}
}
-export const useGameRoundStore = create()((set) => ({
+export const useGameRoundStore = create()((set, get) => ({
...createInitialRoundState(),
adjustBetQuantity: (delta) => {
set((state) => {
@@ -181,6 +184,18 @@ export const useGameRoundStore = create()((set) => ({
},
}))
},
+ consumePendingPayoutHistoryEntry: (roundId) => {
+ const state = get()
+ const entry = state.pendingPayoutHistoryEntry
+
+ if (!entry || entry.periodNo !== roundId) {
+ return null
+ }
+
+ set({ pendingPayoutHistoryEntry: null })
+
+ return entry
+ },
finishRevealAnimation: () => {
set((state) => {
if (
@@ -244,6 +259,7 @@ export const useGameRoundStore = create()((set) => ({
}
})
},
+ pendingPayoutHistoryEntry: null,
placeBet: (cellId) => {
set((state) => {
const activeChip =
@@ -328,7 +344,7 @@ export const useGameRoundStore = create()((set) => ({
}))
},
restoreRecentSuccessfulSelections: () => {
- const state = useGameRoundStore.getState()
+ const state = get()
if (
state.round.phase !== 'betting' ||
@@ -468,6 +484,9 @@ export const useGameRoundStore = create()((set) => ({
}
})
},
+ setPendingPayoutHistoryEntry: (entry) => {
+ set({ pendingPayoutHistoryEntry: entry })
+ },
syncRound: (round) => {
set((state) => {
const previousRound = state.round
diff --git a/src/type/game.type.ts b/src/type/game.type.ts
index e74f415..308e307 100644
--- a/src/type/game.type.ts
+++ b/src/type/game.type.ts
@@ -212,6 +212,30 @@ export interface PeriodEventData {
resultNumber: number | null
}
+export interface PeriodPayoutHistoryEntry {
+ betAmount: string
+ betFlowerNames: string[]
+ betNumbers: number[]
+ id: string
+ periodNo: string
+ resultFlowerName: string | null
+ resultNumber: number
+ serverTime: number
+ winAmount: string
+}
+
+export interface PeriodPayoutEventData extends PeriodEventData {
+ betAmount: string | null
+ betFlowerNames: string[]
+ betNumbers: number[]
+ payoutRemainingSeconds: number | null
+ payoutSeconds: number | null
+ payoutUntil: number | null
+ resultFlowerName: string | null
+ serverTime: number | null
+ winAmount: string | null
+}
+
export interface WalletChangedData {
coin: string
}
@@ -550,6 +574,7 @@ export interface GameRoundStoreState extends GameRoundSlice {
hydrateRound: (snapshot: GameRoundSlice) => void
placeBet: (cellId: number) => void
playPreparedRevealAnimation: (roundId?: string | null) => void
+ pendingPayoutHistoryEntry: PeriodPayoutHistoryEntry | null
prepareRevealAnimation: (input: {
revealKey: string
roundId: string
@@ -559,6 +584,9 @@ export interface GameRoundStoreState extends GameRoundSlice {
revealAnimation: RevealAnimationState
removeSelectionsForCell: (cellId: number) => void
restoreRecentSuccessfulSelections: () => boolean
+ consumePendingPayoutHistoryEntry: (
+ roundId: string,
+ ) => PeriodPayoutHistoryEntry | null
setRecentSuccessfulSelections: (selections: BetSelection[]) => void
selectChip: (chipId: string) => void
setPhase: (phase: RoundPhase) => void
@@ -569,6 +597,7 @@ export interface GameRoundStoreState extends GameRoundSlice {
totalWin: string
winningCellId?: number | null
}) => void
+ setPendingPayoutHistoryEntry: (entry: PeriodPayoutHistoryEntry | null) => void
syncRound: (round: Partial) => void
upsertSelections: (selections: BetSelection[]) => void
}