Compare commits
8 Commits
c74bd5f701
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
827f369eb6 | ||
|
|
508b28df22 | ||
|
|
5ef2e09b04 | ||
|
|
9b3eae1896 | ||
|
|
912cd01383 | ||
|
|
8307cc3f88 | ||
| 406ebb7671 | |||
| 295cfb97cd |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/.playwright-cli/
|
||||||
|
/output/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
|||||||
148
design-qa.md
Normal file
148
design-qa.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# 玩家端 PC 桌面化 Design QA
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|
||||||
|
## 基准与实现证据
|
||||||
|
|
||||||
|
- 视觉基准(方案 3):`/Users/wchino/.codex/generated_images/019f8441-2864-75d2-a2a9-69bc25a3bc7a/exec-3297a599-f1a3-40a2-8845-e4d38b19bc52.png`
|
||||||
|
- 1728×907 最终实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/final-1728x907-populated.jpg`
|
||||||
|
- 同画布左右对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/design-comparison-option3-vs-implementation.jpg`
|
||||||
|
- 1024×768 最终实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/final-1024x768.jpg`
|
||||||
|
- 390×844 移动回归:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/full-390x844.jpg`
|
||||||
|
- 480×844 移动回归:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/viewport-480x844-full.jpg`
|
||||||
|
- 浏览器批注修正后 1567×899:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/followup-audit/hall-after-1567x899.jpg`
|
||||||
|
|
||||||
|
## 验收状态
|
||||||
|
|
||||||
|
- 桌面基准状态:已登录、信用模式、开放期号;大厅使用 4D、四条有效草稿、一家开注商,用于检验工具区、表格密度和复核栏。
|
||||||
|
- 1024×768:复核摘要位于表格下方,宽度 743px;表格宽度 741px。页面 `scrollWidth === clientWidth === 1024`,横向滚动仅在表格内部。
|
||||||
|
- 1280×800、1440×900:复核摘要位于表格下方;1500px 以上复核栏位于表格右侧,避免中等桌面宽度压缩输入区。页面无全局横向滚动。
|
||||||
|
- 390×844、480×844:继续使用原移动布局和底部提交栏;页面无全局横向滚动。
|
||||||
|
- 主导航、玩法切换、号码和金额输入、预览、提交、注单展开、信用流水筛选均可操作。
|
||||||
|
- Chrome 控制台没有新增 error;只有开发模式 HMR、TokenRefresh 和 React DevTools 提示。
|
||||||
|
|
||||||
|
## 真实数据闭环
|
||||||
|
|
||||||
|
- 测试订单号:`TO20260721111116994982`
|
||||||
|
- 票号:`TK20260721111116970261`
|
||||||
|
- 期号:`20260721-1039`
|
||||||
|
- 测试内容:号码 `3147`、玩法 `大`、Singapore、`NPR 1.00`
|
||||||
|
- 我的注单:总记录从 8 增至 9,订单立即出现并可展开查看票号、玩法、号码、开注商和金额。
|
||||||
|
- 信用流水:新增“下注占用 −NPR 1.00”;提交时可用信用从 `NPR 95,891.00` 变为 `NPR 95,890.00`,已用信用从 `NPR 3,098.00` 变为 `NPR 3,099.00`,与授信 `NPR 98,989.00` 一致。
|
||||||
|
|
||||||
|
## 差异检查与迭代
|
||||||
|
|
||||||
|
1. 初版将三步工具区和复核区按方案 3 重组,保留真实表格列、开注商与提交预览。
|
||||||
|
2. 1024px 首轮发现状态卡金额和第三步操作被压缩,调整状态区为 16rem 信用卡、隐藏窄屏次要授信摘要,并收窄玩法按钮。
|
||||||
|
3. 复查 1024px 后期号、倒计时和信用金额完整显示,第三步操作不再裁切,复核区在表格后保持全宽。
|
||||||
|
4. 1728px 同画布对比确认层级、三步工具区、投注台账、右侧复核栏、主操作位置与方案 3 一致;保留现有产品的真实列和业务文案。
|
||||||
|
5. 最终 P0:0,P1:0,P2:0。
|
||||||
|
|
||||||
|
## 浏览器批注复查
|
||||||
|
|
||||||
|
- 大厅汇总条由单行滚动表改为自适应网格:1567、1440、1280、1024、480、390px 均为 `scrollWidth === clientWidth`;本轮进一步扩展为稳定的全玩法换行网格,移动端不显示横向滚动条。
|
||||||
|
- 投注表格收窄桌面列宽并调整复核栏断点:1567、1440、1280px 表格内部无横向滚动;1024px 与 480/390px 只在投注表格内部保留必要横向滚动,页面本身无横向滚动。
|
||||||
|
- 桌面复核区的可用信用、下注后可用和预计扣款已移除 `NPR` 前缀;“提交前仍会显示最终预览”提示已移除。
|
||||||
|
- 开奖结果、我的注单、信用页复查后未发现新的 P0/P1/P2;规则页空数据状态改为有界居中提示,减少空白拉伸感。
|
||||||
|
- 规则页 1567×899:`scrollWidth === clientWidth === 1567`;Chrome 控制台无新增 error。
|
||||||
|
|
||||||
|
## 浏览器批注复查 2
|
||||||
|
|
||||||
|
- 源视觉真值:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/followup-audit/hall-after-1567x899.jpg`,对应本轮三个浏览器批注前的 1567×899 大厅状态。
|
||||||
|
- 实现截图:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-2/hall-after-open-1567x899.jpg`;状态为已登录、开放期号、4D、20 条空白行。
|
||||||
|
- 全视图同画布对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-2/comparison-before-after-open.jpg`。
|
||||||
|
- 汇总区聚焦对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-2/comparison-summary-focused.jpg`;密集表头和金额状态在原始尺寸下可读,因此无需更多局部裁切。
|
||||||
|
- 非零高亮证据:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-2/hall-highlight-1567x899.jpg`。
|
||||||
|
- 移动换行证据:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-2/hall-after-390x844.jpg`;31 个汇总单元固定六列换行,汇总区 `scrollWidth === clientWidth === 354`,页面 `scrollWidth === clientWidth === 390`。
|
||||||
|
|
||||||
|
### 比较历史与修复
|
||||||
|
|
||||||
|
1. P1:汇总区只显示当前玩法列,切换 4D/3D/2D 时内容变化。修复为稳定的 D4、D3、D2 全玩法集合;实测三个 Tab 的 31 个单元内容和顺序完全一致。
|
||||||
|
2. P1:列同步复选框仅切换状态,关闭时不会清列。修复为开启后把该列输入同步到所有号码有效的行,关闭后清空该列;实测两行号码的 4C 金额同步为 `7`,取消后两行 4C 均为空。
|
||||||
|
3. P1:批量清空会把 20 行缩减为 1 行。修复为只清号码和金额,保留行 ID、开注商和选择类型;实测清空前后均为 20 行,号码和金额为空,20 行默认开注商选择保留。
|
||||||
|
4. P2:零值与非零值缺少视觉区分。零值使用灰色表头/数值,非零值使用蓝色表头与浅蓝底;实测 `4A 8.00` 与总计高亮,其他 `0.00` 保持灰态。
|
||||||
|
|
||||||
|
### 忠实度检查
|
||||||
|
|
||||||
|
- 字体与排版:沿用现有字体、字号和表格字重;长标签在单元格内保持清晰,不截断。
|
||||||
|
- 间距与布局:1567px 使用两行自适应汇总网格,390px 使用六列换行;无新增全局或汇总区横向滚动。
|
||||||
|
- 颜色与状态:灰态和蓝色高亮继续使用现有 slate/品牌蓝色 token,金额状态对比明确。
|
||||||
|
- 图片与资产:本轮没有新增或替换图片、Logo、图标或装饰资产。
|
||||||
|
- 文案与内容:保留全部现有玩法名称、金额格式和业务文案,只调整显示集合与交互行为。
|
||||||
|
- Chrome 控制台无 error;本轮最终 P0:0,P1:0,P2:0,`final result` 保持 `passed`。
|
||||||
|
|
||||||
|
## 浏览器批注复查 3
|
||||||
|
|
||||||
|
- 源视觉真值:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-3/hall-before-1221x899.jpg`,对应汇总区仍位于三步工具栏下方的 1221px 大厅状态。
|
||||||
|
- 桌面实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-3/hall-after-desktop-final-1221x899-cdp.jpg`;视口 1221×899、已登录、开放期号、4D。
|
||||||
|
- 移动实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-3/hall-after-mobile-final-390x844-cdp.jpg`;视口 390×844。
|
||||||
|
- 全视图同画布对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-3/comparison-before-after-1221.jpg`。
|
||||||
|
- 汇总位置聚焦对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-3/comparison-summary-position-focused.jpg`。
|
||||||
|
|
||||||
|
### 比较历史与修复
|
||||||
|
|
||||||
|
1. P1:汇总区位于三步工具栏和投注表格之间,不符合“位于选择玩法上方”的信息顺序。修复为桌面端在三步工具栏之前渲染汇总区。
|
||||||
|
2. P2:第一次移动后,移动端快捷填单位于汇总区和玩法 Tab 之间,汇总区与玩法 Tab 相距 84px。修复为移动端按“快捷填单、汇总区、玩法 Tab”排列,并统一使用 12px 垂直间距。
|
||||||
|
3. Post-fix:1221px 下汇总区到三步工具栏为 12px,工具栏到投注表格为 13px;390px 下快捷填单到汇总区、汇总区到玩法 Tab 均为 12px。
|
||||||
|
4. Post-fix:1221px 页面 `scrollWidth === clientWidth === 1221`;390px 页面 `scrollWidth === clientWidth === 390`,汇总区 `scrollWidth === clientWidth === 341`。
|
||||||
|
|
||||||
|
### 忠实度检查
|
||||||
|
|
||||||
|
- 字体与排版:玩法标签、金额字号、字重和换行规则保持不变。
|
||||||
|
- 间距与布局:只改变汇总模块顺序并统一相邻模块 12px 节奏,没有改变状态区、工具栏或表格尺寸。
|
||||||
|
- 颜色与状态:零值灰态和非零蓝色高亮保持不变。
|
||||||
|
- 图片与资产:没有新增或替换 Logo、图标及图片资产。
|
||||||
|
- 文案与内容:全部玩法名称、金额、快捷填单和工具栏文案保持不变。
|
||||||
|
- Chrome 控制台无 error;本轮最终 P0:0,P1:0,P2:0,`final result` 保持 `passed`。
|
||||||
|
|
||||||
|
## 浏览器批注复查 4
|
||||||
|
|
||||||
|
- 源视觉真值:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-4/hall-before-390x844.png`,对应移动端快捷填单、汇总区和玩法 Tab 尚未形成独立上下间距的状态。
|
||||||
|
- 移动实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-4/hall-after-390x844.png`;视口 390×844、已登录、开放期号、4D。
|
||||||
|
- 桌面回归:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-4/hall-after-1221x899.png`;视口 1221×899。
|
||||||
|
- 全视图同画布对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-4/mobile-before-after.png`。
|
||||||
|
- 标注区域聚焦对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-4/mobile-before-after-focus.png`;快捷填单、玩法 Tab 与相邻模块的边界在原始尺寸下清晰可读,无需继续裁切。
|
||||||
|
|
||||||
|
### 比较历史与修复
|
||||||
|
|
||||||
|
1. P2:移动端快捷填单、汇总区和玩法 Tab 被放在同一垂直组内,快捷填单区域没有形成用户标注所需的独立上、下边界。修复为把全玩法汇总区移出快捷填单组,移动端顺序调整为“汇总区、快捷填单、玩法 Tab、投注表格”。
|
||||||
|
2. Post-fix:390px 下汇总区到快捷填单组为 12px、快捷填单到玩法 Tab 为 12px、玩法组到投注表格为 12px;页面 `scrollWidth === clientWidth === 390`。
|
||||||
|
3. Post-fix:1221px 页面 `scrollWidth === clientWidth === 1221`,桌面汇总区、三步工具栏和投注表格结构不变。
|
||||||
|
|
||||||
|
### 忠实度检查
|
||||||
|
|
||||||
|
- 字体与排版:快捷填单、玩法 Tab、汇总金额和表格字号、字重均未改变。
|
||||||
|
- 间距与布局:只拆分移动端模块分组并统一 12px 垂直节奏,标注区域上下都有可见留白。
|
||||||
|
- 颜色与状态:灰态汇总、品牌蓝和表格状态色保持不变。
|
||||||
|
- 图片与资产:没有新增或替换 Logo、钱包背景、图标及图片资产。
|
||||||
|
- 文案与内容:玩法、金额、快捷填单和业务文案保持不变。
|
||||||
|
- Chrome 控制台无 error;本轮最终 P0:0,P1:0,P2:0,`final result` 保持 `passed`。
|
||||||
|
|
||||||
|
## 浏览器批注复查 5
|
||||||
|
|
||||||
|
- 源视觉真值:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-5/hall-before-390x844.png`,对应移动端期号状态卡和可用信用卡直接相贴的状态。
|
||||||
|
- 移动实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-5/hall-after-390x844.png`;视口 390×844、已登录、开放期号、4D。
|
||||||
|
- 桌面回归:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-5/hall-after-1221x899.png`;视口 1221×899。
|
||||||
|
- 全视图同画布对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-5/mobile-before-after.png`。
|
||||||
|
- 顶部模块聚焦对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-5/mobile-before-after-focus.png`;状态卡和信用卡的边界与留白在原始尺寸下清晰可读。
|
||||||
|
|
||||||
|
### 比较历史与修复
|
||||||
|
|
||||||
|
1. P2:移动端期号状态卡与可用信用卡之间为 0px,两个独立信息模块视觉上粘连。修复为移动端容器使用 12px 垂直间距,并在桌面断点归零以继续使用并排网格。
|
||||||
|
2. Post-fix:390px 下状态卡到信用卡为 12px,信用卡到全玩法汇总区为 12px;页面 `scrollWidth === clientWidth === 390`。
|
||||||
|
3. Post-fix:1221px 下状态卡和信用卡顶部差为 0px,继续保持并排等高布局;页面 `scrollWidth === clientWidth === 1221`。
|
||||||
|
|
||||||
|
### 忠实度检查
|
||||||
|
|
||||||
|
- 字体与排版:期号、时间、倒计时、信用金额及辅助文案均未改变。
|
||||||
|
- 间距与布局:只增加移动端两个顶部模块之间的 12px 间距,桌面网格保持原位。
|
||||||
|
- 颜色与状态:状态色、红色信用卡和页面背景 token 保持不变。
|
||||||
|
- 图片与资产:钱包背景、Logo 和现有图标均未新增或替换。
|
||||||
|
- 文案与内容:期号状态、信用金额和导航文案保持不变。
|
||||||
|
- Chrome 控制台无 error;本轮最终 P0:0,P1:0,P2:0,`final result` 保持 `passed`。
|
||||||
|
|
||||||
|
## 工程验证
|
||||||
|
|
||||||
|
- `npm run lint`:passed
|
||||||
|
- `npm run build`:passed
|
||||||
|
- `git diff --check`:passed
|
||||||
698
package-lock.json
generated
698
package-lock.json
generated
@@ -9,17 +9,19 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
"axios": "^1.16.0",
|
"@fontsource-variable/noto-sans-devanagari": "^5.3.0",
|
||||||
|
"axios": "^1.18.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.4.7",
|
"dompurify": "^3.4.12",
|
||||||
|
"geist": "^1.7.2",
|
||||||
"i18next": "^26.1.0",
|
"i18next": "^26.1.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"i18next-http-backend": "^4.0.0",
|
"i18next-http-backend": "^4.0.0",
|
||||||
"laravel-echo": "^2.3.4",
|
"laravel-echo": "^2.4.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pusher-js": "^8.5.0",
|
"pusher-js": "^8.5.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@@ -34,12 +36,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/dompurify": "^3.2.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.6",
|
"eslint-config-next": "16.2.10",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -59,13 +62,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"js-tokens": "^4.0.0",
|
"js-tokens": "^4.0.0",
|
||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
@@ -74,9 +77,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.29.3",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||||
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
|
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -84,21 +87,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-compilation-targets": "^7.28.6",
|
"@babel/helper-compilation-targets": "^7.29.7",
|
||||||
"@babel/helper-module-transforms": "^7.28.6",
|
"@babel/helper-module-transforms": "^7.29.7",
|
||||||
"@babel/helpers": "^7.28.6",
|
"@babel/helpers": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/traverse": "^7.29.0",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
@@ -115,14 +118,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.29.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
@@ -145,14 +148,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-compilation-targets": {
|
"node_modules/@babel/helper-compilation-targets": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/compat-data": "^7.28.6",
|
"@babel/compat-data": "^7.29.7",
|
||||||
"@babel/helper-validator-option": "^7.27.1",
|
"@babel/helper-validator-option": "^7.29.7",
|
||||||
"browserslist": "^4.24.0",
|
"browserslist": "^4.24.0",
|
||||||
"lru-cache": "^5.1.1",
|
"lru-cache": "^5.1.1",
|
||||||
"semver": "^6.3.1"
|
"semver": "^6.3.1"
|
||||||
@@ -184,9 +187,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-globals": {
|
"node_modules/@babel/helper-globals": {
|
||||||
"version": "7.28.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -208,29 +211,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-imports": {
|
"node_modules/@babel/helper-module-imports": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/traverse": "^7.28.6",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-transforms": {
|
"node_modules/@babel/helper-module-transforms": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.28.6",
|
"@babel/helper-module-imports": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"@babel/traverse": "^7.28.6"
|
"@babel/traverse": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -295,9 +298,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -305,9 +308,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -315,9 +318,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-option": {
|
"node_modules/@babel/helper-validator-option": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -325,27 +328,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.3",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -453,33 +456,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/parser": "^7.28.6",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-globals": "^7.28.0",
|
"@babel/helper-globals": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -487,14 +490,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -994,6 +997,15 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/noto-sans-devanagari": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-devanagari/-/noto-sans-devanagari-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-52XBcJS3jRvZ5ugFXJvdKgffXPYnN08LeodXkgCbepOdMKVRVTQu1yj071cq8S3zmUwT6w6XKKXRSQlQkwwNNg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.14",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
@@ -1828,15 +1840,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/env/-/env-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
|
||||||
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
|
"integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz",
|
||||||
"integrity": "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==",
|
"integrity": "sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1844,9 +1856,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
|
||||||
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
|
"integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1860,9 +1872,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
|
||||||
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
|
"integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1876,9 +1888,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
|
||||||
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
|
"integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1895,9 +1907,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
|
||||||
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
|
"integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1914,9 +1926,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
|
||||||
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
|
"integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1933,9 +1945,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
|
||||||
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
|
"integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1952,9 +1964,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
|
||||||
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
|
"integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1968,9 +1980,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
|
||||||
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
|
"integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2125,13 +2137,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -2436,29 +2441,6 @@
|
|||||||
"path-browserify": "^1.0.1"
|
"path-browserify": "^1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ts-morph/common/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.6",
|
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ts-morph/common/node_modules/fast-glob": {
|
"node_modules/@ts-morph/common/node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -2516,6 +2498,17 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dompurify": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
|
||||||
|
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -2786,29 +2779,6 @@
|
|||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.5",
|
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "10.2.5",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz",
|
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
@@ -3588,16 +3558,42 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.16.0",
|
"version": "1.18.1",
|
||||||
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
|
||||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.16.0",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
|
"https-proxy-agent": "^5.0.1",
|
||||||
"proxy-from-env": "^2.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios/node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/axios/node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -3619,11 +3615,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.27",
|
"version": "2.10.27",
|
||||||
@@ -3638,21 +3637,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.2.2",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^2.0.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.1",
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.7.2",
|
||||||
"on-finished": "^2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"qs": "^6.14.1",
|
"qs": "^6.15.2",
|
||||||
"raw-body": "^3.0.1",
|
"raw-body": "^3.0.2",
|
||||||
"type-is": "^2.0.1"
|
"type-is": "^2.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -3662,15 +3661,31 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/body-parser/node_modules/content-type": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.14",
|
"version": "5.0.7",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^4.0.2"
|
||||||
"concat-map": "0.0.1"
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
@@ -4019,7 +4034,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -4425,9 +4440,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.7",
|
"version": "3.4.12",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/dompurify/-/dompurify-3.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||||
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
|
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
@@ -4508,30 +4524,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/engine.io-client": {
|
|
||||||
"version": "6.6.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
|
||||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1",
|
|
||||||
"engine.io-parser": "~5.2.1",
|
|
||||||
"ws": "~8.18.3",
|
|
||||||
"xmlhttprequest-ssl": "~2.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-parser": {
|
|
||||||
"version": "5.2.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
|
||||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.21.1",
|
"version": "5.21.1",
|
||||||
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.1.tgz",
|
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.1.tgz",
|
||||||
@@ -4830,13 +4822,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-config-next": {
|
"node_modules/eslint-config-next": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/eslint-config-next/-/eslint-config-next-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz",
|
||||||
"integrity": "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==",
|
"integrity": "sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "16.2.6",
|
"@next/eslint-plugin-next": "16.2.10",
|
||||||
"eslint-import-resolver-node": "^0.3.6",
|
"eslint-import-resolver-node": "^0.3.6",
|
||||||
"eslint-import-resolver-typescript": "^3.5.2",
|
"eslint-import-resolver-typescript": "^3.5.2",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
@@ -5348,7 +5340,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||||
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
|
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -5365,7 +5357,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/fast-glob/node_modules/glob-parent": {
|
"node_modules/fast-glob/node_modules/glob-parent": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -5607,16 +5599,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.4",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -5717,6 +5709,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/geist": {
|
||||||
|
"version": "1.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/geist/-/geist-1.7.2.tgz",
|
||||||
|
"integrity": "sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg==",
|
||||||
|
"license": "SIL OPEN FONT LICENSE",
|
||||||
|
"peerDependencies": {
|
||||||
|
"next": ">=13.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/generator-function": {
|
"node_modules/generator-function": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/generator-function/-/generator-function-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/generator-function/-/generator-function-2.0.1.tgz",
|
||||||
@@ -6010,9 +6011,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -6050,9 +6051,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.18",
|
"version": "4.12.31",
|
||||||
"resolved": "https://registry.npmmirror.com/hono/-/hono-4.12.18.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
|
||||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6903,10 +6904,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -7046,9 +7057,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/laravel-echo": {
|
"node_modules/laravel-echo": {
|
||||||
"version": "2.3.4",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmmirror.com/laravel-echo/-/laravel-echo-2.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz",
|
||||||
"integrity": "sha512-rpALCIK1uw2SrttcK9P5JzItt5I85RcfXQKUNnkcorzhtKeXi5GS0PVFFBH8ppNo8wnbdBKuD1EtIHgTbXo9FQ==",
|
"integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -7056,6 +7067,14 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pusher-js": "*",
|
"pusher-js": "*",
|
||||||
"socket.io-client": "*"
|
"socket.io-client": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pusher-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"socket.io-client": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
@@ -7433,7 +7452,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -7580,6 +7599,24 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimatch/node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/minimatch/node_modules/brace-expansion": {
|
||||||
|
"version": "1.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0",
|
||||||
|
"concat-map": "0.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimist": {
|
"node_modules/minimist": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
|
||||||
@@ -7666,9 +7703,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -7717,12 +7754,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/next/-/next-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
|
||||||
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
|
"integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.2.6",
|
"@next/env": "16.2.10",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
@@ -7736,14 +7773,14 @@
|
|||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.2.6",
|
"@next/swc-darwin-arm64": "16.2.10",
|
||||||
"@next/swc-darwin-x64": "16.2.6",
|
"@next/swc-darwin-x64": "16.2.10",
|
||||||
"@next/swc-linux-arm64-gnu": "16.2.6",
|
"@next/swc-linux-arm64-gnu": "16.2.10",
|
||||||
"@next/swc-linux-arm64-musl": "16.2.6",
|
"@next/swc-linux-arm64-musl": "16.2.10",
|
||||||
"@next/swc-linux-x64-gnu": "16.2.6",
|
"@next/swc-linux-x64-gnu": "16.2.10",
|
||||||
"@next/swc-linux-x64-musl": "16.2.6",
|
"@next/swc-linux-x64-musl": "16.2.10",
|
||||||
"@next/swc-win32-arm64-msvc": "16.2.6",
|
"@next/swc-win32-arm64-msvc": "16.2.10",
|
||||||
"@next/swc-win32-x64-msvc": "16.2.6",
|
"@next/swc-win32-x64-msvc": "16.2.10",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -7779,34 +7816,6 @@
|
|||||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
|
||||||
"version": "8.4.31",
|
|
||||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz",
|
|
||||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/postcss/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"nanoid": "^3.3.6",
|
|
||||||
"picocolors": "^1.0.0",
|
|
||||||
"source-map-js": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10 || ^12 || >=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
@@ -8344,10 +8353,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.21",
|
||||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
|
||||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
"integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -8364,7 +8372,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.16",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -8504,13 +8512,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"es-define-property": "^1.0.1",
|
||||||
|
"side-channel": "^1.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -9270,15 +9279,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3",
|
"object-inspect": "^1.13.4",
|
||||||
"side-channel-list": "^1.0.0",
|
"side-channel-list": "^1.0.1",
|
||||||
"side-channel-map": "^1.0.1",
|
"side-channel-map": "^1.0.1",
|
||||||
"side-channel-weakmap": "^1.0.2"
|
"side-channel-weakmap": "^1.0.2"
|
||||||
},
|
},
|
||||||
@@ -9365,36 +9374,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-client": {
|
|
||||||
"version": "4.8.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
|
||||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1",
|
|
||||||
"engine.io-client": "~6.6.1",
|
|
||||||
"socket.io-parser": "~4.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser": {
|
|
||||||
"version": "4.2.6",
|
|
||||||
"resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
|
||||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sonner": {
|
"node_modules/sonner": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz",
|
"resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz",
|
||||||
@@ -10001,18 +9980,36 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "2.0.1",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^2.0.0",
|
||||||
"media-typer": "^1.1.0",
|
"media-typer": "^1.1.0",
|
||||||
"mime-types": "^3.0.0"
|
"mime-types": "^3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is/node_modules/content-type": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/type-is/node_modules/mime-db": {
|
"node_modules/type-is/node_modules/mime-db": {
|
||||||
@@ -10543,28 +10540,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
|
||||||
"version": "8.18.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.3.tgz",
|
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wsl-utils": {
|
"node_modules/wsl-utils": {
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
||||||
@@ -10582,15 +10557,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xmlhttprequest-ssl": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
|
||||||
@@ -10603,7 +10569,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
|||||||
21
package.json
21
package.json
@@ -10,17 +10,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
"axios": "^1.16.0",
|
"@fontsource-variable/noto-sans-devanagari": "^5.3.0",
|
||||||
|
"axios": "^1.18.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.4.7",
|
"dompurify": "^3.4.12",
|
||||||
|
"geist": "^1.7.2",
|
||||||
"i18next": "^26.1.0",
|
"i18next": "^26.1.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"i18next-http-backend": "^4.0.0",
|
"i18next-http-backend": "^4.0.0",
|
||||||
"laravel-echo": "^2.3.4",
|
"laravel-echo": "^2.4.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pusher-js": "^8.5.0",
|
"pusher-js": "^8.5.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@@ -41,9 +43,18 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.6",
|
"eslint-config-next": "16.2.10",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@babel/core": "^7.29.1",
|
||||||
|
"body-parser": "^2.3.0",
|
||||||
|
"form-data": "^4.0.6",
|
||||||
|
"hono": "^4.12.25",
|
||||||
|
"js-yaml": "^4.3.0",
|
||||||
|
"postcss": "^8.5.10",
|
||||||
|
"qs": "^6.15.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export type PlayerAuthLoginData = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlayerPasswordUpdatePayload = {
|
||||||
|
current_password: string;
|
||||||
|
password: string;
|
||||||
|
password_confirmation: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** `GET /api/v1/player/auth/captcha`(公开) */
|
/** `GET /api/v1/player/auth/captcha`(公开) */
|
||||||
export function getPlayerAuthCaptcha(): Promise<PlayerAuthCaptchaResponse> {
|
export function getPlayerAuthCaptcha(): Promise<PlayerAuthCaptchaResponse> {
|
||||||
return lotteryRequest.get<PlayerAuthCaptchaResponse>(`/player/auth/captcha`, {
|
return lotteryRequest.get<PlayerAuthCaptchaResponse>(`/player/auth/captcha`, {
|
||||||
@@ -42,3 +48,10 @@ export function postPlayerAuthLogin(body: PlayerAuthLoginPayload): Promise<Playe
|
|||||||
...body,
|
...body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `PUT /api/v1/player/password`(仅彩票端账号密码玩家) */
|
||||||
|
export function putPlayerPassword(
|
||||||
|
body: PlayerPasswordUpdatePayload,
|
||||||
|
): Promise<{ password_changed: boolean }> {
|
||||||
|
return lotteryRequest.put<{ password_changed: boolean }>(`/player/password`, body);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type GetTicketItemsParams = {
|
|||||||
page?: number;
|
page?: number;
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
draw_no?: string;
|
draw_no?: string;
|
||||||
|
ticket_no?: string;
|
||||||
order_no?: string;
|
order_no?: string;
|
||||||
number?: string;
|
number?: string;
|
||||||
status?: string[];
|
status?: string[];
|
||||||
@@ -27,6 +28,7 @@ export function getTicketItems(
|
|||||||
page: params?.page,
|
page: params?.page,
|
||||||
per_page: params?.per_page,
|
per_page: params?.per_page,
|
||||||
draw_no: params?.draw_no,
|
draw_no: params?.draw_no,
|
||||||
|
ticket_no: params?.ticket_no,
|
||||||
order_no: params?.order_no,
|
order_no: params?.order_no,
|
||||||
number: params?.number,
|
number: params?.number,
|
||||||
status: params?.status,
|
status: params?.status,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { PlayerAppShell } from "@/components/layout/player-app-shell";
|
import { PlayerAppShell } from "@/components/layout/player-app-shell";
|
||||||
|
import { PlayEffectiveWsListener } from "@/components/play-effective-ws-listener";
|
||||||
|
import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener";
|
||||||
|
import { TokenSilentRefresh } from "@/components/token-silent-refresh";
|
||||||
|
import { PlayerAuthGate } from "@/features/player/player-auth-gate";
|
||||||
import { HydratePlayerAuth } from "@/features/player/hydrate-player-auth";
|
import { HydratePlayerAuth } from "@/features/player/hydrate-player-auth";
|
||||||
|
|
||||||
export default function PlayerMainLayout({
|
export default function PlayerMainLayout({
|
||||||
@@ -7,9 +11,12 @@ export default function PlayerMainLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<PlayerAuthGate>
|
||||||
<HydratePlayerAuth />
|
<HydratePlayerAuth />
|
||||||
|
<PlayerBalanceWsListener />
|
||||||
|
<PlayEffectiveWsListener />
|
||||||
|
<TokenSilentRefresh />
|
||||||
<PlayerAppShell>{children}</PlayerAppShell>
|
<PlayerAppShell>{children}</PlayerAppShell>
|
||||||
</>
|
</PlayerAuthGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-noto-sans-devanagari: "Noto Sans Devanagari Variable";
|
||||||
--player-viewport-max: 480px;
|
--player-viewport-max: 480px;
|
||||||
--player-sidebar-width: 0px;
|
--player-sidebar-width: 0px;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
@@ -165,10 +166,34 @@
|
|||||||
--sidebar-border: #e4ebf5;
|
--sidebar-border: #e4ebf5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 玩家端桌面壳:侧栏宽度供遮罩/弹层定位 */
|
/* 玩家端桌面壳:侧栏宽度供遮罩/弹层定位(含外侧留白) */
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.player-desktop-shell {
|
.player-desktop-shell {
|
||||||
--player-sidebar-width: 14rem;
|
--player-sidebar-width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-pc-page {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-pc-empty {
|
||||||
|
min-height: 18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-pc-table-head {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-summary-grid {
|
||||||
|
grid-template-columns: repeat(var(--hall-summary-columns), minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1500px) {
|
||||||
|
.hall-summary-grid {
|
||||||
|
grid-template-columns: repeat(var(--hall-summary-columns-wide), minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,3 +345,19 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-strip-sweep {
|
||||||
|
0%,
|
||||||
|
20% {
|
||||||
|
transform: translateX(0) rotate(12deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(500%) rotate(12deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,25 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono, Noto_Sans_Devanagari } from "next/font/google";
|
import { GeistSans } from "geist/font/sans";
|
||||||
|
import { GeistMono } from "geist/font/mono";
|
||||||
|
import "@fontsource-variable/noto-sans-devanagari";
|
||||||
|
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
import { DEFAULT_LANGUAGE } from "@/i18n/language";
|
import { DEFAULT_LANGUAGE } from "@/i18n/language";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const notoSansDevanagari = Noto_Sans_Devanagari({
|
|
||||||
variable: "--font-noto-sans-devanagari",
|
|
||||||
subsets: ["devanagari", "latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Lottery",
|
title: "Lottery",
|
||||||
description: "Lottery player",
|
description: "Lottery player",
|
||||||
@@ -40,7 +27,7 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang={DEFAULT_LANGUAGE}
|
lang={DEFAULT_LANGUAGE}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={`${geistSans.variable} ${geistMono.variable} ${notoSansDevanagari.variable} min-h-full antialiased`}
|
className={`${GeistSans.variable} ${GeistMono.variable} min-h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full">
|
<body className="min-h-full">
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
|
|||||||
@@ -10,17 +10,29 @@ import {
|
|||||||
import { syncPreferredLanguage } from "@/i18n";
|
import { syncPreferredLanguage } from "@/i18n";
|
||||||
|
|
||||||
const I18nHydrationContext = createContext(false);
|
const I18nHydrationContext = createContext(false);
|
||||||
|
const hydrationListeners = new Set<() => void>();
|
||||||
|
let hydrationReady = false;
|
||||||
|
let hydrationSyncPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function ensurePreferredLanguageSynced(): void {
|
||||||
|
if (hydrationSyncPromise) return;
|
||||||
|
|
||||||
|
hydrationSyncPromise = syncPreferredLanguage()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => {
|
||||||
|
hydrationReady = true;
|
||||||
|
hydrationListeners.forEach((listener) => listener());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function subscribeI18nHydration(onStoreChange: () => void): () => void {
|
function subscribeI18nHydration(onStoreChange: () => void): () => void {
|
||||||
queueMicrotask(() => {
|
hydrationListeners.add(onStoreChange);
|
||||||
syncPreferredLanguage();
|
ensurePreferredLanguageSynced();
|
||||||
onStoreChange();
|
return () => hydrationListeners.delete(onStoreChange);
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getI18nHydratedSnapshot(): boolean {
|
function getI18nHydratedSnapshot(): boolean {
|
||||||
return true;
|
return hydrationReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getI18nHydratedServerSnapshot(): boolean {
|
function getI18nHydratedServerSnapshot(): boolean {
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
import { useEffect, useCallback, type ReactNode } from "react";
|
import { useEffect, useCallback, type ReactNode } from "react";
|
||||||
|
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import { setPlayerBearerToken } from "@/lib/lottery-auth";
|
|
||||||
import {
|
import {
|
||||||
isIframeOriginAllowed,
|
|
||||||
loadIframeAllowedOrigins,
|
loadIframeAllowedOrigins,
|
||||||
|
messageToken,
|
||||||
resolvePostMessageTargetOrigin,
|
resolvePostMessageTargetOrigin,
|
||||||
|
resolveTrustedParentMessage,
|
||||||
} from "@/lib/iframe-origins";
|
} from "@/lib/iframe-origins";
|
||||||
|
import { publishIframeTokenRefresh } from "@/lib/iframe-token-refresh-events";
|
||||||
|
|
||||||
function sanitizeUrlForParent(href: string): string {
|
function sanitizeUrlForParent(href: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -123,39 +124,38 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
console.log("[IframeBridge] Setting up iframe communication");
|
console.log("[IframeBridge] Setting up iframe communication");
|
||||||
|
|
||||||
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
||||||
if (!isIframeOriginAllowed(event.origin)) {
|
const data = await resolveTrustedParentMessage(event);
|
||||||
await loadIframeAllowedOrigins(true);
|
if (data === null) {
|
||||||
if (!isIframeOriginAllowed(event.origin)) {
|
console.warn("[IframeBridge] Rejected untrusted parent message from:", event.origin);
|
||||||
console.warn("[IframeBridge] Rejected message from:", event.origin);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = event;
|
|
||||||
if (!data || typeof data !== "object") return;
|
|
||||||
|
|
||||||
console.log("[IframeBridge] Received message:", data.type);
|
console.log("[IframeBridge] Received message:", data.type);
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
// 主站发送初始化 Token
|
// 主站发送初始化 Token
|
||||||
case "MAIN_INIT_TOKEN":
|
case "MAIN_INIT_TOKEN": {
|
||||||
if (data.token) {
|
const token = messageToken(data);
|
||||||
|
if (token !== null) {
|
||||||
console.log("[IframeBridge] Received initial token");
|
console.log("[IframeBridge] Received initial token");
|
||||||
setBearerToken(data.token);
|
setBearerToken(token);
|
||||||
setPlayerBearerToken(data.token);
|
|
||||||
// 勿再 notifyReady(),否则主站会重复 MAIN_INIT_TOKEN 导致消息刷屏
|
// 勿再 notifyReady(),否则主站会重复 MAIN_INIT_TOKEN 导致消息刷屏
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// 主站刷新 Token
|
// 主站刷新 Token
|
||||||
case "MAIN_REFRESH_TOKEN":
|
case "MAIN_REFRESH_TOKEN":
|
||||||
if (data.token) {
|
case "LOTTERY_TOKEN_REFRESH_RESPONSE": {
|
||||||
|
const token = messageToken(data);
|
||||||
|
if (token !== null) {
|
||||||
console.log("[IframeBridge] Received refreshed token");
|
console.log("[IframeBridge] Received refreshed token");
|
||||||
setBearerToken(data.token);
|
setBearerToken(token);
|
||||||
setPlayerBearerToken(data.token);
|
publishIframeTokenRefresh(token);
|
||||||
notifyTokenRefreshed();
|
notifyTokenRefreshed();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// 主站通知 Token 即将过期
|
// 主站通知 Token 即将过期
|
||||||
case "MAIN_TOKEN_EXPIRING":
|
case "MAIN_TOKEN_EXPIRING":
|
||||||
@@ -173,7 +173,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
|
|
||||||
// 主站导航请求
|
// 主站导航请求
|
||||||
case "MAIN_NAVIGATE":
|
case "MAIN_NAVIGATE":
|
||||||
if (data.path && typeof data.path === "string") {
|
if (typeof data.path === "string") {
|
||||||
const nextPath = resolveSafeInAppPath(data.path);
|
const nextPath = resolveSafeInAppPath(data.path);
|
||||||
if (nextPath) {
|
if (nextPath) {
|
||||||
window.history.pushState({}, "", nextPath);
|
window.history.pushState({}, "", nextPath);
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ type PlayerAppShellProps = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 玩家端外壳:
|
* 玩家端外壳:
|
||||||
* - 移动:主体 + 底部 Tab(H5)
|
* - 移动:顶栏 + 主体 + 底部 Tab
|
||||||
* - 桌面:侧栏 + 顶栏 + 宽主内容区(UI 设计稿)
|
* - 桌面:全宽顶栏(Logo 整页居中)+ 侧栏 + 主内容
|
||||||
*/
|
*/
|
||||||
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -33,32 +33,35 @@ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 overflow-hidden bg-white text-foreground lg:flex-row">
|
<div className="player-desktop-shell flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-white text-foreground lg:bg-[#f0f2f6]">
|
||||||
<PlayerSidebar />
|
<PlayerDesktopHeader />
|
||||||
|
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden lg:flex-row lg:gap-0 lg:px-0">
|
||||||
{isMobile ? (
|
<PlayerSidebar />
|
||||||
<PullToRefreshIndicator
|
|
||||||
pullDistance={pullDistance}
|
|
||||||
isRefreshing={isRefreshing}
|
|
||||||
threshold={70}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<NetworkStatusBanner />
|
|
||||||
<PlayerDesktopHeader />
|
|
||||||
|
|
||||||
<main
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:py-4 lg:pr-4">
|
||||||
id="player-scroll-container"
|
{isMobile ? (
|
||||||
className={cn(
|
<PullToRefreshIndicator
|
||||||
"flex min-h-0 w-full flex-1 flex-col overflow-y-auto overscroll-y-contain bg-[#f8fafc] lg:bg-[#f0f2f6]",
|
pullDistance={pullDistance}
|
||||||
playerMainInset,
|
isRefreshing={isRefreshing}
|
||||||
)}
|
threshold={70}
|
||||||
>
|
/>
|
||||||
{children}
|
) : null}
|
||||||
</main>
|
<NetworkStatusBanner />
|
||||||
|
|
||||||
<div className="relative z-50 w-full shrink-0 lg:hidden">
|
<main
|
||||||
<PlayerBottomNav />
|
id="player-scroll-container"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 w-full flex-1 flex-col overflow-y-auto overscroll-y-contain bg-[#f8fafc] lg:rounded-2xl lg:border lg:border-[#e4ebf5] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.04)]",
|
||||||
|
playerMainInset,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="relative z-50 w-full shrink-0 lg:hidden">
|
||||||
|
<PlayerBottomNav />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const tabs = [
|
|||||||
labelKey: "nav.wallet",
|
labelKey: "nav.wallet",
|
||||||
creditLabelKey: "nav.credit",
|
creditLabelKey: "nav.credit",
|
||||||
labelDefault: "钱包",
|
labelDefault: "钱包",
|
||||||
creditLabelDefault: "信用",
|
creditLabelDefault: "额度",
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,39 +4,58 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { PlayerAccountDialog } from "@/features/player/player-account-dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PlayerDesktopHeaderProps = {
|
type PlayerDesktopHeaderProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 全局顶栏:移动显示 Logo;桌面 Logo 在侧栏顶部,此处仅语言切换 */
|
/** 全局顶栏:移动 Logo 左对齐;桌面 Logo 整页水平居中,语言切换靠右 */
|
||||||
export function PlayerDesktopHeader({ className }: PlayerDesktopHeaderProps) {
|
export function PlayerDesktopHeader({ className }: PlayerDesktopHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-12 shrink-0 items-center justify-between border-b border-[#e4ebf5] bg-white px-3 lg:h-14 lg:justify-end lg:px-6",
|
"relative z-20 flex h-12 shrink-0 items-center justify-between border-b border-[#e4ebf5] bg-white px-3 lg:h-14 lg:px-6",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* 移动:左 Logo */}
|
||||||
<Link href="/hall" className="inline-flex min-w-0 items-center lg:hidden" aria-label="N lotto">
|
<Link href="/hall" className="inline-flex min-w-0 items-center lg:hidden" aria-label="N lotto">
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="N lotto"
|
alt="N lotto"
|
||||||
width={243}
|
width={243}
|
||||||
height={84}
|
height={84}
|
||||||
className="h-7 w-auto max-w-[160px] object-contain object-left"
|
className="h-7 w-auto max-w-[112px] object-contain object-left sm:max-w-[160px]"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
{/* 桌面:整页水平居中 Logo */}
|
||||||
|
<Link
|
||||||
|
href="/hall"
|
||||||
|
className="pointer-events-auto absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 lg:inline-flex"
|
||||||
|
aria-label="N lotto"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="N lotto"
|
||||||
|
width={243}
|
||||||
|
height={84}
|
||||||
|
className="h-8 w-auto max-w-[180px] object-contain"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
<LanguageSwitcher
|
<LanguageSwitcher
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
menuAlign="end"
|
menuAlign="end"
|
||||||
showFlag={false}
|
showFlag={false}
|
||||||
className="rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:text-xs lg:[&_button]:h-9 lg:[&_button]:gap-1.5 lg:[&_button]:px-3 lg:[&_button]:text-sm"
|
className="rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:text-xs lg:[&_button]:h-9 lg:[&_button]:gap-1.5 lg:[&_button]:px-3 lg:[&_button]:text-sm"
|
||||||
/>
|
/>
|
||||||
|
<PlayerAccountDialog />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
157
src/components/layout/player-list-pagination.tsx
Normal file
157
src/components/layout/player-list-pagination.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** 页码切片:首尾 + 当前邻页,中间用省略号 */
|
||||||
|
export function playerListPageSlices(
|
||||||
|
current: number,
|
||||||
|
last: number,
|
||||||
|
delta = 1,
|
||||||
|
): (number | "ellipsis")[] {
|
||||||
|
if (last <= 1) {
|
||||||
|
return last === 1 ? [1] : [];
|
||||||
|
}
|
||||||
|
const cap = delta * 2 + 3;
|
||||||
|
if (last <= cap) {
|
||||||
|
return Array.from({ length: last }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
const set = new Set<number>([1, last]);
|
||||||
|
for (let p = current - delta; p <= current + delta; p++) {
|
||||||
|
if (p >= 1 && p <= last) set.add(p);
|
||||||
|
}
|
||||||
|
const sorted = [...set].sort((a, b) => a - b);
|
||||||
|
const out: (number | "ellipsis")[] = [];
|
||||||
|
let prev = 0;
|
||||||
|
for (const p of sorted) {
|
||||||
|
if (prev && p - prev > 1) out.push("ellipsis");
|
||||||
|
out.push(p);
|
||||||
|
prev = p;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerListPaginationProps = {
|
||||||
|
page: number;
|
||||||
|
lastPage: number;
|
||||||
|
total?: number;
|
||||||
|
loading?: boolean;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
className?: string;
|
||||||
|
/** 隐藏「共 x 条」摘要 */
|
||||||
|
hideSummary?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** PC 列表底部分页(移动端仍用无限滚动,勿挂载本组件) */
|
||||||
|
export function PlayerListPagination({
|
||||||
|
page,
|
||||||
|
lastPage,
|
||||||
|
total,
|
||||||
|
loading = false,
|
||||||
|
onPageChange,
|
||||||
|
className,
|
||||||
|
hideSummary = false,
|
||||||
|
}: PlayerListPaginationProps) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
|
if (lastPage <= 1 && (total == null || total <= 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"hidden items-center justify-between gap-3 border-t border-[#e9eff8] bg-[#fbfdff] px-4 py-3 lg:flex",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!hideSummary ? (
|
||||||
|
<p className="text-xs font-semibold tabular-nums text-[#59739f]">
|
||||||
|
{total != null
|
||||||
|
? t("pagination.summary", {
|
||||||
|
defaultValue: "共 {{total}} 条,第 {{page}} / {{lastPage}} 页",
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
lastPage: Math.max(1, lastPage),
|
||||||
|
})
|
||||||
|
: t("pagination.pageOf", {
|
||||||
|
defaultValue: "第 {{page}} / {{lastPage}} 页",
|
||||||
|
page,
|
||||||
|
lastPage: Math.max(1, lastPage),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lastPage > 1 ? (
|
||||||
|
<Pagination className="mx-0 w-auto justify-end">
|
||||||
|
<PaginationContent className="flex-wrap justify-end">
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
type="button"
|
||||||
|
text={t("pagination.previous", { defaultValue: "上一页" })}
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
className="h-8 rounded-lg border border-[#dce7f7] bg-white text-[#32518d] hover:bg-[#f8fbff]"
|
||||||
|
onClick={() => {
|
||||||
|
if (page <= 1 || loading) return;
|
||||||
|
onPageChange(Math.max(1, page - 1));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
{playerListPageSlices(page, lastPage).map((item, idx) =>
|
||||||
|
item === "ellipsis" ? (
|
||||||
|
<PaginationItem key={`e-${idx}`}>
|
||||||
|
<PaginationEllipsis className="text-[#7890b8]" />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem key={item}>
|
||||||
|
<PaginationLink
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
isActive={item === page}
|
||||||
|
disabled={loading}
|
||||||
|
className={cn(
|
||||||
|
"min-w-8 tabular-nums",
|
||||||
|
item === page
|
||||||
|
? "border-[#0b3f96] bg-white font-black text-[#0b3f96]"
|
||||||
|
: "text-[#32518d] hover:bg-[#f8fbff]",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (loading || item === page) return;
|
||||||
|
onPageChange(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
type="button"
|
||||||
|
text={t("pagination.next", { defaultValue: "下一页" })}
|
||||||
|
disabled={page >= lastPage || loading}
|
||||||
|
className="h-8 rounded-lg border border-[#dce7f7] bg-white text-[#32518d] hover:bg-[#f8fbff]"
|
||||||
|
onClick={() => {
|
||||||
|
if (page >= lastPage || loading) return;
|
||||||
|
onPageChange(Math.min(lastPage, page + 1));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export function PlayerPanel({
|
|||||||
<div className={cn(playerContentColumnClass, containerClassName)}>
|
<div className={cn(playerContentColumnClass, containerClassName)}>
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-white text-slate-900 lg:bg-transparent",
|
"player-pc-page bg-white text-slate-900 lg:bg-transparent",
|
||||||
playerPageShellPadding,
|
playerPageShellPadding,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ const navItems = [
|
|||||||
labelKey: "nav.wallet",
|
labelKey: "nav.wallet",
|
||||||
creditLabelKey: "nav.credit",
|
creditLabelKey: "nav.credit",
|
||||||
labelDefault: "钱包",
|
labelDefault: "钱包",
|
||||||
creditLabelDefault: "信用",
|
creditLabelDefault: "额度",
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
||||||
},
|
},
|
||||||
@@ -57,14 +56,13 @@ const navItems = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 与 lotteryadmin `NAV_BTN` / `NAV_ACTIVE` 一致 */
|
|
||||||
const playerNavLinkClass =
|
const playerNavLinkClass =
|
||||||
"flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-[13px] leading-snug font-normal text-sidebar-foreground/90 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0";
|
"flex h-10 w-full items-center gap-2 rounded-xl px-3 text-sm leading-none font-medium text-sidebar-foreground/90 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0";
|
||||||
|
|
||||||
const playerNavLinkActiveClass =
|
const playerNavLinkActiveClass =
|
||||||
"bg-sidebar-primary/90 font-medium text-sidebar-primary-foreground shadow-sm hover:bg-sidebar-primary/90 hover:text-sidebar-primary-foreground";
|
"bg-sidebar-primary/90 font-semibold text-sidebar-primary-foreground shadow-sm hover:bg-sidebar-primary/90 hover:text-sidebar-primary-foreground";
|
||||||
|
|
||||||
/** 桌面端左侧导航(激活态对齐后台侧栏) */
|
/** 桌面端左侧导航:与内容区同高的圆角面板,不贴边 */
|
||||||
export function PlayerSidebar() {
|
export function PlayerSidebar() {
|
||||||
const pathname = usePathname() ?? "";
|
const pathname = usePathname() ?? "";
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
@@ -72,23 +70,10 @@ export function PlayerSidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="player-sidebar hidden h-full w-56 shrink-0 flex-col border-r border-[#e4ebf5] bg-white lg:flex"
|
className="player-sidebar hidden w-[13.5rem] shrink-0 flex-col py-4 pl-4 pr-0 lg:flex"
|
||||||
aria-label={t("nav.aria")}
|
aria-label={t("nav.aria")}
|
||||||
>
|
>
|
||||||
<div className="shrink-0 border-b border-[#e4ebf5] px-2 py-2">
|
<nav className="flex h-full min-h-0 flex-col gap-1 overflow-y-auto rounded-2xl border border-[#e4ebf5] bg-white p-2 shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
|
||||||
<Link href="/hall" className="flex h-10 items-center px-1" aria-label="N lotto">
|
|
||||||
<Image
|
|
||||||
src="/logo.png"
|
|
||||||
alt="N lotto"
|
|
||||||
width={243}
|
|
||||||
height={84}
|
|
||||||
className="h-auto max-h-10 w-full object-contain object-left"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1.5 py-1.5">
|
|
||||||
{navItems.map(({ href, labelKey, labelDefault, icon: Icon, match, ...item }) => {
|
{navItems.map(({ href, labelKey, labelDefault, icon: Icon, match, ...item }) => {
|
||||||
const active = match(pathname);
|
const active = match(pathname);
|
||||||
const creditLabelKey = "creditLabelKey" in item ? item.creditLabelKey : undefined;
|
const creditLabelKey = "creditLabelKey" in item ? item.creditLabelKey : undefined;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAmount, formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PlayerMoneyDisplayProps = {
|
type PlayerMoneyDisplayProps = {
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
showCurrencyCode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 玩家端金额展示:自适应字号 + 防大额截断 */
|
/** 玩家端金额展示:自适应字号 + 防大额截断 */
|
||||||
@@ -14,6 +15,7 @@ export function PlayerMoneyDisplay({
|
|||||||
amountMinor,
|
amountMinor,
|
||||||
currency,
|
currency,
|
||||||
className,
|
className,
|
||||||
|
showCurrencyCode = true,
|
||||||
}: PlayerMoneyDisplayProps) {
|
}: PlayerMoneyDisplayProps) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
@@ -23,7 +25,9 @@ export function PlayerMoneyDisplay({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatMinorAsCurrency(amountMinor, currency)}
|
{showCurrencyCode
|
||||||
|
? formatMinorAsCurrency(amountMinor, currency)
|
||||||
|
: formatMinorAmount(amountMinor)}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import { I18nHydrationProvider } from "@/components/i18n-hydration-provider";
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { ErrorProvider } from "@/components/error-provider";
|
import { ErrorProvider } from "@/components/error-provider";
|
||||||
import { IframeBridge } from "@/components/iframe-bridge";
|
import { IframeBridge } from "@/components/iframe-bridge";
|
||||||
import { PlayEffectiveWsListener } from "@/components/play-effective-ws-listener";
|
|
||||||
import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener";
|
|
||||||
import { TokenSilentRefresh } from "@/components/token-silent-refresh";
|
|
||||||
import "@/i18n";
|
import "@/i18n";
|
||||||
|
|
||||||
type ProvidersProps = {
|
type ProvidersProps = {
|
||||||
@@ -21,13 +18,7 @@ export function Providers({ children }: ProvidersProps): ReactNode {
|
|||||||
<I18nHydrationProvider>
|
<I18nHydrationProvider>
|
||||||
<ErrorProvider>
|
<ErrorProvider>
|
||||||
{/* iframe 通信桥接 - 支持主站嵌入 */}
|
{/* iframe 通信桥接 - 支持主站嵌入 */}
|
||||||
<IframeBridge>
|
<IframeBridge>{children}</IframeBridge>
|
||||||
{children}
|
|
||||||
<PlayerBalanceWsListener />
|
|
||||||
<PlayEffectiveWsListener />
|
|
||||||
{/* Token 静默续签(无 UI) */}
|
|
||||||
<TokenSilentRefresh />
|
|
||||||
</IframeBridge>
|
|
||||||
</ErrorProvider>
|
</ErrorProvider>
|
||||||
</I18nHydrationProvider>
|
</I18nHydrationProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
238
src/components/ui/field.tsx
Normal file
238
src/components/ui/field.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||||
|
horizontal:
|
||||||
|
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
responsive:
|
||||||
|
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1",
|
||||||
|
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-sm font-normal text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
127
src/components/ui/pagination.tsx
Normal file
127
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex items-center gap-0.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
Omit<React.ComponentProps<typeof Button>, "size" | "variant">;
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={isActive ? "outline" : "ghost"}
|
||||||
|
size={size}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive || undefined}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
text = "Previous",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 pl-2!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon data-icon="inline-start" />
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
text = "Next",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 pr-2!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
<ChevronRightIcon data-icon="inline-end" />
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
};
|
||||||
@@ -257,10 +257,9 @@ export function HallBetPreviewDialog({
|
|||||||
</td>
|
</td>
|
||||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
||||||
<span className="block font-mono text-base font-black text-slate-950">{ln.number}</span>
|
<span className="block font-mono text-base font-black text-slate-950">{ln.number}</span>
|
||||||
<span className="text-[11px] font-bold text-[#0755c7]">{playLabel(ln.play_code, t)}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold text-slate-700">
|
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold text-[#0755c7]">
|
||||||
{ln.play_code}
|
{playLabel(ln.play_code, t)}
|
||||||
</td>
|
</td>
|
||||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
||||||
<span className="inline-flex rounded-full bg-[#eef5ff] px-2 py-0.5 font-black text-[#0b56b7]">
|
<span className="inline-flex rounded-full bg-[#eef5ff] px-2 py-0.5 font-black text-[#0b56b7]">
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ export function isValidRollNumber(value: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DraftLineIssueReason = "invalid_number_length" | "roll_requires_r" | "missing_digit_slot";
|
export type DraftLineIssueReason =
|
||||||
|
| "invalid_number_length"
|
||||||
|
| "roll_requires_r"
|
||||||
|
| "missing_digit_slot"
|
||||||
|
| "full_cover_amount_not_divisible";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 某玩法列已填金额但无法组成合法注单行时返回原因;合法则返回 null。
|
* 某玩法列已填金额但无法组成合法注单行时返回原因;合法则返回 null。
|
||||||
|
|||||||
183
src/features/hall/hall-betting-grid-model.ts
Normal file
183
src/features/hall/hall-betting-grid-model.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { draftLineIssueReason } from "@/features/hall/hall-bet-rules";
|
||||||
|
import type { SelectionType } from "@/features/hall/selection-type";
|
||||||
|
import { playLabel } from "@/lib/play-labels";
|
||||||
|
import type { BetProviderRow } from "@/types/api/bet-provider";
|
||||||
|
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
||||||
|
import type { PlayEffectivePlayRow } from "@/types/api/play-effective";
|
||||||
|
|
||||||
|
export type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
|
||||||
|
export type PlayHallCategory = Exclude<HallCategory, "JACKPOT">;
|
||||||
|
|
||||||
|
export type DraftRow = {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
amounts: Record<string, string>;
|
||||||
|
providerCodes: string[];
|
||||||
|
selectionType: SelectionType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayColumn = {
|
||||||
|
key: string;
|
||||||
|
play: PlayEffectivePlayRow;
|
||||||
|
digitSlot?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HallTranslate = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
export type CellRiskState = "open" | "warning" | "sold_out";
|
||||||
|
|
||||||
|
export const FALLBACK_BET_PROVIDERS: BetProviderRow[] = [
|
||||||
|
{ code: "SG", name: "Singapore", short_code: "S", sort_order: 10, is_default: true },
|
||||||
|
{ code: "MY", name: "Malaysia", short_code: "M", sort_order: 20, is_default: false },
|
||||||
|
{ code: "TH", name: "Thailand", short_code: "T", sort_order: 30, is_default: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROVIDER_COLUMN_TONES = [
|
||||||
|
"bg-[#fff875] text-slate-950",
|
||||||
|
"bg-[#b7d9ff] text-slate-950",
|
||||||
|
"bg-[#ffc7ca] text-slate-950",
|
||||||
|
"bg-[#d9d8ff] text-slate-950",
|
||||||
|
"bg-[#a8f8a4] text-slate-950",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function providerColumnTone(index: number): string {
|
||||||
|
return PROVIDER_COLUMN_TONES[index % PROVIDER_COLUMN_TONES.length] ?? "bg-slate-100 text-slate-950";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playCategory(playCode: string): PlayHallCategory {
|
||||||
|
if (playCode.startsWith("pos_3")) return "D3";
|
||||||
|
if (playCode.startsWith("pos_2")) return "D2";
|
||||||
|
return "D4";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function digitSlotOptions(category: PlayHallCategory): number[] {
|
||||||
|
if (category === "D2") return [2, 3];
|
||||||
|
if (category === "D3") return [1, 2, 3];
|
||||||
|
return [0, 1, 2, 3];
|
||||||
|
}
|
||||||
|
|
||||||
|
function digitSlotLabel(
|
||||||
|
category: PlayHallCategory,
|
||||||
|
slot: number,
|
||||||
|
t: HallTranslate,
|
||||||
|
): string {
|
||||||
|
if (!digitSlotOptions(category).includes(slot)) return String(slot + 1);
|
||||||
|
|
||||||
|
return t(`hall.table.digitPosition.${slot}`, {
|
||||||
|
defaultValue: String(10 ** (3 - slot)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playColumnHeaderLabel(
|
||||||
|
play: PlayEffectivePlayRow,
|
||||||
|
category: PlayHallCategory,
|
||||||
|
digitSlot: number | undefined,
|
||||||
|
t: HallTranslate,
|
||||||
|
): string {
|
||||||
|
if (digitSlot !== undefined) {
|
||||||
|
const kind = play.play_code === "digit_big" ? "big" : "small";
|
||||||
|
return `${t(`hall.table.digitShort.${kind}`)}·${digitSlotLabel(category, digitSlot, t)}`;
|
||||||
|
}
|
||||||
|
return playLabel(play.play_code, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberMaxCharsForCategory(category: PlayHallCategory): number {
|
||||||
|
return category === "D2" ? 2 : category === "D3" ? 3 : 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeNumber(raw: string, category: PlayHallCategory): string {
|
||||||
|
const normalized =
|
||||||
|
category === "D4"
|
||||||
|
? raw.replace(/[^0-9Rr]/g, "").toUpperCase()
|
||||||
|
: raw.replace(/\D/g, "");
|
||||||
|
const maxChars = numberMaxCharsForCategory(category);
|
||||||
|
|
||||||
|
return category === "D4" ? normalized.slice(0, maxChars) : normalized.slice(-maxChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedDigits(value: string): string {
|
||||||
|
return value.split("").sort().join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRiskAlert(
|
||||||
|
alertNumber: string,
|
||||||
|
playCode: string,
|
||||||
|
rowNumber: string,
|
||||||
|
category: PlayHallCategory,
|
||||||
|
digitSlot?: number,
|
||||||
|
): boolean {
|
||||||
|
const normalizedRow = rowNumber.toUpperCase();
|
||||||
|
|
||||||
|
if (playCode === "big" || playCode === "small" || playCode === "straight") {
|
||||||
|
return alertNumber === normalizedRow.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playCode === "box" || playCode === "ibox" || playCode === "mbox") {
|
||||||
|
return sortedDigits(alertNumber) === sortedDigits(normalizedRow.slice(0, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playCode === "roll") {
|
||||||
|
const regex = new RegExp(`^${normalizedRow.replace(/R/g, "[0-9]")}$`);
|
||||||
|
return regex.test(alertNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playCode.startsWith("pos_4")) return alertNumber === normalizedRow.slice(0, 4);
|
||||||
|
if (playCode.startsWith("pos_3")) return alertNumber.endsWith(normalizedRow.slice(-3));
|
||||||
|
if (playCode.startsWith("pos_2")) return alertNumber.endsWith(normalizedRow.slice(-2));
|
||||||
|
|
||||||
|
if (playCode === "head") {
|
||||||
|
return ["5", "6", "7", "8", "9"].includes(alertNumber[0] ?? "");
|
||||||
|
}
|
||||||
|
if (playCode === "tail") {
|
||||||
|
return ["0", "1", "2", "3", "4"].includes(alertNumber[0] ?? "");
|
||||||
|
}
|
||||||
|
if (playCode === "odd" || playCode === "even") {
|
||||||
|
const last = alertNumber[3] ?? "";
|
||||||
|
return playCode === "odd"
|
||||||
|
? ["1", "3", "5", "7", "9"].includes(last)
|
||||||
|
: ["0", "2", "4", "6", "8"].includes(last);
|
||||||
|
}
|
||||||
|
if (playCode === "digit_big" || playCode === "digit_small") {
|
||||||
|
const slot = digitSlot ?? digitSlotOptions(category).at(-1) ?? 3;
|
||||||
|
const digit = alertNumber[slot] ?? "";
|
||||||
|
return playCode === "digit_big"
|
||||||
|
? ["5", "6", "7", "8", "9"].includes(digit)
|
||||||
|
: ["0", "1", "2", "3", "4"].includes(digit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cellRiskState(
|
||||||
|
play: PlayEffectivePlayRow,
|
||||||
|
rowNumber: string,
|
||||||
|
category: PlayHallCategory,
|
||||||
|
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
|
||||||
|
liveSoldOutNumbers: ReadonlySet<string>,
|
||||||
|
liveWarningNumbers: ReadonlySet<string>,
|
||||||
|
digitSlot?: number,
|
||||||
|
): CellRiskState {
|
||||||
|
const normalizedRow = rowNumber.trim().toUpperCase();
|
||||||
|
if (!normalizedRow) return "open";
|
||||||
|
|
||||||
|
if (liveSoldOutNumbers.has(normalizedRow)) return "sold_out";
|
||||||
|
if (liveWarningNumbers.has(normalizedRow)) return "warning";
|
||||||
|
|
||||||
|
const alerts = alertRows ?? [];
|
||||||
|
for (const alert of alerts) {
|
||||||
|
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) {
|
||||||
|
return alert.status === "sold_out" ? "sold_out" : "warning";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "open";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDraftAmountInputValid(
|
||||||
|
row: DraftRow,
|
||||||
|
column: PlayColumn,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
row.number.trim().length > 0 &&
|
||||||
|
draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) === null
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronUp, Lock, Ticket, Trash2, Star } from "lucide-react";
|
import { Lock, Ticket, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -10,14 +10,34 @@ import { getPlayEffective } from "@/api/play";
|
|||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
|
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
|
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
|
||||||
import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog";
|
import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog";
|
||||||
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
|
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
|
||||||
|
import { HallBettingTable } from "@/features/hall/hall-betting-table";
|
||||||
|
import { HallMobileQuickFill } from "@/features/hall/hall-mobile-quick-fill";
|
||||||
|
import { HallPlaySummaryGrid } from "@/features/hall/hall-play-summary-grid";
|
||||||
|
import { HallSelectionConfirmDialog } from "@/features/hall/hall-selection-confirm-dialog";
|
||||||
|
import {
|
||||||
|
FALLBACK_BET_PROVIDERS,
|
||||||
|
cellRiskState,
|
||||||
|
digitSlotOptions,
|
||||||
|
numberMaxCharsForCategory,
|
||||||
|
playCategory,
|
||||||
|
playColumnHeaderLabel,
|
||||||
|
sanitizeNumber,
|
||||||
|
type DraftRow,
|
||||||
|
type HallCategory,
|
||||||
|
type PlayColumn,
|
||||||
|
type PlayHallCategory,
|
||||||
|
} from "@/features/hall/hall-betting-grid-model";
|
||||||
|
import {
|
||||||
|
HallDesktopReviewPanel,
|
||||||
|
HallDesktopWorkflowBar,
|
||||||
|
type HallDesktopReviewModel,
|
||||||
|
} from "@/features/hall/hall-desktop-workspace";
|
||||||
import {
|
import {
|
||||||
draftLineIssueReason,
|
draftLineIssueReason,
|
||||||
playNeedsDigitSlot,
|
playNeedsDigitSlot,
|
||||||
@@ -25,8 +45,17 @@ import {
|
|||||||
ticketNumberSpec,
|
ticketNumberSpec,
|
||||||
type DraftLineIssueReason,
|
type DraftLineIssueReason,
|
||||||
} from "@/features/hall/hall-bet-rules";
|
} from "@/features/hall/hall-bet-rules";
|
||||||
|
import {
|
||||||
|
isFullCoverAmountDivisible,
|
||||||
|
isHighCostSelectionType,
|
||||||
|
resolveSelectionTotalBet,
|
||||||
|
selectionCombinationCount,
|
||||||
|
selectionTypesForCategory,
|
||||||
|
type SelectionType,
|
||||||
|
} from "@/features/hall/selection-type";
|
||||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||||
@@ -47,25 +76,17 @@ import { cn } from "@/lib/utils";
|
|||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
||||||
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
||||||
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
|
||||||
import type { BetProviderRow } from "@/types/api/bet-provider";
|
import type { BetProviderRow } from "@/types/api/bet-provider";
|
||||||
|
|
||||||
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
|
|
||||||
type PlayHallCategory = Exclude<HallCategory, "JACKPOT">;
|
|
||||||
|
|
||||||
const TRADITIONAL_PLAY_CODES: Record<PlayHallCategory, readonly string[]> = {
|
const TRADITIONAL_PLAY_CODES: Record<PlayHallCategory, readonly string[]> = {
|
||||||
D4: ["big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower", "five_d", "six_d"],
|
D4: ["big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower", "five_d", "six_d"],
|
||||||
D3: ["pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e"],
|
D3: ["pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e"],
|
||||||
D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any"],
|
D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any"],
|
||||||
};
|
};
|
||||||
|
|
||||||
type DraftRow = {
|
type PendingSelectionChange =
|
||||||
id: string;
|
| { mode: "row"; rowId: string; next: SelectionType; prev: SelectionType }
|
||||||
number: string;
|
| { mode: "all"; next: SelectionType };
|
||||||
amounts: Record<string, string>;
|
|
||||||
providerCodes: string[];
|
|
||||||
selectionType: "straight" | "reverse" | "full_cover" | "full_play" | "half_play";
|
|
||||||
};
|
|
||||||
|
|
||||||
type DraftEntry = {
|
type DraftEntry = {
|
||||||
rowId: string;
|
rowId: string;
|
||||||
@@ -84,23 +105,19 @@ type DraftLineIssue = {
|
|||||||
reason: DraftLineIssueReason;
|
reason: DraftLineIssueReason;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PlayColumn = {
|
|
||||||
key: string;
|
|
||||||
play: PlayEffectivePlayRow;
|
|
||||||
digitSlot?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function playCategory(playCode: string): PlayHallCategory {
|
|
||||||
if (playCode.startsWith("pos_3")) return "D3";
|
|
||||||
if (playCode.startsWith("pos_2")) return "D2";
|
|
||||||
return "D4";
|
|
||||||
}
|
|
||||||
|
|
||||||
type ClosedPlayCleanupData = {
|
type ClosedPlayCleanupData = {
|
||||||
cleanup_hint?: string;
|
cleanup_hint?: string;
|
||||||
cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>;
|
cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TicketPreviewSubmissionSnapshot = {
|
||||||
|
drawId: string;
|
||||||
|
currencyCode: string;
|
||||||
|
clientTraceId: string;
|
||||||
|
lines: TicketLineInput[];
|
||||||
|
expectedConfigVersions: TicketPreviewData["config_versions"];
|
||||||
|
};
|
||||||
|
|
||||||
type PlayToggleWsEvent = {
|
type PlayToggleWsEvent = {
|
||||||
play_code?: string;
|
play_code?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -125,26 +142,8 @@ type RiskWarningWsEvent = {
|
|||||||
usage_percent?: number;
|
usage_percent?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CellRiskState = "open" | "warning" | "sold_out";
|
|
||||||
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
||||||
|
|
||||||
const FALLBACK_BET_PROVIDERS: BetProviderRow[] = [
|
|
||||||
{ code: "SG", name: "Singapore", short_code: "S", sort_order: 10, is_default: true },
|
|
||||||
{ code: "MY", name: "Malaysia", short_code: "M", sort_order: 20, is_default: false },
|
|
||||||
{ code: "TH", name: "Thailand", short_code: "T", sort_order: 30, is_default: false },
|
|
||||||
];
|
|
||||||
const DEFAULT_PROVIDER_CODE = "SG";
|
const DEFAULT_PROVIDER_CODE = "SG";
|
||||||
const PROVIDER_COLUMN_TONES = [
|
|
||||||
"bg-[#fff875] text-slate-950",
|
|
||||||
"bg-[#b7d9ff] text-slate-950",
|
|
||||||
"bg-[#ffc7ca] text-slate-950",
|
|
||||||
"bg-[#d9d8ff] text-slate-950",
|
|
||||||
"bg-[#a8f8a4] text-slate-950",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function providerColumnTone(index: number): string {
|
|
||||||
return PROVIDER_COLUMN_TONES[index % PROVIDER_COLUMN_TONES.length] ?? "bg-slate-100 text-slate-950";
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryTabs: { value: PlayHallCategory; label: string }[] = [
|
const categoryTabs: { value: PlayHallCategory; label: string }[] = [
|
||||||
{ value: "D4", label: "4D" },
|
{ value: "D4", label: "4D" },
|
||||||
@@ -174,9 +173,26 @@ const D4_PLAY_ORDER = [
|
|||||||
"digit_big",
|
"digit_big",
|
||||||
"digit_small",
|
"digit_small",
|
||||||
] as const;
|
] as const;
|
||||||
const MOBILE_QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const;
|
/** 按大厅 Tab 对应的投注列顺序。 */
|
||||||
|
const PLAY_ORDER_BY_CATEGORY: Record<PlayHallCategory, readonly string[]> = {
|
||||||
|
D2: D2_PLAY_ORDER,
|
||||||
|
D3: D3_PLAY_ORDER,
|
||||||
|
D4: D4_PLAY_ORDER,
|
||||||
|
};
|
||||||
|
const CATEGORY_ORDER: readonly PlayHallCategory[] = ["D4", "D3", "D2"];
|
||||||
|
const SUMMARY_PLAY_ORDER = CATEGORY_ORDER.flatMap(
|
||||||
|
(category) => PLAY_ORDER_BY_CATEGORY[category],
|
||||||
|
);
|
||||||
const DEFAULT_DRAFT_ROW_COUNT = 20;
|
const DEFAULT_DRAFT_ROW_COUNT = 20;
|
||||||
|
|
||||||
|
function playOrderForActiveCategory(activeCategory: PlayHallCategory): readonly string[] {
|
||||||
|
const rest = CATEGORY_ORDER.filter((category) => category !== activeCategory);
|
||||||
|
return [
|
||||||
|
...PLAY_ORDER_BY_CATEGORY[activeCategory],
|
||||||
|
...rest.flatMap((category) => PLAY_ORDER_BY_CATEGORY[category]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function newDraftRows(count = DEFAULT_DRAFT_ROW_COUNT): DraftRow[] {
|
function newDraftRows(count = DEFAULT_DRAFT_ROW_COUNT): DraftRow[] {
|
||||||
return Array.from({ length: count }, newDraftRow);
|
return Array.from({ length: count }, newDraftRow);
|
||||||
}
|
}
|
||||||
@@ -193,37 +209,6 @@ function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
|
|||||||
return Boolean(row.master_enabled && row.config?.is_enabled);
|
return Boolean(row.master_enabled && row.config?.is_enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
type HallTranslate = (key: string, options?: Record<string, unknown>) => string;
|
|
||||||
|
|
||||||
/** 表头用短标签,避免 digit_big + 千/百/十/个 挤成一团。 */
|
|
||||||
function playColumnHeaderLabel(
|
|
||||||
play: PlayEffectivePlayRow,
|
|
||||||
category: PlayHallCategory,
|
|
||||||
digitSlot: number | undefined,
|
|
||||||
t: HallTranslate,
|
|
||||||
): string {
|
|
||||||
if (digitSlot !== undefined) {
|
|
||||||
const kind = play.play_code === "digit_big" ? "big" : "small";
|
|
||||||
return `${t(`hall.table.digitShort.${kind}`)}·${digitSlotLabel(category, digitSlot)}`;
|
|
||||||
}
|
|
||||||
return playLabel(play.play_code, t);
|
|
||||||
}
|
|
||||||
|
|
||||||
function digitSlotOptions(category: PlayHallCategory): number[] {
|
|
||||||
if (category === "D2") return [2, 3];
|
|
||||||
if (category === "D3") return [1, 2, 3];
|
|
||||||
return [0, 1, 2, 3];
|
|
||||||
}
|
|
||||||
|
|
||||||
function digitSlotLabel(category: PlayHallCategory, slot: number): string {
|
|
||||||
const labels: Record<PlayHallCategory, Record<number, string>> = {
|
|
||||||
D2: { 2: "十", 3: "个" },
|
|
||||||
D3: { 1: "百", 2: "十", 3: "个" },
|
|
||||||
D4: { 0: "千", 1: "百", 2: "十", 3: "个" },
|
|
||||||
};
|
|
||||||
return labels[category][slot] ?? String(slot + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function amountKeyForPlay(playCode: string, digitSlot?: number): string {
|
function amountKeyForPlay(playCode: string, digitSlot?: number): string {
|
||||||
return digitSlot === undefined ? playCode : `${playCode}@${digitSlot}`;
|
return digitSlot === undefined ? playCode : `${playCode}@${digitSlot}`;
|
||||||
}
|
}
|
||||||
@@ -245,20 +230,6 @@ function playColumnsForCategory(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function numberMaxCharsForCategory(category: PlayHallCategory): number {
|
|
||||||
return category === "D2" ? 2 : category === "D3" ? 3 : 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeNumber(raw: string, category: PlayHallCategory): string {
|
|
||||||
const normalized =
|
|
||||||
category === "D4"
|
|
||||||
? raw.replace(/[^0-9Rr]/g, "").toUpperCase()
|
|
||||||
: raw.replace(/\D/g, "");
|
|
||||||
const maxChars = numberMaxCharsForCategory(category);
|
|
||||||
|
|
||||||
return category === "D4" ? normalized.slice(0, maxChars) : normalized.slice(-maxChars);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeAmount(raw: string): string {
|
function sanitizeAmount(raw: string): string {
|
||||||
return raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").slice(0, 12);
|
return raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").slice(0, 12);
|
||||||
}
|
}
|
||||||
@@ -290,7 +261,7 @@ function lineForPlay(
|
|||||||
displayNumber: string,
|
displayNumber: string,
|
||||||
amountMinor: number,
|
amountMinor: number,
|
||||||
digitSlot?: number,
|
digitSlot?: number,
|
||||||
selectionType: DraftRow["selectionType"] = "straight",
|
selectionType: SelectionType = "straight",
|
||||||
): TicketLineInput | null {
|
): TicketLineInput | null {
|
||||||
const number = normalizeNumberForPlay(displayNumber, play.play_code);
|
const number = normalizeNumberForPlay(displayNumber, play.play_code);
|
||||||
if (draftLineIssueReason(play.play_code, displayNumber, digitSlot) !== null) {
|
if (draftLineIssueReason(play.play_code, displayNumber, digitSlot) !== null) {
|
||||||
@@ -374,99 +345,6 @@ function appendUnique(values: string[], value: string, limit = 20): string[] {
|
|||||||
return next.slice(0, limit);
|
return next.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortedDigits(value: string): string {
|
|
||||||
return value.split("").sort().join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesRiskAlert(
|
|
||||||
alertNumber: string,
|
|
||||||
playCode: string,
|
|
||||||
rowNumber: string,
|
|
||||||
category: Exclude<HallCategory, "JACKPOT">,
|
|
||||||
digitSlot?: number,
|
|
||||||
): boolean {
|
|
||||||
const normalizedRow = rowNumber.toUpperCase();
|
|
||||||
|
|
||||||
if (playCode === "big" || playCode === "small" || playCode === "straight") {
|
|
||||||
return alertNumber === normalizedRow.slice(0, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playCode === "box" || playCode === "ibox" || playCode === "mbox") {
|
|
||||||
return sortedDigits(alertNumber) === sortedDigits(normalizedRow.slice(0, 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playCode === "roll") {
|
|
||||||
const regex = new RegExp(`^${normalizedRow.replace(/R/g, "[0-9]")}$`);
|
|
||||||
return regex.test(alertNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playCode.startsWith("pos_4")) {
|
|
||||||
return alertNumber === normalizedRow.slice(0, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playCode.startsWith("pos_3")) {
|
|
||||||
return alertNumber.endsWith(normalizedRow.slice(-3));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playCode.startsWith("pos_2")) {
|
|
||||||
return alertNumber.endsWith(normalizedRow.slice(-2));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playCode === "head") {
|
|
||||||
return ["5", "6", "7", "8", "9"].includes(alertNumber[0] ?? "");
|
|
||||||
}
|
|
||||||
if (playCode === "tail") {
|
|
||||||
return ["0", "1", "2", "3", "4"].includes(alertNumber[0] ?? "");
|
|
||||||
}
|
|
||||||
if (playCode === "odd" || playCode === "even") {
|
|
||||||
const last = alertNumber[3] ?? "";
|
|
||||||
return playCode === "odd"
|
|
||||||
? ["1", "3", "5", "7", "9"].includes(last)
|
|
||||||
: ["0", "2", "4", "6", "8"].includes(last);
|
|
||||||
}
|
|
||||||
if (playCode === "digit_big" || playCode === "digit_small") {
|
|
||||||
const slot = digitSlot ?? digitSlotOptions(category).at(-1) ?? 3;
|
|
||||||
const last = alertNumber[slot] ?? "";
|
|
||||||
return playCode === "digit_big"
|
|
||||||
? ["5", "6", "7", "8", "9"].includes(last)
|
|
||||||
: ["0", "1", "2", "3", "4"].includes(last);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cellRiskState(
|
|
||||||
play: PlayEffectivePlayRow,
|
|
||||||
rowNumber: string,
|
|
||||||
category: Exclude<HallCategory, "JACKPOT">,
|
|
||||||
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
|
|
||||||
liveSoldOutNumbers: ReadonlySet<string>,
|
|
||||||
liveWarningNumbers: ReadonlySet<string>,
|
|
||||||
digitSlot?: number,
|
|
||||||
): CellRiskState {
|
|
||||||
const normalizedRow = rowNumber.trim().toUpperCase();
|
|
||||||
if (!normalizedRow) return "open";
|
|
||||||
|
|
||||||
if (liveSoldOutNumbers.has(normalizedRow)) {
|
|
||||||
return "sold_out";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (liveWarningNumbers.has(normalizedRow)) {
|
|
||||||
return "warning";
|
|
||||||
}
|
|
||||||
|
|
||||||
const alerts = alertRows ?? [];
|
|
||||||
if (alerts.length === 0) return "open";
|
|
||||||
|
|
||||||
for (const alert of alerts) {
|
|
||||||
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) {
|
|
||||||
return alert.status === "sold_out" ? "sold_out" : "warning";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "open";
|
|
||||||
}
|
|
||||||
|
|
||||||
function quickFillKeys(category: HallCategory): { favorites: string; history: string } {
|
function quickFillKeys(category: HallCategory): { favorites: string; history: string } {
|
||||||
return {
|
return {
|
||||||
favorites: `lottery.hall.quickfill.favorites.${category}`,
|
favorites: `lottery.hall.quickfill.favorites.${category}`,
|
||||||
@@ -500,18 +378,16 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
const [syncAmountColumns, setSyncAmountColumns] = useState<Record<string, boolean>>({});
|
const [syncAmountColumns, setSyncAmountColumns] = useState<Record<string, boolean>>({});
|
||||||
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
|
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
|
||||||
const [quickFillExpanded, setQuickFillExpanded] = useState(false);
|
const [quickFillExpanded, setQuickFillExpanded] = useState(false);
|
||||||
const [riskStateDrawNo, setRiskStateDrawNo] = useState<string | null>(display?.draw_no ?? null);
|
|
||||||
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
|
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
|
||||||
const [liveWarningNumbers, setLiveWarningNumbers] = useState<Set<string>>(() => new Set());
|
const [liveWarningNumbers, setLiveWarningNumbers] = useState<Set<string>>(() => new Set());
|
||||||
const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
|
const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
|
||||||
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
|
const [pendingSelectionChange, setPendingSelectionChange] = useState<PendingSelectionChange | null>(
|
||||||
timer: null,
|
null,
|
||||||
number: null,
|
);
|
||||||
longPress: false,
|
|
||||||
});
|
|
||||||
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
|
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
|
||||||
const placeTraceIdRef = useRef<string | null>(null);
|
const placeTraceIdRef = useRef<string | null>(null);
|
||||||
|
const previewRequestSeqRef = useRef(0);
|
||||||
|
const previewSubmissionRef = useRef<TicketPreviewSubmissionSnapshot | null>(null);
|
||||||
const newPlaceTraceId = (): string =>
|
const newPlaceTraceId = (): string =>
|
||||||
typeof crypto !== "undefined" && crypto.randomUUID
|
typeof crypto !== "undefined" && crypto.randomUUID
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
@@ -520,6 +396,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
const clearPlaceTraceId = useCallback(() => {
|
const clearPlaceTraceId = useCallback(() => {
|
||||||
placeTraceIdRef.current = null;
|
placeTraceIdRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
const invalidatePreview = useCallback(() => {
|
||||||
|
previewRequestSeqRef.current += 1;
|
||||||
|
previewSubmissionRef.current = null;
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setPreviewData(null);
|
||||||
|
clearPlaceTraceId();
|
||||||
|
}, [clearPlaceTraceId]);
|
||||||
const catalogSeqRef = useRef(0);
|
const catalogSeqRef = useRef(0);
|
||||||
const walletSeqRef = useRef(0);
|
const walletSeqRef = useRef(0);
|
||||||
const selectedCatalogProviderCode =
|
const selectedCatalogProviderCode =
|
||||||
@@ -554,11 +437,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
}
|
}
|
||||||
}, [currencyParam]);
|
}, [currencyParam]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void loadCatalog();
|
||||||
void loadCatalog();
|
void refreshWallet();
|
||||||
void refreshWallet();
|
|
||||||
});
|
|
||||||
}, [loadCatalog, refreshWallet]);
|
}, [loadCatalog, refreshWallet]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -594,20 +475,19 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
void loadCatalog();
|
void loadCatalog();
|
||||||
const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail
|
const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail
|
||||||
?.source;
|
?.source;
|
||||||
if (source === "play_config" || source === "risk_cap") {
|
if (source !== undefined) {
|
||||||
setPreviewOpen(false);
|
invalidatePreview();
|
||||||
setPreviewData(null);
|
|
||||||
clearPlaceTraceId();
|
|
||||||
toast.message(t("hall.playConfig.updated"));
|
toast.message(t("hall.playConfig.updated"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
|
window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
|
||||||
return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
|
return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
|
||||||
}, [clearPlaceTraceId, loadCatalog, t]);
|
}, [invalidatePreview, loadCatalog, t]);
|
||||||
|
|
||||||
const openPlays = useMemo(() => {
|
const openPlays = useMemo(() => {
|
||||||
if (catalogState.kind !== "ok") return [];
|
if (catalogState.kind !== "ok") return [];
|
||||||
const order: readonly string[] = [...D2_PLAY_ORDER, ...D3_PLAY_ORDER, ...D4_PLAY_ORDER];
|
// 投注表格列以当前 Tab 玩法为先;顶部汇总另用稳定的全玩法顺序。
|
||||||
|
const order = playOrderForActiveCategory(activeCategory);
|
||||||
const orderSet = new Set(order);
|
const orderSet = new Set(order);
|
||||||
return sortByPlayOrder(
|
return sortByPlayOrder(
|
||||||
catalogState.data.plays
|
catalogState.data.plays
|
||||||
@@ -615,12 +495,16 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
.filter((p) => orderSet.has(p.play_code)),
|
.filter((p) => orderSet.has(p.play_code)),
|
||||||
order,
|
order,
|
||||||
);
|
);
|
||||||
}, [catalogState]);
|
}, [activeCategory, catalogState]);
|
||||||
|
|
||||||
const activeCategoryPlays = useMemo(
|
const activeCategoryPlays = useMemo(
|
||||||
() => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)),
|
() => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)),
|
||||||
[activeCategory, openPlays],
|
[activeCategory, openPlays],
|
||||||
);
|
);
|
||||||
|
const availableCategories = useMemo(
|
||||||
|
() => new Set(openPlays.map((play) => playCategory(play.play_code))),
|
||||||
|
[openPlays],
|
||||||
|
);
|
||||||
|
|
||||||
const currencyCode =
|
const currencyCode =
|
||||||
catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam;
|
catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam;
|
||||||
@@ -639,6 +523,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
});
|
});
|
||||||
}, [openPlays]);
|
}, [openPlays]);
|
||||||
|
|
||||||
|
const summaryPlayColumns = useMemo<PlayColumn[]>(() => {
|
||||||
|
return sortByPlayOrder(openPlays, SUMMARY_PLAY_ORDER).flatMap((play) => {
|
||||||
|
const category = playCategory(play.play_code);
|
||||||
|
if (!playNeedsDigitSlot(play.play_code)) {
|
||||||
|
return [{ key: amountKeyForPlay(play.play_code), play }];
|
||||||
|
}
|
||||||
|
return digitSlotOptions(category).map((digitSlot) => ({
|
||||||
|
key: amountKeyForPlay(play.play_code, digitSlot),
|
||||||
|
play,
|
||||||
|
digitSlot,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}, [openPlays]);
|
||||||
|
|
||||||
const playColumns = useMemo(() => {
|
const playColumns = useMemo(() => {
|
||||||
return playColumnsForCategory(activeCategoryPlays, activeCategory);
|
return playColumnsForCategory(activeCategoryPlays, activeCategory);
|
||||||
}, [activeCategory, activeCategoryPlays]);
|
}, [activeCategory, activeCategoryPlays]);
|
||||||
@@ -646,31 +544,31 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]";
|
const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]";
|
||||||
const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]";
|
const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]";
|
||||||
const mobileSelectionTypeColClass = "w-[4rem] min-w-[4rem]";
|
const mobileSelectionTypeColClass = "w-[4rem] min-w-[4rem]";
|
||||||
const desktopIndexColClass = "w-[2.15rem] min-w-[2.15rem]";
|
const desktopIndexColClass = "w-9 min-w-9";
|
||||||
const desktopNumberColClass = "w-[5.5rem] min-w-[5.5rem]";
|
const desktopNumberColClass = "w-[5.25rem] min-w-[5.25rem]";
|
||||||
const desktopSelectionTypeColClass = "w-[4.75rem] min-w-[4.75rem]";
|
const desktopSelectionTypeColClass = "w-[4.5rem] min-w-[4.5rem]";
|
||||||
const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass;
|
const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass;
|
||||||
const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass;
|
const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass;
|
||||||
const selectionTypeColClass = isMobile ? mobileSelectionTypeColClass : desktopSelectionTypeColClass;
|
const selectionTypeColClass = isMobile ? mobileSelectionTypeColClass : desktopSelectionTypeColClass;
|
||||||
const showSelectionTypeColumn = activeCategory !== "D2";
|
const showSelectionTypeColumn = activeCategory !== "D2";
|
||||||
const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-[2.15rem]";
|
const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-9";
|
||||||
const amountColClass = !isMobile
|
const amountColClass = !isMobile
|
||||||
? "w-[3.9rem] min-w-[3.9rem]"
|
? "w-14 min-w-14"
|
||||||
: "w-[4rem] min-w-[4rem]";
|
: "w-[4rem] min-w-[4rem]";
|
||||||
const providerColClass = !isMobile
|
const providerColClass = !isMobile
|
||||||
? "w-[2.85rem] min-w-[2.85rem]"
|
? "w-11 min-w-11"
|
||||||
: "w-[2.7rem] min-w-[2.7rem]";
|
: "w-[2.7rem] min-w-[2.7rem]";
|
||||||
const rowTotalColClass = !isMobile
|
const rowTotalColClass = !isMobile
|
||||||
? "w-[5.5rem] min-w-[5.5rem]"
|
? "w-20 min-w-20"
|
||||||
: "w-[5rem] min-w-[5rem]";
|
: "w-[5rem] min-w-[5rem]";
|
||||||
|
|
||||||
const tableWidthPx = useMemo(() => {
|
const tableWidthPx = useMemo(() => {
|
||||||
const indexCol = !isMobile ? 34 : 28;
|
const indexCol = !isMobile ? 36 : 28;
|
||||||
const numberCol = !isMobile ? 88 : 68;
|
const numberCol = !isMobile ? 84 : 68;
|
||||||
const selectionTypeCol = !isMobile ? 76 : 64;
|
const selectionTypeCol = !isMobile ? 72 : 64;
|
||||||
const providerCol = !isMobile ? 46 : 43;
|
const providerCol = !isMobile ? 44 : 43;
|
||||||
const rowTotalCol = !isMobile ? 88 : 80;
|
const rowTotalCol = !isMobile ? 80 : 80;
|
||||||
const amountCol = !isMobile ? 62 : 64;
|
const amountCol = !isMobile ? 56 : 64;
|
||||||
return indexCol + numberCol + (showSelectionTypeColumn ? selectionTypeCol : 0) + betProviders.length * providerCol + rowTotalCol + playColumns.length * amountCol;
|
return indexCol + numberCol + (showSelectionTypeColumn ? selectionTypeCol : 0) + betProviders.length * providerCol + rowTotalCol + playColumns.length * amountCol;
|
||||||
}, [betProviders.length, isMobile, playColumns.length, showSelectionTypeColumn]);
|
}, [betProviders.length, isMobile, playColumns.length, showSelectionTypeColumn]);
|
||||||
|
|
||||||
@@ -683,11 +581,19 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
|
|
||||||
const drawNo = display?.draw_no ?? null;
|
const drawNo = display?.draw_no ?? null;
|
||||||
|
|
||||||
if (riskStateDrawNo !== drawNo) {
|
useEffect(() => {
|
||||||
setRiskStateDrawNo(drawNo);
|
let cancelled = false;
|
||||||
setLiveSoldOutNumbers(new Set());
|
queueMicrotask(() => {
|
||||||
setLiveWarningNumbers(new Set());
|
if (cancelled) return;
|
||||||
}
|
setLiveSoldOutNumbers(new Set());
|
||||||
|
setLiveWarningNumbers(new Set());
|
||||||
|
invalidatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [currencyCode, drawNo, invalidatePreview]);
|
||||||
|
|
||||||
const alertRows = useMemo(
|
const alertRows = useMemo(
|
||||||
() => display?.risk_pool_alerts ?? [],
|
() => display?.risk_pool_alerts ?? [],
|
||||||
@@ -712,14 +618,57 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
setActiveRowId(id);
|
setActiveRowId(id);
|
||||||
}, [activeCategory]);
|
}, [activeCategory]);
|
||||||
|
|
||||||
const updateRowSelectionType = useCallback((id: string, selectionType: DraftRow["selectionType"]) => {
|
const applyRowSelectionType = useCallback((id: string, selectionType: SelectionType) => {
|
||||||
setRows((current) => current.map((row) => row.id === id ? { ...row, selectionType } : row));
|
setRows((current) => current.map((row) => (row.id === id ? { ...row, selectionType } : row)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateAllSelectionTypes = useCallback((selectionType: DraftRow["selectionType"]) => {
|
const applyAllSelectionTypes = useCallback((selectionType: SelectionType) => {
|
||||||
setRows((current) => current.map((row) => ({ ...row, selectionType })));
|
setRows((current) => current.map((row) => ({ ...row, selectionType })));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const requestRowSelectionType = useCallback(
|
||||||
|
(id: string, selectionType: SelectionType) => {
|
||||||
|
const row = rows.find((item) => item.id === id);
|
||||||
|
if (!row || row.selectionType === selectionType) return;
|
||||||
|
if (isHighCostSelectionType(selectionType) && !isHighCostSelectionType(row.selectionType)) {
|
||||||
|
setPendingSelectionChange({
|
||||||
|
mode: "row",
|
||||||
|
rowId: id,
|
||||||
|
next: selectionType,
|
||||||
|
prev: row.selectionType,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyRowSelectionType(id, selectionType);
|
||||||
|
},
|
||||||
|
[applyRowSelectionType, rows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestAllSelectionTypes = useCallback(
|
||||||
|
(selectionType: SelectionType) => {
|
||||||
|
if (isHighCostSelectionType(selectionType)) {
|
||||||
|
setPendingSelectionChange({ mode: "all", next: selectionType });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyAllSelectionTypes(selectionType);
|
||||||
|
},
|
||||||
|
[applyAllSelectionTypes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmPendingSelectionChange = useCallback(() => {
|
||||||
|
if (!pendingSelectionChange) return;
|
||||||
|
if (pendingSelectionChange.mode === "row") {
|
||||||
|
applyRowSelectionType(pendingSelectionChange.rowId, pendingSelectionChange.next);
|
||||||
|
} else {
|
||||||
|
applyAllSelectionTypes(pendingSelectionChange.next);
|
||||||
|
}
|
||||||
|
setPendingSelectionChange(null);
|
||||||
|
}, [applyAllSelectionTypes, applyRowSelectionType, pendingSelectionChange]);
|
||||||
|
|
||||||
|
const cancelPendingSelectionChange = useCallback(() => {
|
||||||
|
setPendingSelectionChange(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateAmount = useCallback((rowId: string, playCode: string, value: string) => {
|
const updateAmount = useCallback((rowId: string, playCode: string, value: string) => {
|
||||||
const amount = sanitizeAmount(value);
|
const amount = sanitizeAmount(value);
|
||||||
const column = allPlayColumns.find((item) => item.key === playCode);
|
const column = allPlayColumns.find((item) => item.key === playCode);
|
||||||
@@ -751,11 +700,76 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
setActiveRowId(rowId);
|
setActiveRowId(rowId);
|
||||||
}, [alertRows, allPlayColumns, liveSoldOutNumbers, liveWarningNumbers, syncAmountColumns]);
|
}, [alertRows, allPlayColumns, liveSoldOutNumbers, liveWarningNumbers, syncAmountColumns]);
|
||||||
|
|
||||||
|
const toggleSyncAmountColumn = useCallback((column: PlayColumn, checked: boolean) => {
|
||||||
|
setSyncAmountColumns((current) => ({
|
||||||
|
...current,
|
||||||
|
[column.key]: checked,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setRows((current) => {
|
||||||
|
if (!checked) {
|
||||||
|
return current.map((row) => ({
|
||||||
|
...row,
|
||||||
|
amounts: { ...row.amounts, [column.key]: "" },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceAmount = current.find((row) => {
|
||||||
|
const amount = row.amounts[column.key];
|
||||||
|
return Boolean(
|
||||||
|
amount &&
|
||||||
|
draftLineIssueReason(
|
||||||
|
column.play.play_code,
|
||||||
|
row.number,
|
||||||
|
column.digitSlot,
|
||||||
|
) === null,
|
||||||
|
);
|
||||||
|
})?.amounts[column.key];
|
||||||
|
|
||||||
|
if (!sourceAmount) return current;
|
||||||
|
|
||||||
|
return current.map((row) => {
|
||||||
|
const validNumber =
|
||||||
|
draftLineIssueReason(
|
||||||
|
column.play.play_code,
|
||||||
|
row.number,
|
||||||
|
column.digitSlot,
|
||||||
|
) === null;
|
||||||
|
const status = cellRiskState(
|
||||||
|
column.play,
|
||||||
|
row.number,
|
||||||
|
playCategory(column.play.play_code),
|
||||||
|
alertRows,
|
||||||
|
liveSoldOutNumbers,
|
||||||
|
liveWarningNumbers,
|
||||||
|
column.digitSlot,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!validNumber ||
|
||||||
|
status === "sold_out" ||
|
||||||
|
(column.play.config !== null && !column.play.config.is_enabled)
|
||||||
|
) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
amounts: { ...row.amounts, [column.key]: sourceAmount },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [alertRows, liveSoldOutNumbers, liveWarningNumbers]);
|
||||||
|
|
||||||
const clearAllRows = () => {
|
const clearAllRows = () => {
|
||||||
if (tableDisabled) return;
|
if (tableDisabled) return;
|
||||||
const next = [newDraftRow()];
|
setRows((current) =>
|
||||||
setRows(next);
|
current.map((row) => ({
|
||||||
setActiveRowId(next[0].id);
|
...row,
|
||||||
|
number: "",
|
||||||
|
amounts: {},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setActiveRowId((current) => current ?? rows[0]?.id ?? null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearActiveRowAmounts = useCallback(() => {
|
const clearActiveRowAmounts = useCallback(() => {
|
||||||
@@ -943,9 +957,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
if (evt.enabled === false && typeof evt.play_code === "string") {
|
if (evt.enabled === false && typeof evt.play_code === "string") {
|
||||||
const removed = hasAmountsForPlay(evt.play_code);
|
const removed = hasAmountsForPlay(evt.play_code);
|
||||||
clearAmountsForPlay(evt.play_code);
|
clearAmountsForPlay(evt.play_code);
|
||||||
setPreviewOpen(false);
|
invalidatePreview();
|
||||||
setPreviewData(null);
|
|
||||||
clearPlaceTraceId();
|
|
||||||
toast.warning(
|
toast.warning(
|
||||||
removed
|
removed
|
||||||
? t("hall.playConfig.playClosedDraftCleared", {
|
? t("hall.playConfig.playClosedDraftCleared", {
|
||||||
@@ -959,9 +971,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onOddsUpdate = (evt: OddsUpdateWsEvent) => {
|
const onOddsUpdate = (evt: OddsUpdateWsEvent) => {
|
||||||
setPreviewOpen(false);
|
invalidatePreview();
|
||||||
setPreviewData(null);
|
|
||||||
clearPlaceTraceId();
|
|
||||||
toast.message(evt.message ?? t("hall.playConfig.oddsUpdated"));
|
toast.message(evt.message ?? t("hall.playConfig.oddsUpdated"));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -981,6 +991,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
next.delete(normalized);
|
next.delete(normalized);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
invalidatePreview();
|
||||||
void reloadDraw();
|
void reloadDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1008,7 +1019,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
channel.stopListening(".risk.sold_out");
|
channel.stopListening(".risk.sold_out");
|
||||||
channel.stopListening(".risk.warning");
|
channel.stopListening(".risk.warning");
|
||||||
};
|
};
|
||||||
}, [clearAmountsForPlay, clearPlaceTraceId, drawNo, hasAmountsForPlay, reloadDraw, t]);
|
}, [clearAmountsForPlay, drawNo, hasAmountsForPlay, invalidatePreview, reloadDraw, t]);
|
||||||
|
|
||||||
const collectDraftLineIssues = useCallback((): DraftLineIssue[] => {
|
const collectDraftLineIssues = useCallback((): DraftLineIssue[] => {
|
||||||
const issues: DraftLineIssue[] = [];
|
const issues: DraftLineIssue[] = [];
|
||||||
@@ -1023,6 +1034,18 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
playCode: column.play.play_code,
|
playCode: column.play.play_code,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const comboCount = selectionCombinationCount(row.number, row.selectionType);
|
||||||
|
if (
|
||||||
|
row.selectionType === "full_cover"
|
||||||
|
&& !isFullCoverAmountDivisible(amount, comboCount)
|
||||||
|
) {
|
||||||
|
issues.push({
|
||||||
|
rowNo: rowIndex + 1,
|
||||||
|
playCode: column.play.play_code,
|
||||||
|
reason: "full_cover_amount_not_divisible",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1076,18 +1099,84 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
const draftSummary = useMemo(() => {
|
const draftSummary = useMemo(() => {
|
||||||
return draftEntries.reduce(
|
return draftEntries.reduce(
|
||||||
(acc, entry) => {
|
(acc, entry) => {
|
||||||
|
const selectionType = entry.line.selection_type ?? "straight";
|
||||||
|
const comboCount = selectionCombinationCount(entry.number, selectionType);
|
||||||
|
const totalBet = resolveSelectionTotalBet(entry.amountMinor, selectionType, comboCount);
|
||||||
const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate);
|
const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate);
|
||||||
const rebate = Math.round(entry.amountMinor * rebateRate);
|
const rebate = Math.round(totalBet * rebateRate);
|
||||||
const providerCount = entry.line.provider_codes?.length ?? 0;
|
const providerCount = entry.line.provider_codes?.length ?? 0;
|
||||||
acc.bet += entry.amountMinor * providerCount;
|
acc.bet += totalBet * providerCount;
|
||||||
acc.rebate += rebate * providerCount;
|
acc.rebate += rebate * providerCount;
|
||||||
acc.actual += Math.max(0, entry.amountMinor - rebate) * providerCount;
|
acc.actual += Math.max(0, totalBet - rebate) * providerCount;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{ bet: 0, rebate: 0, actual: 0 },
|
{ bet: 0, rebate: 0, actual: 0 },
|
||||||
);
|
);
|
||||||
}, [draftEntries]);
|
}, [draftEntries]);
|
||||||
|
|
||||||
|
const selectionTypeOptions = useMemo(
|
||||||
|
() => selectionTypesForCategory(activeCategory),
|
||||||
|
[activeCategory],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingSelectionPreview = useMemo(() => {
|
||||||
|
if (!pendingSelectionChange) return null;
|
||||||
|
const next = pendingSelectionChange.next;
|
||||||
|
|
||||||
|
const rowStake = (
|
||||||
|
row: DraftRow,
|
||||||
|
selectionType: SelectionType,
|
||||||
|
mode: "resolved" | "raw",
|
||||||
|
): number => {
|
||||||
|
const comboCount = selectionCombinationCount(row.number, selectionType);
|
||||||
|
const providerCount = Math.max(1, row.providerCodes.length);
|
||||||
|
const stake = playColumns.reduce((total, column) => {
|
||||||
|
if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0;
|
||||||
|
if (amount <= 0) return total;
|
||||||
|
if (mode === "raw") return total + amount;
|
||||||
|
return total + resolveSelectionTotalBet(amount, selectionType, comboCount);
|
||||||
|
}, 0);
|
||||||
|
return stake * providerCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pendingSelectionChange.mode === "row") {
|
||||||
|
const row = rows.find((item) => item.id === pendingSelectionChange.rowId);
|
||||||
|
if (!row) return null;
|
||||||
|
const comboCount = selectionCombinationCount(row.number, next);
|
||||||
|
const fromMinor = rowStake(row, pendingSelectionChange.prev, "resolved");
|
||||||
|
const toMinor =
|
||||||
|
next === "full_cover" ? rowStake(row, next, "raw") : rowStake(row, next, "resolved");
|
||||||
|
return {
|
||||||
|
next,
|
||||||
|
comboCount,
|
||||||
|
number: row.number,
|
||||||
|
fromMinor,
|
||||||
|
toMinor,
|
||||||
|
scope: "row" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let fromMinor = 0;
|
||||||
|
let toMinor = 0;
|
||||||
|
let maxCombo = 1;
|
||||||
|
rows.forEach((row) => {
|
||||||
|
maxCombo = Math.max(maxCombo, selectionCombinationCount(row.number, next));
|
||||||
|
fromMinor += rowStake(row, row.selectionType, "resolved");
|
||||||
|
toMinor += next === "full_cover" ? rowStake(row, next, "raw") : rowStake(row, next, "resolved");
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
next,
|
||||||
|
comboCount: maxCombo,
|
||||||
|
number: "",
|
||||||
|
fromMinor,
|
||||||
|
toMinor,
|
||||||
|
scope: "all" as const,
|
||||||
|
};
|
||||||
|
}, [currencyCode, pendingSelectionChange, playColumns, rows]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setTimeout(() => {
|
const id = window.setTimeout(() => {
|
||||||
setDebouncedSummary(draftSummary);
|
setDebouncedSummary(draftSummary);
|
||||||
@@ -1167,14 +1256,30 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPreviewLoading(true);
|
setPreviewLoading(true);
|
||||||
|
const requestSeq = ++previewRequestSeqRef.current;
|
||||||
|
const traceId = newPlaceTraceId();
|
||||||
|
const drawId = display.draw_no;
|
||||||
|
const previewCurrencyCode = currencyCode;
|
||||||
|
const frozenLines = lines.map((line) => ({
|
||||||
|
...line,
|
||||||
|
provider_codes: line.provider_codes ? [...line.provider_codes] : undefined,
|
||||||
|
}));
|
||||||
|
placeTraceIdRef.current = traceId;
|
||||||
try {
|
try {
|
||||||
placeTraceIdRef.current = newPlaceTraceId();
|
|
||||||
const data = await postTicketPreview({
|
const data = await postTicketPreview({
|
||||||
draw_id: display.draw_no,
|
draw_id: drawId,
|
||||||
currency_code: currencyCode,
|
currency_code: previewCurrencyCode,
|
||||||
client_trace_id: placeTraceIdRef.current,
|
client_trace_id: traceId,
|
||||||
lines,
|
lines: frozenLines,
|
||||||
});
|
});
|
||||||
|
if (requestSeq !== previewRequestSeqRef.current) return;
|
||||||
|
previewSubmissionRef.current = {
|
||||||
|
drawId: data.draw.draw_id.trim() || drawId,
|
||||||
|
currencyCode: previewCurrencyCode,
|
||||||
|
clientTraceId: traceId,
|
||||||
|
lines: frozenLines,
|
||||||
|
expectedConfigVersions: data.config_versions,
|
||||||
|
};
|
||||||
setPreviewData(data);
|
setPreviewData(data);
|
||||||
setPreviewOpen(true);
|
setPreviewOpen(true);
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
@@ -1189,9 +1294,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e instanceof LotteryApiBizError && code === 2008) {
|
if (e instanceof LotteryApiBizError && code === 2008) {
|
||||||
setPreviewOpen(false);
|
invalidatePreview();
|
||||||
setPreviewData(null);
|
|
||||||
clearPlaceTraceId();
|
|
||||||
}
|
}
|
||||||
if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) {
|
if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) {
|
||||||
void reloadDraw();
|
void reloadDraw();
|
||||||
@@ -1203,7 +1306,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePlace = async () => {
|
const handlePlace = async () => {
|
||||||
if (!display || !previewData) return;
|
if (!previewData) return;
|
||||||
if (placeLoading) {
|
if (placeLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1212,29 +1315,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineIssues = collectDraftLineIssues();
|
const snapshot = previewSubmissionRef.current;
|
||||||
const missingProviderAt = providerMissingRow();
|
if (snapshot === null) {
|
||||||
if (missingProviderAt !== null) {
|
|
||||||
toast.error(t("hall.providers.rowRequired", { row: missingProviderAt, defaultValue: `第 ${missingProviderAt} 行请选择至少一个开注商` }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lineIssues.length > 0) {
|
|
||||||
toast.error(formatDraftLineIssue(lineIssues[0]));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = buildLines();
|
|
||||||
if (lines.length === 0) {
|
|
||||||
toast.error(t("hall.changedBeforeSubmit"));
|
toast.error(t("hall.changedBeforeSubmit"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const traceId = placeTraceIdRef.current ?? newPlaceTraceId();
|
if (snapshot.drawId === "") {
|
||||||
placeTraceIdRef.current = traceId;
|
|
||||||
|
|
||||||
const drawIdForBet =
|
|
||||||
previewData.draw.draw_id.trim() || display.draw_no.trim();
|
|
||||||
if (drawIdForBet === "") {
|
|
||||||
toast.error(t("hall.notBettable"));
|
toast.error(t("hall.notBettable"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1242,12 +1329,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
setPlaceLoading(true);
|
setPlaceLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await postTicketPlace({
|
const data = await postTicketPlace({
|
||||||
draw_id: drawIdForBet,
|
draw_id: snapshot.drawId,
|
||||||
currency_code: currencyCode,
|
currency_code: snapshot.currencyCode,
|
||||||
client_trace_id: traceId,
|
client_trace_id: snapshot.clientTraceId,
|
||||||
lines,
|
lines: snapshot.lines,
|
||||||
expected_config_versions: previewData.config_versions,
|
expected_config_versions: snapshot.expectedConfigVersions,
|
||||||
});
|
});
|
||||||
|
previewSubmissionRef.current = null;
|
||||||
clearPlaceTraceId();
|
clearPlaceTraceId();
|
||||||
setPreviewOpen(false);
|
setPreviewOpen(false);
|
||||||
setPreviewData(null);
|
setPreviewData(null);
|
||||||
@@ -1287,15 +1375,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
if (e instanceof LotteryApiBizError && code === 2002 && applyClosedPlayCleanup(e.data)) {
|
if (e instanceof LotteryApiBizError && code === 2002 && applyClosedPlayCleanup(e.data)) {
|
||||||
const payload = e.data as ClosedPlayCleanupData;
|
const payload = e.data as ClosedPlayCleanupData;
|
||||||
toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002"));
|
toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002"));
|
||||||
setPreviewOpen(false);
|
invalidatePreview();
|
||||||
setPreviewData(null);
|
|
||||||
clearPlaceTraceId();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e instanceof LotteryApiBizError && (code === 2008 || code === 2009)) {
|
if (e instanceof LotteryApiBizError && (code === 2008 || code === 2009)) {
|
||||||
setPreviewOpen(false);
|
invalidatePreview();
|
||||||
setPreviewData(null);
|
|
||||||
clearPlaceTraceId();
|
|
||||||
}
|
}
|
||||||
if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) {
|
if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) {
|
||||||
void reloadDraw();
|
void reloadDraw();
|
||||||
@@ -1344,28 +1428,93 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
|
|
||||||
const submitActualMinor =
|
const submitActualMinor =
|
||||||
previewData?.summary.total_actual_deduct ?? debouncedSummary.actual;
|
previewData?.summary.total_actual_deduct ?? debouncedSummary.actual;
|
||||||
const selectedProviderCount = new Set(rows.flatMap((row) => row.providerCodes)).size;
|
const activeDraftRowIds = new Set(draftEntries.map((entry) => entry.rowId));
|
||||||
|
const selectedProviderCount = new Set(
|
||||||
|
rows
|
||||||
|
.filter((row) => activeDraftRowIds.has(row.id))
|
||||||
|
.flatMap((row) => row.providerCodes),
|
||||||
|
).size;
|
||||||
|
const warningRowCount = rows.filter(
|
||||||
|
(row) =>
|
||||||
|
row.number.trim().length > 0 &&
|
||||||
|
playColumns.some(
|
||||||
|
(column) =>
|
||||||
|
cellRiskState(
|
||||||
|
column.play,
|
||||||
|
row.number,
|
||||||
|
playCategory(column.play.play_code),
|
||||||
|
alertRows,
|
||||||
|
liveSoldOutNumbers,
|
||||||
|
liveWarningNumbers,
|
||||||
|
column.digitSlot,
|
||||||
|
) === "warning",
|
||||||
|
),
|
||||||
|
).length;
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
!tableDisabled && draftEntries.length > 0 && providerMissingRow() === null && availableMinor >= submitActualMinor;
|
!tableDisabled && draftEntries.length > 0 && providerMissingRow() === null && availableMinor >= submitActualMinor;
|
||||||
const favoriteChips = favorites.slice(0, 10);
|
const favoriteChips = favorites.slice(0, 10);
|
||||||
const historyChips = historyNumbers.slice(0, 20);
|
const historyChips = historyNumbers.slice(0, 20);
|
||||||
|
const summaryItems = [
|
||||||
|
{
|
||||||
|
key: "total",
|
||||||
|
label: t("hall.table.total", { defaultValue: "共" }),
|
||||||
|
totalMinor: submitActualMinor,
|
||||||
|
},
|
||||||
|
...summaryPlayColumns.map((column) => ({
|
||||||
|
key: column.key,
|
||||||
|
label: playColumnHeaderLabel(
|
||||||
|
column.play,
|
||||||
|
playCategory(column.play.play_code),
|
||||||
|
column.digitSlot,
|
||||||
|
t,
|
||||||
|
),
|
||||||
|
totalMinor: rows.reduce((sum, row) => {
|
||||||
|
const amount = row.amounts[column.key];
|
||||||
|
if (
|
||||||
|
!amount ||
|
||||||
|
row.number.trim().length === 0 ||
|
||||||
|
draftLineIssueReason(
|
||||||
|
column.play.play_code,
|
||||||
|
row.number,
|
||||||
|
column.digitSlot,
|
||||||
|
) !== null
|
||||||
|
) {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
return sum + (parseDecimalInputToMinor(amount, currencyCode) ?? 0);
|
||||||
|
}, 0),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const desktopReviewModel: HallDesktopReviewModel = {
|
||||||
|
lineCount: draftEntries.length,
|
||||||
|
providerCount: selectedProviderCount,
|
||||||
|
actualAmount: formatMinorAmount(submitActualMinor),
|
||||||
|
availableCredit: formatMinorAmount(availableMinor),
|
||||||
|
remainingCredit: formatMinorAmount(Math.max(0, availableMinor - submitActualMinor)),
|
||||||
|
warningCount: warningRowCount,
|
||||||
|
creditSufficient: availableMinor >= submitActualMinor,
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="space-y-3" aria-label={t("hall.aria")}>
|
<section className="space-y-3" aria-label={t("hall.aria")}>
|
||||||
|
|
||||||
{jackpot?.enabled ? (
|
{jackpot?.enabled ? (
|
||||||
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 via-white to-[#f8fbff] px-3 py-2.5">
|
<div className="relative overflow-hidden rounded-xl border border-[#d6b74e]/70 bg-[linear-gradient(115deg,#06183c_0%,#0a3a89_50%,#071f50_100%)] px-3 py-3 text-white shadow-[0_10px_28px_rgba(7,46,112,0.2)] sm:px-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="pointer-events-none absolute -left-10 top-1/2 size-28 -translate-y-1/2 rounded-full bg-[#2d79ff]/25 blur-2xl motion-safe:animate-[jackpot-amount-row-glow_2.8s_ease-in-out_infinite]" />
|
||||||
<div>
|
<div className="pointer-events-none absolute -right-5 -top-16 size-36 rounded-full bg-[#f4ca54]/20 blur-2xl motion-safe:animate-[jackpot-amount-row-glow_3.2s_ease-in-out_infinite]" />
|
||||||
<p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
|
<div className="pointer-events-none absolute -inset-y-10 -left-1/3 w-1/3 rotate-12 bg-gradient-to-r from-transparent via-white/20 to-transparent blur-sm motion-safe:animate-[jackpot-strip-sweep_4s_ease-in-out_infinite]" />
|
||||||
|
|
||||||
|
<div className="relative flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="w-fit bg-gradient-to-r from-[#fff1ad] via-white to-[#e2bd45] bg-[length:200%_auto] bg-clip-text text-[11px] font-black uppercase tracking-[0.16em] text-transparent motion-safe:animate-[jackpot-shimmer_3s_linear_infinite]">
|
||||||
{t("results.jackpotLabel", { defaultValue: "Jackpot" })}
|
{t("results.jackpotLabel", { defaultValue: "Jackpot" })}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-xl font-black tabular-nums text-[#07459f]">
|
<p className="mt-0.5 truncate font-mono text-xl font-black tabular-nums text-white drop-shadow-[0_0_12px_rgba(245,216,122,0.35)] sm:text-2xl motion-safe:animate-[jackpot-amount-glow_2.4s_ease-in-out_infinite]">
|
||||||
{formatMinorAsCurrency(jackpot.current_amount_minor, jackpot.currency_code)}
|
{formatMinorAmount(jackpot.current_amount_minor)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{jackpot.draws_since_last_burst !== null ? (
|
{jackpot.draws_since_last_burst !== null ? (
|
||||||
<p className="rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-800">
|
<p className="ml-auto rounded-full border border-[#f4d66f]/35 bg-white/10 px-3 py-1.5 text-xs font-bold text-[#ffe8a0] shadow-[inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-sm">
|
||||||
{t("results.jackpotGap", {
|
{t("results.jackpotGap", {
|
||||||
count: jackpot.draws_since_last_burst,
|
count: jackpot.draws_since_last_burst,
|
||||||
})}
|
})}
|
||||||
@@ -1375,217 +1524,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div
|
<HallPlaySummaryGrid items={summaryItems} className="hidden lg:grid" />
|
||||||
className={cn(
|
|
||||||
"bg-white",
|
|
||||||
isMobile
|
|
||||||
? "rounded-lg border border-[#e8eef7] px-3 py-2.5"
|
|
||||||
: "rounded-xl border border-[#e3ebf7] px-3 py-3 shadow-[0_8px_24px_rgba(15,23,42,0.045)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-bold leading-5 text-slate-950">
|
|
||||||
{t("hall.quickFill.title")}
|
|
||||||
</p>
|
|
||||||
{!isMobile ? (
|
|
||||||
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
|
||||||
{t("hall.quickFill.description")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-0.5 text-[11px] leading-5 text-slate-500">
|
|
||||||
{t("hall.mobile.quickFillSummary", {
|
|
||||||
defaultValue: "收藏 {{favorites}} 个,历史 {{history}} 个",
|
|
||||||
favorites: favorites.length,
|
|
||||||
history: historyNumbers.length,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
|
||||||
{isMobile ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={
|
|
||||||
quickFillExpanded
|
|
||||||
? t("hall.mobile.collapseQuickFill", { defaultValue: "收起快捷填单" })
|
|
||||||
: t("hall.mobile.expandQuickFill", { defaultValue: "展开快捷填单" })
|
|
||||||
}
|
|
||||||
aria-pressed={quickFillExpanded}
|
|
||||||
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
|
||||||
onClick={() => setQuickFillExpanded((current) => !current)}
|
|
||||||
>
|
|
||||||
{quickFillExpanded ? (
|
|
||||||
<ChevronUp className="size-4" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="size-4" aria-hidden />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{activeRow?.number ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={
|
|
||||||
favorites.includes(activeRow.number)
|
|
||||||
? t("hall.quickFill.unfavorite")
|
|
||||||
: t("hall.quickFill.favorite")
|
|
||||||
}
|
|
||||||
aria-pressed={favorites.includes(activeRow.number)}
|
|
||||||
title={
|
|
||||||
favorites.includes(activeRow.number)
|
|
||||||
? t("hall.quickFill.unfavorite")
|
|
||||||
: t("hall.quickFill.favorite")
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"size-8 rounded-full border border-[#ffd7db] bg-[#fff7f8] text-[#d81435] hover:bg-[#fff1f3] hover:text-[#b80f2b]",
|
|
||||||
favorites.includes(activeRow.number) &&
|
|
||||||
"border-[#d81435] bg-[#d81435] text-white hover:bg-[#c51230] hover:text-white",
|
|
||||||
)}
|
|
||||||
onClick={() => toggleFavoriteNumber(activeRow.number)}
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
className={cn(
|
|
||||||
"size-4",
|
|
||||||
favorites.includes(activeRow.number) && "fill-current",
|
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={t("hall.quickFill.clearAll")}
|
|
||||||
title={t("hall.quickFill.clearAll")}
|
|
||||||
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
|
||||||
onClick={clearAllRows}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn("space-y-2", isMobile ? "mt-2" : "mt-3")}>
|
<HallDesktopWorkflowBar
|
||||||
{isMobile && quickFillExpanded ? (
|
stepGame={t("hall.desktop.stepGame", { defaultValue: "选择玩法" })}
|
||||||
<div className="rounded-xl border border-[#e7eef8] bg-[#f8fbff] px-3 py-2.5">
|
stepFill={t("hall.desktop.stepFill", { defaultValue: "填写号码和金额" })}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
stepReview={t("hall.desktop.stepReview", { defaultValue: "核对并提交" })}
|
||||||
<span className="text-[11px] font-semibold text-[#5b6f95]">
|
gameControls={(
|
||||||
{t("hall.quickFill.batchTitle", { defaultValue: "Quick amount" })}
|
<div className="inline-flex max-w-full items-center gap-1 rounded-lg bg-[#f3f6fb] p-1">
|
||||||
</span>
|
|
||||||
{MOBILE_QUICK_AMOUNT_PRESETS.map((preset) => (
|
|
||||||
<button
|
|
||||||
key={preset}
|
|
||||||
type="button"
|
|
||||||
disabled={tableDisabled}
|
|
||||||
onClick={() => applyQuickAmountToActiveRow(preset)}
|
|
||||||
className="inline-flex h-8 min-w-12 items-center justify-center rounded-full border border-[#cfe0fb] bg-white px-3 text-xs font-bold text-[#0b4ab3] transition-colors hover:border-[#aac7f5] hover:bg-[#edf5ff] disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{preset}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={tableDisabled || !activeRow?.number}
|
|
||||||
onClick={copyPreviousRowToActiveRow}
|
|
||||||
className="inline-flex h-8 items-center justify-center rounded-full border border-[#dfe6f0] bg-white px-3 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{t("hall.quickFill.copyPrev", { defaultValue: "Copy prev row" })}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={tableDisabled}
|
|
||||||
onClick={clearActiveRowAmounts}
|
|
||||||
className="inline-flex h-8 items-center justify-center rounded-full border border-[#ffe0e5] bg-white px-3 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#fff4f6] disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{t("hall.quickFill.clearRow", { defaultValue: "Clear row amounts" })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isMobile || quickFillExpanded ? (
|
|
||||||
<>
|
|
||||||
{favoriteChips.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<span className="mr-0.5 text-[11px] font-semibold text-[#d81435]">
|
|
||||||
{t("hall.quickFill.favorites")}
|
|
||||||
</span>
|
|
||||||
{favoriteChips.map((number) => (
|
|
||||||
<button
|
|
||||||
key={`fav-${number}`}
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-8 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
|
||||||
onPointerDown={() => {
|
|
||||||
const current = holdFavoriteRef.current;
|
|
||||||
current.number = number;
|
|
||||||
current.longPress = false;
|
|
||||||
if (current.timer) window.clearTimeout(current.timer);
|
|
||||||
current.timer = window.setTimeout(() => {
|
|
||||||
current.longPress = true;
|
|
||||||
toggleFavoriteNumber(number);
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
onPointerUp={() => {
|
|
||||||
const current = holdFavoriteRef.current;
|
|
||||||
if (current.timer) {
|
|
||||||
window.clearTimeout(current.timer);
|
|
||||||
current.timer = null;
|
|
||||||
}
|
|
||||||
if (!current.longPress) {
|
|
||||||
fillCurrentRow(number);
|
|
||||||
}
|
|
||||||
current.longPress = false;
|
|
||||||
}}
|
|
||||||
onPointerLeave={() => {
|
|
||||||
const current = holdFavoriteRef.current;
|
|
||||||
if (current.timer) {
|
|
||||||
window.clearTimeout(current.timer);
|
|
||||||
current.timer = null;
|
|
||||||
}
|
|
||||||
current.longPress = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{number}
|
|
||||||
<span className="text-[11px] font-medium opacity-70">
|
|
||||||
{t("hall.quickFill.tapHold")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<span className="mr-0.5 text-[11px] font-semibold text-slate-400">
|
|
||||||
{t("hall.quickFill.history")}
|
|
||||||
</span>
|
|
||||||
{historyChips.length > 0 ? (
|
|
||||||
historyChips.map((number) => (
|
|
||||||
<button
|
|
||||||
key={`his-${number}`}
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-8 min-w-10 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-3 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
|
|
||||||
onClick={() => fillCurrentRow(number)}
|
|
||||||
>
|
|
||||||
{number}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex h-8 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-3 text-xs text-slate-400">
|
|
||||||
{t("hall.quickFill.emptyHistory")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn("overflow-x-auto pb-1", isMobile ? "-mx-1 flex gap-1.5 px-1" : "flex items-end gap-2")}>
|
|
||||||
{categoryTabs.map((tab) => {
|
{categoryTabs.map((tab) => {
|
||||||
const hasPlays = openPlays.some(
|
const hasPlays = openPlays.some(
|
||||||
(play) => playCategory(play.play_code) === tab.value,
|
(play) => playCategory(play.play_code) === tab.value,
|
||||||
@@ -1594,30 +1540,102 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.value}
|
key={`desktop-${tab.value}`}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!hasPlays}
|
disabled={!hasPlays}
|
||||||
onClick={() => setActiveCategory(tab.value)}
|
onClick={() => setActiveCategory(tab.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center border font-bold transition-colors",
|
"inline-flex min-w-14 items-center justify-center rounded-lg px-2 py-2 text-sm font-bold transition-colors xl:min-w-[4.5rem] xl:px-3.5",
|
||||||
isMobile
|
|
||||||
? "min-w-[5.25rem] rounded-t-xl border-b-0 px-4 py-3 text-sm"
|
|
||||||
: "min-w-[6rem] rounded-t-xl px-4 py-3 text-sm",
|
|
||||||
active
|
active
|
||||||
? "border-[#d7e1f3] border-b-white bg-white text-[#21335b] shadow-[0_-1px_0_rgba(255,255,255,0.8)]"
|
? "bg-[#2d63e2] text-white shadow-[0_4px_12px_rgba(45,99,226,0.24)]"
|
||||||
: "border-transparent bg-transparent text-[#6d8fd6] hover:text-[#2d63e2]",
|
: "text-[#5b7fbf] hover:bg-white hover:text-[#2d63e2]",
|
||||||
!hasPlays && "cursor-not-allowed opacity-40",
|
!hasPlays && "cursor-not-allowed opacity-40",
|
||||||
)}
|
)}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
>
|
>
|
||||||
<span className="mr-2 text-[13px]" aria-hidden>
|
|
||||||
▦
|
|
||||||
</span>
|
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
fillControls={(
|
||||||
|
<div className="flex min-h-9 min-w-0 flex-wrap items-center gap-1.5">
|
||||||
|
{favoriteChips.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="mr-0.5 text-xs font-bold text-[#d81435]">
|
||||||
|
{t("hall.quickFill.favorites")}
|
||||||
|
</span>
|
||||||
|
{favoriteChips.slice(0, 5).map((number) => (
|
||||||
|
<button
|
||||||
|
key={`desktop-fav-${number}`}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-8 items-center rounded-full border border-[#ffd7db] bg-[#fff3f5] px-3 text-xs font-bold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||||
|
onClick={() => fillCurrentRow(number)}
|
||||||
|
>
|
||||||
|
{number}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<span className="ml-1 mr-0.5 text-xs font-bold text-slate-400">
|
||||||
|
{t("hall.quickFill.history")}
|
||||||
|
</span>
|
||||||
|
{historyChips.length > 0 ? (
|
||||||
|
historyChips.slice(0, 6).map((number) => (
|
||||||
|
<button
|
||||||
|
key={`desktop-his-${number}`}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-8 min-w-10 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-3 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
|
||||||
|
onClick={() => fillCurrentRow(number)}
|
||||||
|
>
|
||||||
|
{number}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex h-8 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-3 text-xs text-slate-400">
|
||||||
|
{t("hall.quickFill.emptyHistory")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
reviewControls={(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 flex-1 justify-center rounded-lg border-[#dfe6f0] bg-white text-sm font-bold text-slate-600"
|
||||||
|
onClick={clearAllRows}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" aria-hidden />
|
||||||
|
{t("hall.quickFill.clearAll")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HallPlaySummaryGrid items={summaryItems} className="lg:hidden" />
|
||||||
|
|
||||||
|
{isMobile ? (
|
||||||
|
<HallMobileQuickFill
|
||||||
|
activeNumber={activeRow?.number ?? ""}
|
||||||
|
favorites={favoriteChips}
|
||||||
|
history={historyChips}
|
||||||
|
expanded={quickFillExpanded}
|
||||||
|
tableDisabled={tableDisabled}
|
||||||
|
activeCategory={activeCategory}
|
||||||
|
availableCategories={availableCategories}
|
||||||
|
onExpandedChange={setQuickFillExpanded}
|
||||||
|
onCategoryChange={setActiveCategory}
|
||||||
|
onFillNumber={fillCurrentRow}
|
||||||
|
onToggleFavorite={toggleFavoriteNumber}
|
||||||
|
onClearAll={clearAllRows}
|
||||||
|
onApplyQuickAmount={applyQuickAmountToActiveRow}
|
||||||
|
onCopyPreviousRow={copyPreviousRowToActiveRow}
|
||||||
|
onClearActiveRowAmounts={clearActiveRowAmounts}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{activeCategoryPlays.length === 0 ? (
|
{activeCategoryPlays.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
@@ -1628,7 +1646,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className={cn("min-w-0", !isMobile && "flex items-start gap-3")}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0",
|
||||||
|
!isMobile &&
|
||||||
|
"flex flex-col items-stretch gap-3 min-[1500px]:flex-row min-[1500px]:items-start",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 flex-1 overflow-hidden border border-[#e6edf8] bg-white transition-opacity",
|
"min-w-0 flex-1 overflow-hidden border border-[#e6edf8] bg-white transition-opacity",
|
||||||
@@ -1637,238 +1661,74 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
showWideTableHint && "player-table-scroll-wrap",
|
showWideTableHint && "player-table-scroll-wrap",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mb-3 overflow-x-auto rounded border border-[#dae3f3] shadow-sm">
|
<HallBettingTable
|
||||||
<table className="w-full border-collapse text-center text-[11px] tabular-nums">
|
rows={rows}
|
||||||
<thead>
|
activeRowId={activeRowId}
|
||||||
<tr className="bg-[#2d63e2] text-white">
|
tableDisabled={tableDisabled}
|
||||||
<th className="border-r border-[#4a7aeb] px-2 py-1.5 font-bold">
|
tableWidthPx={tableWidthPx}
|
||||||
{t("hall.table.total", { defaultValue: "共" })}
|
isMobile={isMobile}
|
||||||
</th>
|
indexColClass={indexColClass}
|
||||||
{allPlayColumns.map((column) => (
|
numberColClass={numberColClass}
|
||||||
<th key={column.key} className="border-r border-[#4a7aeb] px-1 py-1.5 font-bold last:border-0">
|
stickyNumberLeftClass={stickyNumberLeftClass}
|
||||||
{playColumnHeaderLabel(
|
numberPlaceholder={numberPlaceholder}
|
||||||
column.play,
|
numberMaxChars={numberMaxChars}
|
||||||
playCategory(column.play.play_code),
|
activeCategory={activeCategory}
|
||||||
column.digitSlot,
|
playColumns={playColumns}
|
||||||
t,
|
amountColClass={amountColClass}
|
||||||
)}
|
syncAmountColumns={syncAmountColumns}
|
||||||
</th>
|
selectionTypeColClass={selectionTypeColClass}
|
||||||
))}
|
showSelectionTypeColumn={showSelectionTypeColumn}
|
||||||
</tr>
|
selectionTypeOptions={selectionTypeOptions}
|
||||||
</thead>
|
betProviders={betProviders}
|
||||||
<tbody className="bg-white text-[#17408d]">
|
providerColClass={providerColClass}
|
||||||
<tr>
|
rowTotalColClass={rowTotalColClass}
|
||||||
<td className="border-r border-[#edf2f8] px-2 py-1.5 font-bold text-[#e5002c]">
|
alertRows={alertRows}
|
||||||
{formatMinorAmount(submitActualMinor)}
|
liveSoldOutNumbers={liveSoldOutNumbers}
|
||||||
</td>
|
liveWarningNumbers={liveWarningNumbers}
|
||||||
{allPlayColumns.map((column) => {
|
currencyCode={currencyCode}
|
||||||
const colTotalMinor = rows.reduce((sum, row) => {
|
onToggleSyncAmountColumn={toggleSyncAmountColumn}
|
||||||
const amtStr = row.amounts[column.key];
|
onSetAllSelectionTypes={requestAllSelectionTypes}
|
||||||
if (!amtStr) return sum;
|
onToggleProviderColumn={toggleProviderColumn}
|
||||||
if (
|
onUpdateRowNumber={updateRowNumber}
|
||||||
row.number.trim().length === 0 ||
|
onUpdateRowSelectionType={requestRowSelectionType}
|
||||||
draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null
|
onUpdateAmount={updateAmount}
|
||||||
) {
|
onToggleRowProvider={toggleRowProvider}
|
||||||
return sum;
|
onSetActiveRowId={setActiveRowId}
|
||||||
}
|
t={t}
|
||||||
const minorAmt = parseDecimalInputToMinor(amtStr, currencyCode);
|
/>
|
||||||
return sum + (minorAmt ?? 0);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<td key={`val-${column.key}`} className="border-r border-[#edf2f8] px-1 py-1.5 font-bold last:border-0">
|
|
||||||
{formatMinorAmount(colTotalMinor)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="player-table-scroll flex overflow-x-auto overscroll-x-contain">
|
|
||||||
<table
|
|
||||||
className="w-max border-collapse text-[11px]"
|
|
||||||
style={{ width: tableWidthPx }}
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
|
|
||||||
<th className={cn("sticky left-0 z-30 bg-[#f8fafd] px-0.5 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]", indexColClass)}>
|
|
||||||
{t("hall.table.no", { defaultValue: "No." })}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className={cn(
|
|
||||||
"sticky z-30 bg-[#f8fafd] px-1 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
|
||||||
stickyNumberLeftClass,
|
|
||||||
numberColClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="block text-xs">{t("hall.table.number", { defaultValue: "Number" })}</span>
|
|
||||||
<span className="mt-0.5 block font-mono text-[10px] font-medium text-[#9aa8bd]">
|
|
||||||
{numberPlaceholder}
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
{playColumns.map((column) => (
|
|
||||||
<th
|
|
||||||
key={column.key}
|
|
||||||
className={cn(amountColClass, "px-0.5 py-1.5 text-center font-bold")}
|
|
||||||
>
|
|
||||||
<span className="block whitespace-nowrap text-[10px] leading-tight">
|
|
||||||
{playColumnHeaderLabel(
|
|
||||||
column.play,
|
|
||||||
playCategory(column.play.play_code),
|
|
||||||
column.digitSlot,
|
|
||||||
t,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<Checkbox
|
|
||||||
checked={syncAmountColumns[column.key] === true}
|
|
||||||
disabled={tableDisabled}
|
|
||||||
onCheckedChange={(checked) => setSyncAmountColumns((current) => ({
|
|
||||||
...current,
|
|
||||||
[column.key]: checked === true,
|
|
||||||
}))}
|
|
||||||
aria-label={t("hall.table.syncColumnAmount", {
|
|
||||||
column: playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t),
|
|
||||||
})}
|
|
||||||
title={t("hall.table.syncColumnAmount", {
|
|
||||||
column: playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t),
|
|
||||||
})}
|
|
||||||
className="mx-auto mt-1 size-3.5"
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
{showSelectionTypeColumn ? (
|
|
||||||
<th className={cn(selectionTypeColClass, "px-1 py-1 text-center font-bold")}>
|
|
||||||
<span className="block text-xs">{t("hall.table.selectionType", { defaultValue: "Type" })}</span>
|
|
||||||
<select
|
|
||||||
defaultValue=""
|
|
||||||
disabled={tableDisabled}
|
|
||||||
onChange={(event) => {
|
|
||||||
const selectionType = event.target.value as DraftRow["selectionType"] | "";
|
|
||||||
if (selectionType) updateAllSelectionTypes(selectionType);
|
|
||||||
event.currentTarget.value = "";
|
|
||||||
}}
|
|
||||||
className="mx-auto mt-1 h-5 w-full rounded border border-[#d7e1f3] bg-white px-0.5 text-[10px] font-semibold text-[#304f86]"
|
|
||||||
aria-label={t("hall.table.selectAllTypes")}
|
|
||||||
title={t("hall.table.setAllTypes")}
|
|
||||||
>
|
|
||||||
<option value="">{t("hall.table.selectAll")}</option>
|
|
||||||
<option value="straight">{t("hall.table.selectionTypes.straight")}</option>
|
|
||||||
<option value="reverse">{t("hall.table.selectionTypes.reverse")}</option>
|
|
||||||
<option value="full_cover">{t("hall.table.selectionTypes.full_cover")}</option>
|
|
||||||
<option value="full_play">{t("hall.table.selectionTypes.full_play")}</option>
|
|
||||||
{activeCategory === "D4" ? <option value="half_play">{t("hall.table.selectionTypes.half_play")}</option> : null}
|
|
||||||
</select>
|
|
||||||
</th>
|
|
||||||
) : null}
|
|
||||||
{betProviders.map((provider, providerIndex) => {
|
|
||||||
const checked = rows.length > 0 && rows.every((row) => row.providerCodes.includes(provider.code));
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
key={provider.code}
|
|
||||||
className={cn(providerColClass, providerColumnTone(providerIndex), "px-0.5 py-1 text-center font-black")}
|
|
||||||
title={provider.name}
|
|
||||||
>
|
|
||||||
<span className="block text-sm leading-none">{provider.short_code}</span>
|
|
||||||
<Checkbox
|
|
||||||
checked={checked}
|
|
||||||
disabled={tableDisabled}
|
|
||||||
onCheckedChange={(next) => toggleProviderColumn(provider.code, next === true)}
|
|
||||||
aria-label={t("hall.table.selectAllProvider", { provider: provider.name })}
|
|
||||||
className="mx-auto mt-1 border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800"
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<th className={cn(rowTotalColClass, "bg-[#eef5ff] px-1 py-1 text-center font-black text-[#17408d]")}>
|
|
||||||
<span className="block text-xs">{t("hall.table.rowTotal", { defaultValue: "Total" })}</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row, index) => (
|
|
||||||
<DraftRowItem
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
index={index}
|
|
||||||
rowActive={activeRowId === row.id}
|
|
||||||
tableDisabled={tableDisabled}
|
|
||||||
numberPlaceholder={numberPlaceholder}
|
|
||||||
activeCategory={activeCategory}
|
|
||||||
numberMaxChars={numberMaxChars}
|
|
||||||
betProviders={betProviders}
|
|
||||||
playColumns={playColumns}
|
|
||||||
alertRows={alertRows}
|
|
||||||
liveSoldOutNumbers={liveSoldOutNumbers}
|
|
||||||
liveWarningNumbers={liveWarningNumbers}
|
|
||||||
updateRowNumber={updateRowNumber}
|
|
||||||
updateRowSelectionType={updateRowSelectionType}
|
|
||||||
updateAmount={updateAmount}
|
|
||||||
toggleRowProvider={toggleRowProvider}
|
|
||||||
setActiveRowId={setActiveRowId}
|
|
||||||
t={t}
|
|
||||||
isMobile={isMobile}
|
|
||||||
indexColClass={indexColClass}
|
|
||||||
numberColClass={numberColClass}
|
|
||||||
stickyNumberLeftClass={stickyNumberLeftClass}
|
|
||||||
selectionTypeColClass={selectionTypeColClass}
|
|
||||||
showSelectionTypeColumn={showSelectionTypeColumn}
|
|
||||||
providerColClass={providerColClass}
|
|
||||||
rowTotalColClass={rowTotalColClass}
|
|
||||||
currencyCode={currencyCode}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isMobile ? (
|
{!isMobile ? (
|
||||||
<aside className="sticky top-3 z-20 w-64 shrink-0 self-start rounded-xl border border-[#e6edf8] bg-[#f8fbff] px-4 py-4 text-[#304f86] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
<HallDesktopReviewPanel
|
||||||
<p className="text-xs font-black">{t("hall.table.orderSummary", { defaultValue: "Order summary" })}</p>
|
model={desktopReviewModel}
|
||||||
<dl className="mt-5 space-y-4">
|
disabled={!canSubmit}
|
||||||
<div className="flex items-center justify-between gap-3">
|
loading={previewLoading}
|
||||||
<dt className="text-xs text-slate-500">{t("hall.table.lineCount", { defaultValue: "Lines" })}</dt>
|
isBettable={isBettable}
|
||||||
<dd className="font-mono text-base font-black tabular-nums">{draftEntries.length}</dd>
|
onSubmit={() => void handlePreview()}
|
||||||
</div>
|
labels={{
|
||||||
<div className="flex items-center justify-between gap-3">
|
title: t("hall.desktop.reviewTitle", { defaultValue: "核对与提交" }),
|
||||||
<dt className="text-xs text-slate-500">{t("hall.table.providerCount", { defaultValue: "Providers" })}</dt>
|
lineCount: t("hall.table.lineCount"),
|
||||||
<dd className="font-mono text-base font-black tabular-nums">{selectedProviderCount}</dd>
|
providerCount: t("hall.table.providerCount"),
|
||||||
</div>
|
actualTotal: t("hall.table.actualTotal"),
|
||||||
<div className="border-t border-[#dce6f5] pt-4">
|
availableCredit: t("hall.desktop.availableCredit", { defaultValue: "可用信用" }),
|
||||||
<dt className="text-xs text-slate-500">{t("hall.table.actualTotal")}</dt>
|
remainingCredit: t("hall.desktop.remainingCredit", { defaultValue: "下注后可用" }),
|
||||||
<dd className="mt-1 text-lg font-black tabular-nums text-[#0b3f96]">
|
noWarnings: t("hall.desktop.noWarnings", { defaultValue: "暂无风险提醒" }),
|
||||||
{formatMinorAsCurrency(submitActualMinor, currencyCode)}
|
warningCount: t("hall.desktop.warningCount", {
|
||||||
</dd>
|
defaultValue: "{{count}} 行接近售罄",
|
||||||
</div>
|
count: warningRowCount,
|
||||||
</dl>
|
}),
|
||||||
<Button
|
creditEnough: t("hall.desktop.creditEnough", { defaultValue: "可用信用充足" }),
|
||||||
type="button"
|
creditInsufficient: t("hall.desktop.creditInsufficient", { defaultValue: "可用信用不足" }),
|
||||||
disabled={!canSubmit || previewLoading}
|
submit: previewLoading
|
||||||
onClick={() => void handlePreview()}
|
|
||||||
className={cn(
|
|
||||||
"mt-6 h-11 w-full gap-2 rounded-lg border-0 text-sm font-bold text-white",
|
|
||||||
!isBettable
|
|
||||||
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
|
|
||||||
: "bg-[#e5002c] shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!isBettable ? (
|
|
||||||
<Lock className="size-4 shrink-0" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Ticket className="size-4 shrink-0" aria-hidden />
|
|
||||||
)}
|
|
||||||
{previewLoading
|
|
||||||
? t("hall.table.previewing")
|
? t("hall.table.previewing")
|
||||||
: !isBettable
|
: !isBettable
|
||||||
? t("hall.closed.title")
|
? t("hall.closed.title")
|
||||||
: availableMinor < submitActualMinor
|
: availableMinor < submitActualMinor
|
||||||
? t("hall.table.insufficientBalance")
|
? t("hall.table.insufficientBalance")
|
||||||
: t("hall.table.submitBet")}
|
: t("hall.table.submitBet"),
|
||||||
</Button>
|
}}
|
||||||
</aside>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1933,6 +1793,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
setPreviewData(null);
|
setPreviewData(null);
|
||||||
if (!placeLoading) {
|
if (!placeLoading) {
|
||||||
|
previewRequestSeqRef.current += 1;
|
||||||
|
previewSubmissionRef.current = null;
|
||||||
clearPlaceTraceId();
|
clearPlaceTraceId();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1956,211 +1818,15 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
jackpotEnabled={Boolean(jackpot?.enabled)}
|
jackpotEnabled={Boolean(jackpot?.enabled)}
|
||||||
creditMode={creditMode}
|
creditMode={creditMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<HallSelectionConfirmDialog
|
||||||
|
open={pendingSelectionChange !== null}
|
||||||
|
preview={pendingSelectionPreview}
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
onCancel={cancelPendingSelectionChange}
|
||||||
|
onConfirm={confirmPendingSelectionChange}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DraftRowItem = memo(function DraftRowItem({
|
|
||||||
row,
|
|
||||||
index,
|
|
||||||
rowActive,
|
|
||||||
tableDisabled,
|
|
||||||
numberPlaceholder,
|
|
||||||
activeCategory,
|
|
||||||
numberMaxChars,
|
|
||||||
betProviders,
|
|
||||||
playColumns,
|
|
||||||
alertRows,
|
|
||||||
liveSoldOutNumbers,
|
|
||||||
liveWarningNumbers,
|
|
||||||
updateRowNumber,
|
|
||||||
updateRowSelectionType,
|
|
||||||
updateAmount,
|
|
||||||
toggleRowProvider,
|
|
||||||
setActiveRowId,
|
|
||||||
t,
|
|
||||||
isMobile,
|
|
||||||
indexColClass,
|
|
||||||
numberColClass,
|
|
||||||
stickyNumberLeftClass,
|
|
||||||
selectionTypeColClass,
|
|
||||||
showSelectionTypeColumn,
|
|
||||||
providerColClass,
|
|
||||||
rowTotalColClass,
|
|
||||||
currencyCode,
|
|
||||||
}: {
|
|
||||||
row: DraftRow;
|
|
||||||
index: number;
|
|
||||||
rowActive: boolean;
|
|
||||||
tableDisabled: boolean;
|
|
||||||
numberPlaceholder: string;
|
|
||||||
activeCategory: PlayHallCategory;
|
|
||||||
numberMaxChars: number;
|
|
||||||
betProviders: BetProviderRow[];
|
|
||||||
playColumns: PlayColumn[];
|
|
||||||
alertRows: DrawCurrentRiskPoolAlert[];
|
|
||||||
liveSoldOutNumbers: Set<string>;
|
|
||||||
liveWarningNumbers: Set<string>;
|
|
||||||
updateRowNumber: (id: string, value: string) => void;
|
|
||||||
updateRowSelectionType: (id: string, value: DraftRow["selectionType"]) => void;
|
|
||||||
updateAmount: (rowId: string, playCode: string, value: string) => void;
|
|
||||||
toggleRowProvider: (rowId: string, code: string) => void;
|
|
||||||
setActiveRowId: (id: string) => void;
|
|
||||||
t: HallTranslate;
|
|
||||||
isMobile: boolean;
|
|
||||||
indexColClass: string;
|
|
||||||
numberColClass: string;
|
|
||||||
stickyNumberLeftClass: string;
|
|
||||||
selectionTypeColClass: string;
|
|
||||||
showSelectionTypeColumn: boolean;
|
|
||||||
providerColClass: string;
|
|
||||||
rowTotalColClass: string;
|
|
||||||
currencyCode: string;
|
|
||||||
}) {
|
|
||||||
const displayNumber = sanitizeNumber(row.number, activeCategory);
|
|
||||||
const rowTotalMinor = playColumns.reduce((total, column) => {
|
|
||||||
if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) return total;
|
|
||||||
return total + (parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0);
|
|
||||||
}, 0) * row.providerCodes.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
className={cn(
|
|
||||||
"border-b border-[#f0f3f8] last:border-b-0",
|
|
||||||
rowActive && "bg-[#f5f9ff]/80",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
className={cn(
|
|
||||||
"sticky left-0 z-20 px-0.5 py-1.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
|
||||||
indexColClass,
|
|
||||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className={cn(
|
|
||||||
"sticky z-20 px-1 py-1.5 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
|
||||||
stickyNumberLeftClass,
|
|
||||||
numberColClass,
|
|
||||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={displayNumber}
|
|
||||||
disabled={tableDisabled}
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9Rr]*"
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder={numberPlaceholder}
|
|
||||||
maxLength={numberMaxChars}
|
|
||||||
onFocus={() => setActiveRowId(row.id)}
|
|
||||||
onClick={() => setActiveRowId(row.id)}
|
|
||||||
onChange={(event) => updateRowNumber(row.id, event.target.value)}
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
|
|
||||||
!isMobile ? "text-[13px]" : "text-sm",
|
|
||||||
"tracking-[0.12em]",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
{playColumns.map((column) => {
|
|
||||||
const { play } = column;
|
|
||||||
const amountText = row.amounts[column.key] ?? "";
|
|
||||||
const status = cellRiskState(
|
|
||||||
play,
|
|
||||||
row.number,
|
|
||||||
playCategory(play.play_code),
|
|
||||||
alertRows,
|
|
||||||
liveSoldOutNumbers,
|
|
||||||
liveWarningNumbers,
|
|
||||||
column.digitSlot,
|
|
||||||
);
|
|
||||||
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
|
|
||||||
const hasAmount = amountText.trim().length > 0;
|
|
||||||
const isInputValidForPlay = row.number.trim().length > 0 && draftLineIssueReason(play.play_code, row.number, column.digitSlot) === null;
|
|
||||||
const cellDisabled = disabled || !isInputValidForPlay;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
key={`${row.id}-${column.key}`}
|
|
||||||
className={cn(
|
|
||||||
"px-0.5 py-1.5 align-top",
|
|
||||||
status === "warning" && "bg-amber-50/70",
|
|
||||||
status === "sold_out" && "bg-slate-100 text-slate-400",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isInputValidForPlay ? (
|
|
||||||
<Input
|
|
||||||
value={amountText}
|
|
||||||
disabled={cellDisabled}
|
|
||||||
inputMode="decimal"
|
|
||||||
placeholder={
|
|
||||||
status === "sold_out"
|
|
||||||
? t("hall.table.soldOut")
|
|
||||||
: "-"
|
|
||||||
}
|
|
||||||
onFocus={() => setActiveRowId(row.id)}
|
|
||||||
onClick={() => setActiveRowId(row.id)}
|
|
||||||
onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-0.5 text-center font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
|
|
||||||
!isMobile ? "text-[13px]" : "text-sm",
|
|
||||||
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
|
|
||||||
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
|
|
||||||
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-8 w-full rounded-md border border-slate-200 bg-slate-100/70" />
|
|
||||||
)}
|
|
||||||
{isInputValidForPlay && status === "sold_out" ? (
|
|
||||||
<p className="mt-0.5 text-center text-[10px] font-bold text-slate-500">
|
|
||||||
{t("hall.table.soldOut")}
|
|
||||||
</p>
|
|
||||||
) : status === "warning" ? (
|
|
||||||
<p className="mt-0.5 text-center text-[10px] font-bold text-amber-700">
|
|
||||||
{t("hall.table.warning")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{showSelectionTypeColumn ? (
|
|
||||||
<td className={cn(selectionTypeColClass, "px-1 py-1.5 text-center", rowActive && "bg-[#f5f9ff]")}>
|
|
||||||
<select
|
|
||||||
value={row.selectionType}
|
|
||||||
disabled={tableDisabled}
|
|
||||||
onChange={(event) => updateRowSelectionType(row.id, event.target.value as DraftRow["selectionType"])}
|
|
||||||
className="h-8 w-full rounded-md border border-[#e1e8f3] bg-white px-1 text-[10px] font-semibold text-[#304f86]"
|
|
||||||
aria-label={t("hall.table.selectionTypeForRow", { row: index + 1 })}
|
|
||||||
>
|
|
||||||
<option value="straight">{t("hall.table.selectionTypes.straight")}</option>
|
|
||||||
<option value="reverse">{t("hall.table.selectionTypes.reverse")}</option>
|
|
||||||
<option value="full_cover">{t("hall.table.selectionTypes.full_cover")}</option>
|
|
||||||
<option value="full_play">{t("hall.table.selectionTypes.full_play")}</option>
|
|
||||||
{activeCategory === "D4" ? <option value="half_play">{t("hall.table.selectionTypes.half_play")}</option> : null}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
) : null}
|
|
||||||
{betProviders.map((provider, providerIndex) => (
|
|
||||||
<td
|
|
||||||
key={provider.code}
|
|
||||||
className={cn(providerColClass, providerColumnTone(providerIndex), "px-0.5 py-1.5 text-center")}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={row.providerCodes.includes(provider.code)}
|
|
||||||
disabled={tableDisabled}
|
|
||||||
onCheckedChange={() => toggleRowProvider(row.id, provider.code)}
|
|
||||||
aria-label={`${provider.name} ${index + 1}`}
|
|
||||||
className="mx-auto border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td className={cn(rowTotalColClass, "bg-[#f7fbff] px-1 py-1.5 text-center font-mono font-black tabular-nums text-[#0b3f96]", rowActive && "bg-[#edf5ff]")}>
|
|
||||||
{formatMinorAmount(rowTotalMinor)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
257
src/features/hall/hall-betting-table.tsx
Normal file
257
src/features/hall/hall-betting-table.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { HallDraftRowItem } from "@/features/hall/hall-draft-row-item";
|
||||||
|
import {
|
||||||
|
playCategory,
|
||||||
|
playColumnHeaderLabel,
|
||||||
|
providerColumnTone,
|
||||||
|
type DraftRow,
|
||||||
|
type HallTranslate,
|
||||||
|
type PlayColumn,
|
||||||
|
type PlayHallCategory,
|
||||||
|
} from "@/features/hall/hall-betting-grid-model";
|
||||||
|
import type { SelectionType } from "@/features/hall/selection-type";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { BetProviderRow } from "@/types/api/bet-provider";
|
||||||
|
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
||||||
|
|
||||||
|
type HallBettingTableProps = {
|
||||||
|
rows: DraftRow[];
|
||||||
|
activeRowId: string | null;
|
||||||
|
tableDisabled: boolean;
|
||||||
|
tableWidthPx: number;
|
||||||
|
isMobile: boolean;
|
||||||
|
indexColClass: string;
|
||||||
|
numberColClass: string;
|
||||||
|
stickyNumberLeftClass: string;
|
||||||
|
numberPlaceholder: string;
|
||||||
|
numberMaxChars: number;
|
||||||
|
activeCategory: PlayHallCategory;
|
||||||
|
playColumns: PlayColumn[];
|
||||||
|
amountColClass: string;
|
||||||
|
syncAmountColumns: Record<string, boolean>;
|
||||||
|
selectionTypeColClass: string;
|
||||||
|
showSelectionTypeColumn: boolean;
|
||||||
|
selectionTypeOptions: SelectionType[];
|
||||||
|
betProviders: BetProviderRow[];
|
||||||
|
providerColClass: string;
|
||||||
|
rowTotalColClass: string;
|
||||||
|
alertRows: DrawCurrentRiskPoolAlert[];
|
||||||
|
liveSoldOutNumbers: Set<string>;
|
||||||
|
liveWarningNumbers: Set<string>;
|
||||||
|
currencyCode: string;
|
||||||
|
onToggleSyncAmountColumn: (column: PlayColumn, checked: boolean) => void;
|
||||||
|
onSetAllSelectionTypes: (selectionType: SelectionType) => void;
|
||||||
|
onToggleProviderColumn: (code: string, checked: boolean) => void;
|
||||||
|
onUpdateRowNumber: (id: string, value: string) => void;
|
||||||
|
onUpdateRowSelectionType: (id: string, value: SelectionType) => void;
|
||||||
|
onUpdateAmount: (rowId: string, playCode: string, value: string) => void;
|
||||||
|
onToggleRowProvider: (rowId: string, code: string) => void;
|
||||||
|
onSetActiveRowId: (id: string) => void;
|
||||||
|
t: HallTranslate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HallBettingTable({
|
||||||
|
rows,
|
||||||
|
activeRowId,
|
||||||
|
tableDisabled,
|
||||||
|
tableWidthPx,
|
||||||
|
isMobile,
|
||||||
|
indexColClass,
|
||||||
|
numberColClass,
|
||||||
|
stickyNumberLeftClass,
|
||||||
|
numberPlaceholder,
|
||||||
|
numberMaxChars,
|
||||||
|
activeCategory,
|
||||||
|
playColumns,
|
||||||
|
amountColClass,
|
||||||
|
syncAmountColumns,
|
||||||
|
selectionTypeColClass,
|
||||||
|
showSelectionTypeColumn,
|
||||||
|
selectionTypeOptions,
|
||||||
|
betProviders,
|
||||||
|
providerColClass,
|
||||||
|
rowTotalColClass,
|
||||||
|
alertRows,
|
||||||
|
liveSoldOutNumbers,
|
||||||
|
liveWarningNumbers,
|
||||||
|
currencyCode,
|
||||||
|
onToggleSyncAmountColumn,
|
||||||
|
onSetAllSelectionTypes,
|
||||||
|
onToggleProviderColumn,
|
||||||
|
onUpdateRowNumber,
|
||||||
|
onUpdateRowSelectionType,
|
||||||
|
onUpdateAmount,
|
||||||
|
onToggleRowProvider,
|
||||||
|
onSetActiveRowId,
|
||||||
|
t,
|
||||||
|
}: HallBettingTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="player-table-scroll overflow-x-auto overscroll-x-contain">
|
||||||
|
<table
|
||||||
|
className={cn("mx-auto w-max border-collapse", isMobile ? "text-[11px]" : "text-sm")}
|
||||||
|
style={{ width: tableWidthPx }}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
"sticky left-0 z-30 bg-[#f8fafd] px-0.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||||
|
isMobile ? "py-1.5" : "py-2.5",
|
||||||
|
indexColClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("hall.table.no", { defaultValue: "No." })}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
"sticky z-30 bg-[#f8fafd] px-1 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||||
|
isMobile ? "py-1.5" : "py-2.5",
|
||||||
|
stickyNumberLeftClass,
|
||||||
|
numberColClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block text-xs">
|
||||||
|
{t("hall.table.number", { defaultValue: "Number" })}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block font-mono text-[10px] font-medium text-[#9aa8bd]">
|
||||||
|
{numberPlaceholder}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
{playColumns.map((column) => {
|
||||||
|
const label = playColumnHeaderLabel(
|
||||||
|
column.play,
|
||||||
|
playCategory(column.play.play_code),
|
||||||
|
column.digitSlot,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={column.key}
|
||||||
|
className={cn(
|
||||||
|
amountColClass,
|
||||||
|
"px-0.5 text-center font-bold",
|
||||||
|
isMobile ? "py-1.5" : "py-2.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block whitespace-nowrap text-[10px] leading-tight">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<Checkbox
|
||||||
|
checked={syncAmountColumns[column.key] === true}
|
||||||
|
disabled={tableDisabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onToggleSyncAmountColumn(column, checked === true)
|
||||||
|
}
|
||||||
|
aria-label={t("hall.table.syncColumnAmount", { column: label })}
|
||||||
|
title={t("hall.table.syncColumnAmount", { column: label })}
|
||||||
|
className={cn("mx-auto mt-1", isMobile ? "size-3.5" : "size-4")}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showSelectionTypeColumn ? (
|
||||||
|
<th className={cn(selectionTypeColClass, "px-1 py-1 text-center font-bold")}>
|
||||||
|
<span className="block text-xs">
|
||||||
|
{t("hall.table.selectionType", { defaultValue: "Type" })}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
defaultValue=""
|
||||||
|
disabled={tableDisabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
const selectionType = event.target.value as SelectionType | "";
|
||||||
|
if (selectionType) onSetAllSelectionTypes(selectionType);
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
className="mx-auto mt-1 h-5 w-full rounded border border-[#d7e1f3] bg-white px-0.5 text-[10px] font-semibold text-[#304f86]"
|
||||||
|
aria-label={t("hall.table.selectAllTypes")}
|
||||||
|
title={t("hall.table.setAllTypes")}
|
||||||
|
>
|
||||||
|
<option value="">{t("hall.table.selectAll")}</option>
|
||||||
|
{selectionTypeOptions.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{t(`hall.table.selectionTypes.${type}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</th>
|
||||||
|
) : null}
|
||||||
|
{betProviders.map((provider, providerIndex) => {
|
||||||
|
const checked =
|
||||||
|
rows.length > 0 &&
|
||||||
|
rows.every((row) => row.providerCodes.includes(provider.code));
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={provider.code}
|
||||||
|
className={cn(
|
||||||
|
providerColClass,
|
||||||
|
providerColumnTone(providerIndex),
|
||||||
|
"px-0.5 py-1 text-center font-black",
|
||||||
|
)}
|
||||||
|
title={provider.name}
|
||||||
|
>
|
||||||
|
<span className="block text-sm leading-none">{provider.short_code}</span>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
disabled={tableDisabled}
|
||||||
|
onCheckedChange={(next) =>
|
||||||
|
onToggleProviderColumn(provider.code, next === true)
|
||||||
|
}
|
||||||
|
aria-label={t("hall.table.selectAllProvider", {
|
||||||
|
provider: provider.name,
|
||||||
|
})}
|
||||||
|
className="mx-auto mt-1 border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
rowTotalColClass,
|
||||||
|
"bg-[#eef5ff] px-1 py-1 text-center font-black text-[#17408d]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block text-xs">
|
||||||
|
{t("hall.table.rowTotal", { defaultValue: "Total" })}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<HallDraftRowItem
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
index={index}
|
||||||
|
rowActive={activeRowId === row.id}
|
||||||
|
tableDisabled={tableDisabled}
|
||||||
|
numberPlaceholder={numberPlaceholder}
|
||||||
|
activeCategory={activeCategory}
|
||||||
|
numberMaxChars={numberMaxChars}
|
||||||
|
betProviders={betProviders}
|
||||||
|
playColumns={playColumns}
|
||||||
|
alertRows={alertRows}
|
||||||
|
liveSoldOutNumbers={liveSoldOutNumbers}
|
||||||
|
liveWarningNumbers={liveWarningNumbers}
|
||||||
|
updateRowNumber={onUpdateRowNumber}
|
||||||
|
updateRowSelectionType={onUpdateRowSelectionType}
|
||||||
|
selectionTypeOptions={selectionTypeOptions}
|
||||||
|
updateAmount={onUpdateAmount}
|
||||||
|
toggleRowProvider={onToggleRowProvider}
|
||||||
|
setActiveRowId={onSetActiveRowId}
|
||||||
|
t={t}
|
||||||
|
isMobile={isMobile}
|
||||||
|
indexColClass={indexColClass}
|
||||||
|
numberColClass={numberColClass}
|
||||||
|
stickyNumberLeftClass={stickyNumberLeftClass}
|
||||||
|
selectionTypeColClass={selectionTypeColClass}
|
||||||
|
showSelectionTypeColumn={showSelectionTypeColumn}
|
||||||
|
providerColClass={providerColClass}
|
||||||
|
rowTotalColClass={rowTotalColClass}
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/features/hall/hall-desktop-workspace.tsx
Normal file
184
src/features/hall/hall-desktop-workspace.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { AlertTriangle, CheckCircle2, Lock, Ticket } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type HallDesktopWorkflowBarProps = {
|
||||||
|
gameControls: ReactNode;
|
||||||
|
fillControls: ReactNode;
|
||||||
|
reviewControls: ReactNode;
|
||||||
|
stepGame: string;
|
||||||
|
stepFill: string;
|
||||||
|
stepReview: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HallDesktopWorkflowBar({
|
||||||
|
gameControls,
|
||||||
|
fillControls,
|
||||||
|
reviewControls,
|
||||||
|
stepGame,
|
||||||
|
stepFill,
|
||||||
|
stepReview,
|
||||||
|
}: HallDesktopWorkflowBarProps) {
|
||||||
|
const steps = [
|
||||||
|
{ label: stepGame, content: gameControls },
|
||||||
|
{ label: stepFill, content: fillControls },
|
||||||
|
{ label: stepReview, content: reviewControls },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hidden overflow-hidden rounded-xl border border-[#dfe8f5] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.04)] lg:grid lg:grid-cols-[minmax(10rem,0.8fr)_minmax(16rem,1.5fr)_minmax(10rem,0.65fr)] xl:grid-cols-[minmax(15rem,0.8fr)_minmax(24rem,1.5fr)_minmax(13rem,0.65fr)]">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<section
|
||||||
|
key={step.label}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 px-4 py-3",
|
||||||
|
index > 0 && "border-l border-[#e7edf6]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-[#eaf2ff] text-xs font-black text-[#1658c4]">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-sm font-black text-[#23395f]">{step.label}</h2>
|
||||||
|
</div>
|
||||||
|
{step.content}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HallDesktopReviewModel = {
|
||||||
|
lineCount: number;
|
||||||
|
providerCount: number;
|
||||||
|
actualAmount: string;
|
||||||
|
availableCredit: string;
|
||||||
|
remainingCredit: string;
|
||||||
|
warningCount: number;
|
||||||
|
creditSufficient: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HallDesktopReviewPanelProps = {
|
||||||
|
model: HallDesktopReviewModel;
|
||||||
|
labels: {
|
||||||
|
title: string;
|
||||||
|
lineCount: string;
|
||||||
|
providerCount: string;
|
||||||
|
actualTotal: string;
|
||||||
|
availableCredit: string;
|
||||||
|
remainingCredit: string;
|
||||||
|
noWarnings: string;
|
||||||
|
warningCount: string;
|
||||||
|
creditEnough: string;
|
||||||
|
creditInsufficient: string;
|
||||||
|
submit: string;
|
||||||
|
};
|
||||||
|
disabled: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
isBettable: boolean;
|
||||||
|
onSubmit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HallDesktopReviewPanel({
|
||||||
|
model,
|
||||||
|
labels,
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
|
isBettable,
|
||||||
|
onSubmit,
|
||||||
|
}: HallDesktopReviewPanelProps) {
|
||||||
|
return (
|
||||||
|
<aside className="w-full shrink-0 self-start overflow-hidden rounded-xl border border-[#dfe8f5] bg-white text-[#304f86] shadow-[0_8px_24px_rgba(15,23,42,0.05)] min-[1500px]:sticky min-[1500px]:top-3 min-[1500px]:w-80">
|
||||||
|
<div className="border-b border-[#e7edf6] bg-[#f7faff] px-4 py-3.5">
|
||||||
|
<p className="text-[15px] font-black text-[#20385f]">{labels.title}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 p-4 lg:grid-cols-[repeat(4,minmax(0,1fr))_auto] lg:items-center min-[1500px]:block min-[1500px]:space-y-4">
|
||||||
|
<dl className="grid grid-cols-2 gap-x-5 gap-y-3 lg:contents min-[1500px]:grid">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold text-slate-500">{labels.lineCount}</dt>
|
||||||
|
<dd className="mt-1 font-mono text-lg font-black tabular-nums text-[#143f86]">
|
||||||
|
{model.lineCount}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold text-slate-500">{labels.providerCount}</dt>
|
||||||
|
<dd className="mt-1 font-mono text-lg font-black tabular-nums text-[#143f86]">
|
||||||
|
{model.providerCount}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold text-slate-500">{labels.availableCredit}</dt>
|
||||||
|
<dd className="mt-1 text-sm font-black tabular-nums text-[#143f86]">
|
||||||
|
{model.availableCredit}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold text-slate-500">{labels.remainingCredit}</dt>
|
||||||
|
<dd className="mt-1 text-sm font-black tabular-nums text-[#143f86]">
|
||||||
|
{model.remainingCredit}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[#d8e5f7] bg-[#f7fbff] px-3 py-2.5 lg:min-w-36 min-[1500px]:px-4 min-[1500px]:py-3.5">
|
||||||
|
<p className="text-xs font-semibold text-slate-500">{labels.actualTotal}</p>
|
||||||
|
<p className="mt-1 text-lg font-black tabular-nums text-[#0b3f96] min-[1500px]:text-2xl">
|
||||||
|
{model.actualAmount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 lg:min-w-48 min-[1500px]:border-t min-[1500px]:border-[#e7edf6] min-[1500px]:pt-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-xs font-bold",
|
||||||
|
model.creditSufficient ? "text-emerald-700" : "text-red-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{model.creditSufficient ? (
|
||||||
|
<CheckCircle2 className="size-4 shrink-0" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="size-4 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span>{model.creditSufficient ? labels.creditEnough : labels.creditInsufficient}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||||
|
{model.warningCount > 0 ? (
|
||||||
|
<AlertTriangle className="size-4 shrink-0 text-amber-600" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="size-4 shrink-0 text-slate-400" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{model.warningCount > 0
|
||||||
|
? labels.warningCount
|
||||||
|
: labels.noWarnings}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:min-w-52 min-[1500px]:border-t min-[1500px]:border-[#e7edf6] min-[1500px]:pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onClick={onSubmit}
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full gap-2 rounded-lg border-0 text-sm font-black text-white",
|
||||||
|
!isBettable
|
||||||
|
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
|
||||||
|
: "bg-[#e5002c] shadow-[0_8px_20px_rgba(229,0,44,0.24)] hover:bg-[#d10028]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isBettable ? (
|
||||||
|
<Lock className="size-4 shrink-0" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Ticket className="size-4 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
{labels.submit}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
312
src/features/hall/hall-draft-row-item.tsx
Normal file
312
src/features/hall/hall-draft-row-item.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { Tooltip } from "@base-ui/react/tooltip";
|
||||||
|
import { CircleHelp } from "lucide-react";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { draftLineIssueReason } from "@/features/hall/hall-bet-rules";
|
||||||
|
import {
|
||||||
|
cellRiskState,
|
||||||
|
isDraftAmountInputValid,
|
||||||
|
playCategory,
|
||||||
|
providerColumnTone,
|
||||||
|
sanitizeNumber,
|
||||||
|
type DraftRow,
|
||||||
|
type HallTranslate,
|
||||||
|
type PlayColumn,
|
||||||
|
type PlayHallCategory,
|
||||||
|
} from "@/features/hall/hall-betting-grid-model";
|
||||||
|
import {
|
||||||
|
isFullCoverAmountDivisible,
|
||||||
|
resolveSelectionTotalBet,
|
||||||
|
selectionCombinationCount,
|
||||||
|
type SelectionType,
|
||||||
|
} from "@/features/hall/selection-type";
|
||||||
|
import { formatMinorAmount, parseDecimalInputToMinor } from "@/lib/money";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { BetProviderRow } from "@/types/api/bet-provider";
|
||||||
|
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
||||||
|
|
||||||
|
type HallDraftRowItemProps = {
|
||||||
|
row: DraftRow;
|
||||||
|
index: number;
|
||||||
|
rowActive: boolean;
|
||||||
|
tableDisabled: boolean;
|
||||||
|
numberPlaceholder: string;
|
||||||
|
activeCategory: PlayHallCategory;
|
||||||
|
numberMaxChars: number;
|
||||||
|
betProviders: BetProviderRow[];
|
||||||
|
playColumns: PlayColumn[];
|
||||||
|
alertRows: DrawCurrentRiskPoolAlert[];
|
||||||
|
liveSoldOutNumbers: Set<string>;
|
||||||
|
liveWarningNumbers: Set<string>;
|
||||||
|
updateRowNumber: (id: string, value: string) => void;
|
||||||
|
updateRowSelectionType: (id: string, value: SelectionType) => void;
|
||||||
|
selectionTypeOptions: SelectionType[];
|
||||||
|
updateAmount: (rowId: string, playCode: string, value: string) => void;
|
||||||
|
toggleRowProvider: (rowId: string, code: string) => void;
|
||||||
|
setActiveRowId: (id: string) => void;
|
||||||
|
t: HallTranslate;
|
||||||
|
isMobile: boolean;
|
||||||
|
indexColClass: string;
|
||||||
|
numberColClass: string;
|
||||||
|
stickyNumberLeftClass: string;
|
||||||
|
selectionTypeColClass: string;
|
||||||
|
showSelectionTypeColumn: boolean;
|
||||||
|
providerColClass: string;
|
||||||
|
rowTotalColClass: string;
|
||||||
|
currencyCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HallDraftRowItem = memo(function HallDraftRowItem({
|
||||||
|
row,
|
||||||
|
index,
|
||||||
|
rowActive,
|
||||||
|
tableDisabled,
|
||||||
|
numberPlaceholder,
|
||||||
|
activeCategory,
|
||||||
|
numberMaxChars,
|
||||||
|
betProviders,
|
||||||
|
playColumns,
|
||||||
|
alertRows,
|
||||||
|
liveSoldOutNumbers,
|
||||||
|
liveWarningNumbers,
|
||||||
|
updateRowNumber,
|
||||||
|
updateRowSelectionType,
|
||||||
|
selectionTypeOptions,
|
||||||
|
updateAmount,
|
||||||
|
toggleRowProvider,
|
||||||
|
setActiveRowId,
|
||||||
|
t,
|
||||||
|
isMobile,
|
||||||
|
indexColClass,
|
||||||
|
numberColClass,
|
||||||
|
stickyNumberLeftClass,
|
||||||
|
selectionTypeColClass,
|
||||||
|
showSelectionTypeColumn,
|
||||||
|
providerColClass,
|
||||||
|
rowTotalColClass,
|
||||||
|
currencyCode,
|
||||||
|
}: HallDraftRowItemProps) {
|
||||||
|
const displayNumber = sanitizeNumber(row.number, activeCategory);
|
||||||
|
const comboCount = selectionCombinationCount(displayNumber, row.selectionType);
|
||||||
|
const hasFullCoverAmountError =
|
||||||
|
row.selectionType === "full_cover" &&
|
||||||
|
playColumns.some((column) => {
|
||||||
|
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode);
|
||||||
|
return amount !== null && amount > 0 && !isFullCoverAmountDivisible(amount, comboCount);
|
||||||
|
});
|
||||||
|
const rowTotalMinor =
|
||||||
|
playColumns.reduce((total, column) => {
|
||||||
|
if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0;
|
||||||
|
return total + resolveSelectionTotalBet(amount, row.selectionType, comboCount);
|
||||||
|
}, 0) * row.providerCodes.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
className={cn(
|
||||||
|
"border-b border-[#f0f3f8] last:border-b-0",
|
||||||
|
rowActive && "bg-[#f5f9ff]/80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
"sticky left-0 z-20 align-top px-0.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||||
|
isMobile ? "py-1.5" : "py-2",
|
||||||
|
indexColClass,
|
||||||
|
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
"sticky z-20 align-top px-1 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||||
|
isMobile ? "py-1.5" : "py-2",
|
||||||
|
stickyNumberLeftClass,
|
||||||
|
numberColClass,
|
||||||
|
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={displayNumber}
|
||||||
|
disabled={tableDisabled}
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9Rr]*"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={numberPlaceholder}
|
||||||
|
maxLength={numberMaxChars}
|
||||||
|
onFocus={() => setActiveRowId(row.id)}
|
||||||
|
onClick={() => setActiveRowId(row.id)}
|
||||||
|
onChange={(event) => updateRowNumber(row.id, event.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono font-bold tracking-[0.12em] tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
|
||||||
|
isMobile ? "h-8 text-sm" : "h-9 text-sm",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{playColumns.map((column) => {
|
||||||
|
const { play } = column;
|
||||||
|
const amountText = row.amounts[column.key] ?? "";
|
||||||
|
const status = cellRiskState(
|
||||||
|
play,
|
||||||
|
row.number,
|
||||||
|
playCategory(play.play_code),
|
||||||
|
alertRows,
|
||||||
|
liveSoldOutNumbers,
|
||||||
|
liveWarningNumbers,
|
||||||
|
column.digitSlot,
|
||||||
|
);
|
||||||
|
const disabled =
|
||||||
|
tableDisabled ||
|
||||||
|
status === "sold_out" ||
|
||||||
|
(play.config !== null && !play.config.is_enabled);
|
||||||
|
const hasAmount = amountText.trim().length > 0;
|
||||||
|
const inputValid = isDraftAmountInputValid(row, column);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={`${row.id}-${column.key}`}
|
||||||
|
className={cn(
|
||||||
|
"px-0.5 align-top",
|
||||||
|
isMobile ? "py-1.5" : "py-2",
|
||||||
|
status === "warning" && "bg-amber-50/70",
|
||||||
|
status === "sold_out" && "bg-slate-100 text-slate-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{inputValid ? (
|
||||||
|
<Input
|
||||||
|
value={amountText}
|
||||||
|
disabled={disabled}
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder={status === "sold_out" ? t("hall.table.soldOut") : "-"}
|
||||||
|
onFocus={() => setActiveRowId(row.id)}
|
||||||
|
onClick={() => setActiveRowId(row.id)}
|
||||||
|
onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-md border-[#e1e8f3] bg-white px-0.5 text-center font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
|
||||||
|
isMobile ? "h-8 text-sm" : "h-9 text-sm",
|
||||||
|
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
|
||||||
|
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
|
||||||
|
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-md border border-slate-200 bg-slate-100/70",
|
||||||
|
isMobile ? "h-8" : "h-9",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{inputValid && status === "sold_out" ? (
|
||||||
|
<p className="mt-0.5 text-center text-[10px] font-bold text-slate-500">
|
||||||
|
{t("hall.table.soldOut")}
|
||||||
|
</p>
|
||||||
|
) : status === "warning" ? (
|
||||||
|
<p className="mt-0.5 text-center text-[10px] font-bold text-amber-700">
|
||||||
|
{t("hall.table.warning")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showSelectionTypeColumn ? (
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
selectionTypeColClass,
|
||||||
|
"align-top px-1 text-center",
|
||||||
|
isMobile ? "py-1.5" : "py-2",
|
||||||
|
rowActive && "bg-[#f5f9ff]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={row.selectionType}
|
||||||
|
disabled={tableDisabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateRowSelectionType(row.id, event.target.value as SelectionType)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-md border border-[#e1e8f3] bg-white px-1 font-semibold text-[#304f86]",
|
||||||
|
isMobile ? "h-8 text-[10px]" : "h-9 text-xs",
|
||||||
|
)}
|
||||||
|
aria-label={t("hall.table.selectionTypeForRow", { row: index + 1 })}
|
||||||
|
>
|
||||||
|
{selectionTypeOptions.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{t(`hall.table.selectionTypes.${type}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{comboCount > 1 && displayNumber.length >= 2 ? (
|
||||||
|
<div className="mt-0.5 flex items-center justify-center gap-0.5 text-[9px] font-bold leading-none text-[#0b56b7]">
|
||||||
|
<span>{t("hall.table.comboCount", { count: comboCount })}</span>
|
||||||
|
{row.selectionType === "full_cover" || row.selectionType === "half_play" ? (
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger
|
||||||
|
className="inline-flex size-3 items-center justify-center rounded-full text-[#5378b5] outline-none hover:text-[#0b56b7] focus-visible:ring-1 focus-visible:ring-[#0b56b7]"
|
||||||
|
aria-label={t("hall.table.selectionTypeRule")}
|
||||||
|
title={t("hall.table.selectionTypeRule")}
|
||||||
|
>
|
||||||
|
<CircleHelp className="size-3" aria-hidden />
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Positioner side="bottom" sideOffset={6}>
|
||||||
|
<Tooltip.Popup className="z-[70] max-w-52 rounded-md bg-slate-900 px-2 py-1.5 text-left text-[11px] font-medium leading-snug text-white shadow-lg">
|
||||||
|
{row.selectionType === "full_cover"
|
||||||
|
? t("hall.table.fullCoverSplitHint", { count: comboCount })
|
||||||
|
: t("hall.table.halfPlayHint")}
|
||||||
|
</Tooltip.Popup>
|
||||||
|
</Tooltip.Positioner>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{row.selectionType === "full_cover" &&
|
||||||
|
comboCount > 1 &&
|
||||||
|
hasFullCoverAmountError ? (
|
||||||
|
<p className="mt-0.5 text-[9px] font-medium leading-tight text-red-600">
|
||||||
|
{t("hall.table.fullCoverDivisibilityError", { count: comboCount })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
{betProviders.map((provider, providerIndex) => (
|
||||||
|
<td
|
||||||
|
key={provider.code}
|
||||||
|
className={cn(
|
||||||
|
providerColClass,
|
||||||
|
providerColumnTone(providerIndex),
|
||||||
|
"px-0.5 text-center",
|
||||||
|
isMobile ? "py-1.5" : "py-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={row.providerCodes.includes(provider.code)}
|
||||||
|
disabled={tableDisabled}
|
||||||
|
onCheckedChange={() => toggleRowProvider(row.id, provider.code)}
|
||||||
|
aria-label={`${provider.name} ${index + 1}`}
|
||||||
|
className={cn(
|
||||||
|
"mx-auto border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800",
|
||||||
|
!isMobile && "size-4",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
rowTotalColClass,
|
||||||
|
"bg-[#f7fbff] px-1 text-center font-mono font-black tabular-nums text-[#0b3f96]",
|
||||||
|
isMobile ? "py-1.5" : "py-2",
|
||||||
|
rowActive && "bg-[#edf5ff]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMinorAmount(rowTotalMinor)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Hourglass, Landmark, TimerReset } from "lucide-react";
|
import { Hourglass, Landmark } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -8,7 +8,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import {
|
import {
|
||||||
drawStatusHud,
|
drawStatusHud,
|
||||||
isHallAwaitingDrawProcessing,
|
isHallAwaitingDrawProcessing,
|
||||||
isHallBlockedForBetting,
|
|
||||||
isHallSealedCountdownUi,
|
isHallSealedCountdownUi,
|
||||||
} from "@/features/draw/draw-status-meta";
|
} from "@/features/draw/draw-status-meta";
|
||||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||||
@@ -17,7 +16,13 @@ import { formatPlayerInstant } from "@/lib/player-datetime";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { DrawCurrentPayload } from "@/types/api/draw-current";
|
import type { DrawCurrentPayload } from "@/types/api/draw-current";
|
||||||
|
|
||||||
function ScheduleAnchorTime({ payload }: { payload: DrawCurrentPayload }) {
|
function ScheduleAnchorTime({
|
||||||
|
payload,
|
||||||
|
layout,
|
||||||
|
}: {
|
||||||
|
payload: DrawCurrentPayload;
|
||||||
|
layout: "mobile" | "desktop";
|
||||||
|
}) {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const isPending = payload.status === "pending";
|
const isPending = payload.status === "pending";
|
||||||
const isOpen = payload.status === "open";
|
const isOpen = payload.status === "open";
|
||||||
@@ -30,6 +35,30 @@ function ScheduleAnchorTime({ payload }: { payload: DrawCurrentPayload }) {
|
|||||||
? "draw.scheduledEnd"
|
? "draw.scheduledEnd"
|
||||||
: "draw.scheduledClose";
|
: "draw.scheduledClose";
|
||||||
const formatted = source ? formatPlayerInstant(source) : null;
|
const formatted = source ? formatPlayerInstant(source) : null;
|
||||||
|
|
||||||
|
if (layout === "desktop") {
|
||||||
|
if (!formatted) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-500">{t(labelKey)}</span>
|
||||||
|
<span className="font-mono text-base font-black tabular-nums text-[#0b3f96]">--:--:--</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parts = formatted.split(" ");
|
||||||
|
const date = parts.slice(0, -1).join(" ");
|
||||||
|
const time = parts.at(-1);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-500">{t(labelKey)}</span>
|
||||||
|
<span className="font-mono text-base font-black tabular-nums tracking-tight text-[#0b3f96]">
|
||||||
|
{time}
|
||||||
|
</span>
|
||||||
|
{date ? <span className="hidden text-sm text-slate-400 2xl:inline">{date}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!formatted) {
|
if (!formatted) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -56,10 +85,12 @@ function CloseTime({
|
|||||||
nowMs,
|
nowMs,
|
||||||
hud,
|
hud,
|
||||||
payload,
|
payload,
|
||||||
|
layout,
|
||||||
}: {
|
}: {
|
||||||
nowMs: number;
|
nowMs: number;
|
||||||
hud: ReturnType<typeof drawStatusHud>;
|
hud: ReturnType<typeof drawStatusHud>;
|
||||||
payload: DrawCurrentPayload;
|
payload: DrawCurrentPayload;
|
||||||
|
layout: "mobile" | "desktop";
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const sealedCountdown = isHallSealedCountdownUi(payload.status);
|
const sealedCountdown = isHallSealedCountdownUi(payload.status);
|
||||||
@@ -119,11 +150,31 @@ function CloseTime({
|
|||||||
label = t("draw.coolDown");
|
label = t("draw.coolDown");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clock = showClock ? formatSecondsClock(seconds) : "--:--";
|
||||||
|
|
||||||
|
if (layout === "desktop") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-500">{label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-w-[3.75rem] items-center justify-center rounded-md px-2 py-0.5 font-mono text-base font-black tabular-nums tracking-tight",
|
||||||
|
sealedCountdown ? "bg-[#fff1f3] text-[#ff143d]" : "bg-[#fff5f6] text-[#e5002c]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{clock}
|
||||||
|
</span>
|
||||||
|
<Hourglass
|
||||||
|
className={cn("size-3.5 shrink-0", sealedCountdown ? "text-[#ff143d]" : "text-red-300")}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="text-lg font-black tabular-nums text-[#ff143d]">
|
<span className="text-lg font-black tabular-nums text-[#ff143d]">{clock}</span>
|
||||||
{showClock ? formatSecondsClock(seconds) : "--:--"}
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 text-[11px] text-slate-500">{label}</span>
|
<span className="mt-1 text-[11px] text-slate-500">{label}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -135,7 +186,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<section className="mb-3 rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
<section className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||||||
<p>{t(error, { defaultValue: error })}</p>
|
<p>{t(error, { defaultValue: error })}</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -152,19 +203,23 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
|||||||
|
|
||||||
if (raw === undefined || display === undefined) {
|
if (raw === undefined || display === undefined) {
|
||||||
return (
|
return (
|
||||||
<section className="mb-3 rounded-xl border border-[#e3ebf6] bg-white p-3 shadow-sm">
|
<section className="rounded-xl border border-[#e3ebf6] bg-white p-3 shadow-sm lg:h-[5.5rem] lg:px-4 lg:py-3">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2 lg:hidden">
|
||||||
<Skeleton className="h-14 rounded-lg" />
|
<Skeleton className="h-14 rounded-lg" />
|
||||||
<Skeleton className="h-14 rounded-lg" />
|
<Skeleton className="h-14 rounded-lg" />
|
||||||
<Skeleton className="h-14 rounded-lg" />
|
<Skeleton className="h-14 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="hidden h-full items-center justify-between gap-4 lg:flex">
|
||||||
|
<Skeleton className="h-7 w-56 rounded-md" />
|
||||||
|
<Skeleton className="h-7 w-64 rounded-md" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw === null || display === null) {
|
if (raw === null || display === null) {
|
||||||
return (
|
return (
|
||||||
<section className="mb-3 rounded-xl border border-[#e3ebf6] bg-white px-3 py-3 text-center text-sm text-slate-500 shadow-sm">
|
<section className="flex items-center justify-center rounded-xl border border-[#e3ebf6] bg-white px-3 py-3 text-center text-sm text-slate-500 shadow-sm lg:h-[5.5rem]">
|
||||||
{t("draw.noIssue")}
|
{t("draw.noIssue")}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -172,16 +227,17 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
|||||||
|
|
||||||
const hud = drawStatusHud(display.status);
|
const hud = drawStatusHud(display.status);
|
||||||
const sealedUi = isHallSealedCountdownUi(display.status);
|
const sealedUi = isHallSealedCountdownUi(display.status);
|
||||||
const blockedUi = isHallBlockedForBetting(display.status);
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-3 overflow-hidden rounded-xl border border-[#e1e9f5] bg-white shadow-[0_6px_20px_rgba(15,23,42,0.06)]",
|
"flex h-full flex-col overflow-hidden rounded-xl border border-[#e1e9f5] bg-white shadow-[0_4px_16px_rgba(15,23,42,0.04)] lg:h-[5.5rem]",
|
||||||
sealedUi && "border-red-200 bg-red-50/30",
|
sealedUi && "border-red-200 bg-red-50/30",
|
||||||
)}
|
)}
|
||||||
aria-label={t("draw.currentIssue")}
|
aria-label={t("draw.currentIssue")}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[1fr_1.05fr_1fr] divide-x divide-[#e7edf6]">
|
{/* Mobile: 三列卡片 */}
|
||||||
|
<div className="grid grid-cols-[1fr_1.05fr_1fr] divide-x divide-[#e7edf6] lg:hidden">
|
||||||
<div className="flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
<div className="flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
||||||
<div className="min-w-0 max-w-full overflow-x-auto">
|
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||||
<p className="text-[11px] font-semibold text-slate-500">{t("draw.issueNo")}</p>
|
<p className="text-[11px] font-semibold text-slate-500">{t("draw.issueNo")}</p>
|
||||||
@@ -191,7 +247,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
<div className="flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
||||||
<ScheduleAnchorTime payload={display} />
|
<ScheduleAnchorTime payload={display} layout="mobile" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
<div className="relative flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
||||||
<CloseTime
|
<CloseTime
|
||||||
@@ -199,6 +255,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
|||||||
nowMs={drawLive.nowMs}
|
nowMs={drawLive.nowMs}
|
||||||
hud={hud}
|
hud={hud}
|
||||||
payload={display}
|
payload={display}
|
||||||
|
layout="mobile"
|
||||||
/>
|
/>
|
||||||
<Hourglass
|
<Hourglass
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -209,27 +266,44 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{blockedUi ? (
|
|
||||||
<div className="flex items-center gap-2 border-t border-red-100 bg-red-50 px-3 py-2 text-xs font-medium text-red-600">
|
{/* Desktop: 单行工具条 — 左期号状态,右时间/倒计时,与右侧钱包卡同高 */}
|
||||||
<TimerReset className="size-4 shrink-0" aria-hidden />
|
<div className="hidden h-full items-center justify-between gap-3 px-4 py-2.5 lg:flex xl:gap-6">
|
||||||
{display.status === "review"
|
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
? t("draw.reviewNotice")
|
<div className="flex items-center gap-2">
|
||||||
: sealedUi
|
<span className="whitespace-nowrap text-sm text-slate-500">{t("draw.issueNo")}</span>
|
||||||
? t("draw.sealedNotice")
|
<span className="inline-flex items-center whitespace-nowrap rounded-md bg-[#eef3ff] px-2 py-0.5 font-mono text-sm font-black tabular-nums text-[#0b4ab3]">
|
||||||
: t("draw.notBettableNotice")}
|
{display.draw_no}
|
||||||
</div>
|
</span>
|
||||||
) : (
|
</div>
|
||||||
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500">
|
<span className="inline-flex items-center gap-1.5 text-sm text-slate-500">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className={cn("size-1.5 rounded-full", hud.dotClass)} />
|
||||||
<span className={cn("size-2 rounded-full", hud.dotClass)} />
|
|
||||||
{t(hud.labelKey, { defaultValue: hud.labelKey })}
|
{t(hud.labelKey, { defaultValue: hud.labelKey })}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<Landmark className="size-3.5" aria-hidden />
|
|
||||||
{t("draw.hall")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-3 gap-y-1 xl:gap-x-5">
|
||||||
|
<ScheduleAnchorTime payload={display} layout="desktop" />
|
||||||
|
<CloseTime
|
||||||
|
key={`desk-${display.draw_no}-${display.status}-${display.seconds_to_draw ?? ""}-${display.seconds_remaining_in_cooldown ?? ""}`}
|
||||||
|
nowMs={drawLive.nowMs}
|
||||||
|
hud={hud}
|
||||||
|
payload={display}
|
||||||
|
layout="desktop"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile only: 底部状态条(阻断提示已上移为首页顶栏横幅) */}
|
||||||
|
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500 lg:hidden">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={cn("size-2 rounded-full", hud.dotClass)} />
|
||||||
|
{t(hud.labelKey, { defaultValue: hud.labelKey })}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Landmark className="size-3.5" aria-hidden />
|
||||||
|
{t("draw.hall")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
280
src/features/hall/hall-mobile-quick-fill.tsx
Normal file
280
src/features/hall/hall-mobile-quick-fill.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { ChevronDown, ChevronUp, Star, Trash2 } from "lucide-react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type {
|
||||||
|
HallTranslate,
|
||||||
|
PlayHallCategory,
|
||||||
|
} from "@/features/hall/hall-betting-grid-model";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const;
|
||||||
|
const CATEGORY_TABS: Array<{ value: PlayHallCategory; label: string }> = [
|
||||||
|
{ value: "D4", label: "4D" },
|
||||||
|
{ value: "D3", label: "3D" },
|
||||||
|
{ value: "D2", label: "2D" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type HallMobileQuickFillProps = {
|
||||||
|
activeNumber: string;
|
||||||
|
favorites: string[];
|
||||||
|
history: string[];
|
||||||
|
expanded: boolean;
|
||||||
|
tableDisabled: boolean;
|
||||||
|
activeCategory: PlayHallCategory;
|
||||||
|
availableCategories: ReadonlySet<PlayHallCategory>;
|
||||||
|
onExpandedChange: (expanded: boolean) => void;
|
||||||
|
onCategoryChange: (category: PlayHallCategory) => void;
|
||||||
|
onFillNumber: (number: string) => void;
|
||||||
|
onToggleFavorite: (number: string) => void;
|
||||||
|
onClearAll: () => void;
|
||||||
|
onApplyQuickAmount: (amount: string) => void;
|
||||||
|
onCopyPreviousRow: () => void;
|
||||||
|
onClearActiveRowAmounts: () => void;
|
||||||
|
t: HallTranslate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HallMobileQuickFill({
|
||||||
|
activeNumber,
|
||||||
|
favorites,
|
||||||
|
history,
|
||||||
|
expanded,
|
||||||
|
tableDisabled,
|
||||||
|
activeCategory,
|
||||||
|
availableCategories,
|
||||||
|
onExpandedChange,
|
||||||
|
onCategoryChange,
|
||||||
|
onFillNumber,
|
||||||
|
onToggleFavorite,
|
||||||
|
onClearAll,
|
||||||
|
onApplyQuickAmount,
|
||||||
|
onCopyPreviousRow,
|
||||||
|
onClearActiveRowAmounts,
|
||||||
|
t,
|
||||||
|
}: HallMobileQuickFillProps) {
|
||||||
|
const holdFavoriteRef = useRef<{
|
||||||
|
timer: number | null;
|
||||||
|
longPress: boolean;
|
||||||
|
}>({ timer: null, longPress: false });
|
||||||
|
|
||||||
|
const cancelFavoriteHold = (): void => {
|
||||||
|
const current = holdFavoriteRef.current;
|
||||||
|
if (current.timer !== null) {
|
||||||
|
window.clearTimeout(current.timer);
|
||||||
|
current.timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeIsFavorite = favorites.includes(activeNumber);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg border border-[#e8eef7] bg-white px-3 py-2.5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-bold leading-5 text-slate-950">
|
||||||
|
{t("hall.quickFill.title")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] leading-5 text-slate-500">
|
||||||
|
{t("hall.mobile.quickFillSummary", {
|
||||||
|
defaultValue: "收藏 {{favorites}} 个,历史 {{history}} 个",
|
||||||
|
favorites: favorites.length,
|
||||||
|
history: history.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={
|
||||||
|
expanded
|
||||||
|
? t("hall.mobile.collapseQuickFill", { defaultValue: "收起快捷填单" })
|
||||||
|
: t("hall.mobile.expandQuickFill", { defaultValue: "展开快捷填单" })
|
||||||
|
}
|
||||||
|
aria-pressed={expanded}
|
||||||
|
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
onClick={() => onExpandedChange(!expanded)}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUp className="size-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{activeNumber ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={
|
||||||
|
activeIsFavorite
|
||||||
|
? t("hall.quickFill.unfavorite")
|
||||||
|
: t("hall.quickFill.favorite")
|
||||||
|
}
|
||||||
|
aria-pressed={activeIsFavorite}
|
||||||
|
title={
|
||||||
|
activeIsFavorite
|
||||||
|
? t("hall.quickFill.unfavorite")
|
||||||
|
: t("hall.quickFill.favorite")
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"size-8 rounded-full border border-[#ffd7db] bg-[#fff7f8] text-[#d81435] hover:bg-[#fff1f3] hover:text-[#b80f2b]",
|
||||||
|
activeIsFavorite &&
|
||||||
|
"border-[#d81435] bg-[#d81435] text-white hover:bg-[#c51230] hover:text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => onToggleFavorite(activeNumber)}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn("size-4", activeIsFavorite && "fill-current")}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("hall.quickFill.clearAll")}
|
||||||
|
title={t("hall.quickFill.clearAll")}
|
||||||
|
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
onClick={onClearAll}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="rounded-xl border border-[#e7eef8] bg-[#f8fbff] px-3 py-2.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold text-[#5b6f95]">
|
||||||
|
{t("hall.quickFill.batchTitle", { defaultValue: "Quick amount" })}
|
||||||
|
</span>
|
||||||
|
{QUICK_AMOUNT_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
disabled={tableDisabled}
|
||||||
|
onClick={() => onApplyQuickAmount(preset)}
|
||||||
|
className="inline-flex h-8 min-w-12 items-center justify-center rounded-full border border-[#cfe0fb] bg-white px-3 text-xs font-bold text-[#0b4ab3] transition-colors hover:border-[#aac7f5] hover:bg-[#edf5ff] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{preset}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={tableDisabled || !activeNumber}
|
||||||
|
onClick={onCopyPreviousRow}
|
||||||
|
className="inline-flex h-8 items-center justify-center rounded-full border border-[#dfe6f0] bg-white px-3 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("hall.quickFill.copyPrev", { defaultValue: "Copy prev row" })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={tableDisabled}
|
||||||
|
onClick={onClearActiveRowAmounts}
|
||||||
|
className="inline-flex h-8 items-center justify-center rounded-full border border-[#ffe0e5] bg-white px-3 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#fff4f6] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("hall.quickFill.clearRow", { defaultValue: "Clear row amounts" })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{favorites.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="mr-0.5 text-[11px] font-semibold text-[#d81435]">
|
||||||
|
{t("hall.quickFill.favorites")}
|
||||||
|
</span>
|
||||||
|
{favorites.map((number) => (
|
||||||
|
<button
|
||||||
|
key={`fav-${number}`}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-8 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||||
|
onPointerDown={() => {
|
||||||
|
const current = holdFavoriteRef.current;
|
||||||
|
current.longPress = false;
|
||||||
|
cancelFavoriteHold();
|
||||||
|
current.timer = window.setTimeout(() => {
|
||||||
|
current.longPress = true;
|
||||||
|
onToggleFavorite(number);
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
const current = holdFavoriteRef.current;
|
||||||
|
cancelFavoriteHold();
|
||||||
|
if (!current.longPress) onFillNumber(number);
|
||||||
|
current.longPress = false;
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => {
|
||||||
|
cancelFavoriteHold();
|
||||||
|
holdFavoriteRef.current.longPress = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{number}
|
||||||
|
<span className="text-[11px] font-medium opacity-70">
|
||||||
|
{t("hall.quickFill.tapHold")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="mr-0.5 text-[11px] font-semibold text-slate-400">
|
||||||
|
{t("hall.quickFill.history")}
|
||||||
|
</span>
|
||||||
|
{history.length > 0 ? (
|
||||||
|
history.map((number) => (
|
||||||
|
<button
|
||||||
|
key={`his-${number}`}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-8 min-w-10 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-3 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
|
||||||
|
onClick={() => onFillNumber(number)}
|
||||||
|
>
|
||||||
|
{number}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex h-8 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-3 text-xs text-slate-400">
|
||||||
|
{t("hall.quickFill.emptyHistory")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1">
|
||||||
|
{CATEGORY_TABS.map((tab) => {
|
||||||
|
const enabled = availableCategories.has(tab.value);
|
||||||
|
const active = activeCategory === tab.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
type="button"
|
||||||
|
disabled={!enabled}
|
||||||
|
onClick={() => onCategoryChange(tab.value)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-w-[5.25rem] items-center justify-center rounded-t-xl border border-b-0 px-4 py-3 text-sm font-bold transition-colors",
|
||||||
|
active
|
||||||
|
? "border-[#d7e1f3] border-b-white bg-white text-[#21335b] shadow-[0_-1px_0_rgba(255,255,255,0.8)]"
|
||||||
|
: "border-transparent bg-transparent text-[#6d8fd6] hover:text-[#2d63e2]",
|
||||||
|
!enabled && "cursor-not-allowed opacity-40",
|
||||||
|
)}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<span className="mr-2 text-[13px]" aria-hidden>
|
||||||
|
▦
|
||||||
|
</span>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
|
||||||
import { getPlayEffective } from "@/api/play";
|
import { getPlayEffective } from "@/api/play";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -99,10 +100,8 @@ export function HallPlayCatalogPanel() {
|
|||||||
}
|
}
|
||||||
}, [currencyParam, t]);
|
}, [currencyParam, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
|
||||||
});
|
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
68
src/features/hall/hall-play-summary-grid.tsx
Normal file
68
src/features/hall/hall-play-summary-grid.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import { formatMinorAmount } from "@/lib/money";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type HallSummaryItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
totalMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function balancedColumnCount(itemCount: number, maxColumns: number) {
|
||||||
|
const rowCount = Math.max(1, Math.ceil(itemCount / maxColumns));
|
||||||
|
return Math.max(1, Math.ceil(itemCount / rowCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HallPlaySummaryGrid({
|
||||||
|
items,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
items: HallSummaryItem[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const gridStyle = {
|
||||||
|
"--hall-summary-columns": balancedColumnCount(items.length, 10),
|
||||||
|
"--hall-summary-columns-wide": balancedColumnCount(items.length, 16),
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"hall-summary-grid grid grid-cols-6 overflow-hidden rounded-lg border border-[#dfe6f0] bg-[#f8fafc] text-center tabular-nums shadow-sm sm:grid-cols-8 md:grid-cols-10",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={gridStyle}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const hasValue = item.totalMinor > 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`summary-${item.key}`}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 border-b border-r transition-colors",
|
||||||
|
hasValue ? "border-[#cbdcf7] bg-[#eef5ff]" : "border-slate-200 bg-slate-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-7 items-center justify-center px-1 text-[10px] font-bold leading-tight transition-colors lg:min-h-8 lg:text-[11px]",
|
||||||
|
hasValue ? "bg-[#2d63e2] text-white" : "bg-slate-200 text-slate-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"px-1 py-1 text-[11px] font-black transition-colors lg:py-1.5 lg:text-xs",
|
||||||
|
hasValue ? "text-[#0b3f96]" : "text-slate-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMinorAmount(item.totalMinor)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { TimerReset } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
|
import {
|
||||||
|
isHallBlockedForBetting,
|
||||||
|
isHallSealedCountdownUi,
|
||||||
|
} from "@/features/draw/draw-status-meta";
|
||||||
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
|
||||||
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
||||||
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
||||||
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
|
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
|
||||||
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||||
import { useJackpotBurstLive } from "@/features/hall/use-jackpot-burst-live";
|
import { useJackpotBurstLive } from "@/features/hall/use-jackpot-burst-live";
|
||||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。
|
* 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。
|
||||||
|
* PC:状态工具条与资金摘要并列,下注工具与表格独占工作区宽度。
|
||||||
|
* 不可下注提示为首页顶部全宽横幅,避免撑高左侧期号卡导致与钱包卡高度不一致。
|
||||||
*/
|
*/
|
||||||
export function HallScreen() {
|
export function HallScreen() {
|
||||||
const { t: tp } = useTranslation("player");
|
const { t: tp } = useTranslation("player");
|
||||||
@@ -20,12 +27,39 @@ export function HallScreen() {
|
|||||||
const { activeCurrency } = useActivePlayerCurrency();
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
|
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
|
||||||
|
|
||||||
|
const display = drawLive.display;
|
||||||
|
const blockedUi = display != null && isHallBlockedForBetting(display.status);
|
||||||
|
const sealedUi = display != null && isHallSealedCountdownUi(display.status);
|
||||||
|
const blockedNotice =
|
||||||
|
display == null
|
||||||
|
? null
|
||||||
|
: display.status === "review"
|
||||||
|
? tp("draw.reviewNotice")
|
||||||
|
: sealedUi
|
||||||
|
? tp("draw.sealedNotice")
|
||||||
|
: tp("draw.notBettableNotice");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PlayerPanel>
|
<PlayerPanel>
|
||||||
<HallDrawPanel drawLive={drawLive} />
|
<div className="space-y-3">
|
||||||
<HallWalletStrip />
|
{blockedUi && blockedNotice ? (
|
||||||
<HallBettingGrid key={activeCurrency} drawLive={drawLive} />
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-red-100 bg-red-50 px-3 py-2.5 text-sm font-medium text-red-600 shadow-[0_2px_8px_rgba(229,0,44,0.06)] lg:px-4"
|
||||||
|
>
|
||||||
|
<TimerReset className="size-4 shrink-0" aria-hidden />
|
||||||
|
<span>{blockedNotice}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-3 lg:grid lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-stretch lg:gap-3 lg:space-y-0 xl:grid-cols-[minmax(0,1fr)_20rem] xl:gap-4">
|
||||||
|
<HallDrawPanel drawLive={drawLive} />
|
||||||
|
<aside className="min-w-0">
|
||||||
|
<HallWalletStrip />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<HallBettingGrid key={activeCurrency} drawLive={drawLive} />
|
||||||
|
</div>
|
||||||
</PlayerPanel>
|
</PlayerPanel>
|
||||||
<JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} />
|
<JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
105
src/features/hall/hall-selection-confirm-dialog.tsx
Normal file
105
src/features/hall/hall-selection-confirm-dialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import type { HallTranslate } from "@/features/hall/hall-betting-grid-model";
|
||||||
|
import type { SelectionType } from "@/features/hall/selection-type";
|
||||||
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
|
|
||||||
|
export type HallSelectionPreview = {
|
||||||
|
next: SelectionType;
|
||||||
|
comboCount: number;
|
||||||
|
number: string;
|
||||||
|
fromMinor: number;
|
||||||
|
toMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HallSelectionConfirmDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
preview: HallSelectionPreview | null;
|
||||||
|
currencyCode: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
t: HallTranslate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HallSelectionConfirmDialog({
|
||||||
|
open,
|
||||||
|
preview,
|
||||||
|
currencyCode,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
t,
|
||||||
|
}: HallSelectionConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
|
||||||
|
<DialogContent className="max-w-[380px] rounded-2xl border border-[#dfe8f6] bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
||||||
|
<DialogHeader className="gap-2 border-b border-[#eef2f8] px-5 py-4 text-left">
|
||||||
|
<DialogTitle className="text-base font-black text-[#0b3f96]">
|
||||||
|
{t("hall.table.selectionConfirm.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm leading-relaxed text-slate-600">
|
||||||
|
{preview
|
||||||
|
? preview.next === "full_cover"
|
||||||
|
? t("hall.table.selectionConfirm.fullCoverBody", {
|
||||||
|
type: t(`hall.table.selectionTypes.${preview.next}`),
|
||||||
|
count: preview.comboCount,
|
||||||
|
amount: formatMinorAsCurrency(
|
||||||
|
preview.toMinor || preview.fromMinor,
|
||||||
|
currencyCode,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: preview.fromMinor > 0 && preview.toMinor > preview.fromMinor
|
||||||
|
? t("hall.table.selectionConfirm.increaseBody", {
|
||||||
|
type: t(`hall.table.selectionTypes.${preview.next}`),
|
||||||
|
count: preview.comboCount,
|
||||||
|
from: formatMinorAsCurrency(preview.fromMinor, currencyCode),
|
||||||
|
to: formatMinorAsCurrency(preview.toMinor, currencyCode),
|
||||||
|
})
|
||||||
|
: t("hall.table.selectionConfirm.genericBody", {
|
||||||
|
type: t(`hall.table.selectionTypes.${preview.next}`),
|
||||||
|
count: preview.comboCount,
|
||||||
|
})
|
||||||
|
: t("hall.table.selectionConfirm.genericBody", {
|
||||||
|
type: "",
|
||||||
|
count: 1,
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{preview && preview.comboCount > 1 ? (
|
||||||
|
<div className="space-y-1 px-5 py-3 text-xs text-slate-600">
|
||||||
|
<p className="font-bold text-[#304f86]">
|
||||||
|
{t("hall.table.selectionConfirm.comboHint", {
|
||||||
|
count: preview.comboCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{preview.number ? (
|
||||||
|
<p className="font-mono text-[11px] text-slate-500">
|
||||||
|
{t("hall.table.selectionConfirm.numberHint", {
|
||||||
|
number: preview.number,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter className="mx-0 mb-0 gap-2 rounded-b-2xl border-t border-[#eef2f8] bg-white px-5 py-4 sm:justify-end">
|
||||||
|
<Button type="button" variant="outline" className="rounded-lg" onClick={onCancel}>
|
||||||
|
{t("hall.table.selectionConfirm.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{t("hall.table.selectionConfirm.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { useApiQuery } from "@/hooks/use-api-query";
|
import { useApiQuery } from "@/hooks/use-api-query";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
|
||||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -40,12 +39,10 @@ export function HallWalletStrip() {
|
|||||||
BALANCE_KEY(currency),
|
BALANCE_KEY(currency),
|
||||||
() => getWalletBalance({ currency }),
|
() => getWalletBalance({ currency }),
|
||||||
{
|
{
|
||||||
// 降级模式下 60 秒自动刷新,SWR 内置定时器代替手动 setInterval
|
|
||||||
refreshInterval: isDegraded ? 60_000 : undefined,
|
refreshInterval: isDegraded ? 60_000 : undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 全局事件触发刷新(下注后、币种切换等场景)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
||||||
window.addEventListener("lottery-wallet-refresh", onRefresh);
|
window.addEventListener("lottery-wallet-refresh", onRefresh);
|
||||||
@@ -56,7 +53,6 @@ export function HallWalletStrip() {
|
|||||||
};
|
};
|
||||||
}, [mutate, currency]);
|
}, [mutate, currency]);
|
||||||
|
|
||||||
// 非降级模式时清理遗留计时器(refreshInterval 已由 SWR 管理)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDegraded && degradedWalletPollRef.current !== null) {
|
if (!isDegraded && degradedWalletPollRef.current !== null) {
|
||||||
window.clearInterval(degradedWalletPollRef.current);
|
window.clearInterval(degradedWalletPollRef.current);
|
||||||
@@ -75,94 +71,128 @@ export function HallWalletStrip() {
|
|||||||
? null
|
? null
|
||||||
: Number(balance.main_balance);
|
: Number(balance.main_balance);
|
||||||
|
|
||||||
|
const label = isCreditPlayer
|
||||||
|
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
||||||
|
: t("wallet.balance");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn("mb-3", isMobile ? "space-y-2" : "space-y-2.5")}
|
className={cn("h-full", isMobile ? "space-y-2" : "space-y-2.5")}
|
||||||
aria-label={
|
aria-label={label}
|
||||||
isCreditPlayer
|
|
||||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
|
||||||
: t("wallet.balance")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden rounded-xl bg-[#e5002c] text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]",
|
"relative overflow-hidden rounded-xl bg-[#e5002c] text-white shadow-[0_8px_24px_rgba(229,0,44,0.22)]",
|
||||||
isMobile ? "px-3 py-2.5" : "px-3 py-3",
|
isMobile
|
||||||
|
? "px-3 py-2.5"
|
||||||
|
: cn(
|
||||||
|
"px-5 py-3 lg:h-[5.5rem]",
|
||||||
|
isCreditPlayer ? "lg:px-3 xl:px-4" : "lg:px-4 lg:pr-14",
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/entry/image5.png"
|
src="/entry/image5.png"
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 768px) 100vw, 768px"
|
sizes="(max-width: 1023px) 100vw, 1400px"
|
||||||
|
loading="eager"
|
||||||
className="pointer-events-none object-cover object-center"
|
className="pointer-events-none object-cover object-center"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<div className={cn("relative flex items-center", isMobile ? "gap-2.5" : "gap-3")}>
|
<div className={cn("relative flex items-center", isMobile ? "gap-2.5" : "h-full gap-3 xl:gap-4")}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 rounded-full bg-white text-[#d81435] shadow-sm",
|
"shrink-0 rounded-full bg-white text-[#d81435] shadow-sm",
|
||||||
isMobile
|
isMobile
|
||||||
? "flex size-11 items-center justify-center"
|
? "flex size-11 items-center justify-center"
|
||||||
: "flex size-14 items-center justify-center",
|
: "flex size-11 items-center justify-center lg:size-10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Wallet className={cn(isMobile ? "size-5.5" : "size-7")} aria-hidden />
|
<Wallet className={cn(isMobile ? "size-5.5" : "size-5.5")} aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className={cn("font-semibold text-white/90", isMobile ? "text-[13px]" : "text-sm")}>
|
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
{isCreditPlayer
|
<p className={cn("shrink-0 font-semibold text-white/90", isMobile ? "text-[13px]" : "text-xs xl:text-sm")}>
|
||||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
{label}
|
||||||
: t("wallet.balance")}
|
|
||||||
</p>
|
|
||||||
{loading ? (
|
|
||||||
<Skeleton className={cn("rounded-md bg-white/25", isMobile ? "mt-1.5 h-7 w-36" : "mt-2 h-8 w-44")} />
|
|
||||||
) : (
|
|
||||||
<PlayerMoneyDisplay
|
|
||||||
amountMinor={headlineMinor}
|
|
||||||
currency={currency}
|
|
||||||
className={cn("text-white", isMobile ? "mt-0.5" : "mt-1")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isCreditPlayer && !loading && balance ? (
|
|
||||||
<p className={cn("text-white/75", isMobile ? "mt-1 text-[11px]" : "mt-2 text-xs")}>
|
|
||||||
{t("wallet.creditSummary", {
|
|
||||||
defaultValue: "授信 {{limit}} · 已用 {{used}}",
|
|
||||||
limit: formatMinorAsCurrency(balance.credit_limit ?? 0, currency),
|
|
||||||
used: formatMinorAsCurrency(balance.used_credit ?? 0, currency),
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
{loading ? (
|
||||||
|
<Skeleton
|
||||||
|
className={cn(
|
||||||
|
"rounded-md bg-white/25",
|
||||||
|
isMobile ? "mt-1.5 h-7 w-36" : "h-6 w-32",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PlayerMoneyDisplay
|
||||||
|
amountMinor={headlineMinor}
|
||||||
|
currency={currency}
|
||||||
|
showCurrencyCode={false}
|
||||||
|
className={cn(
|
||||||
|
"text-white",
|
||||||
|
isMobile ? "text-lg" : "text-lg xl:text-xl",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isCreditPlayer ? (
|
||||||
|
<div className="absolute right-3 top-1/2 hidden -translate-y-1/2 flex-col gap-1 lg:flex">
|
||||||
|
<TransferInDialog
|
||||||
|
idPrefix="hall-desktop-"
|
||||||
|
triggerVariant="hall"
|
||||||
|
triggerLabel={t("wallet.transferIn")}
|
||||||
|
triggerIconOnly
|
||||||
|
triggerClassName="size-7 min-h-0 w-7 flex-none rounded-md border border-white/25 bg-white/15 p-0 text-white shadow-none hover:bg-white/25"
|
||||||
|
currency={currency}
|
||||||
|
lotteryMinor={transferInLotteryMinor}
|
||||||
|
mainMinor={mainMinor}
|
||||||
|
onSuccess={async () => {
|
||||||
|
await mutate(BALANCE_KEY(currency));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TransferOutDialog
|
||||||
|
idPrefix="hall-desktop-"
|
||||||
|
triggerVariant="hall"
|
||||||
|
triggerLabel={t("wallet.transferOut")}
|
||||||
|
triggerIconOnly
|
||||||
|
triggerClassName="size-7 min-h-0 w-7 flex-none rounded-md border border-white/25 bg-white/15 p-0 text-white shadow-none hover:bg-white/25"
|
||||||
|
currency={currency}
|
||||||
|
availableMinor={availableMinor}
|
||||||
|
onSuccess={async () => {
|
||||||
|
await mutate(BALANCE_KEY(currency));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCreditPlayer ? null : (
|
{isCreditPlayer ? null : (
|
||||||
<div className={cn("grid grid-cols-2", isMobile ? "gap-2" : "gap-2.5")}>
|
<div className="grid grid-cols-2 gap-2 lg:hidden">
|
||||||
<TransferInDialog
|
<TransferInDialog
|
||||||
idPrefix="hall-"
|
idPrefix="hall-"
|
||||||
triggerVariant="hall"
|
triggerVariant="hall"
|
||||||
triggerLabel={t("wallet.transferIn")}
|
triggerLabel={t("wallet.transferIn")}
|
||||||
triggerClassName={cn(
|
triggerClassName="h-10 rounded-lg text-sm font-bold"
|
||||||
"rounded-lg font-bold",
|
|
||||||
isMobile ? "h-10 text-sm" : "h-12 text-base",
|
|
||||||
)}
|
|
||||||
currency={currency}
|
currency={currency}
|
||||||
lotteryMinor={transferInLotteryMinor}
|
lotteryMinor={transferInLotteryMinor}
|
||||||
mainMinor={mainMinor}
|
mainMinor={mainMinor}
|
||||||
onSuccess={async () => { await mutate(BALANCE_KEY(currency)); }}
|
onSuccess={async () => {
|
||||||
|
await mutate(BALANCE_KEY(currency));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<TransferOutDialog
|
<TransferOutDialog
|
||||||
idPrefix="hall-"
|
idPrefix="hall-"
|
||||||
triggerVariant="hall"
|
triggerVariant="hall"
|
||||||
triggerLabel={t("wallet.transferOut")}
|
triggerLabel={t("wallet.transferOut")}
|
||||||
triggerClassName={cn(
|
triggerClassName="h-10 rounded-lg text-sm font-bold"
|
||||||
"rounded-lg font-bold",
|
|
||||||
isMobile ? "h-10 text-sm" : "h-12 text-base",
|
|
||||||
)}
|
|
||||||
currency={currency}
|
currency={currency}
|
||||||
availableMinor={availableMinor}
|
availableMinor={availableMinor}
|
||||||
onSuccess={async () => { await mutate(BALANCE_KEY(currency)); }}
|
onSuccess={async () => {
|
||||||
|
await mutate(BALANCE_KEY(currency));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
149
src/features/hall/selection-type.ts
Normal file
149
src/features/hall/selection-type.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/** 录单「种类」:正字 / 来回 / 半打 / 全保 / 全打 */
|
||||||
|
|
||||||
|
export type SelectionType = "straight" | "reverse" | "full_cover" | "full_play" | "half_play";
|
||||||
|
|
||||||
|
/** 下拉顺序:风险从低到高 */
|
||||||
|
export const SELECTION_TYPE_ORDER: readonly SelectionType[] = [
|
||||||
|
"straight",
|
||||||
|
"reverse",
|
||||||
|
"half_play",
|
||||||
|
"full_cover",
|
||||||
|
"full_play",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 切换到这些种类时需要二次确认(组合数可能 ×N) */
|
||||||
|
export const HIGH_COST_SELECTION_TYPES: ReadonlySet<SelectionType> = new Set([
|
||||||
|
"full_cover",
|
||||||
|
"full_play",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function selectionTypesForCategory(category: "D2" | "D3" | "D4"): SelectionType[] {
|
||||||
|
if (category === "D2") return ["straight"];
|
||||||
|
if (category === "D3") {
|
||||||
|
return SELECTION_TYPE_ORDER.filter((type) => type !== "half_play");
|
||||||
|
}
|
||||||
|
return [...SELECTION_TYPE_ORDER];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniquePermutations(digits: string): string[] {
|
||||||
|
const results = new Set<string>();
|
||||||
|
const chars = digits.split("");
|
||||||
|
|
||||||
|
function permute(start: number): void {
|
||||||
|
if (start === chars.length) {
|
||||||
|
results.add(chars.join(""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let i = start; i < chars.length; i += 1) {
|
||||||
|
const ch = chars[i]!;
|
||||||
|
if (seen.has(ch)) continue;
|
||||||
|
seen.add(ch);
|
||||||
|
[chars[start], chars[i]] = [chars[i]!, chars[start]!];
|
||||||
|
permute(start + 1);
|
||||||
|
[chars[start], chars[i]] = [chars[i]!, chars[start]!];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
permute(0);
|
||||||
|
return Array.from(results).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展开种类对应的号码组合(与后端 PlayRuleEngine::expandSelection 对齐)。 */
|
||||||
|
export function expandSelectionCombinations(
|
||||||
|
number: string,
|
||||||
|
selectionType: SelectionType,
|
||||||
|
): string[] {
|
||||||
|
const digits = number.replace(/\D/g, "");
|
||||||
|
if (!digits) return [];
|
||||||
|
if (digits.length < 2) return [digits];
|
||||||
|
|
||||||
|
const original = digits;
|
||||||
|
if (selectionType === "straight") return [original];
|
||||||
|
if (selectionType === "reverse") {
|
||||||
|
const rev = original.split("").reverse().join("");
|
||||||
|
return Array.from(new Set([original, rev]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = uniquePermutations(original);
|
||||||
|
if (selectionType === "full_cover" || selectionType === "full_play") return all;
|
||||||
|
|
||||||
|
if (selectionType === "half_play") {
|
||||||
|
const first = digits[0]!;
|
||||||
|
const second = digits[1]!;
|
||||||
|
if (first === second) return all;
|
||||||
|
return all.filter((value) => {
|
||||||
|
const firstIndex = value.indexOf(first);
|
||||||
|
const secondIndex = value.indexOf(second);
|
||||||
|
return firstIndex !== -1 && secondIndex !== -1 && firstIndex < secondIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [original];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionCombinationCount(
|
||||||
|
number: string,
|
||||||
|
selectionType: SelectionType,
|
||||||
|
): number {
|
||||||
|
const cleaned = number.replace(/\D/g, "");
|
||||||
|
if (!cleaned) return 1;
|
||||||
|
if (cleaned.length < 2) return 1;
|
||||||
|
const combos = expandSelectionCombinations(cleaned, selectionType);
|
||||||
|
return Math.max(1, combos.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将「单注输入金额」换算为该种类下的总投注(最小货币单位)。
|
||||||
|
* - full_cover(全保):输入为总金额,平摊到各组合(mbox)
|
||||||
|
* - reverse / half_play / full_play:输入为单注,× 组合数(ibox)
|
||||||
|
* - straight:原样
|
||||||
|
*/
|
||||||
|
export function resolveSelectionTotalBet(
|
||||||
|
amountMinor: number,
|
||||||
|
selectionType: SelectionType,
|
||||||
|
combinationCount: number,
|
||||||
|
): number {
|
||||||
|
if (!Number.isFinite(amountMinor) || amountMinor <= 0) return 0;
|
||||||
|
const count = Math.max(1, combinationCount);
|
||||||
|
|
||||||
|
if (selectionType === "full_cover") {
|
||||||
|
return amountMinor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectionType === "full_play" ||
|
||||||
|
selectionType === "reverse" ||
|
||||||
|
selectionType === "half_play"
|
||||||
|
) {
|
||||||
|
return amountMinor * count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return amountMinor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全保为均摊玩法,输入总额必须恰好能分配到每个排列。 */
|
||||||
|
export function isFullCoverAmountDivisible(
|
||||||
|
amountMinor: number,
|
||||||
|
combinationCount: number,
|
||||||
|
): boolean {
|
||||||
|
if (!Number.isFinite(amountMinor) || amountMinor <= 0) return true;
|
||||||
|
return amountMinor % Math.max(1, combinationCount) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSelectionUnitBet(
|
||||||
|
amountMinor: number,
|
||||||
|
selectionType: SelectionType,
|
||||||
|
combinationCount: number,
|
||||||
|
): number {
|
||||||
|
if (!Number.isFinite(amountMinor) || amountMinor <= 0) return 0;
|
||||||
|
const count = Math.max(1, combinationCount);
|
||||||
|
if (selectionType === "full_cover") {
|
||||||
|
return Math.floor(amountMinor / count);
|
||||||
|
}
|
||||||
|
return amountMinor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHighCostSelectionType(selectionType: SelectionType): boolean {
|
||||||
|
return HIGH_COST_SELECTION_TYPES.has(selectionType);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { ticketDetailHref } from "@/features/orders/group-ticket-items";
|
import { ticketDetailHref } from "@/features/orders/group-ticket-items";
|
||||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||||
@@ -118,10 +119,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
}
|
}
|
||||||
}, [ticketNo, t]);
|
}, [ticketNo, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
|
||||||
});
|
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -261,11 +260,11 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
backLabel={backLabel}
|
backLabel={backLabel}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3 lg:gap-4">
|
||||||
{siblingItems.length > 0 ? (
|
{siblingItems.length > 0 ? (
|
||||||
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] lg:px-5 lg:py-4">
|
||||||
<p className="text-sm font-black text-[#0b3f96]">{t("orders.sameOrderItems")}</p>
|
<p className="text-sm font-black text-[#0b3f96]">{t("orders.sameOrderItems")}</p>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2 lg:mt-3 lg:grid lg:grid-cols-2 lg:gap-3 lg:space-y-0">
|
||||||
{siblingItems.map((row) => {
|
{siblingItems.map((row) => {
|
||||||
const lineSt = ticketStatusDisplay(
|
const lineSt = ticketStatusDisplay(
|
||||||
row.status,
|
row.status,
|
||||||
@@ -311,96 +310,182 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Card className="ring-0 border border-[#e8eef7] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
|
<div className="lg:grid lg:grid-cols-[minmax(0,0.95fr)_minmax(22rem,1.05fr)] lg:items-start lg:gap-4">
|
||||||
<CardHeader className="space-y-2 border-b border-[#edf2f9] pb-3">
|
<Card className="ring-0 border border-[#e8eef7] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<CardHeader className="space-y-2 border-b border-[#edf2f9] pb-3 lg:px-5 lg:pb-4 lg:pt-5">
|
||||||
<CardTitle className="text-base font-black text-[#0b3f96]">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{t("orders.detailTitle")}
|
<CardTitle className="text-base font-black text-[#0b3f96] lg:text-lg">
|
||||||
</CardTitle>
|
{t("orders.detailTitle")}
|
||||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
</CardTitle>
|
||||||
</div>
|
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||||
<CardDescription className="font-mono text-[11px] leading-relaxed text-slate-500">
|
</div>
|
||||||
{t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "}
|
<CardDescription className="font-mono text-[11px] leading-relaxed text-slate-500 lg:text-xs">
|
||||||
{t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
|
{t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "}
|
||||||
</CardDescription>
|
{t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent className="space-y-3 text-sm">
|
</CardHeader>
|
||||||
{isPartialFailedOrder ? (
|
<CardContent className="space-y-3 text-sm lg:space-y-4 lg:px-5 lg:pb-5">
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
{isPartialFailedOrder ? (
|
||||||
<p className="font-bold">{t("orders.partialFailedOrderTitle")}</p>
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||||
<p className="mt-1 leading-relaxed text-amber-800/90">
|
<p className="font-bold">{t("orders.partialFailedOrderTitle")}</p>
|
||||||
{t("orders.partialFailedOrderBody")}
|
<p className="mt-1 leading-relaxed text-amber-800/90">
|
||||||
|
{t("orders.partialFailedOrderBody")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isLineFailed && failReason ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">
|
||||||
|
<p className="font-bold">{t("orders.lineFailedTitle")}</p>
|
||||||
|
<p className="mt-1 leading-relaxed">{failReason}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2.5 text-xs lg:grid lg:grid-cols-2 lg:gap-x-6 lg:gap-y-3 lg:space-y-0 lg:text-sm">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-slate-500">{t("orders.drawNo")}</span>
|
||||||
|
<span className="text-right font-mono font-black text-[#0b3f96]">
|
||||||
|
{data.draw_no ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-slate-500">{t("orders.placedAt")}</span>
|
||||||
|
<span className="text-right font-medium text-slate-800">
|
||||||
|
{formatPlayerInstant(data.placed_at ?? null)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-slate-500">{t("orders.number")}</span>
|
||||||
|
<span className="text-right font-mono text-base font-black text-[#0b3f96]">
|
||||||
|
{data.original_number ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-slate-500">{t("orders.play")}</span>
|
||||||
|
<span className="text-right font-semibold text-[#32518d]">
|
||||||
|
{playLabel(data.play_code, t)} ({data.dimension ?? "—"}D)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-slate-500">
|
||||||
|
{t("hall.providers.title")}
|
||||||
|
</span>
|
||||||
|
<span className="text-right font-black text-[#0b56b7]">
|
||||||
|
{providerLabel(data.provider_name, data.provider_code)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-slate-500">{t("orders.amount")}</span>
|
||||||
|
<span className="text-right font-black tabular-nums text-[#d81435]">
|
||||||
|
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-slate-500">{t("orders.rebateRate")}</span>
|
||||||
|
<span className="text-right font-semibold tabular-nums text-emerald-600">
|
||||||
|
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-slate-500">{t("orders.actualDeduct")}</span>
|
||||||
|
<span className="text-right font-black tabular-nums text-[#0b3f96]">
|
||||||
|
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[#c8daf6] bg-[#f0f6ff] px-3 py-2.5 text-xs lg:px-4 lg:py-3">
|
||||||
|
<p className="font-bold text-[#0b3f96]">{t("orders.oddsSnapshot")}</p>
|
||||||
|
<p className="mt-1 leading-relaxed text-[#32518d]">
|
||||||
|
{formatOddsSnapshot(data.odds_snapshot_json, t)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
{isLineFailed && failReason ? (
|
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">
|
|
||||||
<p className="font-bold">{t("orders.lineFailedTitle")}</p>
|
|
||||||
<p className="mt-1 leading-relaxed">{failReason}</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="space-y-2.5 text-xs">
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="shrink-0 text-slate-500">{t("orders.drawNo")}</span>
|
|
||||||
<span className="text-right font-mono font-black text-[#0b3f96]">
|
|
||||||
{data.draw_no ?? "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="shrink-0 text-slate-500">{t("orders.placedAt")}</span>
|
|
||||||
<span className="text-right font-medium text-slate-800">
|
|
||||||
{formatPlayerInstant(data.placed_at ?? null)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="shrink-0 text-slate-500">{t("orders.number")}</span>
|
|
||||||
<span className="text-right font-mono text-base font-black text-[#0b3f96]">
|
|
||||||
{data.original_number ?? "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="shrink-0 text-slate-500">{t("orders.play")}</span>
|
|
||||||
<span className="text-right font-semibold text-[#32518d]">
|
|
||||||
{playLabel(data.play_code, t)} ({data.dimension ?? "—"}D)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="shrink-0 text-slate-500">
|
|
||||||
{t("hall.providers.title")}
|
|
||||||
</span>
|
|
||||||
<span className="text-right font-black text-[#0b56b7]">
|
|
||||||
{providerLabel(data.provider_name, data.provider_code)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="shrink-0 text-slate-500">{t("orders.amount")}</span>
|
|
||||||
<span className="text-right font-black tabular-nums text-[#d81435]">
|
|
||||||
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="shrink-0 text-slate-500">{t("orders.rebateRate")}</span>
|
|
||||||
<span className="text-right font-semibold tabular-nums text-emerald-600">
|
|
||||||
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="shrink-0 text-slate-500">{t("orders.actualDeduct")}</span>
|
|
||||||
<span className="text-right font-black tabular-nums text-[#0b3f96]">
|
|
||||||
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-[#c8daf6] bg-[#f0f6ff] px-3 py-2.5 text-xs">
|
{data.settlement && tierLabel ? (
|
||||||
<p className="font-bold text-[#0b3f96]">{t("orders.oddsSnapshot")}</p>
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs lg:px-4 lg:py-3">
|
||||||
<p className="mt-1 leading-relaxed text-[#32518d]">
|
<p className="font-bold text-emerald-900">
|
||||||
{formatOddsSnapshot(data.odds_snapshot_json, t)}
|
{t("orders.matchWin", { tier: tierLabel })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p className="mt-1 font-mono text-emerald-800/90">
|
||||||
|
{t("orders.winAmount", {
|
||||||
|
amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur),
|
||||||
|
})}
|
||||||
|
{data.settlement.jackpot_allocation_minor > 0 ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
·{" "}
|
||||||
|
{t("orders.jackpotAmount", {
|
||||||
|
amount: formatMinorAsCurrency(
|
||||||
|
data.settlement.jackpot_allocation_minor,
|
||||||
|
cur,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono font-semibold text-emerald-900">
|
||||||
|
{t(creditMode ? "orders.creditWinTotal" : "orders.payoutTotal", {
|
||||||
|
amount: formatMinorAsCurrency(totalWin, cur),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : hasSettlement ? (
|
||||||
|
<p className="text-xs text-slate-500">{t("orders.matchLose")}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{matchResult && hasSettlement ? (
|
||||||
|
<div className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-3 text-xs lg:px-4">
|
||||||
|
<p className="font-bold text-[#0b3f96]">
|
||||||
|
{t("orders.matchResult")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-slate-600">
|
||||||
|
{matchResult.matched
|
||||||
|
? t("orders.matchWin", { tier: tierLabel ?? (matchResult.matched_prize_tier ?? "—") })
|
||||||
|
: t("orders.matchLose")}
|
||||||
|
</p>
|
||||||
|
{Array.isArray(matchResult.lines) && matchResult.lines.length > 0 ? (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{matchResult.lines.map((line, idx) => (
|
||||||
|
<p key={`${line.number_4d ?? "line"}-${idx}`} className="font-mono text-[11px] text-slate-500">
|
||||||
|
{line.number_4d ?? "—"} · {line.matched_tier ?? "—"} · {formatMinorAsCurrency(line.payout ?? 0, cur)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.settled_at ? (
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
{t("orders.settledAt", { time: formatPlayerInstant(data.settled_at) })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 pt-1 lg:hidden">
|
||||||
|
{data.draw_no ? (
|
||||||
|
<Link
|
||||||
|
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
||||||
|
prefetch={false}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl bg-[#07459f] px-4 text-sm font-bold text-white shadow-sm transition-colors hover:bg-[#063b88]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("orders.viewDraw")}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<Link
|
||||||
|
href={backHref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl border border-[#dce7f7] bg-white px-4 text-sm font-semibold text-[#07459f] transition-colors hover:bg-[#f1f6ff]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("orders.backToOrders")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 lg:gap-4">
|
||||||
{pub?.results ? (
|
{pub?.results ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 rounded-xl border border-[#e8eef7] bg-white p-3 shadow-[0_8px_28px_rgba(15,23,42,0.05)] lg:p-5">
|
||||||
<p className="text-sm font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
<p className="text-sm font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
||||||
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
|
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
|
||||||
{first ? (
|
{first ? (
|
||||||
@@ -422,7 +507,7 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-3 text-xs">
|
<div className="rounded-xl border border-[#dce7f7] bg-white px-3 py-3 text-xs shadow-[0_8px_28px_rgba(15,23,42,0.05)] lg:px-5 lg:py-4">
|
||||||
<p className="font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
<p className="font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
||||||
<p className="mt-1 text-[#32518d]">
|
<p className="mt-1 text-[#32518d]">
|
||||||
{t("orders.drawPendingMatch")}
|
{t("orders.drawPendingMatch")}
|
||||||
@@ -430,68 +515,14 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.settlement && tierLabel ? (
|
|
||||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs">
|
|
||||||
<p className="font-bold text-emerald-900">
|
|
||||||
{t("orders.matchWin", { tier: tierLabel })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-emerald-800/90">
|
|
||||||
{t("orders.winAmount", {
|
|
||||||
amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur),
|
|
||||||
})}
|
|
||||||
{data.settlement.jackpot_allocation_minor > 0 ? (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
·{" "}
|
|
||||||
{t("orders.jackpotAmount", {
|
|
||||||
amount: formatMinorAsCurrency(
|
|
||||||
data.settlement.jackpot_allocation_minor,
|
|
||||||
cur,
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono font-semibold text-emerald-900">
|
|
||||||
{t(creditMode ? "orders.creditWinTotal" : "orders.payoutTotal", {
|
|
||||||
amount: formatMinorAsCurrency(totalWin, cur),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : hasSettlement ? (
|
|
||||||
<p className="text-xs text-slate-500">{t("orders.matchLose")}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{matchResult && hasSettlement ? (
|
|
||||||
<div className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-3 text-xs">
|
|
||||||
<p className="font-bold text-[#0b3f96]">
|
|
||||||
{t("orders.matchResult")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-slate-600">
|
|
||||||
{matchResult.matched
|
|
||||||
? t("orders.matchWin", { tier: tierLabel ?? (matchResult.matched_prize_tier ?? "—") })
|
|
||||||
: t("orders.matchLose")}
|
|
||||||
</p>
|
|
||||||
{Array.isArray(matchResult.lines) && matchResult.lines.length > 0 ? (
|
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
{matchResult.lines.map((line, idx) => (
|
|
||||||
<p key={`${line.number_4d ?? "line"}-${idx}`} className="font-mono text-[11px] text-slate-500">
|
|
||||||
{line.number_4d ?? "—"} · {line.matched_tier ?? "—"} · {formatMinorAsCurrency(line.payout ?? 0, cur)}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{timeline.length > 0 ? (
|
{timeline.length > 0 ? (
|
||||||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] px-3 py-3">
|
<div className="rounded-xl border border-[#e8eef7] bg-white px-3 py-3 shadow-[0_8px_28px_rgba(15,23,42,0.05)] lg:px-5 lg:py-4">
|
||||||
<p className="text-sm font-bold text-[#0b3f96]">
|
<p className="text-sm font-bold text-[#0b3f96]">
|
||||||
{t("orders.timeline")}
|
{t("orders.timeline")}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
{timeline.map((row) => (
|
{timeline.map((row) => (
|
||||||
<div key={row.code} className="flex items-start justify-between gap-3 rounded-lg bg-white px-3 py-2">
|
<div key={row.code} className="flex items-start justify-between gap-3 rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
<p className="min-w-0 text-xs font-bold text-[#32518d]">
|
<p className="min-w-0 text-xs font-bold text-[#32518d]">
|
||||||
{t(`orders.timelineEvent.${row.code}`, { defaultValue: row.label })}
|
{t(`orders.timelineEvent.${row.code}`, { defaultValue: row.label })}
|
||||||
</p>
|
</p>
|
||||||
@@ -504,34 +535,28 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{data.settled_at ? (
|
<div className="hidden flex-wrap gap-3 lg:flex">
|
||||||
<p className="text-[11px] text-slate-500">
|
{data.draw_no ? (
|
||||||
{t("orders.settledAt", { time: formatPlayerInstant(data.settled_at) })}
|
<Link
|
||||||
</p>
|
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
||||||
) : null}
|
prefetch={false}
|
||||||
</CardContent>
|
className={cn(
|
||||||
</Card>
|
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl bg-[#07459f] px-4 text-sm font-bold text-white shadow-sm transition-colors hover:bg-[#063b88]",
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-3">
|
>
|
||||||
{data.draw_no ? (
|
{t("orders.viewDraw")}
|
||||||
<Link
|
</Link>
|
||||||
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
) : null}
|
||||||
prefetch={false}
|
<Link
|
||||||
className={cn(
|
href={backHref}
|
||||||
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl bg-[#07459f] px-4 text-sm font-bold text-white shadow-sm transition-colors hover:bg-[#063b88]",
|
className={cn(
|
||||||
)}
|
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl border border-[#dce7f7] bg-white px-4 text-sm font-semibold text-[#07459f] transition-colors hover:bg-[#f1f6ff]",
|
||||||
>
|
)}
|
||||||
{t("orders.viewDraw")}
|
>
|
||||||
</Link>
|
{t("orders.backToOrders")}
|
||||||
) : null}
|
</Link>
|
||||||
<Link
|
</div>
|
||||||
href={backHref}
|
</div>
|
||||||
className={cn(
|
|
||||||
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl border border-[#dce7f7] bg-white px-4 text-sm font-semibold text-[#07459f] transition-colors hover:bg-[#f1f6ff]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t("orders.backToOrders")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PlayerPanel>
|
</PlayerPanel>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { CalendarRange, ChevronDown, ChevronRight, Search, SlidersHorizontal } from "lucide-react";
|
import { CalendarRange, ChevronDown, ChevronRight, Search, SlidersHorizontal } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -12,12 +12,23 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
|
import { PlayerListPagination } from "@/components/layout/player-list-pagination";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import { groupTicketItems, ticketDetailHref } from "@/features/orders/group-ticket-items";
|
import { groupTicketItems, ticketDetailHref } from "@/features/orders/group-ticket-items";
|
||||||
import { OrderMetaLine } from "@/features/orders/order-meta-line";
|
import { OrderMetaLine } from "@/features/orders/order-meta-line";
|
||||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
|
||||||
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
|
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
@@ -51,12 +62,19 @@ function providerLabel(providerName?: string | null, providerCode?: string | nul
|
|||||||
return providerName || providerCode || "-";
|
return providerName || providerCode || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupProviderLabel(items: TicketItemListRow[]): string {
|
||||||
|
return Array.from(
|
||||||
|
new Set(items.map((item) => providerLabel(item.provider_name, item.provider_code))),
|
||||||
|
).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
export function TicketOrdersListScreen() {
|
export function TicketOrdersListScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const { activeCurrency } = useActivePlayerCurrency();
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||||
|
const isMobile = useIsMobile();
|
||||||
useCurrencyCatalog();
|
useCurrencyCatalog();
|
||||||
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
|
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
|
||||||
const numberFilter = useMemo(() => (searchParams.get("number") ?? "").trim(), [searchParams]);
|
const numberFilter = useMemo(() => (searchParams.get("number") ?? "").trim(), [searchParams]);
|
||||||
@@ -89,9 +107,20 @@ export function TicketOrdersListScreen() {
|
|||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
||||||
const [scheduleTimezone, setScheduleTimezone] = useState(LOTTERY_SCHEDULE_TIMEZONE);
|
const [scheduleTimezone, setScheduleTimezone] = useState(LOTTERY_SCHEDULE_TIMEZONE);
|
||||||
|
/** 注单组默认折叠,点击展开明细 */
|
||||||
|
const [expandedGroupKeys, setExpandedGroupKeys] = useState<Set<string>>(() => new Set());
|
||||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||||
const initialLoadDone = useRef(false);
|
const initialLoadDone = useRef(false);
|
||||||
|
|
||||||
|
const toggleGroupExpanded = useCallback((key: string) => {
|
||||||
|
setExpandedGroupKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const queryDrawNoInput =
|
const queryDrawNoInput =
|
||||||
queryDrawNoState.base === drawNoFilter ? queryDrawNoState.draft : drawNoFilter;
|
queryDrawNoState.base === drawNoFilter ? queryDrawNoState.draft : drawNoFilter;
|
||||||
const queryDrawNo = queryDrawNoInput || drawNoFilter;
|
const queryDrawNo = queryDrawNoInput || drawNoFilter;
|
||||||
@@ -148,7 +177,7 @@ export function TicketOrdersListScreen() {
|
|||||||
[fromDate, queryDrawNo, queryNumber, queryStatuses, t, toDate],
|
[fromDate, queryDrawNo, queryNumber, queryStatuses, t, toDate],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void getDrawCurrent()
|
void getDrawCurrent()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const tz = res.data?.schedule_timezone?.trim();
|
const tz = res.data?.schedule_timezone?.trim();
|
||||||
@@ -164,7 +193,7 @@ export function TicketOrdersListScreen() {
|
|||||||
[scheduleTimezone],
|
[scheduleTimezone],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
if (!initialLoadDone.current) {
|
if (!initialLoadDone.current) {
|
||||||
initialLoadDone.current = true;
|
initialLoadDone.current = true;
|
||||||
void fetchPage(1, false);
|
void fetchPage(1, false);
|
||||||
@@ -189,6 +218,8 @@ export function TicketOrdersListScreen() {
|
|||||||
}, [fetchPage]);
|
}, [fetchPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 仅移动端无限滚动;PC 用分页
|
||||||
|
if (!isMobile) return;
|
||||||
const target = loadMoreRef.current;
|
const target = loadMoreRef.current;
|
||||||
if (!target || loading || loadingMore || page >= lastPage) return;
|
if (!target || loading || loadingMore || page >= lastPage) return;
|
||||||
|
|
||||||
@@ -203,13 +234,207 @@ export function TicketOrdersListScreen() {
|
|||||||
|
|
||||||
observer.observe(target);
|
observer.observe(target);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [fetchPage, lastPage, loading, loadingMore, page]);
|
}, [fetchPage, isMobile, lastPage, loading, loadingMore, page]);
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setQueryDrawNoState({ base: "", draft: "" });
|
||||||
|
setQueryNumberDraft("");
|
||||||
|
setFromDate("");
|
||||||
|
setToDate("");
|
||||||
|
setQueryStatuses([]);
|
||||||
|
setFiltersOpen(false);
|
||||||
|
setFiltersCollapsed(false);
|
||||||
|
setRangeOpen(false);
|
||||||
|
setStatusOpen(false);
|
||||||
|
if (hasUrlFilters) {
|
||||||
|
router.replace("/orders");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = Boolean(
|
||||||
|
queryDrawNoInput || queryNumber || fromDate || toDate || queryStatuses.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterFields = (
|
||||||
|
<>
|
||||||
|
<div className="flex h-9 min-w-0 items-center rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3 lg:h-9 lg:rounded-lg lg:bg-white">
|
||||||
|
<Input
|
||||||
|
value={queryDrawNoInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
setQueryDrawNoState({ base: drawNoFilter, draft: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t("orders.drawNo")}
|
||||||
|
aria-label={t("orders.drawNo")}
|
||||||
|
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-9 min-w-0 items-center gap-2 rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3 lg:h-9 lg:rounded-lg lg:bg-white">
|
||||||
|
<Search className="size-3.5 shrink-0 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
value={queryNumber}
|
||||||
|
onChange={(e) => setQueryNumberDraft(e.target.value)}
|
||||||
|
placeholder={t("orders.number")}
|
||||||
|
aria-label={t("orders.number")}
|
||||||
|
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Popover open={rangeOpen} onOpenChange={setRangeOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 w-full justify-start gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-left text-sm font-bold text-[#32518d] hover:bg-white lg:rounded-lg lg:bg-white"
|
||||||
|
>
|
||||||
|
<CalendarRange className="size-4 text-[#7890b8]" />
|
||||||
|
<span className="truncate">{dateLabel}</span>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
||||||
|
<p className="mb-2 px-1 text-[11px] leading-snug text-muted-foreground">
|
||||||
|
{t("orders.dateRangeHint", { tz: scheduleTzLabel })}
|
||||||
|
</p>
|
||||||
|
<Calendar
|
||||||
|
mode="range"
|
||||||
|
month={calendarMonth}
|
||||||
|
onMonthChange={setCalendarMonth}
|
||||||
|
numberOfMonths={1}
|
||||||
|
selected={selectedRange}
|
||||||
|
onSelect={(range) => {
|
||||||
|
if (!range?.from && !range?.to) {
|
||||||
|
setFromDate("");
|
||||||
|
setToDate("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFromDate(range?.from ? formatSchedulePickerYmd(range.from) : "");
|
||||||
|
setToDate(range?.to ? formatSchedulePickerYmd(range.to) : "");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2 border-t px-2 py-1.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
const today = scheduleTodayYmd(scheduleTimezone);
|
||||||
|
setFromDate(today);
|
||||||
|
setToDate(today);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("orders.scheduleToday")}
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
setFromDate("");
|
||||||
|
setToDate("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("actions.clear")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" size="xs" onClick={() => setRangeOpen(false)}>
|
||||||
|
{t("actions.done")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 w-full justify-between gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-sm font-bold text-[#32518d] hover:bg-white lg:rounded-lg lg:bg-white"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span className="shrink-0">{t("orders.status")}</span>
|
||||||
|
{queryStatuses.length ? (
|
||||||
|
<span className="truncate text-[#0b56b7]">
|
||||||
|
{t(`ticketStatus.${queryStatuses[0]}`, { defaultValue: queryStatuses[0] })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-[#7890b8]">
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
className="w-56 border-[#dce7f7] p-1.5 shadow-[0_16px_40px_rgba(15,23,42,0.14)] lg:w-[28rem] lg:p-3"
|
||||||
|
>
|
||||||
|
<div className="mb-2 hidden items-center justify-between gap-2 px-1 lg:flex">
|
||||||
|
<p className="text-xs font-black text-[#0b3f96]">
|
||||||
|
{t("orders.status")}
|
||||||
|
</p>
|
||||||
|
{queryStatuses.length ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs font-bold text-[#7890b8] transition-colors hover:text-[#0b56b7]"
|
||||||
|
onClick={() => setQueryStatuses([])}
|
||||||
|
>
|
||||||
|
{t("actions.clear")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 lg:grid lg:grid-cols-2 lg:gap-1.5 lg:space-y-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-[13px] font-semibold transition-colors lg:rounded-xl lg:border lg:px-3 lg:py-2.5",
|
||||||
|
queryStatuses.length === 0
|
||||||
|
? "bg-[#eaf2ff] text-[#0b56b7] lg:border-[#b9ccf6]"
|
||||||
|
: "text-[#32518d] hover:bg-[#f8fbff] lg:border-[#e5edf8] lg:bg-[#fbfdff]",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setQueryStatuses([]);
|
||||||
|
setStatusOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("actions.all", { defaultValue: "全部" })}
|
||||||
|
</button>
|
||||||
|
{STATUS_OPTIONS.map((status) => {
|
||||||
|
const checked = queryStatuses[0] === status;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-[13px] font-semibold transition-colors lg:rounded-xl lg:border lg:px-3 lg:py-2.5",
|
||||||
|
checked
|
||||||
|
? "bg-[#eaf2ff] text-[#0b56b7] lg:border-[#b9ccf6]"
|
||||||
|
: "text-[#32518d] hover:bg-[#f8fbff] lg:border-[#e5edf8] lg:bg-[#fbfdff]",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setQueryStatuses(checked ? [] : [status]);
|
||||||
|
setStatusOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{t(`ticketStatus.${status}`, { defaultValue: status })}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerPanel title={t("orders.title")}>
|
<PlayerPanel title={t("orders.title")}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 lg:space-y-4">
|
||||||
<div className="rounded-2xl border border-[#dfe9f8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.05)]">
|
{/* 移动端筛选卡 */}
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="rounded-2xl border border-[#dfe9f8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.05)] lg:hidden">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-[11px] font-black uppercase tracking-wide text-[#6f86ad]">
|
<p className="text-[11px] font-black uppercase tracking-wide text-[#6f86ad]">
|
||||||
{drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}
|
{drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}
|
||||||
@@ -218,7 +443,7 @@ export function TicketOrdersListScreen() {
|
|||||||
{drawNoFilter || total}
|
{drawNoFilter || total}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -243,341 +468,451 @@ export function TicketOrdersListScreen() {
|
|||||||
>
|
>
|
||||||
{t("orders.betNow")}
|
{t("orders.betNow")}
|
||||||
</Link>
|
</Link>
|
||||||
{(queryDrawNoInput || queryNumber || fromDate || toDate || queryStatuses.length > 0) ? (
|
{hasActiveFilters ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-9 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
className="h-9 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||||
onClick={() => {
|
onClick={clearFilters}
|
||||||
setQueryDrawNoState({ base: "", draft: "" });
|
|
||||||
setQueryNumberDraft("");
|
|
||||||
setFromDate("");
|
|
||||||
setToDate("");
|
|
||||||
setQueryStatuses([]);
|
|
||||||
setFiltersOpen(false);
|
|
||||||
setFiltersCollapsed(false);
|
|
||||||
setRangeOpen(false);
|
|
||||||
setStatusOpen(false);
|
|
||||||
if (hasUrlFilters) {
|
|
||||||
router.replace("/orders");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("actions.clear")}
|
{t("actions.clear")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtersExpanded ? (
|
{filtersExpanded ? (
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">{filterFields}</div>
|
||||||
<div className="flex h-9 min-w-0 items-center rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
|
|
||||||
<Input
|
|
||||||
value={queryDrawNoInput}
|
|
||||||
onChange={(e) =>
|
|
||||||
setQueryDrawNoState({ base: drawNoFilter, draft: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder={t("orders.drawNo")}
|
|
||||||
aria-label={t("orders.drawNo")}
|
|
||||||
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex h-9 min-w-0 items-center gap-2 rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
|
|
||||||
<Search className="size-3.5 shrink-0 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
value={queryNumber}
|
|
||||||
onChange={(e) => setQueryNumberDraft(e.target.value)}
|
|
||||||
placeholder={t("orders.number")}
|
|
||||||
aria-label={t("orders.number")}
|
|
||||||
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Popover open={rangeOpen} onOpenChange={setRangeOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-9 w-full justify-start gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-left text-sm font-bold text-[#32518d] hover:bg-white"
|
|
||||||
>
|
|
||||||
<CalendarRange className="size-4 text-[#7890b8]" />
|
|
||||||
<span className="truncate">{dateLabel}</span>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
|
||||||
<p className="mb-2 px-1 text-[11px] leading-snug text-muted-foreground">
|
|
||||||
{t("orders.dateRangeHint", { tz: scheduleTzLabel })}
|
|
||||||
</p>
|
|
||||||
<Calendar
|
|
||||||
mode="range"
|
|
||||||
month={calendarMonth}
|
|
||||||
onMonthChange={setCalendarMonth}
|
|
||||||
numberOfMonths={1}
|
|
||||||
selected={selectedRange}
|
|
||||||
onSelect={(range) => {
|
|
||||||
if (!range?.from && !range?.to) {
|
|
||||||
setFromDate("");
|
|
||||||
setToDate("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFromDate(range?.from ? formatSchedulePickerYmd(range.from) : "");
|
|
||||||
setToDate(range?.to ? formatSchedulePickerYmd(range.to) : "");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between gap-2 border-t px-2 py-1.5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => {
|
|
||||||
const today = scheduleTodayYmd(scheduleTimezone);
|
|
||||||
setFromDate(today);
|
|
||||||
setToDate(today);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("orders.scheduleToday")}
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => {
|
|
||||||
setFromDate("");
|
|
||||||
setToDate("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("actions.clear")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="secondary" size="xs" onClick={() => setRangeOpen(false)}>
|
|
||||||
{t("actions.done")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-9 w-full justify-between gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-sm font-bold text-[#32518d] hover:bg-white"
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<span className="shrink-0">{t("orders.status")}</span>
|
|
||||||
{queryStatuses.length ? (
|
|
||||||
<span className="truncate text-[#0b56b7]">
|
|
||||||
{t(`ticketStatus.${queryStatuses[0]}`, { defaultValue: queryStatuses[0] })}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1 text-[#7890b8]">
|
|
||||||
<ChevronDown className="size-3.5" />
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent align="start" className="w-56 border-[#dce7f7] p-1.5 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-[13px] font-semibold transition-colors",
|
|
||||||
queryStatuses.length === 0 ? "bg-[#eaf2ff] text-[#0b56b7]" : "text-[#32518d] hover:bg-[#f8fbff]",
|
|
||||||
)}
|
|
||||||
onClick={() => setQueryStatuses([])}
|
|
||||||
>
|
|
||||||
{t("actions.all", { defaultValue: "全部" })}
|
|
||||||
</button>
|
|
||||||
{STATUS_OPTIONS.map((status) => {
|
|
||||||
const checked = queryStatuses[0] === status;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={status}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-[13px] font-semibold transition-colors",
|
|
||||||
checked ? "bg-[#eaf2ff] text-[#0b56b7]" : "text-[#32518d] hover:bg-[#f8fbff]",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setQueryStatuses(checked ? [] : [status]);
|
|
||||||
setStatusOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="truncate">{t(`ticketStatus.${status}`, { defaultValue: status })}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
<div className="space-y-3 lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white">
|
||||||
<div className="space-y-3">
|
{/* PC 顶栏:统计 + 筛选 + 操作 */}
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
<div className="hidden lg:block lg:border-b lg:border-[#e5edf8] lg:bg-[#f8fbff] lg:px-5 lg:py-3.5">
|
||||||
<Skeleton key={i} className="h-28 w-full rounded-xl" />
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
))}
|
<div className="min-w-0 shrink-0">
|
||||||
</div>
|
<p className="text-[11px] font-bold text-[#7890b8]">
|
||||||
) : error ? (
|
{drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
</p>
|
||||||
<p>{error}</p>
|
<p className="mt-0.5 font-mono text-lg font-black leading-none text-[#0b3f96]">
|
||||||
<Button
|
{drawNoFilter || total}
|
||||||
type="button"
|
</p>
|
||||||
size="sm"
|
</div>
|
||||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
<div className="grid min-w-0 flex-1 grid-cols-4 gap-2">
|
||||||
onClick={() => void fetchPage(1, false)}
|
{filterFields}
|
||||||
>
|
</div>
|
||||||
{t("actions.retry")}
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
</Button>
|
{hasActiveFilters ? (
|
||||||
</div>
|
<Button
|
||||||
) : items.length === 0 ? (
|
type="button"
|
||||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center">
|
variant="outline"
|
||||||
<p className="text-sm font-bold text-slate-700">{t("orders.empty")}</p>
|
className="h-9 rounded-lg border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||||
<Link
|
onClick={clearFilters}
|
||||||
href="/hall"
|
|
||||||
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-bold text-white"
|
|
||||||
>
|
|
||||||
{t("orders.submitBet")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{orderGroups.map((group) => {
|
|
||||||
const cur = group.currency_code ?? activeCurrency;
|
|
||||||
const st = ticketStatusDisplay(
|
|
||||||
group.status,
|
|
||||||
group.win_amount,
|
|
||||||
group.jackpot_win_amount,
|
|
||||||
t,
|
|
||||||
creditMode,
|
|
||||||
);
|
|
||||||
const totalWin = group.win_amount + group.jackpot_win_amount;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={group.key}
|
|
||||||
className="rounded-xl border border-[#e5edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)]"
|
|
||||||
>
|
>
|
||||||
<div className="p-3 pb-2">
|
{t("actions.clear")}
|
||||||
<div className="flex items-start justify-between gap-3">
|
</Button>
|
||||||
<p className="min-w-0 truncate font-mono text-sm font-black text-[#0b3f96]">
|
) : null}
|
||||||
{group.draw_no ?? "—"}
|
<Link
|
||||||
</p>
|
href="/hall"
|
||||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
className="inline-flex h-9 shrink-0 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-black text-white"
|
||||||
</div>
|
>
|
||||||
<OrderMetaLine
|
{t("orders.betNow")}
|
||||||
orderNo={group.order_no}
|
</Link>
|
||||||
placedAt={group.placed_at}
|
</div>
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
||||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
|
||||||
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
|
||||||
<p className="mt-1 text-sm font-black text-slate-900">
|
|
||||||
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
|
||||||
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
|
||||||
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
|
||||||
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{group.status === "partial_failed" ? (
|
|
||||||
<p className="mt-2 text-xs font-bold text-amber-700">
|
|
||||||
{t("orders.partialFailedHint")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{totalWin > 0 && group.status === "settled_win" ? (
|
|
||||||
<p className="mt-2 text-xs font-bold text-emerald-600">
|
|
||||||
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 border-t border-[#edf2f8] px-3 py-3">
|
|
||||||
<p className="text-[11px] font-bold uppercase tracking-wide text-[#7890b8]">
|
|
||||||
{t("orders.betItems")}
|
|
||||||
</p>
|
|
||||||
{group.items.map((row, index) => {
|
|
||||||
const lineSt = ticketStatusDisplay(
|
|
||||||
row.status,
|
|
||||||
row.win_amount,
|
|
||||||
row.jackpot_win_amount,
|
|
||||||
t,
|
|
||||||
creditMode,
|
|
||||||
);
|
|
||||||
const lineCur = row.currency_code ?? cur;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={row.ticket_no}
|
|
||||||
href={ticketDetailHref(row.ticket_no)}
|
|
||||||
aria-label={t("orders.viewBetLine")}
|
|
||||||
className="flex items-center gap-3 rounded-xl border border-[#e5edf8] bg-[#fbfdff] px-3 py-2.5 transition-colors hover:border-[#b9ccf6] hover:bg-white"
|
|
||||||
>
|
|
||||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#eaf2ff] text-xs font-black text-[#0b56b7]">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-black text-[#0b3f96]">
|
|
||||||
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-xs text-slate-500">
|
|
||||||
<span className="font-bold text-[#0b56b7]">
|
|
||||||
{providerLabel(row.provider_name, row.provider_code)}
|
|
||||||
</span>
|
|
||||||
{" · "}
|
|
||||||
{t("orders.deduction")}{" "}
|
|
||||||
<span className="font-bold tabular-nums text-[#0b3f96]">
|
|
||||||
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
||||||
<StatusDot
|
|
||||||
label={lineSt.label}
|
|
||||||
dotClass={lineSt.dotClass}
|
|
||||||
ring={lineSt.ring}
|
|
||||||
/>
|
|
||||||
<ChevronRight className="size-4 text-[#7890b8]" aria-hidden />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
<div ref={loadMoreRef} className="min-h-1" />
|
</div>
|
||||||
{page < lastPage ? (
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3 lg:space-y-0">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-28 w-full rounded-xl lg:h-12 lg:rounded-none" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 lg:rounded-none lg:border-0 lg:px-5 lg:py-6">
|
||||||
|
<p>{error}</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
size="sm"
|
||||||
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||||
disabled={loadingMore}
|
onClick={() => void fetchPage(1, false)}
|
||||||
onClick={() => void fetchPage(page + 1, true)}
|
|
||||||
>
|
>
|
||||||
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
|
{t("actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
) : lastPage > 1 ? (
|
</div>
|
||||||
<p className="py-2 text-center text-xs text-slate-400">
|
) : items.length === 0 ? (
|
||||||
{t("orders.noMore")}
|
<div className="player-pc-empty flex flex-col items-center justify-center rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center lg:rounded-none lg:border-0 lg:bg-white lg:px-5 lg:py-12">
|
||||||
</p>
|
<p className="text-sm font-bold text-slate-700">{t("orders.empty")}</p>
|
||||||
) : null}
|
<Link
|
||||||
</>
|
href="/hall"
|
||||||
)}
|
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-bold text-white"
|
||||||
|
>
|
||||||
|
{t("orders.submitBet")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 移动端:卡片分组(默认折叠) */}
|
||||||
|
<div className="space-y-3 lg:hidden">
|
||||||
|
{orderGroups.map((group) => {
|
||||||
|
const cur = group.currency_code ?? activeCurrency;
|
||||||
|
const st = ticketStatusDisplay(
|
||||||
|
group.status,
|
||||||
|
group.win_amount,
|
||||||
|
group.jackpot_win_amount,
|
||||||
|
t,
|
||||||
|
creditMode,
|
||||||
|
);
|
||||||
|
const totalWin = group.win_amount + group.jackpot_win_amount;
|
||||||
|
const expanded = expandedGroupKeys.has(group.key);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.key}
|
||||||
|
className="overflow-hidden rounded-xl border border-[#e5edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleGroupExpanded(group.key)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className="w-full p-3 pb-2 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="min-w-0 truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||||
|
{group.draw_no ?? "—"}
|
||||||
|
</p>
|
||||||
|
<OrderMetaLine
|
||||||
|
orderNo={group.order_no}
|
||||||
|
placedAt={group.placed_at}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"size-4 text-[#7890b8] transition-transform",
|
||||||
|
expanded && "rotate-180",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
|
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
||||||
|
<p className="mt-1 text-sm font-black text-slate-900">
|
||||||
|
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
|
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
||||||
|
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||||
|
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs font-semibold text-[#7890b8]">
|
||||||
|
{t("orders.betItems")} · {group.items.length}
|
||||||
|
{" · "}
|
||||||
|
{groupProviderLabel(group.items)}
|
||||||
|
</p>
|
||||||
|
{group.status === "partial_failed" ? (
|
||||||
|
<p className="mt-2 text-xs font-bold text-amber-700">
|
||||||
|
{t("orders.partialFailedHint")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{totalWin > 0 && group.status === "settled_win" ? (
|
||||||
|
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||||
|
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<div className="border-t border-[#edf2f8] px-3 py-3">
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-wide text-[#7890b8]">
|
||||||
|
{t("orders.betItems")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{group.items.map((row, index) => {
|
||||||
|
const lineSt = ticketStatusDisplay(
|
||||||
|
row.status,
|
||||||
|
row.win_amount,
|
||||||
|
row.jackpot_win_amount,
|
||||||
|
t,
|
||||||
|
creditMode,
|
||||||
|
);
|
||||||
|
const lineCur = row.currency_code ?? cur;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={row.ticket_no}
|
||||||
|
href={ticketDetailHref(row.ticket_no)}
|
||||||
|
aria-label={t("orders.viewBetLine")}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-[#e5edf8] bg-[#fbfdff] px-3 py-2.5 transition-colors hover:border-[#b9ccf6] hover:bg-white"
|
||||||
|
>
|
||||||
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#eaf2ff] text-xs font-black text-[#0b56b7]">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-black text-[#0b3f96]">
|
||||||
|
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">
|
||||||
|
<span className="font-bold text-[#0b56b7]">
|
||||||
|
{providerLabel(row.provider_name, row.provider_code)}
|
||||||
|
</span>
|
||||||
|
{" · "}
|
||||||
|
{t("orders.deduction")}{" "}
|
||||||
|
<span className="font-bold tabular-nums text-[#0b3f96]">
|
||||||
|
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<StatusDot
|
||||||
|
label={lineSt.label}
|
||||||
|
dotClass={lineSt.dotClass}
|
||||||
|
ring={lineSt.ring}
|
||||||
|
/>
|
||||||
|
<ChevronRight className="size-4 text-[#7890b8]" aria-hidden />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PC:后台风格表格(默认折叠) */}
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<Table className="table-fixed">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-[#eef3fa] hover:bg-transparent">
|
||||||
|
<TableHead className="player-pc-table-head h-12 w-[22%] bg-[#f8fbff] px-5 font-bold text-[#59739f]">
|
||||||
|
{t("orders.drawNo")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="player-pc-table-head h-12 w-[24%] bg-[#f8fbff] px-3 font-bold text-[#59739f]">
|
||||||
|
{t("orders.betItems")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="player-pc-table-head h-12 w-[12%] bg-[#f8fbff] px-3 font-bold text-[#59739f]">
|
||||||
|
{t("hall.providers.title")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="player-pc-table-head h-12 w-[12%] bg-[#f8fbff] px-3 text-right font-bold text-[#59739f]">
|
||||||
|
{t("orders.stake")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="player-pc-table-head h-12 w-[12%] bg-[#f8fbff] px-3 text-right font-bold text-[#59739f]">
|
||||||
|
{t("orders.deduction")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="player-pc-table-head h-12 w-[14%] bg-[#f8fbff] px-3 text-right font-bold text-[#59739f]">
|
||||||
|
{t("orders.status")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="h-12 w-10 bg-[#f8fbff] px-3" aria-hidden />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{orderGroups.map((group) => {
|
||||||
|
const cur = group.currency_code ?? activeCurrency;
|
||||||
|
const st = ticketStatusDisplay(
|
||||||
|
group.status,
|
||||||
|
group.win_amount,
|
||||||
|
group.jackpot_win_amount,
|
||||||
|
t,
|
||||||
|
creditMode,
|
||||||
|
);
|
||||||
|
const totalWin = group.win_amount + group.jackpot_win_amount;
|
||||||
|
const expanded = expandedGroupKeys.has(group.key);
|
||||||
|
return (
|
||||||
|
<Fragment key={group.key}>
|
||||||
|
<TableRow
|
||||||
|
className="cursor-pointer border-[#eef3fa] bg-white hover:bg-[#f8fafc]"
|
||||||
|
onClick={() => toggleGroupExpanded(group.key)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<TableCell className="px-5 py-2.5 align-middle">
|
||||||
|
<p className="truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||||
|
{group.draw_no ?? "—"}
|
||||||
|
</p>
|
||||||
|
<OrderMetaLine
|
||||||
|
orderNo={group.order_no}
|
||||||
|
placedAt={group.placed_at}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 align-middle">
|
||||||
|
<p className="truncate text-xs font-semibold text-[#7890b8]">
|
||||||
|
{t("orders.betItems")} · {group.items.length}
|
||||||
|
{totalWin > 0 && group.status === "settled_win" ? (
|
||||||
|
<span className="ml-2 text-emerald-600">
|
||||||
|
{t("orders.win", {
|
||||||
|
amount: formatMinorAsCurrency(totalWin, cur),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{group.status === "partial_failed" ? (
|
||||||
|
<span className="ml-2 text-amber-700">
|
||||||
|
{t("orders.partialFailedHint")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 align-middle">
|
||||||
|
<p
|
||||||
|
className="truncate text-sm font-semibold text-[#0b56b7]"
|
||||||
|
title={groupProviderLabel(group.items)}
|
||||||
|
>
|
||||||
|
{groupProviderLabel(group.items)}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||||
|
<p className="text-sm font-black tabular-nums text-slate-900">
|
||||||
|
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||||
|
<p className="text-sm font-black tabular-nums text-[#0b3f96]">
|
||||||
|
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"ml-auto size-4 text-[#7890b8] transition-transform",
|
||||||
|
expanded && "rotate-180",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{expanded
|
||||||
|
? group.items.map((row, index) => {
|
||||||
|
const lineSt = ticketStatusDisplay(
|
||||||
|
row.status,
|
||||||
|
row.win_amount,
|
||||||
|
row.jackpot_win_amount,
|
||||||
|
t,
|
||||||
|
creditMode,
|
||||||
|
);
|
||||||
|
const lineCur = row.currency_code ?? cur;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.ticket_no}
|
||||||
|
className="border-[#f1f5fb] hover:bg-[#f8fafc]"
|
||||||
|
>
|
||||||
|
<TableCell className="px-5 py-2.5 align-middle">
|
||||||
|
<Link
|
||||||
|
href={ticketDetailHref(row.ticket_no)}
|
||||||
|
aria-label={t("orders.viewBetLine")}
|
||||||
|
className="font-mono text-xs text-slate-400"
|
||||||
|
>
|
||||||
|
#{index + 1}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 align-middle">
|
||||||
|
<Link
|
||||||
|
href={ticketDetailHref(row.ticket_no)}
|
||||||
|
className="block min-w-0 truncate text-sm font-bold text-[#0b3f96]"
|
||||||
|
>
|
||||||
|
{playLabel(row.play_code, t)} ·{" "}
|
||||||
|
{row.original_number ?? row.play_code}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 align-middle">
|
||||||
|
<Link
|
||||||
|
href={ticketDetailHref(row.ticket_no)}
|
||||||
|
className="block truncate text-sm font-semibold text-[#0b56b7]"
|
||||||
|
>
|
||||||
|
{providerLabel(row.provider_name, row.provider_code)}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||||
|
<Link
|
||||||
|
href={ticketDetailHref(row.ticket_no)}
|
||||||
|
className="text-sm tabular-nums text-slate-500"
|
||||||
|
>
|
||||||
|
{formatMinorAsCurrency(row.total_bet_amount, lineCur)}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||||
|
<Link
|
||||||
|
href={ticketDetailHref(row.ticket_no)}
|
||||||
|
className="text-sm font-bold tabular-nums text-[#0b3f96]"
|
||||||
|
>
|
||||||
|
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||||
|
<Link
|
||||||
|
href={ticketDetailHref(row.ticket_no)}
|
||||||
|
className="flex justify-end"
|
||||||
|
>
|
||||||
|
<StatusDot
|
||||||
|
label={lineSt.label}
|
||||||
|
dotClass={lineSt.dotClass}
|
||||||
|
ring={lineSt.ring}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||||
|
<Link
|
||||||
|
href={ticketDetailHref(row.ticket_no)}
|
||||||
|
className="inline-flex justify-end text-[#7890b8]"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<div ref={loadMoreRef} className="min-h-1" />
|
||||||
|
{page < lastPage ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||||
|
disabled={loadingMore}
|
||||||
|
onClick={() => void fetchPage(page + 1, true)}
|
||||||
|
>
|
||||||
|
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
|
||||||
|
</Button>
|
||||||
|
) : lastPage > 1 ? (
|
||||||
|
<p className="py-2 text-center text-xs text-slate-400">
|
||||||
|
{t("orders.noMore")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlayerListPagination
|
||||||
|
page={page}
|
||||||
|
lastPage={lastPage}
|
||||||
|
total={total}
|
||||||
|
loading={loading || loadingMore}
|
||||||
|
onPageChange={(nextPage) => {
|
||||||
|
void fetchPage(nextPage, false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PlayerPanel>
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { getPlayerMe, getPlayerPing } from "@/api/player";
|
|||||||
import { isInIframe } from "@/components/iframe-bridge";
|
import { isInIframe } from "@/components/iframe-bridge";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { EntryHeroBanner } from "@/features/player/entry-hero-banner";
|
import { EntryHeroBanner } from "@/features/player/entry-hero-banner";
|
||||||
|
import { PlayerEntryDesktop } from "@/features/player/player-entry-desktop";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
@@ -103,6 +104,20 @@ function getServerUrlTokenSnapshot(): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SSR 与首个客户端画面均视为未就绪,hydration 后才开放入口判断。 */
|
||||||
|
function subscribeHydration(onStoreChange: () => void): () => void {
|
||||||
|
queueMicrotask(onStoreChange);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHydratedSnapshot(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerHydratedSnapshot(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function stripSearchParamFromBrowserUrl(name: string): void {
|
function stripSearchParamFromBrowserUrl(name: string): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
@@ -143,6 +158,15 @@ export function EntryGate() {
|
|||||||
getServerUrlTokenSnapshot,
|
getServerUrlTokenSnapshot,
|
||||||
);
|
);
|
||||||
const sessionExpired = searchParams.get("session") === "expired";
|
const sessionExpired = searchParams.get("session") === "expired";
|
||||||
|
const hasHydrated = useSyncExternalStore(
|
||||||
|
subscribeHydration,
|
||||||
|
getHydratedSnapshot,
|
||||||
|
getServerHydratedSnapshot,
|
||||||
|
);
|
||||||
|
/** URL token 被安全剥离后,直连进场仍须持续到验签完成。 */
|
||||||
|
const [directEntryAccepted, setDirectEntryAccepted] = useState(false);
|
||||||
|
/** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下。 */
|
||||||
|
const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle");
|
||||||
|
|
||||||
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
|
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
|
||||||
usePlayerSessionStore();
|
usePlayerSessionStore();
|
||||||
@@ -162,27 +186,30 @@ export function EntryGate() {
|
|||||||
!(bearerToken ?? "").trim();
|
!(bearerToken ?? "").trim();
|
||||||
|
|
||||||
const gateReady = useMemo(() => {
|
const gateReady = useMemo(() => {
|
||||||
if (typeof window === "undefined") return false;
|
// 首次客户端渲染与 SSR 都显示同一 loading 状态,避免 hydration 重建。
|
||||||
|
if (typeof window === "undefined" || !hasHydrated) return false;
|
||||||
|
|
||||||
if (!isInIframe()) {
|
if (!isInIframe()) {
|
||||||
if (sessionExpired) return false;
|
if (sessionExpired) return false;
|
||||||
return capturedUrlToken !== "" || tokenFromUrl !== "";
|
return directEntryAccepted || capturedUrlToken !== "" || tokenFromUrl !== "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}, [capturedUrlToken, sessionExpired, tokenFromUrl]);
|
}, [capturedUrlToken, directEntryAccepted, hasHydrated, sessionExpired, tokenFromUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!hasHydrated) return;
|
||||||
if (gateReady) return;
|
if (gateReady) return;
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (isInIframe()) return;
|
if (isInIframe()) return;
|
||||||
|
if (entryLifecycleRef.current !== "idle") return;
|
||||||
|
|
||||||
if (sessionExpired) {
|
if (sessionExpired) {
|
||||||
router.replace("/login?session=expired");
|
router.replace("/login?session=expired");
|
||||||
} else if (!tokenFromUrl) {
|
} else if (!tokenFromUrl) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
}
|
}
|
||||||
}, [gateReady, router, sessionExpired, tokenFromUrl]);
|
}, [gateReady, hasHydrated, router, sessionExpired, tokenFromUrl]);
|
||||||
|
|
||||||
const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading");
|
const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading");
|
||||||
const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() =>
|
const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() =>
|
||||||
@@ -203,12 +230,10 @@ export function EntryGate() {
|
|||||||
return urlToken || bearerToken;
|
return urlToken || bearerToken;
|
||||||
}
|
}
|
||||||
if (!isInIframe()) {
|
if (!isInIframe()) {
|
||||||
return urlToken;
|
return urlToken || (directEntryAccepted ? bearerToken : "");
|
||||||
}
|
}
|
||||||
return urlToken || bearerToken;
|
return urlToken || bearerToken;
|
||||||
}, [bearerToken, capturedUrlToken, tokenFromUrl]);
|
}, [bearerToken, capturedUrlToken, directEntryAccepted, tokenFromUrl]);
|
||||||
/** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下 */
|
|
||||||
const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle");
|
|
||||||
|
|
||||||
const updateStep = useCallback((stepId: EntryStepId, status: EntryStepStatus) => {
|
const updateStep = useCallback((stepId: EntryStepId, status: EntryStepStatus) => {
|
||||||
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
|
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
|
||||||
@@ -247,6 +272,9 @@ export function EntryGate() {
|
|||||||
|
|
||||||
const urlToken = capturedUrlToken || tokenFromUrl;
|
const urlToken = capturedUrlToken || tokenFromUrl;
|
||||||
if (urlToken) {
|
if (urlToken) {
|
||||||
|
if (typeof window !== "undefined" && !isInIframe()) {
|
||||||
|
setDirectEntryAccepted(true);
|
||||||
|
}
|
||||||
setBearerToken(urlToken);
|
setBearerToken(urlToken);
|
||||||
pendingUrlToken = "";
|
pendingUrlToken = "";
|
||||||
stripSearchParamFromBrowserUrl("token");
|
stripSearchParamFromBrowserUrl("token");
|
||||||
@@ -464,7 +492,19 @@ export function EntryGate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-white">
|
<>
|
||||||
|
<PlayerEntryDesktop
|
||||||
|
phase={phase}
|
||||||
|
waitingForToken={waitingForEmbeddedToken}
|
||||||
|
progress={progress}
|
||||||
|
steps={steps}
|
||||||
|
failureDetails={failureDetails}
|
||||||
|
t={t}
|
||||||
|
tc={tc}
|
||||||
|
mainSiteUrl={MAIN_SITE_URL}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
/>
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-white lg:hidden">
|
||||||
<div className="absolute left-0 right-0 top-0 z-20 hidden items-center px-4 py-3 lg:flex">
|
<div className="absolute left-0 right-0 top-0 z-20 hidden items-center px-4 py-3 lg:flex">
|
||||||
<LanguageSwitcher variant="header" showFlag={false} />
|
<LanguageSwitcher variant="header" showFlag={false} />
|
||||||
</div>
|
</div>
|
||||||
@@ -684,7 +724,8 @@ export function EntryGate() {
|
|||||||
<span>{t("footer.secure")}</span>
|
<span>{t("footer.secure")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
import { getPublicCurrencies } from "@/api/currency";
|
|
||||||
import { getPlayerMe } from "@/api/player";
|
import { getPlayerMe } from "@/api/player";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { ensureCurrencyCatalogLoaded } from "@/hooks/use-currency-catalog";
|
||||||
import { loadCurrencyDisplayFormat } from "@/lib/currency-display-settings";
|
import { loadCurrencyDisplayFormat } from "@/lib/currency-display-settings";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
@@ -16,9 +15,8 @@ export function HydratePlayerAuth(): null {
|
|||||||
(state) => state.restoreBearerToken,
|
(state) => state.restoreBearerToken,
|
||||||
);
|
);
|
||||||
const setProfile = usePlayerSessionStore((state) => state.setProfile);
|
const setProfile = usePlayerSessionStore((state) => state.setProfile);
|
||||||
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
usePlayerSessionStore.getState().reconcileSelectedCurrency();
|
usePlayerSessionStore.getState().reconcileSelectedCurrency();
|
||||||
void loadCurrencyDisplayFormat();
|
void loadCurrencyDisplayFormat();
|
||||||
const refreshFormat = setInterval(() => {
|
const refreshFormat = setInterval(() => {
|
||||||
@@ -30,8 +28,7 @@ export function HydratePlayerAuth(): null {
|
|||||||
try {
|
try {
|
||||||
if (usePlayerSessionStore.getState().currencies.length === 0) {
|
if (usePlayerSessionStore.getState().currencies.length === 0) {
|
||||||
try {
|
try {
|
||||||
const currencies = await getPublicCurrencies();
|
await ensureCurrencyCatalogLoaded();
|
||||||
setCurrencies(currencies.items);
|
|
||||||
} catch {
|
} catch {
|
||||||
// 币种元数据失败时不影响登录态恢复。
|
// 币种元数据失败时不影响登录态恢复。
|
||||||
}
|
}
|
||||||
@@ -50,7 +47,7 @@ export function HydratePlayerAuth(): null {
|
|||||||
return () => {
|
return () => {
|
||||||
clearInterval(refreshFormat);
|
clearInterval(refreshFormat);
|
||||||
};
|
};
|
||||||
}, [restoreBearerToken, setCurrencies, setProfile]);
|
}, [restoreBearerToken, setProfile]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
279
src/features/player/player-account-dialog.tsx
Normal file
279
src/features/player/player-account-dialog.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown, KeyRound, Loader2, LogOut } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { putPlayerPassword } from "@/api/player-auth";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { validatePlayerLoginPassword } from "@/lib/player-input-validation";
|
||||||
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
|
||||||
|
type AccountView = "profile" | "password";
|
||||||
|
|
||||||
|
function ProfileRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[7.5rem_minmax(0,1fr)] items-start gap-3 py-2.5 text-sm">
|
||||||
|
<dt className="text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="min-w-0 break-words text-right font-medium text-foreground">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountInitial(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed === "" ? "P" : trimmed.slice(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerAccountDialog() {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
const router = useRouter();
|
||||||
|
const profile = usePlayerSessionStore((state) => state.profile);
|
||||||
|
const clearBearerToken = usePlayerSessionStore((state) => state.clearBearerToken);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [view, setView] = useState<AccountView>("profile");
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
profile?.nickname?.trim() ||
|
||||||
|
profile?.username?.trim() ||
|
||||||
|
(profile?.id != null ? t("player.fallback", { id: profile.id }) : t("account.loading"));
|
||||||
|
const isNative = profile?.auth_source === "lottery_native";
|
||||||
|
|
||||||
|
function resetPasswordForm() {
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
setView("profile");
|
||||||
|
resetPasswordForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
handleOpenChange(false);
|
||||||
|
clearBearerToken();
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasswordSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (currentPassword === "" || newPassword === "" || confirmPassword === "") {
|
||||||
|
toast.error(t("account.password.required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (validatePlayerLoginPassword(newPassword) === "too_short") {
|
||||||
|
toast.error(t("account.password.tooShort"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
toast.error(t("account.password.mismatch"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword === currentPassword) {
|
||||||
|
toast.error(t("account.password.mustDiffer"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await putPlayerPassword({
|
||||||
|
current_password: currentPassword,
|
||||||
|
password: newPassword,
|
||||||
|
password_confirmation: confirmPassword,
|
||||||
|
});
|
||||||
|
handleOpenChange(false);
|
||||||
|
clearBearerToken();
|
||||||
|
router.replace("/login?password=changed");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof LotteryApiBizError ? error.message : t("account.password.failed"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={profile === null}
|
||||||
|
aria-label={t("account.open")}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="max-w-[8.5rem] justify-start rounded-full bg-muted/40 px-1.5 sm:max-w-[11rem]"
|
||||||
|
>
|
||||||
|
<Avatar size="sm">
|
||||||
|
<AvatarFallback>{accountInitial(displayName)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-left">{displayName}</span>
|
||||||
|
<ChevronDown data-icon="inline-end" className="hidden sm:block" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
{view === "profile" ? (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("account.title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("account.description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar size="lg">
|
||||||
|
<AvatarFallback>{accountInitial(displayName)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="truncate">{displayName}</CardTitle>
|
||||||
|
<CardDescription className="truncate">
|
||||||
|
{profile?.username || profile?.site_player_id || "—"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{isNative ? t("account.auth.native") : t("account.auth.sso")}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{profile?.funding_mode === "credit"
|
||||||
|
? t("account.funding.credit")
|
||||||
|
: t("account.funding.wallet")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl>
|
||||||
|
<ProfileRow label={t("account.fields.username")} value={profile?.username || "—"} />
|
||||||
|
<Separator />
|
||||||
|
<ProfileRow label={t("account.fields.site")} value={profile?.site_code || "—"} />
|
||||||
|
<Separator />
|
||||||
|
<ProfileRow
|
||||||
|
label={t("account.fields.playerId")}
|
||||||
|
value={profile?.site_player_id || "—"}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<ProfileRow
|
||||||
|
label={t("account.fields.currency")}
|
||||||
|
value={profile?.default_currency || "—"}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{!isNative ? (
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{t("account.ssoManaged")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isNative ? (
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="destructive" onClick={handleLogout}>
|
||||||
|
<LogOut data-icon="inline-start" />
|
||||||
|
{t("account.logout")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setView("password")}>
|
||||||
|
<KeyRound data-icon="inline-start" />
|
||||||
|
{t("account.password.action")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handlePasswordSubmit}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("account.password.title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("account.password.description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="player-current-password">
|
||||||
|
{t("account.password.current")}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="player-current-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="player-new-password">
|
||||||
|
{t("account.password.new")}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="player-new-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>{t("account.password.hint")}</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="player-confirm-password">
|
||||||
|
{t("account.password.confirm")}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="player-confirm-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? <Loader2 data-icon="inline-start" className="animate-spin" /> : null}
|
||||||
|
{saving ? t("account.password.saving") : t("account.password.submit")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" disabled={saving} onClick={() => setView("profile")}>
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/features/player/player-auth-gate.tsx
Normal file
55
src/features/player/player-auth-gate.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
|
function subscribeHydration(onStoreChange: () => void): () => void {
|
||||||
|
let cancelled = false;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!cancelled) onStoreChange();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHydratedSnapshot(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerHydratedSnapshot(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerAuthGate({ children }: { children: ReactNode }): ReactNode {
|
||||||
|
const router = useRouter();
|
||||||
|
const bearerToken = usePlayerSessionStore((state) => state.bearerToken);
|
||||||
|
const hasHydrated = useSyncExternalStore(
|
||||||
|
subscribeHydration,
|
||||||
|
getHydratedSnapshot,
|
||||||
|
getServerHydratedSnapshot,
|
||||||
|
);
|
||||||
|
const redirectStarted = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasHydrated || bearerToken || redirectStarted.current) return;
|
||||||
|
redirectStarted.current = true;
|
||||||
|
router.replace("/login");
|
||||||
|
}, [bearerToken, hasHydrated, router]);
|
||||||
|
|
||||||
|
if (!hasHydrated || !bearerToken) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-1 items-center justify-center bg-white"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Loader2 className="size-8 animate-spin text-red-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
260
src/features/player/player-entry-desktop.tsx
Normal file
260
src/features/player/player-entry-desktop.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Globe,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type DesktopEntryStepStatus = "pending" | "in-progress" | "done" | "error";
|
||||||
|
|
||||||
|
type DesktopEntryStep = {
|
||||||
|
id: "token" | "account" | "hall";
|
||||||
|
status: DesktopEntryStepStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DesktopFailureRow = {
|
||||||
|
code?: string;
|
||||||
|
detailKey?: string;
|
||||||
|
fallbackMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlayerEntryDesktopProps = {
|
||||||
|
phase: "loading" | "success" | "failed";
|
||||||
|
waitingForToken: boolean;
|
||||||
|
progress: number;
|
||||||
|
steps: DesktopEntryStep[];
|
||||||
|
failureDetails: DesktopFailureRow[];
|
||||||
|
t: TFunction<"entry">;
|
||||||
|
tc: TFunction<"common">;
|
||||||
|
mainSiteUrl: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** PC Token 入场校验,视觉与账号密码登录保持一致。 */
|
||||||
|
export function PlayerEntryDesktop({
|
||||||
|
phase,
|
||||||
|
waitingForToken,
|
||||||
|
progress,
|
||||||
|
steps,
|
||||||
|
failureDetails,
|
||||||
|
t,
|
||||||
|
tc,
|
||||||
|
mainSiteUrl,
|
||||||
|
onRetry,
|
||||||
|
}: PlayerEntryDesktopProps) {
|
||||||
|
const isLoading = phase === "loading" || waitingForToken;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative hidden min-h-0 flex-1 overflow-y-auto lg:flex lg:items-center lg:justify-center lg:px-4 lg:py-4 xl:px-8 xl:py-10">
|
||||||
|
<div className="pointer-events-none absolute inset-0" aria-hidden>
|
||||||
|
<Image
|
||||||
|
src="/entry/login-pc-bg.png"
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="100vw"
|
||||||
|
className="object-cover object-center"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex min-h-[min(560px,calc(100dvh-2rem))] w-full max-w-[960px] overflow-hidden rounded-[1.5rem] bg-white shadow-[0_24px_80px_rgba(15,23,42,0.12)] xl:min-h-[min(640px,88vh)] xl:max-w-[1080px] xl:rounded-[2rem]">
|
||||||
|
<div className="relative w-[min(48%,400px)] shrink-0 self-stretch overflow-hidden bg-[#f8fafc] xl:w-[min(50%,460px)]">
|
||||||
|
<Image
|
||||||
|
src="/entry/login-pc-hero.png"
|
||||||
|
alt={t("header.backgroundAlt")}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 1280px) 400px, 460px"
|
||||||
|
className="object-cover object-center"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col px-6 py-5 xl:px-10 xl:py-10">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<LanguageSwitcher variant="pill" menuAlign="end" showFlag={false} useFullLabel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto flex w-full max-w-[25rem] flex-1 flex-col justify-center">
|
||||||
|
{isLoading ? (
|
||||||
|
<EntryProgress progress={progress} steps={steps} t={t} />
|
||||||
|
) : null}
|
||||||
|
{phase === "failed" ? (
|
||||||
|
<EntryFailure
|
||||||
|
details={failureDetails}
|
||||||
|
mainSiteUrl={mainSiteUrl}
|
||||||
|
onRetry={onRetry}
|
||||||
|
t={t}
|
||||||
|
tc={tc}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 text-xs font-medium text-slate-500">
|
||||||
|
<ShieldCheck className="size-4 text-red-500" aria-hidden />
|
||||||
|
<span>{t("footer.secure")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryProgress({
|
||||||
|
progress,
|
||||||
|
steps,
|
||||||
|
t,
|
||||||
|
}: Pick<PlayerEntryDesktopProps, "progress" | "steps" | "t">) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-8 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-red-600 text-white shadow-[0_6px_16px_rgba(220,38,38,0.2)]">
|
||||||
|
<Globe className="size-5" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-semibold text-slate-900">{t("loading.title")}</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">{t("loading.progress")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="mb-2 flex justify-between text-xs text-slate-500">
|
||||||
|
<span>{t("loading.progress")}</span>
|
||||||
|
<span className="font-semibold text-red-600">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-red-600 transition-all duration-500"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<div key={step.id} className="flex items-start gap-3">
|
||||||
|
<StepIcon status={step.status} />
|
||||||
|
<div className="min-w-0 flex-1 pt-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-medium text-slate-800">{t(`steps.${step.id}.title`)}</span>
|
||||||
|
<StepBadge status={step.status} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-slate-500">
|
||||||
|
{t(`steps.${step.id}.description`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryFailure({
|
||||||
|
details,
|
||||||
|
mainSiteUrl,
|
||||||
|
onRetry,
|
||||||
|
t,
|
||||||
|
tc,
|
||||||
|
}: Pick<PlayerEntryDesktopProps, "mainSiteUrl" | "onRetry" | "t" | "tc"> & {
|
||||||
|
details: DesktopFailureRow[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-7 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex size-14 items-center justify-center rounded-full bg-red-50 text-red-600">
|
||||||
|
<AlertTriangle className="size-7" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-red-600">{t("failure.title")}</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{t("failure.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{details.length > 0 ? (
|
||||||
|
<div className="mb-6 overflow-hidden rounded-xl border border-red-100 bg-red-50/60">
|
||||||
|
<div className="border-b border-red-100 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-800">
|
||||||
|
{t("failure.detailsTitle")}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-red-100">
|
||||||
|
{details.map((detail, index) => (
|
||||||
|
<div key={`${detail.code}-${index}`} className="grid grid-cols-[2rem_6.5rem_minmax(0,1fr)] gap-2 px-4 py-3 text-xs">
|
||||||
|
<span className="text-slate-400">{index + 1}</span>
|
||||||
|
<span className="font-medium text-slate-700">{detail.code ?? tc("errors.general")}</span>
|
||||||
|
<span className="leading-5 text-slate-600">
|
||||||
|
{detail.detailKey
|
||||||
|
? t(detail.detailKey)
|
||||||
|
: (detail.fallbackMessage ?? t("errors.unknown"))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button onClick={onRetry} className="flex-1 gap-2 bg-red-600 text-white hover:bg-red-700" type="button">
|
||||||
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
|
{t("failure.reenter")}
|
||||||
|
</Button>
|
||||||
|
{mainSiteUrl ? (
|
||||||
|
<a
|
||||||
|
href={mainSiteUrl}
|
||||||
|
target="_top"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), "flex-1")}
|
||||||
|
>
|
||||||
|
{t("failure.backToMainSite")}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepIcon({ status }: { status: DesktopEntryStepStatus }) {
|
||||||
|
const className = cn(
|
||||||
|
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
|
||||||
|
status === "done" && "border-emerald-500 bg-emerald-500 text-white",
|
||||||
|
status === "in-progress" && "border-blue-600 bg-blue-600 text-white",
|
||||||
|
status === "pending" && "border-slate-200 bg-slate-50 text-slate-400",
|
||||||
|
status === "error" && "border-red-500 bg-red-500 text-white",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{status === "done" ? <CheckCircle2 className="size-4" aria-hidden /> : null}
|
||||||
|
{status === "in-progress" ? <Loader2 className="size-4 animate-spin" aria-hidden /> : null}
|
||||||
|
{status === "pending" ? <div className="size-2 rounded-full bg-current" aria-hidden /> : null}
|
||||||
|
{status === "error" ? <AlertCircle className="size-4" aria-hidden /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepBadge({ status }: { status: DesktopEntryStepStatus }) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const className = cn(
|
||||||
|
"shrink-0 rounded px-2 py-0.5 text-xs font-medium",
|
||||||
|
status === "done" && "bg-emerald-50 text-emerald-700",
|
||||||
|
status === "in-progress" && "bg-blue-50 text-blue-700",
|
||||||
|
status === "pending" && "bg-slate-100 text-slate-500",
|
||||||
|
status === "error" && "bg-red-50 text-red-700",
|
||||||
|
);
|
||||||
|
const label = {
|
||||||
|
done: t("status.done"),
|
||||||
|
"in-progress": t("status.inProgress"),
|
||||||
|
pending: t("status.pending"),
|
||||||
|
error: t("status.failed"),
|
||||||
|
}[status];
|
||||||
|
|
||||||
|
return <span className={className}>{label}</span>;
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ export function PlayerLoginForm({
|
|||||||
}: PlayerLoginFormProps) {
|
}: PlayerLoginFormProps) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const isDesktop = variant === "desktop";
|
const isDesktop = variant === "desktop";
|
||||||
|
const fieldIdPrefix = isDesktop ? "login-desktop" : "login-mobile";
|
||||||
|
const usernameId = `${fieldIdPrefix}-user`;
|
||||||
|
const passwordId = `${fieldIdPrefix}-pass`;
|
||||||
|
const captchaId = `${fieldIdPrefix}-captcha`;
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"h-10 rounded-xl border-gray-200 bg-white text-base shadow-none";
|
"h-10 rounded-xl border-gray-200 bg-white text-base shadow-none";
|
||||||
@@ -55,7 +59,7 @@ export function PlayerLoginForm({
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="login-user" className="text-gray-700">
|
<Label htmlFor={usernameId} className="text-gray-700">
|
||||||
{t("login.username")}
|
{t("login.username")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -64,7 +68,7 @@ export function PlayerLoginForm({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
id="login-user"
|
id={usernameId}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => onUsernameChange(e.target.value)}
|
onChange={(e) => onUsernameChange(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -76,7 +80,7 @@ export function PlayerLoginForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="login-pass" className="text-gray-700">
|
<Label htmlFor={passwordId} className="text-gray-700">
|
||||||
{t("login.password")}
|
{t("login.password")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -85,7 +89,7 @@ export function PlayerLoginForm({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
id="login-pass"
|
id={passwordId}
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => onPasswordChange(e.target.value)}
|
onChange={(e) => onPasswordChange(e.target.value)}
|
||||||
@@ -110,7 +114,7 @@ export function PlayerLoginForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="login-captcha" className="text-gray-700">
|
<Label htmlFor={captchaId} className="text-gray-700">
|
||||||
{t("login.captcha")}
|
{t("login.captcha")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex items-stretch gap-3">
|
<div className="flex items-stretch gap-3">
|
||||||
@@ -120,7 +124,7 @@ export function PlayerLoginForm({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
id="login-captcha"
|
id={captchaId}
|
||||||
name="captcha"
|
name="captcha"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={captchaCode}
|
value={captchaCode}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
|
import { useI18nHydrated } from "@/components/i18n-hydration-provider";
|
||||||
import { useHydrationSafeEntryT } from "@/i18n/hydration-t";
|
import { useHydrationSafeEntryT } from "@/i18n/hydration-t";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ function stripSearchParamFromBrowserUrl(name: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerLoginScreen(): React.ReactElement {
|
export function PlayerLoginScreen(): React.ReactElement {
|
||||||
const { t: tErrors } = useTranslation("entry");
|
const i18nHydrated = useI18nHydrated();
|
||||||
const t = useHydrationSafeEntryT();
|
const t = useHydrationSafeEntryT();
|
||||||
const tRef = useRef(t);
|
const tRef = useRef(t);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +43,9 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken);
|
const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken);
|
||||||
const setProfile = usePlayerSessionStore((s) => s.setProfile);
|
const setProfile = usePlayerSessionStore((s) => s.setProfile);
|
||||||
const clearBearerToken = usePlayerSessionStore((s) => s.clearBearerToken);
|
const clearBearerToken = usePlayerSessionStore((s) => s.clearBearerToken);
|
||||||
const sessionExpiredHandled = useRef(false);
|
const sessionStatusHandled = useRef(false);
|
||||||
|
const passwordChangedHandled = useRef(false);
|
||||||
|
const captchaInitialized = useRef(false);
|
||||||
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -70,29 +72,37 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
if (captchaInitialized.current) return;
|
||||||
|
captchaInitialized.current = true;
|
||||||
void (async () => {
|
void loadCaptcha();
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await loadCaptcha();
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [loadCaptcha]);
|
}, [loadCaptcha]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionExpiredHandled.current) return;
|
if (!i18nHydrated) return;
|
||||||
if (searchParams.get("session") !== "expired") return;
|
if (sessionStatusHandled.current) return;
|
||||||
|
const sessionStatus = searchParams.get("session");
|
||||||
|
if (sessionStatus !== "expired" && sessionStatus !== "replaced") return;
|
||||||
|
|
||||||
sessionExpiredHandled.current = true;
|
sessionStatusHandled.current = true;
|
||||||
clearBearerToken();
|
clearBearerToken();
|
||||||
toast.error(tErrors("errors.sessionExpired"));
|
toast.error(
|
||||||
|
t(
|
||||||
|
sessionStatus === "replaced"
|
||||||
|
? "errors.sessionReplaced"
|
||||||
|
: "errors.sessionExpired",
|
||||||
|
),
|
||||||
|
);
|
||||||
stripSearchParamFromBrowserUrl("session");
|
stripSearchParamFromBrowserUrl("session");
|
||||||
}, [clearBearerToken, searchParams, tErrors]);
|
}, [clearBearerToken, i18nHydrated, searchParams, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (passwordChangedHandled.current) return;
|
||||||
|
if (searchParams.get("password") !== "changed") return;
|
||||||
|
|
||||||
|
passwordChangedHandled.current = true;
|
||||||
|
toast.success(t("login.passwordChanged"));
|
||||||
|
stripSearchParamFromBrowserUrl("password");
|
||||||
|
}, [searchParams, t]);
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent): Promise<void> {
|
async function handleSubmit(event: React.FormEvent): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -108,7 +118,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!captchaCode.trim()) {
|
if (!captchaCode.trim()) {
|
||||||
toast.error(t("login.captchaRequired"));
|
toast.error(t("login.captchaCodeRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-pan
|
|||||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
|
import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
|
||||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
import { norm4d } from "@/lib/norm-4d";
|
import { norm4d } from "@/lib/norm-4d";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import type { DrawResultDetailPayload } from "@/types/api/draw-results";
|
import type { DrawResultDetailPayload } from "@/types/api/draw-results";
|
||||||
@@ -70,10 +70,8 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
}
|
}
|
||||||
}, [activeCurrency, drawNo, t]);
|
}, [activeCurrency, drawNo, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
|
||||||
});
|
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -120,9 +118,9 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
backHref="/results"
|
backHref="/results"
|
||||||
backLabel={t("results.title")}
|
backLabel={t("results.title")}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 lg:space-y-4">
|
||||||
<Skeleton className="h-12 rounded-xl" />
|
<Skeleton className="h-12 rounded-xl lg:h-14" />
|
||||||
<Skeleton className="h-56 rounded-xl" />
|
<Skeleton className="h-56 rounded-xl lg:h-72" />
|
||||||
</div>
|
</div>
|
||||||
</PlayerPanel>
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
@@ -135,9 +133,9 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
backHref="/results"
|
backHref="/results"
|
||||||
backLabel={t("results.title")}
|
backLabel={t("results.title")}
|
||||||
>
|
>
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 lg:px-5 lg:py-5">
|
||||||
<p>{error ?? t("results.noData")}</p>
|
<p>{error ?? t("results.noData")}</p>
|
||||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
<Button type="button" size="sm" variant="secondary" className="mt-3" onClick={() => void load()}>
|
||||||
{t("actions.retry")}
|
{t("actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,33 +168,44 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
backHref="/results"
|
backHref="/results"
|
||||||
backLabel={t("results.title")}
|
backLabel={t("results.title")}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3 lg:gap-5">
|
||||||
<JackpotResultsStrip currencyCode={currency} />
|
<div className="lg:hidden">
|
||||||
|
<JackpotResultsStrip currencyCode={currency} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<JackpotResultsStrip currencyCode={currency} compact />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="overflow-hidden border-[#e5edf8] bg-white shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
<Card className="overflow-hidden border-[#e5edf8] bg-white shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||||
<CardHeader className="space-y-3 border-b border-[#edf2f9] bg-[#f8fbff] pb-3">
|
<CardHeader className="space-y-3 border-b border-[#edf2f9] bg-[#f8fbff] pb-3 lg:px-6 lg:py-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 lg:gap-4">
|
||||||
{data.previous_draw_no ? (
|
{data.previous_draw_no ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
|
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
|
||||||
prefetch={false}
|
prefetch={false}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "outline", size: "sm" }),
|
buttonVariants({ variant: "outline", size: "sm" }),
|
||||||
"min-w-[5rem] rounded-full border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff]",
|
"min-w-[5rem] rounded-full border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff] lg:min-w-[6rem] lg:rounded-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t("results.previous")}
|
{t("results.previous")}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem] rounded-full border-[#e6edf8] bg-white text-slate-400" disabled>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="min-w-[5rem] rounded-full border-[#e6edf8] bg-white text-slate-400 lg:min-w-[6rem] lg:rounded-lg"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
{t("results.previous")}
|
{t("results.previous")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-w-0 flex-1 flex-col items-center text-center">
|
<div className="flex min-w-0 flex-1 flex-col items-center text-center">
|
||||||
<CardTitle className="truncate font-mono text-lg font-black text-[#0b3f96]">
|
<CardTitle className="truncate font-mono text-lg font-black text-[#0b3f96] lg:text-xl">
|
||||||
{data.draw_no}
|
{data.draw_no}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1 font-mono text-xs text-slate-500">
|
<CardDescription className="mt-1 font-mono text-xs text-slate-500 lg:text-sm">
|
||||||
{t("results.drawTime", {
|
{t("results.drawTime", {
|
||||||
time: formatPlayerInstant(data.draw_time_iso ?? data.draw_time ?? null),
|
time: formatPlayerInstant(data.draw_time_iso ?? data.draw_time ?? null),
|
||||||
})}
|
})}
|
||||||
@@ -208,98 +217,83 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
prefetch={false}
|
prefetch={false}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "outline", size: "sm" }),
|
buttonVariants({ variant: "outline", size: "sm" }),
|
||||||
"min-w-[5rem] rounded-full border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff]",
|
"min-w-[5rem] rounded-full border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff] lg:min-w-[6rem] lg:rounded-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t("results.next")}
|
{t("results.next")}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem] rounded-full border-[#e6edf8] bg-white text-slate-400" disabled>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="min-w-[5rem] rounded-full border-[#e6edf8] bg-white text-slate-400 lg:min-w-[6rem] lg:rounded-lg"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
{t("results.next")}
|
{t("results.next")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 pt-3">
|
<CardContent className="space-y-3 pt-3 lg:space-y-5 lg:px-6 lg:pb-6 lg:pt-5">
|
||||||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] p-3 shadow-[0_4px_14px_rgba(15,23,42,0.04)]">
|
{providerResults.length > 1 ? (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-1.5 lg:gap-2">
|
||||||
<p className="text-sm font-black text-[#0b3f96]">
|
{providerResults.map((row) => {
|
||||||
{t("results.detailTitle")}
|
const code = String(row.provider_code ?? "");
|
||||||
</p>
|
const active = code === String(activeProviderResult?.provider_code ?? "");
|
||||||
<span className="rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
return (
|
||||||
{t("results.detail")}
|
<button
|
||||||
</span>
|
key={code || row.provider_name || row.result_version}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveProviderCode(code)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1 text-xs font-bold transition-colors lg:rounded-lg lg:px-3.5 lg:py-1.5 lg:text-sm",
|
||||||
|
active
|
||||||
|
? "border-[#0b56b7] bg-[#0b56b7] text-white"
|
||||||
|
: "border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.provider_name || row.provider_code || "Provider"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{providerResults.length > 1 ? (
|
) : null}
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{providerResults.map((row) => {
|
|
||||||
const code = String(row.provider_code ?? "");
|
|
||||||
const active = code === String(activeProviderResult?.provider_code ?? "");
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={code || row.provider_name || row.result_version}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveProviderCode(code)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full border px-3 py-1 text-xs font-bold transition-colors",
|
|
||||||
active
|
|
||||||
? "border-[#0b56b7] bg-[#0b56b7] text-white"
|
|
||||||
: "border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.provider_name || row.provider_code || "Provider"}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
|
||||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
|
||||||
<div
|
|
||||||
key={tier}
|
|
||||||
className="rounded-lg border border-[#edf2f8] bg-white py-2 shadow-[0_4px_12px_rgba(15,23,42,0.03)]"
|
|
||||||
>
|
|
||||||
<p className="text-[11px] font-bold text-[#7890b8]">
|
|
||||||
{t(resultsPrizeLabelKey(tier))}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
|
||||||
{activeResults[tier]}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TwentyThreeResultsGrid
|
<TwentyThreeResultsGrid
|
||||||
numbers={activeResults}
|
numbers={activeResults}
|
||||||
highlighted4d={highlightSet ?? undefined}
|
highlighted4d={highlightSet ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showMyPayout && myTotals ? (
|
{(showMyPayout && myTotals) || showHitOnly ? (
|
||||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.03)]">
|
<div className="grid gap-3 lg:grid-cols-2 lg:gap-4">
|
||||||
<p className="font-bold text-emerald-900">
|
{showMyPayout && myTotals ? (
|
||||||
{t(creditMode ? "results.creditMyWin" : "results.myPayout")}
|
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.03)] lg:px-4 lg:py-4">
|
||||||
</p>
|
<p className="font-bold text-emerald-900 lg:text-base">
|
||||||
<p className="mt-2 font-mono text-xs tabular-nums text-emerald-900/80">
|
{t(creditMode ? "results.creditMyWin" : "results.myPayout")}
|
||||||
{t("results.regular", {
|
</p>
|
||||||
amount: formatMinorAsCurrency(myTotals.win, currency),
|
<p className="mt-2 font-mono text-xs tabular-nums text-emerald-900/80 lg:text-sm">
|
||||||
})}
|
{t("results.regular", {
|
||||||
{myTotals.jackpot > 0 ? (
|
amount: formatMinorAsCurrency(myTotals.win, currency),
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
·{" "}
|
|
||||||
{t("results.jackpot", {
|
|
||||||
amount: formatMinorAsCurrency(myTotals.jackpot, currency),
|
|
||||||
})}
|
})}
|
||||||
</>
|
{myTotals.jackpot > 0 ? (
|
||||||
) : null}
|
<>
|
||||||
</p>
|
{" "}
|
||||||
</div>
|
·{" "}
|
||||||
) : null}
|
{t("results.jackpot", {
|
||||||
|
amount: formatMinorAsCurrency(myTotals.jackpot, currency),
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showHitOnly ? (
|
{showHitOnly ? (
|
||||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-xs text-amber-950">
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-xs text-amber-950 lg:px-4 lg:py-4 lg:text-sm">
|
||||||
{t(creditMode ? "results.creditHitPending" : "results.hitPending")}
|
{t(creditMode ? "results.creditHitPending" : "results.hitPending")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CalendarIcon, XIcon } from "lucide-react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CalendarIcon, ChevronRight, XIcon } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -17,10 +18,21 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
|
import { PlayerListPagination } from "@/components/layout/player-list-pagination";
|
||||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||||
|
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels";
|
import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels";
|
||||||
@@ -35,7 +47,9 @@ const MONTH_OPTIONS = Array.from({ length: 12 }, (_, value) => ({
|
|||||||
|
|
||||||
export function DrawResultsListScreen() {
|
export function DrawResultsListScreen() {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
|
const router = useRouter();
|
||||||
useCurrencyCatalog();
|
useCurrencyCatalog();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [date, setDate] = useState("");
|
const [date, setDate] = useState("");
|
||||||
@@ -43,6 +57,7 @@ export function DrawResultsListScreen() {
|
|||||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [lastPage, setLastPage] = useState(1);
|
const [lastPage, setLastPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -71,6 +86,7 @@ export function DrawResultsListScreen() {
|
|||||||
setItems((current) => (append && current ? [...current, ...res.items] : res.items));
|
setItems((current) => (append && current ? [...current, ...res.items] : res.items));
|
||||||
setPage(res.page);
|
setPage(res.page);
|
||||||
setLastPage(res.last_page);
|
setLastPage(res.last_page);
|
||||||
|
setTotal(res.total);
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("results.loadFailed"));
|
setError(t("results.loadFailed"));
|
||||||
if (!append) {
|
if (!append) {
|
||||||
@@ -85,13 +101,13 @@ export function DrawResultsListScreen() {
|
|||||||
}
|
}
|
||||||
}, [activeCurrency, businessDate, t]);
|
}, [activeCurrency, businessDate, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void fetchList(1, false);
|
||||||
void fetchList(1, false);
|
|
||||||
});
|
|
||||||
}, [fetchList]);
|
}, [fetchList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 仅移动端无限滚动;PC 用分页
|
||||||
|
if (!isMobile) return;
|
||||||
const target = loadMoreRef.current;
|
const target = loadMoreRef.current;
|
||||||
if (!target || loading || loadingMore || page >= lastPage) return;
|
if (!target || loading || loadingMore || page >= lastPage) return;
|
||||||
|
|
||||||
@@ -106,241 +122,351 @@ export function DrawResultsListScreen() {
|
|||||||
|
|
||||||
observer.observe(target);
|
observer.observe(target);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [fetchList, lastPage, loading, loadingMore, page]);
|
}, [fetchList, isMobile, lastPage, loading, loadingMore, page]);
|
||||||
|
|
||||||
|
const listRows = items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerPanel title={t("results.title")}>
|
<PlayerPanel title={t("results.title")}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 lg:space-y-4">
|
||||||
<JackpotResultsStrip currencyCode={jackpotCurrency} />
|
<div className="space-y-3 lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||||
|
<div className="lg:border-b lg:border-[#eef3fa] lg:bg-[#fbfdff] lg:px-5 lg:py-3">
|
||||||
|
<div className="space-y-3 lg:flex lg:items-center lg:justify-between lg:gap-4 lg:space-y-0">
|
||||||
|
<div className="min-w-0 lg:flex-1">
|
||||||
|
<JackpotResultsStrip currencyCode={jackpotCurrency} compact />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0">
|
||||||
<p className="mb-2 text-xs font-bold text-[#32518d]">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{t("results.businessDate")}
|
<p className="hidden shrink-0 text-xs font-bold text-[#59739f] lg:block">
|
||||||
</p>
|
{t("results.businessDate")}
|
||||||
<div className="flex min-w-0 gap-2">
|
</p>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 lg:w-44 lg:flex-none">
|
||||||
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 w-full justify-start rounded-lg border-[#dce7f7] bg-white px-3 text-left text-sm font-semibold text-[#32518d] hover:bg-[#f8fbff] lg:h-9"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 size-4 text-[#7890b8]" />
|
||||||
|
<span className="truncate">
|
||||||
|
{date
|
||||||
|
|| t("results.selectBusinessDate", {
|
||||||
|
defaultValue: "选择日期",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent align="end" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
||||||
|
<div className="mb-2 grid grid-cols-2 gap-2">
|
||||||
|
<Select
|
||||||
|
value={String(calendarMonth.getMonth())}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setCalendarMonth((current) => new Date(current.getFullYear(), Number(value), 1));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-64 min-w-24">
|
||||||
|
{MONTH_OPTIONS.map((month) => (
|
||||||
|
<SelectItem key={month.value} value={String(month.value)}>
|
||||||
|
{t(month.labelKey)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={String(calendarMonth.getFullYear())}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setCalendarMonth((current) => new Date(Number(value), current.getMonth(), 1));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-64 min-w-24">
|
||||||
|
{quickYears.map((year) => (
|
||||||
|
<SelectItem key={year} value={String(year)}>
|
||||||
|
{year}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
month={calendarMonth}
|
||||||
|
onMonthChange={setCalendarMonth}
|
||||||
|
selected={selectedDate}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (!value) return;
|
||||||
|
setDate(formatBusinessDate(value));
|
||||||
|
setCalendarMonth(value);
|
||||||
|
setDatePickerOpen(false);
|
||||||
|
}}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
{date ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 w-10 rounded-lg border-[#dce7f7] bg-white text-[#7890b8] hover:bg-[#f8fbff] hover:text-[#32518d] lg:h-9 lg:w-9"
|
||||||
|
aria-label={t("actions.clear")}
|
||||||
|
onClick={() => setDate("")}
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-10 shrink-0 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88] lg:h-9"
|
||||||
|
onClick={() => void fetchList(1, false)}
|
||||||
|
>
|
||||||
|
{t("actions.apply")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3 lg:space-y-0 lg:px-0">
|
||||||
|
<Skeleton className="h-28 rounded-xl lg:h-12 lg:rounded-none" />
|
||||||
|
<Skeleton className="h-28 rounded-xl lg:h-12 lg:rounded-none" />
|
||||||
|
<Skeleton className="h-28 rounded-xl lg:h-12 lg:rounded-none" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 lg:rounded-none lg:border-0 lg:px-5 lg:py-6">
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||||
|
onClick={() => void fetchList(1, false)}
|
||||||
|
>
|
||||||
|
{t("actions.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : listRows.length === 0 ? (
|
||||||
|
<div className="player-pc-empty flex items-center justify-center rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center text-sm text-slate-500 lg:rounded-none lg:border-0 lg:bg-white lg:py-12">
|
||||||
|
{t("results.empty")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 移动端:首条高亮卡 */}
|
||||||
|
{featured ? (
|
||||||
|
<div className="rounded-xl border border-[#dce7f7] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:hidden">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#edf2f9] pb-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-normal text-[#0b56b7]">
|
||||||
|
{t("results.latest")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-lg font-black text-[#0b3f96]">
|
||||||
|
{featured.draw_no}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-xs text-slate-500">
|
||||||
|
{t("results.drawTime", {
|
||||||
|
time: formatPlayerInstant(
|
||||||
|
featured.draw_time_iso ?? featured.draw_time ?? null,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{(featured.provider_results?.length ?? 0) > 1 ? (
|
||||||
|
<p className="mt-1 text-[11px] font-semibold text-[#0b56b7]">
|
||||||
|
{featured.provider_results?.map((row) => row.provider_name || row.provider_code).filter(Boolean).join(" / ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/results/${encodeURIComponent(featured.draw_no)}`}
|
||||||
|
prefetch={false}
|
||||||
|
className="inline-flex h-8 shrink-0 items-center justify-center rounded-full border border-[#dce7f7] bg-white px-3 text-sm font-semibold text-[#0b56b7] transition-colors hover:bg-[#f1f6ff]"
|
||||||
|
>
|
||||||
|
{t("results.openDetail", { defaultValue: "查看详情" })}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||||
|
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||||
|
<div key={tier} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
||||||
|
<p className="text-[11px] font-bold text-[#7890b8]">
|
||||||
|
{t(resultsPrizeLabelKey(tier))}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||||||
|
{featured.results[tier]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 移动端:卡片列表(首条已高亮展示时跳过) */}
|
||||||
|
<div className="space-y-3 lg:hidden">
|
||||||
|
{listRows.slice(1).map((row) => (
|
||||||
|
<Link
|
||||||
|
key={row.draw_no}
|
||||||
|
href={`/results/${encodeURIComponent(row.draw_no)}`}
|
||||||
|
prefetch={false}
|
||||||
|
className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||||
|
{row.draw_no}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
|
{formatPlayerInstant(row.draw_time_iso ?? row.draw_time ?? null)}
|
||||||
|
</p>
|
||||||
|
{(row.provider_results?.length ?? 0) > 1 ? (
|
||||||
|
<p className="mt-1 text-[11px] font-semibold text-[#0b56b7]">
|
||||||
|
{row.provider_results?.map((item) => item.provider_name || item.provider_code).filter(Boolean).join(" / ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
||||||
|
{t("results.detail")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||||
|
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||||
|
<div key={tier} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
||||||
|
<p className="text-[11px] font-bold text-[#7890b8]">
|
||||||
|
{t(resultsPrizeLabelKey(tier))}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||||||
|
{row.results[tier]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PC:后台风格表格 */}
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<Table className="table-fixed">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-[#eef3fa] hover:bg-transparent">
|
||||||
|
<TableHead className="player-pc-table-head h-12 w-[30%] bg-[#f8fbff] px-5 font-bold text-[#59739f]">
|
||||||
|
{t("results.detailTitle")}
|
||||||
|
</TableHead>
|
||||||
|
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||||
|
<TableHead
|
||||||
|
key={tier}
|
||||||
|
className="player-pc-table-head h-12 bg-[#f8fbff] px-3 text-center font-bold text-[#59739f]"
|
||||||
|
>
|
||||||
|
{t(resultsPrizeLabelKey(tier))}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
<TableHead className="h-12 w-12 bg-[#f8fbff] px-3" aria-hidden />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{listRows.map((row, index) => {
|
||||||
|
const isLatest = page === 1 && index === 0;
|
||||||
|
const href = `/results/${encodeURIComponent(row.draw_no)}`;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.draw_no}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${t("results.openDetail", { defaultValue: "查看详情" })} ${row.draw_no}`}
|
||||||
|
className="group cursor-pointer border-[#eef3fa] transition-colors hover:bg-[#f8fbff] focus-visible:bg-[#f1f6ff] focus-visible:outline-none"
|
||||||
|
onClick={() => router.push(href)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
router.push(href);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell className="px-5 py-3.5 align-middle">
|
||||||
|
<div className="block min-w-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<p className="truncate font-mono text-sm font-bold text-[#0b3f96]">
|
||||||
|
{row.draw_no}
|
||||||
|
</p>
|
||||||
|
{isLatest ? (
|
||||||
|
<span className="shrink-0 rounded-full bg-[#eaf2ff] px-2 py-0.5 text-[10px] font-black text-[#0b56b7]">
|
||||||
|
{t("results.latest")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500">
|
||||||
|
{formatPlayerInstant(row.draw_time_iso ?? row.draw_time ?? null)}
|
||||||
|
</p>
|
||||||
|
{(row.provider_results?.length ?? 0) > 1 ? (
|
||||||
|
<p className="mt-0.5 truncate text-[11px] font-semibold text-[#0b56b7]">
|
||||||
|
{row.provider_results
|
||||||
|
?.map((item) => item.provider_name || item.provider_code)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||||
|
<TableCell key={tier} className="px-3 py-3.5 text-center align-middle">
|
||||||
|
<span className="font-mono text-base font-bold tabular-nums text-[#e5002c]">
|
||||||
|
{row.results[tier]}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell className="px-3 py-3.5 text-right align-middle">
|
||||||
|
<span className="inline-flex size-8 items-center justify-center rounded-lg text-[#7890b8] transition-colors group-hover:bg-[#eaf2ff] group-hover:text-[#0b56b7]">
|
||||||
|
<ChevronRight className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlayerListPagination
|
||||||
|
page={page}
|
||||||
|
lastPage={lastPage}
|
||||||
|
total={total}
|
||||||
|
loading={loading || loadingMore}
|
||||||
|
onPageChange={(nextPage) => {
|
||||||
|
void fetchList(nextPage, false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<div ref={loadMoreRef} className="min-h-1" />
|
||||||
|
{page < lastPage ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-10 flex-1 justify-start rounded-lg border-[#dce7f7] bg-white px-3 text-left text-sm font-semibold text-[#32518d] hover:bg-[#f8fbff]"
|
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||||
|
disabled={loadingMore}
|
||||||
|
onClick={() => void fetchList(page + 1, true)}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 size-4 text-[#7890b8]" />
|
{loadingMore
|
||||||
{date ||
|
? t("actions.loading")
|
||||||
t("results.selectBusinessDate", {
|
: t("actions.loadMore")}
|
||||||
defaultValue: "选择日期",
|
|
||||||
})}
|
|
||||||
</Button>
|
</Button>
|
||||||
}
|
) : listRows.length > 0 ? (
|
||||||
/>
|
<p className="py-2 text-center text-xs text-slate-400">
|
||||||
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
{t("results.noMore")}
|
||||||
<div className="mb-2 grid grid-cols-2 gap-2">
|
</p>
|
||||||
<Select
|
) : null}
|
||||||
value={String(calendarMonth.getMonth())}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setCalendarMonth((current) => new Date(current.getFullYear(), Number(value), 1));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="max-h-64 min-w-24">
|
|
||||||
{MONTH_OPTIONS.map((month) => (
|
|
||||||
<SelectItem key={month.value} value={String(month.value)}>
|
|
||||||
{t(month.labelKey)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={String(calendarMonth.getFullYear())}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setCalendarMonth((current) => new Date(Number(value), current.getMonth(), 1));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="max-h-64 min-w-24">
|
|
||||||
{quickYears.map((year) => (
|
|
||||||
<SelectItem key={year} value={String(year)}>
|
|
||||||
{year}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
month={calendarMonth}
|
|
||||||
onMonthChange={setCalendarMonth}
|
|
||||||
selected={selectedDate}
|
|
||||||
onSelect={(value) => {
|
|
||||||
if (!value) return;
|
|
||||||
setDate(formatBusinessDate(value));
|
|
||||||
setCalendarMonth(value);
|
|
||||||
setDatePickerOpen(false);
|
|
||||||
}}
|
|
||||||
className="rounded-lg"
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
{date ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 w-10 rounded-lg border-[#dce7f7] bg-white text-[#7890b8] hover:bg-[#f8fbff] hover:text-[#32518d]"
|
|
||||||
aria-label={t("actions.clear")}
|
|
||||||
onClick={() => setDate("")}
|
|
||||||
>
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
className="h-10 shrink-0 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
|
|
||||||
onClick={() => void fetchList(1, false)}
|
|
||||||
>
|
|
||||||
{t("actions.apply")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Skeleton className="h-28 rounded-xl" />
|
|
||||||
<Skeleton className="h-28 rounded-xl" />
|
|
||||||
<Skeleton className="h-28 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
|
||||||
<p>{error}</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
|
||||||
onClick={() => void fetchList(1, false)}
|
|
||||||
>
|
|
||||||
{t("actions.retry")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : items && items.length === 0 ? (
|
|
||||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center text-sm text-slate-500">
|
|
||||||
{t("results.empty")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{featured ? (
|
|
||||||
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#edf2f9] pb-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[11px] font-black uppercase tracking-normal text-[#0b56b7]">
|
|
||||||
{t("results.detailTitle")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-lg font-black text-[#0b3f96]">
|
|
||||||
{featured.draw_no}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-xs text-slate-500">
|
|
||||||
{t("results.drawTime", {
|
|
||||||
time: formatPlayerInstant(
|
|
||||||
featured.draw_time_iso ?? featured.draw_time ?? null,
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
{(featured.provider_results?.length ?? 0) > 1 ? (
|
|
||||||
<p className="mt-1 text-[11px] font-semibold text-[#0b56b7]">
|
|
||||||
{featured.provider_results?.map((row) => row.provider_name || row.provider_code).filter(Boolean).join(" / ")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/results/${encodeURIComponent(featured.draw_no)}`}
|
|
||||||
prefetch={false}
|
|
||||||
className="inline-flex h-8 shrink-0 items-center justify-center rounded-full border border-[#dce7f7] bg-white px-3 text-sm font-semibold text-[#0b56b7] transition-colors hover:bg-[#f1f6ff]"
|
|
||||||
>
|
|
||||||
{t("results.openDetail", { defaultValue: "查看详情" })}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
|
||||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
|
||||||
<div key={tier} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
|
||||||
<p className="text-[11px] font-bold text-[#7890b8]">
|
|
||||||
{t(resultsPrizeLabelKey(tier))}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
|
||||||
{featured.results[tier]}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</>
|
||||||
|
)}
|
||||||
{items?.slice(1).map((row) => (
|
</div>
|
||||||
<Link
|
|
||||||
key={row.draw_no}
|
|
||||||
href={`/results/${encodeURIComponent(row.draw_no)}`}
|
|
||||||
prefetch={false}
|
|
||||||
className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-mono text-sm font-black text-[#0b3f96]">
|
|
||||||
{row.draw_no}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-[11px] text-slate-500">
|
|
||||||
{formatPlayerInstant(row.draw_time_iso ?? row.draw_time ?? null)}
|
|
||||||
</p>
|
|
||||||
{(row.provider_results?.length ?? 0) > 1 ? (
|
|
||||||
<p className="mt-1 text-[11px] font-semibold text-[#0b56b7]">
|
|
||||||
{row.provider_results?.map((item) => item.provider_name || item.provider_code).filter(Boolean).join(" / ")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
|
||||||
{t("results.detail")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
|
||||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
|
||||||
<div key={tier} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
|
||||||
<p className="text-[11px] font-bold text-[#7890b8]">
|
|
||||||
{t(resultsPrizeLabelKey(tier))}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
|
||||||
{row.results[tier]}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<div ref={loadMoreRef} className="min-h-1" />
|
|
||||||
{page < lastPage ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
|
||||||
disabled={loadingMore}
|
|
||||||
onClick={() => void fetchList(page + 1, true)}
|
|
||||||
>
|
|
||||||
{loadingMore
|
|
||||||
? t("actions.loading")
|
|
||||||
: t("actions.loadMore")}
|
|
||||||
</Button>
|
|
||||||
) : items && items.length > 0 ? (
|
|
||||||
<p className="py-2 text-center text-xs text-slate-400">
|
|
||||||
{t("results.noMore")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</PlayerPanel>
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BriefcaseBusiness, CheckCircle2, ChevronDown, Clock3, RefreshCw, XIcon
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getTicketDrawMyMatch, getTicketItems } from "@/api/ticket-items";
|
import { getTicketItems } from "@/api/ticket-items";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -30,6 +30,27 @@ type WinningCheckResult = {
|
|||||||
tickets: TicketItemListRow[];
|
tickets: TicketItemListRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function matchForTickets(
|
||||||
|
drawNo: string,
|
||||||
|
tickets: TicketItemListRow[],
|
||||||
|
): TicketDrawMyMatchPayload {
|
||||||
|
const winningTickets = tickets.filter(
|
||||||
|
(ticket) => ticket.win_amount > 0 || ticket.jackpot_win_amount > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
draw_no: drawNo,
|
||||||
|
hit_numbers_4d: [],
|
||||||
|
total_win_minor: tickets.reduce((sum, ticket) => sum + ticket.win_amount, 0),
|
||||||
|
total_jackpot_win_minor: tickets.reduce(
|
||||||
|
(sum, ticket) => sum + ticket.jackpot_win_amount,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
winning_ticket_count: winningTickets.length,
|
||||||
|
has_bets: tickets.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type DrawWinningCheckPanelProps = {
|
export type DrawWinningCheckPanelProps = {
|
||||||
drawNo: string;
|
drawNo: string;
|
||||||
businessDate?: string | null;
|
businessDate?: string | null;
|
||||||
@@ -66,15 +87,15 @@ export function DrawWinningCheckPanel({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [match, tickets] = await Promise.all([
|
const isTicketNumber = /^TK\d+$/i.test(normalizedTicketNo);
|
||||||
getTicketDrawMyMatch(drawNo),
|
const ticketResult = await getTicketItems({
|
||||||
getTicketItems({
|
draw_no: drawNo,
|
||||||
draw_no: drawNo,
|
ticket_no: isTicketNumber ? normalizedTicketNo.toUpperCase() : undefined,
|
||||||
number: normalizedTicketNo,
|
number: isTicketNumber ? undefined : normalizedTicketNo,
|
||||||
per_page: 10,
|
per_page: 50,
|
||||||
page: 1,
|
page: 1,
|
||||||
}),
|
});
|
||||||
]);
|
const match = matchForTickets(drawNo, ticketResult.items);
|
||||||
const next = {
|
const next = {
|
||||||
draw: {
|
draw: {
|
||||||
draw_id: "",
|
draw_id: "",
|
||||||
@@ -88,7 +109,7 @@ export function DrawWinningCheckPanel({
|
|||||||
result_items: [],
|
result_items: [],
|
||||||
} satisfies DrawResultListItem,
|
} satisfies DrawResultListItem,
|
||||||
match,
|
match,
|
||||||
tickets: tickets.items,
|
tickets: ticketResult.items,
|
||||||
};
|
};
|
||||||
setResult(next);
|
setResult(next);
|
||||||
setRecent((current) =>
|
setRecent((current) =>
|
||||||
@@ -331,7 +352,11 @@ function WinningResultDialog({
|
|||||||
className="h-12 rounded-xl bg-[#07459f] text-base font-black text-white hover:bg-[#063b88]"
|
className="h-12 rounded-xl bg-[#07459f] text-base font-black text-white hover:bg-[#063b88]"
|
||||||
render={
|
render={
|
||||||
<Link
|
<Link
|
||||||
href={`/orders?draw_no=${encodeURIComponent(data.draw.draw_no)}&number=${encodeURIComponent(query)}`}
|
href={
|
||||||
|
/^TK\d+$/i.test(query) && firstTicket
|
||||||
|
? `/orders/${encodeURIComponent(firstTicket.ticket_no)}`
|
||||||
|
: `/orders?draw_no=${encodeURIComponent(data.draw.draw_no)}&number=${encodeURIComponent(query)}`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { getJackpotSummary } from "@/api/jackpot";
|
import { getJackpotSummary } from "@/api/jackpot";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type JackpotResultsStripProps = {
|
type JackpotResultsStripProps = {
|
||||||
currencyCode?: string;
|
currencyCode?: string;
|
||||||
|
/** PC 工具栏内嵌时用更扁的一行布局 */
|
||||||
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 开奖模块顶部:Jackpot 当前池(公开接口) */
|
/** 开奖模块顶部:Jackpot 当前池(公开接口) */
|
||||||
export function JackpotResultsStrip({
|
export function JackpotResultsStrip({
|
||||||
currencyCode = "NPR",
|
currencyCode = "NPR",
|
||||||
|
compact = false,
|
||||||
}: JackpotResultsStripProps) {
|
}: JackpotResultsStripProps) {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const [minor, setMinor] = useState<number | null>(null);
|
const [minor, setMinor] = useState<number | null>(null);
|
||||||
@@ -47,18 +51,46 @@ export function JackpotResultsStrip({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-100 via-white to-[#f8fbff] px-3 py-3 shadow-[0_8px_20px_rgba(180,83,9,0.08)]">
|
<div
|
||||||
<p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
|
className={cn(
|
||||||
{t("results.jackpotLabel")}
|
"rounded-xl border border-amber-200 bg-gradient-to-r from-amber-100 via-white to-[#f8fbff] shadow-[0_8px_20px_rgba(180,83,9,0.08)]",
|
||||||
</p>
|
compact
|
||||||
<p className="font-mono text-lg font-black tabular-nums text-[#0b3f96]">
|
? "px-3 py-2 lg:rounded-lg lg:border-amber-100 lg:shadow-none"
|
||||||
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
|
: "px-3 py-3",
|
||||||
</p>
|
)}
|
||||||
{gap !== null ? (
|
>
|
||||||
<p className="mt-1 text-xs font-semibold text-amber-800">
|
<div
|
||||||
{t("results.jackpotGap", { count: gap })}
|
className={cn(
|
||||||
|
compact && "flex flex-wrap items-center gap-x-3 gap-y-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-black uppercase tracking-normal text-amber-700",
|
||||||
|
compact && "lg:text-[10px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("results.jackpotLabel")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
<p
|
||||||
|
className={cn(
|
||||||
|
"font-mono font-black tabular-nums text-[#0b3f96]",
|
||||||
|
compact ? "text-base lg:text-sm" : "text-lg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
|
||||||
|
</p>
|
||||||
|
{gap !== null ? (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"font-semibold text-amber-800",
|
||||||
|
compact ? "text-[11px] lg:text-[10px]" : "mt-1 text-xs",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("results.jackpotGap", { count: gap })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function TwentyThreeResultsGrid({
|
|||||||
|
|
||||||
const smallCellTone = (raw: string, tone: "red" | "blue") =>
|
const smallCellTone = (raw: string, tone: "red" | "blue") =>
|
||||||
cn(
|
cn(
|
||||||
"grid min-h-[3.875rem] grid-rows-[auto_1fr] rounded-lg border bg-white px-1.5 py-2 text-center shadow-[0_6px_16px_rgba(15,23,42,0.04)]",
|
"grid min-h-[3.25rem] grid-rows-[auto_1fr] rounded-lg border bg-white px-1 py-1.5 text-center shadow-[0_6px_16px_rgba(15,23,42,0.04)] lg:min-h-[3.5rem]",
|
||||||
tone === "red" ? "border-red-100 text-[#e5002c]" : "border-blue-100 text-[#0b56b7]",
|
tone === "red" ? "border-red-100 text-[#e5002c]" : "border-blue-100 text-[#0b56b7]",
|
||||||
isHit(raw) && "border-amber-400 bg-amber-50 text-amber-700 shadow-[0_8px_18px_rgba(245,158,11,0.16)]",
|
isHit(raw) && "border-amber-400 bg-amber-50 text-amber-700 shadow-[0_8px_18px_rgba(245,158,11,0.16)]",
|
||||||
);
|
);
|
||||||
@@ -72,58 +72,60 @@ export function TwentyThreeResultsGrid({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3 lg:gap-4">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2 lg:gap-3">
|
||||||
{prizeCards.map((card) => (
|
{prizeCards.map((card) => (
|
||||||
<div
|
<div
|
||||||
key={card.key}
|
key={card.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden rounded-xl border bg-gradient-to-b px-2 py-4 text-center shadow-[0_10px_24px_rgba(15,23,42,0.06)]",
|
"relative overflow-hidden rounded-xl border bg-gradient-to-b px-2 py-4 text-center shadow-[0_10px_24px_rgba(15,23,42,0.06)] lg:px-3 lg:py-5",
|
||||||
card.border,
|
card.border,
|
||||||
card.wash,
|
card.wash,
|
||||||
isHit(card.value) && "ring-2 ring-amber-300",
|
isHit(card.value) && "ring-2 ring-amber-300",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("mx-auto flex size-9 items-center justify-center rounded-full text-white", card.tone === "red" ? "bg-[#e5002c]" : card.tone === "blue" ? "bg-[#0b56b7]" : "bg-[#0a8f3e]")}>
|
<div className={cn("mx-auto flex size-9 items-center justify-center rounded-full text-white lg:size-10", card.tone === "red" ? "bg-[#e5002c]" : card.tone === "blue" ? "bg-[#0b56b7]" : "bg-[#0a8f3e]")}>
|
||||||
<Trophy className="size-5" />
|
<Trophy className="size-5 lg:size-5" />
|
||||||
</div>
|
</div>
|
||||||
<p className={cn("mt-3 text-xs font-black", card.text)}>{card.label}</p>
|
<p className={cn("mt-3 text-xs font-black lg:text-sm", card.text)}>{card.label}</p>
|
||||||
<p className={cn("mt-2 font-mono text-3xl font-black tabular-nums", card.text)}>{card.value}</p>
|
<p className={cn("mt-2 font-mono text-3xl font-black tabular-nums lg:text-4xl", card.text)}>{card.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.05)]">
|
<div className="grid gap-3 lg:grid-cols-1 xl:grid-cols-2 lg:gap-4">
|
||||||
<p className="mb-3 flex items-center gap-2 text-sm font-black text-[#e5002c]">
|
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.05)] lg:p-4">
|
||||||
<Trophy className="size-4" />
|
<p className="mb-3 flex items-center gap-2 text-sm font-black text-[#e5002c] lg:text-base">
|
||||||
{t("results.grid.starter")}
|
<Trophy className="size-4" />
|
||||||
</p>
|
{t("results.grid.starter")}
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
</p>
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
<div className="grid grid-cols-5 gap-1.5 lg:gap-2">
|
||||||
<div key={`s-${i}`} className={smallCellTone(starters[i] ?? "—", "red")}>
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<span className="text-[11px] font-black">{i + 1}</span>
|
<div key={`s-${i}`} className={smallCellTone(starters[i] ?? "—", "red")}>
|
||||||
<span className="self-center font-mono text-xs font-semibold tabular-nums text-slate-700">
|
<span className="text-[11px] font-black">{i + 1}</span>
|
||||||
{starters[i] ?? "—"}
|
<span className="self-center font-mono text-[11px] font-semibold tabular-nums text-slate-700 sm:text-xs lg:text-sm">
|
||||||
</span>
|
{starters[i] ?? "—"}
|
||||||
</div>
|
</span>
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-blue-100 bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.05)]">
|
<div className="rounded-xl border border-blue-100 bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.05)] lg:p-4">
|
||||||
<p className="mb-3 flex items-center gap-2 text-sm font-black text-[#0b56b7]">
|
<p className="mb-3 flex items-center gap-2 text-sm font-black text-[#0b56b7] lg:text-base">
|
||||||
<Trophy className="size-4" />
|
<Trophy className="size-4" />
|
||||||
{t("results.grid.consolation")}
|
{t("results.grid.consolation")}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
<div className="grid grid-cols-5 gap-1.5 lg:gap-2">
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<div key={`c-${i}`} className={smallCellTone(consos[i] ?? "—", "blue")}>
|
<div key={`c-${i}`} className={smallCellTone(consos[i] ?? "—", "blue")}>
|
||||||
<span className="text-[11px] font-black">{i + 1}</span>
|
<span className="text-[11px] font-black">{i + 1}</span>
|
||||||
<span className="self-center font-mono text-xs font-semibold tabular-nums text-slate-700">
|
<span className="self-center font-mono text-[11px] font-semibold tabular-nums text-slate-700 sm:text-xs lg:text-sm">
|
||||||
{consos[i] ?? "—"}
|
{consos[i] ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,36 +1,56 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Loader2 } from "lucide-react";
|
import { BookOpenText, Loader2, TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
import { getPublicSettings } from "@/api";
|
import { getPublicSettings } from "@/api";
|
||||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
import { resolvePlayRulesHtml } from "@/lib/play-rules-html";
|
import { resolvePlayRulesHtml } from "@/lib/play-rules-html";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
|
||||||
|
const DEFAULT_RULE_SECTIONS = [
|
||||||
|
["dimensions", ["d4", "d3", "d2"]],
|
||||||
|
["bigSmall", ["big", "small"]],
|
||||||
|
["positions", ["d4", "d3", "d2"]],
|
||||||
|
["box", ["straight", "box", "ibox", "mbox", "roll", "half"]],
|
||||||
|
["attributes", ["headTail", "oddEven", "digitSize"]],
|
||||||
|
["wallet", ["rebate", "jackpot", "close", "soldOut"]],
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function PlayRulesScreen() {
|
export function PlayRulesScreen() {
|
||||||
const { t, i18n } = useTranslation("player");
|
const { t, i18n } = useTranslation("player");
|
||||||
const [htmlContent, setHtmlContent] = useState<string | null>(null);
|
const [htmlContent, setHtmlContent] = useState<string | null>(null);
|
||||||
|
const [contentState, setContentState] = useState<"html" | "empty" | "error">("empty");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
async function loadRules() {
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getPublicSettings("frontend");
|
const res = await getPublicSettings("frontend");
|
||||||
const html = resolvePlayRulesHtml(res.items, i18n.language);
|
const html = resolvePlayRulesHtml(res.items, i18n.language);
|
||||||
|
if (cancelled) return;
|
||||||
if (html) {
|
if (html) {
|
||||||
setHtmlContent(html);
|
setHtmlContent(html);
|
||||||
|
setContentState("html");
|
||||||
} else {
|
} else {
|
||||||
setHtmlContent(`<div style="text-align:center;padding:2rem;color:#64748b;">${t("rules.empty", { defaultValue: "暂无玩法规则说明" })}</div>`);
|
setHtmlContent(null);
|
||||||
|
setContentState("empty");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setHtmlContent(`<div style="text-align:center;padding:2rem;color:#64748b;">${t("rules.loadFailed", { defaultValue: "规则加载失败" })}</div>`);
|
if (cancelled) return;
|
||||||
|
setHtmlContent(null);
|
||||||
|
setContentState("error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
})();
|
||||||
void loadRules();
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [i18n.language, t]);
|
}, [i18n.language, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,18 +59,69 @@ export function PlayRulesScreen() {
|
|||||||
backHref="/hall"
|
backHref="/hall"
|
||||||
className="bg-[#f7f9fd]"
|
className="bg-[#f7f9fd]"
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="mx-auto w-full max-w-5xl space-y-3">
|
||||||
<Card className="border-[#dbe7fb] bg-white shadow-sm overflow-hidden min-h-[300px]">
|
<Card className="min-h-[300px] overflow-hidden border-[#dbe7fb] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] lg:min-h-[24rem]">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4 lg:p-8">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-[200px] items-center justify-center">
|
<div className="flex min-h-[18rem] items-center justify-center">
|
||||||
<Loader2 className="size-6 animate-spin text-slate-400" />
|
<Loader2 className="size-6 animate-spin text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : contentState === "html" ? (
|
||||||
<div
|
<div
|
||||||
className="prose prose-sm max-w-none text-slate-700"
|
className="prose prose-sm max-w-none text-slate-700 lg:prose-base lg:leading-7"
|
||||||
dangerouslySetInnerHTML={{ __html: htmlContent || "" }}
|
dangerouslySetInnerHTML={{ __html: htmlContent || "" }}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{contentState === "error" ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||||
|
<TriangleAlert className="size-4 shrink-0" aria-hidden />
|
||||||
|
{t("rules.loadFailed", { defaultValue: "规则加载失败" })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-[#dbe7fb] bg-[#f6f9ff] p-4 lg:p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white text-[#2d63e2] shadow-sm">
|
||||||
|
<BookOpenText className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-black text-[#0b3f96]">
|
||||||
|
{t("rules.quick.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||||
|
{t("rules.quick.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{DEFAULT_RULE_SECTIONS.map(([section, items]) => (
|
||||||
|
<section
|
||||||
|
key={section}
|
||||||
|
className="rounded-2xl border border-[#e5edf8] bg-white p-4 shadow-[0_6px_20px_rgba(15,23,42,0.04)] lg:p-5"
|
||||||
|
>
|
||||||
|
<h2 className="font-black text-[#0b3f96]">
|
||||||
|
{t(`rules.sections.${section}.title`)}
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-3 space-y-2.5 text-sm leading-6 text-slate-600">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item} className="flex gap-2">
|
||||||
|
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-[#e5002c]" aria-hidden />
|
||||||
|
<span>{t(`rules.sections.${section}.${item}`)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 rounded-2xl border border-[#e5edf8] bg-[#f8fbff] p-4 text-xs leading-5 text-slate-500 lg:p-5">
|
||||||
|
<p>{t("rules.footer.config")}</p>
|
||||||
|
<p>{t("rules.footer.phaseTwo")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useMemo, type Ref } from "react";
|
import { useMemo, type Ref } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { PlayerListPagination } from "@/components/layout/player-list-pagination";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { formatLotteryInstantInTimeZone, formatPlayerInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstantInTimeZone, formatPlayerInstant } from "@/lib/player-datetime";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import type { WalletLogItem, WalletLogsData } from "@/types/api/wallet-logs";
|
import type { WalletLogItem, WalletLogsData } from "@/types/api/wallet-logs";
|
||||||
|
import { getWalletLogsLastPage } from "@/types/api/wallet-logs";
|
||||||
|
|
||||||
/** 与 §4.9 筛选一致;接口 `type` 查询参数 */
|
/** 与 §4.9 筛选一致;接口 `type` 查询参数 */
|
||||||
export const WALLET_FLOW_FILTERS: { value: string; labelKey: string }[] = [
|
export const WALLET_FLOW_FILTERS: { value: string; labelKey: string }[] = [
|
||||||
@@ -25,7 +29,6 @@ export const CREDIT_FLOW_FILTERS: { value: string; labelKey: string }[] = [
|
|||||||
{ value: "", labelKey: "wallet.creditFlow.all" },
|
{ value: "", labelKey: "wallet.creditFlow.all" },
|
||||||
{ value: "bet", labelKey: "wallet.creditFlow.bet" },
|
{ value: "bet", labelKey: "wallet.creditFlow.bet" },
|
||||||
{ value: "game_settlement", labelKey: "wallet.creditFlow.game_settlement" },
|
{ value: "game_settlement", labelKey: "wallet.creditFlow.game_settlement" },
|
||||||
{ value: "rebate", labelKey: "wallet.creditFlow.rebate" },
|
|
||||||
{ value: "bill_settlement", labelKey: "wallet.creditFlow.bill_settlement" },
|
{ value: "bill_settlement", labelKey: "wallet.creditFlow.bill_settlement" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -38,8 +41,8 @@ const FLOW_LABEL_FALLBACKS: Record<string, string> = {
|
|||||||
"wallet.flow.refund": "退款",
|
"wallet.flow.refund": "退款",
|
||||||
"wallet.flow.reversal": "冲正",
|
"wallet.flow.reversal": "冲正",
|
||||||
"wallet.creditFlow.all": "全部",
|
"wallet.creditFlow.all": "全部",
|
||||||
"wallet.creditFlow.bet": "下注冻结",
|
"wallet.creditFlow.bet": "下注",
|
||||||
"wallet.creditFlow.game_settlement": "开奖结算",
|
"wallet.creditFlow.game_settlement": "开奖结果",
|
||||||
"wallet.creditFlow.rebate": "账期回水",
|
"wallet.creditFlow.rebate": "账期回水",
|
||||||
"wallet.creditFlow.credit_release": "释额",
|
"wallet.creditFlow.credit_release": "释额",
|
||||||
"wallet.creditFlow.bill_settlement": "账期收付",
|
"wallet.creditFlow.bill_settlement": "账期收付",
|
||||||
@@ -101,6 +104,8 @@ type WalletLogsBlockProps = {
|
|||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
onLoadMore?: () => void;
|
onLoadMore?: () => void;
|
||||||
loadMoreRef?: Ref<HTMLDivElement>;
|
loadMoreRef?: Ref<HTMLDivElement>;
|
||||||
|
/** PC:翻页(替换无限滚动) */
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
filter: string;
|
filter: string;
|
||||||
onFilterChange: (value: string) => void;
|
onFilterChange: (value: string) => void;
|
||||||
currency: string;
|
currency: string;
|
||||||
@@ -119,6 +124,7 @@ export function WalletLogsBlock({
|
|||||||
hasMore = false,
|
hasMore = false,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
loadMoreRef,
|
loadMoreRef,
|
||||||
|
onPageChange,
|
||||||
filter,
|
filter,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
currency,
|
currency,
|
||||||
@@ -130,7 +136,7 @@ export function WalletLogsBlock({
|
|||||||
const resolvedTitle =
|
const resolvedTitle =
|
||||||
title
|
title
|
||||||
?? (creditMode
|
?? (creditMode
|
||||||
? t("wallet.creditFlowsTitle", { defaultValue: "信用流水" })
|
? t("wallet.creditFlowsTitle", { defaultValue: "额度与结算记录" })
|
||||||
: t("wallet.flowsTitle", { defaultValue: "钱包流水" }));
|
: t("wallet.flowsTitle", { defaultValue: "钱包流水" }));
|
||||||
const filters = useMemo(() => {
|
const filters = useMemo(() => {
|
||||||
const source = creditMode ? CREDIT_FLOW_FILTERS : WALLET_FLOW_FILTERS;
|
const source = creditMode ? CREDIT_FLOW_FILTERS : WALLET_FLOW_FILTERS;
|
||||||
@@ -142,29 +148,33 @@ export function WalletLogsBlock({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="space-y-3">
|
<section className="space-y-3 lg:space-y-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between lg:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-black text-[#0b3f96]">{resolvedTitle}</h2>
|
<h2 className="text-sm font-black text-[#0b3f96] lg:text-base">{resolvedTitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="w-full overflow-x-auto overscroll-x-contain [scrollbar-width:none] lg:w-auto lg:max-w-full">
|
||||||
{filters.map((f) => (
|
<div className="inline-flex min-w-max items-center gap-1 rounded-lg bg-[#f3f6fb] p-1">
|
||||||
<Button
|
{filters.map((f) => {
|
||||||
key={f.value || "all"}
|
const active = filter === f.value;
|
||||||
type="button"
|
return (
|
||||||
size="sm"
|
<button
|
||||||
variant={filter === f.value ? "default" : "outline"}
|
key={f.value || "all"}
|
||||||
className={
|
type="button"
|
||||||
filter === f.value
|
disabled={logsLoading && active}
|
||||||
? "h-8 rounded-full bg-[#07459f] px-3 text-xs font-bold text-white hover:bg-[#063b88]"
|
onClick={() => onFilterChange(f.value)}
|
||||||
: "h-8 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
aria-pressed={active}
|
||||||
}
|
className={
|
||||||
disabled={logsLoading && filter === f.value}
|
active
|
||||||
onClick={() => onFilterChange(f.value)}
|
? "inline-flex h-8 items-center justify-center rounded-md bg-[#2d63e2] px-3 text-sm font-bold text-white shadow-[0_3px_8px_rgba(45,99,226,0.24)] transition-colors hover:bg-[#2556c7] disabled:opacity-60 lg:h-auto lg:min-w-[4.5rem] lg:px-3.5 lg:py-2"
|
||||||
>
|
: "inline-flex h-8 items-center justify-center rounded-md px-3 text-sm font-bold text-[#5b7fbf] transition-colors hover:bg-[#f3f7ff] hover:text-[#2d63e2] lg:h-auto lg:min-w-[4.5rem] lg:px-3.5 lg:py-2"
|
||||||
{f.label}
|
}
|
||||||
</Button>
|
>
|
||||||
))}
|
{f.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -174,30 +184,79 @@ export function WalletLogsBlock({
|
|||||||
|
|
||||||
{logs ? (
|
{logs ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-muted-foreground">
|
{/* PC 分页底栏已含总数,避免重复 */}
|
||||||
|
<p className="text-xs text-muted-foreground lg:hidden">
|
||||||
{t("wallet.totalRecords", { total: logs.total })}
|
{t("wallet.totalRecords", { total: logs.total })}
|
||||||
</p>
|
</p>
|
||||||
<ul className={logsLoading ? "space-y-2 opacity-60" : "space-y-2"}>
|
<div className={logsLoading ? "opacity-60" : undefined}>
|
||||||
{logs.items.length === 0 ? (
|
{logs.items.length === 0 ? (
|
||||||
<li className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
<div className="player-pc-empty flex items-center justify-center rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground lg:py-12">
|
||||||
{t(creditMode ? "wallet.emptyCreditLogs" : "wallet.emptyLogs", {
|
{t(creditMode ? "wallet.emptyCreditLogs" : "wallet.emptyLogs", {
|
||||||
defaultValue: creditMode ? "暂无信用流水" : "暂无流水",
|
defaultValue: creditMode ? "暂无信用流水" : "暂无流水",
|
||||||
})}
|
})}
|
||||||
</li>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.items.map((row) => (
|
<>
|
||||||
<LogRow
|
<div className="lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||||
key={row.log_id}
|
<div className={creditMode
|
||||||
item={row}
|
? "hidden lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)] lg:items-center lg:gap-3 lg:border-b lg:border-[#e9eff8] lg:bg-[#f8fbff] lg:px-5 lg:py-3"
|
||||||
currency={currency}
|
: "hidden lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(8rem,0.9fr)_minmax(7rem,0.8fr)_minmax(7rem,0.8fr)_minmax(6rem,0.7fr)] lg:items-center lg:gap-3 lg:border-b lg:border-[#e9eff8] lg:bg-[#f8fbff] lg:px-5 lg:py-3"}
|
||||||
creditMode={creditMode}
|
>
|
||||||
settlementTimeZone={settlementTimeZone}
|
<p className="player-pc-table-head font-bold text-[#59739f]">
|
||||||
/>
|
{t("wallet.logType", { defaultValue: "类型" })}
|
||||||
))
|
</p>
|
||||||
|
<p className="player-pc-table-head font-bold text-[#59739f]">
|
||||||
|
{t("wallet.logTime", { defaultValue: "时间" })}
|
||||||
|
</p>
|
||||||
|
<p className="player-pc-table-head font-bold text-[#59739f]">
|
||||||
|
{t("wallet.logRef", { defaultValue: "关联单号" })}
|
||||||
|
</p>
|
||||||
|
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
|
||||||
|
{t("wallet.logAmount", { defaultValue: "金额" })}
|
||||||
|
</p>
|
||||||
|
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
|
||||||
|
{creditMode
|
||||||
|
? t("wallet.creditAvailableAfter")
|
||||||
|
: t("wallet.balanceAfter")}
|
||||||
|
</p>
|
||||||
|
{!creditMode ? (
|
||||||
|
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
|
||||||
|
{t("orders.status")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 lg:space-y-0">
|
||||||
|
{logs.items.map((row) => creditMode ? (
|
||||||
|
<CreditActivityRow
|
||||||
|
key={row.log_id}
|
||||||
|
item={row}
|
||||||
|
currency={currency}
|
||||||
|
settlementTimeZone={settlementTimeZone}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LogRow
|
||||||
|
key={row.log_id}
|
||||||
|
item={row}
|
||||||
|
currency={currency}
|
||||||
|
settlementTimeZone={settlementTimeZone}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{onPageChange ? (
|
||||||
|
<PlayerListPagination
|
||||||
|
page={logs.page}
|
||||||
|
lastPage={getWalletLogsLastPage(logs)}
|
||||||
|
total={logs.total}
|
||||||
|
loading={logsLoading}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</div>
|
||||||
{logs.items.length > 0 ? (
|
{logs.items.length > 0 ? (
|
||||||
<>
|
<div className="lg:hidden">
|
||||||
<div ref={loadMoreRef} className="min-h-1" />
|
<div ref={loadMoreRef} className="min-h-1" />
|
||||||
{hasMore ? (
|
{hasMore ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -216,7 +275,7 @@ export function WalletLogsBlock({
|
|||||||
{t("wallet.noMoreLogs")}
|
{t("wallet.noMoreLogs")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -225,6 +284,158 @@ export function WalletLogsBlock({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function creditActivityLabel(
|
||||||
|
item: WalletLogItem,
|
||||||
|
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||||
|
): string {
|
||||||
|
const labels: Record<string, { key: string; fallback: string }> = {
|
||||||
|
bet_pending: { key: "wallet.activity.bet", fallback: "下注" },
|
||||||
|
settled_loss: { key: "wallet.activity.settledLoss", fallback: "未中奖" },
|
||||||
|
settled_win: { key: "wallet.activity.settledWin", fallback: "中奖" },
|
||||||
|
settled_even: { key: "wallet.activity.result", fallback: "开奖结果" },
|
||||||
|
bet_refund: { key: "wallet.activity.refunded", fallback: "已退款" },
|
||||||
|
period_paid: { key: "wallet.activity.periodPaid", fallback: "已付款" },
|
||||||
|
period_received: { key: "wallet.activity.periodReceived", fallback: "已收款" },
|
||||||
|
};
|
||||||
|
const resolved = labels[item.biz_type];
|
||||||
|
if (!resolved) {
|
||||||
|
return t("wallet.activity.result", { defaultValue: "开奖结果" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return t(resolved.key, { defaultValue: resolved.fallback });
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreditActivityRow({
|
||||||
|
item,
|
||||||
|
currency,
|
||||||
|
settlementTimeZone,
|
||||||
|
}: {
|
||||||
|
item: WalletLogItem;
|
||||||
|
currency: string;
|
||||||
|
settlementTimeZone?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
const ccy = item.currency_code || currency;
|
||||||
|
const amount = Number(item.net_amount ?? item.amount ?? 0);
|
||||||
|
const amountTone = amount > 0
|
||||||
|
? "text-emerald-700"
|
||||||
|
: amount < 0
|
||||||
|
? "text-[#e5002c]"
|
||||||
|
: "text-[#32518d]";
|
||||||
|
const amountLabel = amount === 0
|
||||||
|
? formatMinorAsCurrency(0, ccy)
|
||||||
|
: `${amount > 0 ? "+" : "−"}${formatMinorAsCurrency(Math.abs(amount), ccy)}`;
|
||||||
|
const timeLabel = settlementTimeZone
|
||||||
|
? formatLotteryInstantInTimeZone(item.created_at, settlementTimeZone)
|
||||||
|
: formatPlayerInstant(item.created_at);
|
||||||
|
const balanceLabel = item.balance_after == null
|
||||||
|
? "—"
|
||||||
|
: formatMinorAsCurrency(item.balance_after, ccy);
|
||||||
|
const refLabel = item.draw_no
|
||||||
|
? `${item.draw_no}${item.order_no ? ` · ${item.order_no}` : ""}`
|
||||||
|
: item.order_no ?? (item.settlement_bill_id ? `#${item.settlement_bill_id}` : "—");
|
||||||
|
const detailHref = item.ticket_no
|
||||||
|
? `/orders/${encodeURIComponent(item.ticket_no)}`
|
||||||
|
: item.draw_no
|
||||||
|
? `/orders?draw_no=${encodeURIComponent(item.draw_no)}`
|
||||||
|
: null;
|
||||||
|
const showStatus = item.activity_status === "pending" || item.activity_status === "reversed";
|
||||||
|
const statusLabel = item.activity_status === "reversed"
|
||||||
|
? t("wallet.activity.reversed", { defaultValue: "已冲正" })
|
||||||
|
: item.activity_kind === "bet"
|
||||||
|
? t("wallet.activity.awaitingDraw", { defaultValue: "待开奖" })
|
||||||
|
: t("wallet.activity.processing", { defaultValue: "处理中" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-2xl border border-[#e1eaf6] bg-white px-3 py-3 text-sm shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)] lg:items-center lg:gap-3 lg:rounded-none lg:border-x-0 lg:border-t-0 lg:border-b-[#edf2f8] lg:px-5 lg:py-4 lg:shadow-none lg:last:border-b-0 lg:hover:bg-[#fbfdff]">
|
||||||
|
<div className="flex items-start justify-between gap-3 lg:contents">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-base font-black leading-tight text-[#101a33] lg:text-sm">
|
||||||
|
{creditActivityLabel(item, t)}
|
||||||
|
</p>
|
||||||
|
{showStatus ? (
|
||||||
|
<span className={item.activity_status === "reversed"
|
||||||
|
? "inline-flex rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-black text-slate-600"
|
||||||
|
: "inline-flex rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10px] font-black text-blue-700"}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-xs font-medium text-slate-500 lg:hidden">{timeLabel}</p>
|
||||||
|
<p className="mt-1 truncate font-mono text-[11px] text-slate-400 lg:hidden">{refLabel}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={`shrink-0 text-right text-lg font-black tabular-nums lg:hidden ${amountTone}`}>
|
||||||
|
{amountLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="hidden text-sm text-slate-600 lg:block">{timeLabel}</p>
|
||||||
|
<div className="hidden min-w-0 lg:block">
|
||||||
|
{detailHref ? (
|
||||||
|
<Link className="block truncate font-mono text-xs text-[#32518d] hover:underline" href={detailHref}>
|
||||||
|
{refLabel}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="truncate font-mono text-xs text-slate-400">{refLabel}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={`hidden text-right text-sm font-black tabular-nums lg:block ${amountTone}`}>
|
||||||
|
{amountLabel}
|
||||||
|
</p>
|
||||||
|
<p className="hidden text-right font-mono text-sm font-black tabular-nums text-[#32518d] lg:block">
|
||||||
|
{balanceLabel}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details className="group mt-3 rounded-xl bg-[#f8fbff] lg:col-span-5 lg:mt-0">
|
||||||
|
<summary className="flex min-h-9 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-bold text-[#32518d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2d63e2] [&::-webkit-details-marker]:hidden">
|
||||||
|
{t("wallet.activity.details", { defaultValue: "查看明细" })}
|
||||||
|
<ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />
|
||||||
|
</summary>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2 border-t border-[#e1eaf6] px-3 py-3 text-xs lg:grid-cols-4">
|
||||||
|
{Number(item.stake_amount ?? 0) > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-500">{t("wallet.activity.stake", { defaultValue: "下注金额" })}</p>
|
||||||
|
<p className="mt-1 font-mono font-black text-[#101a33]">{formatMinorAsCurrency(item.stake_amount ?? 0, ccy)}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{Number(item.win_amount ?? 0) > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-500">{t("wallet.activity.win", { defaultValue: "中奖金额" })}</p>
|
||||||
|
<p className="mt-1 font-mono font-black text-emerald-700">+{formatMinorAsCurrency(item.win_amount ?? 0, ccy)}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{Number(item.rebate_amount ?? 0) > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-500">{t("wallet.activity.rebate", { defaultValue: "回水" })}</p>
|
||||||
|
<p className="mt-1 font-mono font-black text-emerald-700">+{formatMinorAsCurrency(item.rebate_amount ?? 0, ccy)}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-500">{t("wallet.creditAvailableAfter", { defaultValue: "变更后可用额度" })}</p>
|
||||||
|
<p className="mt-1 font-mono font-black text-[#32518d]">{balanceLabel}</p>
|
||||||
|
</div>
|
||||||
|
{Number(item.ticket_count ?? 0) > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-500">{t("wallet.activity.ticketCount", { defaultValue: "注单数量" })}</p>
|
||||||
|
<p className="mt-1 font-black text-[#101a33]">{t("wallet.activity.ticketCountValue", { defaultValue: "{{count}}项", count: item.ticket_count })}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{detailHref ? (
|
||||||
|
<div className="col-span-2 flex items-end justify-end lg:col-span-1">
|
||||||
|
<Link className="font-bold text-[#2d63e2] hover:underline" href={detailHref}>
|
||||||
|
{t("wallet.activity.viewOrder", { defaultValue: "查看注单" })}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LogRow({
|
export function LogRow({
|
||||||
item,
|
item,
|
||||||
currency,
|
currency,
|
||||||
@@ -258,36 +469,52 @@ export function LogRow({
|
|||||||
? "border-slate-200 bg-slate-50 text-slate-600"
|
? "border-slate-200 bg-slate-50 text-slate-600"
|
||||||
: "border-blue-200 bg-blue-50 text-blue-700";
|
: "border-blue-200 bg-blue-50 text-blue-700";
|
||||||
|
|
||||||
|
const timeLabel = creditMode && settlementTimeZone
|
||||||
|
? formatLotteryInstantInTimeZone(item.created_at, settlementTimeZone)
|
||||||
|
: formatPlayerInstant(item.created_at);
|
||||||
|
|
||||||
|
const amountLabel = isCreditSettlementConfirm
|
||||||
|
? t("wallet.creditReleasedAmount", {
|
||||||
|
defaultValue: "释额 {{amount}}",
|
||||||
|
amount: formatMinorAsCurrency(item.amount_abs, ccy),
|
||||||
|
})
|
||||||
|
: `${isIn ? "+" : "−"}${formatMinorAsCurrency(item.amount_abs, ccy)}`;
|
||||||
|
|
||||||
|
const balanceLabel = item.balance_after != null
|
||||||
|
? formatMinorAsCurrency(item.balance_after, ccy)
|
||||||
|
: creditMode && item.affects_available_credit === false
|
||||||
|
? t("wallet.noCreditChange", { defaultValue: "不改变可用信用" })
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const balanceCaption = creditMode
|
||||||
|
? item.affects_available_credit === false
|
||||||
|
? t("wallet.creditBillOnly", { defaultValue: "账期记录" })
|
||||||
|
: t("wallet.creditAvailableAfter")
|
||||||
|
: t("wallet.balanceAfter");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="rounded-2xl border border-[#e1eaf6] bg-white px-3 py-3 text-sm shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
<li className="rounded-2xl border border-[#e1eaf6] bg-white px-3 py-3 text-sm shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(8rem,0.9fr)_minmax(7rem,0.8fr)_minmax(7rem,0.8fr)_minmax(6rem,0.7fr)] lg:items-center lg:gap-3 lg:rounded-none lg:border-x-0 lg:border-t-0 lg:border-b-[#edf2f8] lg:px-5 lg:py-4 lg:shadow-none lg:last:border-b-0 lg:hover:bg-[#fbfdff]">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3 lg:contents">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 lg:min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`size-2.5 rounded-full ${dotTone}`} aria-hidden />
|
<span className={`size-2.5 shrink-0 rounded-full ${dotTone}`} aria-hidden />
|
||||||
<p className="truncate text-base font-black leading-tight text-[#101a33]">
|
<p className="truncate text-base font-black leading-tight text-[#101a33] lg:text-sm">
|
||||||
{logTypeLabel(item.type, t, creditMode, item.biz_type)}
|
{logTypeLabel(item.type, t, creditMode, item.biz_type)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-xs font-medium text-slate-500">
|
<p className="mt-1.5 text-xs font-medium text-slate-500 lg:hidden">
|
||||||
{creditMode && settlementTimeZone
|
{timeLabel}
|
||||||
? formatLotteryInstantInTimeZone(item.created_at, settlementTimeZone)
|
|
||||||
: formatPlayerInstant(item.created_at)}
|
|
||||||
</p>
|
</p>
|
||||||
{item.ref_id ? (
|
{item.ref_id ? (
|
||||||
<p className="mt-1 truncate font-mono text-[11px] text-slate-400">
|
<p className="mt-1 truncate font-mono text-[11px] text-slate-400 lg:hidden">
|
||||||
{item.ref_id}
|
{item.ref_id}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end gap-2 text-right">
|
<div className="flex shrink-0 flex-col items-end gap-2 text-right lg:hidden">
|
||||||
<p className={`text-lg font-black tabular-nums ${amountTone}`}>
|
<p className={`text-lg font-black tabular-nums ${amountTone}`}>
|
||||||
{isCreditSettlementConfirm
|
{amountLabel}
|
||||||
? t("wallet.creditReleasedAmount", {
|
|
||||||
defaultValue: "释额 {{amount}}",
|
|
||||||
amount: formatMinorAsCurrency(item.amount_abs, ccy),
|
|
||||||
})
|
|
||||||
: `${isIn ? "+" : "−"}${formatMinorAsCurrency(item.amount_abs, ccy)}`}
|
|
||||||
</p>
|
</p>
|
||||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-black ${statusTone}`}>
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-black ${statusTone}`}>
|
||||||
{txnStatusLabel(item.status, t)}
|
{txnStatusLabel(item.status, t)}
|
||||||
@@ -295,20 +522,28 @@ export function LogRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-between rounded-xl bg-[#f8fbff] px-3 py-2 text-xs">
|
<p className="hidden text-sm text-slate-600 lg:block">{timeLabel}</p>
|
||||||
|
<p className="hidden truncate font-mono text-xs text-slate-400 lg:block">
|
||||||
|
{item.ref_id || "—"}
|
||||||
|
</p>
|
||||||
|
<p className={`hidden text-right text-sm font-black tabular-nums lg:block ${amountTone}`}>
|
||||||
|
{amountLabel}
|
||||||
|
</p>
|
||||||
|
<p className="hidden text-right font-mono text-sm font-black tabular-nums text-[#32518d] lg:block">
|
||||||
|
{balanceLabel}
|
||||||
|
</p>
|
||||||
|
<div className="hidden justify-end lg:flex">
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-black ${statusTone}`}>
|
||||||
|
{txnStatusLabel(item.status, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between rounded-xl bg-[#f8fbff] px-3 py-2 text-xs lg:hidden">
|
||||||
<span className="font-semibold text-slate-500">
|
<span className="font-semibold text-slate-500">
|
||||||
{creditMode
|
{balanceCaption}
|
||||||
? item.affects_available_credit === false
|
|
||||||
? t("wallet.creditBillOnly", { defaultValue: "账期记录" })
|
|
||||||
: t("wallet.creditAvailableAfter")
|
|
||||||
: t("wallet.balanceAfter")}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono font-black tabular-nums text-[#32518d]">
|
<span className="font-mono font-black tabular-nums text-[#32518d]">
|
||||||
{item.balance_after != null
|
{balanceLabel}
|
||||||
? formatMinorAsCurrency(item.balance_after, ccy)
|
|
||||||
: creditMode && item.affects_available_credit === false
|
|
||||||
? t("wallet.noCreditChange", { defaultValue: "不改变可用信用" })
|
|
||||||
: "—"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowDownLeft, ArrowUpRight, Wallet } from "lucide-react";
|
import { ArrowDownLeft, ArrowUpRight, ChevronDown, Wallet } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
import { PlayerMoneyDisplay } from "@/components/player-money-display";
|
import { PlayerMoneyDisplay } from "@/components/player-money-display";
|
||||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAmount, formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { formatLotteryInstantInTimeZone, formatPlayerInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstantInTimeZone, formatPlayerInstant } from "@/lib/player-datetime";
|
||||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||||
@@ -100,92 +102,86 @@ function CreditSettlementBillsBlock({
|
|||||||
const netPending = Number(summary?.net_pending ?? 0);
|
const netPending = Number(summary?.net_pending ?? 0);
|
||||||
const pendingCount = Number(summary?.pending_count ?? 0);
|
const pendingCount = Number(summary?.pending_count ?? 0);
|
||||||
const netDirection = netPending >= 0 ? "receivable" : "payable";
|
const netDirection = netPending >= 0 ? "receivable" : "payable";
|
||||||
|
const hasBills = (data?.items.length ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-[#dce7f7] bg-white px-3 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
<section className="flex h-full flex-col rounded-2xl border border-[#dce7f7] bg-white px-3 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:px-4 lg:py-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex shrink-0 items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="text-sm font-black text-[#0b3f96]">
|
<h2 className="text-sm font-black text-[#0b3f96] lg:text-base">
|
||||||
{t("wallet.settlementTitle", { defaultValue: "我的账期账单" })}
|
{t("wallet.settlementTitle", { defaultValue: "账期结算" })}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-slate-500 lg:line-clamp-2">
|
||||||
{t("wallet.settlementHint", { defaultValue: "只显示未结清账单,中奖超出授信的部分在这里结算。" })}
|
{t("wallet.settlementHint", { defaultValue: "查看当前需要收取或支付的金额。" })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-[#f1f6ff] px-2.5 py-1 text-xs font-black text-[#32518d]">
|
{pendingCount > 0 ? (
|
||||||
{t("wallet.settlementPendingCount", { defaultValue: "{{count}}笔", count: pendingCount })}
|
<span className="shrink-0 rounded-full bg-[#f1f6ff] px-2.5 py-1 text-xs font-black text-[#32518d]">
|
||||||
</span>
|
{t("wallet.settlementPendingCount", { defaultValue: "{{count}}笔", count: pendingCount })}
|
||||||
</div>
|
</span>
|
||||||
|
) : null}
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
||||||
<div className="rounded-xl bg-emerald-50 px-3 py-2">
|
|
||||||
<p className="text-xs font-bold text-emerald-700">
|
|
||||||
{t("wallet.pendingReceivable", { defaultValue: "待收" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-sm font-black tabular-nums text-emerald-700">
|
|
||||||
{formatMinorAsCurrency(summary?.pending_receivable ?? 0, currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-red-50 px-3 py-2">
|
|
||||||
<p className="text-xs font-bold text-red-700">
|
|
||||||
{t("wallet.pendingPayable", { defaultValue: "待付" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-sm font-black tabular-nums text-red-700">
|
|
||||||
{formatMinorAsCurrency(summary?.pending_payable ?? 0, currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 rounded-xl bg-[#f8fbff] px-3 py-2 text-xs">
|
|
||||||
<span className="font-semibold text-slate-500">
|
|
||||||
{netDirection === "receivable"
|
|
||||||
? t("wallet.netReceivable", { defaultValue: "净待收" })
|
|
||||||
: t("wallet.netPayable", { defaultValue: "净待付" })}
|
|
||||||
</span>
|
|
||||||
<span className={netDirection === "receivable" ? "ml-2 font-mono font-black text-emerald-700" : "ml-2 font-mono font-black text-red-700"}>
|
|
||||||
{formatMinorAsCurrency(Math.abs(netPending), currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data === null ? (
|
{data === null ? (
|
||||||
<Skeleton className="mt-3 h-20 w-full rounded-xl" />
|
<Skeleton className="mt-3 min-h-20 w-full flex-1 rounded-xl" />
|
||||||
) : data.items.length === 0 ? (
|
) : !hasBills ? (
|
||||||
<p className="mt-3 rounded-xl border border-dashed border-[#dce7f7] py-5 text-center text-sm text-slate-500">
|
<p className="mt-3 flex min-h-24 flex-1 items-center justify-center rounded-xl bg-emerald-50 px-3 text-center text-sm font-bold text-emerald-700">
|
||||||
{t("wallet.noSettlementBills", { defaultValue: "暂无待结算账单" })}
|
{t("wallet.settlementClear", { defaultValue: "当前无需结算" })}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-3 space-y-2">
|
<>
|
||||||
{data.items.map((item) => {
|
<div className={netDirection === "receivable"
|
||||||
const receivable = item.direction === "receivable";
|
? "mt-3 rounded-xl bg-emerald-50 px-3 py-3 text-emerald-700"
|
||||||
return (
|
: "mt-3 rounded-xl bg-red-50 px-3 py-3 text-red-700"}
|
||||||
<li key={item.id} className="rounded-xl border border-[#e1eaf6] px-3 py-2">
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<p className="text-xs font-bold">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
{netDirection === "receivable"
|
||||||
<span className={receivable ? "rounded-full bg-emerald-100 p-1 text-emerald-700" : "rounded-full bg-red-100 p-1 text-red-700"}>
|
? t("wallet.netReceivable", { defaultValue: "净待收" })
|
||||||
{receivable ? <ArrowDownLeft className="size-3.5" /> : <ArrowUpRight className="size-3.5" />}
|
: t("wallet.netPayable", { defaultValue: "净待付" })}
|
||||||
</span>
|
</p>
|
||||||
<div className="min-w-0">
|
<p className="mt-1 font-mono text-lg font-black tabular-nums">
|
||||||
<p className="truncate text-sm font-black text-[#101a33]">
|
{formatMinorAsCurrency(Math.abs(netPending), currency)}
|
||||||
{receivable
|
</p>
|
||||||
? t("wallet.agentPaysPlayer", { defaultValue: "代理应付玩家" })
|
</div>
|
||||||
: t("wallet.playerPaysAgent", { defaultValue: "玩家应付代理" })}
|
<details className="group mt-3 rounded-xl border border-[#e1eaf6] bg-[#f8fbff]">
|
||||||
</p>
|
<summary className="flex min-h-10 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-sm font-bold text-[#32518d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2d63e2] [&::-webkit-details-marker]:hidden">
|
||||||
<p className="mt-0.5 text-[11px] text-slate-500">
|
{t("wallet.settlementDetails", { defaultValue: "查看账期明细" })}
|
||||||
{formatPeriodRange(item, settlementTimeZone)}
|
<ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />
|
||||||
</p>
|
</summary>
|
||||||
|
<ul className="max-h-[16rem] space-y-2 overflow-y-auto border-t border-[#e1eaf6] p-2">
|
||||||
|
{data.items.map((item) => {
|
||||||
|
const receivable = item.direction === "receivable";
|
||||||
|
return (
|
||||||
|
<li key={item.id} className="rounded-lg bg-white px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className={receivable ? "rounded-full bg-emerald-100 p-1 text-emerald-700" : "rounded-full bg-red-100 p-1 text-red-700"}>
|
||||||
|
{receivable ? <ArrowDownLeft className="size-3.5" aria-hidden /> : <ArrowUpRight className="size-3.5" aria-hidden />}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-black text-[#101a33]">
|
||||||
|
{receivable
|
||||||
|
? t("wallet.agentPaysPlayer", { defaultValue: "代理应付玩家" })
|
||||||
|
: t("wallet.playerPaysAgent", { defaultValue: "玩家应付代理" })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500">
|
||||||
|
{formatPeriodRange(item, settlementTimeZone)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<p className={receivable ? "font-mono text-sm font-black text-emerald-700" : "font-mono text-sm font-black text-red-700"}>
|
||||||
|
{formatMinorAsCurrency(item.unpaid_amount, currency)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500">{settlementStatusLabel(item.status, t)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
<div className="shrink-0 text-right">
|
);
|
||||||
<p className={receivable ? "font-mono text-sm font-black text-emerald-700" : "font-mono text-sm font-black text-red-700"}>
|
})}
|
||||||
{formatMinorAsCurrency(item.unpaid_amount, currency)}
|
</ul>
|
||||||
</p>
|
</details>
|
||||||
<p className="mt-0.5 text-[11px] text-slate-500">{settlementStatusLabel(item.status, t)}</p>
|
</>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -218,6 +214,7 @@ export function WalletScreen() {
|
|||||||
const fundingModeView = resolveFundingModeView(balance, profile);
|
const fundingModeView = resolveFundingModeView(balance, profile);
|
||||||
const fundingModeKnown = fundingModeView !== "unknown";
|
const fundingModeKnown = fundingModeView !== "unknown";
|
||||||
const isCreditPlayer = fundingModeView === "credit";
|
const isCreditPlayer = fundingModeView === "credit";
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (actionDeepLinkHandledRef.current || loading) return;
|
if (actionDeepLinkHandledRef.current || loading) return;
|
||||||
@@ -266,7 +263,7 @@ export function WalletScreen() {
|
|||||||
return nextLogs;
|
return nextLogs;
|
||||||
}, [currency, filter]);
|
}, [currency, filter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -306,7 +303,7 @@ export function WalletScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currency, t]); // eslint-disable-line react-hooks/exhaustive-deps -- 币种切换整页刷新;流水筛选见下方 effect
|
}, [currency, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filterInitializedRef.current) {
|
if (!filterInitializedRef.current) {
|
||||||
@@ -394,10 +391,12 @@ export function WalletScreen() {
|
|||||||
const displayMinor = isCreditPlayer
|
const displayMinor = isCreditPlayer
|
||||||
? Number(balance?.available_balance ?? 0)
|
? Number(balance?.available_balance ?? 0)
|
||||||
: Number(balance?.balance ?? 0);
|
: Number(balance?.balance ?? 0);
|
||||||
|
const creditLimitMinor = Number(balance?.credit_limit ?? 0);
|
||||||
|
const creditInUseMinor = Math.max(0, creditLimitMinor - displayMinor);
|
||||||
const panelTitle = !fundingModeKnown
|
const panelTitle = !fundingModeKnown
|
||||||
? t("wallet.loadingTitle", { defaultValue: "账户资金" })
|
? t("wallet.loadingTitle", { defaultValue: "账户资金" })
|
||||||
: isCreditPlayer
|
: isCreditPlayer
|
||||||
? t("wallet.creditTitle", { defaultValue: "信用" })
|
? t("wallet.creditTitle", { defaultValue: "额度" })
|
||||||
: t("wallet.title");
|
: t("wallet.title");
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
@@ -414,7 +413,22 @@ export function WalletScreen() {
|
|||||||
});
|
});
|
||||||
}, [hasMore, loadLogs, loadingMore, logs, t]);
|
}, [hasMore, loadLogs, loadingMore, logs, t]);
|
||||||
|
|
||||||
|
const handleLogsPageChange = useCallback((nextPage: number) => {
|
||||||
|
if (!logs || nextPage === logs.page || logsLoading) return;
|
||||||
|
setError(null);
|
||||||
|
setLogsLoading(true);
|
||||||
|
void loadLogs(nextPage, false)
|
||||||
|
.catch((e) => {
|
||||||
|
setError(formatWalletClientError(e, t));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLogsLoading(false);
|
||||||
|
});
|
||||||
|
}, [loadLogs, logs, logsLoading, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 仅移动端无限滚动;PC 用分页
|
||||||
|
if (!isMobile) return;
|
||||||
const target = loadMoreRef.current;
|
const target = loadMoreRef.current;
|
||||||
if (!target || loading || logsLoading || loadingMore || !hasMore) return;
|
if (!target || loading || logsLoading || loadingMore || !hasMore) return;
|
||||||
|
|
||||||
@@ -429,13 +443,13 @@ export function WalletScreen() {
|
|||||||
|
|
||||||
observer.observe(target);
|
observer.observe(target);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [hasMore, loadMore, loading, loadingMore, logsLoading]);
|
}, [hasMore, isMobile, loadMore, loading, loadingMore, logsLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerPanel title={panelTitle}>
|
<PlayerPanel title={panelTitle}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 lg:space-y-4">
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 lg:px-5 lg:py-4">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -447,98 +461,157 @@ export function WalletScreen() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="relative overflow-hidden rounded-xl bg-[#e5002c] px-3 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]">
|
|
||||||
<Image
|
|
||||||
src="/entry/image5.png"
|
|
||||||
alt=""
|
|
||||||
fill
|
|
||||||
className="pointer-events-none object-cover object-center"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div className="relative flex items-center gap-3">
|
|
||||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
|
||||||
<Wallet className="size-7" aria-hidden />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-semibold text-white/90">
|
|
||||||
{fundingModeKnown && isCreditPlayer
|
|
||||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
|
||||||
: fundingModeKnown
|
|
||||||
? t("wallet.balance")
|
|
||||||
: t("wallet.loadingBalance", { defaultValue: "正在加载" })}
|
|
||||||
</p>
|
|
||||||
{loading || !fundingModeKnown ? (
|
|
||||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
|
||||||
) : (
|
|
||||||
<PlayerMoneyDisplay
|
|
||||||
amountMinor={displayMinor}
|
|
||||||
currency={currency}
|
|
||||||
className="mt-1 text-white"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<p className="mt-2 text-xs text-white/75">
|
|
||||||
{!fundingModeKnown
|
|
||||||
? t("wallet.loadingAccountMode", { defaultValue: "正在确认资金模式…" })
|
|
||||||
: isCreditPlayer
|
|
||||||
? t("wallet.creditSummary", {
|
|
||||||
defaultValue: "授信 {{limit}} · 已用 {{used}}",
|
|
||||||
limit: formatMinorAsCurrency(balance?.credit_limit ?? 0, currency),
|
|
||||||
used: formatMinorAsCurrency(balance?.used_credit ?? 0, currency),
|
|
||||||
})
|
|
||||||
: t("wallet.available", {
|
|
||||||
amount: formatMinorAsCurrency(balance?.available_balance ?? 0, currency),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{fundingModeKnown && isCreditPlayer ? (
|
|
||||||
<CreditSettlementBillsBlock
|
|
||||||
data={settlementBills}
|
|
||||||
currency={currency}
|
|
||||||
settlementTimeZone={settlementTimeZone}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{fundingModeKnown && !isCreditPlayer ? (
|
{fundingModeKnown && !isCreditPlayer ? (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="lg:grid lg:grid-cols-[minmax(0,1.2fr)_minmax(16rem,0.8fr)] lg:items-stretch lg:gap-4">
|
||||||
<TransferInDialog
|
<section className="relative overflow-hidden rounded-xl bg-[#e5002c] px-3 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)] lg:px-6 lg:py-6">
|
||||||
idPrefix="wallet-"
|
<Image
|
||||||
currency={currency}
|
src="/entry/image5.png"
|
||||||
lotteryMinor={Number(balance?.balance ?? 0)}
|
alt=""
|
||||||
mainMinor={
|
fill
|
||||||
balance?.main_balance === null || balance?.main_balance === undefined
|
loading="eager"
|
||||||
? null
|
className="pointer-events-none object-cover object-center"
|
||||||
: Number(balance.main_balance)
|
aria-hidden
|
||||||
}
|
/>
|
||||||
onSuccess={refreshAll}
|
<div className="relative flex items-center gap-3 lg:gap-5">
|
||||||
triggerVariant="hall"
|
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm lg:size-16">
|
||||||
triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })}
|
<Wallet className="size-7 lg:size-8" aria-hidden />
|
||||||
triggerClassName="h-14 rounded-2xl text-base font-black"
|
</div>
|
||||||
open={transferInOpen}
|
<div className="min-w-0 flex-1">
|
||||||
onOpenChange={setTransferInOpen}
|
<p className="text-sm font-semibold text-white/90 lg:text-base">
|
||||||
/>
|
{t("wallet.balance")}
|
||||||
<TransferOutDialog
|
</p>
|
||||||
idPrefix="wallet-"
|
{loading ? (
|
||||||
currency={currency}
|
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25 lg:h-10 lg:w-56" />
|
||||||
availableMinor={Number(balance?.available_balance ?? 0)}
|
) : (
|
||||||
onSuccess={refreshAll}
|
<PlayerMoneyDisplay
|
||||||
triggerVariant="hall"
|
amountMinor={displayMinor}
|
||||||
triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })}
|
currency={currency}
|
||||||
triggerClassName="h-14 rounded-2xl text-base font-black"
|
className="mt-1 text-white lg:text-[1.75rem]"
|
||||||
open={transferOutOpen}
|
/>
|
||||||
onOpenChange={setTransferOutOpen}
|
)}
|
||||||
/>
|
<p className="mt-2 text-xs text-white/75 lg:text-sm">
|
||||||
|
{t("wallet.available", {
|
||||||
|
amount: formatMinorAsCurrency(balance?.available_balance ?? 0, currency),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<aside className="mt-3 grid grid-cols-2 gap-3 lg:mt-0 lg:grid-cols-1 lg:grid-rows-2 lg:rounded-xl lg:border lg:border-[#dce7f7] lg:bg-[#f8fbff] lg:p-3">
|
||||||
|
<TransferInDialog
|
||||||
|
idPrefix="wallet-"
|
||||||
|
currency={currency}
|
||||||
|
lotteryMinor={Number(balance?.balance ?? 0)}
|
||||||
|
mainMinor={
|
||||||
|
balance?.main_balance === null || balance?.main_balance === undefined
|
||||||
|
? null
|
||||||
|
: Number(balance.main_balance)
|
||||||
|
}
|
||||||
|
onSuccess={refreshAll}
|
||||||
|
triggerVariant="hall"
|
||||||
|
triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })}
|
||||||
|
triggerClassName="h-10 rounded-lg text-sm font-bold lg:h-full lg:min-h-0 lg:rounded-xl lg:text-lg lg:font-black"
|
||||||
|
open={transferInOpen}
|
||||||
|
onOpenChange={setTransferInOpen}
|
||||||
|
/>
|
||||||
|
<TransferOutDialog
|
||||||
|
idPrefix="wallet-"
|
||||||
|
currency={currency}
|
||||||
|
availableMinor={Number(balance?.available_balance ?? 0)}
|
||||||
|
onSuccess={refreshAll}
|
||||||
|
triggerVariant="hall"
|
||||||
|
triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })}
|
||||||
|
triggerClassName="h-10 rounded-lg border-[#cddbf1] bg-white text-sm font-bold text-[#0b3f96] shadow-none hover:bg-[#f1f6ff] lg:h-full lg:min-h-0 lg:rounded-xl lg:text-lg lg:font-black"
|
||||||
|
open={transferOutOpen}
|
||||||
|
onOpenChange={setTransferOutOpen}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
|
<div className="space-y-3 lg:grid lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)] lg:items-stretch lg:gap-4 lg:space-y-0">
|
||||||
|
<section className="relative flex h-full flex-col overflow-hidden rounded-xl bg-[#e5002c] px-3 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)] lg:px-6 lg:py-5">
|
||||||
|
<Image
|
||||||
|
src="/entry/image5.png"
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
loading="eager"
|
||||||
|
className="pointer-events-none object-cover object-center"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="relative flex flex-1 items-center gap-3 lg:gap-5">
|
||||||
|
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm lg:size-16">
|
||||||
|
<Wallet className="size-7 lg:size-8" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold text-white/90 lg:text-base">
|
||||||
|
{fundingModeKnown
|
||||||
|
? t("wallet.creditAvailable", { defaultValue: "可用额度" })
|
||||||
|
: t("wallet.loadingBalance", { defaultValue: "正在加载" })}
|
||||||
|
</p>
|
||||||
|
{loading || !fundingModeKnown ? (
|
||||||
|
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25 lg:h-10 lg:w-56" />
|
||||||
|
) : (
|
||||||
|
<PlayerMoneyDisplay
|
||||||
|
amountMinor={displayMinor}
|
||||||
|
currency={currency}
|
||||||
|
showCurrencyCode={false}
|
||||||
|
className="mt-1 text-white lg:text-[1.75rem]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-white/75 lg:text-sm">
|
||||||
|
{!fundingModeKnown
|
||||||
|
? t("wallet.loadingAccountMode", { defaultValue: "正在确认资金模式…" })
|
||||||
|
: t("wallet.creditAvailableHint", { defaultValue: "当前可用于下注" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fundingModeKnown ? (
|
||||||
|
<details className="group relative mt-4 shrink-0 rounded-xl bg-white/15 backdrop-blur-[2px] lg:mt-auto">
|
||||||
|
<summary className="flex min-h-10 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-sm font-bold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white [&::-webkit-details-marker]:hidden">
|
||||||
|
{t("wallet.creditDetails", { defaultValue: "额度详情" })}
|
||||||
|
<ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />
|
||||||
|
</summary>
|
||||||
|
<div className="grid grid-cols-2 gap-2 border-t border-white/15 p-2">
|
||||||
|
<div className="rounded-lg bg-white/10 px-3 py-2">
|
||||||
|
<p className="text-[11px] font-bold text-white/75">
|
||||||
|
{t("wallet.creditLimit", { defaultValue: "总额度" })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
|
||||||
|
{formatMinorAmount(creditLimitMinor)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white/10 px-3 py-2">
|
||||||
|
<p className="text-[11px] font-bold text-white/75">
|
||||||
|
{t("wallet.usedCredit", { defaultValue: "使用中" })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
|
||||||
|
{formatMinorAmount(creditInUseMinor)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{fundingModeKnown ? (
|
||||||
|
<CreditSettlementBillsBlock
|
||||||
|
data={settlementBills}
|
||||||
|
currency={currency}
|
||||||
|
settlementTimeZone={settlementTimeZone}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-full min-h-40 w-full rounded-2xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{fundingModeKnown && !isCreditPlayer && (logs?.pending_reconcile?.length ?? 0) > 0 ? (
|
{fundingModeKnown && !isCreditPlayer && (logs?.pending_reconcile?.length ?? 0) > 0 ? (
|
||||||
<section
|
<section
|
||||||
id="wallet-pending"
|
id="wallet-pending"
|
||||||
className="scroll-mt-3 rounded-2xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900"
|
className="scroll-mt-3 rounded-2xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900 lg:px-5 lg:py-4"
|
||||||
>
|
>
|
||||||
<h2 className="text-sm font-black text-amber-800">
|
<h2 className="text-sm font-black text-amber-800 lg:text-base">
|
||||||
{t("wallet.pendingSectionTitle", { defaultValue: "待对账划转" })}
|
{t("wallet.pendingSectionTitle", { defaultValue: "待对账划转" })}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-xs text-amber-700">
|
<p className="mt-1 text-xs text-amber-700">
|
||||||
@@ -546,11 +619,11 @@ export function WalletScreen() {
|
|||||||
defaultValue: "以下划转仍在与主站对账,请稍后刷新查看结果。",
|
defaultValue: "以下划转仍在与主站对账,请稍后刷新查看结果。",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 grid gap-2 lg:grid-cols-2 lg:gap-3">
|
||||||
{logs?.pending_reconcile.map((item) => (
|
{logs?.pending_reconcile.map((item) => (
|
||||||
<li
|
<li
|
||||||
key={item.transfer_no}
|
key={item.transfer_no}
|
||||||
className="rounded-xl border border-amber-200 bg-white px-3 py-2"
|
className="rounded-xl border border-amber-200 bg-white px-3 py-2 lg:px-4 lg:py-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
@@ -579,6 +652,7 @@ export function WalletScreen() {
|
|||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
onLoadMore={loadMore}
|
onLoadMore={loadMore}
|
||||||
loadMoreRef={loadMoreRef}
|
loadMoreRef={loadMoreRef}
|
||||||
|
onPageChange={handleLogsPageChange}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
onFilterChange={handleFilterChange}
|
onFilterChange={handleFilterChange}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export function TransferInDialog({
|
|||||||
triggerClassName,
|
triggerClassName,
|
||||||
triggerVariant = "wallet",
|
triggerVariant = "wallet",
|
||||||
triggerLabel,
|
triggerLabel,
|
||||||
|
triggerDescription,
|
||||||
|
triggerIconOnly = false,
|
||||||
open: controlledOpen,
|
open: controlledOpen,
|
||||||
onOpenChange: controlledOnOpenChange,
|
onOpenChange: controlledOnOpenChange,
|
||||||
}: BaseProps & {
|
}: BaseProps & {
|
||||||
@@ -44,6 +46,8 @@ export function TransferInDialog({
|
|||||||
triggerClassName?: string;
|
triggerClassName?: string;
|
||||||
triggerVariant?: "wallet" | "hall";
|
triggerVariant?: "wallet" | "hall";
|
||||||
triggerLabel?: string;
|
triggerLabel?: string;
|
||||||
|
triggerDescription?: string;
|
||||||
|
triggerIconOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
||||||
const isControlled = controlledOpen !== undefined;
|
const isControlled = controlledOpen !== undefined;
|
||||||
@@ -72,9 +76,20 @@ export function TransferInDialog({
|
|||||||
triggerClassName,
|
triggerClassName,
|
||||||
)}
|
)}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label={triggerIconOnly ? resolvedTriggerLabel : undefined}
|
||||||
|
title={triggerIconOnly ? resolvedTriggerLabel : undefined}
|
||||||
>
|
>
|
||||||
<ArrowDownLeft className="size-4 shrink-0" />
|
<ArrowDownLeft className="size-4 shrink-0" />
|
||||||
{resolvedTriggerLabel}
|
{triggerIconOnly ? (
|
||||||
|
<span className="sr-only">{resolvedTriggerLabel}</span>
|
||||||
|
) : triggerDescription ? (
|
||||||
|
<span className="flex min-w-0 flex-col text-left">
|
||||||
|
<span>{resolvedTriggerLabel}</span>
|
||||||
|
<span className="mt-0.5 text-xs font-medium opacity-75">{triggerDescription}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
resolvedTriggerLabel
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent showCloseButton className="gap-0 overflow-hidden p-0 sm:max-w-md">
|
<DialogContent showCloseButton className="gap-0 overflow-hidden p-0 sm:max-w-md">
|
||||||
<DialogHeader className="space-y-1.5 border-b border-border px-4 py-3 text-left">
|
<DialogHeader className="space-y-1.5 border-b border-border px-4 py-3 text-left">
|
||||||
@@ -110,6 +125,8 @@ export function TransferOutDialog({
|
|||||||
triggerClassName,
|
triggerClassName,
|
||||||
triggerVariant = "wallet",
|
triggerVariant = "wallet",
|
||||||
triggerLabel,
|
triggerLabel,
|
||||||
|
triggerDescription,
|
||||||
|
triggerIconOnly = false,
|
||||||
open: controlledOpen,
|
open: controlledOpen,
|
||||||
onOpenChange: controlledOnOpenChange,
|
onOpenChange: controlledOnOpenChange,
|
||||||
}: BaseProps & {
|
}: BaseProps & {
|
||||||
@@ -117,6 +134,8 @@ export function TransferOutDialog({
|
|||||||
triggerClassName?: string;
|
triggerClassName?: string;
|
||||||
triggerVariant?: "wallet" | "hall";
|
triggerVariant?: "wallet" | "hall";
|
||||||
triggerLabel?: string;
|
triggerLabel?: string;
|
||||||
|
triggerDescription?: string;
|
||||||
|
triggerIconOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
||||||
const isControlled = controlledOpen !== undefined;
|
const isControlled = controlledOpen !== undefined;
|
||||||
@@ -145,9 +164,20 @@ export function TransferOutDialog({
|
|||||||
triggerClassName,
|
triggerClassName,
|
||||||
)}
|
)}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label={triggerIconOnly ? resolvedTriggerLabel : undefined}
|
||||||
|
title={triggerIconOnly ? resolvedTriggerLabel : undefined}
|
||||||
>
|
>
|
||||||
<ArrowUpRight className="size-4 shrink-0" />
|
<ArrowUpRight className="size-4 shrink-0" />
|
||||||
{resolvedTriggerLabel}
|
{triggerIconOnly ? (
|
||||||
|
<span className="sr-only">{resolvedTriggerLabel}</span>
|
||||||
|
) : triggerDescription ? (
|
||||||
|
<span className="flex min-w-0 flex-col text-left">
|
||||||
|
<span>{resolvedTriggerLabel}</span>
|
||||||
|
<span className="mt-0.5 text-xs font-medium opacity-75">{triggerDescription}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
resolvedTriggerLabel
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent showCloseButton className="gap-0 overflow-hidden p-0 sm:max-w-md">
|
<DialogContent showCloseButton className="gap-0 overflow-hidden p-0 sm:max-w-md">
|
||||||
<DialogHeader className="space-y-1.5 border-b border-border px-4 py-3 text-left">
|
<DialogHeader className="space-y-1.5 border-b border-border px-4 py-3 text-left">
|
||||||
|
|||||||
40
src/hooks/use-async-effect.ts
Normal file
40
src/hooks/use-async-effect.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, type DependencyList } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defer asynchronous side effects until the current effect instance survives
|
||||||
|
* React Strict Mode's development-only setup/cleanup probe.
|
||||||
|
*/
|
||||||
|
export function useAsyncEffect(
|
||||||
|
factory: () => void | (() => void) | Promise<void>,
|
||||||
|
deps: DependencyList,
|
||||||
|
): void {
|
||||||
|
const factoryRef = useRef(factory);
|
||||||
|
useEffect(() => {
|
||||||
|
factoryRef.current = factory;
|
||||||
|
}, [factory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cleanup: void | (() => void);
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
void Promise.resolve(factoryRef.current()).then((result) => {
|
||||||
|
if (cancelled) {
|
||||||
|
result?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanup = result;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cleanup?.();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
}
|
||||||
@@ -10,9 +10,31 @@ const RETRY_DELAY_MS = 2_000;
|
|||||||
|
|
||||||
let inflightCurrencyLoad: Promise<void> | null = null;
|
let inflightCurrencyLoad: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export function ensureCurrencyCatalogLoaded(): Promise<void> {
|
||||||
|
if (usePlayerSessionStore.getState().currencies.length > 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inflightCurrencyLoad !== null) {
|
||||||
|
return inflightCurrencyLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
inflightCurrencyLoad = getPublicCurrencies()
|
||||||
|
.then((data) => {
|
||||||
|
if (data.items.length === 0) {
|
||||||
|
throw new Error("empty_currency_catalog");
|
||||||
|
}
|
||||||
|
usePlayerSessionStore.getState().setCurrencies(data.items);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightCurrencyLoad = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return inflightCurrencyLoad;
|
||||||
|
}
|
||||||
|
|
||||||
export function useCurrencyCatalog() {
|
export function useCurrencyCatalog() {
|
||||||
const currencies = usePlayerSessionStore((state) => state.currencies);
|
const currencies = usePlayerSessionStore((state) => state.currencies);
|
||||||
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
|
|
||||||
const [loadGeneration, setLoadGeneration] = useState(0);
|
const [loadGeneration, setLoadGeneration] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -20,27 +42,13 @@ export function useCurrencyCatalog() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inflightCurrencyLoad !== null) {
|
void ensureCurrencyCatalogLoaded()
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
inflightCurrencyLoad = getPublicCurrencies()
|
|
||||||
.then((data) => {
|
|
||||||
if (data.items.length > 0) {
|
|
||||||
setCurrencies(data.items);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error("empty_currency_catalog");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setLoadGeneration((current) => current + 1);
|
setLoadGeneration((current) => current + 1);
|
||||||
}, RETRY_DELAY_MS);
|
}, RETRY_DELAY_MS);
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
inflightCurrencyLoad = null;
|
|
||||||
});
|
});
|
||||||
}, [currencies.length, setCurrencies, loadGeneration]);
|
}, [currencies.length, loadGeneration]);
|
||||||
|
|
||||||
return currencies;
|
return currencies;
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,11 @@ import { useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
import {
|
||||||
|
disconnectLotteryEcho,
|
||||||
|
getLotteryEcho,
|
||||||
|
} from "@/lib/lottery-echo";
|
||||||
|
import { parseJwtSessionVersion } from "@/lib/jwt-payload";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
@@ -17,6 +21,11 @@ type BalanceUpdateWsEvent = {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SessionReplacedWsEvent = {
|
||||||
|
player_id?: number;
|
||||||
|
session_version?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const REASON_I18N_KEY: Record<string, string> = {
|
const REASON_I18N_KEY: Record<string, string> = {
|
||||||
transfer_in: "wallet.wsReason.transferIn",
|
transfer_in: "wallet.wsReason.transferIn",
|
||||||
transfer_out: "wallet.wsReason.transferOut",
|
transfer_out: "wallet.wsReason.transferOut",
|
||||||
@@ -45,7 +54,7 @@ export function usePlayerBalanceWs(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channelName = `player.${playerId}`;
|
const channelName = `player.${playerId}`;
|
||||||
const channel = echo.channel(channelName);
|
const channel = echo.private(channelName);
|
||||||
|
|
||||||
const onBalanceUpdate = (evt: BalanceUpdateWsEvent): void => {
|
const onBalanceUpdate = (evt: BalanceUpdateWsEvent): void => {
|
||||||
const currency = evt.currency_code?.trim().toUpperCase();
|
const currency = evt.currency_code?.trim().toUpperCase();
|
||||||
@@ -71,10 +80,34 @@ export function usePlayerBalanceWs(): void {
|
|||||||
toast.message(t("wallet.wsBalanceUpdated", { change: changeLabel, reason: reasonLabel }));
|
toast.message(t("wallet.wsBalanceUpdated", { change: changeLabel, reason: reasonLabel }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSessionReplaced = (evt: SessionReplacedWsEvent): void => {
|
||||||
|
const nextVersion = evt.session_version;
|
||||||
|
if (
|
||||||
|
evt.player_id !== playerId ||
|
||||||
|
typeof nextVersion !== "number" ||
|
||||||
|
!Number.isInteger(nextVersion)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = usePlayerSessionStore.getState();
|
||||||
|
const currentVersion = parseJwtSessionVersion(state.bearerToken) ?? 0;
|
||||||
|
if (nextVersion <= currentVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectLotteryEcho();
|
||||||
|
state.clearBearerToken();
|
||||||
|
window.location.replace("/login?session=replaced");
|
||||||
|
};
|
||||||
|
|
||||||
channel.listen(".balance.update", onBalanceUpdate);
|
channel.listen(".balance.update", onBalanceUpdate);
|
||||||
|
channel.listen(".session.replaced", onSessionReplaced);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
channel.stopListening(".balance.update");
|
channel.stopListening(".balance.update");
|
||||||
|
channel.stopListening(".session.replaced");
|
||||||
|
echo.leave(channelName);
|
||||||
};
|
};
|
||||||
}, [activeCurrency, bearerToken, playerId, t]);
|
}, [activeCurrency, bearerToken, playerId, t]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { parseJwtExp } from "@/lib/jwt-payload";
|
import { parseJwtExp } from "@/lib/jwt-payload";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import { useErrorStore } from "@/stores/error-store";
|
import { useErrorStore } from "@/stores/error-store";
|
||||||
import {
|
import { resolvePostMessageTargetOrigin } from "@/lib/iframe-origins";
|
||||||
isIframeOriginAllowed,
|
import { subscribeIframeTokenRefresh } from "@/lib/iframe-token-refresh-events";
|
||||||
loadIframeAllowedOrigins,
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
resolvePostMessageTargetOrigin,
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
} from "@/lib/iframe-origins";
|
|
||||||
|
|
||||||
/** Token 过期前警告阈值(毫秒) */
|
/** Token 过期前警告阈值(毫秒) */
|
||||||
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
||||||
@@ -37,7 +35,6 @@ export function useTokenRefresh(): {
|
|||||||
isTokenExpiringSoon: () => boolean;
|
isTokenExpiringSoon: () => boolean;
|
||||||
} {
|
} {
|
||||||
const bearerToken = usePlayerSessionStore((state) => state.bearerToken);
|
const bearerToken = usePlayerSessionStore((state) => state.bearerToken);
|
||||||
const setBearerToken = usePlayerSessionStore((state) => state.setBearerToken);
|
|
||||||
const setServerError = useErrorStore((state) => state.setServerError);
|
const setServerError = useErrorStore((state) => state.setServerError);
|
||||||
const clearServerError = useErrorStore((state) => state.clearServerError);
|
const clearServerError = useErrorStore((state) => state.clearServerError);
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
@@ -109,52 +106,18 @@ export function useTokenRefresh(): {
|
|||||||
}, REFRESH_RESPONSE_TIMEOUT_MS);
|
}, REFRESH_RESPONSE_TIMEOUT_MS);
|
||||||
}, [clearServerError, requestParentRefresh, setServerError, t]);
|
}, [clearServerError, requestParentRefresh, setServerError, t]);
|
||||||
|
|
||||||
/**
|
/** IframeBridge 统一校验父窗口消息;这里只接收已验证的续签结果。 */
|
||||||
* 监听主站 postMessage 发送的新 Token
|
useAsyncEffect(() => {
|
||||||
*/
|
return subscribeIframeTokenRefresh(() => {
|
||||||
useEffect(() => {
|
pendingRefreshRef.current = 0;
|
||||||
if (typeof window === "undefined") return;
|
retryCountRef.current = 0;
|
||||||
|
});
|
||||||
void loadIframeAllowedOrigins();
|
}, []);
|
||||||
|
|
||||||
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
|
||||||
if (!isIframeOriginAllowed(event.origin)) {
|
|
||||||
await loadIframeAllowedOrigins(true);
|
|
||||||
if (!isIframeOriginAllowed(event.origin)) {
|
|
||||||
console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = event;
|
|
||||||
if (!data || typeof data !== "object") return;
|
|
||||||
|
|
||||||
// 处理主站发送的新 Token(兼容 MAIN_REFRESH_TOKEN 与 LOTTERY_TOKEN_REFRESH_RESPONSE)
|
|
||||||
if (
|
|
||||||
(data.type === "LOTTERY_TOKEN_REFRESH_RESPONSE" || data.type === "MAIN_REFRESH_TOKEN") &&
|
|
||||||
data.token
|
|
||||||
) {
|
|
||||||
console.log("[TokenRefresh] Received new token from parent");
|
|
||||||
pendingRefreshRef.current = 0;
|
|
||||||
setBearerToken(data.token);
|
|
||||||
retryCountRef.current = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理主站通知 Token 即将过期
|
|
||||||
if (data.type === "LOTTERY_TOKEN_EXPIRING_WARNING") {
|
|
||||||
console.log("[TokenRefresh] Token expiring warning from parent");
|
|
||||||
// 可以在这里显示提示或自动刷新
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("message", handleMessage);
|
|
||||||
return () => window.removeEventListener("message", handleMessage);
|
|
||||||
}, [setBearerToken]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动刷新逻辑
|
* 自动刷新逻辑
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
if (!bearerToken) {
|
if (!bearerToken) {
|
||||||
if (refreshTimerRef.current) {
|
if (refreshTimerRef.current) {
|
||||||
clearTimeout(refreshTimerRef.current);
|
clearTimeout(refreshTimerRef.current);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function syncDocumentLanguage(lang: AppLanguage): void {
|
|||||||
document.documentElement.lang = lang;
|
document.documentElement.lang = lang;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncPreferredLanguage(): void {
|
export async function syncPreferredLanguage(): Promise<void> {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const stored = window.localStorage.getItem("i18nextLng");
|
const stored = window.localStorage.getItem("i18nextLng");
|
||||||
@@ -67,7 +67,7 @@ export function syncPreferredLanguage(): void {
|
|||||||
const current = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
const current = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||||
|
|
||||||
if (preferred !== current) {
|
if (preferred !== current) {
|
||||||
void i18n.changeLanguage(preferred);
|
await i18n.changeLanguage(preferred);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,18 +65,21 @@
|
|||||||
"captcha": "Captcha",
|
"captcha": "Captcha",
|
||||||
"captchaPlaceholder": "Enter captcha",
|
"captchaPlaceholder": "Enter captcha",
|
||||||
"captchaRequired": "Please load the captcha first",
|
"captchaRequired": "Please load the captcha first",
|
||||||
|
"captchaCodeRequired": "Please enter the captcha",
|
||||||
"captchaLoadFailed": "Failed to load captcha. Try again.",
|
"captchaLoadFailed": "Failed to load captcha. Try again.",
|
||||||
"captchaLoading": "Loading…",
|
"captchaLoading": "Loading…",
|
||||||
"captchaRefresh": "Refresh captcha",
|
"captchaRefresh": "Refresh captcha",
|
||||||
"captchaFetch": "Tap to load",
|
"captchaFetch": "Tap to load",
|
||||||
"usernameInvalidCharset": "Username may only contain letters, numbers, dots (.), underscores, and hyphens",
|
"usernameInvalidCharset": "Username may only contain letters, numbers, dots (.), underscores, and hyphens",
|
||||||
"passwordMinLength": "Password must be at least 6 characters"
|
"passwordMinLength": "Password must be at least 6 characters",
|
||||||
|
"passwordChanged": "Password changed. Sign in again with your new password."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"noToken": "No authorization token found",
|
"noToken": "No authorization token found",
|
||||||
"noTokenDetail": "Please return to the main site and try again.",
|
"noTokenDetail": "Please return to the main site and try again.",
|
||||||
"sessionExpired": "Session expired",
|
"sessionExpired": "Session expired",
|
||||||
"sessionExpiredDetail": "Please return to the main site and open the lottery hall again.",
|
"sessionExpiredDetail": "Please return to the main site and open the lottery hall again.",
|
||||||
|
"sessionReplaced": "This account was signed in on another device. Please sign in again.",
|
||||||
"authFailed": "Authorization failed",
|
"authFailed": "Authorization failed",
|
||||||
"unknown": "Unknown error",
|
"unknown": "Unknown error",
|
||||||
"network": "Network error occurred",
|
"network": "Network error occurred",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"orders": "My Bets",
|
"orders": "My Bets",
|
||||||
"rules": "Rules",
|
"rules": "Rules",
|
||||||
"wallet": "Wallet",
|
"wallet": "Wallet",
|
||||||
"credit": "Credit"
|
"credit": "Limit"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Pending reconciliation",
|
"title": "Pending reconciliation",
|
||||||
@@ -113,6 +113,44 @@
|
|||||||
"player": {
|
"player": {
|
||||||
"fallback": "Player #{{id}}"
|
"fallback": "Player #{{id}}"
|
||||||
},
|
},
|
||||||
|
"account": {
|
||||||
|
"open": "View player profile",
|
||||||
|
"loading": "Player",
|
||||||
|
"title": "My account",
|
||||||
|
"description": "View the signed-in player profile and account security settings.",
|
||||||
|
"auth": {
|
||||||
|
"native": "Lottery account",
|
||||||
|
"sso": "Main-site API"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"credit": "Credit mode",
|
||||||
|
"wallet": "Wallet mode"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"username": "Username",
|
||||||
|
"site": "Site",
|
||||||
|
"playerId": "Player ID",
|
||||||
|
"currency": "Default currency"
|
||||||
|
},
|
||||||
|
"ssoManaged": "This sign-in is managed by the main site. Return to the main site to sign out or change your credentials.",
|
||||||
|
"logout": "Sign out",
|
||||||
|
"password": {
|
||||||
|
"action": "Change password",
|
||||||
|
"title": "Change login password",
|
||||||
|
"description": "After the password is changed, all lottery sessions for this account will be signed out.",
|
||||||
|
"current": "Current password",
|
||||||
|
"new": "New password",
|
||||||
|
"confirm": "Confirm new password",
|
||||||
|
"hint": "Use at least 6 characters.",
|
||||||
|
"required": "Complete the current, new, and confirmation password fields",
|
||||||
|
"tooShort": "The new password must be at least 6 characters",
|
||||||
|
"mismatch": "The new passwords do not match",
|
||||||
|
"mustDiffer": "The new password must differ from the current password",
|
||||||
|
"submit": "Change password",
|
||||||
|
"saving": "Changing...",
|
||||||
|
"failed": "Failed to change password. Try again later."
|
||||||
|
}
|
||||||
|
},
|
||||||
"draw": {
|
"draw": {
|
||||||
"currentIssue": "Current issue",
|
"currentIssue": "Current issue",
|
||||||
"currentTime": "Current Time",
|
"currentTime": "Current Time",
|
||||||
@@ -124,7 +162,7 @@
|
|||||||
"closesIn": "Closes In",
|
"closesIn": "Closes In",
|
||||||
"drawsIn": "Draws In",
|
"drawsIn": "Draws In",
|
||||||
"drawProcessing": "Drawing…",
|
"drawProcessing": "Drawing…",
|
||||||
"coolDown": "Cool Down",
|
"coolDown": "Until automatic settlement",
|
||||||
"loadFailedRefresh": "Failed to load. Pull down to refresh.",
|
"loadFailedRefresh": "Failed to load. Pull down to refresh.",
|
||||||
"issueNo": "Issue No.",
|
"issueNo": "Issue No.",
|
||||||
"hall": "Betting Hall",
|
"hall": "Betting Hall",
|
||||||
@@ -139,7 +177,7 @@
|
|||||||
"closed": "Awaiting draw",
|
"closed": "Awaiting draw",
|
||||||
"drawing": "Drawing",
|
"drawing": "Drawing",
|
||||||
"review": "Under review",
|
"review": "Under review",
|
||||||
"cooldown": "Cool down",
|
"cooldown": "Result confirmation",
|
||||||
"settling": "Settling",
|
"settling": "Settling",
|
||||||
"settled": "Settled",
|
"settled": "Settled",
|
||||||
"cancelled": "Cancelled"
|
"cancelled": "Cancelled"
|
||||||
@@ -155,7 +193,8 @@
|
|||||||
"lineIssue": {
|
"lineIssue": {
|
||||||
"invalid_number_length": "Row {{row}} «{{play}}»: invalid number length. Check the number field.",
|
"invalid_number_length": "Row {{row}} «{{play}}»: invalid number length. Check the number field.",
|
||||||
"roll_requires_r": "Row {{row}} «{{play}}»: use 4 characters with at least one R (e.g. 12R4, RR34). R marks rolling digits.",
|
"roll_requires_r": "Row {{row}} «{{play}}»: use 4 characters with at least one R (e.g. 12R4, RR34). R marks rolling digits.",
|
||||||
"missing_digit_slot": "Row {{row}} «{{play}}»: missing digit slot. Refresh and try again."
|
"missing_digit_slot": "Row {{row}} «{{play}}»: missing digit slot. Refresh and try again.",
|
||||||
|
"full_cover_amount_not_divisible": "Row {{row}}: the Full cover amount for {{play}} must divide evenly across its combinations."
|
||||||
},
|
},
|
||||||
"previewFailed": "Preview failed",
|
"previewFailed": "Preview failed",
|
||||||
"closedSubmit": "Closed. Cannot submit.",
|
"closedSubmit": "Closed. Cannot submit.",
|
||||||
@@ -163,6 +202,20 @@
|
|||||||
"placeFailed": "Submission failed",
|
"placeFailed": "Submission failed",
|
||||||
"placeSuccess": "Bet submitted. Order {{orderNo}}, deducted {{amount}}.",
|
"placeSuccess": "Bet submitted. Order {{orderNo}}, deducted {{amount}}.",
|
||||||
"placePartialFailed": "{{success}} succeeded, {{failed}} failed",
|
"placePartialFailed": "{{success}} succeeded, {{failed}} failed",
|
||||||
|
"desktop": {
|
||||||
|
"stepGame": "Choose game",
|
||||||
|
"stepFill": "Enter number and stake",
|
||||||
|
"stepReview": "Review and submit",
|
||||||
|
"reviewTitle": "Review and submit",
|
||||||
|
"availableCredit": "Available credit",
|
||||||
|
"remainingCredit": "Available after bet",
|
||||||
|
"warnings": "Risk alerts",
|
||||||
|
"noWarnings": "No risk alerts",
|
||||||
|
"warningCount": "{{count}} rows near sold out",
|
||||||
|
"creditEnough": "Sufficient available credit",
|
||||||
|
"creditInsufficient": "Insufficient available credit",
|
||||||
|
"secureHint": "A final preview appears before submission"
|
||||||
|
},
|
||||||
"placeAllFailed": "All {{failed}} lines failed",
|
"placeAllFailed": "All {{failed}} lines failed",
|
||||||
"playConfig": {
|
"playConfig": {
|
||||||
"playClosedDraftCleared": "{{playCode}} is closed. Related draft amounts have been cleared.",
|
"playClosedDraftCleared": "{{playCode}} is closed. Related draft amounts have been cleared.",
|
||||||
@@ -235,6 +288,7 @@
|
|||||||
"no": "No.",
|
"no": "No.",
|
||||||
"number": "Number",
|
"number": "Number",
|
||||||
"selectionType": "Type",
|
"selectionType": "Type",
|
||||||
|
"total": "Total",
|
||||||
"rowTotal": "Row Total",
|
"rowTotal": "Row Total",
|
||||||
"selectAllTypes": "Select all types",
|
"selectAllTypes": "Select all types",
|
||||||
"setAllTypes": "Set the type for every row",
|
"setAllTypes": "Set the type for every row",
|
||||||
@@ -249,6 +303,21 @@
|
|||||||
"full_play": "Full play",
|
"full_play": "Full play",
|
||||||
"half_play": "Half play"
|
"half_play": "Half play"
|
||||||
},
|
},
|
||||||
|
"comboCount": "{{count}} combos",
|
||||||
|
"selectionTypeRule": "View type rule",
|
||||||
|
"fullCoverSplitHint": "Total split across {{count}} combos",
|
||||||
|
"fullCoverDivisibilityError": "Amount must be a multiple of {{count}}",
|
||||||
|
"halfPlayHint": "Keeps the first two digits in this order",
|
||||||
|
"selectionConfirm": {
|
||||||
|
"title": "Confirm type change",
|
||||||
|
"increaseBody": "This play ({{type}}) expands to about {{count}} combinations. Stake will increase from {{from}} to {{to}}. Continue?",
|
||||||
|
"fullCoverBody": "This play ({{type}}) covers about {{count}} combinations. Total stake {{amount}} is split equally across them, and payout uses each combination's stake. Continue?",
|
||||||
|
"genericBody": "This play ({{type}}) covers about {{count}} combinations and may raise the stake significantly. Continue?",
|
||||||
|
"comboHint": "About {{count}} number permutations",
|
||||||
|
"numberHint": "Number: {{number}}",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm"
|
||||||
|
},
|
||||||
"stake": "Stake Amount",
|
"stake": "Stake Amount",
|
||||||
"rebate": "Commission / Rebate",
|
"rebate": "Commission / Rebate",
|
||||||
"actual": "Actual Deduction",
|
"actual": "Actual Deduction",
|
||||||
@@ -270,6 +339,12 @@
|
|||||||
"big": "B",
|
"big": "B",
|
||||||
"small": "S"
|
"small": "S"
|
||||||
},
|
},
|
||||||
|
"digitPosition": {
|
||||||
|
"0": "1000s",
|
||||||
|
"1": "100s",
|
||||||
|
"2": "10s",
|
||||||
|
"3": "1s"
|
||||||
|
},
|
||||||
"filledPlayCount": "{{count}} plays filled",
|
"filledPlayCount": "{{count}} plays filled",
|
||||||
"tapToFill": "Tap to enter amounts",
|
"tapToFill": "Tap to enter amounts",
|
||||||
"rowActual": "Actual",
|
"rowActual": "Actual",
|
||||||
@@ -415,11 +490,15 @@
|
|||||||
"wallet": {
|
"wallet": {
|
||||||
"title": "Wallet",
|
"title": "Wallet",
|
||||||
"subtitle": "Balance and transfer records",
|
"subtitle": "Balance and transfer records",
|
||||||
"creditTitle": "Credit",
|
"creditTitle": "Limit",
|
||||||
"creditSubtitle": "Credit usage and billing-period activity",
|
"creditSubtitle": "Available limit and period settlement",
|
||||||
"balance": "Wallet Balance",
|
"balance": "Wallet Balance",
|
||||||
"creditAvailable": "Available credit",
|
"creditAvailable": "Available limit",
|
||||||
|
"creditAvailableHint": "Available to bet now",
|
||||||
|
"creditDetails": "Limit details",
|
||||||
"creditSummary": "Limit {{limit}} · Used {{used}}",
|
"creditSummary": "Limit {{limit}} · Used {{used}}",
|
||||||
|
"creditLimit": "Total limit",
|
||||||
|
"usedCredit": "In use",
|
||||||
"creditNoTransferHint": "Credit accounts are funded by your agent; no main-site transfers. Wins are not paid out instantly—profit and loss are settled in billing periods. Contact your agent to change your limit.",
|
"creditNoTransferHint": "Credit accounts are funded by your agent; no main-site transfers. Wins are not paid out instantly—profit and loss are settled in billing periods. Contact your agent to change your limit.",
|
||||||
"available": "Available {{amount}}",
|
"available": "Available {{amount}}",
|
||||||
"transferIn": "Transfer In",
|
"transferIn": "Transfer In",
|
||||||
@@ -429,9 +508,13 @@
|
|||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"logsTitle": "Wallet Logs",
|
"logsTitle": "Wallet Logs",
|
||||||
"logsSubtitle": "Transfer and betting records",
|
"logsSubtitle": "Transfer and betting records",
|
||||||
"creditLogsTitle": "Credit activity",
|
"creditLogsTitle": "Limit and settlement activity",
|
||||||
"creditLogsSubtitle": "Holds, releases, and billing-period payments",
|
"creditLogsSubtitle": "Bets, draw results, and period settlement",
|
||||||
"typeFilter": "Type Filter",
|
"typeFilter": "Type Filter",
|
||||||
|
"logType": "Type",
|
||||||
|
"logTime": "Time",
|
||||||
|
"logRef": "Reference",
|
||||||
|
"logAmount": "Amount",
|
||||||
"transferInTitle": "Transfer In",
|
"transferInTitle": "Transfer In",
|
||||||
"transferOutTitle": "Transfer Out",
|
"transferOutTitle": "Transfer Out",
|
||||||
"transferInSubtitle": "Add funds to lottery wallet ({{currency}})",
|
"transferInSubtitle": "Add funds to lottery wallet ({{currency}})",
|
||||||
@@ -464,17 +547,17 @@
|
|||||||
"viewPendingReconcile": "View pending reconciliation ({{count}})",
|
"viewPendingReconcile": "View pending reconciliation ({{count}})",
|
||||||
"pendingStatus": "Processing",
|
"pendingStatus": "Processing",
|
||||||
"flowsTitle": "Wallet logs",
|
"flowsTitle": "Wallet logs",
|
||||||
"creditFlowsTitle": "Credit activity",
|
"creditFlowsTitle": "Limit and settlement activity",
|
||||||
"playerChannel": {
|
"playerChannel": {
|
||||||
"credit": "Credit line: bet holds, draw settlement, and period payments (not wallet payouts)",
|
"credit": "Credit line: bet holds, draw settlement, and period payments (not wallet payouts)",
|
||||||
"wallet": "Main-site wallet player (transfers & wallet ledger)"
|
"wallet": "Main-site wallet player (transfers & wallet ledger)"
|
||||||
},
|
},
|
||||||
"creditAvailableAfter": "Available credit after",
|
"creditAvailableAfter": "Available limit after",
|
||||||
"creditReleasedAmount": "Released credit {{amount}}",
|
"creditReleasedAmount": "Released credit {{amount}}",
|
||||||
"creditPaymentRecordOnly": "Billing payment record only; not counted in available credit",
|
"creditPaymentRecordOnly": "Billing payment record only; not counted in available credit",
|
||||||
"totalRecords": "{{total}} records",
|
"totalRecords": "{{total}} records",
|
||||||
"emptyLogs": "No wallet logs",
|
"emptyLogs": "No wallet logs",
|
||||||
"emptyCreditLogs": "No credit activity",
|
"emptyCreditLogs": "No limit or settlement activity",
|
||||||
"noMoreLogs": "No more transactions",
|
"noMoreLogs": "No more transactions",
|
||||||
"balanceAfter": "Balance after",
|
"balanceAfter": "Balance after",
|
||||||
"wsBalanceUpdated": "Balance {{change}} ({{reason}})",
|
"wsBalanceUpdated": "Balance {{change}} ({{reason}})",
|
||||||
@@ -497,11 +580,11 @@
|
|||||||
},
|
},
|
||||||
"creditFlow": {
|
"creditFlow": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"bet": "Bet hold",
|
"bet": "Bet",
|
||||||
"game_settlement": "Draw settlement",
|
"game_settlement": "Draw results",
|
||||||
"rebate": "Period rebate",
|
"rebate": "Period rebate",
|
||||||
"credit_release": "Credit release",
|
"credit_release": "Credit release",
|
||||||
"bill_settlement": "Period payment",
|
"bill_settlement": "Period settlement",
|
||||||
"win_credit": "Win release",
|
"win_credit": "Win release",
|
||||||
"refund": "Period confirm release",
|
"refund": "Period confirm release",
|
||||||
"reversal": "Refund release"
|
"reversal": "Refund release"
|
||||||
@@ -544,8 +627,29 @@
|
|||||||
"1009": "The main site could not complete this transfer. Please try again later.",
|
"1009": "The main site could not complete this transfer. Please try again later.",
|
||||||
"1010": "Do not use the same idempotency key for transfers with different amounts."
|
"1010": "Do not use the same idempotency key for transfers with different amounts."
|
||||||
},
|
},
|
||||||
"settlementTitle": "My Billing Period Bills",
|
"activity": {
|
||||||
"settlementHint": "Showing only unsettled bills. Winnings exceeding your credit limit are settled here.",
|
"bet": "Bet",
|
||||||
|
"settledLoss": "Not a winner",
|
||||||
|
"settledWin": "Winner",
|
||||||
|
"result": "Draw result",
|
||||||
|
"refunded": "Refunded",
|
||||||
|
"periodPaid": "Paid",
|
||||||
|
"periodReceived": "Received",
|
||||||
|
"awaitingDraw": "Awaiting draw",
|
||||||
|
"processing": "Processing",
|
||||||
|
"reversed": "Reversed",
|
||||||
|
"details": "View details",
|
||||||
|
"stake": "Bet amount",
|
||||||
|
"win": "Win amount",
|
||||||
|
"rebate": "Rebate",
|
||||||
|
"ticketCount": "Bet lines",
|
||||||
|
"ticketCountValue": "{{count}} item(s)",
|
||||||
|
"viewOrder": "View bet"
|
||||||
|
},
|
||||||
|
"settlementTitle": "Period settlement",
|
||||||
|
"settlementHint": "See the amount currently due to you or from you.",
|
||||||
|
"settlementClear": "Nothing to settle right now",
|
||||||
|
"settlementDetails": "View period details",
|
||||||
"settlementPendingCount": "{{count}} bill(s)",
|
"settlementPendingCount": "{{count}} bill(s)",
|
||||||
"pendingReceivable": "Receivable",
|
"pendingReceivable": "Receivable",
|
||||||
"pendingPayable": "Payable",
|
"pendingPayable": "Payable",
|
||||||
@@ -659,6 +763,7 @@
|
|||||||
"results": {
|
"results": {
|
||||||
"title": "Results",
|
"title": "Results",
|
||||||
"subtitle": "Latest draw history",
|
"subtitle": "Latest draw history",
|
||||||
|
"latest": "Latest draw",
|
||||||
"detailTitle": "Result Detail",
|
"detailTitle": "Result Detail",
|
||||||
"businessDate": "Business Date",
|
"businessDate": "Business Date",
|
||||||
"selectBusinessDate": "Select date",
|
"selectBusinessDate": "Select date",
|
||||||
@@ -838,5 +943,11 @@
|
|||||||
"even": "Even",
|
"even": "Even",
|
||||||
"digit_big": "Big Digit",
|
"digit_big": "Big Digit",
|
||||||
"digit_small": "Small Digit"
|
"digit_small": "Small Digit"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"summary": "{{total}} total, page {{page}} / {{lastPage}}",
|
||||||
|
"pageOf": "Page {{page}} / {{lastPage}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,18 +65,21 @@
|
|||||||
"captcha": "क्याप्चा",
|
"captcha": "क्याप्चा",
|
||||||
"captchaPlaceholder": "क्याप्चा प्रविष्ट गर्नुहोस्",
|
"captchaPlaceholder": "क्याप्चा प्रविष्ट गर्नुहोस्",
|
||||||
"captchaRequired": "कृपया पहिले क्याप्चा लोड गर्नुहोस्",
|
"captchaRequired": "कृपया पहिले क्याप्चा लोड गर्नुहोस्",
|
||||||
|
"captchaCodeRequired": "कृपया क्याप्चा प्रविष्ट गर्नुहोस्",
|
||||||
"captchaLoadFailed": "क्याप्चा लोड असफल। फेरि प्रयास गर्नुहोस्।",
|
"captchaLoadFailed": "क्याप्चा लोड असफल। फेरि प्रयास गर्नुहोस्।",
|
||||||
"captchaLoading": "लोड हुँदै…",
|
"captchaLoading": "लोड हुँदै…",
|
||||||
"captchaRefresh": "क्याप्चा रिफ्रेस",
|
"captchaRefresh": "क्याप्चा रिफ्रेस",
|
||||||
"captchaFetch": "लोड गर्न ट्याप गर्नुहोस्",
|
"captchaFetch": "लोड गर्न ट्याप गर्नुहोस्",
|
||||||
"usernameInvalidCharset": "प्रयोगकर्ता नाममा अक्षर, अंक, डट (.), अन्डरस्कोर र हाइफन मात्र हुन सक्छ",
|
"usernameInvalidCharset": "प्रयोगकर्ता नाममा अक्षर, अंक, डट (.), अन्डरस्कोर र हाइफन मात्र हुन सक्छ",
|
||||||
"passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ"
|
"passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
|
||||||
|
"passwordChanged": "पासवर्ड बदलियो। नयाँ पासवर्डले फेरि लगइन गर्नुहोस्।"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"noToken": "कुनै प्राधिकरण टोकन फेला परेन",
|
"noToken": "कुनै प्राधिकरण टोकन फेला परेन",
|
||||||
"noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।",
|
"noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।",
|
||||||
"sessionExpired": "लगइन म्याद सकियो",
|
"sessionExpired": "लगइन म्याद सकियो",
|
||||||
"sessionExpiredDetail": "कृपया मुख्य साइटमा फर्केर लटरी हल फेरि खोल्नुहोस्।",
|
"sessionExpiredDetail": "कृपया मुख्य साइटमा फर्केर लटरी हल फेरि खोल्नुहोस्।",
|
||||||
|
"sessionReplaced": "यो खाता अर्को उपकरणमा लगइन गरिएको छ। कृपया फेरि लगइन गर्नुहोस्।",
|
||||||
"authFailed": "प्राधिकरण असफल",
|
"authFailed": "प्राधिकरण असफल",
|
||||||
"unknown": "अज्ञात त्रुटि",
|
"unknown": "अज्ञात त्रुटि",
|
||||||
"network": "नेटवर्क त्रुटि भयो",
|
"network": "नेटवर्क त्रुटि भयो",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"orders": "मेरा बेट",
|
"orders": "मेरा बेट",
|
||||||
"rules": "नियम",
|
"rules": "नियम",
|
||||||
"wallet": "वालेट",
|
"wallet": "वालेट",
|
||||||
"credit": "क्रेडिट"
|
"credit": "सीमा"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "मिलान बाँकी सूचना",
|
"title": "मिलान बाँकी सूचना",
|
||||||
@@ -113,6 +113,44 @@
|
|||||||
"player": {
|
"player": {
|
||||||
"fallback": "खेलाडी #{{id}}"
|
"fallback": "खेलाडी #{{id}}"
|
||||||
},
|
},
|
||||||
|
"account": {
|
||||||
|
"open": "खेलाडी प्रोफाइल हेर्नुहोस्",
|
||||||
|
"loading": "खेलाडी",
|
||||||
|
"title": "मेरो खाता",
|
||||||
|
"description": "हाल लगइन भएको खेलाडी प्रोफाइल र खाता सुरक्षा सेटिङ हेर्नुहोस्।",
|
||||||
|
"auth": {
|
||||||
|
"native": "लटरी खाता",
|
||||||
|
"sso": "मुख्य साइट API"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"credit": "क्रेडिट मोड",
|
||||||
|
"wallet": "वालेट मोड"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"username": "प्रयोगकर्ता नाम",
|
||||||
|
"site": "साइट",
|
||||||
|
"playerId": "खेलाडी ID",
|
||||||
|
"currency": "पूर्वनिर्धारित मुद्रा"
|
||||||
|
},
|
||||||
|
"ssoManaged": "यो लगइन मुख्य साइटले व्यवस्थापन गर्छ। लगआउट गर्न वा लगइन विवरण बदल्न मुख्य साइटमा फर्कनुहोस्।",
|
||||||
|
"logout": "लगआउट",
|
||||||
|
"password": {
|
||||||
|
"action": "पासवर्ड बदल्नुहोस्",
|
||||||
|
"title": "लगइन पासवर्ड बदल्नुहोस्",
|
||||||
|
"description": "पासवर्ड बदलेपछि यस खाताका सबै लटरी सत्रबाट लगआउट हुनेछ।",
|
||||||
|
"current": "हालको पासवर्ड",
|
||||||
|
"new": "नयाँ पासवर्ड",
|
||||||
|
"confirm": "नयाँ पासवर्ड पुष्टि",
|
||||||
|
"hint": "कम्तीमा ६ वर्ण प्रयोग गर्नुहोस्।",
|
||||||
|
"required": "हालको, नयाँ र पुष्टि पासवर्ड पूरा भर्नुहोस्",
|
||||||
|
"tooShort": "नयाँ पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
|
||||||
|
"mismatch": "नयाँ पासवर्डहरू मिलेनन्",
|
||||||
|
"mustDiffer": "नयाँ पासवर्ड हालको पासवर्डभन्दा फरक हुनुपर्छ",
|
||||||
|
"submit": "पासवर्ड बदल्नुहोस्",
|
||||||
|
"saving": "बदल्दै...",
|
||||||
|
"failed": "पासवर्ड बदल्न असफल भयो। पछि फेरि प्रयास गर्नुहोस्।"
|
||||||
|
}
|
||||||
|
},
|
||||||
"draw": {
|
"draw": {
|
||||||
"currentIssue": "हालको इश्यू",
|
"currentIssue": "हालको इश्यू",
|
||||||
"currentTime": "हालको समय",
|
"currentTime": "हालको समय",
|
||||||
@@ -124,7 +162,7 @@
|
|||||||
"closesIn": "बन्द हुन बाँकी",
|
"closesIn": "बन्द हुन बाँकी",
|
||||||
"drawsIn": "ड्र हुन बाँकी",
|
"drawsIn": "ड्र हुन बाँकी",
|
||||||
"drawProcessing": "ड्रअ प्रक्रियामा",
|
"drawProcessing": "ड्रअ प्रक्रियामा",
|
||||||
"coolDown": "कुल डाउन",
|
"coolDown": "स्वचालित सेटलमेन्टसम्म",
|
||||||
"loadFailedRefresh": "लोड असफल भयो। तल तानेर रिफ्रेस गर्नुहोस्।",
|
"loadFailedRefresh": "लोड असफल भयो। तल तानेर रिफ्रेस गर्नुहोस्।",
|
||||||
"issueNo": "इश्यू नं.",
|
"issueNo": "इश्यू नं.",
|
||||||
"hall": "बेटिङ हल",
|
"hall": "बेटिङ हल",
|
||||||
@@ -139,7 +177,7 @@
|
|||||||
"closed": "ड्र पर्खँदै",
|
"closed": "ड्र पर्खँदै",
|
||||||
"drawing": "ड्र हुँदैछ",
|
"drawing": "ड्र हुँदैछ",
|
||||||
"review": "समीक्षामा",
|
"review": "समीक्षामा",
|
||||||
"cooldown": "कुल डाउन",
|
"cooldown": "नतिजा पुष्टि हुँदैछ",
|
||||||
"settling": "सेटल हुँदैछ",
|
"settling": "सेटल हुँदैछ",
|
||||||
"settled": "सेटल भयो",
|
"settled": "सेटल भयो",
|
||||||
"cancelled": "रद्द"
|
"cancelled": "रद्द"
|
||||||
@@ -155,7 +193,8 @@
|
|||||||
"lineIssue": {
|
"lineIssue": {
|
||||||
"invalid_number_length": "पङ्क्ति {{row}} «{{play}}»: नम्बरको लम्बाइ मिलेन। नम्बर क्षेत्र जाँच गर्नुहोस्।",
|
"invalid_number_length": "पङ्क्ति {{row}} «{{play}}»: नम्बरको लम्बाइ मिलेन। नम्बर क्षेत्र जाँच गर्नुहोस्।",
|
||||||
"roll_requires_r": "पङ्क्ति {{row}} «{{play}}»: 4 अक्षर र कम्तीमा एक R चाहिन्छ (जस्तै 12R4, RR34)। R = घुम्ने अंक।",
|
"roll_requires_r": "पङ्क्ति {{row}} «{{play}}»: 4 अक्षर र कम्तीमा एक R चाहिन्छ (जस्तै 12R4, RR34)। R = घुम्ने अंक।",
|
||||||
"missing_digit_slot": "पङ्क्ति {{row}} «{{play}}»: अंक स्थान छुट्यो। रिफ्रेस गरी पुनः प्रयास गर्नुहोस्।"
|
"missing_digit_slot": "पङ्क्ति {{row}} «{{play}}»: अंक स्थान छुट्यो। रिफ्रेस गरी पुनः प्रयास गर्नुहोस्।",
|
||||||
|
"full_cover_amount_not_divisible": "पङ्क्ति {{row}}: {{play}} को पूर्ण कभर रकम संयोजनमा पूरा बाँडिनुपर्छ।"
|
||||||
},
|
},
|
||||||
"previewFailed": "पूर्वावलोकन असफल",
|
"previewFailed": "पूर्वावलोकन असफल",
|
||||||
"closedSubmit": "बन्द भयो। पेश गर्न सकिँदैन।",
|
"closedSubmit": "बन्द भयो। पेश गर्न सकिँदैन।",
|
||||||
@@ -163,6 +202,20 @@
|
|||||||
"placeFailed": "पेश गर्न असफल",
|
"placeFailed": "पेश गर्न असफल",
|
||||||
"placeSuccess": "बेट पेश भयो। अर्डर {{orderNo}}, कट्टा {{amount}}।",
|
"placeSuccess": "बेट पेश भयो। अर्डर {{orderNo}}, कट्टा {{amount}}।",
|
||||||
"placePartialFailed": "{{success}} सफल, {{failed}} असफल",
|
"placePartialFailed": "{{success}} सफल, {{failed}} असफल",
|
||||||
|
"desktop": {
|
||||||
|
"stepGame": "खेल छान्नुहोस्",
|
||||||
|
"stepFill": "नम्बर र रकम भर्नुहोस्",
|
||||||
|
"stepReview": "जाँच गरी पेश गर्नुहोस्",
|
||||||
|
"reviewTitle": "जाँच र पेश",
|
||||||
|
"availableCredit": "उपलब्ध क्रेडिट",
|
||||||
|
"remainingCredit": "बेटपछि उपलब्ध",
|
||||||
|
"warnings": "जोखिम सूचना",
|
||||||
|
"noWarnings": "जोखिम सूचना छैन",
|
||||||
|
"warningCount": "{{count}} पङ्क्ति बिक्री सकिन नजिक",
|
||||||
|
"creditEnough": "पर्याप्त क्रेडिट उपलब्ध छ",
|
||||||
|
"creditInsufficient": "उपलब्ध क्रेडिट पर्याप्त छैन",
|
||||||
|
"secureHint": "पेश गर्नुअघि अन्तिम पूर्वावलोकन देखाइनेछ"
|
||||||
|
},
|
||||||
"placeAllFailed": "सबै {{failed}} लाइन असफल",
|
"placeAllFailed": "सबै {{failed}} लाइन असफल",
|
||||||
"playConfig": {
|
"playConfig": {
|
||||||
"playClosedDraftCleared": "{{playCode}} बन्द छ। सम्बन्धित ड्राफ्ट रकम हटाइएको छ।",
|
"playClosedDraftCleared": "{{playCode}} बन्द छ। सम्बन्धित ड्राफ्ट रकम हटाइएको छ।",
|
||||||
@@ -235,6 +288,7 @@
|
|||||||
"no": "नं.",
|
"no": "नं.",
|
||||||
"number": "नम्बर",
|
"number": "नम्बर",
|
||||||
"selectionType": "प्रकार",
|
"selectionType": "प्रकार",
|
||||||
|
"total": "जम्मा",
|
||||||
"rowTotal": "पंक्ति जम्मा",
|
"rowTotal": "पंक्ति जम्मा",
|
||||||
"selectAllTypes": "सबै प्रकार छान्नुहोस्",
|
"selectAllTypes": "सबै प्रकार छान्नुहोस्",
|
||||||
"setAllTypes": "सबै पंक्तिको प्रकार सेट गर्नुहोस्",
|
"setAllTypes": "सबै पंक्तिको प्रकार सेट गर्नुहोस्",
|
||||||
@@ -249,6 +303,21 @@
|
|||||||
"full_play": "पूर्ण खेल",
|
"full_play": "पूर्ण खेल",
|
||||||
"half_play": "आधा खेल"
|
"half_play": "आधा खेल"
|
||||||
},
|
},
|
||||||
|
"comboCount": "{{count}} संयोजन",
|
||||||
|
"selectionTypeRule": "प्रकार नियम हेर्नुहोस्",
|
||||||
|
"fullCoverSplitHint": "कुल रकम {{count}} संयोजनमा बाँडिन्छ",
|
||||||
|
"fullCoverDivisibilityError": "रकम {{count}} को गुणज हुनुपर्छ",
|
||||||
|
"halfPlayHint": "पहिला दुई अंकको क्रम राखिन्छ",
|
||||||
|
"selectionConfirm": {
|
||||||
|
"title": "प्रकार परिवर्तन पुष्टि गर्नुहोस्",
|
||||||
|
"increaseBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन बनाउँछ। रकम {{from}} बाट {{to}} मा बढ्नेछ। जारी राख्ने?",
|
||||||
|
"fullCoverBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन समेट्छ। कुल रकम {{amount}} सबै संयोजनमा बराबर बाँडिन्छ। जारी राख्ने?",
|
||||||
|
"genericBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन समेट्छ र रकम धेरै बढ्न सक्छ। जारी राख्ने?",
|
||||||
|
"comboHint": "करिब {{count}} नम्बर संयोजन",
|
||||||
|
"numberHint": "नम्बर: {{number}}",
|
||||||
|
"cancel": "रद्द",
|
||||||
|
"confirm": "पुष्टि"
|
||||||
|
},
|
||||||
"stake": "बेट रकम",
|
"stake": "बेट रकम",
|
||||||
"rebate": "कमिशन / रिबेट",
|
"rebate": "कमिशन / रिबेट",
|
||||||
"actual": "वास्तविक कट्टा",
|
"actual": "वास्तविक कट्टा",
|
||||||
@@ -270,6 +339,12 @@
|
|||||||
"big": "ठू",
|
"big": "ठू",
|
||||||
"small": "सा"
|
"small": "सा"
|
||||||
},
|
},
|
||||||
|
"digitPosition": {
|
||||||
|
"0": "हजार",
|
||||||
|
"1": "सय",
|
||||||
|
"2": "दश",
|
||||||
|
"3": "एकाइ"
|
||||||
|
},
|
||||||
"filledPlayCount": "{{count}} प्ले भरियो",
|
"filledPlayCount": "{{count}} प्ले भरियो",
|
||||||
"tapToFill": "रकम लेख्न ट्याप गर्नुहोस्",
|
"tapToFill": "रकम लेख्न ट्याप गर्नुहोस्",
|
||||||
"rowActual": "वास्तविक",
|
"rowActual": "वास्तविक",
|
||||||
@@ -425,6 +500,10 @@
|
|||||||
"logsTitle": "वालेट लग",
|
"logsTitle": "वालेट लग",
|
||||||
"logsSubtitle": "ट्रान्सफर र बेट रेकर्ड",
|
"logsSubtitle": "ट्रान्सफर र बेट रेकर्ड",
|
||||||
"typeFilter": "प्रकार फिल्टर",
|
"typeFilter": "प्रकार फिल्टर",
|
||||||
|
"logType": "प्रकार",
|
||||||
|
"logTime": "समय",
|
||||||
|
"logRef": "सम्बन्धित नम्बर",
|
||||||
|
"logAmount": "रकम",
|
||||||
"transferInTitle": "फन्ड ट्रान्सफर इन",
|
"transferInTitle": "फन्ड ट्रान्सफर इन",
|
||||||
"transferOutTitle": "फन्ड ट्रान्सफर आउट",
|
"transferOutTitle": "फन्ड ट्रान्सफर आउट",
|
||||||
"transferInSubtitle": "लटरी वालेटमा रकम थप्नुहोस् ({{currency}})",
|
"transferInSubtitle": "लटरी वालेटमा रकम थप्नुहोस् ({{currency}})",
|
||||||
@@ -457,29 +536,33 @@
|
|||||||
"viewPendingReconcile": "मिलान बाँकी हेर्नुहोस् ({{count}})",
|
"viewPendingReconcile": "मिलान बाँकी हेर्नुहोस् ({{count}})",
|
||||||
"pendingStatus": "प्रक्रिया हुँदैछ",
|
"pendingStatus": "प्रक्रिया हुँदैछ",
|
||||||
"flowsTitle": "वालेट लग",
|
"flowsTitle": "वालेट लग",
|
||||||
"creditTitle": "क्रेडिट",
|
"creditTitle": "सीमा",
|
||||||
"creditSubtitle": "क्रेडिट प्रयोग र बिलिङ अवधि लेनदेन",
|
"creditSubtitle": "उपलब्ध सीमा र अवधि सेटलमेन्ट",
|
||||||
"creditAvailable": "उपलब्ध क्रेडिट",
|
"creditAvailable": "उपलब्ध सीमा",
|
||||||
|
"creditAvailableHint": "अहिले बेट गर्न उपलब्ध",
|
||||||
|
"creditDetails": "सीमा विवरण",
|
||||||
"creditSummary": "सीमा {{limit}} · प्रयोग {{used}}",
|
"creditSummary": "सीमा {{limit}} · प्रयोग {{used}}",
|
||||||
|
"creditLimit": "कुल सीमा",
|
||||||
|
"usedCredit": "प्रयोगमा",
|
||||||
"creditNoTransferHint": "क्रेडिट खाता एजेन्टले दिन्छ; मुख्य साइट ट्रान्सफर छैन। जित तुरुन्त भुक्तानी हुँदैन—नाफा/नोक्सान बिलिङ अवधिमा मिल्छ।",
|
"creditNoTransferHint": "क्रेडिट खाता एजेन्टले दिन्छ; मुख्य साइट ट्रान्सफर छैन। जित तुरुन्त भुक्तानी हुँदैन—नाफा/नोक्सान बिलिङ अवधिमा मिल्छ।",
|
||||||
"creditFlowsTitle": "क्रेडिट लेनदेन",
|
"creditFlowsTitle": "सीमा र सेटलमेन्ट रेकर्ड",
|
||||||
"creditLogsTitle": "क्रेडिट लेनदेन",
|
"creditLogsTitle": "सीमा र सेटलमेन्ट रेकर्ड",
|
||||||
"creditLogsSubtitle": "ओगट, मुक्ति र बिलिङ भुक्तानी",
|
"creditLogsSubtitle": "बेट, ड्र नतिजा र अवधि सेटलमेन्ट",
|
||||||
"playerChannel": {
|
"playerChannel": {
|
||||||
"credit": "क्रेडिट लाइन: बेट होल्ड, ड्र सेटलमेन्ट र अवधि भुक्तानी (वालेट भुक्तानी होइन)",
|
"credit": "क्रेडिट लाइन: बेट होल्ड, ड्र सेटलमेन्ट र अवधि भुक्तानी (वालेट भुक्तानी होइन)",
|
||||||
"wallet": "मुख्य साइट वालेट खेलाडी"
|
"wallet": "मुख्य साइट वालेट खेलाडी"
|
||||||
},
|
},
|
||||||
"creditAvailableAfter": "पछि उपलब्ध क्रेडिट",
|
"creditAvailableAfter": "परिवर्तनपछि उपलब्ध सीमा",
|
||||||
"creditReleasedAmount": "मुक्त क्रेडिट {{amount}}",
|
"creditReleasedAmount": "मुक्त क्रेडिट {{amount}}",
|
||||||
"creditPaymentRecordOnly": "बिलिङ भुक्तानी रेकर्ड मात्र; उपलब्ध क्रेडिटमा गणना हुँदैन",
|
"creditPaymentRecordOnly": "बिलिङ भुक्तानी रेकर्ड मात्र; उपलब्ध क्रेडिटमा गणना हुँदैन",
|
||||||
"emptyCreditLogs": "क्रेडिट लेनदेन छैन",
|
"emptyCreditLogs": "सीमा वा सेटलमेन्ट रेकर्ड छैन",
|
||||||
"creditFlow": {
|
"creditFlow": {
|
||||||
"all": "सबै",
|
"all": "सबै",
|
||||||
"bet": "बेट होल्ड",
|
"bet": "बेट",
|
||||||
"game_settlement": "ड्र सेटलमेन्ट",
|
"game_settlement": "ड्र नतिजा",
|
||||||
"rebate": "अवधि रिबेट",
|
"rebate": "अवधि रिबेट",
|
||||||
"credit_release": "मुक्ति",
|
"credit_release": "मुक्ति",
|
||||||
"bill_settlement": "अवधि भुक्तानी",
|
"bill_settlement": "अवधि सेटलमेन्ट",
|
||||||
"win_credit": "जित मुक्ति",
|
"win_credit": "जित मुक्ति",
|
||||||
"refund": "अवधि पुष्टि मुक्ति",
|
"refund": "अवधि पुष्टि मुक्ति",
|
||||||
"reversal": "फिर्ता मुक्ति"
|
"reversal": "फिर्ता मुक्ति"
|
||||||
@@ -544,8 +627,29 @@
|
|||||||
"timeout": "अनुरोध timeout भयो। कृपया पछि प्रयास गर्नुहोस्।",
|
"timeout": "अनुरोध timeout भयो। कृपया पछि प्रयास गर्नुहोस्।",
|
||||||
"fallback": "अनुरोध असफल। कृपया पछि प्रयास गर्नुहोस्।"
|
"fallback": "अनुरोध असफल। कृपया पछि प्रयास गर्नुहोस्।"
|
||||||
},
|
},
|
||||||
"settlementTitle": "मेरो बिलिङ अवधि बिलहरू",
|
"activity": {
|
||||||
"settlementHint": "केवल बाँकी बिलहरू देखाउँदै। क्रेडिट सीमाभन्दा बढी जित रकम यहाँ सेटल हुन्छ।",
|
"bet": "बेट",
|
||||||
|
"settledLoss": "जितेन",
|
||||||
|
"settledWin": "जित",
|
||||||
|
"result": "ड्र नतिजा",
|
||||||
|
"refunded": "फिर्ता भयो",
|
||||||
|
"periodPaid": "भुक्तानी भयो",
|
||||||
|
"periodReceived": "प्राप्त भयो",
|
||||||
|
"awaitingDraw": "ड्र पर्खँदै",
|
||||||
|
"processing": "प्रक्रियामा",
|
||||||
|
"reversed": "रिभर्स भयो",
|
||||||
|
"details": "विवरण हेर्नुहोस्",
|
||||||
|
"stake": "बेट रकम",
|
||||||
|
"win": "जित रकम",
|
||||||
|
"rebate": "रिबेट",
|
||||||
|
"ticketCount": "बेट संख्या",
|
||||||
|
"ticketCountValue": "{{count}} आइटम",
|
||||||
|
"viewOrder": "बेट हेर्नुहोस्"
|
||||||
|
},
|
||||||
|
"settlementTitle": "अवधि सेटलमेन्ट",
|
||||||
|
"settlementHint": "अहिले प्राप्त वा भुक्तानी गर्नुपर्ने रकम हेर्नुहोस्।",
|
||||||
|
"settlementClear": "अहिले सेटल गर्नुपर्ने केही छैन",
|
||||||
|
"settlementDetails": "अवधि विवरण हेर्नुहोस्",
|
||||||
"settlementPendingCount": "{{count}} बिल(हरू)",
|
"settlementPendingCount": "{{count}} बिल(हरू)",
|
||||||
"pendingReceivable": "प्राप्त हुन बाँकी",
|
"pendingReceivable": "प्राप्त हुन बाँकी",
|
||||||
"pendingPayable": "भुक्तानी हुन बाँकी",
|
"pendingPayable": "भुक्तानी हुन बाँकी",
|
||||||
@@ -659,6 +763,7 @@
|
|||||||
"results": {
|
"results": {
|
||||||
"title": "नतिजा",
|
"title": "नतिजा",
|
||||||
"subtitle": "हालका ड्र इतिहास",
|
"subtitle": "हालका ड्र इतिहास",
|
||||||
|
"latest": "पछिल्लो ड्र",
|
||||||
"detailTitle": "नतिजा विवरण",
|
"detailTitle": "नतिजा विवरण",
|
||||||
"businessDate": "व्यावसायिक मिति",
|
"businessDate": "व्यावसायिक मिति",
|
||||||
"selectBusinessDate": "मिति छान्नुहोस्",
|
"selectBusinessDate": "मिति छान्नुहोस्",
|
||||||
@@ -838,5 +943,11 @@
|
|||||||
"even": "जोड",
|
"even": "जोड",
|
||||||
"digit_big": "ठुलो अंक",
|
"digit_big": "ठुलो अंक",
|
||||||
"digit_small": "सानो अंक"
|
"digit_small": "सानो अंक"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"previous": "अघिल्लो",
|
||||||
|
"next": "अर्को",
|
||||||
|
"summary": "जम्मा {{total}}, पृष्ठ {{page}} / {{lastPage}}",
|
||||||
|
"pageOf": "पृष्ठ {{page}} / {{lastPage}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,18 +65,21 @@
|
|||||||
"captcha": "验证码",
|
"captcha": "验证码",
|
||||||
"captchaPlaceholder": "请输入验证码",
|
"captchaPlaceholder": "请输入验证码",
|
||||||
"captchaRequired": "请先加载验证码",
|
"captchaRequired": "请先加载验证码",
|
||||||
|
"captchaCodeRequired": "请输入验证码",
|
||||||
"captchaLoadFailed": "验证码加载失败,请重试",
|
"captchaLoadFailed": "验证码加载失败,请重试",
|
||||||
"captchaLoading": "加载中…",
|
"captchaLoading": "加载中…",
|
||||||
"captchaRefresh": "刷新验证码",
|
"captchaRefresh": "刷新验证码",
|
||||||
"captchaFetch": "点击获取",
|
"captchaFetch": "点击获取",
|
||||||
"usernameInvalidCharset": "账号只能使用字母、数字、点(.)、下划线和连字符",
|
"usernameInvalidCharset": "账号只能使用字母、数字、点(.)、下划线和连字符",
|
||||||
"passwordMinLength": "密码至少需要 6 个字符"
|
"passwordMinLength": "密码至少需要 6 个字符",
|
||||||
|
"passwordChanged": "密码已修改,请使用新密码重新登录"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"noToken": "未发现授权令牌",
|
"noToken": "未发现授权令牌",
|
||||||
"noTokenDetail": "请返回主站后重试。",
|
"noTokenDetail": "请返回主站后重试。",
|
||||||
"sessionExpired": "登录已过期",
|
"sessionExpired": "登录已过期",
|
||||||
"sessionExpiredDetail": "请返回主站重新进入彩票大厅。",
|
"sessionExpiredDetail": "请返回主站重新进入彩票大厅。",
|
||||||
|
"sessionReplaced": "该账号已在其他设备登录,请重新登录",
|
||||||
"authFailed": "授权失败",
|
"authFailed": "授权失败",
|
||||||
"unknown": "未知错误",
|
"unknown": "未知错误",
|
||||||
"network": "网络异常",
|
"network": "网络异常",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"orders": "我的注单",
|
"orders": "我的注单",
|
||||||
"rules": "规则",
|
"rules": "规则",
|
||||||
"wallet": "钱包",
|
"wallet": "钱包",
|
||||||
"credit": "信用"
|
"credit": "额度"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "待对账提醒",
|
"title": "待对账提醒",
|
||||||
@@ -113,6 +113,44 @@
|
|||||||
"player": {
|
"player": {
|
||||||
"fallback": "玩家 #{{id}}"
|
"fallback": "玩家 #{{id}}"
|
||||||
},
|
},
|
||||||
|
"account": {
|
||||||
|
"open": "查看玩家资料",
|
||||||
|
"loading": "玩家",
|
||||||
|
"title": "我的账号",
|
||||||
|
"description": "查看当前登录的玩家资料与账号安全设置。",
|
||||||
|
"auth": {
|
||||||
|
"native": "彩票账号登录",
|
||||||
|
"sso": "主站 API 接入"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"credit": "信用模式",
|
||||||
|
"wallet": "钱包模式"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"username": "登录账号",
|
||||||
|
"site": "所属站点",
|
||||||
|
"playerId": "玩家编号",
|
||||||
|
"currency": "默认币种"
|
||||||
|
},
|
||||||
|
"ssoManaged": "当前登录由主站管理。如需退出或修改登录凭证,请返回主站操作。",
|
||||||
|
"logout": "退出登录",
|
||||||
|
"password": {
|
||||||
|
"action": "修改密码",
|
||||||
|
"title": "修改登录密码",
|
||||||
|
"description": "修改成功后,当前账号的所有彩票端会话都会退出。",
|
||||||
|
"current": "当前密码",
|
||||||
|
"new": "新密码",
|
||||||
|
"confirm": "确认新密码",
|
||||||
|
"hint": "密码至少需要 6 个字符。",
|
||||||
|
"required": "请完整填写当前密码、新密码和确认密码",
|
||||||
|
"tooShort": "新密码至少需要 6 个字符",
|
||||||
|
"mismatch": "两次输入的新密码不一致",
|
||||||
|
"mustDiffer": "新密码不能与当前密码相同",
|
||||||
|
"submit": "确认修改",
|
||||||
|
"saving": "正在修改...",
|
||||||
|
"failed": "修改密码失败,请稍后重试"
|
||||||
|
}
|
||||||
|
},
|
||||||
"draw": {
|
"draw": {
|
||||||
"currentIssue": "当前期号",
|
"currentIssue": "当前期号",
|
||||||
"currentTime": "当前时间",
|
"currentTime": "当前时间",
|
||||||
@@ -124,7 +162,7 @@
|
|||||||
"closesIn": "距封盘",
|
"closesIn": "距封盘",
|
||||||
"drawsIn": "距开奖",
|
"drawsIn": "距开奖",
|
||||||
"drawProcessing": "开奖处理中",
|
"drawProcessing": "开奖处理中",
|
||||||
"coolDown": "冷静期",
|
"coolDown": "距自动结算",
|
||||||
"loadFailedRefresh": "加载失败,请下拉刷新",
|
"loadFailedRefresh": "加载失败,请下拉刷新",
|
||||||
"issueNo": "期号",
|
"issueNo": "期号",
|
||||||
"hall": "下注大厅",
|
"hall": "下注大厅",
|
||||||
@@ -139,7 +177,7 @@
|
|||||||
"closed": "待开奖",
|
"closed": "待开奖",
|
||||||
"drawing": "开奖中",
|
"drawing": "开奖中",
|
||||||
"review": "待审核",
|
"review": "待审核",
|
||||||
"cooldown": "冷静期",
|
"cooldown": "开奖结果确认中",
|
||||||
"settling": "结算中",
|
"settling": "结算中",
|
||||||
"settled": "已结算",
|
"settled": "已结算",
|
||||||
"cancelled": "已取消"
|
"cancelled": "已取消"
|
||||||
@@ -155,7 +193,8 @@
|
|||||||
"lineIssue": {
|
"lineIssue": {
|
||||||
"invalid_number_length": "第 {{row}} 行「{{play}}」号码位数不正确,请检查号码列。",
|
"invalid_number_length": "第 {{row}} 行「{{play}}」号码位数不正确,请检查号码列。",
|
||||||
"roll_requires_r": "第 {{row}} 行「{{play}}」须为 4 位且含 R(如 12R4、RR34),R 表示滚动位。",
|
"roll_requires_r": "第 {{row}} 行「{{play}}」须为 4 位且含 R(如 12R4、RR34),R 表示滚动位。",
|
||||||
"missing_digit_slot": "第 {{row}} 行「{{play}}」缺少位数,请刷新后重试。"
|
"missing_digit_slot": "第 {{row}} 行「{{play}}」缺少位数,请刷新后重试。",
|
||||||
|
"full_cover_amount_not_divisible": "第 {{row}} 行「{{play}}」使用全保时,金额须能被组合数整除。"
|
||||||
},
|
},
|
||||||
"previewFailed": "预览失败",
|
"previewFailed": "预览失败",
|
||||||
"closedSubmit": "已封盘,无法提交。",
|
"closedSubmit": "已封盘,无法提交。",
|
||||||
@@ -163,6 +202,20 @@
|
|||||||
"placeFailed": "提交失败",
|
"placeFailed": "提交失败",
|
||||||
"placeSuccess": "下注成功,订单号 {{orderNo}},实扣 {{amount}}。",
|
"placeSuccess": "下注成功,订单号 {{orderNo}},实扣 {{amount}}。",
|
||||||
"placePartialFailed": "{{success}} 个成功,{{failed}} 个失败",
|
"placePartialFailed": "{{success}} 个成功,{{failed}} 个失败",
|
||||||
|
"desktop": {
|
||||||
|
"stepGame": "选择玩法",
|
||||||
|
"stepFill": "填写号码和金额",
|
||||||
|
"stepReview": "核对并提交",
|
||||||
|
"reviewTitle": "核对与提交",
|
||||||
|
"availableCredit": "可用信用",
|
||||||
|
"remainingCredit": "下注后可用",
|
||||||
|
"warnings": "风险提醒",
|
||||||
|
"noWarnings": "暂无风险提醒",
|
||||||
|
"warningCount": "{{count}} 行接近售罄",
|
||||||
|
"creditEnough": "可用信用充足",
|
||||||
|
"creditInsufficient": "可用信用不足",
|
||||||
|
"secureHint": "提交前仍会显示最终预览"
|
||||||
|
},
|
||||||
"playConfig": {
|
"playConfig": {
|
||||||
"playClosedDraftCleared": "{{playCode}} 已关闭,相关草稿金额已清除。",
|
"playClosedDraftCleared": "{{playCode}} 已关闭,相关草稿金额已清除。",
|
||||||
"playClosed": "{{playCode}} 已关闭。",
|
"playClosed": "{{playCode}} 已关闭。",
|
||||||
@@ -234,6 +287,7 @@
|
|||||||
"no": "序号",
|
"no": "序号",
|
||||||
"number": "号码",
|
"number": "号码",
|
||||||
"selectionType": "种类",
|
"selectionType": "种类",
|
||||||
|
"total": "合计",
|
||||||
"rowTotal": "本行合计",
|
"rowTotal": "本行合计",
|
||||||
"selectAllTypes": "全选种类",
|
"selectAllTypes": "全选种类",
|
||||||
"setAllTypes": "统一设置所有行的种类",
|
"setAllTypes": "统一设置所有行的种类",
|
||||||
@@ -248,6 +302,21 @@
|
|||||||
"full_play": "全打",
|
"full_play": "全打",
|
||||||
"half_play": "半打"
|
"half_play": "半打"
|
||||||
},
|
},
|
||||||
|
"comboCount": "共{{count}}组",
|
||||||
|
"selectionTypeRule": "查看种类规则",
|
||||||
|
"fullCoverSplitHint": "总额均分至{{count}}组",
|
||||||
|
"fullCoverDivisibilityError": "金额须为{{count}}的整数倍",
|
||||||
|
"halfPlayHint": "按前两位先后顺序组合",
|
||||||
|
"selectionConfirm": {
|
||||||
|
"title": "确认切换种类",
|
||||||
|
"increaseBody": "此玩法({{type}})会增加投注组合数量(约 {{count}} 组),投注金额将从 {{from}} 增加至 {{to}},是否继续?",
|
||||||
|
"fullCoverBody": "此玩法({{type}})将覆盖约 {{count}} 组号码。总投注 {{amount}} 会均分至每组,中奖派彩也按每组金额计算。是否继续?",
|
||||||
|
"genericBody": "此玩法({{type}})将覆盖约 {{count}} 组号码,投注金额可能显著增加,是否继续?",
|
||||||
|
"comboHint": "包含约 {{count}} 组号码排列",
|
||||||
|
"numberHint": "号码:{{number}}",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "确认"
|
||||||
|
},
|
||||||
"stake": "下注金额",
|
"stake": "下注金额",
|
||||||
"rebate": "佣金 / 回水",
|
"rebate": "佣金 / 回水",
|
||||||
"actual": "实扣金额",
|
"actual": "实扣金额",
|
||||||
@@ -269,6 +338,12 @@
|
|||||||
"big": "大",
|
"big": "大",
|
||||||
"small": "小"
|
"small": "小"
|
||||||
},
|
},
|
||||||
|
"digitPosition": {
|
||||||
|
"0": "千",
|
||||||
|
"1": "百",
|
||||||
|
"2": "十",
|
||||||
|
"3": "个"
|
||||||
|
},
|
||||||
"filledPlayCount": "已填写 {{count}} 个玩法",
|
"filledPlayCount": "已填写 {{count}} 个玩法",
|
||||||
"tapToFill": "点击填写玩法金额",
|
"tapToFill": "点击填写玩法金额",
|
||||||
"rowActual": "实扣",
|
"rowActual": "实扣",
|
||||||
@@ -415,11 +490,15 @@
|
|||||||
"wallet": {
|
"wallet": {
|
||||||
"title": "钱包",
|
"title": "钱包",
|
||||||
"subtitle": "余额与划转记录",
|
"subtitle": "余额与划转记录",
|
||||||
"creditTitle": "信用",
|
"creditTitle": "额度",
|
||||||
"creditSubtitle": "额度占用与账期流水",
|
"creditSubtitle": "可用额度与账期结算",
|
||||||
"balance": "钱包余额",
|
"balance": "钱包余额",
|
||||||
"creditAvailable": "可用信用",
|
"creditAvailable": "可用额度",
|
||||||
|
"creditAvailableHint": "当前可用于下注",
|
||||||
|
"creditDetails": "额度详情",
|
||||||
"creditSummary": "授信 {{limit}} · 已用 {{used}}",
|
"creditSummary": "授信 {{limit}} · 已用 {{used}}",
|
||||||
|
"creditLimit": "总额度",
|
||||||
|
"usedCredit": "使用中",
|
||||||
"creditNoTransferHint": "由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
|
"creditNoTransferHint": "由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
|
||||||
"available": "可用 {{amount}}",
|
"available": "可用 {{amount}}",
|
||||||
"transferIn": "转入",
|
"transferIn": "转入",
|
||||||
@@ -429,9 +508,13 @@
|
|||||||
"logs": "流水",
|
"logs": "流水",
|
||||||
"logsTitle": "钱包流水",
|
"logsTitle": "钱包流水",
|
||||||
"logsSubtitle": "划转与下注记录",
|
"logsSubtitle": "划转与下注记录",
|
||||||
"creditLogsTitle": "信用流水",
|
"creditLogsTitle": "额度与结算记录",
|
||||||
"creditLogsSubtitle": "占用、释额与账期收付",
|
"creditLogsSubtitle": "下注、开奖结果与账期结算",
|
||||||
"typeFilter": "类型筛选",
|
"typeFilter": "类型筛选",
|
||||||
|
"logType": "类型",
|
||||||
|
"logTime": "时间",
|
||||||
|
"logRef": "关联单号",
|
||||||
|
"logAmount": "金额",
|
||||||
"transferInTitle": "转入资金",
|
"transferInTitle": "转入资金",
|
||||||
"transferOutTitle": "转出资金",
|
"transferOutTitle": "转出资金",
|
||||||
"transferInSubtitle": "转入彩票钱包({{currency}})",
|
"transferInSubtitle": "转入彩票钱包({{currency}})",
|
||||||
@@ -464,17 +547,17 @@
|
|||||||
"viewPendingReconcile": "查看待对账详情({{count}})",
|
"viewPendingReconcile": "查看待对账详情({{count}})",
|
||||||
"pendingStatus": "处理中",
|
"pendingStatus": "处理中",
|
||||||
"flowsTitle": "资金流水",
|
"flowsTitle": "资金流水",
|
||||||
"creditFlowsTitle": "信用流水",
|
"creditFlowsTitle": "额度与结算记录",
|
||||||
"playerChannel": {
|
"playerChannel": {
|
||||||
"credit": "信用盘:展示下注冻结、开奖结算与账期收付(非钱包派彩)",
|
"credit": "信用盘:展示下注冻结、开奖结算与账期收付(非钱包派彩)",
|
||||||
"wallet": "主站钱包玩家(划转与钱包余额流水)"
|
"wallet": "主站钱包玩家(划转与钱包余额流水)"
|
||||||
},
|
},
|
||||||
"creditAvailableAfter": "变更后可用信用",
|
"creditAvailableAfter": "变更后可用额度",
|
||||||
"creditReleasedAmount": "释额 {{amount}}",
|
"creditReleasedAmount": "释额 {{amount}}",
|
||||||
"creditPaymentRecordOnly": "账期收付记账,不计入可用信用",
|
"creditPaymentRecordOnly": "账期收付记账,不计入可用信用",
|
||||||
"totalRecords": "共 {{total}} 条记录",
|
"totalRecords": "共 {{total}} 条记录",
|
||||||
"emptyLogs": "暂无流水",
|
"emptyLogs": "暂无流水",
|
||||||
"emptyCreditLogs": "暂无信用流水",
|
"emptyCreditLogs": "暂无额度与结算记录",
|
||||||
"noMoreLogs": "没有更多流水",
|
"noMoreLogs": "没有更多流水",
|
||||||
"balanceAfter": "变更后余额",
|
"balanceAfter": "变更后余额",
|
||||||
"wsBalanceUpdated": "余额 {{change}}({{reason}})",
|
"wsBalanceUpdated": "余额 {{change}}({{reason}})",
|
||||||
@@ -497,11 +580,11 @@
|
|||||||
},
|
},
|
||||||
"creditFlow": {
|
"creditFlow": {
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"bet": "下注冻结",
|
"bet": "下注",
|
||||||
"game_settlement": "开奖结算",
|
"game_settlement": "开奖结果",
|
||||||
"rebate": "账期回水",
|
"rebate": "账期回水",
|
||||||
"credit_release": "释额",
|
"credit_release": "释额",
|
||||||
"bill_settlement": "账期收付",
|
"bill_settlement": "账期结算",
|
||||||
"win_credit": "中奖释额",
|
"win_credit": "中奖释额",
|
||||||
"refund": "账期确认释额",
|
"refund": "账期确认释额",
|
||||||
"reversal": "退本释额"
|
"reversal": "退本释额"
|
||||||
@@ -544,8 +627,29 @@
|
|||||||
"1009": "主站未能完成本次划转,请稍后重试。",
|
"1009": "主站未能完成本次划转,请稍后重试。",
|
||||||
"1010": "请勿用同一幂等键发起不同金额的转账。"
|
"1010": "请勿用同一幂等键发起不同金额的转账。"
|
||||||
},
|
},
|
||||||
"settlementTitle": "我的账期账单",
|
"activity": {
|
||||||
"settlementHint": "只显示未结清账单,中奖超出授信的部分在这里结算。",
|
"bet": "下注",
|
||||||
|
"settledLoss": "未中奖",
|
||||||
|
"settledWin": "中奖",
|
||||||
|
"result": "开奖结果",
|
||||||
|
"refunded": "已退款",
|
||||||
|
"periodPaid": "已付款",
|
||||||
|
"periodReceived": "已收款",
|
||||||
|
"awaitingDraw": "待开奖",
|
||||||
|
"processing": "处理中",
|
||||||
|
"reversed": "已冲正",
|
||||||
|
"details": "查看明细",
|
||||||
|
"stake": "下注金额",
|
||||||
|
"win": "中奖金额",
|
||||||
|
"rebate": "回水",
|
||||||
|
"ticketCount": "注单数量",
|
||||||
|
"ticketCountValue": "{{count}}项",
|
||||||
|
"viewOrder": "查看注单"
|
||||||
|
},
|
||||||
|
"settlementTitle": "账期结算",
|
||||||
|
"settlementHint": "查看当前需要收取或支付的金额。",
|
||||||
|
"settlementClear": "当前无需结算",
|
||||||
|
"settlementDetails": "查看账期明细",
|
||||||
"settlementPendingCount": "{{count}}笔",
|
"settlementPendingCount": "{{count}}笔",
|
||||||
"pendingReceivable": "待收",
|
"pendingReceivable": "待收",
|
||||||
"pendingPayable": "待付",
|
"pendingPayable": "待付",
|
||||||
@@ -659,6 +763,7 @@
|
|||||||
"results": {
|
"results": {
|
||||||
"title": "开奖结果",
|
"title": "开奖结果",
|
||||||
"subtitle": "最新开奖历史",
|
"subtitle": "最新开奖历史",
|
||||||
|
"latest": "最新开奖",
|
||||||
"detailTitle": "开奖详情",
|
"detailTitle": "开奖详情",
|
||||||
"businessDate": "业务日期",
|
"businessDate": "业务日期",
|
||||||
"selectBusinessDate": "选择日期",
|
"selectBusinessDate": "选择日期",
|
||||||
@@ -838,5 +943,11 @@
|
|||||||
"even": "双",
|
"even": "双",
|
||||||
"digit_big": "位数大",
|
"digit_big": "位数大",
|
||||||
"digit_small": "位数小"
|
"digit_small": "位数小"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"previous": "上一页",
|
||||||
|
"next": "下一页",
|
||||||
|
"summary": "共 {{total}} 条,第 {{page}} / {{lastPage}} 页",
|
||||||
|
"pageOf": "第 {{page}} / {{lastPage}} 页"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,42 @@ export function isIframeOriginAllowed(origin: string): boolean {
|
|||||||
return allowedOrigins.includes(normalized);
|
return allowedOrigins.includes(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainMessageObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
if (Object.prototype.toString.call(value) !== "[object Object]") return false;
|
||||||
|
|
||||||
|
const prototype = Object.getPrototypeOf(value);
|
||||||
|
return prototype === Object.prototype || prototype === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅接收当前 iframe 的真实父窗口发来的白名单消息。 */
|
||||||
|
export async function resolveTrustedParentMessage(
|
||||||
|
event: MessageEvent,
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
if (typeof window === "undefined" || window.parent === window) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (event.source !== window.parent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isIframeOriginAllowed(event.origin)) {
|
||||||
|
await loadIframeAllowedOrigins(true);
|
||||||
|
if (!isIframeOriginAllowed(event.origin)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isPlainMessageObject(event.data) ? event.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageToken(data: Record<string, unknown>): string | null {
|
||||||
|
const token = data.token;
|
||||||
|
if (typeof token !== "string") return null;
|
||||||
|
|
||||||
|
const trimmed = token.trim();
|
||||||
|
return trimmed === "" ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
/** postMessage 目标:优先 referrer 对应白名单,否则取首个白名单 origin;无白名单时拒绝发送 */
|
/** postMessage 目标:优先 referrer 对应白名单,否则取首个白名单 origin;无白名单时拒绝发送 */
|
||||||
export function resolvePostMessageTargetOrigin(): string | null {
|
export function resolvePostMessageTargetOrigin(): string | null {
|
||||||
const allowedOrigins = getKnownIframeAllowedOrigins();
|
const allowedOrigins = getKnownIframeAllowedOrigins();
|
||||||
|
|||||||
17
src/lib/iframe-token-refresh-events.ts
Normal file
17
src/lib/iframe-token-refresh-events.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
type TokenRefreshListener = (token: string) => void;
|
||||||
|
|
||||||
|
const listeners = new Set<TokenRefreshListener>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iframe bridge 是唯一的 window.message 消费者;续签 Hook 通过此内部通道接收结果,
|
||||||
|
* 避免多个全局监听器重复校验、重复写入同一 Token。
|
||||||
|
*/
|
||||||
|
export function publishIframeTokenRefresh(token: string): void {
|
||||||
|
listeners.forEach((listener) => listener(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeIframeTokenRefresh(listener: TokenRefreshListener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
@@ -21,3 +21,13 @@ export function parseJwtExp(token: string | null): number | null {
|
|||||||
const exp = payload?.exp;
|
const exp = payload?.exp;
|
||||||
return typeof exp === "number" ? exp : null;
|
return typeof exp === "number" ? exp : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回原生玩家 JWT 的单设备会话版本;非原生或解析失败返回 null。 */
|
||||||
|
export function parseJwtSessionVersion(token: string | null): number | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const payload = parseJwtPayload(token);
|
||||||
|
const sessionVersion = payload?.session_version;
|
||||||
|
return typeof sessionVersion === "number" && Number.isInteger(sessionVersion)
|
||||||
|
? sessionVersion
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import Echo from "laravel-echo";
|
import Echo from "laravel-echo";
|
||||||
import Pusher from "pusher-js";
|
import Pusher from "pusher-js";
|
||||||
|
|
||||||
|
import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
|
||||||
|
|
||||||
/** 需在浏览器挂载 Pusher(Reverb 走 pusher-js 协议) */
|
/** 需在浏览器挂载 Pusher(Reverb 走 pusher-js 协议) */
|
||||||
function ensurePusherOnWindow(): void {
|
function ensurePusherOnWindow(): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -106,6 +108,17 @@ export function getLotteryEcho(): Echo<"reverb"> | null {
|
|||||||
forceTLS,
|
forceTLS,
|
||||||
enabledTransports: forceTLS ? ["ws", "wss"] : ["ws"],
|
enabledTransports: forceTLS ? ["ws", "wss"] : ["ws"],
|
||||||
disableStats: true,
|
disableStats: true,
|
||||||
|
channelAuthorization: {
|
||||||
|
endpoint: "/api/broadcasting/auth",
|
||||||
|
transport: "ajax",
|
||||||
|
headersProvider: () => {
|
||||||
|
const bearerToken = getPlayerBearerTokenPayload();
|
||||||
|
|
||||||
|
return bearerToken
|
||||||
|
? { Authorization: `Bearer ${bearerToken}` }
|
||||||
|
: {};
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const lotteryHttp = axios.create({
|
|||||||
|
|
||||||
/** 凭据类 401(非 token 失效),不应触发「会话过期」跳转 */
|
/** 凭据类 401(非 token 失效),不应触发「会话过期」跳转 */
|
||||||
const PLAYER_CREDENTIAL_BIZ_CODES = new Set([8006]);
|
const PLAYER_CREDENTIAL_BIZ_CODES = new Set([8006]);
|
||||||
|
const PLAYER_SESSION_REPLACED_BIZ_CODE = 8010;
|
||||||
|
|
||||||
function isPlayerAuthLoginRequest(config: AxiosRequestConfig | undefined): boolean {
|
function isPlayerAuthLoginRequest(config: AxiosRequestConfig | undefined): boolean {
|
||||||
if (!config || (config.method ?? "get").toLowerCase() !== "post") {
|
if (!config || (config.method ?? "get").toLowerCase() !== "post") {
|
||||||
@@ -73,16 +74,26 @@ lotteryHttp.interceptors.response.use(
|
|||||||
|
|
||||||
// 401: 已登录态 token 失效 → 清会话并回入口;登录凭据错误等由页面自行提示
|
// 401: 已登录态 token 失效 → 清会话并回入口;登录凭据错误等由页面自行提示
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
|
const body = error.response?.data;
|
||||||
|
const sessionReplaced =
|
||||||
|
isApiEnvelope(body) && body.code === PLAYER_SESSION_REPLACED_BIZ_CODE;
|
||||||
const redirectSessionExpired = shouldRedirectPlayerSessionExpired(error);
|
const redirectSessionExpired = shouldRedirectPlayerSessionExpired(error);
|
||||||
if (redirectSessionExpired || window.location.pathname === "/login") {
|
if (redirectSessionExpired || window.location.pathname === "/login") {
|
||||||
usePlayerSessionStore.getState().clearBearerToken();
|
usePlayerSessionStore.getState().clearBearerToken();
|
||||||
}
|
}
|
||||||
if (redirectSessionExpired) {
|
if (redirectSessionExpired) {
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
const alreadyExpired = window.location.search.includes("session=expired");
|
const sessionStatus = sessionReplaced ? "replaced" : "expired";
|
||||||
const expiredTarget = isInIframe() ? "/?session=expired" : "/login?session=expired";
|
const alreadyOnTarget = window.location.search.includes(
|
||||||
|
`session=${sessionStatus}`,
|
||||||
|
);
|
||||||
|
const expiredTarget = sessionReplaced
|
||||||
|
? "/login?session=replaced"
|
||||||
|
: isInIframe()
|
||||||
|
? "/?session=expired"
|
||||||
|
: "/login?session=expired";
|
||||||
const onExpiredPage =
|
const onExpiredPage =
|
||||||
(pathname === "/" || pathname === "/login") && alreadyExpired;
|
(pathname === "/" || pathname === "/login") && alreadyOnTarget;
|
||||||
if (!onExpiredPage) {
|
if (!onExpiredPage) {
|
||||||
window.location.replace(expiredTarget);
|
window.location.replace(expiredTarget);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/** 主滚动区外边距(灰底区域与顶栏/底栏留白) */
|
/** 主滚动区外边距(灰底区域与顶栏/底栏留白) */
|
||||||
export const playerMainInset = "px-2 pt-2 pb-2 lg:px-6 lg:pt-4 lg:pb-6";
|
export const playerMainInset = "px-2 pt-2 pb-2 lg:px-4 lg:pt-4 lg:pb-4";
|
||||||
|
|
||||||
/** 页内白卡片内边距 */
|
/** 页内白卡片内边距(桌面主内容已是白卡片,减少二次 padding) */
|
||||||
export const playerPageShellPadding = "px-2 pt-2 pb-4 lg:px-6 lg:pt-6 lg:pb-6";
|
export const playerPageShellPadding = "px-2 pt-2 pb-4 lg:px-0 lg:pt-0 lg:pb-1";
|
||||||
|
|
||||||
/** 大厅等直接写在 section 上的内边距 */
|
/** 大厅等直接写在 section 上的内边距 */
|
||||||
export const playerPageInset = playerPageShellPadding;
|
export const playerPageInset = playerPageShellPadding;
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ export type WalletLogItem = {
|
|||||||
balance_after: number | null;
|
balance_after: number | null;
|
||||||
/** 信用盘:false 表示账期收付/账期回水展示记录,不改变可用信用 */
|
/** 信用盘:false 表示账期收付/账期回水展示记录,不改变可用信用 */
|
||||||
affects_available_credit?: boolean;
|
affects_available_credit?: boolean;
|
||||||
|
/** 信用盘玩家视图:按业务事件聚合后的活动类型 */
|
||||||
|
activity_kind?: "bet" | "draw_result" | "period_settlement" | "refund";
|
||||||
|
activity_status?: "pending" | "completed" | "reversed";
|
||||||
|
order_no?: string | null;
|
||||||
|
draw_no?: string | null;
|
||||||
|
ticket_no?: string | null;
|
||||||
|
ticket_count?: number;
|
||||||
|
settlement_bill_id?: number | null;
|
||||||
|
stake_amount?: number;
|
||||||
|
win_amount?: number;
|
||||||
|
rebate_amount?: number;
|
||||||
|
net_amount?: number;
|
||||||
ref_id: string | null;
|
ref_id: string | null;
|
||||||
idempotent_key: string | null;
|
idempotent_key: string | null;
|
||||||
external_ref_no: string | null;
|
external_ref_no: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user