"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 段 8001–8005(与后端 ErrorCode 一致) */ const PLAYER_BIZ_DETAIL: Partial> = { 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 { 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(sessionExpired ? "failed" : "loading"); const [failureDetails, setFailureDetails] = useState(() => sessionExpired ? [ { code: "SESSION_EXPIRED", detailKey: "errors.sessionExpiredDetail", }, ] : [], ); const [steps, setSteps] = useState(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) { // 主站 iframe:token 由 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 (
{t("header.backgroundAlt")}
{phase === "loading" ? (
{t("loading.title")}
{t("loading.progress")} {progress}%
{steps.map((step) => (
{step.status === "done" ? ( ) : null} {step.status === "in-progress" ? ( ) : null} {step.status === "pending" ? (
) : null} {step.status === "error" ? ( ) : null}
{t(`steps.${step.id}.title`)}

{t(`steps.${step.id}.description`)}

))}
) : null} {phase === "failed" ? (

{t("failure.title")}

{t("failure.subtitle")}

{failureDetails.length > 0 ? (
{t("failure.detailsTitle")}
{failureDetails.map((detail, idx) => ( ))}
{t("failure.table.no")} {t("failure.table.check")} {t("failure.table.reason")}
{idx + 1} {detail.code ?? tc("errors.general")} {detail.detailKey ? t(detail.detailKey) : (detail.fallbackMessage ?? t("errors.unknown"))}
) : null}
{MAIN_SITE_URL !== "" ? ( {t("failure.backToMainSite")} ) : null}
) : null} {phase === "success" ? (

{t("success.title")}

{t("success.subtitle")}

{steps.map((step) => (
{t(`steps.${step.id}.title`)} {t("success.doneLabel")}
))}
) : null}
{t("footer.secure")}
); } function EntryStatusBadge({ status }: { status: EntryStepStatus }) { const { t } = useTranslation("common"); if (status === "done") { return ( {t("status.done")} ); } if (status === "in-progress") { return ( {t("status.inProgress")} ); } if (status === "pending") { return ( {t("status.pending")} ); } return ( {t("status.failed")} ); }