feat(game): 优化界面组件

- 在国际化文件中添加钱包流水相关翻译项
- 在用户个人资料页面添加复制邀请链接功能
- 优化桌面端动物组件的视觉效果和动画参数
- 添加虚拟滚动功能到财务记录标签页提升性能
- 为桌面端控制面板添加投注数量调节按钮
- 更新消息模态框为通知列表和详情展示
- 在头部余额显示旁添加充值图标入口
This commit is contained in:
JiaJun
2026-05-28 11:34:02 +08:00
parent 2b2b86a73d
commit dbfe5701aa
130 changed files with 1357 additions and 597 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -159,6 +159,7 @@ export const FINANCE_API_ENDPOINTS = {
depositTierList: 'api/finance/depositTierList', depositTierList: 'api/finance/depositTierList',
depositWithdrawConfig: 'api/finance/depositWithdrawConfig', depositWithdrawConfig: 'api/finance/depositWithdrawConfig',
legacyCashierConfig: 'api/finance/cashierConfig', legacyCashierConfig: 'api/finance/cashierConfig',
walletRecordList: 'api/wallet/recordList',
withdrawCreate: 'api/finance/withdrawCreate', withdrawCreate: 'api/finance/withdrawCreate',
withdrawList: 'api/finance/withdrawList', withdrawList: 'api/finance/withdrawList',
} as const } as const

View File

@@ -15,12 +15,26 @@ interface UseRegisterFormOptions {
onSuccess?: () => void 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 = {}) { export function useRegisterForm({ onSuccess }: UseRegisterFormOptions = {}) {
const startSession = useAuthStore((state) => state.startSession) const startSession = useAuthStore((state) => state.startSession)
const form = useForm<RegisterFormValues>({ const form = useForm<RegisterFormValues>({
defaultValues: { defaultValues: {
confirmPassword: '', confirmPassword: '',
inviteCode: '', inviteCode: getInitialRegisterInviteCode(),
password: '', password: '',
username: '', username: '',
}, },

View File

@@ -18,6 +18,10 @@ import type {
FinanceRateConfigDto, FinanceRateConfigDto,
FinanceWithdrawBankDto, FinanceWithdrawBankDto,
FinanceWithdrawConfigDto, FinanceWithdrawConfigDto,
WalletRecordItemDto,
WalletRecordList,
WalletRecordListDto,
WalletRecordType,
WithdrawCreateRequestDto, WithdrawCreateRequestDto,
WithdrawCreateResponseDto, WithdrawCreateResponseDto,
} from './finance-types' } 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() { export async function getDepositWithdrawConfig() {
const response = await api.post<DepositWithdrawConfigDto>( const response = await api.post<DepositWithdrawConfigDto>(
FINANCE_API_ENDPOINTS.depositWithdrawConfig, FINANCE_API_ENDPOINTS.depositWithdrawConfig,
@@ -287,6 +334,29 @@ export async function getWithdrawOrderList(params?: {
return normalizeFinanceOrderList(dto) 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) { export async function createWithdraw(payload: WithdrawCreateRequestDto) {
const response = await api.post< const response = await api.post<
WithdrawCreateResponseDto, WithdrawCreateResponseDto,

View File

@@ -190,6 +190,56 @@ export interface FinanceOrderList {
pagination: FinanceOrderPaginationDto 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 { export interface WithdrawCreateRequestDto {
bank_code: string bank_code: string
channel_code: string channel_code: string

View File

@@ -288,10 +288,12 @@ export interface GameBetOrdersDto {
} }
export interface GamePlaceBetRequestDto { export interface GamePlaceBetRequestDto {
bet_amount?: string
bet_id: number bet_id: number
idempotency_key: string idempotency_key: string
numbers: string numbers: string
period_no: string period_no: string
single_bet_amount?: string
} }
export interface GamePlaceBetDto { export interface GamePlaceBetDto {

View File

@@ -298,9 +298,9 @@ export function DesktopAnimal({
isMarqueeActive && 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)]', '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 && 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 && 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 && 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)]', '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', !showStandbyState && !hasPlacedSelection && 'opacity-95',
@@ -341,9 +341,9 @@ export function DesktopAnimal({
isMarqueeActive && 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)]', '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 && 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 && 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 && 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', '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( 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)]', 'absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*14)]',
isRevealRunning && 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 && 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, imageClassName,
)} )}
imgClassName="object-fill" imgClassName="object-fill"
@@ -452,7 +452,7 @@ export function DesktopAnimal({
</div> </div>
) : null} ) : null}
{showStopOverlay ? ( {showStopOverlay && !hostingFlag ? (
<div <div
aria-hidden="true" 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]" 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} ) : null}
{hostingFlag ? ( {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 <SmartBackground
src={hostingBg} src={hostingBg}
size="100% 100%"
repeat="no-repeat"
position="center"
className="h-design-350 w-design-930 flex flex-col items-center justify-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'}> <div className={'flex items-center gap-design-20'}>
<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 <SmartImage
src={refreshIcon} src={refreshIcon}
alt="refreshIcon" alt=""
priority priority
showSkeleton={false} showSkeleton={false}
className="h-design-40 w-design-40" 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)]" imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.36)]"
/> />
<div className={'text-design-20 text-[#ffffff] font-bold'}> </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', { {t('game.autoSpin.runningRounds', {
count: completedAutoHostingRounds, count: completedAutoHostingRounds,
})} })}
@@ -495,7 +523,10 @@ export function DesktopAnimal({
type="button" type="button"
onClick={stopHosting} onClick={stopHosting}
src={hostingBtn} 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')} {t('game.actions.stopAuto')}
</SmartBackground> </SmartBackground>

View File

@@ -24,6 +24,7 @@ export function DesktopControl() {
acceptingBets, acceptingBets,
actionsEnabled, actionsEnabled,
canClear, canClear,
canDecreaseBetQuantity,
chips, chips,
confirmLabel, confirmLabel,
confirmState, confirmState,
@@ -32,9 +33,11 @@ export function DesktopControl() {
onChipSelect, onChipSelect,
onConfirm, onConfirm,
onClearSelections, onClearSelections,
onDecreaseBetQuantity,
onIncreaseBetQuantity,
onOpenAutoSetting, onOpenAutoSetting,
onRepeatSelections, onRepeatSelections,
selectedChipAmountLabel, selectedBetQuantityLabel,
selectedChipId, selectedChipId,
selectedCountLabel, selectedCountLabel,
totalBetAmountLabel, totalBetAmountLabel,
@@ -296,22 +299,44 @@ export function DesktopControl() {
className={ className={
'flex h-design-50 shrink-0 items-center rounded-md bg-[#091118] box-border px-design-2 py-design-3' 'flex h-design-50 shrink-0 items-center rounded-md bg-[#091118] box-border px-design-2 py-design-3'
} }
>
<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 <SmartImage
src={add} src={add}
alt={`add`} alt={`add`}
className={'w-design-40 h-design-40'} className={'w-design-40 h-design-40'}
/> />
</button>
<div <div
className={'w-design-80 h-full flex items-center justify-center'} className={'w-design-80 h-full flex items-center justify-center'}
> >
{selectedChipAmountLabel} {selectedBetQuantityLabel}
</div> </div>
<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 <SmartImage
src={reduce} src={reduce}
alt={`reduce`} alt={`reduce`}
className={'w-design-40 h-design-40'} className={'w-design-40 h-design-40'}
/> />
</button>
</div> </div>
</SmartBackground> </SmartBackground>
<SmartBackground <SmartBackground

View File

@@ -3,12 +3,14 @@ import {
Mail, Mail,
Maximize, Maximize,
Minimize, Minimize,
Plus,
UserKey, UserKey,
UserRoundPlus, UserRoundPlus,
Volume2, Volume2,
VolumeX, VolumeX,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import add from '@/assets/game/add.webp'
import avatar from '@/assets/system/avatar.webp' import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp' import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp' import logo from '@/assets/system/logo.webp'
@@ -205,10 +207,19 @@ export function DesktopHeader() {
/> />
<div <div
className={ 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> </div>
</button> </button>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import streakBg from '@/assets/game/pc-streak.webp'
import down5Animation from '@/assets/lottie/down5.json' import down5Animation from '@/assets/lottie/down5.json'
import diamond from '@/assets/system/diamond.webp' import diamond from '@/assets/system/diamond.webp'
import fire from '@/assets/system/fire.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 { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx' import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts' import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
export function DesktopStatusLine() { export function DesktopStatusLine() {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
@@ -23,9 +25,11 @@ export function DesktopStatusLine() {
phaseToneClassName, phaseToneClassName,
roundId, roundId,
streakLabel, streakLabel,
streakValue,
} = useGameStatusVm() } = useGameStatusVm()
const [remainingMs, setRemainingMs] = useState(countdownMs) const [remainingMs, setRemainingMs] = useState(countdownMs)
const showWarningCountdown = remainingMs <= 5000 && remainingMs > 0 const showWarningCountdown = remainingMs <= 5000 && remainingMs > 0
const showStreakLimitOnly = typeof streakValue === 'number' && streakValue > 1
const countdownClassName = useMemo( const countdownClassName = useMemo(
() => () =>
showWarningCountdown showWarningCountdown
@@ -44,16 +48,28 @@ export function DesktopStatusLine() {
{/* 状态栏左侧 */} {/* 状态栏左侧 */}
<div <div
className={ 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={{*/} {showStreakLimitOnly && (
{/* backgroundImage: `url(${streakBg})`,*/} <div
{/* backgroundSize: '100% 110%',*/} className={
{/* backgroundRepeat: 'no-repeat',*/} 'pointer-events-none absolute z-0 bg-center bg-no-repeat'
{/*}} >*/} }
{/*</div>*/} 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%',
}}
/>
)}
{!showStreakLimitOnly && (
<>
<div className={'text-[#CBD3D5] font-bold'}> <div className={'text-[#CBD3D5] font-bold'}>
{t('gameDesktop.status.odds')}:{' '} {t('gameDesktop.status.odds')}:{' '}
<span className={'text-[#E3D171]'}>{oddsLabel}</span> <span className={'text-[#E3D171]'}>{oddsLabel}</span>
@@ -79,10 +95,12 @@ export function DesktopStatusLine() {
</span> </span>
</div> </div>
</div> </div>
</>
)}
<div <div
className={ 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 <SmartImage

View File

@@ -54,6 +54,9 @@ export function useAnimalVm(
) )
const setModalOpen = useModalStore((state) => state.setModalOpen) const setModalOpen = useModalStore((state) => state.setModalOpen)
const activeChipId = useGameRoundStore((state) => state.activeChipId) const activeChipId = useGameRoundStore((state) => state.activeChipId)
const activeBetQuantity = useGameRoundStore(
(state) => state.activeBetQuantity,
)
const chips = useGameRoundStore((state) => state.chips) const chips = useGameRoundStore((state) => state.chips)
const clearSelections = useGameRoundStore((state) => state.clearSelections) const clearSelections = useGameRoundStore((state) => state.clearSelections)
const roundId = useGameRoundStore((state) => state.round.id) const roundId = useGameRoundStore((state) => state.round.id)
@@ -188,7 +191,9 @@ export function useAnimalVm(
return return
} }
if (totalBetAmount + (activeChip?.amount ?? 0) > balance) { const nextBetAmount = (activeChip?.amount ?? 0) * activeBetQuantity
if (totalBetAmount + nextBetAmount > balance) {
setCellWarning({ setCellWarning({
cellId: animalId, cellId: animalId,
type: 'balance', type: 'balance',

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { placeGameBet } from '@/features/game' import { placeGameBet } from '@/features/game'
@@ -44,16 +44,25 @@ function toBetId(chipId: string) {
return Number.isInteger(betId) && betId >= 1 && betId <= 6 ? betId : null 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[]) { function groupSelections(selections: BetSelection[]) {
return selections.reduce<Map<string, { betId: number; numbers: number[] }>>( return selections.reduce<
(accumulator, selection) => { Map<string, { amount: number; betId: number; numbers: number[] }>
>((accumulator, selection) => {
const betId = toBetId(selection.chipId) const betId = toBetId(selection.chipId)
if (betId === null) { if (betId === null) {
return accumulator return accumulator
} }
const groupKey = String(betId) const groupKey = `${betId}:${selection.amount}`
const current = accumulator.get(groupKey) const current = accumulator.get(groupKey)
if (current) { if (current) {
@@ -62,14 +71,13 @@ function groupSelections(selections: BetSelection[]) {
} }
accumulator.set(groupKey, { accumulator.set(groupKey, {
amount: selection.amount,
betId, betId,
numbers: [selection.cellId], numbers: [selection.cellId],
}) })
return accumulator return accumulator
}, }, new Map())
new Map(),
)
} }
export function useAutoHostingRunner() { export function useAutoHostingRunner() {
@@ -79,9 +87,10 @@ export function useAutoHostingRunner() {
const setCurrentUser = useAuthStore((state) => state.setCurrentUser) const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const round = useGameRoundStore((state) => state.round) const round = useGameRoundStore((state) => state.round)
const clearSelections = useGameRoundStore((state) => state.clearSelections) const clearSelections = useGameRoundStore((state) => state.clearSelections)
const balanceAfterBet = useGameAutoHostingStore( const lastSingleWinAmount = useGameAutoHostingStore(
(state) => state.balanceAfterBet, (state) => state.lastSingleWinAmount,
) )
const lastIsJackpot = useGameAutoHostingStore((state) => state.lastIsJackpot)
const isHosting = useGameAutoHostingStore((state) => state.isHosting) const isHosting = useGameAutoHostingStore((state) => state.isHosting)
const lastSubmittedRoundId = useGameAutoHostingStore( const lastSubmittedRoundId = useGameAutoHostingStore(
(state) => state.lastSubmittedRoundId, (state) => state.lastSubmittedRoundId,
@@ -92,14 +101,10 @@ export function useAutoHostingRunner() {
(state) => state.markRoundSubmitted, (state) => state.markRoundSubmitted,
) )
const stopHosting = useGameAutoHostingStore((state) => state.stopHosting) const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
const [isSubmitting, setIsSubmitting] = useState(false) const inFlightRoundIdRef = useRef<string | null>(null)
const previousJackpotRef = useRef(currentUser?.isJackpot === true)
useEffect(() => { useEffect(() => {
const isJackpot = currentUser?.isJackpot === true
if (!isHosting) { if (!isHosting) {
previousJackpotRef.current = isJackpot
return return
} }
@@ -116,30 +121,24 @@ export function useAutoHostingRunner() {
if ( if (
rules.stopIfSingleWinAbove.enabled && rules.stopIfSingleWinAbove.enabled &&
balanceAfterBet !== null && lastSingleWinAmount !== null &&
balance - balanceAfterBet > rules.stopIfSingleWinAbove.amount lastSingleWinAmount > rules.stopIfSingleWinAbove.amount
) { ) {
stopHosting() stopHosting()
notify.success(t('commonUi.toast.autoHostingStoppedWin')) notify.success(t('commonUi.toast.autoHostingStoppedWin'))
return return
} }
if ( if (rules.stopOnJackpot && lastIsJackpot === true) {
rules.stopOnJackpot &&
isJackpot &&
previousJackpotRef.current === false
) {
stopHosting() stopHosting()
notify.success(t('commonUi.toast.autoHostingStoppedJackpot')) notify.success(t('commonUi.toast.autoHostingStoppedJackpot'))
return return
} }
previousJackpotRef.current = isJackpot
}, [ }, [
balanceAfterBet,
currentUser?.coin, currentUser?.coin,
currentUser?.isJackpot,
isHosting, isHosting,
lastIsJackpot,
lastSingleWinAmount,
rules, rules,
stopHosting, stopHosting,
t, t,
@@ -148,7 +147,7 @@ export function useAutoHostingRunner() {
useEffect(() => { useEffect(() => {
if ( if (
!isHosting || !isHosting ||
isSubmitting || inFlightRoundIdRef.current !== null ||
authStatus !== 'authenticated' || authStatus !== 'authenticated' ||
!currentUser || !currentUser ||
round.phase !== 'betting' || round.phase !== 'betting' ||
@@ -178,11 +177,10 @@ export function useAutoHostingRunner() {
return return
} }
let cancelled = false const submittingRoundId = round.id
inFlightRoundIdRef.current = submittingRoundId
const submitAutoBet = async () => { const submitAutoBet = async () => {
setIsSubmitting(true)
try { try {
let latestBalance = currentUser.coin ?? '0' let latestBalance = currentUser.coin ?? '0'
@@ -190,11 +188,14 @@ export function useAutoHostingRunner() {
const uniqueNumbers = [...new Set(group.numbers)].sort( const uniqueNumbers = [...new Set(group.numbers)].sort(
(left, right) => left - right, (left, right) => left - right,
) )
const formattedSingleBetAmount = formatBetAmount(group.amount)
const result = await placeGameBet({ const result = await placeGameBet({
bet_amount: formattedSingleBetAmount,
bet_id: group.betId, bet_id: group.betId,
idempotency_key: createIdempotencyKey(), idempotency_key: createIdempotencyKey(),
numbers: uniqueNumbers.join(','), numbers: uniqueNumbers.join(','),
period_no: round.id, period_no: round.id,
single_bet_amount: formattedSingleBetAmount,
}) })
if (result.status !== 'accepted') { if (result.status !== 'accepted') {
@@ -204,42 +205,44 @@ export function useAutoHostingRunner() {
latestBalance = result.balance_after latestBalance = result.balance_after
} }
if (cancelled) { const latestHostingState = useGameAutoHostingStore.getState()
const latestUser = useAuthStore.getState().currentUser
if (
!latestHostingState.isHosting ||
latestHostingState.lastSubmittedRoundId === submittingRoundId ||
!latestUser
) {
return return
} }
setCurrentUser({ setCurrentUser({
...currentUser, ...latestUser,
coin: latestBalance, coin: latestBalance,
lastBetPeriodNo: round.id, lastBetPeriodNo: submittingRoundId,
}) })
markRoundSubmitted(round.id, parseBalance(latestBalance)) markRoundSubmitted(submittingRoundId, parseBalance(latestBalance))
clearSelections() clearSelections()
} catch (error) { } catch (error) {
if (!cancelled) { if (useGameAutoHostingStore.getState().isHosting) {
stopHosting() stopHosting()
notify.error(t('commonUi.toast.autoHostingSubmitFailed'), { notify.error(t('commonUi.toast.autoHostingSubmitFailed'), {
description: error instanceof Error ? error.message : undefined, description: error instanceof Error ? error.message : undefined,
}) })
} }
} finally { } finally {
if (!cancelled) { if (inFlightRoundIdRef.current === submittingRoundId) {
setIsSubmitting(false) inFlightRoundIdRef.current = null
} }
} }
} }
void submitAutoBet() void submitAutoBet()
return () => {
cancelled = true
}
}, [ }, [
authStatus, authStatus,
clearSelections, clearSelections,
currentUser, currentUser,
isHosting, isHosting,
isSubmitting,
lastSubmittedRoundId, lastSubmittedRoundId,
markRoundSubmitted, markRoundSubmitted,
round.id, round.id,

View File

@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query' import { useInfiniteQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -38,25 +38,33 @@ export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US' const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
const [recordType, setRecordType] = useState<FinanceRecordType>('deposit') const [recordType, setRecordType] = useState<FinanceRecordType>('deposit')
const [page, setPage] = useState(1)
const query = useQuery({ const query = useInfiniteQuery({
queryKey: ['finance', 'user-info-order-list', recordType, page], queryKey: ['finance', 'user-info-order-list', recordType],
queryFn: () => initialPageParam: 1,
queryFn: ({ pageParam }) =>
recordType === 'deposit' recordType === 'deposit'
? getDepositOrderList({ ? getDepositOrderList({
page, page: pageParam,
pageSize: FINANCE_RECORD_PAGE_SIZE, pageSize: FINANCE_RECORD_PAGE_SIZE,
}) })
: getWithdrawOrderList({ : getWithdrawOrderList({
page, page: pageParam,
pageSize: FINANCE_RECORD_PAGE_SIZE, pageSize: FINANCE_RECORD_PAGE_SIZE,
}), }),
enabled, 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 lastPage = query.data?.pages.at(-1)
const total = pagination?.total ?? 0 const total = lastPage?.pagination.total ?? 0
const loadedPage = lastPage?.pagination.page ?? 1
const recordTypes = useMemo( const recordTypes = useMemo(
() => () =>
@@ -69,58 +77,46 @@ export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
const items = useMemo( const items = useMemo(
() => () =>
(query.data?.list ?? []).map((item) => ({ (query.data?.pages ?? []).flatMap((page) =>
page.list.map((item, index) => ({
amountLabel: formatFinanceAmount(item.amount, locale), amountLabel: formatFinanceAmount(item.amount, locale),
bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale), bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale),
id: item.orderNo, id: item.orderNo || `${page.pagination.page}-${index}`,
orderNoLabel: item.orderNo || '--', orderNoLabel: item.orderNo || '--',
})), })),
[locale, query.data?.list], ),
[locale, query.data?.pages],
) )
const selectRecordType = useCallback((type: FinanceRecordType) => { const selectRecordType = useCallback((type: FinanceRecordType) => {
setRecordType(type) setRecordType(type)
setPage(1)
}, [])
const goPreviousPage = useCallback(() => {
setPage((currentPage) => Math.max(1, currentPage - 1))
}, [])
const goNextPage = useCallback(() => {
setPage((currentPage) => currentPage + 1)
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
setRecordType('deposit') setRecordType('deposit')
setPage(1)
} }
}, [enabled]) }, [enabled])
return { return {
canGoNextPage: page * FINANCE_RECORD_PAGE_SIZE < total,
canGoPreviousPage: page > 1,
emptyText: t('game.modals.userInfo.financeRecords.empty'), emptyText: t('game.modals.userInfo.financeRecords.empty'),
goNextPage, fetchNextPage: query.fetchNextPage,
goPreviousPage, hasNextPage: query.hasNextPage,
headers: { headers: {
amount: t('game.modals.userInfo.financeRecords.amount'), amount: t('game.modals.userInfo.financeRecords.amount'),
bonusAmount: t('game.modals.userInfo.financeRecords.bonusAmount'), bonusAmount: t('game.modals.userInfo.financeRecords.bonusAmount'),
orderNo: t('game.modals.userInfo.financeRecords.orderNo'), orderNo: t('game.modals.userInfo.financeRecords.orderNo'),
}, },
isError: query.isError, isError: query.isError,
isFetching: query.isFetching, isFetchingNextPage: query.isFetchingNextPage,
isLoading: query.isLoading, isLoading: query.isLoading,
items, items,
loadFailedText: t('game.modals.userInfo.financeRecords.loadFailed'), loadFailedText: t('game.modals.userInfo.financeRecords.loadFailed'),
loadingText: t('game.modals.userInfo.financeRecords.loading'), loadingText: t('game.modals.userInfo.financeRecords.loading'),
pageLabel: t('game.modals.userInfo.financeRecords.page', { pageLabel: t('game.modals.userInfo.financeRecords.page', {
page: pagination?.page ?? page, page: loadedPage,
total, total,
}), }),
nextText: t('game.modals.userInfo.financeRecords.next'),
previousText: t('game.modals.userInfo.financeRecords.previous'),
recordType, recordType,
recordTypes, recordTypes,
selectRecordType, selectRecordType,

View File

@@ -62,10 +62,16 @@ export function useGameControlVm() {
const { t } = useTranslation() const { t } = useTranslation()
const chips = useGameRoundStore((state) => state.chips) const chips = useGameRoundStore((state) => state.chips)
const activeChipId = useGameRoundStore((state) => state.activeChipId) const activeChipId = useGameRoundStore((state) => state.activeChipId)
const activeBetQuantity = useGameRoundStore(
(state) => state.activeBetQuantity,
)
const round = useGameRoundStore((state) => state.round) const round = useGameRoundStore((state) => state.round)
const maxSelectionCount = useGameRoundStore( const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount, (state) => state.maxSelectionCount,
) )
const adjustBetQuantity = useGameRoundStore(
(state) => state.adjustBetQuantity,
)
const selections = useGameRoundStore((state) => state.selections) const selections = useGameRoundStore((state) => state.selections)
const clearSelections = useGameRoundStore((state) => state.clearSelections) const clearSelections = useGameRoundStore((state) => state.clearSelections)
const restoreRecentSuccessfulSelections = useGameRoundStore( const restoreRecentSuccessfulSelections = useGameRoundStore(
@@ -151,32 +157,10 @@ export function useGameControlVm() {
return return
} }
const groupedSelections = selections.reduce< const betId = toBetId(selections[0]?.chipId ?? activeChipId)
Map<string, { betId: number; numbers: number[] }> const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0
>((accumulator, selection) => {
const betId = toBetId(selection.chipId)
if (betId === null) { if (betId === null || singleBetAmount <= 0) {
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) {
notify.warning(t('commonUi.toast.betUnavailable')) notify.warning(t('commonUi.toast.betUnavailable'))
return return
} }
@@ -186,15 +170,17 @@ export function useGameControlVm() {
try { try {
let latestBalance = currentUser?.coin ?? '0' let latestBalance = currentUser?.coin ?? '0'
for (const group of groupedSelections.values()) { const uniqueNumbers = [
const uniqueNumbers = [...new Set(group.numbers)].sort( ...new Set(selections.map((item) => item.cellId)),
(left, right) => left - right, ].sort((left, right) => left - right)
) const formattedSingleBetAmount = formatChipDisplayValue(singleBetAmount)
const result = await placeGameBet({ const result = await placeGameBet({
bet_id: group.betId, bet_amount: formattedSingleBetAmount,
bet_id: betId,
idempotency_key: createIdempotencyKey(), idempotency_key: createIdempotencyKey(),
numbers: uniqueNumbers.join(','), numbers: uniqueNumbers.join(','),
period_no: round.id, period_no: round.id,
single_bet_amount: formattedSingleBetAmount,
}) })
if (result.status !== 'accepted') { if (result.status !== 'accepted') {
@@ -202,7 +188,6 @@ export function useGameControlVm() {
} }
latestBalance = result.balance_after latestBalance = result.balance_after
}
if (currentUser) { if (currentUser) {
setCurrentUser({ setCurrentUser({
@@ -223,6 +208,7 @@ export function useGameControlVm() {
setIsSubmitting(false) setIsSubmitting(false)
} }
}, [ }, [
activeChipId,
authStatus, authStatus,
clearSelections, clearSelections,
confirmState, confirmState,
@@ -233,6 +219,7 @@ export function useGameControlVm() {
round.id, round.id,
round.phase, round.phase,
selections, selections,
selectedChip?.amount,
setRecentSuccessfulSelections, setRecentSuccessfulSelections,
setCurrentUser, setCurrentUser,
setModalOpen, setModalOpen,
@@ -282,6 +269,7 @@ export function useGameControlVm() {
round.phase === 'betting' && round.phase === 'betting' &&
!hasSubmittedCurrentRound && !hasSubmittedCurrentRound &&
!isAutoHosting, !isAutoHosting,
canDecreaseBetQuantity: activeBetQuantity > 1,
confirmLabel: confirmLabel:
confirmState === 'idle' confirmState === 'idle'
? t('gameDesktop.control.selectNumbers') ? t('gameDesktop.control.selectNumbers')
@@ -293,12 +281,14 @@ export function useGameControlVm() {
confirmState, confirmState,
isConfirmClickable: confirmState === 'ready' && !isAutoHosting, isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
onChipSelect: selectChip, onChipSelect: selectChip,
onDecreaseBetQuantity: () => adjustBetQuantity(-1),
onIncreaseBetQuantity: () => adjustBetQuantity(1),
onConfirm: handleConfirm, onConfirm: handleConfirm,
onClearSelections: clearSelections, onClearSelections: clearSelections,
onOpenAutoSetting: handleOpenAutoSetting, onOpenAutoSetting: handleOpenAutoSetting,
onRepeatSelections: handleRepeatSelections, onRepeatSelections: handleRepeatSelections,
maxSelectionCountLabel: maxSelectionCount, maxSelectionCountLabel: maxSelectionCount,
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--', selectedBetQuantityLabel: activeBetQuantity,
selectedChipId: activeChipId, selectedChipId: activeChipId,
selectedCountLabel: selections.length, selectedCountLabel: selections.length,
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount), totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),

View File

@@ -13,7 +13,11 @@ import {
type GameSocketMessage, type GameSocketMessage,
} from '@/lib/ws/game-socket-client' } from '@/lib/ws/game-socket-client'
import { getAuthDeviceId, useAuthStore } from '@/store/auth' 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 { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api'
import type { import type {
BetWinEventDataDto, BetWinEventDataDto,
@@ -606,6 +610,10 @@ function applyBetWinMessage(message: GameSocketMessage) {
totalWin: betWinData.total_win, totalWin: betWinData.total_win,
winningCellId: betWinData.result_number, winningCellId: betWinData.result_number,
}) })
useGameAutoHostingStore.getState().recordBetWin({
isJackpot: betWinData.is_jackpot,
singleWinAmount: toOptionalNumber(betWinData.total_win) ?? null,
})
if (!currentUser) { if (!currentUser) {
return return

View 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,
}),
}
}

View File

@@ -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 { useFinanceRecordsVm } from '@/features/game/hooks/use-finance-records-vm'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) { function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
const vm = useFinanceRecordsVm({ enabled }) 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 ( return (
<div className={'flex h-full w-full flex-col p-design-10'}> <div className={'flex h-full w-full flex-col p-design-10'}>
@@ -26,7 +57,10 @@ function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
key={recordType.key} key={recordType.key}
type="button" type="button"
aria-pressed={isActive} aria-pressed={isActive}
onClick={() => vm.selectRecordType(recordType.key)} onClick={() => {
vm.selectRecordType(recordType.key)
rowVirtualizer.scrollToOffset(0)
}}
className={cn( className={cn(
'relative h-design-44 min-w-design-130 cursor-pointer rounded-md px-design-16 text-design-18 transition-colors duration-200', 'relative h-design-44 min-w-design-130 cursor-pointer rounded-md px-design-16 text-design-18 transition-colors duration-200',
isActive isActive
@@ -56,7 +90,7 @@ function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
<div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div> <div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div>
</div> </div>
<div className={'min-h-0 flex-1 overflow-auto rounded-md'}> <div className={'min-h-0 flex-1 rounded-md'}>
<div <div
className={ 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]' '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,24 +102,10 @@ function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
</div> </div>
<div <div
ref={parentRef}
className={ 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 ? ( {vm.isLoading ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}> <div className={'py-design-30 text-center text-[#6CCDCF]'}>
@@ -100,16 +120,30 @@ function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
{vm.emptyText} {vm.emptyText}
</div> </div>
) : ( ) : (
vm.items.map((item, index) => ( <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 <motion.div
key={item.id}
className={ 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)]' '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 }} initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ transition={{
delay: Math.min(index, 6) * 0.025,
duration: 0.16, duration: 0.16,
ease: 'easeOut', ease: 'easeOut',
}} }}
@@ -117,41 +151,28 @@ function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
<div className={'truncate font-medium text-white'}> <div className={'truncate font-medium text-white'}>
{item.orderNoLabel} {item.orderNoLabel}
</div> </div>
<div className={'text-[#FEEEB0]'}>{item.amountLabel}</div> <div className={'truncate text-[#FEEEB0]'}>
<div className={'text-[#7CFFCF]'}> {item.amountLabel}
</div>
<div className={'truncate text-[#7CFFCF]'}>
{item.bonusAmountLabel} {item.bonusAmountLabel}
</div> </div>
</motion.div> </motion.div>
)) ) : (
)}
</motion.div>
</AnimatePresence>
</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={ 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' 'flex h-[calc(var(--design-unit)*62)] items-center justify-center rounded-md bg-[#0A4252]/60 text-design-16 text-[#6CCDCF]'
} }
> >
{vm.previousText} {vm.loadingText}
</button> </div>
<button )}
type="button" </div>
disabled={!vm.canGoNextPage || vm.isFetching} )
onClick={vm.goNextPage} })}
className={ </div>
'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' )}
} </div>
>
{vm.nextText}
</button>
</div> </div>
</div> </div>
) )

View File

@@ -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 { useTranslation } from 'react-i18next'
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp' import blueBtnBg from '@/assets/system/blue-btn.webp'
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
import noticeBg from '@/assets/system/notice-bg.webp'
import { CenterModal } from '@/components/center-modal.tsx' import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx' import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx' import { getNoticeDetail, getNoticeList } from '@/features/game/api'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store' import { useModalStore } from '@/store'
type NoticeViewState = 'detail' | 'list'
function DesktopNoticeModal() { function DesktopNoticeModal() {
const { t } = useTranslation() const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopNotice) const open = useModalStore((state) => state.modals.desktopNotice)
const setModalOpen = useModalStore((state) => state.setModalOpen) 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() { function handleSubmit() {
setModalOpen('desktopNotice', false) setModalOpen('desktopNotice', false)
@@ -22,54 +59,177 @@ function DesktopNoticeModal() {
onClose={handleSubmit} onClose={handleSubmit}
title={ title={
<div className={'modal-title-glow text-design-26'}> <div className={'modal-title-glow text-design-26'}>
{t('game.modals.notice.title')} {t('game.modals.userInfo.message.title')}
</div> </div>
} }
isNormalBg={true} isNormalBg={true}
titleAlign="left" 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={'flex h-full w-full flex-col'}>
{noticeView === 'detail' ? (
<div <div
className={ className={
'w-full h-design-440 overflow-y-auto flex flex-col gap-design-20 bg-[#000000]/50 p-design-10 rounded-md' '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'
} }
> >
<SmartImage <button
className={'w-full h-design-300 shrink-0 rounded-md'} type="button"
imgClassName={'object-contain object-center'} onClick={() => {
alt={'notice'} void handleReturnToList()
src={noticeBg} }}
/> 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={'text-[#74B3BA] text-design-18 leading-[1.6]'}> <div className={'h-full w-full overflow-auto rounded-md'}>
{t('game.modals.notice.content')} {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>
</div> </div>
<div className={'w-full flex justify-around'}>
<SmartBackground <SmartBackground
src={lengthGreenBtn} src={blueBtnBg}
size="106% 108%" size="100% 100%"
repeat="no-repeat"
position="center"
className={ className={
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold' 'flex h-design-64 w-design-150 items-center justify-center text-design-20 font-bold'
} }
> >
{t('game.modals.notice.check')} {t('game.modals.userInfo.message.check')}
</SmartBackground> </SmartBackground>
</button>
<SmartBackground ))
src={lengthBlueBtn} )}
size="100% 90%" </div>
repeat="no-repeat" ) : (
position="center" <div
className={ className={
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold' 'flex h-full w-full flex-col gap-design-16 p-design-10'
} }
> >
{t('game.modals.notice.check')} {noticeDetailQuery.isLoading ? (
</SmartBackground> <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>
</div> </div>
</CenterModal> </CenterModal>

View File

@@ -1,22 +1,25 @@
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs' 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 { motion } from 'motion/react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp' import avatar from '@/assets/system/avatar.webp'
import blueBtnBg from '@/assets/system/blue-btn.webp'
import userInfoBg from '@/assets/system/userInfo-bg.webp' import userInfoBg from '@/assets/system/userInfo-bg.webp'
import { CenterModal } from '@/components/center-modal.tsx' import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx' import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.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 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 { cn } from '@/lib/utils'
import { useAuthStore, useModalStore } from '@/store' import { useAuthStore, useModalStore } from '@/store'
type UserInfoTabKey = 'financeRecords' | 'message' | 'profile' type UserInfoTabKey = 'financeRecords' | 'profile' | 'walletRecords'
type MessageViewState = 'list' | 'detail'
const USER_INFO_TABS: Array<{ const USER_INFO_TABS: Array<{
key: UserInfoTabKey key: UserInfoTabKey
@@ -34,67 +37,81 @@ const USER_INFO_TABS: Array<{
icon: ReceiptText, icon: ReceiptText,
}, },
{ {
key: 'message', key: 'walletRecords',
labelKey: 'game.modals.userInfo.tabs.message', labelKey: 'game.modals.userInfo.tabs.walletRecords',
icon: Mail, 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() { function DesktopUserInfoModal() {
const { t } = useTranslation() const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopUserInfo) const open = useModalStore((state) => state.modals.desktopUserInfo)
const setModalOpen = useModalStore((state) => state.setModalOpen) const setModalOpen = useModalStore((state) => state.setModalOpen)
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile') 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 currentUser = useAuthStore((state) => state.currentUser)
const inviteCode = currentUser?.registerInviteCode?.trim() ?? ''
const noticeListQuery = useQuery({
queryKey: ['game', 'notice-list'],
queryFn: () => getNoticeList(),
enabled: open && activeTab === 'message' && messageView === 'list',
})
const noticeDetailQuery = useQuery({
queryKey: ['game', 'notice-detail', selectedNoticeId],
queryFn: () => getNoticeDetail(selectedNoticeId ?? 0),
enabled:
open &&
activeTab === 'message' &&
messageView === 'detail' &&
selectedNoticeId !== null,
})
const noticeItems = useMemo(
() => noticeListQuery.data?.list ?? [],
[noticeListQuery.data],
)
async function handleReturnToList() {
setMessageView('list')
setSelectedNoticeId(null)
await noticeListQuery.refetch()
}
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setActiveTab('profile') setActiveTab('profile')
setMessageView('list')
setSelectedNoticeId(null)
} }
}, [open]) }, [open])
useEffect(() => {
if (activeTab !== 'message') {
setMessageView('list')
setSelectedNoticeId(null)
}
}, [activeTab])
function handleSubmit() { function handleSubmit() {
setModalOpen('desktopUserInfo', false) 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 ( return (
<CenterModal <CenterModal
open={open} open={open}
@@ -217,7 +234,7 @@ function DesktopUserInfoModal() {
<div <div
className={ 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]'}> <div className={'text-[#6CCDCF]'}>
@@ -225,19 +242,35 @@ function DesktopUserInfoModal() {
<span <span
className={'text-design-18 text-[#599AA3] ml-design-10'} className={'text-design-18 text-[#599AA3] ml-design-10'}
> >
{dayjs(currentUser?.createTime).format( {currentUser?.createTime
'YYYY-MM-DD HH:mm:ss', ? dayjs
) || '--'} .unix(currentUser.createTime)
.format('YYYY-MM-DD HH:mm:ss')
: '--'}
</span> </span>
</div> </div>
<div className={'text-[#6CCDCF]'}> <div className={'flex items-center text-[#6CCDCF]'}>
{t('auth.register.fields.inviteCode.label')} <span>{t('auth.register.fields.inviteCode.label')}</span>
<span <span
className={'text-design-18 text-[#599AA3] ml-design-10'} className={'text-design-18 text-[#599AA3] ml-design-10'}
> >
{currentUser?.registerInviteCode || '--'} {inviteCode || '--'}
</span> </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>
</div> </div>
</SmartBackground> </SmartBackground>
@@ -246,188 +279,9 @@ function DesktopUserInfoModal() {
enabled={open && activeTab === 'financeRecords'} enabled={open && activeTab === 'financeRecords'}
/> />
) : ( ) : (
<div className={'flex h-full w-full flex-col'}> <DesktopWalletRecordsTab
{messageView === 'detail' ? ( enabled={open && activeTab === 'walletRecords'}
<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>
)} )}
</div> </div>
</div> </div>

View 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

View File

@@ -24,12 +24,23 @@ interface NotificationStoreState {
closingDialogId: string | null closingDialogId: string | null
dialogQueue: NotificationDialog[] dialogQueue: NotificationDialog[]
dismissDialog: (id?: string) => void dismissDialog: (id?: string) => void
pushDialog: (dialog: NotificationDialog) => void pushDialog: (dialog: NotificationDialog) => string
} }
const dialogTimers = new Map<string, number>() const dialogTimers = new Map<string, number>()
const dialogExitTimers = 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) { function clearDialogTimer(id: string) {
const timerId = dialogTimers.get(id) const timerId = dialogTimers.get(id)
@@ -129,6 +140,8 @@ export const useNotificationStore = create<NotificationStoreState>()((set) => ({
}) })
}, },
pushDialog: (dialog) => { pushDialog: (dialog) => {
let dialogId = dialog.id
set((state) => { set((state) => {
if (!state.activeDialog) { if (!state.activeDialog) {
clearDialogExitTimer(dialog.id) 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 { return {
dialogQueue: [ dialogQueue: [
...state.dialogQueue.filter((item) => item.id !== dialog.id), ...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 id = createToastId()
const duration = options?.duration ?? DEFAULT_ALERT_DURATION_MS const duration = options?.duration ?? DEFAULT_ALERT_DURATION_MS
useNotificationStore.getState().pushDialog({ return useNotificationStore.getState().pushDialog({
description: options?.description, description: options?.description,
duration, duration,
id, id,
message, message,
type, type,
}) })
return id
} }
export const notify = { export const notify = {

View File

@@ -152,12 +152,14 @@ export default {
tabs: { tabs: {
profile: 'Profile', profile: 'Profile',
financeRecords: 'Top Up / Withdraw Records', financeRecords: 'Top Up / Withdraw Records',
walletRecords: 'Wallet Records',
message: 'Messages', message: 'Messages',
}, },
profile: { profile: {
name: 'Name', name: 'Name',
tel: 'Phone', tel: 'Phone',
registeredAt: 'Registered at', registeredAt: 'Registered at',
copyInviteLink: 'Copy invite link',
signature: signature:
'My signature is as unique as my personality. This area will later display the real profile summary.', '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', previous: 'Previous',
next: 'Next', 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: { withdrawTopup: {
applyWithdraw: 'Apply for Withdrawal', applyWithdraw: 'Apply for Withdrawal',
@@ -229,6 +245,9 @@ export default {
loginRequired: 'Please log in before entering the game', loginRequired: 'Please log in before entering the game',
loginSuccess: 'Login successful', loginSuccess: 'Login successful',
registerSuccess: 'Registration 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.', insufficientBalance: 'Insufficient balance. Please adjust your bet.',
betUnavailable: 'Betting is not available for this round', betUnavailable: 'Betting is not available for this round',
betPlaced: 'Bet placed successfully', betPlaced: 'Bet placed successfully',

View File

@@ -151,12 +151,14 @@ export default {
tabs: { tabs: {
profile: 'Profil', profile: 'Profil',
financeRecords: 'Riwayat Isi Ulang / Penarikan', financeRecords: 'Riwayat Isi Ulang / Penarikan',
walletRecords: 'Riwayat Dompet',
message: 'Pesan', message: 'Pesan',
}, },
profile: { profile: {
name: 'Nama', name: 'Nama',
tel: 'Telepon', tel: 'Telepon',
registeredAt: 'Tanggal daftar', registeredAt: 'Tanggal daftar',
copyInviteLink: 'Salin tautan undangan',
signature: signature:
'Tanda tanganku seunik diriku. Bagian ini nantinya akan menampilkan ringkasan profil yang sebenarnya.', 'Tanda tanganku seunik diriku. Bagian ini nantinya akan menampilkan ringkasan profil yang sebenarnya.',
}, },
@@ -186,6 +188,20 @@ export default {
previous: 'Sebelumnya', previous: 'Sebelumnya',
next: 'Berikutnya', 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: { withdrawTopup: {
applyWithdraw: 'Ajukan Penarikan', applyWithdraw: 'Ajukan Penarikan',
@@ -228,6 +244,9 @@ export default {
loginRequired: 'Silakan masuk sebelum memasuki game', loginRequired: 'Silakan masuk sebelum memasuki game',
loginSuccess: 'Berhasil masuk', loginSuccess: 'Berhasil masuk',
registerSuccess: 'Pendaftaran berhasil', registerSuccess: 'Pendaftaran berhasil',
inviteLinkCopied: 'Tautan undangan disalin',
inviteLinkCopyFailed:
'Gagal menyalin tautan undangan. Silakan salin secara manual.',
insufficientBalance: 'Saldo tidak cukup. Silakan sesuaikan taruhan.', insufficientBalance: 'Saldo tidak cukup. Silakan sesuaikan taruhan.',
betUnavailable: 'Taruhan tidak tersedia untuk ronde ini', betUnavailable: 'Taruhan tidak tersedia untuk ronde ini',
betPlaced: 'Taruhan berhasil dikirim', betPlaced: 'Taruhan berhasil dikirim',

View File

@@ -154,12 +154,14 @@ export default {
tabs: { tabs: {
profile: 'Profil', profile: 'Profil',
financeRecords: 'Rekod Tambah Nilai / Pengeluaran', financeRecords: 'Rekod Tambah Nilai / Pengeluaran',
walletRecords: 'Rekod Dompet',
message: 'Mesej', message: 'Mesej',
}, },
profile: { profile: {
name: 'Nama', name: 'Nama',
tel: 'Telefon', tel: 'Telefon',
registeredAt: 'Tarikh daftar', registeredAt: 'Tarikh daftar',
copyInviteLink: 'Salin pautan jemputan',
signature: signature:
'Tandatangan saya unik seperti personaliti saya. Bahagian ini akan memaparkan ringkasan profil sebenar kemudian.', 'Tandatangan saya unik seperti personaliti saya. Bahagian ini akan memaparkan ringkasan profil sebenar kemudian.',
}, },
@@ -189,6 +191,20 @@ export default {
previous: 'Sebelumnya', previous: 'Sebelumnya',
next: 'Seterusnya', 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: { withdrawTopup: {
applyWithdraw: 'Mohon Pengeluaran', applyWithdraw: 'Mohon Pengeluaran',
@@ -231,6 +247,9 @@ export default {
loginRequired: 'Sila log masuk sebelum memasuki permainan', loginRequired: 'Sila log masuk sebelum memasuki permainan',
loginSuccess: 'Log masuk berjaya', loginSuccess: 'Log masuk berjaya',
registerSuccess: 'Pendaftaran 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.', insufficientBalance: 'Baki tidak mencukupi. Sila laraskan taruhan.',
betUnavailable: 'Taruhan tidak tersedia untuk pusingan ini', betUnavailable: 'Taruhan tidak tersedia untuk pusingan ini',
betPlaced: 'Taruhan berjaya dihantar', betPlaced: 'Taruhan berjaya dihantar',

View File

@@ -149,12 +149,14 @@ export default {
tabs: { tabs: {
profile: '个人信息', profile: '个人信息',
financeRecords: '充值/提现记录', financeRecords: '充值/提现记录',
walletRecords: '钱包流水',
message: '站内消息', message: '站内消息',
}, },
profile: { profile: {
name: '用户名', name: '用户名',
tel: '电话', tel: '电话',
registeredAt: '注册时间', registeredAt: '注册时间',
copyInviteLink: '复制邀请链接',
signature: '我的个性签名和灵魂一样独特,后续这里会接真实资料字段。', signature: '我的个性签名和灵魂一样独特,后续这里会接真实资料字段。',
}, },
message: { message: {
@@ -182,6 +184,20 @@ export default {
previous: '上一页', previous: '上一页',
next: '下一页', next: '下一页',
}, },
walletRecords: {
amount: '变动金额',
balanceAfter: '变动后',
balanceBefore: '变动前',
empty: '暂无钱包流水',
loadFailed: '钱包流水加载失败,请稍后重试',
loading: '钱包流水加载中...',
next: '下一页',
page: '第 {{page}} 页 / 共 {{total}} 条',
previous: '上一页',
remark: '备注',
time: '时间',
type: '钱包流水',
},
}, },
withdrawTopup: { withdrawTopup: {
applyWithdraw: '申请提现', applyWithdraw: '申请提现',
@@ -223,6 +239,8 @@ export default {
loginRequired: '请先登录后进入游戏', loginRequired: '请先登录后进入游戏',
loginSuccess: '登录成功', loginSuccess: '登录成功',
registerSuccess: '注册成功', registerSuccess: '注册成功',
inviteLinkCopied: '邀请链接已复制',
inviteLinkCopyFailed: '邀请链接复制失败,请手动复制',
insufficientBalance: '余额不足,请调整下注金额', insufficientBalance: '余额不足,请调整下注金额',
betUnavailable: '当前期不可下注', betUnavailable: '当前期不可下注',
betPlaced: '下注成功', betPlaced: '下注成功',

View File

@@ -24,10 +24,16 @@ export interface GameAutoHostingStoreState {
balanceAfterBet: number | null balanceAfterBet: number | null
completedRounds: number completedRounds: number
isHosting: boolean isHosting: boolean
lastIsJackpot: boolean | null
lastSingleWinAmount: number | null
lastSubmittedRoundId: string | null lastSubmittedRoundId: string | null
rules: AutoHostingStopRules rules: AutoHostingStopRules
selections: BetSelection[] selections: BetSelection[]
markRoundSubmitted: (roundId: string, balanceAfterBet: number | null) => void markRoundSubmitted: (roundId: string, balanceAfterBet: number | null) => void
recordBetWin: (input: {
isJackpot: boolean
singleWinAmount: number | null
}) => void
startHosting: (input: StartAutoHostingInput) => void startHosting: (input: StartAutoHostingInput) => void
stopHosting: () => void stopHosting: () => void
} }
@@ -49,6 +55,8 @@ export const useGameAutoHostingStore = create<GameAutoHostingStoreState>()(
balanceAfterBet: null, balanceAfterBet: null,
completedRounds: 0, completedRounds: 0,
isHosting: false, isHosting: false,
lastIsJackpot: null,
lastSingleWinAmount: null,
lastSubmittedRoundId: null, lastSubmittedRoundId: null,
rules: DEFAULT_AUTO_HOSTING_RULES, rules: DEFAULT_AUTO_HOSTING_RULES,
selections: [], 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 }) => { startHosting: ({ balanceAfterBet, rules, selections }) => {
set({ set({
balanceAfterBet, balanceAfterBet,
completedRounds: 0, completedRounds: 0,
isHosting: true, isHosting: true,
lastIsJackpot: null,
lastSingleWinAmount: null,
lastSubmittedRoundId: null, lastSubmittedRoundId: null,
rules, rules,
selections: selections.map((selection) => ({ selections: selections.map((selection) => ({
@@ -82,6 +104,8 @@ export const useGameAutoHostingStore = create<GameAutoHostingStoreState>()(
balanceAfterBet: null, balanceAfterBet: null,
completedRounds: 0, completedRounds: 0,
isHosting: false, isHosting: false,
lastIsJackpot: null,
lastSingleWinAmount: null,
lastSubmittedRoundId: null, lastSubmittedRoundId: null,
selections: [], selections: [],
}) })

View File

@@ -31,6 +31,8 @@ type GameRoundSlice = Pick<
| 'trends' | 'trends'
> >
const MIN_BET_QUANTITY = 1
export type RevealAnimationPhase = 'idle' | 'spinning' | 'stopping' | 'result' export type RevealAnimationPhase = 'idle' | 'spinning' | 'stopping' | 'result'
export type RewardAnimationType = 'none' | 'small' | 'big' export type RewardAnimationType = 'none' | 'small' | 'big'
@@ -102,8 +104,49 @@ function resolveRecentActiveChipId(
DEFAULT_ACTIVE_CHIP_ID) 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 { export interface GameRoundStoreState extends GameRoundSlice {
activeChipId: string activeChipId: string
activeBetQuantity: number
adjustBetQuantity: (delta: number) => void
clearSelections: () => void clearSelections: () => void
clearRewardAnimation: () => void clearRewardAnimation: () => void
finishRevealAnimation: () => void finishRevealAnimation: () => void
@@ -135,6 +178,7 @@ export interface GameRoundStoreState extends GameRoundSlice {
function createInitialRoundState(): GameRoundSlice & { function createInitialRoundState(): GameRoundSlice & {
activeChipId: string activeChipId: string
activeBetQuantity: number
recentSuccessfulSelections: BetSelection[] recentSuccessfulSelections: BetSelection[]
revealAnimation: RevealAnimationState revealAnimation: RevealAnimationState
} { } {
@@ -142,6 +186,7 @@ function createInitialRoundState(): GameRoundSlice & {
return { return {
activeChipId: DEFAULT_ACTIVE_CHIP_ID, activeChipId: DEFAULT_ACTIVE_CHIP_ID,
activeBetQuantity: MIN_BET_QUANTITY,
cells: snapshot.cells, cells: snapshot.cells,
chips: snapshot.chips, chips: snapshot.chips,
history: snapshot.history, history: snapshot.history,
@@ -156,6 +201,30 @@ function createInitialRoundState(): GameRoundSlice & {
export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
...createInitialRoundState(), ...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: () => { clearSelections: () => {
set({ selections: [] }) set({ selections: [] })
}, },
@@ -211,24 +280,29 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
}) })
}, },
hydrateRound: (snapshot) => { hydrateRound: (snapshot) => {
set((state) => ({ set((state) => {
activeChipId: getChipById(snapshot.chips, state.activeChipId) const nextActiveChipId = getChipById(snapshot.chips, state.activeChipId)
? state.activeChipId ? state.activeChipId
: (snapshot.chips.find((chip) => chip.isDefault)?.id ?? : (snapshot.chips.find((chip) => chip.isDefault)?.id ??
snapshot.chips[0]?.id ?? snapshot.chips[0]?.id ??
DEFAULT_ACTIVE_CHIP_ID), DEFAULT_ACTIVE_CHIP_ID)
return {
activeChipId: nextActiveChipId,
cells: snapshot.cells, cells: snapshot.cells,
chips: snapshot.chips, chips: snapshot.chips,
history: snapshot.history, history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount, maxSelectionCount: snapshot.maxSelectionCount,
revealAnimation: revealAnimation:
snapshot.round.phase === 'betting' || snapshot.round.phase === 'waiting' snapshot.round.phase === 'betting' ||
snapshot.round.phase === 'waiting'
? createIdleRevealAnimationPreservingReward(state.revealAnimation) ? createIdleRevealAnimationPreservingReward(state.revealAnimation)
: state.revealAnimation, : state.revealAnimation,
round: snapshot.round, round: snapshot.round,
selections: snapshot.selections, selections: snapshot.selections,
trends: snapshot.trends, trends: snapshot.trends,
})) }
})
}, },
placeBet: (cellId) => { placeBet: (cellId) => {
set((state) => { set((state) => {
@@ -256,7 +330,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
selections: [ selections: [
...state.selections, ...state.selections,
{ {
amount: activeChip.amount, amount: getSelectionBetAmount(activeChip, state.activeBetQuantity),
cellId, cellId,
chipId: activeChip.id, chipId: activeChip.id,
id: `bet-${cellId}-${state.selections.length + 1}-${Date.now()}`, id: `bet-${cellId}-${state.selections.length + 1}-${Date.now()}`,
@@ -337,13 +411,28 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
return false return false
} }
set({ const nextActiveChipId = resolveRecentActiveChipId(
activeChipId: resolveRecentActiveChipId(
state.chips, state.chips,
nextSelections, nextSelections,
state.activeChipId, state.activeChipId,
), )
selections: nextSelections, const nextActiveChip = getChipById(state.chips, nextActiveChipId)
const nextBetQuantity = resolveSelectionQuantity(
nextSelections,
state.chips,
nextActiveChipId,
)
set({
activeBetQuantity: nextBetQuantity,
activeChipId: nextActiveChipId,
selections: nextActiveChip
? syncSelectionsBetAmount(
nextSelections,
nextActiveChipId,
getSelectionBetAmount(nextActiveChip, nextBetQuantity),
)
: nextSelections,
}) })
return true return true
@@ -357,11 +446,21 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
}, },
selectChip: (chipId) => { selectChip: (chipId) => {
set((state) => { set((state) => {
if (!getChipById(state.chips, chipId)) { const nextChip = getChipById(state.chips, chipId)
if (!nextChip) {
return state return state
} }
return { activeChipId: chipId } return {
activeBetQuantity: MIN_BET_QUANTITY,
activeChipId: chipId,
selections: syncSelectionsBetAmount(
state.selections,
chipId,
getSelectionBetAmount(nextChip, MIN_BET_QUANTITY),
),
}
}) })
}, },
setPhase: (phase) => { setPhase: (phase) => {

View File

@@ -394,7 +394,7 @@
.gold-reveal-shell { .gold-reveal-shell {
--gold-angle: 0deg; --gold-angle: 0deg;
position: absolute; position: absolute;
inset: calc(var(--design-unit) * 3.2); inset: calc(var(--design-unit) * 2.6);
border-radius: calc(var(--design-unit) * 16); border-radius: calc(var(--design-unit) * 16);
overflow: hidden; overflow: hidden;
clip-path: inset(0 round calc(var(--design-unit) * 16)); clip-path: inset(0 round calc(var(--design-unit) * 16));
@@ -406,7 +406,7 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: calc(var(--design-unit) * 16); border-radius: calc(var(--design-unit) * 16);
padding: calc(var(--design-unit) * 2.6); padding: calc(var(--design-unit) * 5.6);
background: conic-gradient( background: conic-gradient(
from var(--gold-angle), from var(--gold-angle),
#534217 10%, #534217 10%,
@@ -428,9 +428,9 @@
.gold-reveal-shell::after { .gold-reveal-shell::after {
content: ""; content: "";
position: absolute; position: absolute;
inset: calc(var(--design-unit) * 0.8); inset: calc(var(--design-unit) * 1);
border-radius: calc(var(--design-unit) * 15); border-radius: calc(var(--design-unit) * 15);
padding: calc(var(--design-unit) * 1.8); padding: calc(var(--design-unit) * 4);
background: conic-gradient( background: conic-gradient(
from calc(var(--gold-angle) + 180deg), from calc(var(--gold-angle) + 180deg),
rgba(255, 247, 210, 0) 0%, rgba(255, 247, 210, 0) 0%,
@@ -452,12 +452,12 @@
.gold-reveal-static-border { .gold-reveal-static-border {
position: absolute; position: absolute;
inset: calc(var(--design-unit) * 3.2); inset: calc(var(--design-unit) * 2.8);
border-radius: calc(var(--design-unit) * 16); 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: box-shadow:
inset 0 0 calc(var(--design-unit) * 10) rgba(255, 241, 181, 0.28), inset 0 0 calc(var(--design-unit) * 14) rgba(255, 241, 181, 0.38),
0 0 calc(var(--design-unit) * 12) rgba(255, 210, 102, 0.2); 0 0 calc(var(--design-unit) * 18) rgba(255, 210, 102, 0.32);
pointer-events: none; pointer-events: none;
} }