Files
lotteryFront/src/features/player/entry-gate.tsx
kang 10bee1b857 refactor: update environment configuration and enhance notification handling
- Refactored ecosystem configuration to utilize .env for sensitive variables, improving security and flexibility.
- Replaced direct notification button implementation with a dedicated PlayerNotificationBell component for better code organization.
- Updated various screens to integrate the new notification component and adjusted prefetch settings for links to optimize performance.
- Added new translations for notifications and wallet logs to enhance user experience across multiple languages.
2026-06-01 09:29:02 +08:00

557 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getPublicCurrencies } from "@/api/currency";
import { getPlayerMe, getPlayerPing } from "@/api/player";
import { isInIframe } from "@/components/iframe-bridge";
import { LanguageSwitcher } from "@/components/language-switcher";
import { Button, buttonVariants } 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;
/** 嵌入主站 iframe 时,等待父页 postMessage 下发 token 的最长时间 */
const IFRAME_TOKEN_WAIT_MS = 15_000;
const MAIN_SITE_URL = process.env.NEXT_PUBLIC_MAIN_SITE_URL?.trim() ?? "";
/** `/api/player/me` 等业务信封业务码 — 玩家 SSO 段 80018005与后端 ErrorCode 一致) */
const PLAYER_BIZ_DETAIL: Partial<Record<number, string>> = {
8001: "errors.player8001",
8002: "errors.player8002",
8003: "errors.player8003",
8004: "errors.player8004",
8005: "errors.player8005",
};
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 stripSearchParamFromBrowserUrl(name: string): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
if (!url.searchParams.has(name)) return;
url.searchParams.delete(name);
const next = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(null, "", next);
}
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 sessionExpired = searchParams.get("session") === "expired";
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
usePlayerSessionStore();
const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading");
const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() =>
sessionExpired
? [
{
code: "SESSION_EXPIRED",
detailKey: "errors.sessionExpiredDetail",
},
]
: [],
);
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 progress = useMemo(() => {
const doneCount = steps.filter((s) => s.status === "done").length;
const inProgressCount = steps.filter((s) => s.status === "in-progress").length;
return Math.round(((doneCount + inProgressCount * 0.5) / steps.length) * 100);
}, [steps]);
const doEntry = useCallback(async () => {
if (!effectiveToken) {
// 主站 iframetoken 由 MAIN_INIT_TOKEN 稍后到达,勿先闪「授权失败」
if (typeof window !== "undefined" && isInIframe() && !tokenFromUrl) {
return;
}
setPhase("failed");
setFailureDetails([{ code: "NO_TOKEN", detailKey: "errors.noTokenDetail" }]);
return;
}
setPhase("loading");
setFailureDetails([]);
if (tokenFromUrl) {
setBearerToken(tokenFromUrl);
stripSearchParamFromBrowserUrl("token");
}
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)]);
try {
const currencies = await getPublicCurrencies();
setCurrencies(currencies.items);
} catch {
// 不阻断进场主流程;金额精度会退回默认 2 位兜底。
}
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 dk = PLAYER_BIZ_DETAIL[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,
setCurrencies,
clearBearerToken,
router,
updateStep,
t,
]);
const handleRetry = useCallback(() => {
setPhase("loading");
setFailureDetails([]);
setSteps(initialSteps());
void doEntry();
}, [doEntry]);
useEffect(() => {
if (!sessionExpired) return;
clearBearerToken();
stripSearchParamFromBrowserUrl("session");
}, [sessionExpired, clearBearerToken]);
useEffect(() => {
if (sessionExpired) return;
const embedded = typeof window !== "undefined" && isInIframe() && !tokenFromUrl;
if (embedded && !effectiveToken) {
setPhase("loading");
return;
}
const tmr = window.setTimeout(() => {
void doEntry();
}, 300);
return () => window.clearTimeout(tmr);
}, [doEntry, sessionExpired, effectiveToken, tokenFromUrl]);
useEffect(() => {
if (sessionExpired) return;
if (tokenFromUrl || effectiveToken) return;
if (typeof window === "undefined" || !isInIframe()) return;
const tmr = window.setTimeout(() => {
if (usePlayerSessionStore.getState().bearerToken?.trim()) return;
setFailureDetails([{ code: "NO_TOKEN", detailKey: "errors.noTokenDetail" }]);
setPhase("failed");
}, IFRAME_TOKEN_WAIT_MS);
return () => window.clearTimeout(tmr);
}, [sessionExpired, effectiveToken, tokenFromUrl]);
return (
<div className="relative flex min-h-dvh flex-col bg-white">
<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={phase === "success" ? "/entry/image4.png" : "/entry/image1.png"}
alt={t("header.backgroundAlt")}
fill
className="object-cover object-center"
priority
/>
</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-3 py-5">
{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-3">
{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}
<div className="flex flex-col gap-2">
<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>
{MAIN_SITE_URL !== "" ? (
<a
href={MAIN_SITE_URL}
target="_top"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full")}
>
{t("failure.backToMainSite")}
</a>
) : null}
</div>
</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="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>
);
}
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>
);
}