feat(game): 优化界面组件
- 在国际化文件中添加钱包流水相关翻译项 - 在用户个人资料页面添加复制邀请链接功能 - 优化桌面端动物组件的视觉效果和动画参数 - 添加虚拟滚动功能到财务记录标签页提升性能 - 为桌面端控制面板添加投注数量调节按钮 - 更新消息模态框为通知列表和详情展示 - 在头部余额显示旁添加充值图标入口
BIN
figma/img.png
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 236 KiB |
BIN
figma/img_1.png
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
BIN
src/assets/game/pc-streak.webp
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 745 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -159,6 +159,7 @@ export const FINANCE_API_ENDPOINTS = {
|
||||
depositTierList: 'api/finance/depositTierList',
|
||||
depositWithdrawConfig: 'api/finance/depositWithdrawConfig',
|
||||
legacyCashierConfig: 'api/finance/cashierConfig',
|
||||
walletRecordList: 'api/wallet/recordList',
|
||||
withdrawCreate: 'api/finance/withdrawCreate',
|
||||
withdrawList: 'api/finance/withdrawList',
|
||||
} as const
|
||||
|
||||
@@ -15,12 +15,26 @@ interface UseRegisterFormOptions {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
const REGISTER_INVITE_CODE_QUERY_PARAM = 'registerInviteCode'
|
||||
|
||||
function getInitialRegisterInviteCode() {
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
new URLSearchParams(window.location.search)
|
||||
.get(REGISTER_INVITE_CODE_QUERY_PARAM)
|
||||
?.trim() ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
export function useRegisterForm({ onSuccess }: UseRegisterFormOptions = {}) {
|
||||
const startSession = useAuthStore((state) => state.startSession)
|
||||
const form = useForm<RegisterFormValues>({
|
||||
defaultValues: {
|
||||
confirmPassword: '',
|
||||
inviteCode: '',
|
||||
inviteCode: getInitialRegisterInviteCode(),
|
||||
password: '',
|
||||
username: '',
|
||||
},
|
||||
|
||||
@@ -18,6 +18,10 @@ import type {
|
||||
FinanceRateConfigDto,
|
||||
FinanceWithdrawBankDto,
|
||||
FinanceWithdrawConfigDto,
|
||||
WalletRecordItemDto,
|
||||
WalletRecordList,
|
||||
WalletRecordListDto,
|
||||
WalletRecordType,
|
||||
WithdrawCreateRequestDto,
|
||||
WithdrawCreateResponseDto,
|
||||
} from './finance-types'
|
||||
@@ -196,6 +200,49 @@ function normalizeFinanceOrderList(dto: FinanceOrderListDto): FinanceOrderList {
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyNullableValue(value: unknown) {
|
||||
return value === null || value === undefined ? '' : String(value)
|
||||
}
|
||||
|
||||
function normalizeWalletRecordItem(dto: WalletRecordItemDto, index: number) {
|
||||
const createdAt = dto.created_at ?? dto.create_time ?? dto.time ?? null
|
||||
const id =
|
||||
dto.id ??
|
||||
dto.record_id ??
|
||||
dto.wallet_record_id ??
|
||||
dto.order_no ??
|
||||
`${createdAt ?? 'wallet-record'}-${index + 1}`
|
||||
|
||||
return {
|
||||
amount: stringifyNullableValue(
|
||||
dto.amount ?? dto.change_amount ?? dto.coin ?? '',
|
||||
),
|
||||
balanceAfter: stringifyNullableValue(
|
||||
dto.balance_after ?? dto.after_balance ?? dto.balance ?? '',
|
||||
),
|
||||
balanceBefore: stringifyNullableValue(
|
||||
dto.balance_before ?? dto.before_balance ?? '',
|
||||
),
|
||||
createdAt,
|
||||
id: String(id),
|
||||
remark: stringifyNullableValue(dto.remark ?? dto.description ?? dto.memo),
|
||||
type: stringifyNullableValue(
|
||||
dto.type ?? dto.change_type ?? dto.biz_type ?? dto.scene,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWalletRecordList(dto: WalletRecordListDto): WalletRecordList {
|
||||
return {
|
||||
list: (dto.list ?? []).map(normalizeWalletRecordItem),
|
||||
pagination: {
|
||||
page: dto.pagination?.page ?? dto.page ?? 1,
|
||||
page_size: dto.pagination?.page_size ?? dto.page_size ?? 20,
|
||||
total: dto.pagination?.total ?? dto.total ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDepositWithdrawConfig() {
|
||||
const response = await api.post<DepositWithdrawConfigDto>(
|
||||
FINANCE_API_ENDPOINTS.depositWithdrawConfig,
|
||||
@@ -287,6 +334,29 @@ export async function getWithdrawOrderList(params?: {
|
||||
return normalizeFinanceOrderList(dto)
|
||||
}
|
||||
|
||||
export async function getWalletRecordList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
type?: WalletRecordType
|
||||
}) {
|
||||
const response = await api.get<WalletRecordListDto>(
|
||||
FINANCE_API_ENDPOINTS.walletRecordList,
|
||||
{
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? 20),
|
||||
type: params?.type ?? 'payout',
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<WalletRecordListDto>,
|
||||
'Failed to load wallet record list',
|
||||
)
|
||||
|
||||
return normalizeWalletRecordList(dto)
|
||||
}
|
||||
|
||||
export async function createWithdraw(payload: WithdrawCreateRequestDto) {
|
||||
const response = await api.post<
|
||||
WithdrawCreateResponseDto,
|
||||
|
||||
@@ -190,6 +190,56 @@ export interface FinanceOrderList {
|
||||
pagination: FinanceOrderPaginationDto
|
||||
}
|
||||
|
||||
export type WalletRecordType = 'payout' | (string & {})
|
||||
|
||||
export interface WalletRecordItemDto {
|
||||
after_balance?: number | string | null
|
||||
amount?: number | string | null
|
||||
balance?: number | string | null
|
||||
balance_after?: number | string | null
|
||||
balance_before?: number | string | null
|
||||
before_balance?: number | string | null
|
||||
biz_type?: string | null
|
||||
change_amount?: number | string | null
|
||||
change_type?: string | null
|
||||
coin?: number | string | null
|
||||
create_time?: number | string | null
|
||||
created_at?: number | string | null
|
||||
description?: string | null
|
||||
id?: number | string | null
|
||||
memo?: string | null
|
||||
order_no?: string | null
|
||||
record_id?: number | string | null
|
||||
remark?: string | null
|
||||
scene?: string | null
|
||||
time?: number | string | null
|
||||
type?: string | null
|
||||
wallet_record_id?: number | string | null
|
||||
}
|
||||
|
||||
export interface WalletRecordListDto {
|
||||
list: WalletRecordItemDto[]
|
||||
pagination?: FinanceOrderPaginationDto
|
||||
page?: number
|
||||
page_size?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface WalletRecordItem {
|
||||
amount: string
|
||||
balanceAfter: string
|
||||
balanceBefore: string
|
||||
createdAt: number | string | null
|
||||
id: string
|
||||
remark: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface WalletRecordList {
|
||||
list: WalletRecordItem[]
|
||||
pagination: FinanceOrderPaginationDto
|
||||
}
|
||||
|
||||
export interface WithdrawCreateRequestDto {
|
||||
bank_code: string
|
||||
channel_code: string
|
||||
|
||||
@@ -288,10 +288,12 @@ export interface GameBetOrdersDto {
|
||||
}
|
||||
|
||||
export interface GamePlaceBetRequestDto {
|
||||
bet_amount?: string
|
||||
bet_id: number
|
||||
idempotency_key: string
|
||||
numbers: string
|
||||
period_no: string
|
||||
single_bet_amount?: string
|
||||
}
|
||||
|
||||
export interface GamePlaceBetDto {
|
||||
|
||||
@@ -298,9 +298,9 @@ export function DesktopAnimal({
|
||||
isMarqueeActive &&
|
||||
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
|
||||
isRevealRunning &&
|
||||
'border-[rgba(104,255,249,0.9)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(68,244,255,0.68),0_0_calc(var(--design-unit)*26)_rgba(37,214,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(115,255,247,0.24)] brightness-125 saturate-150',
|
||||
'border-[rgba(104,255,249,0.76)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(68,244,255,0.46),0_0_calc(var(--design-unit)*22)_rgba(37,214,255,0.28),inset_0_0_calc(var(--design-unit)*16)_rgba(115,255,247,0.18)] brightness-115 saturate-125',
|
||||
isRevealWinner &&
|
||||
'shadow-[0_0_calc(var(--design-unit)*14)_rgba(81,248,255,0.72),0_0_calc(var(--design-unit)*24)_rgba(30,199,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.34)] brightness-125 saturate-150',
|
||||
'border-[rgba(121,255,250,0.72)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(81,248,255,0.54),0_0_calc(var(--design-unit)*22)_rgba(30,199,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.24)] brightness-110 saturate-120',
|
||||
showCellWarning &&
|
||||
'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]',
|
||||
!showStandbyState && !hasPlacedSelection && 'opacity-95',
|
||||
@@ -341,9 +341,9 @@ export function DesktopAnimal({
|
||||
isMarqueeActive &&
|
||||
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
|
||||
isRevealRunning &&
|
||||
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.5)_0%,rgba(77,244,255,0.24)_40%,rgba(27,183,255,0.1)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*16)_rgba(95,249,255,0.72),inset_0_0_calc(var(--design-unit)*22)_rgba(151,255,250,0.4)]',
|
||||
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.36)_0%,rgba(77,244,255,0.16)_40%,rgba(27,183,255,0.07)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(95,249,255,0.46),inset_0_0_calc(var(--design-unit)*18)_rgba(151,255,250,0.24)]',
|
||||
isRevealWinner &&
|
||||
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.5)_0%,rgba(67,226,255,0.24)_38%,rgba(25,131,255,0.1)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*14)_rgba(92,248,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(126,255,250,0.4)]',
|
||||
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.34)_0%,rgba(67,226,255,0.16)_38%,rgba(25,131,255,0.07)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(92,248,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(126,255,250,0.24)]',
|
||||
showCellWarning &&
|
||||
'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100',
|
||||
)}
|
||||
@@ -360,9 +360,9 @@ export function DesktopAnimal({
|
||||
className={cn(
|
||||
'absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*14)]',
|
||||
isRevealRunning &&
|
||||
'brightness-125 saturate-150 drop-shadow-[0_0_calc(var(--design-unit)*10)_rgba(101,250,255,0.62)]',
|
||||
'brightness-115 saturate-125 drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(101,250,255,0.42)]',
|
||||
isRevealWinner &&
|
||||
'brightness-140 saturate-150 drop-shadow-[0_0_calc(var(--design-unit)*12)_rgba(106,250,255,0.72)]',
|
||||
'brightness-110 saturate-120 drop-shadow-[0_0_calc(var(--design-unit)*9)_rgba(106,250,255,0.44)]',
|
||||
imageClassName,
|
||||
)}
|
||||
imgClassName="object-fill"
|
||||
@@ -452,7 +452,7 @@ export function DesktopAnimal({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showStopOverlay ? (
|
||||
{showStopOverlay && !hostingFlag ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]"
|
||||
@@ -469,22 +469,50 @@ export function DesktopAnimal({
|
||||
) : null}
|
||||
|
||||
{hostingFlag ? (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]">
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center gap-design-22 bg-[rgba(2,8,14,0.6)] px-design-24 backdrop-blur-[1px]">
|
||||
{showStopOverlay ? (
|
||||
<SmartImage
|
||||
src={stopImageSrc}
|
||||
alt="stop betting"
|
||||
priority
|
||||
showSkeleton={false}
|
||||
className="h-design-170 w-design-520 max-w-[72%] overflow-visible"
|
||||
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
|
||||
/>
|
||||
) : null}
|
||||
<SmartBackground
|
||||
src={hostingBg}
|
||||
size="100% 100%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
className="h-design-350 w-design-930 flex flex-col items-center justify-center"
|
||||
>
|
||||
<div className={'flex flex-col gap-design-40 items-center'}>
|
||||
<div className={'flex flex-col gap-design-44 items-center'}>
|
||||
<div className={'flex items-center gap-design-20'}>
|
||||
<SmartImage
|
||||
src={refreshIcon}
|
||||
alt="refreshIcon"
|
||||
priority
|
||||
showSkeleton={false}
|
||||
className="h-design-40 w-design-40"
|
||||
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
|
||||
/>
|
||||
<div className={'text-design-20 text-[#ffffff] font-bold'}>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
animate={prefersReducedMotion ? undefined : { rotate: 360 }}
|
||||
transition={{
|
||||
duration: 1.4,
|
||||
ease: 'linear',
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
className="flex h-design-48 w-design-48 items-center justify-center"
|
||||
>
|
||||
<SmartImage
|
||||
src={refreshIcon}
|
||||
alt=""
|
||||
priority
|
||||
showSkeleton={false}
|
||||
className="h-design-40 w-design-40"
|
||||
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.36)]"
|
||||
/>
|
||||
</motion.span>
|
||||
<div
|
||||
className={
|
||||
'text-design-22 text-[#ffffff] font-bold [text-shadow:0_0_calc(var(--design-unit)*12)_rgba(76,236,255,0.45)]'
|
||||
}
|
||||
>
|
||||
{t('game.autoSpin.runningRounds', {
|
||||
count: completedAutoHostingRounds,
|
||||
})}
|
||||
@@ -495,7 +523,10 @@ export function DesktopAnimal({
|
||||
type="button"
|
||||
onClick={stopHosting}
|
||||
src={hostingBtn}
|
||||
className="h-design-80 w-design-170 flex cursor-pointer flex-col items-center justify-center transition-transform hover:-translate-y-[1px] active:translate-y-0"
|
||||
size="100% 100%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
className="h-design-70 w-design-220 flex cursor-pointer items-center justify-center pb-design-4 text-design-24 font-bold text-[#EFFFFF] [text-shadow:0_1px_0_rgba(255,255,255,0.18),0_0_calc(var(--design-unit)*10)_rgba(46,220,255,0.5)] transition-transform hover:-translate-y-[1px] active:translate-y-0"
|
||||
>
|
||||
{t('game.actions.stopAuto')}
|
||||
</SmartBackground>
|
||||
|
||||
@@ -24,6 +24,7 @@ export function DesktopControl() {
|
||||
acceptingBets,
|
||||
actionsEnabled,
|
||||
canClear,
|
||||
canDecreaseBetQuantity,
|
||||
chips,
|
||||
confirmLabel,
|
||||
confirmState,
|
||||
@@ -32,9 +33,11 @@ export function DesktopControl() {
|
||||
onChipSelect,
|
||||
onConfirm,
|
||||
onClearSelections,
|
||||
onDecreaseBetQuantity,
|
||||
onIncreaseBetQuantity,
|
||||
onOpenAutoSetting,
|
||||
onRepeatSelections,
|
||||
selectedChipAmountLabel,
|
||||
selectedBetQuantityLabel,
|
||||
selectedChipId,
|
||||
selectedCountLabel,
|
||||
totalBetAmountLabel,
|
||||
@@ -297,21 +300,43 @@ export function DesktopControl() {
|
||||
'flex h-design-50 shrink-0 items-center rounded-md bg-[#091118] box-border px-design-2 py-design-3'
|
||||
}
|
||||
>
|
||||
<SmartImage
|
||||
src={add}
|
||||
alt={`add`}
|
||||
className={'w-design-40 h-design-40'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!acceptingBets || !canDecreaseBetQuantity}
|
||||
onClick={onDecreaseBetQuantity}
|
||||
className={cn(
|
||||
'flex h-design-40 w-design-40 shrink-0 items-center justify-center',
|
||||
acceptingBets && canDecreaseBetQuantity
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<SmartImage
|
||||
src={add}
|
||||
alt={`add`}
|
||||
className={'w-design-40 h-design-40'}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={'w-design-80 h-full flex items-center justify-center'}
|
||||
>
|
||||
{selectedChipAmountLabel}
|
||||
{selectedBetQuantityLabel}
|
||||
</div>
|
||||
<SmartImage
|
||||
src={reduce}
|
||||
alt={`reduce`}
|
||||
className={'w-design-40 h-design-40'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!acceptingBets}
|
||||
onClick={onIncreaseBetQuantity}
|
||||
className={cn(
|
||||
'flex h-design-40 w-design-40 shrink-0 items-center justify-center',
|
||||
acceptingBets ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<SmartImage
|
||||
src={reduce}
|
||||
alt={`reduce`}
|
||||
className={'w-design-40 h-design-40'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
<SmartBackground
|
||||
|
||||
@@ -3,12 +3,14 @@ import {
|
||||
Mail,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Plus,
|
||||
UserKey,
|
||||
UserRoundPlus,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import add from '@/assets/game/add.webp'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import logo from '@/assets/system/logo.webp'
|
||||
@@ -205,10 +207,19 @@ export function DesktopHeader() {
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-design-180 items-center justify-end transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
|
||||
'common-neon-inset text-design-16 !py-design-20 !pr-design-14 box-border flex h-design-36 w-design-180 items-center justify-end gap-design-8 transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
|
||||
}
|
||||
>
|
||||
{currentUser?.coin || '--'}
|
||||
<span className="truncate">{currentUser?.coin || '--'}</span>
|
||||
<div className={'common-neon-inset !p-design-3'}>
|
||||
<Plus
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
color="#57B8BF"
|
||||
size={18}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import streakBg from '@/assets/game/pc-streak.webp'
|
||||
import down5Animation from '@/assets/lottie/down5.json'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import fire from '@/assets/system/fire.webp'
|
||||
@@ -12,6 +13,7 @@ import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
|
||||
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
|
||||
|
||||
export function DesktopStatusLine() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@@ -23,9 +25,11 @@ export function DesktopStatusLine() {
|
||||
phaseToneClassName,
|
||||
roundId,
|
||||
streakLabel,
|
||||
streakValue,
|
||||
} = useGameStatusVm()
|
||||
const [remainingMs, setRemainingMs] = useState(countdownMs)
|
||||
const showWarningCountdown = remainingMs <= 5000 && remainingMs > 0
|
||||
const showStreakLimitOnly = typeof streakValue === 'number' && streakValue > 1
|
||||
const countdownClassName = useMemo(
|
||||
() =>
|
||||
showWarningCountdown
|
||||
@@ -44,45 +48,59 @@ export function DesktopStatusLine() {
|
||||
{/* 状态栏左侧 */}
|
||||
<div
|
||||
className={
|
||||
'relative h-full flex-1 flex items-center justify-center gap-design-50'
|
||||
'relative h-full flex-1 flex items-center justify-center gap-design-50 overflow-visible'
|
||||
}
|
||||
>
|
||||
{/*<div className={'flex-1 absolute z-10 -right-20 -top-6 w-full !h-design-105'} style={{*/}
|
||||
{/* backgroundImage: `url(${streakBg})`,*/}
|
||||
{/* backgroundSize: '100% 110%',*/}
|
||||
{/* backgroundRepeat: 'no-repeat',*/}
|
||||
{/*}} >*/}
|
||||
{/*</div>*/}
|
||||
|
||||
<div className={'text-[#CBD3D5] font-bold'}>
|
||||
{t('gameDesktop.status.odds')}:{' '}
|
||||
<span className={'text-[#E3D171]'}>{oddsLabel}</span>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
|
||||
}
|
||||
>
|
||||
<SmartImage
|
||||
className={'w-design-37 h-design-47'}
|
||||
alt={'fire'}
|
||||
src={fire}
|
||||
{showStreakLimitOnly && (
|
||||
<div
|
||||
className={
|
||||
'pointer-events-none absolute z-0 bg-center bg-no-repeat'
|
||||
}
|
||||
style={{
|
||||
top: 'calc(var(--design-unit)*-14)',
|
||||
right: 'calc(var(--design-unit)*-190)',
|
||||
bottom: 'calc(var(--design-unit)*-1)',
|
||||
left: 0,
|
||||
backgroundImage: `url(${streakBg})`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: '113% 150%',
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
{t('gameDesktop.status.streak')}:{' '}
|
||||
<span
|
||||
)}
|
||||
|
||||
{!showStreakLimitOnly && (
|
||||
<>
|
||||
<div className={'text-[#CBD3D5] font-bold'}>
|
||||
{t('gameDesktop.status.odds')}:{' '}
|
||||
<span className={'text-[#E3D171]'}>{oddsLabel}</span>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'bg-gradient-to-b from-[#EBA661] to-[#FCC785] bg-clip-text text-transparent'
|
||||
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
|
||||
}
|
||||
>
|
||||
{streakLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SmartImage
|
||||
className={'w-design-37 h-design-47'}
|
||||
alt={'fire'}
|
||||
src={fire}
|
||||
/>
|
||||
<div>
|
||||
{t('gameDesktop.status.streak')}:{' '}
|
||||
<span
|
||||
className={
|
||||
'bg-gradient-to-b from-[#EBA661] to-[#FCC785] bg-clip-text text-transparent'
|
||||
}
|
||||
>
|
||||
{streakLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
|
||||
'relative z-20 flex items-center gap-design-5 text-[#CBD3D5] font-bold'
|
||||
}
|
||||
>
|
||||
<SmartImage
|
||||
|
||||
@@ -54,6 +54,9 @@ export function useAnimalVm(
|
||||
)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||
const activeBetQuantity = useGameRoundStore(
|
||||
(state) => state.activeBetQuantity,
|
||||
)
|
||||
const chips = useGameRoundStore((state) => state.chips)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const roundId = useGameRoundStore((state) => state.round.id)
|
||||
@@ -188,7 +191,9 @@ export function useAnimalVm(
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBetAmount + (activeChip?.amount ?? 0) > balance) {
|
||||
const nextBetAmount = (activeChip?.amount ?? 0) * activeBetQuantity
|
||||
|
||||
if (totalBetAmount + nextBetAmount > balance) {
|
||||
setCellWarning({
|
||||
cellId: animalId,
|
||||
type: 'balance',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { placeGameBet } from '@/features/game'
|
||||
@@ -44,32 +44,40 @@ function toBetId(chipId: string) {
|
||||
return Number.isInteger(betId) && betId >= 1 && betId <= 6 ? betId : null
|
||||
}
|
||||
|
||||
function formatBetAmount(amount: number) {
|
||||
if (Number.isInteger(amount)) {
|
||||
return String(amount)
|
||||
}
|
||||
|
||||
return amount.toFixed(2).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
function groupSelections(selections: BetSelection[]) {
|
||||
return selections.reduce<Map<string, { betId: number; numbers: number[] }>>(
|
||||
(accumulator, selection) => {
|
||||
const betId = toBetId(selection.chipId)
|
||||
|
||||
if (betId === null) {
|
||||
return accumulator
|
||||
}
|
||||
|
||||
const groupKey = String(betId)
|
||||
const current = accumulator.get(groupKey)
|
||||
|
||||
if (current) {
|
||||
current.numbers.push(selection.cellId)
|
||||
return accumulator
|
||||
}
|
||||
|
||||
accumulator.set(groupKey, {
|
||||
betId,
|
||||
numbers: [selection.cellId],
|
||||
})
|
||||
return selections.reduce<
|
||||
Map<string, { amount: number; betId: number; numbers: number[] }>
|
||||
>((accumulator, selection) => {
|
||||
const betId = toBetId(selection.chipId)
|
||||
|
||||
if (betId === null) {
|
||||
return accumulator
|
||||
},
|
||||
new Map(),
|
||||
)
|
||||
}
|
||||
|
||||
const groupKey = `${betId}:${selection.amount}`
|
||||
const current = accumulator.get(groupKey)
|
||||
|
||||
if (current) {
|
||||
current.numbers.push(selection.cellId)
|
||||
return accumulator
|
||||
}
|
||||
|
||||
accumulator.set(groupKey, {
|
||||
amount: selection.amount,
|
||||
betId,
|
||||
numbers: [selection.cellId],
|
||||
})
|
||||
|
||||
return accumulator
|
||||
}, new Map())
|
||||
}
|
||||
|
||||
export function useAutoHostingRunner() {
|
||||
@@ -79,9 +87,10 @@ export function useAutoHostingRunner() {
|
||||
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const balanceAfterBet = useGameAutoHostingStore(
|
||||
(state) => state.balanceAfterBet,
|
||||
const lastSingleWinAmount = useGameAutoHostingStore(
|
||||
(state) => state.lastSingleWinAmount,
|
||||
)
|
||||
const lastIsJackpot = useGameAutoHostingStore((state) => state.lastIsJackpot)
|
||||
const isHosting = useGameAutoHostingStore((state) => state.isHosting)
|
||||
const lastSubmittedRoundId = useGameAutoHostingStore(
|
||||
(state) => state.lastSubmittedRoundId,
|
||||
@@ -92,14 +101,10 @@ export function useAutoHostingRunner() {
|
||||
(state) => state.markRoundSubmitted,
|
||||
)
|
||||
const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const previousJackpotRef = useRef(currentUser?.isJackpot === true)
|
||||
const inFlightRoundIdRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const isJackpot = currentUser?.isJackpot === true
|
||||
|
||||
if (!isHosting) {
|
||||
previousJackpotRef.current = isJackpot
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,30 +121,24 @@ export function useAutoHostingRunner() {
|
||||
|
||||
if (
|
||||
rules.stopIfSingleWinAbove.enabled &&
|
||||
balanceAfterBet !== null &&
|
||||
balance - balanceAfterBet > rules.stopIfSingleWinAbove.amount
|
||||
lastSingleWinAmount !== null &&
|
||||
lastSingleWinAmount > rules.stopIfSingleWinAbove.amount
|
||||
) {
|
||||
stopHosting()
|
||||
notify.success(t('commonUi.toast.autoHostingStoppedWin'))
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
rules.stopOnJackpot &&
|
||||
isJackpot &&
|
||||
previousJackpotRef.current === false
|
||||
) {
|
||||
if (rules.stopOnJackpot && lastIsJackpot === true) {
|
||||
stopHosting()
|
||||
notify.success(t('commonUi.toast.autoHostingStoppedJackpot'))
|
||||
return
|
||||
}
|
||||
|
||||
previousJackpotRef.current = isJackpot
|
||||
}, [
|
||||
balanceAfterBet,
|
||||
currentUser?.coin,
|
||||
currentUser?.isJackpot,
|
||||
isHosting,
|
||||
lastIsJackpot,
|
||||
lastSingleWinAmount,
|
||||
rules,
|
||||
stopHosting,
|
||||
t,
|
||||
@@ -148,7 +147,7 @@ export function useAutoHostingRunner() {
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isHosting ||
|
||||
isSubmitting ||
|
||||
inFlightRoundIdRef.current !== null ||
|
||||
authStatus !== 'authenticated' ||
|
||||
!currentUser ||
|
||||
round.phase !== 'betting' ||
|
||||
@@ -178,11 +177,10 @@ export function useAutoHostingRunner() {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const submittingRoundId = round.id
|
||||
inFlightRoundIdRef.current = submittingRoundId
|
||||
|
||||
const submitAutoBet = async () => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
let latestBalance = currentUser.coin ?? '0'
|
||||
|
||||
@@ -190,11 +188,14 @@ export function useAutoHostingRunner() {
|
||||
const uniqueNumbers = [...new Set(group.numbers)].sort(
|
||||
(left, right) => left - right,
|
||||
)
|
||||
const formattedSingleBetAmount = formatBetAmount(group.amount)
|
||||
const result = await placeGameBet({
|
||||
bet_amount: formattedSingleBetAmount,
|
||||
bet_id: group.betId,
|
||||
idempotency_key: createIdempotencyKey(),
|
||||
numbers: uniqueNumbers.join(','),
|
||||
period_no: round.id,
|
||||
single_bet_amount: formattedSingleBetAmount,
|
||||
})
|
||||
|
||||
if (result.status !== 'accepted') {
|
||||
@@ -204,42 +205,44 @@ export function useAutoHostingRunner() {
|
||||
latestBalance = result.balance_after
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
const latestHostingState = useGameAutoHostingStore.getState()
|
||||
const latestUser = useAuthStore.getState().currentUser
|
||||
|
||||
if (
|
||||
!latestHostingState.isHosting ||
|
||||
latestHostingState.lastSubmittedRoundId === submittingRoundId ||
|
||||
!latestUser
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentUser({
|
||||
...currentUser,
|
||||
...latestUser,
|
||||
coin: latestBalance,
|
||||
lastBetPeriodNo: round.id,
|
||||
lastBetPeriodNo: submittingRoundId,
|
||||
})
|
||||
markRoundSubmitted(round.id, parseBalance(latestBalance))
|
||||
markRoundSubmitted(submittingRoundId, parseBalance(latestBalance))
|
||||
clearSelections()
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
if (useGameAutoHostingStore.getState().isHosting) {
|
||||
stopHosting()
|
||||
notify.error(t('commonUi.toast.autoHostingSubmitFailed'), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsSubmitting(false)
|
||||
if (inFlightRoundIdRef.current === submittingRoundId) {
|
||||
inFlightRoundIdRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void submitAutoBet()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
authStatus,
|
||||
clearSelections,
|
||||
currentUser,
|
||||
isHosting,
|
||||
isSubmitting,
|
||||
lastSubmittedRoundId,
|
||||
markRoundSubmitted,
|
||||
round.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -38,25 +38,33 @@ export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
|
||||
const { i18n, t } = useTranslation()
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
|
||||
const [recordType, setRecordType] = useState<FinanceRecordType>('deposit')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['finance', 'user-info-order-list', recordType, page],
|
||||
queryFn: () =>
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['finance', 'user-info-order-list', recordType],
|
||||
initialPageParam: 1,
|
||||
queryFn: ({ pageParam }) =>
|
||||
recordType === 'deposit'
|
||||
? getDepositOrderList({
|
||||
page,
|
||||
page: pageParam,
|
||||
pageSize: FINANCE_RECORD_PAGE_SIZE,
|
||||
})
|
||||
: getWithdrawOrderList({
|
||||
page,
|
||||
page: pageParam,
|
||||
pageSize: FINANCE_RECORD_PAGE_SIZE,
|
||||
}),
|
||||
enabled,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextPage = lastPage.pagination.page + 1
|
||||
const loadedCount =
|
||||
lastPage.pagination.page * lastPage.pagination.page_size
|
||||
|
||||
return loadedCount < lastPage.pagination.total ? nextPage : undefined
|
||||
},
|
||||
})
|
||||
|
||||
const pagination = query.data?.pagination
|
||||
const total = pagination?.total ?? 0
|
||||
const lastPage = query.data?.pages.at(-1)
|
||||
const total = lastPage?.pagination.total ?? 0
|
||||
const loadedPage = lastPage?.pagination.page ?? 1
|
||||
|
||||
const recordTypes = useMemo(
|
||||
() =>
|
||||
@@ -69,58 +77,46 @@ export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
(query.data?.list ?? []).map((item) => ({
|
||||
amountLabel: formatFinanceAmount(item.amount, locale),
|
||||
bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale),
|
||||
id: item.orderNo,
|
||||
orderNoLabel: item.orderNo || '--',
|
||||
})),
|
||||
[locale, query.data?.list],
|
||||
(query.data?.pages ?? []).flatMap((page) =>
|
||||
page.list.map((item, index) => ({
|
||||
amountLabel: formatFinanceAmount(item.amount, locale),
|
||||
bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale),
|
||||
id: item.orderNo || `${page.pagination.page}-${index}`,
|
||||
orderNoLabel: item.orderNo || '--',
|
||||
})),
|
||||
),
|
||||
[locale, query.data?.pages],
|
||||
)
|
||||
|
||||
const selectRecordType = useCallback((type: FinanceRecordType) => {
|
||||
setRecordType(type)
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
const goPreviousPage = useCallback(() => {
|
||||
setPage((currentPage) => Math.max(1, currentPage - 1))
|
||||
}, [])
|
||||
|
||||
const goNextPage = useCallback(() => {
|
||||
setPage((currentPage) => currentPage + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setRecordType('deposit')
|
||||
setPage(1)
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return {
|
||||
canGoNextPage: page * FINANCE_RECORD_PAGE_SIZE < total,
|
||||
canGoPreviousPage: page > 1,
|
||||
emptyText: t('game.modals.userInfo.financeRecords.empty'),
|
||||
goNextPage,
|
||||
goPreviousPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
hasNextPage: query.hasNextPage,
|
||||
headers: {
|
||||
amount: t('game.modals.userInfo.financeRecords.amount'),
|
||||
bonusAmount: t('game.modals.userInfo.financeRecords.bonusAmount'),
|
||||
orderNo: t('game.modals.userInfo.financeRecords.orderNo'),
|
||||
},
|
||||
isError: query.isError,
|
||||
isFetching: query.isFetching,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isLoading: query.isLoading,
|
||||
items,
|
||||
loadFailedText: t('game.modals.userInfo.financeRecords.loadFailed'),
|
||||
loadingText: t('game.modals.userInfo.financeRecords.loading'),
|
||||
pageLabel: t('game.modals.userInfo.financeRecords.page', {
|
||||
page: pagination?.page ?? page,
|
||||
page: loadedPage,
|
||||
total,
|
||||
}),
|
||||
nextText: t('game.modals.userInfo.financeRecords.next'),
|
||||
previousText: t('game.modals.userInfo.financeRecords.previous'),
|
||||
recordType,
|
||||
recordTypes,
|
||||
selectRecordType,
|
||||
|
||||
@@ -62,10 +62,16 @@ export function useGameControlVm() {
|
||||
const { t } = useTranslation()
|
||||
const chips = useGameRoundStore((state) => state.chips)
|
||||
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||
const activeBetQuantity = useGameRoundStore(
|
||||
(state) => state.activeBetQuantity,
|
||||
)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const maxSelectionCount = useGameRoundStore(
|
||||
(state) => state.maxSelectionCount,
|
||||
)
|
||||
const adjustBetQuantity = useGameRoundStore(
|
||||
(state) => state.adjustBetQuantity,
|
||||
)
|
||||
const selections = useGameRoundStore((state) => state.selections)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const restoreRecentSuccessfulSelections = useGameRoundStore(
|
||||
@@ -151,32 +157,10 @@ export function useGameControlVm() {
|
||||
return
|
||||
}
|
||||
|
||||
const groupedSelections = selections.reduce<
|
||||
Map<string, { betId: number; numbers: number[] }>
|
||||
>((accumulator, selection) => {
|
||||
const betId = toBetId(selection.chipId)
|
||||
const betId = toBetId(selections[0]?.chipId ?? activeChipId)
|
||||
const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0
|
||||
|
||||
if (betId === null) {
|
||||
return accumulator
|
||||
}
|
||||
|
||||
const groupKey = String(betId)
|
||||
const current = accumulator.get(groupKey)
|
||||
|
||||
if (current) {
|
||||
current.numbers.push(selection.cellId)
|
||||
return accumulator
|
||||
}
|
||||
|
||||
accumulator.set(groupKey, {
|
||||
betId,
|
||||
numbers: [selection.cellId],
|
||||
})
|
||||
|
||||
return accumulator
|
||||
}, new Map())
|
||||
|
||||
if (groupedSelections.size === 0) {
|
||||
if (betId === null || singleBetAmount <= 0) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
@@ -186,24 +170,25 @@ export function useGameControlVm() {
|
||||
try {
|
||||
let latestBalance = currentUser?.coin ?? '0'
|
||||
|
||||
for (const group of groupedSelections.values()) {
|
||||
const uniqueNumbers = [...new Set(group.numbers)].sort(
|
||||
(left, right) => left - right,
|
||||
)
|
||||
const result = await placeGameBet({
|
||||
bet_id: group.betId,
|
||||
idempotency_key: createIdempotencyKey(),
|
||||
numbers: uniqueNumbers.join(','),
|
||||
period_no: round.id,
|
||||
})
|
||||
const uniqueNumbers = [
|
||||
...new Set(selections.map((item) => item.cellId)),
|
||||
].sort((left, right) => left - right)
|
||||
const formattedSingleBetAmount = formatChipDisplayValue(singleBetAmount)
|
||||
const result = await placeGameBet({
|
||||
bet_amount: formattedSingleBetAmount,
|
||||
bet_id: betId,
|
||||
idempotency_key: createIdempotencyKey(),
|
||||
numbers: uniqueNumbers.join(','),
|
||||
period_no: round.id,
|
||||
single_bet_amount: formattedSingleBetAmount,
|
||||
})
|
||||
|
||||
if (result.status !== 'accepted') {
|
||||
throw new Error(t('commonUi.toast.betRejected'))
|
||||
}
|
||||
|
||||
latestBalance = result.balance_after
|
||||
if (result.status !== 'accepted') {
|
||||
throw new Error(t('commonUi.toast.betRejected'))
|
||||
}
|
||||
|
||||
latestBalance = result.balance_after
|
||||
|
||||
if (currentUser) {
|
||||
setCurrentUser({
|
||||
...currentUser,
|
||||
@@ -223,6 +208,7 @@ export function useGameControlVm() {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [
|
||||
activeChipId,
|
||||
authStatus,
|
||||
clearSelections,
|
||||
confirmState,
|
||||
@@ -233,6 +219,7 @@ export function useGameControlVm() {
|
||||
round.id,
|
||||
round.phase,
|
||||
selections,
|
||||
selectedChip?.amount,
|
||||
setRecentSuccessfulSelections,
|
||||
setCurrentUser,
|
||||
setModalOpen,
|
||||
@@ -282,6 +269,7 @@ export function useGameControlVm() {
|
||||
round.phase === 'betting' &&
|
||||
!hasSubmittedCurrentRound &&
|
||||
!isAutoHosting,
|
||||
canDecreaseBetQuantity: activeBetQuantity > 1,
|
||||
confirmLabel:
|
||||
confirmState === 'idle'
|
||||
? t('gameDesktop.control.selectNumbers')
|
||||
@@ -293,12 +281,14 @@ export function useGameControlVm() {
|
||||
confirmState,
|
||||
isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
|
||||
onChipSelect: selectChip,
|
||||
onDecreaseBetQuantity: () => adjustBetQuantity(-1),
|
||||
onIncreaseBetQuantity: () => adjustBetQuantity(1),
|
||||
onConfirm: handleConfirm,
|
||||
onClearSelections: clearSelections,
|
||||
onOpenAutoSetting: handleOpenAutoSetting,
|
||||
onRepeatSelections: handleRepeatSelections,
|
||||
maxSelectionCountLabel: maxSelectionCount,
|
||||
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
|
||||
selectedBetQuantityLabel: activeBetQuantity,
|
||||
selectedChipId: activeChipId,
|
||||
selectedCountLabel: selections.length,
|
||||
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
type GameSocketMessage,
|
||||
} from '@/lib/ws/game-socket-client'
|
||||
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
import {
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
import { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api'
|
||||
import type {
|
||||
BetWinEventDataDto,
|
||||
@@ -606,6 +610,10 @@ function applyBetWinMessage(message: GameSocketMessage) {
|
||||
totalWin: betWinData.total_win,
|
||||
winningCellId: betWinData.result_number,
|
||||
})
|
||||
useGameAutoHostingStore.getState().recordBetWin({
|
||||
isJackpot: betWinData.is_jackpot,
|
||||
singleWinAmount: toOptionalNumber(betWinData.total_win) ?? null,
|
||||
})
|
||||
|
||||
if (!currentUser) {
|
||||
return
|
||||
|
||||
106
src/features/game/hooks/use-wallet-records-vm.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getWalletRecordList } from '@/features/game/api'
|
||||
|
||||
const WALLET_RECORD_PAGE_SIZE = 20
|
||||
const WALLET_RECORD_TYPE = 'payout'
|
||||
|
||||
function formatWalletAmount(value: string, locale: string) {
|
||||
const numberValue = Number(value)
|
||||
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
return value || '--'
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(numberValue)
|
||||
}
|
||||
|
||||
function formatWalletRecordTime(value: number | string | null) {
|
||||
if (value === null || value === '') {
|
||||
return '--'
|
||||
}
|
||||
|
||||
const numericValue = Number(value)
|
||||
const timestamp =
|
||||
Number.isFinite(numericValue) && numericValue > 0
|
||||
? numericValue < 10_000_000_000
|
||||
? numericValue * 1000
|
||||
: numericValue
|
||||
: value
|
||||
const formatted = dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
return formatted === 'Invalid Date' ? String(value) : formatted
|
||||
}
|
||||
|
||||
export function useWalletRecordsVm({ enabled }: { enabled: boolean }) {
|
||||
const { i18n, t } = useTranslation()
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['finance', 'wallet-record-list', WALLET_RECORD_TYPE],
|
||||
initialPageParam: 1,
|
||||
queryFn: ({ pageParam }) =>
|
||||
getWalletRecordList({
|
||||
page: pageParam,
|
||||
pageSize: WALLET_RECORD_PAGE_SIZE,
|
||||
type: WALLET_RECORD_TYPE,
|
||||
}),
|
||||
enabled,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextPage = lastPage.pagination.page + 1
|
||||
const loadedCount =
|
||||
lastPage.pagination.page * lastPage.pagination.page_size
|
||||
|
||||
return loadedCount < lastPage.pagination.total ? nextPage : undefined
|
||||
},
|
||||
})
|
||||
|
||||
const lastPage = query.data?.pages.at(-1)
|
||||
const total = lastPage?.pagination.total ?? 0
|
||||
const loadedPage = lastPage?.pagination.page ?? 1
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
(query.data?.pages ?? []).flatMap((page) =>
|
||||
page.list.map((item, index) => ({
|
||||
amountLabel: formatWalletAmount(item.amount, locale),
|
||||
balanceAfterLabel: formatWalletAmount(item.balanceAfter, locale),
|
||||
balanceBeforeLabel: formatWalletAmount(item.balanceBefore, locale),
|
||||
id: item.id || `${page.pagination.page}-${index}`,
|
||||
remarkLabel: item.remark || '--',
|
||||
timeLabel: formatWalletRecordTime(item.createdAt),
|
||||
typeLabel: item.type || WALLET_RECORD_TYPE,
|
||||
})),
|
||||
),
|
||||
[locale, query.data?.pages],
|
||||
)
|
||||
|
||||
return {
|
||||
emptyText: t('game.modals.userInfo.walletRecords.empty'),
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
hasNextPage: query.hasNextPage,
|
||||
headers: {
|
||||
amount: t('game.modals.userInfo.walletRecords.amount'),
|
||||
balanceAfter: t('game.modals.userInfo.walletRecords.balanceAfter'),
|
||||
balanceBefore: t('game.modals.userInfo.walletRecords.balanceBefore'),
|
||||
remark: t('game.modals.userInfo.walletRecords.remark'),
|
||||
time: t('game.modals.userInfo.walletRecords.time'),
|
||||
type: t('game.modals.userInfo.walletRecords.type'),
|
||||
},
|
||||
isError: query.isError,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isLoading: query.isLoading,
|
||||
items,
|
||||
loadFailedText: t('game.modals.userInfo.walletRecords.loadFailed'),
|
||||
loadingText: t('game.modals.userInfo.walletRecords.loading'),
|
||||
pageLabel: t('game.modals.userInfo.walletRecords.page', {
|
||||
page: loadedPage,
|
||||
total,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,41 @@
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { useFinanceRecordsVm } from '@/features/game/hooks/use-finance-records-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
const vm = useFinanceRecordsVm({ enabled })
|
||||
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: vm.items.length + (vm.hasNextPage ? 1 : 0),
|
||||
estimateSize: () => 72,
|
||||
getScrollElement: () => parentRef.current,
|
||||
overscan: 6,
|
||||
})
|
||||
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||
|
||||
useEffect(() => {
|
||||
const lastItem = virtualItems.at(-1)
|
||||
|
||||
if (
|
||||
!lastItem ||
|
||||
lastItem.index < vm.items.length - 1 ||
|
||||
!vm.hasNextPage ||
|
||||
vm.isFetchingNextPage
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void vm.fetchNextPage()
|
||||
}, [
|
||||
virtualItems,
|
||||
vm.fetchNextPage,
|
||||
vm.hasNextPage,
|
||||
vm.isFetchingNextPage,
|
||||
vm.items.length,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className={'flex h-full w-full flex-col p-design-10'}>
|
||||
@@ -26,7 +57,10 @@ function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
key={recordType.key}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => vm.selectRecordType(recordType.key)}
|
||||
onClick={() => {
|
||||
vm.selectRecordType(recordType.key)
|
||||
rowVirtualizer.scrollToOffset(0)
|
||||
}}
|
||||
className={cn(
|
||||
'relative h-design-44 min-w-design-130 cursor-pointer rounded-md px-design-16 text-design-18 transition-colors duration-200',
|
||||
isActive
|
||||
@@ -56,7 +90,7 @@ function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
<div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className={'min-h-0 flex-1 overflow-auto rounded-md'}>
|
||||
<div className={'min-h-0 flex-1 rounded-md'}>
|
||||
<div
|
||||
className={
|
||||
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] gap-design-10 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-16 py-design-12 text-design-16 text-[#7ECAD1]'
|
||||
@@ -68,91 +102,78 @@ function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={
|
||||
'mt-design-10 flex min-h-[calc(var(--design-unit)*320)] flex-col gap-design-10'
|
||||
'mt-design-10 max-h-[calc(var(--design-unit)*320)] min-h-0 overflow-auto pr-design-4'
|
||||
}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={vm.recordType}
|
||||
className={'flex flex-col gap-design-10'}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
x: vm.recordType === 'deposit' ? -18 : 18,
|
||||
}}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
x: vm.recordType === 'deposit' ? 18 : -18,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
{vm.isLoading ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadingText}
|
||||
</div>
|
||||
) : vm.isError ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadFailedText}
|
||||
</div>
|
||||
) : vm.items.length === 0 ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={'relative w-full'}
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{vm.isLoading ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadingText}
|
||||
</div>
|
||||
) : vm.isError ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadFailedText}
|
||||
</div>
|
||||
) : vm.items.length === 0 ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.emptyText}
|
||||
</div>
|
||||
) : (
|
||||
vm.items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
className={
|
||||
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-18 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
|
||||
}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
delay: Math.min(index, 6) * 0.025,
|
||||
duration: 0.16,
|
||||
ease: 'easeOut',
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const item = vm.items[virtualRow.index]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className={'absolute left-0 top-0 w-full pb-design-10'}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className={'truncate font-medium text-white'}>
|
||||
{item.orderNoLabel}
|
||||
</div>
|
||||
<div className={'text-[#FEEEB0]'}>{item.amountLabel}</div>
|
||||
<div className={'text-[#7CFFCF]'}>
|
||||
{item.bonusAmountLabel}
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
{item ? (
|
||||
<motion.div
|
||||
className={
|
||||
'grid h-[calc(var(--design-unit)*62)] grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-18 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
|
||||
}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.16,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
<div className={'truncate font-medium text-white'}>
|
||||
{item.orderNoLabel}
|
||||
</div>
|
||||
<div className={'truncate text-[#FEEEB0]'}>
|
||||
{item.amountLabel}
|
||||
</div>
|
||||
<div className={'truncate text-[#7CFFCF]'}>
|
||||
{item.bonusAmountLabel}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
'flex h-[calc(var(--design-unit)*62)] items-center justify-center rounded-md bg-[#0A4252]/60 text-design-16 text-[#6CCDCF]'
|
||||
}
|
||||
>
|
||||
{vm.loadingText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={'mt-design-12 flex items-center justify-end gap-design-10'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!vm.canGoPreviousPage || vm.isFetching}
|
||||
onClick={vm.goPreviousPage}
|
||||
className={
|
||||
'h-design-40 cursor-pointer rounded-md border border-[#3EAFC7]/35 bg-[#062E39] px-design-16 text-design-16 text-[#86DAE7] transition hover:bg-[#0A4252] disabled:cursor-not-allowed disabled:opacity-45'
|
||||
}
|
||||
>
|
||||
{vm.previousText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!vm.canGoNextPage || vm.isFetching}
|
||||
onClick={vm.goNextPage}
|
||||
className={
|
||||
'h-design-40 cursor-pointer rounded-md border border-[#3EAFC7]/35 bg-[#062E39] px-design-16 text-design-16 text-[#86DAE7] transition hover:bg-[#0A4252] disabled:cursor-not-allowed disabled:opacity-45'
|
||||
}
|
||||
>
|
||||
{vm.nextText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,53 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useEffect, useMemo, 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 noticeBg from '@/assets/system/notice-bg.webp'
|
||||
import blueBtnBg from '@/assets/system/blue-btn.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'
|
||||
|
||||
type NoticeViewState = 'detail' | 'list'
|
||||
|
||||
function DesktopNoticeModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopNotice)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const [noticeView, setNoticeView] = useState<NoticeViewState>('list')
|
||||
const [selectedNoticeId, setSelectedNoticeId] = useState<number | null>(null)
|
||||
|
||||
const noticeListQuery = useQuery({
|
||||
queryKey: ['game', 'notice-list'],
|
||||
queryFn: () => getNoticeList(),
|
||||
enabled: open && noticeView === 'list',
|
||||
})
|
||||
|
||||
const noticeDetailQuery = useQuery({
|
||||
queryKey: ['game', 'notice-detail', selectedNoticeId],
|
||||
queryFn: () => getNoticeDetail(selectedNoticeId ?? 0),
|
||||
enabled: open && noticeView === 'detail' && selectedNoticeId !== null,
|
||||
})
|
||||
|
||||
const noticeItems = useMemo(
|
||||
() => noticeListQuery.data?.list ?? [],
|
||||
[noticeListQuery.data],
|
||||
)
|
||||
|
||||
async function handleReturnToList() {
|
||||
setNoticeView('list')
|
||||
setSelectedNoticeId(null)
|
||||
await noticeListQuery.refetch()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setNoticeView('list')
|
||||
setSelectedNoticeId(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopNotice', false)
|
||||
@@ -22,54 +59,177 @@ function DesktopNoticeModal() {
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26'}>
|
||||
{t('game.modals.notice.title')}
|
||||
{t('game.modals.userInfo.message.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className={'w-design-1000 h-design-690'}
|
||||
className={'w-design-980 h-design-690'}
|
||||
>
|
||||
<div className={'flex flex-col h-full w-full p-design-10 gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-full h-design-440 overflow-y-auto flex flex-col gap-design-20 bg-[#000000]/50 p-design-10 rounded-md'
|
||||
}
|
||||
>
|
||||
<SmartImage
|
||||
className={'w-full h-design-300 shrink-0 rounded-md'}
|
||||
imgClassName={'object-contain object-center'}
|
||||
alt={'notice'}
|
||||
src={noticeBg}
|
||||
/>
|
||||
|
||||
<div className={'text-[#74B3BA] text-design-18 leading-[1.6]'}>
|
||||
{t('game.modals.notice.content')}
|
||||
<div className={'flex h-full w-full flex-col'}>
|
||||
{noticeView === 'detail' ? (
|
||||
<div
|
||||
className={
|
||||
'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'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleReturnToList()
|
||||
}}
|
||||
className={
|
||||
'flex cursor-pointer 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>
|
||||
</div>
|
||||
<div className={'w-full flex justify-around'}>
|
||||
<SmartBackground
|
||||
src={lengthGreenBtn}
|
||||
size="106% 108%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
className={
|
||||
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
|
||||
}
|
||||
>
|
||||
{t('game.modals.notice.check')}
|
||||
</SmartBackground>
|
||||
) : null}
|
||||
|
||||
<SmartBackground
|
||||
src={lengthBlueBtn}
|
||||
size="100% 90%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
className={
|
||||
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
|
||||
}
|
||||
>
|
||||
{t('game.modals.notice.check')}
|
||||
</SmartBackground>
|
||||
<div className={'h-full w-full overflow-auto rounded-md'}>
|
||||
{noticeView === '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)
|
||||
setNoticeView('detail')
|
||||
}}
|
||||
className={
|
||||
'flex cursor-pointer 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>
|
||||
</CenterModal>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { ArrowLeft, CircleUserRound, Mail, ReceiptText } from 'lucide-react'
|
||||
import {
|
||||
CircleUserRound,
|
||||
ClipboardList,
|
||||
ReceiptText,
|
||||
WalletCards,
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
import blueBtnBg from '@/assets/system/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 DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab'
|
||||
import DesktopWalletRecordsTab from '@/features/game/modal/desktop/desktop-wallet-records-tab'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
|
||||
type UserInfoTabKey = 'financeRecords' | 'message' | 'profile'
|
||||
type MessageViewState = 'list' | 'detail'
|
||||
type UserInfoTabKey = 'financeRecords' | 'profile' | 'walletRecords'
|
||||
|
||||
const USER_INFO_TABS: Array<{
|
||||
key: UserInfoTabKey
|
||||
@@ -34,67 +37,81 @@ const USER_INFO_TABS: Array<{
|
||||
icon: ReceiptText,
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
labelKey: 'game.modals.userInfo.tabs.message',
|
||||
icon: Mail,
|
||||
key: 'walletRecords',
|
||||
labelKey: 'game.modals.userInfo.tabs.walletRecords',
|
||||
icon: WalletCards,
|
||||
},
|
||||
]
|
||||
|
||||
const REGISTER_INVITE_CODE_QUERY_PARAM = 'registerInviteCode'
|
||||
|
||||
function createRegisterInviteUrl(inviteCode: string) {
|
||||
const url = new URL(window.location.href)
|
||||
|
||||
url.searchParams.set(REGISTER_INVITE_CODE_QUERY_PARAM, inviteCode)
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string) {
|
||||
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.style.top = '-9999px'
|
||||
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
const copied = document.execCommand('copy')
|
||||
|
||||
if (!copied) {
|
||||
throw new Error('Copy command failed')
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
function DesktopUserInfoModal() {
|
||||
const { t } = useTranslation()
|
||||
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()
|
||||
}
|
||||
const inviteCode = currentUser?.registerInviteCode?.trim() ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveTab('profile')
|
||||
setMessageView('list')
|
||||
setSelectedNoticeId(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'message') {
|
||||
setMessageView('list')
|
||||
setSelectedNoticeId(null)
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopUserInfo', false)
|
||||
}
|
||||
|
||||
async function handleCopyInviteLink() {
|
||||
if (!inviteCode) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await copyTextToClipboard(createRegisterInviteUrl(inviteCode))
|
||||
notify.success(t('commonUi.toast.inviteLinkCopied'))
|
||||
} catch {
|
||||
notify.error(t('commonUi.toast.inviteLinkCopyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
@@ -217,7 +234,7 @@ function DesktopUserInfoModal() {
|
||||
|
||||
<div
|
||||
className={
|
||||
'w-design-600 flex-1 text-design-18 rounded-md bg-[#000000]/40 flex flex-col gap-design-20 p-design-20 '
|
||||
'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]'}>
|
||||
@@ -225,19 +242,35 @@ function DesktopUserInfoModal() {
|
||||
<span
|
||||
className={'text-design-18 text-[#599AA3] ml-design-10'}
|
||||
>
|
||||
{dayjs(currentUser?.createTime).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
) || '--'}
|
||||
{currentUser?.createTime
|
||||
? dayjs
|
||||
.unix(currentUser.createTime)
|
||||
.format('YYYY-MM-DD HH:mm:ss')
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={'text-[#6CCDCF]'}>
|
||||
{t('auth.register.fields.inviteCode.label')}
|
||||
<div className={'flex items-center text-[#6CCDCF]'}>
|
||||
<span>{t('auth.register.fields.inviteCode.label')}</span>
|
||||
<span
|
||||
className={'text-design-18 text-[#599AA3] ml-design-10'}
|
||||
>
|
||||
{currentUser?.registerInviteCode || '--'}
|
||||
{inviteCode || '--'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleCopyInviteLink()
|
||||
}}
|
||||
disabled={!inviteCode}
|
||||
aria-label={t(
|
||||
'game.modals.userInfo.profile.copyInviteLink',
|
||||
)}
|
||||
title={t('game.modals.userInfo.profile.copyInviteLink')}
|
||||
className="ml-design-10 flex h-design-30 w-design-30 cursor-pointer items-center justify-center rounded-md border border-[#356E76] bg-[#0B2F35]/70 text-[#6CCDCF] transition-colors duration-200 hover:border-[#6CCDCF] hover:text-[#D9FFFF] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#6CCDCF] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<ClipboardList className="h-design-18 w-design-18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
@@ -246,188 +279,9 @@ function DesktopUserInfoModal() {
|
||||
enabled={open && activeTab === 'financeRecords'}
|
||||
/>
|
||||
) : (
|
||||
<div className={'flex h-full w-full flex-col'}>
|
||||
{messageView === 'detail' ? (
|
||||
<div
|
||||
className={
|
||||
'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'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleReturnToList()
|
||||
}}
|
||||
className={
|
||||
'flex cursor-pointer 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 cursor-pointer 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>
|
||||
<DesktopWalletRecordsTab
|
||||
enabled={open && activeTab === 'walletRecords'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
148
src/features/game/modal/desktop/desktop-wallet-records-tab.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { useWalletRecordsVm } from '@/features/game/hooks/use-wallet-records-vm'
|
||||
|
||||
function DesktopWalletRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
const vm = useWalletRecordsVm({ enabled })
|
||||
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: vm.items.length + (vm.hasNextPage ? 1 : 0),
|
||||
estimateSize: () => 72,
|
||||
getScrollElement: () => parentRef.current,
|
||||
overscan: 6,
|
||||
})
|
||||
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||
|
||||
useEffect(() => {
|
||||
const lastItem = virtualItems.at(-1)
|
||||
|
||||
if (
|
||||
!lastItem ||
|
||||
lastItem.index < vm.items.length - 1 ||
|
||||
!vm.hasNextPage ||
|
||||
vm.isFetchingNextPage
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void vm.fetchNextPage()
|
||||
}, [
|
||||
virtualItems,
|
||||
vm.fetchNextPage,
|
||||
vm.hasNextPage,
|
||||
vm.isFetchingNextPage,
|
||||
vm.items.length,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className={'flex h-full w-full flex-col p-design-10'}>
|
||||
<div
|
||||
className={
|
||||
'mb-design-12 flex items-center justify-between gap-design-16 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
|
||||
}
|
||||
>
|
||||
<div className={'text-design-20 font-medium text-[#BFEAEC]'}>
|
||||
{vm.headers.type}
|
||||
</div>
|
||||
<div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className={'min-h-0 flex-1 rounded-md'}>
|
||||
<div
|
||||
className={
|
||||
'grid grid-cols-[minmax(0,1.1fr)_minmax(0,0.75fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,1fr)] gap-design-10 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-16 py-design-12 text-design-16 text-[#7ECAD1]'
|
||||
}
|
||||
>
|
||||
<div>{vm.headers.time}</div>
|
||||
<div>{vm.headers.amount}</div>
|
||||
<div>{vm.headers.balanceBefore}</div>
|
||||
<div>{vm.headers.balanceAfter}</div>
|
||||
<div>{vm.headers.remark}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={
|
||||
'mt-design-10 max-h-[calc(var(--design-unit)*320)] min-h-0 overflow-auto pr-design-4'
|
||||
}
|
||||
>
|
||||
{vm.isLoading ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadingText}
|
||||
</div>
|
||||
) : vm.isError ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadFailedText}
|
||||
</div>
|
||||
) : vm.items.length === 0 ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={'relative w-full'}
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const item = vm.items[virtualRow.index]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className={'absolute left-0 top-0 w-full pb-design-10'}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{item ? (
|
||||
<motion.div
|
||||
className={
|
||||
'grid h-[calc(var(--design-unit)*62)] grid-cols-[minmax(0,1.1fr)_minmax(0,0.75fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,1fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-17 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
|
||||
}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.16,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
<div className={'truncate text-[#BFEAEC]'}>
|
||||
{item.timeLabel}
|
||||
</div>
|
||||
<div className={'truncate font-medium text-[#FEEEB0]'}>
|
||||
{item.amountLabel}
|
||||
</div>
|
||||
<div className={'truncate text-[#86DAE7]'}>
|
||||
{item.balanceBeforeLabel}
|
||||
</div>
|
||||
<div className={'truncate text-[#7CFFCF]'}>
|
||||
{item.balanceAfterLabel}
|
||||
</div>
|
||||
<div className={'truncate text-white'}>
|
||||
{item.remarkLabel}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
'flex h-[calc(var(--design-unit)*62)] items-center justify-center rounded-md bg-[#0A4252]/60 text-design-16 text-[#6CCDCF]'
|
||||
}
|
||||
>
|
||||
{vm.loadingText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopWalletRecordsTab
|
||||
@@ -24,12 +24,23 @@ interface NotificationStoreState {
|
||||
closingDialogId: string | null
|
||||
dialogQueue: NotificationDialog[]
|
||||
dismissDialog: (id?: string) => void
|
||||
pushDialog: (dialog: NotificationDialog) => void
|
||||
pushDialog: (dialog: NotificationDialog) => string
|
||||
}
|
||||
|
||||
const dialogTimers = new Map<string, number>()
|
||||
const dialogExitTimers = new Map<string, number>()
|
||||
|
||||
function isSameDialog(
|
||||
firstDialog: NotificationDialog,
|
||||
secondDialog: NotificationDialog,
|
||||
) {
|
||||
return (
|
||||
firstDialog.type === secondDialog.type &&
|
||||
firstDialog.message === secondDialog.message &&
|
||||
(firstDialog.description ?? '') === (secondDialog.description ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
function clearDialogTimer(id: string) {
|
||||
const timerId = dialogTimers.get(id)
|
||||
|
||||
@@ -129,6 +140,8 @@ export const useNotificationStore = create<NotificationStoreState>()((set) => ({
|
||||
})
|
||||
},
|
||||
pushDialog: (dialog) => {
|
||||
let dialogId = dialog.id
|
||||
|
||||
set((state) => {
|
||||
if (!state.activeDialog) {
|
||||
clearDialogExitTimer(dialog.id)
|
||||
@@ -140,6 +153,42 @@ export const useNotificationStore = create<NotificationStoreState>()((set) => ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isSameDialog(state.activeDialog, dialog)) {
|
||||
dialogId = state.activeDialog.id
|
||||
clearDialogExitTimer(state.activeDialog.id)
|
||||
scheduleDialogDismiss(state.activeDialog.id, dialog.duration)
|
||||
|
||||
return {
|
||||
activeDialog: {
|
||||
...state.activeDialog,
|
||||
duration: dialog.duration,
|
||||
},
|
||||
closingDialogId: null,
|
||||
dialogQueue: state.dialogQueue.filter(
|
||||
(item) => !isSameDialog(item, dialog),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const queuedDialog = state.dialogQueue.find((item) =>
|
||||
isSameDialog(item, dialog),
|
||||
)
|
||||
|
||||
if (queuedDialog) {
|
||||
dialogId = queuedDialog.id
|
||||
|
||||
return {
|
||||
dialogQueue: state.dialogQueue.map((item) =>
|
||||
item.id === queuedDialog.id
|
||||
? {
|
||||
...item,
|
||||
duration: dialog.duration,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dialogQueue: [
|
||||
...state.dialogQueue.filter((item) => item.id !== dialog.id),
|
||||
@@ -147,6 +196,8 @@ export const useNotificationStore = create<NotificationStoreState>()((set) => ({
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return dialogId
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -162,15 +213,13 @@ function showToast(
|
||||
const id = createToastId()
|
||||
const duration = options?.duration ?? DEFAULT_ALERT_DURATION_MS
|
||||
|
||||
useNotificationStore.getState().pushDialog({
|
||||
return useNotificationStore.getState().pushDialog({
|
||||
description: options?.description,
|
||||
duration,
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
})
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
export const notify = {
|
||||
|
||||
@@ -152,12 +152,14 @@ export default {
|
||||
tabs: {
|
||||
profile: 'Profile',
|
||||
financeRecords: 'Top Up / Withdraw Records',
|
||||
walletRecords: 'Wallet Records',
|
||||
message: 'Messages',
|
||||
},
|
||||
profile: {
|
||||
name: 'Name',
|
||||
tel: 'Phone',
|
||||
registeredAt: 'Registered at',
|
||||
copyInviteLink: 'Copy invite link',
|
||||
signature:
|
||||
'My signature is as unique as my personality. This area will later display the real profile summary.',
|
||||
},
|
||||
@@ -187,6 +189,20 @@ export default {
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
},
|
||||
walletRecords: {
|
||||
amount: 'Amount',
|
||||
balanceAfter: 'After',
|
||||
balanceBefore: 'Before',
|
||||
empty: 'No wallet records yet',
|
||||
loadFailed: 'Failed to load wallet records. Please try again later.',
|
||||
loading: 'Loading wallet records...',
|
||||
next: 'Next',
|
||||
page: 'Page {{page}} / {{total}} total',
|
||||
previous: 'Previous',
|
||||
remark: 'Remark',
|
||||
time: 'Time',
|
||||
type: 'Wallet Records',
|
||||
},
|
||||
},
|
||||
withdrawTopup: {
|
||||
applyWithdraw: 'Apply for Withdrawal',
|
||||
@@ -229,6 +245,9 @@ export default {
|
||||
loginRequired: 'Please log in before entering the game',
|
||||
loginSuccess: 'Login successful',
|
||||
registerSuccess: 'Registration successful',
|
||||
inviteLinkCopied: 'Invite link copied',
|
||||
inviteLinkCopyFailed:
|
||||
'Failed to copy invite link. Please copy it manually.',
|
||||
insufficientBalance: 'Insufficient balance. Please adjust your bet.',
|
||||
betUnavailable: 'Betting is not available for this round',
|
||||
betPlaced: 'Bet placed successfully',
|
||||
|
||||
@@ -151,12 +151,14 @@ export default {
|
||||
tabs: {
|
||||
profile: 'Profil',
|
||||
financeRecords: 'Riwayat Isi Ulang / Penarikan',
|
||||
walletRecords: 'Riwayat Dompet',
|
||||
message: 'Pesan',
|
||||
},
|
||||
profile: {
|
||||
name: 'Nama',
|
||||
tel: 'Telepon',
|
||||
registeredAt: 'Tanggal daftar',
|
||||
copyInviteLink: 'Salin tautan undangan',
|
||||
signature:
|
||||
'Tanda tanganku seunik diriku. Bagian ini nantinya akan menampilkan ringkasan profil yang sebenarnya.',
|
||||
},
|
||||
@@ -186,6 +188,20 @@ export default {
|
||||
previous: 'Sebelumnya',
|
||||
next: 'Berikutnya',
|
||||
},
|
||||
walletRecords: {
|
||||
amount: 'Jumlah',
|
||||
balanceAfter: 'Sesudah',
|
||||
balanceBefore: 'Sebelum',
|
||||
empty: 'Belum ada riwayat dompet',
|
||||
loadFailed: 'Gagal memuat riwayat dompet. Silakan coba lagi nanti.',
|
||||
loading: 'Memuat riwayat dompet...',
|
||||
next: 'Berikutnya',
|
||||
page: 'Halaman {{page}} / total {{total}}',
|
||||
previous: 'Sebelumnya',
|
||||
remark: 'Catatan',
|
||||
time: 'Waktu',
|
||||
type: 'Riwayat Dompet',
|
||||
},
|
||||
},
|
||||
withdrawTopup: {
|
||||
applyWithdraw: 'Ajukan Penarikan',
|
||||
@@ -228,6 +244,9 @@ export default {
|
||||
loginRequired: 'Silakan masuk sebelum memasuki game',
|
||||
loginSuccess: 'Berhasil masuk',
|
||||
registerSuccess: 'Pendaftaran berhasil',
|
||||
inviteLinkCopied: 'Tautan undangan disalin',
|
||||
inviteLinkCopyFailed:
|
||||
'Gagal menyalin tautan undangan. Silakan salin secara manual.',
|
||||
insufficientBalance: 'Saldo tidak cukup. Silakan sesuaikan taruhan.',
|
||||
betUnavailable: 'Taruhan tidak tersedia untuk ronde ini',
|
||||
betPlaced: 'Taruhan berhasil dikirim',
|
||||
|
||||
@@ -154,12 +154,14 @@ export default {
|
||||
tabs: {
|
||||
profile: 'Profil',
|
||||
financeRecords: 'Rekod Tambah Nilai / Pengeluaran',
|
||||
walletRecords: 'Rekod Dompet',
|
||||
message: 'Mesej',
|
||||
},
|
||||
profile: {
|
||||
name: 'Nama',
|
||||
tel: 'Telefon',
|
||||
registeredAt: 'Tarikh daftar',
|
||||
copyInviteLink: 'Salin pautan jemputan',
|
||||
signature:
|
||||
'Tandatangan saya unik seperti personaliti saya. Bahagian ini akan memaparkan ringkasan profil sebenar kemudian.',
|
||||
},
|
||||
@@ -189,6 +191,20 @@ export default {
|
||||
previous: 'Sebelumnya',
|
||||
next: 'Seterusnya',
|
||||
},
|
||||
walletRecords: {
|
||||
amount: 'Jumlah',
|
||||
balanceAfter: 'Selepas',
|
||||
balanceBefore: 'Sebelum',
|
||||
empty: 'Belum ada rekod dompet',
|
||||
loadFailed: 'Gagal memuatkan rekod dompet. Sila cuba lagi kemudian.',
|
||||
loading: 'Memuatkan rekod dompet...',
|
||||
next: 'Seterusnya',
|
||||
page: 'Halaman {{page}} / jumlah {{total}}',
|
||||
previous: 'Sebelumnya',
|
||||
remark: 'Catatan',
|
||||
time: 'Masa',
|
||||
type: 'Rekod Dompet',
|
||||
},
|
||||
},
|
||||
withdrawTopup: {
|
||||
applyWithdraw: 'Mohon Pengeluaran',
|
||||
@@ -231,6 +247,9 @@ export default {
|
||||
loginRequired: 'Sila log masuk sebelum memasuki permainan',
|
||||
loginSuccess: 'Log masuk berjaya',
|
||||
registerSuccess: 'Pendaftaran berjaya',
|
||||
inviteLinkCopied: 'Pautan jemputan telah disalin',
|
||||
inviteLinkCopyFailed:
|
||||
'Gagal menyalin pautan jemputan. Sila salin secara manual.',
|
||||
insufficientBalance: 'Baki tidak mencukupi. Sila laraskan taruhan.',
|
||||
betUnavailable: 'Taruhan tidak tersedia untuk pusingan ini',
|
||||
betPlaced: 'Taruhan berjaya dihantar',
|
||||
|
||||
@@ -149,12 +149,14 @@ export default {
|
||||
tabs: {
|
||||
profile: '个人信息',
|
||||
financeRecords: '充值/提现记录',
|
||||
walletRecords: '钱包流水',
|
||||
message: '站内消息',
|
||||
},
|
||||
profile: {
|
||||
name: '用户名',
|
||||
tel: '电话',
|
||||
registeredAt: '注册时间',
|
||||
copyInviteLink: '复制邀请链接',
|
||||
signature: '我的个性签名和灵魂一样独特,后续这里会接真实资料字段。',
|
||||
},
|
||||
message: {
|
||||
@@ -182,6 +184,20 @@ export default {
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
},
|
||||
walletRecords: {
|
||||
amount: '变动金额',
|
||||
balanceAfter: '变动后',
|
||||
balanceBefore: '变动前',
|
||||
empty: '暂无钱包流水',
|
||||
loadFailed: '钱包流水加载失败,请稍后重试',
|
||||
loading: '钱包流水加载中...',
|
||||
next: '下一页',
|
||||
page: '第 {{page}} 页 / 共 {{total}} 条',
|
||||
previous: '上一页',
|
||||
remark: '备注',
|
||||
time: '时间',
|
||||
type: '钱包流水',
|
||||
},
|
||||
},
|
||||
withdrawTopup: {
|
||||
applyWithdraw: '申请提现',
|
||||
@@ -223,6 +239,8 @@ export default {
|
||||
loginRequired: '请先登录后进入游戏',
|
||||
loginSuccess: '登录成功',
|
||||
registerSuccess: '注册成功',
|
||||
inviteLinkCopied: '邀请链接已复制',
|
||||
inviteLinkCopyFailed: '邀请链接复制失败,请手动复制',
|
||||
insufficientBalance: '余额不足,请调整下注金额',
|
||||
betUnavailable: '当前期不可下注',
|
||||
betPlaced: '下注成功',
|
||||
|
||||
@@ -24,10 +24,16 @@ export interface GameAutoHostingStoreState {
|
||||
balanceAfterBet: number | null
|
||||
completedRounds: number
|
||||
isHosting: boolean
|
||||
lastIsJackpot: boolean | null
|
||||
lastSingleWinAmount: number | null
|
||||
lastSubmittedRoundId: string | null
|
||||
rules: AutoHostingStopRules
|
||||
selections: BetSelection[]
|
||||
markRoundSubmitted: (roundId: string, balanceAfterBet: number | null) => void
|
||||
recordBetWin: (input: {
|
||||
isJackpot: boolean
|
||||
singleWinAmount: number | null
|
||||
}) => void
|
||||
startHosting: (input: StartAutoHostingInput) => void
|
||||
stopHosting: () => void
|
||||
}
|
||||
@@ -49,6 +55,8 @@ export const useGameAutoHostingStore = create<GameAutoHostingStoreState>()(
|
||||
balanceAfterBet: null,
|
||||
completedRounds: 0,
|
||||
isHosting: false,
|
||||
lastIsJackpot: null,
|
||||
lastSingleWinAmount: null,
|
||||
lastSubmittedRoundId: null,
|
||||
rules: DEFAULT_AUTO_HOSTING_RULES,
|
||||
selections: [],
|
||||
@@ -65,11 +73,25 @@ export const useGameAutoHostingStore = create<GameAutoHostingStoreState>()(
|
||||
}
|
||||
})
|
||||
},
|
||||
recordBetWin: ({ isJackpot, singleWinAmount }) => {
|
||||
set((state) => {
|
||||
if (!state.isHosting) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
lastIsJackpot: isJackpot,
|
||||
lastSingleWinAmount: singleWinAmount,
|
||||
}
|
||||
})
|
||||
},
|
||||
startHosting: ({ balanceAfterBet, rules, selections }) => {
|
||||
set({
|
||||
balanceAfterBet,
|
||||
completedRounds: 0,
|
||||
isHosting: true,
|
||||
lastIsJackpot: null,
|
||||
lastSingleWinAmount: null,
|
||||
lastSubmittedRoundId: null,
|
||||
rules,
|
||||
selections: selections.map((selection) => ({
|
||||
@@ -82,6 +104,8 @@ export const useGameAutoHostingStore = create<GameAutoHostingStoreState>()(
|
||||
balanceAfterBet: null,
|
||||
completedRounds: 0,
|
||||
isHosting: false,
|
||||
lastIsJackpot: null,
|
||||
lastSingleWinAmount: null,
|
||||
lastSubmittedRoundId: null,
|
||||
selections: [],
|
||||
})
|
||||
|
||||
@@ -31,6 +31,8 @@ type GameRoundSlice = Pick<
|
||||
| 'trends'
|
||||
>
|
||||
|
||||
const MIN_BET_QUANTITY = 1
|
||||
|
||||
export type RevealAnimationPhase = 'idle' | 'spinning' | 'stopping' | 'result'
|
||||
export type RewardAnimationType = 'none' | 'small' | 'big'
|
||||
|
||||
@@ -102,8 +104,49 @@ function resolveRecentActiveChipId(
|
||||
DEFAULT_ACTIVE_CHIP_ID)
|
||||
}
|
||||
|
||||
function normalizeBetQuantity(quantity: number) {
|
||||
if (!Number.isFinite(quantity)) {
|
||||
return MIN_BET_QUANTITY
|
||||
}
|
||||
|
||||
return Math.max(MIN_BET_QUANTITY, Math.floor(quantity))
|
||||
}
|
||||
|
||||
function getSelectionBetAmount(chip: Chip, quantity: number) {
|
||||
return chip.amount * normalizeBetQuantity(quantity)
|
||||
}
|
||||
|
||||
function syncSelectionsBetAmount(
|
||||
selections: BetSelection[],
|
||||
chipId: string,
|
||||
amount: number,
|
||||
) {
|
||||
return selections.map((selection) => ({
|
||||
...selection,
|
||||
amount,
|
||||
chipId,
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveSelectionQuantity(
|
||||
selections: BetSelection[],
|
||||
chips: Chip[],
|
||||
activeChipId: string,
|
||||
) {
|
||||
const chip = getChipById(chips, activeChipId)
|
||||
const firstSelection = selections[0]
|
||||
|
||||
if (!chip || !firstSelection || chip.amount <= 0) {
|
||||
return MIN_BET_QUANTITY
|
||||
}
|
||||
|
||||
return normalizeBetQuantity(firstSelection.amount / chip.amount)
|
||||
}
|
||||
|
||||
export interface GameRoundStoreState extends GameRoundSlice {
|
||||
activeChipId: string
|
||||
activeBetQuantity: number
|
||||
adjustBetQuantity: (delta: number) => void
|
||||
clearSelections: () => void
|
||||
clearRewardAnimation: () => void
|
||||
finishRevealAnimation: () => void
|
||||
@@ -135,6 +178,7 @@ export interface GameRoundStoreState extends GameRoundSlice {
|
||||
|
||||
function createInitialRoundState(): GameRoundSlice & {
|
||||
activeChipId: string
|
||||
activeBetQuantity: number
|
||||
recentSuccessfulSelections: BetSelection[]
|
||||
revealAnimation: RevealAnimationState
|
||||
} {
|
||||
@@ -142,6 +186,7 @@ function createInitialRoundState(): GameRoundSlice & {
|
||||
|
||||
return {
|
||||
activeChipId: DEFAULT_ACTIVE_CHIP_ID,
|
||||
activeBetQuantity: MIN_BET_QUANTITY,
|
||||
cells: snapshot.cells,
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
@@ -156,6 +201,30 @@ function createInitialRoundState(): GameRoundSlice & {
|
||||
|
||||
export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
...createInitialRoundState(),
|
||||
adjustBetQuantity: (delta) => {
|
||||
set((state) => {
|
||||
const activeChip =
|
||||
getChipById(state.chips, state.activeChipId) ??
|
||||
state.chips.find((chip) => chip.isDefault) ??
|
||||
state.chips[0]
|
||||
const nextQuantity = normalizeBetQuantity(state.activeBetQuantity + delta)
|
||||
|
||||
if (!activeChip) {
|
||||
return {
|
||||
activeBetQuantity: nextQuantity,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeBetQuantity: nextQuantity,
|
||||
selections: syncSelectionsBetAmount(
|
||||
state.selections,
|
||||
activeChip.id,
|
||||
getSelectionBetAmount(activeChip, nextQuantity),
|
||||
),
|
||||
}
|
||||
})
|
||||
},
|
||||
clearSelections: () => {
|
||||
set({ selections: [] })
|
||||
},
|
||||
@@ -211,24 +280,29 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
})
|
||||
},
|
||||
hydrateRound: (snapshot) => {
|
||||
set((state) => ({
|
||||
activeChipId: getChipById(snapshot.chips, state.activeChipId)
|
||||
set((state) => {
|
||||
const nextActiveChipId = getChipById(snapshot.chips, state.activeChipId)
|
||||
? state.activeChipId
|
||||
: (snapshot.chips.find((chip) => chip.isDefault)?.id ??
|
||||
snapshot.chips[0]?.id ??
|
||||
DEFAULT_ACTIVE_CHIP_ID),
|
||||
cells: snapshot.cells,
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
maxSelectionCount: snapshot.maxSelectionCount,
|
||||
revealAnimation:
|
||||
snapshot.round.phase === 'betting' || snapshot.round.phase === 'waiting'
|
||||
? createIdleRevealAnimationPreservingReward(state.revealAnimation)
|
||||
: state.revealAnimation,
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
trends: snapshot.trends,
|
||||
}))
|
||||
DEFAULT_ACTIVE_CHIP_ID)
|
||||
|
||||
return {
|
||||
activeChipId: nextActiveChipId,
|
||||
cells: snapshot.cells,
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
maxSelectionCount: snapshot.maxSelectionCount,
|
||||
revealAnimation:
|
||||
snapshot.round.phase === 'betting' ||
|
||||
snapshot.round.phase === 'waiting'
|
||||
? createIdleRevealAnimationPreservingReward(state.revealAnimation)
|
||||
: state.revealAnimation,
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
trends: snapshot.trends,
|
||||
}
|
||||
})
|
||||
},
|
||||
placeBet: (cellId) => {
|
||||
set((state) => {
|
||||
@@ -256,7 +330,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
selections: [
|
||||
...state.selections,
|
||||
{
|
||||
amount: activeChip.amount,
|
||||
amount: getSelectionBetAmount(activeChip, state.activeBetQuantity),
|
||||
cellId,
|
||||
chipId: activeChip.id,
|
||||
id: `bet-${cellId}-${state.selections.length + 1}-${Date.now()}`,
|
||||
@@ -337,13 +411,28 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
return false
|
||||
}
|
||||
|
||||
const nextActiveChipId = resolveRecentActiveChipId(
|
||||
state.chips,
|
||||
nextSelections,
|
||||
state.activeChipId,
|
||||
)
|
||||
const nextActiveChip = getChipById(state.chips, nextActiveChipId)
|
||||
const nextBetQuantity = resolveSelectionQuantity(
|
||||
nextSelections,
|
||||
state.chips,
|
||||
nextActiveChipId,
|
||||
)
|
||||
|
||||
set({
|
||||
activeChipId: resolveRecentActiveChipId(
|
||||
state.chips,
|
||||
nextSelections,
|
||||
state.activeChipId,
|
||||
),
|
||||
selections: nextSelections,
|
||||
activeBetQuantity: nextBetQuantity,
|
||||
activeChipId: nextActiveChipId,
|
||||
selections: nextActiveChip
|
||||
? syncSelectionsBetAmount(
|
||||
nextSelections,
|
||||
nextActiveChipId,
|
||||
getSelectionBetAmount(nextActiveChip, nextBetQuantity),
|
||||
)
|
||||
: nextSelections,
|
||||
})
|
||||
|
||||
return true
|
||||
@@ -357,11 +446,21 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
},
|
||||
selectChip: (chipId) => {
|
||||
set((state) => {
|
||||
if (!getChipById(state.chips, chipId)) {
|
||||
const nextChip = getChipById(state.chips, chipId)
|
||||
|
||||
if (!nextChip) {
|
||||
return state
|
||||
}
|
||||
|
||||
return { activeChipId: chipId }
|
||||
return {
|
||||
activeBetQuantity: MIN_BET_QUANTITY,
|
||||
activeChipId: chipId,
|
||||
selections: syncSelectionsBetAmount(
|
||||
state.selections,
|
||||
chipId,
|
||||
getSelectionBetAmount(nextChip, MIN_BET_QUANTITY),
|
||||
),
|
||||
}
|
||||
})
|
||||
},
|
||||
setPhase: (phase) => {
|
||||
|
||||
@@ -394,7 +394,7 @@
|
||||
.gold-reveal-shell {
|
||||
--gold-angle: 0deg;
|
||||
position: absolute;
|
||||
inset: calc(var(--design-unit) * 3.2);
|
||||
inset: calc(var(--design-unit) * 2.6);
|
||||
border-radius: calc(var(--design-unit) * 16);
|
||||
overflow: hidden;
|
||||
clip-path: inset(0 round calc(var(--design-unit) * 16));
|
||||
@@ -406,7 +406,7 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: calc(var(--design-unit) * 16);
|
||||
padding: calc(var(--design-unit) * 2.6);
|
||||
padding: calc(var(--design-unit) * 5.6);
|
||||
background: conic-gradient(
|
||||
from var(--gold-angle),
|
||||
#534217 10%,
|
||||
@@ -428,9 +428,9 @@
|
||||
.gold-reveal-shell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: calc(var(--design-unit) * 0.8);
|
||||
inset: calc(var(--design-unit) * 1);
|
||||
border-radius: calc(var(--design-unit) * 15);
|
||||
padding: calc(var(--design-unit) * 1.8);
|
||||
padding: calc(var(--design-unit) * 4);
|
||||
background: conic-gradient(
|
||||
from calc(var(--gold-angle) + 180deg),
|
||||
rgba(255, 247, 210, 0) 0%,
|
||||
@@ -452,12 +452,12 @@
|
||||
|
||||
.gold-reveal-static-border {
|
||||
position: absolute;
|
||||
inset: calc(var(--design-unit) * 3.2);
|
||||
inset: calc(var(--design-unit) * 2.8);
|
||||
border-radius: calc(var(--design-unit) * 16);
|
||||
border: calc(var(--design-unit) * 3.8) solid rgba(181, 138, 40, 0.98);
|
||||
border: calc(var(--design-unit) * 7.4) solid rgba(181, 138, 40, 0.98);
|
||||
box-shadow:
|
||||
inset 0 0 calc(var(--design-unit) * 10) rgba(255, 241, 181, 0.28),
|
||||
0 0 calc(var(--design-unit) * 12) rgba(255, 210, 102, 0.2);
|
||||
inset 0 0 calc(var(--design-unit) * 14) rgba(255, 241, 181, 0.38),
|
||||
0 0 calc(var(--design-unit) * 18) rgba(255, 210, 102, 0.32);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||