"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}
{step}
);
}
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" ? (
) : null}
{showCaptcha && captcha.provider === "recaptcha" ? (
) : null}
{/* Form */}
);
}