Compare commits

...

10 Commits

Author SHA1 Message Date
JiaJun
cf9300eefc fix(auth): 修复注册接口中的邀请码参数处理
- 将 RegisterRequestDto 和 RegisterPayload 中的 invite_code 字段改为可选字段
- 修改 registerWithPassword 函数中的请求体构建逻辑,仅在有值时包含邀请码参数
- 移除邀请码验证 schema 中的必填校验规则
- 更新云开发预览配置文件中的端口和进程 ID 信息
2026-07-22 09:43:04 +08:00
JiaJun
1b7557e2cc feat(game): 添加长按取消下注功能和优化控制组件
- 在桌面和移动版动物游戏组件中添加长按取消下注功能
- 新增 LongPressProgress 组件显示长按进度动画
- 重构游戏控制组件,移除增加/减少投注数量按钮
- 更新投注逻辑,优化单笔投注金额计算方式
- 修复移动端触摸目标选择器样式问题
- 添加右键菜单禁用和键盘删除操作支持
- 更新多语言文件中的游戏说明和取消投注标签
- 优化自动托管运行器中的投注分组逻辑
2026-07-17 18:22:14 +08:00
JiaJun
2a76f1d722 feat(game): 添加钱包余额刷新功能
- 在桌面端和移动端头部组件中添加余额刷新按钮
- 实现 useWalletBalanceRefresh 钩子用于余额刷新逻辑
- 添加刷新图标并集成到钻石币显示区域
- 支持多语言本地化包括英语、印尼语、马来语和中文
- 优化消息广播组件的动画过渡效果
- 添加防重复提交机制避免并发刷新请求
- 集成加载状态显示和错误处理通知功能
2026-07-17 17:26:06 +08:00
JiaJun
c841eb6688 fix(game): 关闭桌面端连续效果预览强制显示
- 将 FORCE_DESKTOP_STREAK_EFFECT_PREVIEW 常量设置为 false
- 禁用桌面端连续效果的强制预览模式
2026-07-16 14:47:48 +08:00
JiaJun
596d435d52 feat(game): 添加桌面和移动设备的状态栏功能
- 添加 FORCE_DESKTOP_STREAK_EFFECT_PREVIEW 和 FORCE_MOBILE_STREAK_EFFECT_PREVIEW 常量用于预览
- 在桌面状态栏中实现连击效果显示逻辑
- 在移动状态栏中实现不同的背景图像和布局样式
- 为移动设备添加火焰图标和连击计数显示
- 更新移动设备状态栏的列布局和间距配置
- 使用条件渲染来切换不同的背景和字体样式
2026-07-16 14:47:04 +08:00
JiaJun
20f31ff0a5 feat(game): 更新游戏状态栏和彩金池组件
- 重构桌面版状态栏布局,优化响应式设计和组件结构
- 添加hud变体类型的彩金池ticker组件,支持新的视觉样式
- 为移动版状态栏调整网格布局和间距设置
- 在多个语言包中添加彩金池促销文案
- 设置默认彩金池金额并改进计时器逻辑
- 优化移动版入口文件中的组件排列方式
2026-07-16 14:23:44 +08:00
JiaJun
9ade40c29b feat(auth): 更新用户认证类型定义并优化提款功能
- 添加 AuthCoinAmount 类型和 AuthWithdrawAccount 接口
- 在 AuthUser 和相关 DTO 中增加余额和提款账户字段
- 将获取用户资料的请求方法从 POST 改为 GET
- 实现提款账户信息的默认值设置和状态管理
- 添加 JackpotPoolTicker 组件并在状态栏中显示
- 更新多语言文件中的游戏规则说明
- 实现提款表单的预填充和状态重置功能
2026-07-15 18:00:33 +08:00
JiaJun
fa40c23720 feat(game): 添加连胜触发的奖池刷新功能
- 添加奖池刷新定时器和延迟配置
- 实现从游戏大厅初始化获取最新奖池数据
- 添加连胜计数达到阈值时的奖池刷新调度逻辑
- 在用户连胜状态更新时触发奖池刷新检查
- 添加错误处理和调试日志
- 确保定时器正确清理避免内存泄漏
2026-07-15 15:42:15 +08:00
JiaJun
fe3b839297 feat(game): 添加奖池功能支持
- 在国际化文件中添加奖池相关的翻译配置
- 定义 JackpotPoolState 和 GameJackpotPoolDto 类型接口
- 在 DashboardState 中添加 jackpotPool 字段
- 实现 normalizeJackpotPoolState 函数处理奖池数据转换
- 更新游戏 API 中的初始化和数据归一化逻辑
- 添加 Oxanium 字体依赖并配置奖池金额字体样式
- 实现 JackpotPoolTicker 组件显示动态增长的奖池金额
- 在消息广播组件中集成奖池显示功能
- 更新实时同步钩子中的奖池数据处理逻辑
2026-07-15 15:09:42 +08:00
JiaJun
2c1a69dd9a feat(game): 添加手续费处理功能并优化提现界面
- 在 API 类型定义中添加 handling_fee 字段支持
- 更新提现组件中的快速金额卡片布局和样式
- 重构 QuickAmountCard 组件以显示金额、钻石和手续费信息
- 在预览区域添加手续费显示行
- 实现手续费计算逻辑和格式化函数
- 更新多语言文件中的手续费相关文本
- 修复提交按钮文本国际化引用路径错误
- 调整桌面和移动端提现界面的宽度和间距参数
2026-07-10 17:28:18 +08:00
43 changed files with 1442 additions and 605 deletions

View File

@@ -0,0 +1,12 @@
{
"pid": 24641,
"port": 17175,
"host": "0.0.0.0",
"base": "/",
"framework": "vite-react",
"command": "/Users/jiaunun/.nvm/versions/node/v24.15.0/bin/node /Users/jiaunun/Desktop/36-character-flower/node_modules/vite/bin/vite.js --host 0.0.0.0 --port 17175 --strictPort --base /",
"internalUrl": "http://127.0.0.1:17175/",
"logPath": "/Users/jiaunun/Desktop/36-character-flower/.cloudbase-sites/logs/preview-1784622161040.log",
"cwd": "/Users/jiaunun/Desktop/36-character-flower",
"startedAt": "2026-07-21T08:22:42.070Z"
}

View File

@@ -29,6 +29,7 @@
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/oxanium": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.99.0",
"@tanstack/react-query-devtools": "^5.99.0",

8
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@fontsource-variable/geist':
specifier: ^5.2.8
version: 5.2.9
'@fontsource-variable/oxanium':
specifier: ^5.2.8
version: 5.2.8
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.4.0(react-hook-form@7.75.0(react@19.2.5))
@@ -635,6 +638,9 @@ packages:
'@fontsource-variable/geist@5.2.9':
resolution: {integrity: sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==}
'@fontsource-variable/oxanium@5.2.8':
resolution: {integrity: sha512-W3HWxRLXVB6yox3dgm1DIGOp98pz8KglwiM6/2BsMopvZrg98mXI9citFWz2pUX0FOOLwf8fmHxX+KrIn+6BoA==}
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
@@ -4785,6 +4791,8 @@ snapshots:
'@fontsource-variable/geist@5.2.9': {}
'@fontsource-variable/oxanium@5.2.8': {}
'@hono/node-server@1.19.14(hono@4.12.18)':
dependencies:
hono: 4.12.18

View File

@@ -67,7 +67,7 @@ function logAuthSessionExpiry(action: string, session: AuthSessionInput) {
}
async function getCurrentUserProfileByToken(userToken: string) {
const response = await api.post<AuthUserProfileDto>(AUTH_ENDPOINTS.profile, {
const response = await api.get<AuthUserProfileDto>(AUTH_ENDPOINTS.profile, {
headers: {
Authorization: `Bearer ${userToken}`,
'user-token': userToken,
@@ -145,16 +145,18 @@ export async function logoutWithPassword(
export async function registerWithPassword(
payload: RegisterPayload,
): Promise<AuthSessionInput> {
const inviteCode = payload.inviteCode?.trim() ?? ''
const registerRequest: RegisterRequestDto = {
captcha: payload.captcha,
device_id: getAuthDeviceId(),
password: payload.password,
username: payload.mobile,
...(inviteCode ? { invite_code: inviteCode } : {}),
}
const response = await api.post<AuthSessionDto, RegisterRequestDto>(
AUTH_ENDPOINTS.register,
{
json: {
captcha: payload.captcha,
device_id: getAuthDeviceId(),
invite_code: payload.inviteCode,
password: payload.password,
username: payload.mobile,
},
json: registerRequest,
},
)
@@ -195,7 +197,7 @@ export async function sendSmsCode(
}
export async function getCurrentUserProfile() {
const response = await api.post<AuthUserProfileDto>(AUTH_ENDPOINTS.profile)
const response = await api.get<AuthUserProfileDto>(AUTH_ENDPOINTS.profile)
return normalizeAuthUserProfile(
unwrapEnvelope(

View File

@@ -105,6 +105,7 @@ function normalizePayChannel(dto: FinancePayChannelDto) {
currencyCodes: Array.isArray(dto.currency_codes) ? dto.currency_codes : [],
depositBanks: (dto.deposit_banks ?? []).map(normalizeWithdrawBank),
feeNote: dto.fee_note ?? '',
handlingFee: dto.handling_fee ?? '0.00',
methods: (dto.methods ?? []).map(normalizeDepositMethod),
minBank: dto.min_bank ?? '',
minEwallet: dto.min_ewallet ?? '',

View File

@@ -30,6 +30,7 @@ import type {
GameBootstrapSnapshot,
GameCell,
GameCellDto,
GameJackpotPoolDto,
GameLobbyInitDto,
GameLobbyInitResult,
GameLobbyPeriodDto,
@@ -40,6 +41,7 @@ import type {
GameRoundFeedDto,
HistoryEntry,
HistoryEntryDto,
JackpotPoolState,
NoticeConfirmDto,
NoticeDetailDto,
NoticeListDto,
@@ -164,10 +166,33 @@ function normalizeAnnouncementState(dto: AnnouncementStateDto) {
} satisfies AnnouncementState
}
function normalizeJackpotPoolState(
dto: GameJackpotPoolDto | null | undefined,
updatedAt: string | null,
) {
if (!dto) {
return null
}
const current = Number(dto.current)
const speed = Number(dto.speed)
if (!Number.isFinite(current) || !Number.isFinite(speed)) {
return null
}
return {
current,
speedPerSecond: Math.max(0, speed),
updatedAt,
} satisfies JackpotPoolState
}
function normalizeDashboardState(dto: DashboardStateDto) {
return {
countdownMs: dto.countdown_ms,
featuredCellId: dto.featured_cell_id,
jackpotPool: normalizeJackpotPoolState(dto.jackpot_pool, dto.updated_at),
onlinePlayers: dto.online_players,
tableLimitMax: dto.table_limit_max,
tableLimitMin: dto.table_limit_min,
@@ -334,6 +359,7 @@ export function normalizeGameLobbyInit(dto: GameLobbyInitDto) {
dashboard: {
countdownMs: 0,
featuredCellId: null,
jackpotPool: normalizeJackpotPoolState(dto.jackpot_pool, baseIso),
onlinePlayers: 0,
tableLimitMax: Number(dto.bet_config.max_bet_per_number) || 0,
tableLimitMin: Number(dto.bet_config.min_bet_per_number) || 0,

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -0,0 +1,224 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import diamond from '@/assets/system/diamond.webp'
import jackpotBg from '@/assets/system/jackpot-bg.png'
import jackpotTitle from '@/assets/system/jackpot-title.png'
import { SmartImage } from '@/components/smart-image.tsx'
import { cn } from '@/lib/utils.ts'
import { useGameSessionStore } from '@/store/game'
const JACKPOT_POOL_TICK_MS = 1000
const DEFAULT_JACKPOT_POOL_AMOUNT = 1_000_000
const jackpotPoolFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
})
type JackpotPoolTickerProps = {
className?: string
variant?: 'hud' | 'inline' | 'standalone'
}
export function JackpotPoolTicker({
className,
variant = 'standalone',
}: JackpotPoolTickerProps) {
const { t } = useTranslation()
const jackpotPool = useGameSessionStore(
(state) => state.dashboard.jackpotPool,
)
const poolCurrent = jackpotPool?.current ?? DEFAULT_JACKPOT_POOL_AMOUNT
const poolSpeedPerSecond = jackpotPool?.speedPerSecond ?? 0
const poolUpdatedAt = jackpotPool?.updatedAt ?? null
const [nowMs, setNowMs] = useState(() => Date.now())
useEffect(() => {
setNowMs(Date.now())
if (poolSpeedPerSecond <= 0) {
return
}
const timerId = window.setInterval(() => {
setNowMs(Date.now())
}, JACKPOT_POOL_TICK_MS)
return () => {
window.clearInterval(timerId)
}
}, [poolSpeedPerSecond])
const displayAmount = useMemo(() => {
const updatedAtMs = poolUpdatedAt ? Date.parse(poolUpdatedAt) : Number.NaN
const baseTimeMs = Number.isFinite(updatedAtMs) ? updatedAtMs : nowMs
const elapsedSeconds = Math.max(0, (nowMs - baseTimeMs) / 1000)
return poolCurrent + elapsedSeconds * poolSpeedPerSecond
}, [nowMs, poolCurrent, poolSpeedPerSecond, poolUpdatedAt])
const amountLabel = jackpotPoolFormatter.format(displayAmount)
const isHud = variant === 'hud'
const isInline = variant === 'inline'
const amountCharacterCounts = new Map<string, number>()
const amountCharacterItems = Array.from(amountLabel).map((character) => {
const nextCount = (amountCharacterCounts.get(character) ?? 0) + 1
amountCharacterCounts.set(character, nextCount)
return {
character,
key: `${character}-${nextCount}`,
}
})
if (isHud) {
return (
<section
aria-label={t('game.jackpotPool.ariaLabel', { amount: amountLabel })}
className={cn(
'relative min-w-0 shrink-0 overflow-hidden whitespace-nowrap bg-center bg-no-repeat',
'h-design-55 w-design-126 px-design-7 pb-design-4 pt-design-3',
'md:h-design-150 md:w-design-340 md:px-design-22 md:pb-design-14 md:pt-design-12',
'[background-size:100%_100%]',
className,
)}
style={{ backgroundImage: `url(${jackpotBg})` }}
>
<div
aria-hidden="true"
className="pointer-events-none absolute left-[12%] top-[25%] h-[2px] w-[23%] bg-gradient-to-r from-transparent via-[#27DFFF] to-transparent shadow-[0_0_calc(var(--design-unit)*8)_rgba(39,223,255,0.9)]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute right-[12%] top-[25%] h-[2px] w-[23%] bg-gradient-to-r from-transparent via-[#27DFFF] to-transparent shadow-[0_0_calc(var(--design-unit)*8)_rgba(39,223,255,0.9)]"
/>
<div className="absolute left-1/2 top-[9%] z-10 flex -translate-x-1/2 flex-col items-center leading-none">
<span className="text-design-4 font-black uppercase tracking-[0.16em] text-[#EAF8FC] [text-shadow:0_calc(var(--design-unit)*1)_0_rgba(4,35,51,0.96),0_0_calc(var(--design-unit)*5)_rgba(174,237,255,0.52)] md:text-design-15 md:tracking-[0.22em]">
{t('game.jackpotPool.promoLine1')}
</span>
<span className="mt-design-1 text-design-4 font-black uppercase tracking-[0.16em] text-[#EAF8FC] [text-shadow:0_calc(var(--design-unit)*1)_0_rgba(4,35,51,0.96),0_0_calc(var(--design-unit)*5)_rgba(174,237,255,0.52)] md:text-design-13 md:tracking-[0.22em]">
{t('game.jackpotPool.promoLine2')}
</span>
</div>
<img
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-x-[6%] top-[30%] z-10 h-auto w-[88%] object-contain drop-shadow-[0_calc(var(--design-unit)*4)_0_rgba(0,45,68,0.95)]"
src={jackpotTitle}
/>
<div className="absolute left-1/2 top-[66%] z-10 flex h-[25%] w-[82%] -translate-x-1/2 items-center justify-center">
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
focusable="false"
preserveAspectRatio="none"
viewBox="0 0 1000 180"
>
<defs>
<filter
colorInterpolationFilters="sRGB"
height="160%"
id="jackpotAmountFrameGlow"
width="116%"
x="-8%"
y="-30%"
>
<feDropShadow
dx="0"
dy="0"
floodColor="#28f8ff"
floodOpacity="0.58"
stdDeviation="1"
/>
</filter>
</defs>
<polygon
filter="url(#jackpotAmountFrameGlow)"
fill="rgba(0, 14, 24, 0.44)"
points="32,1 968,1 999,32 999,148 968,179 32,179 1,148 1,32"
stroke="#29F5FF"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
</svg>
<span
aria-hidden="true"
className="pointer-events-none absolute z-10 translate-y-[1%] font-jackpot-amount text-design-14 font-normal leading-none tabular-nums tracking-[0.025em] md:text-design-37"
style={{
WebkitTextFillColor: '#72F9FF',
WebkitTextStroke: 'calc(var(--design-unit) * 0.5) #C9FFFF',
fontVariationSettings: '"wght" 450',
filter:
'drop-shadow(0 0 calc(var(--design-unit) * 1.8) rgba(45, 255, 255, 0.9)) drop-shadow(0 0 calc(var(--design-unit) * 4) rgba(31, 235, 255, 0.5))',
textShadow:
'0 calc(var(--design-unit) * 1) 0 rgba(0, 38, 48, 0.82)',
}}
>
${amountLabel}
</span>
</div>
</section>
)
}
return (
<section
aria-label={t('game.jackpotPool.ariaLabel', { amount: amountLabel })}
className={cn(
'relative flex w-full min-w-0 items-center overflow-hidden whitespace-nowrap',
isInline
? 'h-design-20 rounded-[3px] border border-[#B86A13]/55 bg-[rgba(32,18,3,0.72)] px-design-4 md:h-design-52 md:rounded-[4px] md:px-design-10'
: 'h-design-34 !rounded-[4px] border border-[#B86A13]/70 bg-[rgba(32,18,3,0.82)] !px-design-10 !py-0 md:h-design-48 md:!rounded-[5px] md:!px-design-16',
className,
)}
>
<SmartImage
alt={t('game.jackpotPool.iconAlt')}
className={cn(
'shrink-0',
isInline
? 'mr-design-3 h-design-10 w-design-10 md:mr-design-8 md:h-design-24 md:w-design-24'
: 'mr-design-5 h-design-14 w-design-14 md:mr-design-10 md:h-design-26 md:w-design-26',
)}
src={diamond}
/>
<span
className={cn(
'shrink-0 font-bold tracking-[0.08em] text-[#D8AA49]',
isInline
? 'mr-[20px] text-design-6 md:text-design-12'
: 'mr-[20px] text-design-8 md:text-design-14',
)}
>
{t('game.jackpotPool.label')}
</span>
<span
className={cn(
'min-w-0 shrink truncate font-jackpot-amount font-extrabold leading-none tabular-nums [font-variation-settings:"wght"_760]',
isInline
? 'text-design-10 md:text-design-27'
: 'text-design-16 md:text-design-30',
)}
>
{amountCharacterItems.map(({ character, key }) => {
const isNumber = /\d/.test(character)
return (
<span
aria-hidden="true"
className={cn(
'inline-block text-[#FFD15A]',
isNumber
? '[text-shadow:0_0_calc(var(--design-unit)*3)_rgba(255,240,166,0.88),0_0_calc(var(--design-unit)*7)_rgba(255,209,90,0.64),0_calc(var(--design-unit)*1)_0_rgba(92,43,2,0.92)]'
: 'mx-[0.03em] text-[#D49322]',
)}
key={key}
>
{character}
</span>
)
})}
</span>
</section>
)
}

View File

@@ -2,6 +2,7 @@ import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import broadcast from '@/assets/system/broadcast.webp'
import { JackpotPoolTicker } from '@/components/jackpot-pool-ticker.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { cn } from '@/lib/utils.ts'
import { useGameSessionStore } from '@/store/game'
@@ -20,9 +21,13 @@ function formatWinAmount(value: string) {
type MessageBroadcastProps = {
className?: string
showJackpotPool?: boolean
}
export function MessageBroadcast({ className }: MessageBroadcastProps) {
export function MessageBroadcast({
className,
showJackpotPool = true,
}: MessageBroadcastProps) {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const jackpotBroadcasts = useGameSessionStore(
@@ -103,65 +108,76 @@ export function MessageBroadcast({ className }: MessageBroadcastProps) {
aria-label={t('game.jackpotBroadcast.ariaLabel')}
className={cn(
'common-neon-inset flex w-full min-w-0 items-center overflow-hidden',
'h-design-24 gap-design-5 !rounded-[3px] !px-design-7 !py-0 text-design-8',
'md:h-design-65 md:gap-design-10 md:!rounded-[5px] md:!px-design-20 md:text-design-16',
'h-design-24 gap-design-4 !rounded-[3px] !px-design-5 !py-0 text-design-8',
'md:h-design-65 md:gap-design-12 md:!rounded-[5px] md:!px-design-20 md:text-design-16',
className,
)}
>
<SmartImage
className="h-design-12 w-design-12 shrink-0 md:h-design-24 md:w-design-24"
alt={t('game.jackpotBroadcast.iconAlt')}
src={broadcast}
/>
<div className="relative h-design-16 min-w-0 flex-1 overflow-hidden md:h-design-28">
<AnimatePresence>
{activeBroadcast ? (
<motion.div
aria-label={activeBroadcastLabel}
className="absolute inset-0 flex min-w-max items-center whitespace-nowrap font-semibold"
key={activeBroadcast.id}
initial={
prefersReducedMotion
? { opacity: 0 }
: { opacity: 1, x: '108%' }
}
animate={
prefersReducedMotion
? { opacity: 1 }
: { opacity: 1, x: '-108%' }
}
exit={{ opacity: 0 }}
transition={{
duration: prefersReducedMotion
? 0.18
: MESSAGE_BROADCAST_ANIMATION_MS / 1000,
ease: prefersReducedMotion ? [0.16, 1, 0.3, 1] : 'linear',
}}
>
<span className="text-[#D5F7FF]">
{t('game.jackpotBroadcast.prefix')}
</span>
<span className="ml-design-4 text-[#4BFFFE] md:ml-design-8">
{activeBroadcast.nickname}
</span>
<span className="mx-design-4 text-[#D5F7FF] md:mx-design-8">
{t('game.jackpotBroadcast.separator')}
</span>
<span className="text-[#78FF7F]">
{t('game.jackpotBroadcast.streak', {
count: activeBroadcast.currentStreak,
})}
</span>
<span className="mx-design-4 text-[#D5F7FF] md:mx-design-8">
{t('game.jackpotBroadcast.winAction')}
</span>
<span className="text-[#FFB72B]">
{formatWinAmount(activeBroadcast.totalWin)}
</span>
</motion.div>
) : null}
</AnimatePresence>
<div className="flex min-w-0 flex-1 items-center gap-design-4 md:gap-design-10">
<SmartImage
className="h-design-12 w-design-12 shrink-0 md:h-design-24 md:w-design-24"
alt={t('game.jackpotBroadcast.iconAlt')}
src={broadcast}
/>
<div className="relative h-design-16 min-w-0 flex-1 overflow-hidden md:h-design-28">
<AnimatePresence mode="wait">
{activeBroadcast ? (
<motion.div
aria-label={activeBroadcastLabel}
className="absolute inset-0 flex min-w-max items-center whitespace-nowrap font-semibold"
key={activeBroadcast.id}
initial={
prefersReducedMotion
? { opacity: 0 }
: { opacity: 1, x: '108%' }
}
animate={
prefersReducedMotion
? { opacity: 1 }
: { opacity: 1, x: '-108%' }
}
exit={{
opacity: 0,
transition: { duration: 0.15, ease: 'easeOut' },
}}
transition={{
duration: prefersReducedMotion
? 0.18
: MESSAGE_BROADCAST_ANIMATION_MS / 1000,
ease: prefersReducedMotion ? [0.16, 1, 0.3, 1] : 'linear',
}}
>
<span className="text-[#D5F7FF]">
{t('game.jackpotBroadcast.prefix')}
</span>
<span className="ml-design-4 text-[#4BFFFE] md:ml-design-8">
{activeBroadcast.nickname}
</span>
<span className="mx-design-4 text-[#D5F7FF] md:mx-design-8">
{t('game.jackpotBroadcast.separator')}
</span>
<span className="text-[#78FF7F]">
{t('game.jackpotBroadcast.streak', {
count: activeBroadcast.currentStreak,
})}
</span>
<span className="mx-design-4 text-[#D5F7FF] md:mx-design-8">
{t('game.jackpotBroadcast.winAction')}
</span>
<span className="text-[#FFB72B]">
{formatWinAmount(activeBroadcast.totalWin)}
</span>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
{showJackpotPool ? (
<JackpotPoolTicker
variant="inline"
className="!w-fit max-w-[54%] shrink-0 md:max-w-[calc(var(--design-unit)*320)]"
/>
) : null}
</section>
)
}

View File

@@ -7,6 +7,7 @@ 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 { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image'
import { LongPressProgress } from '@/features/game/components/shared/long-press-progress'
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
import { useAnimalVm } from '@/hooks/use-animal-vm'
@@ -83,10 +84,17 @@ export function DesktopAnimal({
)
const {
cellWarning,
handleClearCell,
handleLongPressCancel,
handleLongPressEnd,
handleLongPressMove,
handleLongPressStart,
handleSelect,
handleStart,
isRealtimeConnecting,
lockInteraction,
longPressCellId,
longPressDurationMs,
marqueeId,
selectionByCell,
showStandbyState,
@@ -212,6 +220,17 @@ export function DesktopAnimal({
type="button"
disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)}
onContextMenu={(event) => event.preventDefault()}
onKeyDown={(event) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
handleClearCell(item.id)
}
}}
onPointerCancel={handleLongPressCancel}
onPointerDown={(event) => handleLongPressStart(item.id, event)}
onPointerMove={handleLongPressMove}
onPointerUp={handleLongPressEnd}
animate={
showCellWarning
? {
@@ -372,6 +391,12 @@ export function DesktopAnimal({
</span>
</span>
) : null}
{longPressCellId === item.id ? (
<LongPressProgress
durationMs={longPressDurationMs}
label={t('gameDesktop.animal.cancelBet')}
/>
) : null}
</motion.button>
)
})}

View File

@@ -1,7 +1,6 @@
import { motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import reduce from '@/assets/game/add.webp'
import arrow from '@/assets/game/arrow.webp'
import chipBg from '@/assets/game/chip-bg.webp'
import chipLineBg from '@/assets/game/chip-line-bg.webp'
@@ -10,7 +9,6 @@ import confirmBg from '@/assets/game/confirm-bg.webp'
import confirmRedBg from '@/assets/game/confirm-red-bg.png'
import controlBg from '@/assets/game/control-bg.png'
import leftBottomBg from '@/assets/game/left-bg.webp'
import add from '@/assets/game/reduce.webp'
import totalBg from '@/assets/game/total-bg.webp'
import diamond from '@/assets/system/diamond.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
@@ -27,8 +25,6 @@ export function DesktopControl() {
acceptingBets,
actionsEnabled,
canClear,
canDecreaseBetQuantity,
canIncreaseBetQuantity,
chips,
confirmLabel,
confirmState,
@@ -37,11 +33,8 @@ export function DesktopControl() {
onChipSelect,
onConfirm,
onClearSelections,
onDecreaseBetQuantity,
onIncreaseBetQuantity,
onOpenAutoSetting,
onRepeatSelections,
selectedBetQuantityLabel,
selectedChipId,
selectedCountLabel,
totalBetAmountLabel,
@@ -181,7 +174,7 @@ export function DesktopControl() {
src={chipLineBg}
size="100% 100%"
className={
'flex min-w-0 flex-1 items-center gap-design-10 overflow-visible'
'flex min-w-0 flex-1 items-center justify-between overflow-visible'
}
>
{chips.map((chip) => {
@@ -338,52 +331,6 @@ export function DesktopControl() {
)
})}
</SmartBackground>
<div
className={
'flex h-design-50 shrink-0 items-center rounded-md bg-[#091118] box-border px-design-2 py-design-3'
}
>
<button
type="button"
disabled={!acceptingBets || !canDecreaseBetQuantity}
onClick={onDecreaseBetQuantity}
className={cn(
'flex h-design-40 w-design-40 shrink-0 items-center justify-center',
acceptingBets && canDecreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage
src={add}
alt={`add`}
className={'w-design-40 h-design-40'}
/>
</button>
<div
className={'w-design-80 h-full flex items-center justify-center'}
>
{selectedBetQuantityLabel}
</div>
<button
type="button"
disabled={!acceptingBets || !canIncreaseBetQuantity}
onClick={onIncreaseBetQuantity}
className={cn(
'flex h-design-40 w-design-40 shrink-0 items-center justify-center',
acceptingBets && canIncreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage
src={reduce}
alt={`reduce`}
className={'w-design-40 h-design-40'}
/>
</button>
</div>
</SmartBackground>
<SmartBackground
src={totalBg}

View File

@@ -3,6 +3,7 @@ import {
Mail,
Maximize,
Minimize,
RefreshCw,
UserKey,
UserRoundPlus,
Volume2,
@@ -17,6 +18,8 @@ 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 '@/hooks/use-header-vm'
import { useWalletBalanceRefresh } from '@/hooks/use-wallet-balance-refresh'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'
function HeaderClock() {
@@ -60,6 +63,8 @@ function SignalBars({
export function DesktopHeader() {
const { t } = useTranslation()
const setModalOpen = useModalStore((state) => state.setModalOpen)
const { isRefreshingBalance, refreshWalletBalance } =
useWalletBalanceRefresh()
const {
authStatus,
currentLanguageLabel,
@@ -217,10 +222,7 @@ export function DesktopHeader() {
</div>
</button>
<button
type="button"
className="group relative flex items-center justify-center transition-transform duration-150"
>
<div className="group relative flex items-center justify-center">
<SmartImage
src={diamond}
alt="diamond"
@@ -234,14 +236,32 @@ export function DesktopHeader() {
>
<div className="truncate">{currentUser?.coin || '--'}</div>
<SmartImage
<button
type="button"
aria-label={t('commonUi.actions.refreshBalance')}
title={t('commonUi.actions.refreshBalance')}
disabled={isRefreshingBalance}
onClick={() => void refreshWalletBalance()}
className="flex h-design-24 w-design-24 shrink-0 cursor-pointer items-center justify-center rounded-[3px] text-[#8DE4EA] transition-colors hover:bg-white/10 disabled:cursor-wait disabled:opacity-65"
>
<RefreshCw
aria-hidden="true"
className={cn(
'h-design-16 w-design-16',
isRefreshingBalance && 'animate-spin',
)}
/>
</button>
<button
type="button"
onClick={onOpenProcedures}
className={'w-design-24 h-design-24 cursor-pointer'}
alt={'add'}
src={add}
/>
className="h-design-24 w-design-24 shrink-0 cursor-pointer"
>
<SmartImage className="h-full w-full" alt="add" src={add} />
</button>
</div>
</button>
</div>
</div>
) : (
<div

View File

@@ -8,6 +8,7 @@ import fire from '@/assets/system/fire.webp'
import lock from '@/assets/system/lock.webp'
import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp'
import { JackpotPoolTicker } from '@/components/jackpot-pool-ticker.tsx'
import { LottiePlayer } from '@/components/lottie-player.tsx'
import { MessageBroadcast } from '@/components/message-broadcast.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
@@ -16,6 +17,8 @@ import { DesktopCountdown } from '@/features/game/components/desktop/desktop-cou
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
import { cn } from '@/lib/utils.ts'
const FORCE_DESKTOP_STREAK_EFFECT_PREVIEW = false
export function DesktopStatusLine() {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
@@ -31,7 +34,9 @@ export function DesktopStatusLine() {
} = useGameStatusVm()
const [remainingMs, setRemainingMs] = useState(countdownMs)
const showWarningCountdown = remainingMs <= 5000 && remainingMs > 0
const showStreakLimitOnly = typeof streakValue === 'number' && streakValue > 1
const showStreakLimitOnly =
FORCE_DESKTOP_STREAK_EFFECT_PREVIEW ||
(typeof streakValue === 'number' && streakValue > 1)
const countdownClassName = useMemo(
() =>
showWarningCountdown
@@ -41,182 +46,196 @@ export function DesktopStatusLine() {
)
return (
<div className={'relative w-full flex flex-col text-design-22'}>
<div className={'w-full px-design-16 mb-design-10'}>
<MessageBroadcast />
</div>
<SmartBackground
src={statusLine}
size="100% 100%"
className="w-full h-design-75 bg-no-repeat bg-center flex items-center justify-center"
>
{/* 状态栏左侧 */}
<div
className={
'relative h-full flex-1 flex items-center justify-center gap-design-50 overflow-visible'
}
>
{showStreakLimitOnly && (
<div className="relative w-full text-design-22">
<div className="flex w-full items-start gap-design-14 px-design-16">
<div className="flex min-w-0 flex-1 flex-col">
<div className="mb-design-10 w-full">
<MessageBroadcast showJackpotPool={false} />
</div>
<SmartBackground
src={statusLine}
size="100% 100%"
className="flex h-design-75 w-full items-center justify-center bg-center bg-no-repeat"
>
{/* 状态栏左侧 */}
<div
className={'pointer-events-none absolute bg-center bg-no-repeat'}
style={{
top: 'calc(var(--design-unit)*-14)',
right: 'calc(var(--design-unit)*-190)',
bottom: 'calc(var(--design-unit)*-1)',
left: 0,
backgroundImage: `url(${streakBg})`,
backgroundPosition: 'center',
backgroundSize: '113% 150%',
}}
/>
)}
className={
'relative flex h-full w-design-760 shrink-0 items-center justify-center gap-design-64 overflow-visible'
}
>
{showStreakLimitOnly && (
<div
className={
'pointer-events-none absolute bg-center bg-no-repeat'
}
style={{
top: 'calc(var(--design-unit)*-14)',
right: 'calc(var(--design-unit)*-190)',
bottom: 'calc(var(--design-unit)*-1)',
left: 0,
backgroundImage: `url(${streakBg})`,
backgroundPosition: 'center',
backgroundSize: '113% 150%',
}}
/>
)}
{!showStreakLimitOnly && (
<>
<div
className={
'shrink-0 whitespace-nowrap font-bold text-[#CBD3D5]'
}
>
{t('gameDesktop.status.odds')}:{' '}
<span className={'text-[#E3D171]'}>{oddsLabel}</span>
</div>
<div
className={
'flex shrink-0 items-center gap-design-5 whitespace-nowrap font-bold text-[#CBD3D5]'
}
>
<SmartImage
className={'w-design-37 h-design-47'}
alt={'fire'}
src={fire}
/>
<div>
{t('gameDesktop.status.streak')}:{' '}
<span
className={
'bg-gradient-to-b from-[#EBA661] to-[#FCC785] bg-clip-text text-transparent'
}
>
{streakLabel}
</span>
</div>
</div>
</>
)}
{!showStreakLimitOnly && (
<>
<div className={'text-[#CBD3D5] font-bold'}>
{t('gameDesktop.status.odds')}:{' '}
<span className={'text-[#E3D171]'}>{oddsLabel}</span>
</div>
<div
className={
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
'relative z-20 flex shrink-0 items-center gap-design-5 whitespace-nowrap font-bold text-[#CBD3D5]'
}
>
<SmartImage
className={'w-design-37 h-design-47'}
alt={'fire'}
src={fire}
className={'w-design-25 h-design-33'}
alt={'lock'}
src={lock}
/>
<div>
{t('gameDesktop.status.streak')}:{' '}
<span
className={
'bg-gradient-to-b from-[#EBA661] to-[#FCC785] bg-clip-text text-transparent'
}
>
{streakLabel}
</span>
<div className={'flex items-center gap-design-10'}>
<div>{t('gameDesktop.status.limit')}:</div>
<div className={'flex items-center gap-design-5'}>
<SmartImage
className={'w-design-35 h-design-35'}
alt={'diamond'}
src={diamond}
/>
<div>{limitLabel}</div>
</div>
</div>
</div>
</>
)}
</div>
<div
className={
'relative z-20 flex items-center gap-design-5 text-[#CBD3D5] font-bold'
}
>
<SmartImage
className={'w-design-25 h-design-33'}
alt={'lock'}
src={lock}
/>
<div className={'flex items-center gap-design-10'}>
<div>{t('gameDesktop.status.limit')}:</div>
<div className={'flex items-center gap-design-5'}>
<SmartImage
className={'w-design-35 h-design-35'}
alt={'diamond'}
src={diamond}
<div className="relative z-20 flex h-[105px] w-design-360 items-center justify-center">
<SmartBackground
src={statusCenter}
className="pointer-events-none absolute inset-0 bg-no-repeat bg-center bg-contain transition-opacity duration-500 ease-out"
size="contain"
style={{
opacity: showWarningCountdown ? 0.18 : 1,
transform: showWarningCountdown ? 'scale(0.985)' : 'scale(1)',
}}
/>
<div
className="pointer-events-none absolute inset-0 overflow-visible transition-all duration-500 ease-out"
style={{
opacity: showWarningCountdown ? 1 : 0,
transform: showWarningCountdown
? 'scale(1.1) translateY(calc(var(--design-unit)*1))'
: 'scale(1.02) translateY(0)',
}}
>
<LottiePlayer
animationData={down5Animation}
className="h-full w-full"
loop
autoplay
/>
<div>{limitLabel}</div>
</div>
<DesktopCountdown
initialMs={countdownMs}
onRemainingMsChange={setRemainingMs}
className={countdownClassName}
/>
</div>
<div className="grid min-w-0 w-design-374 shrink-0 grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] items-center justify-items-stretch pl-design-6 pr-design-16">
<div className="flex min-w-0 items-center justify-center gap-design-6">
<span className="relative flex h-design-24 w-design-24 items-center justify-center">
<motion.span
aria-hidden="true"
className="absolute h-design-24 w-design-24 rounded-full bg-[rgba(120,255,127,0.22)] blur-[calc(var(--design-unit)*3)]"
animate={
prefersReducedMotion
? undefined
: {
opacity: [0.36, 0.88, 0.42],
scale: [0.9, 1.28, 0.98],
}
}
transition={
prefersReducedMotion
? undefined
: {
duration: 1.25,
ease: 'easeInOut',
repeat: Number.POSITIVE_INFINITY,
}
}
/>
<motion.span
aria-hidden="true"
className="relative h-design-14 w-design-14 rounded-full bg-[#78FF7F] shadow-[0_0_calc(var(--design-unit)*8)_rgba(120,255,127,0.78),0_0_calc(var(--design-unit)*16)_rgba(120,255,127,0.34)]"
animate={
prefersReducedMotion
? undefined
: { opacity: [0.78, 1, 0.86], scale: [0.96, 1.12, 1] }
}
transition={
prefersReducedMotion
? undefined
: {
duration: 1.25,
ease: 'easeInOut',
repeat: Number.POSITIVE_INFINITY,
}
}
/>
</span>
<div
className={cn(
phaseToneClassName,
'font-black tracking-[0.08em] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(120,255,127,0.44)]',
)}
>
{phaseLabel}
</div>
</div>
<div className="flex min-w-0 items-baseline justify-center gap-design-5 font-bold leading-none">
<span className="text-design-20 tracking-[0.06em] text-[#9FBAC0]">
{t('gameDesktop.status.roundId')}:
</span>
<span className="min-w-0 truncate text-design-24 font-black tracking-[0.06em] text-[#E7FFFF] [text-shadow:0_0_calc(var(--design-unit)*9)_rgba(75,255,254,0.38)]">
{roundId}
</span>
</div>
</div>
</div>
</SmartBackground>
</div>
<div className="relative z-20 flex h-[105px] w-design-360 items-center justify-center">
<SmartBackground
src={statusCenter}
className="pointer-events-none absolute inset-0 bg-no-repeat bg-center bg-contain transition-opacity duration-500 ease-out"
size="contain"
style={{
opacity: showWarningCountdown ? 0.18 : 1,
transform: showWarningCountdown ? 'scale(0.985)' : 'scale(1)',
}}
/>
<div
className="pointer-events-none absolute inset-0 overflow-visible transition-all duration-500 ease-out"
style={{
opacity: showWarningCountdown ? 1 : 0,
transform: showWarningCountdown
? 'scale(1.1) translateY(calc(var(--design-unit)*1))'
: 'scale(1.02) translateY(0)',
}}
>
<LottiePlayer
animationData={down5Animation}
className="h-full w-full"
loop
autoplay
/>
</div>
<DesktopCountdown
initialMs={countdownMs}
onRemainingMsChange={setRemainingMs}
className={countdownClassName}
/>
</div>
<div className="flex flex-1 items-center justify-center gap-design-88">
<div className="flex items-center gap-design-9">
<span className="relative flex h-design-24 w-design-24 items-center justify-center">
<motion.span
aria-hidden="true"
className="absolute h-design-24 w-design-24 rounded-full bg-[rgba(120,255,127,0.22)] blur-[calc(var(--design-unit)*3)]"
animate={
prefersReducedMotion
? undefined
: { opacity: [0.36, 0.88, 0.42], scale: [0.9, 1.28, 0.98] }
}
transition={
prefersReducedMotion
? undefined
: {
duration: 1.25,
ease: 'easeInOut',
repeat: Number.POSITIVE_INFINITY,
}
}
/>
<motion.span
aria-hidden="true"
className="relative h-design-14 w-design-14 rounded-full bg-[#78FF7F] shadow-[0_0_calc(var(--design-unit)*8)_rgba(120,255,127,0.78),0_0_calc(var(--design-unit)*16)_rgba(120,255,127,0.34)]"
animate={
prefersReducedMotion
? undefined
: { opacity: [0.78, 1, 0.86], scale: [0.96, 1.12, 1] }
}
transition={
prefersReducedMotion
? undefined
: {
duration: 1.25,
ease: 'easeInOut',
repeat: Number.POSITIVE_INFINITY,
}
}
/>
</span>
<div
className={cn(
phaseToneClassName,
'font-black tracking-[0.08em] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(120,255,127,0.44)]',
)}
>
{phaseLabel}
</div>
</div>
<div className="flex items-baseline gap-design-7 font-bold leading-none">
<span className="text-design-20 tracking-[0.06em] text-[#9FBAC0]">
{t('gameDesktop.status.roundId')}:
</span>
<span className="text-design-24 font-black tracking-[0.06em] text-[#E7FFFF] [text-shadow:0_0_calc(var(--design-unit)*9)_rgba(75,255,254,0.38)]">
{roundId}
</span>
</div>
</div>
</SmartBackground>
<JackpotPoolTicker className="mt-design-0" variant="hud" />
</div>
</div>
)
}

View File

@@ -60,7 +60,7 @@ function WithdrawField({
}) {
return (
<div className="flex gap-design-14">
<div className="flex w-design-132 shrink-0 items-center justify-end whitespace-nowrap text-right text-design-16 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
<div className="flex w-design-160 shrink-0 items-center justify-end text-right text-design-15 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
<span>{label}</span>
<span className="pl-design-4">:</span>
</div>
@@ -132,13 +132,15 @@ function AmountShell({
}
function QuickAmountCard({
amount,
preview,
amountLabel,
diamondLabel,
feeLabel,
active,
onClick,
}: {
amount: number
preview: string
amountLabel: string
diamondLabel: string
feeLabel: string
active: boolean
onClick: () => void
}) {
@@ -147,7 +149,7 @@ function QuickAmountCard({
type="button"
onClick={onClick}
className={cn(
'group relative flex h-design-76 min-w-0 w-full cursor-pointer flex-col items-start justify-center overflow-hidden rounded-[calc(var(--design-unit)*8)] border px-design-12 text-left transition-[transform,border-color,background-color,box-shadow] duration-150',
'group relative flex h-design-92 min-w-0 w-full cursor-pointer flex-col gap-design-5 items-start justify-center overflow-hidden rounded-[calc(var(--design-unit)*8)] border px-design-12 text-left transition-[transform,border-color,background-color,box-shadow] duration-150',
active
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(88,54,28,0.96),rgba(56,33,18,0.92))] shadow-[0_0_calc(var(--design-unit)*14)_rgba(209,138,67,0.22),inset_0_0_calc(var(--design-unit)*12)_rgba(255,217,120,0.08)]'
: 'border-[rgba(103,227,239,0.26)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] hover:-translate-y-[1px] hover:border-[rgba(170,247,255,0.62)] hover:shadow-[0_0_calc(var(--design-unit)*12)_rgba(88,225,238,0.12)]',
@@ -161,16 +163,19 @@ function QuickAmountCard({
: 'bg-[rgba(122,220,230,0.26)]',
)}
/>
<div className="text-design-24 font-semibold leading-none text-[#FFE229]">
{amount}
<div className="max-w-full truncate pr-design-6 text-design-14 font-semibold tabular-nums leading-none text-[#FFE229]">
{amountLabel}
</div>
<div
className={cn(
'pt-design-6 text-design-12 leading-none tracking-[0.04em]',
'max-w-full truncate pt-design-7 text-[length:calc(var(--design-unit)*12)] leading-none',
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
)}
>
{preview}
{diamondLabel}
</div>
<div className="max-w-full truncate pt-design-5 text-design-10 leading-none text-[#F0B44A]">
{feeLabel}
</div>
</button>
)
@@ -290,12 +295,12 @@ function PreviewRow({
}) {
return (
<div className="flex border-b border-[rgba(89,209,223,0.2)] last:border-b-0">
<div className="flex w-[44%] shrink-0 items-center border-r border-[rgba(89,209,223,0.2)] px-design-14 py-design-20 text-design-16 font-medium uppercase leading-[1.15] text-[#7CE3E8]">
<div className="flex w-[50%] min-w-0 shrink-0 items-center break-words border-r border-[rgba(89,209,223,0.2)] px-design-14 py-design-18 text-design-15 font-medium uppercase leading-[1.2] text-[#7CE3E8]">
{label}
</div>
<div
className={cn(
'flex min-w-0 flex-1 items-center justify-end px-design-14 py-design-20 text-right text-design-16 text-[#E6FFFF]',
'flex min-w-0 flex-1 items-center justify-end break-words px-design-14 py-design-18 text-right text-design-16 text-[#E6FFFF]',
highlight && 'text-design-18 font-semibold text-[#6DFF83]',
)}
>
@@ -500,13 +505,14 @@ function DesktopWithdraw() {
</WithdrawField>
<div className="flex items-start gap-design-14">
<div className="w-design-132 shrink-0" />
<div className="w-design-160 shrink-0" />
<div className="grid min-w-0 flex-1 grid-cols-3 gap-design-10">
{vm.quickAmounts.map((option) => (
<QuickAmountCard
key={option.id}
amount={option.diamonds}
preview={option.preview}
amountLabel={option.amountLabel}
diamondLabel={option.diamondLabel}
feeLabel={option.feeLabel}
active={option.id === activeQuickAmountId}
onClick={() =>
handleQuickAmountSelect(option.id, option.diamonds)
@@ -664,7 +670,7 @@ function DesktopWithdraw() {
<div className="w-px shrink-0 bg-[linear-gradient(180deg,rgba(89,209,223,0)_0%,rgba(89,209,223,0.4)_12%,rgba(89,209,223,0.5)_88%,rgba(89,209,223,0)_100%)]" />
<div className="flex min-h-full min-w-0 w-design-520 shrink-0 flex-col">
<div className="flex min-h-full min-w-0 w-design-640 shrink-0 flex-col">
<div className="flex h-design-44 items-center border-b border-[rgba(89,209,223,0.2)] bg-[linear-gradient(90deg,rgba(18,99,110,0.8),rgba(7,68,79,0.9))] px-design-12 text-design-20 font-semibold uppercase tracking-[0.04em] text-[#9AF5FB]">
{t('gameDesktop.withdraw.preview.title')}
</div>
@@ -684,6 +690,10 @@ function DesktopWithdraw() {
value={vm.selectedCurrencyPreview.convertibleValue}
highlight={true}
/>
<PreviewRow
label={vm.selectedCurrencyPreview.handlingFeeLabel}
value={vm.selectedCurrencyPreview.handlingFeeValue}
/>
<PreviewRow
label={t(
'gameDesktop.withdraw.preview.fixedExchangeDiamondAmount',
@@ -735,7 +745,7 @@ function DesktopWithdraw() {
)}
>
{withdrawSubmitMutation.isPending
? t('commonUi.action.submitting')
? t('commonUi.actions.submitting')
: `${t('gameDesktop.withdraw.confirm')}${t('gameDesktop.withdraw.withdrawal')}`}
</SmartBackground>
</div>

View File

@@ -8,6 +8,7 @@ import { SmartImage } from '@/components/smart-image'
import { MobileAnimalOverlay } from '@/features/game/components/mobile/mobile-animal-overlay.tsx'
import { MobileStatusLine } from '@/features/game/components/mobile/mobile-status.tsx'
import { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image'
import { LongPressProgress } from '@/features/game/components/shared/long-press-progress'
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
import { useAnimalVm } from '@/hooks/use-animal-vm'
import { cn } from '@/lib/utils'
@@ -83,10 +84,17 @@ export function MobileAnimal({
)
const {
cellWarning,
handleClearCell,
handleLongPressCancel,
handleLongPressEnd,
handleLongPressMove,
handleLongPressStart,
handleSelect,
handleStart,
isRealtimeConnecting,
lockInteraction,
longPressCellId,
longPressDurationMs,
marqueeId,
selectionByCell,
showStandbyState,
@@ -216,6 +224,18 @@ export function MobileAnimal({
type="button"
disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)}
onContextMenu={(event) => event.preventDefault()}
onDragStart={(event) => event.preventDefault()}
onKeyDown={(event) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
handleClearCell(item.id)
}
}}
onPointerCancel={handleLongPressCancel}
onPointerDown={(event) => handleLongPressStart(item.id, event)}
onPointerMove={handleLongPressMove}
onPointerUp={handleLongPressEnd}
animate={
showCellWarning
? {
@@ -238,7 +258,7 @@ export function MobileAnimal({
: { duration: 0.16, ease: 'easeOut' }
}
className={cn(
'relative flex h-design-36 flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
'mobile-animal-touch-target relative flex h-design-36 flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
!isRevealActive && 'overflow-hidden',
lockInteraction
? 'cursor-not-allowed opacity-90'
@@ -376,6 +396,13 @@ export function MobileAnimal({
</span>
</span>
) : null}
{longPressCellId === item.id ? (
<LongPressProgress
compact
durationMs={longPressDurationMs}
label={t('gameDesktop.animal.cancelBet')}
/>
) : null}
</motion.button>
)
})}

View File

@@ -1,17 +1,14 @@
import { motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import reduce from '@/assets/game/add.webp'
import chipBg from '@/assets/game/chip-bg.webp'
import chipLineBg from '@/assets/game/chip-line-bg.webp'
import chipLock from '@/assets/game/chip-lock.webp'
import confirmRedBg from '@/assets/game/confirm-red-bg.png'
import controlBg from '@/assets/game/control-bg.png'
import leftBottomBg from '@/assets/game/left-bg.webp'
import mobileAddReduceBg from '@/assets/game/mobile-add-reduce-bg.webp'
import mobileConfirmBg from '@/assets/game/mobile-contro-comfirm.webp'
import mobileTotalBg from '@/assets/game/mobile-control-number.webp'
import add from '@/assets/game/reduce.webp'
import diamond from '@/assets/system/diamond.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
@@ -27,8 +24,6 @@ export function MobileControl() {
acceptingBets,
actionsEnabled,
canClear,
canDecreaseBetQuantity,
canIncreaseBetQuantity,
chips,
confirmLabel,
confirmState,
@@ -37,11 +32,8 @@ export function MobileControl() {
onChipSelect,
onConfirm,
onClearSelections,
onDecreaseBetQuantity,
onIncreaseBetQuantity,
onOpenAutoSetting,
onRepeatSelections,
selectedBetQuantityLabel,
selectedChipId,
selectedCountLabel,
totalBetAmountLabel,
@@ -154,7 +146,7 @@ export function MobileControl() {
<SmartBackground
src={chipLineBg}
size="100% 100%"
className="flex h-design-32 min-w-0 items-center justify-between gap-design-7 overflow-visible"
className="flex h-design-32 min-w-0 flex-1 items-center justify-around overflow-visible"
>
{chips.map((chip) => {
const isSelected = chip.id === selectedChipId
@@ -287,50 +279,6 @@ export function MobileControl() {
})}
</SmartBackground>
</SmartBackground>
<SmartBackground
src={mobileAddReduceBg}
size="100% 100%"
className="relative z-20 !ml-[calc(var(--design-unit)*-8)] pl-design-15 flex h-full w-design-103 shrink-0 items-center justify-center bg-center bg-no-repeat px-design-7 text-design-13 font-bold"
>
<button
type="button"
disabled={!acceptingBets || !canDecreaseBetQuantity}
onClick={onDecreaseBetQuantity}
className={cn(
'flex h-design-24 w-design-24 shrink-0 items-center justify-center',
acceptingBets && canDecreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage
src={add}
alt="decrease"
className="h-design-16 w-design-16"
/>
</button>
<div className="flex h-design-24 w-design-20 items-center justify-center rounded-sm">
{selectedBetQuantityLabel}
</div>
<button
type="button"
disabled={!acceptingBets || !canIncreaseBetQuantity}
onClick={onIncreaseBetQuantity}
className={cn(
'flex h-design-24 w-design-24 shrink-0 items-center justify-center',
acceptingBets && canIncreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage
src={reduce}
alt="increase"
className="h-design-16 w-design-16"
/>
</button>
</SmartBackground>
</div>
<div className="flex h-design-40 w-full items-center gap-design-3">

View File

@@ -1,6 +1,7 @@
import {
Info,
Mail,
RefreshCw,
UserKey,
UserRoundPlus,
Volume2,
@@ -15,6 +16,7 @@ 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 '@/hooks/use-header-vm'
import { useWalletBalanceRefresh } from '@/hooks/use-wallet-balance-refresh'
import { cn } from '@/lib/utils'
import { useModalStore } from '@/store'
@@ -68,6 +70,8 @@ function SignalBars({
export function MobileHeader() {
const { t } = useTranslation()
const setModalOpen = useModalStore((state) => state.setModalOpen)
const { isRefreshingBalance, refreshWalletBalance } =
useWalletBalanceRefresh()
const {
authStatus,
currentLanguageLabel,
@@ -133,11 +137,7 @@ export function MobileHeader() {
</div>
</button>
<button
type="button"
onClick={onOpenProcedures}
className="group relative flex min-w-0 items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
>
<div className="group relative flex min-w-0 items-center justify-center">
<SmartImage
src={diamond}
alt="diamond"
@@ -150,14 +150,36 @@ export function MobileHeader() {
<span className="min-w-0 truncate">
{currentUser?.coin || '--'}
</span>
<SmartImage
src={add}
alt="add"
priority
<button
type="button"
aria-label={t('commonUi.actions.refreshBalance')}
title={t('commonUi.actions.refreshBalance')}
disabled={isRefreshingBalance}
onClick={() => void refreshWalletBalance()}
className="flex h-design-13 w-design-13 shrink-0 cursor-pointer items-center justify-center text-[#8DE4EA] disabled:cursor-wait disabled:opacity-65"
>
<RefreshCw
aria-hidden="true"
className={cn(
'h-design-9 w-design-9',
isRefreshingBalance && 'animate-spin',
)}
/>
</button>
<button
type="button"
onClick={onOpenProcedures}
className="h-design-13 w-design-13 shrink-0 cursor-pointer"
/>
>
<SmartImage
src={add}
alt="add"
priority
className="h-full w-full"
/>
</button>
</div>
</button>
</div>
</div>
) : (
<div className="flex h-full shrink-0 items-center justify-end gap-design-8 px-design-9">

View File

@@ -15,6 +15,8 @@ import { DesktopCountdown } from '@/features/game/components/desktop/desktop-cou
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
import { cn } from '@/lib/utils.ts'
const FORCE_MOBILE_STREAK_EFFECT_PREVIEW = false
function isIosDevice() {
if (typeof navigator === 'undefined') {
return false
@@ -160,34 +162,41 @@ export function MobileStatusLine() {
export function MobileStatusMetrics() {
const { t } = useTranslation()
const { limitLabel, oddsLabel, streakLabel, streakValue } = useGameStatusVm()
const showStreakLimitOnly = typeof streakValue === 'number' && streakValue > 1
const showStreakLimitOnly =
FORCE_MOBILE_STREAK_EFFECT_PREVIEW ||
(typeof streakValue === 'number' && streakValue > 1)
return (
<SmartBackground
src={statusLine}
size="100% 100%"
className="relative flex h-design-26 w-full items-center overflow-visible bg-center bg-no-repeat px-design-10 font-bold leading-none text-[#CBD3D5]"
style={{ fontSize: 'calc(var(--design-unit) * 7)' }}
src={showStreakLimitOnly ? streakBg : statusLine}
size={showStreakLimitOnly ? '106% 126%' : '100% 100%'}
className={cn(
'relative grid h-design-26 w-full items-center overflow-visible bg-center bg-no-repeat px-design-3 font-bold leading-none text-[#CBD3D5]',
showStreakLimitOnly
? 'grid-cols-2 text-design-7'
: 'grid-cols-3 text-[calc(var(--design-unit)*5.5)]',
)}
>
{showStreakLimitOnly ? (
<div
className="pointer-events-none absolute left-0 right-0 z-0 bg-center bg-no-repeat opacity-95"
style={{
backgroundImage: `url(${streakBg})`,
backgroundSize: '120% 150%',
bottom: 'calc(var(--design-unit)*-5)',
top: 'calc(var(--design-unit)*-6)',
}}
/>
<div className="relative z-10 flex min-w-0 items-center justify-center gap-design-2 whitespace-nowrap [text-shadow:0_0_calc(var(--design-unit)*3)_#000,0_calc(var(--design-unit)*1)_calc(var(--design-unit)*1)_#000]">
<SmartImage
className="h-design-10 w-design-8"
alt="fire"
src={fire}
/>
<span>{t('gameDesktop.status.streak')}:</span>
{/*<span className="bg-gradient-to-b from-[#FF9C36] to-[#FF9C36] bg-clip-text text-transparent">*/}
<span className="text-[#FF9C36]">{streakLabel}</span>
</div>
) : (
<>
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center">
<div className="relative z-10 flex min-w-0 items-center justify-center whitespace-nowrap">
{t('gameDesktop.status.odds')}:
<span className="ml-design-3 text-[#E3D171]">{oddsLabel}</span>
<span className="ml-design-1 text-[#E3D171]">{oddsLabel}</span>
</div>
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center gap-design-2">
<div className="relative z-10 flex min-w-0 items-center justify-center gap-design-1 whitespace-nowrap">
<SmartImage
className="h-design-11 w-design-9"
className="h-design-8 w-design-7"
alt="fire"
src={fire}
/>
@@ -198,11 +207,18 @@ export function MobileStatusMetrics() {
</div>
</>
)}
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center gap-design-2">
<SmartImage className="h-design-10 w-design-8" alt="lock" src={lock} />
<div
className={cn(
'relative z-10 flex min-w-0 items-center justify-center whitespace-nowrap',
showStreakLimitOnly
? 'col-start-2 gap-design-2 [text-shadow:0_0_calc(var(--design-unit)*3)_#000,0_calc(var(--design-unit)*1)_calc(var(--design-unit)*1)_#000]'
: 'col-start-3 gap-design-1',
)}
>
<SmartImage className="h-design-8 w-design-6" alt="lock" src={lock} />
<span>{t('gameDesktop.status.limit')}:</span>
<SmartImage
className="h-design-10 w-design-10"
className="h-design-8 w-design-8"
alt="diamond"
src={diamond}
/>

View File

@@ -122,13 +122,15 @@ function AmountShell({
}
function QuickAmountCard({
amount,
preview,
amountLabel,
diamondLabel,
feeLabel,
active,
onClick,
}: {
amount: number
preview: string
amountLabel: string
diamondLabel: string
feeLabel: string
active: boolean
onClick: () => void
}) {
@@ -137,7 +139,7 @@ function QuickAmountCard({
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',
'group relative flex h-design-58 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',
@@ -151,16 +153,19 @@ function QuickAmountCard({
: 'bg-[rgba(122,220,230,0.26)]',
)}
/>
<div className="max-w-full truncate text-design-13 font-semibold leading-none text-[#FFE229]">
{amount}
<div className="max-w-full truncate text-design-12 font-semibold leading-none text-[#FFE229]">
{amountLabel}
</div>
<div
className={cn(
'max-w-full truncate pt-design-3 text-design-10 leading-none',
'max-w-full truncate pt-design-3 text-[length:calc(var(--design-unit)*10)] leading-none',
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
)}
>
{preview}
{diamondLabel}
</div>
<div className="max-w-full truncate pt-design-2 text-design-10 leading-none text-[#F0B44A]">
{feeLabel}
</div>
</button>
)
@@ -490,8 +495,9 @@ function MobileWithdraw() {
{vm.quickAmounts.map((option) => (
<QuickAmountCard
key={option.id}
amount={option.diamonds}
preview={option.preview}
amountLabel={option.amountLabel}
diamondLabel={option.diamondLabel}
feeLabel={option.feeLabel}
active={option.id === activeQuickAmountId}
onClick={() =>
handleQuickAmountSelect(option.id, option.diamonds)
@@ -654,6 +660,10 @@ function MobileWithdraw() {
value={vm.selectedCurrencyPreview.convertibleValue}
highlight={true}
/>
<PreviewRow
label={vm.selectedCurrencyPreview.handlingFeeLabel}
value={vm.selectedCurrencyPreview.handlingFeeValue}
/>
<PreviewRow
label={t(
'gameDesktop.withdraw.preview.fixedExchangeDiamondAmount',
@@ -707,7 +717,7 @@ function MobileWithdraw() {
)}
>
{withdrawSubmitMutation.isPending
? t('commonUi.action.submitting')
? t('commonUi.actions.submitting')
: `${t('gameDesktop.withdraw.confirm')}`}
</SmartBackground>
</div>

View File

@@ -0,0 +1,77 @@
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
interface LongPressProgressProps {
compact?: boolean
durationMs: number
label: string
}
export function LongPressProgress({
compact = false,
durationMs,
label,
}: LongPressProgressProps) {
return (
<motion.span
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn(
'pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-[rgba(20,3,6,0.42)]',
compact
? 'rounded-[calc(var(--design-unit)*6)]'
: 'rounded-[calc(var(--design-unit)*18)]',
)}
>
<motion.span
initial={{ opacity: 0.25, scale: 0.82 }}
animate={{ opacity: 0.9, scale: 1 }}
transition={{ duration: durationMs / 1000, ease: 'linear' }}
className={cn(
'absolute rounded-full bg-[radial-gradient(circle,rgba(255,104,104,0.28)_0%,rgba(255,48,64,0.12)_52%,transparent_72%)]',
compact ? 'h-design-25 w-design-25' : 'h-design-76 w-design-76',
)}
/>
<svg
aria-hidden="true"
viewBox="0 0 36 36"
className={cn(
'relative -rotate-90 drop-shadow-[0_0_calc(var(--design-unit)*6)_rgba(255,74,86,0.72)]',
compact ? 'h-design-22 w-design-22' : 'h-design-64 w-design-64',
)}
>
<circle
cx="18"
cy="18"
r="15.5"
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="3"
/>
<motion.circle
cx="18"
cy="18"
r="15.5"
fill="none"
stroke="#FF5968"
strokeLinecap="round"
strokeWidth="3"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: durationMs / 1000, ease: 'linear' }}
/>
</svg>
<span
className={cn(
'absolute z-10 text-center font-bold uppercase leading-[1.05] text-[#FFE5E8] [text-shadow:0_0_calc(var(--design-unit)*4)_rgba(255,40,58,0.95)]',
compact
? 'max-w-design-18 text-[calc(var(--design-unit)*3)]'
: 'max-w-design-52 text-design-9',
)}
>
{label}
</span>
</motion.span>
)
}

View File

@@ -48,6 +48,7 @@ function createEmptyDashboardState(nowIso: string): DashboardState {
return {
countdownMs: 0,
featuredCellId: null,
jackpotPool: null,
onlinePlayers: 0,
tableLimitMax: 0,
tableLimitMin: 0,

View File

@@ -1,14 +1,21 @@
import { useEffect, useMemo, useState } from 'react'
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
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'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
import type { DesktopAnimalWarningType } from '@/type'
const LONG_PRESS_DURATION_MS = 500
const LONG_PRESS_CLICK_SUPPRESSION_MS = 1_000
const LONG_PRESS_MOVE_TOLERANCE_PX = 8
function parseBalance(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
@@ -57,9 +64,6 @@ export function useAnimalVm(
)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const activeBetQuantity = useGameRoundStore(
(state) => state.activeBetQuantity,
)
const chips = useGameRoundStore((state) => state.chips)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const roundId = useGameRoundStore((state) => state.round.id)
@@ -72,7 +76,6 @@ export function useAnimalVm(
(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,
@@ -90,6 +93,17 @@ export function useAnimalVm(
cellId: number
type: DesktopAnimalWarningType
} | null>(null)
const [longPressCellId, setLongPressCellId] = useState<number | null>(null)
const longPressTimerRef = useRef<number | null>(null)
const longPressOriginRef = useRef<{
pointerId: number
x: number
y: number
} | null>(null)
const suppressedClickRef = useRef<{
cellId: number
expiresAt: number
} | null>(null)
const activeChip = useMemo(
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
@@ -125,6 +139,13 @@ export function useAnimalVm(
showStandbyState || hasSubmittedCurrentRound || roundPhase !== 'betting'
const selectedCellCount = Object.keys(selectionByCell).length
const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
}, [])
useEffect(() => {
if (cellWarning === null) {
return
@@ -166,6 +187,12 @@ export function useAnimalVm(
}
}, [animalIds, shouldAnimateStandby])
useEffect(() => {
return () => {
clearLongPressTimer()
}
}, [clearLongPressTimer])
const handleStart = () => {
if (authStatus !== 'authenticated') {
notify.warning(t('commonUi.toast.loginRequired'))
@@ -179,6 +206,19 @@ export function useAnimalVm(
}
const handleSelect = (animalId: number) => {
const suppressedClick = suppressedClickRef.current
if (suppressedClick !== null) {
suppressedClickRef.current = null
if (
suppressedClick.cellId === animalId &&
Date.now() <= suppressedClick.expiresAt
) {
return
}
}
if (roundPhase !== 'betting' || lockInteraction) {
return
}
@@ -188,12 +228,9 @@ export function useAnimalVm(
return
}
if (selectionByCell[animalId]) {
removeSelectionsForCell(animalId)
return
}
const hasExistingSelection = Boolean(selectionByCell[animalId])
if (selectedCellCount >= maxSelectionCount) {
if (!hasExistingSelection && selectedCellCount >= maxSelectionCount) {
setCellWarning({
cellId: animalId,
type: 'limit',
@@ -201,9 +238,16 @@ export function useAnimalVm(
return
}
const nextBetAmount = (activeChip?.amount ?? 0) * activeBetQuantity
const currentSingleBetAmount = selections[0]?.amount ?? 0
const nextSingleBetAmount = hasExistingSelection
? currentSingleBetAmount + (activeChip?.amount ?? 0)
: (activeChip?.amount ?? 0)
const nextSelectedCellCount = hasExistingSelection
? selectedCellCount
: selectedCellCount + 1
const nextTotalBetAmount = nextSingleBetAmount * nextSelectedCellCount
if (tableLimitMax > 0 && totalBetAmount + nextBetAmount > tableLimitMax) {
if (tableLimitMax > 0 && nextSingleBetAmount > tableLimitMax) {
setCellWarning({
cellId: animalId,
type: 'betLimit',
@@ -211,7 +255,7 @@ export function useAnimalVm(
return
}
if (totalBetAmount + nextBetAmount > balance) {
if (nextTotalBetAmount > balance) {
setCellWarning({
cellId: animalId,
type: 'balance',
@@ -222,12 +266,94 @@ export function useAnimalVm(
placeBet(animalId)
}
const handleClearCell = (animalId: number) => {
if (lockInteraction || !selectionByCell[animalId]) {
return
}
removeSelectionsForCell(animalId)
}
const handleLongPressStart = (
animalId: number,
event: ReactPointerEvent<HTMLButtonElement>,
) => {
if (
lockInteraction ||
!selectionByCell[animalId] ||
(event.pointerType === 'mouse' && event.button !== 0)
) {
return
}
clearLongPressTimer()
suppressedClickRef.current = null
setLongPressCellId(animalId)
longPressOriginRef.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
}
event.currentTarget.setPointerCapture(event.pointerId)
longPressTimerRef.current = window.setTimeout(() => {
suppressedClickRef.current = {
cellId: animalId,
expiresAt: Date.now() + LONG_PRESS_CLICK_SUPPRESSION_MS,
}
longPressTimerRef.current = null
longPressOriginRef.current = null
setLongPressCellId(null)
handleClearCell(animalId)
if (typeof navigator.vibrate === 'function') {
navigator.vibrate(30)
}
}, LONG_PRESS_DURATION_MS)
}
const handleLongPressMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
const origin = longPressOriginRef.current
if (!origin || origin.pointerId !== event.pointerId) {
return
}
if (
Math.abs(event.clientX - origin.x) > LONG_PRESS_MOVE_TOLERANCE_PX ||
Math.abs(event.clientY - origin.y) > LONG_PRESS_MOVE_TOLERANCE_PX
) {
clearLongPressTimer()
longPressOriginRef.current = null
setLongPressCellId(null)
}
}
const handleLongPressEnd = () => {
clearLongPressTimer()
longPressOriginRef.current = null
setLongPressCellId(null)
}
const handleLongPressCancel = () => {
clearLongPressTimer()
longPressOriginRef.current = null
suppressedClickRef.current = null
setLongPressCellId(null)
}
return {
cellWarning,
handleClearCell,
handleLongPressCancel,
handleLongPressEnd,
handleLongPressMove,
handleLongPressStart,
handleSelect,
handleStart,
isRealtimeConnecting,
lockInteraction,
longPressCellId,
longPressDurationMs: LONG_PRESS_DURATION_MS,
marqueeId: shouldAnimateStandby ? marqueeId : null,
selectionByCell,
showStandbyState,

View File

@@ -56,32 +56,21 @@ function formatBetAmount(amount: number) {
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)
function resolveUniformBet(selections: BetSelection[]) {
const latestSelection = selections.at(-1)
const betId = toBetId(latestSelection?.chipId ?? '')
if (betId === null) {
return accumulator
}
if (!latestSelection || betId === null || latestSelection.amount <= 0) {
return null
}
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())
return {
amount: latestSelection.amount,
betId,
numbers: [...new Set(selections.map((selection) => selection.cellId))].sort(
(left, right) => left - right,
),
}
}
export function useAutoHostingRunner() {
@@ -167,9 +156,9 @@ export function useAutoHostingRunner() {
return
}
const groupedSelections = groupSelections(selections)
const uniformBet = resolveUniformBet(selections)
if (groupedSelections.size === 0) {
if (!uniformBet || uniformBet.numbers.length === 0) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStopped'))
return
@@ -181,7 +170,7 @@ export function useAutoHostingRunner() {
)
const balance = parseBalance(currentUser.coin)
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
if (tableLimitMax > 0 && uniformBet.amount > tableLimitMax) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStoppedBetLimit'))
return
@@ -199,38 +188,22 @@ export function useAutoHostingRunner() {
const submitAutoBet = async () => {
try {
let latestBalance = currentUser.coin ?? '0'
let remainingBalance = parseBalance(latestBalance)
const formattedSingleBetAmount = formatBetAmount(uniformBet.amount)
const result = await placeGameBet({
bet_amount: formattedSingleBetAmount,
bet_id: uniformBet.betId,
idempotency_key: createIdempotencyKey(),
numbers: uniformBet.numbers.join(','),
period_no: round.id,
single_bet_amount: formattedSingleBetAmount,
})
for (const group of groupedSelections.values()) {
const uniqueNumbers = [...new Set(group.numbers)].sort(
(left, right) => left - right,
)
const groupCost = group.amount * uniqueNumbers.length
if (groupCost > remainingBalance) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
return
}
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
remainingBalance = parseBalance(latestBalance)
if (result.status !== 'accepted') {
throw new Error(t('commonUi.toast.betRejected'))
}
latestBalance = result.balance_after
const latestHostingState = useGameAutoHostingStore.getState()
const latestUser = useAuthStore.getState().currentUser

View File

@@ -61,16 +61,10 @@ 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(
@@ -126,31 +120,12 @@ export function useGameControlVm() {
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
const hasExceededBetLimit =
hasSelections && tableLimitMax > 0 && totalBetAmount > tableLimitMax
const canIncreaseBetQuantity = useMemo(() => {
if (!hasSelections) {
return true
}
const activeChip = chips.find((chip) => chip.id === activeChipId)
if (!activeChip) {
return false
}
const nextQuantity = activeBetQuantity + 1
const nextTotalPerSelection = activeChip.amount * nextQuantity
const nextTotal = nextTotalPerSelection * selections.length
return tableLimitMax <= 0 || nextTotal <= tableLimitMax
}, [
hasSelections,
chips,
activeChipId,
activeBetQuantity,
selections.length,
tableLimitMax,
])
hasSelections &&
tableLimitMax > 0 &&
selections.some((selection) => selection.amount > tableLimitMax)
const selectedCellCount = new Set(
selections.map((selection) => selection.cellId),
).size
const confirmState: ConfirmState =
isSubmitting || isAutoHosting
? 'submitting'
@@ -202,8 +177,9 @@ export function useGameControlVm() {
return
}
const betId = toBetId(selections[0]?.chipId ?? activeChipId)
const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0
const latestSelection = selections.at(-1)
const betId = toBetId(latestSelection?.chipId ?? activeChipId)
const singleBetAmount = latestSelection?.amount ?? selectedChip?.amount ?? 0
if (betId === null || singleBetAmount <= 0) {
notify.warning(t('commonUi.toast.betUnavailable'))
@@ -316,8 +292,6 @@ export function useGameControlVm() {
round.phase === 'betting' &&
!hasSubmittedCurrentRound &&
!isAutoHosting,
canDecreaseBetQuantity: activeBetQuantity > 1,
canIncreaseBetQuantity,
confirmLabel:
confirmState === 'idle'
? t('gameDesktop.control.selectNumbers')
@@ -331,23 +305,13 @@ export function useGameControlVm() {
confirmState,
isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
onChipSelect: selectChip,
onDecreaseBetQuantity: () => adjustBetQuantity(-1),
onIncreaseBetQuantity: () => {
if (!canIncreaseBetQuantity) {
notify.warning(t('commonUi.toast.betLimitExceeded'))
return
}
adjustBetQuantity(1)
},
onConfirm: handleConfirm,
onClearSelections: clearSelections,
onOpenAutoSetting: handleOpenAutoSetting,
onRepeatSelections: handleRepeatSelections,
maxSelectionCountLabel: maxSelectionCount,
selectedBetQuantityLabel: activeBetQuantity,
selectedChipId: activeChipId,
selectedCountLabel: selections.length,
selectedCountLabel: selectedCellCount,
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
chips: chipItems,
}

View File

@@ -63,6 +63,7 @@ function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
},
dashboard: {
...currentSessionState.dashboard,
jackpotPool: result.snapshot.dashboard.jackpotPool,
tableLimitMax: result.snapshot.dashboard.tableLimitMax,
tableLimitMin: result.snapshot.dashboard.tableLimitMin,
},
@@ -220,6 +221,55 @@ function applyPeriodPayoutMessage(
useGameRoundStore.getState().playPreparedRevealAnimation(roundId || null)
}
let jackpotPoolRefreshTimerId: number | null = null
const JACKPOT_STREAK_TRIGGER_COUNT = 10
const JACKPOT_POOL_REFRESH_DELAY_MS = 2_000
async function refreshJackpotPoolFromLobbyInit() {
const authState = useAuthStore.getState()
if (authState.status !== 'authenticated' || !authState.accessToken) {
return
}
try {
const result = await getGameLobbyInit()
useGameSessionStore.getState().syncDashboard({
jackpotPool: result.snapshot.dashboard.jackpotPool,
updatedAt: result.snapshot.dashboard.updatedAt,
})
} catch (error) {
console.error(
'Failed to refresh jackpot pool after streak milestone',
error,
)
}
}
function scheduleJackpotPoolRefreshForStreak(
previousStreak: number,
nextStreak: number | undefined,
) {
if (
nextStreak !== JACKPOT_STREAK_TRIGGER_COUNT ||
previousStreak >= JACKPOT_STREAK_TRIGGER_COUNT ||
typeof window === 'undefined'
) {
return
}
if (jackpotPoolRefreshTimerId !== null) {
window.clearTimeout(jackpotPoolRefreshTimerId)
}
jackpotPoolRefreshTimerId = window.setTimeout(() => {
jackpotPoolRefreshTimerId = null
void refreshJackpotPoolFromLobbyInit()
}, JACKPOT_POOL_REFRESH_DELAY_MS)
}
function applyUserStreakMessage(message: GameSocketMessage) {
const streakData = extractUserStreakMessageData(message)
const currentUser = useAuthStore.getState().currentUser
@@ -228,6 +278,11 @@ function applyUserStreakMessage(message: GameSocketMessage) {
return
}
scheduleJackpotPoolRefreshForStreak(
currentUser.currentStreak ?? 0,
streakData.currentStreak,
)
useAuthStore.getState().setCurrentUser({
...currentUser,
currentStreak: streakData.currentStreak,
@@ -298,6 +353,11 @@ function applyBetWinMessage(message: GameSocketMessage) {
return
}
scheduleJackpotPoolRefreshForStreak(
currentUser.currentStreak ?? 0,
betWinData.current_streak,
)
useAuthStore.getState().setCurrentUser({
...currentUser,
coin: betWinData.balance_after ?? currentUser.coin,

View File

@@ -0,0 +1,45 @@
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getCurrentUserProfile } from '@/api'
import { notify } from '@/lib/notify'
import { useAuthStore } from '@/store'
export function useWalletBalanceRefresh() {
const { t } = useTranslation()
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const [isRefreshingBalance, setIsRefreshingBalance] = useState(false)
const refreshPromiseRef = useRef<Promise<void> | null>(null)
const refreshWalletBalance = useCallback(() => {
if (refreshPromiseRef.current) {
return refreshPromiseRef.current
}
setIsRefreshingBalance(true)
const refreshPromise = getCurrentUserProfile()
.then((currentUser) => {
setCurrentUser(currentUser)
})
.catch((error: unknown) => {
notify.error(
error instanceof Error
? error.message
: t('commonUi.toast.requestFailed'),
)
})
.finally(() => {
refreshPromiseRef.current = null
setIsRefreshingBalance(false)
})
refreshPromiseRef.current = refreshPromise
return refreshPromise
}, [setCurrentUser, t])
return {
isRefreshingBalance,
refreshWalletBalance,
}
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
DEFAULT_WITHDRAW_CONFIG,
@@ -7,7 +7,7 @@ import {
} from '@/constants'
import { useDepositWithdrawConfig } from '@/hooks/use-deposit-withdraw-config'
import { useAuthStore } from '@/store'
import type { DepositWithdrawConfig } from '@/type'
import type { AuthWithdrawAccount, DepositWithdrawConfig } from '@/type'
const QUICK_WITHDRAW_OPTION_COUNT = 6
@@ -16,10 +16,64 @@ interface WithdrawDiamondBounds {
minimumDiamonds: number
}
interface WithdrawAccountDefaults {
bankAccount: string
holderName: string
receiverEmail: string
receiverPhone: string
}
function getWithdrawAccountDefaults(
withdraw: AuthWithdrawAccount | null | undefined,
): WithdrawAccountDefaults {
return {
bankAccount: withdraw?.receiveAccount ?? '',
holderName: withdraw?.receiverName ?? '',
receiverEmail: withdraw?.receiverEmail ?? '',
receiverPhone: withdraw?.receiverMobile ?? '',
}
}
function formatNumber(locale: string, value: number) {
return new Intl.NumberFormat(locale).format(value)
}
function formatConfiguredAmount(locale: string, value: string) {
const normalizedValue = value.trim()
const numericValue = Number(normalizedValue)
if (!Number.isFinite(numericValue)) {
return normalizedValue || '0'
}
const fractionDigits = Math.min(
6,
normalizedValue.includes('.')
? (normalizedValue.split('.')[1]?.length ?? 0)
: 0,
)
return new Intl.NumberFormat(locale, {
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
}).format(numericValue)
}
function calculateNetWithdrawalAmount(
grossAmount: number,
handlingFee: string,
) {
const parsedHandlingFee = Number(handlingFee)
if (!Number.isFinite(parsedHandlingFee)) {
return grossAmount
}
const handlingFeeRate = Math.min(100, Math.max(0, parsedHandlingFee)) / 100
return grossAmount * (1 - handlingFeeRate)
}
function getInitialWithdrawAmount(bounds: WithdrawDiamondBounds | null) {
return bounds?.minimumDiamonds ?? 0
}
@@ -164,6 +218,11 @@ export function useWithdrawVm() {
const { i18n, t } = useTranslation()
const currentUser = useAuthStore((state) => state.currentUser)
const withdrawConfigQuery = useDepositWithdrawConfig()
const withdrawAccountDefaults = useMemo(
() => getWithdrawAccountDefaults(currentUser?.withdraw),
[currentUser?.withdraw],
)
const hasEditedWithdrawAccountRef = useRef(false)
const config = useMemo(() => {
const baseConfig = getNormalizedConfig(
withdrawConfigQuery.data,
@@ -192,10 +251,18 @@ export function useWithdrawVm() {
const [paymentChannelCode, setPaymentChannelCode] = useState('')
const [paymentType, setPaymentType] = useState('')
const [bankCode, setBankCode] = useState('')
const [holderName, setHolderName] = useState('')
const [bankAccount, setBankAccount] = useState('')
const [receiverEmail, setReceiverEmail] = useState('')
const [receiverPhone, setReceiverPhone] = useState('')
const [holderName, setHolderNameState] = useState(
() => withdrawAccountDefaults.holderName,
)
const [bankAccount, setBankAccountState] = useState(
() => withdrawAccountDefaults.bankAccount,
)
const [receiverEmail, setReceiverEmailState] = useState(
() => withdrawAccountDefaults.receiverEmail,
)
const [receiverPhone, setReceiverPhoneState] = useState(
() => withdrawAccountDefaults.receiverPhone,
)
const sortedPayChannels = useMemo(
() =>
@@ -209,6 +276,7 @@ export function useWithdrawVm() {
const selectedPaymentChannel =
sortedPayChannels.find((channel) => channel.code === paymentChannelCode) ??
null
const selectedHandlingFee = selectedPaymentChannel?.handlingFee ?? '0.00'
const availablePaymentMethods = useMemo(
() => selectedPaymentChannel?.methods ?? [],
[selectedPaymentChannel],
@@ -223,6 +291,9 @@ export function useWithdrawVm() {
const selectedCurrency =
availableCurrencies.find((item) => item.code === currencyCode) ?? null
const selectedCurrencyCode = selectedCurrency?.code ?? ''
const selectedHandlingFeeValue = selectedPaymentChannel
? `${formatConfiguredAmount(locale, selectedHandlingFee)}%`
: '--'
const selectedRate = selectedCurrency?.withdrawCoinsPerFiatValue ?? 0
const selectedExchangeRate = selectedCurrency
? getCurrencyExchangeRate(config, selectedCurrency.code, selectedRate || 1)
@@ -278,6 +349,28 @@ export function useWithdrawVm() {
},
[maxWithdrawAmount],
)
const applyWithdrawAccountDefaults = useCallback(() => {
setHolderNameState(withdrawAccountDefaults.holderName)
setBankAccountState(withdrawAccountDefaults.bankAccount)
setReceiverEmailState(withdrawAccountDefaults.receiverEmail)
setReceiverPhoneState(withdrawAccountDefaults.receiverPhone)
}, [withdrawAccountDefaults])
const setHolderName = useCallback((nextHolderName: string) => {
hasEditedWithdrawAccountRef.current = true
setHolderNameState(nextHolderName)
}, [])
const setBankAccount = useCallback((nextBankAccount: string) => {
hasEditedWithdrawAccountRef.current = true
setBankAccountState(nextBankAccount)
}, [])
const setReceiverEmail = useCallback((nextReceiverEmail: string) => {
hasEditedWithdrawAccountRef.current = true
setReceiverEmailState(nextReceiverEmail)
}, [])
const setReceiverPhone = useCallback((nextReceiverPhone: string) => {
hasEditedWithdrawAccountRef.current = true
setReceiverPhoneState(nextReceiverPhone)
}, [])
useEffect(() => {
const firstAvailablePayChannel = sortedPayChannels[0]
@@ -366,18 +459,35 @@ export function useWithdrawVm() {
}
}, [amount, maxWithdrawAmount, setAmount])
useEffect(() => {
if (hasEditedWithdrawAccountRef.current) {
return
}
applyWithdrawAccountDefaults()
}, [applyWithdrawAccountDefaults])
const quickAmounts = useMemo(() => {
if (!selectedCurrency || !withdrawDiamondBounds) {
return []
}
return getQuickWithdrawAmounts(withdrawDiamondBounds).map((diamonds) => ({
diamonds,
id: `quick-${paymentChannelCode}-${paymentType}-${selectedCurrency.code}-${diamonds}`,
preview: `${selectedCurrency.code} ${formatNumber(
amountLabel: `${selectedCurrency.code} ${formatNumber(
locale,
diamonds * selectedExchangeRate,
calculateNetWithdrawalAmount(
diamonds * selectedExchangeRate,
selectedHandlingFee,
),
)}`,
diamondLabel: t('gameDesktop.withdraw.preview.quickDiamondAmount', {
amount: formatNumber(locale, diamonds),
}),
diamonds,
feeLabel: t('gameDesktop.withdraw.preview.quickHandlingFee', {
fee: selectedHandlingFeeValue,
}),
id: `quick-${paymentChannelCode}-${paymentType}-${selectedCurrency.code}-${diamonds}`,
}))
}, [
locale,
@@ -385,6 +495,9 @@ export function useWithdrawVm() {
paymentType,
selectedCurrency,
selectedExchangeRate,
selectedHandlingFee,
selectedHandlingFeeValue,
t,
withdrawDiamondBounds,
])
@@ -422,11 +535,14 @@ export function useWithdrawVm() {
setPaymentChannelCode(nextPaymentChannelCode)
setPaymentType(nextPaymentMethod?.code ?? '')
setBankCode('')
setHolderName('')
setBankAccount('')
setReceiverEmail('')
setReceiverPhone('')
}, [config, maxWithdrawAmount, sortedPayChannels])
hasEditedWithdrawAccountRef.current = false
applyWithdrawAccountDefaults()
}, [
applyWithdrawAccountDefaults,
config,
maxWithdrawAmount,
sortedPayChannels,
])
const selectedCurrencyPreview = useMemo(() => {
const previewCurrencyCode = selectedCurrency?.code ?? '--'
@@ -448,11 +564,24 @@ export function useWithdrawVm() {
selectedCurrency && selectedExchangeRate > 0
? `${formatNumber(
locale,
amount * selectedExchangeRate,
calculateNetWithdrawalAmount(
amount * selectedExchangeRate,
selectedHandlingFee,
),
)} ${selectedCurrency.code}`
: '--',
handlingFeeLabel: t('gameDesktop.withdraw.preview.handlingFee'),
handlingFeeValue: selectedHandlingFeeValue,
}
}, [amount, locale, selectedCurrency, selectedExchangeRate, t])
}, [
amount,
locale,
selectedCurrency,
selectedExchangeRate,
selectedHandlingFee,
selectedHandlingFeeValue,
t,
])
return {
amount,

View File

@@ -24,9 +24,11 @@ export function normalizeAuthUserProfile(dto: AuthUserProfileDto): AuthUser {
return {
channelId: dto.channel_id,
coin: dto.coin,
coinBalance: dto.coin_balance,
createTime: dto.create_time,
currentStreak: dto.current_streak,
email: dto.email,
frozenBalance: dto.frozen_balance,
headImage: dto.head_image,
id: dto.uuid,
lastBetPeriodNo: dto.last_bet_period_no,
@@ -34,8 +36,18 @@ export function normalizeAuthUserProfile(dto: AuthUserProfileDto): AuthUser {
phone: dto.phone,
registerInviteCode: dto.register_invite_code,
riskFlags: dto.risk_flags,
totalDepositCoin: dto.total_deposit_coin,
totalWithdrawCoin: dto.total_withdraw_coin,
username: dto.username,
uuid: dto.uuid,
withdraw: dto.withdraw
? {
receiveAccount: dto.withdraw.receive_account,
receiverEmail: dto.withdraw.receiver_email,
receiverMobile: dto.withdraw.receiver_mobile,
receiverName: dto.withdraw.receiver_name,
}
: undefined,
}
}

View File

@@ -178,6 +178,13 @@ export default {
streak: '{{count}}-win streak',
winAction: 'won',
},
jackpotPool: {
ariaLabel: 'Current jackpot pool {{amount}}',
iconAlt: 'Jackpot pool',
label: 'Jackpot',
promoLine1: 'Streak Win 10 Rounds',
promoLine2: 'And Hit The Super',
},
actions: {
unifiedBetHint: 'Unified bet',
totalBet: 'Total bet',
@@ -230,6 +237,7 @@ export default {
'1. **Betting Stage**',
' Players choose the numbers they want to bet on from 36 numbers, select chips, confirm the bet, and the system deducts the balance and creates the bet order.',
' The maximum number of selections per round depends on the current system configuration.',
' (Long press the number to cancel the bet)',
'',
'2. **Bet Closing Stage**',
' Once the round enters the closing stage, the system stops accepting new bets.',
@@ -246,7 +254,8 @@ export default {
'',
'1. Only **1 winning number** is drawn in each round.',
'2. If the players selected numbers **include the winning number**, the bet is considered a win.',
'3. If a player selects multiple numbers and the bet amount is the same for each number, then:',
'3. Each round, a player may bet on **15 numbers only**, and must not select more than **5**.',
'4. If a player selects multiple numbers and the bet amount is the same for each number, then:',
'',
'```text',
'Winning Number Stake = Total Bet Amount ÷ Number of Selected Numbers',
@@ -470,6 +479,10 @@ export default {
},
},
commonUi: {
actions: {
refreshBalance: 'Refresh balance',
submitting: TEXT_SUBMITTING,
},
dialog: {
close: 'Close alert',
confirm: 'OK',
@@ -689,6 +702,7 @@ export default {
announcement: 'Announcement',
},
animal: {
cancelBet: 'Cancel Bet',
insufficientBalanceRecharge: 'Insufficient balance, please top up',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Loading',
@@ -839,6 +853,9 @@ export default {
exchangeRate: 'Exchange Rate ({{currency}})',
exchangeRateValue: '{{coins}} {{platformCoinLabel}} = 1 {{currency}}',
convertible: 'Convertible {{currency}}',
handlingFee: 'Handling Fee',
quickDiamondAmount: '{{amount}} diamonds',
quickHandlingFee: 'Fee deducted: {{fee}}',
fixedExchangeDiamondAmount: 'Fixed Exchange Diamond Amount',
},
},

View File

@@ -177,6 +177,13 @@ export default {
streak: 'streak {{count}} menang',
winAction: 'memenangkan',
},
jackpotPool: {
ariaLabel: 'Pool jackpot saat ini {{amount}}',
iconAlt: 'Pool jackpot',
label: 'Jackpot',
promoLine1: 'Menang 10 Ronde Beruntun',
promoLine2: 'Dan Raih Super Jackpot',
},
actions: {
unifiedBetHint: 'Bet seragam',
totalBet: 'Total bet',
@@ -229,6 +236,7 @@ export default {
'1. **Tahap Taruhan**',
' Pemain memilih nomor yang ingin dipertaruhkan dari 36 nomor, memilih chip, mengonfirmasi taruhan, dan sistem akan memotong saldo serta membuat catatan taruhan.',
' Jumlah maksimum nomor yang dapat dipilih per putaran bergantung pada konfigurasi sistem saat ini.',
' (Tekan lama nomor untuk batalkan taruhan)',
'',
'2. **Tahap Penutupan Taruhan**',
' Setelah putaran memasuki tahap penutupan, sistem berhenti menerima taruhan baru.',
@@ -245,7 +253,8 @@ export default {
'',
'1. Hanya **1 nomor pemenang** yang akan dikeluarkan di setiap putaran.',
'2. Jika nomor yang dipilih pemain **mencakup nomor pemenang**, maka taruhan tersebut dianggap menang.',
'3. Jika pemain memilih beberapa nomor dan jumlah taruhan untuk setiap nomor sama, maka:',
'3. Dalam setiap putaran, pemain hanya boleh bertaruh pada **15 nomor**, dan tidak boleh melebihi **5 nomor**.',
'4. Jika pemain memilih beberapa nomor dan jumlah taruhan untuk setiap nomor sama, maka:',
'',
'```text',
'Nilai Taruhan Nomor Menang = Total Taruhan ÷ Jumlah Nomor yang Dipilih',
@@ -469,6 +478,10 @@ export default {
},
},
commonUi: {
actions: {
refreshBalance: 'Segarkan saldo',
submitting: TEXT_SUBMITTING,
},
dialog: {
close: 'Tutup notifikasi',
confirm: 'OK',
@@ -689,6 +702,7 @@ export default {
announcement: 'Pengumuman',
},
animal: {
cancelBet: 'Batalkan Taruhan',
insufficientBalanceRecharge: 'Saldo tidak cukup, silakan isi ulang',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Memuat',
@@ -841,6 +855,9 @@ export default {
exchangeRate: 'Rasio Penukaran ({{currency}})',
exchangeRateValue: '{{coins}} {{platformCoinLabel}} = 1 {{currency}}',
convertible: 'Bisa Ditukar {{currency}}',
handlingFee: 'Biaya Penanganan',
quickDiamondAmount: '{{amount}} diamond',
quickHandlingFee: 'Biaya dipotong: {{fee}}',
fixedExchangeDiamondAmount: 'Jumlah Berlian Tukar Tetap',
},
},

View File

@@ -180,6 +180,13 @@ export default {
streak: 'streak {{count}} menang',
winAction: 'memenangi',
},
jackpotPool: {
ariaLabel: 'Dana jackpot semasa {{amount}}',
iconAlt: 'Dana jackpot',
label: 'Jackpot',
promoLine1: '10 Kemenangan Berturut',
promoLine2: 'Dan Raih Super Jackpot',
},
actions: {
unifiedBetHint: 'Taruhan seragam',
totalBet: 'Jumlah taruhan',
@@ -232,6 +239,7 @@ export default {
'1. **Peringkat Pertaruhan**',
' Pemain memilih nombor yang ingin dipertaruhkan daripada 36 nombor, memilih cip, mengesahkan pertaruhan, dan sistem akan menolak baki serta menjana rekod pertaruhan.',
' Bilangan maksimum nombor yang boleh dipilih bagi setiap pusingan bergantung pada konfigurasi semasa sistem.',
' (Tekan lama nombor untuk batalkan pertaruhan)',
'',
'2. **Peringkat Penutupan Pertaruhan**',
' Apabila pusingan memasuki peringkat penutupan, sistem akan berhenti menerima pertaruhan baharu.',
@@ -248,7 +256,8 @@ export default {
'',
'1. Hanya **1 nombor kemenangan** akan dikeluarkan dalam setiap pusingan.',
'2. Jika nombor yang dipilih oleh pemain **mengandungi nombor kemenangan**, pertaruhan tersebut dianggap menang.',
'3. Jika pemain memilih beberapa nombor dan jumlah pertaruhan bagi setiap nombor adalah sama, maka:',
'3. Dalam setiap pusingan, pemain hanya boleh bertaruh pada **15 nombor**, dan tidak boleh melebihi **5 nombor**.',
'4. Jika pemain memilih beberapa nombor dan jumlah pertaruhan bagi setiap nombor adalah sama, maka:',
'',
'```text',
'Nilai Pertaruhan Nombor Menang = Jumlah Pertaruhan ÷ Bilangan Nombor yang Dipilih',
@@ -472,6 +481,10 @@ export default {
},
},
commonUi: {
actions: {
refreshBalance: 'Segarkan baki',
submitting: TEXT_SUBMITTING,
},
dialog: {
close: 'Tutup notifikasi',
confirm: 'OK',
@@ -694,6 +707,7 @@ export default {
announcement: 'Pengumuman',
},
animal: {
cancelBet: 'Batalkan Pertaruhan',
insufficientBalanceRecharge: 'Baki tidak mencukupi, sila tambah nilai',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Memuatkan',
@@ -845,6 +859,9 @@ export default {
exchangeRate: 'Kadar Pertukaran ({{currency}})',
exchangeRateValue: '{{coins}} {{platformCoinLabel}} = 1 {{currency}}',
convertible: 'Boleh Tukar {{currency}}',
handlingFee: 'Yuran Pengendalian',
quickDiamondAmount: '{{amount}} berlian',
quickHandlingFee: 'Yuran ditolak: {{fee}}',
fixedExchangeDiamondAmount: 'Jumlah Berlian Tukaran Tetap',
},
},

View File

@@ -175,6 +175,13 @@ export default {
streak: '{{count}} 连胜',
winAction: '赢得',
},
jackpotPool: {
ariaLabel: '彩金池当前金额 {{amount}}',
iconAlt: '彩金池',
label: '彩金池',
promoLine1: '连胜 10 局',
promoLine2: '并触发超级大奖',
},
actions: {
unifiedBetHint: '统一下注额',
totalBet: '总下注',
@@ -224,6 +231,7 @@ export default {
'1. **下注阶段**',
' 玩家在 36 个号码中选择想要押注的号码,选择筹码后确认下注,系统扣款并生成注单。',
' 单期最多可选号码数,以系统当前配置为准。',
' (长按号码取消下注)',
'',
'2. **封盘阶段**',
' 进入封盘后,系统停止接受新注单。',
@@ -238,10 +246,11 @@ export default {
'',
'1. 每期只开出 **1 个中奖号码**。',
'2. 如果玩家押注的号码中 **包含开奖号码**,则视为中奖。',
'3. 如果玩家选择了多个号码,且每个号码押注金额相同,则:',
'3. 每场玩家最多只能下注 **15 个号码**,不能超过 **5 个**。',
'4. 如果玩家选择了多个号码,且每个号码押注金额相同,则:',
'',
'```text',
'压中号码面额 = 本笔注总额 ÷ 所选号码个数',
'压中号码面额 = 本笔注总额 ÷ 所选号码个数',
'```',
'',
'**示例:**',
@@ -453,6 +462,10 @@ export default {
},
},
commonUi: {
actions: {
refreshBalance: '刷新余额',
submitting: TEXT_SUBMITTING,
},
dialog: {
close: '关闭提示',
confirm: '知道了',
@@ -664,6 +677,7 @@ export default {
announcement: '公告栏',
},
animal: {
cancelBet: '取消下注',
insufficientBalanceRecharge: '余额不足,请充值',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: '加载中',
@@ -809,6 +823,9 @@ export default {
exchangeRate: '兑换比例 ({{currency}})',
exchangeRateValue: '{{coins}} {{platformCoinLabel}} = 1 {{currency}}',
convertible: '可兑换{{currency}}',
handlingFee: '手续费',
quickDiamondAmount: '{{amount}} 钻石',
quickHandlingFee: '扣除手续费 {{fee}}',
fixedExchangeDiamondAmount: '固定兑换钻石金额',
},
},

View File

@@ -1,3 +1,4 @@
import { JackpotPoolTicker } from '@/components/jackpot-pool-ticker.tsx'
import { MessageBroadcast } from '@/components/message-broadcast.tsx'
import { MobileAnimal } from '@/features/game/components/mobile/mobile-animal.tsx'
import { MobileBettingStartAlert } from '@/features/game/components/mobile/mobile-betting-start-alert.tsx'
@@ -12,11 +13,12 @@ export function MobileEntry() {
return (
<>
<MobileHeader />
<div className="w-full px-design-10 mb-design-5">
<MessageBroadcast />
</div>
<div className="mx-auto mb-design-5 w-[calc(100%-20*var(--design-unit))]">
<MobileStatusMetrics />
<div className="mx-auto mb-design-5 flex h-design-55 w-[calc(100%-20*var(--design-unit))] items-stretch gap-design-5">
<div className="flex min-w-0 flex-1 flex-col gap-design-5">
<MessageBroadcast showJackpotPool={false} />
<MobileStatusMetrics />
</div>
<JackpotPoolTicker variant="hud" />
</div>
<main className="mx-auto flex w-[calc(100%-20*var(--design-unit))] flex-col gap-design-5 pb-design-8">

View File

@@ -76,7 +76,10 @@ function DesktopAutoSettingModal() {
const balance = parseBalance(currentUser?.coin)
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
if (
tableLimitMax > 0 &&
selections.some((selection) => selection.amount > tableLimitMax)
) {
notify.warning(t('commonUi.toast.betLimitExceeded'))
return
}

View File

@@ -27,7 +27,7 @@ function DesktopWithdrawTopupModal() {
}
isNormalBg={true}
titleAlign="left"
className={'w-design-1200 h-design-700'}
className={'h-design-700 w-design-1320'}
>
<div className={'w-full h-[96%]'}>
{type === 'withdraw' ? <DesktopWithdraw /> : <DesktopTopup />}

View File

@@ -76,7 +76,10 @@ function MobileAutoSettingModal() {
const balance = parseBalance(currentUser?.coin)
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
if (
tableLimitMax > 0 &&
selections.some((selection) => selection.amount > tableLimitMax)
) {
notify.warning(t('commonUi.toast.betLimitExceeded'))
return
}

View File

@@ -35,7 +35,6 @@ export const registerFormSchema = z
inviteCode: z
.string()
.trim()
.min(1, 'auth.validation.inviteCode.required')
.max(INVITE_CODE_MAX_LENGTH, 'auth.validation.inviteCode.max'),
password: passwordSchema,
mobile: usernameSchema,

View File

@@ -86,10 +86,6 @@ function normalizeBetQuantity(quantity: number) {
return Math.max(MIN_BET_QUANTITY, Math.floor(quantity))
}
function getSelectionBetAmount(chip: Chip, quantity: number) {
return chip.amount * normalizeBetQuantity(quantity)
}
function syncSelectionsBetAmount(
selections: BetSelection[],
chipId: string,
@@ -102,21 +98,6 @@ function syncSelectionsBetAmount(
}))
}
function resolveSelectionQuantity(
selections: BetSelection[],
chips: Chip[],
activeChipId: string,
) {
const chip = getChipById(chips, activeChipId)
const firstSelection = selections[0]
if (!chip || !firstSelection || chip.amount <= 0) {
return MIN_BET_QUANTITY
}
return normalizeBetQuantity(firstSelection.amount / chip.amount)
}
function createInitialRoundState(): GameRoundSlice & {
activeChipId: string
activeBetQuantity: number
@@ -158,12 +139,18 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set, get) => ({
}
}
const currentBetAmount = state.selections[0]?.amount ?? activeChip.amount
const nextBetAmount = Math.max(
activeChip.amount,
currentBetAmount + activeChip.amount * delta,
)
return {
activeBetQuantity: nextQuantity,
selections: syncSelectionsBetAmount(
state.selections,
activeChip.id,
getSelectionBetAmount(activeChip, nextQuantity),
nextBetAmount,
),
}
})
@@ -276,17 +263,34 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set, get) => ({
if (
!activeChip ||
state.round.phase !== 'betting' ||
hasExistingSelection ||
selectedCellCount >= state.maxSelectionCount
(!hasExistingSelection && selectedCellCount >= state.maxSelectionCount)
) {
return state
}
const currentBetAmount = state.selections[0]?.amount ?? 0
const nextBetAmount = hasExistingSelection
? currentBetAmount + activeChip.amount
: activeChip.amount
const syncedSelections = syncSelectionsBetAmount(
state.selections,
activeChip.id,
nextBetAmount,
)
if (hasExistingSelection) {
return {
activeBetQuantity: MIN_BET_QUANTITY,
selections: syncedSelections,
}
}
return {
activeBetQuantity: MIN_BET_QUANTITY,
selections: [
...state.selections,
...syncedSelections,
{
amount: getSelectionBetAmount(activeChip, state.activeBetQuantity),
amount: nextBetAmount,
cellId,
chipId: activeChip.id,
id: `bet-${cellId}-${state.selections.length + 1}-${Date.now()}`,
@@ -372,23 +376,11 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set, get) => ({
nextSelections,
state.activeChipId,
)
const nextActiveChip = getChipById(state.chips, nextActiveChipId)
const nextBetQuantity = resolveSelectionQuantity(
nextSelections,
state.chips,
nextActiveChipId,
)
set({
activeBetQuantity: nextBetQuantity,
activeBetQuantity: MIN_BET_QUANTITY,
activeChipId: nextActiveChipId,
selections: nextActiveChip
? syncSelectionsBetAmount(
nextSelections,
nextActiveChipId,
getSelectionBetAmount(nextActiveChip, nextBetQuantity),
)
: nextSelections,
selections: nextSelections,
})
return true
@@ -411,11 +403,6 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set, get) => ({
return {
activeBetQuantity: MIN_BET_QUANTITY,
activeChipId: chipId,
selections: syncSelectionsBetAmount(
state.selections,
chipId,
getSelectionBetAmount(nextChip, MIN_BET_QUANTITY),
),
}
})
},

View File

@@ -3,6 +3,7 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@import "@fontsource-variable/oxanium";
@custom-variant dark (&:is(.dark *));
@@ -12,6 +13,8 @@
--font-mono:
"JetBrains Mono", "SFMono-Regular", "SF Mono", Consolas, monospace;
--font-countdown: "Countdown", "Inter", "SF Pro Display", sans-serif;
--font-jackpot-amount:
"Oxanium Variable", "Countdown", "SF Pro Display", sans-serif;
--color-game-bg: #07111f;
--color-game-surface: #0d1b2d;
--color-game-surface-strong: #12253d;
@@ -194,6 +197,17 @@
}
@layer utilities {
.mobile-animal-touch-target,
.mobile-animal-touch-target * {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.mobile-animal-touch-target img {
-webkit-user-drag: none;
}
.auth-password-input::-ms-clear,
.auth-password-input::-ms-reveal {
display: none;

View File

@@ -30,6 +30,7 @@ export interface FinancePayChannelDto {
currency_codes?: string[]
deposit_banks?: FinanceWithdrawBankDto[]
fee_note?: string
handling_fee?: string
methods?: FinanceDepositMethodDto[]
min_bank?: string
min_ewallet?: string
@@ -171,6 +172,7 @@ export interface FinancePayChannel {
currencyCodes: string[]
depositBanks: FinanceWithdrawBank[]
feeNote: string
handlingFee: string
methods: FinanceDepositMethod[]
minBank: string
minEwallet: string

View File

@@ -4,12 +4,23 @@ export type AuthStatus = 'anonymous' | 'authenticated' | 'restoring'
export type AuthSubmitContext = 'login' | 'register'
export type AuthCoinAmount = number | string
export interface AuthWithdrawAccount {
receiveAccount?: string
receiverEmail?: string
receiverMobile?: string
receiverName?: string
}
export interface AuthUser {
createTime?: number
channelId?: number
coin?: string
coin?: AuthCoinAmount
coinBalance?: number
currentStreak?: number
email?: string
frozenBalance?: number
headImage?: string
id: string
isJackpot?: boolean
@@ -21,8 +32,11 @@ export interface AuthUser {
riskFlags?: number
roles?: string[]
streakLevel?: number
totalDepositCoin?: number
totalWithdrawCoin?: number
username?: string
uuid?: string
withdraw?: AuthWithdrawAccount
}
export interface AuthSessionInput {
@@ -47,7 +61,7 @@ export interface AuthTokenDto {
export interface AuthUserDto {
channel_id: number
coin: string
coin: AuthCoinAmount
phone?: string
risk_flags: number
username: string
@@ -69,17 +83,27 @@ export interface RefreshTokenDto {
export interface AuthUserProfileDto {
channel_id: number
coin: string
coin: number
coin_balance: number
create_time: number
current_streak: number
email: string
frozen_balance: number
head_image: string
last_bet_period_no: string
phone: string
register_invite_code: string
risk_flags: number
total_deposit_coin: number
total_withdraw_coin: number
username: string
uuid: string
withdraw?: {
receive_account: string
receiver_email: string
receiver_mobile: string
receiver_name: string
} | null
}
export interface LoginRequestDto {
@@ -96,7 +120,7 @@ export interface LogoutRequestDto {
export interface RegisterRequestDto {
captcha: string
device_id?: string
invite_code: string
invite_code?: string
password: string
username: string
}
@@ -124,7 +148,7 @@ export type LogoutPayload = LoginPayload
export interface RegisterPayload {
captcha: string
inviteCode: string
inviteCode?: string
mobile: string
password: string
}

View File

@@ -109,9 +109,16 @@ export interface AnnouncementState {
lastUpdatedAt: string | null
}
export interface JackpotPoolState {
current: number
speedPerSecond: number
updatedAt: string | null
}
export interface DashboardState {
countdownMs: number
featuredCellId: number | null
jackpotPool: JackpotPoolState | null
onlinePlayers: number
tableLimitMax: number
tableLimitMin: number
@@ -332,6 +339,7 @@ export interface AnnouncementStateDto {
export interface DashboardStateDto {
countdown_ms: number
featured_cell_id: number | null
jackpot_pool?: GameJackpotPoolDto | null
online_players: number
table_limit_max: number
table_limit_min: number
@@ -339,6 +347,11 @@ export interface DashboardStateDto {
updated_at: string | null
}
export interface GameJackpotPoolDto {
current: string
speed: string
}
export interface ConnectionStateDto {
connected_at: string | null
last_error: string | null
@@ -451,6 +464,7 @@ export interface GameLobbyStreakWinRewardDto {
export interface GameLobbyInitDto {
bet_config: GameLobbyBetConfigDto
dictionary: GameLobbyDictionaryItemDto[]
jackpot_pool?: GameJackpotPoolDto | null
period?: GameLobbyPeriodDto | null
runtime_enabled: boolean
server_time: number