feat: 联调充值和提现接口

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

View File

@@ -7,7 +7,12 @@
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!coverage", "!src/routeTree.gen.ts"]
"includes": [
"**",
"!coverage",
"!src/routeTree.gen.ts",
"!src/assets/lottie"
]
},
"formatter": {
"enabled": true,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -18,7 +18,8 @@ interface CenterModalProps {
className?: string
}
const MODAL_HEADER_HEIGHT = 'calc(100% * 80 / 640)'
const MODAL_HEADER_HEIGHT = 'calc(100% * 80 / 700)'
const NORML_MODAL_HEADER_HEIGHT = 'calc(100% * 80 / 600)'
export function CenterModal({
open,
@@ -81,7 +82,11 @@ export function CenterModal({
>
<div
className="relative w-full shrink-0 mt-design-15 px-design-20"
style={{ height: MODAL_HEADER_HEIGHT }}
style={{
height: isNormalBg
? NORML_MODAL_HEADER_HEIGHT
: MODAL_HEADER_HEIGHT,
}}
>
{title ? (
<div

View File

@@ -0,0 +1,76 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type * as React from 'react'
import { cn } from '@/lib/utils'
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn(
'font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground',
className,
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
className,
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-action"
className={cn('absolute top-2 right-2', className)}
{...props}
/>
)
}
export { Alert, AlertAction, AlertDescription, AlertTitle }

View File

@@ -0,0 +1,67 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : 'button'
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button }

View File

@@ -7,7 +7,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
type={type}
data-slot="input"
className={cn(
'w-full min-w-0 rounded-md border border-transparent bg-[#135E65]/60 px-design-30 py-design-15 text-design-20 text-[#D9FFFF] outline-none transition placeholder:text-[rgba(116,173,175,0.72)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-[rgba(110,255,255,0.72)] focus-visible:ring-0 focus-visible:shadow-[0_0_0_calc(var(--design-unit)*1.5)_rgba(110,255,255,0.16),0_0_calc(var(--design-unit)*8)_rgba(48,214,255,0.36),0_0_calc(var(--design-unit)*18)_rgba(18,162,255,0.22),inset_0_0_calc(var(--design-unit)*6)_rgba(110,255,255,0.08)] aria-invalid:border-[#DF5B5B] aria-invalid:bg-[rgba(78,17,23,0.45)] aria-invalid:text-[#FFF2F2] aria-invalid:focus-visible:shadow-none',
'w-full min-w-0 rounded-md border border-transparent bg-[#135E65]/60 px-design-12 py-design-15 text-design-20 text-[#D9FFFF] outline-none transition placeholder:text-[rgba(116,173,175,0.72)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-[rgba(110,255,255,0.72)] focus-visible:ring-0 focus-visible:shadow-[0_0_0_calc(var(--design-unit)*1.5)_rgba(110,255,255,0.16),0_0_calc(var(--design-unit)*8)_rgba(48,214,255,0.36),0_0_calc(var(--design-unit)*18)_rgba(18,162,255,0.22),inset_0_0_calc(var(--design-unit)*6)_rgba(110,255,255,0.08)] aria-invalid:border-[#DF5B5B] aria-invalid:bg-[rgba(78,17,23,0.45)] aria-invalid:text-[#FFF2F2] aria-invalid:focus-visible:shadow-none',
className,
)}
{...props}

View File

@@ -0,0 +1,180 @@
import {
CheckCircle2,
Info,
LoaderCircle,
TriangleAlert,
XCircle,
} from 'lucide-react'
import { motion } from 'motion/react'
import { createPortal } from 'react-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
NOTIFICATION_EXIT_DURATION_MS,
useNotificationStore,
} from '@/lib/notify'
import { cn } from '@/lib/utils'
const TONE_CLASS_BY_TYPE = {
error: {
alert:
'border-[#E37284]/38 bg-[linear-gradient(180deg,rgba(46,11,18,0.96),rgba(23,7,11,0.98))] shadow-[0_calc(var(--design-unit)*18)_calc(var(--design-unit)*44)_rgba(118,18,40,0.34)]',
glow: 'from-[#FF7D93]/20 via-[#FF7D93]/8 to-transparent',
icon: <XCircle className="h-design-18 w-design-18 text-[#FFB2BE]" />,
iconShell:
'border-[rgba(255,154,168,0.26)] bg-[linear-gradient(180deg,rgba(124,34,46,0.42),rgba(76,19,29,0.3))] shadow-[inset_0_1px_0_rgba(255,224,228,0.16)]',
line: 'bg-[#FF7D93]',
title: 'text-[#FFF5F7]',
},
info: {
alert:
'border-[rgba(114,226,243,0.34)] bg-[linear-gradient(180deg,rgba(5,30,43,0.96),rgba(4,17,26,0.98))] shadow-[0_calc(var(--design-unit)*18)_calc(var(--design-unit)*44)_rgba(9,78,108,0.34)]',
glow: 'from-[#6FEAFF]/20 via-[#6FEAFF]/8 to-transparent',
icon: <Info className="h-design-18 w-design-18 text-[#A5F3FF]" />,
iconShell:
'border-[rgba(121,228,238,0.24)] bg-[linear-gradient(180deg,rgba(17,85,98,0.38),rgba(10,53,63,0.28))] shadow-[inset_0_1px_0_rgba(227,252,255,0.14)]',
line: 'bg-[#73ECFF]',
title: 'text-[#F2FEFF]',
},
loading: {
alert:
'border-[rgba(114,226,243,0.34)] bg-[linear-gradient(180deg,rgba(5,30,43,0.96),rgba(4,17,26,0.98))] shadow-[0_calc(var(--design-unit)*18)_calc(var(--design-unit)*44)_rgba(9,78,108,0.34)]',
glow: 'from-[#6FEAFF]/20 via-[#6FEAFF]/8 to-transparent',
icon: (
<LoaderCircle className="h-design-18 w-design-18 animate-spin text-[#A5F3FF]" />
),
iconShell:
'border-[rgba(121,228,238,0.24)] bg-[linear-gradient(180deg,rgba(17,85,98,0.38),rgba(10,53,63,0.28))] shadow-[inset_0_1px_0_rgba(227,252,255,0.14)]',
line: 'bg-[#73ECFF]',
title: 'text-[#F2FEFF]',
},
success: {
alert:
'border-[rgba(121,229,171,0.34)] bg-[linear-gradient(180deg,rgba(8,36,29,0.96),rgba(5,18,14,0.98))] shadow-[0_calc(var(--design-unit)*18)_calc(var(--design-unit)*44)_rgba(12,89,53,0.34)]',
glow: 'from-[#73F0B0]/18 via-[#73F0B0]/7 to-transparent',
icon: <CheckCircle2 className="h-design-18 w-design-18 text-[#B8FFD8]" />,
iconShell:
'border-[rgba(127,236,177,0.24)] bg-[linear-gradient(180deg,rgba(24,98,66,0.38),rgba(15,60,40,0.28))] shadow-[inset_0_1px_0_rgba(232,255,242,0.14)]',
line: 'bg-[#73F0B0]',
title: 'text-[#F4FFF8]',
},
warning: {
alert:
'border-[rgba(239,197,103,0.36)] bg-[linear-gradient(180deg,rgba(48,31,10,0.96),rgba(25,17,7,0.98))] shadow-[0_calc(var(--design-unit)*18)_calc(var(--design-unit)*44)_rgba(109,72,12,0.34)]',
glow: 'from-[#FFD66E]/20 via-[#FFD66E]/8 to-transparent',
icon: <TriangleAlert className="h-design-18 w-design-18 text-[#FFE4A0]" />,
iconShell:
'border-[rgba(255,214,110,0.24)] bg-[linear-gradient(180deg,rgba(110,77,26,0.38),rgba(66,46,15,0.28))] shadow-[inset_0_1px_0_rgba(255,247,223,0.14)]',
line: 'bg-[#FFD66E]',
title: 'text-[#FFFAEF]',
},
} as const
export function AppNotificationAlert() {
const activeDialog = useNotificationStore((state) => state.activeDialog)
const closingDialogId = useNotificationStore((state) => state.closingDialogId)
if (!activeDialog || typeof document === 'undefined') {
return null
}
const tone = TONE_CLASS_BY_TYPE[activeDialog.type]
const isClosing = closingDialogId === activeDialog.id
const motionState = isClosing ? 'closing' : 'visible'
const motionVariants = {
closing: {
filter: 'blur(1px)',
opacity: 0,
scale: 0.985,
y: -8,
transition: {
duration: NOTIFICATION_EXIT_DURATION_MS / 1000,
ease: [0.22, 1, 0.36, 1],
},
},
hidden: {
filter: 'blur(3px)',
opacity: 0,
scale: 0.94,
y: -18,
},
visible: {
filter: 'blur(0px)',
opacity: 1,
scale: 1,
y: 0,
transition: {
type: 'spring',
damping: 26,
mass: 0.72,
stiffness: 360,
},
},
} as const
return createPortal(
<div className="pointer-events-none fixed inset-x-0 top-[calc(var(--design-unit)*52)] z-[10000] flex justify-center px-4">
<motion.div
key={activeDialog.id}
initial="hidden"
animate={motionState}
variants={motionVariants}
className="w-[min(92vw,calc(var(--design-unit)*468))] will-change-transform"
>
<Alert
className={cn(
'relative origin-top flex w-full max-w-none items-center gap-design-12 overflow-hidden rounded-[calc(var(--design-unit)*14)] border px-design-14 py-design-12 text-left backdrop-blur-xl',
tone.alert,
)}
>
<div
aria-hidden="true"
className={cn(
'absolute inset-x-0 top-0 h-[1px] bg-gradient-to-r from-transparent via-white/24 to-transparent',
)}
/>
<div
aria-hidden="true"
className={cn(
'absolute top-design-10 bottom-design-10 left-design-10 w-[calc(var(--design-unit)*3)] rounded-full opacity-90 shadow-[0_0_calc(var(--design-unit)*14)_currentColor]',
tone.line,
)}
/>
<div
aria-hidden="true"
className={cn(
'absolute inset-0 bg-gradient-to-r opacity-100',
tone.glow,
)}
/>
<div
className={cn(
'relative z-10 flex h-design-38 w-design-38 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*11)] border',
tone.iconShell,
)}
>
{tone.icon}
</div>
<div className="relative z-10 flex min-h-design-38 min-w-0 flex-1 flex-col justify-center">
<AlertTitle
className={cn(
'text-design-15 font-semibold leading-[1.25] text-shadow-[0_1px_0_rgba(0,0,0,0.18)]',
tone.title,
)}
>
{activeDialog.message}
</AlertTitle>
{activeDialog.description ? (
<AlertDescription className="pt-design-3 whitespace-pre-line text-design-12 leading-[1.45] text-white/62">
{activeDialog.description}
</AlertDescription>
) : null}
</div>
</Alert>
</motion.div>
</div>,
document.body,
)
}

View File

@@ -58,7 +58,10 @@ function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
align = 'start',
side = 'bottom',
sideOffset = 6,
avoidCollisions = false,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
@@ -67,13 +70,16 @@ function SelectContent({
data-slot="select-content"
data-align-trigger={position === 'item-aligned'}
className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'relative z-50 max-h-(--radix-select-content-available-height) min-w-(--radix-select-trigger-width) overflow-x-hidden overflow-y-auto rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(8,36,48,0.98),rgba(4,18,28,0.98))] p-design-6 text-[#CFFDFF] shadow-[0_0_calc(var(--design-unit)*16)_rgba(56,241,255,0.12)] ring-0',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
align={align}
side={side}
sideOffset={sideOffset}
avoidCollisions={avoidCollisions}
{...props}
>
<SelectScrollUpButton />
@@ -114,7 +120,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-1.5 rounded-[calc(var(--design-unit)*4)] px-design-12 py-design-10 pr-8 text-design-18 text-[#CFFDFF] outline-hidden select-none transition-colors duration-100 data-disabled:pointer-events-none data-disabled:opacity-50 data-[highlighted]:bg-[rgba(53,154,171,0.18)] data-[highlighted]:text-[#BFFBFF] data-[state=checked]:text-[#FFF2A8] focus:bg-[rgba(53,154,171,0.18)] focus:text-[#BFFBFF] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
@@ -150,7 +156,7 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
"hidden z-10 cursor-default items-center justify-center bg-transparent py-1 text-[#7CE3E8] [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -168,7 +174,7 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
"hidden z-10 cursor-default items-center justify-center bg-transparent py-1 text-[#7CE3E8] [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}

View File

@@ -1,71 +0,0 @@
import {
CheckCircle2,
Info,
LoaderCircle,
TriangleAlert,
X,
XCircle,
} from 'lucide-react'
import { notify, useNotificationStore } from '@/lib/notify'
import { cn } from '@/lib/utils'
const TOAST_ICON_BY_TYPE = {
error: <XCircle className="h-4 w-4 shrink-0 text-[#FF8A9E]" />,
info: <Info className="h-4 w-4 shrink-0 text-[#7CE8FF]" />,
loading: (
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-[#7CE8FF]" />
),
success: <CheckCircle2 className="h-4 w-4 shrink-0 text-[#7CF0B8]" />,
warning: <TriangleAlert className="h-4 w-4 shrink-0 text-[#FFD66E]" />,
} as const
const TOAST_TONE_CLASS_BY_TYPE = {
error: 'game-toast-error',
info: 'game-toast-info',
loading: 'game-toast-loading',
success: 'game-toast-success',
warning: 'game-toast-warning',
} as const
export function AppToaster() {
const toasts = useNotificationStore((state) => state.toasts)
return (
<div
aria-atomic="true"
aria-live="polite"
className="game-toaster pointer-events-none fixed top-[calc(var(--design-unit)*88)] left-1/2 z-[9999] flex w-full -translate-x-1/2 flex-col items-center gap-3 px-4 md:top-[calc(var(--design-unit)*88)]"
>
{toasts.map((toast) => (
<div
key={toast.id}
role="status"
className={cn(
'game-toast pointer-events-auto',
TOAST_TONE_CLASS_BY_TYPE[toast.type],
)}
>
<span aria-hidden="true" className="game-toast-icon">
{TOAST_ICON_BY_TYPE[toast.type]}
</span>
<div className="game-toast-content">
<div className="game-toast-title">{toast.message}</div>
{toast.description ? (
<div className="game-toast-description">{toast.description}</div>
) : null}
</div>
<button
type="button"
aria-label="Close notification"
onClick={() => notify.dismiss(toast.id)}
className="game-toast-close"
>
<X className="h-3.5 w-3.5 text-[#D5FBFF]" />
</button>
</div>
))}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,11 @@
import { TriangleAlert } from 'lucide-react'
import { motion } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import diamondIcon from '@/assets/system/diamond.webp'
import { SmartImage } from '@/components/smart-image'
import { notify } from '@/lib/notify'
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
import { cn } from '@/lib/utils'
import { useAudioStore, useAuthStore, useModalStore } from '@/store'
import {
selectSelectionTotal,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true,
@@ -30,42 +24,6 @@ const animalImageList = Object.entries(animalModules)
.filter((item) => item.id > 0)
.sort((left, right) => left.id - right.id)
function getNextMarqueeId(currentId: number | null) {
if (animalImageList.length === 0) {
return null
}
if (animalImageList.length === 1) {
return animalImageList[0]?.id ?? null
}
let nextId = currentId
while (nextId === currentId) {
nextId =
animalImageList[Math.floor(Math.random() * animalImageList.length)]?.id ??
currentId
}
return nextId
}
function parseBalance(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
if (typeof value !== 'string') {
return 0
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
type CellWarningType = 'balance' | 'limit'
interface DesktopAnimalProps {
activeId?: number | null
className?: string
@@ -82,155 +40,17 @@ export function DesktopAnimal({
onSelect,
}: DesktopAnimalProps) {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
const currentUser = useAuthStore((state) => state.currentUser)
const markSoundPlaybackUnlocked = useAudioStore(
(state) => state.markSoundPlaybackUnlocked,
)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const chips = useGameRoundStore((state) => state.chips)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount,
)
const placeBet = useGameRoundStore((state) => state.placeBet)
const removeSelectionsForCell = useGameRoundStore(
(state) => state.removeSelectionsForCell,
)
const selections = useGameRoundStore((state) => state.selections)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const connection = useGameSessionStore((state) => state.connection)
const requestRealtimeConnection = useGameSessionStore(
(state) => state.requestRealtimeConnection,
)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
const [marqueeId, setMarqueeId] = useState<number | null>(() =>
getNextMarqueeId(null),
)
const [cellWarning, setCellWarning] = useState<{
cellId: number
type: CellWarningType
} | null>(null)
const activeChip = useMemo(
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
[activeChipId, chips],
)
const balance = parseBalance(currentUser?.coin)
const selectionByCell = useMemo(() => {
return selections.reduce<Record<number, { amount: number; count: number }>>(
(accumulator, selection) => {
const current = accumulator[selection.cellId] ?? { amount: 0, count: 0 }
accumulator[selection.cellId] = {
amount: current.amount + selection.amount,
count: current.count + 1,
}
return accumulator
},
{},
)
}, [selections])
const isRealtimeConnected = connection.status === 'connected'
const isRealtimeConnecting =
shouldConnectRealtime &&
(connection.status === 'connecting' || connection.status === 'reconnecting')
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
const lockInteraction = showStandbyState
const isSelectedCell = (animalId: number) =>
Boolean(selectionByCell[animalId])
const selectedCellCount = Object.keys(selectionByCell).length
const handleStart = () => {
if (authStatus !== 'authenticated') {
notify.warning(t('commonUi.toast.loginRequired'))
setModalOpen('desktopLogin', true)
return
}
clearSelections()
markSoundPlaybackUnlocked()
requestRealtimeConnection()
}
const handleSelect = (animalId: number) => {
if (showStandbyState) {
return
}
if (onSelect) {
onSelect(animalId)
return
}
if (isSelectedCell(animalId)) {
removeSelectionsForCell(animalId)
return
}
if (selectedCellCount >= maxSelectionCount) {
setCellWarning({
cellId: animalId,
type: 'limit',
})
return
}
if (totalBetAmount + (activeChip?.amount ?? 0) > balance) {
setCellWarning({
cellId: animalId,
type: 'balance',
})
return
}
placeBet(animalId)
}
useEffect(() => {
if (cellWarning === null) {
return
}
const timerId = window.setTimeout(() => {
setCellWarning((currentWarning) =>
currentWarning?.cellId === cellWarning.cellId &&
currentWarning.type === cellWarning.type
? null
: currentWarning,
)
}, 1200)
return () => {
window.clearTimeout(timerId)
}
}, [cellWarning])
useEffect(() => {
if (!showStandbyState) {
setMarqueeId(null)
return
}
setMarqueeId((currentId) => getNextMarqueeId(currentId))
let timerId = 0
const loop = () => {
setMarqueeId((currentId) => getNextMarqueeId(currentId))
timerId = window.setTimeout(loop, 180 + Math.floor(Math.random() * 220))
}
timerId = window.setTimeout(loop, 220)
return () => {
window.clearTimeout(timerId)
}
}, [showStandbyState])
const animalIds = useMemo(() => animalImageList.map((item) => item.id), [])
const {
cellWarning,
handleSelect,
handleStart,
isRealtimeConnecting,
lockInteraction,
marqueeId,
selectionByCell,
showStandbyState,
} = useAnimalVm(animalIds, onSelect)
return (
<section

View File

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

View File

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

View File

@@ -6,117 +6,12 @@ import {
Volume2,
VolumeX,
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
import {
isDesktopFullscreen,
subscribeDesktopFullscreenChange,
toggleDesktopFullscreen,
} from '@/lib/utils'
import {
useAudioStore,
useAuthStore,
useGameSessionStore,
useModalStore,
} from '@/store'
type BrowserNetworkInformation = {
addEventListener?: (type: 'change', listener: () => void) => void
downlink?: number
effectiveType?: string
removeEventListener?: (type: 'change', listener: () => void) => void
rtt?: number
}
type SignalPresentation = {
activeBars: number
latencyLabel: string
toneClassName: string
}
function formatTimezoneOffset(date: Date) {
const offsetMinutes = -date.getTimezoneOffset()
const sign = offsetMinutes >= 0 ? '+' : '-'
const absoluteMinutes = Math.abs(offsetMinutes)
const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0')
const minutes = String(absoluteMinutes % 60).padStart(2, '0')
return `GMT${sign}${hours}${minutes === '00' ? '' : `:${minutes}`}`
}
function formatHeaderTime(date: Date) {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${hours}:${minutes}:${seconds} ${formatTimezoneOffset(date)}`
}
function getBrowserNetworkInformation() {
if (typeof navigator === 'undefined') {
return null
}
return (navigator as Navigator & { connection?: BrowserNetworkInformation })
.connection
}
function resolveSignalPresentation(input: {
isOnline: boolean
latencyMs: number | null
status: string
}) {
if (!input.isOnline || input.status === 'disconnected') {
return {
activeBars: 0,
latencyLabel: '--',
toneClassName: 'text-[#FF6B6B]',
} satisfies SignalPresentation
}
if (input.latencyMs === null) {
return {
activeBars: input.status === 'connected' ? 2 : 1,
latencyLabel: '--',
toneClassName: 'text-[#7F8EA3]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 80) {
return {
activeBars: 4,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#74FF69]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 150) {
return {
activeBars: 3,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#B7FF6A]',
} satisfies SignalPresentation
}
if (input.latencyMs <= 300) {
return {
activeBars: 2,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#FFD76A]',
} satisfies SignalPresentation
}
return {
activeBars: 1,
latencyLabel: String(input.latencyMs),
toneClassName: 'text-[#FF8A6A]',
} satisfies SignalPresentation
}
import { useHeaderVm } from '@/features/game/hooks/use-header-vm'
function SignalBars({
activeBars,
@@ -152,130 +47,25 @@ function SignalBars({
export function DesktopHeader() {
const { t } = useTranslation()
const [isFullscreen, setIsFullscreen] = useState(false)
const [clockNow, setClockNow] = useState(() => Date.now())
const [isOnline, setIsOnline] = useState(() =>
typeof navigator === 'undefined' ? true : navigator.onLine,
)
const [browserNetworkRttMs, setBrowserNetworkRttMs] = useState<number | null>(
() => {
const rtt = getBrowserNetworkInformation()?.rtt
return typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0
? rtt
: null
},
)
const currentUser = useAuthStore((state) => state.currentUser)
const authStatus = useAuthStore((state) => state.status)
const isSoundEnabled = useAudioStore((state) => state.isSoundEnabled)
const toggleSoundEnabled = useAudioStore((state) => state.toggleSoundEnabled)
const connection = useGameSessionStore((state) => state.connection)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const { currentLanguageLabel, currentLanguageOption } = useAppLanguage()
const handleOpenUserInfo = () => {
setModalOpen('desktopUserInfo', true)
}
const handleOpenProcedures = () => {
setModalOpen('desktopProcedures', true)
}
const serverClockOffsetMs = useMemo(() => {
if (
connection.status !== 'connected' ||
connection.transport !== 'websocket' ||
!connection.lastMessageAt
) {
return null
}
const serverTimestamp = Date.parse(connection.lastMessageAt)
if (Number.isNaN(serverTimestamp)) {
return null
}
return serverTimestamp - Date.now()
}, [connection.lastMessageAt, connection.status, connection.transport])
const systemTimeLabel = useMemo(() => {
const activeTimestamp =
serverClockOffsetMs === null ? clockNow : clockNow + serverClockOffsetMs
return formatHeaderTime(new Date(activeTimestamp))
}, [clockNow, serverClockOffsetMs])
const signalLatencyMs = useMemo(() => {
if (
typeof connection.latencyMs === 'number' &&
Number.isFinite(connection.latencyMs) &&
connection.latencyMs >= 0
) {
return connection.latencyMs
}
return browserNetworkRttMs
}, [browserNetworkRttMs, connection.latencyMs])
const signalPresentation = useMemo(
() =>
resolveSignalPresentation({
isOnline,
latencyMs: signalLatencyMs,
status: connection.status,
}),
[connection.status, isOnline, signalLatencyMs],
)
useEffect(() => {
const syncFullscreenState = () => {
setIsFullscreen(isDesktopFullscreen())
}
syncFullscreenState()
return subscribeDesktopFullscreenChange(syncFullscreenState)
}, [])
useEffect(() => {
const timer = window.setInterval(() => {
setClockNow(Date.now())
}, 1000)
return () => {
window.clearInterval(timer)
}
}, [])
useEffect(() => {
const syncBrowserNetworkState = () => {
setIsOnline(navigator.onLine)
const rtt = getBrowserNetworkInformation()?.rtt
setBrowserNetworkRttMs(
typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0 ? rtt : null,
)
}
const networkInformation = getBrowserNetworkInformation()
syncBrowserNetworkState()
window.addEventListener('online', syncBrowserNetworkState)
window.addEventListener('offline', syncBrowserNetworkState)
networkInformation?.addEventListener?.('change', syncBrowserNetworkState)
return () => {
window.removeEventListener('online', syncBrowserNetworkState)
window.removeEventListener('offline', syncBrowserNetworkState)
networkInformation?.removeEventListener?.(
'change',
syncBrowserNetworkState,
)
}
}, [])
const handleFullscreenToggle = async () => {
await toggleDesktopFullscreen()
}
const {
authStatus,
currentLanguageLabel,
currentLanguageOption,
currentUser,
handleFullscreenToggle,
isFullscreen,
isSoundEnabled,
onOpenLanguage,
onOpenLogin,
onOpenNotice,
onOpenProcedures,
onOpenRegister,
onOpenRules,
onOpenUserInfo,
signalPresentation,
systemTimeLabel,
toggleSoundEnabled,
} = useHeaderVm()
return (
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
@@ -310,17 +100,21 @@ export function DesktopHeader() {
<div className="flex h-full flex-1 items-center justify-around gap-design-10 border-r border-[rgba(128,223,231,0.65)] px-design-20">
<button
type="button"
onClick={() => setModalOpen('desktopRules', true)}
onClick={onOpenRules}
className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85"
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.rules')}</div>
</button>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<button
type="button"
onClick={onOpenNotice}
className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85"
>
<Mail color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.message')}</div>
</div>
</button>
<button
type="button"
@@ -338,7 +132,7 @@ export function DesktopHeader() {
<div className={'flex items-center justify-center'}>
<button
type="button"
onClick={() => setModalOpen('desktopLanguage', true)}
onClick={onOpenLanguage}
className={
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-fit items-center justify-between gap-design-8 !px-design-20 transition-opacity hover:opacity-85'
}
@@ -376,7 +170,7 @@ export function DesktopHeader() {
>
<button
type="button"
onClick={handleOpenUserInfo}
onClick={onOpenUserInfo}
className="group relative flex items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
>
<SmartImage
@@ -396,7 +190,7 @@ export function DesktopHeader() {
<button
type="button"
onClick={handleOpenProcedures}
onClick={onOpenProcedures}
className="group relative flex items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
>
<SmartImage
@@ -425,7 +219,7 @@ export function DesktopHeader() {
className={
'min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85'
}
onClick={() => setModalOpen('desktopLogin', true)}
onClick={onOpenLogin}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.login')}</div>
@@ -435,7 +229,7 @@ export function DesktopHeader() {
className={
'min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85'
}
onClick={() => setModalOpen('desktopRegister', true)}
onClick={onOpenRegister}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.register')}</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,13 +5,18 @@ import {
DEFAULT_REQUEST_TIMEOUT_MS,
} from '@/constants'
import type { AuthTokenDto } from '@/features/auth/api/types'
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
import { ApiError } from '@/lib/api/api-error.ts'
import {
handleUnauthorizedSession,
tryRefreshAuthSession,
} from '@/lib/auth/auth-session'
import { md5 } from '@/lib/crypto/md5'
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
import {
getAuthDeviceId,
getStoredAppLanguage,
useAuthStore,
} from '@/store/auth'
import type { ApiResponse } from '@/type'
type RequestOptions = Omit<Options, 'json'>
@@ -29,6 +34,16 @@ const appEnv = import.meta.env.VITE_APP_ENV
const authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim()
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
function getRequestLanguage() {
const storedLanguage = getStoredAppLanguage()
if (isSupportedLanguage(storedLanguage)) {
return storedLanguage
}
return getPreferredLanguage()
}
function normalizeApiBaseUrl(baseUrl: string | undefined) {
const candidate = baseUrl?.trim()
@@ -123,6 +138,7 @@ const apiClient = ky.create({
beforeRequest: [
({ request }) => {
request.headers.set('Accept', DEFAULT_REQUEST_ACCEPT_HEADER)
request.headers.set('lang', getRequestLanguage())
const token = useAuthStore.getState().accessToken

View File

@@ -1,15 +1,15 @@
import { create } from 'zustand'
const DEFAULT_TOAST_DURATION_MS = 3200
type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading'
const DEFAULT_ALERT_DURATION_MS = 2600
export const NOTIFICATION_EXIT_DURATION_MS = 220
export interface NotifyOptions {
description?: string
duration?: number
}
interface NotificationToast {
interface NotificationDialog {
description?: string
duration: number
id: string
@@ -18,32 +18,134 @@ interface NotificationToast {
}
interface NotificationStoreState {
dismissToast: (id: string) => void
pushToast: (toast: NotificationToast) => void
toasts: NotificationToast[]
activeDialog: NotificationDialog | null
closingDialogId: string | null
dialogQueue: NotificationDialog[]
dismissDialog: (id?: string) => void
pushDialog: (dialog: NotificationDialog) => void
}
const toastTimers = new Map<string, number>()
const dialogTimers = new Map<string, number>()
const dialogExitTimers = new Map<string, number>()
function clearDialogTimer(id: string) {
const timerId = dialogTimers.get(id)
if (timerId) {
window.clearTimeout(timerId)
dialogTimers.delete(id)
}
}
function clearDialogExitTimer(id: string) {
const timerId = dialogExitTimers.get(id)
if (timerId) {
window.clearTimeout(timerId)
dialogExitTimers.delete(id)
}
}
function scheduleDialogDismiss(id: string, duration: number) {
clearDialogTimer(id)
if (duration <= 0) {
return
}
const timerId = window.setTimeout(() => {
useNotificationStore.getState().dismissDialog(id)
}, duration)
dialogTimers.set(id, timerId)
}
function scheduleDialogFinalizeDismiss(id: string) {
clearDialogExitTimer(id)
const timerId = window.setTimeout(() => {
useNotificationStore.setState((state) => {
if (!state.activeDialog || state.activeDialog.id !== id) {
return state
}
const [nextDialog, ...restQueue] = state.dialogQueue
if (nextDialog) {
scheduleDialogDismiss(nextDialog.id, nextDialog.duration)
}
return {
activeDialog: nextDialog ?? null,
closingDialogId: null,
dialogQueue: restQueue,
}
})
dialogExitTimers.delete(id)
}, NOTIFICATION_EXIT_DURATION_MS)
dialogExitTimers.set(id, timerId)
}
export const useNotificationStore = create<NotificationStoreState>()((set) => ({
dismissToast: (id) => {
const timerId = toastTimers.get(id)
activeDialog: null,
closingDialogId: null,
dialogQueue: [],
dismissDialog: (id) => {
set((state) => {
if (state.activeDialog) {
clearDialogTimer(state.activeDialog.id)
}
if (timerId) {
window.clearTimeout(timerId)
toastTimers.delete(id)
}
if (!state.activeDialog) {
return id
? {
closingDialogId: null,
dialogQueue: state.dialogQueue.filter(
(dialog) => dialog.id !== id,
),
}
: state
}
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
}))
if (id && state.activeDialog.id !== id) {
return {
dialogQueue: state.dialogQueue.filter((dialog) => dialog.id !== id),
}
}
if (state.closingDialogId === state.activeDialog.id) {
return state
}
scheduleDialogFinalizeDismiss(state.activeDialog.id)
return {
closingDialogId: state.activeDialog.id,
}
})
},
pushToast: (toast) => {
set((state) => ({
toasts: [...state.toasts.filter((item) => item.id !== toast.id), toast],
}))
pushDialog: (dialog) => {
set((state) => {
if (!state.activeDialog) {
clearDialogExitTimer(dialog.id)
scheduleDialogDismiss(dialog.id, dialog.duration)
return {
activeDialog: dialog,
closingDialogId: null,
}
}
return {
dialogQueue: [
...state.dialogQueue.filter((item) => item.id !== dialog.id),
dialog,
],
}
})
},
toasts: [],
}))
function createToastId() {
@@ -56,9 +158,9 @@ function showToast(
options?: NotifyOptions,
) {
const id = createToastId()
const duration = options?.duration ?? DEFAULT_TOAST_DURATION_MS
const duration = options?.duration ?? DEFAULT_ALERT_DURATION_MS
useNotificationStore.getState().pushToast({
useNotificationStore.getState().pushDialog({
description: options?.description,
duration,
id,
@@ -66,31 +168,13 @@ function showToast(
type,
})
if (duration > 0) {
const timerId = window.setTimeout(() => {
useNotificationStore.getState().dismissToast(id)
}, duration)
toastTimers.set(id, timerId)
}
return id
}
export const notify = {
dismiss(id?: string) {
if (id) {
useNotificationStore.getState().dismissToast(id)
return id
}
const { toasts } = useNotificationStore.getState()
for (const toast of toasts) {
useNotificationStore.getState().dismissToast(toast.id)
}
return null
useNotificationStore.getState().dismissDialog(id)
return id ?? null
},
error(message: string, options?: NotifyOptions) {
return showToast('error', message, options)

View File

@@ -124,13 +124,6 @@ export default {
'This area will later load the real event announcement body, rich media, and a longer scrollable message. The current version focuses on shared multilingual modal wiring.',
check: 'View',
},
protocol: {
title: 'User Agreement',
content:
'Welcome to the 36-Character Flower game lobby.\n\nBefore entering the site, please read and confirm the following:\n1. You have reached the legal age required in your region.\n2. You understand the current content is only for use within this account and this site, and must not be copied, redistributed, or used for unlawful purposes.\n3. You agree to follow the site rules regarding account usage, top-up, withdrawal, risk control, and gameplay.\n4. By continuing into the game lobby, you acknowledge and accept the relevant service terms and data handling rules.\n\nPlease check the agreement to continue.',
agreeLabel: 'I have read and agree to the User Agreement',
confirm: 'Agree and Enter',
},
rules: {
title: 'Game Rules',
content:
@@ -166,6 +159,13 @@ export default {
'My signature is as unique as my personality. This area will later display the real profile summary.',
},
message: {
title: 'Messages',
back: 'Back',
loading: 'Loading messages...',
loadFailed: 'Failed to load messages. Please try again later.',
empty: 'No messages yet',
read: 'Read',
unread: 'Unread',
eventBonus:
'[Top-up Bonus Event] From October 1 to October 7, 2026, claim your rebate rewards...',
check: 'View',
@@ -197,6 +197,12 @@ export default {
},
},
commonUi: {
dialog: {
close: 'Close alert',
confirm: 'OK',
no: 'No',
yes: 'Yes',
},
modal: {
close: 'Close modal',
defaultAriaLabel: 'Modal',
@@ -370,6 +376,7 @@ export default {
},
history: {
title: 'History',
pending: 'PENDING',
win: 'WIN',
lost: 'LOST',
orderNo: 'Order No.',
@@ -385,29 +392,56 @@ export default {
settled: 'Settled',
},
topup: {
placeholder: 'Top-up content is under construction',
title: 'Top-up Config',
platformCoinLabel: 'Platform Coin',
currencyLabel: 'Currency Type',
channelLabel: 'Payment Channel',
rateHint:
'Exchange rates are for reference only. The final amount follows the top-up-time rate.',
tier: {
bonus: 'Bonus',
coins: 'Credited Coins',
createSuccess: 'Deposit order created',
empty: 'No deposit tiers',
failed: 'Failed to load deposit tiers',
loading: 'Loading deposit tiers...',
missingPayUrl: 'Payment link is missing. Please try again later.',
openPayUrlFailed:
'Failed to open the payment page. Check your browser popup settings.',
source: 'Deposit tier endpoint',
title: 'Deposit Tiers',
},
preview: {
title: 'Top-up Preview',
depositTitle: 'Select a top-up currency and payment channel',
depositRate: 'Top-up Rate ({{currency}})',
depositRateValue: '1 {{currency}} = {{coins}} {{platformCoinLabel}}',
amount: 'Sample Credit',
},
},
mobile: {
placeholder: 'Mobile entry is under construction',
},
withdraw: {
availableBalance: 'Available balance: {{amount}}',
currencySelection: 'Currency selection',
selectCurrency: 'Select currency',
exchangeRateNotice:
'Exchange rates and final payout amounts follow the platform real-time settlement.',
wallet: 'Wallet',
currencySelection: 'Currency type selection',
selectCurrency: 'Select currency type',
referenceRateNotice:
'Exchange rates are for reference only. The final payout follows the withdrawal-time rate.',
eWallet: 'E-wallet',
bank: 'Bank',
minimumRm10: 'Minimum RM 10',
minimumAmount: 'Minimum {{currency}} {{amount}}',
processingTime: 'Processing time',
fundsArrivalTime: 'Expected within 1-15 minutes',
arrivalTimeValue: 'Arrives in 30s',
notice: 'Note',
feeNotice:
'Please confirm the receiving information carefully. It cannot be changed after submission.',
'Transactions between RM10 and RM99.99 will be charged a minimum withdrawal fee of RM 1.',
cancel: 'Cancel',
confirm: 'Confirm',
submitSuccess: 'Withdrawal request submitted',
withdrawal: 'Withdrawal',
fields: {
diamondWithdrawalAmount: 'Diamond Withdrawal Amount',
diamondAmount: 'Withdrawal Diamond Amount',
currencyType: 'Currency Type',
paymentChannel: 'Payment Channel',
bankCode: 'Bank Code',
@@ -417,27 +451,39 @@ export default {
receiverPhone: 'Receiver Phone',
},
placeholders: {
bankCode: 'Select bank code',
cardHolderName: 'Enter card holder name',
bankAccountNumber: 'Enter bank account number',
receiverEmail: 'Enter receiver email',
receiverPhone: 'Enter receiver phone number',
},
errors: {
amountRequired: 'Please enter the withdrawal diamond amount.',
amountBelowMinimum:
'The withdrawal amount cannot be lower than the minimum amount ({{currency}} {{amount}} / {{diamonds}} diamonds).',
bankCodeRequired: 'Please select a bank code.',
bankCodeUnavailable: 'No bank code is currently available.',
cardHolderNameRequired: 'Please enter the card holder name.',
bankAccountRequired: 'Please enter the bank account number.',
paymentChannelRequired: 'Please select a payment channel.',
paymentChannelUnavailable: 'No payment channel is currently available.',
receiverEmailInvalid: 'Please enter a valid email address.',
receiverPhoneInvalid: 'Please enter a valid phone number.',
amountExceedsBalance:
'The withdrawal amount cannot exceed the current balance.',
},
success: {
orderNo: 'Order No: {{orderNo}}',
actualArrivalCoin: 'Actual arrival diamonds: {{amount}}',
feeCoin: 'Fee diamonds: {{amount}}',
reviewRequired: 'Risk review required: {{value}}',
},
preview: {
title: 'Exchange Preview',
diamondAmount: 'Diamond Amount',
rateMyr: 'MYR Rate',
rateMyrValue: '{{diamonds}} diamonds = 1 MYR',
convertibleMyr: 'Convertible MYR',
usdtMyrRate: 'USDT / MYR Rate',
usdtMyrRateValue: '1 USDT = {{rate}} MYR',
rateVnd: 'VND Rate',
rateVndValue: '1 diamond = {{diamonds}} VND',
convertibleVnd: 'Convertible VND',
convertibleUsdt: 'Convertible USDT',
exchangeRate: 'Exchange Rate ({{currency}})',
exchangeRateValue: '{{coins}} {{platformCoinLabel}} = 1 {{currency}}',
convertible: 'Convertible {{currency}}',
fixedExchangeDiamondAmount: 'Fixed Exchange Diamond Amount',
},
},

View File

@@ -123,13 +123,6 @@ export default {
'Bagian ini nantinya akan memuat konten pengumuman acara yang sebenarnya, materi visual, dan pesan panjang yang dapat digulir. Versi saat ini fokus pada sambungan modal multibahasa.',
check: 'Lihat',
},
protocol: {
title: 'Perjanjian Pengguna',
content:
'Selamat datang di lobi game 36-Character Flower.\n\nSebelum masuk ke situs, mohon baca dan konfirmasi hal berikut:\n1. Kamu telah mencapai usia legal yang diwajibkan di wilayahmu.\n2. Kamu memahami bahwa konten saat ini hanya untuk penggunaan pada akun dan situs ini, serta tidak boleh disalin, disebarkan, atau digunakan untuk tujuan yang melanggar hukum.\n3. Kamu setuju untuk mematuhi aturan situs terkait akun, isi ulang, penarikan, kontrol risiko, dan permainan.\n4. Dengan melanjutkan ke lobi game, kamu menyatakan telah mengetahui dan menerima ketentuan layanan serta aturan pemrosesan data yang berlaku.\n\nSilakan centang persetujuan untuk melanjutkan.',
agreeLabel: 'Saya telah membaca dan menyetujui Perjanjian Pengguna',
confirm: 'Setuju dan Masuk',
},
rules: {
title: 'Aturan Permainan',
content:
@@ -165,6 +158,13 @@ export default {
'Tanda tanganku seunik diriku. Bagian ini nantinya akan menampilkan ringkasan profil yang sebenarnya.',
},
message: {
title: 'Pesan',
back: 'Kembali',
loading: 'Memuat pesan...',
loadFailed: 'Gagal memuat pesan. Silakan coba lagi nanti.',
empty: 'Belum ada pesan',
read: 'Sudah dibaca',
unread: 'Belum dibaca',
eventBonus:
'[Event Bonus Isi Ulang] Dari 1 Oktober hingga 7 Oktober 2026, klaim hadiah rebate kamu...',
check: 'Lihat',
@@ -196,6 +196,12 @@ export default {
},
},
commonUi: {
dialog: {
close: 'Tutup notifikasi',
confirm: 'OK',
no: 'Tidak',
yes: 'Ya',
},
modal: {
close: 'Tutup modal',
defaultAriaLabel: 'Modal',
@@ -369,6 +375,7 @@ export default {
},
history: {
title: 'Riwayat',
pending: 'PENDING',
win: 'WIN',
lost: 'LOST',
orderNo: 'No. Order',
@@ -384,29 +391,57 @@ export default {
settled: 'Selesai',
},
topup: {
placeholder: 'Konten isi ulang sedang dibangun',
title: 'Konfigurasi Isi Ulang',
platformCoinLabel: 'Koin Platform',
currencyLabel: 'Jenis Mata Uang',
channelLabel: 'Saluran Pembayaran',
rateHint:
'Kurs hanya sebagai referensi. Jumlah akhir mengikuti kurs saat isi ulang.',
tier: {
bonus: 'Bonus',
coins: 'Koin Masuk',
createSuccess: 'Order isi ulang berhasil dibuat',
empty: 'Belum ada tier isi ulang',
failed: 'Gagal memuat tier isi ulang',
loading: 'Memuat tier isi ulang...',
missingPayUrl:
'Tautan pembayaran tidak tersedia. Silakan coba lagi nanti.',
openPayUrlFailed:
'Gagal membuka halaman pembayaran. Periksa pengaturan popup browser Anda.',
source: 'Endpoint tier isi ulang',
title: 'Tier Isi Ulang',
},
preview: {
title: 'Pratinjau Isi Ulang',
depositTitle: 'Pilih mata uang isi ulang dan saluran pembayaran',
depositRate: 'Rasio Isi Ulang ({{currency}})',
depositRateValue: '1 {{currency}} = {{coins}} {{platformCoinLabel}}',
amount: 'Contoh Kredit',
},
},
mobile: {
placeholder: 'Halaman mobile sedang dibangun',
},
withdraw: {
availableBalance: 'Saldo tersedia: {{amount}}',
currencySelection: 'Pilihan mata uang',
selectCurrency: 'Pilih mata uang',
exchangeRateNotice:
'Kurs dan jumlah akhir mengikuti penyelesaian real-time platform.',
wallet: 'Dompet',
currencySelection: 'Pilihan jenis mata uang',
selectCurrency: 'Pilih jenis mata uang',
referenceRateNotice:
'Kurs hanya sebagai referensi. Jumlah akhir mengikuti kurs saat penarikan.',
eWallet: 'Dompet elektronik',
bank: 'Bank',
minimumRm10: 'Minimum RM 10',
minimumAmount: 'Minimum {{currency}} {{amount}}',
processingTime: 'Waktu proses',
fundsArrivalTime: 'Diperkirakan masuk dalam 1-15 menit',
arrivalTimeValue: 'Masuk dalam 30 detik',
notice: 'Perhatian',
feeNotice:
'Pastikan informasi penerima benar. Data tidak dapat diubah setelah dikirim.',
'Transaksi antara RM10 dan RM99.99 akan dikenakan biaya penarikan minimum RM 1.',
cancel: 'Batal',
confirm: 'Konfirmasi',
submitSuccess: 'Permintaan penarikan berhasil dikirim',
withdrawal: 'Penarikan',
fields: {
diamondWithdrawalAmount: 'Jumlah Berlian Ditarik',
diamondAmount: 'Jumlah Berlian Penarikan',
currencyType: 'Jenis Mata Uang',
paymentChannel: 'Saluran Pembayaran',
bankCode: 'Kode Bank',
@@ -416,27 +451,40 @@ export default {
receiverPhone: 'Telepon Penerima',
},
placeholders: {
bankCode: 'Pilih kode bank',
cardHolderName: 'Masukkan nama pemilik rekening',
bankAccountNumber: 'Masukkan nomor rekening bank',
receiverEmail: 'Masukkan email penerima',
receiverPhone: 'Masukkan nomor telepon penerima',
},
errors: {
amountRequired: 'Silakan masukkan jumlah diamond penarikan.',
amountBelowMinimum:
'Jumlah penarikan tidak boleh lebih kecil dari minimum ({{currency}} {{amount}} / {{diamonds}} diamond).',
bankCodeRequired: 'Silakan pilih kode bank.',
bankCodeUnavailable: 'Saat ini tidak ada kode bank yang tersedia.',
cardHolderNameRequired: 'Silakan masukkan nama pemilik rekening.',
bankAccountRequired: 'Silakan masukkan nomor rekening bank.',
paymentChannelRequired: 'Silakan pilih saluran pembayaran.',
paymentChannelUnavailable:
'Saat ini tidak ada saluran pembayaran yang tersedia.',
receiverEmailInvalid: 'Silakan masukkan alamat email yang valid.',
receiverPhoneInvalid: 'Silakan masukkan nomor telepon yang valid.',
amountExceedsBalance:
'Jumlah penarikan tidak boleh melebihi saldo saat ini.',
},
success: {
orderNo: 'No. pesanan: {{orderNo}}',
actualArrivalCoin: 'Diamond masuk aktual: {{amount}}',
feeCoin: 'Diamond biaya: {{amount}}',
reviewRequired: 'Perlu tinjauan risiko: {{value}}',
},
preview: {
title: 'Pratinjau Penukaran',
diamondAmount: 'Jumlah Berlian',
rateMyr: 'Kurs MYR',
rateMyrValue: '{{diamonds}} berlian = 1 MYR',
convertibleMyr: 'Bisa Ditukar ke MYR',
usdtMyrRate: 'Kurs USDT / MYR',
usdtMyrRateValue: '1 USDT = {{rate}} MYR',
rateVnd: 'Kurs VND',
rateVndValue: '1 berlian = {{diamonds}} VND',
convertibleVnd: 'Bisa Ditukar ke VND',
convertibleUsdt: 'Bisa Ditukar ke USDT',
exchangeRate: 'Rasio Penukaran ({{currency}})',
exchangeRateValue: '{{coins}} {{platformCoinLabel}} = 1 {{currency}}',
convertible: 'Bisa Ditukar {{currency}}',
fixedExchangeDiamondAmount: 'Jumlah Berlian Tukar Tetap',
},
},

View File

@@ -126,14 +126,6 @@ export default {
'Bahagian ini akan memuatkan kandungan notis acara sebenar, bahan visual, dan mesej boleh skrol yang lebih panjang. Versi semasa memfokuskan sambungan modal pelbagai bahasa.',
check: 'Semak',
},
protocol: {
title: 'Perjanjian Pengguna',
content:
'Selamat datang ke lobi permainan 36-Character Flower.\n\nSebelum memasuki laman ini, sila baca dan sahkan perkara berikut:\n1. Anda telah mencapai umur sah yang ditetapkan di kawasan anda.\n2. Anda memahami bahawa kandungan semasa hanya untuk digunakan dalam akaun dan laman ini, dan tidak boleh disalin, diedarkan semula, atau digunakan untuk tujuan yang menyalahi undang-undang.\n3. Anda bersetuju untuk mematuhi peraturan laman berkaitan akaun, tambah nilai, pengeluaran, kawalan risiko, dan permainan.\n4. Dengan meneruskan ke lobi permainan, anda mengakui dan menerima terma perkhidmatan serta peraturan pemprosesan data yang berkaitan.\n\nSila tandakan persetujuan untuk meneruskan.',
agreeLabel:
'Saya telah membaca dan bersetuju dengan Perjanjian Pengguna',
confirm: 'Setuju dan Masuk',
},
rules: {
title: 'Peraturan Permainan',
content:
@@ -169,6 +161,13 @@ export default {
'Tandatangan saya unik seperti personaliti saya. Bahagian ini akan memaparkan ringkasan profil sebenar kemudian.',
},
message: {
title: 'Mesej',
back: 'Kembali',
loading: 'Memuatkan mesej...',
loadFailed: 'Gagal memuatkan mesej. Sila cuba lagi kemudian.',
empty: 'Belum ada mesej',
read: 'Sudah dibaca',
unread: 'Belum dibaca',
eventBonus:
'[Acara Bonus Tambah Nilai] Dari 1 Oktober hingga 7 Oktober 2026, tuntut ganjaran rebat anda...',
check: 'Semak',
@@ -200,6 +199,12 @@ export default {
},
},
commonUi: {
dialog: {
close: 'Tutup notifikasi',
confirm: 'OK',
no: 'Tidak',
yes: 'Ya',
},
modal: {
close: 'Tutup modal',
defaultAriaLabel: 'Modal',
@@ -373,6 +378,7 @@ export default {
},
history: {
title: 'Sejarah',
pending: 'PENDING',
win: 'WIN',
lost: 'LOST',
orderNo: 'No. Pesanan',
@@ -388,29 +394,56 @@ export default {
settled: 'Selesai',
},
topup: {
placeholder: 'Kandungan tambah nilai sedang dibina',
title: 'Konfigurasi Tambah Nilai',
platformCoinLabel: 'Syiling Platform',
currencyLabel: 'Jenis Mata Wang',
channelLabel: 'Saluran Pembayaran',
rateHint:
'Kadar pertukaran hanya untuk rujukan. Jumlah akhir tertakluk kepada kadar semasa tambah nilai.',
tier: {
bonus: 'Bonus',
coins: 'Syiling Diterima',
createSuccess: 'Pesanan tambah nilai berjaya dicipta',
empty: 'Tiada tier tambah nilai',
failed: 'Gagal memuatkan tier tambah nilai',
loading: 'Memuatkan tier tambah nilai...',
missingPayUrl: 'Pautan pembayaran tiada. Sila cuba lagi kemudian.',
openPayUrlFailed:
'Gagal membuka halaman pembayaran. Semak tetapan popup pelayar anda.',
source: 'Endpoint tier tambah nilai',
title: 'Tier Tambah Nilai',
},
preview: {
title: 'Pratonton Tambah Nilai',
depositTitle: 'Pilih mata wang tambah nilai dan saluran pembayaran',
depositRate: 'Kadar Tambah Nilai ({{currency}})',
depositRateValue: '1 {{currency}} = {{coins}} {{platformCoinLabel}}',
amount: 'Contoh Kredit',
},
},
mobile: {
placeholder: 'Halaman mudah alih sedang dibina',
},
withdraw: {
availableBalance: 'Baki tersedia: {{amount}}',
currencySelection: 'Pilihan mata wang',
selectCurrency: 'Pilih mata wang',
exchangeRateNotice:
'Kadar pertukaran dan jumlah akhir tertakluk kepada penyelesaian masa nyata platform.',
wallet: 'Dompet',
currencySelection: 'Pilihan jenis mata wang',
selectCurrency: 'Pilih jenis mata wang',
referenceRateNotice:
'Kadar pertukaran hanya untuk rujukan. Jumlah akhir tertakluk kepada kadar semasa pengeluaran.',
eWallet: 'Dompet elektronik',
bank: 'Bank',
minimumRm10: 'Minimum RM 10',
minimumAmount: 'Minimum {{currency}} {{amount}}',
processingTime: 'Masa pemprosesan',
fundsArrivalTime: 'Dijangka tiba dalam 1-15 minit',
arrivalTimeValue: 'Masuk dalam 30 saat',
notice: 'Perhatian',
feeNotice:
'Sila pastikan maklumat penerima adalah tepat. Ia tidak boleh diubah selepas dihantar.',
'Transaksi antara RM10 dan RM99.99 akan dikenakan yuran pengeluaran minimum RM 1.',
cancel: 'Batal',
confirm: 'Sahkan',
submitSuccess: 'Permohonan pengeluaran telah dihantar',
withdrawal: 'Pengeluaran',
fields: {
diamondWithdrawalAmount: 'Jumlah Berlian Dikeluarkan',
diamondAmount: 'Jumlah Berlian Pengeluaran',
currencyType: 'Jenis Mata Wang',
paymentChannel: 'Saluran Pembayaran',
bankCode: 'Kod Bank',
@@ -420,27 +453,40 @@ export default {
receiverPhone: 'Telefon Penerima',
},
placeholders: {
bankCode: 'Pilih kod bank',
cardHolderName: 'Masukkan nama pemegang kad',
bankAccountNumber: 'Masukkan nombor akaun bank',
receiverEmail: 'Masukkan e-mel penerima',
receiverPhone: 'Masukkan nombor telefon penerima',
},
errors: {
amountRequired: 'Sila masukkan jumlah berlian pengeluaran.',
amountBelowMinimum:
'Jumlah pengeluaran tidak boleh kurang daripada minimum ({{currency}} {{amount}} / {{diamonds}} berlian).',
bankCodeRequired: 'Sila pilih kod bank.',
bankCodeUnavailable: 'Tiada kod bank tersedia buat masa ini.',
cardHolderNameRequired: 'Sila masukkan nama pemegang kad.',
bankAccountRequired: 'Sila masukkan nombor akaun bank.',
paymentChannelRequired: 'Sila pilih saluran pembayaran.',
paymentChannelUnavailable:
'Tiada saluran pembayaran tersedia buat masa ini.',
receiverEmailInvalid: 'Sila masukkan alamat e-mel yang sah.',
receiverPhoneInvalid: 'Sila masukkan nombor telefon yang sah.',
amountExceedsBalance:
'Jumlah pengeluaran tidak boleh melebihi baki semasa.',
},
success: {
orderNo: 'No. pesanan: {{orderNo}}',
actualArrivalCoin: 'Berlian masuk sebenar: {{amount}}',
feeCoin: 'Berlian yuran: {{amount}}',
reviewRequired: 'Semakan risiko diperlukan: {{value}}',
},
preview: {
title: 'Pratonton Pertukaran',
diamondAmount: 'Jumlah Berlian',
rateMyr: 'Kadar MYR',
rateMyrValue: '{{diamonds}} berlian = 1 MYR',
convertibleMyr: 'Boleh Tukar MYR',
usdtMyrRate: 'Kadar USDT / MYR',
usdtMyrRateValue: '1 USDT = {{rate}} MYR',
rateVnd: 'Kadar VND',
rateVndValue: '1 berlian = {{diamonds}} VND',
convertibleVnd: 'Boleh Tukar VND',
convertibleUsdt: 'Boleh Tukar USDT',
exchangeRate: 'Kadar Pertukaran ({{currency}})',
exchangeRateValue: '{{coins}} {{platformCoinLabel}} = 1 {{currency}}',
convertible: 'Boleh Tukar {{currency}}',
fixedExchangeDiamondAmount: 'Jumlah Berlian Tukaran Tetap',
},
},

View File

@@ -121,13 +121,6 @@ export default {
'这里后续将接入真实的活动公告内容、图文素材和滚动阅读区域。当前版本先用多语言结构完成桌面端公告弹窗能力。',
check: '查看',
},
protocol: {
title: '用户协议',
content:
'欢迎进入 36 字花游戏大厅。\n\n进入站点前请先阅读并确认以下协议内容\n1. 你已年满所在地区法律要求的法定年龄。\n2. 你理解当前展示内容仅限当前账号与当前站点使用,不得擅自复制、转发或用于非法用途。\n3. 你同意遵守站点的账户、充值、提现、风控与游戏规则说明。\n4. 若你继续进入游戏大厅,即表示你已知悉并接受相关服务条款与数据处理规则。\n\n请勾选同意后继续进入游戏界面。',
agreeLabel: '我已阅读并同意《用户协议》',
confirm: '同意并进入',
},
rules: {
title: '玩法规则',
content:
@@ -162,6 +155,13 @@ export default {
signature: '我的个性签名和灵魂一样独特,后续这里会接真实资料字段。',
},
message: {
title: '站内消息',
back: '返回',
loading: '消息加载中...',
loadFailed: '消息加载失败,请稍后重试',
empty: '暂无消息',
read: '已读',
unread: '未读',
eventBonus: '[充值活动] 10 月 1 日至 10 月 7 日期间可获得返利奖励……',
check: '查看',
deleteRecords: '删除记录',
@@ -191,6 +191,12 @@ export default {
},
},
commonUi: {
dialog: {
close: '关闭提示',
confirm: '知道了',
no: '否',
yes: '是',
},
modal: {
close: '关闭弹窗',
defaultAriaLabel: '弹窗',
@@ -360,6 +366,7 @@ export default {
},
history: {
title: '历史记录',
pending: 'PENDING',
win: 'WIN',
lost: 'LOST',
orderNo: '订单号',
@@ -375,57 +382,93 @@ export default {
settled: '已结算',
},
topup: {
placeholder: '充值内容建设中',
title: '充值配置',
platformCoinLabel: '平台币',
currencyLabel: '货币类型',
channelLabel: '支付渠道',
rateHint: '汇率为参考价格,实际以充值时为准。',
tier: {
bonus: '赠送',
coins: '到账钻石',
createSuccess: '充值订单已创建',
empty: '暂无充值档位',
failed: '充值档位加载失败',
loading: '充值档位加载中...',
missingPayUrl: '充值链接缺失,请稍后重试',
openPayUrlFailed: '支付页面打开失败,请检查浏览器弹窗拦截',
source: '充值档位接口',
title: '充值档位',
},
preview: {
title: '充值预览',
depositTitle: '请选择充值货币和支付渠道',
depositRate: '充值比例 ({{currency}})',
depositRateValue: '1 {{currency}} = {{coins}} {{platformCoinLabel}}',
amount: '示例到账',
},
},
mobile: {
placeholder: '移动端页面建设中',
},
withdraw: {
availableBalance: '可用余额:{{amount}}',
currencySelection: '币种选择',
selectCurrency: '请选择币种',
exchangeRateNotice: '汇率与到账金额以平台实时结算为准。',
wallet: '钱包',
bank: '银行',
minimumRm10: '最低 RM 10',
currencySelection: '货币类型选择',
selectCurrency: '请选择货币类型',
referenceRateNotice: '汇率为参考价格,实际以提现时为准。',
eWallet: '电子钱包',
bank: '银行',
minimumAmount: '最低 {{currency}} {{amount}}',
processingTime: '处理时间',
fundsArrivalTime: '预计 1-15 分钟到账',
feeNotice: '请确认收款信息准确无误,提交后不可修改。',
arrivalTimeValue: '30s即可到账',
notice: '注意',
feeNotice: 'RM10 - RM99.99 之间的交易将收取最低RM 1的提现手续费',
cancel: '取消',
confirm: '确认',
submitSuccess: '提现申请已提交',
withdrawal: '提现',
fields: {
diamondWithdrawalAmount: '提钻石数量',
currencyType: '币类型',
paymentChannel: '付渠道',
diamondAmount: '提钻石数量',
currencyType: '币类型',
paymentChannel: '付渠道',
bankCode: '银行代码',
cardHolderName: '持卡人姓名',
bankAccountNumber: '银行账号',
receiverEmail: '收款邮箱',
receiverPhone: '收款手机',
receiverEmail: '收款邮箱',
receiverPhone: '收款手机',
},
placeholders: {
bankCode: '请选择银行代码',
cardHolderName: '请输入持卡人姓名',
bankAccountNumber: '请输入银行账号',
receiverEmail: '请输入收款邮箱',
receiverPhone: '请输入收款手机号',
receiverEmail: '请输入收款邮箱',
receiverPhone: '请输入收款手机号',
},
errors: {
amountRequired: '请输入提现钻石数量',
amountBelowMinimum:
'提现金额不能低于最低提现金额({{currency}} {{amount}} / {{diamonds}} 钻石)',
bankCodeRequired: '请选择银行代码',
bankCodeUnavailable: '当前暂无可用银行代码',
cardHolderNameRequired: '请输入持卡人姓名',
bankAccountRequired: '请输入银行账号',
paymentChannelRequired: '请选择支付渠道',
paymentChannelUnavailable: '当前暂无可用支付渠道',
receiverEmailInvalid: '请输入正确的邮箱',
receiverPhoneInvalid: '请输入正确的手机号',
amountExceedsBalance: '提现金额不能大于当前拥有的金额',
},
success: {
orderNo: '订单号:{{orderNo}}',
actualArrivalCoin: '实际到账钻石:{{amount}}',
feeCoin: '手续费钻石:{{amount}}',
reviewRequired: '需要风控审核:{{value}}',
},
preview: {
title: '兑换预览',
diamondAmount: '钻石数量',
rateMyr: '马币汇率',
rateMyrValue: '{{diamonds}} 钻石 = 1 MYR',
convertibleMyr: '可兑换 MYR',
usdtMyrRate: 'USDT / MYR 汇率',
usdtMyrRateValue: '1 USDT = {{rate}} MYR',
rateVnd: '越南盾汇率',
rateVndValue: '1 钻石 = {{diamonds}} VND',
convertibleVnd: '可兑换 VND',
convertibleUsdt: '可兑换 USDT',
exchangeRate: '兑换比例 ({{currency}})',
exchangeRateValue: '{{coins}} {{platformCoinLabel}} = 1 {{currency}}',
convertible: '可兑换{{currency}}',
fixedExchangeDiamondAmount: '固定兑换钻石金额',
},
},

View File

@@ -3,7 +3,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { RouterProvider } from '@tanstack/react-router'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { AppToaster } from '@/components/ui/toaster'
import { AppNotificationAlert } from '@/components/ui/notification-alert'
import { APP_ROOT_ELEMENT_ID } from '@/constants'
import {
getCurrentUserProfile,
@@ -46,7 +46,7 @@ createRoot(rootElement).render(
<QueryClientProvider client={queryClient}>
<GlobalAudioController />
<RouterProvider router={router} />
<AppToaster />
<AppNotificationAlert />
{shouldShowQueryDevtools && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</StrictMode>,

View File

@@ -50,7 +50,6 @@ interface PersistedAuthState {
interface PersistedAppPreferenceState {
appLanguage: string | null
deviceId: string | null
hasAcceptedProtocol: boolean
}
interface AuthState extends PersistedAuthState {
@@ -222,7 +221,6 @@ interface AppPreferenceStoreState extends PersistedAppPreferenceState {
getOrCreateDeviceId: () => string
isHydrated: boolean
setAppLanguage: (language: string) => void
setProtocolAccepted: (accepted: boolean) => void
}
export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
@@ -230,7 +228,6 @@ export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
(set, get) => ({
appLanguage: null,
deviceId: null,
hasAcceptedProtocol: false,
isHydrated: false,
finishHydration: () => {
set({ isHydrated: true })
@@ -251,9 +248,6 @@ export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
setAppLanguage: (language) => {
set({ appLanguage: language })
},
setProtocolAccepted: (accepted) => {
set({ hasAcceptedProtocol: accepted })
},
}),
{
name: APP_PREFERENCES_STORAGE_KEY,
@@ -261,7 +255,6 @@ export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
partialize: (state) => ({
appLanguage: state.appLanguage,
deviceId: state.deviceId,
hasAcceptedProtocol: state.hasAcceptedProtocol,
}),
onRehydrateStorage: () => (state) => {
state?.finishHydration()
@@ -281,11 +274,3 @@ export function getStoredAppLanguage() {
export function setStoredAppLanguage(language: string) {
useAppPreferenceStore.getState().setAppLanguage(language)
}
export function getStoredProtocolAccepted() {
return useAppPreferenceStore.getState().hasAcceptedProtocol
}
export function setStoredProtocolAccepted(accepted: boolean) {
useAppPreferenceStore.getState().setProtocolAccepted(accepted)
}

View File

@@ -8,8 +8,6 @@ export const MODAL_KEYS = [
'desktopRegister',
/**@description 桌面端多语言弹窗*/
'desktopLanguage',
/**@description 桌面端协议弹窗*/
'desktopProtocol',
/**@description 桌面端规则弹窗*/
'desktopRules',
/**@description 桌面端用户信息弹窗*/
@@ -32,7 +30,6 @@ const INITIAL_MODAL_VISIBILITY: ModalVisibilityMap = {
desktopLogin: false,
desktopRegister: false,
desktopLanguage: false,
desktopProtocol: false,
desktopRules: false,
desktopUserInfo: false,
desktopNotice: false,

View File

@@ -347,173 +347,6 @@
height: 0;
display: none;
}
.game-toaster {
--width: min(
calc(100vw - calc(var(--design-unit) * 32)),
calc(var(--design-unit) * 520)
);
}
.game-toast {
width: min(
calc(100vw - calc(var(--design-unit) * 32)),
calc(var(--design-unit) * 520)
);
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: calc(var(--design-unit) * 14);
align-items: center;
min-height: calc(var(--design-unit) * 60);
padding: calc(var(--design-unit) * 16) calc(var(--design-unit) * 20);
border-radius: calc(var(--design-unit) * 16);
border: 1px solid rgba(128, 223, 231, 0.68);
background:
linear-gradient(180deg, rgba(15, 35, 49, 0.98), rgba(6, 16, 24, 0.98)),
radial-gradient(circle at top, rgba(124, 232, 255, 0.22), transparent 58%);
box-shadow:
inset 0 0 calc(var(--design-unit) * 16) rgba(128, 223, 231, 0.18),
0 0 calc(var(--design-unit) * 24) rgba(29, 190, 219, 0.26),
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
rgba(2, 8, 16, 0.42);
backdrop-filter: blur(14px);
position: relative;
overflow: hidden;
}
.game-toast::before {
content: "";
position: absolute;
inset: 1px;
border-radius: inherit;
border: 1px solid rgba(255, 255, 255, 0.04);
border-top-color: rgba(215, 250, 255, 0.32);
pointer-events: none;
}
.game-toast-success {
border-color: rgba(79, 220, 155, 0.72);
box-shadow:
inset 0 0 calc(var(--design-unit) * 16) rgba(79, 220, 155, 0.16),
0 0 calc(var(--design-unit) * 24) rgba(79, 220, 155, 0.24),
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
rgba(2, 8, 16, 0.42);
}
.game-toast-error {
border-color: rgba(255, 94, 122, 0.68);
box-shadow:
inset 0 0 calc(var(--design-unit) * 16) rgba(255, 94, 122, 0.16),
0 0 calc(var(--design-unit) * 24) rgba(255, 94, 122, 0.24),
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
rgba(2, 8, 16, 0.42);
}
.game-toast-warning {
border-color: rgba(255, 214, 110, 0.72);
box-shadow:
inset 0 0 calc(var(--design-unit) * 16) rgba(255, 214, 110, 0.16),
0 0 calc(var(--design-unit) * 24) rgba(255, 214, 110, 0.22),
0 calc(var(--design-unit) * 18) calc(var(--design-unit) * 52)
rgba(2, 8, 16, 0.42);
}
.game-toast-info,
.game-toast-loading,
.game-toast-default {
border-color: rgba(128, 223, 231, 0.52);
}
.game-toast-icon {
display: flex;
align-items: center;
justify-content: center;
width: calc(var(--design-unit) * 30);
height: calc(var(--design-unit) * 30);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
box-shadow:
inset 0 0 calc(var(--design-unit) * 8) rgba(255, 255, 255, 0.04),
0 0 calc(var(--design-unit) * 12) rgba(124, 232, 255, 0.12);
}
.game-toast-content {
min-width: 0;
padding-right: calc(var(--design-unit) * 22);
}
.game-toast-title {
font-size: calc(var(--design-unit) * 17);
line-height: 1.3;
font-weight: 800;
letter-spacing: 0.03em;
color: #f2fdff;
text-shadow:
0 0 calc(var(--design-unit) * 8) rgba(124, 232, 255, 0.18),
0 0 calc(var(--design-unit) * 16) rgba(124, 232, 255, 0.08);
}
.game-toast-description {
margin-top: calc(var(--design-unit) * 5);
font-size: calc(var(--design-unit) * 13);
line-height: 1.45;
color: rgba(213, 251, 255, 0.84);
}
.game-toast-close {
position: absolute;
top: 50%;
right: calc(var(--design-unit) * 14);
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: calc(var(--design-unit) * 28);
height: calc(var(--design-unit) * 28);
border-radius: 999px;
border: 1px solid rgba(128, 223, 231, 0.34);
background: rgba(4, 18, 27, 0.82);
box-shadow:
inset 0 0 calc(var(--design-unit) * 6) rgba(255, 255, 255, 0.04),
0 0 calc(var(--design-unit) * 10) rgba(124, 232, 255, 0.14);
transition:
border-color 180ms ease,
background-color 180ms ease,
transform 180ms ease,
box-shadow 180ms ease;
cursor: pointer;
}
.game-toast-close:hover {
transform: translateY(-50%) scale(1.04);
border-color: rgba(128, 223, 231, 0.58);
background: rgba(10, 28, 40, 0.96);
box-shadow:
inset 0 0 calc(var(--design-unit) * 8) rgba(255, 255, 255, 0.06),
0 0 calc(var(--design-unit) * 14) rgba(124, 232, 255, 0.22);
}
.game-toast-action,
.game-toast-cancel {
margin-top: calc(var(--design-unit) * 10);
border-radius: calc(var(--design-unit) * 999);
padding: calc(var(--design-unit) * 6) calc(var(--design-unit) * 12);
font-size: calc(var(--design-unit) * 12);
font-weight: 600;
cursor: pointer;
}
.game-toast-action {
border: 1px solid rgba(128, 223, 231, 0.52);
background: rgba(10, 34, 47, 0.9);
color: #d5fbff;
}
.game-toast-cancel {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(213, 251, 255, 0.74);
}
}
@theme inline {