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',
depositWithdrawConfig: 'api/finance/depositWithdrawConfig',
legacyCashierConfig: 'api/finance/cashierConfig',
walletRecordList: 'api/wallet/recordList',
withdrawCreate: 'api/finance/withdrawCreate',
withdrawList: 'api/finance/withdrawList',
} as const

View File

@@ -15,12 +15,26 @@ interface UseRegisterFormOptions {
onSuccess?: () => void
}
const REGISTER_INVITE_CODE_QUERY_PARAM = 'registerInviteCode'
function getInitialRegisterInviteCode() {
if (typeof window === 'undefined') {
return ''
}
return (
new URLSearchParams(window.location.search)
.get(REGISTER_INVITE_CODE_QUERY_PARAM)
?.trim() ?? ''
)
}
export function useRegisterForm({ onSuccess }: UseRegisterFormOptions = {}) {
const startSession = useAuthStore((state) => state.startSession)
const form = useForm<RegisterFormValues>({
defaultValues: {
confirmPassword: '',
inviteCode: '',
inviteCode: getInitialRegisterInviteCode(),
password: '',
username: '',
},

View File

@@ -18,6 +18,10 @@ import type {
FinanceRateConfigDto,
FinanceWithdrawBankDto,
FinanceWithdrawConfigDto,
WalletRecordItemDto,
WalletRecordList,
WalletRecordListDto,
WalletRecordType,
WithdrawCreateRequestDto,
WithdrawCreateResponseDto,
} from './finance-types'
@@ -196,6 +200,49 @@ function normalizeFinanceOrderList(dto: FinanceOrderListDto): FinanceOrderList {
}
}
function stringifyNullableValue(value: unknown) {
return value === null || value === undefined ? '' : String(value)
}
function normalizeWalletRecordItem(dto: WalletRecordItemDto, index: number) {
const createdAt = dto.created_at ?? dto.create_time ?? dto.time ?? null
const id =
dto.id ??
dto.record_id ??
dto.wallet_record_id ??
dto.order_no ??
`${createdAt ?? 'wallet-record'}-${index + 1}`
return {
amount: stringifyNullableValue(
dto.amount ?? dto.change_amount ?? dto.coin ?? '',
),
balanceAfter: stringifyNullableValue(
dto.balance_after ?? dto.after_balance ?? dto.balance ?? '',
),
balanceBefore: stringifyNullableValue(
dto.balance_before ?? dto.before_balance ?? '',
),
createdAt,
id: String(id),
remark: stringifyNullableValue(dto.remark ?? dto.description ?? dto.memo),
type: stringifyNullableValue(
dto.type ?? dto.change_type ?? dto.biz_type ?? dto.scene,
),
}
}
function normalizeWalletRecordList(dto: WalletRecordListDto): WalletRecordList {
return {
list: (dto.list ?? []).map(normalizeWalletRecordItem),
pagination: {
page: dto.pagination?.page ?? dto.page ?? 1,
page_size: dto.pagination?.page_size ?? dto.page_size ?? 20,
total: dto.pagination?.total ?? dto.total ?? 0,
},
}
}
export async function getDepositWithdrawConfig() {
const response = await api.post<DepositWithdrawConfigDto>(
FINANCE_API_ENDPOINTS.depositWithdrawConfig,
@@ -287,6 +334,29 @@ export async function getWithdrawOrderList(params?: {
return normalizeFinanceOrderList(dto)
}
export async function getWalletRecordList(params?: {
page?: number
pageSize?: number
type?: WalletRecordType
}) {
const response = await api.get<WalletRecordListDto>(
FINANCE_API_ENDPOINTS.walletRecordList,
{
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? 20),
type: params?.type ?? 'payout',
},
},
)
const dto = unwrapFinanceEnvelope(
response as ApiResponse<WalletRecordListDto>,
'Failed to load wallet record list',
)
return normalizeWalletRecordList(dto)
}
export async function createWithdraw(payload: WithdrawCreateRequestDto) {
const response = await api.post<
WithdrawCreateResponseDto,

View File

@@ -190,6 +190,56 @@ export interface FinanceOrderList {
pagination: FinanceOrderPaginationDto
}
export type WalletRecordType = 'payout' | (string & {})
export interface WalletRecordItemDto {
after_balance?: number | string | null
amount?: number | string | null
balance?: number | string | null
balance_after?: number | string | null
balance_before?: number | string | null
before_balance?: number | string | null
biz_type?: string | null
change_amount?: number | string | null
change_type?: string | null
coin?: number | string | null
create_time?: number | string | null
created_at?: number | string | null
description?: string | null
id?: number | string | null
memo?: string | null
order_no?: string | null
record_id?: number | string | null
remark?: string | null
scene?: string | null
time?: number | string | null
type?: string | null
wallet_record_id?: number | string | null
}
export interface WalletRecordListDto {
list: WalletRecordItemDto[]
pagination?: FinanceOrderPaginationDto
page?: number
page_size?: number
total?: number
}
export interface WalletRecordItem {
amount: string
balanceAfter: string
balanceBefore: string
createdAt: number | string | null
id: string
remark: string
type: string
}
export interface WalletRecordList {
list: WalletRecordItem[]
pagination: FinanceOrderPaginationDto
}
export interface WithdrawCreateRequestDto {
bank_code: string
channel_code: string

View File

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

View File

@@ -298,9 +298,9 @@ export function DesktopAnimal({
isMarqueeActive &&
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
isRevealRunning &&
'border-[rgba(104,255,249,0.9)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(68,244,255,0.68),0_0_calc(var(--design-unit)*26)_rgba(37,214,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(115,255,247,0.24)] brightness-125 saturate-150',
'border-[rgba(104,255,249,0.76)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(68,244,255,0.46),0_0_calc(var(--design-unit)*22)_rgba(37,214,255,0.28),inset_0_0_calc(var(--design-unit)*16)_rgba(115,255,247,0.18)] brightness-115 saturate-125',
isRevealWinner &&
'shadow-[0_0_calc(var(--design-unit)*14)_rgba(81,248,255,0.72),0_0_calc(var(--design-unit)*24)_rgba(30,199,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.34)] brightness-125 saturate-150',
'border-[rgba(121,255,250,0.72)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(81,248,255,0.54),0_0_calc(var(--design-unit)*22)_rgba(30,199,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.24)] brightness-110 saturate-120',
showCellWarning &&
'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]',
!showStandbyState && !hasPlacedSelection && 'opacity-95',
@@ -341,9 +341,9 @@ export function DesktopAnimal({
isMarqueeActive &&
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
isRevealRunning &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.5)_0%,rgba(77,244,255,0.24)_40%,rgba(27,183,255,0.1)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*16)_rgba(95,249,255,0.72),inset_0_0_calc(var(--design-unit)*22)_rgba(151,255,250,0.4)]',
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.36)_0%,rgba(77,244,255,0.16)_40%,rgba(27,183,255,0.07)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(95,249,255,0.46),inset_0_0_calc(var(--design-unit)*18)_rgba(151,255,250,0.24)]',
isRevealWinner &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.5)_0%,rgba(67,226,255,0.24)_38%,rgba(25,131,255,0.1)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*14)_rgba(92,248,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(126,255,250,0.4)]',
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.34)_0%,rgba(67,226,255,0.16)_38%,rgba(25,131,255,0.07)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(92,248,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(126,255,250,0.24)]',
showCellWarning &&
'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100',
)}
@@ -360,9 +360,9 @@ export function DesktopAnimal({
className={cn(
'absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*14)]',
isRevealRunning &&
'brightness-125 saturate-150 drop-shadow-[0_0_calc(var(--design-unit)*10)_rgba(101,250,255,0.62)]',
'brightness-115 saturate-125 drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(101,250,255,0.42)]',
isRevealWinner &&
'brightness-140 saturate-150 drop-shadow-[0_0_calc(var(--design-unit)*12)_rgba(106,250,255,0.72)]',
'brightness-110 saturate-120 drop-shadow-[0_0_calc(var(--design-unit)*9)_rgba(106,250,255,0.44)]',
imageClassName,
)}
imgClassName="object-fill"
@@ -452,7 +452,7 @@ export function DesktopAnimal({
</div>
) : null}
{showStopOverlay ? (
{showStopOverlay && !hostingFlag ? (
<div
aria-hidden="true"
className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]"
@@ -469,22 +469,50 @@ export function DesktopAnimal({
) : null}
{hostingFlag ? (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]">
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center gap-design-22 bg-[rgba(2,8,14,0.6)] px-design-24 backdrop-blur-[1px]">
{showStopOverlay ? (
<SmartImage
src={stopImageSrc}
alt="stop betting"
priority
showSkeleton={false}
className="h-design-170 w-design-520 max-w-[72%] overflow-visible"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
/>
) : null}
<SmartBackground
src={hostingBg}
size="100% 100%"
repeat="no-repeat"
position="center"
className="h-design-350 w-design-930 flex flex-col items-center justify-center"
>
<div className={'flex flex-col gap-design-40 items-center'}>
<div className={'flex flex-col gap-design-44 items-center'}>
<div className={'flex items-center gap-design-20'}>
<SmartImage
src={refreshIcon}
alt="refreshIcon"
priority
showSkeleton={false}
className="h-design-40 w-design-40"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
/>
<div className={'text-design-20 text-[#ffffff] font-bold'}>
<motion.span
aria-hidden="true"
animate={prefersReducedMotion ? undefined : { rotate: 360 }}
transition={{
duration: 1.4,
ease: 'linear',
repeat: Number.POSITIVE_INFINITY,
}}
className="flex h-design-48 w-design-48 items-center justify-center"
>
<SmartImage
src={refreshIcon}
alt=""
priority
showSkeleton={false}
className="h-design-40 w-design-40"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.36)]"
/>
</motion.span>
<div
className={
'text-design-22 text-[#ffffff] font-bold [text-shadow:0_0_calc(var(--design-unit)*12)_rgba(76,236,255,0.45)]'
}
>
{t('game.autoSpin.runningRounds', {
count: completedAutoHostingRounds,
})}
@@ -495,7 +523,10 @@ export function DesktopAnimal({
type="button"
onClick={stopHosting}
src={hostingBtn}
className="h-design-80 w-design-170 flex cursor-pointer flex-col items-center justify-center transition-transform hover:-translate-y-[1px] active:translate-y-0"
size="100% 100%"
repeat="no-repeat"
position="center"
className="h-design-70 w-design-220 flex cursor-pointer items-center justify-center pb-design-4 text-design-24 font-bold text-[#EFFFFF] [text-shadow:0_1px_0_rgba(255,255,255,0.18),0_0_calc(var(--design-unit)*10)_rgba(46,220,255,0.5)] transition-transform hover:-translate-y-[1px] active:translate-y-0"
>
{t('game.actions.stopAuto')}
</SmartBackground>

View File

@@ -24,6 +24,7 @@ export function DesktopControl() {
acceptingBets,
actionsEnabled,
canClear,
canDecreaseBetQuantity,
chips,
confirmLabel,
confirmState,
@@ -32,9 +33,11 @@ export function DesktopControl() {
onChipSelect,
onConfirm,
onClearSelections,
onDecreaseBetQuantity,
onIncreaseBetQuantity,
onOpenAutoSetting,
onRepeatSelections,
selectedChipAmountLabel,
selectedBetQuantityLabel,
selectedChipId,
selectedCountLabel,
totalBetAmountLabel,
@@ -297,21 +300,43 @@ export function DesktopControl() {
'flex h-design-50 shrink-0 items-center rounded-md bg-[#091118] box-border px-design-2 py-design-3'
}
>
<SmartImage
src={add}
alt={`add`}
className={'w-design-40 h-design-40'}
/>
<button
type="button"
disabled={!acceptingBets || !canDecreaseBetQuantity}
onClick={onDecreaseBetQuantity}
className={cn(
'flex h-design-40 w-design-40 shrink-0 items-center justify-center',
acceptingBets && canDecreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage
src={add}
alt={`add`}
className={'w-design-40 h-design-40'}
/>
</button>
<div
className={'w-design-80 h-full flex items-center justify-center'}
>
{selectedChipAmountLabel}
{selectedBetQuantityLabel}
</div>
<SmartImage
src={reduce}
alt={`reduce`}
className={'w-design-40 h-design-40'}
/>
<button
type="button"
disabled={!acceptingBets}
onClick={onIncreaseBetQuantity}
className={cn(
'flex h-design-40 w-design-40 shrink-0 items-center justify-center',
acceptingBets ? 'cursor-pointer' : 'cursor-not-allowed',
)}
>
<SmartImage
src={reduce}
alt={`reduce`}
className={'w-design-40 h-design-40'}
/>
</button>
</div>
</SmartBackground>
<SmartBackground

View File

@@ -3,12 +3,14 @@ import {
Mail,
Maximize,
Minimize,
Plus,
UserKey,
UserRoundPlus,
Volume2,
VolumeX,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import add from '@/assets/game/add.webp'
import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
@@ -205,10 +207,19 @@ export function DesktopHeader() {
/>
<div
className={
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-design-180 items-center justify-end transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
'common-neon-inset text-design-16 !py-design-20 !pr-design-14 box-border flex h-design-36 w-design-180 items-center justify-end gap-design-8 transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
}
>
{currentUser?.coin || '--'}
<span className="truncate">{currentUser?.coin || '--'}</span>
<div className={'common-neon-inset !p-design-3'}>
<Plus
aria-hidden="true"
className="shrink-0"
color="#57B8BF"
size={18}
strokeWidth={2.5}
/>
</div>
</div>
</button>
</div>

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import streakBg from '@/assets/game/pc-streak.webp'
import down5Animation from '@/assets/lottie/down5.json'
import diamond from '@/assets/system/diamond.webp'
import fire from '@/assets/system/fire.webp'
@@ -12,6 +13,7 @@ import { SmartImage } from '@/components/smart-image.tsx'
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
export function DesktopStatusLine() {
const { t } = useTranslation()
const {
@@ -23,9 +25,11 @@ export function DesktopStatusLine() {
phaseToneClassName,
roundId,
streakLabel,
streakValue,
} = useGameStatusVm()
const [remainingMs, setRemainingMs] = useState(countdownMs)
const showWarningCountdown = remainingMs <= 5000 && remainingMs > 0
const showStreakLimitOnly = typeof streakValue === 'number' && streakValue > 1
const countdownClassName = useMemo(
() =>
showWarningCountdown
@@ -44,45 +48,59 @@ export function DesktopStatusLine() {
{/* 状态栏左侧 */}
<div
className={
'relative h-full flex-1 flex items-center justify-center gap-design-50'
'relative h-full flex-1 flex items-center justify-center gap-design-50 overflow-visible'
}
>
{/*<div className={'flex-1 absolute z-10 -right-20 -top-6 w-full !h-design-105'} style={{*/}
{/* backgroundImage: `url(${streakBg})`,*/}
{/* backgroundSize: '100% 110%',*/}
{/* backgroundRepeat: 'no-repeat',*/}
{/*}} >*/}
{/*</div>*/}
<div className={'text-[#CBD3D5] font-bold'}>
{t('gameDesktop.status.odds')}:{' '}
<span className={'text-[#E3D171]'}>{oddsLabel}</span>
</div>
<div
className={
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
}
>
<SmartImage
className={'w-design-37 h-design-47'}
alt={'fire'}
src={fire}
{showStreakLimitOnly && (
<div
className={
'pointer-events-none absolute z-0 bg-center bg-no-repeat'
}
style={{
top: 'calc(var(--design-unit)*-14)',
right: 'calc(var(--design-unit)*-190)',
bottom: 'calc(var(--design-unit)*-1)',
left: 0,
backgroundImage: `url(${streakBg})`,
backgroundPosition: 'center',
backgroundSize: '113% 150%',
}}
/>
<div>
{t('gameDesktop.status.streak')}:{' '}
<span
)}
{!showStreakLimitOnly && (
<>
<div className={'text-[#CBD3D5] font-bold'}>
{t('gameDesktop.status.odds')}:{' '}
<span className={'text-[#E3D171]'}>{oddsLabel}</span>
</div>
<div
className={
'bg-gradient-to-b from-[#EBA661] to-[#FCC785] bg-clip-text text-transparent'
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
}
>
{streakLabel}
</span>
</div>
</div>
<SmartImage
className={'w-design-37 h-design-47'}
alt={'fire'}
src={fire}
/>
<div>
{t('gameDesktop.status.streak')}:{' '}
<span
className={
'bg-gradient-to-b from-[#EBA661] to-[#FCC785] bg-clip-text text-transparent'
}
>
{streakLabel}
</span>
</div>
</div>
</>
)}
<div
className={
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
'relative z-20 flex items-center gap-design-5 text-[#CBD3D5] font-bold'
}
>
<SmartImage

View File

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

View File

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

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

View File

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

View File

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

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 { cn } from '@/lib/utils'
function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
const vm = useFinanceRecordsVm({ enabled })
const parentRef = useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: vm.items.length + (vm.hasNextPage ? 1 : 0),
estimateSize: () => 72,
getScrollElement: () => parentRef.current,
overscan: 6,
})
const virtualItems = rowVirtualizer.getVirtualItems()
useEffect(() => {
const lastItem = virtualItems.at(-1)
if (
!lastItem ||
lastItem.index < vm.items.length - 1 ||
!vm.hasNextPage ||
vm.isFetchingNextPage
) {
return
}
void vm.fetchNextPage()
}, [
virtualItems,
vm.fetchNextPage,
vm.hasNextPage,
vm.isFetchingNextPage,
vm.items.length,
])
return (
<div className={'flex h-full w-full flex-col p-design-10'}>
@@ -26,7 +57,10 @@ function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
key={recordType.key}
type="button"
aria-pressed={isActive}
onClick={() => vm.selectRecordType(recordType.key)}
onClick={() => {
vm.selectRecordType(recordType.key)
rowVirtualizer.scrollToOffset(0)
}}
className={cn(
'relative h-design-44 min-w-design-130 cursor-pointer rounded-md px-design-16 text-design-18 transition-colors duration-200',
isActive
@@ -56,7 +90,7 @@ function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
<div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div>
</div>
<div className={'min-h-0 flex-1 overflow-auto rounded-md'}>
<div className={'min-h-0 flex-1 rounded-md'}>
<div
className={
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] gap-design-10 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-16 py-design-12 text-design-16 text-[#7ECAD1]'
@@ -68,91 +102,78 @@ function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
</div>
<div
ref={parentRef}
className={
'mt-design-10 flex min-h-[calc(var(--design-unit)*320)] flex-col gap-design-10'
'mt-design-10 max-h-[calc(var(--design-unit)*320)] min-h-0 overflow-auto pr-design-4'
}
>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={vm.recordType}
className={'flex flex-col gap-design-10'}
initial={{
opacity: 0,
x: vm.recordType === 'deposit' ? -18 : 18,
}}
animate={{ opacity: 1, x: 0 }}
exit={{
opacity: 0,
x: vm.recordType === 'deposit' ? 18 : -18,
}}
transition={{ duration: 0.18, ease: 'easeOut' }}
{vm.isLoading ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.loadingText}
</div>
) : vm.isError ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.loadFailedText}
</div>
) : vm.items.length === 0 ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.emptyText}
</div>
) : (
<div
className={'relative w-full'}
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{vm.isLoading ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.loadingText}
</div>
) : vm.isError ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.loadFailedText}
</div>
) : vm.items.length === 0 ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.emptyText}
</div>
) : (
vm.items.map((item, index) => (
<motion.div
key={item.id}
className={
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-18 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: Math.min(index, 6) * 0.025,
duration: 0.16,
ease: 'easeOut',
{virtualItems.map((virtualRow) => {
const item = vm.items[virtualRow.index]
return (
<div
key={virtualRow.key}
className={'absolute left-0 top-0 w-full pb-design-10'}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className={'truncate font-medium text-white'}>
{item.orderNoLabel}
</div>
<div className={'text-[#FEEEB0]'}>{item.amountLabel}</div>
<div className={'text-[#7CFFCF]'}>
{item.bonusAmountLabel}
</div>
</motion.div>
))
)}
</motion.div>
</AnimatePresence>
{item ? (
<motion.div
className={
'grid h-[calc(var(--design-unit)*62)] grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-18 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.16,
ease: 'easeOut',
}}
>
<div className={'truncate font-medium text-white'}>
{item.orderNoLabel}
</div>
<div className={'truncate text-[#FEEEB0]'}>
{item.amountLabel}
</div>
<div className={'truncate text-[#7CFFCF]'}>
{item.bonusAmountLabel}
</div>
</motion.div>
) : (
<div
className={
'flex h-[calc(var(--design-unit)*62)] items-center justify-center rounded-md bg-[#0A4252]/60 text-design-16 text-[#6CCDCF]'
}
>
{vm.loadingText}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
<div
className={'mt-design-12 flex items-center justify-end gap-design-10'}
>
<button
type="button"
disabled={!vm.canGoPreviousPage || vm.isFetching}
onClick={vm.goPreviousPage}
className={
'h-design-40 cursor-pointer rounded-md border border-[#3EAFC7]/35 bg-[#062E39] px-design-16 text-design-16 text-[#86DAE7] transition hover:bg-[#0A4252] disabled:cursor-not-allowed disabled:opacity-45'
}
>
{vm.previousText}
</button>
<button
type="button"
disabled={!vm.canGoNextPage || vm.isFetching}
onClick={vm.goNextPage}
className={
'h-design-40 cursor-pointer rounded-md border border-[#3EAFC7]/35 bg-[#062E39] px-design-16 text-design-16 text-[#86DAE7] transition hover:bg-[#0A4252] disabled:cursor-not-allowed disabled:opacity-45'
}
>
{vm.nextText}
</button>
</div>
</div>
)
}

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 lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
import noticeBg from '@/assets/system/notice-bg.webp'
import blueBtnBg from '@/assets/system/blue-btn.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { getNoticeDetail, getNoticeList } from '@/features/game/api'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'
type NoticeViewState = 'detail' | 'list'
function DesktopNoticeModal() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopNotice)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const [noticeView, setNoticeView] = useState<NoticeViewState>('list')
const [selectedNoticeId, setSelectedNoticeId] = useState<number | null>(null)
const noticeListQuery = useQuery({
queryKey: ['game', 'notice-list'],
queryFn: () => getNoticeList(),
enabled: open && noticeView === 'list',
})
const noticeDetailQuery = useQuery({
queryKey: ['game', 'notice-detail', selectedNoticeId],
queryFn: () => getNoticeDetail(selectedNoticeId ?? 0),
enabled: open && noticeView === 'detail' && selectedNoticeId !== null,
})
const noticeItems = useMemo(
() => noticeListQuery.data?.list ?? [],
[noticeListQuery.data],
)
async function handleReturnToList() {
setNoticeView('list')
setSelectedNoticeId(null)
await noticeListQuery.refetch()
}
useEffect(() => {
if (!open) {
setNoticeView('list')
setSelectedNoticeId(null)
}
}, [open])
function handleSubmit() {
setModalOpen('desktopNotice', false)
@@ -22,54 +59,177 @@ function DesktopNoticeModal() {
onClose={handleSubmit}
title={
<div className={'modal-title-glow text-design-26'}>
{t('game.modals.notice.title')}
{t('game.modals.userInfo.message.title')}
</div>
}
isNormalBg={true}
titleAlign="left"
className={'w-design-1000 h-design-690'}
className={'w-design-980 h-design-690'}
>
<div className={'flex flex-col h-full w-full p-design-10 gap-design-30'}>
<div
className={
'w-full h-design-440 overflow-y-auto flex flex-col gap-design-20 bg-[#000000]/50 p-design-10 rounded-md'
}
>
<SmartImage
className={'w-full h-design-300 shrink-0 rounded-md'}
imgClassName={'object-contain object-center'}
alt={'notice'}
src={noticeBg}
/>
<div className={'text-[#74B3BA] text-design-18 leading-[1.6]'}>
{t('game.modals.notice.content')}
<div className={'flex h-full w-full flex-col'}>
{noticeView === 'detail' ? (
<div
className={
'mb-design-12 flex items-center mx-design-10 my-design-10 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
}
>
<button
type="button"
onClick={() => {
void handleReturnToList()
}}
className={
'flex cursor-pointer items-center gap-design-10 text-[#86DAE7] transition hover:text-white'
}
>
<span
className={
'flex h-design-40 w-design-40 items-center justify-center rounded-full border border-[#4AC6DE]/45 bg-[#0B4454]'
}
>
<ArrowLeft className={'h-design-22 w-design-22'} />
</span>
<span className={'text-design-20 font-medium tracking-wide'}>
{t('game.modals.userInfo.message.back')}
</span>
</button>
</div>
</div>
<div className={'w-full flex justify-around'}>
<SmartBackground
src={lengthGreenBtn}
size="106% 108%"
repeat="no-repeat"
position="center"
className={
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
}
>
{t('game.modals.notice.check')}
</SmartBackground>
) : null}
<SmartBackground
src={lengthBlueBtn}
size="100% 90%"
repeat="no-repeat"
position="center"
className={
'w-design-270 h-design-72 pb-design-5 flex items-center justify-center text-design-20 font-bold'
}
>
{t('game.modals.notice.check')}
</SmartBackground>
<div className={'h-full w-full overflow-auto rounded-md'}>
{noticeView === 'list' ? (
<div
className={
'flex h-full w-full flex-col gap-design-10 p-design-10'
}
>
{noticeListQuery.isLoading ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{t('game.modals.userInfo.message.loading')}
</div>
) : noticeListQuery.isError ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{t('game.modals.userInfo.message.loadFailed')}
</div>
) : noticeItems.length === 0 ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{t('game.modals.userInfo.message.empty')}
</div>
) : (
noticeItems.map((item) => (
<button
key={item.notice_id}
type="button"
onClick={() => {
setSelectedNoticeId(item.notice_id)
setNoticeView('detail')
}}
className={
'flex cursor-pointer items-center gap-design-20 rounded-md bg-[#0A4252] px-design-15 py-design-15 text-left transition hover:bg-[#0E576D]'
}
>
<div
className={cn(
'relative flex h-design-95 w-design-95 items-center justify-center rounded-md text-design-18 font-bold',
item.notice_type === 'popout'
? 'bg-[#203C49] text-[#FEEEB0]'
: 'bg-[#111111] text-[#6CCDCF]',
)}
>
<span
className={cn(
'absolute -right-design-8 top-design-8 z-10 min-w-design-50 -rotate-[8deg] rounded-[calc(var(--design-unit)*4)] border px-design-6 py-design-4 text-center text-design-11 font-semibold leading-none shadow-[0_0_calc(var(--design-unit)*8)_rgba(0,0,0,0.2)]',
item.is_read
? 'border-[#2D7384] bg-[linear-gradient(180deg,#20596A,#153A47)] text-[#B4E9F0]'
: 'border-[#9B6427] bg-[linear-gradient(180deg,#8A5320,#5E3616)] text-[#FFF0A8]',
)}
>
{item.is_read
? t('game.modals.userInfo.message.read')
: t('game.modals.userInfo.message.unread')}
</span>
{item.notice_type.toUpperCase()}
</div>
<div className={'min-w-0 flex-1'}>
<div className={'text-design-18 text-[#BFEAEC]'}>
{dayjs(item.publish_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)}
</div>
<div
className={
'mt-design-4 flex items-center gap-design-12'
}
>
<div className={'truncate text-design-20 text-white'}>
{item.title}
</div>
</div>
</div>
<SmartBackground
src={blueBtnBg}
size="100% 100%"
className={
'flex h-design-64 w-design-150 items-center justify-center text-design-20 font-bold'
}
>
{t('game.modals.userInfo.message.check')}
</SmartBackground>
</button>
))
)}
</div>
) : (
<div
className={
'flex h-full w-full flex-col gap-design-16 p-design-10'
}
>
{noticeDetailQuery.isLoading ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{t('game.modals.userInfo.message.loading')}
</div>
) : noticeDetailQuery.isError ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{t('game.modals.userInfo.message.loadFailed')}
</div>
) : noticeDetailQuery.data ? (
<div
className={
'rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-24 shadow-[0_0_24px_rgba(14,108,132,0.16)]'
}
>
<div
className={
'mb-design-14 inline-flex rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-14 py-design-6 text-design-16 text-[#9CE8F2]'
}
>
{dayjs(noticeDetailQuery.data.publish_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)}
</div>
<div
className={
'text-design-28 font-semibold leading-tight text-white'
}
>
{noticeDetailQuery.data.title}
</div>
<div
className={
'mt-design-18 whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]'
}
>
{noticeDetailQuery.data.content}
</div>
</div>
) : (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{t('game.modals.userInfo.message.empty')}
</div>
)}
</div>
)}
</div>
</div>
</CenterModal>

View File

@@ -1,22 +1,25 @@
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { ArrowLeft, CircleUserRound, Mail, ReceiptText } from 'lucide-react'
import {
CircleUserRound,
ClipboardList,
ReceiptText,
WalletCards,
} from 'lucide-react'
import { motion } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp'
import blueBtnBg from '@/assets/system/blue-btn.webp'
import userInfoBg from '@/assets/system/userInfo-bg.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { getNoticeDetail, getNoticeList } from '@/features/game/api'
import DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab'
import DesktopWalletRecordsTab from '@/features/game/modal/desktop/desktop-wallet-records-tab'
import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils'
import { useAuthStore, useModalStore } from '@/store'
type UserInfoTabKey = 'financeRecords' | 'message' | 'profile'
type MessageViewState = 'list' | 'detail'
type UserInfoTabKey = 'financeRecords' | 'profile' | 'walletRecords'
const USER_INFO_TABS: Array<{
key: UserInfoTabKey
@@ -34,67 +37,81 @@ const USER_INFO_TABS: Array<{
icon: ReceiptText,
},
{
key: 'message',
labelKey: 'game.modals.userInfo.tabs.message',
icon: Mail,
key: 'walletRecords',
labelKey: 'game.modals.userInfo.tabs.walletRecords',
icon: WalletCards,
},
]
const REGISTER_INVITE_CODE_QUERY_PARAM = 'registerInviteCode'
function createRegisterInviteUrl(inviteCode: string) {
const url = new URL(window.location.href)
url.searchParams.set(REGISTER_INVITE_CODE_QUERY_PARAM, inviteCode)
return url.toString()
}
async function copyTextToClipboard(text: string) {
if (navigator.clipboard?.writeText && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
const copied = document.execCommand('copy')
if (!copied) {
throw new Error('Copy command failed')
}
} finally {
document.body.removeChild(textarea)
}
}
function DesktopUserInfoModal() {
const { t } = useTranslation()
const open = useModalStore((state) => state.modals.desktopUserInfo)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
const [messageView, setMessageView] = useState<MessageViewState>('list')
const [selectedNoticeId, setSelectedNoticeId] = useState<number | null>(null)
const currentUser = useAuthStore((state) => state.currentUser)
const noticeListQuery = useQuery({
queryKey: ['game', 'notice-list'],
queryFn: () => getNoticeList(),
enabled: open && activeTab === 'message' && messageView === 'list',
})
const noticeDetailQuery = useQuery({
queryKey: ['game', 'notice-detail', selectedNoticeId],
queryFn: () => getNoticeDetail(selectedNoticeId ?? 0),
enabled:
open &&
activeTab === 'message' &&
messageView === 'detail' &&
selectedNoticeId !== null,
})
const noticeItems = useMemo(
() => noticeListQuery.data?.list ?? [],
[noticeListQuery.data],
)
async function handleReturnToList() {
setMessageView('list')
setSelectedNoticeId(null)
await noticeListQuery.refetch()
}
const inviteCode = currentUser?.registerInviteCode?.trim() ?? ''
useEffect(() => {
if (!open) {
setActiveTab('profile')
setMessageView('list')
setSelectedNoticeId(null)
}
}, [open])
useEffect(() => {
if (activeTab !== 'message') {
setMessageView('list')
setSelectedNoticeId(null)
}
}, [activeTab])
function handleSubmit() {
setModalOpen('desktopUserInfo', false)
}
async function handleCopyInviteLink() {
if (!inviteCode) {
return
}
try {
await copyTextToClipboard(createRegisterInviteUrl(inviteCode))
notify.success(t('commonUi.toast.inviteLinkCopied'))
} catch {
notify.error(t('commonUi.toast.inviteLinkCopyFailed'))
}
}
return (
<CenterModal
open={open}
@@ -217,7 +234,7 @@ function DesktopUserInfoModal() {
<div
className={
'w-design-600 flex-1 text-design-18 rounded-md bg-[#000000]/40 flex flex-col gap-design-20 p-design-20 '
'w-design-600 flex-1 text-design-18 rounded-md bg-[#000000]/40 flex flex-col gap-design-20 p-design-20'
}
>
<div className={'text-[#6CCDCF]'}>
@@ -225,19 +242,35 @@ function DesktopUserInfoModal() {
<span
className={'text-design-18 text-[#599AA3] ml-design-10'}
>
{dayjs(currentUser?.createTime).format(
'YYYY-MM-DD HH:mm:ss',
) || '--'}
{currentUser?.createTime
? dayjs
.unix(currentUser.createTime)
.format('YYYY-MM-DD HH:mm:ss')
: '--'}
</span>
</div>
<div className={'text-[#6CCDCF]'}>
{t('auth.register.fields.inviteCode.label')}
<div className={'flex items-center text-[#6CCDCF]'}>
<span>{t('auth.register.fields.inviteCode.label')}</span>
<span
className={'text-design-18 text-[#599AA3] ml-design-10'}
>
{currentUser?.registerInviteCode || '--'}
{inviteCode || '--'}
</span>
<button
type="button"
onClick={() => {
void handleCopyInviteLink()
}}
disabled={!inviteCode}
aria-label={t(
'game.modals.userInfo.profile.copyInviteLink',
)}
title={t('game.modals.userInfo.profile.copyInviteLink')}
className="ml-design-10 flex h-design-30 w-design-30 cursor-pointer items-center justify-center rounded-md border border-[#356E76] bg-[#0B2F35]/70 text-[#6CCDCF] transition-colors duration-200 hover:border-[#6CCDCF] hover:text-[#D9FFFF] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#6CCDCF] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45"
>
<ClipboardList className="h-design-18 w-design-18" />
</button>
</div>
</div>
</SmartBackground>
@@ -246,188 +279,9 @@ function DesktopUserInfoModal() {
enabled={open && activeTab === 'financeRecords'}
/>
) : (
<div className={'flex h-full w-full flex-col'}>
{messageView === 'detail' ? (
<div
className={
'mb-design-12 flex items-center mx-design-10 my-design-10 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
}
>
<button
type="button"
onClick={() => {
void handleReturnToList()
}}
className={
'flex cursor-pointer items-center gap-design-10 text-[#86DAE7] transition hover:text-white'
}
>
<span
className={
'flex h-design-40 w-design-40 items-center justify-center rounded-full border border-[#4AC6DE]/45 bg-[#0B4454]'
}
>
<ArrowLeft className={'h-design-22 w-design-22'} />
</span>
<span
className={'text-design-20 font-medium tracking-wide'}
>
{t('game.modals.userInfo.message.back')}
</span>
</button>
</div>
) : null}
<div className={'h-full w-full overflow-auto rounded-md'}>
{messageView === 'list' ? (
<div
className={
'flex h-full w-full flex-col gap-design-10 p-design-10'
}
>
{noticeListQuery.isLoading ? (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.loading')}
</div>
) : noticeListQuery.isError ? (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.loadFailed')}
</div>
) : noticeItems.length === 0 ? (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.empty')}
</div>
) : (
noticeItems.map((item) => (
<button
key={item.notice_id}
type="button"
onClick={() => {
setSelectedNoticeId(item.notice_id)
setMessageView('detail')
}}
className={
'flex cursor-pointer items-center gap-design-20 rounded-md bg-[#0A4252] px-design-15 py-design-15 text-left transition hover:bg-[#0E576D]'
}
>
<div
className={cn(
'relative flex h-design-95 w-design-95 items-center justify-center rounded-md text-design-18 font-bold',
item.notice_type === 'popout'
? 'bg-[#203C49] text-[#FEEEB0]'
: 'bg-[#111111] text-[#6CCDCF]',
)}
>
<span
className={cn(
'absolute -right-design-8 top-design-8 z-10 min-w-design-50 -rotate-[8deg] rounded-[calc(var(--design-unit)*4)] border px-design-6 py-design-4 text-center text-design-11 font-semibold leading-none shadow-[0_0_calc(var(--design-unit)*8)_rgba(0,0,0,0.2)]',
item.is_read
? 'border-[#2D7384] bg-[linear-gradient(180deg,#20596A,#153A47)] text-[#B4E9F0]'
: 'border-[#9B6427] bg-[linear-gradient(180deg,#8A5320,#5E3616)] text-[#FFF0A8]',
)}
>
{item.is_read
? t('game.modals.userInfo.message.read')
: t('game.modals.userInfo.message.unread')}
</span>
{item.notice_type.toUpperCase()}
</div>
<div className={'min-w-0 flex-1'}>
<div className={'text-design-18 text-[#BFEAEC]'}>
{dayjs(item.publish_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)}
</div>
<div
className={
'mt-design-4 flex items-center gap-design-12'
}
>
<div
className={'truncate text-design-20 text-white'}
>
{item.title}
</div>
</div>
</div>
<SmartBackground
src={blueBtnBg}
size="100% 100%"
className={
'flex h-design-64 w-design-150 items-center justify-center text-design-20 font-bold'
}
>
{t('game.modals.userInfo.message.check')}
</SmartBackground>
</button>
))
)}
</div>
) : (
<div
className={
'flex h-full w-full flex-col gap-design-16 p-design-10'
}
>
{noticeDetailQuery.isLoading ? (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.loading')}
</div>
) : noticeDetailQuery.isError ? (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.loadFailed')}
</div>
) : noticeDetailQuery.data ? (
<div
className={
'rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-24 shadow-[0_0_24px_rgba(14,108,132,0.16)]'
}
>
<div
className={
'mb-design-14 inline-flex rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-14 py-design-6 text-design-16 text-[#9CE8F2]'
}
>
{dayjs(
noticeDetailQuery.data.publish_time * 1000,
).format('YYYY-MM-DD HH:mm:ss')}
</div>
<div
className={
'text-design-28 font-semibold leading-tight text-white'
}
>
{noticeDetailQuery.data.title}
</div>
<div
className={
'mt-design-18 whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]'
}
>
{noticeDetailQuery.data.content}
</div>
</div>
) : (
<div
className={'py-design-30 text-center text-[#6CCDCF]'}
>
{t('game.modals.userInfo.message.empty')}
</div>
)}
</div>
)}
</div>
</div>
<DesktopWalletRecordsTab
enabled={open && activeTab === 'walletRecords'}
/>
)}
</div>
</div>

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
dialogQueue: NotificationDialog[]
dismissDialog: (id?: string) => void
pushDialog: (dialog: NotificationDialog) => void
pushDialog: (dialog: NotificationDialog) => string
}
const dialogTimers = new Map<string, number>()
const dialogExitTimers = new Map<string, number>()
function isSameDialog(
firstDialog: NotificationDialog,
secondDialog: NotificationDialog,
) {
return (
firstDialog.type === secondDialog.type &&
firstDialog.message === secondDialog.message &&
(firstDialog.description ?? '') === (secondDialog.description ?? '')
)
}
function clearDialogTimer(id: string) {
const timerId = dialogTimers.get(id)
@@ -129,6 +140,8 @@ export const useNotificationStore = create<NotificationStoreState>()((set) => ({
})
},
pushDialog: (dialog) => {
let dialogId = dialog.id
set((state) => {
if (!state.activeDialog) {
clearDialogExitTimer(dialog.id)
@@ -140,6 +153,42 @@ export const useNotificationStore = create<NotificationStoreState>()((set) => ({
}
}
if (isSameDialog(state.activeDialog, dialog)) {
dialogId = state.activeDialog.id
clearDialogExitTimer(state.activeDialog.id)
scheduleDialogDismiss(state.activeDialog.id, dialog.duration)
return {
activeDialog: {
...state.activeDialog,
duration: dialog.duration,
},
closingDialogId: null,
dialogQueue: state.dialogQueue.filter(
(item) => !isSameDialog(item, dialog),
),
}
}
const queuedDialog = state.dialogQueue.find((item) =>
isSameDialog(item, dialog),
)
if (queuedDialog) {
dialogId = queuedDialog.id
return {
dialogQueue: state.dialogQueue.map((item) =>
item.id === queuedDialog.id
? {
...item,
duration: dialog.duration,
}
: item,
),
}
}
return {
dialogQueue: [
...state.dialogQueue.filter((item) => item.id !== dialog.id),
@@ -147,6 +196,8 @@ export const useNotificationStore = create<NotificationStoreState>()((set) => ({
],
}
})
return dialogId
},
}))
@@ -162,15 +213,13 @@ function showToast(
const id = createToastId()
const duration = options?.duration ?? DEFAULT_ALERT_DURATION_MS
useNotificationStore.getState().pushDialog({
return useNotificationStore.getState().pushDialog({
description: options?.description,
duration,
id,
message,
type,
})
return id
}
export const notify = {

View File

@@ -152,12 +152,14 @@ export default {
tabs: {
profile: 'Profile',
financeRecords: 'Top Up / Withdraw Records',
walletRecords: 'Wallet Records',
message: 'Messages',
},
profile: {
name: 'Name',
tel: 'Phone',
registeredAt: 'Registered at',
copyInviteLink: 'Copy invite link',
signature:
'My signature is as unique as my personality. This area will later display the real profile summary.',
},
@@ -187,6 +189,20 @@ export default {
previous: 'Previous',
next: 'Next',
},
walletRecords: {
amount: 'Amount',
balanceAfter: 'After',
balanceBefore: 'Before',
empty: 'No wallet records yet',
loadFailed: 'Failed to load wallet records. Please try again later.',
loading: 'Loading wallet records...',
next: 'Next',
page: 'Page {{page}} / {{total}} total',
previous: 'Previous',
remark: 'Remark',
time: 'Time',
type: 'Wallet Records',
},
},
withdrawTopup: {
applyWithdraw: 'Apply for Withdrawal',
@@ -229,6 +245,9 @@ export default {
loginRequired: 'Please log in before entering the game',
loginSuccess: 'Login successful',
registerSuccess: 'Registration successful',
inviteLinkCopied: 'Invite link copied',
inviteLinkCopyFailed:
'Failed to copy invite link. Please copy it manually.',
insufficientBalance: 'Insufficient balance. Please adjust your bet.',
betUnavailable: 'Betting is not available for this round',
betPlaced: 'Bet placed successfully',

View File

@@ -151,12 +151,14 @@ export default {
tabs: {
profile: 'Profil',
financeRecords: 'Riwayat Isi Ulang / Penarikan',
walletRecords: 'Riwayat Dompet',
message: 'Pesan',
},
profile: {
name: 'Nama',
tel: 'Telepon',
registeredAt: 'Tanggal daftar',
copyInviteLink: 'Salin tautan undangan',
signature:
'Tanda tanganku seunik diriku. Bagian ini nantinya akan menampilkan ringkasan profil yang sebenarnya.',
},
@@ -186,6 +188,20 @@ export default {
previous: 'Sebelumnya',
next: 'Berikutnya',
},
walletRecords: {
amount: 'Jumlah',
balanceAfter: 'Sesudah',
balanceBefore: 'Sebelum',
empty: 'Belum ada riwayat dompet',
loadFailed: 'Gagal memuat riwayat dompet. Silakan coba lagi nanti.',
loading: 'Memuat riwayat dompet...',
next: 'Berikutnya',
page: 'Halaman {{page}} / total {{total}}',
previous: 'Sebelumnya',
remark: 'Catatan',
time: 'Waktu',
type: 'Riwayat Dompet',
},
},
withdrawTopup: {
applyWithdraw: 'Ajukan Penarikan',
@@ -228,6 +244,9 @@ export default {
loginRequired: 'Silakan masuk sebelum memasuki game',
loginSuccess: 'Berhasil masuk',
registerSuccess: 'Pendaftaran berhasil',
inviteLinkCopied: 'Tautan undangan disalin',
inviteLinkCopyFailed:
'Gagal menyalin tautan undangan. Silakan salin secara manual.',
insufficientBalance: 'Saldo tidak cukup. Silakan sesuaikan taruhan.',
betUnavailable: 'Taruhan tidak tersedia untuk ronde ini',
betPlaced: 'Taruhan berhasil dikirim',

View File

@@ -154,12 +154,14 @@ export default {
tabs: {
profile: 'Profil',
financeRecords: 'Rekod Tambah Nilai / Pengeluaran',
walletRecords: 'Rekod Dompet',
message: 'Mesej',
},
profile: {
name: 'Nama',
tel: 'Telefon',
registeredAt: 'Tarikh daftar',
copyInviteLink: 'Salin pautan jemputan',
signature:
'Tandatangan saya unik seperti personaliti saya. Bahagian ini akan memaparkan ringkasan profil sebenar kemudian.',
},
@@ -189,6 +191,20 @@ export default {
previous: 'Sebelumnya',
next: 'Seterusnya',
},
walletRecords: {
amount: 'Jumlah',
balanceAfter: 'Selepas',
balanceBefore: 'Sebelum',
empty: 'Belum ada rekod dompet',
loadFailed: 'Gagal memuatkan rekod dompet. Sila cuba lagi kemudian.',
loading: 'Memuatkan rekod dompet...',
next: 'Seterusnya',
page: 'Halaman {{page}} / jumlah {{total}}',
previous: 'Sebelumnya',
remark: 'Catatan',
time: 'Masa',
type: 'Rekod Dompet',
},
},
withdrawTopup: {
applyWithdraw: 'Mohon Pengeluaran',
@@ -231,6 +247,9 @@ export default {
loginRequired: 'Sila log masuk sebelum memasuki permainan',
loginSuccess: 'Log masuk berjaya',
registerSuccess: 'Pendaftaran berjaya',
inviteLinkCopied: 'Pautan jemputan telah disalin',
inviteLinkCopyFailed:
'Gagal menyalin pautan jemputan. Sila salin secara manual.',
insufficientBalance: 'Baki tidak mencukupi. Sila laraskan taruhan.',
betUnavailable: 'Taruhan tidak tersedia untuk pusingan ini',
betPlaced: 'Taruhan berjaya dihantar',

View File

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

View File

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

View File

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

View File

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