feat: 优化开奖结果查询与下注弹窗视觉交互,新增中奖查询页

This commit is contained in:
2026-05-19 14:40:00 +08:00
parent 321b56e997
commit a2a29107f8
17 changed files with 488 additions and 82 deletions

View File

@@ -0,0 +1,5 @@
import { CheckWinningScreen } from "@/features/results/check-winning-screen";
export default function CheckWinningPage() {
return <CheckWinningScreen />;
}

View File

@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "@/components/providers";
import { DEFAULT_LANGUAGE } from "@/i18n";
import { DEFAULT_LANGUAGE } from "@/i18n/language";
import "./globals.css";
const geistSans = Geist({

View File

@@ -2,8 +2,6 @@
import type { ReactNode } from "react";
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/sonner";
import { ErrorProvider } from "@/components/error-provider";
import { IframeBridge } from "@/components/iframe-bridge";
@@ -16,7 +14,7 @@ type ProvidersProps = {
export function Providers({ children }: ProvidersProps): ReactNode {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<>
<ErrorProvider>
{/* iframe 通信桥接 - 支持主站嵌入 */}
<IframeBridge>
@@ -26,6 +24,6 @@ export function Providers({ children }: ProvidersProps): ReactNode {
</IframeBridge>
</ErrorProvider>
<Toaster />
</ThemeProvider>
</>
);
}

View File

@@ -7,6 +7,8 @@ import {
WalletCards,
XIcon,
} from "lucide-react";
import Image from "next/image";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -65,11 +67,17 @@ function SubmittingPanel() {
showCloseButton={false}
className="max-w-[340px] overflow-hidden rounded-2xl border border-white/70 bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div className="px-8 py-10 text-center">
<div className="mx-auto flex size-20 rotate-[-8deg] items-center justify-center rounded-2xl bg-[#e5002c] text-4xl font-black italic text-white shadow-[0_16px_28px_rgba(229,0,44,0.28)]">
N
</div>
<div className="mx-auto mt-8 h-2 w-44 overflow-hidden rounded-full bg-[#dbe3f1]">
<div className="px-8 py-8 text-center">
<Image
src="/entry/image6.png"
alt=""
width={150}
height={119}
className="mx-auto h-[118px] w-[150px] object-contain"
priority
aria-hidden
/>
<div className="mx-auto mt-2 h-2 w-44 overflow-hidden rounded-full bg-[#dbe3f1]">
<div className="h-full w-2/3 rounded-full bg-[#0755c7] shadow-[0_0_14px_rgba(7,85,199,0.45)]" />
</div>
<DialogHeader className="mt-8 items-center gap-2">
@@ -105,6 +113,12 @@ export function HallBetPreviewDialog({
const summary = data?.summary;
const lines = data?.lines ?? [];
useEffect(() => {
if (open && !placing && !data) {
onOpenChange(false);
}
}, [data, onOpenChange, open, placing]);
if (placing) {
return (
<Dialog open={open} onOpenChange={() => {}}>
@@ -113,13 +127,17 @@ export function HallBetPreviewDialog({
);
}
if (!data) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="max-h-[min(92vh,760px)] gap-0 overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_18px_60px_rgba(15,23,42,0.18)] ring-[#e8eef7] sm:max-w-lg"
className="flex max-h-[calc(100dvh-24px)] flex-col gap-0 overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_18px_60px_rgba(15,23,42,0.18)] ring-[#e8eef7] sm:max-h-[min(92vh,760px)] sm:max-w-lg"
>
<div className="relative px-4 pb-3 pt-5 sm:px-5">
<div className="relative shrink-0 px-4 pb-3 pt-5 sm:px-5">
<button
type="button"
onClick={() => onOpenChange(false)}
@@ -154,8 +172,7 @@ export function HallBetPreviewDialog({
</div>
<div
className="overflow-y-auto border-y border-[#e8eef7] px-4 sm:px-5"
style={{ maxHeight: "min(58vh, 470px)" }}
className="min-h-0 flex-1 overflow-y-auto border-y border-[#e8eef7] px-4 sm:px-5"
>
<div className="space-y-4 py-4">
{!data ? (
@@ -274,7 +291,7 @@ export function HallBetPreviewDialog({
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t border-[#e8eef7] bg-white p-4 sm:p-5">
<div className="grid shrink-0 grid-cols-2 gap-3 border-t border-[#e8eef7] bg-white p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] sm:p-5">
<Button
type="button"
variant="outline"

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { CheckCircle2, ClipboardList, Ticket } from "lucide-react";
import { CheckCircle2, ClipboardList, Ticket, XIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -38,8 +38,19 @@ export function HallBetResultDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(92vh,760px)] gap-0 overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_18px_60px_rgba(15,23,42,0.18)] ring-[#e8eef7] sm:max-w-lg">
<div className="px-4 pb-3 pt-7 text-center sm:px-5">
<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_18px_60px_rgba(15,23,42,0.18)] ring-[#e8eef7] sm:max-h-[min(92vh,760px)] sm:max-w-lg"
>
<div className="relative shrink-0 px-4 pb-3 pt-7 text-center sm:px-5">
<button
type="button"
onClick={() => onOpenChange(false)}
className="absolute right-3 top-3 inline-flex size-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
aria-label={t("actions.close", { defaultValue: "关闭" })}
>
<XIcon className="size-5" />
</button>
<div className="mx-auto flex size-16 items-center justify-center rounded-full border-4 border-emerald-100 bg-white text-emerald-600 shadow-[0_10px_24px_rgba(22,163,74,0.12)]">
<CheckCircle2 className="size-11" strokeWidth={2.5} />
</div>
@@ -57,8 +68,7 @@ export function HallBetResultDialog({
</div>
<div
className="overflow-y-auto border-y border-[#e8eef7] px-4 sm:px-5"
style={{ maxHeight: "min(58vh, 470px)" }}
className="min-h-0 flex-1 overflow-y-auto border-y border-[#e8eef7] px-4 sm:px-5"
>
<div className="space-y-4 py-4">
{!data ? (
@@ -190,7 +200,7 @@ export function HallBetResultDialog({
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t border-[#e8eef7] bg-white p-4 sm:p-5">
<div className="grid shrink-0 grid-cols-2 gap-3 border-t border-[#e8eef7] bg-white p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] sm:p-5">
<Button
type="button"
variant="outline"

View File

@@ -1,6 +1,7 @@
"use client";
import { Wallet } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -89,9 +90,15 @@ export function HallWalletStrip() {
<div
className={cn(
"relative overflow-hidden rounded-xl bg-[#e5002c] px-4 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]",
"before:absolute before:inset-y-0 before:right-0 before:w-44 before:bg-[radial-gradient(circle_at_70%_70%,rgba(255,255,255,0.22),transparent_38%),linear-gradient(135deg,transparent,rgba(255,255,255,0.13))] before:content-['']",
)}
>
<Image
src="/entry/image5.png"
alt=""
fill
className="pointer-events-none object-cover object-center"
aria-hidden
/>
<div className="relative flex items-center gap-3">
<div className="flex size-13 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
<Wallet className="size-7" aria-hidden />

View File

@@ -231,16 +231,15 @@ export function EntryGate() {
return (
<div className="relative flex min-h-dvh flex-col bg-white">
<div className="relative h-[45vh] min-h-[320px] bg-red-600">
<div className={cn("relative h-[45vh] min-h-[320px]", phase === "success" ? "bg-white" : "bg-red-600")}>
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<Image
src="/entry/image1.png"
src={phase === "success" ? "/entry/image4.png" : "/entry/image1.png"}
alt={t("header.backgroundAlt")}
fill
className="object-cover object-center"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-red-600/20 to-red-600/80" />
</div>
<div className="absolute left-0 right-0 top-0 z-20 flex items-center px-4 py-3">
@@ -438,9 +437,18 @@ export function EntryGate() {
) : null}
</div>
<div className="flex items-center justify-center gap-2 py-4 text-xs text-gray-500">
<ShieldCheck className="size-4 text-red-500" aria-hidden />
<span>{t("footer.secure")}</span>
<div className="relative min-h-[150px] overflow-hidden px-4 pb-8 pt-16">
<Image
src="/entry/image2.png"
alt=""
fill
className="pointer-events-none object-cover object-bottom"
aria-hidden
/>
<div className="relative z-10 flex items-center justify-center gap-2 text-xs font-medium text-gray-600">
<ShieldCheck className="size-4 text-red-500" aria-hidden />
<span>{t("footer.secure")}</span>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,302 @@
"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 { formatMinorAsCurrency } from "@/lib/money";
import { formatLotteryInstant } from "@/lib/player-datetime";
import { playLabel } from "@/lib/play-labels";
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");
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", { defaultValue: "查询失败,请稍后重试。" }));
} finally {
setLoading(false);
}
}, [latestDraw, normalizedTicketNo, t]);
return (
<PlayerPanel title={t("results.check.title", { defaultValue: "查我的中奖" })} backHref="/results" backLabel={t("results.title")}>
<div className="space-y-4">
<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", { defaultValue: "输入你的票号或号码" })}
</h2>
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-slate-500">
{t("results.check.description", { defaultValue: "系统会按最新已发布期号查询你的注单和中奖情况。" })}
</p>
</div>
<div className="space-y-3 px-4 pb-4">
<label className="block space-y-1.5">
<span className="text-xs font-black text-slate-700">
{t("results.check.ticketNumber", { defaultValue: "票号 / 号码" })}
</span>
<Input
value={ticketNo}
placeholder={t("results.check.placeholder", { defaultValue: "请输入票号或号码" })}
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, defaultValue: "最新期号 {{drawNo}}" })}
</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("actions.loading", { defaultValue: "查询中..." }) : t("results.check.submit", { defaultValue: "立即查询" })}
</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", { defaultValue: "最近查询" })}
</h3>
{recent.length > 0 ? (
<button type="button" className="text-sm font-bold text-[#0b56b7]" onClick={() => setRecent([])}>
{t("actions.clear", { defaultValue: "清空" })}
</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", { defaultValue: "暂无查询记录。" })}
</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 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]);
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", { defaultValue: "关闭" })}
>
<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", { defaultValue: "恭喜,你中奖了" })
: t("results.check.noWinTitle", { defaultValue: "未查询到中奖" })}
</DialogTitle>
<DialogDescription className="text-sm text-slate-500">
{t("results.check.ticketNumber", { defaultValue: "票号 / 号码" })}
</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", { defaultValue: "匹配" })}
</p>
<p className="mt-2 text-lg font-black text-[#0a8f3e]">
{firstTicket ? playLabel(firstTicket.play_code, t) : isWon ? t("orders.hit", { defaultValue: "命中" }) : "—"}
</p>
</div>
<div className="px-3 py-4">
<p className="text-xs font-medium text-slate-500">
{t("results.check.amount", { defaultValue: "中奖金额" })}
</p>
<p className="mt-2 font-mono text-lg font-black text-[#0a8f3e]">
{formatMinorAsCurrency(totalWin, firstTicket?.currency_code ?? "NPR")}
</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", { defaultValue: "开奖信息" })}
</p>
<div className="mt-3 grid grid-cols-2 gap-4 text-slate-500">
<div>
<p className="text-xs">{t("results.check.issueNo", { defaultValue: "期号" })}</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", { defaultValue: "查看注单详情" })}
</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", { defaultValue: "查询另一张票" })}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -271,7 +271,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
{t("results.hitHint")}
</p>
<Link
href={`/orders?draw_no=${encodeURIComponent(data.draw_no)}&status=settled_win`}
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] sm:w-auto",

View File

@@ -268,7 +268,7 @@ export function DrawResultsListScreen() {
<div className="pt-4">
<TwentyThreeResultsGrid numbers={featured.results} />
<Link
href={`/orders?draw_no=${encodeURIComponent(featured.draw_no)}&status=settled_win`}
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")}

View File

@@ -1,4 +1,5 @@
import type { DrawResultsNumbers } from "@/types/api/draw-results";
import { Trophy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { norm4d } from "@/lib/norm-4d";
@@ -22,65 +23,105 @@ export function TwentyThreeResultsGrid({
const consos = numbers.consolation ?? [];
const hits = highlighted4d ?? null;
const cellBase =
"flex min-h-[2.75rem] items-center justify-center rounded-md border font-mono text-base font-semibold tracking-wide tabular-nums";
const cellTone = (raw: string) => {
const isHit = (raw: string): boolean => {
const v = (raw || "").trim();
const isHit =
return (
hits !== null &&
hits.size > 0 &&
v !== "" &&
v !== "—" &&
hits.has(norm4d(v));
return cn(
cellBase,
isHit
? "border-amber-500/80 bg-gradient-to-br from-amber-300 via-amber-400 to-amber-500 text-amber-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.35)]"
: "border-border bg-card",
hits.has(norm4d(v))
);
};
const smallCellTone = (raw: string, tone: "red" | "blue") =>
cn(
"grid min-h-[3.875rem] grid-rows-[auto_1fr] rounded-lg border bg-white px-1.5 py-2 text-center shadow-[0_6px_16px_rgba(15,23,42,0.04)]",
tone === "red" ? "border-red-100 text-[#e5002c]" : "border-blue-100 text-[#0b56b7]",
isHit(raw) && "border-amber-400 bg-amber-50 text-amber-700 shadow-[0_8px_18px_rgba(245,158,11,0.16)]",
);
const prizeCards = [
{
key: "1st" as const,
label: t("results.grid.first"),
value: numbers["1st"] || "—",
tone: "red",
border: "border-[#ffb8c3]",
text: "text-[#e5002c]",
wash: "from-[#fff4f6] to-white",
},
{
key: "2nd" as const,
label: t("results.grid.second"),
value: numbers["2nd"] || "—",
tone: "blue",
border: "border-[#b9ccf6]",
text: "text-[#0b56b7]",
wash: "from-[#f3f7ff] to-white",
},
{
key: "3rd" as const,
label: t("results.grid.third"),
value: numbers["3rd"] || "—",
tone: "green",
border: "border-[#bde7cc]",
text: "text-[#0a8f3e]",
wash: "from-[#f0fff5] to-white",
},
];
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-2">
{(["1st", "2nd", "3rd"] as const).map((key) => (
<div key={key} className="flex flex-col gap-1.5 text-center">
<span className="text-xs font-medium uppercase text-muted-foreground">
{key === "1st"
? t("results.grid.first")
: key === "2nd"
? t("results.grid.second")
: t("results.grid.third")}
</span>
<div className={cellTone(numbers[key] || "")}>
{numbers[key] || "—"}
{prizeCards.map((card) => (
<div
key={card.key}
className={cn(
"relative overflow-hidden rounded-xl border bg-gradient-to-b px-2 py-4 text-center shadow-[0_10px_24px_rgba(15,23,42,0.06)]",
card.border,
card.wash,
isHit(card.value) && "ring-2 ring-amber-300",
)}
>
<div className={cn("mx-auto flex size-9 items-center justify-center rounded-full text-white", card.tone === "red" ? "bg-[#e5002c]" : card.tone === "blue" ? "bg-[#0b56b7]" : "bg-[#0a8f3e]")}>
<Trophy className="size-5" />
</div>
<p className={cn("mt-3 text-xs font-black", card.text)}>{card.label}</p>
<p className={cn("mt-2 font-mono text-3xl font-black tabular-nums", card.text)}>{card.value}</p>
</div>
))}
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
{t("results.grid.starter")} (Starter)
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.05)]">
<p className="mb-3 flex items-center gap-2 text-sm font-black text-[#e5002c]">
<Trophy className="size-4" />
{t("results.grid.starter")}
</p>
<div className="grid grid-cols-5 gap-2">
<div className="grid grid-cols-5 gap-1.5">
{Array.from({ length: 10 }).map((_, i) => (
<div key={`s-${i}`} className={cellTone(starters[i] ?? "—")}>
{starters[i] ?? "—"}
<div key={`s-${i}`} className={smallCellTone(starters[i] ?? "—", "red")}>
<span className="text-[11px] font-black">{i + 1}</span>
<span className="self-center font-mono text-xs font-semibold tabular-nums text-slate-700">
{starters[i] ?? "—"}
</span>
</div>
))}
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
{t("results.grid.consolation")} (Consolation)
<div className="rounded-xl border border-blue-100 bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.05)]">
<p className="mb-3 flex items-center gap-2 text-sm font-black text-[#0b56b7]">
<Trophy className="size-4" />
{t("results.grid.consolation")}
</p>
<div className="grid grid-cols-5 gap-2">
<div className="grid grid-cols-5 gap-1.5">
{Array.from({ length: 10 }).map((_, i) => (
<div key={`c-${i}`} className={cellTone(consos[i] ?? "—")}>
{consos[i] ?? "—"}
<div key={`c-${i}`} className={smallCellTone(consos[i] ?? "—", "blue")}>
<span className="text-[11px] font-black">{i + 1}</span>
<span className="self-center font-mono text-xs font-semibold tabular-nums text-slate-700">
{consos[i] ?? "—"}
</span>
</div>
))}
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { Wallet } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -157,6 +158,13 @@ export function WalletScreen() {
) : null}
<section className="relative overflow-hidden rounded-xl bg-[#e5002c] px-4 py-5 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]">
<Image
src="/entry/image5.png"
alt=""
fill
className="pointer-events-none object-cover object-center"
aria-hidden
/>
<div className="relative flex items-center gap-3">
<div className="flex size-13 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
<Wallet className="size-7" aria-hidden />

View File

@@ -16,17 +16,17 @@ import zhCommon from "./locales/zh/common.json";
import zhEntry from "./locales/zh/entry.json";
import zhLayout from "./locales/zh/layout.json";
import zhPlayer from "./locales/zh/player.json";
/** 对齐后端与产品:尼泊尔语 / 英语 / 中文(简体) */
export const SUPPORTED_LANGUAGES = [
{ code: "en" as const, flag: "🇺🇸" },
{ code: "ne" as const, flag: "🇳🇵" },
{ code: "zh" as const, flag: "🇨🇳" },
];
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
export const DEFAULT_LANGUAGE: AppLanguage = "en";
import {
DEFAULT_LANGUAGE,
normalizeLanguage,
type AppLanguage,
} from "@/i18n/language";
export {
DEFAULT_LANGUAGE,
normalizeLanguage,
SUPPORTED_LANGUAGES,
type AppLanguage,
} from "@/i18n/language";
const namespaces = ["common", "entry", "layout", "player"] as const;
@@ -54,13 +54,6 @@ const resources = {
Record<(typeof namespaces)[number], Record<string, unknown>>
>;
export function normalizeLanguage(lang: string | undefined): AppLanguage {
const base = lang?.split("-")[0]?.toLowerCase();
if (base === "ne") return "ne";
if (base === "zh") return "zh";
return "en";
}
export function syncDocumentLanguage(lang: AppLanguage): void {
if (typeof document === "undefined") return;

17
src/i18n/language.ts Normal file
View File

@@ -0,0 +1,17 @@
/** 对齐后端与产品:尼泊尔语 / 英语 / 中文(简体) */
export const SUPPORTED_LANGUAGES = [
{ code: "en" as const, flag: "🇺🇸" },
{ code: "ne" as const, flag: "🇳🇵" },
{ code: "zh" as const, flag: "🇨🇳" },
];
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
export const DEFAULT_LANGUAGE: AppLanguage = "zh";
export function normalizeLanguage(lang: string | undefined): AppLanguage {
const base = lang?.split("-")[0]?.toLowerCase();
if (base === "ne") return "ne";
if (base === "zh") return "zh";
return "en";
}