198 lines
6.3 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|