Files
EpicNext-Cms/src/components/auth/register-form.tsx
T

598 lines
18 KiB
TypeScript

"use client";
import { KeyRound, Lock, Mail, ShieldCheck, UserRound } 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 { 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;
}
function passwordStrength(
pw: string,
t: (key: string) => string,
): {
score: number;
label: string;
color: string;
} {
let score = 0;
if (pw.length >= 8) score += 1;
if (pw.length >= 12) 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" };
}
export function RegisterForm({
hotelName,
captcha,
error,
nonce,
}: 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<HTMLDivElement>(null);
const [password, setPassword] = 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);
// The invite link is `/register?ref=<username>`; 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 (
<div className="mx-auto w-full max-w-[800px]">
{showCaptcha && captcha.provider === "turnstile" ? (
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
nonce={nonce}
/>
) : null}
{showCaptcha && captcha.provider === "recaptcha" ? (
<Script
src="https://www.google.com/recaptcha/api.js"
async
defer
nonce={nonce}
/>
) : null}
{/* Form */}
<form
action={(fd) => {
credentialsRef.current = {
username: String(fd.get("username") ?? "").trim(),
password: String(fd.get("password") ?? ""),
};
formAction(fd);
}}
className="flex flex-col gap-6"
>
{errorMessage && (
<p
role="alert"
aria-live="polite"
className="auth-alert animate-fade-in-up m-0"
>
{errorMessage}
</p>
)}
{referralCode && (
<div
className="animate-fade-in-up flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-semibold"
style={{
background:
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
borderColor:
"color-mix(in srgb, var(--color-primary) 26%, transparent)",
}}
>
{t("invitedBy", { username: referralCode })}
</div>
)}
<input type="hidden" name="ref" value={referralCode} />
{/* Username & Email */}
<div className="space-y-2">
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
style={{
background:
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
}}
>
<UserRound
className="h-3.5 w-3.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
/>
</span>
<span
className="text-[11px] font-black uppercase tracking-widest"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{t("accountDetails")}
</span>
<span
className="h-px flex-1"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
}}
/>
<span aria-hidden="true" className="number-mark text-lg">
01
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<div className="space-y-1.5">
<label htmlFor="username" className="auth-label">
{t("username")}
</label>
<div className="relative">
<UserRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="username"
name="username"
type="text"
placeholder={t("usernamePlaceholder")}
autoComplete="username"
className="auth-input has-icon"
required
/>
</div>
</div>
<div className="space-y-1.5">
<label htmlFor="mail" className="auth-label">
{t("email")}
</label>
<div className="relative">
<Mail
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="mail"
name="mail"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
className="auth-input has-icon"
/>
</div>
</div>
</div>
</div>
{/* Password & Confirm */}
<div className="space-y-2">
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
style={{
background:
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
}}
>
<KeyRound
className="h-3.5 w-3.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
/>
</span>
<span
className="text-[11px] font-black uppercase tracking-widest"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{t("credentials")}
</span>
<span
className="h-px flex-1"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
}}
/>
<span aria-hidden="true" className="number-mark text-lg">
02
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<div className="space-y-1.5">
<label htmlFor="password" className="auth-label">
{t("password")}
</label>
<div className="relative">
<Lock
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="auth-input has-icon has-action"
required
/>
<PasswordToggle
show={showPassword}
onToggle={() => setShowPassword((v) => !v)}
labelShow={t("showPassword")}
labelHide={t("hidePassword")}
/>
</div>
{password.length > 0 && (
<div className="animate-fade-in-up space-y-1.5 pt-0.5">
<div className="flex gap-1.5">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-1.5 flex-1 rounded-full transition-all duration-300"
style={{
backgroundColor:
i <= strength.score
? strength.color
: "color-mix(in srgb, var(--color-text-muted) 18%, transparent)",
boxShadow:
i <= strength.score
? `0 0 8px color-mix(in srgb, ${strength.color} 45%, transparent)`
: undefined,
}}
/>
))}
</div>
<div className="flex items-center gap-1.5">
<ShieldCheck
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0"
style={{ color: strength.color }}
/>
<p
className="text-[11px] font-extrabold uppercase tracking-wider"
style={{ color: strength.color }}
>
{strength.label}
</p>
</div>
</div>
)}
</div>
<div className="space-y-1.5">
<label htmlFor="password_confirmation" className="auth-label">
{t("confirmPassword")}
</label>
<div className="relative">
<Lock
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="password_confirmation"
name="password_confirmation"
type={showConfirm ? "text" : "password"}
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
className="auth-input has-icon has-action"
required
/>
<PasswordToggle
show={showConfirm}
onToggle={() => setShowConfirm((v) => !v)}
labelShow={t("showPassword")}
labelHide={t("hidePassword")}
/>
</div>
</div>
</div>
</div>
{/* Terms */}
<div
ref={termsRef}
className={`rounded-xl p-4 flex items-center justify-between gap-4 transition-all duration-300 ${
termsError ? "animate-shake" : ""
}`}
style={{
backgroundColor:
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
border: `1px solid ${
termsError
? "var(--color-danger)"
: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)"
}`,
boxShadow: termsError
? "0 0 0 3px color-mix(in srgb, var(--color-danger) 14%, transparent)"
: undefined,
}}
>
<label
htmlFor="terms"
className="group flex flex-1 items-center gap-3 cursor-pointer select-none"
>
<input
type="checkbox"
id="terms"
name="terms"
checked={termsAccepted}
onChange={(e) => {
setTermsAccepted(e.target.checked);
if (e.target.checked) setTermsError(false);
}}
aria-invalid={termsError}
className="peer sr-only"
/>
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border-2 transition-all duration-300 group-hover:scale-105 group-hover:border-[var(--color-primary)] peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--color-primary)]"
style={{
borderColor: termsError
? "var(--color-danger)"
: termsAccepted
? "var(--color-primary)"
: "color-mix(in srgb, var(--color-text-muted) 45%, transparent)",
background: termsAccepted
? "var(--color-primary)"
: "transparent",
boxShadow: termsAccepted
? "0 0 18px color-mix(in srgb, var(--color-primary) 40%, transparent)"
: undefined,
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-primary-foreground)"
strokeWidth="3.5"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-all duration-300 ${
termsAccepted ? "opacity-100 scale-100" : "opacity-0 scale-50"
}`}
>
<title>{t("accepted")}</title>
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
<span
className="text-sm font-semibold leading-snug"
style={{ color: "var(--color-text-readable)" }}
>
{t("termsAccept", { hotel: hotelName })}
</span>
</label>
<span
className="hidden sm:inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider transition-colors duration-300"
style={{
color: termsError
? "var(--color-danger)"
: termsAccepted
? "var(--color-primary)"
: "var(--color-text-muted)",
background: termsError
? "color-mix(in srgb, var(--color-danger) 10%, transparent)"
: termsAccepted
? "color-mix(in srgb, var(--color-primary) 12%, transparent)"
: "color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
}}
>
{termsError || !termsAccepted ? t("termsRequired") : t("accepted")}
</span>
</div>
{termsError && (
<p
className="animate-fade-in-up text-xs font-bold"
style={{ color: "var(--color-danger)" }}
>
{t("termsRequired")}
</p>
)}
{/* Captcha */}
{showCaptcha && (
<div className="flex min-h-[70px] items-center justify-center">
{captcha.provider === "turnstile" && (
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
)}
{captcha.provider === "recaptcha" && (
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
)}
{captcha.provider === "hcaptcha" && (
<div className="h-captcha" data-sitekey={captcha.siteKey} />
)}
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={isPending || signingIn}
onClick={() => {
if (!termsAccepted) {
setTermsError(true);
termsRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}}
className={`btn-brand btn-shine w-full py-3.5 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60 ${
termsAccepted ? "" : "opacity-70 saturate-[0.85]"
}`}
style={{
boxShadow: termsAccepted
? undefined
: "0 4px 14px -8px color-mix(in srgb, var(--color-primary) 50%, transparent)",
}}
>
{isPending || signingIn ? (
<span className="inline-flex items-center justify-center gap-2">
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
role="img"
aria-label="Loading"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{signingIn ? t("redirecting") : t("creatingAccount")}
</span>
) : (
t("createAccount")
)}
</button>
<div
className="border-t pt-4 text-center"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 14%, transparent)",
}}
>
<Link
href="/login"
className="inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-xs font-extrabold transition-all duration-200 hover:-translate-y-0.5"
style={{
color: "var(--color-primary)",
background:
"color-mix(in srgb, var(--color-primary) 8%, transparent)",
borderColor:
"color-mix(in srgb, var(--color-primary) 35%, transparent)",
}}
>
<UserRound aria-hidden="true" className="h-3.5 w-3.5" />
{t("alreadyHaveAccount")}
</Link>
</div>
</form>
</div>
);
}