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

@@ -1,17 +1,11 @@
import { TriangleAlert } from 'lucide-react'
import { motion } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { useMemo } 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 { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
import { cn } from '@/lib/utils'
import { useAudioStore, useAuthStore, useModalStore } from '@/store'
import {
selectSelectionTotal,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true,
@@ -30,42 +24,6 @@ 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 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
}
type CellWarningType = 'balance' | 'limit'
interface DesktopAnimalProps {
activeId?: number | null
className?: string
@@ -82,155 +40,17 @@ export function DesktopAnimal({
onSelect,
}: DesktopAnimalProps) {
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(null),
)
const [cellWarning, setCellWarning] = useState<{
cellId: number
type: CellWarningType
} | 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 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()
markSoundPlaybackUnlocked()
requestRealtimeConnection()
}
const handleSelect = (animalId: number) => {
if (showStandbyState) {
return
}
if (onSelect) {
onSelect(animalId)
return
}
if (isSelectedCell(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)
}
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(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])
const animalIds = useMemo(() => animalImageList.map((item) => item.id), [])
const {
cellWarning,
handleSelect,
handleStart,
isRealtimeConnecting,
lockInteraction,
marqueeId,
selectionByCell,
showStandbyState,
} = useAnimalVm(animalIds, onSelect)
return (
<section

View File

@@ -77,7 +77,7 @@ export function DesktopCountdown({
return (
<div
className={cn(
'relative z-10 flex items-center justify-center font-countdown text-design-48 leading-none tracking-[0.08em] text-[#4BFFFE]',
'relative z-10 flex items-center justify-center font-countdown text-design-56 leading-none tracking-[0.08em] text-[#4BFFFE]',
className,
)}
>

View File

@@ -72,63 +72,83 @@ export function DesktopGameHistory() {
</div>
) : (
<>
{items.map((item) => (
<div key={item.id} className="w-full pb-design-12 last:pb-0">
<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 font-bold tracking-[0.08em]"
style={{
color: item.isWin ? '#FFE375' : '#8DFF98',
textShadow: item.isWin
? '0 0 calc(var(--design-unit)*10) #FFE375, 0 0 calc(var(--design-unit)*22) rgba(255,227,117,0.48)'
: '0 0 calc(var(--design-unit)*10) #8DFF98, 0 0 calc(var(--design-unit)*22) rgba(141,255,152,0.48)',
}}
>
{item.isWin
? t('gameDesktop.history.win')
: t('gameDesktop.history.lost')}
</div>
{items.map((item) => {
const isWin = item.resultState === 'win'
const statusLabel =
item.resultState === 'pending'
? t('gameDesktop.history.pending')
: isWin
? t('gameDesktop.history.win')
: t('gameDesktop.history.lost')
const statusColor =
item.resultState === 'pending'
? '#D5FBFF'
: isWin
? '#FFE375'
: '#8DFF98'
const statusTextShadow =
item.resultState === 'pending'
? '0 0 calc(var(--design-unit)*10) rgba(213,251,255,0.85), 0 0 calc(var(--design-unit)*22) rgba(213,251,255,0.32)'
: isWin
? '0 0 calc(var(--design-unit)*10) #FFE375, 0 0 calc(var(--design-unit)*22) rgba(255,227,117,0.48)'
: '0 0 calc(var(--design-unit)*10) #8DFF98, 0 0 calc(var(--design-unit)*22) rgba(141,255,152,0.48)'
return (
<div key={item.id} className="w-full pb-design-12 last:pb-0">
<div
className={
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
}
>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.roundId')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>{item.periodNo}</span>
<div
className="common-neon-inset w-full !rounded-b-none text-center text-design-20 font-bold tracking-[0.08em]"
style={{
color: statusColor,
textShadow: statusTextShadow,
}}
>
{statusLabel}
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.numbers')}:{' '}
</span>
<span>{item.numbersLabel}</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
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.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.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>
</div>
</div>
</div>
))}
)
})}
<div className="flex min-h-[calc(var(--design-unit)*40)] items-center justify-center text-design-16 text-[#84A2A2]">
{isFetchingNextPage ? loadingText : hasNextPage ? '' : endText}
</div>

View File

@@ -6,117 +6,12 @@ import {
Volume2,
VolumeX,
} 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 { SmartImage } from '@/components/smart-image.tsx'
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
}
import { useHeaderVm } from '@/features/game/hooks/use-header-vm'
function SignalBars({
activeBars,
@@ -152,130 +47,25 @@ function SignalBars({
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 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 handleOpenUserInfo = () => {
setModalOpen('desktopUserInfo', true)
}
const handleOpenProcedures = () => {
setModalOpen('desktopProcedures', true)
}
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()
}
const {
authStatus,
currentLanguageLabel,
currentLanguageOption,
currentUser,
handleFullscreenToggle,
isFullscreen,
isSoundEnabled,
onOpenLanguage,
onOpenLogin,
onOpenNotice,
onOpenProcedures,
onOpenRegister,
onOpenRules,
onOpenUserInfo,
signalPresentation,
systemTimeLabel,
toggleSoundEnabled,
} = useHeaderVm()
return (
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
@@ -310,17 +100,21 @@ export function DesktopHeader() {
<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">
<button
type="button"
onClick={() => setModalOpen('desktopRules', true)}
onClick={onOpenRules}
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.rules')}</div>
</button>
<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">
<button
type="button"
onClick={onOpenNotice}
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>{t('gameDesktop.header.message')}</div>
</div>
</button>
<button
type="button"
@@ -338,7 +132,7 @@ export function DesktopHeader() {
<div className={'flex items-center justify-center'}>
<button
type="button"
onClick={() => setModalOpen('desktopLanguage', true)}
onClick={onOpenLanguage}
className={
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-fit items-center justify-between gap-design-8 !px-design-20 transition-opacity hover:opacity-85'
}
@@ -376,7 +170,7 @@ export function DesktopHeader() {
>
<button
type="button"
onClick={handleOpenUserInfo}
onClick={onOpenUserInfo}
className="group relative flex items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
>
<SmartImage
@@ -396,7 +190,7 @@ export function DesktopHeader() {
<button
type="button"
onClick={handleOpenProcedures}
onClick={onOpenProcedures}
className="group relative flex items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
>
<SmartImage
@@ -425,7 +219,7 @@ export function DesktopHeader() {
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('desktopLogin', true)}
onClick={onOpenLogin}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.login')}</div>
@@ -435,7 +229,7 @@ export function DesktopHeader() {
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)}
onClick={onOpenRegister}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.register')}</div>

View File

@@ -26,8 +26,8 @@ export function DesktopStatusLine() {
const countdownClassName = useMemo(
() =>
showWarningCountdown
? 'text-[#FF5A5A] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,90,90,0.85),0_0_calc(var(--design-unit)*22)_rgba(255,90,90,0.32)]'
: 'text-[#4BFFFE] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(75,255,254,0.85),0_0_calc(var(--design-unit)*22)_rgba(75,255,254,0.32)]',
? 'text-design-64 scale-[1.2] text-[#FF5A5A] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,90,90,0.85),0_0_calc(var(--design-unit)*22)_rgba(255,90,90,0.32)]'
: 'text-design-64 scale-[1.2] text-[#4BFFFE] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(75,255,254,0.85),0_0_calc(var(--design-unit)*22)_rgba(75,255,254,0.32)]',
[showWarningCountdown],
)

View File

@@ -1,9 +1,184 @@
import { useMutation } from '@tanstack/react-query'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { createDeposit, type DepositTierItem } from '@/features/game/api'
import { useDepositTierList } from '@/features/game/hooks/use-deposit-tier-list'
import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils'
const PANEL_CLASS =
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.08),0_0_calc(var(--design-unit)*10)_rgba(32,163,186,0.12)]'
function formatNumber(value: number) {
return new Intl.NumberFormat('en-US').format(value)
}
function DesktopTopup() {
const { t } = useTranslation()
const tierListQuery = useDepositTierList()
const tiers = tierListQuery.data ?? []
const createDepositInFlightRef = useRef(false)
const pendingPayWindowRef = useRef<Window | null>(null)
const createDepositMutation = useMutation({
mutationFn: ({
channelCode,
tierId,
}: {
channelCode: string
tierId: string
}) =>
createDeposit({
channel_code: channelCode,
idempotency_key: String(Date.now()),
tier_id: tierId,
}),
})
return <div>{t('gameDesktop.topup.placeholder')}</div>
const handleCreateDeposit = async (tier: DepositTierItem) => {
if (createDepositInFlightRef.current || createDepositMutation.isPending) {
return
}
const channelCode = tier.payChannelCode ?? tier.channels[0]?.code ?? ''
if (!channelCode) {
notify.error(t('commonUi.toast.requestFailed'))
return
}
createDepositInFlightRef.current = true
const payWindow = window.open('', '_blank')
if (!payWindow) {
createDepositInFlightRef.current = false
notify.error(t('gameDesktop.topup.tier.openPayUrlFailed'))
return
}
payWindow.opener = null
pendingPayWindowRef.current = payWindow
try {
const result = await createDepositMutation.mutateAsync({
channelCode,
tierId: tier.id,
})
const payUrl = result.pay_url.trim()
if (!payUrl) {
payWindow.close()
notify.error(t('gameDesktop.topup.tier.missingPayUrl'))
return
}
payWindow.location.replace(payUrl)
notify.success(t('gameDesktop.topup.tier.createSuccess'))
} catch (error) {
payWindow.close()
notify.error(
error instanceof Error
? error.message
: t('commonUi.toast.requestFailed'),
)
} finally {
createDepositInFlightRef.current = false
if (pendingPayWindowRef.current === payWindow) {
pendingPayWindowRef.current = null
}
}
}
return (
<div className="flex h-full min-h-0 w-full px-design-12 pb-design-12 text-[#D9FFFF]">
<div
className={cn(
PANEL_CLASS,
'flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden px-design-16 py-design-14',
)}
>
<div className="mb-design-10 flex items-center border-b border-[rgba(89,209,223,0.2)] pb-design-10">
<div className="text-design-20 font-semibold text-[#9AF5FB]">
{t('gameDesktop.topup.tier.title')}
</div>
</div>
{tierListQuery.isLoading ? (
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.18)] bg-[rgba(6,24,35,0.52)] text-design-16 text-[#8FDDE6]">
{t('gameDesktop.topup.tier.loading')}
</div>
) : tierListQuery.isError ? (
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(185,63,68,0.28)] bg-[rgba(34,13,16,0.42)] text-design-16 text-[#F4A9AE]">
{t('gameDesktop.topup.tier.failed')}
</div>
) : tiers.length === 0 ? (
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.18)] bg-[rgba(6,24,35,0.52)] text-design-16 text-[#8FDDE6]">
{t('gameDesktop.topup.tier.empty')}
</div>
) : (
<div className="grid min-w-0 grid-cols-4 gap-design-8">
{tiers.map((tier) => (
<button
key={tier.id}
type="button"
onClick={() => {
void handleCreateDeposit(tier)
}}
className={cn(
'relative overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] px-design-10 py-design-10 text-left shadow-[0_0_calc(var(--design-unit)*10)_rgba(88,225,238,0.08)] transition-[transform,border-color,box-shadow] duration-150',
createDepositMutation.isPending
? 'cursor-wait opacity-80'
: 'cursor-pointer hover:-translate-y-[1px] hover:border-[rgba(170,247,255,0.62)] hover:shadow-[0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.14)]',
)}
>
<div className="absolute right-design-8 top-design-8 rounded-full border border-[rgba(121,219,229,0.28)] bg-[rgba(10,39,52,0.7)] px-design-6 py-[2px] text-design-10 leading-none text-[#7CDDE7]">
{tier.currency ?? 'FIAT'}
</div>
<div className="pr-design-46 text-design-11 uppercase tracking-[0.06em] text-[#63AEB6]">
{tier.title}
</div>
<div className="pt-design-5 text-design-20 font-semibold leading-none text-[#FFE229]">
{formatNumber(tier.payAmount)}
</div>
<div className="pt-design-3 text-design-11 text-[#9FDCE3]">
{t('gameDesktop.topup.tier.coins')}:{' '}
{formatNumber(tier.totalAmount)}
</div>
<div className="mt-design-8 rounded-[calc(var(--design-unit)*5)] border border-[rgba(89,209,223,0.18)] bg-[rgba(4,19,28,0.58)] px-design-8 py-design-6">
<div className="flex items-center justify-between gap-design-8 text-design-11">
<span className="text-[#7CE3E8]">
{t('gameDesktop.topup.tier.bonus')}
</span>
<span className="text-[#FFF1C9]">
{formatNumber(tier.bonusAmount)}
</span>
</div>
<div className="mt-design-4 flex items-center justify-between gap-design-8 text-design-11">
<span className="text-[#7CE3E8]">Channels</span>
<span className="line-clamp-1 text-right text-[#6DFF83]">
{tier.channels.length > 0
? tier.channels
.map((channel) => channel.name)
.join(', ')
: '--'}
</span>
</div>
</div>
{tier.desc ? (
<div className="mt-design-6 line-clamp-2 text-design-10 leading-[1.3] text-[#6DAAB0]">
{tier.desc}
</div>
) : null}
</button>
))}
</div>
)}
</div>
</div>
)
}
export default DesktopTopup

View File

@@ -0,0 +1,671 @@
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'
import { Input } from '@/components/ui/input.tsx'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select.tsx'
import { cn } from '@/lib/utils'
const AVAILABLE_BALANCE = 6628
const MYR_PER_100_DIAMONDS = 1
const USDT_TO_MYR_RATE = 4.049
const VND_PER_DIAMOND = 10
const QUICK_AMOUNTS = [
{ diamonds: 210, preview: 'MYR 3' },
{ diamonds: 2250, preview: 'MYR 30' },
{ diamonds: 4000, preview: 'MYR 50' },
{ diamonds: 8000, preview: 'MYR 100' },
{ diamonds: 17000, preview: 'MYR 200' },
{ diamonds: 45000, preview: 'MYR 500' },
] as const
const CURRENCY_OPTIONS = ['MYR'] as const
const PAYMENT_CHANNELS = [
{
id: 'alipay-primary',
label: 'Alipay',
glyph: '支',
},
{
id: 'alipay-secondary',
label: 'Alipay',
glyph: '支',
},
{
id: 'alipay-third',
label: 'Alipay',
glyph: '支',
},
] as const
const BANK_OPTIONS = [
{
id: 'bca',
label: 'BCA',
brand: 'BCA',
subtitle: 'Bank Central Asia',
surface:
'bg-[linear-gradient(180deg,rgba(251,252,255,0.98),rgba(224,239,255,0.96))] text-[#1E53A4]',
},
{
id: 'mandiri',
label: 'Mandiri',
brand: 'mandiri',
subtitle: 'Mandiri',
surface:
'bg-[linear-gradient(180deg,rgba(26,53,93,0.98),rgba(9,22,43,0.96))] text-[#F5C247]',
},
{
id: 'bni',
label: 'BNI',
brand: 'BNI',
subtitle: 'BNI',
surface:
'bg-[linear-gradient(180deg,rgba(254,253,252,0.98),rgba(239,242,247,0.96))] text-[#E1742B]',
},
{
id: 'bri',
label: 'BRI',
brand: 'BRI',
subtitle: 'BRI',
surface:
'bg-[linear-gradient(180deg,rgba(253,254,255,0.98),rgba(234,243,255,0.96))] text-[#0E56A5]',
},
] as const
type PaymentChannelId = (typeof PAYMENT_CHANNELS)[number]['id']
type BankId = (typeof BANK_OPTIONS)[number]['id']
const numberFormatter = new Intl.NumberFormat('en-US')
const fixedTwoFormatter = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
const fixedSixFormatter = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 6,
maximumFractionDigits: 6,
})
const PANEL_CLASS =
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.08),0_0_calc(var(--design-unit)*10)_rgba(32,163,186,0.12)]'
const SELECTABLE_CARD_CLASS =
'flex shrink-0 cursor-pointer flex-col items-center justify-between rounded-[calc(var(--design-unit)*6)] border px-design-8 py-design-8 transition'
const SELECTABLE_CARD_ACTIVE_CLASS =
'border-[#D18A43] bg-[linear-gradient(180deg,rgba(65,45,28,0.92),rgba(39,26,16,0.9))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
const SELECTABLE_CARD_IDLE_CLASS =
'border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(8,34,48,0.92),rgba(5,19,29,0.94))] hover:border-[rgba(170,247,255,0.7)]'
function formatNumber(value: number) {
return numberFormatter.format(value)
}
function formatFixedTwo(value: number) {
return fixedTwoFormatter.format(value)
}
function formatFixedSix(value: number) {
return fixedSixFormatter.format(value)
}
function WithdrawField({
label,
children,
alignStart = true,
}: {
label: string
children: ReactNode
alignStart?: boolean
}) {
return (
<div className="flex gap-design-14">
<div className="flex w-design-108 shrink-0 items-center justify-end text-right text-design-16 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
<span>{label}</span>
<span className="pl-design-4">:</span>
</div>
<div
className={cn(
'min-w-0 flex-1',
alignStart ? 'pt-design-2' : 'flex items-center',
)}
>
{children}
</div>
</div>
)
}
function AmountShell({
amount,
availableBalanceText,
onMinus,
onPlus,
}: {
amount: number
availableBalanceText: string
onMinus: () => void
onPlus: () => void
}) {
return (
<div className="flex flex-col gap-design-6">
<div className="flex h-design-52 items-center gap-design-10 rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(14,64,74,0.82),rgba(8,36,47,0.78))] px-design-10 shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(93,239,255,0.08)]">
<button
type="button"
onClick={onMinus}
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
>
<Minus className="h-design-16 w-design-16" />
</button>
<div className="flex min-w-0 flex-1 items-center justify-center text-design-24 font-medium tracking-[0.04em] text-[#A1EBF3]">
{formatNumber(amount)}
</div>
<button
type="button"
onClick={onPlus}
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
>
<Plus className="h-design-16 w-design-16" />
</button>
</div>
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
{availableBalanceText}
</div>
</div>
)
}
function QuickAmountCard({
amount,
preview,
active,
onClick,
}: {
amount: number
preview: string
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex h-design-68 w-design-104 shrink-0 cursor-pointer flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border transition',
active
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(84,48,24,0.92),rgba(60,34,18,0.88))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
: 'border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(10,44,58,0.84),rgba(5,21,32,0.92))] hover:border-[rgba(170,247,255,0.7)]',
)}
>
<div className="text-design-24 font-semibold leading-none text-[#FFE229]">
{amount}
</div>
<div className="pt-design-6 text-design-12 uppercase leading-none tracking-[0.04em] text-[#63AEB6]">
{preview}
</div>
</button>
)
}
function PaymentCard({
active,
label,
glyph,
onClick,
}: {
active: boolean
label: string
glyph: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
SELECTABLE_CARD_CLASS,
'h-design-92 w-design-86',
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
)}
>
<div
className={cn(
'flex h-design-58 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-42 font-semibold leading-none',
active
? 'bg-[linear-gradient(180deg,#1F9DE8,#0E6BCF)] text-white'
: 'bg-[linear-gradient(180deg,#1C96DF,#0B6ECF)] text-white',
)}
>
{glyph}
</div>
<div className="text-design-14 text-[#AEE8EE]">{label}</div>
</button>
)
}
function BankCard({
active,
brand,
subtitle,
surface,
onClick,
}: {
active: boolean
brand: string
subtitle: string
surface: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
SELECTABLE_CARD_CLASS,
'h-design-86 w-design-86',
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
)}
>
<div
className={cn(
'flex h-design-52 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-20 font-bold uppercase',
surface,
)}
>
{brand}
</div>
<div className="text-design-13 text-[#AEE8EE]">{subtitle}</div>
</button>
)
}
function InputShell({
value,
onChange,
placeholder,
error,
errorMessage,
uppercase = false,
}: {
value: string
onChange: (value: string) => void
placeholder: string
error?: boolean
errorMessage?: string
uppercase?: boolean
}) {
return (
<div className="flex flex-col gap-design-5">
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className={cn(
'h-design-42 rounded-[calc(var(--design-unit)*5)] border px-design-14 text-design-16',
uppercase && 'uppercase',
error
? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]'
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(10,47,57,0.84),rgba(5,23,32,0.92))] text-[#ACF1F6]',
)}
/>
{error && errorMessage ? (
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
{errorMessage}
</div>
) : null}
</div>
)
}
function PreviewRow({
label,
value,
highlight = false,
}: {
label: string
value: ReactNode
highlight?: boolean
}) {
return (
<div className="flex border-b border-[rgba(89,209,223,0.2)] last:border-b-0">
<div className="flex w-[44%] shrink-0 items-center border-r border-[rgba(89,209,223,0.2)] px-design-14 py-design-20 text-design-16 font-medium uppercase leading-[1.15] text-[#7CE3E8]">
{label}
</div>
<div
className={cn(
'flex min-w-0 flex-1 items-center justify-end px-design-14 py-design-20 text-right text-design-16 text-[#E6FFFF]',
highlight && 'text-design-18 font-semibold text-[#6DFF83]',
)}
>
{value}
</div>
</div>
)
}
function DesktopWithdraw() {
const { t } = useTranslation()
const [amount, setAmount] = useState(6626)
const [currency, setCurrency] =
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
const [paymentChannel, setPaymentChannel] =
useState<PaymentChannelId>('alipay-primary')
const [bank, setBank] = useState<BankId>('bca')
const [holderName, setHolderName] = useState('')
const [bankAccount, setBankAccount] = useState('')
const [receiverEmail, setReceiverEmail] = useState('')
const [receiverPhone, setReceiverPhone] = useState('')
const withdrawMyr = amount / 100
const withdrawVnd = amount * VND_PER_DIAMOND
const withdrawUsdt = withdrawMyr / USDT_TO_MYR_RATE
const holderNameError = holderName.trim().length === 0
const bankAccountError = bankAccount.trim().length === 0
function handleAmountChange(nextAmount: number) {
setAmount(Math.max(0, nextAmount))
}
return (
<div className="flex h-full min-h-0 w-full px-design-12 pb-design-12 text-[#D9FFFF]">
<div
className={cn(
PANEL_CLASS,
'flex h-full min-h-0 w-full min-w-0 overflow-y-auto',
)}
>
<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={t('提现钻石数量')}>
<AmountShell
amount={amount}
availableBalanceText={t(
'gameDesktop.withdraw.availableBalance',
{ amount: formatNumber(AVAILABLE_BALANCE) },
)}
onMinus={() => handleAmountChange(amount - 1)}
onPlus={() => handleAmountChange(amount + 1)}
/>
</WithdrawField>
<WithdrawField label={t('货币类型')} alignStart={false}>
<Select
value={currency}
onValueChange={(value) =>
setCurrency(value as (typeof CURRENCY_OPTIONS)[number])
}
>
<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={t('gameDesktop.withdraw.currencySelection')}
>
<SelectValue
placeholder={t('gameDesktop.withdraw.selectCurrency')}
/>
</SelectTrigger>
<SelectContent
position="popper"
className="min-w-(--radix-select-trigger-width) rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(8,36,48,0.98),rgba(4,18,28,0.98))] text-[#CFFDFF] shadow-[0_0_calc(var(--design-unit)*16)_rgba(56,241,255,0.12)]"
>
{CURRENCY_OPTIONS.map((option) => (
<SelectItem
key={option}
value={option}
className="rounded-[calc(var(--design-unit)*4)] px-design-12 py-design-10 text-design-18 focus:bg-[rgba(53,154,171,0.2)] focus:text-white"
>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</WithdrawField>
<div className="flex gap-design-14">
<div className="w-design-108 shrink-0" />
<div className="flex min-w-0 flex-1 flex-wrap gap-design-10">
{QUICK_AMOUNTS.map((option) => (
<QuickAmountCard
key={option.diamonds}
amount={option.diamonds}
preview={option.preview}
active={option.diamonds === amount}
onClick={() => handleAmountChange(option.diamonds)}
/>
))}
</div>
</div>
<WithdrawField label={t('支付渠道')}>
<div className="flex flex-wrap gap-design-10">
{PAYMENT_CHANNELS.map((channel) => (
<PaymentCard
key={channel.id}
active={channel.id === paymentChannel}
label={channel.label}
glyph={channel.glyph}
onClick={() => setPaymentChannel(channel.id)}
/>
))}
</div>
</WithdrawField>
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
<div className="flex flex-col gap-design-10">
<div className="flex flex-wrap gap-design-10">
{BANK_OPTIONS.map((option) => (
<BankCard
key={option.id}
active={option.id === bank}
brand={option.brand}
subtitle={option.label}
surface={option.surface}
onClick={() => setBank(option.id)}
/>
))}
</div>
</div>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.cardHolderName')}
>
<InputShell
value={holderName}
onChange={setHolderName}
placeholder={t(
'gameDesktop.withdraw.placeholders.cardHolderName',
)}
error={holderNameError}
errorMessage={t(
'gameDesktop.withdraw.errors.cardHolderNameRequired',
)}
/>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
>
<InputShell
value={bankAccount}
onChange={setBankAccount}
placeholder={t(
'gameDesktop.withdraw.placeholders.bankAccountNumber',
)}
error={bankAccountError}
errorMessage={t(
'gameDesktop.withdraw.errors.bankAccountRequired',
)}
/>
</WithdrawField>
<WithdrawField label={t('收款人邮箱')} alignStart={false}>
<InputShell
value={receiverEmail}
onChange={setReceiverEmail}
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverEmail',
)}
uppercase={true}
/>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverPhone')}
alignStart={false}
>
<InputShell
value={receiverPhone}
onChange={setReceiverPhone}
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverPhone',
)}
uppercase={true}
/>
</WithdrawField>
</div>
</div>
<div className="w-px shrink-0 bg-[linear-gradient(180deg,rgba(89,209,223,0)_0%,rgba(89,209,223,0.4)_12%,rgba(89,209,223,0.5)_88%,rgba(89,209,223,0)_100%)]" />
<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]">
{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={t('gameDesktop.withdraw.preview.diamondAmount')}
value={formatNumber(amount)}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.exchangeRate', {
currency: 'MYR',
})}
value={t('gameDesktop.withdraw.preview.exchangeRateValue', {
coins: 100 * MYR_PER_100_DIAMONDS,
currency: 'MYR',
platformCoinLabel: '钻石',
})}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertible', {
currency: 'MYR',
})}
value={`RM ${formatFixedTwo(withdrawMyr)}`}
highlight={true}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.exchangeRate', {
currency: 'USDT',
})}
value={t('gameDesktop.withdraw.preview.exchangeRateValue', {
coins: formatFixedTwo(100 * USDT_TO_MYR_RATE),
currency: 'USDT',
platformCoinLabel: '钻石',
})}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertible', {
currency: 'VND',
})}
value={`${formatNumber(withdrawVnd)} VND`}
highlight={true}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertible', {
currency: 'USDT',
})}
value={`${formatFixedSix(withdrawUsdt)} USDT`}
highlight={true}
/>
<PreviewRow
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]">
{t('gameDesktop.withdraw.referenceRateNotice')}
</div>
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
<div>
{t('gameDesktop.withdraw.eWallet')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumAmount', {
amount: '10',
currency: 'MYR',
})}
</span>
</div>
<div>
{t('gameDesktop.withdraw.bank')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumAmount', {
amount: '10',
currency: 'MYR',
})}
</span>
</div>
<div>
{t('gameDesktop.withdraw.processingTime')}:{' '}
<span className="text-[#77FF76]">
{t('gameDesktop.withdraw.arrivalTimeValue')}
</span>
</div>
<div>
{t('gameDesktop.withdraw.notice')}:{' '}
<span className="text-red-700">
{t('gameDesktop.withdraw.feeNotice')}
</span>
</div>
</div>
<div className="mt-auto flex items-end justify-between gap-design-10 pt-design-10">
<SmartBackground
as="button"
type="button"
src={lengthGreenBtn}
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]"
>
{t('gameDesktop.withdraw.cancel')}
</SmartBackground>
<SmartBackground
as="button"
type="button"
src={lengthBlueBtn}
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]"
>
{t('gameDesktop.withdraw.confirm')}
<br />
{t('gameDesktop.withdraw.withdrawal')}
</SmartBackground>
</div>
</div>
</div>
</div>
</div>
)
}
export default DesktopWithdraw

View File

@@ -12,112 +12,24 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select.tsx'
import { useWithdrawSubmit } from '@/features/game/hooks/use-withdraw-submit'
import { useWithdrawVm } from '@/features/game/hooks/use-withdraw-vm'
import { cn } from '@/lib/utils'
const AVAILABLE_BALANCE = 6628
const MYR_PER_100_DIAMONDS = 1
const USDT_TO_MYR_RATE = 4.049
const VND_PER_DIAMOND = 10
const QUICK_AMOUNTS = [
{ diamonds: 210, preview: 'MYR 3' },
{ diamonds: 2250, preview: 'MYR 30' },
{ diamonds: 4000, preview: 'MYR 50' },
{ diamonds: 8000, preview: 'MYR 100' },
{ diamonds: 17000, preview: 'MYR 200' },
{ diamonds: 45000, preview: 'MYR 500' },
] as const
const CURRENCY_OPTIONS = ['MYR'] as const
const PAYMENT_CHANNELS = [
{
id: 'alipay-primary',
label: 'Alipay',
glyph: '支',
},
{
id: 'alipay-secondary',
label: 'Alipay',
glyph: '支',
},
{
id: 'alipay-third',
label: 'Alipay',
glyph: '支',
},
] as const
const BANK_OPTIONS = [
{
id: 'bca',
label: 'BCA',
brand: 'BCA',
subtitle: 'Bank Central Asia',
surface:
'bg-[linear-gradient(180deg,rgba(251,252,255,0.98),rgba(224,239,255,0.96))] text-[#1E53A4]',
},
{
id: 'mandiri',
label: 'Mandiri',
brand: 'mandiri',
subtitle: 'Mandiri',
surface:
'bg-[linear-gradient(180deg,rgba(26,53,93,0.98),rgba(9,22,43,0.96))] text-[#F5C247]',
},
{
id: 'bni',
label: 'BNI',
brand: 'BNI',
subtitle: 'BNI',
surface:
'bg-[linear-gradient(180deg,rgba(254,253,252,0.98),rgba(239,242,247,0.96))] text-[#E1742B]',
},
{
id: 'bri',
label: 'BRI',
brand: 'BRI',
subtitle: 'BRI',
surface:
'bg-[linear-gradient(180deg,rgba(253,254,255,0.98),rgba(234,243,255,0.96))] text-[#0E56A5]',
},
] as const
type PaymentChannelId = (typeof PAYMENT_CHANNELS)[number]['id']
type BankId = (typeof BANK_OPTIONS)[number]['id']
const numberFormatter = new Intl.NumberFormat('en-US')
const fixedTwoFormatter = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
const fixedSixFormatter = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 6,
maximumFractionDigits: 6,
})
import { useModalStore } from '@/store'
const PANEL_CLASS =
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.08),0_0_calc(var(--design-unit)*10)_rgba(32,163,186,0.12)]'
const SELECTABLE_CARD_CLASS =
'flex shrink-0 cursor-pointer flex-col items-center justify-between rounded-[calc(var(--design-unit)*6)] border px-design-8 py-design-8 transition'
const SELECTABLE_CARD_ACTIVE_CLASS =
'border-[#D18A43] bg-[linear-gradient(180deg,rgba(65,45,28,0.92),rgba(39,26,16,0.9))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
const SELECTABLE_CARD_IDLE_CLASS =
'border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(8,34,48,0.92),rgba(5,19,29,0.94))] hover:border-[rgba(170,247,255,0.7)]'
function formatNumber(value: number) {
return numberFormatter.format(value)
return new Intl.NumberFormat('en-US').format(value)
}
function formatFixedTwo(value: number) {
return fixedTwoFormatter.format(value)
}
function getPaymentGlyph(code: string, name: string) {
if (code.toLowerCase().includes('alipay')) {
return '支'
}
function formatFixedSix(value: number) {
return fixedSixFormatter.format(value)
return name.trim().slice(0, 1).toUpperCase() || code.slice(0, 1).toUpperCase()
}
function WithdrawField({
@@ -131,7 +43,7 @@ function WithdrawField({
}) {
return (
<div className="flex gap-design-14">
<div className="flex w-design-108 shrink-0 items-center justify-end text-right text-design-16 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
<div className="flex w-design-132 shrink-0 items-center justify-end whitespace-nowrap text-right text-design-16 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
<span>{label}</span>
<span className="pl-design-4">:</span>
</div>
@@ -150,14 +62,22 @@ function WithdrawField({
function AmountShell({
amount,
availableBalanceText,
onAmountChange,
onMinus,
onPlus,
}: {
amount: number
availableBalanceText: string
onAmountChange: (value: number) => void
onMinus: () => void
onPlus: () => void
}) {
function handleInputChange(value: string) {
const nextValue = Number(value.replace(/[^\d]/g, ''))
onAmountChange(Number.isFinite(nextValue) ? nextValue : 0)
}
return (
<div className="flex flex-col gap-design-6">
<div className="flex h-design-52 items-center gap-design-10 rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(14,64,74,0.82),rgba(8,36,47,0.78))] px-design-10 shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(93,239,255,0.08)]">
@@ -169,9 +89,14 @@ function AmountShell({
<Minus className="h-design-16 w-design-16" />
</button>
<div className="flex min-w-0 flex-1 items-center justify-center text-design-24 font-medium tracking-[0.04em] text-[#A1EBF3]">
{formatNumber(amount)}
</div>
<input
value={amount === 0 ? '' : String(amount)}
onChange={(event) => handleInputChange(event.target.value)}
inputMode="numeric"
pattern="[0-9]*"
className="h-full min-w-0 flex-1 bg-transparent text-center text-design-24 font-medium tracking-[0.04em] text-[#A1EBF3] outline-none placeholder:text-[rgba(109,170,176,0.55)]"
placeholder="0"
/>
<button
type="button"
@@ -205,16 +130,29 @@ function QuickAmountCard({
type="button"
onClick={onClick}
className={cn(
'flex h-design-68 w-design-104 shrink-0 cursor-pointer flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border transition',
'group relative flex h-design-76 min-w-0 w-full cursor-pointer flex-col items-start justify-center overflow-hidden rounded-[calc(var(--design-unit)*8)] border px-design-12 text-left transition-[transform,border-color,background-color,box-shadow] duration-150',
active
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(84,48,24,0.92),rgba(60,34,18,0.88))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
: 'border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(10,44,58,0.84),rgba(5,21,32,0.92))] hover:border-[rgba(170,247,255,0.7)]',
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(88,54,28,0.96),rgba(56,33,18,0.92))] shadow-[0_0_calc(var(--design-unit)*14)_rgba(209,138,67,0.22),inset_0_0_calc(var(--design-unit)*12)_rgba(255,217,120,0.08)]'
: 'border-[rgba(103,227,239,0.26)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] hover:-translate-y-[1px] hover:border-[rgba(170,247,255,0.62)] hover:shadow-[0_0_calc(var(--design-unit)*12)_rgba(88,225,238,0.12)]',
)}
>
<span
className={cn(
'absolute right-design-10 top-design-10 h-design-8 w-design-8 rounded-full transition',
active
? 'bg-[#FFD15E] shadow-[0_0_10px_rgba(255,209,94,0.8)]'
: 'bg-[rgba(122,220,230,0.26)]',
)}
/>
<div className="text-design-24 font-semibold leading-none text-[#FFE229]">
{amount}
</div>
<div className="pt-design-6 text-design-12 uppercase leading-none tracking-[0.04em] text-[#63AEB6]">
<div
className={cn(
'pt-design-6 text-design-12 leading-none tracking-[0.04em]',
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
)}
>
{preview}
</div>
</button>
@@ -237,58 +175,48 @@ function PaymentCard({
type="button"
onClick={onClick}
className={cn(
SELECTABLE_CARD_CLASS,
'h-design-92 w-design-86',
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
'group relative flex h-design-76 min-w-design-120 cursor-pointer items-center gap-design-10 rounded-[calc(var(--design-unit)*8)] border px-design-12 text-left transition-[transform,border-color,background-color,box-shadow] duration-150',
active
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(88,54,28,0.96),rgba(56,33,18,0.92))] shadow-[0_0_calc(var(--design-unit)*14)_rgba(209,138,67,0.18),inset_0_0_calc(var(--design-unit)*12)_rgba(255,217,120,0.08)]'
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] hover:-translate-y-[1px] hover:border-[rgba(170,247,255,0.62)] hover:shadow-[0_0_calc(var(--design-unit)*12)_rgba(88,225,238,0.12)]',
)}
>
<span
className={cn(
'absolute right-design-10 top-design-10 h-design-8 w-design-8 rounded-full transition',
active
? 'bg-[#FFD15E] shadow-[0_0_10px_rgba(255,209,94,0.8)]'
: 'bg-[rgba(122,220,230,0.26)]',
)}
/>
<div
className={cn(
'flex h-design-58 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-42 font-semibold leading-none',
'flex h-design-42 w-design-42 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border text-design-22 font-semibold leading-none transition-colors',
active
? 'bg-[linear-gradient(180deg,#1F9DE8,#0E6BCF)] text-white'
: 'bg-[linear-gradient(180deg,#1C96DF,#0B6ECF)] text-white',
? 'border-[rgba(255,218,132,0.45)] bg-[rgba(255,211,113,0.16)] text-[#FFD97A]'
: 'border-[rgba(121,219,229,0.28)] bg-[rgba(10,39,52,0.7)] text-[#8DE4EA]',
)}
>
{glyph}
</div>
<div className="text-design-14 text-[#AEE8EE]">{label}</div>
</button>
)
}
function BankCard({
active,
brand,
subtitle,
surface,
onClick,
}: {
active: boolean
brand: string
subtitle: string
surface: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
SELECTABLE_CARD_CLASS,
'h-design-86 w-design-86',
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
)}
>
<div
className={cn(
'flex h-design-52 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-20 font-bold uppercase',
surface,
)}
>
{brand}
<div className="min-w-0 flex-1 pr-design-10">
<div
className={cn(
'truncate text-design-16 font-medium leading-none',
active ? 'text-[#FFF1C9]' : 'text-[#D7FBFF]',
)}
>
{label}
</div>
<div
className={cn(
'pt-design-6 text-design-11 uppercase leading-none tracking-[0.08em]',
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
)}
>
Channel
</div>
</div>
<div className="text-design-13 text-[#AEE8EE]">{subtitle}</div>
</button>
)
}
@@ -300,6 +228,7 @@ function InputShell({
error,
errorMessage,
uppercase = false,
type = 'text',
}: {
value: string
onChange: (value: string) => void
@@ -307,15 +236,17 @@ function InputShell({
error?: boolean
errorMessage?: string
uppercase?: boolean
type?: 'text' | 'email' | 'tel'
}) {
return (
<div className="flex flex-col gap-design-5">
<div className="flex w-full flex-col gap-design-5">
<Input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className={cn(
'h-design-42 rounded-[calc(var(--design-unit)*5)] border px-design-14 text-design-16',
'h-design-42 rounded-[calc(var(--design-unit)*5)] border text-design-16',
uppercase && 'uppercase',
error
? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]'
@@ -359,27 +290,73 @@ function PreviewRow({
function DesktopWithdraw() {
const { t } = useTranslation()
const [amount, setAmount] = useState(6626)
const [currency, setCurrency] =
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
const [paymentChannel, setPaymentChannel] =
useState<PaymentChannelId>('alipay-primary')
const [bank, setBank] = useState<BankId>('bca')
const [holderName, setHolderName] = useState('')
const [bankAccount, setBankAccount] = useState('')
const [receiverEmail, setReceiverEmail] = useState('')
const [receiverPhone, setReceiverPhone] = useState('')
const withdrawMyr = amount / 100
const withdrawVnd = amount * VND_PER_DIAMOND
const withdrawUsdt = withdrawMyr / USDT_TO_MYR_RATE
const selectedBank = BANK_OPTIONS.find((item) => item.id === bank)
const holderNameError = holderName.trim().length === 0
const bankAccountError = bankAccount.trim().length === 0
const vm = useWithdrawVm()
const withdrawSubmitMutation = useWithdrawSubmit()
const setModalOpen = useModalStore((state) => state.setModalOpen)
const [hasSubmitted, setHasSubmitted] = useState(false)
const [activeQuickAmountId, setActiveQuickAmountId] = useState<string | null>(
null,
)
function handleAmountChange(nextAmount: number) {
setAmount(Math.max(0, nextAmount))
vm.setAmount(Math.max(0, nextAmount))
setActiveQuickAmountId(null)
}
function handleQuickAmountSelect(optionId: string, amount: number) {
vm.setAmount(Math.max(0, amount))
setActiveQuickAmountId(optionId)
}
function resetWithdrawFormState() {
setHasSubmitted(false)
setActiveQuickAmountId(null)
vm.resetForm()
}
function handleCloseWithdraw() {
resetWithdrawFormState()
setModalOpen('desktopWithdrawTopup', false)
}
function handleConfirmWithdraw() {
if (withdrawSubmitMutation.isPending) {
return
}
setHasSubmitted(true)
if (
vm.amountRequiredError ||
vm.amountExceedsBalance ||
vm.holderNameError ||
vm.bankAccountError ||
vm.paymentChannelCodeError ||
vm.bankCodeError ||
vm.receiverEmailError ||
vm.receiverPhoneError
) {
return
}
withdrawSubmitMutation.mutate(
{
bank_code: vm.bankCode,
channel_code: vm.paymentChannelCode,
idempotency_key: String(Date.now()),
receive_account: vm.bankAccount.trim(),
receiver_email: vm.receiverEmail.trim(),
receiver_mobile: vm.receiverPhone.trim(),
receiver_name: vm.holderName.trim(),
receive_type: 'bank',
withdraw_coin: vm.amount,
},
{
onSuccess: () => {
handleCloseWithdraw()
},
},
)
}
return (
@@ -393,102 +370,145 @@ 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={t('gameDesktop.withdraw.fields.diamondWithdrawalAmount')}
label={t('gameDesktop.withdraw.fields.diamondAmount')}
>
<AmountShell
amount={amount}
amount={vm.amount}
availableBalanceText={t(
'gameDesktop.withdraw.availableBalance',
{ amount: formatNumber(AVAILABLE_BALANCE) },
{ amount: formatNumber(vm.availableBalance) },
)}
onMinus={() => handleAmountChange(amount - 1)}
onPlus={() => handleAmountChange(amount + 1)}
onAmountChange={handleAmountChange}
onMinus={() => handleAmountChange(vm.amount - 1)}
onPlus={() => handleAmountChange(vm.amount + 1)}
/>
{hasSubmitted && vm.amountRequiredError ? (
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
{t('gameDesktop.withdraw.errors.amountRequired')}
</div>
) : null}
{hasSubmitted && vm.amountExceedsBalance ? (
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
{t('gameDesktop.withdraw.errors.amountExceedsBalance')}
</div>
) : null}
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.currencyType')}
alignStart={false}
>
<Select
value={currency}
onValueChange={(value) =>
setCurrency(value as (typeof CURRENCY_OPTIONS)[number])
}
>
<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={t('gameDesktop.withdraw.currencySelection')}
>
<SelectValue
placeholder={t('gameDesktop.withdraw.selectCurrency')}
/>
</SelectTrigger>
<SelectContent
position="popper"
className="min-w-(--radix-select-trigger-width) rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(8,36,48,0.98),rgba(4,18,28,0.98))] text-[#CFFDFF] shadow-[0_0_calc(var(--design-unit)*16)_rgba(56,241,255,0.12)]"
>
{CURRENCY_OPTIONS.map((option) => (
<SelectItem
key={option}
value={option}
className="rounded-[calc(var(--design-unit)*4)] px-design-12 py-design-10 text-design-18 focus:bg-[rgba(53,154,171,0.2)] focus:text-white"
>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</WithdrawField>
<div className="flex gap-design-14">
<div className="w-design-108 shrink-0" />
<div className="flex min-w-0 flex-1 flex-wrap gap-design-10">
{QUICK_AMOUNTS.map((option) => (
<div className="flex items-start gap-design-14">
<div className="w-design-132 shrink-0" />
<div className="grid min-w-0 flex-1 grid-cols-3 gap-design-10">
{vm.quickAmounts.map((option) => (
<QuickAmountCard
key={option.diamonds}
key={option.id}
amount={option.diamonds}
preview={option.preview}
active={option.diamonds === amount}
onClick={() => handleAmountChange(option.diamonds)}
active={option.id === activeQuickAmountId}
onClick={() =>
handleQuickAmountSelect(option.id, option.diamonds)
}
/>
))}
</div>
</div>
<WithdrawField
label={t('gameDesktop.withdraw.fields.currencyType')}
alignStart={false}
>
<Select
value={vm.currencyCode}
onValueChange={vm.setCurrencyCode}
>
<SelectTrigger
className="h-design-42 w-full rounded-[calc(var(--design-unit)*5)] 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-14 text-left !text-design-16 text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-42 data-[placeholder]:text-[rgba(109,170,176,0.55)] [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
aria-label={t('gameDesktop.withdraw.currencySelection')}
>
<SelectValue
placeholder={t('gameDesktop.withdraw.selectCurrency')}
/>
</SelectTrigger>
<SelectContent>
{vm.config.currencies.map((option) => (
<SelectItem key={option.code} value={option.code}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.paymentChannel')}
>
<div className="flex flex-wrap gap-design-10">
{PAYMENT_CHANNELS.map((channel) => (
<PaymentCard
key={channel.id}
active={channel.id === paymentChannel}
label={channel.label}
glyph={channel.glyph}
onClick={() => setPaymentChannel(channel.id)}
/>
))}
<div className="flex w-full flex-col gap-design-5">
{vm.sortedPayChannels.length > 0 ? (
<div className="flex flex-wrap gap-design-10">
{vm.sortedPayChannels.map((channel) => (
<PaymentCard
key={channel.code}
active={channel.code === vm.paymentChannelCode}
label={channel.name}
glyph={getPaymentGlyph(channel.code, channel.name)}
onClick={() => vm.setPaymentChannelCode(channel.code)}
/>
))}
</div>
) : (
<div className="flex h-design-76 items-center rounded-[calc(var(--design-unit)*8)] border border-[rgba(185,63,68,0.45)] bg-[rgba(34,13,16,0.6)] px-design-12 text-design-14 text-[#F4B1B1]">
{t('gameDesktop.withdraw.errors.paymentChannelUnavailable')}
</div>
)}
{hasSubmitted && vm.paymentChannelCodeError ? (
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
{t('gameDesktop.withdraw.errors.paymentChannelRequired')}
</div>
) : null}
</div>
</WithdrawField>
<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`}
</div>
<div className="flex flex-wrap gap-design-10">
{BANK_OPTIONS.map((option) => (
<BankCard
key={option.id}
active={option.id === bank}
brand={option.brand}
subtitle={option.label}
surface={option.surface}
onClick={() => setBank(option.id)}
<WithdrawField
label={t('gameDesktop.withdraw.fields.bankCode')}
alignStart={false}
>
<div className="flex w-full flex-col gap-design-5">
<Select
value={vm.bankCode}
onValueChange={vm.setBankCode}
disabled={vm.sortedBanks.length === 0}
>
<SelectTrigger
className={cn(
'h-design-42 w-full rounded-[calc(var(--design-unit)*5)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-14 text-left !text-design-18 text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-42 data-[placeholder]:text-[rgba(109,170,176,0.55)] [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]',
hasSubmitted && vm.bankCodeError
? 'border-[#B93F44]'
: 'border-[rgba(103,227,239,0.3)]',
)}
aria-label={t('gameDesktop.withdraw.fields.bankCode')}
>
<SelectValue
placeholder={t(
'gameDesktop.withdraw.placeholders.bankCode',
)}
/>
))}
</div>
</SelectTrigger>
<SelectContent>
{vm.sortedBanks.map((bank) => (
<SelectItem key={bank.code} value={bank.code}>
{bank.label}
</SelectItem>
))}
</SelectContent>
</Select>
{vm.sortedBanks.length === 0 ? (
<div className="pl-design-2 text-design-13 text-[#F4B1B1]">
{t('gameDesktop.withdraw.errors.bankCodeUnavailable')}
</div>
) : null}
{hasSubmitted && vm.bankCodeError ? (
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
{t('gameDesktop.withdraw.errors.bankCodeRequired')}
</div>
) : null}
</div>
</WithdrawField>
@@ -496,12 +516,12 @@ function DesktopWithdraw() {
label={t('gameDesktop.withdraw.fields.cardHolderName')}
>
<InputShell
value={holderName}
onChange={setHolderName}
value={vm.holderName}
onChange={vm.setHolderName}
placeholder={t(
'gameDesktop.withdraw.placeholders.cardHolderName',
)}
error={holderNameError}
error={hasSubmitted && vm.holderNameError}
errorMessage={t(
'gameDesktop.withdraw.errors.cardHolderNameRequired',
)}
@@ -512,12 +532,12 @@ function DesktopWithdraw() {
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
>
<InputShell
value={bankAccount}
onChange={setBankAccount}
value={vm.bankAccount}
onChange={vm.setBankAccount}
placeholder={t(
'gameDesktop.withdraw.placeholders.bankAccountNumber',
)}
error={bankAccountError}
error={hasSubmitted && vm.bankAccountError}
errorMessage={t(
'gameDesktop.withdraw.errors.bankAccountRequired',
)}
@@ -526,29 +546,35 @@ function DesktopWithdraw() {
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverEmail')}
alignStart={false}
>
<InputShell
value={receiverEmail}
onChange={setReceiverEmail}
value={vm.receiverEmail}
onChange={vm.setReceiverEmail}
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverEmail',
)}
uppercase={true}
type="email"
error={hasSubmitted && vm.receiverEmailError}
errorMessage={t(
'gameDesktop.withdraw.errors.receiverEmailInvalid',
)}
/>
</WithdrawField>
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverPhone')}
alignStart={false}
>
<InputShell
value={receiverPhone}
onChange={setReceiverPhone}
value={vm.receiverPhone}
onChange={vm.setReceiverPhone}
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverPhone',
)}
uppercase={true}
type="tel"
error={hasSubmitted && vm.receiverPhoneError}
errorMessage={t(
'gameDesktop.withdraw.errors.receiverPhoneInvalid',
)}
/>
</WithdrawField>
</div>
@@ -565,39 +591,15 @@ function DesktopWithdraw() {
<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={t('gameDesktop.withdraw.preview.diamondAmount')}
value={formatNumber(amount)}
value={formatNumber(vm.amount)}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.rateMyr')}
value={t('gameDesktop.withdraw.preview.rateMyrValue', {
diamonds: 100 * MYR_PER_100_DIAMONDS,
})}
label={vm.selectedCurrencyPreview.exchangeRateLabel}
value={vm.selectedCurrencyPreview.exchangeRateValue}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertibleMyr')}
value={`RM ${formatFixedTwo(withdrawMyr)}`}
highlight={true}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.usdtMyrRate')}
value={t('gameDesktop.withdraw.preview.usdtMyrRateValue', {
rate: USDT_TO_MYR_RATE,
})}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.rateVnd')}
value={t('gameDesktop.withdraw.preview.rateVndValue', {
diamonds: VND_PER_DIAMOND,
})}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertibleVnd')}
value={`${formatNumber(withdrawVnd)} VND`}
highlight={true}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertibleUsdt')}
value={`${formatFixedSix(withdrawUsdt)} USDT`}
label={vm.selectedCurrencyPreview.convertibleLabel}
value={vm.selectedCurrencyPreview.convertibleValue}
highlight={true}
/>
<PreviewRow
@@ -609,30 +611,19 @@ function DesktopWithdraw() {
</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]">
{t('gameDesktop.withdraw.exchangeRateNotice')}
{vm.withdrawCopy.rateHint}
</div>
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
<div>
{t('gameDesktop.withdraw.wallet')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumRm10')}
</span>
</div>
<div>
{t('gameDesktop.withdraw.bank')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumRm10')}
</span>
</div>
<div>
{t('gameDesktop.withdraw.processingTime')}:{' '}
{vm.withdrawCopy.processingLabel}:{' '}
<span className="text-[#77FF76]">
{t('gameDesktop.withdraw.fundsArrivalTime')}
{vm.withdrawCopy.processingValue}
</span>
</div>
<div className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.feeNotice')}
<div>
{vm.withdrawCopy.noticeLabel}:{' '}
<span className="text-red-700">{vm.withdrawCopy.feeNote}</span>
</div>
</div>
@@ -642,7 +633,8 @@ function DesktopWithdraw() {
type="button"
src={lengthGreenBtn}
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]"
onClick={handleCloseWithdraw}
className="flex h-design-64 w-design-220 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]"
>
{t('gameDesktop.withdraw.cancel')}
</SmartBackground>
@@ -651,11 +643,18 @@ function DesktopWithdraw() {
type="button"
src={lengthBlueBtn}
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]"
onClick={handleConfirmWithdraw}
disabled={withdrawSubmitMutation.isPending}
className={cn(
'flex h-design-64 w-design-220 shrink-0 items-center justify-center whitespace-nowrap pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition',
withdrawSubmitMutation.isPending
? 'cursor-not-allowed opacity-70'
: 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]',
)}
>
{t('gameDesktop.withdraw.confirm')}
<br />
{t('gameDesktop.withdraw.withdrawal')}
{withdrawSubmitMutation.isPending
? t('commonUi.action.submitting')
: `${t('gameDesktop.withdraw.confirm')} ${t('gameDesktop.withdraw.withdrawal')}`}
</SmartBackground>
</div>
</div>