Files
lotteryFront/src/features/results/check-winning-screen.tsx
kang 0cd85ae287 feat: enhance UI consistency and improve spacing across components
- Added styles for player-side toast notifications to improve user feedback.
- Adjusted padding and spacing in various components for a more cohesive layout.
- Updated card and dialog components to streamline visual hierarchy and enhance readability.
- Refactored player panel and navigation elements for better alignment and user experience.
2026-05-21 17:28:06 +08:00

309 lines
12 KiB
TypeScript

"use client";
import Link from "next/link";
import { BriefcaseBusiness, CheckCircle2, Clock3, RefreshCw, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, 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 { formatLotteryInstant } from "@/lib/player-datetime";
import { playLabel } from "@/lib/play-labels";
import { resolvePlayerCurrency } from "@/lib/player-currency";
import { usePlayerSessionStore } from "@/stores/player-session-store";
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 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);
useEffect(() => {
queueMicrotask(() => {
void (async () => {
try {
const res = await getDrawResults({ page: 1, size: 1 });
setLatestDraw(res.items[0] ?? null);
} catch {
setLatestDraw(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("");
}}
/>
</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 profile = usePlayerSessionStore((state) => state.profile);
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 currency = firstTicket?.currency_code ?? resolvePlayerCurrency(profile);
if (!data) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="max-h-[calc(100dvh-24px)] overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.28)] sm:max-w-md"
>
<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>
<div className="max-h-[calc(100dvh-24px)] overflow-y-auto px-5 pb-5 pt-8">
<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 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">
{formatLotteryInstant(data.draw.draw_time_iso ?? data.draw.draw_time ?? null)}
</p>
</div>
</div>
</div>
<div className="mt-5 grid gap-3">
<Button
type="button"
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>
);
}