Files
lotteryFront/src/features/player/player-login-screen.tsx
wchino 827f369eb6
Some checks failed
lotteryfront CI / build (push) Has been cancelled
feat(player): improve wallet activity and session handling
2026-07-22 20:53:43 +08:00

198 lines
6.3 KiB
TypeScript

"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useI18nHydrated } from "@/components/i18n-hydration-provider";
import { useHydrationSafeEntryT } from "@/i18n/hydration-t";
import { toast } from "sonner";
import { getPlayerMe } from "@/api/player";
import { getPlayerAuthCaptcha, postPlayerAuthLogin } from "@/api/player-auth";
import { getPublicCurrencies } from "@/api/currency";
import { LanguageSwitcher } from "@/components/language-switcher";
import { EntryHeroBanner } from "@/features/player/entry-hero-banner";
import { PlayerLoginDesktop } from "@/features/player/player-login-desktop";
import { PlayerLoginForm } from "@/features/player/player-login-form";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import {
validatePlayerLoginPassword,
validatePlayerLoginUsername,
} from "@/lib/player-input-validation";
import { LotteryApiBizError } from "@/types/api/errors";
import "@/i18n";
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);
}
export function PlayerLoginScreen(): React.ReactElement {
const i18nHydrated = useI18nHydrated();
const t = useHydrationSafeEntryT();
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
}, [t]);
const router = useRouter();
const searchParams = useSearchParams();
const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken);
const setProfile = usePlayerSessionStore((s) => s.setProfile);
const clearBearerToken = usePlayerSessionStore((s) => s.clearBearerToken);
const sessionStatusHandled = useRef(false);
const passwordChangedHandled = useRef(false);
const captchaInitialized = useRef(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [captchaCode, setCaptchaCode] = useState("");
const [captchaKey, setCaptchaKey] = useState<string | null>(null);
const [captchaSrc, setCaptchaSrc] = useState<string | null>(null);
const [loadingCaptcha, setLoadingCaptcha] = useState(false);
const [loading, setLoading] = useState(false);
const loadCaptcha = useCallback(async () => {
setLoadingCaptcha(true);
try {
const data = await getPlayerAuthCaptcha();
setCaptchaKey(data.captcha_key);
setCaptchaSrc(`data:image/svg+xml;base64,${data.image_base64}`);
setCaptchaCode("");
} catch {
toast.error(tRef.current("login.captchaLoadFailed"));
setCaptchaKey(null);
setCaptchaSrc(null);
} finally {
setLoadingCaptcha(false);
}
}, []);
useEffect(() => {
if (captchaInitialized.current) return;
captchaInitialized.current = true;
void loadCaptcha();
}, [loadCaptcha]);
useEffect(() => {
if (!i18nHydrated) return;
if (sessionStatusHandled.current) return;
const sessionStatus = searchParams.get("session");
if (sessionStatus !== "expired" && sessionStatus !== "replaced") return;
sessionStatusHandled.current = true;
clearBearerToken();
toast.error(
t(
sessionStatus === "replaced"
? "errors.sessionReplaced"
: "errors.sessionExpired",
),
);
stripSearchParamFromBrowserUrl("session");
}, [clearBearerToken, i18nHydrated, searchParams, t]);
useEffect(() => {
if (passwordChangedHandled.current) return;
if (searchParams.get("password") !== "changed") return;
passwordChangedHandled.current = true;
toast.success(t("login.passwordChanged"));
stripSearchParamFromBrowserUrl("password");
}, [searchParams, t]);
async function handleSubmit(event: React.FormEvent): Promise<void> {
event.preventDefault();
if (!username.trim() || !password) {
toast.error(t("login.missingFields"));
return;
}
if (!captchaKey || !captchaSrc) {
toast.error(t("login.captchaRequired"));
void loadCaptcha();
return;
}
if (!captchaCode.trim()) {
toast.error(t("login.captchaCodeRequired"));
return;
}
const usernameIssue = validatePlayerLoginUsername(username);
if (usernameIssue === "invalid_charset") {
toast.error(t("login.usernameInvalidCharset"));
return;
}
const passwordIssue = validatePlayerLoginPassword(password);
if (passwordIssue === "too_short") {
toast.error(t("login.passwordMinLength"));
return;
}
setLoading(true);
try {
const data = await postPlayerAuthLogin({
username: username.trim(),
password,
captcha_key: captchaKey,
captcha_code: captchaCode.trim(),
});
setBearerToken(data.access_token);
const me = await getPlayerMe();
setProfile(me);
try {
const currencies = await getPublicCurrencies();
usePlayerSessionStore.getState().setCurrencies(currencies.items);
} catch {
// 不阻断登录
}
router.replace("/hall");
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("login.failed"));
void loadCaptcha();
} finally {
setLoading(false);
}
}
const formProps = {
t,
username,
password,
captchaCode,
captchaSrc,
loading,
loadingCaptcha,
onUsernameChange: setUsername,
onPasswordChange: setPassword,
onCaptchaCodeChange: setCaptchaCode,
onRefreshCaptcha: () => void loadCaptcha(),
onSubmit: (event: React.FormEvent) => void handleSubmit(event),
};
return (
<>
<PlayerLoginDesktop {...formProps} />
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-white lg:hidden">
<EntryHeroBanner src="/entry/image1.png" alt="" className="bg-[#f8fafc]">
<div className="absolute left-0 right-0 top-0 z-20 flex items-center px-4 py-3">
<LanguageSwitcher variant="header" showFlag={false} />
</div>
</EntryHeroBanner>
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-5 py-6">
<div className="w-full rounded-2xl bg-white px-1 py-2">
<PlayerLoginForm variant="mobile" {...formProps} />
</div>
</div>
</div>
</>
);
}