feat: 联调充值和提现接口

This commit is contained in:
JiaJun
2026-05-21 13:40:32 +08:00
parent 6ac42cf35e
commit 44c984d59e
51 changed files with 3830 additions and 1478 deletions

View File

@@ -0,0 +1,209 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { notify } from '@/lib/notify'
import { useAudioStore, useAuthStore, useModalStore } from '@/store'
import {
selectSelectionTotal,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
function parseBalance(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
if (typeof value !== 'string') {
return 0
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
export type DesktopAnimalWarningType = 'balance' | 'limit'
function getNextMarqueeId(ids: number[], currentId: number | null) {
if (ids.length === 0) {
return null
}
if (ids.length === 1) {
return ids[0] ?? null
}
let nextId = currentId
while (nextId === currentId) {
nextId = ids[Math.floor(Math.random() * ids.length)] ?? currentId
}
return nextId
}
export function useAnimalVm(
animalIds: number[],
onSelect?: (animalId: number) => void,
) {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
const currentUser = useAuthStore((state) => state.currentUser)
const markSoundPlaybackUnlocked = useAudioStore(
(state) => state.markSoundPlaybackUnlocked,
)
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 totalBetAmount = useGameRoundStore(selectSelectionTotal)
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(animalIds, null),
)
const [cellWarning, setCellWarning] = useState<{
cellId: number
type: DesktopAnimalWarningType
} | null>(null)
const activeChip = useMemo(
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
[activeChipId, chips],
)
const balance = parseBalance(currentUser?.coin)
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 selectedCellCount = Object.keys(selectionByCell).length
useEffect(() => {
if (cellWarning === null) {
return
}
const timerId = window.setTimeout(() => {
setCellWarning((currentWarning) =>
currentWarning?.cellId === cellWarning.cellId &&
currentWarning.type === cellWarning.type
? null
: currentWarning,
)
}, 1200)
return () => {
window.clearTimeout(timerId)
}
}, [cellWarning])
useEffect(() => {
if (!showStandbyState) {
setMarqueeId(null)
return
}
setMarqueeId((currentId) => getNextMarqueeId(animalIds, currentId))
let timerId = 0
const loop = () => {
setMarqueeId((currentId) => getNextMarqueeId(animalIds, currentId))
timerId = window.setTimeout(loop, 180 + Math.floor(Math.random() * 220))
}
timerId = window.setTimeout(loop, 220)
return () => {
window.clearTimeout(timerId)
}
}, [animalIds, showStandbyState])
const handleStart = () => {
if (authStatus !== 'authenticated') {
notify.warning(t('commonUi.toast.loginRequired'))
setModalOpen('desktopLogin', true)
return
}
clearSelections()
markSoundPlaybackUnlocked()
requestRealtimeConnection()
}
const handleSelect = (animalId: number) => {
if (showStandbyState) {
return
}
if (onSelect) {
onSelect(animalId)
return
}
if (selectionByCell[animalId]) {
removeSelectionsForCell(animalId)
return
}
if (selectedCellCount >= maxSelectionCount) {
setCellWarning({
cellId: animalId,
type: 'limit',
})
return
}
if (totalBetAmount + (activeChip?.amount ?? 0) > balance) {
setCellWarning({
cellId: animalId,
type: 'balance',
})
return
}
placeBet(animalId)
}
return {
cellWarning,
handleSelect,
handleStart,
isRealtimeConnecting,
lockInteraction,
marqueeId,
selectionByCell,
showStandbyState,
}
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { getDepositTierList } from '@/features/game/api'
export function useDepositTierList() {
const { i18n } = useTranslation()
const language = i18n.resolvedLanguage ?? i18n.language ?? 'zh-CN'
return useQuery({
queryKey: ['finance', 'deposit-tier-list', language],
queryFn: () => getDepositTierList(),
staleTime: 5 * 60 * 1000,
})
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { getDepositWithdrawConfig } from '@/features/game/api'
export function useDepositWithdrawConfig() {
const { i18n } = useTranslation()
const language = i18n.resolvedLanguage ?? i18n.language ?? 'zh-CN'
return useQuery({
queryKey: ['finance', 'deposit-withdraw-config', language],
queryFn: () => getDepositWithdrawConfig(),
staleTime: 5 * 60 * 1000,
})
}

View File

@@ -175,7 +175,6 @@ export function useGameControlVm() {
try {
let latestBalance = currentUser?.coin ?? '0'
let latestStreak = currentUser?.currentStreak ?? 0
for (const group of groupedSelections.values()) {
const uniqueNumbers = [...new Set(group.numbers)].sort(
@@ -193,14 +192,12 @@ export function useGameControlVm() {
}
latestBalance = result.balance_after
latestStreak = result.current_streak
}
if (currentUser) {
setCurrentUser({
...currentUser,
coin: latestBalance,
currentStreak: latestStreak,
lastBetPeriodNo: round.id,
})
}

View File

@@ -33,6 +33,8 @@ function formatNumbers(numbers: number[]) {
return numbers.map((number) => String(number).padStart(2, '0')).join(', ')
}
type HistoryResultState = 'lost' | 'pending' | 'win'
export function useGameHistoryVm() {
const { i18n, t } = useTranslation()
const accessToken = useAuthStore((state) => state.accessToken)
@@ -69,9 +71,12 @@ export function useGameHistoryVm() {
i18n.resolvedLanguage ?? 'en-US',
),
id: entry.order_no,
isWin:
entry.result_number !== null &&
entry.numbers.includes(entry.result_number),
resultState:
entry.result_number === null
? ('pending' satisfies HistoryResultState)
: entry.numbers.includes(entry.result_number)
? ('win' satisfies HistoryResultState)
: ('lost' satisfies HistoryResultState),
numbersLabel: formatNumbers(entry.numbers),
numbers: entry.numbers,
orderNo: entry.order_no,

View File

@@ -8,7 +8,13 @@ import {
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'
import type { GamePeriodTickDto } from '../api/types'
type UserStreakMessageData = {
currentStreak: number
oddsFactor?: number
streakLevel?: number
}
const FALLBACK_POLL_INTERVAL_MS = 10_000
const GAME_SOCKET_TOPICS = {
@@ -36,6 +42,10 @@ const GAME_SOCKET_TOPICS = {
adminLiveOpened: 'admin.live.opened',
} as const
const GAME_SOCKET_TOPIC_VALUES = new Set<string>(
Object.values(GAME_SOCKET_TOPICS),
)
// 当前 H5 游戏页实际需要的用户侧事件。
// 后台专用事件保持在 GAME_SOCKET_TOPICS 中做口径对齐,但不在这里订阅。
const PLAYER_SOCKET_TOPICS = [
@@ -93,6 +103,22 @@ function getNestedRecord(
: null
}
function getMessageTopic(message: GameSocketMessage) {
const root = message as Record<string, unknown>
const event = typeof root.event === 'string' ? root.event : null
const topic = typeof root.topic === 'string' ? root.topic : null
if (event && GAME_SOCKET_TOPIC_VALUES.has(event)) {
return event
}
if (topic && GAME_SOCKET_TOPIC_VALUES.has(topic)) {
return topic
}
return event ?? topic
}
function extractServerTime(message: GameSocketMessage) {
const root = message as Record<string, unknown>
@@ -105,31 +131,22 @@ function extractServerTime(message: GameSocketMessage) {
return typeof data?.server_time === 'number' ? data.server_time : null
}
function extractUserSnapshot(
function extractUserStreakMessageData(
message: GameSocketMessage,
): GameLobbyUserSnapshotDto | null {
): UserStreakMessageData | null {
const direct = getNestedRecord(message, 'user_snapshot')
const nested = getNestedRecord(
getNestedRecord(message, 'data'),
'user_snapshot',
)
const source = direct ?? nested
const data = getNestedRecord(message, 'data')
const nested = getNestedRecord(data, 'user_snapshot')
const source = direct ?? nested ?? data
if (
!source ||
typeof source.coin !== 'string' ||
typeof source.current_streak !== 'number'
) {
if (!source || 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),
currentStreak: source.current_streak,
oddsFactor: toOptionalNumber(source.odds_factor),
streakLevel: toOptionalNumber(source.streak_level),
}
}
@@ -169,6 +186,25 @@ function extractPeriodTick(
}
}
function extractWalletCoin(message: GameSocketMessage) {
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
const coin = source.coin ?? source.balance ?? source.balance_after
if (typeof coin === 'string') {
return coin
}
return typeof coin === 'number' && Number.isFinite(coin) ? String(coin) : null
}
function extractJackpotStatus(message: GameSocketMessage) {
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
return typeof source.is_jackpot === 'boolean' ? source.is_jackpot : true
}
function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
const currentRoundState = useGameRoundStore.getState()
const currentSessionState = useGameSessionStore.getState()
@@ -211,52 +247,122 @@ function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
}
}
function applyRealtimeMessage(message: GameSocketMessage) {
const serverTime = extractServerTime(message)
function applyPeriodMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
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 (!period) {
return
}
if (userSnapshot) {
const currentUser = useAuthStore.getState().currentUser
const previousRound = useGameRoundStore.getState().round
const round = normalizePeriodTickRound(
{
...period,
server_time: serverTime ?? period.server_time,
},
previousRound,
)
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,
})
}
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),
})
}
function applyPeriodPhase(phase: 'locked' | 'revealing' | 'settled') {
useGameRoundStore.getState().setPhase(phase)
}
function applyUserStreakMessage(message: GameSocketMessage) {
const streakData = extractUserStreakMessageData(message)
const currentUser = useAuthStore.getState().currentUser
if (!streakData || !currentUser) {
return
}
useAuthStore.getState().setCurrentUser({
...currentUser,
currentStreak: streakData.currentStreak,
oddsFactor: streakData.oddsFactor,
streakLevel: streakData.streakLevel,
})
}
function applyWalletChangedMessage(message: GameSocketMessage) {
const coin = extractWalletCoin(message)
const currentUser = useAuthStore.getState().currentUser
if (coin === null || !currentUser) {
return
}
useAuthStore.getState().setCurrentUser({
...currentUser,
coin,
})
}
function applyJackpotHitMessage(message: GameSocketMessage) {
const currentUser = useAuthStore.getState().currentUser
if (!currentUser) {
return
}
useAuthStore.getState().setCurrentUser({
...currentUser,
isJackpot: extractJackpotStatus(message),
})
}
function applyRealtimeMessage(message: GameSocketMessage) {
const serverTime = extractServerTime(message)
const topic = getMessageTopic(message)
switch (topic) {
case GAME_SOCKET_TOPICS.periodTick:
applyPeriodMessage(message, serverTime)
break
case GAME_SOCKET_TOPICS.periodLocked:
applyPeriodMessage(message, serverTime)
applyPeriodPhase('locked')
break
case GAME_SOCKET_TOPICS.periodOpened:
applyPeriodMessage(message, serverTime)
applyPeriodPhase('revealing')
break
case GAME_SOCKET_TOPICS.periodPayout:
applyPeriodMessage(message, serverTime)
applyPeriodPhase('settled')
break
case GAME_SOCKET_TOPICS.userStreak:
applyUserStreakMessage(message)
break
case GAME_SOCKET_TOPICS.walletChanged:
applyWalletChangedMessage(message)
break
case GAME_SOCKET_TOPICS.jackpotHit:
applyJackpotHitMessage(message)
break
case GAME_SOCKET_TOPICS.betAccepted:
case GAME_SOCKET_TOPICS.autoSpinProgress:
break
}
useGameSessionStore.getState().syncConnection({

View File

@@ -0,0 +1,244 @@
import { useEffect, useMemo, useState } from 'react'
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
import {
isDesktopFullscreen,
subscribeDesktopFullscreenChange,
toggleDesktopFullscreen,
} from '@/lib/utils'
import {
useAudioStore,
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
}
export function useHeaderVm() {
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 isSoundEnabled = useAudioStore((state) => state.isSoundEnabled)
const toggleSoundEnabled = useAudioStore((state) => state.toggleSoundEnabled)
const connection = useGameSessionStore((state) => state.connection)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const { currentLanguageLabel, currentLanguageOption } = useAppLanguage()
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,
)
}
}, [])
return {
authStatus,
currentLanguageLabel,
currentLanguageOption,
currentUser,
handleFullscreenToggle: () => toggleDesktopFullscreen(),
isFullscreen,
isSoundEnabled,
onOpenLanguage: () => setModalOpen('desktopLanguage', true),
onOpenLogin: () => setModalOpen('desktopLogin', true),
onOpenNotice: () => setModalOpen('desktopNotice', true),
onOpenProcedures: () => setModalOpen('desktopProcedures', true),
onOpenRegister: () => setModalOpen('desktopRegister', true),
onOpenRules: () => setModalOpen('desktopRules', true),
onOpenUserInfo: () => setModalOpen('desktopUserInfo', true),
signalPresentation,
systemTimeLabel,
toggleSoundEnabled,
}
}

View File

@@ -1,39 +0,0 @@
import { useEffect } from 'react'
import { useAppPreferenceStore, useModalStore } from '@/store'
export function useProtocolAgreement() {
const isHydrated = useAppPreferenceStore((state) => state.isHydrated)
const hasAcceptedProtocol = useAppPreferenceStore(
(state) => state.hasAcceptedProtocol,
)
const setProtocolAccepted = useAppPreferenceStore(
(state) => state.setProtocolAccepted,
)
const open = useModalStore((state) => state.modals.desktopProtocol)
const setModalOpen = useModalStore((state) => state.setModalOpen)
useEffect(() => {
if (!isHydrated) {
return
}
if (!hasAcceptedProtocol) {
setModalOpen('desktopProtocol', true)
return
}
setModalOpen('desktopProtocol', false)
}, [hasAcceptedProtocol, isHydrated, setModalOpen])
const acceptProtocol = () => {
setProtocolAccepted(true)
setModalOpen('desktopProtocol', false)
}
return {
acceptProtocol,
hasAcceptedProtocol,
isHydrated,
open,
}
}

View File

@@ -0,0 +1,3 @@
export function useTopupVm() {
return {}
}

View File

@@ -0,0 +1,48 @@
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
createWithdraw,
type WithdrawCreateRequestDto,
} from '@/features/game/api'
import { notify } from '@/lib/notify'
export function useWithdrawSubmit() {
const { i18n, t } = useTranslation()
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
return useMutation({
mutationFn: (payload: WithdrawCreateRequestDto) => createWithdraw(payload),
onError: (error) => {
notify.error(
error instanceof Error
? error.message
: t('commonUi.toast.requestFailed'),
)
},
onSuccess: (data) => {
const formatter = new Intl.NumberFormat(locale, {
maximumFractionDigits: 2,
})
notify.success(t('gameDesktop.withdraw.submitSuccess'), {
description: [
t('gameDesktop.withdraw.success.orderNo', {
orderNo: data.order_no,
}),
t('gameDesktop.withdraw.success.actualArrivalCoin', {
amount: formatter.format(data.actual_arrival_coin),
}),
t('gameDesktop.withdraw.success.feeCoin', {
amount: formatter.format(data.fee_coin),
}),
t('gameDesktop.withdraw.success.reviewRequired', {
value: data.risk_review_required
? t('commonUi.dialog.yes')
: t('commonUi.dialog.no'),
}),
].join('\n'),
})
},
})
}

View File

@@ -0,0 +1,339 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { DepositWithdrawConfig } from '@/features/game/api'
import { useDepositWithdrawConfig } from '@/features/game/hooks/use-deposit-withdraw-config'
import { useAuthStore } from '@/store'
const QUICK_FIAT_AMOUNTS = [3, 30, 50, 100, 200, 500] as const
const DEFAULT_WITHDRAW_CONFIG: DepositWithdrawConfig = {
currencies: [
{
code: 'MYR',
depositCoinsPerFiat: '100',
depositCoinsPerFiatValue: 100,
label: 'MYR',
withdrawCoinsPerFiat: '100',
withdrawCoinsPerFiatValue: 100,
},
],
payChannels: [],
platformCoinLabel: '钻石',
rates: [
{
currency: 'MYR',
diamondsPerFiatUnit: '100',
diamondsPerFiatUnitValue: 100,
},
],
withdraw: {
banks: [],
feeNote: 'RM10 - RM99.99 之间的交易将收取最低RM 1的提现手续费',
minBank: '10',
minEwallet: '10',
processingNote: '30s即可到账',
rateHint: '汇率为参考价格,实际以提现时为准。',
rateMode: 'fixed' as const,
},
}
function formatNumber(locale: string, value: number) {
return new Intl.NumberFormat(locale).format(value)
}
function getInitialWithdrawAmount(
selectedRate: number,
maxWithdrawAmount: number,
) {
if (maxWithdrawAmount <= 0) {
return 0
}
return Math.min(
maxWithdrawAmount,
Math.max(1, Math.round(selectedRate * QUICK_FIAT_AMOUNTS[0])),
)
}
function getActiveCurrencyCode(
currencies: DepositWithdrawConfig['currencies'],
selectedCurrencyCode: string,
) {
return (
currencies.find((item) => item.code === selectedCurrencyCode) ??
currencies[0] ??
DEFAULT_WITHDRAW_CONFIG.currencies[0]
)
}
function getNormalizedConfig(
config: DepositWithdrawConfig | undefined,
fallback: DepositWithdrawConfig,
) {
return config ?? fallback
}
function isValidEmail(value: string) {
if (value.trim().length === 0) {
return false
}
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
}
function isValidPhone(value: string) {
const normalized = value.replace(/[^\d+]/g, '')
if (normalized.length === 0) {
return false
}
return /^\+?\d{6,20}$/.test(normalized)
}
export function useWithdrawVm() {
const { i18n, t } = useTranslation()
const currentUser = useAuthStore((state) => state.currentUser)
const withdrawConfigQuery = useDepositWithdrawConfig()
const config = useMemo(() => {
const baseConfig = getNormalizedConfig(
withdrawConfigQuery.data,
DEFAULT_WITHDRAW_CONFIG,
)
return {
...baseConfig,
currencies:
baseConfig.currencies.length > 0
? baseConfig.currencies
: DEFAULT_WITHDRAW_CONFIG.currencies,
payChannels: baseConfig.payChannels,
withdraw: {
...baseConfig.withdraw,
banks: baseConfig.withdraw.banks,
},
}
}, [withdrawConfigQuery.data])
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
const [amount, setAmountState] = useState(0)
const [hasInitializedAmount, setHasInitializedAmount] = useState(false)
const [currencyCode, setCurrencyCode] = useState(
config.currencies[0]?.code ?? 'MYR',
)
const [paymentChannelCode, setPaymentChannelCode] = useState('')
const [bankCode, setBankCode] = useState('')
const [holderName, setHolderName] = useState('')
const [bankAccount, setBankAccount] = useState('')
const [receiverEmail, setReceiverEmail] = useState('')
const [receiverPhone, setReceiverPhone] = useState('')
const selectedCurrency = getActiveCurrencyCode(
config.currencies,
currencyCode,
)
const selectedRate = selectedCurrency.withdrawCoinsPerFiatValue || 1
const sortedPayChannels = useMemo(
() =>
[...config.payChannels]
.filter((channel) => channel.status === 1)
.sort((left, right) => left.sort - right.sort),
[config.payChannels],
)
const sortedBanks = useMemo(
() =>
[...config.withdraw.banks]
.filter((bank) => bank.status === 1)
.sort((left, right) => left.sort - right.sort),
[config.withdraw.banks],
)
const availableBalance = Number(currentUser?.coin ?? 0)
const maxWithdrawAmount = Math.max(0, Math.floor(availableBalance))
const selectedPaymentChannel =
sortedPayChannels.find((channel) => channel.code === paymentChannelCode) ??
null
const setAmount = useCallback(
(nextAmount: number) => {
setAmountState(
Math.min(maxWithdrawAmount, Math.max(0, Math.floor(nextAmount))),
)
},
[maxWithdrawAmount],
)
useEffect(() => {
if (
selectedCurrency &&
selectedCurrency.code !== currencyCode &&
config.currencies.some((item) => item.code === currencyCode)
) {
return
}
if (selectedCurrency && selectedCurrency.code !== currencyCode) {
setCurrencyCode(selectedCurrency.code)
}
}, [config.currencies, currencyCode, selectedCurrency])
useEffect(() => {
const firstAvailablePayChannel = sortedPayChannels[0]
if (!firstAvailablePayChannel) {
if (paymentChannelCode) {
setPaymentChannelCode('')
}
return
}
const hasSelectedAvailablePayChannel = sortedPayChannels.some(
(channel) => channel.code === paymentChannelCode,
)
if (!hasSelectedAvailablePayChannel) {
setPaymentChannelCode(firstAvailablePayChannel.code)
}
}, [paymentChannelCode, sortedPayChannels])
useEffect(() => {
if (sortedBanks.length === 0) {
if (bankCode) {
setBankCode('')
}
return
}
const hasSelectedAvailableBank = sortedBanks.some(
(bank) => bank.code === bankCode,
)
if (!hasSelectedAvailableBank) {
setBankCode('')
}
}, [bankCode, sortedBanks])
useEffect(() => {
if (!hasInitializedAmount && selectedRate > 0) {
setAmount(getInitialWithdrawAmount(selectedRate, maxWithdrawAmount))
setHasInitializedAmount(true)
}
}, [hasInitializedAmount, maxWithdrawAmount, selectedRate, setAmount])
useEffect(() => {
if (amount > maxWithdrawAmount) {
setAmount(maxWithdrawAmount)
}
}, [amount, maxWithdrawAmount, setAmount])
const quickAmounts = useMemo(() => {
return QUICK_FIAT_AMOUNTS.map((fiatAmount) => ({
diamonds: Math.min(
maxWithdrawAmount,
Math.max(1, Math.round(selectedRate * fiatAmount)),
),
id: `quick-${selectedCurrency.code}-${fiatAmount}`,
preview: `${selectedCurrency.code} ${formatNumber(locale, fiatAmount)}`,
}))
}, [locale, maxWithdrawAmount, selectedCurrency.code, selectedRate])
const resetForm = useCallback(() => {
const nextCurrencyCode = config.currencies[0]?.code ?? 'MYR'
const nextCurrency = getActiveCurrencyCode(
config.currencies,
nextCurrencyCode,
)
const nextRate = nextCurrency.withdrawCoinsPerFiatValue || 1
setAmountState(getInitialWithdrawAmount(nextRate, maxWithdrawAmount))
setHasInitializedAmount(true)
setCurrencyCode(nextCurrencyCode)
setPaymentChannelCode(sortedPayChannels[0]?.code ?? '')
setBankCode('')
setHolderName('')
setBankAccount('')
setReceiverEmail('')
setReceiverPhone('')
}, [config.currencies, maxWithdrawAmount, sortedPayChannels])
const selectedCurrencyPreview = useMemo(
() => ({
currencyCode: selectedCurrency.code,
currencyLabel: selectedCurrency.label,
exchangeRateLabel: t('gameDesktop.withdraw.preview.exchangeRate', {
currency: selectedCurrency.code,
}),
exchangeRateValue: t('gameDesktop.withdraw.preview.exchangeRateValue', {
coins: formatNumber(locale, selectedRate),
currency: selectedCurrency.code,
platformCoinLabel: config.platformCoinLabel,
}),
convertibleLabel: t('gameDesktop.withdraw.preview.convertible', {
currency: selectedCurrency.code,
}),
convertibleValue: `${formatNumber(
locale,
selectedRate > 0 ? amount / selectedRate : 0,
)} ${selectedCurrency.code}`,
}),
[
amount,
config.platformCoinLabel,
locale,
selectedCurrency.code,
selectedCurrency.label,
selectedRate,
t,
],
)
return {
amount,
amountExceedsBalance: amount > maxWithdrawAmount,
amountRequiredError: amount <= 0,
availableBalance,
bankAccount,
bankAccountError: bankAccount.trim().length === 0,
bankCode,
bankCodeError: bankCode.trim().length === 0,
config,
currencyCode,
holderName,
holderNameError: holderName.trim().length === 0,
isLoading: withdrawConfigQuery.isLoading,
isRefetching: withdrawConfigQuery.isFetching,
maxWithdrawAmount,
paymentChannelCode,
paymentChannelCodeError: paymentChannelCode.trim().length === 0,
quickAmounts,
receiverEmail,
receiverEmailError: !isValidEmail(receiverEmail),
receiverPhone,
receiverPhoneError: !isValidPhone(receiverPhone),
selectedCurrency,
selectedCurrencyPreview,
selectedPaymentChannel,
selectedRate,
resetForm,
setAmount,
setBankAccount,
setBankCode,
setCurrencyCode,
setHolderName,
setPaymentChannelCode,
setReceiverEmail,
setReceiverPhone,
sortedBanks,
sortedPayChannels,
withdrawCopy: {
bankLabel: t('gameDesktop.withdraw.bank'),
eWalletLabel: t('gameDesktop.withdraw.eWallet'),
feeNote: config.withdraw.feeNote,
noticeLabel: t('gameDesktop.withdraw.notice'),
processingLabel: t('gameDesktop.withdraw.processingTime'),
processingValue: config.withdraw.processingNote,
rateHint: config.withdraw.rateHint,
},
}
}