feat: 优化大厅下注结果与风险提示展示,重构期号与订单详情样式
This commit is contained in:
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 369 KiB |
@@ -33,7 +33,7 @@ function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) {
|
||||
|
||||
if (warnings.length === 0) return null;
|
||||
return (
|
||||
<Alert className="border-amber-500/40 bg-amber-500/5 text-amber-950 dark:text-amber-100">
|
||||
<Alert className="border-amber-200 bg-amber-50 text-amber-950">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{t("hall.preview.warningsTitle")}</AlertTitle>
|
||||
<AlertDescription className="space-y-1">
|
||||
@@ -70,16 +70,18 @@ export function HallBetPreviewDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[min(90vh,560px)] gap-0 overflow-hidden p-0 sm:max-w-md">
|
||||
<DialogContent className="max-h-[min(90vh,560px)] gap-0 overflow-hidden border-[#e8eef7] p-0 shadow-[0_12px_40px_rgba(15,23,42,0.12)] ring-[#e8eef7] sm:max-w-md">
|
||||
<div className="p-4 pb-2">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("hall.preview.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogTitle className="text-lg font-black text-[#0b3f96]">
|
||||
{t("hall.preview.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed text-slate-500">
|
||||
{t("hall.preview.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!allowSubmit ? (
|
||||
<Alert className="mt-3 border-[#ff4d4f]/35 bg-[#ff4d4f]/8 text-[#ff4d4f] dark:bg-[#ff4d4f]/12">
|
||||
<Alert className="mt-3 border-[#ff4d4f]/35 bg-[#ff4d4f]/8 text-[#ff4d4f]">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{t("hall.preview.sealedTitle")}</AlertTitle>
|
||||
<AlertDescription className="text-xs leading-relaxed">
|
||||
@@ -89,42 +91,42 @@ export function HallBetPreviewDialog({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[min(52vh,360px)] border-y px-4">
|
||||
<ScrollArea className="max-h-[min(52vh,360px)] border-y border-[#e8eef7] px-4">
|
||||
<div className="space-y-4 py-3 pr-3">
|
||||
{!data ? (
|
||||
<p className="text-sm text-muted-foreground">{t("hall.preview.empty")}</p>
|
||||
<p className="text-sm text-slate-500">{t("hall.preview.empty")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-xs">
|
||||
<p>
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] p-3 text-xs shadow-[0_4px_14px_rgba(15,23,42,0.04)]">
|
||||
<p className="text-slate-600">
|
||||
{t("hall.preview.draw")}{" "}
|
||||
<span className="font-mono font-semibold">{data.draw.draw_id}</span> ·{" "}
|
||||
<span className="font-mono font-black text-[#0b3f96]">{data.draw.draw_id}</span> ·{" "}
|
||||
{t("hall.preview.status")}{" "}
|
||||
<span className="font-medium">{data.draw.status}</span>
|
||||
<span className="font-semibold text-[#32518d]">{data.draw.status}</span>
|
||||
</p>
|
||||
{summary ? (
|
||||
<ul className="mt-2 space-y-1 tabular-nums">
|
||||
<ul className="mt-2 space-y-1 tabular-nums text-slate-700">
|
||||
<li>
|
||||
{t("hall.preview.totalBet")}{" "}
|
||||
<span className="font-medium">
|
||||
<span className="font-semibold text-[#d81435]">
|
||||
{formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.preview.rebateDeduct")}{" "}
|
||||
<span className="font-medium">
|
||||
<span className="font-semibold text-emerald-600">
|
||||
{formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.preview.actualDeduct")}{" "}
|
||||
<span className="font-semibold text-primary">
|
||||
<span className="font-bold text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.preview.estimatedPayout")}{" "}
|
||||
<span className="font-medium">
|
||||
<span className="font-medium text-slate-800">
|
||||
{formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
@@ -135,39 +137,41 @@ export function HallBetPreviewDialog({
|
||||
<WarningsBlock warnings={data.warnings} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
{t("hall.preview.lines")}
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{lines.map((ln) => (
|
||||
<li
|
||||
key={ln.client_line_no}
|
||||
className="rounded-md border border-border/80 bg-card px-2 py-2"
|
||||
className="rounded-xl border border-[#e6edf8] bg-white px-3 py-2 shadow-[0_4px_14px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
<span className="font-mono text-xs text-slate-400">
|
||||
#{ln.client_line_no}
|
||||
</span>
|
||||
<span className="font-mono font-medium">{ln.play_code}</span>
|
||||
<span className="font-mono text-sm font-semibold text-[#32518d]">
|
||||
{ln.play_code}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-base">{ln.number}</p>
|
||||
<Separator className="my-2" />
|
||||
<p className="mt-1 font-mono text-lg font-black text-[#0b3f96]">{ln.number}</p>
|
||||
<Separator className="my-2 bg-[#e8eef7]" />
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs tabular-nums">
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-slate-500">
|
||||
{t("hall.preview.normalizedNumber")}
|
||||
</span>
|
||||
<span className="text-right font-mono">{ln.normalized_number}</span>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-slate-500">
|
||||
{t("hall.preview.combinationCount")}
|
||||
</span>
|
||||
<span className="text-right">{ln.combination_count}</span>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-slate-500">
|
||||
{t("hall.preview.actual")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
<span className="text-right font-semibold text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-slate-500">
|
||||
{t("hall.preview.estimatedMax")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
@@ -183,11 +187,22 @@ export function HallBetPreviewDialog({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 border-t bg-muted/30 p-4 sm:flex-row sm:justify-between">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={placing}>
|
||||
<div className="flex flex-col-reverse gap-2 border-t border-[#e8eef7] bg-[#f8fbff] p-4 sm:flex-row sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={placing}
|
||||
className="h-11 rounded-xl border-[#dce7f7] bg-white text-sm font-semibold text-[#07459f] hover:bg-[#f1f6ff]"
|
||||
>
|
||||
{t("hall.preview.backEdit")}
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirmPlace} disabled={!data || placing || !allowSubmit}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirmPlace}
|
||||
disabled={!data || placing || !allowSubmit}
|
||||
className="h-11 rounded-xl border-0 bg-[#e5002c] text-sm font-bold text-white shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028] disabled:bg-slate-300 disabled:shadow-none"
|
||||
>
|
||||
{placing
|
||||
? t("hall.preview.submitting")
|
||||
: allowSubmit
|
||||
|
||||
179
src/features/hall/hall-bet-result-dialog.tsx
Normal file
179
src/features/hall/hall-bet-result-dialog.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import type { TicketPlaceData } from "@/types/api/ticket";
|
||||
|
||||
type HallBetResultDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currencyCode: string;
|
||||
data: TicketPlaceData | null;
|
||||
};
|
||||
|
||||
export function HallBetResultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currencyCode,
|
||||
data,
|
||||
}: HallBetResultDialogProps) {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
const totalItems = data?.items.length ?? 0;
|
||||
const totalSuccess = totalItems;
|
||||
const totalFailure = 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[min(90vh,620px)] gap-0 overflow-hidden border-[#e8eef7] p-0 shadow-[0_12px_40px_rgba(15,23,42,0.12)] ring-[#e8eef7] sm:max-w-md">
|
||||
<div className="p-4 pb-2">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-black text-[#0b3f96]">
|
||||
{t("hall.result.title", { defaultValue: "下注结果" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed text-slate-500">
|
||||
{t("hall.result.description", {
|
||||
defaultValue: "本次提交已完成,以下为本次结果明细。",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[min(56vh,400px)] border-y border-[#e8eef7] px-4">
|
||||
<div className="space-y-4 py-3 pr-3">
|
||||
{!data ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("hall.result.empty", { defaultValue: "暂无结果。" })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] p-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.04)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="size-4 text-emerald-600" />
|
||||
<span className="font-semibold text-slate-800">
|
||||
{t("hall.result.orderNo", {
|
||||
defaultValue: "订单号",
|
||||
})}{" "}
|
||||
<span className="font-mono font-black text-[#0b3f96]">{data.order_no}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{t("hall.result.draw", { defaultValue: "期号" })}{" "}
|
||||
<span className="font-mono font-semibold text-[#32518d]">{data.draw.draw_id}</span>
|
||||
</p>
|
||||
<Separator className="my-3 bg-[#e8eef7]" />
|
||||
<ul className="space-y-1 text-xs tabular-nums text-slate-700">
|
||||
<li>
|
||||
{t("hall.result.successCount", { defaultValue: "成功数" })}{" "}
|
||||
<span className="font-semibold text-emerald-700">{totalSuccess}</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.result.failureCount", { defaultValue: "失败数" })}{" "}
|
||||
<span className="font-semibold text-rose-600">{totalFailure}</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.result.totalDeduct", { defaultValue: "成功扣款" })}{" "}
|
||||
<span className="font-bold text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.result.balanceAfter", { defaultValue: "剩余余额" })}{" "}
|
||||
<span className="font-semibold text-slate-800">
|
||||
{formatMinorAsCurrency(data.balance_after, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
{t("hall.result.items", {
|
||||
defaultValue: "每一注成功/失败详情",
|
||||
})}
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{data.items.map((item, index) => (
|
||||
<li
|
||||
key={`${item.ticket_no}-${index}`}
|
||||
className="rounded-xl border border-[#e6edf8] bg-white px-3 py-2 shadow-[0_4px_14px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-xs text-slate-400">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-emerald-700">
|
||||
{t("hall.result.success", { defaultValue: "成功" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-[#32518d]">{playLabel(item.play_code, t)}</span>
|
||||
<span className="font-mono text-xs text-slate-500">
|
||||
{item.ticket_no}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-2 gap-y-1 text-xs tabular-nums">
|
||||
<span className="text-slate-500">
|
||||
{t("hall.result.number", { defaultValue: "号码" })}
|
||||
</span>
|
||||
<span className="text-right font-mono font-black text-[#0b3f96]">{item.number}</span>
|
||||
<span className="text-slate-500">
|
||||
{t("hall.result.comboCount", { defaultValue: "组合数" })}
|
||||
</span>
|
||||
<span className="text-right">{item.combination_count}</span>
|
||||
<span className="text-slate-500">
|
||||
{t("hall.result.actualDeduct", { defaultValue: "实扣" })}
|
||||
</span>
|
||||
<span className="text-right font-semibold text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(item.actual_deduct_amount, currencyCode)}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
{t("hall.result.estimatedMax", { defaultValue: "最坏赔付" })}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{formatMinorAsCurrency(item.estimated_max_payout, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
{item.combination_count > 1 ? (
|
||||
<div className="mt-2 inline-flex items-center gap-1 rounded-full border border-[#dce7f7] bg-[#f8fbff] px-2 py-1 text-[11px] text-[#07459f]">
|
||||
<ChevronRight className="size-3" />
|
||||
{t("hall.result.comboHint", {
|
||||
defaultValue: "已按展开组合分摊",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex justify-end border-t border-[#e8eef7] bg-[#f8fbff] p-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-11 rounded-xl border-0 bg-[#07459f] px-6 text-sm font-bold text-white hover:bg-[#063b88]"
|
||||
>
|
||||
{t("actions.close", { defaultValue: "关闭" })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CirclePlus, Cuboid, PackageOpen, Ticket, Trash2 } from "lucide-react";
|
||||
import { ChevronRight, CirclePlus, Ticket, Trash2, Star } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getPlayEffective } from "@/api/play";
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
||||
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
|
||||
import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog";
|
||||
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
|
||||
import {
|
||||
playNeedsDigitSlot,
|
||||
playNeedsDimension,
|
||||
ticketNumberSpec,
|
||||
} from "@/features/hall/hall-bet-rules";
|
||||
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
||||
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
|
||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
||||
import type { TicketLineInput, TicketPreviewData } from "@/types/api/ticket";
|
||||
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
||||
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
||||
|
||||
const DEFAULT_POLL_MS = 120_000;
|
||||
const MAX_ROWS = 20;
|
||||
|
||||
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
|
||||
type BoxMode = "ibox" | "box";
|
||||
|
||||
type DraftRow = {
|
||||
id: string;
|
||||
@@ -48,6 +50,9 @@ type DraftEntry = {
|
||||
line: TicketLineInput;
|
||||
};
|
||||
|
||||
type CellRiskState = "open" | "warning" | "sold_out";
|
||||
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
||||
|
||||
const categoryTabs: { value: HallCategory; label: string }[] = [
|
||||
{ value: "D2", label: "2D" },
|
||||
{ value: "D3", label: "3D" },
|
||||
@@ -55,22 +60,33 @@ const categoryTabs: { value: HallCategory; label: string }[] = [
|
||||
{ value: "JACKPOT", label: "Jackpot" },
|
||||
];
|
||||
|
||||
const preferredD4Columns = [
|
||||
const D2_PLAY_ORDER = ["pos_2a", "pos_2b", "pos_2c", "pos_2abc"] as const;
|
||||
const D3_PLAY_ORDER = ["pos_3a", "pos_3b", "pos_3c", "pos_3abc"] as const;
|
||||
const D4_PLAY_ORDER = [
|
||||
"big",
|
||||
"small",
|
||||
"pos_3c",
|
||||
"pos_3a",
|
||||
"pos_4a",
|
||||
"pos_4b",
|
||||
"pos_4c",
|
||||
"pos_4d",
|
||||
"pos_4e",
|
||||
"box",
|
||||
"ibox",
|
||||
"mbox",
|
||||
"roll",
|
||||
"straight",
|
||||
"head",
|
||||
"tail",
|
||||
"odd",
|
||||
"even",
|
||||
"digit_big",
|
||||
"digit_small",
|
||||
] as const;
|
||||
|
||||
const simpleCategoryPreferred: Record<"D2" | "D3", string[]> = {
|
||||
D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2abc"],
|
||||
D3: ["pos_3a", "pos_3b", "pos_3c", "pos_3abc"],
|
||||
const categoryPlayOrders: Record<Exclude<HallCategory, "JACKPOT">, readonly string[]> = {
|
||||
D2: D2_PLAY_ORDER,
|
||||
D3: D3_PLAY_ORDER,
|
||||
D4: D4_PLAY_ORDER,
|
||||
};
|
||||
|
||||
function newDraftRow(): DraftRow {
|
||||
@@ -82,29 +98,19 @@ function newDraftRow(): DraftRow {
|
||||
}
|
||||
|
||||
function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
|
||||
if (!row.master_enabled || row.config === null) {
|
||||
return false;
|
||||
}
|
||||
return row.config.is_enabled;
|
||||
return Boolean(row.master_enabled && row.config?.is_enabled);
|
||||
}
|
||||
|
||||
function pickDisplayName(row: PlayEffectivePlayRow): string {
|
||||
const loc = getLotteryRequestLocale();
|
||||
if (loc === "zh") {
|
||||
return row.display_name_zh ?? row.display_name_en ?? row.play_code;
|
||||
}
|
||||
if (loc === "ne") {
|
||||
return row.display_name_ne ?? row.display_name_en ?? row.play_code;
|
||||
}
|
||||
if (loc === "zh") return row.display_name_zh ?? row.display_name_en ?? row.play_code;
|
||||
if (loc === "ne") return row.display_name_ne ?? row.display_name_en ?? row.play_code;
|
||||
return row.display_name_en ?? row.display_name_zh ?? row.play_code;
|
||||
}
|
||||
|
||||
function inferCategory(row: PlayEffectivePlayRow): HallCategory {
|
||||
function inferCategory(row: PlayEffectivePlayRow): Exclude<HallCategory, "JACKPOT"> {
|
||||
if (row.play_code.startsWith("pos_2")) return "D2";
|
||||
if (row.play_code.startsWith("pos_3")) return "D3";
|
||||
if (row.category.toLowerCase().includes("jackpot")) return "JACKPOT";
|
||||
if (row.dimension === 2) return "D2";
|
||||
if (row.dimension === 3) return "D3";
|
||||
return "D4";
|
||||
}
|
||||
|
||||
@@ -116,8 +122,10 @@ function categoryDigits(category: HallCategory): number {
|
||||
}
|
||||
|
||||
function sanitizeNumber(raw: string, category: HallCategory): string {
|
||||
const max = categoryDigits(category);
|
||||
return raw.replace(/\D/g, "").slice(0, max);
|
||||
if (category === "D4") {
|
||||
return raw.replace(/[^0-9Rr]/g, "").toUpperCase().slice(0, 4);
|
||||
}
|
||||
return raw.replace(/\D/g, "").slice(0, categoryDigits(category));
|
||||
}
|
||||
|
||||
function sanitizeAmount(raw: string): string {
|
||||
@@ -130,11 +138,6 @@ function parseRebateRate(rate: string | undefined): number {
|
||||
return n > 1 ? n / 100 : n;
|
||||
}
|
||||
|
||||
function formatRatePercent(rate: string | undefined): string {
|
||||
const n = parseRebateRate(rate);
|
||||
return `${(n * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function amountToDisplay(minor: number): string {
|
||||
return (minor / 100).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
@@ -158,8 +161,13 @@ function normalizeNumberForPlay(number: string, playCode: string): string {
|
||||
return number;
|
||||
}
|
||||
|
||||
function pickDigitSlot(category: HallCategory): number {
|
||||
if (category === "D2") return 3;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function lineForPlay(
|
||||
category: "D2" | "D3" | "D4",
|
||||
category: Exclude<HallCategory, "JACKPOT">,
|
||||
play: PlayEffectivePlayRow,
|
||||
displayNumber: string,
|
||||
amountMinor: number,
|
||||
@@ -180,57 +188,202 @@ function lineForPlay(
|
||||
line.dimension = category;
|
||||
}
|
||||
if (playNeedsDigitSlot(play.play_code)) {
|
||||
line.digit_slot = 3;
|
||||
line.digit_slot = pickDigitSlot(category);
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
function findPlayByCode(
|
||||
plays: PlayEffectivePlayRow[],
|
||||
function sortByPlayOrder(plays: PlayEffectivePlayRow[], order: readonly string[]): PlayEffectivePlayRow[] {
|
||||
const orderMap = new Map(order.map((code, idx) => [code, idx]));
|
||||
return [...plays].sort((a, b) => {
|
||||
const ai = orderMap.get(a.play_code) ?? 999;
|
||||
const bi = orderMap.get(b.play_code) ?? 999;
|
||||
return ai - bi || a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code);
|
||||
});
|
||||
}
|
||||
|
||||
function loadStringArray(key: string): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((v): v is string => typeof v === "string");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveStringArray(key: string, values: string[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(key, JSON.stringify(values));
|
||||
}
|
||||
|
||||
function loadQuickFillState(): QuickFillState {
|
||||
return {
|
||||
D2: {
|
||||
favorites: loadStringArray(quickFillKeys("D2").favorites),
|
||||
history: loadStringArray(quickFillKeys("D2").history),
|
||||
},
|
||||
D3: {
|
||||
favorites: loadStringArray(quickFillKeys("D3").favorites),
|
||||
history: loadStringArray(quickFillKeys("D3").history),
|
||||
},
|
||||
D4: {
|
||||
favorites: loadStringArray(quickFillKeys("D4").favorites),
|
||||
history: loadStringArray(quickFillKeys("D4").history),
|
||||
},
|
||||
JACKPOT: {
|
||||
favorites: [],
|
||||
history: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function appendUnique(values: string[], value: string, limit = 20): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return values;
|
||||
const next = [trimmed, ...values.filter((v) => v !== trimmed)];
|
||||
return next.slice(0, limit);
|
||||
}
|
||||
|
||||
function sortedDigits(value: string): string {
|
||||
return value.split("").sort().join("");
|
||||
}
|
||||
|
||||
function matchesRiskAlert(
|
||||
alertNumber: string,
|
||||
playCode: string,
|
||||
): PlayEffectivePlayRow | undefined {
|
||||
return plays.find((p) => p.play_code === playCode);
|
||||
rowNumber: string,
|
||||
category: Exclude<HallCategory, "JACKPOT">,
|
||||
): 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 last = alertNumber[pickDigitSlot(category)] ?? "";
|
||||
return playCode === "digit_big"
|
||||
? ["5", "6", "7", "8", "9"].includes(last)
|
||||
: ["0", "1", "2", "3", "4"].includes(last);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function pickSimplePlay(
|
||||
plays: PlayEffectivePlayRow[],
|
||||
category: "D2" | "D3",
|
||||
): PlayEffectivePlayRow | undefined {
|
||||
const preferred = simpleCategoryPreferred[category];
|
||||
return (
|
||||
preferred.map((code) => findPlayByCode(plays, code)).find(Boolean) ??
|
||||
plays.find((p) => inferCategory(p) === category)
|
||||
);
|
||||
function cellRiskState(
|
||||
play: PlayEffectivePlayRow,
|
||||
rowNumber: string,
|
||||
category: Exclude<HallCategory, "JACKPOT">,
|
||||
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
|
||||
): CellRiskState {
|
||||
const alerts = alertRows ?? [];
|
||||
if (alerts.length === 0) return "open";
|
||||
const normalizedRow = rowNumber.trim().toUpperCase();
|
||||
if (!normalizedRow) return "open";
|
||||
|
||||
for (const alert of alerts) {
|
||||
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category)) {
|
||||
return alert.is_sold_out ? "sold_out" : "warning";
|
||||
}
|
||||
}
|
||||
|
||||
return "open";
|
||||
}
|
||||
|
||||
export function HallBettingGrid() {
|
||||
const { display, isBettable, reload: reloadDraw } = useHallDrawLive();
|
||||
function tableCellClass(status: CellRiskState, disabled: boolean): string {
|
||||
if (disabled) {
|
||||
return "opacity-50";
|
||||
}
|
||||
if (status === "sold_out") {
|
||||
return "bg-slate-100 text-slate-400 line-through";
|
||||
}
|
||||
if (status === "warning") {
|
||||
return "bg-amber-50 text-amber-700";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function quickFillKeys(category: HallCategory): { favorites: string; history: string } {
|
||||
return {
|
||||
favorites: `lottery.hall.quickfill.favorites.${category}`,
|
||||
history: `lottery.hall.quickfill.history.${category}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }) {
|
||||
const { display, isBettable, reload: reloadDraw } = drawLive;
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
||||
const [boxMode, setBoxMode] = useState<BoxMode>("ibox");
|
||||
const [rows, setRows] = useState<DraftRow[]>(() => [
|
||||
{ ...newDraftRow(), number: "23", amounts: {} },
|
||||
{ ...newDraftRow(), number: "75", amounts: {} },
|
||||
{ ...newDraftRow(), number: "08", amounts: {} },
|
||||
{ ...newDraftRow(), number: "46", amounts: {} },
|
||||
]);
|
||||
|
||||
const currencyParam = useMemo(() => {
|
||||
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
|
||||
return fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined;
|
||||
}, []);
|
||||
|
||||
const [activeRowId, setActiveRowId] = useState<string | null>(null);
|
||||
const [catalogState, setCatalogState] = useState<
|
||||
| { kind: "loading" }
|
||||
| { kind: "ok"; data: PlayEffectivePayload }
|
||||
| { kind: "error"; message: string }
|
||||
>({ kind: "loading" });
|
||||
|
||||
const [availableMinor, setAvailableMinor] = useState<number>(0);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<TicketPreviewData | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [placeLoading, setPlaceLoading] = useState(false);
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
|
||||
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
|
||||
const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
|
||||
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
|
||||
timer: null,
|
||||
number: null,
|
||||
longPress: false,
|
||||
});
|
||||
|
||||
const currencyParam = useMemo(() => {
|
||||
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
|
||||
return fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined;
|
||||
}, []);
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
||||
@@ -240,54 +393,73 @@ export function HallBettingGrid() {
|
||||
);
|
||||
setCatalogState({ kind: "ok", data });
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
|
||||
setCatalogState({ kind: "error", message: msg });
|
||||
}
|
||||
}, [currencyParam, t]);
|
||||
|
||||
const refreshWallet = useCallback(async () => {
|
||||
try {
|
||||
const wallet = await getWalletBalance(
|
||||
currencyParam !== undefined ? { currency: currencyParam } : undefined,
|
||||
);
|
||||
setAvailableMinor(Number(wallet.available_balance ?? 0));
|
||||
} catch {
|
||||
setAvailableMinor(0);
|
||||
}
|
||||
}, [currencyParam]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadCatalog();
|
||||
void refreshWallet();
|
||||
});
|
||||
}, [loadCatalog]);
|
||||
}, [loadCatalog, refreshWallet]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
void loadCatalog();
|
||||
void refreshWallet();
|
||||
}, DEFAULT_POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [loadCatalog]);
|
||||
}, [loadCatalog, refreshWallet]);
|
||||
|
||||
const openPlays = useMemo(() => {
|
||||
if (catalogState.kind !== "ok") return [];
|
||||
return [...catalogState.data.plays]
|
||||
.filter(isPlayOpenForPlayer)
|
||||
.filter((p) => p.play_code !== "half_box" && p.play_code !== "roll")
|
||||
.sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code));
|
||||
}, [catalogState]);
|
||||
const order = categoryPlayOrders[activeCategory === "JACKPOT" ? "D4" : activeCategory];
|
||||
return sortByPlayOrder(
|
||||
catalogState.data.plays
|
||||
.filter(isPlayOpenForPlayer)
|
||||
.filter((p) => order.includes(p.play_code)),
|
||||
order,
|
||||
);
|
||||
}, [activeCategory, catalogState]);
|
||||
|
||||
const currencyCode =
|
||||
catalogState.kind === "ok" ? catalogState.data.currency_code : "NPR";
|
||||
const currencyCode = catalogState.kind === "ok" ? catalogState.data.currency_code : "NPR";
|
||||
|
||||
const simplePlay = useMemo(() => {
|
||||
if (activeCategory !== "D2" && activeCategory !== "D3") return undefined;
|
||||
return pickSimplePlay(openPlays, activeCategory);
|
||||
}, [activeCategory, openPlays]);
|
||||
const categoryPlays = useMemo(() => {
|
||||
if (catalogState.kind !== "ok") return [];
|
||||
if (activeCategory === "JACKPOT") return [];
|
||||
const order = categoryPlayOrders[activeCategory];
|
||||
return sortByPlayOrder(
|
||||
openPlays.filter((p) => inferCategory(p) === activeCategory || activeCategory === "D4"),
|
||||
order,
|
||||
);
|
||||
}, [activeCategory, catalogState, openPlays]);
|
||||
|
||||
const d4Columns = useMemo(() => {
|
||||
if (activeCategory !== "D4") return [];
|
||||
const first = findPlayByCode(openPlays, boxMode);
|
||||
const preferred = preferredD4Columns
|
||||
.map((code) => findPlayByCode(openPlays, code))
|
||||
.filter((p): p is PlayEffectivePlayRow => Boolean(p));
|
||||
const merged = first ? [first, ...preferred] : preferred;
|
||||
return merged.filter((p, i, arr) => arr.findIndex((x) => x.play_code === p.play_code) === i);
|
||||
}, [activeCategory, boxMode, openPlays]);
|
||||
const activeRow = useMemo(
|
||||
() => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null,
|
||||
[activeRowId, rows],
|
||||
);
|
||||
|
||||
const tableDisabled =
|
||||
activeCategory === "JACKPOT" || !isBettable || catalogState.kind !== "ok";
|
||||
const alertRows = display?.risk_pool_alerts ?? [];
|
||||
const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] };
|
||||
const favorites = currentQuickFill.favorites;
|
||||
const historyNumbers = currentQuickFill.history;
|
||||
|
||||
const tableDisabled = activeCategory === "JACKPOT" || !isBettable || catalogState.kind !== "ok";
|
||||
const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status));
|
||||
const numberPlaceholder = activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
|
||||
|
||||
const updateRowNumber = (id: string, value: string) => {
|
||||
setRows((current) =>
|
||||
@@ -295,51 +467,95 @@ export function HallBettingGrid() {
|
||||
row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row,
|
||||
),
|
||||
);
|
||||
setActiveRowId(id);
|
||||
};
|
||||
|
||||
const updateAmount = (rowId: string, playCode: string, value: string) => {
|
||||
setRows((current) =>
|
||||
current.map((row) =>
|
||||
row.id === rowId
|
||||
? {
|
||||
...row,
|
||||
amounts: { ...row.amounts, [playCode]: sanitizeAmount(value) },
|
||||
}
|
||||
? { ...row, amounts: { ...row.amounts, [playCode]: sanitizeAmount(value) } }
|
||||
: row,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
setRows((current) => (current.length >= MAX_ROWS ? current : [...current, newDraftRow()]));
|
||||
setRows((current) => {
|
||||
if (current.length >= MAX_ROWS) return current;
|
||||
const next = [...current, newDraftRow()];
|
||||
setActiveRowId(next[next.length - 1].id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeRow = (id: string) => {
|
||||
setRows((current) =>
|
||||
current.length <= 1 ? current : current.filter((row) => row.id !== id),
|
||||
);
|
||||
setRows((current) => {
|
||||
if (current.length <= 1) return current;
|
||||
const next = current.filter((row) => row.id !== id);
|
||||
setActiveRowId((prev) => (prev === id ? next[0]?.id ?? null : prev));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllRows = () => {
|
||||
if (tableDisabled) return;
|
||||
const next = [newDraftRow()];
|
||||
setRows(next);
|
||||
setActiveRowId(next[0].id);
|
||||
};
|
||||
|
||||
const fillCurrentRow = (number: string) => {
|
||||
if (tableDisabled) return;
|
||||
const targetId = activeRowId ?? rows[0]?.id;
|
||||
if (!targetId) return;
|
||||
updateRowNumber(targetId, number);
|
||||
};
|
||||
|
||||
const toggleFavoriteNumber = (number: string) => {
|
||||
const keys = quickFillKeys(activeCategory);
|
||||
setQuickFillState((current) => {
|
||||
const currentFavorites = current[activeCategory]?.favorites ?? [];
|
||||
const exists = currentFavorites.includes(number);
|
||||
const next = exists
|
||||
? currentFavorites.filter((n) => n !== number)
|
||||
: [number, ...currentFavorites].slice(0, 20);
|
||||
saveStringArray(keys.favorites, next);
|
||||
return {
|
||||
...current,
|
||||
[activeCategory]: {
|
||||
...(current[activeCategory] ?? { favorites: [], history: [] }),
|
||||
favorites: next,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const pushHistory = (number: string) => {
|
||||
const keys = quickFillKeys(activeCategory);
|
||||
setQuickFillState((current) => {
|
||||
const currentHistory = current[activeCategory]?.history ?? [];
|
||||
const next = appendUnique(currentHistory, number, 20);
|
||||
saveStringArray(keys.history, next);
|
||||
return {
|
||||
...current,
|
||||
[activeCategory]: {
|
||||
...(current[activeCategory] ?? { favorites: [], history: [] }),
|
||||
history: next,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const collectEntries = useCallback((): DraftEntry[] => {
|
||||
if (activeCategory === "JACKPOT") return [];
|
||||
|
||||
const entries: DraftEntry[] = [];
|
||||
const plays =
|
||||
activeCategory === "D4" ? d4Columns : simplePlay ? [simplePlay] : [];
|
||||
|
||||
rows.forEach((row, rowIndex) => {
|
||||
plays.forEach((play) => {
|
||||
categoryPlays.forEach((play) => {
|
||||
const amount = parseDecimalInputToMinor(row.amounts[play.play_code] ?? "");
|
||||
if (amount === null || amount <= 0) return;
|
||||
|
||||
const line = lineForPlay(
|
||||
activeCategory as "D2" | "D3" | "D4",
|
||||
play,
|
||||
row.number,
|
||||
amount,
|
||||
);
|
||||
const line = lineForPlay(activeCategory, play, row.number, amount);
|
||||
if (!line) return;
|
||||
|
||||
entries.push({
|
||||
rowId: row.id,
|
||||
rowNo: rowIndex + 1,
|
||||
@@ -350,9 +566,8 @@ export function HallBettingGrid() {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}, [activeCategory, d4Columns, rows, simplePlay]);
|
||||
}, [activeCategory, categoryPlays, rows]);
|
||||
|
||||
const draftEntries = collectEntries();
|
||||
const draftSummary = useMemo(() => {
|
||||
@@ -369,9 +584,14 @@ export function HallBettingGrid() {
|
||||
);
|
||||
}, [draftEntries]);
|
||||
|
||||
const buildLines = (): TicketLineInput[] => {
|
||||
return collectEntries().map((entry) => entry.line);
|
||||
};
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
setDebouncedSummary(draftSummary);
|
||||
}, 300);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [draftSummary]);
|
||||
|
||||
const buildLines = (): TicketLineInput[] => collectEntries().map((entry) => entry.line);
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!display) {
|
||||
@@ -398,11 +618,18 @@ export function HallBettingGrid() {
|
||||
const data = await postTicketPreview({
|
||||
draw_id: display.draw_no,
|
||||
currency_code: currencyCode,
|
||||
client_trace_id: `pv-${typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : String(Date.now())}`,
|
||||
client_trace_id: `pv-${
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now())
|
||||
}`,
|
||||
lines,
|
||||
});
|
||||
setPreviewData(data);
|
||||
setPreviewOpen(true);
|
||||
rows.forEach((row) => {
|
||||
if (row.number.trim()) pushHistory(row.number.trim());
|
||||
});
|
||||
} catch (e) {
|
||||
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.previewFailed");
|
||||
@@ -437,17 +664,21 @@ export function HallBettingGrid() {
|
||||
lines,
|
||||
expected_config_versions: previewData.config_versions,
|
||||
});
|
||||
setPreviewOpen(false);
|
||||
setPreviewData(null);
|
||||
setResultData(data);
|
||||
setResultOpen(true);
|
||||
setRows([newDraftRow()]);
|
||||
setActiveRowId(null);
|
||||
triggerWalletPollingAfterBet();
|
||||
void refreshWallet();
|
||||
void reloadDraw();
|
||||
toast.success(
|
||||
t("hall.placeSuccess", {
|
||||
orderNo: data.order_no,
|
||||
amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode),
|
||||
}),
|
||||
);
|
||||
setPreviewOpen(false);
|
||||
setPreviewData(null);
|
||||
setRows([newDraftRow()]);
|
||||
triggerWalletPollingAfterBet();
|
||||
void reloadDraw();
|
||||
} catch (e) {
|
||||
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed");
|
||||
@@ -457,6 +688,12 @@ export function HallBettingGrid() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onRefresh = () => void refreshWallet();
|
||||
window.addEventListener("lottery-wallet-refresh", onRefresh);
|
||||
return () => window.removeEventListener("lottery-wallet-refresh", onRefresh);
|
||||
}, [refreshWallet]);
|
||||
|
||||
if (catalogState.kind === "loading") {
|
||||
return (
|
||||
<section className="space-y-3" aria-label={t("hall.aria")}>
|
||||
@@ -484,100 +721,44 @@ export function HallBettingGrid() {
|
||||
);
|
||||
}
|
||||
|
||||
const simplePlayCode = simplePlay?.play_code ?? "";
|
||||
const numberPlaceholder =
|
||||
activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
|
||||
const canSubmit = !tableDisabled && draftEntries.length > 0 && availableMinor >= debouncedSummary.actual;
|
||||
const favoriteChips = favorites.slice(0, 10);
|
||||
const historyChips = historyNumbers.slice(0, 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4" aria-label={t("hall.aria")}>
|
||||
<div className="grid grid-cols-4 rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
|
||||
{categoryTabs.map((tab) => {
|
||||
const active = activeCategory === tab.value;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveCategory(tab.value);
|
||||
setRows((current) =>
|
||||
current.map((row) => ({
|
||||
...row,
|
||||
number: sanitizeNumber(row.number, tab.value),
|
||||
})),
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex h-10 min-w-0 items-center justify-center rounded-lg text-sm font-semibold transition-colors",
|
||||
active
|
||||
? "bg-[#07459f] text-white shadow-[inset_0_-2px_0_rgba(255,255,255,0.28)]"
|
||||
: "text-[#4b5563] hover:bg-[#f4f7fb]",
|
||||
tab.value === "JACKPOT" && active && "bg-[#7b8492]",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeCategory === "D4" ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBoxMode("ibox")}
|
||||
disabled={tableDisabled}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 rounded-lg border bg-white px-3 py-2 text-left shadow-sm transition-colors",
|
||||
boxMode === "ibox"
|
||||
? "border-[#b9ccf6] text-[#07459f]"
|
||||
: "border-[#f2b4bc] text-[#e11d48]",
|
||||
tableDisabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full text-white",
|
||||
boxMode === "ibox" ? "bg-[#0956c8]" : "bg-[#ff4158]",
|
||||
)}
|
||||
>
|
||||
<Cuboid className="size-4" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-bold">
|
||||
{t("hall.boxMode.iboxTitle")}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-slate-500">
|
||||
{t("hall.boxMode.iboxDesc")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBoxMode("box")}
|
||||
disabled={tableDisabled}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 rounded-lg border bg-white px-3 py-2 text-left shadow-sm transition-colors",
|
||||
boxMode === "box"
|
||||
? "border-[#f2b4bc] text-[#e11d48]"
|
||||
: "border-[#e8eef7] text-[#ef4056]",
|
||||
tableDisabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[#ff4158] text-white">
|
||||
<PackageOpen className="size-4" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-bold">
|
||||
{t("hall.boxMode.boxTitle")}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-slate-500">
|
||||
{t("hall.boxMode.boxDesc")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{categoryTabs.map((tab) => {
|
||||
const active = activeCategory === tab.value;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveCategory(tab.value);
|
||||
setRows((current) =>
|
||||
current.map((row) => ({
|
||||
...row,
|
||||
number: sanitizeNumber(row.number, tab.value),
|
||||
})),
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex h-10 min-w-0 items-center justify-center rounded-lg text-sm font-semibold transition-colors",
|
||||
active
|
||||
? "bg-[#07459f] text-white shadow-[inset_0_-2px_0_rgba(255,255,255,0.28)]"
|
||||
: "text-[#4b5563] hover:bg-[#f4f7fb]",
|
||||
tab.value === "JACKPOT" && active && "bg-[#7b8492]",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activeCategory === "JACKPOT" ? (
|
||||
<div className="rounded-xl border border-[#edf1f7] bg-[#f7f9fc] p-7 text-center text-slate-500">
|
||||
@@ -593,174 +774,316 @@ export function HallBettingGrid() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)] transition-opacity",
|
||||
tableDisabled && "opacity-55",
|
||||
<>
|
||||
<div className="space-y-3 rounded-xl border border-[#e9eef7] bg-white p-4 shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{t("hall.quickFill.title", { defaultValue: "快速填单" })}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("hall.quickFill.description", {
|
||||
defaultValue: "收藏号码、最近号码可一键填入当前行。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={clearAllRows}>
|
||||
{t("hall.quickFill.clearAll", { defaultValue: "批量清空" })}
|
||||
</Button>
|
||||
{activeRow?.number ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleFavoriteNumber(activeRow.number)}
|
||||
>
|
||||
<Star className="mr-1 size-4" />
|
||||
{favorites.includes(activeRow.number)
|
||||
? t("hall.quickFill.unfavorite", { defaultValue: "取消收藏" })
|
||||
: t("hall.quickFill.favorite", { defaultValue: "收藏号码" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-slate-400">
|
||||
{t("hall.quickFill.favorites", { defaultValue: "收藏" })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{favoriteChips.length > 0 ? (
|
||||
favoriteChips.map((number) => (
|
||||
<button
|
||||
key={`fav-${number}`}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-3 py-1 text-xs font-medium text-[#d81435]"
|
||||
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-[10px] opacity-70">
|
||||
{t("hall.quickFill.tapHold", { defaultValue: "长按取消" })}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">
|
||||
{t("hall.quickFill.emptyFavorites", { defaultValue: "暂无收藏" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-slate-400">
|
||||
{t("hall.quickFill.history", { defaultValue: "最近 20 个历史号码" })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{historyChips.length > 0 ? (
|
||||
historyChips.map((number) => (
|
||||
<button
|
||||
key={`his-${number}`}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-[#dce7f7] bg-[#f8fbff] px-3 py-1 text-xs font-medium text-[#07459f]"
|
||||
onClick={() => fillCurrentRow(number)}
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">
|
||||
{t("hall.quickFill.emptyHistory", { defaultValue: "暂无历史号码" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categoryPlays.length > 0 ? (
|
||||
<p className="flex items-start justify-center gap-2 rounded-lg border border-[#c8daf6] bg-[#f0f6ff] px-3 py-2.5 text-left text-[11px] font-medium leading-snug text-[#0b3f96]">
|
||||
<ChevronRight className="mt-0.5 size-4 shrink-0 text-[#1d57b7]" aria-hidden />
|
||||
<span>{t("hall.table.scrollHint")}</span>
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-center text-xs text-amber-950"
|
||||
role="status"
|
||||
>
|
||||
{t("hall.table.noPlaysInCategory")}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className={cn("w-full border-collapse text-sm", activeCategory === "D4" ? "min-w-[760px]" : "min-w-[460px]")}>
|
||||
<thead>
|
||||
<tr className="border-b border-[#e8eef7] bg-[#f5f8fd] text-[11px] font-semibold text-[#32518d]">
|
||||
<th className="sticky left-0 z-20 w-12 bg-[#f5f8fd] px-2 py-3 text-center">
|
||||
{t("hall.table.no")}
|
||||
</th>
|
||||
<th className="sticky left-12 z-20 w-24 bg-[#f5f8fd] px-2 py-3 text-center">
|
||||
{t("hall.table.number")}
|
||||
<span className="block text-[10px] font-normal text-[#6b7896]">
|
||||
({numberPlaceholder})
|
||||
</span>
|
||||
</th>
|
||||
{activeCategory === "D4" ? (
|
||||
d4Columns.map((play) => (
|
||||
<th key={play.play_code} className="min-w-20 px-2 py-3 text-center">
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)] transition-opacity",
|
||||
tableDisabled && "opacity-55",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className={cn(
|
||||
"w-full border-collapse text-sm",
|
||||
activeCategory === "D4" ? "min-w-[1240px]" : "min-w-[740px]",
|
||||
)}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-[#e8eef7] bg-[#f5f8fd] text-[11px] font-semibold text-[#32518d]">
|
||||
<th className="sticky left-0 z-20 w-12 bg-[#f5f8fd] px-2 py-3 text-center">
|
||||
{t("hall.table.no", { defaultValue: "No." })}
|
||||
</th>
|
||||
<th className="sticky left-12 z-20 w-24 bg-[#f5f8fd] px-2 py-3 text-center">
|
||||
{t("hall.table.number", { defaultValue: "Number" })}
|
||||
<span className="block text-[10px] font-normal text-[#6b7896]">
|
||||
({numberPlaceholder})
|
||||
</span>
|
||||
</th>
|
||||
{categoryPlays.map((play) => (
|
||||
<th key={play.play_code} className="min-w-24 px-2 py-3 text-center">
|
||||
{pickDisplayName(play)}
|
||||
</th>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<th className="min-w-28 px-2 py-3 text-center">
|
||||
{t("hall.table.stake")}
|
||||
</th>
|
||||
<th className="min-w-28 px-2 py-3 text-center">
|
||||
{t("hall.table.rebate")}
|
||||
</th>
|
||||
<th className="min-w-28 px-2 py-3 text-center">
|
||||
{t("hall.table.actual")}
|
||||
</th>
|
||||
</>
|
||||
)}
|
||||
<th className="w-10 px-2 py-3" aria-label={t("hall.table.delete")} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const simpleAmount = parseDecimalInputToMinor(
|
||||
row.amounts[simplePlayCode] ?? "",
|
||||
);
|
||||
const simpleRebate =
|
||||
simpleAmount !== null && simplePlay
|
||||
? Math.round(simpleAmount * parseRebateRate(simplePlay.odds?.rebate_rate))
|
||||
: 0;
|
||||
const simpleActual =
|
||||
simpleAmount !== null ? Math.max(0, simpleAmount - simpleRebate) : 0;
|
||||
))}
|
||||
<th className="w-10 px-2 py-3" aria-label={t("hall.table.delete")} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const rowKey = row.id;
|
||||
return (
|
||||
<tr key={rowKey} className="border-b border-[#eef2f8] last:border-b-0">
|
||||
<td className="sticky left-0 z-10 bg-white px-2 py-3 text-center font-semibold text-[#17408d]">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="sticky left-12 z-10 bg-white px-2 py-3">
|
||||
<Input
|
||||
value={row.number}
|
||||
disabled={tableDisabled}
|
||||
inputMode="text"
|
||||
placeholder={numberPlaceholder}
|
||||
onFocus={() => setActiveRowId(row.id)}
|
||||
onClick={() => setActiveRowId(row.id)}
|
||||
onChange={(event) => updateRowNumber(row.id, event.target.value)}
|
||||
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center font-mono text-sm font-semibold text-slate-900 shadow-sm"
|
||||
/>
|
||||
</td>
|
||||
{categoryPlays.map((play) => {
|
||||
const amountText = row.amounts[play.play_code] ?? "";
|
||||
const amountMinor = parseDecimalInputToMinor(amountText);
|
||||
const rebate = amountMinor != null
|
||||
? Math.round(amountMinor * parseRebateRate(play.odds?.rebate_rate))
|
||||
: 0;
|
||||
const actual = amountMinor != null ? Math.max(0, amountMinor - rebate) : 0;
|
||||
const status = cellRiskState(
|
||||
play,
|
||||
row.number,
|
||||
activeCategory as Exclude<HallCategory, "JACKPOT">,
|
||||
alertRows,
|
||||
);
|
||||
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-b border-[#eef2f8] last:border-b-0"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white px-2 py-3 text-center font-semibold text-[#17408d]">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="sticky left-12 z-10 bg-white px-2 py-3">
|
||||
<Input
|
||||
value={row.number}
|
||||
disabled={tableDisabled}
|
||||
inputMode="numeric"
|
||||
placeholder={numberPlaceholder}
|
||||
onChange={(event) => updateRowNumber(row.id, event.target.value)}
|
||||
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center font-mono text-sm font-semibold text-slate-900 shadow-sm"
|
||||
/>
|
||||
</td>
|
||||
{activeCategory === "D4" ? (
|
||||
d4Columns.map((play) => (
|
||||
<td key={play.play_code} className="px-1.5 py-3">
|
||||
<Input
|
||||
value={row.amounts[play.play_code] ?? ""}
|
||||
disabled={tableDisabled}
|
||||
inputMode="decimal"
|
||||
placeholder="-"
|
||||
onChange={(event) =>
|
||||
updateAmount(row.id, play.play_code, event.target.value)
|
||||
}
|
||||
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-xs tabular-nums shadow-sm"
|
||||
/>
|
||||
</td>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<td className="px-2 py-3">
|
||||
<Input
|
||||
value={row.amounts[simplePlayCode] ?? ""}
|
||||
disabled={tableDisabled || !simplePlay}
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
onChange={(event) =>
|
||||
updateAmount(row.id, simplePlayCode, event.target.value)
|
||||
}
|
||||
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-sm tabular-nums shadow-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-3 text-center leading-tight">
|
||||
<span className="block font-semibold tabular-nums text-[#34a853]">
|
||||
-{simpleRebate > 0 ? amountToDisplay(simpleRebate) : "0.00"}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
({formatRatePercent(simplePlay?.odds?.rebate_rate)})
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-3 text-center tabular-nums text-slate-700">
|
||||
{simpleActual > 0 ? amountToDisplay(simpleActual) : "0.00"}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className="px-1 py-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled || rows.length <= 1}
|
||||
onClick={() => removeRow(row.id)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-[#ff4d4f] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
|
||||
aria-label={t("actions.deleteRow", { row: index + 1 })}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
return (
|
||||
<td
|
||||
key={`${rowKey}-${play.play_code}`}
|
||||
className={cn("px-1.5 py-3", tableCellClass(status, disabled))}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={amountText}
|
||||
disabled={disabled}
|
||||
inputMode="decimal"
|
||||
placeholder={
|
||||
status === "sold_out"
|
||||
? t("hall.table.soldOut", { defaultValue: "售罄" })
|
||||
: "-"
|
||||
}
|
||||
onFocus={() => setActiveRowId(row.id)}
|
||||
onClick={() => setActiveRowId(row.id)}
|
||||
onChange={(event) => updateAmount(row.id, play.play_code, event.target.value)}
|
||||
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-xs tabular-nums shadow-sm"
|
||||
/>
|
||||
{status === "sold_out" ? (
|
||||
<p className="text-center text-[10px] font-semibold text-slate-500">
|
||||
{t("hall.table.soldOut", { defaultValue: "售罄" })}
|
||||
</p>
|
||||
) : status === "warning" ? (
|
||||
<p className="text-center text-[10px] font-semibold text-amber-600">
|
||||
{t("hall.table.warning", { defaultValue: "接近售罄" })}
|
||||
</p>
|
||||
) : activeCategory !== "D4" && amountMinor !== null ? (
|
||||
<p className="text-center text-[10px] text-slate-500">
|
||||
{t("hall.table.actual", { defaultValue: "实扣" })}{" "}
|
||||
{amountToDisplay(actual)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-1 py-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled || rows.length <= 1}
|
||||
onClick={() => removeRow(row.id)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-[#ff4d4f] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
|
||||
aria-label={t("actions.deleteRow", { row: index + 1 })}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled || rows.length >= MAX_ROWS}
|
||||
onClick={addRow}
|
||||
className="flex h-11 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] text-sm font-semibold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
|
||||
>
|
||||
<CirclePlus className="size-4" aria-hidden />
|
||||
{t("hall.table.addRow", { defaultValue: "添加一行" })}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
|
||||
<div className="grid gap-3 rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-4 py-3 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)] md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500">
|
||||
{t("hall.summary.expectedDeduct", { defaultValue: "预计扣款" })}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-bold tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500">
|
||||
{t("hall.summary.rebate", { defaultValue: "回水" })}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-bold tabular-nums text-emerald-600">
|
||||
{formatMinorAsCurrency(debouncedSummary.rebate, currencyCode)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500">
|
||||
{t("hall.summary.total", { defaultValue: "合计" })}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-black tabular-nums text-[#d81435]">
|
||||
{formatMinorAsCurrency(debouncedSummary.bet, currencyCode)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sealedBetUi ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
|
||||
{t("hall.table.sealedHint")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={tableDisabled || rows.length >= MAX_ROWS}
|
||||
onClick={addRow}
|
||||
className="flex h-11 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] text-sm font-semibold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
|
||||
disabled={!canSubmit || previewLoading}
|
||||
onClick={() => void handlePreview()}
|
||||
className="h-12 w-full rounded-xl border-0 bg-[#e5002c] text-base font-bold text-white shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]"
|
||||
>
|
||||
<CirclePlus className="size-4" aria-hidden />
|
||||
{t("hall.table.addRow")}
|
||||
</button>
|
||||
</div>
|
||||
<Ticket className="size-5" aria-hidden />
|
||||
{previewLoading
|
||||
? t("hall.table.previewing", { defaultValue: "预览中..." })
|
||||
: !isBettable
|
||||
? t("hall.closed.title")
|
||||
: availableMinor < debouncedSummary.actual
|
||||
? t("hall.table.insufficientBalance", { defaultValue: "余额不足" })
|
||||
: t("hall.table.submitBet", { defaultValue: "提交下注" })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-4 py-3 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)]">
|
||||
<span className="font-medium text-slate-800">{t("hall.table.draftTotal")}</span>
|
||||
<span className="text-lg font-bold tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(draftSummary.actual, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sealedBetUi ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
|
||||
{t("hall.table.sealedHint")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={tableDisabled || previewLoading || draftEntries.length === 0}
|
||||
onClick={() => void handlePreview()}
|
||||
className="h-12 w-full rounded-xl border-0 bg-[#e5002c] text-base font-bold text-white shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]"
|
||||
>
|
||||
<Ticket className="size-5" aria-hidden />
|
||||
{previewLoading
|
||||
? t("hall.table.previewing")
|
||||
: activeCategory === "JACKPOT"
|
||||
? t("hall.closed.title")
|
||||
: t("hall.table.submitBet")}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<HallBetPreviewDialog
|
||||
@@ -772,9 +1095,19 @@ export function HallBettingGrid() {
|
||||
currencyCode={currencyCode}
|
||||
data={previewData}
|
||||
placing={placeLoading}
|
||||
allowSubmit={isBettable}
|
||||
allowSubmit={isBettable && availableMinor >= debouncedSummary.actual}
|
||||
onConfirmPlace={() => void handlePlace()}
|
||||
/>
|
||||
|
||||
<HallBetResultDialog
|
||||
open={resultOpen}
|
||||
onOpenChange={(open) => {
|
||||
setResultOpen(open);
|
||||
if (!open) setResultData(null);
|
||||
}}
|
||||
currencyCode={currencyCode}
|
||||
data={resultData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Hourglass, Landmark, TimerReset, WalletCards } from "lucide-react";
|
||||
import { Hourglass, Landmark, TimerReset } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { drawStatusHud, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
||||
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||
import { formatSecondsClock } from "@/lib/format-gmt";
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -69,8 +69,8 @@ function CloseTime({
|
||||
);
|
||||
}
|
||||
|
||||
export function HallDrawPanel() {
|
||||
const { raw, display, error, reload } = useHallDrawLive();
|
||||
export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot }) {
|
||||
const { raw, display, error, reload } = drawLive;
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
if (error) {
|
||||
@@ -122,13 +122,10 @@ export function HallDrawPanel() {
|
||||
aria-label={t("draw.currentIssue")}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_1.05fr_1fr] divide-x divide-[#e7edf6]">
|
||||
<div className="flex min-w-0 items-center justify-center gap-2 px-2 py-3 text-center">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[#eef4ff] text-[#0b56b7]">
|
||||
<WalletCards className="size-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<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">
|
||||
<p className="text-[11px] font-semibold text-slate-500">{t("draw.issueNo")}</p>
|
||||
<p className="truncate text-sm font-black tabular-nums text-[#ff143d]">
|
||||
<p className="whitespace-nowrap text-xs font-black tabular-nums text-[#ff143d] sm:text-sm">
|
||||
{display.draw_no}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
||||
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
||||
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
||||
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||
|
||||
/**
|
||||
* 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。
|
||||
*/
|
||||
export function HallScreen() {
|
||||
const { t } = useTranslation("common");
|
||||
const drawLive = useHallDrawLive();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[480px]">
|
||||
<section className="overflow-hidden bg-white px-4 pb-8 pt-4 text-slate-900">
|
||||
<div className="mb-3 flex items-center gap-2 px-1 pt-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="relative flex size-10 shrink-0 rotate-[-10deg] items-center justify-center rounded-lg bg-[#e60023] text-white shadow-[0_7px_14px_rgba(230,0,35,0.22)]">
|
||||
<span className="absolute -left-1.5 top-1 flex size-6 rotate-[18deg] items-center justify-center rounded-sm bg-[#0b56b7] text-xs font-black">
|
||||
N
|
||||
</span>
|
||||
<span className="ml-4 text-lg font-black italic">N</span>
|
||||
</div>
|
||||
<div className="min-w-0 text-[28px] font-black italic leading-none tracking-normal">
|
||||
<span className="text-[#ed001c]">N</span>{" "}
|
||||
<span className="text-[#0a3f94]">lotto</span>
|
||||
<div className="mb-2 flex items-center gap-1 px-1 pt-2">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<div className="inline-flex min-w-0 items-center">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Nlotto"
|
||||
width={243}
|
||||
height={84}
|
||||
className="h-9 w-auto max-w-[min(100%,220px)] object-contain object-left"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<LanguageSwitcher
|
||||
@@ -37,19 +40,19 @@ export function HallScreen() {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex size-9 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
|
||||
className="relative flex size-8 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
|
||||
aria-label={t("navigation.notifications")}
|
||||
>
|
||||
<Bell className="size-5" aria-hidden />
|
||||
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" />
|
||||
<Bell className="size-4" aria-hidden />
|
||||
<span className="absolute right-1.5 top-1.5 size-2 rounded-full bg-[#ff143d]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<HallDrawPanel />
|
||||
<HallDrawPanel drawLive={drawLive} />
|
||||
|
||||
<HallWalletStrip />
|
||||
|
||||
<HallBettingGrid />
|
||||
<HallBettingGrid drawLive={drawLive} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,15 @@ import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
||||
import type { DrawCurrentPayload } from "@/types/api/draw-current";
|
||||
|
||||
/** 大厅共享的当期快照(由 {@link useHallDrawLive} 产出,供期号条与下注表共用)。 */
|
||||
export type HallDrawLiveSnapshot = {
|
||||
raw: DrawCurrentPayload | null | undefined;
|
||||
display: DrawCurrentPayload | null | undefined;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
isBettable: boolean;
|
||||
};
|
||||
|
||||
/** 界面文档 §2.1:`draw.countdown` / `draw.status_change` / `result.published` 载荷 */
|
||||
export type HallWsEnvelope = {
|
||||
data: DrawCurrentPayload | null;
|
||||
@@ -34,16 +43,10 @@ function applySnapshotDrift(
|
||||
}
|
||||
|
||||
/**
|
||||
* 大厅期号:WebSocket `lottery-hall` + 轮询降级(与 {@link HallDrawPanel} 同源逻辑)。
|
||||
* 大厅期号:WebSocket `lottery-hall` + 轮询降级;由 {@link HallScreen} 调用一次,注入 {@link HallDrawPanel} 与 {@link HallBettingGrid}。
|
||||
* 已集成网络连接管理,WebSocket断开时自动切换到轮询模式。
|
||||
*/
|
||||
export function useHallDrawLive(): {
|
||||
raw: DrawCurrentPayload | null | undefined;
|
||||
display: DrawCurrentPayload | null | undefined;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
isBettable: boolean;
|
||||
} {
|
||||
export function useHallDrawLive(): HallDrawLiveSnapshot {
|
||||
const [raw, setRaw] = useState<DrawCurrentPayload | null | undefined>(undefined);
|
||||
const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now());
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getTicketItemDetail } from "@/api/ticket-items";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import {
|
||||
Card,
|
||||
@@ -74,7 +74,7 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
subtitle={ticketNo}
|
||||
eyebrow={t("orders.title")}
|
||||
eyebrow={t("brand.name")}
|
||||
backHref="/orders"
|
||||
backLabel={t("orders.title")}
|
||||
>
|
||||
@@ -91,13 +91,18 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
subtitle={ticketNo}
|
||||
eyebrow={t("orders.title")}
|
||||
eyebrow={t("brand.name")}
|
||||
backHref="/orders"
|
||||
backLabel={t("orders.title")}
|
||||
>
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||
<p>{error ?? t("orders.noData")}</p>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -145,72 +150,87 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
subtitle={data.ticket_no}
|
||||
eyebrow={t("orders.title")}
|
||||
eyebrow={t("brand.name")}
|
||||
backHref="/orders"
|
||||
backLabel={t("orders.title")}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<CardHeader className="space-y-2 pb-2">
|
||||
<Card className="ring-0 border border-[#e8eef7] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
|
||||
<CardHeader className="space-y-2 border-b border-[#edf2f9] pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">{t("orders.detailTitle")}</CardTitle>
|
||||
<CardTitle className="text-base font-black text-[#0b3f96]">
|
||||
{t("orders.detailTitle")}
|
||||
</CardTitle>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
<CardDescription className="font-mono text-[11px] leading-relaxed text-slate-500">
|
||||
{t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "}
|
||||
{t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid gap-1 text-xs">
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.drawNo")}</span>{" "}
|
||||
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.placedAt")}</span>{" "}
|
||||
{formatLotteryInstant(data.placed_at ?? null)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.number")}</span>{" "}
|
||||
<span className="font-mono">{data.original_number ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.play")}</span>{" "}
|
||||
{playLabel(data.play_code, t)} (
|
||||
{data.dimension ?? "—"}D)
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.amount")}</span>{" "}
|
||||
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.rebateRate")}</span>{" "}
|
||||
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.actualDeduct")}</span>{" "}
|
||||
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<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">
|
||||
{formatLotteryInstant(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("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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
||||
<p className="font-medium text-foreground">{t("orders.oddsSnapshot")}</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{formatOddsSnapshot(data.odds_snapshot_json, t)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pub?.results ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{t("orders.drawNumbers")}</p>
|
||||
<p className="text-sm font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
||||
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
|
||||
{first ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("orders.firstPrize")}{" "}
|
||||
<span className="font-mono font-semibold text-foreground">{first}</span>
|
||||
<span className="font-mono font-semibold text-slate-900">{first}</span>
|
||||
{comboHits.length > 0 ? (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
<span className="font-semibold text-emerald-600">
|
||||
{" "}
|
||||
← {t("orders.hit")}
|
||||
</span>
|
||||
@@ -219,15 +239,17 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t("orders.notPublished")}</p>
|
||||
<p className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-2 text-xs text-[#32518d]">
|
||||
{t("orders.notPublished")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.settlement && tierLabel ? (
|
||||
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs">
|
||||
<p className="font-medium text-emerald-900 dark:text-emerald-100">
|
||||
<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-muted-foreground">
|
||||
<p className="mt-1 font-mono text-emerald-800/90">
|
||||
{t("orders.winAmount", {
|
||||
amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur),
|
||||
})}
|
||||
@@ -244,32 +266,39 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-muted-foreground">
|
||||
<p className="mt-1 font-mono font-semibold text-emerald-900">
|
||||
{t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||
</p>
|
||||
</div>
|
||||
) : data.status === "settled_lose" ? (
|
||||
<p className="text-xs text-muted-foreground">{t("orders.matchLose")}</p>
|
||||
<p className="text-xs text-slate-500">{t("orders.matchLose")}</p>
|
||||
) : null}
|
||||
|
||||
{data.settled_at ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{t("orders.settledAt", { time: formatLotteryInstant(data.settled_at) })}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
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="/orders" className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}>
|
||||
<Link
|
||||
href="/orders"
|
||||
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>
|
||||
|
||||
@@ -121,9 +121,14 @@
|
||||
"delete": "Delete",
|
||||
"addRow": "Add Row",
|
||||
"draftTotal": "Draft Total",
|
||||
"totalBet": "Total",
|
||||
"totalRebate": "Rebate",
|
||||
"actualTotal": "Estimated Deduction",
|
||||
"sealedHint": "Closed: this table is locked. Please wait for the next issue.",
|
||||
"previewing": "Previewing...",
|
||||
"submitBet": "Submit Bet"
|
||||
"submitBet": "Submit Bet",
|
||||
"scrollHint": "This table is wide: swipe or scroll horizontally, then enter your stake in each play column on the right (e.g. Big/Small, position plays).",
|
||||
"noPlaysInCategory": "No open play types in this tab, so there are no amount fields. Try 2D / 3D / 4D, or ask an admin to enable plays for this category."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Confirm bet",
|
||||
|
||||
@@ -121,9 +121,14 @@
|
||||
"delete": "हटाउनुहोस्",
|
||||
"addRow": "पंक्ति थप्नुहोस्",
|
||||
"draftTotal": "ड्राफ्ट जम्मा",
|
||||
"totalBet": "जम्मा",
|
||||
"totalRebate": "रिबेट",
|
||||
"actualTotal": "अनुमानित कट्टा",
|
||||
"sealedHint": "बन्द: यो तालिका लक छ। कृपया अर्को इश्यू पर्खनुहोस्।",
|
||||
"previewing": "पूर्वावलोकन...",
|
||||
"submitBet": "बेट पेश गर्नुहोस्"
|
||||
"submitBet": "बेट पेश गर्नुहोस्",
|
||||
"scrollHint": "तालिका फराकिलो छ: दायाँतिर स्क्रोल गर्नुहोस्, दायाँका प्रत्येक खेल स्तम्भमा बेट रकम लेख्नुहोस्।",
|
||||
"noPlaysInCategory": "यस ट्याबमा खुला खेल प्रकार छैन। २D / ३D / ४D प्रयास गर्नुहोस् वा व्यवस्थापकले खेल खोल्नुपर्छ।"
|
||||
},
|
||||
"preview": {
|
||||
"title": "बेट पुष्टि गर्नुहोस्",
|
||||
|
||||
@@ -121,9 +121,14 @@
|
||||
"delete": "删除",
|
||||
"addRow": "添加一行",
|
||||
"draftTotal": "草稿合计",
|
||||
"totalBet": "合计",
|
||||
"totalRebate": "回水",
|
||||
"actualTotal": "预计扣款",
|
||||
"sealedHint": "已封盘:当前表格不可编辑,请等待下一期。",
|
||||
"previewing": "预览中...",
|
||||
"submitBet": "提交下注"
|
||||
"submitBet": "提交下注",
|
||||
"scrollHint": "表格较宽:请向右滑动,在右侧各玩法列(如 Big / Small、位置玩法等)输入下注金额。",
|
||||
"noPlaysInCategory": "当前分类没有已开放的玩法,无法填写金额。请尝试切换 2D / 3D / 4D,或在后台开放对应玩法。"
|
||||
},
|
||||
"preview": {
|
||||
"title": "确认下注",
|
||||
|
||||
@@ -9,6 +9,16 @@ export type DrawCurrentResultItem = {
|
||||
tail_digit: number | null;
|
||||
};
|
||||
|
||||
export type DrawCurrentRiskPoolAlert = {
|
||||
normalized_number: string;
|
||||
total_cap_amount: number;
|
||||
locked_amount: number;
|
||||
remaining_amount: number;
|
||||
sold_out_status: number;
|
||||
is_sold_out: boolean;
|
||||
usage_ratio: number | null;
|
||||
};
|
||||
|
||||
export type DrawCurrentPayload = {
|
||||
draw_no: string;
|
||||
business_date: string;
|
||||
@@ -21,6 +31,7 @@ export type DrawCurrentPayload = {
|
||||
seconds_to_draw: number;
|
||||
cooling_end_time: string | null;
|
||||
seconds_remaining_in_cooldown: number | null;
|
||||
risk_pool_alerts?: DrawCurrentRiskPoolAlert[];
|
||||
result_items?: DrawCurrentResultItem[];
|
||||
result_version?: number;
|
||||
result_source?: string | null;
|
||||
|
||||
@@ -72,5 +72,6 @@ export type TicketPlaceData = {
|
||||
order_no: string;
|
||||
draw: { draw_id: string; status: string };
|
||||
summary: TicketPreviewData["summary"];
|
||||
balance_after: number;
|
||||
items: TicketPlaceItem[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user