i18n: translate all public page bodies (EN + IT)

Internationalised the ~44 public pages with next-intl (the shell was
already translated). Each page now pulls its copy from a "pages.<slug>"
namespace via getTranslations (server) / useTranslations (client); the
EN + IT catalogs were authored by parallel agents and merged centrally,
with natural Italian (ICU plurals) and it backfilled from en for any
gap. request.ts gained getMessageFallback/onError so a missing key
degrades to the English value, never a raw key.

Behaviour unchanged (only display text moved to t() calls; queries,
actions, fields, ContentCard structure preserved). Verified on the prod
server: with NEXT_LOCALE=it, home/community/staff/news/shop/rankings all
render Italian copy, no raw-key leakage; English unchanged. The nav
language switcher toggles EN/IT live. tsc 0, vitest 49/49, next build 0.

Admin pages intentionally left in English (staff tooling).
This commit is contained in:
Simo committed 2026-06-28 20:40:06 +02:00
1 parent 1199176883
commit e004dfedaf
47 files changed
+1740 -471

No files matched your search

+22 -19
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth";
@@ -10,6 +11,8 @@ export default async function RadioApplyPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.radioApply");
const userId = Number(session.user.id);
// Surface the applicant's most recent application (if any) so they aren't
@@ -30,30 +33,30 @@ export default async function RadioApplyPage() {
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🎤"
title="Apply to be a Radio DJ"
subtitle="Tell us about yourself and why you'd be a great fit for the airwaves"
title={t("title")}
subtitle={t("subtitle")}
/>
{existing ? (
<ContentCard icon="📋" title="Your latest application">
<ContentCard icon="📋" title={t("latestTitle")}>
<p style={{ margin: 0 }}>
<strong>Status:</strong> {existing.status}
<strong>{t("statusLabel")}</strong> {existing.status}
{submittedDate ? (
<span className="muted"> · submitted {submittedDate}</span>
<span className="muted"> {t("submittedOn", { date: submittedDate })}</span>
) : null}
</p>
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
You can submit again below if your details have changed.
{t("submitAgain")}
</p>
</ContentCard>
) : null}
<ContentCard icon="📝" title="Application form" subtitle="All fields marked required must be filled in">
<ContentCard icon="📝" title={t("formTitle")} subtitle={t("formSubtitle")}>
<form action={applyDj}>
<div className="card-grid sm-2">
<div>
<label htmlFor="realName" className="muted">
Real name
{t("realNameLabel")}
</label>
<input
id="realName"
@@ -61,13 +64,13 @@ export default async function RadioApplyPage() {
type="text"
required
maxLength={255}
placeholder="Your full name"
placeholder={t("realNamePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div>
<label htmlFor="age" className="muted">
Age
{t("ageLabel")}
</label>
<input
id="age"
@@ -84,61 +87,61 @@ export default async function RadioApplyPage() {
<div style={{ marginTop: "1rem" }}>
<label htmlFor="availability" className="muted">
Availability
{t("availabilityLabel")}
</label>
<textarea
id="availability"
name="availability"
required
rows={3}
placeholder="Which days and times can you broadcast?"
placeholder={t("availabilityPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="musicStyle" className="muted">
Music style
{t("musicStyleLabel")}
</label>
<input
id="musicStyle"
name="musicStyle"
type="text"
maxLength={255}
placeholder="e.g. House, Pop, Hip-Hop"
placeholder={t("musicStylePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="experience" className="muted">
Experience <span className="muted">(optional)</span>
{t("experienceLabel")} <span className="muted">{t("optional")}</span>
</label>
<textarea
id="experience"
name="experience"
rows={3}
placeholder="Any prior DJ or hosting experience?"
placeholder={t("experiencePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="motivation" className="muted">
Motivation
{t("motivationLabel")}
</label>
<textarea
id="motivation"
name="motivation"
required
rows={4}
placeholder="Why do you want to join the radio team?"
placeholder={t("motivationPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}>
Submit application
{t("submitButton")}
</button>
</form>
</ContentCard>
+8 -5
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
@@ -9,6 +10,8 @@ function formatDate(d: Date | null | undefined): string {
}
export default async function RadioContestsPage() {
const t = await getTranslations("pages.radioContests");
// RadioContests.id is a BigInt — stringify before use in keys/routes.
const contests = await prisma.radioContests
.findMany({
@@ -21,13 +24,13 @@ export default async function RadioContestsPage() {
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🎉"
title="Radio Contests"
subtitle="Tune in and take part in our latest on-air contests"
title={t("title")}
subtitle={t("subtitle")}
/>
<ContentCard padded={contests.length === 0}>
{contests.length === 0 ? (
<EmptyState icon="🎉">No contests yet.</EmptyState>
<EmptyState icon="🎉">{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{contests.map((c) => {
@@ -40,10 +43,10 @@ export default async function RadioContestsPage() {
style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }}
>
<h3 style={{ margin: 0, fontSize: "1rem" }}>
<span aria-hidden>🎉</span> Contest #{id}
<span aria-hidden>🎉</span> {t("contestLabel", { id })}
</h3>
<p className="muted" style={{ margin: 0 }}>
{formatDate(c.createdAt) || "Date unknown"}
{formatDate(c.createdAt) || t("dateUnknown")}
</p>
</Link>
);
+8 -5
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
@@ -9,6 +10,8 @@ function formatDate(d: Date | null | undefined): string {
}
export default async function RadioGiveawaysPage() {
const t = await getTranslations("pages.radioGiveaways");
// RadioGiveaways.id is a BigInt — stringify before use in keys/routes.
const giveaways = await prisma.radioGiveaways
.findMany({
@@ -21,13 +24,13 @@ export default async function RadioGiveawaysPage() {
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🎁"
title="Radio Giveaways"
subtitle="Listen in for your chance to win in our radio giveaways"
title={t("title")}
subtitle={t("subtitle")}
/>
<ContentCard padded={giveaways.length === 0}>
{giveaways.length === 0 ? (
<EmptyState icon="🎁">No giveaways yet.</EmptyState>
<EmptyState icon="🎁">{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{giveaways.map((g) => {
@@ -40,10 +43,10 @@ export default async function RadioGiveawaysPage() {
style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }}
>
<h3 style={{ margin: 0, fontSize: "1rem" }}>
<span aria-hidden>🎁</span> Giveaway #{id}
<span aria-hidden>🎁</span> {t("giveawayLabel", { id })}
</h3>
<p className="muted" style={{ margin: 0 }}>
{formatDate(g.createdAt) || "Date unknown"}
{formatDate(g.createdAt) || t("dateUnknown")}
</p>
</Link>
);
+10 -7
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format";
@@ -38,6 +39,8 @@ async function loadRows(): Promise<{ username: string; look: string; points: num
}
export default async function RadioLeaderboardPage() {
const t = await getTranslations("pages.radioLeaderboard");
const [rows, imagerBase] = await Promise.all([
loadRows(),
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
@@ -47,21 +50,21 @@ export default async function RadioLeaderboardPage() {
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🏆"
title="Radio Listener Leaderboard"
subtitle="Top 50 listeners by points earned tuning in to the radio"
title={t("title")}
subtitle={t("subtitle")}
/>
<ContentCard padded={rows.length === 0}>
{rows.length === 0 ? (
<EmptyState icon="🏆">No listener points to show yet.</EmptyState>
<EmptyState icon="🏆">{t("empty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th style={{ width: "3.5rem" }}>Rank</th>
<th style={{ width: "3.5rem" }}>Avatar</th>
<th>Listener</th>
<th style={{ textAlign: "right" }}>Points</th>
<th style={{ width: "3.5rem" }}>{t("colRank")}</th>
<th style={{ width: "3.5rem" }}>{t("colAvatar")}</th>
<th>{t("colListener")}</th>
<th style={{ textAlign: "right" }}>{t("colPoints")}</th>
</tr>
</thead>
<tbody>
+30 -27
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
@@ -6,15 +7,6 @@ import { siteSettings } from "@/lib/services/site-settings";
// Radio config + today's schedule are read live from the DB / settings table.
export const dynamic = "force-dynamic";
// Sub-pages linked from the radio home hub.
const RADIO_LINKS: { label: string; href: string; icon: string; text: string }[] = [
{ label: "Schedule", href: "/radio/schedule", icon: "🗓️", text: "Our weekly line-up of DJs and shows." },
{ label: "Shouts", href: "/radio/shouts", icon: "📣", text: "Shout-outs and requests from the community." },
{ label: "Requests", href: "/radio/requests", icon: "🎵", text: "Ask the DJ to spin your favourite track." },
{ label: "Apply as DJ", href: "/radio/apply", icon: "🎤", text: "Join the team and take to the airwaves." },
{ label: "Leaderboard", href: "/radio/leaderboard", icon: "🏆", text: "Top listeners ranked by points earned." },
];
// Canonical weekday order so today's slots come out chronologically and we can
// match the current day against whatever casing the `day` column stores.
const WEEKDAYS = [
@@ -36,6 +28,17 @@ function formatTime(d: Date | null | undefined): string {
}
export default async function RadioPage() {
const t = await getTranslations("pages.radio");
// Sub-pages linked from the radio home hub.
const RADIO_LINKS: { label: string; href: string; icon: string; text: string }[] = [
{ label: t("linkSchedule"), href: "/radio/schedule", icon: "🗓️", text: t("linkScheduleText") },
{ label: t("linkShouts"), href: "/radio/shouts", icon: "📣", text: t("linkShoutsText") },
{ label: t("linkRequests"), href: "/radio/requests", icon: "🎵", text: t("linkRequestsText") },
{ label: t("linkApply"), href: "/radio/apply", icon: "🎤", text: t("linkApplyText") },
{ label: t("linkLeaderboard"), href: "/radio/leaderboard", icon: "🏆", text: t("linkLeaderboardText") },
];
// ── Radio config from website_settings (graceful fallbacks) ──
const [streamUrl, currentDj, currentSong] = await Promise.all([
siteSettings.get("radio_stream_url", ""),
@@ -77,20 +80,20 @@ export default async function RadioPage() {
{/* ── Header: live stream + now playing ─────────────────────── */}
<ContentCard
icon="📻"
title="Radio"
subtitle="Tune in to the hotel radio — live DJ sets, requests and shoutouts."
title={t("title")}
subtitle={t("subtitle")}
action={<OnlineBadge online={isLive} />}
>
<div className="card-grid sm-2" style={{ alignItems: "stretch" }}>
<div className="card" style={{ display: "grid", gap: "0.35rem", alignContent: "center" }}>
<p className="muted" style={{ margin: 0, fontSize: "0.8rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Now playing
{t("nowPlaying")}
</p>
<p style={{ margin: 0, fontSize: "1.1rem", fontWeight: 700 }}>
{currentSong ? currentSong : <span className="muted" style={{ fontWeight: 400 }}>Nothing playing right now.</span>}
{currentSong ? currentSong : <span className="muted" style={{ fontWeight: 400 }}>{t("nothingPlaying")}</span>}
</p>
<p className="muted" style={{ margin: 0 }}>
{currentDj ? `On air: ${currentDj}` : "No DJ on air."}
{currentDj ? t("onAir", { dj: currentDj }) : t("noDj")}
</p>
</div>
<div className="card" style={{ display: "flex", alignItems: "center" }}>
@@ -101,11 +104,11 @@ export default async function RadioPage() {
style={{ width: "100%" }}
preload="none"
>
Your browser does not support the audio element.
{t("audioUnsupported")}
</audio>
) : (
<p className="muted" style={{ margin: 0 }}>
The stream is currently offline.
{t("streamOffline")}
</p>
)}
</div>
@@ -115,8 +118,8 @@ export default async function RadioPage() {
{/* ── Sub-page links ──────────────────────────────────────── */}
<ContentCard
icon="🎚️"
title="Explore the radio"
subtitle="Schedule, shouts, requests and more"
title={t("exploreTitle")}
subtitle={t("exploreSubtitle")}
>
<div className="card-grid sm-2 lg-3">
{RADIO_LINKS.map((l) => (
@@ -140,24 +143,24 @@ export default async function RadioPage() {
{/* ── Today's schedule ────────────────────────────────────── */}
<ContentCard
icon="🗓️"
title={`Today's schedule${today ? ` · ${today}` : ""}`}
subtitle="What's on the airwaves today"
title={today ? t("todayTitleWithDay", { day: today }) : t("todayTitle")}
subtitle={t("todaySubtitle")}
action={
<Link className="btn btn-outline" href="/radio/schedule">
Full week →
{t("fullWeek")}
</Link>
}
padded={todaysSlots.length === 0}
>
{todaysSlots.length === 0 ? (
<EmptyState icon="📻">No shows scheduled for today.</EmptyState>
<EmptyState icon="📻">{t("noShowsToday")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>Time</th>
<th>Show</th>
<th>DJ</th>
<th>{t("colTime")}</th>
<th>{t("colShow")}</th>
<th>{t("colDj")}</th>
</tr>
</thead>
<tbody>
@@ -169,13 +172,13 @@ export default async function RadioPage() {
{formatTime(s.startTime)} &ndash; {formatTime(s.endTime)}
</td>
<td>
{s.showName || <span className="muted">Untitled show</span>}
{s.showName || <span className="muted">{t("untitledShow")}</span>}
</td>
<td>
{djName ? (
<Link href={`/u/${djName}`}>{djName}</Link>
) : (
<span className="muted">{`User #${s.userId}`}</span>
<span className="muted">{t("userNumber", { id: String(s.userId) })}</span>
)}
</td>
</tr>
+11 -8
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
@@ -32,6 +33,8 @@ type Slot = {
};
export default async function RadioSchedulePage() {
const t = await getTranslations("pages.radioSchedule");
const schedules = await prisma.radioSchedules
.findMany({
where: { isActive: true },
@@ -76,21 +79,21 @@ export default async function RadioSchedulePage() {
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🗓️"
title="Radio Schedule"
subtitle="Our weekly line-up of DJs and shows"
title={t("title")}
subtitle={t("subtitle")}
/>
<ContentCard padded={!hasAny}>
{!hasAny ? (
<EmptyState icon="🗓️">No shows scheduled yet.</EmptyState>
<EmptyState icon="🗓️">{t("empty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>Day</th>
<th>Time</th>
<th>DJ</th>
<th>Show</th>
<th>{t("colDay")}</th>
<th>{t("colTime")}</th>
<th>{t("colDj")}</th>
<th>{t("colShow")}</th>
</tr>
</thead>
<tbody>
@@ -103,7 +106,7 @@ export default async function RadioSchedulePage() {
<strong>{day}</strong>
</td>
<td colSpan={3} className="muted">
No shows
{t("noShows")}
</td>
</tr>
);
+13 -10
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
@@ -12,6 +13,8 @@ function formatDate(d: Date | null | undefined): string {
}
export default async function RadioShoutsPage() {
const t = await getTranslations("pages.radioShouts");
const [shouts, imagerBase, session] = await Promise.all([
prisma.radioShouts
.findMany({
@@ -44,17 +47,17 @@ export default async function RadioShoutsPage() {
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="📣"
title="Radio Shouts"
subtitle="Shout-outs and requests from the community"
title={t("title")}
subtitle={t("subtitle")}
/>
{isLoggedIn ? (
<ContentCard icon="✍️" title="Post a shout" subtitle="Send a message to the DJ on air">
<ContentCard icon="✍️" title={t("postTitle")} subtitle={t("postSubtitle")}>
{/* The author is taken from the session inside the action, never from
this form — no user id is submitted. */}
<form action={postShout}>
<label htmlFor="shout-message" className="muted">
Your message
{t("messageLabel")}
</label>
<textarea
id="shout-message"
@@ -62,28 +65,28 @@ export default async function RadioShoutsPage() {
required
maxLength={255}
rows={3}
placeholder="Send a shout to the DJ…"
placeholder={t("messagePlaceholder")}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/>
<button type="submit" className="btn btn-primary">
Shout
{t("shoutButton")}
</button>
</form>
</ContentCard>
) : (
<ContentCard padded>
<EmptyState icon="🔒">Log in to post a shout.</EmptyState>
<EmptyState icon="🔒">{t("loginToPost")}</EmptyState>
</ContentCard>
)}
<ContentCard
icon="💬"
title="Latest shouts"
subtitle="The most recent messages from listeners"
title={t("latestTitle")}
subtitle={t("latestSubtitle")}
padded={shouts.length === 0}
>
{shouts.length === 0 ? (
<EmptyState icon="📣">No shouts yet. Be the first!</EmptyState>
<EmptyState icon="📣">{t("empty")}</EmptyState>
) : (
<div className="card-grid">
{shouts.map((s) => {