Replaced the classic Habbo landing style on the home, login and register pages with a modern premium dark-gaming look: always-dark hero canvas with brand glows, grid overlay and ambient orbs, glass panels, gradient text and floating art. Adds shared LandingTopBar, AuthShell and BrandFrank components plus reusable premium CSS utilities. Build, typecheck and lint pass.
538 lines
16 KiB
TypeScript
538 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
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 RegisterState, register } from "@/actions/register";
|
|
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): {
|
|
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: "Weak", color: "#ef4444" };
|
|
if (score <= 2) return { score, label: "Fair", color: "#f59e0b" };
|
|
if (score <= 3) return { score, label: "Good", color: "#22c55e" };
|
|
return { score: 5, label: "Strong", 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 });
|
|
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 strength = passwordStrength(password);
|
|
|
|
// 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}
|
|
{/* Header Banner */}
|
|
<div className="mb-7 flex items-center gap-4">
|
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5">
|
|
<Image
|
|
src="/assets/images/icons/navigation/me.png"
|
|
alt=""
|
|
width={22}
|
|
height={22}
|
|
unoptimized
|
|
className="opacity-90"
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p
|
|
className="text-[11px] font-extrabold uppercase tracking-widest"
|
|
style={{ color: "var(--color-primary)" }}
|
|
>
|
|
{t("accountDetails")}
|
|
</p>
|
|
<h2 className="m-0 text-2xl font-black tracking-tight text-white">
|
|
{t("title")}
|
|
</h2>
|
|
<p className="mt-0.5 truncate text-sm text-white/55">
|
|
{t("subtitle")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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"
|
|
>
|
|
{(error || state.error) && (
|
|
<div
|
|
className="animate-fade-in-up p-3 rounded-lg text-sm font-semibold text-primary-foreground"
|
|
style={{ backgroundColor: "var(--color-danger)" }}
|
|
>
|
|
{error || state.error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Username & Email */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<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)",
|
|
}}
|
|
/>
|
|
</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="block text-xs font-bold uppercase tracking-wider"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{t("username")}
|
|
</label>
|
|
<input
|
|
id="username"
|
|
name="username"
|
|
type="text"
|
|
placeholder={t("usernamePlaceholder")}
|
|
autoComplete="username"
|
|
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
|
style={{
|
|
backgroundColor: "var(--color-background)",
|
|
color: "var(--color-text-readable)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
|
}}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label
|
|
htmlFor="mail"
|
|
className="block text-xs font-bold uppercase tracking-wider"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{t("email")}
|
|
</label>
|
|
<input
|
|
id="mail"
|
|
name="mail"
|
|
type="email"
|
|
placeholder={t("emailPlaceholder")}
|
|
autoComplete="email"
|
|
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
|
style={{
|
|
backgroundColor: "var(--color-background)",
|
|
color: "var(--color-text-readable)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Password & Confirm */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<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)",
|
|
}}
|
|
/>
|
|
</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="block text-xs font-bold uppercase tracking-wider"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{t("password")}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
id="password"
|
|
name="password"
|
|
type={showPassword ? "text" : "password"}
|
|
placeholder={t("passwordPlaceholder")}
|
|
autoComplete="new-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
|
style={{
|
|
backgroundColor: "var(--color-background)",
|
|
color: "var(--color-text-readable)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
|
}}
|
|
required
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
tabIndex={-1}
|
|
>
|
|
{showPassword ? t("hidePassword") : t("showPassword")}
|
|
</button>
|
|
</div>
|
|
{password.length > 0 && (
|
|
<div className="animate-fade-in-up space-y-1">
|
|
<div className="flex gap-1">
|
|
{[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) 15%, transparent)",
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<p
|
|
className="text-xs font-bold"
|
|
style={{ color: strength.color }}
|
|
>
|
|
{strength.label}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label
|
|
htmlFor="password_confirmation"
|
|
className="block text-xs font-bold uppercase tracking-wider"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{t("confirmPassword")}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
id="password_confirmation"
|
|
name="password_confirmation"
|
|
type={showConfirm ? "text" : "password"}
|
|
placeholder={t("confirmPasswordPlaceholder")}
|
|
autoComplete="new-password"
|
|
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
|
style={{
|
|
backgroundColor: "var(--color-background)",
|
|
color: "var(--color-text-readable)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
|
}}
|
|
required
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowConfirm(!showConfirm)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
tabIndex={-1}
|
|
>
|
|
{showConfirm ? t("hidePassword") : t("showPassword")}
|
|
</button>
|
|
</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: "var(--color-background)",
|
|
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-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg disabled:opacity-60 ${
|
|
termsAccepted ? "" : "opacity-70 saturate-[0.85] hover:scale-100"
|
|
}`}
|
|
style={{
|
|
background: "var(--color-primary)",
|
|
color: "var(--color-primary-foreground)",
|
|
boxShadow: termsAccepted
|
|
? "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)"
|
|
: "0 2px 10px color-mix(in srgb, var(--color-primary) 12%, 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="text-center">
|
|
<Link
|
|
href="/login"
|
|
className="text-sm font-bold hover:underline transition-all"
|
|
style={{
|
|
color: "var(--color-primary-readable, var(--color-primary))",
|
|
}}
|
|
>
|
|
{t("alreadyHaveAccount")}
|
|
</Link>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|