refactor: centralize hotel name fallback via FALLBACK_HOTEL_NAME
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m6s

Route all user-facing Atom hotel defaults through resolveHotelName (settings then HOTEL_NAME env then brand constant). Exclude Playwright e2e from tsconfig until deps are installed.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-22 18:45:59 +02:00
1 parent 6b2819aa20
commit e00e9ca2dc
31 files changed
+113 -56

No files matched your search

+2 -3
View File
@@ -3,8 +3,8 @@
import { createHmac, timingSafeEqual } from "node:crypto"; import { createHmac, timingSafeEqual } from "node:crypto";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { env } from "@/env"; import { env } from "@/env";
import { resolveHotelName } from "@/lib/hotel-name";
import { sendMail } from "@/lib/services/email"; import { sendMail } from "@/lib/services/email";
import { siteSettings } from "@/lib/services/site-settings";
// Stateless email verification with a time-limited HMAC token. // Stateless email verification with a time-limited HMAC token.
// //
@@ -78,8 +78,7 @@ export async function sendVerification(email: string): Promise<boolean> {
const base = env.APP_URL.replace(/\/+$/, ""); const base = env.APP_URL.replace(/\/+$/, "");
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`; const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
const hotelName = const hotelName = await resolveHotelName();
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
let subject = `Verify your email · ${hotelName}`; let subject = `Verify your email · ${hotelName}`;
let heading = "Verify your email"; let heading = "Verify your email";
+2 -2
View File
@@ -5,7 +5,7 @@ import { applyStaff } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { resolveHotelName } from "@/lib/hotel-name";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -53,7 +53,7 @@ export default async function ApplyStaffPage({
: null; : null;
const userId = Number(session.user.id); const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const hotelName = await resolveHotelName();
function formatDeadline(d: Date | null | undefined): string { function formatDeadline(d: Date | null | undefined): string {
if (!d) return t("noDeadline"); if (!d) return t("noDeadline");
+2 -2
View File
@@ -5,7 +5,7 @@ import { applyTeam } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { resolveHotelName } from "@/lib/hotel-name";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -53,7 +53,7 @@ export default async function ApplyTeamPage({
: null; : null;
const userId = Number(session.user.id); const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const hotelName = await resolveHotelName();
// ── Teams open for application ────────────────────────────── // ── Teams open for application ──────────────────────────────
// The Prisma slice exposes teams directly (no separate team-position table // The Prisma slice exposes teams directly (no separate team-position table
+2 -2
View File
@@ -5,7 +5,7 @@ import { ContentCard } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sanitize } from "@/lib/sanitize"; import { sanitize } from "@/lib/sanitize";
import { SanitizedHtml } from "@/components/shared/sanitized-html"; import { SanitizedHtml } from "@/components/shared/sanitized-html";
import { siteSettings } from "@/lib/services/site-settings"; import { resolveHotelName } from "@/lib/hotel-name";
export const revalidate = 300; export const revalidate = 300;
@@ -75,7 +75,7 @@ export default async function HelpCategoryPage({
if (!cat) notFound(); if (!cat) notFound();
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const hotelName = await resolveHotelName();
// Sibling help topics, so the reader can jump between categories. Isolated // Sibling help topics, so the reader can jump between categories. Isolated
// query: a DB hiccup degrades this list to empty rather than 500-ing. // query: a DB hiccup degrades this list to empty rather than 500-ing.
+3 -2
View File
@@ -7,13 +7,14 @@ import { cached } from "@/lib/cache";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { captchaConfig } from "@/lib/services/captcha"; import { captchaConfig } from "@/lib/services/captcha";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export default async function LoginPage() { export default async function LoginPage() {
const t = await getTranslations("pages.login"); const t = await getTranslations("pages.login");
const th = await getTranslations("header"); const th = await getTranslations("header");
const [hotelName, imagerRaw, cfg] = await Promise.all([ const [hotelName, imagerRaw, cfg] = await Promise.all([
siteSettings.get("hotel_name", "Atom"), resolveHotelName(),
siteSettings.get( siteSettings.get(
"habbo_imaging_url", "habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage", "https://www.habbo.com/habbo-imaging/avatarimage",
@@ -87,7 +88,7 @@ export default async function LoginPage() {
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("welcomeBackSub", { hotelName: hotelName ?? "Atom" })} {t("welcomeBackSub", { hotelName })}
</p> </p>
</div> </div>
</div> </div>
+4 -2
View File
@@ -1,5 +1,6 @@
import LogoGenerator from "@/components/public/logo-generator"; import LogoGenerator from "@/components/public/logo-generator";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { resolveHotelName } from "@/lib/hotel-name";
export const metadata = { title: "Logo generator" }; export const metadata = { title: "Logo generator" };
@@ -9,7 +10,8 @@ export const metadata = { title: "Logo generator" };
* client-side <LogoGenerator>, which does all styling, live preview, and PNG * client-side <LogoGenerator>, which does all styling, live preview, and PNG
* export in the browser (no server data, no DB). * export in the browser (no server data, no DB).
*/ */
export default function LogoPage() { export default async function LogoPage() {
const initialText = await resolveHotelName();
return ( return (
<main <main
style={{ style={{
@@ -25,7 +27,7 @@ export default function LogoPage() {
subtitle="Design a logo for your hotel — pick a font, colours and size, then download it as a PNG." subtitle="Design a logo for your hotel — pick a font, colours and size, then download it as a PNG."
/> />
<LogoGenerator /> <LogoGenerator initialText={initialText} />
</main> </main>
); );
} }
+3 -2
View File
@@ -1,5 +1,6 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300; export const revalidate = 300;
@@ -7,7 +8,7 @@ export const revalidate = 300;
export default async function MaintenancePage() { export default async function MaintenancePage() {
const t = await getTranslations("pages.maintenance"); const t = await getTranslations("pages.maintenance");
const [hotel, message] = await Promise.all([ const [hotel, message] = await Promise.all([
siteSettings.get("hotel_name", "Atom"), resolveHotelName(),
siteSettings.get( siteSettings.get(
"maintenance_message", "maintenance_message",
"We are currently performing maintenance. Please check back soon.", "We are currently performing maintenance. Please check back soon.",
@@ -18,7 +19,7 @@ export default async function MaintenancePage() {
<main style={{ maxWidth: 560, margin: "2rem auto" }}> <main style={{ maxWidth: 560, margin: "2rem auto" }}>
<ContentCard <ContentCard
icon="🛠️" icon="🛠️"
title={t("title", { hotel: hotel ?? "Atom" })} title={t("title", { hotel })}
subtitle={t("subtitle")} subtitle={t("subtitle")}
> >
<p style={{ marginTop: 0 }}>{message}</p> <p style={{ marginTop: 0 }}>{message}</p>
+3 -2
View File
@@ -6,6 +6,7 @@ import { claimReferral } from "@/actions/referral";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, OnlineBadge } 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 { resolveHotelName } from "@/lib/hotel-name";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import CopyReferralButton from "./CopyReferralButton"; import CopyReferralButton from "./CopyReferralButton";
@@ -85,7 +86,7 @@ export default async function MePage({
"habbo_imaging_url", "habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage", "https://www.habbo.com/habbo-imaging/avatarimage",
), ),
siteSettings.get("hotel_name", "Atom"), resolveHotelName(),
prisma.websiteSetting prisma.websiteSetting
.findUnique({ .findUnique({
where: { key: "referrals_needed" }, where: { key: "referrals_needed" },
@@ -251,7 +252,7 @@ export default async function MePage({
height={22} height={22}
unoptimized unoptimized
/> />
Enter {hotelName ?? "Hotel"} Enter {hotelName}
</Link> </Link>
{/* ── Stat tiles ── */} {/* ── Stat tiles ── */}
+5 -4
View File
@@ -6,6 +6,7 @@ import { cached } from "@/lib/cache";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
@@ -13,7 +14,7 @@ export const revalidate = 120;
async function getHotelData() { async function getHotelData() {
const [hotelName, imagerRaw] = await Promise.all([ const [hotelName, imagerRaw] = await Promise.all([
siteSettings.get("hotel_name", "Atom"), resolveHotelName(),
siteSettings.get( siteSettings.get(
"habbo_imaging_url", "habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage", "https://www.habbo.com/habbo-imaging/avatarimage",
@@ -103,13 +104,13 @@ export default async function Home() {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" /> <span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
</span> </span>
{th("online", { count: online, hotel: hotelName ?? "Atom" })} {th("online", { count: online, hotel: hotelName })}
</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)" }}
> >
{tp("welcome", { hotelName: hotelName ?? "Atom" })} {tp("welcome", { hotelName })}
</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"
@@ -317,7 +318,7 @@ export default async function Home() {
}} }}
> >
<h2 className="text-2xl md:text-3xl font-black tracking-tight mb-2" style={{ color: "var(--color-text-readable)" }}> <h2 className="text-2xl md:text-3xl font-black tracking-tight mb-2" style={{ color: "var(--color-text-readable)" }}>
{tp("joinNow", { hotelName: hotelName ?? "Atom" })} {tp("joinNow", { hotelName })}
</h2> </h2>
<p className="text-sm mb-6 max-w-sm mx-auto" style={{ color: "var(--color-text-muted)" }}> <p className="text-sm mb-6 max-w-sm mx-auto" style={{ color: "var(--color-text-muted)" }}>
{tp("welcomeBody")} {tp("welcomeBody")}
+3 -2
View File
@@ -7,13 +7,14 @@ import { cached } from "@/lib/cache";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { captchaConfig } from "@/lib/services/captcha"; import { captchaConfig } from "@/lib/services/captcha";
import { resolveHotelName } from "@/lib/hotel-name";
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 th = await getTranslations("header"); const th = await getTranslations("header");
const tpr = await getTranslations("pages.register"); 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"), resolveHotelName(),
siteSettings.get( siteSettings.get(
"habbo_imaging_url", "habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage", "https://www.habbo.com/habbo-imaging/avatarimage",
@@ -167,7 +168,7 @@ export default async function RegisterPage() {
</p> </p>
</div> </div>
<RegisterForm <RegisterForm
hotelName={hotelName ?? "Hotel"} hotelName={hotelName}
captcha={{ captcha={{
provider: cfg.provider, provider: cfg.provider,
siteKey: cfg.siteKey || undefined, siteKey: cfg.siteKey || undefined,
+2 -2
View File
@@ -12,7 +12,7 @@ import { auth } from "@/lib/auth";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { totpKeyUri } from "@/lib/auth/totp"; import { totpKeyUri } from "@/lib/auth/totp";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { resolveHotelName } from "@/lib/hotel-name";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -52,7 +52,7 @@ export default async function TwoFactorPage({
const hasAppKey = Boolean(env.APP_KEY); const hasAppKey = Boolean(env.APP_KEY);
const enabled = Boolean(user?.twoFactorConfirmedAt); const enabled = Boolean(user?.twoFactorConfirmedAt);
const pending = Boolean(user?.twoFactorSecret && !user?.twoFactorConfirmedAt); const pending = Boolean(user?.twoFactorSecret && !user?.twoFactorConfirmedAt);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const hotelName = await resolveHotelName();
let secret = ""; let secret = "";
let uri = ""; let uri = "";
+2 -2
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { EmbedSnippet } from "./embed-snippet"; import { EmbedSnippet } from "./embed-snippet";
@@ -18,8 +19,7 @@ export default async function AdminRadioEmbedPage() {
streamUrl = (await siteSettings.get("radio_stream_url", "")) ?? ""; streamUrl = (await siteSettings.get("radio_stream_url", "")) ?? "";
radioName = radioName =
(await siteSettings.get("radio_name", null)) ?? (await siteSettings.get("radio_name", null)) ??
(await siteSettings.get("hotel_name", "Radio")) ?? (await resolveHotelName());
"Radio";
nowPlayingApiUrl = nowPlayingApiUrl =
(await siteSettings.get("radio_now_playing_api_url", "")) ?? ""; (await siteSettings.get("radio_now_playing_api_url", "")) ?? "";
} catch { } catch {
@@ -1,4 +1,5 @@
import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel"; import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
export type FieldType = export type FieldType =
| "text" | "text"
@@ -49,7 +50,7 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
label: "Hotel name", label: "Hotel name",
type: "text", type: "text",
placeholder: "Epicnabbo", placeholder: "Epicnabbo",
defaultValue: "Atom", defaultValue: FALLBACK_HOTEL_NAME,
}, },
{ {
key: "cms_logo", key: "cms_logo",
+4 -3
View File
@@ -1,7 +1,8 @@
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { resolveHotelName } from "@/lib/hotel-name";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { redisCache, apiCacheKey } from "@/lib/redis-cache"; import { redisCache, apiCacheKey } from "@/lib/redis-cache";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -30,13 +31,13 @@ export async function GET(_req: Request) {
take: 4, take: 4,
}), }),
prisma.user.count({ where: { online: "1" } }), prisma.user.count({ where: { online: "1" } }),
siteSettings.get("hotel_name", "Atom").then((v) => v ?? "Atom"), resolveHotelName(),
]); ]);
return { articles, online, hotelName }; return { articles, online, hotelName };
}, },
); );
return apiJson(data); return apiJson(data);
} catch { } catch {
return apiJson({ articles: [], online: 0, hotelName: "Atom" }, { status: 200 }); return apiJson({ articles: [], online: 0, hotelName: FALLBACK_HOTEL_NAME }, { status: 200 });
} }
} }
+2 -3
View File
@@ -19,7 +19,7 @@ import {
} from "@/lib/services/paypal-topup"; } from "@/lib/services/paypal-topup";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency"; import { sendCurrency } from "@/lib/services/send-currency";
import { siteSettings } from "@/lib/services/site-settings"; import { resolveHotelName } from "@/lib/hotel-name";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -158,8 +158,7 @@ export async function POST(req: Request): Promise<Response> {
); );
} }
const hotelName = const hotelName = await resolveHotelName();
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
if (result.status !== "COMPLETED") { if (result.status !== "COMPLETED") {
// Record the non-completed attempt so support can trace it. // Record the non-completed attempt so support can trace it.
+2 -3
View File
@@ -11,7 +11,7 @@ import {
PAYPAL_CURRENCY, PAYPAL_CURRENCY,
} from "@/lib/services/paypal"; } from "@/lib/services/paypal";
import { recordCreatedTopup } from "@/lib/services/paypal-topup"; import { recordCreatedTopup } from "@/lib/services/paypal-topup";
import { siteSettings } from "@/lib/services/site-settings"; import { resolveHotelName } from "@/lib/hotel-name";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -69,8 +69,7 @@ export async function POST(req: Request): Promise<Response> {
const credits = Math.floor(amount * creditsPerUnit()); const credits = Math.floor(amount * creditsPerUnit());
const base = env.APP_URL.replace(/\/+$/, ""); const base = env.APP_URL.replace(/\/+$/, "");
const hotelName = const hotelName = await resolveHotelName();
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
try { try {
const order = await createOrder(amount, { const order = await createOrder(amount, {
+4 -3
View File
@@ -4,6 +4,7 @@ import { auth } from "@/lib/auth";
import { issueSsoTicket } from "@/lib/auth/sso-ticket"; import { issueSsoTicket } from "@/lib/auth/sso-ticket";
import { cached } from "@/lib/cache"; import { cached } from "@/lib/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { ClientView } from "./client-view"; import { ClientView } from "./client-view";
@@ -16,12 +17,12 @@ export default async function ClientPage() {
const userId = Number(session.user.id); const userId = Number(session.user.id);
const [hotelName, clientUrl] = await Promise.all([ const [hotelName, clientUrl] = await Promise.all([
siteSettings.get("hotel_name", "Atom"), resolveHotelName(),
siteSettings.get("nitro_client_url", ""), siteSettings.get("nitro_client_url", ""),
]); ]);
const ip = (await headers()).get("x-real-client-ip") ?? "0.0.0.0"; const ip = (await headers()).get("x-real-client-ip") ?? "0.0.0.0";
const ticket = await issueSsoTicket(prisma, userId, hotelName ?? "Atom", ip); const ticket = await issueSsoTicket(prisma, userId, hotelName, ip);
const onlineCount = await cached("online_count", 10_000, () => const onlineCount = await cached("online_count", 10_000, () =>
prisma.user.count({ where: { online: "1" } }), prisma.user.count({ where: { online: "1" } }),
@@ -31,7 +32,7 @@ export default async function ClientPage() {
<ClientView <ClientView
ticket={ticket} ticket={ticket}
clientUrl={clientUrl || "/nitro-client/"} clientUrl={clientUrl || "/nitro-client/"}
hotelName={hotelName ?? "Atom"} hotelName={hotelName}
initialOnline={onlineCount} initialOnline={onlineCount}
/> />
); );
+2 -1
View File
@@ -10,6 +10,7 @@ import { PwaRegister } from "@/components/pwa-register";
import { ThemeVars } from "@/components/theme-vars"; import { ThemeVars } from "@/components/theme-vars";
import { SmoothScroll } from "@/components/smooth-scroll"; import { SmoothScroll } from "@/components/smooth-scroll";
import { enforceSiteAccess } from "@/lib/access-guard"; import { enforceSiteAccess } from "@/lib/access-guard";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import "./globals.css"; import "./globals.css";
@@ -29,7 +30,7 @@ const pixelFont = Pixelify_Sans({
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const appUrl = process.env.APP_URL ?? "http://localhost:3000"; const appUrl = process.env.APP_URL ?? "http://localhost:3000";
const hotel = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const hotel = await resolveHotelName();
const customFavicon = await siteSettings.get("cms_favicon"); const customFavicon = await siteSettings.get("cms_favicon");
const faviconUrl = customFavicon ?? "/favicon.svg"; const faviconUrl = customFavicon ?? "/favicon.svg";
const icons = [ const icons = [
+2 -1
View File
@@ -1,11 +1,12 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
// Web app manifest — makes the hotel installable as a PWA (AtomCMS exposed PWA // Web app manifest — makes the hotel installable as a PWA (AtomCMS exposed PWA
// settings but the rewrite ships a real, themeable manifest). Name + theme // settings but the rewrite ships a real, themeable manifest). Name + theme
// colour follow the live website_settings; falls back to defaults with no DB. // colour follow the live website_settings; falls back to defaults with no DB.
export default async function manifest(): Promise<MetadataRoute.Manifest> { export default async function manifest(): Promise<MetadataRoute.Manifest> {
const name = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const name = await resolveHotelName();
const theme = const theme =
(await siteSettings.get("color_primary", "#f59e0b")) ?? "#f59e0b"; (await siteSettings.get("color_primary", "#f59e0b")) ?? "#f59e0b";
-1
View File
@@ -11,7 +11,6 @@ import { siteSettings } from "@/lib/services/site-settings";
export default async function GuestView() { export default async function GuestView() {
const t = await getTranslations("pages.home"); const t = await getTranslations("pages.home");
const _hotelName = await siteSettings.get("hotel_name", "Atom");
const imager = const imager =
(await siteSettings.get( (await siteSettings.get(
"habbo_imaging_url", "habbo_imaging_url",
+2 -1
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Reveal } from "@/components/motion-reveal"; import { Reveal } from "@/components/motion-reveal";
import { avatarImageUrl, excerpt } from "@/lib/format"; import { avatarImageUrl, excerpt } from "@/lib/format";
import { resolveHotelName } from "@/lib/hotel-name";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -20,7 +21,7 @@ export default async function UserView({
motto, motto,
}: UserViewProps) { }: UserViewProps) {
const t = await getTranslations("pages.home"); const t = await getTranslations("pages.home");
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const hotelName = await resolveHotelName();
const imager = const imager =
(await siteSettings.get( (await siteSettings.get(
"habbo_imaging_url", "habbo_imaging_url",
+2 -1
View File
@@ -3,6 +3,7 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import Image from "next/image"; import Image from "next/image";
import { type ReactNode, useRef, useState } from "react"; import { type ReactNode, useRef, useState } from "react";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { mobileMenuVariants } from "@/lib/motion"; import { mobileMenuVariants } from "@/lib/motion";
interface MobileNavProps { interface MobileNavProps {
@@ -16,7 +17,7 @@ export function MobileNav({
children, children,
menuLabel = "Open menu", menuLabel = "Open menu",
closeLabel = "Close menu", closeLabel = "Close menu",
brandLabel = "Atom", brandLabel = FALLBACK_HOTEL_NAME,
}: MobileNavProps) { }: MobileNavProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const detailsRef = useRef<HTMLDivElement>(null); const detailsRef = useRef<HTMLDivElement>(null);
+3
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { resolveHotelName } from "@/lib/hotel-name";
import { MobileNav } from "@/components/mobile-nav"; import { MobileNav } from "@/components/mobile-nav";
import { NavDropdown } from "@/components/nav-dropdown"; import { NavDropdown } from "@/components/nav-dropdown";
import { NavbarColorPicker } from "@/components/navbar-color-picker"; import { NavbarColorPicker } from "@/components/navbar-color-picker";
@@ -10,6 +11,7 @@ import { ThemeSwitcher } from "@/components/theme-switcher";
export async function Navigation({ session }: { session: Session | null }) { export async function Navigation({ session }: { session: Session | null }) {
const t = await getTranslations("nav"); const t = await getTranslations("nav");
const brandLabel = await resolveHotelName();
const isStaff = (session?.user?.rank ?? 0) >= 7; const isStaff = (session?.user?.rank ?? 0) >= 7;
return ( return (
@@ -170,6 +172,7 @@ export async function Navigation({ session }: { session: Session | null }) {
<MobileNav <MobileNav
menuLabel={t("openMenu") || "Open menu"} menuLabel={t("openMenu") || "Open menu"}
closeLabel={t("closeMenu") || "Close menu"} closeLabel={t("closeMenu") || "Close menu"}
brandLabel={brandLabel}
> >
<Link href="/" className="nav-item"> <Link href="/" className="nav-item">
<Image <Image
+7 -2
View File
@@ -9,6 +9,7 @@ import {
useTransition, useTransition,
} from "react"; } from "react";
import { saveLogo } from "@/actions/save-logo"; import { saveLogo } from "@/actions/save-logo";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { HABBO_FONT_GROUPS } from "@/components/public/habbo-fonts"; import { HABBO_FONT_GROUPS } from "@/components/public/habbo-fonts";
import { import {
getFontInfo, getFontInfo,
@@ -17,8 +18,12 @@ import {
} from "@/components/public/sprite-font"; } from "@/components/public/sprite-font";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
export default function LogoGenerator() { export default function LogoGenerator({
const [text, setText] = useState("Atom"); initialText = FALLBACK_HOTEL_NAME,
}: {
initialText?: string;
}) {
const [text, setText] = useState(initialText);
const [styleName, setStyleName] = useState("habbo"); const [styleName, setStyleName] = useState("habbo");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [saveStatus, setSaveStatus] = useState< const [saveStatus, setSaveStatus] = useState<
+3 -3
View File
@@ -1,9 +1,9 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { CmsInfoPopup } from "@/components/cms-info-popup"; import { CmsInfoPopup } from "@/components/cms-info-popup";
import { siteSettings } from "@/lib/services/site-settings"; import { resolveHotelName } from "@/lib/hotel-name";
export async function SiteFooter() { export async function SiteFooter() {
const hotelName = await siteSettings.get("hotel_name", "Atom"); const hotelName = await resolveHotelName();
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const t = await getTranslations("footer"); const t = await getTranslations("footer");
@@ -16,7 +16,7 @@ export async function SiteFooter() {
}} }}
> >
<div className="md:font-semibold text-[12px] md:text-[14px]"> <div className="md:font-semibold text-[12px] md:text-[14px]">
{t("copyright", { year, hotel: hotelName ?? "Atom" })} {t("copyright", { year, hotel: hotelName })}
</div> </div>
<CmsInfoPopup /> <CmsInfoPopup />
</footer> </footer>
+4 -3
View File
@@ -3,12 +3,13 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { cached } from "@/lib/cache"; import { cached } from "@/lib/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export async function SiteHeader() { export async function SiteHeader() {
const t = await getTranslations("header"); const t = await getTranslations("header");
const [hotelName, header, logo] = await Promise.all([ const [hotelName, header, logo] = await Promise.all([
siteSettings.get("hotel_name", "Atom"), resolveHotelName(),
siteSettings.get("cms_header", "/assets/images/background.png"), siteSettings.get("cms_header", "/assets/images/background.png"),
siteSettings.get("cms_logo", ""), siteSettings.get("cms_logo", ""),
]); ]);
@@ -77,7 +78,7 @@ export async function SiteHeader() {
<Image <Image
className="drop-shadow-2xl block" className="drop-shadow-2xl block"
src={logo} src={logo}
alt={hotelName ?? "Hotel"} alt={hotelName}
width={200} width={200}
height={120} height={120}
style={{ maxHeight: 120, width: "auto", height: "auto" }} style={{ maxHeight: 120, width: "auto", height: "auto" }}
@@ -130,7 +131,7 @@ export async function SiteHeader() {
className="text-sm font-bold tracking-wide" className="text-sm font-bold tracking-wide"
style={{ color: "rgba(255,255,255,0.9)" }} style={{ color: "rgba(255,255,255,0.9)" }}
> >
{t("online", { count: online, hotel: hotelName ?? "Atom" })} {t("online", { count: online, hotel: hotelName })}
</span> </span>
</div> </div>
</div> </div>
+2 -1
View File
@@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
// Minimal validated env for the foundation. When the Next.js app is added this // Minimal validated env for the foundation. When the Next.js app is added this
// will move to @t3-oss/env-nextjs (the habbo-next pattern), but the data layer // will move to @t3-oss/env-nextjs (the habbo-next pattern), but the data layer
@@ -15,7 +16,7 @@ const schema = z.object({
.int() .int()
.positive() .positive()
.default(10_000), .default(10_000),
HOTEL_NAME: z.string().default("Atom"), HOTEL_NAME: z.string().default(FALLBACK_HOTEL_NAME),
APP_URL: z.string().url().default("http://localhost:3000"), APP_URL: z.string().url().default("http://localhost:3000"),
// Resend API key (preferred — simpler HTTP API, always works). // Resend API key (preferred — simpler HTTP API, always works).
RESEND_API_KEY: z.string().optional(), RESEND_API_KEY: z.string().optional(),
+10
View File
@@ -0,0 +1,10 @@
/**
* Single hardcoded fallback hotel brand.
* Override order at runtime:
* 1. website_settings.hotel_name
* 2. HOTEL_NAME env
* 3. this constant
*
* Safe for client components (no env / DB imports).
*/
export const FALLBACK_HOTEL_NAME = "Atom";
+26
View File
@@ -0,0 +1,26 @@
import "server-only";
import { env } from "@/env";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { siteSettings } from "@/lib/services/site-settings";
/**
* Resolve the public hotel name: CMS setting → HOTEL_NAME env → FALLBACK_HOTEL_NAME.
*/
export async function resolveHotelName(): Promise<string> {
const fromSettings = await siteSettings.get("hotel_name", env.HOTEL_NAME);
const trimmed = fromSettings?.trim();
if (trimmed) return trimmed;
const fromEnv = env.HOTEL_NAME?.trim();
if (fromEnv) return fromEnv;
return FALLBACK_HOTEL_NAME;
}
/** Coalesce an already-fetched hotel name string. */
export function coalesceHotelName(value: string | null | undefined): string {
const trimmed = value?.trim();
if (trimmed) return trimmed;
const fromEnv = env.HOTEL_NAME?.trim();
if (fromEnv) return fromEnv;
return FALLBACK_HOTEL_NAME;
}
+2 -1
View File
@@ -1,10 +1,11 @@
import "server-only"; import "server-only";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
const DEFAULTS: Record<string, string> = { const DEFAULTS: Record<string, string> = {
hotel_name: "Atom", hotel_name: FALLBACK_HOTEL_NAME,
habbo_imaging_url: "/imaging", habbo_imaging_url: "/imaging",
logo_url: "", logo_url: "",
nitro_client_url: "", nitro_client_url: "",
+1 -1
View File
@@ -30,5 +30,5 @@
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": ["node_modules"] "exclude": ["node_modules", "e2e", "playwright.config.ts"]
} }