feat(auth): 集成认证授权功能并优化API客户端
- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取 - 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理 - 添加了WebSocket连接配置和API基础URL环境变量设置 - 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑 - 集成了MD5加密和认证令牌缓存机制,提升安全性 - 添加了多语言国际化支持,包括英语、中文、马来语和印尼语 - 实现了认证状态管理和本地存储持久化功能 - 添加了表单验证schema和错误处理机制,增强用户体验
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user