- 合并钱包、订单、开奖、通知等独立 screen 到页面层,减少重复壳组件 - 优化订单列表/详情、钱包划转与待对账展示、开奖核对与结果详情交互 - 改进入口 gate、移动端视口与下拉刷新在窄屏下的表现 - dev 绑定 0.0.0.0 并默认放行 192.168/10 网段,修复局域网 HMR WebSocket
This commit is contained in:
29
src/features/results/check-winning-redirect.tsx
Normal file
29
src/features/results/check-winning-redirect.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getDrawResults } from "@/api/draw";
|
||||
|
||||
/** 旧 `/results/check` 路由:跳转到最新期详情并展开查奖面板 */
|
||||
export function CheckWinningRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getDrawResults({ page: 1, size: 1 });
|
||||
const drawNo = res.items[0]?.draw_no;
|
||||
if (drawNo) {
|
||||
router.replace(`/results/${encodeURIComponent(drawNo)}?check=1`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* 回落到列表 */
|
||||
}
|
||||
router.replace("/results");
|
||||
})();
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,310 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { BriefcaseBusiness, CheckCircle2, Clock3, RefreshCw, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getDrawResults } from "@/api/draw";
|
||||
import { getTicketDrawMyMatch, getTicketItems } from "@/api/ticket-items";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items";
|
||||
|
||||
type WinningCheckResult = {
|
||||
draw: DrawResultListItem;
|
||||
match: TicketDrawMyMatchPayload;
|
||||
tickets: TicketItemListRow[];
|
||||
};
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-panel";
|
||||
|
||||
/** 保留组件供内嵌使用;独立路由已 redirect 至期号详情 */
|
||||
export function CheckWinningScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
useCurrencyCatalog();
|
||||
const [ticketNo, setTicketNo] = useState("");
|
||||
const [latestDraw, setLatestDraw] = useState<DrawResultListItem | null>(null);
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
const [result, setResult] = useState<WinningCheckResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drawNo, setDrawNo] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getDrawResults({ page: 1, size: 1 });
|
||||
setLatestDraw(res.items[0] ?? null);
|
||||
} catch {
|
||||
setLatestDraw(null);
|
||||
}
|
||||
})();
|
||||
});
|
||||
void getDrawResults({ page: 1, size: 1 })
|
||||
.then((res) => setDrawNo(res.items[0]?.draw_no ?? null))
|
||||
.catch(() => setDrawNo(null));
|
||||
}, []);
|
||||
|
||||
const normalizedTicketNo = ticketNo.trim();
|
||||
|
||||
const runCheck = useCallback(async () => {
|
||||
if (!latestDraw || normalizedTicketNo === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [match, tickets] = await Promise.all([
|
||||
getTicketDrawMyMatch(latestDraw.draw_no),
|
||||
getTicketItems({
|
||||
draw_no: latestDraw.draw_no,
|
||||
number: normalizedTicketNo,
|
||||
per_page: 10,
|
||||
page: 1,
|
||||
}),
|
||||
]);
|
||||
const next = {
|
||||
draw: latestDraw,
|
||||
match,
|
||||
tickets: tickets.items,
|
||||
};
|
||||
setResult(next);
|
||||
setRecent((current) => [normalizedTicketNo, ...current.filter((x) => x !== normalizedTicketNo)].slice(0, 5));
|
||||
} catch {
|
||||
setError(t("results.check.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [latestDraw, normalizedTicketNo, t]);
|
||||
|
||||
return (
|
||||
<PlayerPanel title={t("results.check.title")} backHref="/results" backLabel={t("results.title")}>
|
||||
<div className="space-y-3">
|
||||
<section className="overflow-hidden rounded-2xl border border-red-100 bg-white shadow-[0_12px_32px_rgba(15,23,42,0.06)]">
|
||||
<div className="bg-gradient-to-b from-red-50 to-white px-5 pb-5 pt-8 text-center">
|
||||
<div className="mx-auto flex size-24 items-center justify-center rounded-full bg-white text-[#e5002c] shadow-[0_18px_40px_rgba(229,0,44,0.14)]">
|
||||
<BriefcaseBusiness className="size-12" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-lg font-black text-slate-950">
|
||||
{t("results.check.enterTicket")}
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-slate-500">
|
||||
{t("results.check.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 px-3 pb-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-black text-slate-700">
|
||||
{t("results.check.ticketNumber")}
|
||||
</span>
|
||||
<Input
|
||||
value={ticketNo}
|
||||
placeholder={t("results.check.placeholder")}
|
||||
onChange={(e) => setTicketNo(e.target.value)}
|
||||
className="h-12 rounded-xl border-[#dce7f7] bg-white font-mono text-base font-bold"
|
||||
/>
|
||||
</label>
|
||||
{latestDraw ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("results.check.latestDraw", { drawNo: latestDraw.draw_no })}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p className="text-sm font-semibold text-[#e5002c]">{error}</p> : null}
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!latestDraw || normalizedTicketNo === "" || loading}
|
||||
onClick={() => void runCheck()}
|
||||
className="h-12 w-full rounded-xl bg-[#e5002c] text-base font-black text-white hover:bg-[#d10028]"
|
||||
>
|
||||
{loading ? t("results.check.loading") : t("results.check.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[#dfe8f6] bg-white p-4 shadow-[0_10px_26px_rgba(15,23,42,0.05)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-black text-slate-950">
|
||||
{t("results.check.recent")}
|
||||
</h3>
|
||||
{recent.length > 0 ? (
|
||||
<button type="button" className="text-sm font-bold text-[#0b56b7]" onClick={() => setRecent([])}>
|
||||
{t("actions.clear")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 divide-y divide-[#edf2f9]">
|
||||
{recent.length === 0 ? (
|
||||
<p className="py-4 text-sm text-slate-500">
|
||||
{t("results.check.noRecent")}
|
||||
</p>
|
||||
) : (
|
||||
recent.map((row) => (
|
||||
<button
|
||||
key={row}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between py-3 text-left"
|
||||
onClick={() => setTicketNo(row)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-mono text-sm font-black text-slate-800">
|
||||
<Clock3 className="size-4 text-slate-400" />
|
||||
{row}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{latestDraw?.business_date ?? "—"}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<WinningResultDialog
|
||||
open={result !== null}
|
||||
data={result}
|
||||
query={normalizedTicketNo}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResult(null);
|
||||
}}
|
||||
onCheckAnother={() => {
|
||||
setResult(null);
|
||||
setTicketNo("");
|
||||
}}
|
||||
/>
|
||||
{drawNo ? (
|
||||
<DrawWinningCheckPanel drawNo={drawNo} collapsible={false} />
|
||||
) : (
|
||||
<Skeleton className="h-56 rounded-2xl" />
|
||||
)}
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function WinningResultDialog({
|
||||
open,
|
||||
data,
|
||||
query,
|
||||
onOpenChange,
|
||||
onCheckAnother,
|
||||
}: {
|
||||
open: boolean;
|
||||
data: WinningCheckResult | null;
|
||||
query: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCheckAnother: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const totalWin = (data?.match.total_win_minor ?? 0) + (data?.match.total_jackpot_win_minor ?? 0);
|
||||
const isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0;
|
||||
const firstTicket = useMemo(() => data?.tickets[0] ?? null, [data]);
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const currency = firstTicket?.currency_code ?? activeCurrency;
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-h-[calc(100dvh-24px)] flex-col gap-0 overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.28)] sm:max-w-md"
|
||||
>
|
||||
<div className="relative shrink-0 px-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-3 top-3 z-10 inline-flex size-9 items-center justify-center rounded-full text-slate-500 hover:bg-slate-100"
|
||||
aria-label={t("actions.close")}
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</button>
|
||||
<DialogHeader className="items-center text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full border-4 border-emerald-100 bg-white text-emerald-600">
|
||||
<CheckCircle2 className="size-11" />
|
||||
</div>
|
||||
<DialogTitle className="mt-3 text-xl font-black text-slate-950">
|
||||
{isWon
|
||||
? t("results.check.winTitle")
|
||||
: t("results.check.noWinTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-slate-500">
|
||||
{t("results.check.ticketNumber")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
|
||||
<div className="mx-auto mt-3 w-fit rounded-xl bg-emerald-50 px-8 py-2 font-mono text-lg font-black text-[#0a8f3e]">
|
||||
{query}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 overflow-hidden rounded-xl border border-emerald-100 bg-emerald-50 text-center">
|
||||
<div className="border-r border-emerald-100 px-3 py-4">
|
||||
<p className="text-xs font-medium text-slate-500">
|
||||
{t("results.check.match")}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-black text-[#0a8f3e]">
|
||||
{firstTicket ? playLabel(firstTicket.play_code, t) : isWon ? t("orders.hit") : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 py-4">
|
||||
<p className="text-xs font-medium text-slate-500">
|
||||
{t("results.check.amount")}
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-lg font-black text-[#0a8f3e]">
|
||||
{formatMinorAsCurrency(totalWin, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-xl border border-[#e8eef7] bg-white p-4 text-sm">
|
||||
<p className="font-black text-slate-950">
|
||||
{t("results.check.drawInfo")}
|
||||
</p>
|
||||
<div className="mt-2.5 grid grid-cols-2 gap-3 text-slate-500">
|
||||
<div>
|
||||
<p className="text-xs">{t("results.check.issueNo")}</p>
|
||||
<p className="mt-1 font-mono font-black text-slate-900">{data.draw.draw_no}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs">{t("results.businessDate")}</p>
|
||||
<p className="mt-1 font-semibold text-slate-900">{data.draw.business_date}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs">{t("results.drawTime", { time: "" }).replace(":", "").trim()}</p>
|
||||
<p className="mt-1 font-semibold text-slate-900">
|
||||
{formatPlayerInstant(data.draw.draw_time_iso ?? data.draw.draw_time ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
nativeButton={false}
|
||||
className="h-12 rounded-xl bg-[#07459f] text-base font-black text-white hover:bg-[#063b88]"
|
||||
render={<Link href={`/orders?draw_no=${encodeURIComponent(data.draw.draw_no)}&number=${encodeURIComponent(query)}`} />}
|
||||
>
|
||||
{t("results.check.viewBetDetails")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 rounded-xl border-[#ff3650] text-base font-black text-[#e5002c] hover:bg-[#fff5f6]"
|
||||
onClick={onCheckAnother}
|
||||
>
|
||||
<RefreshCw className="size-5" />
|
||||
{t("results.check.checkAnother")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getDrawResultByNo } from "@/api/draw";
|
||||
import { getTicketDrawMyMatch } from "@/api/ticket-items";
|
||||
import Link from "next/link";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-panel";
|
||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
@@ -37,6 +39,8 @@ type DrawResultDetailScreenProps = {
|
||||
/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
|
||||
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const searchParams = useSearchParams();
|
||||
const checkOpen = searchParams.get("check") === "1";
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
useCurrencyCatalog();
|
||||
@@ -268,20 +272,14 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] px-3 py-3">
|
||||
<p className="text-xs leading-relaxed text-slate-500">
|
||||
{t("results.hitHint")}
|
||||
</p>
|
||||
<Link
|
||||
href="/results/check"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "default", size: "sm" }),
|
||||
"mt-3 h-10 w-full rounded-xl bg-[#e5002c] text-white hover:bg-[#d10028]",
|
||||
)}
|
||||
>
|
||||
{t("results.viewMyWinning")}
|
||||
</Link>
|
||||
</div>
|
||||
<DrawWinningCheckPanel
|
||||
key={checkOpen ? `${data.draw_no}:check` : data.draw_no}
|
||||
drawNo={data.draw_no}
|
||||
businessDate={data.business_date}
|
||||
drawTimeIso={data.draw_time_iso}
|
||||
drawTime={data.draw_time}
|
||||
defaultOpen={checkOpen}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
@@ -259,14 +259,17 @@ export function DrawResultsListScreen() {
|
||||
{t("results.openDetail", { defaultValue: "查看详情" })}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<TwentyThreeResultsGrid numbers={featured.results} />
|
||||
<Link
|
||||
href="/results/check"
|
||||
className="mt-4 inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#e5002c] px-4 text-sm font-bold text-white transition-colors hover:bg-[#d10028]"
|
||||
>
|
||||
{t("results.viewMyWinning")}
|
||||
</Link>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||
<div key={tier} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
||||
<p className="text-[11px] font-bold text-[#7890b8]">
|
||||
{t(resultsPrizeLabelKey(tier))}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||||
{featured.results[tier]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
354
src/features/results/draw-winning-check-panel.tsx
Normal file
354
src/features/results/draw-winning-check-panel.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { BriefcaseBusiness, CheckCircle2, ChevronDown, Clock3, RefreshCw, XIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getTicketDrawMyMatch, getTicketItems } from "@/api/ticket-items";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items";
|
||||
|
||||
type WinningCheckResult = {
|
||||
draw: DrawResultListItem;
|
||||
match: TicketDrawMyMatchPayload;
|
||||
tickets: TicketItemListRow[];
|
||||
};
|
||||
|
||||
export type DrawWinningCheckPanelProps = {
|
||||
drawNo: string;
|
||||
businessDate?: string | null;
|
||||
drawTimeIso?: string | null;
|
||||
drawTime?: string | null;
|
||||
defaultOpen?: boolean;
|
||||
collapsible?: boolean;
|
||||
};
|
||||
|
||||
export function DrawWinningCheckPanel({
|
||||
drawNo,
|
||||
businessDate,
|
||||
drawTimeIso,
|
||||
drawTime,
|
||||
defaultOpen = false,
|
||||
collapsible = true,
|
||||
}: DrawWinningCheckPanelProps) {
|
||||
const { t } = useTranslation("player");
|
||||
useCurrencyCatalog();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [ticketNo, setTicketNo] = useState("");
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
const [result, setResult] = useState<WinningCheckResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedTicketNo = ticketNo.trim();
|
||||
|
||||
const runCheck = useCallback(async () => {
|
||||
if (normalizedTicketNo === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [match, tickets] = await Promise.all([
|
||||
getTicketDrawMyMatch(drawNo),
|
||||
getTicketItems({
|
||||
draw_no: drawNo,
|
||||
number: normalizedTicketNo,
|
||||
per_page: 10,
|
||||
page: 1,
|
||||
}),
|
||||
]);
|
||||
const next = {
|
||||
draw: {
|
||||
draw_id: "",
|
||||
draw_no: drawNo,
|
||||
business_date: businessDate ?? "",
|
||||
draw_time: drawTime ?? null,
|
||||
draw_time_iso: drawTimeIso ?? null,
|
||||
result_version: 0,
|
||||
result_source: null,
|
||||
results: { "1st": "", "2nd": "", "3rd": "", starter: [], consolation: [] },
|
||||
result_items: [],
|
||||
} satisfies DrawResultListItem,
|
||||
match,
|
||||
tickets: tickets.items,
|
||||
};
|
||||
setResult(next);
|
||||
setRecent((current) =>
|
||||
[normalizedTicketNo, ...current.filter((x) => x !== normalizedTicketNo)].slice(0, 5),
|
||||
);
|
||||
} catch {
|
||||
setError(t("results.check.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [businessDate, drawNo, drawTime, drawTimeIso, normalizedTicketNo, t]);
|
||||
|
||||
const panelBody = (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-black text-slate-700">
|
||||
{t("results.check.ticketNumber")}
|
||||
</span>
|
||||
<Input
|
||||
value={ticketNo}
|
||||
placeholder={t("results.check.placeholder")}
|
||||
onChange={(e) => setTicketNo(e.target.value)}
|
||||
className="h-12 rounded-xl border-[#dce7f7] bg-white font-mono text-base font-bold"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("results.check.forDraw", { drawNo })}
|
||||
</p>
|
||||
{error ? <p className="text-sm font-semibold text-[#e5002c]">{error}</p> : null}
|
||||
<Button
|
||||
type="button"
|
||||
disabled={normalizedTicketNo === "" || loading}
|
||||
onClick={() => void runCheck()}
|
||||
className="h-12 w-full rounded-xl bg-[#e5002c] text-base font-black text-white hover:bg-[#d10028]"
|
||||
>
|
||||
{loading ? t("results.check.loading") : t("results.check.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[#dfe8f6] bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-black text-slate-950">{t("results.check.recent")}</h3>
|
||||
{recent.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-bold text-[#0b56b7]"
|
||||
onClick={() => setRecent([])}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 divide-y divide-[#edf2f9]">
|
||||
{recent.length === 0 ? (
|
||||
<p className="py-4 text-sm text-slate-500">{t("results.check.noRecent")}</p>
|
||||
) : (
|
||||
recent.map((row) => (
|
||||
<button
|
||||
key={row}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between py-3 text-left"
|
||||
onClick={() => setTicketNo(row)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-mono text-sm font-black text-slate-800">
|
||||
<Clock3 className="size-4 text-slate-400" />
|
||||
{row}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{businessDate ?? "—"}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="overflow-hidden rounded-2xl border border-red-100 bg-white shadow-[0_12px_32px_rgba(15,23,42,0.06)]">
|
||||
{collapsible ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 bg-gradient-to-b from-red-50 to-white px-4 py-4 text-left"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-white text-[#e5002c] shadow-[0_12px_28px_rgba(229,0,44,0.12)]">
|
||||
<BriefcaseBusiness className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-black text-slate-950">
|
||||
{t("results.check.title")}
|
||||
</h2>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">
|
||||
{t("results.check.forDraw", { drawNo })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-5 shrink-0 text-[#7890b8] transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{open ? <div className="border-t border-red-100 px-3 pb-3 pt-2">{panelBody}</div> : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-gradient-to-b from-red-50 to-white px-5 pb-5 pt-8 text-center">
|
||||
<div className="mx-auto flex size-24 items-center justify-center rounded-full bg-white text-[#e5002c] shadow-[0_18px_40px_rgba(229,0,44,0.14)]">
|
||||
<BriefcaseBusiness className="size-12" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-lg font-black text-slate-950">
|
||||
{t("results.check.enterTicket")}
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-slate-500">
|
||||
{t("results.check.forDraw", { drawNo })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pb-3">{panelBody}</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<WinningResultDialog
|
||||
open={result !== null}
|
||||
data={result}
|
||||
query={normalizedTicketNo}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setResult(null);
|
||||
}}
|
||||
onCheckAnother={() => {
|
||||
setResult(null);
|
||||
setTicketNo("");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WinningResultDialog({
|
||||
open,
|
||||
data,
|
||||
query,
|
||||
onOpenChange,
|
||||
onCheckAnother,
|
||||
}: {
|
||||
open: boolean;
|
||||
data: WinningCheckResult | null;
|
||||
query: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCheckAnother: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const totalWin = (data?.match.total_win_minor ?? 0) + (data?.match.total_jackpot_win_minor ?? 0);
|
||||
const isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0;
|
||||
const firstTicket = data?.tickets[0] ?? null;
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const currency = firstTicket?.currency_code ?? activeCurrency;
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-h-[calc(100dvh-24px)] flex-col gap-0 overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.28)] sm:max-w-md"
|
||||
>
|
||||
<div className="relative shrink-0 px-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-3 top-3 z-10 inline-flex size-9 items-center justify-center rounded-full text-slate-500 hover:bg-slate-100"
|
||||
aria-label={t("actions.close")}
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</button>
|
||||
<DialogHeader className="items-center text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full border-4 border-emerald-100 bg-white text-emerald-600">
|
||||
<CheckCircle2 className="size-11" />
|
||||
</div>
|
||||
<DialogTitle className="mt-3 text-xl font-black text-slate-950">
|
||||
{isWon ? t("results.check.winTitle") : t("results.check.noWinTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-slate-500">
|
||||
{t("results.check.ticketNumber")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
|
||||
<div className="mx-auto mt-3 w-fit rounded-xl bg-emerald-50 px-8 py-2 font-mono text-lg font-black text-[#0a8f3e]">
|
||||
{query}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 overflow-hidden rounded-xl border border-emerald-100 bg-emerald-50 text-center">
|
||||
<div className="border-r border-emerald-100 px-3 py-4">
|
||||
<p className="text-xs font-medium text-slate-500">{t("results.check.match")}</p>
|
||||
<p className="mt-2 text-lg font-black text-[#0a8f3e]">
|
||||
{firstTicket ? playLabel(firstTicket.play_code, t) : isWon ? t("orders.hit") : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 py-4">
|
||||
<p className="text-xs font-medium text-slate-500">{t("results.check.amount")}</p>
|
||||
<p className="mt-2 font-mono text-lg font-black text-[#0a8f3e]">
|
||||
{formatMinorAsCurrency(totalWin, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-xl border border-[#e8eef7] bg-white p-4 text-sm">
|
||||
<p className="font-black text-slate-950">{t("results.check.drawInfo")}</p>
|
||||
<div className="mt-2.5 grid grid-cols-2 gap-3 text-slate-500">
|
||||
<div>
|
||||
<p className="text-xs">{t("results.check.issueNo")}</p>
|
||||
<p className="mt-1 font-mono font-black text-slate-900">{data.draw.draw_no}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs">{t("results.businessDate")}</p>
|
||||
<p className="mt-1 font-semibold text-slate-900">{data.draw.business_date}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs">{t("results.drawTime", { time: "" }).replace(":", "").trim()}</p>
|
||||
<p className="mt-1 font-semibold text-slate-900">
|
||||
{formatPlayerInstant(data.draw.draw_time_iso ?? data.draw.draw_time ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
nativeButton={false}
|
||||
className="h-12 rounded-xl bg-[#07459f] text-base font-black text-white hover:bg-[#063b88]"
|
||||
render={
|
||||
<Link
|
||||
href={`/orders?draw_no=${encodeURIComponent(data.draw.draw_no)}&number=${encodeURIComponent(query)}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("results.check.viewBetDetails")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 rounded-xl border-[#ff3650] text-base font-black text-[#e5002c] hover:bg-[#fff5f6]"
|
||||
onClick={onCheckAnother}
|
||||
>
|
||||
<RefreshCw className="size-5" />
|
||||
{t("results.check.checkAnother")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user