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

+6 -4
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentCard } from "@/components/public/ui";
@@ -39,6 +40,7 @@ export default async function HelpCategoryPage({
}: {
params: Promise<{ category: string }>;
}) {
const t = await getTranslations("pages.helpCategory");
const { category } = await params;
let categoryId: bigint;
@@ -96,10 +98,10 @@ export default async function HelpCategoryPage({
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: 0 }}>
<Link href="/help">← Help Center</Link>
<Link href="/help">{t("back")}</Link>
</p>
<ContentCard icon="❓" title={title} subtitle="Help Center topic">
<ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}>
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
@@ -140,8 +142,8 @@ export default async function HelpCategoryPage({
{siblings.length > 0 ? (
<ContentCard
icon="🧭"
title="More help topics"
subtitle="Jump to another support topic"
title={t("moreTitle")}
subtitle={t("moreSubtitle")}
>
<div className="card-grid sm-2 lg-3">
{siblings.map((s) => (
+13 -10
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";
@@ -35,6 +36,8 @@ function isExternal(url: string): boolean {
}
export default async function HelpCenterPage() {
const t = await getTranslations("pages.help");
let categories: HelpCategory[] = [];
try {
categories = await prisma.websiteHelpCenterCategories.findMany({
@@ -91,23 +94,23 @@ export default async function HelpCenterPage() {
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="❓"
title="Help Center"
subtitle="Find guides, support topics, and the hotel rules — everything you need to get help."
title={t("title")}
subtitle={t("subtitle")}
action={
<Link className="btn btn-outline" href="/help/tickets">
My tickets →
{t("myTickets")}
</Link>
}
/>
<ContentCard
icon="🧭"
title="Support topics"
subtitle="Browse guides and answers to common questions"
title={t("topicsTitle")}
subtitle={t("topicsSubtitle")}
padded={categories.length === 0}
>
{categories.length === 0 ? (
<EmptyState icon="🧭">No help topics yet.</EmptyState>
<EmptyState icon="🧭">{t("topicsEmpty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{categories.map((c) => (
@@ -169,12 +172,12 @@ export default async function HelpCenterPage() {
<ContentCard
icon="📜"
title="Hotel rules"
subtitle="Please read these before hanging out in the hotel"
title={t("rulesTitle")}
subtitle={t("rulesSubtitle")}
padded={ruleCategories.length === 0}
>
{ruleCategories.length === 0 ? (
<EmptyState icon="📜">No rules published yet.</EmptyState>
<EmptyState icon="📜">{t("rulesEmpty")}</EmptyState>
) : (
<div style={{ display: "grid", gap: "1rem", padding: "1rem" }}>
{ruleCategories.map((cat) => {
@@ -200,7 +203,7 @@ export default async function HelpCenterPage() {
{catRules.length === 0 ? (
<p className="muted" style={{ marginBottom: 0 }}>
No rules in this category.
{t("categoryEmpty")}
</p>
) : (
<ul style={{ margin: "0.85rem 0 0", paddingLeft: "1.2rem" }}>
+26 -23
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { createTicket } from "@/actions/help-tickets";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
@@ -14,6 +15,8 @@ export default async function HelpTicketsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.helpTickets");
const userId = Number(session.user.id);
let tickets: Awaited<ReturnType<typeof prisma.websiteHelpCenterTickets.findMany>> = [];
@@ -34,30 +37,30 @@ export default async function HelpTicketsPage() {
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🎫"
title="Help Tickets"
subtitle="Submit a ticket to staff and track your open requests."
title={t("title")}
subtitle={t("subtitle")}
>
<div className="stat-grid">
<StatBlock icon="🎫" value={tickets.length} label="Total tickets" />
<StatBlock icon="📬" value={openCount} label="Open" />
<StatBlock icon="✅" value={closedCount} label="Closed" />
<StatBlock icon="🎫" value={tickets.length} label={t("statTotal")} />
<StatBlock icon="📬" value={openCount} label={t("statOpen")} />
<StatBlock icon="✅" value={closedCount} label={t("statClosed")} />
</div>
</ContentCard>
<ContentCard icon="✏️" title="New ticket" subtitle="Describe your issue and staff will follow up">
<ContentCard icon="✏️" title={t("newTitle")} subtitle={t("newSubtitle")}>
<form action={createTicket}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}>
<input name="title" placeholder="Subject" maxLength={255} required />
<input name="title" placeholder={t("subjectPlaceholder")} maxLength={255} required />
<textarea
name="content"
placeholder="Describe your issue…"
placeholder={t("contentPlaceholder")}
rows={5}
maxLength={5000}
required
/>
<div>
<button type="submit" className="btn btn-primary">
Submit ticket
{t("submit")}
</button>
</div>
</div>
@@ -66,34 +69,34 @@ export default async function HelpTicketsPage() {
<ContentCard
icon="📋"
title="Your tickets"
subtitle="Every request you have sent to staff"
title={t("listTitle")}
subtitle={t("listSubtitle")}
padded={tickets.length === 0}
>
{tickets.length === 0 ? (
<EmptyState icon="🎫">You have no tickets yet.</EmptyState>
<EmptyState icon="🎫">{t("listEmpty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Subject</th>
<th>Status</th>
<th>Created</th>
<th>{t("colId")}</th>
<th>{t("colSubject")}</th>
<th>{t("colStatus")}</th>
<th>{t("colCreated")}</th>
</tr>
</thead>
<tbody>
{tickets.map((t) => (
<tr key={String(t.id)}>
<td>{String(t.id)}</td>
<td>{t.title}</td>
{tickets.map((t2) => (
<tr key={String(t2.id)}>
<td>{String(t2.id)}</td>
<td>{t2.title}</td>
<td>
<span className={`online-badge${t.open ? " online" : ""}`}>
<span className={`online-badge${t2.open ? " online" : ""}`}>
<span className="online-dot" aria-hidden />
{t.open ? "Open" : "Closed"}
{t2.open ? t("statusOpen") : t("statusClosed")}
</span>
</td>
<td className="muted">{formatDate(t.createdAt)}</td>
<td className="muted">{formatDate(t2.createdAt)}</td>
</tr>
))}
</tbody>