- 在 .env.example 中新增 i18next 相关配置项以支持多语言功能 - 在 next.config.ts 中添加安全头配置以支持 iframe 嵌入 - 更新 Providers 组件以引入 i18n 配置 - 在 PlayerAppShell 中集成 LanguageSwitcher 组件以实现语言切换功能 - 优化 HallWalletStrip 组件的网络状态管理逻辑 - 更新多个组件以支持国际化文本
468 lines
16 KiB
TypeScript
468 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { isAxiosError } from "axios";
|
||
import {
|
||
AlertCircle,
|
||
AlertTriangle,
|
||
CheckCircle2,
|
||
ChevronRight,
|
||
Globe,
|
||
Loader2,
|
||
ShieldCheck,
|
||
} from "lucide-react";
|
||
import Image from "next/image";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
import { getPlayerMe, getPlayerPing } from "@/api/player";
|
||
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 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;
|
||
}
|
||
if (isAxiosError(error)) {
|
||
if (error.code === "ECONNABORTED") {
|
||
return true;
|
||
}
|
||
if (!error.response) {
|
||
return true;
|
||
}
|
||
if (error.response.status >= 500) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function EntryGate() {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const { t } = useTranslation("entry");
|
||
const { t: tc } = useTranslation("common");
|
||
|
||
const tokenFromUrl = searchParams.get("token") ?? "";
|
||
|
||
const { bearerToken, setBearerToken, setProfile, clearBearerToken } =
|
||
usePlayerSessionStore();
|
||
|
||
const [phase, setPhase] = useState<Phase>("loading");
|
||
const [progress, setProgress] = useState(0);
|
||
const [failureDetails, setFailureDetails] = useState<FailureRow[]>([]);
|
||
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
|
||
|
||
const effectiveToken = tokenFromUrl || bearerToken;
|
||
|
||
const updateStep = useCallback((stepId: EntryStepId, status: EntryStepStatus) => {
|
||
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
|
||
}, []);
|
||
|
||
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;
|
||
}
|
||
|
||
if (tokenFromUrl) {
|
||
setBearerToken(tokenFromUrl);
|
||
}
|
||
|
||
setSteps((prev) =>
|
||
prev.map((s) => (s.id === "token" ? { ...s, status: "in-progress" } : s)),
|
||
);
|
||
|
||
await sleep(500);
|
||
|
||
let lastError: unknown = null;
|
||
|
||
for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
|
||
try {
|
||
const [me] = await Promise.all([getPlayerMe(), sleep(300)]);
|
||
|
||
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"),
|
||
},
|
||
]);
|
||
}, [
|
||
effectiveToken,
|
||
tokenFromUrl,
|
||
setBearerToken,
|
||
setProfile,
|
||
clearBearerToken,
|
||
router,
|
||
updateStep,
|
||
t,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
const tmr = window.setTimeout(() => {
|
||
void doEntry();
|
||
}, 300);
|
||
return () => window.clearTimeout(tmr);
|
||
}, [doEntry]);
|
||
|
||
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="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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
);
|
||
}
|