latest changes
This commit is contained in:
1 parent
56cd6fd058
commit
1de72863db
40 files changed
+3837
-568
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,44 +2,90 @@
|
||||
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useTransition, useState, useRef, useEffect } from "react";
|
||||
|
||||
const LOCALES: { code: string; label: string }[] = [
|
||||
{ code: "en", label: "EN" },
|
||||
{ code: "it", label: "IT" },
|
||||
const LOCALES: { code: string; label: string; lang: string }[] = [
|
||||
{ code: "nl", label: "Nederlands", lang: "nl" },
|
||||
{ code: "en", label: "English", lang: "en" },
|
||||
{ code: "de", label: "Deutsch", lang: "de" },
|
||||
{ code: "fr", label: "Français", lang: "fr" },
|
||||
{ code: "es", label: "Español", lang: "es" },
|
||||
{ code: "it", label: "Italiano", lang: "it" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Locale picker. Writes the chosen locale to the `NEXT_LOCALE` cookie (read by
|
||||
* src/i18n/request.ts on the server) and refreshes the route so server
|
||||
* components re-render with the new messages. No URL change — matches the
|
||||
* cookie-based, routing-free i18n setup.
|
||||
*/
|
||||
export function LanguageSwitcher() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const current = LOCALES.find((l) => l.code === locale) ?? LOCALES[0];
|
||||
|
||||
function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const next = e.target.value;
|
||||
document.cookie = `NEXT_LOCALE=${next};path=/;max-age=31536000;samesite=lax`;
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
function switchLocale(code: string) {
|
||||
if (code === locale) return;
|
||||
document.cookie = `NEXT_LOCALE=${code};path=/;max-age=31536000;samesite=lax`;
|
||||
startTransition(() => router.refresh());
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
value={locale}
|
||||
onChange={onChange}
|
||||
disabled={pending}
|
||||
aria-label="Language"
|
||||
className="nav-item cursor-pointer bg-transparent text-[13px]"
|
||||
style={{ border: "none", padding: "0 0.25rem" }}
|
||||
>
|
||||
{LOCALES.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
disabled={pending}
|
||||
className="nav-item flex items-center gap-1.5 bg-transparent text-[13px]"
|
||||
style={{ border: "none", cursor: "pointer", padding: "0 0.25rem" }}
|
||||
>
|
||||
<img
|
||||
src={`/assets/images/icons/flags/${current.code}.png`}
|
||||
alt={current.lang}
|
||||
className="inline-block h-4 w-auto"
|
||||
/>
|
||||
<span>{current.code.toUpperCase()}</span>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 mt-1 min-w-[160px] rounded-md shadow-lg z-50 py-1"
|
||||
style={{ backgroundColor: "var(--color-dropdown)" }}
|
||||
>
|
||||
{LOCALES.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
type="button"
|
||||
onClick={() => switchLocale(l.code)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm font-semibold transition-colors ${
|
||||
l.code === locale ? "opacity-100" : "opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/assets/images/icons/flags/${l.code}.png`}
|
||||
alt={l.lang}
|
||||
className="inline-block h-4 w-auto"
|
||||
/>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,98 +11,82 @@ export async function Navigation() {
|
||||
|
||||
return (
|
||||
<nav className="relative shadow-sm" style={{ backgroundColor: "var(--color-navbar)" }}>
|
||||
<div className="max-w-7xl min-h-[60px] px-4 flex items-center gap-x-6 md:gap-x-8 mx-auto flex-wrap">
|
||||
<div className="mx-auto flex min-h-[60px] max-w-7xl flex-wrap items-center px-4 gap-x-1 md:gap-x-2">
|
||||
<Link href="/" className="nav-item">
|
||||
<i className="navigation-icon home mr-1 hidden lg:inline-flex" />
|
||||
<img src="/assets/images/icons/navigation/me.png" alt="" className="mr-1 hidden w-5 h-5 lg:inline-block" />
|
||||
{t("home")}
|
||||
</Link>
|
||||
|
||||
<details className="relative">
|
||||
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
|
||||
<i className="navigation-icon community mr-1 hidden lg:inline-flex" />
|
||||
<img src="/assets/images/icons/navigation/community.png" alt="" className="mr-1 hidden w-5 h-5 lg:inline-block" />
|
||||
{t("community")}
|
||||
</summary>
|
||||
<div
|
||||
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
|
||||
style={{ backgroundColor: "var(--color-dropdown)" }}
|
||||
>
|
||||
<Link href="/news" className="dropdown-item">
|
||||
{t("articles")}
|
||||
</Link>
|
||||
<Link href="/photos" className="dropdown-item">
|
||||
{t("photos")}
|
||||
</Link>
|
||||
<Link href="/staff" className="dropdown-item">
|
||||
{t("staff")}
|
||||
</Link>
|
||||
<Link href="/rankings" className="dropdown-item">
|
||||
{t("rankings")}
|
||||
</Link>
|
||||
<Link href="/guilds" className="dropdown-item">
|
||||
{t("guilds")}
|
||||
</Link>
|
||||
<Link href="/news" className="dropdown-item">{t("articles")}</Link>
|
||||
<Link href="/photos" className="dropdown-item">{t("photos")}</Link>
|
||||
<Link href="/staff" className="dropdown-item">{t("staff")}</Link>
|
||||
<Link href="/rankings" className="dropdown-item">{t("rankings")}</Link>
|
||||
<Link href="/guilds" className="dropdown-item">{t("guilds")}</Link>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="relative">
|
||||
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
|
||||
<img src="/assets/images/icons/navigation/shop.png" alt="" className="mr-1 hidden w-5 h-5 lg:inline-block" />
|
||||
{t("shop")}
|
||||
</summary>
|
||||
<div
|
||||
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
|
||||
style={{ backgroundColor: "var(--color-dropdown)" }}
|
||||
>
|
||||
<Link href="/shop" className="dropdown-item">{t("shop")}</Link>
|
||||
<Link href="/marketplace" className="dropdown-item">{t("market")}</Link>
|
||||
<Link href="/rares" className="dropdown-item">{t("rareValues")}</Link>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Link href="/leaderboard" className="nav-item">
|
||||
<i className="navigation-icon leaderboards mr-1 hidden lg:inline-flex" />
|
||||
<img src="/assets/images/icons/navigation/leaderboards.png" alt="" className="mr-1 hidden w-5 h-5 lg:inline-block" />
|
||||
{t("leaderboards")}
|
||||
</Link>
|
||||
<Link href="/rares" className="nav-item">
|
||||
{t("rareValues")}
|
||||
</Link>
|
||||
<Link href="/shop" className="nav-item">
|
||||
<i className="navigation-icon shop mr-1 hidden lg:inline-flex" />
|
||||
{t("shop")}
|
||||
</Link>
|
||||
<Link href="/marketplace" className="nav-item">
|
||||
{t("market")}
|
||||
|
||||
<Link href="/radio" className="nav-item">
|
||||
{t("radio")}
|
||||
</Link>
|
||||
|
||||
<details className="relative">
|
||||
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
|
||||
<i className="navigation-icon rules mr-1 hidden lg:inline-flex" />
|
||||
<img src="/assets/images/icons/navigation/rules.gif" alt="" className="mr-1 hidden w-5 h-5 lg:inline-block" />
|
||||
{t("assistance")}
|
||||
</summary>
|
||||
<div
|
||||
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
|
||||
style={{ backgroundColor: "var(--color-dropdown)" }}
|
||||
>
|
||||
<Link href="/help" className="dropdown-item">
|
||||
{t("helpCenter")}
|
||||
</Link>
|
||||
<Link href="/help" className="dropdown-item">{t("helpCenter")}</Link>
|
||||
{session?.user ? (
|
||||
<Link href="/help/tickets" className="dropdown-item">
|
||||
{t("myTickets")}
|
||||
</Link>
|
||||
<Link href="/help/tickets" className="dropdown-item">{t("myTickets")}</Link>
|
||||
) : null}
|
||||
<Link href="/badges" className="dropdown-item">
|
||||
{t("badges")}
|
||||
</Link>
|
||||
<Link href="/draw-badge" className="dropdown-item">
|
||||
{t("drawBadge")}
|
||||
</Link>
|
||||
<Link href="/badges" className="dropdown-item">{t("badges")}</Link>
|
||||
<Link href="/draw-badge" className="dropdown-item">{t("drawBadge")}</Link>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Link href="/radio" className="nav-item">
|
||||
{t("radio")}
|
||||
</Link>
|
||||
{session?.user ? (
|
||||
<Link href="/friends" className="nav-item">
|
||||
{t("friends")}
|
||||
</Link>
|
||||
) : null}
|
||||
{session?.user ? (
|
||||
<Link href="/messages" className="nav-item">
|
||||
{t("messages")}
|
||||
</Link>
|
||||
<>
|
||||
<Link href="/friends" className="nav-item">{t("friends")}</Link>
|
||||
<Link href="/messages" className="nav-item">{t("messages")}</Link>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isStaff ? (
|
||||
<Link href="/admin" className="nav-item">
|
||||
{t("admin")}
|
||||
</Link>
|
||||
<Link href="/admin" className="nav-item">{t("admin")}</Link>
|
||||
) : null}
|
||||
|
||||
<div className="ml-auto flex items-center gap-x-2">
|
||||
<LanguageSwitcher />
|
||||
<ThemeSwitcher />
|
||||
|
||||
@@ -20,94 +20,66 @@ export async function SiteHeader() {
|
||||
online = 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex min-h-[13rem] h-52 w-full items-center justify-center"
|
||||
style={{ background: `url(${header})`, backgroundSize: "cover", backgroundPosition: "center" }}
|
||||
>
|
||||
<div className="absolute h-full w-full bg-black/50" />
|
||||
|
||||
{session?.user ? (
|
||||
<div className="relative flex h-full w-full max-w-7xl items-center justify-center pr-10 md:justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Link href="/" className="ml-7">
|
||||
if (session?.user) {
|
||||
return (
|
||||
<div
|
||||
className="relative flex min-h-[13rem] h-52 w-full items-center justify-center"
|
||||
style={{ background: `url(${header})`, backgroundSize: "cover", backgroundPosition: "center" }}
|
||||
>
|
||||
<div className="absolute h-full w-full bg-black/50" />
|
||||
<div className="relative flex items-center justify-center w-full px-4">
|
||||
<div className="flex flex-col items-center justify-center max-w-[600px]">
|
||||
<Link href="/" className="transition-all duration-300 hover:scale-110 mb-2.5">
|
||||
{logo ? (
|
||||
// biome-ignore lint/performance/noImgElement: external/CDN logo
|
||||
<img
|
||||
className="transition duration-300 ease-in-out hover:scale-105"
|
||||
src={logo}
|
||||
alt="Hotel logo"
|
||||
/>
|
||||
<img className="drop-shadow block" src={logo} alt="Hotel logo" />
|
||||
) : (
|
||||
<span className="text-3xl font-extrabold text-white text-shadow">{hotelName}</span>
|
||||
<span className="text-4xl font-extrabold text-white text-shadow">{hotelName}</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div
|
||||
className="hidden md:flex items-center px-4 rounded-md relative h-[50px]"
|
||||
style={{ backgroundColor: "var(--color-surface)", color: "var(--color-text)" }}
|
||||
>
|
||||
<div
|
||||
className="absolute -left-1 h-6 w-6 rotate-45"
|
||||
style={{ backgroundColor: "var(--color-surface)" }}
|
||||
/>
|
||||
<span className="relative">
|
||||
{t("online", { count: online, hotel: hotelName ?? "Atom" })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2 md:flex-row md:gap-x-4">
|
||||
<Link href="/client">
|
||||
<button
|
||||
type="button"
|
||||
className="font-bold py-3 px-6 rounded-xl shadow-xl border-2 transition-all duration-300 hover:scale-110 hover:shadow-2xl text-base tracking-wide"
|
||||
style={{
|
||||
backgroundColor: "var(--button-color)",
|
||||
color: "var(--button-text-color)",
|
||||
borderColor: "rgba(255,255,255,0.3)",
|
||||
}}
|
||||
>
|
||||
{t("nitroClient")}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex justify-center px-4">
|
||||
<div
|
||||
className="font-semibold flex flex-col items-center md:w-[600px]"
|
||||
style={{ color: "#ffffff", textShadow: "0 1px 4px rgba(0,0,0,0.7)" }}
|
||||
>
|
||||
<p className="hidden text-center text-xl md:block">{t("tagline")}</p>
|
||||
<div className="flex flex-col items-center justify-center gap-x-6 gap-y-4 md:mt-6 md:flex-row md:gap-y-0">
|
||||
<Link href="/login">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border-2 px-8 py-2 uppercase transition duration-200 ease-in-out"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
backgroundColor: "var(--color-primary)",
|
||||
color: "var(--button-text-color)",
|
||||
}}
|
||||
>
|
||||
{t("login")}
|
||||
</button>
|
||||
</Link>
|
||||
<p className="text-sm uppercase">{t("or")}</p>
|
||||
<Link href="/register">
|
||||
<button
|
||||
type="button"
|
||||
className="uppercase px-8 py-2.5 rounded-full transition ease-in-out duration-200"
|
||||
style={{ backgroundColor: "var(--color-accent)", color: "#ffffff" }}
|
||||
>
|
||||
{t("createAccount")}
|
||||
</button>
|
||||
</Link>
|
||||
<div className="relative flex items-center bg-white dark:bg-gray-800 rounded-md h-[50px] px-4 mt-2">
|
||||
<div className="absolute w-6 h-6 bg-white dark:bg-gray-800 rotate-45 -translate-x-1/2 left-1/2 -top-3 z-0" aria-hidden="true" />
|
||||
<div className="flex items-center gap-x-2 relative z-10">
|
||||
<img src="/assets/images/user-online.png" alt="Online Icon" className="p-1" style={{ marginTop: "-2px" }} />
|
||||
<span className="whitespace-nowrap text-gray-800 dark:text-white">
|
||||
{t("online", { count: online, hotel: hotelName ?? "Atom" })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex items-center justify-center w-full h-52"
|
||||
style={{
|
||||
backgroundImage: `url(/assets/images/mybobba_banner.png)`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
>
|
||||
<div className="absolute w-full h-full bg-black/50" />
|
||||
<div className="relative flex flex-col items-center justify-center px-4">
|
||||
<Link href="/" className="transition-all duration-300 hover:scale-110 mb-2.5">
|
||||
{logo ? (
|
||||
<img className="drop-shadow block" src={logo} alt="Hotel logo" />
|
||||
) : (
|
||||
<span className="text-4xl font-extrabold text-white text-shadow">{hotelName}</span>
|
||||
)}
|
||||
</Link>
|
||||
<div className="relative flex items-center bg-white dark:bg-gray-800 rounded-md h-[50px] px-4 mt-2">
|
||||
<div className="absolute w-6 h-6 bg-white dark:bg-gray-800 rotate-45 -translate-x-1/2 left-1/2 -top-3 z-0" aria-hidden="true" />
|
||||
<div className="flex items-center gap-x-2 relative z-10">
|
||||
<img src="/assets/images/user-online.png" alt="Online Icon" className="p-1" style={{ marginTop: "-2px" }} />
|
||||
<span className="whitespace-nowrap text-gray-800 dark:text-white">
|
||||
{t("online", { count: online, hotel: hotelName ?? "Atom" })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user