"use client"; import { Check, KeyRound, Lock, type LucideIcon, Mail, ShieldCheck, UserRound, X, } from "lucide-react"; import Script from "next/script"; import { signIn } from "next-auth/react"; import { useTranslations } from "next-intl"; import { useActionState, useEffect, useRef, useState } from "react"; import { type RegisterErrorCode, type RegisterState, register, } from "@/actions/register"; import { AuthAlert } from "@/components/auth/auth-alert"; import { AuthField } from "@/components/auth/auth-field"; import { PasswordToggle } from "@/components/auth/password-toggle"; import Link from "@/components/link"; import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry"; interface RegisterFormProps { hotelName: string; captcha: { provider: string; siteKey?: string }; error?: string; nonce?: string; /** Effective policy from `PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH`. */ passwordMinLength: number; passwordMaxLength: number; } function passwordStrength( pw: string, t: (key: string) => string, minLength: number, ): { score: number; label: string; color: string; } { let score = 0; if (pw.length >= minLength) score += 1; if (pw.length >= minLength + 4) score += 1; if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score += 1; if (/\d/.test(pw)) score += 1; if (/[^a-zA-Z0-9]/.test(pw)) score += 1; if (score <= 1) return { score, label: t("strengthWeak"), color: "#ef4444" }; if (score <= 2) return { score, label: t("strengthFair"), color: "#f59e0b" }; if (score <= 3) return { score, label: t("strengthGood"), color: "#22c55e" }; return { score: 5, label: t("strengthStrong"), color: "#16a34a" }; } /** The numbered section rule above each group of fields ("01 Account", ...). */ function AuthSectionHeader({ icon: Icon, label, step, }: { icon: LucideIcon; label: string; step: string; }) { const accent = "var(--color-primary-readable, var(--color-primary))"; return (
{label}
); } export function RegisterForm({ hotelName, captcha, error, nonce, passwordMinLength, passwordMaxLength, }: RegisterFormProps) { const t = useTranslations("pages.register"); const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey; const [state, formAction, isPending] = useActionState< RegisterState, FormData >(register, { error: null, ok: false }); /** * Prefer the locale-independent `code` so the message follows the visitor's * language, falling back to the server's English string when a code has no * translation yet. */ const translateErrorCode = (code: RegisterErrorCode | undefined) => { if (!code) return null; try { return t(code); } catch { return null; } }; const errorMessage = error ?? translateErrorCode(state.code) ?? state.error; const [termsAccepted, setTermsAccepted] = useState(false); const [termsError, setTermsError] = useState(false); const termsRef = useRef(null); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const [signingIn, setSigningIn] = useState(false); const credentialsRef = useRef<{ username: string; password: string } | null>( null, ); const autoLoginStartedRef = useRef(false); const [referralCode, setReferralCode] = useState(""); const strength = passwordStrength(password, t, passwordMinLength); /** * The password policy the server enforces, evaluated live so the visitor can * see which rule they are still missing instead of submitting and reading a * single error back. */ const requirements = [ { label: t("passwordMinLength", { min: passwordMinLength }), met: password.length >= passwordMinLength, }, { label: t("passwordUpper"), met: /[A-Z]/.test(password) }, { label: t("passwordLower"), met: /[a-z]/.test(password) }, { label: t("passwordDigit"), met: /\d/.test(password) }, { label: t("passwordSpecial"), met: /[^A-Za-z0-9]/.test(password) }, ]; /** Shown as soon as the confirmation is filled in and disagrees. */ const mismatch = confirmPassword.length > 0 && confirmPassword !== password; // The invite link is `/register?ref=`; read it client-side so the // attribution travels with the sign-up form without server state. useEffect(() => { const ref = new URLSearchParams(window.location.search).get("ref"); if (ref) setReferralCode(ref); }, []); // Successful sign-up: sign in with the submitted credentials and go // straight to /me. If the automatic sign-in is refused (e.g. email // verification required), fall back to the regular login page. useEffect(() => { if (!state.ok || autoLoginStartedRef.current) return; autoLoginStartedRef.current = true; const creds = credentialsRef.current; if (!creds) { window.location.href = "/login?registered=1"; return; } let cancelled = false; setSigningIn(true); (async () => { try { const res = await signInWithTransientRetry(() => signIn("credentials", { ...creds, code: "", redirect: false }), ); if (!cancelled && res && !res.error) { window.location.href = "/me"; return; } } catch { /* fall through to fallback below */ } if (!cancelled) window.location.href = "/login?registered=1"; })(); return () => { cancelled = true; }; }, [state.ok]); return (
{showCaptcha && captcha.provider === "turnstile" ? (