docs(game): 添加游戏模块数据
- 新增 useGameBoardVm 数据层实施说明文档 - 添加 36字花核心玩法与前端规则摘要 - 创建游戏模块数据与界面分层第一阶段实施稿 - 定义四层架构:api/dto、store、view-model hooks、ui层 - 规范 PC 与 Mobile 共享业务逻辑的改造方案 - 明确各层职责边界和组件改造顺序
248
docs/36字花-useGameBoardVm-数据层实施说明.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# 36字花 useGameBoardVm 数据层实施说明
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
本说明只服务下一步开发:实现 `useGameBoardVm.ts`。
|
||||||
|
|
||||||
|
当前目标很单一:
|
||||||
|
|
||||||
|
- 让桌面选号盘从 store 读取真实业务数据
|
||||||
|
- 让点击格子时真正触发下注动作
|
||||||
|
- 不在 [desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) 内写业务逻辑
|
||||||
|
- 不同时改造 mobile
|
||||||
|
|
||||||
|
本阶段不做:
|
||||||
|
|
||||||
|
- 不改造 mobile
|
||||||
|
- 不新增 `game-ui-store`
|
||||||
|
- 不重写 `DesktopAnimal` 整个展示结构
|
||||||
|
- 不处理 auto-spin / modal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 相关文件
|
||||||
|
|
||||||
|
本次只围绕以下文件展开:
|
||||||
|
|
||||||
|
- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
||||||
|
- [src/features/game/entry/pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx)
|
||||||
|
- [src/store/game/game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts)
|
||||||
|
- [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts)
|
||||||
|
|
||||||
|
将新增:
|
||||||
|
|
||||||
|
- `src/features/game/hooks/use-game-board-vm.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 当前问题
|
||||||
|
|
||||||
|
当前 [pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx) 里虽然已经挂载了 `DesktopAnimal`,但还没有把桌面主玩法接到业务链路。
|
||||||
|
|
||||||
|
当前状态:
|
||||||
|
|
||||||
|
- `DesktopAnimal` 只是展示组件
|
||||||
|
- `DesktopAnimal` 支持 `activeId` 和 `onSelect`
|
||||||
|
- `PcEntry` 直接渲染 `<DesktopAnimal />`
|
||||||
|
- 点击格子不会写入真实下注状态
|
||||||
|
|
||||||
|
结果是:
|
||||||
|
|
||||||
|
- 控制栏虽然已经接入了部分业务数据
|
||||||
|
- 状态栏、历史区也开始接入 store
|
||||||
|
- 但桌面最核心的“点击动物下注”链路还没有打通
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. useGameBoardVm 的职责
|
||||||
|
|
||||||
|
`useGameBoardVm` 只做 3 件事:
|
||||||
|
|
||||||
|
1. 从 `game-round-store` 读取选号盘需要的业务数据
|
||||||
|
2. 组织出桌面选号盘可以直接消费的 view-model
|
||||||
|
3. 暴露点击格子的业务动作
|
||||||
|
|
||||||
|
它不负责:
|
||||||
|
|
||||||
|
- 直接渲染 UI
|
||||||
|
- 做 hover / 动画状态
|
||||||
|
- 控制 modal
|
||||||
|
- 处理移动端布局
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据来源
|
||||||
|
|
||||||
|
`useGameBoardVm` 第一版只从 [game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts) 读取:
|
||||||
|
|
||||||
|
- `cells`
|
||||||
|
- `round`
|
||||||
|
- `selections`
|
||||||
|
- `trends`
|
||||||
|
- `placeBet`
|
||||||
|
|
||||||
|
可复用的派生逻辑来自 [selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts):
|
||||||
|
|
||||||
|
- `buildGameCellViewModels`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 第一版输出字段
|
||||||
|
|
||||||
|
第一版不追求一步到位,输出保持最小可用。
|
||||||
|
|
||||||
|
建议 `useGameBoardVm` 返回:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
cells: GameCellViewModel[]
|
||||||
|
activeId: number | null
|
||||||
|
canPlaceBets: boolean
|
||||||
|
onCellPress: (cellId: number) => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
#### `cells`
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- `buildGameCellViewModels({ cells, round, selections, trends })`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 给未来第二版 board 组件升级时使用
|
||||||
|
- 即使第一版 `DesktopAnimal` 还没完全吃它,也应该先在 hook 里产出来
|
||||||
|
|
||||||
|
#### `activeId`
|
||||||
|
|
||||||
|
第一版定义:
|
||||||
|
|
||||||
|
- 当前有下注的最后一个格子 id
|
||||||
|
- 如果没有任何下注,则为 `null`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 兼容当前 [desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) 现有接口
|
||||||
|
- 因为这个组件当前只支持单个 `activeId`,还不支持多个已选格子
|
||||||
|
|
||||||
|
#### `canPlaceBets`
|
||||||
|
|
||||||
|
定义:
|
||||||
|
|
||||||
|
- `round.phase === 'betting'`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 控制点击是否真正触发下注
|
||||||
|
- 也为后续 UI 禁用态预留
|
||||||
|
|
||||||
|
#### `onCellPress`
|
||||||
|
|
||||||
|
定义:
|
||||||
|
|
||||||
|
- 当 `canPlaceBets === true` 时,调用 `placeBet(cellId)`
|
||||||
|
- 否则不执行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 第一版实现规则
|
||||||
|
|
||||||
|
### 7.1 不在 DesktopAnimal 内直接读 store
|
||||||
|
|
||||||
|
[desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) 必须继续保持展示组件定位。
|
||||||
|
|
||||||
|
不应该在里面直接写:
|
||||||
|
|
||||||
|
- `useGameRoundStore`
|
||||||
|
- `placeBet`
|
||||||
|
- `buildGameCellViewModels`
|
||||||
|
- `round.phase` 判断
|
||||||
|
|
||||||
|
### 7.2 业务写在 hook,UI 只接 props
|
||||||
|
|
||||||
|
推荐接线方式:
|
||||||
|
|
||||||
|
在 [pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx) 中:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { activeId, onCellPress } = useGameBoardVm()
|
||||||
|
|
||||||
|
<DesktopAnimal activeId={activeId} onSelect={onCellPress} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 第一版先兼容现有 DesktopAnimal 接口
|
||||||
|
|
||||||
|
当前 `DesktopAnimal` 只支持:
|
||||||
|
|
||||||
|
- `activeId?: number | null`
|
||||||
|
- `onSelect?: (animalId: number) => void`
|
||||||
|
|
||||||
|
所以第一版不要强行重做它的 props 结构。
|
||||||
|
|
||||||
|
先兼容现状,把业务链路打通即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 第一版文件改动范围
|
||||||
|
|
||||||
|
本次改动建议控制在 2 到 3 个文件:
|
||||||
|
|
||||||
|
1. 新增 `src/features/game/hooks/use-game-board-vm.ts`
|
||||||
|
2. 修改 [pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx)
|
||||||
|
3. 如有必要,微调 [desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
||||||
|
|
||||||
|
其中第 3 项不是必须,优先争取只改前两个文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 第一版完成标准
|
||||||
|
|
||||||
|
完成后应满足:
|
||||||
|
|
||||||
|
- `PcEntry` 不再裸挂 `<DesktopAnimal />`
|
||||||
|
- `PcEntry` 通过 `useGameBoardVm` 把选号盘接到 store
|
||||||
|
- 点击格子会真实调用下注动作
|
||||||
|
- 控制栏中的总下注额会随点击变化
|
||||||
|
- `DesktopAnimal` 仍然保持展示组件定位
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 第二版演进方向
|
||||||
|
|
||||||
|
第一版做完后,下一版再考虑升级 `DesktopAnimal` 接口。
|
||||||
|
|
||||||
|
当前限制:
|
||||||
|
|
||||||
|
- `DesktopAnimal` 只能高亮一个 `activeId`
|
||||||
|
|
||||||
|
第二版建议升级为:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
items: GameCellViewModel[]
|
||||||
|
onSelect: (cellId: number) => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这样就可以支持:
|
||||||
|
|
||||||
|
- 多个已下注格子同时高亮
|
||||||
|
- 依据 `status` 区分 `betting / selected / won / lost`
|
||||||
|
- PC 和 Mobile 共用同一份 board view-model
|
||||||
|
|
||||||
|
但这些都不属于当前这一步。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 结论
|
||||||
|
|
||||||
|
当前最正确的操作顺序是:
|
||||||
|
|
||||||
|
1. 先写 `useGameBoardVm.ts`
|
||||||
|
2. 再在 `pc-entry.tsx` 中接入
|
||||||
|
3. 暂时不把业务写进 `desktop-animal.tsx`
|
||||||
|
|
||||||
|
这样可以在最小改动范围内,把桌面端最核心的“点击格子下注”链路打通,并继续保持“数据层在外、UI 层在内”的改造方向。
|
||||||
|
|
||||||
396
docs/36字花-核心玩法与前端规则摘要.md
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
# 36字花核心玩法与前端规则摘要
|
||||||
|
|
||||||
|
## 1. 项目定位
|
||||||
|
|
||||||
|
36字花是一个**单期开奖结果、单期号循环运行**的实时开奖类游戏。
|
||||||
|
|
||||||
|
前端的核心界面是:
|
||||||
|
|
||||||
|
- 36 宫格下注盘
|
||||||
|
- 倒计时与当前期状态栏
|
||||||
|
- 筹码与确认下注区
|
||||||
|
- 开奖历史与走势信息
|
||||||
|
- 公告、规则、自动托管、充值提现等外围模块
|
||||||
|
|
||||||
|
产品运行原则:
|
||||||
|
|
||||||
|
- 全平台共享同一局数据
|
||||||
|
- `PC` 与 `Mobile` 只分界面,不分玩法、不分对局
|
||||||
|
- 前端必须以服务端状态为准,不能靠本地时间自行判断开奖或封盘
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心玩法
|
||||||
|
|
||||||
|
### 2.1 基本盘面
|
||||||
|
|
||||||
|
- 游戏共有 **36 个号码格子**
|
||||||
|
- 每个格子代表一个“字花编号”
|
||||||
|
- 编号范围为 **1-36**
|
||||||
|
- 当前前端盘面布局为 **6 x 6**
|
||||||
|
|
||||||
|
相关代码与约束:
|
||||||
|
|
||||||
|
- [src/features/game/shared/constants.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/constants.ts)
|
||||||
|
- `GAME_GRID_ROWS = 6`
|
||||||
|
- `GAME_GRID_COLUMNS = 6`
|
||||||
|
- `GAME_TOTAL_CELLS = 36`
|
||||||
|
|
||||||
|
### 2.2 开奖规则
|
||||||
|
|
||||||
|
- 每一期只会开出 **1 个中奖号码**
|
||||||
|
- 开奖号码属于 1-36 中的一个
|
||||||
|
- 用户只要下注号码集合中包含该号码,即视为中奖
|
||||||
|
|
||||||
|
接口口径见:
|
||||||
|
|
||||||
|
- [docs/36字花-移动端接口设计草案.md](/Users/jiaunun/Desktop/36-character-flower/docs/36字花-移动端接口设计草案.md)
|
||||||
|
- `POST /api/game/placeBet`
|
||||||
|
|
||||||
|
文档原意:
|
||||||
|
|
||||||
|
- 玩家提交的是“号码集合 + 单注金额”
|
||||||
|
- 系统按“单注金额 × 号码数量”计算本笔总扣款
|
||||||
|
- 开奖后只出一个号码
|
||||||
|
- 若该号码命中玩家所选集合,则该笔下注中奖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 下注模型
|
||||||
|
|
||||||
|
### 3.1 单注结构
|
||||||
|
|
||||||
|
一笔下注至少包含:
|
||||||
|
|
||||||
|
- `period_no`:下注目标期号
|
||||||
|
- `numbers`:本次下注号码集合
|
||||||
|
- `single_bet_amount`:单个号码的下注金额
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `numbers` 是一个号码集合,而不是单个号码
|
||||||
|
- 多选号码时,总扣款 = `single_bet_amount × numbers数量`
|
||||||
|
- 重复号码应去重
|
||||||
|
|
||||||
|
### 3.2 前端当前数据模型
|
||||||
|
|
||||||
|
当前前端 store 中的单笔选择数据为:
|
||||||
|
|
||||||
|
- `cellId`
|
||||||
|
- `chipId`
|
||||||
|
- `amount`
|
||||||
|
- `placedAt`
|
||||||
|
- `source`
|
||||||
|
|
||||||
|
定义见:
|
||||||
|
|
||||||
|
- [src/features/game/shared/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/types.ts)
|
||||||
|
- `BetSelection`
|
||||||
|
|
||||||
|
当前本地 mock 与 store 落地方式是:
|
||||||
|
|
||||||
|
- 每点击一个格子,会追加一条 `selection`
|
||||||
|
- 每条 `selection` 对应一个格子和一个筹码金额
|
||||||
|
|
||||||
|
这与接口文档中的“号码集合提交”并不完全一致。
|
||||||
|
|
||||||
|
因此当前前端可以先采用如下理解:
|
||||||
|
|
||||||
|
- UI 交互阶段:按“逐格选择”记录本地状态
|
||||||
|
- 提交到后端阶段:再把本地多个格子聚合成 `numbers`
|
||||||
|
|
||||||
|
这是后续 `confirm bet` 需要承担的转换逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 回合状态机
|
||||||
|
|
||||||
|
### 4.1 当前状态枚举
|
||||||
|
|
||||||
|
当前项目中定义的回合阶段为:
|
||||||
|
|
||||||
|
- `waiting`
|
||||||
|
- `betting`
|
||||||
|
- `locked`
|
||||||
|
- `revealing`
|
||||||
|
- `settled`
|
||||||
|
|
||||||
|
定义见:
|
||||||
|
|
||||||
|
- [src/features/game/shared/constants.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/constants.ts)
|
||||||
|
|
||||||
|
接口文档中的状态口径包括:
|
||||||
|
|
||||||
|
- `betting`
|
||||||
|
- `locked`
|
||||||
|
- `settling`
|
||||||
|
- `finished`
|
||||||
|
- `void`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 当前前端内部状态和接口文档状态还未完全统一
|
||||||
|
- 第一阶段前端可继续沿用内部状态机
|
||||||
|
- 后续真实联调时,要补一层接口状态 -> 前端状态的映射
|
||||||
|
|
||||||
|
### 4.2 各阶段前端规则
|
||||||
|
|
||||||
|
#### `BETTING`
|
||||||
|
|
||||||
|
- 允许选格子
|
||||||
|
- 允许切换筹码
|
||||||
|
- 允许清除当前选择
|
||||||
|
- 允许重复上一注
|
||||||
|
- 允许确认下注
|
||||||
|
- 允许开启自动托管
|
||||||
|
|
||||||
|
#### `LOCKED`
|
||||||
|
|
||||||
|
- 已封盘
|
||||||
|
- 前端必须立即停止下注交互
|
||||||
|
- 不应等待后端返回后才禁用点击
|
||||||
|
|
||||||
|
#### `REVEALING / SETTLING`
|
||||||
|
|
||||||
|
- 等待开奖与派彩
|
||||||
|
- 展示开奖结果、跑马灯、中奖态
|
||||||
|
|
||||||
|
#### `SETTLED / FINISHED`
|
||||||
|
|
||||||
|
- 本期结束
|
||||||
|
- 准备进入下一轮
|
||||||
|
- 历史、走势、余额等应刷新
|
||||||
|
|
||||||
|
#### `VOID`
|
||||||
|
|
||||||
|
- 本期作废
|
||||||
|
- 需要退款待开奖本金
|
||||||
|
- 前端要清理本期下注态并正确提示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 封盘与倒计时规则
|
||||||
|
|
||||||
|
### 5.1 核心原则
|
||||||
|
|
||||||
|
- 前端必须以服务端返回的时间和阶段为准
|
||||||
|
- 到达封盘时间点时,前端应立即锁盘
|
||||||
|
- 即使网络延迟,也不能继续允许下注交互
|
||||||
|
|
||||||
|
这点在需求文档中是明确要求:
|
||||||
|
|
||||||
|
- [docs/frontend-baseline-requirements.md](/Users/jiaunun/Desktop/36-character-flower/docs/frontend-baseline-requirements.md)
|
||||||
|
|
||||||
|
### 5.2 当前前端倒计时模型
|
||||||
|
|
||||||
|
当前前端使用:
|
||||||
|
|
||||||
|
- `round.bettingClosesAt`
|
||||||
|
- `round.revealingAt`
|
||||||
|
- `round.settledAt`
|
||||||
|
|
||||||
|
并通过:
|
||||||
|
|
||||||
|
- [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts)
|
||||||
|
- `getRoundCountdownMs(round)`
|
||||||
|
|
||||||
|
来计算当前倒计时毫秒数。
|
||||||
|
|
||||||
|
规则如下:
|
||||||
|
|
||||||
|
- `waiting / betting`:倒计时到 `bettingClosesAt`
|
||||||
|
- `locked / revealing`:倒计时到 `revealingAt`
|
||||||
|
- 其余:倒计时到 `settledAt`
|
||||||
|
|
||||||
|
### 5.3 Mock 数据口径
|
||||||
|
|
||||||
|
当前 mock 数据中:
|
||||||
|
|
||||||
|
- 开始后约 18 秒封盘
|
||||||
|
- 约 24 秒开奖
|
||||||
|
- 约 30 秒结算
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- [src/features/game/shared/mock-data.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/mock-data.ts)
|
||||||
|
|
||||||
|
这只是前端演示节奏,不代表最终真实服务端配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 赔率、金额与筹码规则
|
||||||
|
|
||||||
|
### 6.1 当前赔率
|
||||||
|
|
||||||
|
当前 mock 中每个格子赔率固定为:
|
||||||
|
|
||||||
|
- `36`
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- [src/features/game/shared/mock-data.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/mock-data.ts)
|
||||||
|
- `createGameCells()`
|
||||||
|
|
||||||
|
### 6.2 当前默认筹码
|
||||||
|
|
||||||
|
当前默认筹码面额为:
|
||||||
|
|
||||||
|
- `10`
|
||||||
|
- `25`
|
||||||
|
- `50`
|
||||||
|
- `100`
|
||||||
|
- `200`
|
||||||
|
- `500`
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- [src/features/game/shared/constants.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/constants.ts)
|
||||||
|
- `DEFAULT_GAME_CHIP_AMOUNTS`
|
||||||
|
|
||||||
|
### 6.3 下注限制
|
||||||
|
|
||||||
|
从接口文档与基线需求中可得出以下前端规则:
|
||||||
|
|
||||||
|
- 单次下注号码数量不得超过 `pick_max_number_count`
|
||||||
|
- 单号码下注金额不得超过 `single_number_max_bet`
|
||||||
|
- 总下注额不得超过余额
|
||||||
|
- 封盘后不能继续下注
|
||||||
|
|
||||||
|
文档来源:
|
||||||
|
|
||||||
|
- [docs/36字花-移动端接口设计草案.md](/Users/jiaunun/Desktop/36-character-flower/docs/36字花-移动端接口设计草案.md)
|
||||||
|
- [docs/frontend-baseline-requirements.md](/Users/jiaunun/Desktop/36-character-flower/docs/frontend-baseline-requirements.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 中奖、历史与走势
|
||||||
|
|
||||||
|
### 7.1 历史记录
|
||||||
|
|
||||||
|
每期开奖后应产生一条历史记录,至少包括:
|
||||||
|
|
||||||
|
- `roundId`
|
||||||
|
- `winningCellId`
|
||||||
|
- `settledAt`
|
||||||
|
- `payoutMultiplier`
|
||||||
|
- `totalPoolAmount`
|
||||||
|
|
||||||
|
定义见:
|
||||||
|
|
||||||
|
- [src/features/game/shared/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/types.ts)
|
||||||
|
- `HistoryEntry`
|
||||||
|
|
||||||
|
### 7.2 走势
|
||||||
|
|
||||||
|
前端会基于历史数据派生走势信息,包括:
|
||||||
|
|
||||||
|
- `currentStreak`
|
||||||
|
- `hitCount`
|
||||||
|
- `missCount`
|
||||||
|
- `direction`
|
||||||
|
- `lastHitRoundId`
|
||||||
|
|
||||||
|
派生逻辑见:
|
||||||
|
|
||||||
|
- [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts)
|
||||||
|
- `deriveTrendEntries(history)`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 走势属于纯派生展示数据
|
||||||
|
- 不应由 UI 组件自己重复计算
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 公告、维护与运行开关
|
||||||
|
|
||||||
|
### 8.1 运行开关
|
||||||
|
|
||||||
|
接口文档中存在:
|
||||||
|
|
||||||
|
- `runtime_enabled`
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- `true`:游戏正常运行
|
||||||
|
- `false`:后台维护中,禁止下注
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 维护中不允许新下注
|
||||||
|
- 当前已开盘的一局仍可正常开奖和派彩
|
||||||
|
- 前端应禁用下注入口并提示维护状态
|
||||||
|
|
||||||
|
### 8.2 公告
|
||||||
|
|
||||||
|
公告是前端大厅的一部分,但不属于主玩法。
|
||||||
|
|
||||||
|
当前前端已存在公告模型:
|
||||||
|
|
||||||
|
- `AnnouncementState`
|
||||||
|
- `AnnouncementItem`
|
||||||
|
|
||||||
|
它们属于会话层状态,不应混入下注与回合逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 自动托管
|
||||||
|
|
||||||
|
接口文档中已定义自动托管能力:
|
||||||
|
|
||||||
|
- `POST /api/game/autoSpin`
|
||||||
|
|
||||||
|
入参包括:
|
||||||
|
|
||||||
|
- `action`
|
||||||
|
- `period_no`
|
||||||
|
- `numbers`
|
||||||
|
- `single_bet_amount`
|
||||||
|
- `rounds`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 自动托管属于建立在主玩法之上的扩展能力
|
||||||
|
- 它依赖同一套选号与下注规则
|
||||||
|
- 当前前端可以先保留 UI 壳层,不需要在主玩法没走通前抢先落业务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 前端当前最应该优先走通的主玩法链路
|
||||||
|
|
||||||
|
基于上述规则,当前前端最核心、最应该优先走通的是:
|
||||||
|
|
||||||
|
1. 状态栏拿到当前期状态与倒计时
|
||||||
|
2. 控制栏拿到当前筹码与总下注额
|
||||||
|
3. 选号盘点击格子后写入本地下注选择
|
||||||
|
4. 总下注额、选中数量、选号高亮联动刷新
|
||||||
|
5. 后续再补确认下注请求与开奖结果回写
|
||||||
|
|
||||||
|
这也是为什么当前下一步应优先实现:
|
||||||
|
|
||||||
|
- `useGameBoardVm`
|
||||||
|
|
||||||
|
而不是优先改 `mobile` 或外围弹窗。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 总结
|
||||||
|
|
||||||
|
36字花这个项目的核心,不是“36 张图摆出来”,而是下面这条实时对局链路:
|
||||||
|
|
||||||
|
- 同一局
|
||||||
|
- 同一倒计时
|
||||||
|
- 同一开奖结果
|
||||||
|
- 36 格可选号码
|
||||||
|
- 用户用统一筹码模型下注
|
||||||
|
- 封盘、开奖、派奖按服务端状态推进
|
||||||
|
|
||||||
|
前端实现时必须坚持两点:
|
||||||
|
|
||||||
|
1. **玩法规则统一**
|
||||||
|
- PC 和 Mobile 只能换壳,不能换规则
|
||||||
|
|
||||||
|
2. **服务端状态优先**
|
||||||
|
- 前端可以先做本地交互反馈
|
||||||
|
- 但回合状态、封盘、开奖、派彩都必须最终以服务端为准
|
||||||
|
|
||||||
846
docs/36字花-游戏模块数据与界面分层第一阶段实施稿.md
Normal file
@@ -0,0 +1,846 @@
|
|||||||
|
# 36字花游戏模块数据与界面分层第一阶段实施稿
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
第一阶段的目标不是做一次彻底重构,而是先把当前项目中的“业务数据”和“界面渲染”拆出清晰边界,让同一套数据和交互逻辑可以同时服务 `PC` 界面与 `Mobile` 界面。
|
||||||
|
|
||||||
|
本阶段只解决以下问题:
|
||||||
|
|
||||||
|
- 一套业务数据,供 `PC` 与 `Mobile` 共同消费
|
||||||
|
- 统一业务交互逻辑,避免双端各自维护一份
|
||||||
|
- 保持 `PC` 与 `Mobile` 布局、样式、视觉独立
|
||||||
|
- 不大范围推翻现有组件,优先在现有代码上增量改造
|
||||||
|
|
||||||
|
本阶段暂不追求:
|
||||||
|
|
||||||
|
- 所有组件完全抽象成跨端共享组件
|
||||||
|
- 一次性去掉所有本地 `useState`
|
||||||
|
- 一次性重写所有弹窗和表单
|
||||||
|
- 大规模目录迁移导致整仓成本过高
|
||||||
|
|
||||||
|
|
||||||
|
## 2. 当前代码现状
|
||||||
|
|
||||||
|
当前仓库中已经存在一部分良好的基础设施:
|
||||||
|
|
||||||
|
- 数据请求与 DTO 归一化:
|
||||||
|
- [src/features/game/api/game-api.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/api/game-api.ts)
|
||||||
|
- 共享领域类型与派生逻辑:
|
||||||
|
- [src/features/game/shared/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/types.ts)
|
||||||
|
- [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts)
|
||||||
|
- 回合与会话状态:
|
||||||
|
- [src/store/game/game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts)
|
||||||
|
- [src/store/game/game-session-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-session-store.ts)
|
||||||
|
- 页面入口已统一在:
|
||||||
|
- [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx)
|
||||||
|
|
||||||
|
当前主要问题有 4 个:
|
||||||
|
|
||||||
|
1. `Mobile` 界面直接复用了 `Desktop` 组件
|
||||||
|
例如 [src/features/game/entry/mobile-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/mobile-entry.tsx) 当前直接引用了 `DesktopAnimal`、`DesktopGameHistory`、`DesktopTitle`。这会导致移动端无法形成独立布局层。
|
||||||
|
|
||||||
|
2. 业务状态和界面状态混杂
|
||||||
|
一些状态是业务性的,例如当前选中筹码、是否允许下注;另一些只是表现性的,例如按钮点击动画、过渡效果。当前这两种状态没有被清晰拆开。
|
||||||
|
|
||||||
|
3. 组件层直接拼 store 数据或自己维护业务数据
|
||||||
|
例如 [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx) 内部自己维护了部分核心控制状态,导致未来 `Mobile` 难以复用。
|
||||||
|
|
||||||
|
4. 弹窗与 UI 业务状态分散在各组件本地 `useState`
|
||||||
|
比如 `auto setting`、`notice`、`user info`、`procedures` 等弹窗,当前都在各自文件内部自管 `open` 或 tab 状态,不利于双端共享和统一调度。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 第一阶段总体原则
|
||||||
|
|
||||||
|
第一阶段采用四层结构:
|
||||||
|
|
||||||
|
1. `api / dto / normalize`
|
||||||
|
2. `store / domain actions`
|
||||||
|
3. `view-model hooks`
|
||||||
|
4. `pc ui` 与 `mobile ui`
|
||||||
|
|
||||||
|
每层职责如下:
|
||||||
|
|
||||||
|
### 3.1 API 层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 调用后端接口
|
||||||
|
- DTO 转前端领域模型
|
||||||
|
- 不参与 UI 展示逻辑
|
||||||
|
|
||||||
|
保留现状:
|
||||||
|
|
||||||
|
- [src/features/game/api/game-api.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/api/game-api.ts)
|
||||||
|
|
||||||
|
### 3.2 Store 层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 保存当前页面共享的真实业务状态
|
||||||
|
- 提供业务动作
|
||||||
|
- 不直接处理视觉布局
|
||||||
|
|
||||||
|
### 3.3 View-model hooks 层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 从 store 中读取数据
|
||||||
|
- 聚合派生字段
|
||||||
|
- 封装业务事件
|
||||||
|
- 返回给 `PC` 与 `Mobile` 可直接渲染的数据结构
|
||||||
|
|
||||||
|
这层是本次改造的核心。
|
||||||
|
|
||||||
|
### 3.4 UI 层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只负责布局、视觉、交互表现
|
||||||
|
- `PC` 与 `Mobile` 各自独立
|
||||||
|
- 不直接拼底层 store
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 第一阶段目录改造清单
|
||||||
|
|
||||||
|
建议目录结构如下。第一阶段以“新增”为主,不要求立刻大规模迁移原文件。
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/features/game/
|
||||||
|
api/
|
||||||
|
game-api.ts
|
||||||
|
index.ts
|
||||||
|
types.ts
|
||||||
|
|
||||||
|
shared/
|
||||||
|
constants.ts
|
||||||
|
mock-data.ts
|
||||||
|
selectors.ts
|
||||||
|
types.ts
|
||||||
|
|
||||||
|
hooks/
|
||||||
|
use-game-header-vm.ts
|
||||||
|
use-game-status-vm.ts
|
||||||
|
use-game-board-vm.ts
|
||||||
|
use-game-history-vm.ts
|
||||||
|
use-game-control-vm.ts
|
||||||
|
use-game-announcement-vm.ts
|
||||||
|
use-game-auto-spin-vm.ts
|
||||||
|
index.ts
|
||||||
|
|
||||||
|
views/
|
||||||
|
pc/
|
||||||
|
pc-entry.tsx
|
||||||
|
mobile/
|
||||||
|
mobile-entry.tsx
|
||||||
|
|
||||||
|
components/
|
||||||
|
shared/
|
||||||
|
game-board.tsx
|
||||||
|
game-history-list.tsx
|
||||||
|
game-status-bar.tsx
|
||||||
|
game-control-panel.tsx
|
||||||
|
desktop/
|
||||||
|
mobile/
|
||||||
|
|
||||||
|
modal/
|
||||||
|
desktop/
|
||||||
|
mobile/
|
||||||
|
```
|
||||||
|
|
||||||
|
Store 目录建议补齐成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/store/game/
|
||||||
|
game-round-store.ts
|
||||||
|
game-session-store.ts
|
||||||
|
game-ui-store.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 第一阶段可以继续保留现有 `components/desktop` 与 `entry/` 目录
|
||||||
|
- `views/pc` 与 `views/mobile` 可以先作为新目录开始承接之后的容器入口
|
||||||
|
- `components/shared` 第一阶段只抽少量共用展示结构,不强行全部收拢
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 第一阶段 Store 设计
|
||||||
|
|
||||||
|
### 5.1 `game-round-store`
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
- [src/store/game/game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts)
|
||||||
|
|
||||||
|
该 store 保留为“回合与下注核心业务状态”。
|
||||||
|
|
||||||
|
#### 现有字段
|
||||||
|
|
||||||
|
- `cells`
|
||||||
|
- `chips`
|
||||||
|
- `history`
|
||||||
|
- `round`
|
||||||
|
- `selections`
|
||||||
|
- `trends`
|
||||||
|
- `activeChipId`
|
||||||
|
|
||||||
|
#### 现有动作
|
||||||
|
|
||||||
|
- `hydrateRound`
|
||||||
|
- `selectChip`
|
||||||
|
- `placeBet`
|
||||||
|
- `removeSelectionsForCell`
|
||||||
|
- `clearSelections`
|
||||||
|
- `setPhase`
|
||||||
|
- `syncRound`
|
||||||
|
- `upsertSelections`
|
||||||
|
|
||||||
|
#### 第一阶段建议新增动作
|
||||||
|
|
||||||
|
- `setActiveChip(chipId: string)`
|
||||||
|
- `replaceHistory(history: HistoryEntry[])`
|
||||||
|
- `replaceTrends(trends: TrendEntry[])`
|
||||||
|
- `replaceCells(cells: GameCell[])`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 如果不想增加重复动作,也可以只统一命名 `selectChip`
|
||||||
|
- 新增 `replaceHistory / replaceTrends / replaceCells` 是为了后续轮询、增量同步更清晰
|
||||||
|
|
||||||
|
#### 不应该放在这里的内容
|
||||||
|
|
||||||
|
- 各类 modal 的打开关闭
|
||||||
|
- 用户信息 tab
|
||||||
|
- auto-spin 面板设置
|
||||||
|
- 登录注册表单
|
||||||
|
- 按钮 hover / 点击动画态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 `game-session-store`
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
- [src/store/game/game-session-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-session-store.ts)
|
||||||
|
|
||||||
|
该 store 保留为“会话、连接、公告、面板摘要信息”。
|
||||||
|
|
||||||
|
#### 现有字段
|
||||||
|
|
||||||
|
- `announcements`
|
||||||
|
- `connection`
|
||||||
|
- `dashboard`
|
||||||
|
|
||||||
|
#### 现有动作
|
||||||
|
|
||||||
|
- `hydrateSession`
|
||||||
|
- `dismissAnnouncement`
|
||||||
|
- `markAnnouncementRead`
|
||||||
|
- `setConnectionLatency`
|
||||||
|
- `setConnectionStatus`
|
||||||
|
- `syncConnection`
|
||||||
|
- `syncDashboard`
|
||||||
|
|
||||||
|
#### 第一阶段建议新增字段
|
||||||
|
|
||||||
|
- `serverTimeIso: string | null`
|
||||||
|
- `lastBootstrapAt: string | null`
|
||||||
|
|
||||||
|
#### 第一阶段建议新增动作
|
||||||
|
|
||||||
|
- `setServerTime(serverTimeIso: string)`
|
||||||
|
- `setLastBootstrapAt(iso: string)`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx) 当前展示系统时间,后续建议统一从这里拿
|
||||||
|
- `lastBootstrapAt` 方便后续做重连、刷新、数据同步判断
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 新增 `game-ui-store`
|
||||||
|
|
||||||
|
新文件建议:
|
||||||
|
|
||||||
|
- `src/store/game/game-ui-store.ts`
|
||||||
|
|
||||||
|
该 store 专门管理“共享的 UI 业务状态”。
|
||||||
|
|
||||||
|
#### 建议字段
|
||||||
|
|
||||||
|
- `activeModal: GameModalType | null`
|
||||||
|
- `modalPayload: Record<string, unknown> | null`
|
||||||
|
- `selectedUserInfoTab: 'profile' | 'message'`
|
||||||
|
- `isBgmEnabled: boolean`
|
||||||
|
- `isMuted: boolean`
|
||||||
|
- `autoSpinEnabled: boolean`
|
||||||
|
- `autoSpinSettings`
|
||||||
|
- `stopIfBalanceLowerThan: string`
|
||||||
|
- `stopIfSingleWinExceeds: string`
|
||||||
|
- `stopOnAnyJackpot: boolean`
|
||||||
|
- `authForm`
|
||||||
|
- `loginAccount: string`
|
||||||
|
- `loginPassword: string`
|
||||||
|
- `registerAccount: string`
|
||||||
|
- `registerPassword: string`
|
||||||
|
- `registerConfirmPassword: string`
|
||||||
|
- `proceduresType: 'withdraw' | 'topup' | null`
|
||||||
|
|
||||||
|
#### 建议动作
|
||||||
|
|
||||||
|
- `openModal(type, payload?)`
|
||||||
|
- `closeModal()`
|
||||||
|
- `setSelectedUserInfoTab(tab)`
|
||||||
|
- `toggleBgm()`
|
||||||
|
- `setAutoSpinEnabled(enabled)`
|
||||||
|
- `setAutoSpinSetting(key, value)`
|
||||||
|
- `setAuthField(field, value)`
|
||||||
|
- `setProceduresType(type)`
|
||||||
|
- `resetAuthForm()`
|
||||||
|
- `resetAutoSpinSettings()`
|
||||||
|
|
||||||
|
#### 第一阶段要接管的现有状态来源
|
||||||
|
|
||||||
|
- [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx)
|
||||||
|
- [src/features/game/modal/desktop/desktop-notice-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-notice-modal.tsx)
|
||||||
|
- [src/features/game/modal/desktop/desktop-userInfo-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-userInfo-modal.tsx)
|
||||||
|
- [src/features/game/modal/desktop/desktop-procedures-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-procedures-modal.tsx)
|
||||||
|
- [src/features/game/modal/desktop/desktop-login-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-login-modal.tsx)
|
||||||
|
- [src/features/game/modal/desktop/desktop-register-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-register-modal.tsx)
|
||||||
|
- [src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx)
|
||||||
|
|
||||||
|
#### 不要放到 `game-ui-store` 的状态
|
||||||
|
|
||||||
|
- `confirmClicked`
|
||||||
|
- `clickedId`
|
||||||
|
- `hidingId`
|
||||||
|
|
||||||
|
这些状态目前位于 [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx),属于纯动画与表现状态,继续留在本地组件即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 第一阶段 Hook 设计
|
||||||
|
|
||||||
|
本阶段重点不是“先抽 shared 组件”,而是先抽统一的 `view-model hooks`。
|
||||||
|
|
||||||
|
这些 hook 的目标:
|
||||||
|
|
||||||
|
- 统一封装 store 读取
|
||||||
|
- 统一组织派生字段
|
||||||
|
- 统一输出 PC/Mobile 都能消费的 props
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.1 `useGameHeaderVm`
|
||||||
|
|
||||||
|
建议文件:
|
||||||
|
|
||||||
|
- `src/features/game/hooks/use-game-header-vm.ts`
|
||||||
|
|
||||||
|
服务组件:
|
||||||
|
|
||||||
|
- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx)
|
||||||
|
- 未来的 `mobile-header.tsx`
|
||||||
|
|
||||||
|
#### 建议暴露字段
|
||||||
|
|
||||||
|
- `latencyMs: number | null`
|
||||||
|
- `latencyLabel: string`
|
||||||
|
- `connectionStatus: 'connected' | 'reconnecting' | 'offline'`
|
||||||
|
- `systemTimeLabel: string`
|
||||||
|
- `username: string`
|
||||||
|
- `balanceLabel: string`
|
||||||
|
- `avatarUrl: string | null`
|
||||||
|
- `unreadMessageCount: number`
|
||||||
|
- `isBgmEnabled: boolean`
|
||||||
|
|
||||||
|
#### 建议暴露动作
|
||||||
|
|
||||||
|
- `onOpenRules()`
|
||||||
|
- `onOpenMessages()`
|
||||||
|
- `onToggleBgm()`
|
||||||
|
- `onOpenProfile()`
|
||||||
|
|
||||||
|
#### 说明
|
||||||
|
|
||||||
|
- `DesktopHeader` 未来不再直接读取 store 或写死文案来源
|
||||||
|
- header 只消费“最终展示值”与“用户点击后的动作”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 `useGameStatusVm`
|
||||||
|
|
||||||
|
建议文件:
|
||||||
|
|
||||||
|
- `src/features/game/hooks/use-game-status-vm.ts`
|
||||||
|
|
||||||
|
服务组件:
|
||||||
|
|
||||||
|
- [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx)
|
||||||
|
|
||||||
|
#### 建议暴露字段
|
||||||
|
|
||||||
|
- `roundId: string`
|
||||||
|
- `oddsLabel: string`
|
||||||
|
- `streakLabel: string`
|
||||||
|
- `limitLabel: string`
|
||||||
|
- `countdownSeconds: number`
|
||||||
|
- `countdownMs: number`
|
||||||
|
- `phase: RoundPhase`
|
||||||
|
- `phaseLabel: string`
|
||||||
|
- `phaseTone: 'open' | 'locked' | 'settled'`
|
||||||
|
- `acceptingBets: boolean`
|
||||||
|
|
||||||
|
#### 说明
|
||||||
|
|
||||||
|
- 倒计时展示逻辑、回合阶段文案逻辑统一在这里处理
|
||||||
|
- `PC` 和 `Mobile` 不各自重复拼 countdown 与 round phase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.3 `useGameBoardVm`
|
||||||
|
|
||||||
|
建议文件:
|
||||||
|
|
||||||
|
- `src/features/game/hooks/use-game-board-vm.ts`
|
||||||
|
|
||||||
|
服务组件:
|
||||||
|
|
||||||
|
- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
||||||
|
- 未来 `mobile-board.tsx`
|
||||||
|
|
||||||
|
#### 建议暴露字段
|
||||||
|
|
||||||
|
- `cells`
|
||||||
|
- `id`
|
||||||
|
- `imageUrl`
|
||||||
|
- `status`
|
||||||
|
- `isSelected`
|
||||||
|
- `isWinningCell`
|
||||||
|
- `selectionAmount`
|
||||||
|
- `selectionCount`
|
||||||
|
- `hitCount`
|
||||||
|
- `currentStreak`
|
||||||
|
- `activeCellId: number | null`
|
||||||
|
- `canPlaceBets: boolean`
|
||||||
|
- `totalSelectedCount: number`
|
||||||
|
- `totalSelectedAmount: number`
|
||||||
|
|
||||||
|
#### 建议暴露动作
|
||||||
|
|
||||||
|
- `onCellPress(cellId: number)`
|
||||||
|
- `onCellLongPress?(cellId: number)`
|
||||||
|
- `onCellClear(cellId: number)`
|
||||||
|
|
||||||
|
#### 说明
|
||||||
|
|
||||||
|
- 优先基于 [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts) 中的:
|
||||||
|
- `buildGameCellViewModels`
|
||||||
|
- `groupSelectionsByCell`
|
||||||
|
- `getSelectionTotal`
|
||||||
|
- `DesktopAnimal` 不应该自己管理业务选择逻辑,只负责渲染 cell grid
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.4 `useGameHistoryVm`
|
||||||
|
|
||||||
|
建议文件:
|
||||||
|
|
||||||
|
- `src/features/game/hooks/use-game-history-vm.ts`
|
||||||
|
|
||||||
|
服务组件:
|
||||||
|
|
||||||
|
- [src/features/game/components/desktop/desktop-game-history.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-game-history.tsx)
|
||||||
|
- 未来 `mobile-history.tsx`
|
||||||
|
|
||||||
|
#### 建议暴露字段
|
||||||
|
|
||||||
|
- `items`
|
||||||
|
- `orderNo`
|
||||||
|
- `roundId`
|
||||||
|
- `winningCellId`
|
||||||
|
- `betAmountLabel`
|
||||||
|
- `totalAmountLabel`
|
||||||
|
- `winAmountLabel`
|
||||||
|
- `statusLabel`
|
||||||
|
- `createdAtLabel`
|
||||||
|
- `isEmpty: boolean`
|
||||||
|
- `emptyText: string`
|
||||||
|
- `recentWinningCellIds: number[]`
|
||||||
|
|
||||||
|
#### 说明
|
||||||
|
|
||||||
|
- 当前 [src/features/game/components/desktop/desktop-game-history.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-game-history.tsx) 里还是本地 fake data,第一阶段应切换为 VM 数据
|
||||||
|
- `PC` 与 `Mobile` 历史列表的数据格式统一,只是布局不同
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.5 `useGameControlVm`
|
||||||
|
|
||||||
|
建议文件:
|
||||||
|
|
||||||
|
- `src/features/game/hooks/use-game-control-vm.ts`
|
||||||
|
|
||||||
|
服务组件:
|
||||||
|
|
||||||
|
- [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx)
|
||||||
|
- 未来 `mobile-control.tsx`
|
||||||
|
|
||||||
|
#### 建议暴露字段
|
||||||
|
|
||||||
|
- `chips`
|
||||||
|
- `id`
|
||||||
|
- `value`
|
||||||
|
- `amount`
|
||||||
|
- `color`
|
||||||
|
- `isSelected`
|
||||||
|
- `isDefault`
|
||||||
|
- `selectedChipId: string | null`
|
||||||
|
- `selectedChipAmountLabel: string`
|
||||||
|
- `selectionTotalLabel: string`
|
||||||
|
- `selectedCountLabel: string`
|
||||||
|
- `actionButtons`
|
||||||
|
- `id`
|
||||||
|
- `label`
|
||||||
|
- `disabled`
|
||||||
|
- `canConfirm: boolean`
|
||||||
|
- `canClear: boolean`
|
||||||
|
- `canRepeat: boolean`
|
||||||
|
- `canOpenAutoSpin: boolean`
|
||||||
|
|
||||||
|
#### 建议暴露动作
|
||||||
|
|
||||||
|
- `onChipSelect(chipId: string)`
|
||||||
|
- `onAddChip()`
|
||||||
|
- `onReduceChip()`
|
||||||
|
- `onClearSelections()`
|
||||||
|
- `onRepeatLastRound()`
|
||||||
|
- `onOpenAutoSpin()`
|
||||||
|
- `onConfirmBet()`
|
||||||
|
|
||||||
|
#### 说明
|
||||||
|
|
||||||
|
- [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx) 内部仍可保留点击动画和短暂视觉反馈
|
||||||
|
- 但“当前选中筹码、是否可确认、操作是否禁用”等必须来自 VM,而不是继续散在组件内部
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.6 `useGameAnnouncementVm`
|
||||||
|
|
||||||
|
建议文件:
|
||||||
|
|
||||||
|
- `src/features/game/hooks/use-game-announcement-vm.ts`
|
||||||
|
|
||||||
|
服务组件:
|
||||||
|
|
||||||
|
- [src/features/game/components/shared/game-announcement-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-announcement-modal.tsx)
|
||||||
|
- header 消息入口
|
||||||
|
- notice modal
|
||||||
|
|
||||||
|
#### 建议暴露字段
|
||||||
|
|
||||||
|
- `activeAnnouncement: AnnouncementItem | null`
|
||||||
|
- `visibleAnnouncements: AnnouncementItem[]`
|
||||||
|
- `unreadCount: number`
|
||||||
|
- `hasUnread: boolean`
|
||||||
|
- `isOpen: boolean`
|
||||||
|
|
||||||
|
#### 建议暴露动作
|
||||||
|
|
||||||
|
- `onOpenAnnouncement(id?: string)`
|
||||||
|
- `onDismissAnnouncement(id: string)`
|
||||||
|
- `onMarkRead(id: string)`
|
||||||
|
- `onCloseAnnouncement()`
|
||||||
|
|
||||||
|
#### 说明
|
||||||
|
|
||||||
|
- 这样公告逻辑不会散落在 [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx) 和多个具体 modal 中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.7 `useGameAutoSpinVm`
|
||||||
|
|
||||||
|
建议文件:
|
||||||
|
|
||||||
|
- `src/features/game/hooks/use-game-auto-spin-vm.ts`
|
||||||
|
|
||||||
|
服务组件:
|
||||||
|
|
||||||
|
- [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx)
|
||||||
|
- 未来 `mobile-auto-setting-modal.tsx`
|
||||||
|
|
||||||
|
#### 建议暴露字段
|
||||||
|
|
||||||
|
- `open: boolean`
|
||||||
|
- `enabled: boolean`
|
||||||
|
- `stopIfBalanceLowerThan: string`
|
||||||
|
- `stopIfSingleWinExceeds: string`
|
||||||
|
- `stopOnAnyJackpot: boolean`
|
||||||
|
- `canSubmit: boolean`
|
||||||
|
|
||||||
|
#### 建议暴露动作
|
||||||
|
|
||||||
|
- `onOpen()`
|
||||||
|
- `onClose()`
|
||||||
|
- `onToggleEnabled(enabled: boolean)`
|
||||||
|
- `onChangeBalanceThreshold(value: string)`
|
||||||
|
- `onChangeSingleWinThreshold(value: string)`
|
||||||
|
- `onToggleStopOnJackpot(enabled: boolean)`
|
||||||
|
- `onSubmit()`
|
||||||
|
|
||||||
|
#### 说明
|
||||||
|
|
||||||
|
- 这是最适合第一阶段打样的一类场景:数据结构固定、PC/Mobile 表现不同、交互规则相同
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 第一阶段共享展示组件建议
|
||||||
|
|
||||||
|
本阶段不要一口气把所有 `Desktop` 组件变成跨端共享。
|
||||||
|
|
||||||
|
建议只优先抽 2 到 3 个 shared 展示组件:
|
||||||
|
|
||||||
|
- `src/features/game/components/shared/game-board.tsx`
|
||||||
|
- `src/features/game/components/shared/game-history-list.tsx`
|
||||||
|
- `src/features/game/components/shared/game-status-bar.tsx`
|
||||||
|
|
||||||
|
### 7.1 `game-board`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只接收 `cells`
|
||||||
|
- 只接收 `onCellPress`
|
||||||
|
- 不直接读 store
|
||||||
|
|
||||||
|
这样:
|
||||||
|
|
||||||
|
- `PC` 可以包一层桌面 grid 样式
|
||||||
|
- `Mobile` 可以包一层移动端布局样式
|
||||||
|
|
||||||
|
### 7.2 `game-history-list`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只负责历史列表渲染
|
||||||
|
- 不关心历史数据怎么来
|
||||||
|
|
||||||
|
### 7.3 `game-status-bar`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 渲染赔率、回合、倒计时、当前状态
|
||||||
|
- 由 `useGameStatusVm` 驱动
|
||||||
|
|
||||||
|
不建议第一阶段就抽 shared 的内容:
|
||||||
|
|
||||||
|
- header
|
||||||
|
- control panel
|
||||||
|
- 大多数 modal
|
||||||
|
|
||||||
|
因为这些区域 `PC/Mobile` 差异通常更大,先共用 hook 比先共用视觉组件更稳。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 第一阶段 UI 层改造边界
|
||||||
|
|
||||||
|
本阶段只定 3 条规则:
|
||||||
|
|
||||||
|
### 8.1 `EntryPage` 可以直接处理 hydration
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
- [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx)
|
||||||
|
|
||||||
|
保留它作为:
|
||||||
|
|
||||||
|
- bootstrap 获取入口
|
||||||
|
- store hydrate 入口
|
||||||
|
- PC/Mobile 分流入口
|
||||||
|
|
||||||
|
### 8.2 容器组件可以调用 VM hooks
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- `PcEntry`
|
||||||
|
- `MobileEntry`
|
||||||
|
- 各 section 容器
|
||||||
|
- modal host
|
||||||
|
|
||||||
|
这些组件可以:
|
||||||
|
|
||||||
|
- `useGameBoardVm()`
|
||||||
|
- `useGameControlVm()`
|
||||||
|
- `useGameStatusVm()`
|
||||||
|
|
||||||
|
### 8.3 纯展示组件不直接碰 store
|
||||||
|
|
||||||
|
比如:
|
||||||
|
|
||||||
|
- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
||||||
|
- [src/features/game/components/desktop/desktop-game-history.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-game-history.tsx)
|
||||||
|
- [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx)
|
||||||
|
- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx)
|
||||||
|
|
||||||
|
未来都应该尽量演进为:
|
||||||
|
|
||||||
|
- 只吃 props
|
||||||
|
- 不主动拼底层 store
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 第一阶段实施顺序
|
||||||
|
|
||||||
|
建议严格按这个顺序执行,避免改动面过大。
|
||||||
|
|
||||||
|
### Step 1:新增 UI Store 与 Hooks 目录
|
||||||
|
|
||||||
|
新增:
|
||||||
|
|
||||||
|
- `src/store/game/game-ui-store.ts`
|
||||||
|
- `src/features/game/hooks/`
|
||||||
|
|
||||||
|
这一步只建壳子,不急着接业务。
|
||||||
|
|
||||||
|
### Step 2:实现 `useGameBoardVm` 与 `useGameControlVm`
|
||||||
|
|
||||||
|
这两块是主玩法交互核心,优先收益最大。
|
||||||
|
|
||||||
|
优先接的现有文件:
|
||||||
|
|
||||||
|
- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
||||||
|
- [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx)
|
||||||
|
|
||||||
|
### Step 3:让 `DesktopAnimal` 只吃 props
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 不直接读 store
|
||||||
|
- 不自己处理业务选择逻辑
|
||||||
|
|
||||||
|
保留字段形态:
|
||||||
|
|
||||||
|
- `cells`
|
||||||
|
- `activeId`
|
||||||
|
- `onSelect`
|
||||||
|
|
||||||
|
### Step 4:让 `DesktopControl` 业务数据来自 VM
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 当前筹码列表、已选筹码、总下注额等来自 `useGameControlVm`
|
||||||
|
- 点击动画状态仍然保留本地
|
||||||
|
|
||||||
|
### Step 5:停止 `Mobile` 直接复用 `Desktop` 组件
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
- [src/features/game/entry/mobile-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/mobile-entry.tsx)
|
||||||
|
|
||||||
|
当前问题:
|
||||||
|
|
||||||
|
- 直接 import `DesktopAnimal`
|
||||||
|
- 直接 import `DesktopGameHistory`
|
||||||
|
- 直接 import `DesktopTitle`
|
||||||
|
|
||||||
|
第一阶段目标:
|
||||||
|
|
||||||
|
- 改成 `MobileBoardSection`
|
||||||
|
- 改成 `MobileHistorySection`
|
||||||
|
- 共享 hook,不共享 `Desktop` 布局组件
|
||||||
|
|
||||||
|
### Step 6:实现 `useGameStatusVm` 与 `useGameHeaderVm`
|
||||||
|
|
||||||
|
接入文件:
|
||||||
|
|
||||||
|
- [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx)
|
||||||
|
- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx)
|
||||||
|
|
||||||
|
### Step 7:实现 `game-ui-store` + `useGameAutoSpinVm`
|
||||||
|
|
||||||
|
先接一个最合适的 modal:
|
||||||
|
|
||||||
|
- [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx)
|
||||||
|
|
||||||
|
跑通后,再接:
|
||||||
|
|
||||||
|
- notice
|
||||||
|
- user info
|
||||||
|
- procedures
|
||||||
|
- login/register
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 第一阶段优先改造文件清单
|
||||||
|
|
||||||
|
第一阶段最值得优先改的 6 个文件如下:
|
||||||
|
|
||||||
|
1. [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx)
|
||||||
|
2. [src/features/game/entry/mobile-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/mobile-entry.tsx)
|
||||||
|
3. [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
||||||
|
4. [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx)
|
||||||
|
5. [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx)
|
||||||
|
6. [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx)
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 它们覆盖了主玩法、主状态、移动端入口和共享 modal 模式
|
||||||
|
- 先把这 6 个改通,数据与界面分层的骨架就建立起来了
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 第一阶段完成后的验收标准
|
||||||
|
|
||||||
|
做到以下几点,即可视为第一阶段完成:
|
||||||
|
|
||||||
|
- `PC` 与 `Mobile` 都不直接拼 API / store 的底层结构
|
||||||
|
- `Mobile` 不再直接 import `DesktopXxx`
|
||||||
|
- 至少 `board / control / status` 这 3 类区域中的 2 类已经改为 “VM 驱动 + 展示组件消费 props”
|
||||||
|
- modal 开关与 tab 这类共享 UI 业务状态已进入 `game-ui-store`
|
||||||
|
- 视觉动画状态与业务状态分层清晰
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 风险与边界说明
|
||||||
|
|
||||||
|
### 12.1 不要在第一阶段做的事情
|
||||||
|
|
||||||
|
- 不要一次性把所有桌面组件都改成共享组件
|
||||||
|
- 不要同时把全部 modal 状态都收进 store
|
||||||
|
- 不要同时重做 API 调用方式
|
||||||
|
- 不要试图在第一阶段解决所有命名和目录历史包袱
|
||||||
|
|
||||||
|
### 12.2 第一阶段最容易犯的错误
|
||||||
|
|
||||||
|
- 把所有状态都塞进 store
|
||||||
|
结果动画、hover、点击反馈这些纯表现状态也被全局化,反而变复杂
|
||||||
|
|
||||||
|
- 先抽 shared UI,而不是先抽 VM
|
||||||
|
这样容易做出一堆仍然耦合 store 的“伪共享组件”
|
||||||
|
|
||||||
|
- Mobile 继续复用 Desktop 组件
|
||||||
|
这会让后面任何布局调整都越来越痛苦
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 结论
|
||||||
|
|
||||||
|
本项目第一阶段的正确方向不是:
|
||||||
|
|
||||||
|
- “PC 一套状态,Mobile 一套状态”
|
||||||
|
|
||||||
|
而是:
|
||||||
|
|
||||||
|
- 一套 `domain state`
|
||||||
|
- 一套 `domain actions`
|
||||||
|
- 一套 `view-model hooks`
|
||||||
|
- 两套 `presentation`
|
||||||
|
|
||||||
|
按本实施稿推进后,后续才适合继续做:
|
||||||
|
|
||||||
|
- 第二阶段:shared UI 组件整理
|
||||||
|
- 第三阶段:modal host 统一
|
||||||
|
- 第四阶段:实时数据同步、轮询或 websocket 接入
|
||||||
|
|
||||||
BIN
figma/img.png
|
Before Width: | Height: | Size: 246 KiB |
BIN
figma/原型图.jpg
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
figma/设计图.png
Normal file
|
After Width: | Height: | Size: 643 KiB |
@@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "^5.99.0",
|
"@tanstack/react-query": "^5.99.0",
|
||||||
"@tanstack/react-query-devtools": "^5.99.0",
|
"@tanstack/react-query-devtools": "^5.99.0",
|
||||||
"@tanstack/react-router": "^1.168.22",
|
"@tanstack/react-router": "^1.168.22",
|
||||||
@@ -40,10 +41,12 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-hook-form": "^7.75.0",
|
||||||
"react-i18next": "^17.0.3",
|
"react-i18next": "^17.0.3",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
39
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@fontsource-variable/geist':
|
'@fontsource-variable/geist':
|
||||||
specifier: ^5.2.8
|
specifier: ^5.2.8
|
||||||
version: 5.2.8
|
version: 5.2.8
|
||||||
|
'@hookform/resolvers':
|
||||||
|
specifier: ^5.2.2
|
||||||
|
version: 5.2.2(react-hook-form@7.75.0(react@19.2.5))
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.99.0
|
specifier: ^5.99.0
|
||||||
version: 5.99.0(react@19.2.5)
|
version: 5.99.0(react@19.2.5)
|
||||||
@@ -53,6 +56,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.4
|
specifier: ^19.2.4
|
||||||
version: 19.2.5(react@19.2.5)
|
version: 19.2.5(react@19.2.5)
|
||||||
|
react-hook-form:
|
||||||
|
specifier: ^7.75.0
|
||||||
|
version: 7.75.0(react@19.2.5)
|
||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^17.0.3
|
specifier: ^17.0.3
|
||||||
version: 17.0.3(i18next@26.0.5(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2)
|
version: 17.0.3(i18next@26.0.5(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2)
|
||||||
@@ -65,6 +71,9 @@ importers:
|
|||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
zod:
|
||||||
|
specifier: ^4.4.3
|
||||||
|
version: 4.4.3
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.12
|
specifier: ^5.0.12
|
||||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
|
version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
|
||||||
@@ -608,6 +617,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: ^4
|
hono: ^4
|
||||||
|
|
||||||
|
'@hookform/resolvers@5.2.2':
|
||||||
|
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
|
||||||
|
peerDependencies:
|
||||||
|
react-hook-form: ^7.55.0
|
||||||
|
|
||||||
'@inquirer/ansi@2.0.5':
|
'@inquirer/ansi@2.0.5':
|
||||||
resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
|
resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
|
||||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||||
@@ -1541,6 +1555,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0':
|
||||||
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
'@tailwindcss/node@4.2.2':
|
'@tailwindcss/node@4.2.2':
|
||||||
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
|
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
|
||||||
|
|
||||||
@@ -3180,6 +3197,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.5
|
react: ^19.2.5
|
||||||
|
|
||||||
|
react-hook-form@7.75.0:
|
||||||
|
resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
react-i18next@17.0.3:
|
react-i18next@17.0.3:
|
||||||
resolution: {integrity: sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==}
|
resolution: {integrity: sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3748,6 +3771,9 @@ packages:
|
|||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
|
zod@4.4.3:
|
||||||
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
zustand@5.0.12:
|
zustand@5.0.12:
|
||||||
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
|
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -4247,6 +4273,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.12.18
|
hono: 4.12.18
|
||||||
|
|
||||||
|
'@hookform/resolvers@5.2.2(react-hook-form@7.75.0(react@19.2.5))':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/utils': 0.3.0
|
||||||
|
react-hook-form: 7.75.0(react@19.2.5)
|
||||||
|
|
||||||
'@inquirer/ansi@2.0.5': {}
|
'@inquirer/ansi@2.0.5': {}
|
||||||
|
|
||||||
'@inquirer/confirm@6.0.12(@types/node@24.12.2)':
|
'@inquirer/confirm@6.0.12(@types/node@24.12.2)':
|
||||||
@@ -5183,6 +5214,8 @@ snapshots:
|
|||||||
|
|
||||||
'@sindresorhus/merge-streams@4.0.0': {}
|
'@sindresorhus/merge-streams@4.0.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
'@tailwindcss/node@4.2.2':
|
'@tailwindcss/node@4.2.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
@@ -6796,6 +6829,10 @@ snapshots:
|
|||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
react-hook-form@7.75.0(react@19.2.5):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.5
|
||||||
|
|
||||||
react-i18next@17.0.3(i18next@26.0.5(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2):
|
react-i18next@17.0.3(i18next@26.0.5(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.2
|
'@babel/runtime': 7.29.2
|
||||||
@@ -7342,6 +7379,8 @@ snapshots:
|
|||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
|
zod@4.4.3: {}
|
||||||
|
|
||||||
zustand@5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)):
|
zustand@5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 73 KiB |
18
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
'w-full min-w-0 rounded-md border border-transparent bg-[#135E65]/60 px-design-30 py-design-15 text-design-20 text-[#D9FFFF] outline-none transition placeholder:text-[rgba(116,173,175,0.72)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-[rgba(110,255,255,0.72)] focus-visible:ring-0 focus-visible:shadow-[0_0_0_calc(var(--design-unit)*1.5)_rgba(110,255,255,0.16),0_0_calc(var(--design-unit)*8)_rgba(48,214,255,0.36),0_0_calc(var(--design-unit)*18)_rgba(18,162,255,0.22),inset_0_0_calc(var(--design-unit)*6)_rgba(110,255,255,0.08)] aria-invalid:border-[#DF5B5B] aria-invalid:bg-[rgba(78,17,23,0.45)] aria-invalid:text-[#FFF2F2] aria-invalid:focus-visible:shadow-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
192
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
|
||||||
|
import { Select as SelectPrimitive } from 'radix-ui'
|
||||||
|
import type * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn('scroll-my-1 p-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: 'sm' | 'default'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = 'item-aligned',
|
||||||
|
align = 'center',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={position === 'item-aligned'}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
data-position={position}
|
||||||
|
className={cn(
|
||||||
|
'data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)',
|
||||||
|
position === 'popper' && '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn('px-1.5 py-1 text-xs text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -11,48 +11,47 @@ import reduce from '@/assets/game/reduce.webp'
|
|||||||
import totalBg from '@/assets/game/total-bg.webp'
|
import totalBg from '@/assets/game/total-bg.webp'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import { ACTION_OPTIONS, CHIP_OPTIONS } from '@/constants'
|
import { ACTION_OPTIONS } from '@/constants'
|
||||||
|
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function DesktopControl() {
|
export function DesktopControl() {
|
||||||
const [chips, setChips] = useState(CHIP_OPTIONS)
|
const {
|
||||||
const [selectedChipId, setSelectedChipId] = useState(
|
canClear,
|
||||||
CHIP_OPTIONS[CHIP_OPTIONS.length - 1]?.id ?? '',
|
chips,
|
||||||
)
|
onChipSelect,
|
||||||
|
onClearSelections,
|
||||||
|
selectedChipAmountLabel,
|
||||||
|
selectedChipId,
|
||||||
|
selectedCountLabel,
|
||||||
|
totalBetAmountLabel,
|
||||||
|
} = useGameControlVm()
|
||||||
|
|
||||||
const [clickedId, setClickedId] = useState<string | null>(null)
|
const [clickedId, setClickedId] = useState<string | null>(null)
|
||||||
const [hidingId, setHidingId] = useState<string | null>(null)
|
const [hidingId, setHidingId] = useState<string | null>(null)
|
||||||
const [confirmClicked, setConfirmClicked] = useState(false)
|
const [confirmClicked, setConfirmClicked] = useState(false)
|
||||||
|
|
||||||
const selectedChip =
|
|
||||||
chips.find((chip) => chip.id === selectedChipId) ?? CHIP_OPTIONS[0]
|
|
||||||
|
|
||||||
const handleChipClick = (chipId: string) => {
|
const handleChipClick = (chipId: string) => {
|
||||||
setSelectedChipId(chipId)
|
onChipSelect(chipId)
|
||||||
setChips((current) => {
|
|
||||||
const next = [...current]
|
|
||||||
const index = next.findIndex((chip) => chip.id === chipId)
|
|
||||||
|
|
||||||
if (index === -1 || index === next.length - 1) {
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const [selected] = next.splice(index, 1)
|
|
||||||
next.push(selected)
|
|
||||||
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleActionClick = useCallback((id: string) => {
|
const handleActionClick = useCallback(
|
||||||
setClickedId(id)
|
(id: string) => {
|
||||||
setTimeout(() => {
|
if (id === 'clear' && canClear) {
|
||||||
setClickedId(null)
|
onClearSelections()
|
||||||
setHidingId(id)
|
}
|
||||||
|
|
||||||
|
setClickedId(id)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setHidingId(null)
|
setClickedId(null)
|
||||||
}, 180)
|
setHidingId(id)
|
||||||
}, 200)
|
setTimeout(() => {
|
||||||
}, [])
|
setHidingId(null)
|
||||||
|
}, 180)
|
||||||
|
}, 200)
|
||||||
|
},
|
||||||
|
[canClear, onClearSelections],
|
||||||
|
)
|
||||||
|
|
||||||
const handleConfirmClick = useCallback(() => {
|
const handleConfirmClick = useCallback(() => {
|
||||||
setConfirmClicked(true)
|
setConfirmClicked(true)
|
||||||
@@ -202,7 +201,7 @@ export function DesktopControl() {
|
|||||||
>
|
>
|
||||||
<motion.img
|
<motion.img
|
||||||
src={chip.src}
|
src={chip.src}
|
||||||
alt={`chip-${chip.value}`}
|
alt={`chip-${chip.amount}`}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className={'h-design-70 w-design-70 object-contain'}
|
className={'h-design-70 w-design-70 object-contain'}
|
||||||
/>
|
/>
|
||||||
@@ -225,7 +224,7 @@ export function DesktopControl() {
|
|||||||
<div
|
<div
|
||||||
className={'w-design-80 h-full flex items-center justify-center'}
|
className={'w-design-80 h-full flex items-center justify-center'}
|
||||||
>
|
>
|
||||||
{selectedChip.value}
|
{selectedChipAmountLabel}
|
||||||
</div>
|
</div>
|
||||||
<SmartImage
|
<SmartImage
|
||||||
src={reduce}
|
src={reduce}
|
||||||
@@ -241,8 +240,8 @@ export function DesktopControl() {
|
|||||||
'desktop-control-total relative flex flex-col items-center justify-center z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
|
'desktop-control-total relative flex flex-col items-center justify-center z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>SELECTED:3/5</div>
|
<div>SELECTED:{selectedCountLabel}</div>
|
||||||
<div>Total Bet:150</div>
|
<div>Total Bet:{totalBetAmountLabel}</div>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
src={controlBg}
|
src={controlBg}
|
||||||
|
|||||||
@@ -1,119 +1,9 @@
|
|||||||
import historyBg from '@/assets/system/history-bg.png'
|
import historyBg from '@/assets/system/history-bg.png'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts'
|
||||||
|
|
||||||
export function DesktopGameHistory() {
|
export function DesktopGameHistory() {
|
||||||
const data = [
|
const { emptyText, isEmpty, items } = useGameHistoryVm()
|
||||||
{
|
|
||||||
order_no: 'BET202604290001',
|
|
||||||
period_no: '202604290101',
|
|
||||||
numbers: [3, 8, 12],
|
|
||||||
bet_amount: '100.00',
|
|
||||||
total_amount: '100.00',
|
|
||||||
result_number: 8,
|
|
||||||
win_amount: '330.00',
|
|
||||||
status: 'won',
|
|
||||||
create_time: 1745881200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order_no: 'BET202604290002',
|
|
||||||
period_no: '202604290102',
|
|
||||||
numbers: [5],
|
|
||||||
bet_amount: '50.00',
|
|
||||||
total_amount: '50.00',
|
|
||||||
result_number: 11,
|
|
||||||
win_amount: '0.00',
|
|
||||||
status: 'lost',
|
|
||||||
create_time: 1745882100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order_no: 'BET202604290003',
|
|
||||||
period_no: '202604290103',
|
|
||||||
numbers: [1, 7],
|
|
||||||
bet_amount: '88.00',
|
|
||||||
total_amount: '88.00',
|
|
||||||
result_number: 7,
|
|
||||||
win_amount: '176.00',
|
|
||||||
status: 'won',
|
|
||||||
create_time: 1745883000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order_no: 'BET202604290004',
|
|
||||||
period_no: '202604290104',
|
|
||||||
numbers: [9, 10, 15],
|
|
||||||
bet_amount: '120.00',
|
|
||||||
total_amount: '120.00',
|
|
||||||
result_number: 4,
|
|
||||||
win_amount: '0.00',
|
|
||||||
status: 'settled',
|
|
||||||
create_time: 1745883900,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order_no: 'BET202604290005',
|
|
||||||
period_no: '202604290105',
|
|
||||||
numbers: [6],
|
|
||||||
bet_amount: '66.00',
|
|
||||||
total_amount: '66.00',
|
|
||||||
result_number: null,
|
|
||||||
win_amount: '0.00',
|
|
||||||
status: 'pending',
|
|
||||||
create_time: 1745884800,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order_no: 'BET202604290006',
|
|
||||||
period_no: '202604290106',
|
|
||||||
numbers: [2, 14],
|
|
||||||
bet_amount: '200.00',
|
|
||||||
total_amount: '200.00',
|
|
||||||
result_number: 14,
|
|
||||||
win_amount: '400.00',
|
|
||||||
status: 'won',
|
|
||||||
create_time: 1745885700,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order_no: 'BET202604290007',
|
|
||||||
period_no: '202604290107',
|
|
||||||
numbers: [13],
|
|
||||||
bet_amount: '30.00',
|
|
||||||
total_amount: '30.00',
|
|
||||||
result_number: 13,
|
|
||||||
win_amount: '99.00',
|
|
||||||
status: 'won',
|
|
||||||
create_time: 1745886600,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order_no: 'BET202604290008',
|
|
||||||
period_no: '202604290108',
|
|
||||||
numbers: [4, 16],
|
|
||||||
bet_amount: '150.00',
|
|
||||||
total_amount: '150.00',
|
|
||||||
result_number: 1,
|
|
||||||
win_amount: '0.00',
|
|
||||||
status: 'lost',
|
|
||||||
create_time: 1745887500,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order_no: 'BET202604290009',
|
|
||||||
period_no: '202604290109',
|
|
||||||
numbers: [11, 18, 20],
|
|
||||||
bet_amount: '300.00',
|
|
||||||
total_amount: '300.00',
|
|
||||||
result_number: null,
|
|
||||||
win_amount: '0.00',
|
|
||||||
status: 'pending',
|
|
||||||
create_time: 1745888400,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order_no: 'BET202604290010',
|
|
||||||
period_no: '202604290110',
|
|
||||||
numbers: [17],
|
|
||||||
bet_amount: '80.00',
|
|
||||||
total_amount: '80.00',
|
|
||||||
result_number: 17,
|
|
||||||
win_amount: '264.00',
|
|
||||||
status: 'won',
|
|
||||||
create_time: 1745889300,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
@@ -133,49 +23,66 @@ export function DesktopGameHistory() {
|
|||||||
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
|
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{data.map((item) => {
|
{isEmpty ? (
|
||||||
return (
|
<div
|
||||||
<div
|
className={
|
||||||
key={item.order_no}
|
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
|
||||||
className={
|
}
|
||||||
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
>
|
||||||
}
|
{emptyText}
|
||||||
>
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((item) => {
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
|
key={item.id}
|
||||||
className={
|
className={
|
||||||
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
|
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{item.status}
|
<div
|
||||||
</div>
|
className={
|
||||||
<div
|
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
|
||||||
className={
|
}
|
||||||
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
|
>
|
||||||
}
|
{item.statusLabel}
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span className={'text-[#84A2A2]'}>Round ID: </span>
|
|
||||||
<span className={'text-[#C0E7EB]'}>{item.order_no}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div
|
||||||
<span className={'text-[#84A2A2]'}>Animals Bet: </span>
|
className={
|
||||||
<span>{item.numbers.join(', ')}</span>
|
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
|
||||||
</div>
|
}
|
||||||
<div>
|
>
|
||||||
<span className={'text-[#84A2A2]'}>Total Bet Amount: </span>
|
<div>
|
||||||
<span className={'text-[#FFE375]'}>{item.bet_amount}</span>
|
<span className={'text-[#84A2A2]'}>Round ID: </span>
|
||||||
</div>
|
<span className={'text-[#C0E7EB]'}>{item.roundId}</span>
|
||||||
<div>
|
</div>
|
||||||
<span className={'text-[#84A2A2]'}> Winning Result:</span>
|
<div>
|
||||||
<span className={'text-[#FF7575]'}>
|
<span className={'text-[#84A2A2]'}>Settled At: </span>
|
||||||
{' '}
|
<span>{item.settledAtLabel}</span>
|
||||||
{item.result_number === null ? '--' : item.result_number}
|
</div>
|
||||||
</span>
|
<div>
|
||||||
|
<span className={'text-[#84A2A2]'}>
|
||||||
|
Total Pool Amount:{' '}
|
||||||
|
</span>
|
||||||
|
<span className={'text-[#FFE375]'}>
|
||||||
|
{item.totalPoolAmountLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={'text-[#84A2A2]'}>Winning Result: </span>
|
||||||
|
<span className={'text-[#FF7575]'}>
|
||||||
|
{item.winningCellIdLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={'text-[#84A2A2]'}>Payout: </span>
|
||||||
|
<span>{item.payoutMultiplierLabel}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
})
|
||||||
})}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,20 @@ import statusLine from '@/assets/system/status-line.webp'
|
|||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
|
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
|
||||||
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||||
|
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
|
||||||
|
|
||||||
export function DesktopStatusLine() {
|
export function DesktopStatusLine() {
|
||||||
|
const {
|
||||||
|
countdownMs,
|
||||||
|
limitLabel,
|
||||||
|
oddsLabel,
|
||||||
|
phaseDescription,
|
||||||
|
phaseLabel,
|
||||||
|
phaseToneClassName,
|
||||||
|
roundId,
|
||||||
|
streakLabel,
|
||||||
|
} = useGameStatusVm()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'relative w-full flex flex-col text-design-22'}>
|
<div className={'relative w-full flex flex-col text-design-22'}>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
@@ -12,10 +24,12 @@ export function DesktopStatusLine() {
|
|||||||
size="100% 100%"
|
size="100% 100%"
|
||||||
className="w-full h-design-60 bg-no-repeat bg-center flex items-center justify-center"
|
className="w-full h-design-60 bg-no-repeat bg-center flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<div className={'flex-1 flex items-center justify-center'}>
|
<div
|
||||||
<div>Odds: 1:33</div>
|
className={'flex-1 flex items-center justify-center gap-design-24'}
|
||||||
<div>Streak: X2</div>
|
>
|
||||||
<div>Limit: 100</div>
|
<div>Odds: {oddsLabel}</div>
|
||||||
|
<div>Streak: {streakLabel}</div>
|
||||||
|
<div>Limit: {limitLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
src={statusCenter}
|
src={statusCenter}
|
||||||
@@ -23,22 +37,22 @@ export function DesktopStatusLine() {
|
|||||||
size="contain"
|
size="contain"
|
||||||
>
|
>
|
||||||
<DesktopCountdown
|
<DesktopCountdown
|
||||||
initialSeconds={30}
|
initialMs={countdownMs}
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
console.log('countdown finished')
|
console.log('countdown finished')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
<div className={'flex-1 flex items-center justify-center gap-10'}>
|
<div className={'flex-1 flex items-center justify-center gap-10'}>
|
||||||
<div>Round ID:20241026120</div>
|
<div>Round ID:{roundId}</div>
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
<div
|
<div
|
||||||
className={'w-design-20 h-design-20 bg-[#78FF7F] rounded-[50%]'}
|
className={'w-design-20 h-design-20 bg-[#78FF7F] rounded-[50%]'}
|
||||||
></div>
|
></div>
|
||||||
<div className={'text-[#78FF7F]'}>OPEN</div>
|
<div className={phaseToneClassName}>{phaseLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>(Menerima Taruhan)</div>
|
<div>{phaseDescription}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
|
|||||||
@@ -1,5 +1,615 @@
|
|||||||
|
import { Minus, Plus } from 'lucide-react'
|
||||||
|
import { type ReactNode, useState } from 'react'
|
||||||
|
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||||
|
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select.tsx'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const AVAILABLE_BALANCE = 6628
|
||||||
|
const MYR_PER_100_DIAMONDS = 1
|
||||||
|
const USDT_TO_MYR_RATE = 4.049
|
||||||
|
const VND_PER_DIAMOND = 10
|
||||||
|
|
||||||
|
const QUICK_AMOUNTS = [
|
||||||
|
{ diamonds: 210, preview: 'MYR 3' },
|
||||||
|
{ diamonds: 2250, preview: 'MYR 30' },
|
||||||
|
{ diamonds: 4000, preview: 'MYR 50' },
|
||||||
|
{ diamonds: 8000, preview: 'MYR 100' },
|
||||||
|
{ diamonds: 17000, preview: 'MYR 200' },
|
||||||
|
{ diamonds: 45000, preview: 'MYR 500' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const CURRENCY_OPTIONS = ['MYR'] as const
|
||||||
|
|
||||||
|
const PAYMENT_CHANNELS = [
|
||||||
|
{
|
||||||
|
id: 'alipay-primary',
|
||||||
|
label: 'Alipay',
|
||||||
|
glyph: '支',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alipay-secondary',
|
||||||
|
label: 'Alipay',
|
||||||
|
glyph: '支',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alipay-third',
|
||||||
|
label: 'Alipay',
|
||||||
|
glyph: '支',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const BANK_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: 'bca',
|
||||||
|
label: 'BCA',
|
||||||
|
brand: 'BCA',
|
||||||
|
subtitle: 'Bank Central Asia',
|
||||||
|
surface:
|
||||||
|
'bg-[linear-gradient(180deg,rgba(251,252,255,0.98),rgba(224,239,255,0.96))] text-[#1E53A4]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandiri',
|
||||||
|
label: 'Mandiri',
|
||||||
|
brand: 'mandiri',
|
||||||
|
subtitle: 'Mandiri',
|
||||||
|
surface:
|
||||||
|
'bg-[linear-gradient(180deg,rgba(26,53,93,0.98),rgba(9,22,43,0.96))] text-[#F5C247]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bni',
|
||||||
|
label: 'BNI',
|
||||||
|
brand: 'BNI',
|
||||||
|
subtitle: 'BNI',
|
||||||
|
surface:
|
||||||
|
'bg-[linear-gradient(180deg,rgba(254,253,252,0.98),rgba(239,242,247,0.96))] text-[#E1742B]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bri',
|
||||||
|
label: 'BRI',
|
||||||
|
brand: 'BRI',
|
||||||
|
subtitle: 'BRI',
|
||||||
|
surface:
|
||||||
|
'bg-[linear-gradient(180deg,rgba(253,254,255,0.98),rgba(234,243,255,0.96))] text-[#0E56A5]',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type PaymentChannelId = (typeof PAYMENT_CHANNELS)[number]['id']
|
||||||
|
type BankId = (typeof BANK_OPTIONS)[number]['id']
|
||||||
|
|
||||||
|
const numberFormatter = new Intl.NumberFormat('en-US')
|
||||||
|
const fixedTwoFormatter = new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})
|
||||||
|
const fixedSixFormatter = new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits: 6,
|
||||||
|
maximumFractionDigits: 6,
|
||||||
|
})
|
||||||
|
|
||||||
|
const PANEL_CLASS =
|
||||||
|
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.08),0_0_calc(var(--design-unit)*10)_rgba(32,163,186,0.12)]'
|
||||||
|
|
||||||
|
const SELECTABLE_CARD_CLASS =
|
||||||
|
'flex shrink-0 cursor-pointer flex-col items-center justify-between rounded-[calc(var(--design-unit)*6)] border px-design-8 py-design-8 transition'
|
||||||
|
|
||||||
|
const SELECTABLE_CARD_ACTIVE_CLASS =
|
||||||
|
'border-[#D18A43] bg-[linear-gradient(180deg,rgba(65,45,28,0.92),rgba(39,26,16,0.9))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
|
||||||
|
|
||||||
|
const SELECTABLE_CARD_IDLE_CLASS =
|
||||||
|
'border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(8,34,48,0.92),rgba(5,19,29,0.94))] hover:border-[rgba(170,247,255,0.7)]'
|
||||||
|
|
||||||
|
function formatNumber(value: number) {
|
||||||
|
return numberFormatter.format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFixedTwo(value: number) {
|
||||||
|
return fixedTwoFormatter.format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFixedSix(value: number) {
|
||||||
|
return fixedSixFormatter.format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WithdrawField({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
alignStart = true,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
children: ReactNode
|
||||||
|
alignStart?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-design-14">
|
||||||
|
<div className="flex w-design-108 shrink-0 items-center justify-end text-right text-design-16 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="pl-design-4">:</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1',
|
||||||
|
alignStart ? 'pt-design-2' : 'flex items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AmountShell({
|
||||||
|
amount,
|
||||||
|
onMinus,
|
||||||
|
onPlus,
|
||||||
|
}: {
|
||||||
|
amount: number
|
||||||
|
onMinus: () => void
|
||||||
|
onPlus: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-design-6">
|
||||||
|
<div className="flex h-design-52 items-center gap-design-10 rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(14,64,74,0.82),rgba(8,36,47,0.78))] px-design-10 shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(93,239,255,0.08)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMinus}
|
||||||
|
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
||||||
|
>
|
||||||
|
<Minus className="h-design-16 w-design-16" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 items-center justify-center text-design-24 font-medium tracking-[0.04em] text-[#A1EBF3]">
|
||||||
|
{formatNumber(amount)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPlus}
|
||||||
|
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
||||||
|
>
|
||||||
|
<Plus className="h-design-16 w-design-16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
|
||||||
|
Saldo Tersedia: {formatNumber(AVAILABLE_BALANCE)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickAmountCard({
|
||||||
|
amount,
|
||||||
|
preview,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
amount: number
|
||||||
|
preview: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'flex h-design-68 w-design-104 shrink-0 cursor-pointer flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border transition',
|
||||||
|
active
|
||||||
|
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(84,48,24,0.92),rgba(60,34,18,0.88))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
|
||||||
|
: 'border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(10,44,58,0.84),rgba(5,21,32,0.92))] hover:border-[rgba(170,247,255,0.7)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-design-24 font-semibold leading-none text-[#FFE229]">
|
||||||
|
{amount}
|
||||||
|
</div>
|
||||||
|
<div className="pt-design-6 text-design-12 uppercase leading-none tracking-[0.04em] text-[#63AEB6]">
|
||||||
|
{preview}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentCard({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
glyph,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
label: string
|
||||||
|
glyph: string
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
SELECTABLE_CARD_CLASS,
|
||||||
|
'h-design-92 w-design-86',
|
||||||
|
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-design-58 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-42 font-semibold leading-none',
|
||||||
|
active
|
||||||
|
? 'bg-[linear-gradient(180deg,#1F9DE8,#0E6BCF)] text-white'
|
||||||
|
: 'bg-[linear-gradient(180deg,#1C96DF,#0B6ECF)] text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</div>
|
||||||
|
<div className="text-design-14 text-[#AEE8EE]">{label}</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BankCard({
|
||||||
|
active,
|
||||||
|
brand,
|
||||||
|
subtitle,
|
||||||
|
surface,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
brand: string
|
||||||
|
subtitle: string
|
||||||
|
surface: string
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
SELECTABLE_CARD_CLASS,
|
||||||
|
'h-design-86 w-design-86',
|
||||||
|
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-design-52 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-20 font-bold uppercase',
|
||||||
|
surface,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{brand}
|
||||||
|
</div>
|
||||||
|
<div className="text-design-13 text-[#AEE8EE]">{subtitle}</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputShell({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
error,
|
||||||
|
errorMessage,
|
||||||
|
uppercase = false,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder: string
|
||||||
|
error?: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
uppercase?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-design-5">
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(
|
||||||
|
'h-design-42 rounded-[calc(var(--design-unit)*5)] border px-design-14 text-design-16',
|
||||||
|
uppercase && 'uppercase',
|
||||||
|
error
|
||||||
|
? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]'
|
||||||
|
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(10,47,57,0.84),rgba(5,23,32,0.92))] text-[#ACF1F6]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{error && errorMessage ? (
|
||||||
|
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
highlight = false,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: ReactNode
|
||||||
|
highlight?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex border-b border-[rgba(89,209,223,0.2)] last:border-b-0">
|
||||||
|
<div className="flex w-[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]">
|
||||||
|
{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]',
|
||||||
|
highlight && 'text-design-18 font-semibold text-[#6DFF83]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DesktopWithdraw() {
|
function DesktopWithdraw() {
|
||||||
return <div>DesktopWithdraw</div>
|
const [amount, setAmount] = useState(6626)
|
||||||
|
const [currency, setCurrency] =
|
||||||
|
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
|
||||||
|
const [paymentChannel, setPaymentChannel] =
|
||||||
|
useState<PaymentChannelId>('alipay-primary')
|
||||||
|
const [bank, setBank] = useState<BankId>('bca')
|
||||||
|
const [holderName, setHolderName] = useState('')
|
||||||
|
const [bankAccount, setBankAccount] = useState('')
|
||||||
|
const [receiverEmail, setReceiverEmail] = useState('')
|
||||||
|
const [receiverPhone, setReceiverPhone] = useState('')
|
||||||
|
|
||||||
|
const withdrawMyr = amount / 100
|
||||||
|
const withdrawVnd = amount * VND_PER_DIAMOND
|
||||||
|
const withdrawUsdt = withdrawMyr / USDT_TO_MYR_RATE
|
||||||
|
|
||||||
|
const selectedBank = BANK_OPTIONS.find((item) => item.id === bank)
|
||||||
|
const holderNameError = holderName.trim().length === 0
|
||||||
|
const bankAccountError = bankAccount.trim().length === 0
|
||||||
|
|
||||||
|
function handleAmountChange(nextAmount: number) {
|
||||||
|
setAmount(Math.max(0, nextAmount))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 w-full px-design-12 pb-design-12 text-[#D9FFFF]">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
PANEL_CLASS,
|
||||||
|
'flex h-full min-h-0 w-full min-w-0 overflow-y-auto',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-full min-w-0 flex-[1.7] flex-col px-design-16 py-design-14">
|
||||||
|
<div className="flex flex-col gap-design-12">
|
||||||
|
<WithdrawField label="Jumlah Penarikan Berlian">
|
||||||
|
<AmountShell
|
||||||
|
amount={amount}
|
||||||
|
onMinus={() => handleAmountChange(amount - 1)}
|
||||||
|
onPlus={() => handleAmountChange(amount + 1)}
|
||||||
|
/>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField label="Jenis Mata Uang" alignStart={false}>
|
||||||
|
<Select
|
||||||
|
value={currency}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setCurrency(value as (typeof CURRENCY_OPTIONS)[number])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-design-52 w-full rounded-[calc(var(--design-unit)*6)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-16 text-left text-design-20 font-semibold text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-52 [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
|
||||||
|
aria-label="Currency selection"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select currency" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
position="popper"
|
||||||
|
className="min-w-(--radix-select-trigger-width) rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(8,36,48,0.98),rgba(4,18,28,0.98))] text-[#CFFDFF] shadow-[0_0_calc(var(--design-unit)*16)_rgba(56,241,255,0.12)]"
|
||||||
|
>
|
||||||
|
{CURRENCY_OPTIONS.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option}
|
||||||
|
value={option}
|
||||||
|
className="rounded-[calc(var(--design-unit)*4)] px-design-12 py-design-10 text-design-18 focus:bg-[rgba(53,154,171,0.2)] focus:text-white"
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<div className="flex gap-design-14">
|
||||||
|
<div className="w-design-108 shrink-0" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap gap-design-10">
|
||||||
|
{QUICK_AMOUNTS.map((option) => (
|
||||||
|
<QuickAmountCard
|
||||||
|
key={option.diamonds}
|
||||||
|
amount={option.diamonds}
|
||||||
|
preview={option.preview}
|
||||||
|
active={option.diamonds === amount}
|
||||||
|
onClick={() => handleAmountChange(option.diamonds)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WithdrawField label="Saluran Pembayaran">
|
||||||
|
<div className="flex flex-wrap gap-design-10">
|
||||||
|
{PAYMENT_CHANNELS.map((channel) => (
|
||||||
|
<PaymentCard
|
||||||
|
key={channel.id}
|
||||||
|
active={channel.id === paymentChannel}
|
||||||
|
label={channel.label}
|
||||||
|
glyph={channel.glyph}
|
||||||
|
onClick={() => setPaymentChannel(channel.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField label="Kode Bank">
|
||||||
|
<div className="flex flex-col gap-design-10">
|
||||||
|
<div className="flex h-design-40 items-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(12,61,72,0.78),rgba(6,28,39,0.88))] px-design-12 text-design-15 uppercase tracking-[0.02em] text-[#A4EAF2] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.07)]">
|
||||||
|
{`014${selectedBank?.label ?? 'BCA'} (${selectedBank?.subtitle ?? 'BANK CENTRAL ASIA'}): 014`}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-design-10">
|
||||||
|
{BANK_OPTIONS.map((option) => (
|
||||||
|
<BankCard
|
||||||
|
key={option.id}
|
||||||
|
active={option.id === bank}
|
||||||
|
brand={option.brand}
|
||||||
|
subtitle={option.label}
|
||||||
|
surface={option.surface}
|
||||||
|
onClick={() => setBank(option.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField label="Nama Pemegang Kartu">
|
||||||
|
<InputShell
|
||||||
|
value={holderName}
|
||||||
|
onChange={setHolderName}
|
||||||
|
placeholder="Mohon masukkan nama pemegang kartu."
|
||||||
|
error={holderNameError}
|
||||||
|
errorMessage="Mohon masukkan nama pemegang kartu."
|
||||||
|
/>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField label="Nomor Rekening Bank">
|
||||||
|
<InputShell
|
||||||
|
value={bankAccount}
|
||||||
|
onChange={setBankAccount}
|
||||||
|
placeholder="Silakan masukkan nomor rekening bank Anda."
|
||||||
|
error={bankAccountError}
|
||||||
|
errorMessage="Silakan masukkan nomor rekening bank Anda."
|
||||||
|
/>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField label="Email Penerima" alignStart={false}>
|
||||||
|
<InputShell
|
||||||
|
value={receiverEmail}
|
||||||
|
onChange={setReceiverEmail}
|
||||||
|
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
|
||||||
|
uppercase={true}
|
||||||
|
/>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField label="Nomor Ponsel Penerima" alignStart={false}>
|
||||||
|
<InputShell
|
||||||
|
value={receiverPhone}
|
||||||
|
onChange={setReceiverPhone}
|
||||||
|
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
|
||||||
|
uppercase={true}
|
||||||
|
/>
|
||||||
|
</WithdrawField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 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]">
|
||||||
|
Pratinjau Penukaran
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-design-12 px-design-10 py-design-10">
|
||||||
|
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
|
||||||
|
<PreviewRow label="Jumlah Berlian" value={formatNumber(amount)} />
|
||||||
|
<PreviewRow
|
||||||
|
label="Kurs (MYR)"
|
||||||
|
value={`${100 * MYR_PER_100_DIAMONDS} BERLIAN = 1 MYR`}
|
||||||
|
/>
|
||||||
|
<PreviewRow
|
||||||
|
label="Dapat Ditukarkan MYR"
|
||||||
|
value={`RM ${formatFixedTwo(withdrawMyr)}`}
|
||||||
|
highlight={true}
|
||||||
|
/>
|
||||||
|
<PreviewRow
|
||||||
|
label="Nilai Tukar USDT/MYR"
|
||||||
|
value={`1 USDT = RM ${USDT_TO_MYR_RATE}`}
|
||||||
|
/>
|
||||||
|
<PreviewRow
|
||||||
|
label="Nilai Tukar (VND)"
|
||||||
|
value={`${VND_PER_DIAMOND} BERLIAN = 1 VND`}
|
||||||
|
/>
|
||||||
|
<PreviewRow
|
||||||
|
label="Dapat Dikonversi ke VND"
|
||||||
|
value={`${formatNumber(withdrawVnd)} VND`}
|
||||||
|
highlight={true}
|
||||||
|
/>
|
||||||
|
<PreviewRow
|
||||||
|
label="Dapat Ditukarkan dengan USDT"
|
||||||
|
value={`${formatFixedSix(withdrawUsdt)} USDT`}
|
||||||
|
highlight={true}
|
||||||
|
/>
|
||||||
|
<PreviewRow
|
||||||
|
label="Jumlah Berlian Nilai Tukar Tetap"
|
||||||
|
value="0-0-0 0:0:0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-12 py-design-10 text-design-16 leading-[1.35] text-[#F0B44A]">
|
||||||
|
Nilai tukar berfungsi sebagai harga acuan; nilai tukar aktual yang
|
||||||
|
berlaku ditentukan pada saat penarikan.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
|
||||||
|
<div>
|
||||||
|
Dompet Elektronik:{' '}
|
||||||
|
<span className="text-[#B9F4F8]">Minimal RM10</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Bank: <span className="text-[#B9F4F8]">Minimal RM10</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Waktu Pengerjaan:{' '}
|
||||||
|
<span className="text-[#77FF76]">
|
||||||
|
Dana Tiba Hanya Dalam 9 Detik.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[#B9F4F8]">
|
||||||
|
Melihat: Transaksi antara RM10 dan RM99,99 akan dikenakan biaya
|
||||||
|
penarikan minimum sebesar RM1.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-end justify-between gap-design-10 pt-design-10">
|
||||||
|
<SmartBackground
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
src={lengthGreenBtn}
|
||||||
|
size="100% 100%"
|
||||||
|
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-18 font-bold uppercase tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Membatalkan
|
||||||
|
</SmartBackground>
|
||||||
|
<SmartBackground
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
src={lengthBlueBtn}
|
||||||
|
size="100% 100%"
|
||||||
|
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Konfirmasi
|
||||||
|
<br />
|
||||||
|
Penarikan
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DesktopWithdraw
|
export default DesktopWithdraw
|
||||||
|
|||||||
@@ -1,24 +1,3 @@
|
|||||||
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
|
|
||||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
|
||||||
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
|
||||||
|
|
||||||
export function MobileEntry() {
|
export function MobileEntry() {
|
||||||
return (
|
return <div>mobile component entry</div>
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={'mx-auto my-design-10 w-[calc(100%-24*var(--design-unit))]'}
|
|
||||||
>
|
|
||||||
<DesktopTitle />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'mx-auto flex w-[calc(100%-24*var(--design-unit))] flex-col gap-design-10'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DesktopGameHistory />
|
|
||||||
<DesktopAnimal />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import { DesktopControl } from '@/features/game/components/desktop/desktop-contr
|
|||||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
||||||
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
||||||
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
|
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
|
||||||
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
|
|
||||||
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
||||||
import DesktopRegisterModal from '../modal/desktop/desktop-register-modal'
|
|
||||||
|
|
||||||
export function PcEntry() {
|
export function PcEntry() {
|
||||||
return (
|
return (
|
||||||
@@ -49,9 +47,9 @@ export function PcEntry() {
|
|||||||
{/*公告弹窗*/}
|
{/*公告弹窗*/}
|
||||||
{/*<DesktopNoticeModal />*/}
|
{/*<DesktopNoticeModal />*/}
|
||||||
{/*自动托管弹窗*/}
|
{/*自动托管弹窗*/}
|
||||||
{/* <DesktopAutoSettingModal/>*/}
|
<DesktopAutoSettingModal />
|
||||||
{/* 充值提现前置选择弹窗*/}
|
{/* 充值提现前置选择弹窗*/}
|
||||||
<DesktopProceduresModal />
|
{/*<DesktopProceduresModal />*/}
|
||||||
{/* 充值和提现弹窗 */}
|
{/* 充值和提现弹窗 */}
|
||||||
{/*<DesktopWithdrawTopupModal/>*/}
|
{/*<DesktopWithdrawTopupModal/>*/}
|
||||||
</>
|
</>
|
||||||
|
|||||||
42
src/features/game/hooks/use-game-control-vm.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { CHIP_OPTIONS } from '@/constants'
|
||||||
|
import { selectSelectionTotal, useGameRoundStore } from '@/store/game'
|
||||||
|
|
||||||
|
const CHIP_IMAGE_MAP = new Map(
|
||||||
|
CHIP_OPTIONS.map((chip) => [chip.value, chip.src] as const),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function useGameControlVm() {
|
||||||
|
const chips = useGameRoundStore((state) => state.chips)
|
||||||
|
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||||
|
const selections = useGameRoundStore((state) => state.selections)
|
||||||
|
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||||
|
const selectChip = useGameRoundStore((state) => state.selectChip)
|
||||||
|
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||||
|
|
||||||
|
const chipItems = useMemo(
|
||||||
|
() =>
|
||||||
|
chips.map((chip) => ({
|
||||||
|
amount: chip.amount,
|
||||||
|
id: chip.id,
|
||||||
|
isSelected: chip.id === activeChipId,
|
||||||
|
src: CHIP_IMAGE_MAP.get(chip.amount) ?? CHIP_OPTIONS[0]?.src ?? '',
|
||||||
|
valueLabel: String(chip.amount),
|
||||||
|
})),
|
||||||
|
[activeChipId, chips],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedChip =
|
||||||
|
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
|
||||||
|
|
||||||
|
return {
|
||||||
|
canClear: selections.length > 0,
|
||||||
|
onChipSelect: selectChip,
|
||||||
|
onClearSelections: clearSelections,
|
||||||
|
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
|
||||||
|
selectedChipId: activeChipId,
|
||||||
|
selectedCountLabel: `${selections.length}/5`,
|
||||||
|
totalBetAmountLabel: String(totalBetAmount),
|
||||||
|
chips: chipItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/features/game/hooks/use-game-history-vm.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useGameRoundStore } from '@/store/game'
|
||||||
|
|
||||||
|
function formatSettledTime(iso: string) {
|
||||||
|
const date = new Date(iso)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
hour12: false,
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameHistoryVm() {
|
||||||
|
const history = useGameRoundStore((state) => state.history)
|
||||||
|
|
||||||
|
const items = useMemo(
|
||||||
|
() =>
|
||||||
|
history.map((entry) => ({
|
||||||
|
id: entry.roundId,
|
||||||
|
payoutMultiplierLabel: `${entry.payoutMultiplier}x`,
|
||||||
|
roundId: entry.roundId,
|
||||||
|
settledAtLabel: formatSettledTime(entry.settledAt),
|
||||||
|
statusLabel: 'settled',
|
||||||
|
totalPoolAmountLabel: entry.totalPoolAmount.toFixed(2),
|
||||||
|
winningCellIdLabel: String(entry.winningCellId),
|
||||||
|
})),
|
||||||
|
[history],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
emptyText: 'No history yet',
|
||||||
|
isEmpty: items.length === 0,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/features/game/hooks/use-game-status-vm.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { getRoundCountdownMs } from '@/features/game/shared/selectors'
|
||||||
|
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||||
|
|
||||||
|
const PHASE_META = {
|
||||||
|
betting: {
|
||||||
|
description: '(Menerima Taruhan)',
|
||||||
|
label: 'OPEN',
|
||||||
|
toneClassName: 'text-[#78FF7F]',
|
||||||
|
},
|
||||||
|
locked: {
|
||||||
|
description: '(Taruhan Ditutup)',
|
||||||
|
label: 'LOCKED',
|
||||||
|
toneClassName: 'text-[#FFE375]',
|
||||||
|
},
|
||||||
|
revealing: {
|
||||||
|
description: '(Mengundi Hasil)',
|
||||||
|
label: 'DRAWING',
|
||||||
|
toneClassName: 'text-[#57E8FF]',
|
||||||
|
},
|
||||||
|
settled: {
|
||||||
|
description: '(Putaran Selesai)',
|
||||||
|
label: 'SETTLED',
|
||||||
|
toneClassName: 'text-[#FF9C6B]',
|
||||||
|
},
|
||||||
|
waiting: {
|
||||||
|
description: '(Menunggu Putaran Berikutnya)',
|
||||||
|
label: 'WAITING',
|
||||||
|
toneClassName: 'text-[#A7B6C7]',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function useGameStatusVm() {
|
||||||
|
const cells = useGameRoundStore((state) => state.cells)
|
||||||
|
const round = useGameRoundStore((state) => state.round)
|
||||||
|
const trends = useGameRoundStore((state) => state.trends)
|
||||||
|
const dashboard = useGameSessionStore((state) => state.dashboard)
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const oddsValue = cells[0]?.odds ?? '--'
|
||||||
|
const featuredTrend = trends.find(
|
||||||
|
(entry) => entry.cellId === dashboard.featuredCellId,
|
||||||
|
)
|
||||||
|
const phaseMeta = PHASE_META[round.phase]
|
||||||
|
|
||||||
|
return {
|
||||||
|
acceptingBets: round.phase === 'betting',
|
||||||
|
countdownMs: getRoundCountdownMs(round),
|
||||||
|
limitLabel: `${dashboard.tableLimitMin}-${dashboard.tableLimitMax}`,
|
||||||
|
oddsLabel: `1:${oddsValue}`,
|
||||||
|
phase: round.phase,
|
||||||
|
phaseDescription: phaseMeta.description,
|
||||||
|
phaseLabel: phaseMeta.label,
|
||||||
|
phaseToneClassName: phaseMeta.toneClassName,
|
||||||
|
roundId: round.id,
|
||||||
|
streakLabel: featuredTrend ? `X${featuredTrend.currentStreak}` : '--',
|
||||||
|
}
|
||||||
|
}, [cells, dashboard, round, trends])
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
import { Switch } from '@/components/ui/switch.tsx'
|
import { Switch } from '@/components/ui/switch.tsx'
|
||||||
|
|
||||||
const AUTO_STOP_ROWS = [
|
const AUTO_STOP_ROWS = [
|
||||||
@@ -17,6 +18,7 @@ const AUTO_STOP_ROWS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Stop on any Jackpot',
|
label: 'Stop on any Jackpot',
|
||||||
|
// value: '50000',
|
||||||
checked: false,
|
checked: false,
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
@@ -66,7 +68,7 @@ function DesktopAutoSettingModal() {
|
|||||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
defaultValue={row.value}
|
defaultValue={row.value}
|
||||||
className={
|
className={
|
||||||
'game-setting-input h-full w-design-280 text-design-18'
|
'game-setting-input h-full w-design-280 text-design-18'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import rightImg from '@/assets/system/right.webp'
|
|||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
|
|
||||||
function DesktopLoginModal() {
|
function DesktopLoginModal() {
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
@@ -39,7 +40,7 @@ function DesktopLoginModal() {
|
|||||||
>
|
>
|
||||||
Akun/TEL:
|
Akun/TEL:
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
className={'flex-1 text-left'}
|
className={'flex-1 text-left'}
|
||||||
placeholder={'Silakan masukkan akun atau nomor ponsel Anda.'}
|
placeholder={'Silakan masukkan akun atau nomor ponsel Anda.'}
|
||||||
/>
|
/>
|
||||||
@@ -52,7 +53,7 @@ function DesktopLoginModal() {
|
|||||||
>
|
>
|
||||||
Kata Sandi:
|
Kata Sandi:
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
className={'flex-1 text-left'}
|
className={'flex-1 text-left'}
|
||||||
placeholder={'Masukkan Kata Sandi'}
|
placeholder={'Masukkan Kata Sandi'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import rightImg from '@/assets/system/right.webp'
|
|||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
|
|
||||||
function DesktopRegisterModal() {
|
function DesktopRegisterModal() {
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
@@ -37,7 +38,7 @@ function DesktopRegisterModal() {
|
|||||||
>
|
>
|
||||||
Akun/TEL:
|
Akun/TEL:
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
className={'flex-1 text-left'}
|
className={'flex-1 text-left'}
|
||||||
placeholder={'Silakan masukkan akun atau nomor ponsel Anda.'}
|
placeholder={'Silakan masukkan akun atau nomor ponsel Anda.'}
|
||||||
/>
|
/>
|
||||||
@@ -50,7 +51,7 @@ function DesktopRegisterModal() {
|
|||||||
>
|
>
|
||||||
Kata Sandi:
|
Kata Sandi:
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
className={'flex-1 text-left'}
|
className={'flex-1 text-left'}
|
||||||
placeholder={'Masukkan Kata Sandi'}
|
placeholder={'Masukkan Kata Sandi'}
|
||||||
/>
|
/>
|
||||||
@@ -63,7 +64,7 @@ function DesktopRegisterModal() {
|
|||||||
>
|
>
|
||||||
Kata Sandi:
|
Kata Sandi:
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
className={'flex-1 text-left'}
|
className={'flex-1 text-left'}
|
||||||
placeholder={'Masukkan Kata Sandi'}
|
placeholder={'Masukkan Kata Sandi'}
|
||||||
/>
|
/>
|
||||||
@@ -76,7 +77,7 @@ function DesktopRegisterModal() {
|
|||||||
>
|
>
|
||||||
Kata Sandi:
|
Kata Sandi:
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
className={'flex-1 text-left'}
|
className={'flex-1 text-left'}
|
||||||
placeholder={'Masukkan Kata Sandi'}
|
placeholder={'Masukkan Kata Sandi'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type WithdrawType = 'withdraw' | 'topup'
|
|||||||
|
|
||||||
function DesktopWithdrawTopupModal() {
|
function DesktopWithdrawTopupModal() {
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
const [type, setType] = useState<WithdrawType>('withdraw')
|
const [type] = useState<WithdrawType>('withdraw')
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
@@ -18,15 +18,16 @@ function DesktopWithdrawTopupModal() {
|
|||||||
onClose={handleSubmit}
|
onClose={handleSubmit}
|
||||||
title={
|
title={
|
||||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||||
{type}
|
{type === 'withdraw' ? '申请提现' : '申请充值'}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
isShowClose={false}
|
|
||||||
isNormalBg={true}
|
isNormalBg={true}
|
||||||
titleAlign="left"
|
titleAlign="left"
|
||||||
className={'w-design-835 h-design-500'}
|
className={'w-design-1200 h-design-700'}
|
||||||
>
|
>
|
||||||
<div>{type ? <DesktopWithdraw /> : <DesktopTopup />}</div>
|
<div className={'w-full h-[96%]'}>
|
||||||
|
{type === 'withdraw' ? <DesktopWithdraw /> : <DesktopTopup />}
|
||||||
|
</div>
|
||||||
</CenterModal>
|
</CenterModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ export const BET_SOURCES = ['local', 'server'] as const
|
|||||||
|
|
||||||
export const TREND_DIRECTIONS = ['rising', 'steady', 'falling'] as const
|
export const TREND_DIRECTIONS = ['rising', 'steady', 'falling'] as const
|
||||||
|
|
||||||
export const DEFAULT_GAME_CHIP_AMOUNTS = [10, 25, 50, 100, 200, 500] as const
|
|
||||||
|
|
||||||
export const DEFAULT_GAME_CHIP_COLORS = [
|
export const DEFAULT_GAME_CHIP_COLORS = [
|
||||||
'#1D4ED8',
|
'#1D4ED8',
|
||||||
'#0F766E',
|
'#0F766E',
|
||||||
@@ -55,7 +53,7 @@ export const DEFAULT_GAME_CHIP_COLORS = [
|
|||||||
'#111827',
|
'#111827',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-50'
|
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-5'
|
||||||
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
|
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
|
||||||
export const GAME_RECENT_HISTORY_LIMIT = 12
|
export const GAME_RECENT_HISTORY_LIMIT = 12
|
||||||
export const GAME_BOARD_COLUMNS = GAME_GRID_COLUMNS
|
export const GAME_BOARD_COLUMNS = GAME_GRID_COLUMNS
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { CHIP_OPTIONS } from '@/constants'
|
||||||
import {
|
import {
|
||||||
DEFAULT_ACTIVE_CHIP_ID,
|
DEFAULT_ACTIVE_CHIP_ID,
|
||||||
DEFAULT_ANNOUNCEMENT_TTL_MS,
|
DEFAULT_ANNOUNCEMENT_TTL_MS,
|
||||||
DEFAULT_GAME_CHIP_AMOUNTS,
|
|
||||||
DEFAULT_GAME_CHIP_COLORS,
|
DEFAULT_GAME_CHIP_COLORS,
|
||||||
GAME_GRID_COLUMNS,
|
GAME_GRID_COLUMNS,
|
||||||
GAME_TOTAL_CELLS,
|
GAME_TOTAL_CELLS,
|
||||||
@@ -41,12 +41,12 @@ export function createGameCells() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultChips() {
|
export function createDefaultChips() {
|
||||||
return DEFAULT_GAME_CHIP_AMOUNTS.map((amount, index) => ({
|
return CHIP_OPTIONS.map((chip, index) => ({
|
||||||
amount,
|
amount: chip.value,
|
||||||
color: DEFAULT_GAME_CHIP_COLORS[index],
|
color: DEFAULT_GAME_CHIP_COLORS[index],
|
||||||
id: `chip-${amount}`,
|
id: chip.id,
|
||||||
isDefault: `chip-${amount}` === DEFAULT_ACTIVE_CHIP_ID,
|
isDefault: chip.id === DEFAULT_ACTIVE_CHIP_ID,
|
||||||
label: amount >= 100 ? `${amount / 100}x` : String(amount),
|
label: chip.value >= 100 ? `${chip.value / 100}x` : String(chip.value),
|
||||||
})) satisfies Chip[]
|
})) satisfies Chip[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,25 +190,6 @@
|
|||||||
linear-gradient(180deg, #07111f 0%, #040812 100%);
|
linear-gradient(180deg, #07111f 0%, #040812 100%);
|
||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
|
||||||
@apply border border-transparent bg-[#135E65]/60 text-[#D9FFFF] py-design-15 px-design-30 text-design-20 rounded-md outline-none transition;
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder {
|
|
||||||
color: rgba(116, 173, 175, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus,
|
|
||||||
input:focus-visible {
|
|
||||||
border-color: rgba(110, 255, 255, 0.72);
|
|
||||||
outline: none;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 calc(var(--design-unit) * 1.5) rgba(110, 255, 255, 0.16),
|
|
||||||
0 0 calc(var(--design-unit) * 8) rgba(48, 214, 255, 0.36),
|
|
||||||
0 0 calc(var(--design-unit) * 18) rgba(18, 162, 255, 0.22),
|
|
||||||
inset 0 0 calc(var(--design-unit) * 6) rgba(110, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|||||||