- 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.
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { CalendarIcon, XIcon } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { getDrawResults } from "@/api/draw";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} 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 { formatLotteryInstant } from "@/lib/player-datetime";
|
|
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
import type { DrawResultListItem } from "@/types/api/draw-results";
|
|
|
|
const RESULTS_PAGE_SIZE = 10;
|
|
|
|
const MONTH_OPTIONS = Array.from({ length: 12 }, (_, value) => ({
|
|
value,
|
|
labelKey: `calendar.months.${value + 1}`,
|
|
}));
|
|
|
|
export function DrawResultsListScreen() {
|
|
const { t } = useTranslation("player");
|
|
const profile = usePlayerSessionStore((state) => state.profile);
|
|
useCurrencyCatalog();
|
|
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [date, setDate] = useState("");
|
|
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
|
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
|
const [page, setPage] = useState(1);
|
|
const [lastPage, setLastPage] = useState(1);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
|
const selectedDate = useMemo(() => parseBusinessDate(date), [date]);
|
|
const businessDate = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined;
|
|
const quickYears = useMemo(() => buildYearOptions(calendarMonth), [calendarMonth]);
|
|
const featured = items?.[0] ?? null;
|
|
const jackpotCurrency = featured?.jackpot?.currency_code ?? resolvePlayerCurrency(profile);
|
|
|
|
const fetchList = useCallback(async (targetPage = 1, append = false) => {
|
|
setError(null);
|
|
if (append) {
|
|
setLoadingMore(true);
|
|
} else {
|
|
setLoading(true);
|
|
}
|
|
|
|
try {
|
|
const res = await getDrawResults({
|
|
page: targetPage,
|
|
size: RESULTS_PAGE_SIZE,
|
|
business_date: businessDate,
|
|
});
|
|
setItems((current) => (append && current ? [...current, ...res.items] : res.items));
|
|
setPage(res.page);
|
|
setLastPage(res.last_page);
|
|
} catch {
|
|
setError(t("results.loadFailed"));
|
|
if (!append) {
|
|
setItems(null);
|
|
}
|
|
} finally {
|
|
if (append) {
|
|
setLoadingMore(false);
|
|
} else {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}, [businessDate, t]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void fetchList(1, false);
|
|
});
|
|
}, [fetchList]);
|
|
|
|
useEffect(() => {
|
|
const target = loadMoreRef.current;
|
|
if (!target || loading || loadingMore || page >= lastPage) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry?.isIntersecting) {
|
|
void fetchList(page + 1, true);
|
|
}
|
|
},
|
|
{ rootMargin: "160px" },
|
|
);
|
|
|
|
observer.observe(target);
|
|
return () => observer.disconnect();
|
|
}, [fetchList, lastPage, loading, loadingMore, page]);
|
|
|
|
return (
|
|
<PlayerPanel title={t("results.title")}>
|
|
<div className="space-y-3">
|
|
<JackpotResultsStrip currencyCode={jackpotCurrency} />
|
|
|
|
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
|
<p className="mb-2 text-xs font-bold text-[#32518d]">
|
|
{t("results.businessDate")}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-10 flex-1 justify-start rounded-lg border-[#dce7f7] bg-white px-3 text-left text-sm font-semibold text-[#32518d] hover:bg-[#f8fbff]"
|
|
>
|
|
<CalendarIcon className="mr-2 size-4 text-[#7890b8]" />
|
|
{date ||
|
|
t("results.selectBusinessDate", {
|
|
defaultValue: "选择日期",
|
|
})}
|
|
</Button>
|
|
}
|
|
/>
|
|
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
|
<div className="mb-2 grid grid-cols-2 gap-2">
|
|
<Select
|
|
value={String(calendarMonth.getMonth())}
|
|
onValueChange={(value) => {
|
|
setCalendarMonth((current) => new Date(current.getFullYear(), Number(value), 1));
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-64 min-w-24">
|
|
{MONTH_OPTIONS.map((month) => (
|
|
<SelectItem key={month.value} value={String(month.value)}>
|
|
{t(month.labelKey)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
value={String(calendarMonth.getFullYear())}
|
|
onValueChange={(value) => {
|
|
setCalendarMonth((current) => new Date(Number(value), current.getMonth(), 1));
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-64 min-w-24">
|
|
{quickYears.map((year) => (
|
|
<SelectItem key={year} value={String(year)}>
|
|
{year}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Calendar
|
|
mode="single"
|
|
month={calendarMonth}
|
|
onMonthChange={setCalendarMonth}
|
|
selected={selectedDate}
|
|
onSelect={(value) => {
|
|
if (!value) return;
|
|
setDate(formatBusinessDate(value));
|
|
setCalendarMonth(value);
|
|
setDatePickerOpen(false);
|
|
}}
|
|
className="rounded-lg"
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{date ? (
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="outline"
|
|
className="h-10 w-10 rounded-lg border-[#dce7f7] bg-white text-[#7890b8] hover:bg-[#f8fbff] hover:text-[#32518d]"
|
|
aria-label={t("actions.clear")}
|
|
onClick={() => setDate("")}
|
|
>
|
|
<XIcon className="size-4" />
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="h-10 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
|
|
onClick={() => void fetchList(1, false)}
|
|
>
|
|
{t("actions.apply")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-28 rounded-xl" />
|
|
<Skeleton className="h-28 rounded-xl" />
|
|
<Skeleton className="h-28 rounded-xl" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
|
<p>{error}</p>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
|
onClick={() => void fetchList(1, false)}
|
|
>
|
|
{t("actions.retry")}
|
|
</Button>
|
|
</div>
|
|
) : items && items.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center text-sm text-slate-500">
|
|
{t("results.empty")}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{featured ? (
|
|
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#edf2f9] pb-3">
|
|
<div className="min-w-0">
|
|
<p className="text-[11px] font-black uppercase tracking-normal text-[#0b56b7]">
|
|
{t("results.detailTitle")}
|
|
</p>
|
|
<p className="mt-1 font-mono text-lg font-black text-[#0b3f96]">
|
|
{featured.draw_no}
|
|
</p>
|
|
<p className="mt-1 font-mono text-xs text-slate-500">
|
|
{t("results.drawTime", {
|
|
time: formatLotteryInstant(
|
|
featured.draw_time_iso ?? featured.draw_time ?? null,
|
|
),
|
|
})}
|
|
</p>
|
|
</div>
|
|
<Link
|
|
href={`/results/${encodeURIComponent(featured.draw_no)}`}
|
|
className="inline-flex h-8 shrink-0 items-center justify-center rounded-full border border-[#dce7f7] bg-white px-3 text-sm font-semibold text-[#0b56b7] transition-colors hover:bg-[#f1f6ff]"
|
|
>
|
|
{t("results.openDetail", { defaultValue: "查看详情" })}
|
|
</Link>
|
|
</div>
|
|
<div className="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>
|
|
</div>
|
|
) : null}
|
|
|
|
{items?.map((row) => (
|
|
<Link
|
|
key={row.draw_no}
|
|
href={`/results/${encodeURIComponent(row.draw_no)}`}
|
|
className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate font-mono text-sm font-black text-[#0b3f96]">
|
|
{row.draw_no}
|
|
</p>
|
|
<p className="mt-1 text-[11px] text-slate-500">
|
|
{formatLotteryInstant(row.draw_time_iso ?? row.draw_time ?? null)}
|
|
</p>
|
|
</div>
|
|
<span className="shrink-0 rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
|
{t("results.detail")}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
|
{[
|
|
["1st", row.results["1st"]],
|
|
["2nd", row.results["2nd"]],
|
|
["3rd", row.results["3rd"]],
|
|
].map(([label, value]) => (
|
|
<div key={label} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
|
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{label}</p>
|
|
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
|
{value}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Link>
|
|
))}
|
|
<div ref={loadMoreRef} className="min-h-1" />
|
|
{page < lastPage ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
|
disabled={loadingMore}
|
|
onClick={() => void fetchList(page + 1, true)}
|
|
>
|
|
{loadingMore
|
|
? t("actions.loading")
|
|
: t("actions.loadMore")}
|
|
</Button>
|
|
) : items && items.length > 0 ? (
|
|
<p className="py-2 text-center text-xs text-slate-400">
|
|
{t("results.noMore")}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PlayerPanel>
|
|
);
|
|
}
|
|
|
|
function buildYearOptions(month: Date): number[] {
|
|
const currentYear = new Date().getFullYear();
|
|
const visibleYear = month.getFullYear();
|
|
const start = Math.min(currentYear - 10, visibleYear - 2);
|
|
const end = Math.max(currentYear + 2, visibleYear + 2);
|
|
|
|
return Array.from({ length: end - start + 1 }, (_, index) => start + index);
|
|
}
|
|
|
|
function parseBusinessDate(value: string): Date | undefined {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
if (!match) return undefined;
|
|
|
|
const [, year, month, day] = match;
|
|
return new Date(Number(year), Number(month) - 1, Number(day));
|
|
}
|
|
|
|
function formatBusinessDate(value: Date): string {
|
|
const year = value.getFullYear();
|
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
|
const day = String(value.getDate()).padStart(2, "0");
|
|
|
|
return `${year}-${month}-${day}`;
|
|
}
|