Files
lotteryFront/src/features/results/draw-results-list-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

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}`;
}