latest changes

This commit is contained in:
remco committed 2026-07-01 15:06:52 +02:00
1 parent 56cd6fd058
commit 1de72863db
40 files changed
+3837 -568

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { avatarImageUrl } from "@/lib/format";
interface AvatarCarouselProps {
figures: string[];
imager: string;
onSelect: (figure: string) => void;
initialIndex?: number;
}
export function AvatarCarousel({
figures,
imager,
onSelect,
initialIndex = 0,
}: AvatarCarouselProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [direction, setDirection] = useState(0);
const isAnimating = useRef(false);
const prevIndexRef = useRef(initialIndex);
const navigate = useCallback((dir: number) => {
if (isAnimating.current || figures.length <= 1) return;
isAnimating.current = true;
setDirection(dir);
prevIndexRef.current = currentIndex;
setCurrentIndex((prev) => (prev + dir + figures.length) % figures.length);
setTimeout(() => { isAnimating.current = false; }, 300);
}, [currentIndex, figures.length]);
const goToIndex = useCallback((index: number) => {
if (isAnimating.current) return;
isAnimating.current = true;
const dir = index > currentIndex ? 1 : -1;
setDirection(dir);
prevIndexRef.current = currentIndex;
setCurrentIndex(index);
setTimeout(() => { isAnimating.current = false; }, 300);
}, [currentIndex]);
return (
<div className="relative w-full max-w-sm mx-auto" role="region" aria-label="Avatar selection">
<div className="relative overflow-hidden rounded-2xl bg-white p-4 shadow-xl dark:bg-gray-800">
<div className="transition-transform duration-300 ease-out" style={{ transform: `translateX(${direction * 100}%)` }}>
{figures.map((figure, idx) => (
<div
key={figure}
className="relative w-full h-48 flex items-center justify-center"
style={{
position: "absolute",
left: `${idx * 100}%`,
top: 0,
transition: "transform 300ms ease-out",
transform: `translateX(${(currentIndex - prevIndexRef.current) * -100}%)`,
}}
>
<div className="relative w-48 h-64">
<img
src={avatarImageUrl(imager, figure, { headOnly: false, direction: 2 })}
alt={`Avatar ${idx + 1}`}
className="w-full h-full object-contain"
loading="lazy"
/>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-center gap-3 mt-4">
<button
type="button"
onClick={() => navigate(-1)}
disabled={isAnimating.current || figures.length <= 1}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/80 dark:bg-gray-800/80 text-gray-700 dark:text-gray-300 shadow-md hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Previous avatar"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div className="flex items-center gap-2">
{figures.map((_, idx) => (
<button
key={idx}
type="button"
onClick={() => goToIndex(idx)}
disabled={isAnimating.current}
className={`w-2.5 h-2.5 rounded-full transition-all ${
idx === currentIndex
? "bg-[#eeb425] scale-125"
: "bg-gray-400 hover:bg-gray-500 dark:bg-gray-600 dark:hover:bg-gray-500"
}`}
aria-label={`Select avatar ${idx + 1}`}
/>
))}
</div>
<button
type="button"
onClick={() => navigate(1)}
disabled={isAnimating.current || figures.length <= 1}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/80 dark:bg-gray-800/80 text-gray-700 dark:text-gray-300 shadow-md hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Next avatar"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { signIn } from "next-auth/react";
import { type FormEvent, useState } from "react";
import { precheckLogin } from "@/actions/auth-precheck";
export function HomeLoginForm() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [needs2fa, setNeeds2fa] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setPending(true);
try {
if (!needs2fa) {
const pre = await precheckLogin(username, password);
if (pre === "invalid") {
setError("Invalid username or password");
return;
}
if (pre === "twofactor") {
setNeeds2fa(true);
return;
}
}
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
if (!res || res.error) {
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
return;
}
window.location.href = "/";
} finally {
setPending(false);
}
}
return (
<form onSubmit={onSubmit} className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800">
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder=""
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
required
disabled={needs2fa}
/>
</fieldset>
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder=""
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
required
disabled={needs2fa}
/>
</fieldset>
{needs2fa ? (
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">2FA Code</label>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Enter your 2FA code"
inputMode="numeric"
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
autoFocus
/>
</fieldset>
) : null}
{error ? (
<p className="m-0 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
) : null}
<button
type="submit"
disabled={pending}
className="w-full cursor-pointer rounded-md border-2 p-2 text-base font-semibold text-white transition-colors disabled:opacity-50"
style={{
backgroundColor: "#27a44d",
borderColor: "#34d399",
}}
>
{pending ? "Please wait..." : needs2fa ? "Verify" : "Login"}
</button>
</form>
);
}
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { useState, FormEvent, ChangeEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { AvatarCarousel } from "@/components/auth/avatar-carousel";
interface RegisterFormProps {
initialFigures: string[];
imager: string;
hotelName: string;
captcha: { provider: string; siteKey?: string };
error?: string;
}
export function RegisterForm({
initialFigures,
imager,
hotelName,
captcha,
error,
}: RegisterFormProps) {
const t = useTranslations("pages.register");
const router = useRouter();
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
const [selectedFigure, setSelectedFigure] = useState(initialFigures[0] || "");
const [formData, setFormData] = useState({
username: "",
mail: "",
password: "",
password_confirmation: "",
terms: false,
beta_code: "",
referral_code: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({ ...prev, [name]: type === "checkbox" ? checked : value }));
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: "" }));
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const newErrors: Record<string, string> = {};
if (!formData.username.trim()) newErrors.username = t("usernameError");
if (!formData.mail.trim()) newErrors.mail = t("emailError");
if (!formData.password) newErrors.password = t("passwordError");
if (formData.password !== formData.password_confirmation) {
newErrors.password_confirmation = t("confirmPasswordError");
}
if (!formData.terms) newErrors.terms = t("termsError");
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
setIsSubmitting(false);
return;
}
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...formData,
look: selectedFigure,
}),
});
const data = await res.json();
if (!res.ok) {
setErrors({ form: data.message || "Registration failed" });
setIsSubmitting(false);
return;
}
router.push("/login?registered=1");
} catch {
setErrors({ form: "An error occurred. Please try again." });
setIsSubmitting(false);
}
};
return (
<div className="mx-auto w-full max-w-[800px]">
{/* Header Banner */}
<div
className="relative overflow-hidden bg-center bg-cover rounded-t-lg"
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" 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>
<img src="/assets/images/FrankwithBag.gif" className="w-full h-full" style={{ objectFit: "contain", objectPosition: "center" }} />
</div>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 flex flex-col gap-6" style={{
backgroundColor: "color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
borderRadius: "0 0 8px 8px",
}}>
{error && (
<div className="p-3 rounded-md text-sm" style={{ backgroundColor: "#ef4444", color: "white" }}>
{error}
</div>
)}
{/* Username & Email */}
<div className="w-full flex flex-col md:flex-row gap-4">
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label htmlFor="username" className="block font-semibold text-sm" style={{ color: "var(--color-text)" }}>
{t("username")}
</label>
<input
id="username"
name="username"
type="text"
placeholder={t("usernamePlaceholder")}
autoComplete="username"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.username && <p className="text-xs text-red-500">{errors.username}</p>}
</fieldset>
</div>
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label htmlFor="mail" className="block font-semibold text-sm" style={{ color: "var(--color-text)" }}>
{t("email")}
</label>
<input
id="mail"
name="mail"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.mail && <p className="text-xs text-red-500">{errors.mail}</p>}
</fieldset>
</div>
</div>
{/* Password & Confirm */}
<div className="w-full flex flex-col md:flex-row gap-4">
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label htmlFor="password" className="block font-semibold text-sm" style={{ color: "var(--color-text)" }}>
{t("password")}
</label>
<input
id="password"
name="password"
type="password"
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.password && <p className="text-xs text-red-500">{errors.password}</p>}
</fieldset>
</div>
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label htmlFor="password_confirmation" className="block font-semibold text-sm" style={{ color: "var(--color-text)" }}>
{t("confirmPassword")}
</label>
<input
id="password_confirmation"
name="password_confirmation"
type="password"
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.password_confirmation && <p className="text-xs text-red-500">{errors.password_confirmation}</p>}
</fieldset>
</div>
</div>
{/* Avatar Selection */}
<div className="w-full rounded-md p-4" style={{
backgroundColor: "color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
border: "1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
}}>
<legend className="text-sm font-semibold mb-3" style={{ color: "var(--color-text)" }}>
{t("selectOutfit")}
</legend>
<AvatarCarousel
figures={initialFigures}
imager={imager}
onSelect={setSelectedFigure}
/>
</div>
{/* Terms */}
<div className="rounded-md p-3 flex flex-col gap-3" style={{
backgroundColor: "color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
}}>
<fieldset className="flex flex-wrap items-center w-full gap-3 text-sm">
<input
id="terms"
name="terms"
type="checkbox"
className="w-4 h-4 border-gray-300 rounded focus:ring-0"
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 30%, transparent)" }}
required
onChange={handleChange}
/>
<label htmlFor="terms" className="font-semibold" style={{ color: "var(--color-text)" }}>
<a href="/help-center/rules" target="_blank" rel="noopener noreferrer" className="hover:underline" style={{ color: "var(--color-text)" }}>
{t("termsAccept", { hotel: hotelName })}
</a>
</label>
</fieldset>
{errors.terms && <p className="text-xs text-red-500 italic">{errors.terms}</p>}
</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={isSubmitting}
className="w-full rounded p-2 text-white font-semibold transition-all duration-200 text-base"
style={{
backgroundColor: "#27a44d",
border: "2px solid #34c661",
}}
>
{isSubmitting ? t("creatingAccount") : t("createAccount")}
</button>
{/* Social Login - optional */}
{false && (
<div className="pt-4 border-t" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" }}>
<p className="text-center text-sm mb-3" style={{ color: "var(--color-text-muted)" }}>
Or register with
</p>
<div className="flex flex-col gap-2">
{/* Google, Discord, GitHub buttons would go here */}
</div>
</div>
)}
<div className="text-center">
<Link href="/login" className="text-sm font-semibold hover:underline" style={{ color: "var(--color-primary)" }}>
{t("alreadyHaveAccount")}
</Link>
</div>
</form>
</div>
);
}