feat: 联调充值和提现接口
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
671
src/features/game/components/desktop/desktop-withdraw-copy.tsx
Normal file
671
src/features/game/components/desktop/desktop-withdraw-copy.tsx
Normal 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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user