feat: 优化大厅下注结果与风险提示展示,重构期号与订单详情样式

This commit is contained in:
2026-05-15 15:31:00 +08:00
parent f2c7f5e4f1
commit a83920aa2a
13 changed files with 1078 additions and 492 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 369 KiB

View File

@@ -33,7 +33,7 @@ function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) {
if (warnings.length === 0) return null; if (warnings.length === 0) return null;
return ( 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 /> <AlertTriangleIcon />
<AlertTitle>{t("hall.preview.warningsTitle")}</AlertTitle> <AlertTitle>{t("hall.preview.warningsTitle")}</AlertTitle>
<AlertDescription className="space-y-1"> <AlertDescription className="space-y-1">
@@ -70,16 +70,18 @@ export function HallBetPreviewDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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"> <div className="p-4 pb-2">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("hall.preview.title")}</DialogTitle> <DialogTitle className="text-lg font-black text-[#0b3f96]">
<DialogDescription> {t("hall.preview.title")}
</DialogTitle>
<DialogDescription className="text-xs leading-relaxed text-slate-500">
{t("hall.preview.description")} {t("hall.preview.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{!allowSubmit ? ( {!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 /> <AlertTriangleIcon />
<AlertTitle>{t("hall.preview.sealedTitle")}</AlertTitle> <AlertTitle>{t("hall.preview.sealedTitle")}</AlertTitle>
<AlertDescription className="text-xs leading-relaxed"> <AlertDescription className="text-xs leading-relaxed">
@@ -89,42 +91,42 @@ export function HallBetPreviewDialog({
) : null} ) : null}
</div> </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"> <div className="space-y-4 py-3 pr-3">
{!data ? ( {!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"> <div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] p-3 text-xs shadow-[0_4px_14px_rgba(15,23,42,0.04)]">
<p> <p className="text-slate-600">
{t("hall.preview.draw")}{" "} {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")}{" "} {t("hall.preview.status")}{" "}
<span className="font-medium">{data.draw.status}</span> <span className="font-semibold text-[#32518d]">{data.draw.status}</span>
</p> </p>
{summary ? ( {summary ? (
<ul className="mt-2 space-y-1 tabular-nums"> <ul className="mt-2 space-y-1 tabular-nums text-slate-700">
<li> <li>
{t("hall.preview.totalBet")}{" "} {t("hall.preview.totalBet")}{" "}
<span className="font-medium"> <span className="font-semibold text-[#d81435]">
{formatMinorAsCurrency(summary.total_bet_amount, currencyCode)} {formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
</span> </span>
</li> </li>
<li> <li>
{t("hall.preview.rebateDeduct")}{" "} {t("hall.preview.rebateDeduct")}{" "}
<span className="font-medium"> <span className="font-semibold text-emerald-600">
{formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)} {formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)}
</span> </span>
</li> </li>
<li> <li>
{t("hall.preview.actualDeduct")}{" "} {t("hall.preview.actualDeduct")}{" "}
<span className="font-semibold text-primary"> <span className="font-bold text-[#0b3f96]">
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)} {formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
</span> </span>
</li> </li>
<li> <li>
{t("hall.preview.estimatedPayout")}{" "} {t("hall.preview.estimatedPayout")}{" "}
<span className="font-medium"> <span className="font-medium text-slate-800">
{formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)} {formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)}
</span> </span>
</li> </li>
@@ -135,39 +137,41 @@ export function HallBetPreviewDialog({
<WarningsBlock warnings={data.warnings} /> <WarningsBlock warnings={data.warnings} />
<div className="space-y-2"> <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")} {t("hall.preview.lines")}
</p> </p>
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
{lines.map((ln) => ( {lines.map((ln) => (
<li <li
key={ln.client_line_no} 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"> <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} #{ln.client_line_no}
</span> </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> </div>
<p className="mt-1 font-mono text-base">{ln.number}</p> <p className="mt-1 font-mono text-lg font-black text-[#0b3f96]">{ln.number}</p>
<Separator className="my-2" /> <Separator className="my-2 bg-[#e8eef7]" />
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs tabular-nums"> <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")} {t("hall.preview.normalizedNumber")}
</span> </span>
<span className="text-right font-mono">{ln.normalized_number}</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")} {t("hall.preview.combinationCount")}
</span> </span>
<span className="text-right">{ln.combination_count}</span> <span className="text-right">{ln.combination_count}</span>
<span className="text-muted-foreground"> <span className="text-slate-500">
{t("hall.preview.actual")} {t("hall.preview.actual")}
</span> </span>
<span className="text-right"> <span className="text-right font-semibold text-[#0b3f96]">
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)} {formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
</span> </span>
<span className="text-muted-foreground"> <span className="text-slate-500">
{t("hall.preview.estimatedMax")} {t("hall.preview.estimatedMax")}
</span> </span>
<span className="text-right"> <span className="text-right">
@@ -183,11 +187,22 @@ export function HallBetPreviewDialog({
</div> </div>
</ScrollArea> </ScrollArea>
<div className="flex flex-col-reverse gap-2 border-t bg-muted/30 p-4 sm:flex-row sm:justify-between"> <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}> <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")} {t("hall.preview.backEdit")}
</Button> </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 {placing
? t("hall.preview.submitting") ? t("hall.preview.submitting")
: allowSubmit : allowSubmit

View 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>
);
}

View File

@@ -1,37 +1,39 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronRight, CirclePlus, Ticket, Trash2, Star } from "lucide-react";
import { CirclePlus, Cuboid, PackageOpen, Ticket, Trash2 } from "lucide-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";
import { getPlayEffective } from "@/api/play"; import { getPlayEffective } from "@/api/play";
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 { Input } from "@/components/ui/input"; 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 { 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 { mapTicketBetError } from "@/features/hall/hall-bet-errors"; import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
import { import {
playNeedsDigitSlot, playNeedsDigitSlot,
playNeedsDimension, playNeedsDimension,
ticketNumberSpec, ticketNumberSpec,
} from "@/features/hall/hall-bet-rules"; } 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 { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
import { getLotteryRequestLocale } from "@/lib/lottery-locale"; import { getLotteryRequestLocale } from "@/lib/lottery-locale";
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money"; import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
import { cn } from "@/lib/utils"; 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, 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 DEFAULT_POLL_MS = 120_000;
const MAX_ROWS = 20; const MAX_ROWS = 20;
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT"; type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
type BoxMode = "ibox" | "box";
type DraftRow = { type DraftRow = {
id: string; id: string;
@@ -48,6 +50,9 @@ type DraftEntry = {
line: TicketLineInput; line: TicketLineInput;
}; };
type CellRiskState = "open" | "warning" | "sold_out";
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
const categoryTabs: { value: HallCategory; label: string }[] = [ const categoryTabs: { value: HallCategory; label: string }[] = [
{ value: "D2", label: "2D" }, { value: "D2", label: "2D" },
{ value: "D3", label: "3D" }, { value: "D3", label: "3D" },
@@ -55,22 +60,33 @@ const categoryTabs: { value: HallCategory; label: string }[] = [
{ value: "JACKPOT", label: "Jackpot" }, { 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", "big",
"small", "small",
"pos_3c",
"pos_3a",
"pos_4a", "pos_4a",
"pos_4b", "pos_4b",
"pos_4c", "pos_4c",
"pos_4d", "pos_4d",
"pos_4e", "pos_4e",
"box",
"ibox",
"mbox",
"roll",
"straight", "straight",
"head",
"tail",
"odd",
"even",
"digit_big",
"digit_small",
] as const; ] as const;
const simpleCategoryPreferred: Record<"D2" | "D3", string[]> = { const categoryPlayOrders: Record<Exclude<HallCategory, "JACKPOT">, readonly string[]> = {
D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2abc"], D2: D2_PLAY_ORDER,
D3: ["pos_3a", "pos_3b", "pos_3c", "pos_3abc"], D3: D3_PLAY_ORDER,
D4: D4_PLAY_ORDER,
}; };
function newDraftRow(): DraftRow { function newDraftRow(): DraftRow {
@@ -82,29 +98,19 @@ function newDraftRow(): DraftRow {
} }
function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean { function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
if (!row.master_enabled || row.config === null) { return Boolean(row.master_enabled && row.config?.is_enabled);
return false;
}
return row.config.is_enabled;
} }
function pickDisplayName(row: PlayEffectivePlayRow): string { function pickDisplayName(row: PlayEffectivePlayRow): string {
const loc = getLotteryRequestLocale(); const loc = getLotteryRequestLocale();
if (loc === "zh") { if (loc === "zh") return row.display_name_zh ?? row.display_name_en ?? row.play_code;
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 === "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; 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_2")) return "D2";
if (row.play_code.startsWith("pos_3")) return "D3"; 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"; return "D4";
} }
@@ -116,8 +122,10 @@ function categoryDigits(category: HallCategory): number {
} }
function sanitizeNumber(raw: string, category: HallCategory): string { function sanitizeNumber(raw: string, category: HallCategory): string {
const max = categoryDigits(category); if (category === "D4") {
return raw.replace(/\D/g, "").slice(0, max); return raw.replace(/[^0-9Rr]/g, "").toUpperCase().slice(0, 4);
}
return raw.replace(/\D/g, "").slice(0, categoryDigits(category));
} }
function sanitizeAmount(raw: string): string { function sanitizeAmount(raw: string): string {
@@ -130,11 +138,6 @@ function parseRebateRate(rate: string | undefined): number {
return n > 1 ? n / 100 : n; 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 { function amountToDisplay(minor: number): string {
return (minor / 100).toLocaleString(undefined, { return (minor / 100).toLocaleString(undefined, {
minimumFractionDigits: 2, minimumFractionDigits: 2,
@@ -158,8 +161,13 @@ function normalizeNumberForPlay(number: string, playCode: string): string {
return number; return number;
} }
function pickDigitSlot(category: HallCategory): number {
if (category === "D2") return 3;
return 3;
}
function lineForPlay( function lineForPlay(
category: "D2" | "D3" | "D4", category: Exclude<HallCategory, "JACKPOT">,
play: PlayEffectivePlayRow, play: PlayEffectivePlayRow,
displayNumber: string, displayNumber: string,
amountMinor: number, amountMinor: number,
@@ -180,57 +188,202 @@ function lineForPlay(
line.dimension = category; line.dimension = category;
} }
if (playNeedsDigitSlot(play.play_code)) { if (playNeedsDigitSlot(play.play_code)) {
line.digit_slot = 3; line.digit_slot = pickDigitSlot(category);
} }
return line; return line;
} }
function findPlayByCode( function sortByPlayOrder(plays: PlayEffectivePlayRow[], order: readonly string[]): PlayEffectivePlayRow[] {
plays: 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, playCode: string,
): PlayEffectivePlayRow | undefined { rowNumber: string,
return plays.find((p) => p.play_code === playCode); 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( function cellRiskState(
plays: PlayEffectivePlayRow[], play: PlayEffectivePlayRow,
category: "D2" | "D3", rowNumber: string,
): PlayEffectivePlayRow | undefined { category: Exclude<HallCategory, "JACKPOT">,
const preferred = simpleCategoryPreferred[category]; alertRows: DrawCurrentRiskPoolAlert[] | undefined,
return ( ): CellRiskState {
preferred.map((code) => findPlayByCode(plays, code)).find(Boolean) ?? const alerts = alertRows ?? [];
plays.find((p) => inferCategory(p) === category) 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() { function tableCellClass(status: CellRiskState, disabled: boolean): string {
const { display, isBettable, reload: reloadDraw } = useHallDrawLive(); 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 { t } = useTranslation("player");
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2"); const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
const [boxMode, setBoxMode] = useState<BoxMode>("ibox");
const [rows, setRows] = useState<DraftRow[]>(() => [ const [rows, setRows] = useState<DraftRow[]>(() => [
{ ...newDraftRow(), number: "23", amounts: {} }, { ...newDraftRow(), number: "23", amounts: {} },
{ ...newDraftRow(), number: "75", amounts: {} }, { ...newDraftRow(), number: "75", amounts: {} },
{ ...newDraftRow(), number: "08", amounts: {} }, { ...newDraftRow(), number: "08", amounts: {} },
{ ...newDraftRow(), number: "46", amounts: {} }, { ...newDraftRow(), number: "46", amounts: {} },
]); ]);
const [activeRowId, setActiveRowId] = useState<string | null>(null);
const currencyParam = useMemo(() => {
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
return fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined;
}, []);
const [catalogState, setCatalogState] = useState< const [catalogState, setCatalogState] = useState<
| { kind: "loading" } | { kind: "loading" }
| { kind: "ok"; data: PlayEffectivePayload } | { kind: "ok"; data: PlayEffectivePayload }
| { kind: "error"; message: string } | { kind: "error"; message: string }
>({ kind: "loading" }); >({ kind: "loading" });
const [availableMinor, setAvailableMinor] = useState<number>(0);
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [previewData, setPreviewData] = useState<TicketPreviewData | null>(null); const [previewData, setPreviewData] = useState<TicketPreviewData | null>(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [placeLoading, setPlaceLoading] = 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 () => { const loadCatalog = useCallback(async () => {
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" })); setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
@@ -240,54 +393,73 @@ export function HallBettingGrid() {
); );
setCatalogState({ kind: "ok", data }); setCatalogState({ kind: "ok", data });
} catch (e) { } catch (e) {
const msg = const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
setCatalogState({ kind: "error", message: msg }); setCatalogState({ kind: "error", message: msg });
} }
}, [currencyParam, t]); }, [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(() => { useEffect(() => {
queueMicrotask(() => { queueMicrotask(() => {
void loadCatalog(); void loadCatalog();
void refreshWallet();
}); });
}, [loadCatalog]); }, [loadCatalog, refreshWallet]);
useEffect(() => { useEffect(() => {
const id = window.setInterval(() => { const id = window.setInterval(() => {
void loadCatalog(); void loadCatalog();
void refreshWallet();
}, DEFAULT_POLL_MS); }, DEFAULT_POLL_MS);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [loadCatalog]); }, [loadCatalog, refreshWallet]);
const openPlays = useMemo(() => { const openPlays = useMemo(() => {
if (catalogState.kind !== "ok") return []; if (catalogState.kind !== "ok") return [];
return [...catalogState.data.plays] const order = categoryPlayOrders[activeCategory === "JACKPOT" ? "D4" : activeCategory];
.filter(isPlayOpenForPlayer) return sortByPlayOrder(
.filter((p) => p.play_code !== "half_box" && p.play_code !== "roll") catalogState.data.plays
.sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code)); .filter(isPlayOpenForPlayer)
}, [catalogState]); .filter((p) => order.includes(p.play_code)),
order,
);
}, [activeCategory, catalogState]);
const currencyCode = const currencyCode = catalogState.kind === "ok" ? catalogState.data.currency_code : "NPR";
catalogState.kind === "ok" ? catalogState.data.currency_code : "NPR";
const simplePlay = useMemo(() => { const categoryPlays = useMemo(() => {
if (activeCategory !== "D2" && activeCategory !== "D3") return undefined; if (catalogState.kind !== "ok") return [];
return pickSimplePlay(openPlays, activeCategory); if (activeCategory === "JACKPOT") return [];
}, [activeCategory, openPlays]); const order = categoryPlayOrders[activeCategory];
return sortByPlayOrder(
openPlays.filter((p) => inferCategory(p) === activeCategory || activeCategory === "D4"),
order,
);
}, [activeCategory, catalogState, openPlays]);
const d4Columns = useMemo(() => { const activeRow = useMemo(
if (activeCategory !== "D4") return []; () => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null,
const first = findPlayByCode(openPlays, boxMode); [activeRowId, rows],
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 tableDisabled = const alertRows = display?.risk_pool_alerts ?? [];
activeCategory === "JACKPOT" || !isBettable || catalogState.kind !== "ok"; 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 sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status));
const numberPlaceholder = activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
const updateRowNumber = (id: string, value: string) => { const updateRowNumber = (id: string, value: string) => {
setRows((current) => setRows((current) =>
@@ -295,51 +467,95 @@ export function HallBettingGrid() {
row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row, row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row,
), ),
); );
setActiveRowId(id);
}; };
const updateAmount = (rowId: string, playCode: string, value: string) => { const updateAmount = (rowId: string, playCode: string, value: string) => {
setRows((current) => setRows((current) =>
current.map((row) => current.map((row) =>
row.id === rowId row.id === rowId
? { ? { ...row, amounts: { ...row.amounts, [playCode]: sanitizeAmount(value) } }
...row,
amounts: { ...row.amounts, [playCode]: sanitizeAmount(value) },
}
: row, : row,
), ),
); );
}; };
const addRow = () => { 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) => { const removeRow = (id: string) => {
setRows((current) => setRows((current) => {
current.length <= 1 ? current : current.filter((row) => row.id !== id), 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[] => { const collectEntries = useCallback((): DraftEntry[] => {
if (activeCategory === "JACKPOT") return []; if (activeCategory === "JACKPOT") return [];
const entries: DraftEntry[] = []; const entries: DraftEntry[] = [];
const plays =
activeCategory === "D4" ? d4Columns : simplePlay ? [simplePlay] : [];
rows.forEach((row, rowIndex) => { rows.forEach((row, rowIndex) => {
plays.forEach((play) => { categoryPlays.forEach((play) => {
const amount = parseDecimalInputToMinor(row.amounts[play.play_code] ?? ""); const amount = parseDecimalInputToMinor(row.amounts[play.play_code] ?? "");
if (amount === null || amount <= 0) return; if (amount === null || amount <= 0) return;
const line = lineForPlay(activeCategory, play, row.number, amount);
const line = lineForPlay(
activeCategory as "D2" | "D3" | "D4",
play,
row.number,
amount,
);
if (!line) return; if (!line) return;
entries.push({ entries.push({
rowId: row.id, rowId: row.id,
rowNo: rowIndex + 1, rowNo: rowIndex + 1,
@@ -350,9 +566,8 @@ export function HallBettingGrid() {
}); });
}); });
}); });
return entries; return entries;
}, [activeCategory, d4Columns, rows, simplePlay]); }, [activeCategory, categoryPlays, rows]);
const draftEntries = collectEntries(); const draftEntries = collectEntries();
const draftSummary = useMemo(() => { const draftSummary = useMemo(() => {
@@ -369,9 +584,14 @@ export function HallBettingGrid() {
); );
}, [draftEntries]); }, [draftEntries]);
const buildLines = (): TicketLineInput[] => { useEffect(() => {
return collectEntries().map((entry) => entry.line); const id = window.setTimeout(() => {
}; setDebouncedSummary(draftSummary);
}, 300);
return () => window.clearTimeout(id);
}, [draftSummary]);
const buildLines = (): TicketLineInput[] => collectEntries().map((entry) => entry.line);
const handlePreview = async () => { const handlePreview = async () => {
if (!display) { if (!display) {
@@ -398,11 +618,18 @@ export function HallBettingGrid() {
const data = await postTicketPreview({ const data = await postTicketPreview({
draw_id: display.draw_no, draw_id: display.draw_no,
currency_code: currencyCode, 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, lines,
}); });
setPreviewData(data); setPreviewData(data);
setPreviewOpen(true); setPreviewOpen(true);
rows.forEach((row) => {
if (row.number.trim()) pushHistory(row.number.trim());
});
} catch (e) { } catch (e) {
const code = e instanceof LotteryApiBizError ? e.code : 0; const code = e instanceof LotteryApiBizError ? e.code : 0;
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.previewFailed"); const msg = e instanceof LotteryApiBizError ? e.message : t("hall.previewFailed");
@@ -437,17 +664,21 @@ export function HallBettingGrid() {
lines, lines,
expected_config_versions: previewData.config_versions, 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( toast.success(
t("hall.placeSuccess", { t("hall.placeSuccess", {
orderNo: data.order_no, orderNo: data.order_no,
amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode), amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode),
}), }),
); );
setPreviewOpen(false);
setPreviewData(null);
setRows([newDraftRow()]);
triggerWalletPollingAfterBet();
void reloadDraw();
} catch (e) { } catch (e) {
const code = e instanceof LotteryApiBizError ? e.code : 0; const code = e instanceof LotteryApiBizError ? e.code : 0;
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed"); 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") { if (catalogState.kind === "loading") {
return ( return (
<section className="space-y-3" aria-label={t("hall.aria")}> <section className="space-y-3" aria-label={t("hall.aria")}>
@@ -484,100 +721,44 @@ export function HallBettingGrid() {
); );
} }
const simplePlayCode = simplePlay?.play_code ?? ""; const canSubmit = !tableDisabled && draftEntries.length > 0 && availableMinor >= debouncedSummary.actual;
const numberPlaceholder = const favoriteChips = favorites.slice(0, 10);
activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000"; const historyChips = historyNumbers.slice(0, 20);
return ( return (
<> <>
<section className="space-y-4" aria-label={t("hall.aria")}> <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)]"> <div className="rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
{categoryTabs.map((tab) => { <div className="grid grid-cols-4 gap-1">
const active = activeCategory === tab.value; {categoryTabs.map((tab) => {
return ( const active = activeCategory === tab.value;
<button return (
key={tab.value} <button
type="button" key={tab.value}
onClick={() => { type="button"
setActiveCategory(tab.value); onClick={() => {
setRows((current) => setActiveCategory(tab.value);
current.map((row) => ({ setRows((current) =>
...row, current.map((row) => ({
number: sanitizeNumber(row.number, tab.value), ...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", className={cn(
active "relative flex h-10 min-w-0 items-center justify-center rounded-lg text-sm font-semibold transition-colors",
? "bg-[#07459f] text-white shadow-[inset_0_-2px_0_rgba(255,255,255,0.28)]" active
: "text-[#4b5563] hover:bg-[#f4f7fb]", ? "bg-[#07459f] text-white shadow-[inset_0_-2px_0_rgba(255,255,255,0.28)]"
tab.value === "JACKPOT" && active && "bg-[#7b8492]", : "text-[#4b5563] hover:bg-[#f4f7fb]",
)} tab.value === "JACKPOT" && active && "bg-[#7b8492]",
> )}
<span className="truncate">{tab.label}</span> >
</button> <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> </div>
) : null} </div>
{activeCategory === "JACKPOT" ? ( {activeCategory === "JACKPOT" ? (
<div className="rounded-xl border border-[#edf1f7] bg-[#f7f9fc] p-7 text-center text-slate-500"> <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> </div>
) : ( ) : (
<div <>
className={cn( <div className="space-y-3 rounded-xl border border-[#e9eef7] bg-white p-4 shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)] transition-opacity", <div className="flex flex-wrap items-center justify-between gap-2">
tableDisabled && "opacity-55", <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"> <div
<table className={cn("w-full border-collapse text-sm", activeCategory === "D4" ? "min-w-[760px]" : "min-w-[460px]")}> className={cn(
<thead> "overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)] transition-opacity",
<tr className="border-b border-[#e8eef7] bg-[#f5f8fd] text-[11px] font-semibold text-[#32518d]"> tableDisabled && "opacity-55",
<th className="sticky left-0 z-20 w-12 bg-[#f5f8fd] px-2 py-3 text-center"> )}
{t("hall.table.no")} >
</th> <div className="overflow-x-auto">
<th className="sticky left-12 z-20 w-24 bg-[#f5f8fd] px-2 py-3 text-center"> <table
{t("hall.table.number")} className={cn(
<span className="block text-[10px] font-normal text-[#6b7896]"> "w-full border-collapse text-sm",
({numberPlaceholder}) activeCategory === "D4" ? "min-w-[1240px]" : "min-w-[740px]",
</span> )}
</th> >
{activeCategory === "D4" ? ( <thead>
d4Columns.map((play) => ( <tr className="border-b border-[#e8eef7] bg-[#f5f8fd] text-[11px] font-semibold text-[#32518d]">
<th key={play.play_code} className="min-w-20 px-2 py-3 text-center"> <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)} {pickDisplayName(play)}
</th> </th>
)) ))}
) : ( <th className="w-10 px-2 py-3" aria-label={t("hall.table.delete")} />
<> </tr>
<th className="min-w-28 px-2 py-3 text-center"> </thead>
{t("hall.table.stake")} <tbody>
</th> {rows.map((row, index) => {
<th className="min-w-28 px-2 py-3 text-center"> const rowKey = row.id;
{t("hall.table.rebate")} return (
</th> <tr key={rowKey} className="border-b border-[#eef2f8] last:border-b-0">
<th className="min-w-28 px-2 py-3 text-center"> <td className="sticky left-0 z-10 bg-white px-2 py-3 text-center font-semibold text-[#17408d]">
{t("hall.table.actual")} {index + 1}
</th> </td>
</> <td className="sticky left-12 z-10 bg-white px-2 py-3">
)} <Input
<th className="w-10 px-2 py-3" aria-label={t("hall.table.delete")} /> value={row.number}
</tr> disabled={tableDisabled}
</thead> inputMode="text"
<tbody> placeholder={numberPlaceholder}
{rows.map((row, index) => { onFocus={() => setActiveRowId(row.id)}
const simpleAmount = parseDecimalInputToMinor( onClick={() => setActiveRowId(row.id)}
row.amounts[simplePlayCode] ?? "", 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"
const simpleRebate = />
simpleAmount !== null && simplePlay </td>
? Math.round(simpleAmount * parseRebateRate(simplePlay.odds?.rebate_rate)) {categoryPlays.map((play) => {
: 0; const amountText = row.amounts[play.play_code] ?? "";
const simpleActual = const amountMinor = parseDecimalInputToMinor(amountText);
simpleAmount !== null ? Math.max(0, simpleAmount - simpleRebate) : 0; 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 ( return (
<tr <td
key={row.id} key={`${rowKey}-${play.play_code}`}
className="border-b border-[#eef2f8] last:border-b-0" className={cn("px-1.5 py-3", tableCellClass(status, disabled))}
> >
<td className="sticky left-0 z-10 bg-white px-2 py-3 text-center font-semibold text-[#17408d]"> <div className="space-y-1">
{index + 1} <Input
</td> value={amountText}
<td className="sticky left-12 z-10 bg-white px-2 py-3"> disabled={disabled}
<Input inputMode="decimal"
value={row.number} placeholder={
disabled={tableDisabled} status === "sold_out"
inputMode="numeric" ? t("hall.table.soldOut", { defaultValue: "售罄" })
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" onFocus={() => setActiveRowId(row.id)}
/> onClick={() => setActiveRowId(row.id)}
</td> onChange={(event) => updateAmount(row.id, play.play_code, event.target.value)}
{activeCategory === "D4" ? ( className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-xs tabular-nums shadow-sm"
d4Columns.map((play) => ( />
<td key={play.play_code} className="px-1.5 py-3"> {status === "sold_out" ? (
<Input <p className="text-center text-[10px] font-semibold text-slate-500">
value={row.amounts[play.play_code] ?? ""} {t("hall.table.soldOut", { defaultValue: "售罄" })}
disabled={tableDisabled} </p>
inputMode="decimal" ) : status === "warning" ? (
placeholder="-" <p className="text-center text-[10px] font-semibold text-amber-600">
onChange={(event) => {t("hall.table.warning", { defaultValue: "接近售罄" })}
updateAmount(row.id, play.play_code, event.target.value) </p>
} ) : activeCategory !== "D4" && amountMinor !== null ? (
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-xs tabular-nums shadow-sm" <p className="text-center text-[10px] text-slate-500">
/> {t("hall.table.actual", { defaultValue: "实扣" })}{" "}
</td> {amountToDisplay(actual)}
)) </p>
) : ( ) : null}
<> </div>
<td className="px-2 py-3"> </td>
<Input );
value={row.amounts[simplePlayCode] ?? ""} })}
disabled={tableDisabled || !simplePlay} <td className="px-1 py-3 text-center">
inputMode="decimal" <button
placeholder="0.00" type="button"
onChange={(event) => disabled={tableDisabled || rows.length <= 1}
updateAmount(row.id, simplePlayCode, event.target.value) 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"
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-sm tabular-nums shadow-sm" aria-label={t("actions.deleteRow", { row: index + 1 })}
/> >
</td> <Trash2 className="size-4" aria-hidden />
<td className="px-2 py-3 text-center leading-tight"> </button>
<span className="block font-semibold tabular-nums text-[#34a853]"> </td>
-{simpleRebate > 0 ? amountToDisplay(simpleRebate) : "0.00"} </tr>
</span> );
<span className="text-xs text-slate-500"> })}
({formatRatePercent(simplePlay?.odds?.rebate_rate)}) </tbody>
</span> </table>
</td> </div>
<td className="px-2 py-3 text-center tabular-nums text-slate-700"> <button
{simpleActual > 0 ? amountToDisplay(simpleActual) : "0.00"} type="button"
</td> 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"
<td className="px-1 py-3 text-center"> >
<button <CirclePlus className="size-4" aria-hidden />
type="button" {t("hall.table.addRow", { defaultValue: "添加一行" })}
disabled={tableDisabled || rows.length <= 1} </button>
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> </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" type="button"
disabled={tableDisabled || rows.length >= MAX_ROWS} disabled={!canSubmit || previewLoading}
onClick={addRow} onClick={() => void handlePreview()}
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" 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 /> <Ticket className="size-5" aria-hidden />
{t("hall.table.addRow")} {previewLoading
</button> ? t("hall.table.previewing", { defaultValue: "预览中..." })
</div> : !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> </section>
<HallBetPreviewDialog <HallBetPreviewDialog
@@ -772,9 +1095,19 @@ export function HallBettingGrid() {
currencyCode={currencyCode} currencyCode={currencyCode}
data={previewData} data={previewData}
placing={placeLoading} placing={placeLoading}
allowSubmit={isBettable} allowSubmit={isBettable && availableMinor >= debouncedSummary.actual}
onConfirmPlace={() => void handlePlace()} onConfirmPlace={() => void handlePlace()}
/> />
<HallBetResultDialog
open={resultOpen}
onOpenChange={(open) => {
setResultOpen(open);
if (!open) setResultData(null);
}}
currencyCode={currencyCode}
data={resultData}
/>
</> </>
); );
} }

View File

@@ -1,12 +1,12 @@
"use client"; "use client";
import { Hourglass, Landmark, TimerReset, WalletCards } from "lucide-react"; import { Hourglass, Landmark, TimerReset } 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";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { drawStatusHud, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta"; 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 { formatSecondsClock } from "@/lib/format-gmt";
import { formatLotteryInstant } from "@/lib/player-datetime"; import { formatLotteryInstant } from "@/lib/player-datetime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -69,8 +69,8 @@ function CloseTime({
); );
} }
export function HallDrawPanel() { export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot }) {
const { raw, display, error, reload } = useHallDrawLive(); const { raw, display, error, reload } = drawLive;
const { t } = useTranslation("player"); const { t } = useTranslation("player");
if (error) { if (error) {
@@ -122,13 +122,10 @@ export function HallDrawPanel() {
aria-label={t("draw.currentIssue")} aria-label={t("draw.currentIssue")}
> >
<div className="grid grid-cols-[1fr_1.05fr_1fr] divide-x divide-[#e7edf6]"> <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"> <div className="flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[#eef4ff] text-[#0b56b7]"> <div className="min-w-0 max-w-full overflow-x-auto">
<WalletCards className="size-4" aria-hidden />
</span>
<div className="min-w-0">
<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>
<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} {display.draw_no}
</p> </p>
</div> </div>

View File

@@ -1,33 +1,36 @@
"use client"; "use client";
import { Bell } from "lucide-react"; import { Bell } from "lucide-react";
import Image from "next/image";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { HallBettingGrid } from "@/features/hall/hall-betting-grid"; import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
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 { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
/** /**
* 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。 * 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。
*/ */
export function HallScreen() { export function HallScreen() {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
const drawLive = useHallDrawLive();
return ( return (
<div className="mx-auto w-full max-w-[480px]"> <div className="mx-auto w-full max-w-[480px]">
<section className="overflow-hidden bg-white px-4 pb-8 pt-4 text-slate-900"> <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="mb-2 flex items-center gap-1 px-1 pt-2">
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center">
<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)]"> <div className="inline-flex min-w-0 items-center">
<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"> <Image
N src="/logo.png"
</span> alt="Nlotto"
<span className="ml-4 text-lg font-black italic">N</span> width={243}
</div> height={84}
<div className="min-w-0 text-[28px] font-black italic leading-none tracking-normal"> className="h-9 w-auto max-w-[min(100%,220px)] object-contain object-left"
<span className="text-[#ed001c]">N</span>{" "} priority
<span className="text-[#0a3f94]">lotto</span> />
</div> </div>
</div> </div>
<LanguageSwitcher <LanguageSwitcher
@@ -37,19 +40,19 @@ export function HallScreen() {
/> />
<button <button
type="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")} aria-label={t("navigation.notifications")}
> >
<Bell className="size-5" aria-hidden /> <Bell className="size-4" aria-hidden />
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" /> <span className="absolute right-1.5 top-1.5 size-2 rounded-full bg-[#ff143d]" />
</button> </button>
</div> </div>
<HallDrawPanel /> <HallDrawPanel drawLive={drawLive} />
<HallWalletStrip /> <HallWalletStrip />
<HallBettingGrid /> <HallBettingGrid drawLive={drawLive} />
</section> </section>
</div> </div>
); );

View File

@@ -7,6 +7,15 @@ import { getLotteryEcho } from "@/lib/lottery-echo";
import { useNetworkConnectionStore } from "@/stores/network-connection-store"; import { useNetworkConnectionStore } from "@/stores/network-connection-store";
import type { DrawCurrentPayload } from "@/types/api/draw-current"; 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` 载荷 */ /** 界面文档 §2.1`draw.countdown` / `draw.status_change` / `result.published` 载荷 */
export type HallWsEnvelope = { export type HallWsEnvelope = {
data: DrawCurrentPayload | null; data: DrawCurrentPayload | null;
@@ -34,16 +43,10 @@ function applySnapshotDrift(
} }
/** /**
* 大厅期号WebSocket `lottery-hall` + 轮询降级(与 {@link HallDrawPanel} 同源逻辑) * 大厅期号WebSocket `lottery-hall` + 轮询降级;由 {@link HallScreen} 调用一次,注入 {@link HallDrawPanel} 与 {@link HallBettingGrid}
* 已集成网络连接管理WebSocket断开时自动切换到轮询模式。 * 已集成网络连接管理WebSocket断开时自动切换到轮询模式。
*/ */
export function useHallDrawLive(): { export function useHallDrawLive(): HallDrawLiveSnapshot {
raw: DrawCurrentPayload | null | undefined;
display: DrawCurrentPayload | null | undefined;
error: string | null;
reload: () => Promise<void>;
isBettable: boolean;
} {
const [raw, setRaw] = useState<DrawCurrentPayload | null | undefined>(undefined); const [raw, setRaw] = useState<DrawCurrentPayload | null | undefined>(undefined);
const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now()); const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now());
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());

View File

@@ -5,7 +5,7 @@ import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTicketItemDetail } from "@/api/ticket-items"; 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 { PlayerPanel } from "@/components/layout/player-panel";
import { import {
Card, Card,
@@ -74,7 +74,7 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
<PlayerPanel <PlayerPanel
title={t("orders.betDetail")} title={t("orders.betDetail")}
subtitle={ticketNo} subtitle={ticketNo}
eyebrow={t("orders.title")} eyebrow={t("brand.name")}
backHref="/orders" backHref="/orders"
backLabel={t("orders.title")} backLabel={t("orders.title")}
> >
@@ -91,13 +91,18 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
<PlayerPanel <PlayerPanel
title={t("orders.betDetail")} title={t("orders.betDetail")}
subtitle={ticketNo} subtitle={ticketNo}
eyebrow={t("orders.title")} eyebrow={t("brand.name")}
backHref="/orders" backHref="/orders"
backLabel={t("orders.title")} backLabel={t("orders.title")}
> >
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700"> <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> <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")} {t("actions.retry")}
</Button> </Button>
</div> </div>
@@ -145,72 +150,87 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
<PlayerPanel <PlayerPanel
title={t("orders.betDetail")} title={t("orders.betDetail")}
subtitle={data.ticket_no} subtitle={data.ticket_no}
eyebrow={t("orders.title")} eyebrow={t("brand.name")}
backHref="/orders" backHref="/orders"
backLabel={t("orders.title")} backLabel={t("orders.title")}
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]"> <Card className="ring-0 border border-[#e8eef7] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
<CardHeader className="space-y-2 pb-2"> <CardHeader className="space-y-2 border-b border-[#edf2f9] pb-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <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} /> <StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
</div> </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.ticketNo", { ticketNo: data.ticket_no })} ·{" "}
{t("orders.orderNo", { orderNo: data.order_no ?? "—" })} {t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-4 text-sm">
<div className="grid gap-1 text-xs"> <div className="space-y-2.5 text-xs">
<p> <div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground">{t("orders.drawNo")}</span>{" "} <span className="shrink-0 text-slate-500">{t("orders.drawNo")}</span>
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span> <span className="text-right font-mono font-black text-[#0b3f96]">
</p> {data.draw_no ?? "—"}
<p> </span>
<span className="text-muted-foreground">{t("orders.placedAt")}</span>{" "} </div>
{formatLotteryInstant(data.placed_at ?? null)} <div className="flex items-baseline justify-between gap-3">
</p> <span className="shrink-0 text-slate-500">{t("orders.placedAt")}</span>
<p> <span className="text-right font-medium text-slate-800">
<span className="text-muted-foreground">{t("orders.number")}</span>{" "} {formatLotteryInstant(data.placed_at ?? null)}
<span className="font-mono">{data.original_number ?? "—"}</span> </span>
</p> </div>
<p> <div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground">{t("orders.play")}</span>{" "} <span className="shrink-0 text-slate-500">{t("orders.number")}</span>
{playLabel(data.play_code, t)} ( <span className="text-right font-mono text-base font-black text-[#0b3f96]">
{data.dimension ?? "—"}D) {data.original_number ?? "—"}
</p> </span>
<p> </div>
<span className="text-muted-foreground">{t("orders.amount")}</span>{" "} <div className="flex items-baseline justify-between gap-3">
{formatMinorAsCurrency(data.total_bet_amount, cur)} <span className="shrink-0 text-slate-500">{t("orders.play")}</span>
</p> <span className="text-right font-semibold text-[#32518d]">
<p> {playLabel(data.play_code, t)} ({data.dimension ?? "—"}D)
<span className="text-muted-foreground">{t("orders.rebateRate")}</span>{" "} </span>
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}% </div>
</p> <div className="flex items-baseline justify-between gap-3">
<p> <span className="shrink-0 text-slate-500">{t("orders.amount")}</span>
<span className="text-muted-foreground">{t("orders.actualDeduct")}</span>{" "} <span className="text-right font-black tabular-nums text-[#d81435]">
{formatMinorAsCurrency(data.actual_deduct_amount, cur)} {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> </p>
</div> </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 ? ( {pub?.results ? (
<div className="space-y-2"> <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} /> <TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
{first ? ( {first ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-slate-500">
{t("orders.firstPrize")}{" "} {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 ? ( {comboHits.length > 0 ? (
<span className="text-emerald-600 dark:text-emerald-400"> <span className="font-semibold text-emerald-600">
{" "} {" "}
{t("orders.hit")} {t("orders.hit")}
</span> </span>
@@ -219,15 +239,17 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
) : null} ) : null}
</div> </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 ? ( {data.settlement && tierLabel ? (
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs"> <div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs">
<p className="font-medium text-emerald-900 dark:text-emerald-100"> <p className="font-bold text-emerald-900">
{t("orders.matchWin", { tier: tierLabel })} {t("orders.matchWin", { tier: tierLabel })}
</p> </p>
<p className="mt-1 font-mono text-muted-foreground"> <p className="mt-1 font-mono text-emerald-800/90">
{t("orders.winAmount", { {t("orders.winAmount", {
amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur), amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur),
})} })}
@@ -244,32 +266,39 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
</> </>
) : null} ) : null}
</p> </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) })} {t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })}
</p> </p>
</div> </div>
) : data.status === "settled_lose" ? ( ) : 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} ) : null}
{data.settled_at ? ( {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) })} {t("orders.settledAt", { time: formatLotteryInstant(data.settled_at) })}
</p> </p>
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-3">
{data.draw_no ? ( {data.draw_no ? (
<Link <Link
href={`/results/${encodeURIComponent(data.draw_no)}`} 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")} {t("orders.viewDraw")}
</Link> </Link>
) : null} ) : 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")} {t("orders.backToOrders")}
</Link> </Link>
</div> </div>

View File

@@ -121,9 +121,14 @@
"delete": "Delete", "delete": "Delete",
"addRow": "Add Row", "addRow": "Add Row",
"draftTotal": "Draft Total", "draftTotal": "Draft Total",
"totalBet": "Total",
"totalRebate": "Rebate",
"actualTotal": "Estimated Deduction",
"sealedHint": "Closed: this table is locked. Please wait for the next issue.", "sealedHint": "Closed: this table is locked. Please wait for the next issue.",
"previewing": "Previewing...", "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": { "preview": {
"title": "Confirm bet", "title": "Confirm bet",

View File

@@ -121,9 +121,14 @@
"delete": "हटाउनुहोस्", "delete": "हटाउनुहोस्",
"addRow": "पंक्ति थप्नुहोस्", "addRow": "पंक्ति थप्नुहोस्",
"draftTotal": "ड्राफ्ट जम्मा", "draftTotal": "ड्राफ्ट जम्मा",
"totalBet": "जम्मा",
"totalRebate": "रिबेट",
"actualTotal": "अनुमानित कट्टा",
"sealedHint": "बन्द: यो तालिका लक छ। कृपया अर्को इश्यू पर्खनुहोस्।", "sealedHint": "बन्द: यो तालिका लक छ। कृपया अर्को इश्यू पर्खनुहोस्।",
"previewing": "पूर्वावलोकन...", "previewing": "पूर्वावलोकन...",
"submitBet": "बेट पेश गर्नुहोस्" "submitBet": "बेट पेश गर्नुहोस्",
"scrollHint": "तालिका फराकिलो छ: दायाँतिर स्क्रोल गर्नुहोस्, दायाँका प्रत्येक खेल स्तम्भमा बेट रकम लेख्नुहोस्।",
"noPlaysInCategory": "यस ट्याबमा खुला खेल प्रकार छैन। २D / ३D / ४D प्रयास गर्नुहोस् वा व्यवस्थापकले खेल खोल्नुपर्छ।"
}, },
"preview": { "preview": {
"title": "बेट पुष्टि गर्नुहोस्", "title": "बेट पुष्टि गर्नुहोस्",

View File

@@ -121,9 +121,14 @@
"delete": "删除", "delete": "删除",
"addRow": "添加一行", "addRow": "添加一行",
"draftTotal": "草稿合计", "draftTotal": "草稿合计",
"totalBet": "合计",
"totalRebate": "回水",
"actualTotal": "预计扣款",
"sealedHint": "已封盘:当前表格不可编辑,请等待下一期。", "sealedHint": "已封盘:当前表格不可编辑,请等待下一期。",
"previewing": "预览中...", "previewing": "预览中...",
"submitBet": "提交下注" "submitBet": "提交下注",
"scrollHint": "表格较宽:请向右滑动,在右侧各玩法列(如 Big / Small、位置玩法等输入下注金额。",
"noPlaysInCategory": "当前分类没有已开放的玩法,无法填写金额。请尝试切换 2D / 3D / 4D或在后台开放对应玩法。"
}, },
"preview": { "preview": {
"title": "确认下注", "title": "确认下注",

View File

@@ -9,6 +9,16 @@ export type DrawCurrentResultItem = {
tail_digit: number | null; 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 = { export type DrawCurrentPayload = {
draw_no: string; draw_no: string;
business_date: string; business_date: string;
@@ -21,6 +31,7 @@ export type DrawCurrentPayload = {
seconds_to_draw: number; seconds_to_draw: number;
cooling_end_time: string | null; cooling_end_time: string | null;
seconds_remaining_in_cooldown: number | null; seconds_remaining_in_cooldown: number | null;
risk_pool_alerts?: DrawCurrentRiskPoolAlert[];
result_items?: DrawCurrentResultItem[]; result_items?: DrawCurrentResultItem[];
result_version?: number; result_version?: number;
result_source?: string | null; result_source?: string | null;

View File

@@ -72,5 +72,6 @@ export type TicketPlaceData = {
order_no: string; order_no: string;
draw: { draw_id: string; status: string }; draw: { draw_id: string; status: string };
summary: TicketPreviewData["summary"]; summary: TicketPreviewData["summary"];
balance_after: number;
items: TicketPlaceItem[]; items: TicketPlaceItem[];
}; };