feat: 联调充值和提现接口

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

View File

@@ -0,0 +1,246 @@
import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type'
import type {
DepositCreateRequestDto,
DepositCreateResponseDto,
DepositTierItem,
DepositTierItemDto,
DepositWithdrawConfig,
DepositWithdrawConfigDto,
FinanceCurrencyConfigDto,
FinancePayChannelDto,
FinanceRateConfigDto,
FinanceWithdrawBankDto,
FinanceWithdrawConfigDto,
WithdrawCreateRequestDto,
WithdrawCreateResponseDto,
} from './finance-types'
export const FINANCE_API_ENDPOINTS = {
depositCreate: 'api/finance/depositCreate',
depositTierList: 'api/finance/depositTierList',
depositWithdrawConfig: 'api/finance/depositWithdrawConfig',
legacyCashierConfig: 'api/finance/cashierConfig',
withdrawCreate: 'api/finance/withdrawCreate',
} as const
function unwrapFinanceEnvelope<T>(
response: ApiResponse<T>,
fallbackMessage = 'Finance request failed',
) {
if (response.code === 1) {
return response.data
}
const responseMessage =
typeof response.message === 'string' && response.message.length > 0
? response.message
: typeof response.msg === 'string' && response.msg.length > 0
? response.msg
: fallbackMessage
throw new ApiError({
data: response,
message: responseMessage,
})
}
function toFiniteNumber(value: string | number | null | undefined) {
const numericValue = Number(value)
return Number.isFinite(numericValue) ? numericValue : 0
}
function normalizeCurrency(dto: FinanceCurrencyConfigDto) {
return {
code: dto.code,
depositCoinsPerFiat: dto.deposit_coins_per_fiat,
depositCoinsPerFiatValue: toFiniteNumber(dto.deposit_coins_per_fiat),
label: dto.label,
withdrawCoinsPerFiat: dto.withdraw_coins_per_fiat,
withdrawCoinsPerFiatValue: toFiniteNumber(dto.withdraw_coins_per_fiat),
}
}
function normalizeRate(dto: FinanceRateConfigDto) {
return {
currency: dto.currency,
diamondsPerFiatUnit: dto.diamonds_per_fiat_unit,
diamondsPerFiatUnitValue: toFiniteNumber(dto.diamonds_per_fiat_unit),
}
}
function normalizePayChannel(dto: FinancePayChannelDto) {
return {
code: dto.code,
name: dto.name,
sort: Number.isFinite(dto.sort) ? dto.sort : 0,
status: dto.status,
tierIds: Array.isArray(dto.tier_ids) ? dto.tier_ids : [],
}
}
function normalizeWithdrawBank(dto: FinanceWithdrawBankDto, index: number) {
const code = dto.code ?? dto.id ?? dto.name ?? `bank-${index + 1}`
const label = dto.label ?? dto.name ?? code
return {
code,
label,
sort:
typeof dto.sort === 'number' && Number.isFinite(dto.sort)
? dto.sort
: index,
status: typeof dto.status === 'number' ? dto.status : 1,
}
}
function normalizeWithdrawConfig(dto: FinanceWithdrawConfigDto) {
return {
banks: (dto.banks ?? []).map(normalizeWithdrawBank),
feeNote: dto.fee_note,
minBank: dto.min_bank,
minEwallet: dto.min_ewallet,
processingNote: dto.processing_note,
rateHint: dto.rate_hint,
rateMode: dto.rate_mode,
}
}
function normalizeDepositWithdrawConfig(
dto: DepositWithdrawConfigDto,
): DepositWithdrawConfig {
return {
currencies: (dto.currencies ?? []).map(normalizeCurrency),
payChannels: (dto.pay_channels ?? []).map(normalizePayChannel),
platformCoinLabel: dto.platform_coin_label,
rates: (dto.rates ?? []).map(normalizeRate),
withdraw: normalizeWithdrawConfig(dto.withdraw),
}
}
function normalizeDepositTierItem(
dto: DepositTierItemDto,
index: number,
): DepositTierItem {
const id = String(dto.id ?? dto.tier_id ?? `tier-${index + 1}`)
const amount = toFiniteNumber(dto.amount)
const bonusAmount = toFiniteNumber(dto.bonus_amount ?? dto.bonus_coins)
const payAmount = toFiniteNumber(dto.pay_amount ?? dto.amount)
const totalAmount = toFiniteNumber(
dto.total_amount ?? dto.coins ?? amount + bonusAmount,
)
const coins = totalAmount
const currency =
typeof dto.currency === 'string' && dto.currency.length > 0
? dto.currency
: null
const payChannelCode =
typeof dto.pay_channel_code === 'string' && dto.pay_channel_code.length > 0
? dto.pay_channel_code
: null
const sort = toFiniteNumber(dto.sort ?? dto.pay_amount ?? dto.amount)
const status = toFiniteNumber(dto.status ?? 1)
const channels = (dto.channels ?? [])
.map((channel, channelIndex) => ({
code: channel.code ?? `channel-${channelIndex + 1}`,
name: channel.name ?? channel.code ?? '--',
sort: toFiniteNumber(channel.sort ?? channelIndex),
}))
.sort((left, right) => left.sort - right.sort)
return {
amount,
bonusAmount,
channels,
coins,
currency,
desc: dto.desc ?? '',
id,
name:
typeof dto.name === 'string' && dto.name.length > 0
? dto.name
: `${amount}`,
payAmount,
payChannelCode,
sort,
status,
tierKey:
typeof dto.tier_key === 'string' && dto.tier_key.length > 0
? dto.tier_key
: null,
totalAmount,
title:
typeof dto.title === 'string' && dto.title.length > 0
? dto.title
: typeof dto.name === 'string' && dto.name.length > 0
? dto.name
: `${amount}`,
}
}
export async function getDepositWithdrawConfig() {
const response = await api.post<DepositWithdrawConfigDto>(
FINANCE_API_ENDPOINTS.depositWithdrawConfig,
)
const dto = unwrapFinanceEnvelope(
response as ApiResponse<DepositWithdrawConfigDto>,
'Failed to load deposit and withdrawal config',
)
return normalizeDepositWithdrawConfig(dto)
}
export async function getDepositTierList() {
const response = await api.post<
| DepositTierItemDto[]
| { items?: DepositTierItemDto[]; list?: DepositTierItemDto[] },
undefined
>(FINANCE_API_ENDPOINTS.depositTierList)
const dto = unwrapFinanceEnvelope(
response as ApiResponse<
| DepositTierItemDto[]
| { items?: DepositTierItemDto[]; list?: DepositTierItemDto[] }
>,
'Failed to load deposit tier list',
)
const tierItems = Array.isArray(dto) ? dto : (dto?.list ?? dto?.items ?? [])
return tierItems
.map(normalizeDepositTierItem)
.filter((item) => item.status === 1)
.sort((left, right) => left.sort - right.sort)
}
export async function createDeposit(payload: DepositCreateRequestDto) {
const response = await api.post<
DepositCreateResponseDto,
DepositCreateRequestDto
>(FINANCE_API_ENDPOINTS.depositCreate, {
json: payload,
})
const dto = unwrapFinanceEnvelope(
response as ApiResponse<DepositCreateResponseDto>,
'Failed to create deposit',
)
return dto
}
export async function createWithdraw(payload: WithdrawCreateRequestDto) {
const response = await api.post<
WithdrawCreateResponseDto,
WithdrawCreateRequestDto
>(FINANCE_API_ENDPOINTS.withdrawCreate, {
json: payload,
})
const dto = unwrapFinanceEnvelope(
response as ApiResponse<WithdrawCreateResponseDto>,
'Failed to create withdraw',
)
return dto
}

View File

@@ -0,0 +1,183 @@
export interface FinanceCurrencyConfigDto {
code: string
deposit_coins_per_fiat: string
label: string
withdraw_coins_per_fiat: string
}
export interface FinanceRateConfigDto {
currency: string
diamonds_per_fiat_unit: string
}
export interface FinancePayChannelDto {
code: string
name: string
sort: number
status: number
tier_ids: number[]
}
export interface FinanceWithdrawBankDto {
code?: string
id?: string
label?: string
name?: string
sort?: number
status?: number
}
export interface FinanceWithdrawConfigDto {
banks: FinanceWithdrawBankDto[]
fee_note: string
min_bank: string
min_ewallet: string
processing_note: string
rate_hint: string
rate_mode: 'fixed' | 'live' | (string & {})
}
export interface DepositWithdrawConfigDto {
currencies: FinanceCurrencyConfigDto[]
pay_channels: FinancePayChannelDto[]
platform_coin_label: string
rates: FinanceRateConfigDto[]
withdraw: FinanceWithdrawConfigDto
}
export interface DepositTierItemDto {
amount?: number | string
bonus_amount?: number | string
bonus_coins?: number | string
channels?: Array<{
code?: string
name?: string
sort?: number | string
}>
coins?: number | string
currency?: string
desc?: string
id?: number | string
name?: string
pay_amount?: number | string
pay_channel_code?: string
sort?: number | string
status?: number | string
tier_key?: string
tier_id?: number | string
total_amount?: number | string
title?: string
}
export interface FinanceCurrencyConfig {
code: string
depositCoinsPerFiat: string
depositCoinsPerFiatValue: number
label: string
withdrawCoinsPerFiat: string
withdrawCoinsPerFiatValue: number
}
export interface FinanceRateConfig {
currency: string
diamondsPerFiatUnit: string
diamondsPerFiatUnitValue: number
}
export interface FinancePayChannel {
code: string
name: string
sort: number
status: number
tierIds: number[]
}
export interface FinanceWithdrawBank {
code: string
label: string
sort: number
status: number
}
export interface FinanceWithdrawConfig {
banks: FinanceWithdrawBank[]
feeNote: string
minBank: string
minEwallet: string
processingNote: string
rateHint: string
rateMode: FinanceWithdrawConfigDto['rate_mode']
}
export interface DepositWithdrawConfig {
currencies: FinanceCurrencyConfig[]
payChannels: FinancePayChannel[]
platformCoinLabel: string
rates: FinanceRateConfig[]
withdraw: FinanceWithdrawConfig
}
export interface DepositTierItem {
amount: number
bonusAmount: number
channels: Array<{
code: string
name: string
sort: number
}>
coins: number
currency: string | null
desc: string
id: string
name: string
payAmount: number
payChannelCode: string | null
sort: number
status: number
tierKey: string | null
totalAmount: number
title: string
}
export interface DepositCreateRequestDto {
channel_code: string
idempotency_key: string
tier_id: string
}
export interface DepositCreateResponseDto {
amount: number
bonus_amount: number
create_time: number
expire_at: number
expire_seconds: number
order_no: string
paid: boolean
pay_channel: string
pay_time: number
pay_url: string
reject_reason: string | null
review_required: boolean
status: 'pending' | (string & {})
total_amount: number
}
export interface WithdrawCreateRequestDto {
bank_code: string
channel_code: string
idempotency_key: string
receive_account: string
receiver_email: string
receiver_mobile: string
receiver_name: string
receive_type: string
withdraw_coin: number
}
export interface WithdrawCreateResponseDto {
actual_arrival_coin: number
fee_coin: number
order_no: string
risk_review_required: boolean
status: 'pending_review' | (string & {})
}

View File

@@ -33,6 +33,7 @@ import type {
GameBootstrapDto,
GameCellDto,
GameLobbyInitDto,
GameLobbyPeriodDto,
GamePeriodTickDto,
GamePlaceBetDto,
GamePlaceBetRequestDto,
@@ -352,7 +353,12 @@ export function normalizeGameBootstrap(dto: GameBootstrapDto) {
connection: normalizeConnectionState(dto.connection),
dashboard: normalizeDashboardState(dto.dashboard),
history: dto.history.map(normalizeHistoryEntry),
maxSelectionCount: GAME_MAX_SELECTION_CELLS,
maxSelectionCount:
typeof dto.max_selection_count === 'number' &&
Number.isFinite(dto.max_selection_count) &&
dto.max_selection_count > 0
? Math.min(36, Math.floor(dto.max_selection_count))
: GAME_MAX_SELECTION_CELLS,
round: normalizeRoundSnapshot(dto.round),
selections: dto.selections.map(normalizeBetSelection),
trends: dto.trends.map(normalizeTrendEntry),
@@ -444,7 +450,7 @@ export async function getNoticeDetail(id: number) {
GAME_API_ENDPOINTS.noticeDetail,
{
searchParams: {
id: String(id),
notice_id: String(id),
},
},
)

View File

@@ -1,2 +1,4 @@
export * from './finance-api'
export * from './finance-types'
export * from './game-api'
export * from './types'

View File

@@ -107,6 +107,7 @@ export interface GameBootstrapDto {
connection: ConnectionStateDto
dashboard: DashboardStateDto
history: HistoryEntryDto[]
max_selection_count?: number
round: RoundSnapshotDto
selections: BetSelectionDto[]
trends: TrendEntryDto[]

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>

View File

@@ -8,7 +8,6 @@ import DesktopLanguageModal from '@/features/game/modal/desktop/desktop-language
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
import DesktopProtocolModal from '@/features/game/modal/desktop/desktop-protocol-modal.tsx'
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
@@ -52,8 +51,6 @@ export function PcEntry() {
<DesktopRegisterModal />
{/* 桌面端语言切换弹窗:用于选择当前站点展示语言 */}
<DesktopLanguageModal />
{/* 桌面端协议弹窗:首次进入站点时强制同意协议后才可继续 */}
<DesktopProtocolModal />
{/* 桌面端规则弹窗:展示当前游戏玩法、下注与结算规则 */}
<DesktopRulesModal />
{/* 桌面端用户信息弹窗:展示个人资料与站内消息 */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,244 @@
import { useEffect, useMemo, useState } from 'react'
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
import {
isDesktopFullscreen,
subscribeDesktopFullscreenChange,
toggleDesktopFullscreen,
} from '@/lib/utils'
import {
useAudioStore,
useAuthStore,
useGameSessionStore,
useModalStore,
} from '@/store'
type BrowserNetworkInformation = {
addEventListener?: (type: 'change', listener: () => void) => void
downlink?: number
effectiveType?: string
removeEventListener?: (type: 'change', listener: () => void) => void
rtt?: number
}
type SignalPresentation = {
activeBars: number
latencyLabel: string
toneClassName: string
}
function formatTimezoneOffset(date: Date) {
const offsetMinutes = -date.getTimezoneOffset()
const sign = offsetMinutes >= 0 ? '+' : '-'
const absoluteMinutes = Math.abs(offsetMinutes)
const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0')
const minutes = String(absoluteMinutes % 60).padStart(2, '0')
return `GMT${sign}${hours}${minutes === '00' ? '' : `:${minutes}`}`
}
function formatHeaderTime(date: Date) {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${hours}:${minutes}:${seconds} ${formatTimezoneOffset(date)}`
}
function getBrowserNetworkInformation() {
if (typeof navigator === 'undefined') {
return null
}
return (navigator as Navigator & { connection?: BrowserNetworkInformation })
.connection
}
function resolveSignalPresentation(input: {
isOnline: boolean
latencyMs: number | null
status: string
}) {
if (!input.isOnline || input.status === 'disconnected') {
return {
activeBars: 0,
latencyLabel: '--',
toneClassName: 'text-[#FF6B6B]',
} satisfies SignalPresentation
}
if (input.latencyMs === null) {
return {
activeBars: input.status === 'connected' ? 2 : 1,
latencyLabel: '--',
toneClassName: 'text-[#7F8EA3]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 80) {
return {
activeBars: 4,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#74FF69]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 150) {
return {
activeBars: 3,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#B7FF6A]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 300) {
return {
activeBars: 2,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#FFD76A]',
} satisfies SignalPresentation
}
return {
activeBars: 1,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#FF8A6A]',
} satisfies SignalPresentation
}
export function useHeaderVm() {
const [isFullscreen, setIsFullscreen] = useState(false)
const [clockNow, setClockNow] = useState(() => Date.now())
const [isOnline, setIsOnline] = useState(() =>
typeof navigator === 'undefined' ? true : navigator.onLine,
)
const [browserNetworkRttMs, setBrowserNetworkRttMs] = useState<number | null>(
() => {
const rtt = getBrowserNetworkInformation()?.rtt
return typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0
? rtt
: null
},
)
const currentUser = useAuthStore((state) => state.currentUser)
const authStatus = useAuthStore((state) => state.status)
const isSoundEnabled = useAudioStore((state) => state.isSoundEnabled)
const toggleSoundEnabled = useAudioStore((state) => state.toggleSoundEnabled)
const connection = useGameSessionStore((state) => state.connection)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const { currentLanguageLabel, currentLanguageOption } = useAppLanguage()
const serverClockOffsetMs = useMemo(() => {
if (
connection.status !== 'connected' ||
connection.transport !== 'websocket' ||
!connection.lastMessageAt
) {
return null
}
const serverTimestamp = Date.parse(connection.lastMessageAt)
if (Number.isNaN(serverTimestamp)) {
return null
}
return serverTimestamp - Date.now()
}, [connection.lastMessageAt, connection.status, connection.transport])
const systemTimeLabel = useMemo(() => {
const activeTimestamp =
serverClockOffsetMs === null ? clockNow : clockNow + serverClockOffsetMs
return formatHeaderTime(new Date(activeTimestamp))
}, [clockNow, serverClockOffsetMs])
const signalLatencyMs = useMemo(() => {
if (
typeof connection.latencyMs === 'number' &&
Number.isFinite(connection.latencyMs) &&
connection.latencyMs >= 0
) {
return connection.latencyMs
}
return browserNetworkRttMs
}, [browserNetworkRttMs, connection.latencyMs])
const signalPresentation = useMemo(
() =>
resolveSignalPresentation({
isOnline,
latencyMs: signalLatencyMs,
status: connection.status,
}),
[connection.status, isOnline, signalLatencyMs],
)
useEffect(() => {
const syncFullscreenState = () => {
setIsFullscreen(isDesktopFullscreen())
}
syncFullscreenState()
return subscribeDesktopFullscreenChange(syncFullscreenState)
}, [])
useEffect(() => {
const timer = window.setInterval(() => {
setClockNow(Date.now())
}, 1000)
return () => {
window.clearInterval(timer)
}
}, [])
useEffect(() => {
const syncBrowserNetworkState = () => {
setIsOnline(navigator.onLine)
const rtt = getBrowserNetworkInformation()?.rtt
setBrowserNetworkRttMs(
typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0 ? rtt : null,
)
}
const networkInformation = getBrowserNetworkInformation()
syncBrowserNetworkState()
window.addEventListener('online', syncBrowserNetworkState)
window.addEventListener('offline', syncBrowserNetworkState)
networkInformation?.addEventListener?.('change', syncBrowserNetworkState)
return () => {
window.removeEventListener('online', syncBrowserNetworkState)
window.removeEventListener('offline', syncBrowserNetworkState)
networkInformation?.removeEventListener?.(
'change',
syncBrowserNetworkState,
)
}
}, [])
return {
authStatus,
currentLanguageLabel,
currentLanguageOption,
currentUser,
handleFullscreenToggle: () => toggleDesktopFullscreen(),
isFullscreen,
isSoundEnabled,
onOpenLanguage: () => setModalOpen('desktopLanguage', true),
onOpenLogin: () => setModalOpen('desktopLogin', true),
onOpenNotice: () => setModalOpen('desktopNotice', true),
onOpenProcedures: () => setModalOpen('desktopProcedures', true),
onOpenRegister: () => setModalOpen('desktopRegister', true),
onOpenRules: () => setModalOpen('desktopRules', true),
onOpenUserInfo: () => setModalOpen('desktopUserInfo', true),
signalPresentation,
systemTimeLabel,
toggleSoundEnabled,
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,8 @@ function DesktopLanguageModal() {
{t('language.label')}
</div>
}
titleAlign="center"
isNormalBg={true}
titleAlign="left"
className="h-design-560 w-design-620"
>
<div className="flex h-full flex-col px-design-24 pb-design-28 pt-design-10">

View File

@@ -1,10 +1,12 @@
import { useTranslation } from 'react-i18next'
import diamond from '@/assets/system/diamond.webp'
import proceduresBg from '@/assets/system/procedures-bg.webp'
import topupBtnBg from '@/assets/system/topup.webp'
import withdrawBtnBg from '@/assets/system/withdraw.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { useModalStore } from '@/store'
import { SmartImage } from '@/components/smart-image.tsx'
import { useAuthStore, useModalStore } from '@/store'
function DesktopProceduresModal() {
const { t } = useTranslation()
@@ -13,6 +15,7 @@ function DesktopProceduresModal() {
const setWithdrawTopupType = useModalStore(
(state) => state.setWithdrawTopupType,
)
const currentUser = useAuthStore((state) => state.currentUser)
function handleSubmit() {
setModalOpen('desktopProcedures', false)
@@ -45,15 +48,26 @@ function DesktopProceduresModal() {
'h-[95%] w-full rounded-md flex flex-col items-center justify-between'
}
>
<div className={'mt-design-190'}>
{t('game.modals.procedures.contentPlaceholder')}
<div
className={
'mt-design-170 ml-design-120 flex items-center gap-design-50'
}
>
<SmartImage className={'w-design-80'} alt={'diamond'} src={diamond} />
<div
className={
'modal-title-gold-glow text-[#F7DC7A] text-design-32 font-bold tracking-[0.08em]'
}
>
{currentUser?.coin || 0}
</div>
</div>
<div className={'flex items-center ml-design-180'}>
<SmartBackground
src={withdrawBtnBg}
onClick={() => handleOpenWithdrawTopup('withdraw')}
className={
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-10 text-design-32 font-bold'
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-10 text-design-32 font-bold transition-[transform,filter] duration-150 hover:scale-[1.02] hover:brightness-110 active:translate-y-[calc(var(--design-unit)*2)] active:scale-[0.97] active:brightness-95'
}
>
{t('game.modals.procedures.withdraw')}
@@ -62,7 +76,7 @@ function DesktopProceduresModal() {
src={topupBtnBg}
onClick={() => handleOpenWithdrawTopup('topup')}
className={
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-20 text-design-32 font-bold'
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-20 text-design-32 font-bold transition-[transform,filter] duration-150 hover:scale-[1.02] hover:brightness-110 active:translate-y-[calc(var(--design-unit)*2)] active:scale-[0.97] active:brightness-95'
}
>
{t('game.modals.procedures.topup')}

View File

@@ -1,76 +0,0 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
import rightImg from '@/assets/system/right.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { useProtocolAgreement } from '@/features/game/hooks/use-protocol-agreement'
function DesktopProtocolModal() {
const { t } = useTranslation()
const { acceptProtocol, isHydrated, open } = useProtocolAgreement()
const [isChecked, setIsChecked] = useState(false)
if (!isHydrated) {
return null
}
return (
<CenterModal
open={open}
title={
<div className={'modal-title-glow text-design-28'}>
{t('game.modals.protocol.title')}
</div>
}
titleAlign="center"
isShowClose={false}
className={'w-design-980 h-design-680'}
>
<div className="flex h-full flex-col gap-design-24 px-design-28 pb-design-30 pt-design-10">
<div className="flex-1 rounded-[12px] bg-black/35 p-design-18 text-design-18 leading-[1.8] text-[#B9E7EA]">
<div className="h-full overflow-y-auto whitespace-pre-line">
{t('game.modals.protocol.content')}
</div>
</div>
<button
type="button"
onClick={() => setIsChecked((value) => !value)}
className="flex items-center justify-center gap-design-14 self-center text-design-20 text-white"
>
<span className="flex h-design-34 w-design-34 items-center justify-center rounded-[6px] border border-[#80DFE7] bg-slate-950/60">
{isChecked ? (
<SmartImage
src={rightImg}
alt=""
aria-hidden="true"
className="h-design-22 w-design-28 object-contain"
/>
) : null}
</span>
<span>{t('game.modals.protocol.agreeLabel')}</span>
</button>
<div className="flex justify-center">
<SmartBackground
as="button"
type="button"
src={lengthBlueBtn}
size="100% 90%"
repeat="no-repeat"
position="center"
onClick={isChecked ? acceptProtocol : undefined}
className="modal-title-glow flex h-design-72 w-design-270 items-center justify-center pb-design-5 text-design-20 font-bold disabled:pointer-events-none disabled:opacity-50"
disabled={!isChecked}
>
{t('game.modals.protocol.confirm')}
</SmartBackground>
</div>
</div>
</CenterModal>
)
}
export default DesktopProtocolModal

View File

@@ -16,13 +16,14 @@ function DesktopRulesModal() {
return (
<CenterModal
open={open}
isNormalBg={true}
onClose={handleClose}
title={
<div className={'modal-title-glow text-design-28'}>
<div className={'modal-title-glow text-design-28 '}>
{t('game.modals.rules.title')}
</div>
}
titleAlign="center"
titleAlign="left"
className={'w-design-1040 h-design-720'}
>
<div className="flex h-full flex-col gap-design-24 px-design-28 pb-design-30 pt-design-10">
@@ -39,7 +40,7 @@ function DesktopRulesModal() {
repeat="no-repeat"
position="center"
onClick={handleClose}
className="modal-title-glow flex h-design-72 w-design-270 items-center justify-center pb-design-5 text-design-20 font-bold"
className="modal-title-glow flex h-design-85 w-design-270 items-center justify-center pb-design-5 text-design-20 font-bold"
>
{t('game.modals.rules.confirm')}
</SmartBackground>

View File

@@ -1,17 +1,20 @@
import { CircleUserRound, Mail } from 'lucide-react'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { ArrowLeft, CircleUserRound, Mail } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp'
import blueBtnBg from '@/assets/system/blue-btn.webp'
import lengthBtnBg from '@/assets/system/length-blue-btn.webp'
import userInfoBg from '@/assets/system/userInfo-bg.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { getNoticeDetail, getNoticeList } from '@/features/game/api'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'
import { useAuthStore, useModalStore } from '@/store'
type UserInfoTabKey = 'profile' | 'message'
type MessageViewState = 'list' | 'detail'
const USER_INFO_TABS: Array<{
key: UserInfoTabKey
@@ -35,6 +38,51 @@ function DesktopUserInfoModal() {
const open = useModalStore((state) => state.modals.desktopUserInfo)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
const [messageView, setMessageView] = useState<MessageViewState>('list')
const [selectedNoticeId, setSelectedNoticeId] = useState<number | null>(null)
const currentUser = useAuthStore((state) => state.currentUser)
const noticeListQuery = useQuery({
queryKey: ['game', 'notice-list'],
queryFn: () => getNoticeList(),
enabled: open && activeTab === 'message' && messageView === 'list',
})
const noticeDetailQuery = useQuery({
queryKey: ['game', 'notice-detail', selectedNoticeId],
queryFn: () => getNoticeDetail(selectedNoticeId ?? 0),
enabled:
open &&
activeTab === 'message' &&
messageView === 'detail' &&
selectedNoticeId !== null,
})
const noticeItems = useMemo(
() => noticeListQuery.data?.list ?? [],
[noticeListQuery.data],
)
async function handleReturnToList() {
setMessageView('list')
setSelectedNoticeId(null)
await noticeListQuery.refetch()
}
useEffect(() => {
if (!open) {
setActiveTab('profile')
setMessageView('list')
setSelectedNoticeId(null)
}
}, [open])
useEffect(() => {
if (activeTab !== 'message') {
setMessageView('list')
setSelectedNoticeId(null)
}
}, [activeTab])
function handleSubmit() {
setModalOpen('desktopUserInfo', false)
@@ -115,95 +163,234 @@ function DesktopUserInfoModal() {
src={userInfoBg}
size="120% 100%"
className={
'flex flex-col h-full w-full items-start justify-between bg-top bg-no-repeat px-design-40 py-design-32 text-[#6CCDCF] text-design-24'
'flex flex-col h-full w-full items-start justify-between bg-top bg-no-repeat px-design-40 py-design-32 text-[#6CCDCF] text-design-24 gap-design-80'
}
>
<div className={'flex items-center gap-design-30'}>
<SmartImage
className={'h-design-100 w-design-100'}
src={avatar}
src={currentUser?.headImage || avatar}
alt={'avatar'}
/>
<div className={'flex flex-col gap-design-30'}>
<div className={'flex flex-col gap-design-30 text-[#6CCDCF]'}>
<div>
{t('game.modals.userInfo.profile.name')} Biomond Balance
{t('game.modals.userInfo.profile.name')}
{currentUser?.name ?? '--'}
</div>
<div>
{t('game.modals.userInfo.profile.tel')} 12345678901
{t('game.modals.userInfo.profile.tel')} {' '}
{currentUser?.phone ?? '--'}
</div>
</div>
</div>
<div className={'flex flex-col gap-design-20'}>
<div className={'flex flex-col gap-design-5'}>
{[1, 2, 3, 4].map((item) => (
<div key={item}>
{t('game.modals.userInfo.profile.registeredAt')}
<span className={'text-design-18 text-[#599AA3]'}>
2022-10-06 2336
</span>
</div>
))}
<div
className={
'w-design-600 flex-1 text-design-18 rounded-md bg-[#000000]/40 flex flex-col gap-design-20 p-design-20 '
}
>
<div className={'text-[#6CCDCF]'}>
{t('game.modals.userInfo.profile.registeredAt')}:
<span
className={'text-design-18 text-[#599AA3] ml-design-10'}
>
{dayjs(currentUser?.createTime).format(
'YYYY-MM-DD HH:mm:ss',
) || '--'}
</span>
</div>
<div
className={
'w-design-600 h-design-120 text-design-18 rounded-md bg-[#000000]/40 flex items-center justify-center'
}
>
{t('game.modals.userInfo.profile.signature')}
<div className={'text-[#6CCDCF]'}>
{t('auth.register.fields.inviteCode.label')}
<span
className={'text-design-18 text-[#599AA3] ml-design-10'}
>
{currentUser?.registerInviteCode || '--'}
</span>
</div>
</div>
</SmartBackground>
) : (
<div
className={
'relative flex h-full w-full items-start justify-start'
}
>
<div
className={
'h-full w-full flex flex-col gap-design-10 bg-red-600 p-design-10 overflow-auto'
}
>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((item) => (
<div
key={item}
className={
'flex items-center bg-[#0A4252] px-design-15 py-design-15 rounded-md gap-design-20'
}
>
<div className={'h-design-95 w-design-95 bg-black'}></div>
<div className={'flex-1'}>
<div>2026-10-10 08:32:56</div>
<div>{t('game.modals.userInfo.message.eventBonus')}</div>
</div>
<SmartBackground
src={blueBtnBg}
size="100% 100%"
className={
'w-design-150 h-design-64 flex items-center justify-center text-design-20 font-bold'
}
>
{t('game.modals.userInfo.message.check')}
</SmartBackground>
</div>
))}
</div>
<div
className={
'absolute bottom-0 left-0 w-full bg-[#266477]/70 rounded-md h-design-85 flex items-center justify-center'
}
>
<SmartBackground
src={lengthBtnBg}
size="100% 100%"
<div className={'flex h-full w-full flex-col'}>
{messageView === 'detail' ? (
<div
className={
'w-design-275 h-design-65 flex items-center justify-center text-design-22 font-bold'
'mb-design-12 flex items-center mx-design-10 my-design-10 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
}
>
{t('game.modals.userInfo.message.deleteRecords')}
</SmartBackground>
<button
type="button"
onClick={() => {
void handleReturnToList()
}}
className={
'flex items-center gap-design-10 text-[#86DAE7] transition hover:text-white'
}
>
<span
className={
'flex h-design-40 w-design-40 items-center justify-center rounded-full border border-[#4AC6DE]/45 bg-[#0B4454]'
}
>
<ArrowLeft className={'h-design-22 w-design-22'} />
</span>
<span
className={'text-design-20 font-medium tracking-wide'}
>
{t('game.modals.userInfo.message.back')}
</span>
</button>
</div>
) : null}
<div className={'h-full w-full overflow-auto rounded-md'}>
{messageView === 'list' ? (
<div
className={
'flex h-full w-full flex-col gap-design-10 p-design-10'
}
>
{noticeListQuery.isLoading ? (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.loading')}
</div>
) : noticeListQuery.isError ? (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.loadFailed')}
</div>
) : noticeItems.length === 0 ? (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.empty')}
</div>
) : (
noticeItems.map((item) => (
<button
key={item.notice_id}
type="button"
onClick={() => {
setSelectedNoticeId(item.notice_id)
setMessageView('detail')
}}
className={
'flex items-center gap-design-20 rounded-md bg-[#0A4252] px-design-15 py-design-15 text-left transition hover:bg-[#0E576D]'
}
>
<div
className={cn(
'relative flex h-design-95 w-design-95 items-center justify-center rounded-md text-design-18 font-bold',
item.notice_type === 'popout'
? 'bg-[#203C49] text-[#FEEEB0]'
: 'bg-[#111111] text-[#6CCDCF]',
)}
>
<span
className={cn(
'absolute -right-design-8 top-design-8 z-10 min-w-design-50 -rotate-[8deg] rounded-[calc(var(--design-unit)*4)] border px-design-6 py-design-4 text-center text-design-11 font-semibold leading-none shadow-[0_0_calc(var(--design-unit)*8)_rgba(0,0,0,0.2)]',
item.is_read
? 'border-[#2D7384] bg-[linear-gradient(180deg,#20596A,#153A47)] text-[#B4E9F0]'
: 'border-[#9B6427] bg-[linear-gradient(180deg,#8A5320,#5E3616)] text-[#FFF0A8]',
)}
>
{item.is_read
? t('game.modals.userInfo.message.read')
: t('game.modals.userInfo.message.unread')}
</span>
{item.notice_type.toUpperCase()}
</div>
<div className={'min-w-0 flex-1'}>
<div className={'text-design-18 text-[#BFEAEC]'}>
{dayjs(item.publish_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)}
</div>
<div
className={
'mt-design-4 flex items-center gap-design-12'
}
>
<div
className={'truncate text-design-20 text-white'}
>
{item.title}
</div>
</div>
</div>
<SmartBackground
src={blueBtnBg}
size="100% 100%"
className={
'flex h-design-64 w-design-150 items-center justify-center text-design-20 font-bold'
}
>
{t('game.modals.userInfo.message.check')}
</SmartBackground>
</button>
))
)}
</div>
) : (
<div
className={
'flex h-full w-full flex-col gap-design-16 p-design-10'
}
>
{noticeDetailQuery.isLoading ? (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.loading')}
</div>
) : noticeDetailQuery.isError ? (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.loadFailed')}
</div>
) : noticeDetailQuery.data ? (
<div
className={
'rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-24 shadow-[0_0_24px_rgba(14,108,132,0.16)]'
}
>
<div
className={
'mb-design-14 inline-flex rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-14 py-design-6 text-design-16 text-[#9CE8F2]'
}
>
{dayjs(
noticeDetailQuery.data.publish_time * 1000,
).format('YYYY-MM-DD HH:mm:ss')}
</div>
<div
className={
'text-design-28 font-semibold leading-tight text-white'
}
>
{noticeDetailQuery.data.title}
</div>
<div
className={
'mt-design-18 whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]'
}
>
{noticeDetailQuery.data.content}
</div>
</div>
) : (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.empty')}
</div>
)}
</div>
)}
</div>
</div>
)}