refactor(game): 重构项目结构,优化链路, 移动端适配
- 移除 useGameBoardVm 数据层实施说明文档 - 移除核心玩法与前端规则摘要文档 - 移除游戏模块数据与界面分层第一阶段实施稿文档 - 清理与数据层重构相关的技术方案说明 - 删除关于 PC 和 Mobile 界面分离的设计规划 - 移除 view-model hooks 架构设计相关内容
This commit is contained in:
@@ -1,378 +0,0 @@
|
||||
import {
|
||||
API_SUCCESS_CODE,
|
||||
DEFAULT_LIST_PAGE_SIZE,
|
||||
FINANCE_API_ENDPOINTS,
|
||||
} from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { ApiResponse } from '@/type'
|
||||
|
||||
import type {
|
||||
DepositCreateRequestDto,
|
||||
DepositCreateResponseDto,
|
||||
DepositTierItem,
|
||||
DepositTierItemDto,
|
||||
DepositWithdrawConfig,
|
||||
DepositWithdrawConfigDto,
|
||||
FinanceCurrencyConfigDto,
|
||||
FinanceOrderItemDto,
|
||||
FinanceOrderList,
|
||||
FinanceOrderListDto,
|
||||
FinancePayChannelDto,
|
||||
FinanceRateConfigDto,
|
||||
FinanceWithdrawBankDto,
|
||||
FinanceWithdrawConfigDto,
|
||||
WalletRecordItemDto,
|
||||
WalletRecordList,
|
||||
WalletRecordListDto,
|
||||
WalletRecordType,
|
||||
WithdrawCreateRequestDto,
|
||||
WithdrawCreateResponseDto,
|
||||
} from './finance-types'
|
||||
|
||||
function unwrapFinanceEnvelope<T>(
|
||||
response: ApiResponse<T>,
|
||||
fallbackMessage = 'Finance request failed',
|
||||
) {
|
||||
if (response.code === API_SUCCESS_CODE) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
const responseMessage =
|
||||
typeof response.message === 'string' && response.message.length > 0
|
||||
? response.message
|
||||
: typeof response.msg === 'string' && response.msg.length > 0
|
||||
? response.msg
|
||||
: fallbackMessage
|
||||
|
||||
throw new ApiError({
|
||||
data: response,
|
||||
message: responseMessage,
|
||||
})
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: string | number | null | undefined) {
|
||||
const numericValue = Number(value)
|
||||
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
function normalizeCurrency(dto: FinanceCurrencyConfigDto) {
|
||||
return {
|
||||
code: dto.code,
|
||||
depositCoinsPerFiat: dto.deposit_coins_per_fiat,
|
||||
depositCoinsPerFiatValue: toFiniteNumber(dto.deposit_coins_per_fiat),
|
||||
label: dto.label,
|
||||
withdrawCoinsPerFiat: dto.withdraw_coins_per_fiat,
|
||||
withdrawCoinsPerFiatValue: toFiniteNumber(dto.withdraw_coins_per_fiat),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRate(dto: FinanceRateConfigDto) {
|
||||
return {
|
||||
currency: dto.currency,
|
||||
diamondsPerFiatUnit: dto.diamonds_per_fiat_unit,
|
||||
diamondsPerFiatUnitValue: toFiniteNumber(dto.diamonds_per_fiat_unit),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePayChannel(dto: FinancePayChannelDto) {
|
||||
return {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
sort: Number.isFinite(dto.sort) ? dto.sort : 0,
|
||||
status: dto.status,
|
||||
tierIds: Array.isArray(dto.tier_ids) ? dto.tier_ids : [],
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWithdrawBank(dto: FinanceWithdrawBankDto, index: number) {
|
||||
const code = dto.code ?? dto.id ?? dto.name ?? `bank-${index + 1}`
|
||||
const label = dto.label ?? dto.name ?? code
|
||||
|
||||
return {
|
||||
code,
|
||||
label,
|
||||
sort:
|
||||
typeof dto.sort === 'number' && Number.isFinite(dto.sort)
|
||||
? dto.sort
|
||||
: index,
|
||||
status: typeof dto.status === 'number' ? dto.status : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWithdrawConfig(dto: FinanceWithdrawConfigDto) {
|
||||
return {
|
||||
banks: (dto.banks ?? []).map(normalizeWithdrawBank),
|
||||
feeNote: dto.fee_note,
|
||||
minBank: dto.min_bank,
|
||||
minEwallet: dto.min_ewallet,
|
||||
processingNote: dto.processing_note,
|
||||
rateHint: dto.rate_hint,
|
||||
rateMode: dto.rate_mode,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDepositWithdrawConfig(
|
||||
dto: DepositWithdrawConfigDto,
|
||||
): DepositWithdrawConfig {
|
||||
return {
|
||||
currencies: (dto.currencies ?? []).map(normalizeCurrency),
|
||||
payChannels: (dto.pay_channels ?? []).map(normalizePayChannel),
|
||||
platformCoinLabel: dto.platform_coin_label,
|
||||
rates: (dto.rates ?? []).map(normalizeRate),
|
||||
withdraw: normalizeWithdrawConfig(dto.withdraw),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDepositTierItem(
|
||||
dto: DepositTierItemDto,
|
||||
index: number,
|
||||
): DepositTierItem {
|
||||
const id = String(dto.id ?? dto.tier_id ?? `tier-${index + 1}`)
|
||||
const amount = toFiniteNumber(dto.amount)
|
||||
const bonusAmount = toFiniteNumber(dto.bonus_amount ?? dto.bonus_coins)
|
||||
const payAmount = toFiniteNumber(dto.pay_amount ?? dto.amount)
|
||||
const totalAmount = toFiniteNumber(
|
||||
dto.total_amount ?? dto.coins ?? amount + bonusAmount,
|
||||
)
|
||||
const coins = totalAmount
|
||||
const currency =
|
||||
typeof dto.currency === 'string' && dto.currency.length > 0
|
||||
? dto.currency
|
||||
: null
|
||||
const payChannelCode =
|
||||
typeof dto.pay_channel_code === 'string' && dto.pay_channel_code.length > 0
|
||||
? dto.pay_channel_code
|
||||
: null
|
||||
const sort = toFiniteNumber(dto.sort ?? dto.pay_amount ?? dto.amount)
|
||||
const status = toFiniteNumber(dto.status ?? 1)
|
||||
const channels = (dto.channels ?? [])
|
||||
.map((channel, channelIndex) => ({
|
||||
code: channel.code ?? `channel-${channelIndex + 1}`,
|
||||
name: channel.name ?? channel.code ?? '--',
|
||||
sort: toFiniteNumber(channel.sort ?? channelIndex),
|
||||
}))
|
||||
.sort((left, right) => left.sort - right.sort)
|
||||
|
||||
return {
|
||||
amount,
|
||||
bonusAmount,
|
||||
channels,
|
||||
coins,
|
||||
currency,
|
||||
desc: dto.desc ?? '',
|
||||
id,
|
||||
name:
|
||||
typeof dto.name === 'string' && dto.name.length > 0
|
||||
? dto.name
|
||||
: `${amount}`,
|
||||
payAmount,
|
||||
payChannelCode,
|
||||
sort,
|
||||
status,
|
||||
tierKey:
|
||||
typeof dto.tier_key === 'string' && dto.tier_key.length > 0
|
||||
? dto.tier_key
|
||||
: null,
|
||||
totalAmount,
|
||||
title:
|
||||
typeof dto.title === 'string' && dto.title.length > 0
|
||||
? dto.title
|
||||
: typeof dto.name === 'string' && dto.name.length > 0
|
||||
? dto.name
|
||||
: `${amount}`,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFinanceOrderItem(dto: FinanceOrderItemDto) {
|
||||
return {
|
||||
amount: String(dto.amount ?? ''),
|
||||
bonusAmount: String(dto.bonus_amount ?? ''),
|
||||
orderNo: dto.order_no,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFinanceOrderList(dto: FinanceOrderListDto): FinanceOrderList {
|
||||
return {
|
||||
list: (dto.list ?? []).map(normalizeFinanceOrderItem),
|
||||
pagination: {
|
||||
page: dto.pagination?.page ?? 1,
|
||||
page_size: dto.pagination?.page_size ?? DEFAULT_LIST_PAGE_SIZE,
|
||||
total: dto.pagination?.total ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 ?? DEFAULT_LIST_PAGE_SIZE,
|
||||
total: dto.pagination?.total ?? dto.total ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDepositWithdrawConfig() {
|
||||
const response = await api.post<DepositWithdrawConfigDto>(
|
||||
FINANCE_API_ENDPOINTS.depositWithdrawConfig,
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<DepositWithdrawConfigDto>,
|
||||
'Failed to load deposit and withdrawal config',
|
||||
)
|
||||
|
||||
return normalizeDepositWithdrawConfig(dto)
|
||||
}
|
||||
|
||||
export async function getDepositTierList() {
|
||||
const response = await api.post<
|
||||
| DepositTierItemDto[]
|
||||
| { items?: DepositTierItemDto[]; list?: DepositTierItemDto[] },
|
||||
undefined
|
||||
>(FINANCE_API_ENDPOINTS.depositTierList)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<
|
||||
| DepositTierItemDto[]
|
||||
| { items?: DepositTierItemDto[]; list?: DepositTierItemDto[] }
|
||||
>,
|
||||
'Failed to load deposit tier list',
|
||||
)
|
||||
|
||||
const tierItems = Array.isArray(dto) ? dto : (dto?.list ?? dto?.items ?? [])
|
||||
|
||||
return tierItems
|
||||
.map(normalizeDepositTierItem)
|
||||
.filter((item) => item.status === 1)
|
||||
.sort((left, right) => left.sort - right.sort)
|
||||
}
|
||||
|
||||
export async function createDeposit(payload: DepositCreateRequestDto) {
|
||||
const response = await api.post<
|
||||
DepositCreateResponseDto,
|
||||
DepositCreateRequestDto
|
||||
>(FINANCE_API_ENDPOINTS.depositCreate, {
|
||||
json: payload,
|
||||
})
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<DepositCreateResponseDto>,
|
||||
'Failed to create deposit',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function getDepositOrderList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.get<FinanceOrderListDto>(
|
||||
FINANCE_API_ENDPOINTS.depositList,
|
||||
{
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<FinanceOrderListDto>,
|
||||
'Failed to load deposit order list',
|
||||
)
|
||||
|
||||
return normalizeFinanceOrderList(dto)
|
||||
}
|
||||
|
||||
export async function getWithdrawOrderList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.get<FinanceOrderListDto>(
|
||||
FINANCE_API_ENDPOINTS.withdrawList,
|
||||
{
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<FinanceOrderListDto>,
|
||||
'Failed to load withdraw order list',
|
||||
)
|
||||
|
||||
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 ?? DEFAULT_LIST_PAGE_SIZE),
|
||||
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,
|
||||
WithdrawCreateRequestDto
|
||||
>(FINANCE_API_ENDPOINTS.withdrawCreate, {
|
||||
json: payload,
|
||||
})
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<WithdrawCreateResponseDto>,
|
||||
'Failed to create withdraw',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
export interface FinanceCurrencyConfigDto {
|
||||
code: string
|
||||
deposit_coins_per_fiat: string
|
||||
label: string
|
||||
withdraw_coins_per_fiat: string
|
||||
}
|
||||
|
||||
export interface FinanceRateConfigDto {
|
||||
currency: string
|
||||
diamonds_per_fiat_unit: string
|
||||
}
|
||||
|
||||
export interface FinancePayChannelDto {
|
||||
code: string
|
||||
name: string
|
||||
sort: number
|
||||
status: number
|
||||
tier_ids: number[]
|
||||
}
|
||||
|
||||
export interface FinanceWithdrawBankDto {
|
||||
code?: string
|
||||
id?: string
|
||||
label?: string
|
||||
name?: string
|
||||
sort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface FinanceWithdrawConfigDto {
|
||||
banks: FinanceWithdrawBankDto[]
|
||||
fee_note: string
|
||||
min_bank: string
|
||||
min_ewallet: string
|
||||
processing_note: string
|
||||
rate_hint: string
|
||||
rate_mode: 'fixed' | 'live' | (string & {})
|
||||
}
|
||||
|
||||
export interface DepositWithdrawConfigDto {
|
||||
currencies: FinanceCurrencyConfigDto[]
|
||||
pay_channels: FinancePayChannelDto[]
|
||||
platform_coin_label: string
|
||||
rates: FinanceRateConfigDto[]
|
||||
withdraw: FinanceWithdrawConfigDto
|
||||
}
|
||||
|
||||
export interface DepositTierItemDto {
|
||||
amount?: number | string
|
||||
bonus_amount?: number | string
|
||||
bonus_coins?: number | string
|
||||
channels?: Array<{
|
||||
code?: string
|
||||
name?: string
|
||||
sort?: number | string
|
||||
}>
|
||||
coins?: number | string
|
||||
currency?: string
|
||||
desc?: string
|
||||
id?: number | string
|
||||
name?: string
|
||||
pay_amount?: number | string
|
||||
pay_channel_code?: string
|
||||
sort?: number | string
|
||||
status?: number | string
|
||||
tier_key?: string
|
||||
tier_id?: number | string
|
||||
total_amount?: number | string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface FinanceCurrencyConfig {
|
||||
code: string
|
||||
depositCoinsPerFiat: string
|
||||
depositCoinsPerFiatValue: number
|
||||
label: string
|
||||
withdrawCoinsPerFiat: string
|
||||
withdrawCoinsPerFiatValue: number
|
||||
}
|
||||
|
||||
export interface FinanceRateConfig {
|
||||
currency: string
|
||||
diamondsPerFiatUnit: string
|
||||
diamondsPerFiatUnitValue: number
|
||||
}
|
||||
|
||||
export interface FinancePayChannel {
|
||||
code: string
|
||||
name: string
|
||||
sort: number
|
||||
status: number
|
||||
tierIds: number[]
|
||||
}
|
||||
|
||||
export interface FinanceWithdrawBank {
|
||||
code: string
|
||||
label: string
|
||||
sort: number
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface FinanceWithdrawConfig {
|
||||
banks: FinanceWithdrawBank[]
|
||||
feeNote: string
|
||||
minBank: string
|
||||
minEwallet: string
|
||||
processingNote: string
|
||||
rateHint: string
|
||||
rateMode: FinanceWithdrawConfigDto['rate_mode']
|
||||
}
|
||||
|
||||
export interface DepositWithdrawConfig {
|
||||
currencies: FinanceCurrencyConfig[]
|
||||
payChannels: FinancePayChannel[]
|
||||
platformCoinLabel: string
|
||||
rates: FinanceRateConfig[]
|
||||
withdraw: FinanceWithdrawConfig
|
||||
}
|
||||
|
||||
export interface DepositTierItem {
|
||||
amount: number
|
||||
bonusAmount: number
|
||||
channels: Array<{
|
||||
code: string
|
||||
name: string
|
||||
sort: number
|
||||
}>
|
||||
coins: number
|
||||
currency: string | null
|
||||
desc: string
|
||||
id: string
|
||||
name: string
|
||||
payAmount: number
|
||||
payChannelCode: string | null
|
||||
sort: number
|
||||
status: number
|
||||
tierKey: string | null
|
||||
totalAmount: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface DepositCreateRequestDto {
|
||||
channel_code: string
|
||||
idempotency_key: string
|
||||
tier_id: string
|
||||
}
|
||||
|
||||
export interface DepositCreateResponseDto {
|
||||
amount: number
|
||||
bonus_amount: number
|
||||
create_time: number
|
||||
expire_at: number
|
||||
expire_seconds: number
|
||||
order_no: string
|
||||
paid: boolean
|
||||
pay_channel: string
|
||||
pay_time: number
|
||||
pay_url: string
|
||||
reject_reason: string | null
|
||||
review_required: boolean
|
||||
status: 'pending' | (string & {})
|
||||
total_amount: number
|
||||
}
|
||||
|
||||
export interface FinanceOrderItemDto {
|
||||
amount: number | string
|
||||
bonus_amount: number | string
|
||||
order_no: string
|
||||
}
|
||||
|
||||
export interface FinanceOrderPaginationDto {
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface FinanceOrderListDto {
|
||||
list: FinanceOrderItemDto[]
|
||||
pagination: FinanceOrderPaginationDto
|
||||
}
|
||||
|
||||
export interface FinanceOrderItem {
|
||||
amount: string
|
||||
bonusAmount: string
|
||||
orderNo: string
|
||||
}
|
||||
|
||||
export interface FinanceOrderList {
|
||||
list: FinanceOrderItem[]
|
||||
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
|
||||
idempotency_key: string
|
||||
receive_account: string
|
||||
receiver_email: string
|
||||
receiver_mobile: string
|
||||
receiver_name: string
|
||||
receive_type: string
|
||||
withdraw_coin: number
|
||||
}
|
||||
|
||||
export interface WithdrawCreateResponseDto {
|
||||
actual_arrival_coin: number
|
||||
fee_coin: number
|
||||
order_no: string
|
||||
risk_review_required: boolean
|
||||
status: 'pending_review' | (string & {})
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
import {
|
||||
API_SUCCESS_CODE,
|
||||
DEFAULT_LIST_PAGE_SIZE,
|
||||
GAME_API_ENDPOINTS,
|
||||
} from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { ApiResponse } from '@/type'
|
||||
|
||||
import type {
|
||||
AnnouncementItem,
|
||||
AnnouncementState,
|
||||
BetSelection,
|
||||
ConnectionState,
|
||||
DashboardState,
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
HistoryEntry,
|
||||
RoundPhase,
|
||||
RoundSnapshot,
|
||||
TrendEntry,
|
||||
} from '../shared'
|
||||
import {
|
||||
createEmptyGameBootstrapSnapshot,
|
||||
DEFAULT_GAME_CHIP_COLORS,
|
||||
deriveTrendEntries,
|
||||
GAME_GRID_COLUMNS,
|
||||
GAME_MAX_SELECTION_CELLS,
|
||||
} from '../shared'
|
||||
import type {
|
||||
AnnouncementStateDto,
|
||||
BetSelectionDto,
|
||||
ChipDto,
|
||||
ConnectionStateDto,
|
||||
DashboardStateDto,
|
||||
GameAnnouncementsDto,
|
||||
GameBetOrdersDto,
|
||||
GameBootstrapDto,
|
||||
GameCellDto,
|
||||
GameLobbyInitDto,
|
||||
GameLobbyPeriodDto,
|
||||
GamePeriodTickDto,
|
||||
GamePlaceBetDto,
|
||||
GamePlaceBetRequestDto,
|
||||
GameRoundFeedDto,
|
||||
HistoryEntryDto,
|
||||
NoticeConfirmDto,
|
||||
NoticeDetailDto,
|
||||
NoticeListDto,
|
||||
RoundSnapshotDto,
|
||||
TrendEntryDto,
|
||||
} from './types'
|
||||
|
||||
function unwrapGameEnvelope<T>(
|
||||
response: ApiResponse<T>,
|
||||
fallbackMessage = 'Game request failed',
|
||||
) {
|
||||
if (response.code === API_SUCCESS_CODE) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
throw new ApiError({
|
||||
data: response,
|
||||
message:
|
||||
typeof response.msg === 'string' && response.msg.length > 0
|
||||
? response.msg
|
||||
: typeof response.message === 'string' && response.message.length > 0
|
||||
? response.message
|
||||
: fallbackMessage,
|
||||
})
|
||||
}
|
||||
|
||||
function assertLobbyInitDto(
|
||||
dto: GameLobbyInitDto,
|
||||
): asserts dto is GameLobbyInitDto {
|
||||
if (
|
||||
!Number.isFinite(dto.server_time) ||
|
||||
!Array.isArray(dto.dictionary) ||
|
||||
!dto.bet_config ||
|
||||
!Number.isFinite(dto.bet_config.default_bet_chip_id)
|
||||
) {
|
||||
throw new ApiError({
|
||||
data: dto,
|
||||
message: 'Invalid game lobby init payload',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface GameLobbyInitResult {
|
||||
runtimeEnabled: boolean
|
||||
serverTime: number
|
||||
snapshot: GameBootstrapSnapshot
|
||||
userSnapshot: GameLobbyInitDto['user_snapshot']
|
||||
}
|
||||
|
||||
function normalizeGameCell(dto: GameCellDto) {
|
||||
return dto satisfies GameCell
|
||||
}
|
||||
|
||||
function normalizeChip(dto: ChipDto) {
|
||||
return {
|
||||
amount: dto.amount,
|
||||
color: dto.color,
|
||||
id: dto.id,
|
||||
isDefault: dto.is_default,
|
||||
label: dto.label,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBetSelection(dto: BetSelectionDto) {
|
||||
return {
|
||||
amount: dto.amount,
|
||||
cellId: dto.cell_id,
|
||||
chipId: dto.chip_id,
|
||||
id: dto.id,
|
||||
placedAt: dto.placed_at,
|
||||
source: dto.source,
|
||||
} satisfies BetSelection
|
||||
}
|
||||
|
||||
function normalizeRoundSnapshot(dto: RoundSnapshotDto) {
|
||||
return {
|
||||
bettingClosesAt: dto.betting_closes_at,
|
||||
id: dto.id,
|
||||
phase: dto.phase,
|
||||
revealingAt: dto.revealing_at,
|
||||
settledAt: dto.settled_at,
|
||||
startedAt: dto.started_at,
|
||||
winningCellId: dto.winning_cell_id,
|
||||
} satisfies RoundSnapshot
|
||||
}
|
||||
|
||||
function normalizeHistoryEntry(dto: HistoryEntryDto) {
|
||||
return {
|
||||
payoutMultiplier: dto.payout_multiplier,
|
||||
roundId: dto.round_id,
|
||||
settledAt: dto.settled_at,
|
||||
totalPoolAmount: dto.total_pool_amount,
|
||||
winningCellId: dto.winning_cell_id,
|
||||
} satisfies HistoryEntry
|
||||
}
|
||||
|
||||
function normalizeTrendEntry(dto: TrendEntryDto) {
|
||||
return {
|
||||
cellId: dto.cell_id,
|
||||
currentStreak: dto.current_streak,
|
||||
direction: dto.direction,
|
||||
hitCount: dto.hit_count,
|
||||
lastHitRoundId: dto.last_hit_round_id,
|
||||
missCount: dto.miss_count,
|
||||
} satisfies TrendEntry
|
||||
}
|
||||
|
||||
function normalizeAnnouncementState(dto: AnnouncementStateDto) {
|
||||
return {
|
||||
activeAnnouncementId: dto.active_announcement_id,
|
||||
items: dto.items.map(
|
||||
(item) =>
|
||||
({
|
||||
createdAt: item.created_at,
|
||||
expiresAt: item.expires_at,
|
||||
id: item.id,
|
||||
isPinned: item.is_pinned,
|
||||
isRead: item.is_read,
|
||||
message: item.message,
|
||||
title: item.title,
|
||||
tone: item.tone,
|
||||
}) satisfies AnnouncementItem,
|
||||
),
|
||||
lastUpdatedAt: dto.last_updated_at,
|
||||
} satisfies AnnouncementState
|
||||
}
|
||||
|
||||
function normalizeDashboardState(dto: DashboardStateDto) {
|
||||
return {
|
||||
countdownMs: dto.countdown_ms,
|
||||
featuredCellId: dto.featured_cell_id,
|
||||
onlinePlayers: dto.online_players,
|
||||
tableLimitMax: dto.table_limit_max,
|
||||
tableLimitMin: dto.table_limit_min,
|
||||
totalPoolAmount: dto.total_pool_amount,
|
||||
updatedAt: dto.updated_at,
|
||||
} satisfies DashboardState
|
||||
}
|
||||
|
||||
function normalizeConnectionState(dto: ConnectionStateDto) {
|
||||
return {
|
||||
connectedAt: dto.connected_at,
|
||||
lastError: dto.last_error,
|
||||
lastMessageAt: dto.last_message_at,
|
||||
latencyMs: dto.latency_ms,
|
||||
reconnectAttempt: dto.reconnect_attempt,
|
||||
status: dto.status,
|
||||
transport: dto.transport,
|
||||
} satisfies ConnectionState
|
||||
}
|
||||
|
||||
function toIsoFromUnixSeconds(seconds: number) {
|
||||
const timestamp = Number(seconds)
|
||||
const date = new Date(timestamp * 1000)
|
||||
|
||||
if (!Number.isFinite(timestamp) || Number.isNaN(date.valueOf())) {
|
||||
throw new ApiError({
|
||||
data: { seconds },
|
||||
message: 'Invalid unix timestamp',
|
||||
})
|
||||
}
|
||||
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
export function normalizeLobbyRoundPhase(
|
||||
status: GameLobbyPeriodDto['status'],
|
||||
runtimeEnabled: boolean,
|
||||
): RoundPhase {
|
||||
if (!runtimeEnabled && status === 'betting') {
|
||||
return 'locked'
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'betting':
|
||||
return 'betting'
|
||||
case 'locked':
|
||||
return 'locked'
|
||||
case 'settling':
|
||||
return 'revealing'
|
||||
case 'payouting':
|
||||
case 'finished':
|
||||
case 'void':
|
||||
return 'settled'
|
||||
default:
|
||||
return 'waiting'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLobbyChips(
|
||||
chips: Record<string, string>,
|
||||
defaultBetChipId: number,
|
||||
) {
|
||||
return Object.entries(chips)
|
||||
.sort(([leftId], [rightId]) => Number(leftId) - Number(rightId))
|
||||
.map(([chipId, chipAmount], index) => {
|
||||
const amount = Number(chipAmount)
|
||||
|
||||
return {
|
||||
amount: Number.isFinite(amount) ? amount : 0,
|
||||
color:
|
||||
DEFAULT_GAME_CHIP_COLORS[index % DEFAULT_GAME_CHIP_COLORS.length] ??
|
||||
DEFAULT_GAME_CHIP_COLORS[0],
|
||||
id: `chip-${chipId}`,
|
||||
isDefault: Number(chipId) === defaultBetChipId,
|
||||
label: chipAmount,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeLobbyCells(dictionary: GameLobbyInitDto['dictionary']) {
|
||||
return [...dictionary]
|
||||
.sort((left, right) => left.number - right.number)
|
||||
.map(
|
||||
(item, index) =>
|
||||
({
|
||||
column: (index % GAME_GRID_COLUMNS) + 1,
|
||||
id: item.number,
|
||||
label: item.name,
|
||||
odds: 36,
|
||||
row: Math.floor(index / GAME_GRID_COLUMNS) + 1,
|
||||
}) satisfies GameCell,
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizePeriodTickRound(
|
||||
period: GamePeriodTickDto,
|
||||
previousRound?: Pick<RoundSnapshot, 'id' | 'startedAt'> | null,
|
||||
) {
|
||||
const startedAt =
|
||||
previousRound?.id === period.period_no
|
||||
? previousRound.startedAt
|
||||
: toIsoFromUnixSeconds(period.server_time)
|
||||
const countdownSeconds = Math.max(0, period.countdown)
|
||||
const betCloseSeconds = Math.max(0, period.bet_close_in)
|
||||
const phase = normalizeLobbyRoundPhase(period.status, period.runtime_enabled)
|
||||
const nextPhaseAt = toIsoFromUnixSeconds(
|
||||
period.server_time + countdownSeconds,
|
||||
)
|
||||
|
||||
return {
|
||||
bettingClosesAt: toIsoFromUnixSeconds(period.server_time + betCloseSeconds),
|
||||
id: period.period_no,
|
||||
phase,
|
||||
revealingAt: nextPhaseAt,
|
||||
settledAt: nextPhaseAt,
|
||||
startedAt,
|
||||
winningCellId:
|
||||
typeof period.result_number === 'number' ? period.result_number : null,
|
||||
} satisfies RoundSnapshot
|
||||
}
|
||||
|
||||
export function normalizeGameLobbyInit(dto: GameLobbyInitDto) {
|
||||
const baseIso = toIsoFromUnixSeconds(dto.server_time)
|
||||
const template = createEmptyGameBootstrapSnapshot(baseIso)
|
||||
const cells = normalizeLobbyCells(dto.dictionary)
|
||||
const chips = normalizeLobbyChips(
|
||||
dto.bet_config.chips,
|
||||
dto.bet_config.default_bet_chip_id,
|
||||
)
|
||||
const trends = deriveTrendEntries([])
|
||||
|
||||
return {
|
||||
announcements: {
|
||||
activeAnnouncementId: null,
|
||||
items: [],
|
||||
lastUpdatedAt: null,
|
||||
} satisfies AnnouncementState,
|
||||
cells,
|
||||
chips: chips.length > 0 ? chips : template.chips,
|
||||
connection: {
|
||||
...template.connection,
|
||||
transport: 'polling',
|
||||
},
|
||||
dashboard: {
|
||||
countdownMs: 0,
|
||||
featuredCellId: null,
|
||||
onlinePlayers: 0,
|
||||
tableLimitMax: Number(dto.bet_config.max_bet_per_number) || 0,
|
||||
tableLimitMin: Number(dto.bet_config.min_bet_per_number) || 0,
|
||||
totalPoolAmount: 0,
|
||||
updatedAt: baseIso,
|
||||
} satisfies DashboardState,
|
||||
history: [],
|
||||
maxSelectionCount:
|
||||
Number.isFinite(dto.bet_config.pick_max_number_count) &&
|
||||
dto.bet_config.pick_max_number_count > 0
|
||||
? Math.min(36, Math.floor(dto.bet_config.pick_max_number_count))
|
||||
: GAME_MAX_SELECTION_CELLS,
|
||||
round: template.round,
|
||||
selections: [],
|
||||
trends,
|
||||
} satisfies GameBootstrapSnapshot
|
||||
}
|
||||
|
||||
export function normalizeGameBootstrap(dto: GameBootstrapDto) {
|
||||
return {
|
||||
announcements: normalizeAnnouncementState(dto.announcements),
|
||||
cells: dto.cells.map(normalizeGameCell),
|
||||
chips: dto.chips.map(normalizeChip),
|
||||
connection: normalizeConnectionState(dto.connection),
|
||||
dashboard: normalizeDashboardState(dto.dashboard),
|
||||
history: dto.history.map(normalizeHistoryEntry),
|
||||
maxSelectionCount:
|
||||
typeof dto.max_selection_count === 'number' &&
|
||||
Number.isFinite(dto.max_selection_count) &&
|
||||
dto.max_selection_count > 0
|
||||
? Math.min(36, Math.floor(dto.max_selection_count))
|
||||
: GAME_MAX_SELECTION_CELLS,
|
||||
round: normalizeRoundSnapshot(dto.round),
|
||||
selections: dto.selections.map(normalizeBetSelection),
|
||||
trends: dto.trends.map(normalizeTrendEntry),
|
||||
} satisfies GameBootstrapSnapshot
|
||||
}
|
||||
|
||||
export function normalizeGameRoundFeed(dto: GameRoundFeedDto) {
|
||||
return {
|
||||
history: dto.history.map(normalizeHistoryEntry),
|
||||
round: normalizeRoundSnapshot(dto.round),
|
||||
selections: dto.selections.map(normalizeBetSelection),
|
||||
trends: dto.trends.map(normalizeTrendEntry),
|
||||
} satisfies Pick<
|
||||
GameBootstrapSnapshot,
|
||||
'history' | 'round' | 'selections' | 'trends'
|
||||
>
|
||||
}
|
||||
|
||||
export async function getGameBootstrap() {
|
||||
const response = await api.get<GameBootstrapDto>(GAME_API_ENDPOINTS.bootstrap)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GameBootstrapDto>,
|
||||
'Failed to load game bootstrap',
|
||||
)
|
||||
|
||||
return normalizeGameBootstrap(dto)
|
||||
}
|
||||
|
||||
export async function getGameRoundFeed() {
|
||||
const response = await api.get<GameRoundFeedDto>(GAME_API_ENDPOINTS.roundFeed)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GameRoundFeedDto>,
|
||||
'Failed to load game round feed',
|
||||
)
|
||||
|
||||
return normalizeGameRoundFeed(dto)
|
||||
}
|
||||
|
||||
export async function getGameAnnouncements() {
|
||||
const response = await api.get<GameAnnouncementsDto>(
|
||||
GAME_API_ENDPOINTS.announcements,
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GameAnnouncementsDto>,
|
||||
'Failed to load game announcements',
|
||||
)
|
||||
|
||||
return normalizeAnnouncementState(dto.announcements)
|
||||
}
|
||||
|
||||
export async function getGameLobbyInit() {
|
||||
const response = await api.post<GameLobbyInitDto>(
|
||||
GAME_API_ENDPOINTS.lobbyInit,
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GameLobbyInitDto>,
|
||||
'Failed to load game lobby init',
|
||||
)
|
||||
assertLobbyInitDto(dto)
|
||||
|
||||
return {
|
||||
runtimeEnabled: dto.runtime_enabled,
|
||||
serverTime: dto.server_time,
|
||||
snapshot: normalizeGameLobbyInit(dto),
|
||||
userSnapshot: dto.user_snapshot,
|
||||
} satisfies GameLobbyInitResult
|
||||
}
|
||||
|
||||
export async function getNoticeList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.get<NoticeListDto>(GAME_API_ENDPOINTS.noticeList, {
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
|
||||
},
|
||||
})
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<NoticeListDto>,
|
||||
'Failed to load notice list',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function getNoticeDetail(id: number) {
|
||||
const response = await api.get<NoticeDetailDto>(
|
||||
GAME_API_ENDPOINTS.noticeDetail,
|
||||
{
|
||||
searchParams: {
|
||||
notice_id: String(id),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<NoticeDetailDto>,
|
||||
'Failed to load notice detail',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function confirmNotice(noticeId: number) {
|
||||
const response = await api.get<NoticeConfirmDto>(
|
||||
GAME_API_ENDPOINTS.noticeConfirm,
|
||||
{
|
||||
searchParams: {
|
||||
notice_id: String(noticeId),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<NoticeConfirmDto>,
|
||||
'Failed to confirm notice',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function getGameBetMyOrders(params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.post<GameBetOrdersDto>(
|
||||
GAME_API_ENDPOINTS.betMyOrders,
|
||||
{
|
||||
json: {
|
||||
page: params.page ?? 1,
|
||||
page_size: params.pageSize ?? DEFAULT_LIST_PAGE_SIZE,
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GameBetOrdersDto>,
|
||||
'Failed to load bet orders',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function placeGameBet(payload: GamePlaceBetRequestDto) {
|
||||
const response = await api.post<GamePlaceBetDto>(
|
||||
GAME_API_ENDPOINTS.placeBet,
|
||||
{
|
||||
json: payload,
|
||||
},
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GamePlaceBetDto>,
|
||||
'Failed to place game bet',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './finance-api'
|
||||
export * from './finance-types'
|
||||
export * from './game-api'
|
||||
export * from './types'
|
||||
@@ -1,47 +0,0 @@
|
||||
import { API_SUCCESS_CODE, GAME_API_ENDPOINTS } from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { ApiResponse } from '@/type'
|
||||
|
||||
export interface GamePeriodHistoryItemDto {
|
||||
open_time: number
|
||||
period_no: string
|
||||
result_number: number
|
||||
}
|
||||
|
||||
interface GamePeriodHistoryDto {
|
||||
list: GamePeriodHistoryItemDto[]
|
||||
}
|
||||
|
||||
function unwrapPeriodHistoryEnvelope(
|
||||
response: ApiResponse<GamePeriodHistoryDto>,
|
||||
) {
|
||||
if (response.code === API_SUCCESS_CODE) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
throw new ApiError({
|
||||
data: response,
|
||||
message:
|
||||
typeof response.msg === 'string' && response.msg.length > 0
|
||||
? response.msg
|
||||
: typeof response.message === 'string' && response.message.length > 0
|
||||
? response.message
|
||||
: 'Failed to load period history',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getGamePeriodHistory(params: { limit?: number } = {}) {
|
||||
const response = await api.get<GamePeriodHistoryDto>(
|
||||
GAME_API_ENDPOINTS.periodHistory,
|
||||
{
|
||||
searchParams: {
|
||||
limit: String(params.limit ?? 30),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return unwrapPeriodHistoryEnvelope(
|
||||
response as ApiResponse<GamePeriodHistoryDto>,
|
||||
)
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import type {
|
||||
AnnouncementState,
|
||||
BetSelection,
|
||||
Chip,
|
||||
ConnectionState,
|
||||
DashboardState,
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
HistoryEntry,
|
||||
RoundSnapshot,
|
||||
TrendEntry,
|
||||
} from '../shared'
|
||||
|
||||
export interface GameCellDto {
|
||||
column: number
|
||||
id: number
|
||||
label: string
|
||||
odds: number
|
||||
row: number
|
||||
}
|
||||
|
||||
export interface ChipDto {
|
||||
amount: number
|
||||
color: string
|
||||
id: string
|
||||
is_default?: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface BetSelectionDto {
|
||||
amount: number
|
||||
cell_id: number
|
||||
chip_id: string
|
||||
id: string
|
||||
placed_at: string
|
||||
source: BetSelection['source']
|
||||
}
|
||||
|
||||
export interface RoundSnapshotDto {
|
||||
betting_closes_at: string
|
||||
id: string
|
||||
phase: RoundSnapshot['phase']
|
||||
revealing_at: string
|
||||
settled_at: string | null
|
||||
started_at: string
|
||||
winning_cell_id: number | null
|
||||
}
|
||||
|
||||
export interface HistoryEntryDto {
|
||||
payout_multiplier: number
|
||||
round_id: string
|
||||
settled_at: string
|
||||
total_pool_amount: number
|
||||
winning_cell_id: number
|
||||
}
|
||||
|
||||
export interface TrendEntryDto {
|
||||
cell_id: number
|
||||
current_streak: number
|
||||
direction: TrendEntry['direction']
|
||||
hit_count: number
|
||||
last_hit_round_id: string | null
|
||||
miss_count: number
|
||||
}
|
||||
|
||||
export interface AnnouncementItemDto {
|
||||
created_at: string
|
||||
expires_at: string | null
|
||||
id: string
|
||||
is_pinned?: boolean
|
||||
is_read?: boolean
|
||||
message: string
|
||||
title: string
|
||||
tone: 'info' | 'success' | 'warning' | 'critical'
|
||||
}
|
||||
|
||||
export interface AnnouncementStateDto {
|
||||
active_announcement_id: string | null
|
||||
items: AnnouncementItemDto[]
|
||||
last_updated_at: string | null
|
||||
}
|
||||
|
||||
export interface DashboardStateDto {
|
||||
countdown_ms: number
|
||||
featured_cell_id: number | null
|
||||
online_players: number
|
||||
table_limit_max: number
|
||||
table_limit_min: number
|
||||
total_pool_amount: number
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface ConnectionStateDto {
|
||||
connected_at: string | null
|
||||
last_error: string | null
|
||||
last_message_at: string | null
|
||||
latency_ms: number | null
|
||||
reconnect_attempt: number
|
||||
status: ConnectionState['status']
|
||||
transport: ConnectionState['transport']
|
||||
}
|
||||
|
||||
export interface GameBootstrapDto {
|
||||
announcements: AnnouncementStateDto
|
||||
cells: GameCellDto[]
|
||||
chips: ChipDto[]
|
||||
connection: ConnectionStateDto
|
||||
dashboard: DashboardStateDto
|
||||
history: HistoryEntryDto[]
|
||||
max_selection_count?: number
|
||||
round: RoundSnapshotDto
|
||||
selections: BetSelectionDto[]
|
||||
trends: TrendEntryDto[]
|
||||
}
|
||||
|
||||
export interface GameRoundFeedDto {
|
||||
history: HistoryEntryDto[]
|
||||
round: RoundSnapshotDto
|
||||
selections: BetSelectionDto[]
|
||||
trends: TrendEntryDto[]
|
||||
}
|
||||
|
||||
export interface GameAnnouncementsDto {
|
||||
announcements: AnnouncementStateDto
|
||||
}
|
||||
|
||||
export interface NoticeListItemDto {
|
||||
content?: string
|
||||
is_read: boolean
|
||||
must_confirm?: boolean
|
||||
notice_id: number
|
||||
notice_type: 'silent' | 'popout' | (string & {})
|
||||
publish_time: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface NoticeListDto {
|
||||
list: NoticeListItemDto[]
|
||||
}
|
||||
|
||||
export interface NoticeDetailDto {
|
||||
content: string
|
||||
must_confirm: boolean
|
||||
notice_id: number
|
||||
notice_type: 'silent' | 'popout' | (string & {})
|
||||
publish_time: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface NoticeConfirmDto {
|
||||
confirm_time: number
|
||||
confirmed: boolean
|
||||
notice_id: number
|
||||
}
|
||||
|
||||
export type GamePeriodStatus =
|
||||
| 'betting'
|
||||
| 'locked'
|
||||
| 'settling'
|
||||
| 'payouting'
|
||||
| 'finished'
|
||||
| 'void'
|
||||
| (string & {})
|
||||
|
||||
export interface GameLobbyPeriodDto {
|
||||
countdown: number
|
||||
lock_at: number
|
||||
open_at: number
|
||||
period_no: string
|
||||
status: GamePeriodStatus
|
||||
}
|
||||
|
||||
export interface GameLobbyBetConfigDto {
|
||||
chips: Record<string, string>
|
||||
default_bet_chip_id: number
|
||||
max_bet_per_number: string
|
||||
min_bet_per_number: string
|
||||
pick_max_number_count: number
|
||||
}
|
||||
|
||||
export interface GameLobbyDictionaryItemDto {
|
||||
category: string
|
||||
icon: string
|
||||
name: string
|
||||
number: number
|
||||
}
|
||||
|
||||
export interface GameLobbyUserSnapshotDto {
|
||||
coin: string
|
||||
current_streak: number
|
||||
is_jackpot?: boolean
|
||||
odds_factor?: number
|
||||
streak_level?: number
|
||||
}
|
||||
|
||||
export interface GameLobbyInitDto {
|
||||
bet_config: GameLobbyBetConfigDto
|
||||
dictionary: GameLobbyDictionaryItemDto[]
|
||||
period?: GameLobbyPeriodDto | null
|
||||
runtime_enabled: boolean
|
||||
server_time: number
|
||||
user_snapshot: GameLobbyUserSnapshotDto
|
||||
}
|
||||
|
||||
export interface GamePeriodTickDto {
|
||||
bet_close_in: number
|
||||
countdown: number
|
||||
period_id: number | null
|
||||
period_no: string
|
||||
result_number: number | null
|
||||
runtime_enabled: boolean
|
||||
server_time: number
|
||||
status: GamePeriodStatus
|
||||
}
|
||||
|
||||
export interface JackpotHitItemDto {
|
||||
nickname: string
|
||||
period_no: string
|
||||
result_number: number
|
||||
total_win: string
|
||||
}
|
||||
|
||||
export interface JackpotHitEventDataDto {
|
||||
hits: JackpotHitItemDto[]
|
||||
period_id: number | null
|
||||
period_no: string
|
||||
result_number: number | null
|
||||
server_time: number
|
||||
}
|
||||
|
||||
export interface JackpotHitEventDto {
|
||||
data: JackpotHitEventDataDto
|
||||
event: 'jackpot.hit'
|
||||
server_time: number
|
||||
topic?: 'jackpot.hit'
|
||||
}
|
||||
|
||||
export interface BetWinItemDto {
|
||||
bet_id: number
|
||||
win_amount: string
|
||||
}
|
||||
|
||||
export interface BetWinEventDataDto {
|
||||
balance_after?: string
|
||||
bets: BetWinItemDto[]
|
||||
current_streak?: number
|
||||
is_jackpot: boolean
|
||||
is_win: boolean
|
||||
odds_factor?: number
|
||||
payout_pending_review: boolean
|
||||
period_id?: number
|
||||
period_no: string
|
||||
result_number: number | null
|
||||
server_time?: number
|
||||
streak_level?: number
|
||||
total_win: string
|
||||
user_id?: number
|
||||
}
|
||||
|
||||
export interface BetWinEventDto {
|
||||
data: BetWinEventDataDto
|
||||
event: 'bet.win'
|
||||
server_time: number
|
||||
topic?: 'bet.win'
|
||||
}
|
||||
|
||||
export interface GameBetOrderDto {
|
||||
bet_amount: string
|
||||
create_time: number
|
||||
numbers: number[]
|
||||
order_no: string
|
||||
period_no: string
|
||||
result_number: number | null
|
||||
status: string
|
||||
total_amount: string
|
||||
win_amount: string
|
||||
}
|
||||
|
||||
export interface GameBetOrdersPaginationDto {
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface GameBetOrdersDto {
|
||||
list: GameBetOrderDto[]
|
||||
pagination: GameBetOrdersPaginationDto
|
||||
}
|
||||
|
||||
export interface GamePlaceBetRequestDto {
|
||||
bet_amount?: string
|
||||
bet_id: number
|
||||
idempotency_key: string
|
||||
numbers: string
|
||||
period_no: string
|
||||
single_bet_amount?: string
|
||||
}
|
||||
|
||||
export interface GamePlaceBetDto {
|
||||
balance_after: string
|
||||
current_streak: number
|
||||
locked_balance?: string
|
||||
numbers_count: number
|
||||
order_no: string
|
||||
period_no: string
|
||||
status: 'accepted' | 'rejected' | (string & {})
|
||||
}
|
||||
|
||||
export type {
|
||||
AnnouncementState,
|
||||
Chip,
|
||||
DashboardState,
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
HistoryEntry,
|
||||
}
|
||||
@@ -1,5 +1,2 @@
|
||||
export {
|
||||
AUDIO_ASSET_DEFINITIONS,
|
||||
type AudioAssetDefinition,
|
||||
type AudioAssetId,
|
||||
} from '@/constants/game'
|
||||
export { AUDIO_ASSET_DEFINITIONS } from '@/constants/game'
|
||||
export type { AudioAssetDefinition, AudioAssetId } from '@/type'
|
||||
|
||||
@@ -17,7 +17,6 @@ import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image'
|
||||
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
|
||||
import {
|
||||
type BetSelection,
|
||||
FLOWER_IMAGE_BY_ID,
|
||||
groupSelectionsByCell,
|
||||
} from '@/features/game/shared'
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
} from '@/store/game'
|
||||
import type { BetSelection } from '@/type'
|
||||
|
||||
const REWARD_OVERLAY_FADE_OUT_MS = 300
|
||||
const REWARD_CHILDREN_FADE_IN_MS = 2_000
|
||||
|
||||
@@ -7,8 +7,8 @@ import diamondIcon from '@/assets/system/diamond.webp'
|
||||
import { SmartImage } from '@/components/smart-image'
|
||||
import { DesktopAnimalOverlay } from '@/features/game/components/desktop/desktop-animal-overlay.tsx'
|
||||
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
|
||||
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
|
||||
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
|
||||
import { useAnimalVm } from '@/hooks/use-animal-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore } from '@/store/game'
|
||||
|
||||
@@ -16,7 +16,7 @@ import diamond from '@/assets/system/diamond.webp'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { ACTION_OPTIONS } from '@/constants'
|
||||
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
|
||||
import { useGameControlVm } from '@/hooks/use-game-control-vm.ts'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import historyBg from '@/assets/system/history-bg.png'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts'
|
||||
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
|
||||
import { useGameHistoryVm } from '@/hooks/use-game-history-vm.ts'
|
||||
|
||||
function HistoryRewardNumber({
|
||||
className,
|
||||
|
||||
@@ -16,10 +16,7 @@ import chatImage from '@/assets/system/chat.webp'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import logo from '@/assets/system/logo.webp'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import {
|
||||
useHeaderClockLabel,
|
||||
useHeaderVm,
|
||||
} from '@/features/game/hooks/use-header-vm'
|
||||
import { useHeaderClockLabel, useHeaderVm } from '@/hooks/use-header-vm'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function HeaderClock() {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
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'
|
||||
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
|
||||
import { cn } from '@/lib/utils.ts'
|
||||
|
||||
export function DesktopStatusLine() {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createDeposit, type DepositTierItem } from '@/api'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { createDeposit, type DepositTierItem } from '@/features/game/api'
|
||||
import { useDepositTierList } from '@/features/game/hooks/use-deposit-tier-list'
|
||||
import { useDepositTierList } from '@/hooks/use-deposit-tier-list'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select.tsx'
|
||||
import { useWithdrawSubmit } from '@/features/game/hooks/use-withdraw-submit'
|
||||
import { useWithdrawVm } from '@/features/game/hooks/use-withdraw-vm'
|
||||
import { useWithdrawSubmit } from '@/hooks/use-withdraw-submit'
|
||||
import { useWithdrawVm } from '@/hooks/use-withdraw-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx'
|
||||
export type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
|
||||
export { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
|
||||
export { EntryNoticeGateModal } from '@/features/game/components/shared/entry-notice-gate-modal'
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
VolumeX,
|
||||
Wifi,
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
import chatImage from '@/assets/system/chat.webp'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import logo from '@/assets/system/logo.webp'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import {
|
||||
useHeaderClockLabel,
|
||||
useHeaderVm,
|
||||
} from '@/features/game/hooks/use-header-vm'
|
||||
import { useHeaderClockLabel, useHeaderVm } from '@/hooks/use-header-vm'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function MobileHeaderClock() {
|
||||
const systemTimeLabel = useHeaderClockLabel()
|
||||
@@ -29,6 +29,7 @@ function MobileHeaderClock() {
|
||||
|
||||
export function MobileHeader() {
|
||||
const { t } = useTranslation()
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const {
|
||||
authStatus,
|
||||
currentLanguageLabel,
|
||||
@@ -53,7 +54,7 @@ export function MobileHeader() {
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 h-design-62">
|
||||
<div className="border-b-2 border-[#787553] bg-[#020B14]] bg-[#020B14] flex h-design-33 w-full items-center">
|
||||
<div className="border-b-2 border-[#787553] bg-[#020B14] flex h-design-33 w-full items-center">
|
||||
<div className="flex h-design-23 w-design-130 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.45)] px-design-10">
|
||||
<SmartImage
|
||||
src={logo}
|
||||
@@ -64,6 +65,20 @@ export function MobileHeader() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLanguage}
|
||||
className={`${actionButtonClassName} !px-design-10 justify-between`}
|
||||
>
|
||||
<SmartImage
|
||||
src={currentLanguageOption.icon}
|
||||
alt={currentLanguageLabel}
|
||||
className="h-design-14 w-design-14 shrink-0 rounded-full"
|
||||
imgClassName="object-cover"
|
||||
/>
|
||||
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
|
||||
</button>
|
||||
|
||||
{authStatus === 'authenticated' ? (
|
||||
<div className="flex h-full min-w-0 flex-1 items-center justify-end gap-design-7 px-design-9">
|
||||
<button
|
||||
@@ -213,19 +228,20 @@ export function MobileHeader() {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onOpenLanguage}
|
||||
className={`${actionButtonClassName} !px-design-10 justify-between`}
|
||||
onClick={() => setModalOpen('desktopSupport', true)}
|
||||
whileTap={{
|
||||
scale: 0.95,
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<SmartImage
|
||||
src={currentLanguageOption.icon}
|
||||
alt={currentLanguageLabel}
|
||||
className="h-design-14 w-design-14 shrink-0 rounded-full"
|
||||
imgClassName="object-cover"
|
||||
className={'h-design-20 w-design-20 cursor-pointer'}
|
||||
alt={'chatImage'}
|
||||
src={chatImage}
|
||||
/>
|
||||
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
|
||||
</button>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
186
src/features/game/components/mobile/mobile-topup.tsx
Normal file
186
src/features/game/components/mobile/mobile-topup.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createDeposit, type DepositTierItem } from '@/api'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { useDepositTierList } from '@/hooks/use-deposit-tier-list'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PANEL_CLASS =
|
||||
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(88,225,238,0.08)]'
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('en-US').format(value)
|
||||
}
|
||||
|
||||
function MobileTopup() {
|
||||
const { t } = useTranslation()
|
||||
const tierListQuery = useDepositTierList()
|
||||
const tiers = tierListQuery.data ?? []
|
||||
const createDepositInFlightRef = useRef(false)
|
||||
const pendingPayWindowRef = useRef<Window | null>(null)
|
||||
const createDepositMutation = useMutation({
|
||||
mutationFn: ({
|
||||
channelCode,
|
||||
tierId,
|
||||
}: {
|
||||
channelCode: string
|
||||
tierId: string
|
||||
}) =>
|
||||
createDeposit({
|
||||
channel_code: channelCode,
|
||||
idempotency_key: String(Date.now()),
|
||||
tier_id: tierId,
|
||||
}),
|
||||
})
|
||||
|
||||
const handleCreateDeposit = async (tier: DepositTierItem) => {
|
||||
if (createDepositInFlightRef.current || createDepositMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
const channelCode = tier.payChannelCode ?? tier.channels[0]?.code ?? ''
|
||||
|
||||
if (!channelCode) {
|
||||
notify.error(t('commonUi.toast.requestFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
createDepositInFlightRef.current = true
|
||||
const payWindow = window.open('', '_blank')
|
||||
|
||||
if (!payWindow) {
|
||||
createDepositInFlightRef.current = false
|
||||
notify.error(t('gameDesktop.topup.tier.openPayUrlFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
payWindow.opener = null
|
||||
pendingPayWindowRef.current = payWindow
|
||||
|
||||
try {
|
||||
const result = await createDepositMutation.mutateAsync({
|
||||
channelCode,
|
||||
tierId: tier.id,
|
||||
})
|
||||
const payUrl = result.pay_url.trim()
|
||||
|
||||
if (!payUrl) {
|
||||
payWindow.close()
|
||||
notify.error(t('gameDesktop.topup.tier.missingPayUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
payWindow.location.replace(payUrl)
|
||||
notify.success(t('gameDesktop.topup.tier.createSuccess'))
|
||||
} catch (error) {
|
||||
payWindow.close()
|
||||
notify.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('commonUi.toast.requestFailed'),
|
||||
)
|
||||
} finally {
|
||||
createDepositInFlightRef.current = false
|
||||
|
||||
if (pendingPayWindowRef.current === payWindow) {
|
||||
pendingPayWindowRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full px-design-8 pb-design-8 text-[#D9FFFF]">
|
||||
<div
|
||||
className={cn(
|
||||
PANEL_CLASS,
|
||||
'flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden px-design-10 py-design-10',
|
||||
)}
|
||||
>
|
||||
<div className="mb-design-8 flex items-center border-b border-[rgba(89,209,223,0.2)] pb-design-8">
|
||||
<div className="text-design-16 font-semibold text-[#9AF5FB]">
|
||||
{t('gameDesktop.topup.tier.title')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tierListQuery.isLoading ? (
|
||||
<DataLoadingIndicator
|
||||
label={t('gameDesktop.topup.tier.loading')}
|
||||
className="h-full min-h-0 rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.18)] bg-[rgba(6,24,35,0.52)]"
|
||||
/>
|
||||
) : tierListQuery.isError ? (
|
||||
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(185,63,68,0.28)] bg-[rgba(34,13,16,0.42)] px-design-12 text-center text-design-14 text-[#F4A9AE]">
|
||||
{t('gameDesktop.topup.tier.failed')}
|
||||
</div>
|
||||
) : tiers.length === 0 ? (
|
||||
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.18)] bg-[rgba(6,24,35,0.52)] px-design-12 text-center text-design-14 text-[#8FDDE6]">
|
||||
{t('gameDesktop.topup.tier.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-0 min-w-0 grid-cols-2 gap-design-8 overflow-y-auto pr-design-1">
|
||||
{tiers.map((tier) => (
|
||||
<button
|
||||
key={tier.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleCreateDeposit(tier)
|
||||
}}
|
||||
className={cn(
|
||||
'relative min-h-design-126 overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] px-design-8 py-design-8 text-left shadow-[0_0_calc(var(--design-unit)*8)_rgba(88,225,238,0.08)] transition-[border-color,box-shadow,filter] duration-150',
|
||||
createDepositMutation.isPending
|
||||
? 'cursor-wait opacity-80'
|
||||
: 'cursor-pointer hover:border-[rgba(170,247,255,0.62)] hover:brightness-110 active:brightness-90',
|
||||
)}
|
||||
>
|
||||
<div className="absolute right-design-6 top-design-6 max-w-design-48 truncate rounded-full border border-[rgba(121,219,229,0.28)] bg-[rgba(10,39,52,0.7)] px-design-5 py-[2px] text-design-8 leading-none text-[#7CDDE7]">
|
||||
{tier.currency ?? 'FIAT'}
|
||||
</div>
|
||||
|
||||
<div className="pr-design-46 text-design-10 uppercase leading-tight tracking-[0.04em] text-[#63AEB6]">
|
||||
{tier.title}
|
||||
</div>
|
||||
|
||||
<div className="pt-design-5 text-design-18 font-semibold leading-none text-[#FFE229]">
|
||||
{formatNumber(tier.payAmount)}
|
||||
</div>
|
||||
<div className="pt-design-3 text-design-10 text-[#9FDCE3]">
|
||||
{t('gameDesktop.topup.tier.coins')}:{' '}
|
||||
{formatNumber(tier.totalAmount)}
|
||||
</div>
|
||||
|
||||
<div className="mt-design-7 rounded-[calc(var(--design-unit)*5)] border border-[rgba(89,209,223,0.18)] bg-[rgba(4,19,28,0.58)] px-design-6 py-design-5">
|
||||
<div className="flex items-center justify-between gap-design-6 text-design-10">
|
||||
<span className="text-[#7CE3E8]">
|
||||
{t('gameDesktop.topup.tier.bonus')}
|
||||
</span>
|
||||
<span className="text-[#FFF1C9]">
|
||||
{formatNumber(tier.bonusAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-design-3 flex items-center justify-between gap-design-6 text-design-10">
|
||||
<span className="text-[#7CE3E8]">Channels</span>
|
||||
<span className="line-clamp-1 text-right text-[#6DFF83]">
|
||||
{tier.channels.length > 0
|
||||
? tier.channels
|
||||
.map((channel) => channel.name)
|
||||
.join(', ')
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{tier.desc ? (
|
||||
<div className="mt-design-5 line-clamp-2 text-design-9 leading-[1.25] text-[#6DAAB0]">
|
||||
{tier.desc}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileTopup
|
||||
640
src/features/game/components/mobile/mobile-withdraw.tsx
Normal file
640
src/features/game/components/mobile/mobile-withdraw.tsx
Normal file
@@ -0,0 +1,640 @@
|
||||
import { Minus, Plus } from 'lucide-react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select.tsx'
|
||||
import { useWithdrawSubmit } from '@/hooks/use-withdraw-submit'
|
||||
import { useWithdrawVm } from '@/hooks/use-withdraw-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
const PANEL_CLASS =
|
||||
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(88,225,238,0.08)]'
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('en-US').format(value)
|
||||
}
|
||||
|
||||
function getPaymentGlyph(code: string, name: string) {
|
||||
if (code.toLowerCase().includes('alipay')) {
|
||||
return '支'
|
||||
}
|
||||
|
||||
return name.trim().slice(0, 1).toUpperCase() || code.slice(0, 1).toUpperCase()
|
||||
}
|
||||
|
||||
function WithdrawField({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-design-3">
|
||||
<div className="text-design-10 font-medium uppercase leading-none text-[#6FD4DA]">
|
||||
{label}
|
||||
</div>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AmountShell({
|
||||
amount,
|
||||
availableBalanceText,
|
||||
onAmountChange,
|
||||
onMinus,
|
||||
onPlus,
|
||||
}: {
|
||||
amount: number
|
||||
availableBalanceText: string
|
||||
onAmountChange: (value: number) => void
|
||||
onMinus: () => void
|
||||
onPlus: () => void
|
||||
}) {
|
||||
function handleInputChange(value: string) {
|
||||
const nextValue = Number(value.replace(/[^\d]/g, ''))
|
||||
|
||||
onAmountChange(Number.isFinite(nextValue) ? nextValue : 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-design-2">
|
||||
<div className="flex h-design-34 items-center gap-design-6 rounded-[calc(var(--design-unit)*5)] border border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(14,64,74,0.82),rgba(8,36,47,0.78))] px-design-6 shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(93,239,255,0.08)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMinus}
|
||||
className="flex h-design-24 w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
||||
>
|
||||
<Minus className="h-design-12 w-design-12" />
|
||||
</button>
|
||||
|
||||
<input
|
||||
value={amount === 0 ? '' : String(amount)}
|
||||
onChange={(event) => handleInputChange(event.target.value)}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-center text-design-16 font-medium text-[#A1EBF3] outline-none placeholder:text-[rgba(109,170,176,0.55)]"
|
||||
placeholder="0"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlus}
|
||||
className="flex h-design-24 w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
||||
>
|
||||
<Plus className="h-design-12 w-design-12" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pl-design-2 text-design-10 text-[#6DAAB0]">
|
||||
{availableBalanceText}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickAmountCard({
|
||||
amount,
|
||||
preview,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
amount: number
|
||||
preview: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group relative flex h-design-42 min-w-0 w-full cursor-pointer flex-col items-start justify-center overflow-hidden rounded-[calc(var(--design-unit)*6)] border px-design-6 text-left transition-[border-color,background-color,box-shadow,filter] duration-150',
|
||||
active
|
||||
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(88,54,28,0.96),rgba(56,33,18,0.92))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.2),inset_0_0_calc(var(--design-unit)*10)_rgba(255,217,120,0.08)]'
|
||||
: 'border-[rgba(103,227,239,0.26)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] hover:border-[rgba(170,247,255,0.62)] hover:brightness-110',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute right-design-6 top-design-6 h-design-5 w-design-5 rounded-full transition',
|
||||
active
|
||||
? 'bg-[#FFD15E] shadow-[0_0_8px_rgba(255,209,94,0.8)]'
|
||||
: 'bg-[rgba(122,220,230,0.26)]',
|
||||
)}
|
||||
/>
|
||||
<div className="max-w-full truncate text-design-13 font-semibold leading-none text-[#FFE229]">
|
||||
{amount}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-full truncate pt-design-3 text-design-10 leading-none',
|
||||
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
|
||||
)}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PaymentCard({
|
||||
active,
|
||||
label,
|
||||
glyph,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
glyph: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group relative flex h-design-42 min-w-0 cursor-pointer items-center gap-design-6 rounded-[calc(var(--design-unit)*6)] border px-design-6 text-left transition-[border-color,background-color,box-shadow,filter] duration-150',
|
||||
active
|
||||
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(88,54,28,0.96),rgba(56,33,18,0.92))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18),inset_0_0_calc(var(--design-unit)*10)_rgba(255,217,120,0.08)]'
|
||||
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] hover:border-[rgba(170,247,255,0.62)] hover:brightness-110',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute right-design-6 top-design-6 h-design-5 w-design-5 rounded-full transition',
|
||||
active
|
||||
? 'bg-[#FFD15E] shadow-[0_0_8px_rgba(255,209,94,0.8)]'
|
||||
: 'bg-[rgba(122,220,230,0.26)]',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-design-26 w-design-26 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] border text-design-12 font-semibold leading-none transition-colors',
|
||||
active
|
||||
? 'border-[rgba(255,218,132,0.45)] bg-[rgba(255,211,113,0.16)] text-[#FFD97A]'
|
||||
: 'border-[rgba(121,219,229,0.28)] bg-[rgba(10,39,52,0.7)] text-[#8DE4EA]',
|
||||
)}
|
||||
>
|
||||
{glyph}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pr-design-7">
|
||||
<div
|
||||
className={cn(
|
||||
'truncate !text-design-12 font-medium leading-none',
|
||||
active ? 'text-[#FFF1C9]' : 'text-[#D7FBFF]',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'pt-design-2 !text-design-10 uppercase leading-none tracking-[0.03em]',
|
||||
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
|
||||
)}
|
||||
>
|
||||
Channel
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function InputShell({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
error,
|
||||
errorMessage,
|
||||
uppercase = false,
|
||||
type = 'text',
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
error?: boolean
|
||||
errorMessage?: string
|
||||
uppercase?: boolean
|
||||
type?: 'text' | 'email' | 'tel'
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-design-3">
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'h-design-30 rounded-[calc(var(--design-unit)*5)] border px-design-8 text-design-12',
|
||||
uppercase && 'uppercase',
|
||||
error
|
||||
? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]'
|
||||
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(10,47,57,0.84),rgba(5,23,32,0.92))] text-[#ACF1F6]',
|
||||
)}
|
||||
/>
|
||||
{error && errorMessage ? (
|
||||
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewRow({
|
||||
label,
|
||||
value,
|
||||
highlight = false,
|
||||
}: {
|
||||
label: string
|
||||
value: ReactNode
|
||||
highlight?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex border-b border-[rgba(89,209,223,0.2)] last:border-b-0">
|
||||
<div className="flex w-[46%] shrink-0 items-center border-r border-[rgba(89,209,223,0.2)] px-design-6 py-design-7 text-design-10 font-medium uppercase leading-[1.15] text-[#7CE3E8]">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center justify-end px-design-6 py-design-7 text-right text-design-10 text-[#E6FFFF]',
|
||||
highlight && 'text-design-11 font-semibold text-[#6DFF83]',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileWithdraw() {
|
||||
const { t } = useTranslation()
|
||||
const vm = useWithdrawVm()
|
||||
const withdrawSubmitMutation = useWithdrawSubmit()
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false)
|
||||
const [activeQuickAmountId, setActiveQuickAmountId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
function handleAmountChange(nextAmount: number) {
|
||||
vm.setAmount(Math.max(0, nextAmount))
|
||||
setActiveQuickAmountId(null)
|
||||
}
|
||||
|
||||
function handleQuickAmountSelect(optionId: string, amount: number) {
|
||||
vm.setAmount(Math.max(0, amount))
|
||||
setActiveQuickAmountId(optionId)
|
||||
}
|
||||
|
||||
function resetWithdrawFormState() {
|
||||
setHasSubmitted(false)
|
||||
setActiveQuickAmountId(null)
|
||||
vm.resetForm()
|
||||
}
|
||||
|
||||
function handleCloseWithdraw() {
|
||||
resetWithdrawFormState()
|
||||
setModalOpen('desktopWithdrawTopup', false)
|
||||
}
|
||||
|
||||
function handleConfirmWithdraw() {
|
||||
if (withdrawSubmitMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
setHasSubmitted(true)
|
||||
|
||||
if (
|
||||
vm.amountRequiredError ||
|
||||
vm.amountExceedsBalance ||
|
||||
vm.holderNameError ||
|
||||
vm.bankAccountError ||
|
||||
vm.paymentChannelCodeError ||
|
||||
vm.bankCodeError ||
|
||||
vm.receiverEmailError ||
|
||||
vm.receiverPhoneError
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
withdrawSubmitMutation.mutate(
|
||||
{
|
||||
bank_code: vm.bankCode,
|
||||
channel_code: vm.paymentChannelCode,
|
||||
idempotency_key: String(Date.now()),
|
||||
receive_account: vm.bankAccount.trim(),
|
||||
receiver_email: vm.receiverEmail.trim(),
|
||||
receiver_mobile: vm.receiverPhone.trim(),
|
||||
receiver_name: vm.holderName.trim(),
|
||||
receive_type: 'bank',
|
||||
withdraw_coin: vm.amount,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleCloseWithdraw()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full px-design-6 pb-design-6 text-[#D9FFFF]">
|
||||
<div
|
||||
className={cn(
|
||||
PANEL_CLASS,
|
||||
'flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden',
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-design-8 py-design-7">
|
||||
<div className="flex flex-col !gap-design-10">
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.diamondAmount')}
|
||||
>
|
||||
<AmountShell
|
||||
amount={vm.amount}
|
||||
availableBalanceText={t(
|
||||
'gameDesktop.withdraw.availableBalance',
|
||||
{ amount: formatNumber(vm.availableBalance) },
|
||||
)}
|
||||
onAmountChange={handleAmountChange}
|
||||
onMinus={() => handleAmountChange(vm.amount - 1)}
|
||||
onPlus={() => handleAmountChange(vm.amount + 1)}
|
||||
/>
|
||||
{hasSubmitted && vm.amountRequiredError ? (
|
||||
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
|
||||
{t('gameDesktop.withdraw.errors.amountRequired')}
|
||||
</div>
|
||||
) : null}
|
||||
{hasSubmitted && vm.amountExceedsBalance ? (
|
||||
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
|
||||
{t('gameDesktop.withdraw.errors.amountExceedsBalance')}
|
||||
</div>
|
||||
) : null}
|
||||
</WithdrawField>
|
||||
|
||||
<div className="grid min-w-0 grid-cols-3 gap-design-5">
|
||||
{vm.quickAmounts.map((option) => (
|
||||
<QuickAmountCard
|
||||
key={option.id}
|
||||
amount={option.diamonds}
|
||||
preview={option.preview}
|
||||
active={option.id === activeQuickAmountId}
|
||||
onClick={() =>
|
||||
handleQuickAmountSelect(option.id, option.diamonds)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.currencyType')}
|
||||
>
|
||||
<Select
|
||||
value={vm.currencyCode}
|
||||
onValueChange={vm.setCurrencyCode}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-design-30 w-full rounded-[calc(var(--design-unit)*5)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-8 text-left !text-design-12 text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.08)] data-[size=default]:h-design-30 data-[placeholder]:text-[rgba(109,170,176,0.55)] [&_svg]:h-design-14 [&_svg]:w-design-14 [&_svg]:text-[#79DFEA]"
|
||||
aria-label={t('gameDesktop.withdraw.currencySelection')}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t('gameDesktop.withdraw.selectCurrency')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vm.config.currencies.map((option) => (
|
||||
<SelectItem key={option.code} value={option.code}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.paymentChannel')}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-design-3">
|
||||
{vm.sortedPayChannels.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-design-5">
|
||||
{vm.sortedPayChannels.map((channel) => (
|
||||
<PaymentCard
|
||||
key={channel.code}
|
||||
active={channel.code === vm.paymentChannelCode}
|
||||
label={channel.name}
|
||||
glyph={getPaymentGlyph(channel.code, channel.name)}
|
||||
onClick={() => vm.setPaymentChannelCode(channel.code)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-design-38 items-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(185,63,68,0.45)] bg-[rgba(34,13,16,0.6)] px-design-8 text-design-10 text-[#F4B1B1]">
|
||||
{t('gameDesktop.withdraw.errors.paymentChannelUnavailable')}
|
||||
</div>
|
||||
)}
|
||||
{hasSubmitted && vm.paymentChannelCodeError ? (
|
||||
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
|
||||
{t('gameDesktop.withdraw.errors.paymentChannelRequired')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
|
||||
<div className="flex w-full flex-col gap-design-3">
|
||||
<Select
|
||||
value={vm.bankCode}
|
||||
onValueChange={vm.setBankCode}
|
||||
disabled={vm.sortedBanks.length === 0}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'h-design-30 w-full rounded-[calc(var(--design-unit)*5)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-8 text-left !text-design-12 text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.08)] data-[size=default]:h-design-30 data-[placeholder]:text-[rgba(109,170,176,0.55)] [&_svg]:h-design-14 [&_svg]:w-design-14 [&_svg]:text-[#79DFEA]',
|
||||
hasSubmitted && vm.bankCodeError
|
||||
? 'border-[#B93F44]'
|
||||
: 'border-[rgba(103,227,239,0.3)]',
|
||||
)}
|
||||
aria-label={t('gameDesktop.withdraw.fields.bankCode')}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.bankCode',
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vm.sortedBanks.map((bank) => (
|
||||
<SelectItem key={bank.code} value={bank.code}>
|
||||
{bank.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{vm.sortedBanks.length === 0 ? (
|
||||
<div className="pl-design-2 text-design-10 text-[#F4B1B1]">
|
||||
{t('gameDesktop.withdraw.errors.bankCodeUnavailable')}
|
||||
</div>
|
||||
) : null}
|
||||
{hasSubmitted && vm.bankCodeError ? (
|
||||
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
|
||||
{t('gameDesktop.withdraw.errors.bankCodeRequired')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.cardHolderName')}
|
||||
>
|
||||
<InputShell
|
||||
value={vm.holderName}
|
||||
onChange={vm.setHolderName}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.cardHolderName',
|
||||
)}
|
||||
error={hasSubmitted && vm.holderNameError}
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.cardHolderNameRequired',
|
||||
)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
|
||||
>
|
||||
<InputShell
|
||||
value={vm.bankAccount}
|
||||
onChange={vm.setBankAccount}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.bankAccountNumber',
|
||||
)}
|
||||
error={hasSubmitted && vm.bankAccountError}
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.bankAccountRequired',
|
||||
)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.receiverEmail')}
|
||||
>
|
||||
<InputShell
|
||||
value={vm.receiverEmail}
|
||||
onChange={vm.setReceiverEmail}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.receiverEmail',
|
||||
)}
|
||||
type="email"
|
||||
error={hasSubmitted && vm.receiverEmailError}
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.receiverEmailInvalid',
|
||||
)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField
|
||||
label={t('gameDesktop.withdraw.fields.receiverPhone')}
|
||||
>
|
||||
<InputShell
|
||||
value={vm.receiverPhone}
|
||||
onChange={vm.setReceiverPhone}
|
||||
placeholder={t(
|
||||
'gameDesktop.withdraw.placeholders.receiverPhone',
|
||||
)}
|
||||
type="tel"
|
||||
error={hasSubmitted && vm.receiverPhoneError}
|
||||
errorMessage={t(
|
||||
'gameDesktop.withdraw.errors.receiverPhoneInvalid',
|
||||
)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
|
||||
<PreviewRow
|
||||
label={t('gameDesktop.withdraw.preview.diamondAmount')}
|
||||
value={formatNumber(vm.amount)}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={vm.selectedCurrencyPreview.exchangeRateLabel}
|
||||
value={vm.selectedCurrencyPreview.exchangeRateValue}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={vm.selectedCurrencyPreview.convertibleLabel}
|
||||
value={vm.selectedCurrencyPreview.convertibleValue}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label={t(
|
||||
'gameDesktop.withdraw.preview.fixedExchangeDiamondAmount',
|
||||
)}
|
||||
value="0-0-0 0:0:0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-8 py-design-6 text-design-10 leading-[1.3] text-[#F0B44A]">
|
||||
{vm.withdrawCopy.rateHint}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-design-3 px-design-1 text-design-10 uppercase leading-[1.3] text-[#7AD8E0]">
|
||||
<div>
|
||||
{vm.withdrawCopy.processingLabel}:{' '}
|
||||
<span className="text-[#77FF76]">
|
||||
{vm.withdrawCopy.processingValue}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{vm.withdrawCopy.noticeLabel}:{' '}
|
||||
<span className="text-red-700">{vm.withdrawCopy.feeNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-design-6 border-t border-[rgba(89,209,223,0.22)] bg-[rgba(3,15,24,0.86)] px-design-8 py-design-5">
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthGreenBtn}
|
||||
size="100% 100%"
|
||||
onClick={handleCloseWithdraw}
|
||||
className="flex h-design-38 flex-1 cursor-pointer items-center justify-center pb-design-2 text-center !text-design-12 font-bold uppercase text-[#F0FFFF] transition hover:brightness-110 active:scale-[0.98]"
|
||||
>
|
||||
{t('gameDesktop.withdraw.cancel')}
|
||||
</SmartBackground>
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 100%"
|
||||
onClick={handleConfirmWithdraw}
|
||||
disabled={withdrawSubmitMutation.isPending}
|
||||
className={cn(
|
||||
'flex h-design-38 flex-1 items-center justify-center whitespace-nowrap pb-design-2 text-center !text-design-12 font-bold uppercase leading-[1.05] text-[#F0FFFF] transition',
|
||||
withdrawSubmitMutation.isPending
|
||||
? 'cursor-not-allowed opacity-70'
|
||||
: 'cursor-pointer hover:brightness-110 active:scale-[0.98]',
|
||||
)}
|
||||
>
|
||||
{withdrawSubmitMutation.isPending
|
||||
? t('commonUi.action.submitting')
|
||||
: `${t('gameDesktop.withdraw.confirm')}`}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileWithdraw
|
||||
@@ -3,9 +3,11 @@ import dayjs from 'dayjs'
|
||||
import { RotateCw } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getNoticeList } from '@/api'
|
||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||
import checkIcon from '@/assets/system/right.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator.tsx'
|
||||
@@ -13,7 +15,6 @@ import {
|
||||
ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
|
||||
ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY,
|
||||
} from '@/constants'
|
||||
import { getNoticeList } from '@/features/game/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useModalStore } from '@/store/modal'
|
||||
@@ -36,8 +37,15 @@ function setLastConfirmedAt(storageKey: string, timestamp: number) {
|
||||
localStorage.setItem(storageKey, String(timestamp))
|
||||
}
|
||||
|
||||
export function EntryNoticeGateModal() {
|
||||
interface EntryNoticeGateModalProps {
|
||||
variant?: 'desktop' | 'mobile'
|
||||
}
|
||||
|
||||
export function EntryNoticeGateModal({
|
||||
variant = 'desktop',
|
||||
}: EntryNoticeGateModalProps = {}) {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = variant === 'mobile'
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const authIsHydrated = useAuthStore((state) => state.isHydrated)
|
||||
const accessToken = useAuthStore((state) => state.accessToken)
|
||||
@@ -118,29 +126,64 @@ export function EntryNoticeGateModal() {
|
||||
return null
|
||||
}
|
||||
|
||||
const Modal = isMobile ? MobileCenterModal : CenterModal
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
<Modal
|
||||
open={shouldShowModal}
|
||||
isShowClose={false}
|
||||
isNormalBg={true}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-26">
|
||||
<div
|
||||
className={cn(
|
||||
'modal-title-glow',
|
||||
isMobile ? 'text-design-16' : 'text-design-26',
|
||||
)}
|
||||
>
|
||||
{t('game.modals.entryNotice.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="left"
|
||||
className="h-design-700 w-design-1000 max-h-[92vh] max-w-[92vw]"
|
||||
className={
|
||||
isMobile
|
||||
? 'h-design-400'
|
||||
: 'h-design-700 w-design-1000 max-h-[92vh] max-w-[92vw]'
|
||||
}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col gap-design-20 px-design-14 pb-design-30 pt-design-8">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-[#2B8CA3]/45 bg-[#001B24]/70 p-design-18 shadow-[inset_0_0_calc(var(--design-unit)*18)_rgba(39,175,205,0.1)]">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col',
|
||||
isMobile
|
||||
? 'gap-design-10 px-design-8 pb-design-8 pt-design-4'
|
||||
: 'gap-design-20 px-design-14 pb-design-30 pt-design-8',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 flex-1 overflow-y-auto rounded-md border border-[#2B8CA3]/45 bg-[#001B24]/70 shadow-[inset_0_0_calc(var(--design-unit)*18)_rgba(39,175,205,0.1)]',
|
||||
isMobile ? 'p-design-8' : 'p-design-18',
|
||||
)}
|
||||
>
|
||||
{noticeListQuery.isPending ? (
|
||||
<DataLoadingIndicator
|
||||
label={t('game.modals.entryNotice.loading')}
|
||||
className="h-full min-h-[calc(var(--design-unit)*320)]"
|
||||
className={cn(
|
||||
'h-full',
|
||||
isMobile
|
||||
? 'min-h-[calc(var(--design-unit)*220)] text-design-12'
|
||||
: 'min-h-[calc(var(--design-unit)*320)]',
|
||||
)}
|
||||
/>
|
||||
) : noticeListQuery.isError ? (
|
||||
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] flex-col items-center justify-center gap-design-18 text-center text-[#9CE8F2]">
|
||||
<div className="text-design-22">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col items-center justify-center text-center text-[#9CE8F2]',
|
||||
isMobile
|
||||
? 'min-h-[calc(var(--design-unit)*220)] gap-design-10'
|
||||
: 'min-h-[calc(var(--design-unit)*320)] gap-design-18',
|
||||
)}
|
||||
>
|
||||
<div className={isMobile ? 'text-design-13' : 'text-design-22'}>
|
||||
{t('game.modals.entryNotice.loadFailed')}
|
||||
</div>
|
||||
<button
|
||||
@@ -148,30 +191,75 @@ export function EntryNoticeGateModal() {
|
||||
onClick={() => {
|
||||
void noticeListQuery.refetch()
|
||||
}}
|
||||
className="inline-flex items-center gap-design-8 rounded-md border border-[#4AC6DE]/45 bg-[#0B4454] px-design-18 py-design-10 text-design-18 text-[#D7FFFF] transition hover:bg-[#0E576D]"
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border border-[#4AC6DE]/45 bg-[#0B4454] text-[#D7FFFF] transition hover:bg-[#0E576D]',
|
||||
isMobile
|
||||
? 'gap-design-5 px-design-10 py-design-6 text-design-12'
|
||||
: 'gap-design-8 px-design-18 py-design-10 text-design-18',
|
||||
)}
|
||||
>
|
||||
<RotateCw className="h-design-18 w-design-18" />
|
||||
<RotateCw
|
||||
className={
|
||||
isMobile
|
||||
? 'h-design-13 w-design-13'
|
||||
: 'h-design-18 w-design-18'
|
||||
}
|
||||
/>
|
||||
{t('game.modals.entryNotice.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-design-16">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col',
|
||||
isMobile ? 'gap-design-8' : 'gap-design-16',
|
||||
)}
|
||||
>
|
||||
{popoutNotices.map((notice, index) => (
|
||||
<article
|
||||
key={notice.notice_id}
|
||||
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-20"
|
||||
className={cn(
|
||||
'rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)]',
|
||||
isMobile ? 'p-design-9' : 'p-design-20',
|
||||
)}
|
||||
>
|
||||
<div className="mb-design-12 flex flex-wrap items-center justify-between gap-design-12">
|
||||
<div className="min-w-0 flex-1 text-design-24 font-semibold leading-tight text-white">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center justify-between',
|
||||
isMobile
|
||||
? 'mb-design-6 gap-design-6'
|
||||
: 'mb-design-12 gap-design-12',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 flex-1 font-semibold leading-tight text-white',
|
||||
isMobile ? 'text-design-13' : 'text-design-24',
|
||||
)}
|
||||
>
|
||||
{index + 1}. {notice.title}
|
||||
</div>
|
||||
<div className="rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-12 py-design-5 text-design-15 text-[#9CE8F2]">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 text-[#9CE8F2]',
|
||||
isMobile
|
||||
? 'px-design-7 py-design-3 text-design-9'
|
||||
: 'px-design-12 py-design-5 text-design-15',
|
||||
)}
|
||||
>
|
||||
{dayjs(notice.publish_time * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]">
|
||||
<div
|
||||
className={cn(
|
||||
'whitespace-pre-wrap text-[#C4F2F7]',
|
||||
isMobile
|
||||
? 'text-design-11 leading-[1.55]'
|
||||
: 'text-design-18 leading-[1.8]',
|
||||
)}
|
||||
>
|
||||
{notice.content ?? ''}
|
||||
</div>
|
||||
</article>
|
||||
@@ -180,8 +268,20 @@ export function EntryNoticeGateModal() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-center justify-center gap-design-20">
|
||||
<label className="inline-flex cursor-pointer items-center justify-center gap-design-12 text-design-20 text-[#C4F2F7]">
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col items-center justify-center',
|
||||
isMobile ? 'gap-design-8' : 'gap-design-20',
|
||||
)}
|
||||
>
|
||||
<label
|
||||
className={cn(
|
||||
'inline-flex cursor-pointer items-center justify-center text-[#C4F2F7]',
|
||||
isMobile
|
||||
? 'gap-design-6 text-design-11'
|
||||
: 'gap-design-12 text-design-20',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasAgreed}
|
||||
@@ -192,7 +292,10 @@ export function EntryNoticeGateModal() {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex h-design-32 w-design-32 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] border transition',
|
||||
'flex shrink-0 items-center justify-center border transition',
|
||||
isMobile
|
||||
? 'h-design-20 w-design-20 rounded-[calc(var(--design-unit)*4)]'
|
||||
: 'h-design-32 w-design-32 rounded-[calc(var(--design-unit)*5)]',
|
||||
hasAgreed
|
||||
? 'border-[#4AFF49]/80 bg-[#071F11]'
|
||||
: 'border-[#6CCDCF]/70 bg-[#031D25]',
|
||||
@@ -204,7 +307,12 @@ export function EntryNoticeGateModal() {
|
||||
alt=""
|
||||
priority={true}
|
||||
showSkeleton={false}
|
||||
className="h-design-34 w-design-38 overflow-visible"
|
||||
className={cn(
|
||||
'overflow-visible',
|
||||
isMobile
|
||||
? 'h-design-22 w-design-24'
|
||||
: 'h-design-34 w-design-38',
|
||||
)}
|
||||
imgClassName="object-contain"
|
||||
/>
|
||||
) : null}
|
||||
@@ -230,7 +338,10 @@ export function EntryNoticeGateModal() {
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-design-72 w-design-270 items-center justify-center rounded-md pb-design-5 text-design-22 font-bold transition',
|
||||
'flex items-center justify-center rounded-md font-bold transition',
|
||||
isMobile
|
||||
? 'h-design-42 w-design-156 pb-design-3 text-design-14'
|
||||
: 'h-design-72 w-design-270 pb-design-5 text-design-22',
|
||||
canEnter
|
||||
? 'modal-title-glow cursor-pointer text-white hover:brightness-110 active:brightness-95'
|
||||
: 'cursor-not-allowed text-white opacity-80 grayscale',
|
||||
@@ -248,6 +359,10 @@ export function EntryNoticeGateModal() {
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileEntryNoticeGateModal() {
|
||||
return <EntryNoticeGateModal variant="mobile" />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import type { PeriodHistoryDisplayItem } from '@/features/game/hooks/use-period-history-vm'
|
||||
import type { PeriodHistoryDisplayItem } from '@/hooks/use-period-history-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PeriodHistoryListLabels {
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { startTransition, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { MOBILE_LAYOUT_BREAKPOINT_PX } from '@/constants'
|
||||
import { getGameLobbyInit } from '@/features/game'
|
||||
import { EntryNoticeGateModal } from '@/features/game/components'
|
||||
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
|
||||
import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
|
||||
import { useGameRealtimeSync } from '@/features/game/hooks/use-game-realtime-sync.ts'
|
||||
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
|
||||
import DesktopLanguageModal from '@/features/game/modal/desktop/desktop-language-modal.tsx'
|
||||
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
|
||||
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
|
||||
import { DesktopPeriodHistoryDrawer } from '@/features/game/modal/desktop/desktop-period-history-drawer.tsx'
|
||||
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
|
||||
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
|
||||
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
|
||||
import DesktopSupportModal from '@/features/game/modal/desktop/desktop-support-modal.tsx'
|
||||
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
|
||||
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
||||
import { useDocumentMetadata } from '@/lib/head/document-metadata'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
function EntryModalHost() {
|
||||
return (
|
||||
<>
|
||||
{/* 桌面端登录弹窗:用于未登录用户进入登录流程 */}
|
||||
<DesktopLoginModal />
|
||||
{/* 桌面端注册弹窗:用于新用户注册账号 */}
|
||||
<DesktopRegisterModal />
|
||||
{/* 桌面端语言切换弹窗:用于选择当前站点展示语言 */}
|
||||
<DesktopLanguageModal />
|
||||
{/* 桌面端规则弹窗:展示当前游戏玩法、下注与结算规则 */}
|
||||
<DesktopRulesModal />
|
||||
{/* 桌面端用户信息弹窗:展示个人资料与站内消息 */}
|
||||
<DesktopUserInfoModal />
|
||||
{/* 桌面端公告弹窗:展示活动公告或运营通知内容 */}
|
||||
<DesktopNoticeModal />
|
||||
{/* 桌面端自动托管弹窗:配置自动托管相关条件 */}
|
||||
<DesktopAutoSettingModal />
|
||||
{/* 桌面端充值/提现前置选择弹窗:先选择进入充值还是提现 */}
|
||||
<DesktopProceduresModal />
|
||||
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
|
||||
<DesktopWithdrawTopupModal />
|
||||
{/* 桌面端客服弹窗:承载在线客服 iframe */}
|
||||
<DesktopSupportModal />
|
||||
{/* 强制弹窗 */}
|
||||
<EntryNoticeGateModal />
|
||||
{/* 历史开奖信息弹窗 */}
|
||||
<DesktopPeriodHistoryDrawer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function EntryPage() {
|
||||
const { t } = useTranslation()
|
||||
useGameRealtimeSync()
|
||||
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
|
||||
const selectChip = useGameRoundStore((state) => state.selectChip)
|
||||
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
|
||||
const syncConnection = useGameSessionStore((state) => state.syncConnection)
|
||||
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const authIsHydrated = useAuthStore((state) => state.isHydrated)
|
||||
const accessToken = useAuthStore((state) => state.accessToken)
|
||||
const lastUnauthorizedAt = useAuthStore((state) => state.lastUnauthorizedAt)
|
||||
const isReloginRequired =
|
||||
authStatus === 'anonymous' && Boolean(lastUnauthorizedAt)
|
||||
|
||||
const [isHydrating, setIsHydrating] = useState(true)
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
return window.matchMedia(`(max-width: ${MOBILE_LAYOUT_BREAKPOINT_PX}px)`)
|
||||
.matches
|
||||
})
|
||||
|
||||
useDocumentMetadata({
|
||||
title: t('game.metaTitle'),
|
||||
description: t('game.metaDescription'),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!authIsHydrated) {
|
||||
setIsHydrating(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isReloginRequired || authStatus !== 'authenticated' || !accessToken) {
|
||||
setIsHydrating(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
void getGameLobbyInit()
|
||||
.then((result) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
const snapshot = result.snapshot
|
||||
|
||||
hydrateRound({
|
||||
cells: snapshot.cells,
|
||||
chips: snapshot.chips,
|
||||
history: snapshot.history,
|
||||
maxSelectionCount: snapshot.maxSelectionCount,
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
trends: snapshot.trends,
|
||||
})
|
||||
const defaultChipId =
|
||||
snapshot.chips.find((chip) => chip.isDefault)?.id ?? null
|
||||
|
||||
if (defaultChipId) {
|
||||
selectChip(defaultChipId)
|
||||
}
|
||||
hydrateSession({
|
||||
announcements: snapshot.announcements,
|
||||
connection: snapshot.connection,
|
||||
dashboard: snapshot.dashboard,
|
||||
})
|
||||
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
|
||||
if (currentUser) {
|
||||
setCurrentUser({
|
||||
...currentUser,
|
||||
coin: result.userSnapshot.coin,
|
||||
currentStreak: result.userSnapshot.current_streak,
|
||||
isJackpot: result.userSnapshot.is_jackpot,
|
||||
oddsFactor: result.userSnapshot.odds_factor,
|
||||
streakLevel: result.userSnapshot.streak_level,
|
||||
})
|
||||
}
|
||||
|
||||
setIsHydrating(false)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load game lobby init', error)
|
||||
|
||||
if (!cancelled) {
|
||||
if (authStatus === 'authenticated') {
|
||||
notify.error(t('commonUi.toast.lobbyInitFailed'), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
syncConnection({
|
||||
connectedAt: null,
|
||||
lastError:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load game lobby init',
|
||||
lastMessageAt: null,
|
||||
latencyMs: null,
|
||||
status: 'disconnected',
|
||||
transport: 'offline',
|
||||
})
|
||||
setIsHydrating(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
accessToken,
|
||||
authIsHydrated,
|
||||
authStatus,
|
||||
hydrateRound,
|
||||
hydrateSession,
|
||||
isReloginRequired,
|
||||
selectChip,
|
||||
setCurrentUser,
|
||||
syncConnection,
|
||||
t,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(
|
||||
`(max-width: ${MOBILE_LAYOUT_BREAKPOINT_PX}px)`,
|
||||
)
|
||||
const syncLayout = (event?: MediaQueryListEvent) => {
|
||||
setIsMobile(event?.matches ?? mediaQuery.matches)
|
||||
}
|
||||
|
||||
syncLayout()
|
||||
mediaQuery.addEventListener('change', syncLayout)
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', syncLayout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-busy={isHydrating}
|
||||
aria-label={t('game.lobbyTitle')}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{isMobile ? <MobileEntry /> : <PcEntry />}
|
||||
<EntryModalHost />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { MobileHeader } from '@/features/game/components/mobile/mobile-header.tsx'
|
||||
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
|
||||
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
|
||||
|
||||
export function MobileEntry() {
|
||||
useAutoHostingRunner()
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileHeader />
|
||||
<RoundBettingStartAlert placement="fixed" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { DesktopHeader } from '@/features/game/components'
|
||||
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
|
||||
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
|
||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
||||
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
||||
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
|
||||
|
||||
export function PcEntry() {
|
||||
useAutoHostingRunner()
|
||||
|
||||
return (
|
||||
<>
|
||||
<DesktopHeader />
|
||||
|
||||
<div
|
||||
className={'mx-auto my-design-10 w-[calc(100%-40*var(--design-unit))]'}
|
||||
>
|
||||
<DesktopStatusLine />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={'mx-auto w-[calc(100%-72*var(--design-unit))] mb-design-5'}
|
||||
>
|
||||
<div className={'flex w-full items-start gap-design-10'}>
|
||||
<div className={'flex-1'}>
|
||||
<DesktopAnimal />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'flex h-[calc(var(--design-unit)*715)] min-h-0 w-design-370'
|
||||
}
|
||||
>
|
||||
<DesktopGameHistory />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={'mx-auto mt-design-10 w-[calc(100%-40*var(--design-unit))]'}
|
||||
>
|
||||
<DesktopControl />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useAudioStore, useAuthStore, useModalStore } from '@/store'
|
||||
import {
|
||||
selectSelectionTotal,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
export type DesktopAnimalWarningType = 'balance' | 'betLimit' | 'limit'
|
||||
|
||||
function getNextMarqueeId(ids: number[], currentId: number | null) {
|
||||
if (ids.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (ids.length === 1) {
|
||||
return ids[0] ?? null
|
||||
}
|
||||
|
||||
let nextId = currentId
|
||||
|
||||
while (nextId === currentId) {
|
||||
nextId = ids[Math.floor(Math.random() * ids.length)] ?? currentId
|
||||
}
|
||||
|
||||
return nextId
|
||||
}
|
||||
|
||||
export function useAnimalVm(
|
||||
animalIds: number[],
|
||||
onSelect?: (animalId: number) => void,
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const markSoundPlaybackUnlocked = useAudioStore(
|
||||
(state) => state.markSoundPlaybackUnlocked,
|
||||
)
|
||||
const isLoginModalOpen = useModalStore((state) => state.modals.desktopLogin)
|
||||
const isRegisterModalOpen = useModalStore(
|
||||
(state) => state.modals.desktopRegister,
|
||||
)
|
||||
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)
|
||||
const roundPhase = useGameRoundStore((state) => state.round.phase)
|
||||
const maxSelectionCount = useGameRoundStore(
|
||||
(state) => state.maxSelectionCount,
|
||||
)
|
||||
const placeBet = useGameRoundStore((state) => state.placeBet)
|
||||
const removeSelectionsForCell = useGameRoundStore(
|
||||
(state) => state.removeSelectionsForCell,
|
||||
)
|
||||
const selections = useGameRoundStore((state) => state.selections)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
const connection = useGameSessionStore((state) => state.connection)
|
||||
const tableLimitMax = useGameSessionStore(
|
||||
(state) => state.dashboard.tableLimitMax,
|
||||
)
|
||||
const requestRealtimeConnection = useGameSessionStore(
|
||||
(state) => state.requestRealtimeConnection,
|
||||
)
|
||||
const shouldConnectRealtime = useGameSessionStore(
|
||||
(state) => state.shouldConnectRealtime,
|
||||
)
|
||||
const [marqueeId, setMarqueeId] = useState<number | null>(() =>
|
||||
getNextMarqueeId(animalIds, null),
|
||||
)
|
||||
const [cellWarning, setCellWarning] = useState<{
|
||||
cellId: number
|
||||
type: DesktopAnimalWarningType
|
||||
} | null>(null)
|
||||
|
||||
const activeChip = useMemo(
|
||||
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
|
||||
[activeChipId, chips],
|
||||
)
|
||||
const balance = parseBalance(currentUser?.coin)
|
||||
const selectionByCell = useMemo(() => {
|
||||
return selections.reduce<Record<number, { amount: number; count: number }>>(
|
||||
(accumulator, selection) => {
|
||||
const current = accumulator[selection.cellId] ?? { amount: 0, count: 0 }
|
||||
|
||||
accumulator[selection.cellId] = {
|
||||
amount: current.amount + selection.amount,
|
||||
count: current.count + 1,
|
||||
}
|
||||
|
||||
return accumulator
|
||||
},
|
||||
{},
|
||||
)
|
||||
}, [selections])
|
||||
|
||||
const isRealtimeConnected = connection.status === 'connected'
|
||||
const isRealtimeConnecting =
|
||||
shouldConnectRealtime &&
|
||||
(connection.status === 'connecting' || connection.status === 'reconnecting')
|
||||
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
|
||||
const isAuthModalOpen = isLoginModalOpen || isRegisterModalOpen
|
||||
const shouldAnimateStandby = showStandbyState && !isAuthModalOpen
|
||||
const hasSubmittedCurrentRound =
|
||||
Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId
|
||||
const lockInteraction =
|
||||
showStandbyState || hasSubmittedCurrentRound || roundPhase !== 'betting'
|
||||
const selectedCellCount = Object.keys(selectionByCell).length
|
||||
|
||||
useEffect(() => {
|
||||
if (cellWarning === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
setCellWarning((currentWarning) =>
|
||||
currentWarning?.cellId === cellWarning.cellId &&
|
||||
currentWarning.type === cellWarning.type
|
||||
? null
|
||||
: currentWarning,
|
||||
)
|
||||
}, 1200)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
}, [cellWarning])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAnimateStandby) {
|
||||
setMarqueeId(null)
|
||||
return
|
||||
}
|
||||
|
||||
setMarqueeId((currentId) => getNextMarqueeId(animalIds, currentId))
|
||||
|
||||
let timerId = 0
|
||||
|
||||
const loop = () => {
|
||||
setMarqueeId((currentId) => getNextMarqueeId(animalIds, currentId))
|
||||
timerId = window.setTimeout(loop, 180 + Math.floor(Math.random() * 220))
|
||||
}
|
||||
|
||||
timerId = window.setTimeout(loop, 220)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
}, [animalIds, shouldAnimateStandby])
|
||||
|
||||
const handleStart = () => {
|
||||
if (authStatus !== 'authenticated') {
|
||||
notify.warning(t('commonUi.toast.loginRequired'))
|
||||
setModalOpen('desktopLogin', true)
|
||||
return
|
||||
}
|
||||
|
||||
clearSelections()
|
||||
markSoundPlaybackUnlocked()
|
||||
requestRealtimeConnection()
|
||||
}
|
||||
|
||||
const handleSelect = (animalId: number) => {
|
||||
if (roundPhase !== 'betting' || lockInteraction) {
|
||||
return
|
||||
}
|
||||
|
||||
if (onSelect) {
|
||||
onSelect(animalId)
|
||||
return
|
||||
}
|
||||
|
||||
if (selectionByCell[animalId]) {
|
||||
removeSelectionsForCell(animalId)
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedCellCount >= maxSelectionCount) {
|
||||
setCellWarning({
|
||||
cellId: animalId,
|
||||
type: 'limit',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextBetAmount = (activeChip?.amount ?? 0) * activeBetQuantity
|
||||
|
||||
if (tableLimitMax > 0 && totalBetAmount + nextBetAmount > tableLimitMax) {
|
||||
setCellWarning({
|
||||
cellId: animalId,
|
||||
type: 'betLimit',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBetAmount + nextBetAmount > balance) {
|
||||
setCellWarning({
|
||||
cellId: animalId,
|
||||
type: 'balance',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
placeBet(animalId)
|
||||
}
|
||||
|
||||
return {
|
||||
cellWarning,
|
||||
handleSelect,
|
||||
handleStart,
|
||||
isRealtimeConnecting,
|
||||
lockInteraction,
|
||||
marqueeId: shouldAnimateStandby ? marqueeId : null,
|
||||
selectionByCell,
|
||||
showStandbyState,
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useLocation } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DEFAULT_APP_LANGUAGE,
|
||||
LANGUAGE_OPTIONS,
|
||||
SUPPORTED_LANGUAGES,
|
||||
} from '@/constants'
|
||||
import type { AppLanguage } from '@/i18n'
|
||||
|
||||
const languagePrefixPattern = new RegExp(
|
||||
`^/(${SUPPORTED_LANGUAGES.join('|')})(?=/|$)`,
|
||||
)
|
||||
|
||||
function resolveNextPathname(pathname: string, language: AppLanguage) {
|
||||
if (languagePrefixPattern.test(pathname)) {
|
||||
return pathname.replace(languagePrefixPattern, `/${language}`)
|
||||
}
|
||||
|
||||
return `/${language}${pathname.startsWith('/') ? pathname : `/${pathname}`}`
|
||||
}
|
||||
|
||||
export function useAppLanguage() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
const currentLanguage = (i18n.resolvedLanguage ??
|
||||
i18n.language ??
|
||||
DEFAULT_APP_LANGUAGE) as AppLanguage
|
||||
|
||||
const currentLanguageOption = useMemo(
|
||||
() =>
|
||||
LANGUAGE_OPTIONS.find((option) => option.code === currentLanguage) ??
|
||||
LANGUAGE_OPTIONS.find((option) => option.code === DEFAULT_APP_LANGUAGE) ??
|
||||
LANGUAGE_OPTIONS[0],
|
||||
[currentLanguage],
|
||||
)
|
||||
|
||||
const selectLanguage = async (language: AppLanguage) => {
|
||||
if (language === currentLanguage) {
|
||||
return
|
||||
}
|
||||
|
||||
await i18n.changeLanguage(language)
|
||||
|
||||
const nextPathname = resolveNextPathname(location.pathname, language)
|
||||
|
||||
window.location.assign(
|
||||
`${nextPathname}${window.location.search}${window.location.hash}`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
currentLanguage,
|
||||
currentLanguageLabel: t(currentLanguageOption.labelKey),
|
||||
currentLanguageOption,
|
||||
languageOptions: LANGUAGE_OPTIONS,
|
||||
selectLanguage,
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { placeGameBet } from '@/features/game'
|
||||
import type { BetSelection } from '@/features/game/shared'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import {
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function createIdempotencyKey() {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
) {
|
||||
return `auto-bet-${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
return `auto-bet-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function toBetId(chipId: string) {
|
||||
const match = chipId.match(/^chip-(\d+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const betId = Number(match[1])
|
||||
|
||||
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, { amount: number; betId: number; numbers: number[] }>
|
||||
>((accumulator, selection) => {
|
||||
const betId = toBetId(selection.chipId)
|
||||
|
||||
if (betId === null) {
|
||||
return accumulator
|
||||
}
|
||||
|
||||
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() {
|
||||
const { t } = useTranslation()
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const tableLimitMax = useGameSessionStore(
|
||||
(state) => state.dashboard.tableLimitMax,
|
||||
)
|
||||
const lastSingleWinAmount = useGameAutoHostingStore(
|
||||
(state) => state.lastSingleWinAmount,
|
||||
)
|
||||
const lastIsJackpot = useGameAutoHostingStore((state) => state.lastIsJackpot)
|
||||
const isHosting = useGameAutoHostingStore((state) => state.isHosting)
|
||||
const lastSubmittedRoundId = useGameAutoHostingStore(
|
||||
(state) => state.lastSubmittedRoundId,
|
||||
)
|
||||
const rules = useGameAutoHostingStore((state) => state.rules)
|
||||
const selections = useGameAutoHostingStore((state) => state.selections)
|
||||
const markRoundSubmitted = useGameAutoHostingStore(
|
||||
(state) => state.markRoundSubmitted,
|
||||
)
|
||||
const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
|
||||
const inFlightRoundIdRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHosting) {
|
||||
return
|
||||
}
|
||||
|
||||
const balance = parseBalance(currentUser?.coin)
|
||||
|
||||
if (
|
||||
rules.stopIfBalanceBelow.enabled &&
|
||||
balance < rules.stopIfBalanceBelow.amount
|
||||
) {
|
||||
stopHosting()
|
||||
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
rules.stopIfSingleWinAbove.enabled &&
|
||||
lastSingleWinAmount !== null &&
|
||||
lastSingleWinAmount > rules.stopIfSingleWinAbove.amount
|
||||
) {
|
||||
stopHosting()
|
||||
notify.success(t('commonUi.toast.autoHostingStoppedWin'))
|
||||
return
|
||||
}
|
||||
|
||||
if (rules.stopOnJackpot && lastIsJackpot === true) {
|
||||
stopHosting()
|
||||
notify.success(t('commonUi.toast.autoHostingStoppedJackpot'))
|
||||
return
|
||||
}
|
||||
}, [
|
||||
currentUser?.coin,
|
||||
isHosting,
|
||||
lastIsJackpot,
|
||||
lastSingleWinAmount,
|
||||
rules,
|
||||
stopHosting,
|
||||
t,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isHosting ||
|
||||
inFlightRoundIdRef.current !== null ||
|
||||
authStatus !== 'authenticated' ||
|
||||
!currentUser ||
|
||||
round.phase !== 'betting' ||
|
||||
!round.id ||
|
||||
lastSubmittedRoundId === round.id
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const groupedSelections = groupSelections(selections)
|
||||
|
||||
if (groupedSelections.size === 0) {
|
||||
stopHosting()
|
||||
notify.warning(t('commonUi.toast.autoHostingStopped'))
|
||||
return
|
||||
}
|
||||
|
||||
const totalBetAmount = selections.reduce(
|
||||
(total, selection) => total + selection.amount,
|
||||
0,
|
||||
)
|
||||
const balance = parseBalance(currentUser.coin)
|
||||
|
||||
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
|
||||
stopHosting()
|
||||
notify.warning(t('commonUi.toast.autoHostingStoppedBetLimit'))
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBetAmount > balance) {
|
||||
stopHosting()
|
||||
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
|
||||
return
|
||||
}
|
||||
|
||||
const submittingRoundId = round.id
|
||||
inFlightRoundIdRef.current = submittingRoundId
|
||||
|
||||
const submitAutoBet = async () => {
|
||||
try {
|
||||
let latestBalance = currentUser.coin ?? '0'
|
||||
|
||||
for (const group of groupedSelections.values()) {
|
||||
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') {
|
||||
throw new Error(t('commonUi.toast.betRejected'))
|
||||
}
|
||||
|
||||
latestBalance = result.balance_after
|
||||
}
|
||||
|
||||
const latestHostingState = useGameAutoHostingStore.getState()
|
||||
const latestUser = useAuthStore.getState().currentUser
|
||||
|
||||
if (
|
||||
!latestHostingState.isHosting ||
|
||||
latestHostingState.lastSubmittedRoundId === submittingRoundId ||
|
||||
!latestUser
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentUser({
|
||||
...latestUser,
|
||||
coin: latestBalance,
|
||||
lastBetPeriodNo: submittingRoundId,
|
||||
})
|
||||
markRoundSubmitted(submittingRoundId, parseBalance(latestBalance))
|
||||
clearSelections()
|
||||
} catch (error) {
|
||||
if (useGameAutoHostingStore.getState().isHosting) {
|
||||
stopHosting()
|
||||
notify.error(t('commonUi.toast.autoHostingSubmitFailed'), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (inFlightRoundIdRef.current === submittingRoundId) {
|
||||
inFlightRoundIdRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void submitAutoBet()
|
||||
}, [
|
||||
authStatus,
|
||||
clearSelections,
|
||||
currentUser,
|
||||
isHosting,
|
||||
lastSubmittedRoundId,
|
||||
markRoundSubmitted,
|
||||
round.id,
|
||||
round.phase,
|
||||
selections,
|
||||
setCurrentUser,
|
||||
stopHosting,
|
||||
tableLimitMax,
|
||||
t,
|
||||
])
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
DEFAULT_APP_LANGUAGE,
|
||||
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
|
||||
} from '@/constants'
|
||||
import { getDepositTierList } from '@/features/game/api'
|
||||
|
||||
export function useDepositTierList() {
|
||||
const { i18n } = useTranslation()
|
||||
const language =
|
||||
i18n.resolvedLanguage ?? i18n.language ?? DEFAULT_APP_LANGUAGE
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['finance', 'deposit-tier-list', language],
|
||||
queryFn: () => getDepositTierList(),
|
||||
staleTime: FINANCE_CONFIG_QUERY_STALE_TIME_MS,
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
DEFAULT_APP_LANGUAGE,
|
||||
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
|
||||
} from '@/constants'
|
||||
import { getDepositWithdrawConfig } from '@/features/game/api'
|
||||
|
||||
export function useDepositWithdrawConfig() {
|
||||
const { i18n } = useTranslation()
|
||||
const language =
|
||||
i18n.resolvedLanguage ?? i18n.language ?? DEFAULT_APP_LANGUAGE
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['finance', 'deposit-withdraw-config', language],
|
||||
queryFn: () => getDepositWithdrawConfig(),
|
||||
staleTime: FINANCE_CONFIG_QUERY_STALE_TIME_MS,
|
||||
})
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
|
||||
import { getDepositOrderList, getWithdrawOrderList } from '@/features/game/api'
|
||||
|
||||
export type FinanceRecordType = 'deposit' | 'withdraw'
|
||||
|
||||
const FINANCE_RECORD_TYPE_OPTIONS: Array<{
|
||||
key: FinanceRecordType
|
||||
labelKey: string
|
||||
}> = [
|
||||
{
|
||||
key: 'deposit',
|
||||
labelKey: 'game.modals.userInfo.financeRecords.deposit',
|
||||
},
|
||||
{
|
||||
key: 'withdraw',
|
||||
labelKey: 'game.modals.userInfo.financeRecords.withdraw',
|
||||
},
|
||||
]
|
||||
|
||||
function formatFinanceAmount(value: string, locale: string) {
|
||||
const numberValue = Number(value)
|
||||
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
return value || '--'
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(numberValue)
|
||||
}
|
||||
|
||||
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 query = useInfiniteQuery({
|
||||
queryKey: ['finance', 'user-info-order-list', recordType],
|
||||
initialPageParam: 1,
|
||||
queryFn: ({ pageParam }) =>
|
||||
recordType === 'deposit'
|
||||
? getDepositOrderList({
|
||||
page: pageParam,
|
||||
pageSize: DEFAULT_LIST_PAGE_SIZE,
|
||||
})
|
||||
: getWithdrawOrderList({
|
||||
page: pageParam,
|
||||
pageSize: DEFAULT_LIST_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 lastPage = query.data?.pages.at(-1)
|
||||
const total = lastPage?.pagination.total ?? 0
|
||||
const loadedPage = lastPage?.pagination.page ?? 1
|
||||
|
||||
const recordTypes = useMemo(
|
||||
() =>
|
||||
FINANCE_RECORD_TYPE_OPTIONS.map((option) => ({
|
||||
key: option.key,
|
||||
label: t(option.labelKey),
|
||||
})),
|
||||
[t],
|
||||
)
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
(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)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setRecordType('deposit')
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return {
|
||||
emptyText: t('game.modals.userInfo.financeRecords.empty'),
|
||||
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,
|
||||
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: loadedPage,
|
||||
total,
|
||||
}),
|
||||
recordType,
|
||||
recordTypes,
|
||||
selectRecordType,
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants'
|
||||
import { placeGameBet } from '@/features/game'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
import {
|
||||
selectSelectionTotal,
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
|
||||
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'limit' | 'submitting'
|
||||
|
||||
function formatChipDisplayValue(amount: number) {
|
||||
if (Number.isInteger(amount)) {
|
||||
return String(amount)
|
||||
}
|
||||
|
||||
return amount.toFixed(2).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function createIdempotencyKey() {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
) {
|
||||
return `bet-${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
return `bet-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function toBetId(chipId: string) {
|
||||
const match = chipId.match(/^chip-(\d+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const betId = Number(match[1])
|
||||
|
||||
return Number.isInteger(betId) && betId >= 1 && betId <= 6 ? betId : null
|
||||
}
|
||||
|
||||
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(
|
||||
(state) => state.restoreRecentSuccessfulSelections,
|
||||
)
|
||||
const setRecentSuccessfulSelections = useGameRoundStore(
|
||||
(state) => state.setRecentSuccessfulSelections,
|
||||
)
|
||||
const isAutoHosting = useGameAutoHostingStore((state) => state.isHosting)
|
||||
const selectChip = useGameRoundStore((state) => state.selectChip)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
const connectionStatus = useGameSessionStore(
|
||||
(state) => state.connection.status,
|
||||
)
|
||||
const tableLimitMax = useGameSessionStore(
|
||||
(state) => state.dashboard.tableLimitMax,
|
||||
)
|
||||
const shouldConnectRealtime = useGameSessionStore(
|
||||
(state) => state.shouldConnectRealtime,
|
||||
)
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const chipItems = useMemo(() => {
|
||||
const items = chips.map((chip) => ({
|
||||
amount: chip.amount,
|
||||
id: chip.id,
|
||||
isSelected: chip.id === activeChipId,
|
||||
src: CHIP_IMAGE_MAP.get(chip.id) ?? CHIP_IMAGE_OPTIONS[0]?.src ?? '',
|
||||
valueLabel: formatChipDisplayValue(chip.amount),
|
||||
}))
|
||||
|
||||
return items.sort((left, right) => {
|
||||
if (left.isSelected === right.isSelected) {
|
||||
return left.id.localeCompare(right.id, undefined, { numeric: true })
|
||||
}
|
||||
|
||||
return left.isSelected ? 1 : -1
|
||||
})
|
||||
}, [activeChipId, chips])
|
||||
|
||||
const selectedChip =
|
||||
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
|
||||
const balance = parseBalance(currentUser?.coin)
|
||||
const hasSelections = selections.length > 0
|
||||
const hasEnteredGame =
|
||||
shouldConnectRealtime && connectionStatus === 'connected'
|
||||
const hasSubmittedCurrentRound =
|
||||
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
|
||||
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
|
||||
const hasExceededBetLimit =
|
||||
hasSelections && tableLimitMax > 0 && totalBetAmount > tableLimitMax
|
||||
const confirmState: ConfirmState =
|
||||
isSubmitting || isAutoHosting
|
||||
? 'submitting'
|
||||
: !hasSelections
|
||||
? 'idle'
|
||||
: hasExceededBetLimit
|
||||
? 'limit'
|
||||
: hasInsufficientBalance
|
||||
? 'insufficient'
|
||||
: 'ready'
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (confirmState === 'submitting' || !hasSelections) {
|
||||
return
|
||||
}
|
||||
|
||||
if (authStatus !== 'authenticated') {
|
||||
notify.warning(t('commonUi.toast.loginRequired'))
|
||||
setModalOpen('desktopLogin', true)
|
||||
return
|
||||
}
|
||||
|
||||
if (hasExceededBetLimit) {
|
||||
notify.warning(t('commonUi.toast.betLimitExceeded'))
|
||||
return
|
||||
}
|
||||
|
||||
if (hasInsufficientBalance) {
|
||||
notify.warning(t('commonUi.toast.insufficientBalance'))
|
||||
return
|
||||
}
|
||||
|
||||
if (round.phase !== 'betting' || !round.id) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
if (hasSubmittedCurrentRound) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
const betId = toBetId(selections[0]?.chipId ?? activeChipId)
|
||||
const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0
|
||||
|
||||
if (betId === null || singleBetAmount <= 0) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
let latestBalance = currentUser?.coin ?? '0'
|
||||
|
||||
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 (currentUser) {
|
||||
setCurrentUser({
|
||||
...currentUser,
|
||||
coin: latestBalance,
|
||||
lastBetPeriodNo: round.id,
|
||||
})
|
||||
}
|
||||
|
||||
setRecentSuccessfulSelections(selections)
|
||||
clearSelections()
|
||||
notify.success(t('commonUi.toast.betPlaced'))
|
||||
} catch (error) {
|
||||
notify.error(t('commonUi.toast.betPlaceFailed'), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [
|
||||
activeChipId,
|
||||
authStatus,
|
||||
clearSelections,
|
||||
confirmState,
|
||||
currentUser,
|
||||
hasInsufficientBalance,
|
||||
hasExceededBetLimit,
|
||||
hasSelections,
|
||||
hasSubmittedCurrentRound,
|
||||
round.id,
|
||||
round.phase,
|
||||
selections,
|
||||
selectedChip?.amount,
|
||||
setRecentSuccessfulSelections,
|
||||
setCurrentUser,
|
||||
setModalOpen,
|
||||
t,
|
||||
])
|
||||
|
||||
const handleRepeatSelections = useCallback(() => {
|
||||
if (round.phase !== 'betting' || hasSubmittedCurrentRound) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
const restored = restoreRecentSuccessfulSelections()
|
||||
|
||||
if (!restored) {
|
||||
notify.warning(t('commonUi.toast.noRecentSuccessfulBet'))
|
||||
return
|
||||
}
|
||||
|
||||
notify.success(t('commonUi.toast.repeatSelectionsRestored'))
|
||||
}, [
|
||||
hasSubmittedCurrentRound,
|
||||
restoreRecentSuccessfulSelections,
|
||||
round.phase,
|
||||
t,
|
||||
])
|
||||
|
||||
const handleOpenAutoSetting = useCallback(() => {
|
||||
if (!hasSelections) {
|
||||
notify.warning(t('commonUi.toast.selectNumbersBeforeAutoHosting'))
|
||||
return
|
||||
}
|
||||
|
||||
setModalOpen('desktopAutoSetting', true)
|
||||
}, [hasSelections, setModalOpen, t])
|
||||
|
||||
return {
|
||||
acceptingBets:
|
||||
round.phase === 'betting' && !hasSubmittedCurrentRound && !isAutoHosting,
|
||||
actionsEnabled:
|
||||
hasEnteredGame &&
|
||||
round.phase === 'betting' &&
|
||||
!hasSubmittedCurrentRound &&
|
||||
!isAutoHosting,
|
||||
canClear:
|
||||
selections.length > 0 &&
|
||||
round.phase === 'betting' &&
|
||||
!hasSubmittedCurrentRound &&
|
||||
!isAutoHosting,
|
||||
canDecreaseBetQuantity: activeBetQuantity > 1,
|
||||
confirmLabel:
|
||||
confirmState === 'idle'
|
||||
? t('gameDesktop.control.selectNumbers')
|
||||
: confirmState === 'insufficient'
|
||||
? t('gameDesktop.control.insufficientBalance')
|
||||
: confirmState === 'limit'
|
||||
? t('gameDesktop.control.betLimitExceeded')
|
||||
: confirmState === 'submitting'
|
||||
? t('gameDesktop.control.submitting')
|
||||
: t('gameDesktop.control.confirm'),
|
||||
confirmState,
|
||||
isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
|
||||
onChipSelect: selectChip,
|
||||
onDecreaseBetQuantity: () => adjustBetQuantity(-1),
|
||||
onIncreaseBetQuantity: () => adjustBetQuantity(1),
|
||||
onConfirm: handleConfirm,
|
||||
onClearSelections: clearSelections,
|
||||
onOpenAutoSetting: handleOpenAutoSetting,
|
||||
onRepeatSelections: handleRepeatSelections,
|
||||
maxSelectionCountLabel: maxSelectionCount,
|
||||
selectedBetQuantityLabel: activeBetQuantity,
|
||||
selectedChipId: activeChipId,
|
||||
selectedCountLabel: selections.length,
|
||||
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
|
||||
chips: chipItems,
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { GAME_HISTORY_PAGE_SIZE } from '@/constants'
|
||||
import { getGameBetMyOrders } from '@/features/game/api/game-api'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore } from '@/store/game'
|
||||
|
||||
function formatCreatedTime(timestamp: number, locale: string) {
|
||||
const date = new Date(timestamp * 1000)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return date.toLocaleString(locale, {
|
||||
hour12: false,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatNumbers(numbers: number[]) {
|
||||
if (numbers.length === 0) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return numbers.map((number) => String(number).padStart(2, '0')).join(', ')
|
||||
}
|
||||
|
||||
type HistoryResultState = 'lost' | 'pending' | 'win'
|
||||
|
||||
export function useGameHistoryVm() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const accessToken = useAuthStore((state) => state.accessToken)
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
|
||||
const revealRoundId = useGameRoundStore(
|
||||
(state) => state.revealAnimation.roundId,
|
||||
)
|
||||
const lastRevealedRoundRef = useRef<string | null>(null)
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['game', 'bet-my-orders', accessToken],
|
||||
enabled: authStatus === 'authenticated' && Boolean(accessToken),
|
||||
initialPageParam: 1,
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
getGameBetMyOrders({
|
||||
page: pageParam,
|
||||
pageSize: GAME_HISTORY_PAGE_SIZE,
|
||||
}),
|
||||
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 items = useMemo(
|
||||
() =>
|
||||
(query.data?.pages ?? []).flatMap((page) =>
|
||||
page.list.map((entry) => {
|
||||
const shouldHideResult =
|
||||
entry.period_no === revealRoundId && revealPhase !== 'result'
|
||||
const resultNumber = shouldHideResult ? null : entry.result_number
|
||||
|
||||
return {
|
||||
amountLabel: entry.total_amount,
|
||||
createdAtLabel: formatCreatedTime(
|
||||
entry.create_time,
|
||||
i18n.resolvedLanguage ?? 'en-US',
|
||||
),
|
||||
id: entry.order_no,
|
||||
resultState:
|
||||
resultNumber === null
|
||||
? ('pending' satisfies HistoryResultState)
|
||||
: entry.numbers.includes(resultNumber)
|
||||
? ('win' satisfies HistoryResultState)
|
||||
: ('lost' satisfies HistoryResultState),
|
||||
numbersLabel: formatNumbers(entry.numbers),
|
||||
numbers: entry.numbers,
|
||||
orderNo: entry.order_no,
|
||||
periodNo: entry.period_no,
|
||||
resultNumber,
|
||||
resultNumberLabel:
|
||||
resultNumber === null
|
||||
? '--'
|
||||
: String(resultNumber).padStart(2, '0'),
|
||||
winAmountLabel: entry.win_amount,
|
||||
}
|
||||
}),
|
||||
),
|
||||
[i18n.resolvedLanguage, query.data?.pages, revealPhase, revealRoundId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (revealPhase !== 'result' || !revealRoundId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lastRevealedRoundRef.current === revealRoundId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (authStatus !== 'authenticated' || query.isFetching || query.isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
lastRevealedRoundRef.current = revealRoundId
|
||||
|
||||
void query.refetch()
|
||||
}, [
|
||||
authStatus,
|
||||
query.isFetching,
|
||||
query.isLoading,
|
||||
query.refetch,
|
||||
revealPhase,
|
||||
revealRoundId,
|
||||
])
|
||||
|
||||
return {
|
||||
emptyText: t('gameDesktop.history.empty'),
|
||||
endText: t('gameDesktop.history.end'),
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
hasNextPage: query.hasNextPage,
|
||||
isEmpty: authStatus !== 'authenticated' || items.length === 0,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isInitialLoading: query.isLoading,
|
||||
items,
|
||||
loadingText: t('gameDesktop.history.loading'),
|
||||
}
|
||||
}
|
||||
@@ -1,913 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
FALLBACK_POLL_INTERVAL_MS,
|
||||
GAME_SOCKET_TOPIC_VALUES,
|
||||
GAME_SOCKET_TOPICS,
|
||||
PLAYER_SOCKET_TOPICS,
|
||||
SOCKET_DISCONNECT_DELAY_MS,
|
||||
} from '@/constants'
|
||||
import i18n from '@/i18n'
|
||||
import { prefetchAuthToken } from '@/lib/api/api-client'
|
||||
import {
|
||||
GameSocketClient,
|
||||
type GameSocketMessage,
|
||||
} from '@/lib/ws/game-socket-client'
|
||||
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
|
||||
import {
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
import { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api'
|
||||
import type {
|
||||
BetWinEventDataDto,
|
||||
GamePeriodTickDto,
|
||||
JackpotHitEventDataDto,
|
||||
JackpotHitItemDto,
|
||||
} from '../api/types'
|
||||
|
||||
type UserStreakMessageData = {
|
||||
currentStreak: number
|
||||
oddsFactor?: number
|
||||
streakLevel?: number
|
||||
}
|
||||
|
||||
type PeriodEventData = {
|
||||
openTime: number | null
|
||||
periodNo: string
|
||||
resultNumber: number | null
|
||||
}
|
||||
|
||||
type WalletChangedData = {
|
||||
coin: string
|
||||
}
|
||||
|
||||
let sharedSocketClient: GameSocketClient | null = null
|
||||
let sharedSocketKey: string | null = null
|
||||
let sharedSocketDisconnectTimerId: number | null = null
|
||||
|
||||
function toIsoFromUnixSeconds(seconds: number) {
|
||||
return new Date(seconds * 1000).toISOString()
|
||||
}
|
||||
|
||||
function toSocketLang(language: string | null | undefined) {
|
||||
return language?.startsWith('zh') ? 'zh' : 'en'
|
||||
}
|
||||
|
||||
function toOptionalNumber(value: unknown) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toOptionalString(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toOptionalBoolean(value: unknown) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value === 1 || value === '1' || value === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (value === 0 || value === '0' || value === 'false') {
|
||||
return false
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getNestedRecord(
|
||||
value: unknown,
|
||||
key: string,
|
||||
): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const nested = (value as Record<string, unknown>)[key]
|
||||
|
||||
return nested && typeof nested === 'object'
|
||||
? (nested as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function getMessageTopic(message: GameSocketMessage) {
|
||||
const root = message as Record<string, unknown>
|
||||
const event = typeof root.event === 'string' ? root.event : null
|
||||
const topic = typeof root.topic === 'string' ? root.topic : null
|
||||
|
||||
if (event && GAME_SOCKET_TOPIC_VALUES.has(event)) {
|
||||
return event
|
||||
}
|
||||
|
||||
if (topic && GAME_SOCKET_TOPIC_VALUES.has(topic)) {
|
||||
return topic
|
||||
}
|
||||
|
||||
return event ?? topic
|
||||
}
|
||||
|
||||
function extractServerTime(message: GameSocketMessage) {
|
||||
const root = message as Record<string, unknown>
|
||||
|
||||
if (typeof root.server_time === 'number') {
|
||||
return root.server_time
|
||||
}
|
||||
|
||||
const data = getNestedRecord(message, 'data')
|
||||
|
||||
return typeof data?.server_time === 'number' ? data.server_time : null
|
||||
}
|
||||
|
||||
function extractUserStreakMessageData(
|
||||
message: GameSocketMessage,
|
||||
): UserStreakMessageData | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const direct = getNestedRecord(message, 'user_snapshot')
|
||||
const nested = getNestedRecord(data, 'user_snapshot')
|
||||
const source =
|
||||
data && 'current_streak' in data ? data : (nested ?? direct ?? data)
|
||||
|
||||
if (!source || typeof source.current_streak !== 'number') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
currentStreak: source.current_streak,
|
||||
oddsFactor: toOptionalNumber(source.odds_factor),
|
||||
streakLevel: toOptionalNumber(source.streak_level),
|
||||
}
|
||||
}
|
||||
|
||||
function extractPeriodTick(
|
||||
message: GameSocketMessage,
|
||||
): GamePeriodTickDto | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const nested = getNestedRecord(data, 'period')
|
||||
const source = nested ?? data
|
||||
|
||||
if (
|
||||
!source ||
|
||||
typeof source.period_no !== 'string' ||
|
||||
typeof source.status !== 'string' ||
|
||||
typeof source.countdown !== 'number' ||
|
||||
typeof source.bet_close_in !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
bet_close_in: source.bet_close_in,
|
||||
countdown: source.countdown,
|
||||
period_id: typeof source.period_id === 'number' ? source.period_id : null,
|
||||
period_no: source.period_no,
|
||||
result_number:
|
||||
typeof source.result_number === 'number' ? source.result_number : null,
|
||||
runtime_enabled:
|
||||
typeof source.runtime_enabled === 'boolean'
|
||||
? source.runtime_enabled
|
||||
: true,
|
||||
server_time:
|
||||
typeof source.server_time === 'number'
|
||||
? source.server_time
|
||||
: Math.floor(Date.now() / 1000),
|
||||
status: source.status as GamePeriodTickDto['status'],
|
||||
}
|
||||
}
|
||||
|
||||
function extractPeriodEventData(
|
||||
message: GameSocketMessage,
|
||||
): PeriodEventData | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const source = data ?? (message as Record<string, unknown>)
|
||||
const periodNo =
|
||||
typeof source.period_no === 'string'
|
||||
? source.period_no
|
||||
: typeof source.periodNo === 'string'
|
||||
? source.periodNo
|
||||
: null
|
||||
|
||||
if (!periodNo) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resultNumber = toOptionalNumber(
|
||||
source.result_number ?? source.resultNumber,
|
||||
)
|
||||
const openTime = toOptionalNumber(source.open_time ?? source.openTime)
|
||||
|
||||
return {
|
||||
openTime: openTime ?? null,
|
||||
periodNo,
|
||||
resultNumber:
|
||||
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
||||
? resultNumber
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function extractWalletChangedData(
|
||||
message: GameSocketMessage,
|
||||
): WalletChangedData | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const source = data ?? (message as Record<string, unknown>)
|
||||
const coin = source.coin ?? source.balance ?? source.balance_after
|
||||
const normalizedCoin =
|
||||
typeof coin === 'string'
|
||||
? coin
|
||||
: typeof coin === 'number' && Number.isFinite(coin)
|
||||
? String(coin)
|
||||
: null
|
||||
|
||||
if (normalizedCoin === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
coin: normalizedCoin,
|
||||
}
|
||||
}
|
||||
|
||||
function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const source = value as Record<string, unknown>
|
||||
|
||||
if (
|
||||
typeof source.nickname !== 'string' ||
|
||||
typeof source.period_no !== 'string' ||
|
||||
typeof source.total_win !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resultNumber = toOptionalNumber(source.result_number)
|
||||
|
||||
if (typeof resultNumber !== 'number' || !Number.isInteger(resultNumber)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
nickname: source.nickname,
|
||||
period_no: source.period_no,
|
||||
result_number: resultNumber,
|
||||
total_win: source.total_win,
|
||||
}
|
||||
}
|
||||
|
||||
function extractJackpotHitData(
|
||||
message: GameSocketMessage,
|
||||
): JackpotHitEventDataDto | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
|
||||
if (!data || typeof data.period_no !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestedHits = data.hits
|
||||
const sourceHits = Array.isArray(nestedHits)
|
||||
? nestedHits
|
||||
: nestedHits && typeof nestedHits === 'object'
|
||||
? [nestedHits]
|
||||
: [data]
|
||||
const firstHitSource = sourceHits.find(
|
||||
(item): item is Record<string, unknown> =>
|
||||
Boolean(item) && typeof item === 'object',
|
||||
)
|
||||
const hits = sourceHits
|
||||
.map((item) => extractJackpotHitItem(item))
|
||||
.filter((item): item is JackpotHitItemDto => item !== null)
|
||||
const root = message as Record<string, unknown>
|
||||
const serverTime = toOptionalNumber(
|
||||
data.server_time ?? firstHitSource?.server_time ?? root.server_time,
|
||||
)
|
||||
const resultNumber = toOptionalNumber(
|
||||
data.result_number ?? data['result number'],
|
||||
)
|
||||
|
||||
if (typeof serverTime !== 'number') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
hits,
|
||||
period_id:
|
||||
typeof data.period_id === 'number' && Number.isInteger(data.period_id)
|
||||
? data.period_id
|
||||
: null,
|
||||
period_no: data.period_no,
|
||||
result_number:
|
||||
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
||||
? resultNumber
|
||||
: null,
|
||||
server_time: serverTime,
|
||||
}
|
||||
}
|
||||
|
||||
function extractBetWinData(
|
||||
message: GameSocketMessage,
|
||||
): BetWinEventDataDto | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const root = message as Record<string, unknown>
|
||||
const userId = toOptionalNumber(data.user_id)
|
||||
const periodId = toOptionalNumber(data.period_id)
|
||||
const resultNumber = toOptionalNumber(data.result_number)
|
||||
const totalWin = toOptionalString(data.total_win)
|
||||
const balanceAfter = toOptionalString(data.balance_after)
|
||||
const serverTime = toOptionalNumber(data.server_time ?? root.server_time)
|
||||
const currentStreak = toOptionalNumber(data.current_streak)
|
||||
const streakLevel = toOptionalNumber(data.streak_level)
|
||||
const oddsFactor = toOptionalNumber(data.odds_factor)
|
||||
const isJackpot = toOptionalBoolean(data.is_jackpot)
|
||||
|
||||
if (
|
||||
typeof totalWin !== 'string' ||
|
||||
typeof data.period_no !== 'string' ||
|
||||
typeof isJackpot !== 'boolean'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
balance_after: balanceAfter,
|
||||
bets: Array.isArray(data.bets)
|
||||
? data.bets
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const bet = item as Record<string, unknown>
|
||||
const betId = toOptionalNumber(bet.bet_id)
|
||||
const winAmount = toOptionalString(bet.win_amount)
|
||||
|
||||
return typeof betId === 'number' && typeof winAmount === 'string'
|
||||
? {
|
||||
bet_id: betId,
|
||||
win_amount: winAmount,
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter(
|
||||
(item): item is BetWinEventDataDto['bets'][number] => item !== null,
|
||||
)
|
||||
: [],
|
||||
current_streak: currentStreak,
|
||||
is_jackpot: isJackpot,
|
||||
is_win: toOptionalBoolean(data.is_win) ?? true,
|
||||
odds_factor: typeof oddsFactor === 'number' ? oddsFactor : undefined,
|
||||
payout_pending_review:
|
||||
toOptionalBoolean(data.payout_pending_review) ?? false,
|
||||
period_id: periodId,
|
||||
period_no: data.period_no,
|
||||
result_number:
|
||||
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
||||
? resultNumber
|
||||
: null,
|
||||
server_time: serverTime,
|
||||
streak_level: typeof streakLevel === 'number' ? streakLevel : undefined,
|
||||
total_win: totalWin,
|
||||
user_id: userId,
|
||||
}
|
||||
}
|
||||
|
||||
function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
|
||||
const currentRoundState = useGameRoundStore.getState()
|
||||
const currentSessionState = useGameSessionStore.getState()
|
||||
|
||||
useGameRoundStore.getState().hydrateRound({
|
||||
cells: result.snapshot.cells,
|
||||
chips: result.snapshot.chips,
|
||||
history: currentRoundState.history,
|
||||
maxSelectionCount: result.snapshot.maxSelectionCount,
|
||||
round: currentRoundState.round,
|
||||
selections: currentRoundState.selections,
|
||||
trends: currentRoundState.trends,
|
||||
})
|
||||
|
||||
useGameSessionStore.getState().hydrateSession({
|
||||
announcements: result.snapshot.announcements,
|
||||
connection: {
|
||||
...result.snapshot.connection,
|
||||
status: 'connected',
|
||||
transport: 'polling',
|
||||
},
|
||||
dashboard: {
|
||||
...currentSessionState.dashboard,
|
||||
tableLimitMax: result.snapshot.dashboard.tableLimitMax,
|
||||
tableLimitMin: result.snapshot.dashboard.tableLimitMin,
|
||||
},
|
||||
})
|
||||
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
|
||||
if (currentUser) {
|
||||
useAuthStore.getState().setCurrentUser({
|
||||
...currentUser,
|
||||
coin: result.userSnapshot.coin,
|
||||
currentStreak: result.userSnapshot.current_streak,
|
||||
isJackpot: result.userSnapshot.is_jackpot,
|
||||
oddsFactor: result.userSnapshot.odds_factor,
|
||||
streakLevel: result.userSnapshot.streak_level,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function applyPeriodMessage(
|
||||
message: GameSocketMessage,
|
||||
serverTime: number | null,
|
||||
) {
|
||||
const period = extractPeriodTick(message)
|
||||
|
||||
if (!period) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousRound = useGameRoundStore.getState().round
|
||||
const round = normalizePeriodTickRound(
|
||||
{
|
||||
...period,
|
||||
server_time: serverTime ?? period.server_time,
|
||||
},
|
||||
previousRound,
|
||||
)
|
||||
|
||||
useGameRoundStore.getState().syncRound({
|
||||
bettingClosesAt: round.bettingClosesAt,
|
||||
id: round.id,
|
||||
phase: round.phase,
|
||||
revealingAt: round.revealingAt,
|
||||
settledAt: round.settledAt,
|
||||
startedAt: round.startedAt,
|
||||
winningCellId: previousRound.winningCellId,
|
||||
})
|
||||
useGameSessionStore.getState().syncDashboard({
|
||||
countdownMs: period.countdown * 1000,
|
||||
updatedAt:
|
||||
serverTime !== null
|
||||
? toIsoFromUnixSeconds(serverTime)
|
||||
: toIsoFromUnixSeconds(period.server_time),
|
||||
})
|
||||
}
|
||||
|
||||
function applyPeriodPhase(phase: 'locked' | 'revealing' | 'settled') {
|
||||
useGameRoundStore.getState().setPhase(phase)
|
||||
}
|
||||
|
||||
function applyPeriodLockedMessage(
|
||||
message: GameSocketMessage,
|
||||
serverTime: number | null,
|
||||
) {
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
const period = extractPeriodEventData(message)
|
||||
const roundState = useGameRoundStore.getState()
|
||||
const roundId = period?.periodNo ?? roundState.round.id
|
||||
|
||||
if (roundId) {
|
||||
roundState.syncRound({
|
||||
id: roundId,
|
||||
phase: 'locked',
|
||||
})
|
||||
} else {
|
||||
roundState.setPhase('locked')
|
||||
}
|
||||
}
|
||||
|
||||
function applyPeriodOpenedMessage(
|
||||
message: GameSocketMessage,
|
||||
serverTime: number | null,
|
||||
) {
|
||||
console.log('%c[period.opened 开奖数据]', 'color: red;', message)
|
||||
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
const period = extractPeriodEventData(message)
|
||||
|
||||
if (!period || period.resultNumber === null) {
|
||||
applyPeriodPhase('revealing')
|
||||
return
|
||||
}
|
||||
|
||||
const roundState = useGameRoundStore.getState()
|
||||
const openedAt = toIsoFromUnixSeconds(
|
||||
period.openTime ?? serverTime ?? Math.floor(Date.now() / 1000),
|
||||
)
|
||||
const revealKey = `${period.periodNo}:${period.resultNumber}`
|
||||
|
||||
roundState.syncRound({
|
||||
id: period.periodNo,
|
||||
phase: 'revealing',
|
||||
revealingAt: openedAt,
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
useGameRoundStore.getState().prepareRevealAnimation({
|
||||
revealKey,
|
||||
roundId: period.periodNo,
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
}
|
||||
|
||||
function applyPeriodPayoutMessage(
|
||||
message: GameSocketMessage,
|
||||
serverTime: number | null,
|
||||
) {
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
const period = extractPeriodEventData(message)
|
||||
|
||||
if (period?.resultNumber !== null && period?.resultNumber !== undefined) {
|
||||
const roundState = useGameRoundStore.getState()
|
||||
const revealKey = `${period.periodNo}:${period.resultNumber}`
|
||||
|
||||
roundState.syncRound({
|
||||
id: period.periodNo,
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
roundState.prepareRevealAnimation({
|
||||
revealKey,
|
||||
roundId: period.periodNo,
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
}
|
||||
|
||||
const roundId = period?.periodNo ?? useGameRoundStore.getState().round.id
|
||||
|
||||
applyPeriodPhase('settled')
|
||||
useGameRoundStore.getState().playPreparedRevealAnimation(roundId || null)
|
||||
}
|
||||
|
||||
function applyUserStreakMessage(message: GameSocketMessage) {
|
||||
const streakData = extractUserStreakMessageData(message)
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
|
||||
if (!streakData || !currentUser) {
|
||||
return
|
||||
}
|
||||
|
||||
useAuthStore.getState().setCurrentUser({
|
||||
...currentUser,
|
||||
currentStreak: streakData.currentStreak,
|
||||
oddsFactor: streakData.oddsFactor ?? currentUser.oddsFactor,
|
||||
streakLevel: streakData.streakLevel ?? currentUser.streakLevel,
|
||||
})
|
||||
}
|
||||
|
||||
function applyWalletChangedMessage(message: GameSocketMessage) {
|
||||
const walletData = extractWalletChangedData(message)
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
|
||||
if (!walletData || !currentUser) {
|
||||
return
|
||||
}
|
||||
|
||||
useAuthStore.getState().setCurrentUser({
|
||||
...currentUser,
|
||||
coin: walletData.coin,
|
||||
})
|
||||
}
|
||||
|
||||
function applyJackpotHitMessage(message: GameSocketMessage) {
|
||||
console.log('%c[jackpot.hit 数据]', 'color: red;', message)
|
||||
|
||||
const jackpotHitData = extractJackpotHitData(message)
|
||||
|
||||
if (jackpotHitData?.hits.length) {
|
||||
useGameSessionStore.getState().pushJackpotBroadcasts(
|
||||
jackpotHitData.hits.map((hit) => ({
|
||||
id: `${jackpotHitData.period_no}:${hit.result_number}:${hit.nickname}:${hit.total_win}`,
|
||||
message: `恭喜${hit.nickname} 用户中奖,获得${hit.total_win}`,
|
||||
nickname: hit.nickname,
|
||||
periodNo: hit.period_no,
|
||||
totalWin: hit.total_win,
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function applyBetWinMessage(message: GameSocketMessage) {
|
||||
const betWinData = extractBetWinData(message)
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
|
||||
if (!betWinData) {
|
||||
return
|
||||
}
|
||||
|
||||
useGameRoundStore.getState().setPendingBetWinReward({
|
||||
isJackpot: betWinData.is_jackpot,
|
||||
revealKey: `${betWinData.period_no}:${betWinData.result_number ?? 'pending'}:${betWinData.total_win}`,
|
||||
roundId: betWinData.period_no,
|
||||
totalWin: betWinData.total_win,
|
||||
winningCellId: betWinData.result_number,
|
||||
})
|
||||
useGameAutoHostingStore.getState().recordBetWin({
|
||||
isJackpot: betWinData.is_jackpot,
|
||||
singleWinAmount: toOptionalNumber(betWinData.total_win) ?? null,
|
||||
})
|
||||
|
||||
if (!currentUser) {
|
||||
return
|
||||
}
|
||||
|
||||
useAuthStore.getState().setCurrentUser({
|
||||
...currentUser,
|
||||
coin: betWinData.balance_after ?? currentUser.coin,
|
||||
currentStreak: betWinData.current_streak ?? currentUser.currentStreak,
|
||||
isJackpot: betWinData.is_jackpot,
|
||||
oddsFactor: betWinData.odds_factor ?? currentUser.oddsFactor,
|
||||
streakLevel: betWinData.streak_level ?? currentUser.streakLevel,
|
||||
})
|
||||
}
|
||||
|
||||
function applyRealtimeMessage(message: GameSocketMessage) {
|
||||
const serverTime = extractServerTime(message)
|
||||
const topic = getMessageTopic(message)
|
||||
|
||||
switch (topic) {
|
||||
case GAME_SOCKET_TOPICS.periodTick:
|
||||
applyPeriodMessage(message, serverTime)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.periodLocked:
|
||||
applyPeriodLockedMessage(message, serverTime)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.periodOpened:
|
||||
applyPeriodOpenedMessage(message, serverTime)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.periodPayout:
|
||||
applyPeriodPayoutMessage(message, serverTime)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.userStreak:
|
||||
applyUserStreakMessage(message)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.walletChanged:
|
||||
applyWalletChangedMessage(message)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.jackpotHit:
|
||||
applyJackpotHitMessage(message)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.betWin:
|
||||
applyBetWinMessage(message)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.betAccepted:
|
||||
case GAME_SOCKET_TOPICS.autoSpinProgress:
|
||||
break
|
||||
}
|
||||
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
lastMessageAt:
|
||||
serverTime !== null
|
||||
? toIsoFromUnixSeconds(serverTime)
|
||||
: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useGameRealtimeSync() {
|
||||
const accessToken = useAuthStore((state) => state.accessToken)
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const lastUnauthorizedAt = useAuthStore((state) => state.lastUnauthorizedAt)
|
||||
const shouldConnectRealtime = useGameSessionStore(
|
||||
(state) => state.shouldConnectRealtime,
|
||||
)
|
||||
const socketClientRef = useRef<GameSocketClient | null>(null)
|
||||
const isReloginRequired =
|
||||
authStatus === 'anonymous' && Boolean(lastUnauthorizedAt)
|
||||
|
||||
useEffect(() => {
|
||||
if (sharedSocketDisconnectTimerId !== null) {
|
||||
window.clearTimeout(sharedSocketDisconnectTimerId)
|
||||
sharedSocketDisconnectTimerId = null
|
||||
}
|
||||
|
||||
if (isReloginRequired) {
|
||||
sharedSocketClient?.disconnect()
|
||||
sharedSocketClient = null
|
||||
sharedSocketKey = null
|
||||
socketClientRef.current = null
|
||||
|
||||
const gameSession = useGameSessionStore.getState()
|
||||
|
||||
gameSession.resetRealtimeConnectionRequest()
|
||||
gameSession.syncConnection({
|
||||
lastError: null,
|
||||
latencyMs: null,
|
||||
reconnectAttempt: 0,
|
||||
status: 'disconnected',
|
||||
transport: 'offline',
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldConnectRealtime ||
|
||||
authStatus !== 'authenticated' ||
|
||||
!accessToken
|
||||
) {
|
||||
sharedSocketDisconnectTimerId = window.setTimeout(() => {
|
||||
sharedSocketClient?.disconnect()
|
||||
sharedSocketClient = null
|
||||
sharedSocketKey = null
|
||||
sharedSocketDisconnectTimerId = null
|
||||
}, SOCKET_DISCONNECT_DELAY_MS)
|
||||
socketClientRef.current = sharedSocketClient
|
||||
return
|
||||
}
|
||||
|
||||
const websocketUrl = import.meta.env.VITE_WEBSOCKET_URL?.trim() || null
|
||||
const socketKey = `${websocketUrl ?? ''}::${accessToken}`
|
||||
|
||||
if (sharedSocketClient && sharedSocketKey === socketKey) {
|
||||
socketClientRef.current = sharedSocketClient
|
||||
|
||||
return () => {
|
||||
sharedSocketDisconnectTimerId = window.setTimeout(() => {
|
||||
sharedSocketClient?.disconnect()
|
||||
sharedSocketClient = null
|
||||
sharedSocketKey = null
|
||||
sharedSocketDisconnectTimerId = null
|
||||
}, SOCKET_DISCONNECT_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
sharedSocketClient?.disconnect()
|
||||
|
||||
const socketClient = new GameSocketClient({
|
||||
getContext: async () => {
|
||||
await prefetchAuthToken()
|
||||
|
||||
const authToken = useAuthStore.getState().apiAuthToken
|
||||
|
||||
if (!authToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
token: accessToken,
|
||||
authToken,
|
||||
deviceId: getAuthDeviceId(),
|
||||
lang: toSocketLang(i18n.resolvedLanguage),
|
||||
}
|
||||
},
|
||||
getUrl: () => websocketUrl,
|
||||
onError: (error) => {
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
lastError:
|
||||
'message' in error && typeof error.message === 'string'
|
||||
? error.message
|
||||
: 'WebSocket error',
|
||||
})
|
||||
},
|
||||
onLatencyChange: (latencyMs) => {
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
latencyMs,
|
||||
})
|
||||
},
|
||||
onMessage: (message) => {
|
||||
if (message.event === 'ws.connected') {
|
||||
const serverTime = extractServerTime(message)
|
||||
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
connectedAt:
|
||||
serverTime !== null
|
||||
? toIsoFromUnixSeconds(serverTime)
|
||||
: new Date().toISOString(),
|
||||
lastError: null,
|
||||
lastMessageAt:
|
||||
serverTime !== null
|
||||
? toIsoFromUnixSeconds(serverTime)
|
||||
: new Date().toISOString(),
|
||||
reconnectAttempt: 0,
|
||||
status: 'connected',
|
||||
transport: 'websocket',
|
||||
})
|
||||
}
|
||||
|
||||
applyRealtimeMessage(message)
|
||||
},
|
||||
onStatusChange: (status, reconnectAttempt) => {
|
||||
const mappedStatus =
|
||||
status === 'idle'
|
||||
? 'idle'
|
||||
: status === 'connected'
|
||||
? 'connected'
|
||||
: status
|
||||
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
latencyMs: mappedStatus === 'connected' ? undefined : null,
|
||||
reconnectAttempt,
|
||||
status: mappedStatus,
|
||||
transport: websocketUrl ? 'websocket' : 'polling',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
sharedSocketClient = socketClient
|
||||
sharedSocketKey = socketKey
|
||||
socketClientRef.current = socketClient
|
||||
socketClient.subscribe([...PLAYER_SOCKET_TOPICS])
|
||||
void socketClient.connect()
|
||||
|
||||
return () => {
|
||||
sharedSocketDisconnectTimerId = window.setTimeout(() => {
|
||||
if (sharedSocketClient === socketClient) {
|
||||
socketClient.disconnect()
|
||||
sharedSocketClient = null
|
||||
sharedSocketKey = null
|
||||
}
|
||||
|
||||
sharedSocketDisconnectTimerId = null
|
||||
}, SOCKET_DISCONNECT_DELAY_MS)
|
||||
socketClientRef.current = sharedSocketClient
|
||||
}
|
||||
}, [accessToken, authStatus, isReloginRequired, shouldConnectRealtime])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isReloginRequired ||
|
||||
!shouldConnectRealtime ||
|
||||
authStatus !== 'authenticated'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let intervalId = 0
|
||||
|
||||
const pollLobbyState = async () => {
|
||||
const connection = useGameSessionStore.getState().connection
|
||||
|
||||
if (
|
||||
connection.status === 'connected' &&
|
||||
connection.transport === 'websocket'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const result = await getGameLobbyInit()
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
applyLobbySync(result)
|
||||
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
lastError: null,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
status: 'connected',
|
||||
transport: 'polling',
|
||||
})
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
lastError: error instanceof Error ? error.message : 'Polling failed',
|
||||
status: 'reconnecting',
|
||||
transport: 'polling',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
intervalId = window.setInterval(() => {
|
||||
void pollLobbyState()
|
||||
}, FALLBACK_POLL_INTERVAL_MS)
|
||||
void pollLobbyState()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}, [authStatus, isReloginRequired, shouldConnectRealtime])
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PHASE_META } from '@/constants'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
export function useGameStatusVm() {
|
||||
const { t } = useTranslation()
|
||||
const cells = useGameRoundStore((state) => state.cells)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const trends = useGameRoundStore((state) => state.trends)
|
||||
const dashboard = useGameSessionStore((state) => state.dashboard)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
|
||||
return useMemo(() => {
|
||||
const oddsValue =
|
||||
typeof currentUser?.oddsFactor === 'number'
|
||||
? currentUser.oddsFactor
|
||||
: (cells[0]?.odds ?? '--')
|
||||
const featuredTrend = trends.find(
|
||||
(entry) => entry.cellId === dashboard.featuredCellId,
|
||||
)
|
||||
const phaseMeta = PHASE_META[round.phase]
|
||||
const streakValue =
|
||||
currentUser?.currentStreak ?? featuredTrend?.currentStreak ?? null
|
||||
|
||||
return {
|
||||
acceptingBets: round.phase === 'betting',
|
||||
countdownMs: dashboard.countdownMs,
|
||||
limitLabel: `${dashboard.tableLimitMin}-${dashboard.tableLimitMax}`,
|
||||
oddsLabel: `1:${oddsValue}`,
|
||||
phase: round.phase,
|
||||
phaseDescription: t(phaseMeta.descriptionKey),
|
||||
phaseLabel: t(phaseMeta.labelKey),
|
||||
phaseToneClassName: phaseMeta.toneClassName,
|
||||
roundId: round.id || '--',
|
||||
streakLabel: typeof streakValue === 'number' ? `X${streakValue}` : '--',
|
||||
streakValue,
|
||||
}
|
||||
}, [cells, currentUser, dashboard, round, t, trends])
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
CONNECTION_LATENCY_FAIR_MS,
|
||||
CONNECTION_LATENCY_GOOD_MS,
|
||||
CONNECTION_LATENCY_POOR_MS,
|
||||
} from '@/constants'
|
||||
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
|
||||
import {
|
||||
isDesktopFullscreen,
|
||||
subscribeDesktopFullscreenChange,
|
||||
toggleDesktopFullscreen,
|
||||
} from '@/lib/utils'
|
||||
import {
|
||||
useAudioStore,
|
||||
useAuthStore,
|
||||
useGameSessionStore,
|
||||
useModalStore,
|
||||
} from '@/store'
|
||||
|
||||
type BrowserNetworkInformation = {
|
||||
addEventListener?: (type: 'change', listener: () => void) => void
|
||||
downlink?: number
|
||||
effectiveType?: string
|
||||
removeEventListener?: (type: 'change', listener: () => void) => void
|
||||
rtt?: number
|
||||
}
|
||||
|
||||
type SignalPresentation = {
|
||||
activeBars: number
|
||||
latencyLabel: string
|
||||
toneClassName: string
|
||||
}
|
||||
|
||||
function formatTimezoneOffset(date: Date) {
|
||||
const offsetMinutes = -date.getTimezoneOffset()
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-'
|
||||
const absoluteMinutes = Math.abs(offsetMinutes)
|
||||
const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0')
|
||||
const minutes = String(absoluteMinutes % 60).padStart(2, '0')
|
||||
|
||||
return `GMT${sign}${hours}${minutes === '00' ? '' : `:${minutes}`}`
|
||||
}
|
||||
|
||||
function formatHeaderTime(date: Date) {
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
|
||||
return `${hours}:${minutes}:${seconds} ${formatTimezoneOffset(date)}`
|
||||
}
|
||||
|
||||
function getBrowserNetworkInformation() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (navigator as Navigator & { connection?: BrowserNetworkInformation })
|
||||
.connection
|
||||
}
|
||||
|
||||
function resolveSignalPresentation(input: {
|
||||
isOnline: boolean
|
||||
latencyMs: number | null
|
||||
status: string
|
||||
}) {
|
||||
if (!input.isOnline || input.status === 'disconnected') {
|
||||
return {
|
||||
activeBars: 0,
|
||||
latencyLabel: '--',
|
||||
toneClassName: 'text-[#FF6B6B]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
|
||||
if (input.latencyMs === null) {
|
||||
return {
|
||||
activeBars: input.status === 'connected' ? 2 : 1,
|
||||
latencyLabel: '--',
|
||||
toneClassName: 'text-[#7F8EA3]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
|
||||
if (input.latencyMs <= CONNECTION_LATENCY_GOOD_MS) {
|
||||
return {
|
||||
activeBars: 4,
|
||||
latencyLabel: String(input.latencyMs),
|
||||
toneClassName: 'text-[#74FF69]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
|
||||
if (input.latencyMs <= CONNECTION_LATENCY_FAIR_MS) {
|
||||
return {
|
||||
activeBars: 3,
|
||||
latencyLabel: String(input.latencyMs),
|
||||
toneClassName: 'text-[#B7FF6A]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
|
||||
if (input.latencyMs <= CONNECTION_LATENCY_POOR_MS) {
|
||||
return {
|
||||
activeBars: 2,
|
||||
latencyLabel: String(input.latencyMs),
|
||||
toneClassName: 'text-[#FFD76A]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
|
||||
return {
|
||||
activeBars: 1,
|
||||
latencyLabel: String(input.latencyMs),
|
||||
toneClassName: 'text-[#FF8A6A]',
|
||||
} satisfies SignalPresentation
|
||||
}
|
||||
|
||||
export function useHeaderVm() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [isOnline, setIsOnline] = useState(() =>
|
||||
typeof navigator === 'undefined' ? true : navigator.onLine,
|
||||
)
|
||||
const [browserNetworkRttMs, setBrowserNetworkRttMs] = useState<number | null>(
|
||||
() => {
|
||||
const rtt = getBrowserNetworkInformation()?.rtt
|
||||
|
||||
return typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0
|
||||
? rtt
|
||||
: null
|
||||
},
|
||||
)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const isSoundEnabled = useAudioStore((state) => state.isSoundEnabled)
|
||||
const toggleSoundEnabled = useAudioStore((state) => state.toggleSoundEnabled)
|
||||
const connection = useGameSessionStore((state) => state.connection)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const { currentLanguageLabel, currentLanguageOption } = useAppLanguage()
|
||||
|
||||
const signalLatencyMs = useMemo(() => {
|
||||
if (
|
||||
typeof connection.latencyMs === 'number' &&
|
||||
Number.isFinite(connection.latencyMs) &&
|
||||
connection.latencyMs >= 0
|
||||
) {
|
||||
return connection.latencyMs
|
||||
}
|
||||
|
||||
return browserNetworkRttMs
|
||||
}, [browserNetworkRttMs, connection.latencyMs])
|
||||
|
||||
const signalPresentation = useMemo(
|
||||
() =>
|
||||
resolveSignalPresentation({
|
||||
isOnline,
|
||||
latencyMs: signalLatencyMs,
|
||||
status: connection.status,
|
||||
}),
|
||||
[connection.status, isOnline, signalLatencyMs],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const syncFullscreenState = () => {
|
||||
setIsFullscreen(isDesktopFullscreen())
|
||||
}
|
||||
|
||||
syncFullscreenState()
|
||||
return subscribeDesktopFullscreenChange(syncFullscreenState)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const syncBrowserNetworkState = () => {
|
||||
setIsOnline(navigator.onLine)
|
||||
|
||||
const rtt = getBrowserNetworkInformation()?.rtt
|
||||
|
||||
setBrowserNetworkRttMs(
|
||||
typeof rtt === 'number' && Number.isFinite(rtt) && rtt > 0 ? rtt : null,
|
||||
)
|
||||
}
|
||||
|
||||
const networkInformation = getBrowserNetworkInformation()
|
||||
|
||||
syncBrowserNetworkState()
|
||||
window.addEventListener('online', syncBrowserNetworkState)
|
||||
window.addEventListener('offline', syncBrowserNetworkState)
|
||||
networkInformation?.addEventListener?.('change', syncBrowserNetworkState)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', syncBrowserNetworkState)
|
||||
window.removeEventListener('offline', syncBrowserNetworkState)
|
||||
networkInformation?.removeEventListener?.(
|
||||
'change',
|
||||
syncBrowserNetworkState,
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
authStatus,
|
||||
currentLanguageLabel,
|
||||
currentLanguageOption,
|
||||
currentUser,
|
||||
handleFullscreenToggle: () => toggleDesktopFullscreen(),
|
||||
isFullscreen,
|
||||
isSoundEnabled,
|
||||
onOpenLanguage: () => setModalOpen('desktopLanguage', true),
|
||||
onOpenLogin: () => setModalOpen('desktopLogin', true),
|
||||
onOpenNotice: () => setModalOpen('desktopNotice', true),
|
||||
onOpenProcedures: () => setModalOpen('desktopProcedures', true),
|
||||
onOpenRegister: () => setModalOpen('desktopRegister', true),
|
||||
onOpenRules: () => setModalOpen('desktopRules', true),
|
||||
onOpenUserInfo: () => setModalOpen('desktopUserInfo', true),
|
||||
signalPresentation,
|
||||
toggleSoundEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
export function useHeaderClockLabel() {
|
||||
const [clockNow, setClockNow] = useState(() => Date.now())
|
||||
const lastMessageAt = useGameSessionStore(
|
||||
(state) => state.connection.lastMessageAt,
|
||||
)
|
||||
const connectionStatus = useGameSessionStore(
|
||||
(state) => state.connection.status,
|
||||
)
|
||||
const connectionTransport = useGameSessionStore(
|
||||
(state) => state.connection.transport,
|
||||
)
|
||||
|
||||
const serverClockOffsetMs = useMemo(() => {
|
||||
if (
|
||||
connectionStatus !== 'connected' ||
|
||||
connectionTransport !== 'websocket' ||
|
||||
!lastMessageAt
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const serverTimestamp = Date.parse(lastMessageAt)
|
||||
|
||||
if (Number.isNaN(serverTimestamp)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return serverTimestamp - Date.now()
|
||||
}, [connectionStatus, connectionTransport, lastMessageAt])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setClockNow(Date.now())
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const activeTimestamp =
|
||||
serverClockOffsetMs === null ? clockNow : clockNow + serverClockOffsetMs
|
||||
|
||||
return formatHeaderTime(new Date(activeTimestamp))
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import {
|
||||
type GamePeriodHistoryItemDto,
|
||||
getGamePeriodHistory,
|
||||
} from '@/features/game/api/period-history-api'
|
||||
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
|
||||
|
||||
export const DEFAULT_PERIOD_HISTORY_LIMIT = 36
|
||||
|
||||
export interface PeriodHistoryDisplayItem {
|
||||
displayPeriodNo: string
|
||||
displayResultNumber: string
|
||||
image: string
|
||||
isOdd: boolean
|
||||
openTime: number
|
||||
periodNo: string
|
||||
resultNumber: number
|
||||
}
|
||||
|
||||
function formatPeriodNo(periodNo: string) {
|
||||
const [, timeSegment] = periodNo.split('-')
|
||||
|
||||
return timeSegment && timeSegment.length <= 8 ? timeSegment : periodNo
|
||||
}
|
||||
|
||||
function formatResultNumber(number: number) {
|
||||
return String(number).padStart(2, '0')
|
||||
}
|
||||
|
||||
export function toPeriodHistoryDisplayItem(
|
||||
item: GamePeriodHistoryItemDto,
|
||||
): PeriodHistoryDisplayItem {
|
||||
return {
|
||||
displayPeriodNo: formatPeriodNo(item.period_no),
|
||||
displayResultNumber: formatResultNumber(item.result_number),
|
||||
image: FLOWER_IMAGE_BY_ID[item.result_number]?.animalUrl ?? '',
|
||||
isOdd: item.result_number % 2 === 1,
|
||||
openTime: item.open_time,
|
||||
periodNo: item.period_no,
|
||||
resultNumber: item.result_number,
|
||||
}
|
||||
}
|
||||
|
||||
export function usePeriodHistoryVm({
|
||||
enabled,
|
||||
limit = DEFAULT_PERIOD_HISTORY_LIMIT,
|
||||
}: {
|
||||
enabled: boolean
|
||||
limit?: number
|
||||
}) {
|
||||
const query = useQuery({
|
||||
queryKey: ['game', 'period-history', limit],
|
||||
enabled,
|
||||
queryFn: () => getGamePeriodHistory({ limit }),
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
(query.data?.list ?? []).map((item) => toPeriodHistoryDisplayItem(item)),
|
||||
[query.data?.list],
|
||||
)
|
||||
|
||||
return {
|
||||
isError: query.isError,
|
||||
isLoading: query.isLoading,
|
||||
items,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function useTopupVm() {
|
||||
return {}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
|
||||
import { getWalletRecordList } from '@/features/game/api'
|
||||
|
||||
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: DEFAULT_LIST_PAGE_SIZE,
|
||||
type: WALLET_RECORD_TYPE,
|
||||
}),
|
||||
enabled,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextPage = lastPage.pagination.page + 1
|
||||
const loadedCount =
|
||||
lastPage.pagination.page * lastPage.pagination.page_size
|
||||
|
||||
return loadedCount < lastPage.pagination.total ? nextPage : undefined
|
||||
},
|
||||
})
|
||||
|
||||
const lastPage = query.data?.pages.at(-1)
|
||||
const total = lastPage?.pagination.total ?? 0
|
||||
const loadedPage = lastPage?.pagination.page ?? 1
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
(query.data?.pages ?? []).flatMap((page) =>
|
||||
page.list.map((item, index) => ({
|
||||
amountLabel: formatWalletAmount(item.amount, locale),
|
||||
balanceAfterLabel: formatWalletAmount(item.balanceAfter, locale),
|
||||
balanceBeforeLabel: formatWalletAmount(item.balanceBefore, locale),
|
||||
id: item.id || `${page.pagination.page}-${index}`,
|
||||
remarkLabel: item.remark || '--',
|
||||
timeLabel: formatWalletRecordTime(item.createdAt),
|
||||
typeLabel: item.type || WALLET_RECORD_TYPE,
|
||||
})),
|
||||
),
|
||||
[locale, query.data?.pages],
|
||||
)
|
||||
|
||||
return {
|
||||
emptyText: t('game.modals.userInfo.walletRecords.empty'),
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
hasNextPage: query.hasNextPage,
|
||||
headers: {
|
||||
amount: t('game.modals.userInfo.walletRecords.amount'),
|
||||
balanceAfter: t('game.modals.userInfo.walletRecords.balanceAfter'),
|
||||
balanceBefore: t('game.modals.userInfo.walletRecords.balanceBefore'),
|
||||
remark: t('game.modals.userInfo.walletRecords.remark'),
|
||||
time: t('game.modals.userInfo.walletRecords.time'),
|
||||
type: t('game.modals.userInfo.walletRecords.type'),
|
||||
},
|
||||
isError: query.isError,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isLoading: query.isLoading,
|
||||
items,
|
||||
loadFailedText: t('game.modals.userInfo.walletRecords.loadFailed'),
|
||||
loadingText: t('game.modals.userInfo.walletRecords.loading'),
|
||||
pageLabel: t('game.modals.userInfo.walletRecords.page', {
|
||||
page: loadedPage,
|
||||
total,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
createWithdraw,
|
||||
type WithdrawCreateRequestDto,
|
||||
} from '@/features/game/api'
|
||||
import { notify } from '@/lib/notify'
|
||||
|
||||
export function useWithdrawSubmit() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: WithdrawCreateRequestDto) => createWithdraw(payload),
|
||||
onError: (error) => {
|
||||
notify.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('commonUi.toast.requestFailed'),
|
||||
)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
|
||||
notify.success(t('gameDesktop.withdraw.submitSuccess'), {
|
||||
description: [
|
||||
t('gameDesktop.withdraw.success.orderNo', {
|
||||
orderNo: data.order_no,
|
||||
}),
|
||||
t('gameDesktop.withdraw.success.actualArrivalCoin', {
|
||||
amount: formatter.format(data.actual_arrival_coin),
|
||||
}),
|
||||
t('gameDesktop.withdraw.success.feeCoin', {
|
||||
amount: formatter.format(data.fee_coin),
|
||||
}),
|
||||
t('gameDesktop.withdraw.success.reviewRequired', {
|
||||
value: data.risk_review_required
|
||||
? t('commonUi.dialog.yes')
|
||||
: t('commonUi.dialog.no'),
|
||||
}),
|
||||
].join('\n'),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
DEFAULT_CURRENCY_CODE,
|
||||
DEFAULT_WITHDRAW_CONFIG,
|
||||
QUICK_FIAT_AMOUNTS,
|
||||
WITHDRAW_EMAIL_PATTERN,
|
||||
WITHDRAW_PHONE_PATTERN,
|
||||
} from '@/constants'
|
||||
import type { DepositWithdrawConfig } from '@/features/game/api'
|
||||
import { useDepositWithdrawConfig } from '@/features/game/hooks/use-deposit-withdraw-config'
|
||||
import { useAuthStore } from '@/store'
|
||||
|
||||
function formatNumber(locale: string, value: number) {
|
||||
return new Intl.NumberFormat(locale).format(value)
|
||||
}
|
||||
|
||||
function getInitialWithdrawAmount(
|
||||
selectedRate: number,
|
||||
maxWithdrawAmount: number,
|
||||
) {
|
||||
if (maxWithdrawAmount <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
maxWithdrawAmount,
|
||||
Math.max(1, Math.round(selectedRate * QUICK_FIAT_AMOUNTS[0])),
|
||||
)
|
||||
}
|
||||
|
||||
function getActiveCurrencyCode(
|
||||
currencies: DepositWithdrawConfig['currencies'],
|
||||
selectedCurrencyCode: string,
|
||||
) {
|
||||
return (
|
||||
currencies.find((item) => item.code === selectedCurrencyCode) ??
|
||||
currencies[0] ??
|
||||
DEFAULT_WITHDRAW_CONFIG.currencies[0]
|
||||
)
|
||||
}
|
||||
|
||||
function getNormalizedConfig(
|
||||
config: DepositWithdrawConfig | undefined,
|
||||
fallback: DepositWithdrawConfig,
|
||||
) {
|
||||
return config ?? fallback
|
||||
}
|
||||
|
||||
function isValidEmail(value: string) {
|
||||
if (value.trim().length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return WITHDRAW_EMAIL_PATTERN.test(value.trim())
|
||||
}
|
||||
|
||||
function isValidPhone(value: string) {
|
||||
const normalized = value.replace(/[^\d+]/g, '')
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return WITHDRAW_PHONE_PATTERN.test(normalized)
|
||||
}
|
||||
|
||||
export function useWithdrawVm() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const withdrawConfigQuery = useDepositWithdrawConfig()
|
||||
const config = useMemo(() => {
|
||||
const baseConfig = getNormalizedConfig(
|
||||
withdrawConfigQuery.data,
|
||||
DEFAULT_WITHDRAW_CONFIG,
|
||||
)
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
currencies:
|
||||
baseConfig.currencies.length > 0
|
||||
? baseConfig.currencies
|
||||
: DEFAULT_WITHDRAW_CONFIG.currencies,
|
||||
payChannels: baseConfig.payChannels,
|
||||
withdraw: {
|
||||
...baseConfig.withdraw,
|
||||
banks: baseConfig.withdraw.banks,
|
||||
},
|
||||
}
|
||||
}, [withdrawConfigQuery.data])
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
|
||||
|
||||
const [amount, setAmountState] = useState(0)
|
||||
const [hasInitializedAmount, setHasInitializedAmount] = useState(false)
|
||||
const [currencyCode, setCurrencyCode] = useState(
|
||||
config.currencies[0]?.code ?? DEFAULT_CURRENCY_CODE,
|
||||
)
|
||||
const [paymentChannelCode, setPaymentChannelCode] = useState('')
|
||||
const [bankCode, setBankCode] = useState('')
|
||||
const [holderName, setHolderName] = useState('')
|
||||
const [bankAccount, setBankAccount] = useState('')
|
||||
const [receiverEmail, setReceiverEmail] = useState('')
|
||||
const [receiverPhone, setReceiverPhone] = useState('')
|
||||
|
||||
const selectedCurrency = getActiveCurrencyCode(
|
||||
config.currencies,
|
||||
currencyCode,
|
||||
)
|
||||
const selectedRate = selectedCurrency.withdrawCoinsPerFiatValue || 1
|
||||
const sortedPayChannels = useMemo(
|
||||
() =>
|
||||
[...config.payChannels]
|
||||
.filter((channel) => channel.status === 1)
|
||||
.sort((left, right) => left.sort - right.sort),
|
||||
[config.payChannels],
|
||||
)
|
||||
const sortedBanks = useMemo(
|
||||
() =>
|
||||
[...config.withdraw.banks]
|
||||
.filter((bank) => bank.status === 1)
|
||||
.sort((left, right) => left.sort - right.sort),
|
||||
[config.withdraw.banks],
|
||||
)
|
||||
const availableBalance = Number(currentUser?.coin ?? 0)
|
||||
const maxWithdrawAmount = Math.max(0, Math.floor(availableBalance))
|
||||
const selectedPaymentChannel =
|
||||
sortedPayChannels.find((channel) => channel.code === paymentChannelCode) ??
|
||||
null
|
||||
const setAmount = useCallback(
|
||||
(nextAmount: number) => {
|
||||
setAmountState(
|
||||
Math.min(maxWithdrawAmount, Math.max(0, Math.floor(nextAmount))),
|
||||
)
|
||||
},
|
||||
[maxWithdrawAmount],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedCurrency &&
|
||||
selectedCurrency.code !== currencyCode &&
|
||||
config.currencies.some((item) => item.code === currencyCode)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedCurrency && selectedCurrency.code !== currencyCode) {
|
||||
setCurrencyCode(selectedCurrency.code)
|
||||
}
|
||||
}, [config.currencies, currencyCode, selectedCurrency])
|
||||
|
||||
useEffect(() => {
|
||||
const firstAvailablePayChannel = sortedPayChannels[0]
|
||||
|
||||
if (!firstAvailablePayChannel) {
|
||||
if (paymentChannelCode) {
|
||||
setPaymentChannelCode('')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const hasSelectedAvailablePayChannel = sortedPayChannels.some(
|
||||
(channel) => channel.code === paymentChannelCode,
|
||||
)
|
||||
|
||||
if (!hasSelectedAvailablePayChannel) {
|
||||
setPaymentChannelCode(firstAvailablePayChannel.code)
|
||||
}
|
||||
}, [paymentChannelCode, sortedPayChannels])
|
||||
|
||||
useEffect(() => {
|
||||
if (sortedBanks.length === 0) {
|
||||
if (bankCode) {
|
||||
setBankCode('')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const hasSelectedAvailableBank = sortedBanks.some(
|
||||
(bank) => bank.code === bankCode,
|
||||
)
|
||||
|
||||
if (!hasSelectedAvailableBank) {
|
||||
setBankCode('')
|
||||
}
|
||||
}, [bankCode, sortedBanks])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitializedAmount && selectedRate > 0) {
|
||||
setAmount(getInitialWithdrawAmount(selectedRate, maxWithdrawAmount))
|
||||
setHasInitializedAmount(true)
|
||||
}
|
||||
}, [hasInitializedAmount, maxWithdrawAmount, selectedRate, setAmount])
|
||||
|
||||
useEffect(() => {
|
||||
if (amount > maxWithdrawAmount) {
|
||||
setAmount(maxWithdrawAmount)
|
||||
}
|
||||
}, [amount, maxWithdrawAmount, setAmount])
|
||||
|
||||
const quickAmounts = useMemo(() => {
|
||||
return QUICK_FIAT_AMOUNTS.map((fiatAmount) => ({
|
||||
diamonds: Math.min(
|
||||
maxWithdrawAmount,
|
||||
Math.max(1, Math.round(selectedRate * fiatAmount)),
|
||||
),
|
||||
id: `quick-${selectedCurrency.code}-${fiatAmount}`,
|
||||
preview: `${selectedCurrency.code} ${formatNumber(locale, fiatAmount)}`,
|
||||
}))
|
||||
}, [locale, maxWithdrawAmount, selectedCurrency.code, selectedRate])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
const nextCurrencyCode = config.currencies[0]?.code ?? DEFAULT_CURRENCY_CODE
|
||||
const nextCurrency = getActiveCurrencyCode(
|
||||
config.currencies,
|
||||
nextCurrencyCode,
|
||||
)
|
||||
const nextRate = nextCurrency.withdrawCoinsPerFiatValue || 1
|
||||
|
||||
setAmountState(getInitialWithdrawAmount(nextRate, maxWithdrawAmount))
|
||||
setHasInitializedAmount(true)
|
||||
setCurrencyCode(nextCurrencyCode)
|
||||
setPaymentChannelCode(sortedPayChannels[0]?.code ?? '')
|
||||
setBankCode('')
|
||||
setHolderName('')
|
||||
setBankAccount('')
|
||||
setReceiverEmail('')
|
||||
setReceiverPhone('')
|
||||
}, [config.currencies, maxWithdrawAmount, sortedPayChannels])
|
||||
|
||||
const selectedCurrencyPreview = useMemo(
|
||||
() => ({
|
||||
currencyCode: selectedCurrency.code,
|
||||
currencyLabel: selectedCurrency.label,
|
||||
exchangeRateLabel: t('gameDesktop.withdraw.preview.exchangeRate', {
|
||||
currency: selectedCurrency.code,
|
||||
}),
|
||||
exchangeRateValue: t('gameDesktop.withdraw.preview.exchangeRateValue', {
|
||||
coins: formatNumber(locale, selectedRate),
|
||||
currency: selectedCurrency.code,
|
||||
platformCoinLabel: config.platformCoinLabel,
|
||||
}),
|
||||
convertibleLabel: t('gameDesktop.withdraw.preview.convertible', {
|
||||
currency: selectedCurrency.code,
|
||||
}),
|
||||
convertibleValue: `${formatNumber(
|
||||
locale,
|
||||
selectedRate > 0 ? amount / selectedRate : 0,
|
||||
)} ${selectedCurrency.code}`,
|
||||
}),
|
||||
[
|
||||
amount,
|
||||
config.platformCoinLabel,
|
||||
locale,
|
||||
selectedCurrency.code,
|
||||
selectedCurrency.label,
|
||||
selectedRate,
|
||||
t,
|
||||
],
|
||||
)
|
||||
|
||||
return {
|
||||
amount,
|
||||
amountExceedsBalance: amount > maxWithdrawAmount,
|
||||
amountRequiredError: amount <= 0,
|
||||
availableBalance,
|
||||
bankAccount,
|
||||
bankAccountError: bankAccount.trim().length === 0,
|
||||
bankCode,
|
||||
bankCodeError: bankCode.trim().length === 0,
|
||||
config,
|
||||
currencyCode,
|
||||
holderName,
|
||||
holderNameError: holderName.trim().length === 0,
|
||||
isLoading: withdrawConfigQuery.isLoading,
|
||||
isRefetching: withdrawConfigQuery.isFetching,
|
||||
maxWithdrawAmount,
|
||||
paymentChannelCode,
|
||||
paymentChannelCodeError: paymentChannelCode.trim().length === 0,
|
||||
quickAmounts,
|
||||
receiverEmail,
|
||||
receiverEmailError: !isValidEmail(receiverEmail),
|
||||
receiverPhone,
|
||||
receiverPhoneError: !isValidPhone(receiverPhone),
|
||||
selectedCurrency,
|
||||
selectedCurrencyPreview,
|
||||
selectedPaymentChannel,
|
||||
selectedRate,
|
||||
resetForm,
|
||||
setAmount,
|
||||
setBankAccount,
|
||||
setBankCode,
|
||||
setCurrencyCode,
|
||||
setHolderName,
|
||||
setPaymentChannelCode,
|
||||
setReceiverEmail,
|
||||
setReceiverPhone,
|
||||
sortedBanks,
|
||||
sortedPayChannels,
|
||||
withdrawCopy: {
|
||||
bankLabel: t('gameDesktop.withdraw.bank'),
|
||||
eWalletLabel: t('gameDesktop.withdraw.eWallet'),
|
||||
feeNote: config.withdraw.feeNote,
|
||||
noticeLabel: t('gameDesktop.withdraw.notice'),
|
||||
processingLabel: t('gameDesktop.withdraw.processingTime'),
|
||||
processingValue: config.withdraw.processingNote,
|
||||
rateHint: config.withdraw.rateHint,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './api'
|
||||
export * from './shared'
|
||||
@@ -1,229 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { Switch } from '@/components/ui/switch.tsx'
|
||||
import { AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD } from '@/constants'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useModalStore } from '@/store'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import {
|
||||
type AutoHostingStopRules,
|
||||
selectSelectionTotal,
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
|
||||
function parseAmount(value: string) {
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
||||
}
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function DesktopAutoSettingModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopAutoSetting)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const selections = useGameRoundStore((state) => state.selections)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
const tableLimitMax = useGameSessionStore(
|
||||
(state) => state.dashboard.tableLimitMax,
|
||||
)
|
||||
const startHosting = useGameAutoHostingStore((state) => state.startHosting)
|
||||
const [balanceLimitEnabled, setBalanceLimitEnabled] = useState(false)
|
||||
const [balanceLimitValue, setBalanceLimitValue] = useState('0')
|
||||
const [singleWinLimitEnabled, setSingleWinLimitEnabled] = useState(false)
|
||||
const [singleWinLimitValue, setSingleWinLimitValue] = useState(
|
||||
String(AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD),
|
||||
)
|
||||
const [jackpotStopEnabled, setJackpotStopEnabled] = useState(false)
|
||||
|
||||
function handleClose() {
|
||||
setModalOpen('desktopAutoSetting', false)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (round.phase !== 'betting' || !round.id) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (selections.length === 0) {
|
||||
notify.warning(t('commonUi.toast.selectNumbersBeforeAutoHosting'))
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
const balance = parseBalance(currentUser?.coin)
|
||||
|
||||
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
|
||||
notify.warning(t('commonUi.toast.betLimitExceeded'))
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBetAmount > balance) {
|
||||
notify.warning(t('commonUi.toast.insufficientBalance'))
|
||||
return
|
||||
}
|
||||
|
||||
const rules: AutoHostingStopRules = {
|
||||
stopIfBalanceBelow: {
|
||||
amount: parseAmount(balanceLimitValue),
|
||||
enabled: balanceLimitEnabled,
|
||||
},
|
||||
stopIfSingleWinAbove: {
|
||||
amount: parseAmount(singleWinLimitValue),
|
||||
enabled: singleWinLimitEnabled,
|
||||
},
|
||||
stopOnJackpot: jackpotStopEnabled,
|
||||
}
|
||||
|
||||
startHosting({
|
||||
balanceAfterBet: balance,
|
||||
rules,
|
||||
selections,
|
||||
})
|
||||
notify.success(t('commonUi.toast.autoHostingStarted'))
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||
{t('game.modals.autoSetting.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className={'w-design-835 !h-design-500'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-full w-full flex-col justify-between px-design-18 pt-design-30 pb-design-60'
|
||||
}
|
||||
>
|
||||
<div className={'flex w-full flex-col gap-design-26'}>
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.rows.stopIfBalanceLowerThan')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={balanceLimitValue}
|
||||
inputMode="decimal"
|
||||
onChange={(event) => setBalanceLimitValue(event.target.value)}
|
||||
className={
|
||||
'game-setting-input h-full w-design-280 text-design-18'
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={balanceLimitEnabled}
|
||||
onCheckedChange={setBalanceLimitEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.rows.stopIfSingleWinExceeds')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={singleWinLimitValue}
|
||||
inputMode="decimal"
|
||||
onChange={(event) => setSingleWinLimitValue(event.target.value)}
|
||||
className={
|
||||
'game-setting-input h-full w-design-280 text-design-18'
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={singleWinLimitEnabled}
|
||||
onCheckedChange={setSingleWinLimitEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.rows.stopOnAnyJackpot')}
|
||||
</div>
|
||||
|
||||
<div className={'flex w-design-410 justify-end pr-design-2'}>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={jackpotStopEnabled}
|
||||
onCheckedChange={setJackpotStopEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex w-full justify-center'}>
|
||||
<SmartBackground
|
||||
as="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 100%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className={
|
||||
'w-design-300 h-design-72 pb-design-4 flex cursor-pointer items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF] transition-transform hover:-translate-y-[1px] active:translate-y-0'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.startAutoSpin')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopAutoSettingModal
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
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'}>
|
||||
<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={
|
||||
'relative grid grid-cols-2 overflow-hidden rounded-md border border-[#3EAFC7]/30 bg-[#031B24]/75 p-design-4'
|
||||
}
|
||||
>
|
||||
{vm.recordTypes.map((recordType) => {
|
||||
const isActive = recordType.key === vm.recordType
|
||||
|
||||
return (
|
||||
<button
|
||||
key={recordType.key}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
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
|
||||
? 'text-white'
|
||||
: 'text-[#6CCDCF] hover:bg-[#0A4252] hover:text-white',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="finance-record-type-active"
|
||||
className={
|
||||
'absolute inset-0 rounded-md bg-[linear-gradient(180deg,#3DA5BD,#166477)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(62,175,199,0.26)]'
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 420,
|
||||
damping: 34,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className={'relative z-10'}>{recordType.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</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.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]'
|
||||
}
|
||||
>
|
||||
<div>{vm.headers.orderNo}</div>
|
||||
<div>{vm.headers.amount}</div>
|
||||
<div>{vm.headers.bonusAmount}</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 ? (
|
||||
<DataLoadingIndicator label={vm.loadingText} />
|
||||
) : 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.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>
|
||||
) : (
|
||||
<DataLoadingIndicator
|
||||
compact
|
||||
label={vm.loadingText}
|
||||
className="h-[calc(var(--design-unit)*62)] rounded-md bg-[#0A4252]/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopFinanceRecordsTab
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopLanguageModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopLanguage)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const { currentLanguage, languageOptions, selectLanguage } = useAppLanguage()
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopLanguage', false)
|
||||
}
|
||||
|
||||
const handleSelectLanguage = async (
|
||||
language: (typeof languageOptions)[number]['code'],
|
||||
) => {
|
||||
await selectLanguage(language)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-30'}>
|
||||
{t('language.label')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="h-design-560 w-design-620"
|
||||
>
|
||||
<div className="flex h-full flex-col px-design-24 pb-design-28 pt-design-10">
|
||||
<div className="grid flex-1 grid-cols-2 gap-design-16">
|
||||
{languageOptions.map((option: (typeof languageOptions)[number]) => {
|
||||
const isActive = option.code === currentLanguage
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.code}
|
||||
type="button"
|
||||
onClick={() => void handleSelectLanguage(option.code)}
|
||||
className={cn(
|
||||
'group relative flex h-full min-h-design-150 w-full flex-col justify-between overflow-hidden rounded-[18px] border px-design-18 py-design-18 text-left transition-all duration-200',
|
||||
isActive
|
||||
? 'border-[#8BF5FF] bg-[linear-gradient(180deg,rgba(22,64,80,0.94),rgba(7,21,31,0.96))] shadow-[inset_0_0_18px_rgba(128,223,231,0.55),0_0_22px_rgba(66,227,255,0.2)]'
|
||||
: 'border-[#62BFC8]/45 bg-[linear-gradient(180deg,rgba(10,30,43,0.92),rgba(4,13,21,0.94))] shadow-[inset_0_0_14px_rgba(128,223,231,0.18)] hover:border-[#86EFFF]/80 hover:shadow-[inset_0_0_18px_rgba(128,223,231,0.3),0_0_18px_rgba(66,227,255,0.12)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-200',
|
||||
isActive
|
||||
? 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.22),transparent_42%)] opacity-100'
|
||||
: 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.14),transparent_42%)] group-hover:opacity-100',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative flex items-start justify-between gap-design-12">
|
||||
<SmartImage
|
||||
src={option.icon}
|
||||
alt={t(option.labelKey)}
|
||||
className="h-design-32 w-design-32 shrink-0 rounded-[10px] object-cover shadow-[0_8px_18px_rgba(0,0,0,0.28)]"
|
||||
/>
|
||||
{isActive ? (
|
||||
<div className="rounded-full border border-[#8BF5FF]/55 bg-[#8BF5FF]/18 px-design-12 py-design-6 text-design-14 font-semibold uppercase tracking-[0.14em] text-[#C9FCFF] shadow-[0_0_14px_rgba(66,227,255,0.18)]">
|
||||
{t('gameDesktop.control.selected')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative mt-design-18">
|
||||
<div className="text-design-24 font-semibold text-[#F3FFFF]">
|
||||
{t(option.labelKey)}
|
||||
</div>
|
||||
<div className="mt-design-8 text-design-15 uppercase tracking-[0.2em] text-[#7EDAE3]">
|
||||
{option.code}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-design-16 h-px w-full bg-[linear-gradient(90deg,rgba(128,223,231,0),rgba(128,223,231,0.65),rgba(128,223,231,0))]" />
|
||||
|
||||
<div className="relative mt-design-12 flex items-center justify-between text-design-15 text-[#98D6DC]">
|
||||
<span>{t('language.label')}</span>
|
||||
<span className="text-[#D8FDFF]">{option.code}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopLanguageModal
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { DesktopLoginForm } from '@/features/auth/components/desktop-login-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopLoginModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopLogin)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopLogin', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={() => setModalOpen('desktopLogin', false)}
|
||||
title={
|
||||
<div className={'modal-title-glow'}>{t('game.modals.login.title')}</div>
|
||||
}
|
||||
titleAlign="center"
|
||||
className={'w-design-980 h-design-540'}
|
||||
backdropClassName="backdrop-blur-none"
|
||||
>
|
||||
<DesktopLoginForm onSuccess={handleSubmit} />
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopLoginModal
|
||||
@@ -1,240 +0,0 @@
|
||||
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 blueBtnBg from '@/assets/system/blue-btn.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26'}>
|
||||
{t('game.modals.userInfo.message.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className={'w-design-980 h-design-690'}
|
||||
>
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
<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 ? (
|
||||
<DataLoadingIndicator
|
||||
label={t('game.modals.userInfo.message.loading')}
|
||||
/>
|
||||
) : 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 ? (
|
||||
<DataLoadingIndicator
|
||||
label={t('game.modals.userInfo.message.loading')}
|
||||
/>
|
||||
) : 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>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopNoticeModal
|
||||
@@ -1,181 +0,0 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PeriodHistoryList } from '@/features/game/components/shared/period-history-list'
|
||||
import {
|
||||
DEFAULT_PERIOD_HISTORY_LIMIT,
|
||||
type PeriodHistoryDisplayItem,
|
||||
usePeriodHistoryVm,
|
||||
} from '@/features/game/hooks/use-period-history-vm'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
const OVERLAY_EASE = [0.16, 1, 0.3, 1] as const
|
||||
const DRAWER_TRANSITION = {
|
||||
type: 'tween',
|
||||
duration: 0.34,
|
||||
ease: OVERLAY_EASE,
|
||||
} as const
|
||||
|
||||
interface PeriodHistoryDrawerLabels {
|
||||
close: string
|
||||
empty: string
|
||||
failed: string
|
||||
loading: string
|
||||
retry: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface DesktopPeriodHistoryDrawerViewProps {
|
||||
isError: boolean
|
||||
isLoading: boolean
|
||||
items: PeriodHistoryDisplayItem[]
|
||||
labels: PeriodHistoryDrawerLabels
|
||||
onClose: () => void
|
||||
onRetry: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function DesktopPeriodHistoryDrawer() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopPeriodHistory)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const vm = usePeriodHistoryVm({
|
||||
enabled: open,
|
||||
limit: DEFAULT_PERIOD_HISTORY_LIMIT,
|
||||
})
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopPeriodHistory', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopPeriodHistoryDrawerView
|
||||
open={open}
|
||||
items={vm.items}
|
||||
isLoading={vm.isLoading}
|
||||
isError={vm.isError}
|
||||
labels={{
|
||||
close: t('gameDesktop.periodHistory.close'),
|
||||
empty: t('gameDesktop.periodHistory.empty'),
|
||||
failed: t('gameDesktop.periodHistory.failed'),
|
||||
loading: t('gameDesktop.periodHistory.loading'),
|
||||
retry: t('gameDesktop.periodHistory.retry'),
|
||||
title: t('gameDesktop.periodHistory.title'),
|
||||
}}
|
||||
onClose={handleClose}
|
||||
onRetry={() => void vm.refetch()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DesktopPeriodHistoryDrawerView({
|
||||
isError,
|
||||
isLoading,
|
||||
items,
|
||||
labels,
|
||||
onClose,
|
||||
onRetry,
|
||||
open,
|
||||
}: DesktopPeriodHistoryDrawerViewProps) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const [isDrawerAnimating, setIsDrawerAnimating] = useState(false)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label={labels.close}
|
||||
className="fixed left-0 right-0 top-0 bottom-[calc(var(--design-unit)*150)] z-30 cursor-default bg-black/48"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: prefersReducedMotion ? 0.12 : 0.26,
|
||||
ease: OVERLAY_EASE,
|
||||
}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.aside
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={labels.title}
|
||||
className="fixed left-0 top-design-16 bottom-[calc(var(--design-unit)*150)] z-40 flex w-design-1120 max-w-[calc(100vw-var(--design-unit)*24)] origin-left flex-col overflow-hidden rounded-r-[calc(var(--design-unit)*10)] border border-[rgba(81,230,255,0.62)] bg-[linear-gradient(180deg,rgba(6,19,32,0.98),rgba(3,12,22,0.96))] text-[#D5FBFF] shadow-[0_0_calc(var(--design-unit)*18)_rgba(39,216,255,0.28),0_0_calc(var(--design-unit)*54)_rgba(39,216,255,0.16),inset_0_0_calc(var(--design-unit)*18)_rgba(74,224,255,0.16)]"
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
? { opacity: 0 }
|
||||
: { x: '-100%', opacity: 0.98 }
|
||||
}
|
||||
animate={
|
||||
prefersReducedMotion ? { opacity: 1 } : { x: 0, opacity: 1 }
|
||||
}
|
||||
exit={
|
||||
prefersReducedMotion
|
||||
? { opacity: 0 }
|
||||
: { x: '-100%', opacity: 0.98 }
|
||||
}
|
||||
transition={
|
||||
prefersReducedMotion ? { duration: 0.12 } : DRAWER_TRANSITION
|
||||
}
|
||||
onAnimationStart={() => setIsDrawerAnimating(true)}
|
||||
onAnimationComplete={() => setIsDrawerAnimating(false)}
|
||||
style={
|
||||
isDrawerAnimating
|
||||
? { willChange: 'transform, opacity' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-design-8 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(80,241,255,0.96),transparent)]"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-0 left-0 h-design-28 w-design-28 border-b-2 border-l-2 border-[#28E6FF]"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-0 right-0 h-design-28 w-design-28 border-b-2 border-r-2 border-[#28E6FF]"
|
||||
/>
|
||||
<div className="relative flex h-design-78 shrink-0 items-center justify-between border-b border-[rgba(80,224,255,0.38)] px-design-42">
|
||||
<h2 className="text-design-28 font-bold leading-none text-white [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(156,244,255,0.42)]">
|
||||
{labels.title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={labels.close}
|
||||
className="flex h-design-42 w-design-42 cursor-pointer items-center justify-center text-[#C8F7FF] transition-colors duration-200 hover:text-white focus-visible:ring-2 focus-visible:ring-[#4FEAFF]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={32} strokeWidth={2.1} />
|
||||
</button>
|
||||
</div>
|
||||
<motion.div
|
||||
className="history-scroll-hidden min-h-0 flex-1 overflow-y-auto px-design-34 py-design-26"
|
||||
initial={
|
||||
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }
|
||||
}
|
||||
animate={
|
||||
prefersReducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
||||
}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? { duration: 0.12 }
|
||||
: { duration: 0.22, delay: 0.08, ease: OVERLAY_EASE }
|
||||
}
|
||||
>
|
||||
<PeriodHistoryList
|
||||
items={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
labels={labels}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import proceduresBg from '@/assets/system/procedures-bg.webp'
|
||||
import topupBtnBg from '@/assets/system/topup.webp'
|
||||
import withdrawBtnBg from '@/assets/system/withdraw.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
|
||||
function DesktopProceduresModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopProcedures)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const setWithdrawTopupType = useModalStore(
|
||||
(state) => state.setWithdrawTopupType,
|
||||
)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopProcedures', false)
|
||||
}
|
||||
|
||||
function handleOpenWithdrawTopup(type: 'withdraw' | 'topup') {
|
||||
setModalOpen('desktopProcedures', false)
|
||||
setWithdrawTopupType(type)
|
||||
setModalOpen('desktopWithdrawTopup', true)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||
{t('game.modals.procedures.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className={'w-design-1000 h-design-610'}
|
||||
>
|
||||
<SmartBackground
|
||||
src={proceduresBg}
|
||||
repeat="no-repeat"
|
||||
size="cover"
|
||||
className={
|
||||
'h-[95%] w-full rounded-md flex flex-col items-center justify-between'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'mt-design-170 ml-design-120 flex items-center gap-design-50'
|
||||
}
|
||||
>
|
||||
<SmartImage className={'w-design-80'} alt={'diamond'} src={diamond} />
|
||||
<div
|
||||
className={
|
||||
'modal-title-gold-glow text-[#F7DC7A] text-design-32 font-bold tracking-[0.08em]'
|
||||
}
|
||||
>
|
||||
{currentUser?.coin || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className={'flex items-center ml-design-180'}>
|
||||
<SmartBackground
|
||||
src={withdrawBtnBg}
|
||||
onClick={() => handleOpenWithdrawTopup('withdraw')}
|
||||
className={
|
||||
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-10 text-design-32 font-bold transition-[transform,filter] duration-150 hover:scale-[1.02] hover:brightness-110 active:translate-y-[calc(var(--design-unit)*2)] active:scale-[0.97] active:brightness-95'
|
||||
}
|
||||
>
|
||||
{t('game.modals.procedures.withdraw')}
|
||||
</SmartBackground>
|
||||
<SmartBackground
|
||||
src={topupBtnBg}
|
||||
onClick={() => handleOpenWithdrawTopup('topup')}
|
||||
className={
|
||||
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-20 text-design-32 font-bold transition-[transform,filter] duration-150 hover:scale-[1.02] hover:brightness-110 active:translate-y-[calc(var(--design-unit)*2)] active:scale-[0.97] active:brightness-95'
|
||||
}
|
||||
>
|
||||
{t('game.modals.procedures.topup')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopProceduresModal
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { DesktopRegisterForm } from '@/features/auth/components/desktop-register-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopRegisterModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopRegister)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopRegister', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={() => setModalOpen('desktopRegister', false)}
|
||||
title={
|
||||
<div className={'modal-title-glow'}>
|
||||
{t('game.modals.register.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="center"
|
||||
className={'w-design-980 h-design-840'}
|
||||
backdropClassName="backdrop-blur-none"
|
||||
>
|
||||
<DesktopRegisterForm onSuccess={handleSubmit} />
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopRegisterModal
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopRulesModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopRules)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopRules', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
isNormalBg={true}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-28 '}>
|
||||
{t('game.modals.rules.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="left"
|
||||
className={'w-design-1040 h-design-720'}
|
||||
>
|
||||
<div className="flex h-full flex-col gap-design-24 px-design-28 pb-design-30 pt-design-10">
|
||||
<div className="flex-1 overflow-y-auto rounded-[12px] bg-black/35 p-design-20 text-design-18 leading-[1.8] text-[#B9E7EA] whitespace-pre-line">
|
||||
{t('game.modals.rules.content')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 90%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
onClick={handleClose}
|
||||
className="modal-title-glow flex h-design-85 w-design-270 items-center justify-center pb-design-5 text-design-20 font-bold"
|
||||
>
|
||||
{t('game.modals.rules.confirm')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopRulesModal
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
const SUPPORT_CHAT_URL =
|
||||
'https://tawk.to/chat/6a1d23d9e29f411c2ce86772/1jq0t82lu'
|
||||
const IFRAME_READY_DELAY_MS = 2_000
|
||||
|
||||
function DesktopSupportModal() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const readyTimerRef = useRef<number | null>(null)
|
||||
const open = useModalStore((state) => state.modals.desktopSupport)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
const clearReadyTimer = useCallback(() => {
|
||||
if (readyTimerRef.current === null) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearTimeout(readyTimerRef.current)
|
||||
readyTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopSupport', false)
|
||||
}
|
||||
|
||||
const handleLoaded = () => {
|
||||
clearReadyTimer()
|
||||
readyTimerRef.current = window.setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
readyTimerRef.current = null
|
||||
}, IFRAME_READY_DELAY_MS)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
clearReadyTimer()
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
return clearReadyTimer
|
||||
}, [clearReadyTimer, open])
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
isNormalBg={true}
|
||||
onClose={handleClose}
|
||||
titleAlign="left"
|
||||
title={<div className="modal-title-glow text-design-30">在线客服</div>}
|
||||
className="h-design-760 w-design-980"
|
||||
>
|
||||
<div className="h-full px-design-24 pb-design-40 pt-design-10">
|
||||
<div className="relative h-full overflow-hidden rounded-[calc(var(--design-unit)*14)] border border-[#2A6D73] bg-[linear-gradient(180deg,rgba(5,22,31,0.98),rgba(2,10,17,0.98))] shadow-[inset_0_0_calc(var(--design-unit)*22)_rgba(88,205,218,0.13),0_0_calc(var(--design-unit)*18)_rgba(31,156,174,0.14)]">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
|
||||
<DataLoadingIndicator label="客服连线中" />
|
||||
</div>
|
||||
) : null}
|
||||
<iframe
|
||||
title="customer-service-chat"
|
||||
src={SUPPORT_CHAT_URL}
|
||||
onLoad={handleLoaded}
|
||||
className="h-full w-full bg-[linear-gradient(180deg,#061923,#020A11)]"
|
||||
allow="microphone; camera; clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopSupportModal
|
||||
@@ -1,337 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
CircleUserRound,
|
||||
ClipboardList,
|
||||
LogOut,
|
||||
ReceiptText,
|
||||
WalletCards,
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import avatar from '@/assets/system/avatar.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 { REGISTER_INVITE_CODE_QUERY_PARAM } from '@/constants'
|
||||
import { logoutWithPassword } from '@/features/auth/api/auth-api'
|
||||
import DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab'
|
||||
import DesktopWalletRecordsTab from '@/features/game/modal/desktop/desktop-wallet-records-tab'
|
||||
import { clearAuthenticatedSession } from '@/lib/auth/auth-session'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
|
||||
type UserInfoTabKey = 'financeRecords' | 'profile' | 'walletRecords'
|
||||
|
||||
const USER_INFO_TABS: Array<{
|
||||
key: UserInfoTabKey
|
||||
labelKey: string
|
||||
icon: typeof CircleUserRound
|
||||
}> = [
|
||||
{
|
||||
key: 'profile',
|
||||
labelKey: 'game.modals.userInfo.tabs.profile',
|
||||
icon: CircleUserRound,
|
||||
},
|
||||
{
|
||||
key: 'financeRecords',
|
||||
labelKey: 'game.modals.userInfo.tabs.financeRecords',
|
||||
icon: ReceiptText,
|
||||
},
|
||||
{
|
||||
key: 'walletRecords',
|
||||
labelKey: 'game.modals.userInfo.tabs.walletRecords',
|
||||
icon: WalletCards,
|
||||
},
|
||||
]
|
||||
|
||||
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 currentUser = useAuthStore((state) => state.currentUser)
|
||||
const inviteCode = currentUser?.registerInviteCode?.trim() ?? ''
|
||||
const logoutUsername =
|
||||
currentUser?.username ?? currentUser?.phone ?? currentUser?.name ?? ''
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: logoutWithPassword,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveTab('profile')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
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'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (logoutMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await logoutMutation.mutateAsync({
|
||||
password: '',
|
||||
username: logoutUsername,
|
||||
})
|
||||
notify.success(t('commonUi.toast.logoutSuccess'))
|
||||
} catch {
|
||||
notify.warning(t('commonUi.toast.logoutLocalOnly'))
|
||||
} finally {
|
||||
clearAuthenticatedSession({ clearBrowserStorage: true })
|
||||
setModalOpen('desktopUserInfo', false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26'}>
|
||||
{t('game.modals.userInfo.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className={'w-design-980 h-design-590'}
|
||||
>
|
||||
<div className={'relative flex h-[96%] w-full'}>
|
||||
<div className={'relative w-design-230 shrink-0'}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute right-0 top-0 h-full w-[calc(var(--design-unit)*2)] bg-[linear-gradient(180deg,rgba(68,244,255,0)_0%,rgba(68,244,255,0.55)_12%,rgba(130,255,255,0.95)_50%,rgba(68,244,255,0.55)_88%,rgba(68,244,255,0)_100%)] shadow-[0_0_calc(var(--design-unit)*8)_rgba(49,208,255,0.45)]"
|
||||
/>
|
||||
|
||||
{USER_INFO_TABS.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = tab.key === activeTab
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'relative flex h-design-150 w-full cursor-pointer flex-col items-center justify-center gap-design-8 overflow-hidden px-design-10 transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-[#FEEEB0]'
|
||||
: 'text-[#58ADAF] hover:text-[#BFEAEC]',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="user-info-tab-active-bg"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-full bg-[linear-gradient(to_left,rgba(254,238,176,0.46)_0%,rgba(254,238,176,0.28)_42%,rgba(254,238,176,0.12)_68%,rgba(254,238,176,0)_100%)]"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 430,
|
||||
damping: 36,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="user-info-tab-active-indicator"
|
||||
aria-hidden="true"
|
||||
className="absolute right-0 top-1/2 h-[72%] w-[calc(var(--design-unit)*3)] -translate-y-1/2 rounded-l-full bg-[linear-gradient(180deg,rgba(255,248,214,0.96)_0%,rgba(254,238,176,0.92)_48%,rgba(232,188,112,0.88)_100%)] shadow-[-2px_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.36)]"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 430,
|
||||
damping: 36,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative z-10 transition',
|
||||
isActive &&
|
||||
'drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.5)]',
|
||||
)}
|
||||
animate={{
|
||||
scale: isActive ? 1.06 : 1,
|
||||
y: isActive ? -2 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
<Icon className={'h-design-40 w-design-40'} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative z-10 text-center text-design-20 leading-tight',
|
||||
isActive && 'modal-title-gold-glow',
|
||||
)}
|
||||
animate={{
|
||||
scale: isActive ? 1.04 : 1,
|
||||
y: isActive ? -1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</motion.div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={'flex-1'}>
|
||||
{activeTab === 'profile' ? (
|
||||
<SmartBackground
|
||||
src={userInfoBg}
|
||||
size="120% 100%"
|
||||
className={
|
||||
'flex flex-col h-full w-full items-start justify-between bg-top bg-no-repeat px-design-40 py-design-32 text-[#6CCDCF] text-design-24 gap-design-80'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center gap-design-30'}>
|
||||
<SmartImage
|
||||
className={'h-design-100 w-design-100'}
|
||||
src={currentUser?.headImage || avatar}
|
||||
alt={'avatar'}
|
||||
/>
|
||||
<div className={'flex flex-col gap-design-30 text-[#6CCDCF]'}>
|
||||
<div>
|
||||
{t('game.modals.userInfo.profile.name')} :
|
||||
{currentUser?.name ?? '--'}
|
||||
</div>
|
||||
<div>
|
||||
{t('game.modals.userInfo.profile.tel')} :{' '}
|
||||
{currentUser?.phone ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'w-design-600 flex-1 text-design-18 rounded-md bg-[#000000]/40 flex flex-col gap-design-20 p-design-20'
|
||||
}
|
||||
>
|
||||
<div className={'text-[#6CCDCF]'}>
|
||||
{t('game.modals.userInfo.profile.registeredAt')}:
|
||||
<span
|
||||
className={'text-design-18 text-[#599AA3] ml-design-10'}
|
||||
>
|
||||
{currentUser?.createTime
|
||||
? dayjs
|
||||
.unix(currentUser.createTime)
|
||||
.format('YYYY-MM-DD HH:mm:ss')
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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'}
|
||||
>
|
||||
{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>
|
||||
|
||||
<div className={'w-full flex justify-end'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleLogout()
|
||||
}}
|
||||
disabled={logoutMutation.isPending}
|
||||
className="mt-auto inline-flex h-design-44 min-w-design-170 cursor-pointer items-center justify-center gap-design-10 rounded-md border border-[#8F4747] bg-[#3A1111]/80 px-design-18 text-design-18 text-[#FFD7D7] transition-colors duration-200 hover:border-[#FF8A8A] hover:bg-[#5A1818]/85 hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#FF8A8A] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<LogOut className="h-design-20 w-design-20" />
|
||||
<span>
|
||||
{logoutMutation.isPending
|
||||
? t('game.modals.userInfo.profile.loggingOut')
|
||||
: t('game.modals.userInfo.profile.logout')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
) : activeTab === 'financeRecords' ? (
|
||||
<DesktopFinanceRecordsTab
|
||||
enabled={open && activeTab === 'financeRecords'}
|
||||
/>
|
||||
) : (
|
||||
<DesktopWalletRecordsTab
|
||||
enabled={open && activeTab === 'walletRecords'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopUserInfoModal
|
||||
@@ -1,145 +0,0 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
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 ? (
|
||||
<DataLoadingIndicator label={vm.loadingText} />
|
||||
) : 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>
|
||||
) : (
|
||||
<DataLoadingIndicator
|
||||
compact
|
||||
label={vm.loadingText}
|
||||
className="h-[calc(var(--design-unit)*62)] rounded-md bg-[#0A4252]/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopWalletRecordsTab
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import DesktopTopup from '@/features/game/components/desktop/desktop-topup.tsx'
|
||||
import DesktopWithdraw from '@/features/game/components/desktop/desktop-withdraw.tsx'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopWithdrawTopupModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopWithdrawTopup)
|
||||
const type = useModalStore((state) => state.withdrawTopupType)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopWithdrawTopup', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||
{type === 'withdraw'
|
||||
? t('game.modals.withdrawTopup.applyWithdraw')
|
||||
: t('game.modals.withdrawTopup.applyTopup')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className={'w-design-1200 h-design-700'}
|
||||
>
|
||||
<div className={'w-full h-[96%]'}>
|
||||
{type === 'withdraw' ? <DesktopWithdraw /> : <DesktopTopup />}
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopWithdrawTopupModal
|
||||
@@ -1,17 +0,0 @@
|
||||
export {
|
||||
ANNOUNCEMENT_TONES,
|
||||
BET_SOURCES,
|
||||
CELL_STATUSES,
|
||||
CONNECTION_STATUSES,
|
||||
CONNECTION_TRANSPORTS,
|
||||
DEFAULT_ACTIVE_CHIP_ID,
|
||||
DEFAULT_ANNOUNCEMENT_TTL_MS,
|
||||
DEFAULT_GAME_CHIP_COLORS,
|
||||
GAME_GRID_COLUMNS,
|
||||
GAME_GRID_ROWS,
|
||||
GAME_MAX_SELECTION_CELLS,
|
||||
GAME_RECENT_HISTORY_LIMIT,
|
||||
GAME_TOTAL_CELLS,
|
||||
ROUND_PHASES,
|
||||
TREND_DIRECTIONS,
|
||||
} from '@/constants/game'
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { FlowerImageAsset } from '@/type'
|
||||
|
||||
const animalModules = import.meta.glob('../../../assets/animal/*.webp', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
@@ -8,12 +10,6 @@ const rewardModules = import.meta.glob('../../../assets/reward/*.webp', {
|
||||
import: 'default',
|
||||
}) as Record<string, string>
|
||||
|
||||
export interface FlowerImageAsset {
|
||||
animalUrl: string
|
||||
id: number
|
||||
rewardUrl: string
|
||||
}
|
||||
|
||||
export const FLOWER_IMAGE_LIST: FlowerImageAsset[] = Array.from(
|
||||
{ length: 36 },
|
||||
(_, index) => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export * from './constants'
|
||||
export * from './flower-assets'
|
||||
export * from './initial-state'
|
||||
export * from './selectors'
|
||||
export * from './types'
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { DEFAULT_CHIP_AMOUNTS } from '@/constants'
|
||||
import { DEFAULT_GAME_CHIP_COLORS, GAME_MAX_SELECTION_CELLS } from './constants'
|
||||
import {
|
||||
DEFAULT_CHIP_AMOUNTS,
|
||||
DEFAULT_GAME_CHIP_COLORS,
|
||||
GAME_MAX_SELECTION_CELLS,
|
||||
} from '@/constants'
|
||||
import type {
|
||||
AnnouncementState,
|
||||
Chip,
|
||||
@@ -7,7 +10,7 @@ import type {
|
||||
DashboardState,
|
||||
GameBootstrapSnapshot,
|
||||
RoundSnapshot,
|
||||
} from './types'
|
||||
} from '@/type'
|
||||
|
||||
function createEmptyRoundSnapshot(nowIso: string): RoundSnapshot {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GAME_RECENT_HISTORY_LIMIT, GAME_TOTAL_CELLS } from './constants'
|
||||
import { GAME_RECENT_HISTORY_LIMIT, GAME_TOTAL_CELLS } from '@/constants'
|
||||
import type {
|
||||
AnnouncementState,
|
||||
BetSelection,
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
RoundSnapshot,
|
||||
TrendDirection,
|
||||
TrendEntry,
|
||||
} from './types'
|
||||
} from '@/type'
|
||||
|
||||
export function getChipById(chips: Chip[], chipId: string) {
|
||||
return chips.find((chip) => chip.id === chipId) ?? null
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import type {
|
||||
ANNOUNCEMENT_TONES,
|
||||
BET_SOURCES,
|
||||
CELL_STATUSES,
|
||||
CONNECTION_STATUSES,
|
||||
CONNECTION_TRANSPORTS,
|
||||
ROUND_PHASES,
|
||||
TREND_DIRECTIONS,
|
||||
} from './constants'
|
||||
|
||||
export type RoundPhase = (typeof ROUND_PHASES)[number]
|
||||
export type CellStatus = (typeof CELL_STATUSES)[number]
|
||||
export type ConnectionStatus = (typeof CONNECTION_STATUSES)[number]
|
||||
export type ConnectionTransport = (typeof CONNECTION_TRANSPORTS)[number]
|
||||
export type AnnouncementTone = (typeof ANNOUNCEMENT_TONES)[number]
|
||||
export type BetSource = (typeof BET_SOURCES)[number]
|
||||
export type TrendDirection = (typeof TREND_DIRECTIONS)[number]
|
||||
|
||||
export interface GameCell {
|
||||
column: number
|
||||
id: number
|
||||
label: string
|
||||
odds: number
|
||||
row: number
|
||||
}
|
||||
|
||||
export interface Chip {
|
||||
amount: number
|
||||
color: string
|
||||
id: string
|
||||
isDefault?: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface BetSelection {
|
||||
amount: number
|
||||
cellId: number
|
||||
chipId: string
|
||||
id: string
|
||||
placedAt: string
|
||||
source: BetSource
|
||||
}
|
||||
|
||||
export interface RoundSnapshot {
|
||||
bettingClosesAt: string
|
||||
id: string
|
||||
phase: RoundPhase
|
||||
revealingAt: string
|
||||
settledAt: string | null
|
||||
startedAt: string
|
||||
winningCellId: number | null
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
payoutMultiplier: number
|
||||
roundId: string
|
||||
settledAt: string
|
||||
totalPoolAmount: number
|
||||
winningCellId: number
|
||||
}
|
||||
|
||||
export interface TrendEntry {
|
||||
cellId: number
|
||||
currentStreak: number
|
||||
direction: TrendDirection
|
||||
hitCount: number
|
||||
lastHitRoundId: string | null
|
||||
missCount: number
|
||||
}
|
||||
|
||||
export interface AnnouncementItem {
|
||||
createdAt: string
|
||||
expiresAt: string | null
|
||||
id: string
|
||||
isPinned?: boolean
|
||||
isRead?: boolean
|
||||
message: string
|
||||
title: string
|
||||
tone: AnnouncementTone
|
||||
}
|
||||
|
||||
export interface AnnouncementState {
|
||||
activeAnnouncementId: string | null
|
||||
items: AnnouncementItem[]
|
||||
lastUpdatedAt: string | null
|
||||
}
|
||||
|
||||
export interface DashboardState {
|
||||
countdownMs: number
|
||||
featuredCellId: number | null
|
||||
onlinePlayers: number
|
||||
tableLimitMax: number
|
||||
tableLimitMin: number
|
||||
totalPoolAmount: number
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface ConnectionState {
|
||||
connectedAt: string | null
|
||||
lastError: string | null
|
||||
lastMessageAt: string | null
|
||||
latencyMs: number | null
|
||||
reconnectAttempt: number
|
||||
status: ConnectionStatus
|
||||
transport: ConnectionTransport
|
||||
}
|
||||
|
||||
export interface GameBootstrapSnapshot {
|
||||
announcements: AnnouncementState
|
||||
cells: GameCell[]
|
||||
chips: Chip[]
|
||||
connection: ConnectionState
|
||||
dashboard: DashboardState
|
||||
history: HistoryEntry[]
|
||||
maxSelectionCount: number
|
||||
round: RoundSnapshot
|
||||
selections: BetSelection[]
|
||||
trends: TrendEntry[]
|
||||
}
|
||||
|
||||
export interface GameCellViewModel extends GameCell {
|
||||
currentStreak: number
|
||||
hitCount: number
|
||||
isSelected: boolean
|
||||
isWinningCell: boolean
|
||||
selectionAmount: number
|
||||
selectionCount: number
|
||||
status: CellStatus
|
||||
}
|
||||
|
||||
export interface SelectionSummary {
|
||||
amount: number
|
||||
cellId: number
|
||||
count: number
|
||||
}
|
||||
Reference in New Issue
Block a user