feat: 联调充值和提现接口
This commit is contained in:
@@ -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,
|
||||
|
||||
1
src/assets/lottie/pc-big-reward.json
Normal file
1
src/assets/lottie/pc-big-reward.json
Normal file
File diff suppressed because one or more lines are too long
1
src/assets/lottie/pc-combo.json
Normal file
1
src/assets/lottie/pc-combo.json
Normal file
File diff suppressed because one or more lines are too long
1
src/assets/lottie/pc-small-reward.json
Normal file
1
src/assets/lottie/pc-small-reward.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
76
src/components/ui/alert.tsx
Normal file
76
src/components/ui/alert.tsx
Normal 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 }
|
||||
67
src/components/ui/button.tsx
Normal file
67
src/components/ui/button.tsx
Normal 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 }
|
||||
@@ -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}
|
||||
|
||||
180
src/components/ui/notification-alert.tsx
Normal file
180
src/components/ui/notification-alert.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
246
src/features/game/api/finance-api.ts
Normal file
246
src/features/game/api/finance-api.ts
Normal 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
|
||||
}
|
||||
183
src/features/game/api/finance-types.ts
Normal file
183
src/features/game/api/finance-types.ts
Normal 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 & {})
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './finance-api'
|
||||
export * from './finance-types'
|
||||
export * from './game-api'
|
||||
export * from './types'
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface GameBootstrapDto {
|
||||
connection: ConnectionStateDto
|
||||
dashboard: DashboardStateDto
|
||||
history: HistoryEntryDto[]
|
||||
max_selection_count?: number
|
||||
round: RoundSnapshotDto
|
||||
selections: BetSelectionDto[]
|
||||
trends: TrendEntryDto[]
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import diamondIcon from '@/assets/system/diamond.webp'
|
||||
import { SmartImage } from '@/components/smart-image'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAudioStore, useAuthStore, useModalStore } from '@/store'
|
||||
import {
|
||||
selectSelectionTotal,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
|
||||
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
|
||||
eager: true,
|
||||
@@ -30,42 +24,6 @@ const animalImageList = Object.entries(animalModules)
|
||||
.filter((item) => item.id > 0)
|
||||
.sort((left, right) => left.id - right.id)
|
||||
|
||||
function getNextMarqueeId(currentId: number | null) {
|
||||
if (animalImageList.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (animalImageList.length === 1) {
|
||||
return animalImageList[0]?.id ?? null
|
||||
}
|
||||
|
||||
let nextId = currentId
|
||||
|
||||
while (nextId === currentId) {
|
||||
nextId =
|
||||
animalImageList[Math.floor(Math.random() * animalImageList.length)]?.id ??
|
||||
currentId
|
||||
}
|
||||
|
||||
return nextId
|
||||
}
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
type CellWarningType = 'balance' | 'limit'
|
||||
|
||||
interface DesktopAnimalProps {
|
||||
activeId?: number | null
|
||||
className?: string
|
||||
@@ -82,155 +40,17 @@ export function DesktopAnimal({
|
||||
onSelect,
|
||||
}: DesktopAnimalProps) {
|
||||
const { t } = useTranslation()
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const markSoundPlaybackUnlocked = useAudioStore(
|
||||
(state) => state.markSoundPlaybackUnlocked,
|
||||
)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||
const chips = useGameRoundStore((state) => state.chips)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const maxSelectionCount = useGameRoundStore(
|
||||
(state) => state.maxSelectionCount,
|
||||
)
|
||||
const placeBet = useGameRoundStore((state) => state.placeBet)
|
||||
const removeSelectionsForCell = useGameRoundStore(
|
||||
(state) => state.removeSelectionsForCell,
|
||||
)
|
||||
const selections = useGameRoundStore((state) => state.selections)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
const connection = useGameSessionStore((state) => state.connection)
|
||||
const requestRealtimeConnection = useGameSessionStore(
|
||||
(state) => state.requestRealtimeConnection,
|
||||
)
|
||||
const shouldConnectRealtime = useGameSessionStore(
|
||||
(state) => state.shouldConnectRealtime,
|
||||
)
|
||||
const [marqueeId, setMarqueeId] = useState<number | null>(() =>
|
||||
getNextMarqueeId(null),
|
||||
)
|
||||
const [cellWarning, setCellWarning] = useState<{
|
||||
cellId: number
|
||||
type: CellWarningType
|
||||
} | null>(null)
|
||||
const activeChip = useMemo(
|
||||
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
|
||||
[activeChipId, chips],
|
||||
)
|
||||
const balance = parseBalance(currentUser?.coin)
|
||||
const selectionByCell = useMemo(() => {
|
||||
return selections.reduce<Record<number, { amount: number; count: number }>>(
|
||||
(accumulator, selection) => {
|
||||
const current = accumulator[selection.cellId] ?? { amount: 0, count: 0 }
|
||||
|
||||
accumulator[selection.cellId] = {
|
||||
amount: current.amount + selection.amount,
|
||||
count: current.count + 1,
|
||||
}
|
||||
|
||||
return accumulator
|
||||
},
|
||||
{},
|
||||
)
|
||||
}, [selections])
|
||||
|
||||
const isRealtimeConnected = connection.status === 'connected'
|
||||
const isRealtimeConnecting =
|
||||
shouldConnectRealtime &&
|
||||
(connection.status === 'connecting' || connection.status === 'reconnecting')
|
||||
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
|
||||
const lockInteraction = showStandbyState
|
||||
const isSelectedCell = (animalId: number) =>
|
||||
Boolean(selectionByCell[animalId])
|
||||
const selectedCellCount = Object.keys(selectionByCell).length
|
||||
|
||||
const handleStart = () => {
|
||||
if (authStatus !== 'authenticated') {
|
||||
notify.warning(t('commonUi.toast.loginRequired'))
|
||||
setModalOpen('desktopLogin', true)
|
||||
return
|
||||
}
|
||||
|
||||
clearSelections()
|
||||
markSoundPlaybackUnlocked()
|
||||
requestRealtimeConnection()
|
||||
}
|
||||
|
||||
const handleSelect = (animalId: number) => {
|
||||
if (showStandbyState) {
|
||||
return
|
||||
}
|
||||
|
||||
if (onSelect) {
|
||||
onSelect(animalId)
|
||||
return
|
||||
}
|
||||
|
||||
if (isSelectedCell(animalId)) {
|
||||
removeSelectionsForCell(animalId)
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedCellCount >= maxSelectionCount) {
|
||||
setCellWarning({
|
||||
cellId: animalId,
|
||||
type: 'limit',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBetAmount + (activeChip?.amount ?? 0) > balance) {
|
||||
setCellWarning({
|
||||
cellId: animalId,
|
||||
type: 'balance',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
placeBet(animalId)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (cellWarning === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
setCellWarning((currentWarning) =>
|
||||
currentWarning?.cellId === cellWarning.cellId &&
|
||||
currentWarning.type === cellWarning.type
|
||||
? null
|
||||
: currentWarning,
|
||||
)
|
||||
}, 1200)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
}, [cellWarning])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showStandbyState) {
|
||||
setMarqueeId(null)
|
||||
return
|
||||
}
|
||||
|
||||
setMarqueeId((currentId) => getNextMarqueeId(currentId))
|
||||
|
||||
let timerId = 0
|
||||
|
||||
const loop = () => {
|
||||
setMarqueeId((currentId) => getNextMarqueeId(currentId))
|
||||
timerId = window.setTimeout(loop, 180 + Math.floor(Math.random() * 220))
|
||||
}
|
||||
|
||||
timerId = window.setTimeout(loop, 220)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
}, [showStandbyState])
|
||||
const animalIds = useMemo(() => animalImageList.map((item) => item.id), [])
|
||||
const {
|
||||
cellWarning,
|
||||
handleSelect,
|
||||
handleStart,
|
||||
isRealtimeConnecting,
|
||||
lockInteraction,
|
||||
marqueeId,
|
||||
selectionByCell,
|
||||
showStandbyState,
|
||||
} = useAnimalVm(animalIds, onSelect)
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -77,7 +77,7 @@ export function DesktopCountdown({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex items-center justify-center font-countdown text-design-48 leading-none tracking-[0.08em] text-[#4BFFFE]',
|
||||
'relative z-10 flex items-center justify-center font-countdown text-design-56 leading-none tracking-[0.08em] text-[#4BFFFE]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -72,63 +72,83 @@ export function DesktopGameHistory() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="w-full pb-design-12 last:pb-0">
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="common-neon-inset w-full !rounded-b-none text-center text-design-20 font-bold tracking-[0.08em]"
|
||||
style={{
|
||||
color: item.isWin ? '#FFE375' : '#8DFF98',
|
||||
textShadow: item.isWin
|
||||
? '0 0 calc(var(--design-unit)*10) #FFE375, 0 0 calc(var(--design-unit)*22) rgba(255,227,117,0.48)'
|
||||
: '0 0 calc(var(--design-unit)*10) #8DFF98, 0 0 calc(var(--design-unit)*22) rgba(141,255,152,0.48)',
|
||||
}}
|
||||
>
|
||||
{item.isWin
|
||||
? t('gameDesktop.history.win')
|
||||
: t('gameDesktop.history.lost')}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
const isWin = item.resultState === 'win'
|
||||
const statusLabel =
|
||||
item.resultState === 'pending'
|
||||
? t('gameDesktop.history.pending')
|
||||
: isWin
|
||||
? t('gameDesktop.history.win')
|
||||
: t('gameDesktop.history.lost')
|
||||
const statusColor =
|
||||
item.resultState === 'pending'
|
||||
? '#D5FBFF'
|
||||
: isWin
|
||||
? '#FFE375'
|
||||
: '#8DFF98'
|
||||
const statusTextShadow =
|
||||
item.resultState === 'pending'
|
||||
? '0 0 calc(var(--design-unit)*10) rgba(213,251,255,0.85), 0 0 calc(var(--design-unit)*22) rgba(213,251,255,0.32)'
|
||||
: isWin
|
||||
? '0 0 calc(var(--design-unit)*10) #FFE375, 0 0 calc(var(--design-unit)*22) rgba(255,227,117,0.48)'
|
||||
: '0 0 calc(var(--design-unit)*10) #8DFF98, 0 0 calc(var(--design-unit)*22) rgba(141,255,152,0.48)'
|
||||
|
||||
return (
|
||||
<div key={item.id} className="w-full pb-design-12 last:pb-0">
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
|
||||
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.roundId')}:{' '}
|
||||
</span>
|
||||
<span className={'text-[#C0E7EB]'}>{item.periodNo}</span>
|
||||
<div
|
||||
className="common-neon-inset w-full !rounded-b-none text-center text-design-20 font-bold tracking-[0.08em]"
|
||||
style={{
|
||||
color: statusColor,
|
||||
textShadow: statusTextShadow,
|
||||
}}
|
||||
>
|
||||
{statusLabel}
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.numbers')}:{' '}
|
||||
</span>
|
||||
<span>{item.numbersLabel}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.totalPoolAmount')}:{' '}
|
||||
</span>
|
||||
<span className={'text-[#FFE375]'}>
|
||||
{item.amountLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.winningResult')}:{' '}
|
||||
</span>
|
||||
<span className={'text-[#FF7575]'}>
|
||||
{item.resultNumberLabel}
|
||||
</span>
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.roundId')}:{' '}
|
||||
</span>
|
||||
<span className={'text-[#C0E7EB]'}>
|
||||
{item.periodNo}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.numbers')}:{' '}
|
||||
</span>
|
||||
<span>{item.numbersLabel}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.totalPoolAmount')}:{' '}
|
||||
</span>
|
||||
<span className={'text-[#FFE375]'}>
|
||||
{item.amountLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>
|
||||
{t('gameDesktop.history.winningResult')}:{' '}
|
||||
</span>
|
||||
<span className={'text-[#FF7575]'}>
|
||||
{item.resultNumberLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
<div className="flex min-h-[calc(var(--design-unit)*40)] items-center justify-center text-design-16 text-[#84A2A2]">
|
||||
{isFetchingNextPage ? loadingText : hasNextPage ? '' : endText}
|
||||
</div>
|
||||
|
||||
@@ -6,117 +6,12 @@ import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import logo from '@/assets/system/logo.webp'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
|
||||
import {
|
||||
isDesktopFullscreen,
|
||||
subscribeDesktopFullscreenChange,
|
||||
toggleDesktopFullscreen,
|
||||
} from '@/lib/utils'
|
||||
import {
|
||||
useAudioStore,
|
||||
useAuthStore,
|
||||
useGameSessionStore,
|
||||
useModalStore,
|
||||
} from '@/store'
|
||||
|
||||
type BrowserNetworkInformation = {
|
||||
addEventListener?: (type: 'change', listener: () => void) => void
|
||||
downlink?: number
|
||||
effectiveType?: string
|
||||
removeEventListener?: (type: 'change', listener: () => void) => void
|
||||
rtt?: number
|
||||
}
|
||||
|
||||
type SignalPresentation = {
|
||||
activeBars: number
|
||||
latencyLabel: string
|
||||
toneClassName: string
|
||||
}
|
||||
|
||||
function formatTimezoneOffset(date: Date) {
|
||||
const offsetMinutes = -date.getTimezoneOffset()
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-'
|
||||
const absoluteMinutes = Math.abs(offsetMinutes)
|
||||
const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0')
|
||||
const minutes = String(absoluteMinutes % 60).padStart(2, '0')
|
||||
|
||||
return `GMT${sign}${hours}${minutes === '00' ? '' : `:${minutes}`}`
|
||||
}
|
||||
|
||||
function formatHeaderTime(date: Date) {
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
|
||||
return `${hours}:${minutes}:${seconds} ${formatTimezoneOffset(date)}`
|
||||
}
|
||||
|
||||
function getBrowserNetworkInformation() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (navigator as Navigator & { connection?: BrowserNetworkInformation })
|
||||
.connection
|
||||
}
|
||||
|
||||
function resolveSignalPresentation(input: {
|
||||
isOnline: boolean
|
||||
latencyMs: number | null
|
||||
status: string
|
||||
}) {
|
||||
if (!input.isOnline || input.status === 'disconnected') {
|
||||
return {
|
||||
activeBars: 0,
|
||||
latencyLabel: '--',
|
||||
toneClassName: 'text-[#FF6B6B]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
|
||||
if (input.latencyMs === null) {
|
||||
return {
|
||||
activeBars: input.status === 'connected' ? 2 : 1,
|
||||
latencyLabel: '--',
|
||||
toneClassName: 'text-[#7F8EA3]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
|
||||
if (input.latencyMs <= 80) {
|
||||
return {
|
||||
activeBars: 4,
|
||||
latencyLabel: String(input.latencyMs),
|
||||
toneClassName: 'text-[#74FF69]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
|
||||
if (input.latencyMs <= 150) {
|
||||
return {
|
||||
activeBars: 3,
|
||||
latencyLabel: String(input.latencyMs),
|
||||
toneClassName: 'text-[#B7FF6A]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
|
||||
if (input.latencyMs <= 300) {
|
||||
return {
|
||||
activeBars: 2,
|
||||
latencyLabel: String(input.latencyMs),
|
||||
toneClassName: 'text-[#FFD76A]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
|
||||
return {
|
||||
activeBars: 1,
|
||||
latencyLabel: String(input.latencyMs),
|
||||
toneClassName: 'text-[#FF8A6A]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
import { useHeaderVm } from '@/features/game/hooks/use-header-vm'
|
||||
|
||||
function SignalBars({
|
||||
activeBars,
|
||||
@@ -152,130 +47,25 @@ function SignalBars({
|
||||
|
||||
export function DesktopHeader() {
|
||||
const { t } = useTranslation()
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [clockNow, setClockNow] = useState(() => Date.now())
|
||||
const [isOnline, setIsOnline] = useState(() =>
|
||||
typeof navigator === 'undefined' ? true : navigator.onLine,
|
||||
)
|
||||
const [browserNetworkRttMs, setBrowserNetworkRttMs] = useState<number | null>(
|
||||
() => {
|
||||
const rtt = getBrowserNetworkInformation()?.rtt
|
||||
|
||||
return typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0
|
||||
? rtt
|
||||
: null
|
||||
},
|
||||
)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const isSoundEnabled = useAudioStore((state) => state.isSoundEnabled)
|
||||
const toggleSoundEnabled = useAudioStore((state) => state.toggleSoundEnabled)
|
||||
const connection = useGameSessionStore((state) => state.connection)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const { currentLanguageLabel, currentLanguageOption } = useAppLanguage()
|
||||
const handleOpenUserInfo = () => {
|
||||
setModalOpen('desktopUserInfo', true)
|
||||
}
|
||||
const handleOpenProcedures = () => {
|
||||
setModalOpen('desktopProcedures', true)
|
||||
}
|
||||
|
||||
const serverClockOffsetMs = useMemo(() => {
|
||||
if (
|
||||
connection.status !== 'connected' ||
|
||||
connection.transport !== 'websocket' ||
|
||||
!connection.lastMessageAt
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const serverTimestamp = Date.parse(connection.lastMessageAt)
|
||||
|
||||
if (Number.isNaN(serverTimestamp)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return serverTimestamp - Date.now()
|
||||
}, [connection.lastMessageAt, connection.status, connection.transport])
|
||||
|
||||
const systemTimeLabel = useMemo(() => {
|
||||
const activeTimestamp =
|
||||
serverClockOffsetMs === null ? clockNow : clockNow + serverClockOffsetMs
|
||||
|
||||
return formatHeaderTime(new Date(activeTimestamp))
|
||||
}, [clockNow, serverClockOffsetMs])
|
||||
|
||||
const signalLatencyMs = useMemo(() => {
|
||||
if (
|
||||
typeof connection.latencyMs === 'number' &&
|
||||
Number.isFinite(connection.latencyMs) &&
|
||||
connection.latencyMs >= 0
|
||||
) {
|
||||
return connection.latencyMs
|
||||
}
|
||||
|
||||
return browserNetworkRttMs
|
||||
}, [browserNetworkRttMs, connection.latencyMs])
|
||||
|
||||
const signalPresentation = useMemo(
|
||||
() =>
|
||||
resolveSignalPresentation({
|
||||
isOnline,
|
||||
latencyMs: signalLatencyMs,
|
||||
status: connection.status,
|
||||
}),
|
||||
[connection.status, isOnline, signalLatencyMs],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const syncFullscreenState = () => {
|
||||
setIsFullscreen(isDesktopFullscreen())
|
||||
}
|
||||
syncFullscreenState()
|
||||
return subscribeDesktopFullscreenChange(syncFullscreenState)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setClockNow(Date.now())
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const syncBrowserNetworkState = () => {
|
||||
setIsOnline(navigator.onLine)
|
||||
|
||||
const rtt = getBrowserNetworkInformation()?.rtt
|
||||
|
||||
setBrowserNetworkRttMs(
|
||||
typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0 ? rtt : null,
|
||||
)
|
||||
}
|
||||
|
||||
const networkInformation = getBrowserNetworkInformation()
|
||||
|
||||
syncBrowserNetworkState()
|
||||
window.addEventListener('online', syncBrowserNetworkState)
|
||||
window.addEventListener('offline', syncBrowserNetworkState)
|
||||
networkInformation?.addEventListener?.('change', syncBrowserNetworkState)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', syncBrowserNetworkState)
|
||||
window.removeEventListener('offline', syncBrowserNetworkState)
|
||||
networkInformation?.removeEventListener?.(
|
||||
'change',
|
||||
syncBrowserNetworkState,
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFullscreenToggle = async () => {
|
||||
await toggleDesktopFullscreen()
|
||||
}
|
||||
const {
|
||||
authStatus,
|
||||
currentLanguageLabel,
|
||||
currentLanguageOption,
|
||||
currentUser,
|
||||
handleFullscreenToggle,
|
||||
isFullscreen,
|
||||
isSoundEnabled,
|
||||
onOpenLanguage,
|
||||
onOpenLogin,
|
||||
onOpenNotice,
|
||||
onOpenProcedures,
|
||||
onOpenRegister,
|
||||
onOpenRules,
|
||||
onOpenUserInfo,
|
||||
signalPresentation,
|
||||
systemTimeLabel,
|
||||
toggleSoundEnabled,
|
||||
} = useHeaderVm()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
|
||||
@@ -310,17 +100,21 @@ export function DesktopHeader() {
|
||||
<div className="flex h-full flex-1 items-center justify-around gap-design-10 border-r border-[rgba(128,223,231,0.65)] px-design-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen('desktopRules', true)}
|
||||
onClick={onOpenRules}
|
||||
className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85"
|
||||
>
|
||||
<CircleAlert color={'#57B8BF'} size={16} />
|
||||
<div>{t('gameDesktop.header.rules')}</div>
|
||||
</button>
|
||||
|
||||
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenNotice}
|
||||
className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85"
|
||||
>
|
||||
<Mail color={'#57B8BF'} size={16} />
|
||||
<div>{t('gameDesktop.header.message')}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -338,7 +132,7 @@ export function DesktopHeader() {
|
||||
<div className={'flex items-center justify-center'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen('desktopLanguage', true)}
|
||||
onClick={onOpenLanguage}
|
||||
className={
|
||||
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-fit items-center justify-between gap-design-8 !px-design-20 transition-opacity hover:opacity-85'
|
||||
}
|
||||
@@ -376,7 +170,7 @@ export function DesktopHeader() {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenUserInfo}
|
||||
onClick={onOpenUserInfo}
|
||||
className="group relative flex items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
|
||||
>
|
||||
<SmartImage
|
||||
@@ -396,7 +190,7 @@ export function DesktopHeader() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenProcedures}
|
||||
onClick={onOpenProcedures}
|
||||
className="group relative flex items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
|
||||
>
|
||||
<SmartImage
|
||||
@@ -425,7 +219,7 @@ export function DesktopHeader() {
|
||||
className={
|
||||
'min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85'
|
||||
}
|
||||
onClick={() => setModalOpen('desktopLogin', true)}
|
||||
onClick={onOpenLogin}
|
||||
>
|
||||
<CircleAlert color={'#57B8BF'} size={16} />
|
||||
<div>{t('gameDesktop.header.login')}</div>
|
||||
@@ -435,7 +229,7 @@ export function DesktopHeader() {
|
||||
className={
|
||||
'min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85'
|
||||
}
|
||||
onClick={() => setModalOpen('desktopRegister', true)}
|
||||
onClick={onOpenRegister}
|
||||
>
|
||||
<CircleAlert color={'#57B8BF'} size={16} />
|
||||
<div>{t('gameDesktop.header.register')}</div>
|
||||
|
||||
@@ -26,8 +26,8 @@ export function DesktopStatusLine() {
|
||||
const countdownClassName = useMemo(
|
||||
() =>
|
||||
showWarningCountdown
|
||||
? 'text-[#FF5A5A] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,90,90,0.85),0_0_calc(var(--design-unit)*22)_rgba(255,90,90,0.32)]'
|
||||
: 'text-[#4BFFFE] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(75,255,254,0.85),0_0_calc(var(--design-unit)*22)_rgba(75,255,254,0.32)]',
|
||||
? 'text-design-64 scale-[1.2] text-[#FF5A5A] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,90,90,0.85),0_0_calc(var(--design-unit)*22)_rgba(255,90,90,0.32)]'
|
||||
: 'text-design-64 scale-[1.2] text-[#4BFFFE] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(75,255,254,0.85),0_0_calc(var(--design-unit)*22)_rgba(75,255,254,0.32)]',
|
||||
[showWarningCountdown],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,184 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createDeposit, type DepositTierItem } from '@/features/game/api'
|
||||
import { useDepositTierList } from '@/features/game/hooks/use-deposit-tier-list'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PANEL_CLASS =
|
||||
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.08),0_0_calc(var(--design-unit)*10)_rgba(32,163,186,0.12)]'
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('en-US').format(value)
|
||||
}
|
||||
|
||||
function DesktopTopup() {
|
||||
const { t } = useTranslation()
|
||||
const tierListQuery = useDepositTierList()
|
||||
const tiers = tierListQuery.data ?? []
|
||||
const createDepositInFlightRef = useRef(false)
|
||||
const pendingPayWindowRef = useRef<Window | null>(null)
|
||||
const createDepositMutation = useMutation({
|
||||
mutationFn: ({
|
||||
channelCode,
|
||||
tierId,
|
||||
}: {
|
||||
channelCode: string
|
||||
tierId: string
|
||||
}) =>
|
||||
createDeposit({
|
||||
channel_code: channelCode,
|
||||
idempotency_key: String(Date.now()),
|
||||
tier_id: tierId,
|
||||
}),
|
||||
})
|
||||
|
||||
return <div>{t('gameDesktop.topup.placeholder')}</div>
|
||||
const handleCreateDeposit = async (tier: DepositTierItem) => {
|
||||
if (createDepositInFlightRef.current || createDepositMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
const channelCode = tier.payChannelCode ?? tier.channels[0]?.code ?? ''
|
||||
|
||||
if (!channelCode) {
|
||||
notify.error(t('commonUi.toast.requestFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
createDepositInFlightRef.current = true
|
||||
const payWindow = window.open('', '_blank')
|
||||
|
||||
if (!payWindow) {
|
||||
createDepositInFlightRef.current = false
|
||||
notify.error(t('gameDesktop.topup.tier.openPayUrlFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
payWindow.opener = null
|
||||
pendingPayWindowRef.current = payWindow
|
||||
|
||||
try {
|
||||
const result = await createDepositMutation.mutateAsync({
|
||||
channelCode,
|
||||
tierId: tier.id,
|
||||
})
|
||||
const payUrl = result.pay_url.trim()
|
||||
|
||||
if (!payUrl) {
|
||||
payWindow.close()
|
||||
notify.error(t('gameDesktop.topup.tier.missingPayUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
payWindow.location.replace(payUrl)
|
||||
notify.success(t('gameDesktop.topup.tier.createSuccess'))
|
||||
} catch (error) {
|
||||
payWindow.close()
|
||||
notify.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('commonUi.toast.requestFailed'),
|
||||
)
|
||||
} finally {
|
||||
createDepositInFlightRef.current = false
|
||||
|
||||
if (pendingPayWindowRef.current === payWindow) {
|
||||
pendingPayWindowRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full px-design-12 pb-design-12 text-[#D9FFFF]">
|
||||
<div
|
||||
className={cn(
|
||||
PANEL_CLASS,
|
||||
'flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden px-design-16 py-design-14',
|
||||
)}
|
||||
>
|
||||
<div className="mb-design-10 flex items-center border-b border-[rgba(89,209,223,0.2)] pb-design-10">
|
||||
<div className="text-design-20 font-semibold text-[#9AF5FB]">
|
||||
{t('gameDesktop.topup.tier.title')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tierListQuery.isLoading ? (
|
||||
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.18)] bg-[rgba(6,24,35,0.52)] text-design-16 text-[#8FDDE6]">
|
||||
{t('gameDesktop.topup.tier.loading')}
|
||||
</div>
|
||||
) : tierListQuery.isError ? (
|
||||
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(185,63,68,0.28)] bg-[rgba(34,13,16,0.42)] text-design-16 text-[#F4A9AE]">
|
||||
{t('gameDesktop.topup.tier.failed')}
|
||||
</div>
|
||||
) : tiers.length === 0 ? (
|
||||
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.18)] bg-[rgba(6,24,35,0.52)] text-design-16 text-[#8FDDE6]">
|
||||
{t('gameDesktop.topup.tier.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-w-0 grid-cols-4 gap-design-8">
|
||||
{tiers.map((tier) => (
|
||||
<button
|
||||
key={tier.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleCreateDeposit(tier)
|
||||
}}
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] px-design-10 py-design-10 text-left shadow-[0_0_calc(var(--design-unit)*10)_rgba(88,225,238,0.08)] transition-[transform,border-color,box-shadow] duration-150',
|
||||
createDepositMutation.isPending
|
||||
? 'cursor-wait opacity-80'
|
||||
: 'cursor-pointer hover:-translate-y-[1px] hover:border-[rgba(170,247,255,0.62)] hover:shadow-[0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.14)]',
|
||||
)}
|
||||
>
|
||||
<div className="absolute right-design-8 top-design-8 rounded-full border border-[rgba(121,219,229,0.28)] bg-[rgba(10,39,52,0.7)] px-design-6 py-[2px] text-design-10 leading-none text-[#7CDDE7]">
|
||||
{tier.currency ?? 'FIAT'}
|
||||
</div>
|
||||
|
||||
<div className="pr-design-46 text-design-11 uppercase tracking-[0.06em] text-[#63AEB6]">
|
||||
{tier.title}
|
||||
</div>
|
||||
|
||||
<div className="pt-design-5 text-design-20 font-semibold leading-none text-[#FFE229]">
|
||||
{formatNumber(tier.payAmount)}
|
||||
</div>
|
||||
<div className="pt-design-3 text-design-11 text-[#9FDCE3]">
|
||||
{t('gameDesktop.topup.tier.coins')}:{' '}
|
||||
{formatNumber(tier.totalAmount)}
|
||||
</div>
|
||||
|
||||
<div className="mt-design-8 rounded-[calc(var(--design-unit)*5)] border border-[rgba(89,209,223,0.18)] bg-[rgba(4,19,28,0.58)] px-design-8 py-design-6">
|
||||
<div className="flex items-center justify-between gap-design-8 text-design-11">
|
||||
<span className="text-[#7CE3E8]">
|
||||
{t('gameDesktop.topup.tier.bonus')}
|
||||
</span>
|
||||
<span className="text-[#FFF1C9]">
|
||||
{formatNumber(tier.bonusAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-design-4 flex items-center justify-between gap-design-8 text-design-11">
|
||||
<span className="text-[#7CE3E8]">Channels</span>
|
||||
<span className="line-clamp-1 text-right text-[#6DFF83]">
|
||||
{tier.channels.length > 0
|
||||
? tier.channels
|
||||
.map((channel) => channel.name)
|
||||
.join(', ')
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{tier.desc ? (
|
||||
<div className="mt-design-6 line-clamp-2 text-design-10 leading-[1.3] text-[#6DAAB0]">
|
||||
{tier.desc}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopTopup
|
||||
|
||||
671
src/features/game/components/desktop/desktop-withdraw-copy.tsx
Normal file
671
src/features/game/components/desktop/desktop-withdraw-copy.tsx
Normal file
@@ -0,0 +1,671 @@
|
||||
import { Minus, Plus } from 'lucide-react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select.tsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const AVAILABLE_BALANCE = 6628
|
||||
const MYR_PER_100_DIAMONDS = 1
|
||||
const USDT_TO_MYR_RATE = 4.049
|
||||
const VND_PER_DIAMOND = 10
|
||||
|
||||
const QUICK_AMOUNTS = [
|
||||
{ diamonds: 210, preview: 'MYR 3' },
|
||||
{ diamonds: 2250, preview: 'MYR 30' },
|
||||
{ diamonds: 4000, preview: 'MYR 50' },
|
||||
{ diamonds: 8000, preview: 'MYR 100' },
|
||||
{ diamonds: 17000, preview: 'MYR 200' },
|
||||
{ diamonds: 45000, preview: 'MYR 500' },
|
||||
] as const
|
||||
|
||||
const CURRENCY_OPTIONS = ['MYR'] as const
|
||||
|
||||
const PAYMENT_CHANNELS = [
|
||||
{
|
||||
id: 'alipay-primary',
|
||||
label: 'Alipay',
|
||||
glyph: '支',
|
||||
},
|
||||
{
|
||||
id: 'alipay-secondary',
|
||||
label: 'Alipay',
|
||||
glyph: '支',
|
||||
},
|
||||
{
|
||||
id: 'alipay-third',
|
||||
label: 'Alipay',
|
||||
glyph: '支',
|
||||
},
|
||||
] as const
|
||||
|
||||
const BANK_OPTIONS = [
|
||||
{
|
||||
id: 'bca',
|
||||
label: 'BCA',
|
||||
brand: 'BCA',
|
||||
subtitle: 'Bank Central Asia',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(251,252,255,0.98),rgba(224,239,255,0.96))] text-[#1E53A4]',
|
||||
},
|
||||
{
|
||||
id: 'mandiri',
|
||||
label: 'Mandiri',
|
||||
brand: 'mandiri',
|
||||
subtitle: 'Mandiri',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(26,53,93,0.98),rgba(9,22,43,0.96))] text-[#F5C247]',
|
||||
},
|
||||
{
|
||||
id: 'bni',
|
||||
label: 'BNI',
|
||||
brand: 'BNI',
|
||||
subtitle: 'BNI',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(254,253,252,0.98),rgba(239,242,247,0.96))] text-[#E1742B]',
|
||||
},
|
||||
{
|
||||
id: 'bri',
|
||||
label: 'BRI',
|
||||
brand: 'BRI',
|
||||
subtitle: 'BRI',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(253,254,255,0.98),rgba(234,243,255,0.96))] text-[#0E56A5]',
|
||||
},
|
||||
] as const
|
||||
|
||||
type PaymentChannelId = (typeof PAYMENT_CHANNELS)[number]['id']
|
||||
type BankId = (typeof BANK_OPTIONS)[number]['id']
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US')
|
||||
const fixedTwoFormatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
const fixedSixFormatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 6,
|
||||
maximumFractionDigits: 6,
|
||||
})
|
||||
|
||||
const PANEL_CLASS =
|
||||
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.08),0_0_calc(var(--design-unit)*10)_rgba(32,163,186,0.12)]'
|
||||
|
||||
const SELECTABLE_CARD_CLASS =
|
||||
'flex shrink-0 cursor-pointer flex-col items-center justify-between rounded-[calc(var(--design-unit)*6)] border px-design-8 py-design-8 transition'
|
||||
|
||||
const SELECTABLE_CARD_ACTIVE_CLASS =
|
||||
'border-[#D18A43] bg-[linear-gradient(180deg,rgba(65,45,28,0.92),rgba(39,26,16,0.9))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
|
||||
|
||||
const SELECTABLE_CARD_IDLE_CLASS =
|
||||
'border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(8,34,48,0.92),rgba(5,19,29,0.94))] hover:border-[rgba(170,247,255,0.7)]'
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return numberFormatter.format(value)
|
||||
}
|
||||
|
||||
function formatFixedTwo(value: number) {
|
||||
return fixedTwoFormatter.format(value)
|
||||
}
|
||||
|
||||
function formatFixedSix(value: number) {
|
||||
return fixedSixFormatter.format(value)
|
||||
}
|
||||
|
||||
function WithdrawField({
|
||||
label,
|
||||
children,
|
||||
alignStart = true,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
alignStart?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-design-14">
|
||||
<div className="flex w-design-108 shrink-0 items-center justify-end text-right text-design-16 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
|
||||
<span>{label}</span>
|
||||
<span className="pl-design-4">:</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 flex-1',
|
||||
alignStart ? 'pt-design-2' : 'flex items-center',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AmountShell({
|
||||
amount,
|
||||
availableBalanceText,
|
||||
onMinus,
|
||||
onPlus,
|
||||
}: {
|
||||
amount: number
|
||||
availableBalanceText: string
|
||||
onMinus: () => void
|
||||
onPlus: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-design-6">
|
||||
<div className="flex h-design-52 items-center gap-design-10 rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(14,64,74,0.82),rgba(8,36,47,0.78))] px-design-10 shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(93,239,255,0.08)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMinus}
|
||||
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
||||
>
|
||||
<Minus className="h-design-16 w-design-16" />
|
||||
</button>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center text-design-24 font-medium tracking-[0.04em] text-[#A1EBF3]">
|
||||
{formatNumber(amount)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlus}
|
||||
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
||||
>
|
||||
<Plus className="h-design-16 w-design-16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
|
||||
{availableBalanceText}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickAmountCard({
|
||||
amount,
|
||||
preview,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
amount: number
|
||||
preview: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex h-design-68 w-design-104 shrink-0 cursor-pointer flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border transition',
|
||||
active
|
||||
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(84,48,24,0.92),rgba(60,34,18,0.88))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
|
||||
: 'border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(10,44,58,0.84),rgba(5,21,32,0.92))] hover:border-[rgba(170,247,255,0.7)]',
|
||||
)}
|
||||
>
|
||||
<div className="text-design-24 font-semibold leading-none text-[#FFE229]">
|
||||
{amount}
|
||||
</div>
|
||||
<div className="pt-design-6 text-design-12 uppercase leading-none tracking-[0.04em] text-[#63AEB6]">
|
||||
{preview}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PaymentCard({
|
||||
active,
|
||||
label,
|
||||
glyph,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
glyph: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
SELECTABLE_CARD_CLASS,
|
||||
'h-design-92 w-design-86',
|
||||
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-design-58 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-42 font-semibold leading-none',
|
||||
active
|
||||
? 'bg-[linear-gradient(180deg,#1F9DE8,#0E6BCF)] text-white'
|
||||
: 'bg-[linear-gradient(180deg,#1C96DF,#0B6ECF)] text-white',
|
||||
)}
|
||||
>
|
||||
{glyph}
|
||||
</div>
|
||||
<div className="text-design-14 text-[#AEE8EE]">{label}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function BankCard({
|
||||
active,
|
||||
brand,
|
||||
subtitle,
|
||||
surface,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
brand: string
|
||||
subtitle: string
|
||||
surface: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
SELECTABLE_CARD_CLASS,
|
||||
'h-design-86 w-design-86',
|
||||
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-design-52 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-20 font-bold uppercase',
|
||||
surface,
|
||||
)}
|
||||
>
|
||||
{brand}
|
||||
</div>
|
||||
<div className="text-design-13 text-[#AEE8EE]">{subtitle}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function InputShell({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
error,
|
||||
errorMessage,
|
||||
uppercase = false,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
error?: boolean
|
||||
errorMessage?: string
|
||||
uppercase?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-design-5">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'h-design-42 rounded-[calc(var(--design-unit)*5)] border px-design-14 text-design-16',
|
||||
uppercase && 'uppercase',
|
||||
error
|
||||
? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]'
|
||||
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(10,47,57,0.84),rgba(5,23,32,0.92))] text-[#ACF1F6]',
|
||||
)}
|
||||
/>
|
||||
{error && errorMessage ? (
|
||||
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewRow({
|
||||
label,
|
||||
value,
|
||||
highlight = false,
|
||||
}: {
|
||||
label: string
|
||||
value: ReactNode
|
||||
highlight?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex border-b border-[rgba(89,209,223,0.2)] last:border-b-0">
|
||||
<div className="flex w-[44%] shrink-0 items-center border-r border-[rgba(89,209,223,0.2)] px-design-14 py-design-20 text-design-16 font-medium uppercase leading-[1.15] text-[#7CE3E8]">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center justify-end px-design-14 py-design-20 text-right text-design-16 text-[#E6FFFF]',
|
||||
highlight && 'text-design-18 font-semibold text-[#6DFF83]',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopWithdraw() {
|
||||
const { t } = useTranslation()
|
||||
const [amount, setAmount] = useState(6626)
|
||||
const [currency, setCurrency] =
|
||||
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
|
||||
const [paymentChannel, setPaymentChannel] =
|
||||
useState<PaymentChannelId>('alipay-primary')
|
||||
const [bank, setBank] = useState<BankId>('bca')
|
||||
const [holderName, setHolderName] = useState('')
|
||||
const [bankAccount, setBankAccount] = useState('')
|
||||
const [receiverEmail, setReceiverEmail] = useState('')
|
||||
const [receiverPhone, setReceiverPhone] = useState('')
|
||||
|
||||
const withdrawMyr = amount / 100
|
||||
const withdrawVnd = amount * VND_PER_DIAMOND
|
||||
const withdrawUsdt = withdrawMyr / USDT_TO_MYR_RATE
|
||||
|
||||
const holderNameError = holderName.trim().length === 0
|
||||
const bankAccountError = bankAccount.trim().length === 0
|
||||
|
||||
function handleAmountChange(nextAmount: number) {
|
||||
setAmount(Math.max(0, nextAmount))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full px-design-12 pb-design-12 text-[#D9FFFF]">
|
||||
<div
|
||||
className={cn(
|
||||
PANEL_CLASS,
|
||||
'flex h-full min-h-0 w-full min-w-0 overflow-y-auto',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-full min-w-0 flex-[1.7] flex-col px-design-16 py-design-14">
|
||||
<div className="flex flex-col gap-design-12">
|
||||
<WithdrawField label={t('提现钻石数量')}>
|
||||
<AmountShell
|
||||
amount={amount}
|
||||
availableBalanceText={t(
|
||||
'gameDesktop.withdraw.availableBalance',
|
||||
{ amount: formatNumber(AVAILABLE_BALANCE) },
|
||||
)}
|
||||
onMinus={() => handleAmountChange(amount - 1)}
|
||||
onPlus={() => handleAmountChange(amount + 1)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label={t('货币类型')} alignStart={false}>
|
||||
<Select
|
||||
value={currency}
|
||||
onValueChange={(value) =>
|
||||
setCurrency(value as (typeof CURRENCY_OPTIONS)[number])
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-design-52 w-full rounded-[calc(var(--design-unit)*6)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-16 text-left text-design-20 font-semibold text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-52 [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
|
||||
aria-label={t('gameDesktop.withdraw.currencySelection')}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t('gameDesktop.withdraw.selectCurrency')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="min-w-(--radix-select-trigger-width) rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(8,36,48,0.98),rgba(4,18,28,0.98))] text-[#CFFDFF] shadow-[0_0_calc(var(--design-unit)*16)_rgba(56,241,255,0.12)]"
|
||||
>
|
||||
{CURRENCY_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option}
|
||||
value={option}
|
||||
className="rounded-[calc(var(--design-unit)*4)] px-design-12 py-design-10 text-design-18 focus:bg-[rgba(53,154,171,0.2)] focus:text-white"
|
||||
>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</WithdrawField>
|
||||
|
||||
<div className="flex gap-design-14">
|
||||
<div className="w-design-108 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-wrap gap-design-10">
|
||||
{QUICK_AMOUNTS.map((option) => (
|
||||
<QuickAmountCard
|
||||
key={option.diamonds}
|
||||
amount={option.diamonds}
|
||||
preview={option.preview}
|
||||
active={option.diamonds === amount}
|
||||
onClick={() => handleAmountChange(option.diamonds)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WithdrawField label={t('支付渠道')}>
|
||||
<div className="flex flex-wrap gap-design-10">
|
||||
{PAYMENT_CHANNELS.map((channel) => (
|
||||
<PaymentCard
|
||||
key={channel.id}
|
||||
active={channel.id === paymentChannel}
|
||||
label={channel.label}
|
||||
glyph={channel.glyph}
|
||||
onClick={() => setPaymentChannel(channel.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
|
||||
<div className="flex flex-col gap-design-10">
|
||||
<div className="flex flex-wrap gap-design-10">
|
||||
{BANK_OPTIONS.map((option) => (
|
||||
<BankCard
|
||||
key={option.id}
|
||||
active={option.id === bank}
|
||||
brand={option.brand}
|
||||
subtitle={option.label}
|
||||
surface={option.surface}
|
||||
onClick={() => setBank(option.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.cardHolderName')}
|
||||
>
|
||||
<InputShell
|
||||
value={holderName}
|
||||
onChange={setHolderName}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.cardHolderName',
|
||||
)}
|
||||
error={holderNameError}
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.cardHolderNameRequired',
|
||||
)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
|
||||
>
|
||||
<InputShell
|
||||
value={bankAccount}
|
||||
onChange={setBankAccount}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.bankAccountNumber',
|
||||
)}
|
||||
error={bankAccountError}
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.bankAccountRequired',
|
||||
)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label={t('收款人邮箱')} alignStart={false}>
|
||||
<InputShell
|
||||
value={receiverEmail}
|
||||
onChange={setReceiverEmail}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.receiverEmail',
|
||||
)}
|
||||
uppercase={true}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.receiverPhone')}
|
||||
alignStart={false}
|
||||
>
|
||||
<InputShell
|
||||
value={receiverPhone}
|
||||
onChange={setReceiverPhone}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.receiverPhone',
|
||||
)}
|
||||
uppercase={true}
|
||||
/>
|
||||
</WithdrawField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px shrink-0 bg-[linear-gradient(180deg,rgba(89,209,223,0)_0%,rgba(89,209,223,0.4)_12%,rgba(89,209,223,0.5)_88%,rgba(89,209,223,0)_100%)]" />
|
||||
|
||||
<div className="flex min-h-full min-w-0 w-design-520 shrink-0 flex-col">
|
||||
<div className="flex h-design-44 items-center border-b border-[rgba(89,209,223,0.2)] bg-[linear-gradient(90deg,rgba(18,99,110,0.8),rgba(7,68,79,0.9))] px-design-12 text-design-20 font-semibold uppercase tracking-[0.04em] text-[#9AF5FB]">
|
||||
{t('gameDesktop.withdraw.preview.title')}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-design-12 px-design-10 py-design-10">
|
||||
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.diamondAmount')}
|
||||
value={formatNumber(amount)}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.exchangeRate', {
|
||||
currency: 'MYR',
|
||||
})}
|
||||
value={t('gameDesktop.withdraw.preview.exchangeRateValue', {
|
||||
coins: 100 * MYR_PER_100_DIAMONDS,
|
||||
currency: 'MYR',
|
||||
platformCoinLabel: '钻石',
|
||||
})}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.convertible', {
|
||||
currency: 'MYR',
|
||||
})}
|
||||
value={`RM ${formatFixedTwo(withdrawMyr)}`}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.exchangeRate', {
|
||||
currency: 'USDT',
|
||||
})}
|
||||
value={t('gameDesktop.withdraw.preview.exchangeRateValue', {
|
||||
coins: formatFixedTwo(100 * USDT_TO_MYR_RATE),
|
||||
currency: 'USDT',
|
||||
platformCoinLabel: '钻石',
|
||||
})}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.convertible', {
|
||||
currency: 'VND',
|
||||
})}
|
||||
value={`${formatNumber(withdrawVnd)} VND`}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.convertible', {
|
||||
currency: 'USDT',
|
||||
})}
|
||||
value={`${formatFixedSix(withdrawUsdt)} USDT`}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t(
|
||||
'gameDesktop.withdraw.preview.fixedExchangeDiamondAmount',
|
||||
)}
|
||||
value="0-0-0 0:0:0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-12 py-design-10 text-design-16 leading-[1.35] text-[#F0B44A]">
|
||||
{t('gameDesktop.withdraw.referenceRateNotice')}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
|
||||
<div>
|
||||
{t('gameDesktop.withdraw.eWallet')}:{' '}
|
||||
<span className="text-[#B9F4F8]">
|
||||
{t('gameDesktop.withdraw.minimumAmount', {
|
||||
amount: '10',
|
||||
currency: 'MYR',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('gameDesktop.withdraw.bank')}:{' '}
|
||||
<span className="text-[#B9F4F8]">
|
||||
{t('gameDesktop.withdraw.minimumAmount', {
|
||||
amount: '10',
|
||||
currency: 'MYR',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('gameDesktop.withdraw.processingTime')}:{' '}
|
||||
<span className="text-[#77FF76]">
|
||||
{t('gameDesktop.withdraw.arrivalTimeValue')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('gameDesktop.withdraw.notice')}:{' '}
|
||||
<span className="text-red-700">
|
||||
{t('gameDesktop.withdraw.feeNotice')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-end justify-between gap-design-10 pt-design-10">
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthGreenBtn}
|
||||
size="100% 100%"
|
||||
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-18 font-bold uppercase tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
{t('gameDesktop.withdraw.cancel')}
|
||||
</SmartBackground>
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 100%"
|
||||
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
{t('gameDesktop.withdraw.confirm')}
|
||||
<br />
|
||||
{t('gameDesktop.withdraw.withdrawal')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopWithdraw
|
||||
@@ -12,112 +12,24 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select.tsx'
|
||||
import { useWithdrawSubmit } from '@/features/game/hooks/use-withdraw-submit'
|
||||
import { useWithdrawVm } from '@/features/game/hooks/use-withdraw-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const AVAILABLE_BALANCE = 6628
|
||||
const MYR_PER_100_DIAMONDS = 1
|
||||
const USDT_TO_MYR_RATE = 4.049
|
||||
const VND_PER_DIAMOND = 10
|
||||
|
||||
const QUICK_AMOUNTS = [
|
||||
{ diamonds: 210, preview: 'MYR 3' },
|
||||
{ diamonds: 2250, preview: 'MYR 30' },
|
||||
{ diamonds: 4000, preview: 'MYR 50' },
|
||||
{ diamonds: 8000, preview: 'MYR 100' },
|
||||
{ diamonds: 17000, preview: 'MYR 200' },
|
||||
{ diamonds: 45000, preview: 'MYR 500' },
|
||||
] as const
|
||||
|
||||
const CURRENCY_OPTIONS = ['MYR'] as const
|
||||
|
||||
const PAYMENT_CHANNELS = [
|
||||
{
|
||||
id: 'alipay-primary',
|
||||
label: 'Alipay',
|
||||
glyph: '支',
|
||||
},
|
||||
{
|
||||
id: 'alipay-secondary',
|
||||
label: 'Alipay',
|
||||
glyph: '支',
|
||||
},
|
||||
{
|
||||
id: 'alipay-third',
|
||||
label: 'Alipay',
|
||||
glyph: '支',
|
||||
},
|
||||
] as const
|
||||
|
||||
const BANK_OPTIONS = [
|
||||
{
|
||||
id: 'bca',
|
||||
label: 'BCA',
|
||||
brand: 'BCA',
|
||||
subtitle: 'Bank Central Asia',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(251,252,255,0.98),rgba(224,239,255,0.96))] text-[#1E53A4]',
|
||||
},
|
||||
{
|
||||
id: 'mandiri',
|
||||
label: 'Mandiri',
|
||||
brand: 'mandiri',
|
||||
subtitle: 'Mandiri',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(26,53,93,0.98),rgba(9,22,43,0.96))] text-[#F5C247]',
|
||||
},
|
||||
{
|
||||
id: 'bni',
|
||||
label: 'BNI',
|
||||
brand: 'BNI',
|
||||
subtitle: 'BNI',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(254,253,252,0.98),rgba(239,242,247,0.96))] text-[#E1742B]',
|
||||
},
|
||||
{
|
||||
id: 'bri',
|
||||
label: 'BRI',
|
||||
brand: 'BRI',
|
||||
subtitle: 'BRI',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(253,254,255,0.98),rgba(234,243,255,0.96))] text-[#0E56A5]',
|
||||
},
|
||||
] as const
|
||||
|
||||
type PaymentChannelId = (typeof PAYMENT_CHANNELS)[number]['id']
|
||||
type BankId = (typeof BANK_OPTIONS)[number]['id']
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US')
|
||||
const fixedTwoFormatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
const fixedSixFormatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 6,
|
||||
maximumFractionDigits: 6,
|
||||
})
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
const PANEL_CLASS =
|
||||
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.08),0_0_calc(var(--design-unit)*10)_rgba(32,163,186,0.12)]'
|
||||
|
||||
const SELECTABLE_CARD_CLASS =
|
||||
'flex shrink-0 cursor-pointer flex-col items-center justify-between rounded-[calc(var(--design-unit)*6)] border px-design-8 py-design-8 transition'
|
||||
|
||||
const SELECTABLE_CARD_ACTIVE_CLASS =
|
||||
'border-[#D18A43] bg-[linear-gradient(180deg,rgba(65,45,28,0.92),rgba(39,26,16,0.9))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
|
||||
|
||||
const SELECTABLE_CARD_IDLE_CLASS =
|
||||
'border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(8,34,48,0.92),rgba(5,19,29,0.94))] hover:border-[rgba(170,247,255,0.7)]'
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return numberFormatter.format(value)
|
||||
return new Intl.NumberFormat('en-US').format(value)
|
||||
}
|
||||
|
||||
function formatFixedTwo(value: number) {
|
||||
return fixedTwoFormatter.format(value)
|
||||
}
|
||||
function getPaymentGlyph(code: string, name: string) {
|
||||
if (code.toLowerCase().includes('alipay')) {
|
||||
return '支'
|
||||
}
|
||||
|
||||
function formatFixedSix(value: number) {
|
||||
return fixedSixFormatter.format(value)
|
||||
return name.trim().slice(0, 1).toUpperCase() || code.slice(0, 1).toUpperCase()
|
||||
}
|
||||
|
||||
function WithdrawField({
|
||||
@@ -131,7 +43,7 @@ function WithdrawField({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-design-14">
|
||||
<div className="flex w-design-108 shrink-0 items-center justify-end text-right text-design-16 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
|
||||
<div className="flex w-design-132 shrink-0 items-center justify-end whitespace-nowrap text-right text-design-16 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
|
||||
<span>{label}</span>
|
||||
<span className="pl-design-4">:</span>
|
||||
</div>
|
||||
@@ -150,14 +62,22 @@ function WithdrawField({
|
||||
function AmountShell({
|
||||
amount,
|
||||
availableBalanceText,
|
||||
onAmountChange,
|
||||
onMinus,
|
||||
onPlus,
|
||||
}: {
|
||||
amount: number
|
||||
availableBalanceText: string
|
||||
onAmountChange: (value: number) => void
|
||||
onMinus: () => void
|
||||
onPlus: () => void
|
||||
}) {
|
||||
function handleInputChange(value: string) {
|
||||
const nextValue = Number(value.replace(/[^\d]/g, ''))
|
||||
|
||||
onAmountChange(Number.isFinite(nextValue) ? nextValue : 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-design-6">
|
||||
<div className="flex h-design-52 items-center gap-design-10 rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(14,64,74,0.82),rgba(8,36,47,0.78))] px-design-10 shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(93,239,255,0.08)]">
|
||||
@@ -169,9 +89,14 @@ function AmountShell({
|
||||
<Minus className="h-design-16 w-design-16" />
|
||||
</button>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center text-design-24 font-medium tracking-[0.04em] text-[#A1EBF3]">
|
||||
{formatNumber(amount)}
|
||||
</div>
|
||||
<input
|
||||
value={amount === 0 ? '' : String(amount)}
|
||||
onChange={(event) => handleInputChange(event.target.value)}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-center text-design-24 font-medium tracking-[0.04em] text-[#A1EBF3] outline-none placeholder:text-[rgba(109,170,176,0.55)]"
|
||||
placeholder="0"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -205,16 +130,29 @@ function QuickAmountCard({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex h-design-68 w-design-104 shrink-0 cursor-pointer flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border transition',
|
||||
'group relative flex h-design-76 min-w-0 w-full cursor-pointer flex-col items-start justify-center overflow-hidden rounded-[calc(var(--design-unit)*8)] border px-design-12 text-left transition-[transform,border-color,background-color,box-shadow] duration-150',
|
||||
active
|
||||
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(84,48,24,0.92),rgba(60,34,18,0.88))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
|
||||
: 'border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(10,44,58,0.84),rgba(5,21,32,0.92))] hover:border-[rgba(170,247,255,0.7)]',
|
||||
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(88,54,28,0.96),rgba(56,33,18,0.92))] shadow-[0_0_calc(var(--design-unit)*14)_rgba(209,138,67,0.22),inset_0_0_calc(var(--design-unit)*12)_rgba(255,217,120,0.08)]'
|
||||
: 'border-[rgba(103,227,239,0.26)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] hover:-translate-y-[1px] hover:border-[rgba(170,247,255,0.62)] hover:shadow-[0_0_calc(var(--design-unit)*12)_rgba(88,225,238,0.12)]',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute right-design-10 top-design-10 h-design-8 w-design-8 rounded-full transition',
|
||||
active
|
||||
? 'bg-[#FFD15E] shadow-[0_0_10px_rgba(255,209,94,0.8)]'
|
||||
: 'bg-[rgba(122,220,230,0.26)]',
|
||||
)}
|
||||
/>
|
||||
<div className="text-design-24 font-semibold leading-none text-[#FFE229]">
|
||||
{amount}
|
||||
</div>
|
||||
<div className="pt-design-6 text-design-12 uppercase leading-none tracking-[0.04em] text-[#63AEB6]">
|
||||
<div
|
||||
className={cn(
|
||||
'pt-design-6 text-design-12 leading-none tracking-[0.04em]',
|
||||
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
|
||||
)}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
</button>
|
||||
@@ -237,58 +175,48 @@ function PaymentCard({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
SELECTABLE_CARD_CLASS,
|
||||
'h-design-92 w-design-86',
|
||||
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
|
||||
'group relative flex h-design-76 min-w-design-120 cursor-pointer items-center gap-design-10 rounded-[calc(var(--design-unit)*8)] border px-design-12 text-left transition-[transform,border-color,background-color,box-shadow] duration-150',
|
||||
active
|
||||
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(88,54,28,0.96),rgba(56,33,18,0.92))] shadow-[0_0_calc(var(--design-unit)*14)_rgba(209,138,67,0.18),inset_0_0_calc(var(--design-unit)*12)_rgba(255,217,120,0.08)]'
|
||||
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] hover:-translate-y-[1px] hover:border-[rgba(170,247,255,0.62)] hover:shadow-[0_0_calc(var(--design-unit)*12)_rgba(88,225,238,0.12)]',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute right-design-10 top-design-10 h-design-8 w-design-8 rounded-full transition',
|
||||
active
|
||||
? 'bg-[#FFD15E] shadow-[0_0_10px_rgba(255,209,94,0.8)]'
|
||||
: 'bg-[rgba(122,220,230,0.26)]',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-design-58 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-42 font-semibold leading-none',
|
||||
'flex h-design-42 w-design-42 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border text-design-22 font-semibold leading-none transition-colors',
|
||||
active
|
||||
? 'bg-[linear-gradient(180deg,#1F9DE8,#0E6BCF)] text-white'
|
||||
: 'bg-[linear-gradient(180deg,#1C96DF,#0B6ECF)] text-white',
|
||||
? 'border-[rgba(255,218,132,0.45)] bg-[rgba(255,211,113,0.16)] text-[#FFD97A]'
|
||||
: 'border-[rgba(121,219,229,0.28)] bg-[rgba(10,39,52,0.7)] text-[#8DE4EA]',
|
||||
)}
|
||||
>
|
||||
{glyph}
|
||||
</div>
|
||||
<div className="text-design-14 text-[#AEE8EE]">{label}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function BankCard({
|
||||
active,
|
||||
brand,
|
||||
subtitle,
|
||||
surface,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
brand: string
|
||||
subtitle: string
|
||||
surface: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
SELECTABLE_CARD_CLASS,
|
||||
'h-design-86 w-design-86',
|
||||
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-design-52 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-20 font-bold uppercase',
|
||||
surface,
|
||||
)}
|
||||
>
|
||||
{brand}
|
||||
<div className="min-w-0 flex-1 pr-design-10">
|
||||
<div
|
||||
className={cn(
|
||||
'truncate text-design-16 font-medium leading-none',
|
||||
active ? 'text-[#FFF1C9]' : 'text-[#D7FBFF]',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'pt-design-6 text-design-11 uppercase leading-none tracking-[0.08em]',
|
||||
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
|
||||
)}
|
||||
>
|
||||
Channel
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-design-13 text-[#AEE8EE]">{subtitle}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -300,6 +228,7 @@ function InputShell({
|
||||
error,
|
||||
errorMessage,
|
||||
uppercase = false,
|
||||
type = 'text',
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
@@ -307,15 +236,17 @@ function InputShell({
|
||||
error?: boolean
|
||||
errorMessage?: string
|
||||
uppercase?: boolean
|
||||
type?: 'text' | 'email' | 'tel'
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-design-5">
|
||||
<div className="flex w-full flex-col gap-design-5">
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'h-design-42 rounded-[calc(var(--design-unit)*5)] border px-design-14 text-design-16',
|
||||
'h-design-42 rounded-[calc(var(--design-unit)*5)] border text-design-16',
|
||||
uppercase && 'uppercase',
|
||||
error
|
||||
? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]'
|
||||
@@ -359,27 +290,73 @@ function PreviewRow({
|
||||
|
||||
function DesktopWithdraw() {
|
||||
const { t } = useTranslation()
|
||||
const [amount, setAmount] = useState(6626)
|
||||
const [currency, setCurrency] =
|
||||
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
|
||||
const [paymentChannel, setPaymentChannel] =
|
||||
useState<PaymentChannelId>('alipay-primary')
|
||||
const [bank, setBank] = useState<BankId>('bca')
|
||||
const [holderName, setHolderName] = useState('')
|
||||
const [bankAccount, setBankAccount] = useState('')
|
||||
const [receiverEmail, setReceiverEmail] = useState('')
|
||||
const [receiverPhone, setReceiverPhone] = useState('')
|
||||
|
||||
const withdrawMyr = amount / 100
|
||||
const withdrawVnd = amount * VND_PER_DIAMOND
|
||||
const withdrawUsdt = withdrawMyr / USDT_TO_MYR_RATE
|
||||
|
||||
const selectedBank = BANK_OPTIONS.find((item) => item.id === bank)
|
||||
const holderNameError = holderName.trim().length === 0
|
||||
const bankAccountError = bankAccount.trim().length === 0
|
||||
const vm = useWithdrawVm()
|
||||
const withdrawSubmitMutation = useWithdrawSubmit()
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false)
|
||||
const [activeQuickAmountId, setActiveQuickAmountId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
function handleAmountChange(nextAmount: number) {
|
||||
setAmount(Math.max(0, nextAmount))
|
||||
vm.setAmount(Math.max(0, nextAmount))
|
||||
setActiveQuickAmountId(null)
|
||||
}
|
||||
|
||||
function handleQuickAmountSelect(optionId: string, amount: number) {
|
||||
vm.setAmount(Math.max(0, amount))
|
||||
setActiveQuickAmountId(optionId)
|
||||
}
|
||||
|
||||
function resetWithdrawFormState() {
|
||||
setHasSubmitted(false)
|
||||
setActiveQuickAmountId(null)
|
||||
vm.resetForm()
|
||||
}
|
||||
|
||||
function handleCloseWithdraw() {
|
||||
resetWithdrawFormState()
|
||||
setModalOpen('desktopWithdrawTopup', false)
|
||||
}
|
||||
|
||||
function handleConfirmWithdraw() {
|
||||
if (withdrawSubmitMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
setHasSubmitted(true)
|
||||
|
||||
if (
|
||||
vm.amountRequiredError ||
|
||||
vm.amountExceedsBalance ||
|
||||
vm.holderNameError ||
|
||||
vm.bankAccountError ||
|
||||
vm.paymentChannelCodeError ||
|
||||
vm.bankCodeError ||
|
||||
vm.receiverEmailError ||
|
||||
vm.receiverPhoneError
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
withdrawSubmitMutation.mutate(
|
||||
{
|
||||
bank_code: vm.bankCode,
|
||||
channel_code: vm.paymentChannelCode,
|
||||
idempotency_key: String(Date.now()),
|
||||
receive_account: vm.bankAccount.trim(),
|
||||
receiver_email: vm.receiverEmail.trim(),
|
||||
receiver_mobile: vm.receiverPhone.trim(),
|
||||
receiver_name: vm.holderName.trim(),
|
||||
receive_type: 'bank',
|
||||
withdraw_coin: vm.amount,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleCloseWithdraw()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -393,102 +370,145 @@ function DesktopWithdraw() {
|
||||
<div className="flex min-h-full min-w-0 flex-[1.7] flex-col px-design-16 py-design-14">
|
||||
<div className="flex flex-col gap-design-12">
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.diamondWithdrawalAmount')}
|
||||
label={t('gameDesktop.withdraw.fields.diamondAmount')}
|
||||
>
|
||||
<AmountShell
|
||||
amount={amount}
|
||||
amount={vm.amount}
|
||||
availableBalanceText={t(
|
||||
'gameDesktop.withdraw.availableBalance',
|
||||
{ amount: formatNumber(AVAILABLE_BALANCE) },
|
||||
{ amount: formatNumber(vm.availableBalance) },
|
||||
)}
|
||||
onMinus={() => handleAmountChange(amount - 1)}
|
||||
onPlus={() => handleAmountChange(amount + 1)}
|
||||
onAmountChange={handleAmountChange}
|
||||
onMinus={() => handleAmountChange(vm.amount - 1)}
|
||||
onPlus={() => handleAmountChange(vm.amount + 1)}
|
||||
/>
|
||||
{hasSubmitted && vm.amountRequiredError ? (
|
||||
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
|
||||
{t('gameDesktop.withdraw.errors.amountRequired')}
|
||||
</div>
|
||||
) : null}
|
||||
{hasSubmitted && vm.amountExceedsBalance ? (
|
||||
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
|
||||
{t('gameDesktop.withdraw.errors.amountExceedsBalance')}
|
||||
</div>
|
||||
) : null}
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.currencyType')}
|
||||
alignStart={false}
|
||||
>
|
||||
<Select
|
||||
value={currency}
|
||||
onValueChange={(value) =>
|
||||
setCurrency(value as (typeof CURRENCY_OPTIONS)[number])
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-design-52 w-full rounded-[calc(var(--design-unit)*6)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-16 text-left text-design-20 font-semibold text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-52 [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
|
||||
aria-label={t('gameDesktop.withdraw.currencySelection')}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t('gameDesktop.withdraw.selectCurrency')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="min-w-(--radix-select-trigger-width) rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(8,36,48,0.98),rgba(4,18,28,0.98))] text-[#CFFDFF] shadow-[0_0_calc(var(--design-unit)*16)_rgba(56,241,255,0.12)]"
|
||||
>
|
||||
{CURRENCY_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option}
|
||||
value={option}
|
||||
className="rounded-[calc(var(--design-unit)*4)] px-design-12 py-design-10 text-design-18 focus:bg-[rgba(53,154,171,0.2)] focus:text-white"
|
||||
>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</WithdrawField>
|
||||
|
||||
<div className="flex gap-design-14">
|
||||
<div className="w-design-108 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-wrap gap-design-10">
|
||||
{QUICK_AMOUNTS.map((option) => (
|
||||
<div className="flex items-start gap-design-14">
|
||||
<div className="w-design-132 shrink-0" />
|
||||
<div className="grid min-w-0 flex-1 grid-cols-3 gap-design-10">
|
||||
{vm.quickAmounts.map((option) => (
|
||||
<QuickAmountCard
|
||||
key={option.diamonds}
|
||||
key={option.id}
|
||||
amount={option.diamonds}
|
||||
preview={option.preview}
|
||||
active={option.diamonds === amount}
|
||||
onClick={() => handleAmountChange(option.diamonds)}
|
||||
active={option.id === activeQuickAmountId}
|
||||
onClick={() =>
|
||||
handleQuickAmountSelect(option.id, option.diamonds)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.currencyType')}
|
||||
alignStart={false}
|
||||
>
|
||||
<Select
|
||||
value={vm.currencyCode}
|
||||
onValueChange={vm.setCurrencyCode}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-design-42 w-full rounded-[calc(var(--design-unit)*5)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-14 text-left !text-design-16 text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-42 data-[placeholder]:text-[rgba(109,170,176,0.55)] [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
|
||||
aria-label={t('gameDesktop.withdraw.currencySelection')}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t('gameDesktop.withdraw.selectCurrency')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vm.config.currencies.map((option) => (
|
||||
<SelectItem key={option.code} value={option.code}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.paymentChannel')}
|
||||
>
|
||||
<div className="flex flex-wrap gap-design-10">
|
||||
{PAYMENT_CHANNELS.map((channel) => (
|
||||
<PaymentCard
|
||||
key={channel.id}
|
||||
active={channel.id === paymentChannel}
|
||||
label={channel.label}
|
||||
glyph={channel.glyph}
|
||||
onClick={() => setPaymentChannel(channel.id)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex w-full flex-col gap-design-5">
|
||||
{vm.sortedPayChannels.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-design-10">
|
||||
{vm.sortedPayChannels.map((channel) => (
|
||||
<PaymentCard
|
||||
key={channel.code}
|
||||
active={channel.code === vm.paymentChannelCode}
|
||||
label={channel.name}
|
||||
glyph={getPaymentGlyph(channel.code, channel.name)}
|
||||
onClick={() => vm.setPaymentChannelCode(channel.code)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-design-76 items-center rounded-[calc(var(--design-unit)*8)] border border-[rgba(185,63,68,0.45)] bg-[rgba(34,13,16,0.6)] px-design-12 text-design-14 text-[#F4B1B1]">
|
||||
{t('gameDesktop.withdraw.errors.paymentChannelUnavailable')}
|
||||
</div>
|
||||
)}
|
||||
{hasSubmitted && vm.paymentChannelCodeError ? (
|
||||
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
|
||||
{t('gameDesktop.withdraw.errors.paymentChannelRequired')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
|
||||
<div className="flex flex-col gap-design-10">
|
||||
<div className="flex h-design-40 items-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(12,61,72,0.78),rgba(6,28,39,0.88))] px-design-12 text-design-15 uppercase tracking-[0.02em] text-[#A4EAF2] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.07)]">
|
||||
{`014${selectedBank?.label ?? 'BCA'} (${selectedBank?.subtitle ?? 'BANK CENTRAL ASIA'}): 014`}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-design-10">
|
||||
{BANK_OPTIONS.map((option) => (
|
||||
<BankCard
|
||||
key={option.id}
|
||||
active={option.id === bank}
|
||||
brand={option.brand}
|
||||
subtitle={option.label}
|
||||
surface={option.surface}
|
||||
onClick={() => setBank(option.id)}
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.bankCode')}
|
||||
alignStart={false}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-design-5">
|
||||
<Select
|
||||
value={vm.bankCode}
|
||||
onValueChange={vm.setBankCode}
|
||||
disabled={vm.sortedBanks.length === 0}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'h-design-42 w-full rounded-[calc(var(--design-unit)*5)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-14 text-left !text-design-18 text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-42 data-[placeholder]:text-[rgba(109,170,176,0.55)] [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]',
|
||||
hasSubmitted && vm.bankCodeError
|
||||
? 'border-[#B93F44]'
|
||||
: 'border-[rgba(103,227,239,0.3)]',
|
||||
)}
|
||||
aria-label={t('gameDesktop.withdraw.fields.bankCode')}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.bankCode',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vm.sortedBanks.map((bank) => (
|
||||
<SelectItem key={bank.code} value={bank.code}>
|
||||
{bank.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{vm.sortedBanks.length === 0 ? (
|
||||
<div className="pl-design-2 text-design-13 text-[#F4B1B1]">
|
||||
{t('gameDesktop.withdraw.errors.bankCodeUnavailable')}
|
||||
</div>
|
||||
) : null}
|
||||
{hasSubmitted && vm.bankCodeError ? (
|
||||
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
|
||||
{t('gameDesktop.withdraw.errors.bankCodeRequired')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</WithdrawField>
|
||||
|
||||
@@ -496,12 +516,12 @@ function DesktopWithdraw() {
|
||||
label={t('gameDesktop.withdraw.fields.cardHolderName')}
|
||||
>
|
||||
<InputShell
|
||||
value={holderName}
|
||||
onChange={setHolderName}
|
||||
value={vm.holderName}
|
||||
onChange={vm.setHolderName}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.cardHolderName',
|
||||
)}
|
||||
error={holderNameError}
|
||||
error={hasSubmitted && vm.holderNameError}
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.cardHolderNameRequired',
|
||||
)}
|
||||
@@ -512,12 +532,12 @@ function DesktopWithdraw() {
|
||||
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
|
||||
>
|
||||
<InputShell
|
||||
value={bankAccount}
|
||||
onChange={setBankAccount}
|
||||
value={vm.bankAccount}
|
||||
onChange={vm.setBankAccount}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.bankAccountNumber',
|
||||
)}
|
||||
error={bankAccountError}
|
||||
error={hasSubmitted && vm.bankAccountError}
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.bankAccountRequired',
|
||||
)}
|
||||
@@ -526,29 +546,35 @@ function DesktopWithdraw() {
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.receiverEmail')}
|
||||
alignStart={false}
|
||||
>
|
||||
<InputShell
|
||||
value={receiverEmail}
|
||||
onChange={setReceiverEmail}
|
||||
value={vm.receiverEmail}
|
||||
onChange={vm.setReceiverEmail}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.receiverEmail',
|
||||
)}
|
||||
uppercase={true}
|
||||
type="email"
|
||||
error={hasSubmitted && vm.receiverEmailError}
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.receiverEmailInvalid',
|
||||
)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.receiverPhone')}
|
||||
alignStart={false}
|
||||
>
|
||||
<InputShell
|
||||
value={receiverPhone}
|
||||
onChange={setReceiverPhone}
|
||||
value={vm.receiverPhone}
|
||||
onChange={vm.setReceiverPhone}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.receiverPhone',
|
||||
)}
|
||||
uppercase={true}
|
||||
type="tel"
|
||||
error={hasSubmitted && vm.receiverPhoneError}
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.receiverPhoneInvalid',
|
||||
)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
</div>
|
||||
@@ -565,39 +591,15 @@ function DesktopWithdraw() {
|
||||
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.diamondAmount')}
|
||||
value={formatNumber(amount)}
|
||||
value={formatNumber(vm.amount)}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.rateMyr')}
|
||||
value={t('gameDesktop.withdraw.preview.rateMyrValue', {
|
||||
diamonds: 100 * MYR_PER_100_DIAMONDS,
|
||||
})}
|
||||
label={vm.selectedCurrencyPreview.exchangeRateLabel}
|
||||
value={vm.selectedCurrencyPreview.exchangeRateValue}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.convertibleMyr')}
|
||||
value={`RM ${formatFixedTwo(withdrawMyr)}`}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.usdtMyrRate')}
|
||||
value={t('gameDesktop.withdraw.preview.usdtMyrRateValue', {
|
||||
rate: USDT_TO_MYR_RATE,
|
||||
})}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.rateVnd')}
|
||||
value={t('gameDesktop.withdraw.preview.rateVndValue', {
|
||||
diamonds: VND_PER_DIAMOND,
|
||||
})}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.convertibleVnd')}
|
||||
value={`${formatNumber(withdrawVnd)} VND`}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.convertibleUsdt')}
|
||||
value={`${formatFixedSix(withdrawUsdt)} USDT`}
|
||||
label={vm.selectedCurrencyPreview.convertibleLabel}
|
||||
value={vm.selectedCurrencyPreview.convertibleValue}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
@@ -609,30 +611,19 @@ function DesktopWithdraw() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-12 py-design-10 text-design-16 leading-[1.35] text-[#F0B44A]">
|
||||
{t('gameDesktop.withdraw.exchangeRateNotice')}
|
||||
{vm.withdrawCopy.rateHint}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
|
||||
<div>
|
||||
{t('gameDesktop.withdraw.wallet')}:{' '}
|
||||
<span className="text-[#B9F4F8]">
|
||||
{t('gameDesktop.withdraw.minimumRm10')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('gameDesktop.withdraw.bank')}:{' '}
|
||||
<span className="text-[#B9F4F8]">
|
||||
{t('gameDesktop.withdraw.minimumRm10')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('gameDesktop.withdraw.processingTime')}:{' '}
|
||||
{vm.withdrawCopy.processingLabel}:{' '}
|
||||
<span className="text-[#77FF76]">
|
||||
{t('gameDesktop.withdraw.fundsArrivalTime')}
|
||||
{vm.withdrawCopy.processingValue}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[#B9F4F8]">
|
||||
{t('gameDesktop.withdraw.feeNotice')}
|
||||
<div>
|
||||
{vm.withdrawCopy.noticeLabel}:{' '}
|
||||
<span className="text-red-700">{vm.withdrawCopy.feeNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -642,7 +633,8 @@ function DesktopWithdraw() {
|
||||
type="button"
|
||||
src={lengthGreenBtn}
|
||||
size="100% 100%"
|
||||
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-18 font-bold uppercase tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
||||
onClick={handleCloseWithdraw}
|
||||
className="flex h-design-64 w-design-220 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-18 font-bold uppercase tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
{t('gameDesktop.withdraw.cancel')}
|
||||
</SmartBackground>
|
||||
@@ -651,11 +643,18 @@ function DesktopWithdraw() {
|
||||
type="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 100%"
|
||||
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
||||
onClick={handleConfirmWithdraw}
|
||||
disabled={withdrawSubmitMutation.isPending}
|
||||
className={cn(
|
||||
'flex h-design-64 w-design-220 shrink-0 items-center justify-center whitespace-nowrap pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition',
|
||||
withdrawSubmitMutation.isPending
|
||||
? 'cursor-not-allowed opacity-70'
|
||||
: 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]',
|
||||
)}
|
||||
>
|
||||
{t('gameDesktop.withdraw.confirm')}
|
||||
<br />
|
||||
{t('gameDesktop.withdraw.withdrawal')}
|
||||
{withdrawSubmitMutation.isPending
|
||||
? t('commonUi.action.submitting')
|
||||
: `${t('gameDesktop.withdraw.confirm')} ${t('gameDesktop.withdraw.withdrawal')}`}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 />
|
||||
{/* 桌面端用户信息弹窗:展示个人资料与站内消息 */}
|
||||
|
||||
209
src/features/game/hooks/use-animal-vm.ts
Normal file
209
src/features/game/hooks/use-animal-vm.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
15
src/features/game/hooks/use-deposit-tier-list.ts
Normal file
15
src/features/game/hooks/use-deposit-tier-list.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
15
src/features/game/hooks/use-deposit-withdraw-config.ts
Normal file
15
src/features/game/hooks/use-deposit-withdraw-config.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
244
src/features/game/hooks/use-header-vm.ts
Normal file
244
src/features/game/hooks/use-header-vm.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
3
src/features/game/hooks/use-topup-vm.ts
Normal file
3
src/features/game/hooks/use-topup-vm.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function useTopupVm() {
|
||||
return {}
|
||||
}
|
||||
48
src/features/game/hooks/use-withdraw-submit.ts
Normal file
48
src/features/game/hooks/use-withdraw-submit.ts
Normal 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'),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
339
src/features/game/hooks/use-withdraw-vm.ts
Normal file
339
src/features/game/hooks/use-withdraw-vm.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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 23:36
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: '固定兑换钻石金额',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user