feat: 增强国际化支持与安全头配置

- 在 .env.example 中新增 i18next 相关配置项以支持多语言功能
- 在 next.config.ts 中添加安全头配置以支持 iframe 嵌入
- 更新 Providers 组件以引入 i18n 配置
- 在 PlayerAppShell 中集成 LanguageSwitcher 组件以实现语言切换功能
- 优化 HallWalletStrip 组件的网络状态管理逻辑
- 更新多个组件以支持国际化文本
This commit is contained in:
2026-05-13 17:53:56 +08:00
parent c8f8f90515
commit 587a6ad66c
32 changed files with 2126 additions and 436 deletions

View File

@@ -2,7 +2,7 @@
import { Wallet } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getWalletBalance } from "@/api/wallet";
import { buttonVariants } from "@/components/ui/button";
@@ -27,18 +27,10 @@ export function HallWalletStrip() {
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [loading, setLoading] = useState(true);
// 网络连接状态(用于降级模式下的轮询)
const mode = useNetworkConnectionStore((s) => s.mode);
const walletPollingIntervalId = useNetworkConnectionStore(
(s) => s.walletPollingIntervalId,
);
const setWalletPollingIntervalId = useNetworkConnectionStore(
(s) => s.setWalletPollingIntervalId,
);
const setWalletPollingExpiryAt = useNetworkConnectionStore(
(s) => s.setWalletPollingExpiryAt,
);
const clearWalletPolling = useNetworkConnectionStore((s) => s.clearWalletPolling);
/** 降级模式下的本地兜底轮询(勿写入全局 walletPollingIntervalId避免与 useWebSocketManager 互相覆盖/触发 effect 死循环) */
const degradedWalletPollRef = useRef<number | null>(null);
const currency = useMemo(
() =>
@@ -72,49 +64,31 @@ export function HallWalletStrip() {
return () => window.removeEventListener("lottery-wallet-refresh", onRefresh);
}, [refresh]);
// 监听钱包轮询状态变化(由下注或开奖结果触发)
// 降级模式下本地兜底轮询60s与 WebSocket 管理器里的 *_wallet* 全局 timer 隔离
useEffect(() => {
// 如果有活跃的轮询计时器,监听它并执行刷新
if (walletPollingIntervalId) {
// 轮询已在全局管理中设置,这里只需监听轮询触发的事件
const handlePollingRefresh = () => void refresh();
window.addEventListener("lottery-wallet-refresh", handlePollingRefresh);
return () => {
window.removeEventListener("lottery-wallet-refresh", handlePollingRefresh);
};
}
}, [walletPollingIntervalId, refresh]);
// 降级模式下的定期刷新(作为兜底)
useEffect(() => {
// 只有在降级模式下才启动兜底轮询
if (mode !== "polling" && mode !== "offline") {
if (degradedWalletPollRef.current !== null) {
window.clearInterval(degradedWalletPollRef.current);
degradedWalletPollRef.current = null;
}
return;
}
// 如果已经有活跃的轮询计时器,不重复设置
if (walletPollingIntervalId) {
if (degradedWalletPollRef.current !== null) {
return;
}
// 设置兜底轮询60秒一次避免过于频繁
const intervalId = window.setInterval(() => {
degradedWalletPollRef.current = window.setInterval(() => {
void refresh();
}, 60_000);
setWalletPollingIntervalId(intervalId);
return () => {
window.clearInterval(intervalId);
clearWalletPolling();
if (degradedWalletPollRef.current !== null) {
window.clearInterval(degradedWalletPollRef.current);
degradedWalletPollRef.current = null;
}
};
}, [
mode,
walletPollingIntervalId,
refresh,
setWalletPollingIntervalId,
clearWalletPolling,
]);
}, [mode, refresh]);
const lotteryMinor = Number(balance?.balance ?? 0);
const availableMinor = Number(balance?.available_balance ?? 0);

View File

@@ -2,41 +2,60 @@
import { isAxiosError } from "axios";
import {
AlertCircle,
AlertTriangle,
Bell,
Check,
CheckCircle2,
ChevronRight,
Languages,
Globe,
Loader2,
Shield,
ShieldCheck,
} from "lucide-react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getPlayerMe, getPlayerPing } from "@/api/player";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { LanguageSwitcher } from "@/components/language-switcher";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { LotteryApiBizError } from "@/types/api/errors";
const MAIN_SITE_URL = process.env.NEXT_PUBLIC_MAIN_SITE_URL?.trim() ?? "";
const RETRY_ATTEMPTS = 3;
const RETRY_DELAY_MS = 2000;
type EntryStepId = "token" | "account" | "hall";
type EntryStepStatus = "pending" | "in-progress" | "done" | "error";
type EntryStep = {
id: EntryStepId;
status: EntryStepStatus;
};
type Phase = "loading" | "success" | "failed";
type FailureRow = {
code?: string;
/** `entry` 命名空间下的 key例如 `errors.noTokenDetail` */
detailKey?: string;
/** 服务端或动态错误兜底 */
fallbackMessage?: string;
};
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function initialSteps(): EntryStep[] {
return [
{ id: "token", status: "pending" },
{ id: "account", status: "pending" },
{ id: "hall", status: "pending" },
];
}
function shouldRetryEntryRequest(error: unknown): boolean {
if (error instanceof LotteryApiBizError) {
return false;
@@ -48,347 +67,401 @@ function shouldRetryEntryRequest(error: unknown): boolean {
if (!error.response) {
return true;
}
const s = error.response.status;
return s >= 500 || s === 429;
if (error.response.status >= 500) {
return true;
}
}
return false;
}
async function withEntryRetries<T>(fn: () => Promise<T>): Promise<T> {
let last: unknown;
for (let i = 0; i < RETRY_ATTEMPTS; i++) {
try {
return await fn();
} catch (e) {
last = e;
if (!shouldRetryEntryRequest(e) || i === RETRY_ATTEMPTS - 1) {
throw e;
}
await sleep(RETRY_DELAY_MS);
}
}
throw last;
}
function normalizeTokenInput(raw: string | null): string | null {
if (raw === null) {
return null;
}
const t = decodeURIComponent(raw).trim();
return t === "" ? null : t;
}
function stripTokenFromUrl(): void {
if (typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
if (!url.searchParams.has("token")) {
return;
}
url.searchParams.delete("token");
const qs = url.searchParams.toString();
window.history.replaceState(
{},
"",
`${url.pathname}${qs ? `?${qs}` : ""}${url.hash}`,
);
}
export function EntryGate(): ReactNode {
export function EntryGate() {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation("entry");
const { t: tc } = useTranslation("common");
const phase = usePlayerSessionStore((state) => state.phase);
const progress = usePlayerSessionStore((state) => state.progress);
const errorMessage = usePlayerSessionStore((state) => state.errorMessage);
const steps = usePlayerSessionStore((state) => state.steps);
const setBearerToken = usePlayerSessionStore((state) => state.setBearerToken);
const restoreBearerToken = usePlayerSessionStore(
(state) => state.restoreBearerToken,
);
const clearBearerToken = usePlayerSessionStore(
(state) => state.clearBearerToken,
);
const setProfile = usePlayerSessionStore((state) => state.setProfile);
const setPhase = usePlayerSessionStore((state) => state.setPhase);
const setProgress = usePlayerSessionStore((state) => state.setProgress);
const setErrorMessage = usePlayerSessionStore(
(state) => state.setErrorMessage,
);
const updateStep = usePlayerSessionStore((state) => state.updateStep);
const resetEntryFlow = usePlayerSessionStore((state) => state.resetEntryFlow);
const tokenFromUrl = searchParams.get("token") ?? "";
const applyProgress = useCallback(
(doneCount: number) => {
setProgress(Math.round((doneCount / 3) * 100));
},
[setProgress],
);
const { bearerToken, setBearerToken, setProfile, clearBearerToken } =
usePlayerSessionStore();
const runBootstrap = useCallback(async () => {
resetEntryFlow();
const [phase, setPhase] = useState<Phase>("loading");
const [progress, setProgress] = useState(0);
const [failureDetails, setFailureDetails] = useState<FailureRow[]>([]);
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
const fromQuery = normalizeTokenInput(searchParams.get("token"));
const fromStorage = normalizeTokenInput(restoreBearerToken());
const token = fromQuery ?? fromStorage;
const effectiveToken = tokenFromUrl || bearerToken;
if (fromQuery) {
stripTokenFromUrl();
}
const updateStep = useCallback((stepId: EntryStepId, status: EntryStepStatus) => {
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
}, []);
if (!token) {
setPhase("error");
const sessionFlag = searchParams.get("session");
setErrorMessage(
sessionFlag === "expired"
? "登录已失效,请从主站重新进入彩票系统。"
: "缺少登录凭证,请从主站重新进入彩票系统。",
);
updateStep("token", "pending");
applyProgress(0);
const calculateProgress = useCallback((currentSteps: EntryStep[]) => {
const doneCount = currentSteps.filter((s) => s.status === "done").length;
const inProgressCount = currentSteps.filter((s) => s.status === "in-progress").length;
return Math.round(((doneCount + inProgressCount * 0.5) / currentSteps.length) * 100);
}, []);
useEffect(() => {
setProgress(calculateProgress(steps));
}, [steps, calculateProgress]);
const handleRetry = useCallback(() => {
setPhase("loading");
setFailureDetails([]);
setSteps(initialSteps());
}, []);
const doEntry = useCallback(async () => {
if (!effectiveToken) {
setPhase("failed");
setFailureDetails([{ code: "NO_TOKEN", detailKey: "errors.noTokenDetail" }]);
return;
}
setBearerToken(token);
if (tokenFromUrl) {
setBearerToken(tokenFromUrl);
}
try {
updateStep("token", "done");
applyProgress(1);
updateStep("account", "active");
setSteps((prev) =>
prev.map((s) => (s.id === "token" ? { ...s, status: "in-progress" } : s)),
);
const profile = await withEntryRetries(() => getPlayerMe());
setProfile(profile);
await sleep(500);
updateStep("account", "done");
applyProgress(2);
updateStep("hall", "active");
let lastError: unknown = null;
await withEntryRetries(() => getPlayerPing());
for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
try {
const [me] = await Promise.all([getPlayerMe(), sleep(300)]);
updateStep("hall", "done");
applyProgress(3);
setProgress(100);
setPhase("success");
} catch (e) {
const authFailure =
e instanceof LotteryApiBizError ||
(isAxiosError(e) && e.response?.status === 401);
if (authFailure) {
clearBearerToken();
}
setPhase("error");
if (e instanceof LotteryApiBizError) {
setErrorMessage(
e.code === 8001 || e.code === 8002
? "授权已失效,请从主站重新进入。"
: e.message,
);
} else if (isAxiosError(e) && !e.response) {
setErrorMessage(
`网络异常,已重试 ${RETRY_ATTEMPTS} 次仍失败,请稍后再试。`,
);
} else if (isAxiosError(e)) {
setErrorMessage(e.message || "请求失败,请稍后重试。");
} else {
setErrorMessage(
e instanceof Error ? e.message : "进入彩票系统失败,请稍后重试。",
updateStep("token", "done");
updateStep("account", "done");
setSteps((prev) =>
prev.map((s) => (s.id === "hall" ? { ...s, status: "in-progress" } : s)),
);
await Promise.all([getPlayerPing(), sleep(300)]);
updateStep("hall", "done");
setProfile(me);
setPhase("success");
await sleep(600);
router.replace("/hall");
return;
} catch (err) {
lastError = err;
if (err instanceof LotteryApiBizError) {
updateStep("token", "error");
setPhase("failed");
const details: FailureRow[] = [];
if (typeof err.code === "number") {
const keyByCode: Partial<Record<number, string>> = {
401: "errors.http401",
403: "errors.http403",
404: "errors.http404",
};
const dk = keyByCode[err.code];
details.push({
code: String(err.code),
...(dk ? { detailKey: dk } : {}),
fallbackMessage:
typeof err.message === "string" ? err.message : undefined,
});
}
const rows =
details.length > 0
? details
: [
{
fallbackMessage: err.message ?? t("errors.unknown"),
},
];
setFailureDetails(rows);
clearBearerToken();
return;
}
if (!shouldRetryEntryRequest(err)) {
updateStep("token", "error");
setPhase("failed");
setFailureDetails([{ code: "NETWORK_ERROR", detailKey: "errors.networkDetail" }]);
return;
}
if (attempt < RETRY_ATTEMPTS) {
await sleep(RETRY_DELAY_MS);
}
}
}
updateStep("token", "error");
setPhase("failed");
setFailureDetails([
{ code: "MAX_RETRIES", detailKey: "errors.maxRetriesDetail" },
{
fallbackMessage:
lastError instanceof Error ? lastError.message : t("errors.tryLater"),
},
]);
}, [
applyProgress,
clearBearerToken,
resetEntryFlow,
restoreBearerToken,
searchParams,
effectiveToken,
tokenFromUrl,
setBearerToken,
setErrorMessage,
setPhase,
setProfile,
setProgress,
clearBearerToken,
router,
updateStep,
t,
]);
useEffect(() => {
void runBootstrap();
}, [runBootstrap]);
const tmr = window.setTimeout(() => {
void doEntry();
}, 300);
return () => window.clearTimeout(tmr);
}, [doEntry]);
return (
<div className="flex min-h-dvh flex-col bg-gradient-to-b from-red-800 via-red-900 to-red-950 text-white">
<header className="flex h-14 shrink-0 items-center justify-between px-4 sm:h-16">
<span className="text-lg font-bold tracking-tight">Lottery</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="gap-1 text-white hover:bg-white/10 hover:text-white"
type="button"
disabled
aria-label="语言(即将支持)"
>
<Languages className="size-4 opacity-80" />
<span className="text-xs opacity-90">EN</span>
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
type="button"
disabled
aria-label="通知(即将支持)"
>
<Bell className="size-4 opacity-80" />
</Button>
<div className="relative flex min-h-dvh flex-col bg-white">
<div className="relative h-[45vh] min-h-[320px] bg-red-600">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<Image
src="/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>
</header>
<div className="flex min-h-0 flex-1 flex-col justify-center px-4 py-6 sm:py-8">
<div className="mx-auto w-full max-w-lg">
<p className="mb-6 text-center text-sm text-white/85">
Play smart. Win more.
</p>
{phase === "loading" || phase === "success" ? (
<Card className="border-0 shadow-xl">
<CardHeader className="text-center">
<CardTitle className="text-foreground">
{phase === "success"
? "授权成功"
: "正在进入彩票系统"}
</CardTitle>
<CardDescription>
{phase === "success"
? "即将跳转至下注大厅"
: "请稍候,正在连接服务器"}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div>
<div className="mb-2 flex justify-between text-xs text-muted-foreground">
<span></span>
<span>{progress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<ul className="flex flex-col gap-3">
{steps.map((s) => (
<li
key={s.id}
className="flex items-center gap-3 text-sm text-foreground"
>
{s.status === "done" ? (
<Check
aria-hidden
className="size-4 shrink-0 text-green-600"
/>
) : s.status === "active" ? (
<Loader2
aria-hidden
className="size-4 shrink-0 animate-spin text-primary"
/>
) : (
<span
aria-hidden
className="size-4 shrink-0 rounded-full border border-muted-foreground/40"
/>
)}
<span className="flex-1">{s.label}</span>
<span className="text-xs text-muted-foreground">
{s.status === "done"
? "完成"
: s.status === "active"
? "进行中"
: "等待"}
</span>
</li>
))}
</ul>
</CardContent>
{phase === "success" ? (
<CardFooter className="flex flex-col gap-2">
<Button
className="w-full"
type="button"
onClick={() => router.push("/hall")}
>
<ChevronRight data-icon="inline-end" />
</Button>
</CardFooter>
) : null}
</Card>
) : null}
{phase === "error" ? (
<Card className="border-destructive/30 bg-destructive/5 shadow-xl">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex size-12 items-center justify-center rounded-full bg-destructive/15">
<AlertTriangle className="size-7 text-destructive" />
</div>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription className="text-destructive/90">
{errorMessage}
</CardDescription>
</CardHeader>
<CardContent className="rounded-lg border border-border bg-card p-3 text-left text-foreground">
<p className="mb-2 text-xs font-medium text-muted-foreground">
</p>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
<li> Token </li>
<li> </li>
<li> </li>
</ul>
</CardContent>
<CardFooter className="flex flex-col gap-2">
{MAIN_SITE_URL ? (
<a
href={MAIN_SITE_URL}
className={cn(
buttonVariants({ variant: "destructive" }),
"w-full justify-center no-underline",
)}
>
</a>
) : (
<Button
variant="destructive"
className="w-full"
type="button"
disabled
title="未配置 NEXT_PUBLIC_MAIN_SITE_URL"
>
</Button>
)}
<Button
variant="outline"
className="w-full bg-transparent text-foreground"
type="button"
onClick={() => {
resetEntryFlow();
void runBootstrap();
}}
>
</Button>
</CardFooter>
</Card>
) : null}
<div className="absolute left-0 right-0 top-0 z-20 flex items-center px-4 py-3">
<LanguageSwitcher variant="header" showFlag={false} />
</div>
</div>
<footer className="flex h-14 shrink-0 items-center justify-center gap-2 px-4 text-xs text-white/70 sm:h-16">
<Shield className="size-3.5 shrink-0 opacity-80" />
<span>Secure · Trusted · Authorized access</span>
</footer>
<div className="flex flex-1 flex-col px-4 py-6">
{phase === "loading" ? (
<div className="mx-auto w-full max-w-md">
<div className="mb-6 flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded bg-red-600 text-white">
<Globe className="size-5" aria-hidden />
</div>
<span className="font-medium text-gray-800">{t("loading.title")}</span>
</div>
<div className="mb-6">
<div className="mb-2 flex justify-between text-xs text-gray-500">
<span>{t("loading.progress")}</span>
<span className="font-medium text-red-600">{progress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-red-600 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="space-y-4">
{steps.map((step) => (
<div key={step.id} className="flex items-start gap-3">
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
step.status === "done" &&
"border-green-500 bg-green-500 text-white",
step.status === "in-progress" &&
"border-blue-600 bg-blue-600 text-white",
step.status === "pending" &&
"border-gray-300 bg-gray-100 text-gray-400",
step.status === "error" && "border-red-500 bg-red-500 text-white",
)}
>
{step.status === "done" ? (
<CheckCircle2 className="size-4" aria-hidden />
) : null}
{step.status === "in-progress" ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
{step.status === "pending" ? (
<div className="size-2 rounded-full bg-gray-400" aria-hidden />
) : null}
{step.status === "error" ? (
<AlertCircle className="size-4" aria-hidden />
) : null}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span
className={cn(
"font-medium",
step.status === "done" && "text-green-600",
step.status === "in-progress" && "text-blue-600",
step.status === "pending" && "text-gray-600",
step.status === "error" && "text-red-600",
)}
>
{t(`steps.${step.id}.title`)}
</span>
<EntryStatusBadge status={step.status} />
</div>
<p className="text-xs text-gray-500">
{t(`steps.${step.id}.description`)}
</p>
</div>
</div>
))}
</div>
</div>
) : null}
{phase === "failed" ? (
<div className="mx-auto w-full max-w-md">
<div className="mb-6 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle className="size-8" aria-hidden />
</div>
<h2 className="mb-1 text-xl font-bold text-red-600">{t("failure.title")}</h2>
<p className="text-sm text-gray-600">{t("failure.subtitle")}</p>
</div>
{failureDetails.length > 0 ? (
<div className="mb-6 overflow-hidden rounded-lg border border-red-200 bg-red-50">
<div className="border-b border-red-200 bg-red-100 px-4 py-2">
<span className="text-sm font-medium text-red-800">
{t("failure.detailsTitle")}
</span>
</div>
<table className="w-full text-sm">
<thead className="bg-red-100/50 text-xs">
<tr>
<th className="px-3 py-2 text-left font-medium text-red-700">
{t("failure.table.no")}
</th>
<th className="px-3 py-2 text-left font-medium text-red-700">
{t("failure.table.check")}
</th>
<th className="px-3 py-2 text-left font-medium text-red-700">
{t("failure.table.reason")}
</th>
</tr>
</thead>
<tbody>
{failureDetails.map((detail, idx) => (
<tr key={`${detail.code}-${idx}`} className="border-t border-red-100">
<td className="px-3 py-2 text-gray-600">{idx + 1}</td>
<td className="px-3 py-2 text-gray-800">
{detail.code ?? tc("errors.general")}
</td>
<td className="px-3 py-2 text-gray-600">
{detail.detailKey
? t(detail.detailKey)
: (detail.fallbackMessage ?? t("errors.unknown"))}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<Button
onClick={handleRetry}
className="w-full gap-2 bg-red-600 text-white hover:bg-red-700"
size="lg"
type="button"
>
<Loader2 className="size-4" aria-hidden />
{t("failure.reenter")}
</Button>
</div>
) : null}
{phase === "success" ? (
<div className="mx-auto w-full max-w-md text-center">
<div className="mb-6">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-green-100 text-green-600">
<CheckCircle2 className="size-8" aria-hidden />
</div>
<h2 className="mb-1 text-xl font-bold text-green-600">
{t("success.title")}
</h2>
<p className="text-sm text-gray-600">{t("success.subtitle")}</p>
</div>
<div className="mb-6 space-y-3">
{steps.map((step) => (
<div key={step.id} className="flex items-center gap-3">
<div className="flex size-6 items-center justify-center rounded-full bg-green-500 text-white">
<CheckCircle2 className="size-4" aria-hidden />
</div>
<span className="flex-1 text-left font-medium text-gray-700">
{t(`steps.${step.id}.title`)}
</span>
<span className="text-xs text-green-600">{t("success.doneLabel")}</span>
<CheckCircle2 className="size-4 text-green-500" aria-hidden />
</div>
))}
</div>
<Button
onClick={() => router.push("/hall")}
className="w-full gap-2 bg-blue-600 text-white hover:bg-blue-700"
size="lg"
type="button"
>
{t("success.continue")}
<ChevronRight className="size-4" aria-hidden />
</Button>
</div>
) : 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>
</div>
);
}
function EntryStatusBadge({ status }: { status: EntryStepStatus }) {
const { t } = useTranslation("common");
if (status === "done") {
return (
<span className="flex items-center gap-1 rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
{t("status.done")}
<CheckCircle2 className="size-3" aria-hidden />
</span>
);
}
if (status === "in-progress") {
return (
<span className="flex items-center gap-1 rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
{t("status.inProgress")}
<Loader2 className="size-3 animate-spin" aria-hidden />
</span>
);
}
if (status === "pending") {
return (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
{t("status.pending")}
</span>
);
}
return (
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
{t("status.failed")}
</span>
);
}