|
|
@@ -0,0 +1,331 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import { useTranslations } from "next-intl";
|
|
|
+import { useRouter } from "@/i18n/navigation";
|
|
|
+import { Link } from "@/i18n/navigation";
|
|
|
+import { useEffect, useState } from "react";
|
|
|
+import { useAuth } from "@/providers/auth-provider";
|
|
|
+import { ApiError } from "@/lib/api";
|
|
|
+import { isRegisterPasswordValid } from "@/lib/password-rules";
|
|
|
+import { sendRegisterVerificationCode } from "@/lib/register-api";
|
|
|
+import { Eye, EyeOff, UserPlus, CheckCircle2, X } from "lucide-react";
|
|
|
+import { cn } from "@/lib/utils";
|
|
|
+
|
|
|
+const SEND_CODE_COOLDOWN_SEC = 60;
|
|
|
+const inputCls =
|
|
|
+ "mt-2 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3.5 text-sm text-white placeholder-slate-500 focus:border-[#b89458] focus:outline-none focus:ring-1 focus:ring-[#b89458] transition-all";
|
|
|
+
|
|
|
+type Props = {
|
|
|
+ layout: "fullscreen" | "embedded";
|
|
|
+ onDismiss?: () => void;
|
|
|
+ /** 弹窗内:注册成功后回到登录 */
|
|
|
+ onRegisterComplete?: () => void;
|
|
|
+ /** 弹窗内:已有账号,返回登录 */
|
|
|
+ onBackToLogin?: () => void;
|
|
|
+};
|
|
|
+
|
|
|
+export function RegisterFormPanel({
|
|
|
+ layout,
|
|
|
+ onDismiss,
|
|
|
+ onRegisterComplete,
|
|
|
+ onBackToLogin,
|
|
|
+}: Props) {
|
|
|
+ const t = useTranslations("auth");
|
|
|
+ const router = useRouter();
|
|
|
+ const { register, logout } = useAuth();
|
|
|
+ const [email, setEmail] = useState("");
|
|
|
+ const [password, setPassword] = useState("");
|
|
|
+ const [showPassword, setShowPassword] = useState(false);
|
|
|
+ const [code, setCode] = useState("");
|
|
|
+ const [err, setErr] = useState<string | null>(null);
|
|
|
+ const [sendCooldown, setSendCooldown] = useState(0);
|
|
|
+ const [sendingCode, setSendingCode] = useState(false);
|
|
|
+ const [submitting, setSubmitting] = useState(false);
|
|
|
+ const [registerSuccess, setRegisterSuccess] = useState(false);
|
|
|
+ const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
|
|
|
+ const canSendCode = emailValid && !sendingCode && sendCooldown <= 0;
|
|
|
+ const canSubmit = email.trim().length > 0 && code.trim().length > 0 && password.length > 0 && !submitting;
|
|
|
+ const embedded = layout === "embedded";
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (sendCooldown <= 0) return;
|
|
|
+ const id = window.setInterval(() => setSendCooldown((s) => (s <= 1 ? 0 : s - 1)), 1000);
|
|
|
+ return () => window.clearInterval(id);
|
|
|
+ }, [sendCooldown]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!registerSuccess || embedded) return;
|
|
|
+ const tm = window.setTimeout(() => router.push("/auth/login?registered=1"), 1600);
|
|
|
+ return () => window.clearTimeout(tm);
|
|
|
+ }, [registerSuccess, router, embedded]);
|
|
|
+
|
|
|
+ const onSendCode = async () => {
|
|
|
+ if (!canSendCode) return;
|
|
|
+ setErr(null);
|
|
|
+ if (!emailValid) {
|
|
|
+ setErr(t("errorInvalidEmail"));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setSendingCode(true);
|
|
|
+ try {
|
|
|
+ await sendRegisterVerificationCode(email.trim());
|
|
|
+ setSendCooldown(SEND_CODE_COOLDOWN_SEC);
|
|
|
+ } catch (e) {
|
|
|
+ setErr(e instanceof ApiError ? e.message : t("errorApi"));
|
|
|
+ } finally {
|
|
|
+ setSendingCode(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const onSubmit = async (e: React.FormEvent) => {
|
|
|
+ e.preventDefault();
|
|
|
+ if (!canSubmit) return;
|
|
|
+ setErr(null);
|
|
|
+ if (!isRegisterPasswordValid(password)) {
|
|
|
+ setErr(t("errorWeakPassword"));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setSubmitting(true);
|
|
|
+ const r = await register({
|
|
|
+ country: "CN",
|
|
|
+ email: email.trim(),
|
|
|
+ password,
|
|
|
+ name: "学员",
|
|
|
+ code: code.trim(),
|
|
|
+ });
|
|
|
+ if (!r.ok) {
|
|
|
+ if (r.message) setErr(r.message);
|
|
|
+ else if (r.error === "invalid_email") setErr(t("errorInvalidEmail"));
|
|
|
+ else if (r.error === "weak_password") setErr(t("errorWeakPassword"));
|
|
|
+ else if (r.error === "invalid_code") setErr(t("errorCode"));
|
|
|
+ else if (r.error === "country_required") setErr(t("countryRequired"));
|
|
|
+ else setErr(t("errorApi"));
|
|
|
+ setSubmitting(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ logout();
|
|
|
+ setRegisterSuccess(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ if (registerSuccess) {
|
|
|
+ const successCard = (
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-400">
|
|
|
+ <CheckCircle2 size={40} />
|
|
|
+ </div>
|
|
|
+ <h1 className="mb-2 font-serif text-2xl font-bold text-white">{t("registerSuccessTitle")}</h1>
|
|
|
+ <p className="text-sm text-slate-400">
|
|
|
+ {embedded ? "请使用邮箱与密码登录以继续。" : t("registerSuccessHint")}
|
|
|
+ </p>
|
|
|
+ {embedded && onRegisterComplete ? (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onRegisterComplete}
|
|
|
+ className="mt-6 w-full rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] py-3.5 text-sm font-bold text-[#5c461a] shadow-xl"
|
|
|
+ >
|
|
|
+ {t("loginBtn")}
|
|
|
+ </button>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ if (layout === "fullscreen") {
|
|
|
+ return (
|
|
|
+ <div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-[#050b14] p-4 font-sans">
|
|
|
+ <div className="pointer-events-none fixed inset-0 z-0">
|
|
|
+ <div className="absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#b89458]/10 blur-[150px]" />
|
|
|
+ </div>
|
|
|
+ <div className="relative z-10 w-full max-w-md rounded-[2.5rem] border border-white/10 bg-[#0a1120]/80 p-10 text-center backdrop-blur-2xl shadow-2xl">
|
|
|
+ {successCard}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="relative w-full max-w-md">
|
|
|
+ {onDismiss ? (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onDismiss}
|
|
|
+ className="absolute right-3 top-3 z-10 rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
|
|
|
+ aria-label="关闭"
|
|
|
+ >
|
|
|
+ <X size={18} />
|
|
|
+ </button>
|
|
|
+ ) : null}
|
|
|
+ <div className="auth-modal-body rounded-2xl border border-white/10 bg-[#0a1120]/80 p-6 text-center backdrop-blur-2xl shadow-2xl sm:p-8">
|
|
|
+ {successCard}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const card = (
|
|
|
+ <div
|
|
|
+ className={cn(
|
|
|
+ "auth-modal-body relative border border-white/10 bg-[#0a1120]/80 backdrop-blur-2xl shadow-2xl",
|
|
|
+ layout === "fullscreen" ? "rounded-[2.5rem] p-8 md:p-10" : "rounded-2xl p-6 sm:p-8",
|
|
|
+ )}
|
|
|
+ >
|
|
|
+ {embedded && onDismiss ? (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onDismiss}
|
|
|
+ className="absolute right-3 top-3 rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
|
|
|
+ aria-label="关闭"
|
|
|
+ >
|
|
|
+ <X size={18} />
|
|
|
+ </button>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {layout === "fullscreen" ? (
|
|
|
+ <div className="mb-8 flex justify-center">
|
|
|
+ <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] text-[#5c461a] shadow-lg shadow-[#b89458]/20">
|
|
|
+ <UserPlus size={28} className="translate-x-[2px]" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="mb-6 flex justify-center">
|
|
|
+ <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] text-[#5c461a] shadow-md shadow-[#b89458]/20">
|
|
|
+ <UserPlus size={22} className="translate-x-[2px]" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div className={layout === "fullscreen" ? "mb-8 text-center" : "mb-6 text-center"}>
|
|
|
+ <h1
|
|
|
+ className={cn(
|
|
|
+ "font-serif font-bold text-white",
|
|
|
+ layout === "fullscreen" ? "text-2xl" : "text-xl",
|
|
|
+ )}
|
|
|
+ >
|
|
|
+ {t("registerTitle")}
|
|
|
+ </h1>
|
|
|
+ <p className="mt-2 text-sm text-slate-400">{t("registerEmailOnlyHint")}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <form onSubmit={onSubmit} className="space-y-5">
|
|
|
+ <div>
|
|
|
+ <label className="ml-1 text-sm font-bold text-slate-300">{t("email")}</label>
|
|
|
+ <input
|
|
|
+ type="email"
|
|
|
+ required
|
|
|
+ value={email}
|
|
|
+ onChange={(e) => {
|
|
|
+ setEmail(e.target.value);
|
|
|
+ if (err) setErr(null);
|
|
|
+ }}
|
|
|
+ onBlur={() => setEmail((v) => v.trim())}
|
|
|
+ autoComplete="email"
|
|
|
+ className={inputCls}
|
|
|
+ placeholder="admin@example.com"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="ml-1 text-sm font-bold text-slate-300">{t("code")}</label>
|
|
|
+ <div className="mt-2 flex gap-3">
|
|
|
+ <input
|
|
|
+ required
|
|
|
+ value={code}
|
|
|
+ onChange={(e) => {
|
|
|
+ setCode(e.target.value.replace(/\D/g, "").slice(0, 6));
|
|
|
+ if (err) setErr(null);
|
|
|
+ }}
|
|
|
+ className={cn(inputCls, "mt-0 flex-1")}
|
|
|
+ placeholder="000000"
|
|
|
+ inputMode="numeric"
|
|
|
+ autoComplete="one-time-code"
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ disabled={!canSendCode}
|
|
|
+ onClick={onSendCode}
|
|
|
+ className="shrink-0 rounded-xl border border-white/10 bg-white/5 px-4 text-xs font-bold text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
+ >
|
|
|
+ {sendingCode ? `${t("sendCode")}...` : sendCooldown > 0 ? t("sendCodeCooldown", { sec: sendCooldown }) : t("sendCode")}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="ml-1 text-sm font-bold text-slate-300">{t("password")}</label>
|
|
|
+ <div className="relative mt-2">
|
|
|
+ <input
|
|
|
+ type={showPassword ? "text" : "password"}
|
|
|
+ required
|
|
|
+ autoComplete="new-password"
|
|
|
+ value={password}
|
|
|
+ onChange={(e) => {
|
|
|
+ setPassword(e.target.value);
|
|
|
+ if (err) setErr(null);
|
|
|
+ }}
|
|
|
+ className={`${inputCls} mt-0 pr-12`}
|
|
|
+ placeholder="设置密码"
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setShowPassword((v) => !v)}
|
|
|
+ className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 hover:text-white focus:outline-none focus:ring-1 focus:ring-[#b89458]"
|
|
|
+ aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
|
|
+ >
|
|
|
+ {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <ul className="mt-3 list-disc space-y-1.5 pl-5 text-xs font-medium text-slate-500">
|
|
|
+ <li>{t("passwordRuleLen")}</li>
|
|
|
+ <li>{t("passwordRuleCase")}</li>
|
|
|
+ <li>{t("passwordRuleMix")}</li>
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {err ? (
|
|
|
+ <div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3 text-center text-sm font-medium text-rose-400" role="alert">
|
|
|
+ {err}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ <button
|
|
|
+ type="submit"
|
|
|
+ disabled={!canSubmit}
|
|
|
+ className="mt-6 w-full rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] py-4 text-sm font-bold text-[#5c461a] shadow-xl shadow-[#b89458]/10 transition-transform hover:scale-[1.02] disabled:opacity-50 disabled:hover:scale-100"
|
|
|
+ >
|
|
|
+ {submitting ? `${t("registerBtn")}中...` : t("registerBtn")}
|
|
|
+ </button>
|
|
|
+ </form>
|
|
|
+
|
|
|
+ <p className="mt-8 text-center text-sm text-slate-400">
|
|
|
+ {embedded && onBackToLogin ? (
|
|
|
+ <>
|
|
|
+ 已有账户?{" "}
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onBackToLogin}
|
|
|
+ className="font-bold text-white transition-colors hover:text-[#f3deae]"
|
|
|
+ >
|
|
|
+ {t("loginBtn")}
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <Link href="/auth/login" className="font-bold text-white transition-colors hover:text-[#f3deae]">
|
|
|
+ {t("toLogin")}
|
|
|
+ </Link>
|
|
|
+ )}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ if (layout === "fullscreen") {
|
|
|
+ return (
|
|
|
+ <div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-[#050b14] p-4 py-16 font-sans">
|
|
|
+ <div className="pointer-events-none fixed inset-0 z-0">
|
|
|
+ <div className="absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#b89458]/10 blur-[150px]" />
|
|
|
+ </div>
|
|
|
+ <div className="relative z-10 w-full max-w-md">{card}</div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return <div className="w-full max-w-md">{card}</div>;
|
|
|
+}
|