feat(auth): 集成认证授权功能并优化API客户端

- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取
- 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理
- 添加了WebSocket连接配置和API基础URL环境变量设置
- 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑
- 集成了MD5加密和认证令牌缓存机制,提升安全性
- 添加了多语言国际化支持,包括英语、中文、马来语和印尼语
- 实现了认证状态管理和本地存储持久化功能
- 添加了表单验证schema和错误处理机制,增强用户体验
This commit is contained in:
JiaJun
2026-05-16 09:03:55 +08:00
parent 6aaf90a6ac
commit 5dd4e31db4
81 changed files with 6086 additions and 627 deletions

View File

@@ -1,5 +1,11 @@
import { useEffect, useMemo, useState } 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 { cn } from '@/lib/utils'
import { useAuthStore, useModalStore } from '@/store'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true,
@@ -18,6 +24,37 @@ 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 formatSelectedLog(
selectionByCell: Record<number, { amount: number; count: number }>,
) {
return Object.entries(selectionByCell)
.map(([cellId, value]) => ({
字花: String(cellId).padStart(2, '0'),
筹码: value.amount,
}))
.sort((left, right) => Number(left.) - Number(right.))
}
interface DesktopAnimalProps {
activeId?: number | null
className?: string
@@ -33,40 +70,220 @@ export function DesktopAnimal({
imageClassName,
onSelect,
}: DesktopAnimalProps) {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
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 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 activeChip = useMemo(
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
[activeChipId, chips],
)
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()
requestRealtimeConnection()
}
const handleSelect = (animalId: number) => {
if (showStandbyState) {
return
}
if (onSelect) {
onSelect(animalId)
return
}
if (isSelectedCell(animalId)) {
const nextSelectionByCell = { ...selectionByCell }
delete nextSelectionByCell[animalId]
console.log('已选', formatSelectedLog(nextSelectionByCell))
removeSelectionsForCell(animalId)
return
}
if (selectedCellCount >= maxSelectionCount) {
return
}
console.log(
'已选',
formatSelectedLog({
...selectionByCell,
[animalId]: {
amount: activeChip?.amount ?? 0,
count: 1,
},
}),
)
placeBet(animalId)
}
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])
return (
<section
className={cn(
'grid w-full grid-cols-6 gap-design-5 common-neon-inset',
'relative grid w-full grid-cols-6 gap-design-5 overflow-hidden common-neon-inset',
className,
)}
>
{animalImageList.map((item) => {
const isActive = item.id === activeId
const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta)
const isActive = item.id === activeId || hasPlacedSelection
const isMarqueeActive = showStandbyState && item.id === marqueeId
return (
<button
key={item.id}
type="button"
onClick={() => onSelect?.(item.id)}
disabled={lockInteraction}
onClick={() => handleSelect(item.id)}
className={cn(
'flex flex-col items-center transition',
'cursor-pointer',
'relative flex flex-col items-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
lockInteraction
? 'cursor-not-allowed opacity-90'
: 'cursor-pointer hover:-translate-y-[1px]',
isMarqueeActive &&
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
isActive &&
'border-[rgba(255,151,15,0.95)] shadow-[inset_0_0_16px_rgba(255,151,15,0.55)]',
'border-[rgba(255,187,61,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,175,52,0.82),0_0_calc(var(--design-unit)*30)_rgba(255,151,15,0.46),inset_0_0_calc(var(--design-unit)*20)_rgba(255,177,70,0.58)]',
!showStandbyState && !hasPlacedSelection && 'opacity-95',
itemClassName,
)}
>
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150',
isMarqueeActive &&
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
isActive &&
'bg-[radial-gradient(circle_at_center,rgba(255,207,116,0.42)_0%,rgba(255,181,61,0.16)_42%,transparent_74%)] opacity-100',
)}
/>
{!showStandbyState && !hasPlacedSelection ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[calc(var(--design-unit)*2)] z-20 rounded-[calc(var(--design-unit)*15)] bg-[rgba(4,16,24,0.52)] shadow-[inset_0_0_calc(var(--design-unit)*20)_rgba(3,9,14,0.56)]"
/>
) : null}
<SmartImage
src={item.url}
alt={`animal-${item.id}`}
className={cn(
'h-design-112 w-design-223 rounded-2xl object-contain',
'relative z-10 h-design-112 w-design-223 rounded-2xl object-contain',
imageClassName,
)}
/>
{hasPlacedSelection ? (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="flex min-w-design-96 items-center justify-center gap-design-4 rounded-full border border-[rgba(162,242,255,0.48)] bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-10 py-design-6 shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.18)]">
<SmartImage
src={diamondIcon}
alt="diamond"
className="h-design-24 w-design-24 shrink-0 object-contain"
/>
<span className="text-design-18 font-semibold leading-none tracking-[0.06em] text-[#D8FBFF]">
{selectionMeta.amount}
</span>
</span>
</span>
) : null}
</button>
)
})}
{showStandbyState ? (
<button
type="button"
onClick={handleStart}
className="absolute inset-0 z-10 flex cursor-pointer items-center justify-center bg-[rgba(3,13,20,0.62)]"
>
<div className="relative flex flex-col items-center gap-design-8 rounded-[calc(var(--design-unit)*20)] border border-[rgba(111,255,247,0.54)] bg-[linear-gradient(180deg,rgba(6,28,38,0.92),rgba(4,14,20,0.94))] px-design-28 py-design-16 text-center shadow-[0_0_calc(var(--design-unit)*16)_rgba(70,245,255,0.34),0_0_calc(var(--design-unit)*34)_rgba(19,210,232,0.22)] transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-[1px] hover:border-[rgba(141,255,250,0.8)] hover:shadow-[0_0_calc(var(--design-unit)*22)_rgba(88,247,255,0.48),0_0_calc(var(--design-unit)*42)_rgba(32,228,255,0.3)]">
<span className="text-design-14 uppercase tracking-[0.42em] text-[rgba(111,255,247,0.76)]">
{isRealtimeConnecting ? '' : t('gameDesktop.animal.tapToEnter')}
</span>
<span className="text-design-28 font-semibold tracking-[0.18em] text-[#D2FFFF]">
{isRealtimeConnecting
? t('gameDesktop.animal.loading')
: t('gameDesktop.animal.getStart')}
</span>
</div>
</button>
) : null}
</section>
)
}

View File

@@ -1,5 +1,6 @@
import { motion } from 'motion/react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import add from '@/assets/game/add.webp'
import arrow from '@/assets/game/arrow.webp'
import chipBg from '@/assets/game/chip-bg.webp'
@@ -9,16 +10,18 @@ import controlBg from '@/assets/game/control-bg.png'
import leftBottomBg from '@/assets/game/left-bg.webp'
import reduce from '@/assets/game/reduce.webp'
import totalBg from '@/assets/game/total-bg.webp'
import diamond from '@/assets/system/diamond.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { ACTION_OPTIONS } from '@/constants'
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
import { cn } from '@/lib/utils'
export function DesktopControl() {
const { t } = useTranslation()
const {
canClear,
chips,
maxSelectionCountLabel,
onChipSelect,
onClearSelections,
selectedChipAmountLabel,
@@ -26,7 +29,6 @@ export function DesktopControl() {
selectedCountLabel,
totalBetAmountLabel,
} = useGameControlVm()
const [clickedId, setClickedId] = useState<string | null>(null)
const [hidingId, setHidingId] = useState<string | null>(null)
const [confirmClicked, setConfirmClicked] = useState(false)
@@ -74,8 +76,8 @@ export function DesktopControl() {
}
>
<div className={'flex flex-col items-center justify-center'}>
<div>TREBD</div>
<div>MAP</div>
<div>{t('gameDesktop.control.trend')}</div>
<div>{t('gameDesktop.control.map')}</div>
</div>
<SmartImage
src={arrow}
@@ -110,10 +112,10 @@ export function DesktopControl() {
transition={{
layout: {
type: 'spring',
stiffness: 420,
damping: 32,
stiffness: 360,
damping: 26,
},
duration: 0.18,
duration: 0.26,
}}
className={
'relative flex h-design-70 w-design-70 shrink-0 cursor-pointer items-center justify-center rounded-full'
@@ -178,15 +180,16 @@ export function DesktopControl() {
}
/>
<motion.div
layout
animate={
isSelected
? {
y: [-1, -3, -1],
scale: [1.02, 1.06, 1.02],
y: [-1, -4, -1],
scale: [1.04, 1.1, 1.04],
filter: [
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
'drop-shadow(0 10px 14px rgba(245, 200, 107, 0.22))',
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
'drop-shadow(0 8px 10px rgba(0,0,0,0.22))',
'drop-shadow(0 12px 16px rgba(245, 200, 107, 0.28))',
'drop-shadow(0 8px 10px rgba(0,0,0,0.22))',
],
}
: {
@@ -205,6 +208,27 @@ export function DesktopControl() {
draggable={false}
className={'h-design-70 w-design-70 object-contain'}
/>
<span
className={
'pointer-events-none absolute inset-x-0 top-1/2 z-[8] -translate-y-[calc(50%-1*var(--design-unit))] text-center text-design-16 font-black leading-none tracking-[0.06em] text-[rgba(96,54,0,0.85)] blur-[1px]'
}
>
{chip.valueLabel}
</span>
<span
className={
'pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-[calc(50%+1*var(--design-unit))] text-center text-design-16 font-black leading-none tracking-[0.06em] text-[rgba(66,28,0,0.72)]'
}
>
{chip.valueLabel}
</span>
<span
className={
'pointer-events-none absolute inset-x-0 top-1/2 z-[11] -translate-y-1/2 text-center text-design-16 font-black leading-none tracking-[0.06em] text-white [text-shadow:0_1px_0_rgba(255,255,255,0.6),0_2px_4px_rgba(0,0,0,0.72),0_0_10px_rgba(255,255,255,0.22)]'
}
>
{chip.valueLabel}
</span>
</motion.div>
</motion.button>
)
@@ -237,11 +261,26 @@ export function DesktopControl() {
src={totalBg}
size="100% 100%"
className={
'desktop-control-total relative flex flex-col items-center justify-center z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
'desktop-control-total relative flex items-center justify-center text-design-20 gap-design-40 z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
}
>
<div>SELECTED:{selectedCountLabel}</div>
<div>Total Bet{totalBetAmountLabel}</div>
<div>
{t('gameDesktop.control.selected')}:{' '}
<span className={'text-red-500'}>{selectedCountLabel}</span> /{' '}
{maxSelectionCountLabel}
</div>
<div className={'flex'}>
<div>{t('gameDesktop.control.totalBet')}</div>
<div className={'flex items-center gap-design-10'}>
<SmartImage
className={'w-design-30 h-design-30'}
src={diamond}
alt={'diamond'}
/>
<div>{totalBetAmountLabel}</div>
</div>
</div>
</SmartBackground>
<SmartBackground
src={controlBg}
@@ -250,7 +289,7 @@ export function DesktopControl() {
'desktop-control-actions relative z-10 flex h-full w-design-385 shrink-0 items-center bg-center bg-no-repeat pl-design-15',
)}
>
{ACTION_OPTIONS.map(({ id, label, Icon, bg }) => {
{ACTION_OPTIONS.map(({ id, labelKey, Icon, bg }) => {
const isClicked = clickedId === id
const isHiding = hidingId === id
const showBg = isClicked || isHiding
@@ -315,7 +354,7 @@ export function DesktopControl() {
className={showBg ? 'text-[#D9FEFF]' : 'text-[#37D5CB]'}
/>
<div className={'mt-design-6 text-design-14 leading-none'}>
{label}
{t(labelKey)}
</div>
</motion.div>
</motion.button>
@@ -351,7 +390,7 @@ export function DesktopControl() {
transition={{ duration: 0.15 }}
className="relative"
>
confirm
{t('gameDesktop.control.confirm')}
</motion.span>
</SmartBackground>
</div>

View File

@@ -1,9 +1,54 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import historyBg from '@/assets/system/history-bg.png'
import { SmartBackground } from '@/components/smart-background.tsx'
import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts'
export function DesktopGameHistory() {
const { emptyText, isEmpty, items } = useGameHistoryVm()
const { t } = useTranslation()
const {
emptyText,
endText,
fetchNextPage,
hasNextPage,
isEmpty,
isFetchingNextPage,
isInitialLoading,
items,
loadingText,
} = useGameHistoryVm()
const parentRef = useRef<HTMLDivElement | null>(null)
const rowCount = hasNextPage ? items.length + 1 : items.length
const virtualizer = useVirtualizer({
count: rowCount,
estimateSize: () => 196,
getScrollElement: () => parentRef.current,
overscan: 4,
})
useEffect(() => {
const virtualItems = virtualizer.getVirtualItems()
const lastItem = virtualItems[virtualItems.length - 1]
if (
!lastItem ||
!hasNextPage ||
isFetchingNextPage ||
lastItem.index < items.length - 1
) {
return
}
void fetchNextPage()
}, [
fetchNextPage,
hasNextPage,
isFetchingNextPage,
items.length,
virtualizer,
])
return (
<SmartBackground
@@ -16,14 +61,23 @@ export function DesktopGameHistory() {
'relative z-20 flex h-design-50 shrink-0 items-center justify-center text-design-30 text-[#D5FBFF]'
}
>
History
{t('gameDesktop.history.title')}
</div>
<div
ref={parentRef}
className={
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
}
>
{isEmpty ? (
{isInitialLoading ? (
<div
className={
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
}
>
{loadingText}
</div>
) : isEmpty ? (
<div
className={
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
@@ -32,56 +86,98 @@ export function DesktopGameHistory() {
{emptyText}
</div>
) : (
items.map((item) => {
return (
<div
key={item.id}
className={
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
}
>
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index]
return (
<div
className={
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
}
key={item?.id ?? `loader-${virtualRow.index}`}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{item.statusLabel}
{item ? (
<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'
}
>
{item.statusLabel}
</div>
<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.orderNo')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>
{item.orderNo}
</span>
</div>
<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.settledAt')}:{' '}
</span>
<span>{item.createdAtLabel}</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>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.payout')}:{' '}
</span>
<span>{item.winAmountLabel}</span>
</div>
</div>
</div>
) : (
<div className="flex h-[calc(var(--design-unit)*60)] items-center justify-center text-design-16 text-[#84A2A2]">
{isFetchingNextPage ? loadingText : endText}
</div>
)}
</div>
<div
className={
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
}
>
<div>
<span className={'text-[#84A2A2]'}>Round ID: </span>
<span className={'text-[#C0E7EB]'}>{item.roundId}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>Settled At: </span>
<span>{item.settledAtLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
Total Pool Amount:{' '}
</span>
<span className={'text-[#FFE375]'}>
{item.totalPoolAmountLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>Winning Result: </span>
<span className={'text-[#FF7575]'}>
{item.winningCellIdLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>Payout: </span>
<span>{item.payoutMultiplierLabel}</span>
</div>
</div>
</div>
)
})
)
})}
</div>
)}
</div>
</SmartBackground>

View File

@@ -1,10 +1,260 @@
import { CircleAlert, Mail, Volume2 } from 'lucide-react'
import { CircleAlert, Mail, Maximize, Minimize, Volume2 } 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 wifi from '@/assets/system/wifi.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import {
isDesktopFullscreen,
subscribeDesktopFullscreenChange,
toggleDesktopFullscreen,
} from '@/lib/utils'
import { 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
}
function SignalBars({
activeBars,
toneClassName,
}: {
activeBars: number
toneClassName: string
}) {
const barHeights = ['h-[6px]', 'h-[10px]', 'h-[14px]', 'h-[18px]'] as const
return (
<div
className="flex h-design-20 w-design-28 items-end gap-[2px]"
aria-hidden="true"
>
{barHeights.map((heightClassName, index) => {
const isActive = index < activeBars
return (
<div
key={heightClassName}
className={[
'w-[5px] rounded-t-[2px] transition-colors',
heightClassName,
isActive ? `bg-current ${toneClassName}` : 'bg-white/18',
].join(' ')}
/>
)
})}
</div>
)
}
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 connection = useGameSessionStore((state) => state.connection)
const setModalOpen = useModalStore((state) => state.setModalOpen)
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()
}
return (
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
<div className="flex h-design-70 w-full items-center px-design-12">
@@ -18,92 +268,124 @@ export function DesktopHeader() {
</div>
<div className="flex h-full w-design-130 items-center justify-center gap-design-10 border-r border-[rgba(128,223,231,0.65)]">
<SmartImage
src={wifi}
alt="wifi"
priority
className="h-design-20 w-design-28"
/>
<div className={'text-[#74FF69] text-design-20'}>
24 <span className={'text-design-16'}>ms</span>
<div className={signalPresentation.toneClassName}>
<SignalBars
activeBars={signalPresentation.activeBars}
toneClassName={signalPresentation.toneClassName}
/>
</div>
<div className={`${signalPresentation.toneClassName} text-design-20`}>
{signalPresentation.latencyLabel}{' '}
<span className={'text-design-16'}>ms</span>
</div>
</div>
<div className="flex h-full w-design-175 flex-col items-center justify-center gap-design-5 border-r border-[rgba(128,223,231,0.65)]">
<div>System Time</div>
<div>20:05:12 GMT+08</div>
<div>{t('gameDesktop.header.systemTime')}</div>
<div>{systemTimeLabel}</div>
</div>
<div className="flex h-full flex-1 items-center justify-around gap-design-10 px-design-40 text-[#D5FBFF] border-r border-[rgba(128,223,231,0.65)]">
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<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">
<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">
<CircleAlert color={'#57B8BF'} size={16} />
<div>Rules & Ddds</div>
<div>{t('gameDesktop.header.rules')}</div>
</div>
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<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">
<Mail color={'#57B8BF'} size={16} />
<div>Pesan</div>
<div>{t('gameDesktop.header.message')}</div>
</div>
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<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">
<Volume2 color={'#57B8BF'} size={16} />
<div>BGM</div>
<div>{t('gameDesktop.header.bgm')}</div>
</div>
<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">
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.id')}</div>
</div>
<button
type="button"
onClick={handleFullscreenToggle}
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"
>
{isFullscreen ? (
<Minimize color={'#57B8BF'} size={16} />
) : (
<Maximize color={'#57B8BF'} size={16} />
)}
<div>{t('gameDesktop.header.fullscreen')}</div>
</button>
</div>
{authStatus === 'authenticated' ? (
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
'flex items-center justify-center gap-design-30 pl-design-30 pr-design-10'
}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>ID</div>
</div>
</div>
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute -left-5 z-20 h-design-50 w-design-50"
/>
<div
className={
'common-neon-inset text-design-16 !py-design-20 flex h-design-36 w-design-180 items-center justify-end'
}
>
{currentUser?.username || '--'}
</div>
</div>
<div className={'flex items-center justify-center px-design-35'}>
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
/>
<div
className={
'common-neon-inset !py-design-20 flex h-design-36 w-design-160 items-center justify-end'
}
>
Biomond Balance
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={diamond}
alt="diamond"
priority
className="absolute -left-5 z-20 h-design-50 w-design-50"
/>
<div
className={
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-design-180 items-center justify-end'
}
>
{currentUser?.coin || '--'}
</div>
</div>
</div>
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
/>
<div
) : (
<div
className={
'flex items-center justify-center gap-design-30 pl-design-30 pr-design-10'
}
>
<button
type="button"
className={
'common-neon-inset !py-design-20 box-border flex h-design-36 w-design-160 items-center justify-end'
'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)}
>
Biomond Balance
</div>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.login')}</div>
</button>
<button
type="button"
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)}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.register')}</div>
</button>
</div>
</div>
)}
</div>
</header>
)

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
@@ -6,6 +7,7 @@ import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.t
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
export function DesktopStatusLine() {
const { t } = useTranslation()
const {
countdownMs,
limitLabel,
@@ -27,9 +29,15 @@ export function DesktopStatusLine() {
<div
className={'flex-1 flex items-center justify-center gap-design-24'}
>
<div>Odds: {oddsLabel}</div>
<div>Streak: {streakLabel}</div>
<div>Limit: {limitLabel}</div>
<div>
{t('gameDesktop.status.odds')}: {oddsLabel}
</div>
<div>
{t('gameDesktop.status.streak')}: {streakLabel}
</div>
<div>
{t('gameDesktop.status.limit')}: {limitLabel}
</div>
</div>
<SmartBackground
src={statusCenter}
@@ -44,7 +52,9 @@ export function DesktopStatusLine() {
/>
</SmartBackground>
<div className={'flex-1 flex items-center justify-center gap-10'}>
<div>Round ID:{roundId}</div>
<div>
{t('gameDesktop.status.roundId')}:{roundId}
</div>
<div className={'flex items-center gap-2'}>
<div className={'flex items-center gap-2'}>
<div

View File

@@ -1,12 +1,12 @@
import { Megaphone } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export function DesktopTitle() {
const { t } = useTranslation()
return (
<section className="common-neon-inset text-design-16 w-full flex h-design-50 items-end gap-design-10 !px-design-20 text-[#FF970F]">
<Megaphone color={'#57B8BF'} />
<div>
Selamat kepada pemain Wu Yanzu yang telah memenangkan hadiah utama
sebesar 5.000 yuan sebanyak lima kali berturut-turut!🎉🎉🎉
</div>
<div>{t('gameDesktop.title.announcement')}</div>
</section>
)
}

View File

@@ -1,5 +1,9 @@
import { useTranslation } from 'react-i18next'
function DesktopTopup() {
return <div>DesktopTopup</div>
const { t } = useTranslation()
return <div>{t('gameDesktop.topup.placeholder')}</div>
}
export default DesktopTopup

View File

@@ -1,5 +1,6 @@
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'
@@ -148,10 +149,12 @@ function WithdrawField({
function AmountShell({
amount,
availableBalanceText,
onMinus,
onPlus,
}: {
amount: number
availableBalanceText: string
onMinus: () => void
onPlus: () => void
}) {
@@ -180,7 +183,7 @@ function AmountShell({
</div>
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
Saldo Tersedia: {formatNumber(AVAILABLE_BALANCE)}
{availableBalanceText}
</div>
</div>
)
@@ -355,6 +358,7 @@ function PreviewRow({
}
function DesktopWithdraw() {
const { t } = useTranslation()
const [amount, setAmount] = useState(6626)
const [currency, setCurrency] =
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
@@ -388,15 +392,24 @@ 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="Jumlah Penarikan Berlian">
<WithdrawField
label={t('gameDesktop.withdraw.fields.diamondWithdrawalAmount')}
>
<AmountShell
amount={amount}
availableBalanceText={t(
'gameDesktop.withdraw.availableBalance',
{ amount: formatNumber(AVAILABLE_BALANCE) },
)}
onMinus={() => handleAmountChange(amount - 1)}
onPlus={() => handleAmountChange(amount + 1)}
/>
</WithdrawField>
<WithdrawField label="Jenis Mata Uang" alignStart={false}>
<WithdrawField
label={t('gameDesktop.withdraw.fields.currencyType')}
alignStart={false}
>
<Select
value={currency}
onValueChange={(value) =>
@@ -405,9 +418,11 @@ function DesktopWithdraw() {
>
<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="Currency selection"
aria-label={t('gameDesktop.withdraw.currencySelection')}
>
<SelectValue placeholder="Select currency" />
<SelectValue
placeholder={t('gameDesktop.withdraw.selectCurrency')}
/>
</SelectTrigger>
<SelectContent
position="popper"
@@ -441,7 +456,9 @@ function DesktopWithdraw() {
</div>
</div>
<WithdrawField label="Saluran Pembayaran">
<WithdrawField
label={t('gameDesktop.withdraw.fields.paymentChannel')}
>
<div className="flex flex-wrap gap-design-10">
{PAYMENT_CHANNELS.map((channel) => (
<PaymentCard
@@ -455,7 +472,7 @@ function DesktopWithdraw() {
</div>
</WithdrawField>
<WithdrawField label="Kode Bank">
<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`}
@@ -475,40 +492,62 @@ function DesktopWithdraw() {
</div>
</WithdrawField>
<WithdrawField label="Nama Pemegang Kartu">
<WithdrawField
label={t('gameDesktop.withdraw.fields.cardHolderName')}
>
<InputShell
value={holderName}
onChange={setHolderName}
placeholder="Mohon masukkan nama pemegang kartu."
placeholder={t(
'gameDesktop.withdraw.placeholders.cardHolderName',
)}
error={holderNameError}
errorMessage="Mohon masukkan nama pemegang kartu."
errorMessage={t(
'gameDesktop.withdraw.errors.cardHolderNameRequired',
)}
/>
</WithdrawField>
<WithdrawField label="Nomor Rekening Bank">
<WithdrawField
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
>
<InputShell
value={bankAccount}
onChange={setBankAccount}
placeholder="Silakan masukkan nomor rekening bank Anda."
placeholder={t(
'gameDesktop.withdraw.placeholders.bankAccountNumber',
)}
error={bankAccountError}
errorMessage="Silakan masukkan nomor rekening bank Anda."
errorMessage={t(
'gameDesktop.withdraw.errors.bankAccountRequired',
)}
/>
</WithdrawField>
<WithdrawField label="Email Penerima" alignStart={false}>
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverEmail')}
alignStart={false}
>
<InputShell
value={receiverEmail}
onChange={setReceiverEmail}
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverEmail',
)}
uppercase={true}
/>
</WithdrawField>
<WithdrawField label="Nomor Ponsel Penerima" alignStart={false}>
<WithdrawField
label={t('gameDesktop.withdraw.fields.receiverPhone')}
alignStart={false}
>
<InputShell
value={receiverPhone}
onChange={setReceiverPhone}
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
placeholder={t(
'gameDesktop.withdraw.placeholders.receiverPhone',
)}
uppercase={true}
/>
</WithdrawField>
@@ -519,67 +558,81 @@ function DesktopWithdraw() {
<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]">
Pratinjau Penukaran
{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="Jumlah Berlian" value={formatNumber(amount)} />
<PreviewRow
label="Kurs (MYR)"
value={`${100 * MYR_PER_100_DIAMONDS} BERLIAN = 1 MYR`}
label={t('gameDesktop.withdraw.preview.diamondAmount')}
value={formatNumber(amount)}
/>
<PreviewRow
label="Dapat Ditukarkan MYR"
label={t('gameDesktop.withdraw.preview.rateMyr')}
value={t('gameDesktop.withdraw.preview.rateMyrValue', {
diamonds: 100 * MYR_PER_100_DIAMONDS,
})}
/>
<PreviewRow
label={t('gameDesktop.withdraw.preview.convertibleMyr')}
value={`RM ${formatFixedTwo(withdrawMyr)}`}
highlight={true}
/>
<PreviewRow
label="Nilai Tukar USDT/MYR"
value={`1 USDT = RM ${USDT_TO_MYR_RATE}`}
label={t('gameDesktop.withdraw.preview.usdtMyrRate')}
value={t('gameDesktop.withdraw.preview.usdtMyrRateValue', {
rate: USDT_TO_MYR_RATE,
})}
/>
<PreviewRow
label="Nilai Tukar (VND)"
value={`${VND_PER_DIAMOND} BERLIAN = 1 VND`}
label={t('gameDesktop.withdraw.preview.rateVnd')}
value={t('gameDesktop.withdraw.preview.rateVndValue', {
diamonds: VND_PER_DIAMOND,
})}
/>
<PreviewRow
label="Dapat Dikonversi ke VND"
label={t('gameDesktop.withdraw.preview.convertibleVnd')}
value={`${formatNumber(withdrawVnd)} VND`}
highlight={true}
/>
<PreviewRow
label="Dapat Ditukarkan dengan USDT"
label={t('gameDesktop.withdraw.preview.convertibleUsdt')}
value={`${formatFixedSix(withdrawUsdt)} USDT`}
highlight={true}
/>
<PreviewRow
label="Jumlah Berlian Nilai Tukar Tetap"
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]">
Nilai tukar berfungsi sebagai harga acuan; nilai tukar aktual yang
berlaku ditentukan pada saat penarikan.
{t('gameDesktop.withdraw.exchangeRateNotice')}
</div>
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
<div>
Dompet Elektronik:{' '}
<span className="text-[#B9F4F8]">Minimal RM10</span>
{t('gameDesktop.withdraw.wallet')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumRm10')}
</span>
</div>
<div>
Bank: <span className="text-[#B9F4F8]">Minimal RM10</span>
{t('gameDesktop.withdraw.bank')}:{' '}
<span className="text-[#B9F4F8]">
{t('gameDesktop.withdraw.minimumRm10')}
</span>
</div>
<div>
Waktu Pengerjaan:{' '}
{t('gameDesktop.withdraw.processingTime')}:{' '}
<span className="text-[#77FF76]">
Dana Tiba Hanya Dalam 9 Detik.
{t('gameDesktop.withdraw.fundsArrivalTime')}
</span>
</div>
<div className="text-[#B9F4F8]">
Melihat: Transaksi antara RM10 dan RM99,99 akan dikenakan biaya
penarikan minimum sebesar RM1.
{t('gameDesktop.withdraw.feeNotice')}
</div>
</div>
@@ -591,7 +644,7 @@ function DesktopWithdraw() {
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]"
>
Membatalkan
{t('gameDesktop.withdraw.cancel')}
</SmartBackground>
<SmartBackground
as="button"
@@ -600,9 +653,9 @@ function DesktopWithdraw() {
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]"
>
Konfirmasi
{t('gameDesktop.withdraw.confirm')}
<br />
Penarikan
{t('gameDesktop.withdraw.withdrawal')}
</SmartBackground>
</div>
</div>