Redesign login, register, me pages with premium layout and i18n
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m7s

This commit is contained in:
openhands committed 2026-07-22 14:42:01 +02:00
1 parent 063ac9ec40
commit 1ffd46713a
7 files changed
+621 -279

No files matched your search

+170 -9
View File
@@ -1,19 +1,180 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { LoginForm } from "@/components/auth/login-form"; import { LoginForm } from "@/components/auth/login-form";
import { cached } from "@/lib/cache";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { captchaConfig } from "@/lib/services/captcha"; import { captchaConfig } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
export default async function LoginPage() { export default async function LoginPage() {
const cfg = await captchaConfig(); const t = await getTranslations("pages.login");
const th = await getTranslations("header");
const [hotelName, imagerRaw, cfg] = await Promise.all([
siteSettings.get("hotel_name", "Hotel"),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
captchaConfig(),
]);
const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage";
const nonce = (await headers()).get("x-nonce") ?? undefined; const nonce = (await headers()).get("x-nonce") ?? undefined;
const [online, recentUsers] = await Promise.all([
cached("online_count", 10_000, () =>
prisma.user.count({ where: { online: "1" } }),
).catch(() => 0),
prisma.user
.findMany({
where: { online: "1" },
select: { username: true, look: true },
take: 8,
})
.catch(() => []),
]);
return ( return (
<LoginForm <div className="flex flex-col lg:flex-row gap-10">
captcha={{ {/* Left panel */}
provider: cfg.provider, <div className="lg:w-80 shrink-0 space-y-4">
siteKey: cfg.siteKey || undefined, <div
field: cfg.field || undefined, className="rounded-2xl border p-6 text-center"
}} style={{
nonce={nonce} borderColor:
/> "color-mix(in srgb, var(--color-primary) 20%, transparent)",
background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)), var(--color-surface))`,
}}
>
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={130}
height={170}
className="object-contain mx-auto drop-shadow-xl"
unoptimized
priority
/>
<div
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
background:
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
<span className="w-2 h-2 rounded-full bg-green-500" />
{th("online", { count: online, hotel: "" }).trim()}
</div>
<h1
className="text-xl font-black"
style={{ color: "var(--color-text-readable)" }}
>
{t("welcomeBack")}
</h1>
<p
className="text-xs mt-1 mx-auto max-w-[200px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{t("welcomeBackSub", { hotelName: hotelName ?? "Hotel" })}
</p>
</div>
{recentUsers.length > 0 && (
<div
className="rounded-2xl border p-4"
style={{
background: "var(--color-surface)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
>
<h3
className="text-xs font-bold uppercase tracking-widest mb-3"
style={{ color: "var(--color-text-muted)" }}
>
{th("online", { count: online, hotel: "" }).trim()}
</h3>
<div className="grid grid-cols-4 gap-1.5">
{recentUsers.map((u) => (
<div
key={u.username}
className="flex flex-col items-center gap-0.5 p-1.5 rounded-lg"
style={{
background:
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
}}
>
<Image
src={avatarImageUrl(imager, u.look, {
headOnly: true,
direction: 2,
size: "s",
})}
alt=""
width={26}
height={32}
className="rounded-md"
unoptimized
/>
<span
className="text-[8px] font-bold truncate w-full text-center leading-tight"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
</div>
))}
</div>
</div>
)}
<p
className="text-xs text-center"
style={{ color: "var(--color-text-muted)" }}
>
No account?{" "}
<Link
href="/register"
className="font-extrabold hover:underline"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
Create one
</Link>
</p>
</div>
{/* Right: form */}
<div className="flex-1">
<div className="mb-6">
<h2
className="text-2xl font-black tracking-tight"
style={{ color: "var(--color-text-readable)" }}
>
{t("title")}
</h2>
<p
className="text-sm mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
{t("subtitle")}
</p>
</div>
<LoginForm
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
</div>
</div>
); );
} }
+10 -7
View File
@@ -2,11 +2,6 @@
import { useState } from "react"; import { useState } from "react";
/**
* Copies the user's referral link to the clipboard. Mirrors AtomCMS's
* copyCode() helper on the /me page, but resolves the link to an absolute URL
* (the server only knows a relative path, so we prepend the current origin).
*/
export default function CopyReferralButton({ value }: { value: string }) { export default function CopyReferralButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -18,7 +13,6 @@ export default function CopyReferralButton({ value }: { value: string }) {
try { try {
await navigator.clipboard.writeText(absolute); await navigator.clipboard.writeText(absolute);
} catch { } catch {
// Older browsers / insecure contexts: fall back to a hidden textarea.
const ta = document.createElement("textarea"); const ta = document.createElement("textarea");
ta.value = absolute; ta.value = absolute;
ta.style.position = "fixed"; ta.style.position = "fixed";
@@ -37,7 +31,16 @@ export default function CopyReferralButton({ value }: { value: string }) {
} }
return ( return (
<button type="button" className="btn btn-secondary" onClick={copy}> <button
type="button"
onClick={copy}
className="rounded-xl font-bold text-sm px-5 py-2.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shrink-0"
style={{
background: "color-mix(in srgb, var(--color-primary) 12%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
border: `2px solid color-mix(in srgb, var(--color-primary) 20%, transparent)`,
}}
>
{copied ? "Copied!" : "Copy link"} {copied ? "Copied!" : "Copy link"}
</button> </button>
); );
+202 -107
View File
@@ -1,13 +1,9 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { claimReferral } from "@/actions/referral"; import { claimReferral } from "@/actions/referral";
import { import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
ContentCard,
EmptyState,
OnlineBadge,
StatBlock,
} from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -20,7 +16,6 @@ export const metadata = { title: "My dashboard" };
type SearchParams = Promise<{ claimed?: string; error?: string }>; type SearchParams = Promise<{ claimed?: string; error?: string }>;
/** Inline-styled feedback banner using the theme's CSS variables. */
function feedbackStyle(tone: "success" | "error" | "warning"): CSSProperties { function feedbackStyle(tone: "success" | "error" | "warning"): CSSProperties {
const accent = const accent =
tone === "error" tone === "error"
@@ -72,7 +67,6 @@ export default async function MePage({
let content: ReactNode; let content: ReactNode;
try { try {
// --- Account + reward config -----------------------------------------
const [user, imagerBase, neededRaw, amountRaw, currencyRaw] = const [user, imagerBase, neededRaw, amountRaw, currencyRaw] =
await Promise.all([ await Promise.all([
prisma.user.findUnique({ prisma.user.findUnique({
@@ -118,9 +112,6 @@ export default async function MePage({
.catch(() => null), .catch(() => null),
]); ]);
// The session was already validated above; a missing row here is an
// exceptional state, so surface the error card rather than redirect from
// inside the try (a redirect() throw would be swallowed by the catch).
if (!user) throw new Error("user-not-found"); if (!user) throw new Error("user-not-found");
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5; const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
@@ -129,7 +120,6 @@ export default async function MePage({
.trim() .trim()
.toLowerCase(); .toLowerCase();
// --- Referral tally ---------------------------------------------------
const referrals = await prisma.userReferrals const referrals = await prisma.userReferrals
.findFirst({ .findFirst({
where: { userId }, where: { userId },
@@ -141,9 +131,6 @@ export default async function MePage({
const canClaim = referralTotal >= needed; const canClaim = referralTotal >= needed;
const remaining = Math.max(0, needed - referralTotal); const remaining = Math.max(0, needed - referralTotal);
// --- Online friends ---------------------------------------------------
// Friendships are bidirectional and stored once: the user can be either
// user_one_id or user_two_id; the OTHER column is the friend.
const friendships = await prisma.messengerFriendships const friendships = await prisma.messengerFriendships
.findMany({ .findMany({
where: { OR: [{ userOneId: userId }, { userTwoId: userId }] }, where: { OR: [{ userOneId: userId }, { userTwoId: userId }] },
@@ -181,13 +168,16 @@ export default async function MePage({
.slice(0, 10); .slice(0, 10);
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`; const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
const imager = imagerBase ?? "https://www.habbo.com/habbo-imaging/avatarimage";
const avatarFull = avatarImageUrl(imager, user.look, { size: "l", direction: 2 });
const avatarHead = avatarImageUrl(imager, user.look, { headOnly: true, direction: 2, size: "s" });
content = ( content = (
<> <div className="flex flex-col gap-8 pb-8">
{/* Feedback banners --------------------------------------------- */} {/* Feedback banners */}
{claimed ? ( {claimed ? (
<div role="status" style={feedbackStyle("success")}> <div role="status" style={feedbackStyle("success")}>
Woah! You have successfully claimed your reward — keep up the good Woah! You have successfully claimed your reward — keep up the good work!
work!
</div> </div>
) : null} ) : null}
{error ? ( {error ? (
@@ -196,129 +186,222 @@ export default async function MePage({
</div> </div>
) : null} ) : null}
{/* Header card -------------------------------------------------- */} {/* ── Profile header ── */}
<ContentCard <div
icon="🏠" className="rounded-2xl border p-6 flex flex-col sm:flex-row items-center sm:items-start gap-6"
title={user.username} style={{
subtitle={user.motto || "Welcome back!"} background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)), var(--color-surface))`,
/> borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
{/* Stat tiles --------------------------------------------------- */} >
<div className="card-grid sm-2 lg-3"> <div className="relative shrink-0">
<StatBlock <div
icon="💰" className="absolute -inset-3 rounded-full opacity-[0.15] blur-[40px]"
value={user.credits.toLocaleString()} style={{ background: "var(--color-primary)" }}
label="Credits" />
/> <Image
<StatBlock icon="🎖️" value={`Rank ${user.rank}`} label="Hotel rank" /> src={avatarFull}
<StatBlock icon="📅" value={registered} label="Registered" /> alt={`${user.username} avatar`}
width={120}
height={160}
className="object-contain drop-shadow-xl relative"
unoptimized
priority
/>
</div>
<div className="flex-1 text-center sm:text-left">
<div
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-3 border"
style={{
borderColor: "color-mix(in srgb, var(--color-primary) 18%, transparent)",
background: "color-mix(in srgb, var(--color-primary) 10%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
Rank {user.rank}
</div>
<h1
className="text-2xl md:text-3xl font-black tracking-tight"
style={{ color: "var(--color-text-readable)" }}
>
{user.username}
</h1>
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
{user.motto || "No motto"}
</p>
</div>
</div> </div>
{/* Online friends ----------------------------------------------- */} {/* ── Stat tiles ── */}
<ContentCard <div className="grid grid-cols-3 gap-4">
icon="🟢" {[
title="Online friends" { value: user.credits.toLocaleString(), label: "Credits" },
subtitle={ { value: `Rank ${user.rank}`, label: "Hotel rank" },
onlineFriends.length === 0 { value: registered, label: "Registered" },
? "None of your friends are online right now" ].map((s) => (
: `${onlineFriends.length} of your ${friends.length} friends online` <div
} key={s.label}
action={<Link href="/friends">View all</Link>} className="rounded-2xl border p-5 text-center transition-all duration-200 hover:-translate-y-0.5"
padded={onlineFriends.length === 0} style={{
background: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
>
<div
className="text-xl md:text-2xl font-black tracking-tight mb-0.5"
style={{ color: "var(--color-primary)" }}
>
{s.value}
</div>
<div
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--color-text-muted)" }}
>
{s.label}
</div>
</div>
))}
</div>
{/* ── Online friends ── */}
<div
className="rounded-2xl border p-5"
style={{
background: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
> >
<div className="flex items-center justify-between mb-4">
<div>
<h2
className="text-lg font-black tracking-tight"
style={{ color: "var(--color-text-readable)" }}
>
Online friends
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--color-text-muted)" }}>
{onlineFriends.length === 0
? "None of your friends are online right now"
: `${onlineFriends.length} of your ${friends.length} friends online`}
</p>
</div>
<Link
href="/friends"
className="text-xs font-bold hover:underline shrink-0"
style={{ color: "var(--color-primary)" }}
>
View all
</Link>
</div>
{onlineFriends.length === 0 ? ( {onlineFriends.length === 0 ? (
<EmptyState icon="💤"> <div className="text-center py-6">
No friends online right now. Check back later! <p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
</EmptyState> No friends online right now. Check back later!
</p>
</div>
) : ( ) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{onlineFriends.map((friend) => { {onlineFriends.map((friend) => {
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, { const avatar = avatarImageUrl(imager, friend.look, {
size: "s", size: "s",
headOnly: true, headOnly: true,
}); });
return ( return (
<div <Link
key={friend.id} key={friend.id}
className="card hover" href={`/u/${friend.username}`}
className="flex items-center gap-3 p-3 rounded-xl transition-all duration-200 hover:-translate-y-0.5"
style={{ style={{
display: "flex", background: "color-mix(in srgb, var(--color-primary) 4%, transparent)",
gap: "0.85rem", textDecoration: "none",
alignItems: "center",
}} }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} <Image
<img
className="avatar"
src={avatar} src={avatar}
alt={`${friend.username} avatar`} alt={`${friend.username} avatar`}
width={50} width={40}
height={50} height={50}
className="rounded-lg shrink-0"
unoptimized
/> />
<div style={{ minWidth: 0, flex: 1 }}> <div className="min-w-0">
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
<Link href={`/u/${friend.username}`}>
{friend.username}
</Link>
</h3>
<p <p
className="muted" className="text-sm font-extrabold truncate"
style={{ style={{ color: "var(--color-text-readable)" }}
margin: "0 0 0.4rem", >
overflow: "hidden", {friend.username}
textOverflow: "ellipsis", </p>
whiteSpace: "nowrap", <p
}} className="text-[10px] font-semibold truncate mt-0.5"
style={{ color: "var(--color-text-muted)" }}
> >
{friend.motto || "No motto"} {friend.motto || "No motto"}
</p> </p>
<OnlineBadge online /> <span
className="inline-flex items-center gap-1 text-[10px] font-bold mt-1"
style={{ color: "var(--color-success)" }}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: "var(--color-success)" }} />
Online
</span>
</div> </div>
</div> </Link>
); );
})} })}
</div> </div>
)} )}
</ContentCard> </div>
{/* Referral card ------------------------------------------------ */} {/* ── Referral card ── */}
<ContentCard <div
icon="🤝" className="rounded-2xl border p-5"
title={`Refer a friend (${referralTotal}/${needed})`} style={{
subtitle="Refer new users and earn in-game rewards" background: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
> >
<div style={{ display: "grid", gap: "1rem" }}> <div className="mb-4">
<p style={{ margin: 0 }}> <h2
className="text-lg font-black tracking-tight"
style={{ color: "var(--color-text-readable)" }}
>
Refer a friend ({referralTotal}/{needed})
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--color-text-muted)" }}>
Refer new users and earn in-game rewards
</p>
</div>
<div className="flex flex-col gap-4">
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
{rewardAmount > 0 {rewardAmount > 0
? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!` ? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!`
: `Invite ${needed} users through your referral link to claim a reward!`} : `Invite ${needed} users through your referral link to claim a reward!`}
</p> </p>
<div role="note" style={feedbackStyle("warning")}> <div role="note" style={feedbackStyle("warning")}>
⚠️ Boosting referrals by making your own accounts will lead to ⚠️ Boosting referrals by making your own accounts will lead to removal of all progress, currency, inventory and a potential ban.
removal of all progress, currency, inventory and a potential ban.
</div> </div>
<div> <div>
<label <label
htmlFor="referral-link" htmlFor="referral-link"
className="muted" className="text-xs font-bold mb-1.5 block"
style={{ style={{ color: "var(--color-text-muted)" }}
display: "block",
marginBottom: "0.35rem",
fontSize: "0.85rem",
}}
> >
Your referral link Your referral link
</label> </label>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div className="flex gap-2 flex-wrap">
<input <input
id="referral-link" id="referral-link"
type="text" type="text"
readOnly readOnly
defaultValue={referralLink} defaultValue={referralLink}
className="input" className="flex-1 min-w-0 rounded-xl border-2 px-4 py-2.5 text-sm font-medium"
style={{ flex: "1 1 240px", minWidth: 0 }} style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/> />
<CopyReferralButton value={referralLink} /> <CopyReferralButton value={referralLink} />
</div> </div>
@@ -328,8 +411,12 @@ export default async function MePage({
<form action={claimReferral}> <form action={claimReferral}>
<button <button
type="submit" type="submit"
className="btn btn-primary" className="w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
style={{ width: "100%" }} style={{
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
}}
> >
Claim your referral reward! Claim your referral reward!
</button> </button>
@@ -338,26 +425,34 @@ export default async function MePage({
<button <button
type="button" type="button"
disabled disabled
className="btn" className="w-full rounded-xl font-extrabold text-sm py-3.5 opacity-60 cursor-not-allowed"
style={{ width: "100%", opacity: 0.6, cursor: "not-allowed" }} style={{
background: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
color: "var(--color-text-muted)",
}}
> >
{`Refer ${remaining} more ${remaining === 1 ? "user" : "users"} to unlock your reward`} {`Refer ${remaining} more ${remaining === 1 ? "user" : "users"} to unlock your reward`}
</button> </button>
)} )}
</div> </div>
</ContentCard> </div>
</> </div>
); );
} catch { } catch {
content = ( content = (
<ContentCard icon="⚠️" title="My dashboard"> <div
<EmptyState icon="⚠️"> className="rounded-2xl border p-6 text-center"
We couldn&apos;t load your dashboard right now. Please try again style={{
shortly. background: "var(--color-surface)",
</EmptyState> borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
</ContentCard> }}
>
<p className="text-sm font-bold" style={{ color: "var(--color-text-muted)" }}>
We couldn&apos;t load your dashboard right now. Please try again shortly.
</p>
</div>
); );
} }
return <main style={{ display: "grid", gap: "1.5rem" }}>{content}</main>; return <main>{content}</main>;
} }
+89 -38
View File
@@ -33,7 +33,7 @@ async function getHotelData() {
.findMany({ .findMany({
where: { online: "1" }, where: { online: "1" },
select: { username: true, look: true }, select: { username: true, look: true },
take: 12, take: 30,
}) })
.catch(() => []), .catch(() => []),
prisma.cameraWeb prisma.cameraWeb
@@ -54,7 +54,8 @@ async function getHotelData() {
} }
export default async function Home() { export default async function Home() {
const t = await getTranslations("header"); const tp = await getTranslations("pages.home");
const th = await getTranslations("header");
const { const {
hotelName, hotelName,
imager, imager,
@@ -68,6 +69,21 @@ export default async function Home() {
return ( return (
<div className="flex flex-col gap-16 pb-16"> <div className="flex flex-col gap-16 pb-16">
<style>{`
@keyframes train-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.train-track {
animation: train-scroll 40s linear infinite;
display: flex;
gap: 0.75rem;
width: max-content;
}
.train-track:hover {
animation-play-state: paused;
}
`}</style>
{/* ── 1. Welcome section ── */} {/* ── 1. Welcome section ── */}
<div className="flex flex-col lg:flex-row items-center gap-10 lg:gap-16"> <div className="flex flex-col lg:flex-row items-center gap-10 lg:gap-16">
<div className="flex-1 text-center lg:text-left"> <div className="flex-1 text-center lg:text-left">
@@ -82,20 +98,13 @@ export default async function Home() {
}} }}
> >
<span className="w-2 h-2 rounded-full bg-green-500" /> <span className="w-2 h-2 rounded-full bg-green-500" />
{online.toLocaleString()} online {th("online", { count: online, hotel: hotelName ?? "Hotel" })}
</div> </div>
<h1 <h1
className="text-4xl md:text-5xl lg:text-6xl font-black leading-[1.05] tracking-tight mb-4" className="text-4xl md:text-5xl lg:text-6xl font-black leading-[1.05] tracking-tight mb-4"
style={{ color: "var(--color-text-readable)" }} style={{ color: "var(--color-text-readable)" }}
> >
Welcome to{" "} {tp("welcome", { hotelName: hotelName ?? "Hotel" })}
<span
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{hotelName}
</span>
</h1> </h1>
<p <p
className="text-base md:text-lg leading-relaxed max-w-xl mb-8" className="text-base md:text-lg leading-relaxed max-w-xl mb-8"
@@ -104,7 +113,7 @@ export default async function Home() {
"var(--color-text-muted-readable, var(--color-text-muted))", "var(--color-text-muted-readable, var(--color-text-muted))",
}} }}
> >
{t("tagline")} {th("tagline")}
</p> </p>
<div className="flex flex-wrap gap-3 justify-center lg:justify-start"> <div className="flex flex-wrap gap-3 justify-center lg:justify-start">
<Link <Link
@@ -124,7 +133,7 @@ export default async function Home() {
height={20} height={20}
unoptimized unoptimized
/> />
Enter {hotelName} {tp("enterHotel", { hotelName: hotelName ?? "Hotel" })}
</Link> </Link>
<Link <Link
href="/client" href="/client"
@@ -135,7 +144,7 @@ export default async function Home() {
color: "var(--color-text-readable)", color: "var(--color-text-readable)",
}} }}
> >
Play now {th("nitroClient")}
</Link> </Link>
</div> </div>
</div> </div>
@@ -181,7 +190,7 @@ export default async function Home() {
{u.username} {u.username}
</p> </p>
<p className="text-[10px] font-semibold text-green-500"> <p className="text-[10px] font-semibold text-green-500">
Online {tp("statsOnline")}
</p> </p>
</div> </div>
</div> </div>
@@ -193,9 +202,9 @@ export default async function Home() {
{/* ── 2. Stats ── */} {/* ── 2. Stats ── */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{[ {[
{ value: online.toLocaleString(), label: "Online now", icon: "🟢" }, { value: online.toLocaleString(), label: tp("statsOnline"), icon: "🟢" },
{ value: users.toLocaleString(), label: "Total users", icon: "👤" }, { value: users.toLocaleString(), label: tp("statsCitizens"), icon: "👤" },
{ value: rooms.toLocaleString(), label: "Rooms", icon: "🏠" }, { value: rooms.toLocaleString(), label: tp("statsRooms"), icon: "🏠" },
].map((s) => ( ].map((s) => (
<div <div
key={s.label} key={s.label}
@@ -222,28 +231,70 @@ export default async function Home() {
))} ))}
</div> </div>
{/* ── 3. Features ── */} {/* ── 3. User train ── */}
{recentUsers.length > 0 && (
<div
className="rounded-2xl border overflow-hidden py-3"
style={{
background: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
>
<div className="train-track">
{[...recentUsers, ...recentUsers].map((u, i) => (
<div
key={`${u.username}-${i}`}
className="flex flex-col items-center gap-0.5 px-2 py-1.5 rounded-xl transition-all duration-200 hover:scale-110"
style={{
background: "color-mix(in srgb, var(--color-primary) 4%, transparent)",
}}
>
<Image
src={avatarImageUrl(imager, u.look, {
headOnly: true,
direction: 2,
size: "s",
})}
alt={u.username}
width={34}
height={42}
className="rounded-md"
unoptimized
/>
<span
className="text-[9px] font-bold truncate max-w-[54px] text-center leading-tight"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
</div>
))}
</div>
</div>
)}
{/* ── 4. Features ── */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[ {[
{ {
img: "/assets/images/icons/star.gif", img: "/assets/images/icons/star.gif",
title: "Avatar", title: tp("featuresAvatar"),
desc: "Create your unique look with thousands of outfits", desc: tp("featuresAvatarDesc"),
}, },
{ {
img: "/assets/images/icons/catalog.png", img: "/assets/images/icons/catalog.png",
title: "Furniture", title: tp("featuresFurniture"),
desc: "Decorate rooms with hundreds of items", desc: tp("featuresFurnitureDesc"),
}, },
{ {
img: "/assets/images/icons/navigation/community.png", img: "/assets/images/icons/navigation/community.png",
title: "Community", title: tp("featuresCommunity"),
desc: "Make friends and join groups", desc: tp("featuresCommunityDesc"),
}, },
{ {
img: "/assets/images/icons/navigation/bb.png", img: "/assets/images/icons/navigation/bb.png",
title: "Games", title: tp("featuresGames"),
desc: "Compete in battles and events", desc: tp("featuresGamesDesc"),
}, },
].map((f) => ( ].map((f) => (
<div <div
@@ -280,7 +331,7 @@ export default async function Home() {
))} ))}
</div> </div>
{/* ── 4. News + Sidebar ── */} {/* ── 5. News + Sidebar ── */}
<div className="grid lg:grid-cols-12 gap-6"> <div className="grid lg:grid-cols-12 gap-6">
<div className="lg:col-span-8 space-y-4"> <div className="lg:col-span-8 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -288,14 +339,14 @@ export default async function Home() {
className="text-xl font-black tracking-tight" className="text-xl font-black tracking-tight"
style={{ color: "var(--color-text-readable)" }} style={{ color: "var(--color-text-readable)" }}
> >
News {tp("latestNews")}
</h2> </h2>
<Link <Link
href="/news" href="/news"
className="text-sm font-bold hover:underline" className="text-sm font-bold hover:underline"
style={{ color: "var(--color-primary)" }} style={{ color: "var(--color-primary)" }}
> >
View all → {tp("allNews")}
</Link> </Link>
</div> </div>
{articles.length > 0 ? ( {articles.length > 0 ? (
@@ -349,7 +400,7 @@ export default async function Home() {
</div> </div>
) : ( ) : (
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}> <p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
No news yet. {tp("noArticles")}
</p> </p>
)} )}
</div> </div>
@@ -369,7 +420,7 @@ export default async function Home() {
style={{ color: "var(--color-text-muted)" }} style={{ color: "var(--color-text-muted)" }}
> >
<span className="w-2 h-2 rounded-full bg-green-500" /> <span className="w-2 h-2 rounded-full bg-green-500" />
Online now {tp("recentUsers")}
</h3> </h3>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{recentUsers.slice(0, 9).map((u) => ( {recentUsers.slice(0, 9).map((u) => (
@@ -418,7 +469,7 @@ export default async function Home() {
className="text-xs font-bold uppercase tracking-widest mb-3" className="text-xs font-bold uppercase tracking-widest mb-3"
style={{ color: "var(--color-text-muted)" }} style={{ color: "var(--color-text-muted)" }}
> >
Photos {tp("recentPhotos")}
</h3> </h3>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{recentPhotos.map((p) => ( {recentPhotos.map((p) => (
@@ -461,13 +512,13 @@ export default async function Home() {
className="text-xs font-bold" className="text-xs font-bold"
style={{ color: "var(--color-text-muted)" }} style={{ color: "var(--color-text-muted)" }}
> >
See you inside! {tp("joinNow", { hotelName: hotelName ?? "Hotel" })}
</p> </p>
</div> </div>
</aside> </aside>
</div> </div>
{/* ── 5. CTA ── */} {/* ── 6. CTA ── */}
<div <div
className="rounded-2xl border text-center px-6 py-10" className="rounded-2xl border text-center px-6 py-10"
style={{ style={{
@@ -480,13 +531,13 @@ export default async function Home() {
className="text-2xl md:text-3xl font-black tracking-tight mb-2" className="text-2xl md:text-3xl font-black tracking-tight mb-2"
style={{ color: "var(--color-text-readable)" }} style={{ color: "var(--color-text-readable)" }}
> >
Ready to join? {tp("joinNow", { hotelName: hotelName ?? "Hotel" })}
</h2> </h2>
<p <p
className="text-sm mb-6 max-w-sm mx-auto" className="text-sm mb-6 max-w-sm mx-auto"
style={{ color: "var(--color-text-muted)" }} style={{ color: "var(--color-text-muted)" }}
> >
Create your free account and start your adventure today. {tp("welcomeBody")}
</p> </p>
<Link <Link
href="/register" href="/register"
@@ -505,7 +556,7 @@ export default async function Home() {
height={20} height={20}
unoptimized unoptimized
/> />
Create free account {tp("register")}
</Link> </Link>
</div> </div>
</div> </div>
+10 -16
View File
@@ -10,7 +10,8 @@ import { captchaConfig } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export default async function RegisterPage() { export default async function RegisterPage() {
const t = await getTranslations("header"); const th = await getTranslations("header");
const tpr = await getTranslations("pages.register");
const [hotelName, imagerRaw, cfg] = await Promise.all([ const [hotelName, imagerRaw, cfg] = await Promise.all([
siteSettings.get("hotel_name", "Hotel"), siteSettings.get("hotel_name", "Hotel"),
siteSettings.get( siteSettings.get(
@@ -67,26 +68,19 @@ export default async function RegisterPage() {
}} }}
> >
<span className="w-2 h-2 rounded-full bg-green-500" /> <span className="w-2 h-2 rounded-full bg-green-500" />
{online.toLocaleString()} online {tpr("usersOnline", { count: online })}
</div> </div>
<h1 <h1
className="text-xl font-black" className="text-xl font-black"
style={{ color: "var(--color-text-readable)" }} style={{ color: "var(--color-text-readable)" }}
> >
Join{" "} {tpr("title")} — {hotelName}
<span
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{hotelName}
</span>
</h1> </h1>
<p <p
className="text-xs mt-1 mx-auto max-w-[200px] leading-relaxed" className="text-xs mt-1 mx-auto max-w-[200px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }} style={{ color: "var(--color-text-muted)" }}
> >
{t("tagline")} {th("tagline")}
</p> </p>
</div> </div>
@@ -103,7 +97,7 @@ export default async function RegisterPage() {
className="text-xs font-bold uppercase tracking-widest mb-3" className="text-xs font-bold uppercase tracking-widest mb-3"
style={{ color: "var(--color-text-muted)" }} style={{ color: "var(--color-text-muted)" }}
> >
Who&apos;s online {tpr("whoIsOnline")}
</h3> </h3>
<div className="grid grid-cols-4 gap-1.5"> <div className="grid grid-cols-4 gap-1.5">
{recentUsers.map((u) => ( {recentUsers.map((u) => (
@@ -143,7 +137,7 @@ export default async function RegisterPage() {
className="text-xs text-center" className="text-xs text-center"
style={{ color: "var(--color-text-muted)" }} style={{ color: "var(--color-text-muted)" }}
> >
Already have an account?{" "} {tpr("alreadyHaveAccountPre")}{" "}
<Link <Link
href="/login" href="/login"
className="font-extrabold hover:underline" className="font-extrabold hover:underline"
@@ -151,7 +145,7 @@ export default async function RegisterPage() {
color: "var(--color-primary-readable, var(--color-primary))", color: "var(--color-primary-readable, var(--color-primary))",
}} }}
> >
Sign in {tpr("alreadyHaveAccountLink")}
</Link> </Link>
</p> </p>
</div> </div>
@@ -163,13 +157,13 @@ export default async function RegisterPage() {
className="text-2xl font-black tracking-tight" className="text-2xl font-black tracking-tight"
style={{ color: "var(--color-text-readable)" }} style={{ color: "var(--color-text-readable)" }}
> >
Create your account {tpr("title")}
</h2> </h2>
<p <p
className="text-sm mt-0.5" className="text-sm mt-0.5"
style={{ color: "var(--color-text-muted)" }} style={{ color: "var(--color-text-muted)" }}
> >
Free and takes less than a minute {tpr("subtitle")}
</p> </p>
</div> </div>
<RegisterForm <RegisterForm
+122 -101
View File
@@ -10,7 +10,6 @@ import {
type CaptchaPublicConfig, type CaptchaPublicConfig,
readCaptchaToken, readCaptchaToken,
} from "@/components/auth/captcha-widget"; } from "@/components/auth/captcha-widget";
import { ContentCard } from "@/components/public/ui";
export function LoginForm({ export function LoginForm({
captcha = { provider: "none" }, captcha = { provider: "none" },
@@ -64,117 +63,139 @@ export function LoginForm({
); );
return; return;
} }
window.location.href = "/"; window.location.href = "/me";
} finally { } finally {
setPending(false); setPending(false);
} }
} }
return ( return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}> <>
<ContentCard {needs2fa && (
icon={needs2fa ? "🔒" : "🔑"} <p className="text-sm font-semibold mb-4" style={{ color: "var(--color-text-muted)" }}>
title={t("title")} {t("subtitle2fa")}
subtitle={needs2fa ? t("subtitle2fa") : t("subtitle")} </p>
> )}
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}> <form onSubmit={onSubmit} className="flex flex-col gap-4">
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
autoComplete="username"
disabled={needs2fa}
className="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, var(--color-text))",
borderColor: needs2fa
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
autoComplete="current-password"
disabled={needs2fa}
className="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, var(--color-text))",
borderColor: needs2fa
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
{needs2fa ? (
<input <input
value={username} value={code}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setCode(e.target.value)}
placeholder={t("usernamePlaceholder")} placeholder={t("codePlaceholder")}
autoComplete="username" inputMode="numeric"
disabled={needs2fa} autoComplete="one-time-code"
/> autoFocus
<input className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
autoComplete="current-password"
disabled={needs2fa}
/>
{needs2fa ? (
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("codePlaceholder")}
inputMode="numeric"
autoComplete="one-time-code"
// biome-ignore lint/a11y/noAutofocus: focus the only relevant field in the 2FA step
autoFocus
/>
) : (
<CaptchaWidget captcha={captcha} nonce={nonce} />
)}
<button type="submit" className="btn btn-primary" disabled={pending}>
{pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")}
</button>
</form>
{!needs2fa ? (
<>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
margin: "1rem 0",
}}
>
<span
style={{
flex: 1,
height: 1,
background: "var(--border-color)",
}}
/>
<span className="muted">{t("or")}</span>
<span
style={{
flex: 1,
height: 1,
background: "var(--border-color)",
}}
/>
</div>
<div style={{ display: "grid", gap: "0.5rem" }}>
<button
type="button"
className="btn btn-outline"
onClick={() => signIn("discord", { callbackUrl: "/" })}
>
{t("continueWithDiscord")}
</button>
<button
type="button"
className="btn btn-outline"
onClick={() => signIn("google", { callbackUrl: "/" })}
>
{t("continueWithGoogle")}
</button>
</div>
</>
) : null}
{error ? (
<p
style={{ style={{
color: "var(--color-danger)", backgroundColor: "var(--color-background)",
textAlign: "center", color: "var(--color-text-readable, var(--color-text))",
marginBottom: 0, borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}} }}
> />
) : (
<CaptchaWidget captcha={captcha} nonce={nonce} />
)}
{error && (
<p className="text-sm text-center font-semibold" style={{ color: "var(--color-danger)" }}>
{error} {error}
</p> </p>
) : null} )}
<p
className="muted" <button
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }} type="submit"
disabled={pending}
className="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)",
}}
> >
{t("noAccount")} <Link href="/register">{t("createOne")}</Link> ·{" "} {pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")}
<Link href="/forgot">{t("forgotPassword")}</Link> </button>
</p> </form>
</ContentCard>
</main> {!needs2fa && (
<>
<div className="flex items-center gap-3 my-5">
<span className="flex-1 h-px" style={{ background: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" }} />
<span className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--color-text-muted)" }}>
{t("or")}
</span>
<span className="flex-1 h-px" style={{ background: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" }} />
</div>
<div className="flex flex-col gap-3">
<button
type="button"
className="w-full rounded-xl border-2 font-bold text-sm py-3 transition-all hover:scale-[1.02] active:scale-95"
onClick={() => signIn("discord", { callbackUrl: "/" })}
style={{
borderColor: "color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
color: "var(--color-text-readable)",
}}
>
{t("continueWithDiscord")}
</button>
<button
type="button"
className="w-full rounded-xl border-2 font-bold text-sm py-3 transition-all hover:scale-[1.02] active:scale-95"
onClick={() => signIn("google", { callbackUrl: "/" })}
style={{
borderColor: "color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
color: "var(--color-text-readable)",
}}
>
{t("continueWithGoogle")}
</button>
</div>
</>
)}
<p
className="text-xs text-center mt-6"
style={{ color: "var(--color-text-muted)" }}
>
{t("noAccount")}{" "}
<Link href="/register" className="font-extrabold hover:underline" style={{ color: "var(--color-primary)" }}>
{t("createOne")}
</Link>{" "}
·{" "}
<Link href="/forgot" className="font-extrabold hover:underline" style={{ color: "var(--color-primary)" }}>
{t("forgotPassword")}
</Link>
</p>
</>
); );
} }
+18 -1
View File
@@ -234,7 +234,18 @@
"noArticles": "No articles yet.", "noArticles": "No articles yet.",
"noPhotos": "No photos yet.", "noPhotos": "No photos yet.",
"noUsers": "No users online", "noUsers": "No users online",
"latestArticle": "Latest Article" "latestArticle": "Latest Article",
"statsOnline": "Online",
"statsCitizens": "Citizens",
"statsRooms": "Rooms",
"featuresAvatar": "Avatar",
"featuresAvatarDesc": "Create your unique look",
"featuresFurniture": "Furniture",
"featuresFurnitureDesc": "Decorate rooms with items",
"featuresCommunity": "Community",
"featuresCommunityDesc": "Make friends and chat",
"featuresGames": "Games",
"featuresGamesDesc": "Compete and have fun"
}, },
"news": { "news": {
"title": "News", "title": "News",
@@ -894,6 +905,8 @@
"title": "Sign in", "title": "Sign in",
"subtitle": "Welcome back — log in to enter the hotel.", "subtitle": "Welcome back — log in to enter the hotel.",
"subtitle2fa": "Enter the 6-digit code from your authenticator.", "subtitle2fa": "Enter the 6-digit code from your authenticator.",
"welcomeBack": "Welcome back",
"welcomeBackSub": "Sign in to continue to {hotelName}",
"usernamePlaceholder": "Username", "usernamePlaceholder": "Username",
"passwordPlaceholder": "Password", "passwordPlaceholder": "Password",
"codePlaceholder": "2FA code", "codePlaceholder": "2FA code",
@@ -927,7 +940,11 @@
"createAccount": "Create account", "createAccount": "Create account",
"creatingAccount": "Creating account...", "creatingAccount": "Creating account...",
"alreadyHaveAccount": "Already have an account? Login!", "alreadyHaveAccount": "Already have an account? Login!",
"alreadyHaveAccountPre": "Already have an account?",
"alreadyHaveAccountLink": "Sign in",
"register": "Register", "register": "Register",
"whoIsOnline": "Who's online",
"usersOnline": "{count} online",
"termsError": "You must accept the terms & rules to register.", "termsError": "You must accept the terms & rules to register.",
"usernameError": "Username is required.", "usernameError": "Username is required.",
"emailError": "Valid email is required.", "emailError": "Valid email is required.",