- Add LICENSE file (CC BY-NC-SA 4.0) - Add .nvmrc pinning Node 22 - Add Renovate config with daily schedule and Gitea Actions workflow - Reduce ESLint max-warnings from 1000 to 50 - Re-enable Biome a11y/security recommended rules - Fix Biome lint issues (a11y, hook deps, SVG labels, checkbox semantics) - Improve CI: run on pushes to feat/fix branches, add pnpm audit - Add Vitest coverage with v8 provider and thresholds - Add E2E tests (auth, admin, navigation specs) - Add admin-maintenance server action test - Install @vitest/coverage-v8 - Ignore coverage/ directory
384 lines
11 KiB
TypeScript
384 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import Script from "next/script";
|
|
import { useTranslations } from "next-intl";
|
|
import { useActionState, useState } from "react";
|
|
import { register } from "@/actions/register";
|
|
|
|
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 [serverError, formAction, isPending] = useActionState(register, null);
|
|
const [termsAccepted, setTermsAccepted] = useState(false);
|
|
const [password, setPassword] = useState("");
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
const strength = passwordStrength(password);
|
|
|
|
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="relative overflow-hidden bg-center bg-cover rounded-t-xl"
|
|
style={{
|
|
backgroundImage: "url(/assets/images/mybobba_banner.png)",
|
|
height: "180px",
|
|
maxHeight: "150px",
|
|
}}
|
|
>
|
|
<div className="absolute inset-0 z-10 bg-black/30" />
|
|
<div className="relative flex items-center justify-center w-full h-full z-20">
|
|
<div
|
|
className="relative animate-float"
|
|
style={{ width: "140px", height: "140px" }}
|
|
>
|
|
<div
|
|
className="absolute inset-0 rounded-full overflow-hidden"
|
|
style={{ zIndex: -1 }}
|
|
>
|
|
<div
|
|
className="w-full h-full"
|
|
style={{
|
|
background: "black",
|
|
filter: "blur(8px)",
|
|
transform: "scale(1.2)",
|
|
opacity: 0.6,
|
|
}}
|
|
/>
|
|
</div>
|
|
<Image
|
|
src="/assets/images/FrankwithBag.gif"
|
|
alt=""
|
|
fill
|
|
className="!static"
|
|
style={{
|
|
objectFit: "contain",
|
|
objectPosition: "center",
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<form
|
|
action={formAction}
|
|
className="p-6 flex flex-col gap-6"
|
|
style={{
|
|
backgroundColor:
|
|
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
|
|
borderRadius: "0 0 12px 12px",
|
|
}}
|
|
>
|
|
{(error || serverError) && (
|
|
<div
|
|
className="animate-fade-in-up p-3 rounded-lg text-sm font-semibold text-white"
|
|
style={{ backgroundColor: "var(--color-danger)" }}
|
|
>
|
|
{error || serverError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Username & Email */}
|
|
<div className="w-full 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>
|
|
|
|
{/* Password & Confirm */}
|
|
<div className="w-full 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 ? "Hide" : "Show"}
|
|
</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 ? "Hide" : "Show"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Terms */}
|
|
<div
|
|
className="rounded-xl p-4 flex flex-col gap-3"
|
|
style={{
|
|
backgroundColor:
|
|
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
|
|
border:
|
|
"1px solid color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
id="terms"
|
|
name="terms"
|
|
checked={termsAccepted}
|
|
onChange={(e) => setTermsAccepted(e.target.checked)}
|
|
className="w-5 h-5 rounded border-2 accent-(--color-primary) shrink-0 cursor-pointer"
|
|
/>
|
|
<label
|
|
htmlFor="terms"
|
|
className="font-semibold cursor-pointer select-none"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
onClick={() => setTermsAccepted(!termsAccepted)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
setTermsAccepted(!termsAccepted);
|
|
}
|
|
}}
|
|
>
|
|
{t("termsAccept", { hotel: hotelName })}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Captcha */}
|
|
{showCaptcha && captcha.provider === "turnstile" && (
|
|
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
|
|
)}
|
|
{showCaptcha && captcha.provider === "recaptcha" && (
|
|
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
|
|
)}
|
|
|
|
{/* Submit */}
|
|
<button
|
|
type="submit"
|
|
disabled={isPending}
|
|
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"
|
|
style={{
|
|
background: "var(--color-primary)",
|
|
color: "var(--color-primary-foreground)",
|
|
boxShadow:
|
|
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
|
}}
|
|
>
|
|
{isPending ? (
|
|
<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>
|
|
{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>
|
|
);
|
|
}
|