feat(auth): implement all 16 homepage/login/register review items
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m47s
CI / tests-ui (push) Successful in 2m30s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m56s

- add countArticles() (published-only, mirrors news-list) and warm total_articles
- localize homepage metadata; bind articleCount to both stats; unique photo alts
- drop duplicate news date and the mascot preload priorities
- extract shared AuthPageFrame/AuthUsersCards used by /login and /register
- login: localized noindex metadata, session redirect via safeRedirectPath,
  ?from passthrough from proxy, unified auth roster cache keys, registered notice
- register: localized metadata, session redirect to /me, unified cache keys
- add resend-verification flow on /verify with rate-limited non-enumerable action
- add safeRedirectPath() with unit tests
- register form: live requirements checklist + password mismatch guard
- login form: unverified state with resend-link CTA
- honour prefers-reduced-motion in TypewriterText
- add 6 translations across all 25 locales
This commit is contained in:
openhands committed 2026-10-08 18:49:27 +02:00
1 parent 8561c3f85e
commit 3933214953
43 files changed
+1037 -490

No files matched your search

+164 -209
View File
@@ -1,20 +1,41 @@
import { count, desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuthTopBar } from "@/components/auth/auth-top-bar";
import {
AuthPageFrame,
AuthUsersCards,
} from "@/components/auth/auth-page-frame";
import { LoginForm } from "@/components/auth/login-form";
import { Reveal } from "@/components/motion-reveal";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth";
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
export default async function LoginPage() {
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.login");
const title = t("title");
const description = t("subtitle");
return {
title,
description,
// Sign-in is a dead end for crawlers and duplicates the homepage copy.
robots: { index: false, follow: false },
openGraph: { title, description, type: "website" },
};
}
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ from?: string; registered?: string }>;
}) {
const t = await getTranslations("pages.login");
const [hotelName, cfg, logo] = await Promise.all([
resolveHotelName(),
@@ -23,6 +44,14 @@ export default async function LoginPage() {
]);
const nonce = (await headers()).get("x-nonce") ?? undefined;
const sp = await searchParams;
const redirectTo = safeRedirectPath(sp.from);
// Already signed in: the form has nothing to do here. Honour `from` first so
// an admin bounced off /admin lands back where they were heading.
const session = await auth();
if (session?.user?.id) redirect(redirectTo);
const [online, recentUsers, latestUsers] = await Promise.all([
cached("online_count", 10_000, () =>
db
@@ -31,227 +60,153 @@ export default async function LoginPage() {
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0),
).catch(() => 0),
db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8)
.catch(() => []),
db
.select({ username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8)
.catch(() => []),
cached(
"auth_online_users",
10_000,
() =>
db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8),
{ staleMs: 30000 },
).catch(() => []),
cached(
"auth_latest_users",
30_000,
() =>
db
.select({ username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8),
{ staleMs: 60000 },
).catch(() => []),
]);
// `/login?registered=1` is where the sign-up form lands when it could not
// auto sign-in (e-mail verification still pending). Without this notice the
// visitor would only see an empty login form and no sign their account
// exists.
const notice =
sp.registered === "1" ? (
<p
role="status"
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
>
{t("registeredSuccess")}
</p>
) : undefined;
return (
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
<div className="relative">
<AuthPageFrame
hotelName={hotelName}
logo={logo}
title={t("title")}
subtitle={t("subtitle")}
notice={notice}
form={
<LoginForm
redirectTo={redirectTo}
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
}
>
<SurfaceCard
className="card-glow relative overflow-hidden p-6 text-center"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
aria-hidden="true"
className="pointer-events-none absolute -inset-x-16 -top-24 overflow-hidden"
>
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-20 top-0 h-80 w-80"
className="aurora-blob -left-16 top-10 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 34%, transparent)",
opacity: 0.45,
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-20 top-6 h-72 w-72"
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 30%, transparent)",
animationDelay: "-7s",
opacity: 0.4,
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
</div>
<AuthTopBar hotelName={hotelName} logo={logo} />
</div>
<Reveal>
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
{/* Left panel */}
<div className="lg:w-96 shrink-0 space-y-4">
<SurfaceCard
className="card-glow relative overflow-hidden p-6 text-center"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
<div className="relative">
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={130}
height={170}
className="relative object-contain mx-auto drop-shadow-xl animate-float"
unoptimized
/>
<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) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<div
aria-hidden="true"
className="absolute inset-0 overflow-hidden"
>
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-16 top-10 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
</div>
<div className="relative">
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={130}
height={170}
className="relative object-contain mx-auto drop-shadow-xl animate-float"
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) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color:
"var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{t("usersOnline", { count: online })}
</div>
<h1
className="text-xl font-black"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{t("welcomeBack")}
</h1>
<p
className="text-xs mt-1.5 mx-auto max-w-[220px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{t("welcomeBackSub", { hotelName })}
</p>
</div>
</SurfaceCard>
{recentUsers.length > 0 && (
<SurfaceCard
title={t("whoIsOnline")}
icon={ICON_NAV_GOODY}
bodyClassName="p-4"
className="card-glow"
>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
{recentUsers.map((u) => (
<div
key={u.username}
title={u.username}
className="avatar-tile"
>
<div className="relative">
<UserAvatarThumbnail
figure={u.look}
alt=""
options={{ direction: 2 }}
className="rounded-lg"
/>
<span className="avatar-online absolute -bottom-0.5 -right-0.5" />
</div>
<span className="avatar-name">{u.username}</span>
</div>
))}
</div>
</SurfaceCard>
)}
{latestUsers.length > 0 && (
<SurfaceCard
title={t("newestCitizens")}
icon={ICON_FRIENDS}
bodyClassName="p-4"
className="card-glow"
>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
{latestUsers.map((u) => (
<div
key={u.username}
title={u.username}
className="avatar-tile"
>
<UserAvatarThumbnail
figure={u.look}
alt=""
options={{ direction: 2 }}
className="rounded-lg"
/>
<span className="avatar-name">{u.username}</span>
</div>
))}
</div>
</SurfaceCard>
)}
</div>
{/* Right: form */}
<div className="flex-1 lg:sticky lg:top-6">
<div className="gradient-border">
<SurfaceCard
title={t("title")}
icon={ICON_NAV_ME}
bodyClassName="p-6 sm:p-7"
className="card-glow"
>
<p
className="text-sm mb-6 leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{t("subtitle")}
</p>
<LoginForm
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
</SurfaceCard>
</div>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{t("usersOnline", { count: online })}
</div>
<h1
className="text-xl font-black"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{t("welcomeBack")}
</h1>
<p
className="text-xs mt-1.5 mx-auto max-w-[220px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{t("welcomeBackSub", { hotelName })}
</p>
</div>
</Reveal>
</div>
</SurfaceCard>
<AuthUsersCards
recentUsers={recentUsers}
latestUsers={latestUsers}
recentTitle={t("whoIsOnline")}
latestTitle={t("newestCitizens")}
/>
</AuthPageFrame>
);
}
+22 -24
View File
@@ -26,6 +26,7 @@ import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { getNewsList } from "@/lib/services/news-list";
import {
countArticles,
countPhotos,
countRooms,
countUsers,
@@ -42,17 +43,17 @@ import {
ICON_NAV_ME,
} from "@/lib/site-icons";
export const metadata: Metadata = {
title: "Home",
description:
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
openGraph: {
title: "Home",
description:
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
type: "website",
},
};
export async function generateMetadata(): Promise<Metadata> {
const tn = await getTranslations("nav");
const tp = await getTranslations("pages.home");
const title = tn("home");
const description = tp("welcomeBody");
return {
title,
description,
openGraph: { title, description, type: "website" },
};
}
// ── Local visual language (theme-aware, no big white blocks) ──────────
const PANEL_BG =
@@ -177,6 +178,7 @@ async function getHotelData() {
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers,
recentPhotos,
@@ -197,6 +199,9 @@ async function getHotelData() {
cached("total_photos", 300_000, countPhotos, { staleMs: 300000 }).catch(
publicReadFailure("home.photos-count"),
),
cached("total_articles", 300_000, countArticles, {
staleMs: 300000,
}).catch(publicReadFailure("home.articles-count")),
getNewsList(4, { throwOnError: true }).catch(
publicReadFailure("home.news"),
),
@@ -230,6 +235,7 @@ async function getHotelData() {
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers,
recentPhotos,
@@ -251,6 +257,7 @@ export default async function Home() {
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers,
recentPhotos,
@@ -344,12 +351,6 @@ export default async function Home() {
>
{a.title}
</h3>
<p
className="mt-0.5 text-[10px] sm:text-[11px] font-semibold opacity-80"
style={{ color: "#cbd5e1" }}
>
{formatDate(a.createdAt, "date", "")}
</p>
</div>
</div>
</Link>
@@ -373,7 +374,7 @@ export default async function Home() {
color: "var(--color-primary)",
},
{
value: articles?.length ?? null,
value: articleCount,
label: tp("statsArticles"),
color: "var(--color-accent)",
},
@@ -699,7 +700,6 @@ export default async function Home() {
alt=""
width={96}
height={128}
priority
unoptimized
className="relative animate-float object-contain drop-shadow-2xl"
/>
@@ -856,7 +856,7 @@ export default async function Home() {
icon: ICON_CAMERA,
},
{
value: articles?.length ?? null,
value: articleCount,
label: tp("statsArticles"),
icon: ICON_ARTICLE,
},
@@ -1025,12 +1025,10 @@ export default async function Home() {
<Reveal className="mx-auto w-full max-w-7xl">
<GlassPanel title={tp("recentPhotos")} icon={ICON_CAMERA}>
<div className="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-4">
{recentPhotos.slice(0, 4).map((p) => (
{recentPhotos.slice(0, 4).map((p, i) => (
<Link
key={p.id}
href="/photos"
title=""
aria-label={tp("recentPhotos")}
className="block aspect-[4/3] overflow-hidden rounded-2xl border transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:ring-2 hover:ring-[color-mix(in_srgb,var(--color-primary)_45%,transparent)]"
style={{
borderColor:
@@ -1039,7 +1037,7 @@ export default async function Home() {
>
<Image
src={p.url}
alt={tp("recentPhotos")}
alt={`${tp("recentPhotos")} ${i + 1}`}
width={280}
height={210}
className="h-full w-full object-cover"
+117 -199
View File
@@ -1,18 +1,32 @@
import { count, desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuthTopBar } from "@/components/auth/auth-top-bar";
import {
AuthPageFrame,
AuthUsersCards,
} from "@/components/auth/auth-page-frame";
import { RegisterForm } from "@/components/auth/register-form";
import { Reveal } from "@/components/motion-reveal";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.register");
const title = t("title");
const description = t("subtitle");
return {
title,
description,
openGraph: { title, description, type: "website" },
};
}
export default async function RegisterPage() {
const tpr = await getTranslations("pages.register");
@@ -23,6 +37,10 @@ export default async function RegisterPage() {
]);
const nonce = (await headers()).get("x-nonce") ?? undefined;
// An account already exists in this browser — there is nothing to sign up.
const session = await auth();
if (session?.user?.id) redirect("/me");
const [online, recentUsers, latestUsers] = await Promise.all([
cached("online_count", 10_000, () =>
db
@@ -32,7 +50,7 @@ export default async function RegisterPage() {
.then((rows) => rows[0]?.total ?? 0),
).catch(() => 0),
cached(
"register_online_users",
"auth_online_users",
10_000,
() =>
db
@@ -43,7 +61,7 @@ export default async function RegisterPage() {
{ staleMs: 30000 },
).catch(() => []),
cached(
"register_latest_users",
"auth_latest_users",
30_000,
() =>
db
@@ -56,219 +74,119 @@ export default async function RegisterPage() {
]);
return (
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
<div className="relative">
<AuthPageFrame
hotelName={hotelName}
logo={logo}
title={tpr("title")}
subtitle={tpr("subtitle")}
form={
<RegisterForm
hotelName={hotelName}
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
}}
nonce={nonce}
/>
}
>
<SurfaceCard
className="card-glow relative overflow-hidden p-6"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
aria-hidden="true"
className="pointer-events-none absolute -inset-x-16 -top-24 overflow-hidden"
>
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-20 top-0 h-80 w-80"
className="aurora-blob -left-16 top-6 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 34%, transparent)",
opacity: 0.45,
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-20 top-6 h-72 w-72"
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 30%, transparent)",
animationDelay: "-7s",
opacity: 0.4,
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
</div>
<AuthTopBar hotelName={hotelName} logo={logo} />
</div>
<Reveal>
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
{/* Left: intro panel */}
<div className="lg:w-96 shrink-0 space-y-4">
<SurfaceCard
className="card-glow relative overflow-hidden p-6"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
<div className="relative">
<div className="flex items-center gap-4">
<div className="shrink-0 animate-float">
<div className="brand-halo left-1/2 top-1/2 h-52 w-52 -translate-x-1/2 -translate-y-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={110}
height={145}
className="relative object-contain drop-shadow-xl"
unoptimized
/>
</div>
<div className="flex-1">
<div
aria-hidden="true"
className="absolute inset-0 overflow-hidden"
className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-2 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-16 top-6 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{tpr("usersOnline", { count: online })}
</div>
<div className="relative">
<div className="flex items-center gap-4">
<div className="shrink-0 animate-float">
<div className="brand-halo left-1/2 top-1/2 h-52 w-52 -translate-x-1/2 -translate-y-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={110}
height={145}
className="relative object-contain drop-shadow-xl"
unoptimized
priority
/>
</div>
<div className="flex-1">
<div
className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-2 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color:
"var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{tpr("usersOnline", { count: online })}
</div>
<h1
className="text-lg font-black leading-tight"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{tpr("title")} —{" "}
<span className="gradient-text">{hotelName}</span>
</h1>
<p
className="text-xs mt-1.5 leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{tpr("subtitle")}
</p>
</div>
</div>
</div>
</SurfaceCard>
{recentUsers.length > 0 && (
<SurfaceCard
title={tpr("whoIsOnline")}
icon={ICON_NAV_GOODY}
bodyClassName="p-4"
className="card-glow"
<h1
className="text-lg font-black leading-tight"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
<div className="grid grid-cols-4 gap-1.5">
{recentUsers.map((u) => (
<div
key={u.username}
title={u.username}
className="avatar-tile"
>
<div className="relative">
<UserAvatarThumbnail
figure={u.look}
alt=""
options={{ direction: 2 }}
className="rounded-lg"
/>
<span className="avatar-online absolute -bottom-0.5 -right-0.5" />
</div>
<span className="avatar-name">{u.username}</span>
</div>
))}
</div>
</SurfaceCard>
)}
{latestUsers.length > 0 && (
<SurfaceCard
title={tpr("newestCitizens")}
icon={ICON_FRIENDS}
bodyClassName="p-4"
className="card-glow"
{tpr("title")} —{" "}
<span className="gradient-text">{hotelName}</span>
</h1>
<p
className="text-xs mt-1.5 leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
<div className="grid grid-cols-4 gap-1.5">
{latestUsers.map((u) => (
<div
key={u.username}
title={u.username}
className="avatar-tile"
>
<UserAvatarThumbnail
figure={u.look}
alt=""
options={{ direction: 2 }}
className="rounded-lg"
/>
<span className="avatar-name">{u.username}</span>
</div>
))}
</div>
</SurfaceCard>
)}
</div>
{/* Right: form */}
<div className="flex-1 lg:sticky lg:top-6">
<div className="gradient-border">
<SurfaceCard
title={tpr("title")}
icon={ICON_NAV_ME}
bodyClassName="p-6 sm:p-7"
className="card-glow"
>
<p
className="text-sm mb-6 leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{tpr("subtitle")}
</p>
<RegisterForm
hotelName={hotelName}
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
}}
nonce={nonce}
/>
</SurfaceCard>
{tpr("subtitle")}
</p>
</div>
</div>
</div>
</Reveal>
</div>
</SurfaceCard>
<AuthUsersCards
recentUsers={recentUsers}
latestUsers={latestUsers}
recentTitle={tpr("whoIsOnline")}
latestTitle={tpr("newestCitizens")}
/>
</AuthPageFrame>
);
}
+2
View File
@@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import { CheckCircle2, Clock, MailX } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { ResendVerificationForm } from "@/components/auth/resend-verification-form";
import Link from "@/components/link";
import { SurfaceCard } from "@/components/surface-card";
import { isValidVerificationToken } from "@/lib/auth/email-verification";
@@ -185,6 +186,7 @@ export default async function VerifyPage({
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
{t("invalidBody")}
</p>
<ResendVerificationForm />
<Link
href="/login"
className={`${linkClass} !shadow-none`}