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

+17 -17
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { applyStaff } from "@/actions/applications"; import { applyStaff } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -10,18 +11,20 @@ export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN (same base used by the profile page). // Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
function formatDeadline(d: Date | null | undefined): string {
if (!d) return "No deadline set";
return d.toISOString().slice(0, 16).replace("T", " ");
}
export default async function ApplyStaffPage() { export default async function ApplyStaffPage() {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyStaff");
const userId = Number(session.user.id); const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
function formatDeadline(d: Date | null | undefined): string {
if (!d) return t("noDeadline");
return d.toISOString().slice(0, 16).replace("T", " ");
}
// ── Open positions ────────────────────────────────────────── // ── Open positions ──────────────────────────────────────────
// Each query is isolated so a DB hiccup degrades that section to empty // Each query is isolated so a DB hiccup degrades that section to empty
// rather than 500-ing the whole page. // rather than 500-ing the whole page.
@@ -52,16 +55,13 @@ export default async function ApplyStaffPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🛡️" icon="🛡️"
title={`Apply for ${hotelName} staff`} title={t("title", { hotel: hotelName })}
subtitle="We open staff applications every now and then. If you come across a position you feel you would fit perfectly into, do not hesitate to apply for it." subtitle={t("subtitle")}
/> />
{positions.length === 0 ? ( {positions.length === 0 ? (
<ContentCard icon="🛡️" title="No positions open"> <ContentCard icon="🛡️" title={t("noPositionsTitle")}>
<EmptyState icon="🛡️"> <EmptyState icon="🛡️">{t("noPositionsBody")}</EmptyState>
There are currently no positions open. Please come back at a later time to check if we
have any openings by then. Thank you for your interest.
</EmptyState>
</ContentCard> </ContentCard>
) : ( ) : (
<div className="card-grid sm-2"> <div className="card-grid sm-2">
@@ -98,7 +98,7 @@ export default async function ApplyStaffPage() {
<div className="content-card-body"> <div className="content-card-body">
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p> <p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
<p className="muted" style={{ margin: "0 0 0.75rem" }}> <p className="muted" style={{ margin: "0 0 0.75rem" }}>
Application deadline: {formatDeadline(position.applyTo)} {t("deadline", { date: formatDeadline(position.applyTo) })}
</p> </p>
{alreadyApplied ? ( {alreadyApplied ? (
@@ -108,7 +108,7 @@ export default async function ApplyStaffPage() {
disabled disabled
style={{ width: "100%" }} style={{ width: "100%" }}
> >
You have already applied for {rankName} {t("alreadyApplied", { rank: rankName })}
</button> </button>
) : ( ) : (
<form action={applyStaff}> <form action={applyStaff}>
@@ -116,7 +116,7 @@ export default async function ApplyStaffPage() {
re-read from the session inside the action. */} re-read from the session inside the action. */}
<input type="hidden" name="rankId" value={String(position.permissionId)} /> <input type="hidden" name="rankId" value={String(position.permissionId)} />
<label htmlFor={`content-${position.id}`} className="muted"> <label htmlFor={`content-${position.id}`} className="muted">
About you {t("aboutYou")}
</label> </label>
<textarea <textarea
id={`content-${position.id}`} id={`content-${position.id}`}
@@ -124,11 +124,11 @@ export default async function ApplyStaffPage() {
required required
minLength={10} minLength={10}
rows={5} rows={5}
placeholder={`Tell us why you'd be a great ${rankName}…`} placeholder={t("aboutPlaceholder", { rank: rankName })}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }} style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/> />
<button type="submit" className="btn btn-secondary" style={{ width: "100%" }}> <button type="submit" className="btn btn-secondary" style={{ width: "100%" }}>
Apply for {rankName} {t("applyFor", { rank: rankName })}
</button> </button>
</form> </form>
)} )}
+11 -11
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { applyTeam } from "@/actions/applications"; import { applyTeam } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -14,6 +15,8 @@ export default async function ApplyTeamPage() {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyTeam");
const userId = Number(session.user.id); const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
@@ -39,16 +42,13 @@ export default async function ApplyTeamPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🤝" icon="🤝"
title={`Apply for the ${hotelName} team`} title={t("title", { hotel: hotelName })}
subtitle="We open team applications periodically. If you see a team you fit, do not hesitate to apply!" subtitle={t("subtitle")}
/> />
{teams.length === 0 ? ( {teams.length === 0 ? (
<ContentCard icon="🤝" title="No team positions open"> <ContentCard icon="🤝" title={t("noPositionsTitle")}>
<EmptyState icon="🤝"> <EmptyState icon="🤝">{t("noPositionsBody")}</EmptyState>
There are currently no open team positions. Please come back later to check for new
openings. Thank you!
</EmptyState>
</ContentCard> </ContentCard>
) : ( ) : (
<div className="card-grid sm-2"> <div className="card-grid sm-2">
@@ -89,7 +89,7 @@ export default async function ApplyTeamPage() {
disabled disabled
style={{ width: "100%" }} style={{ width: "100%" }}
> >
Your application is pending {t("pending")}
</button> </button>
) : ( ) : (
<form action={applyTeam}> <form action={applyTeam}>
@@ -97,7 +97,7 @@ export default async function ApplyTeamPage() {
is re-read from the session inside the action. */} is re-read from the session inside the action. */}
<input type="hidden" name="teamId" value={String(team.id)} /> <input type="hidden" name="teamId" value={String(team.id)} />
<label htmlFor={`content-${team.id}`} className="muted"> <label htmlFor={`content-${team.id}`} className="muted">
About you {t("aboutYou")}
</label> </label>
<textarea <textarea
id={`content-${team.id}`} id={`content-${team.id}`}
@@ -105,11 +105,11 @@ export default async function ApplyTeamPage() {
required required
minLength={10} minLength={10}
rows={5} rows={5}
placeholder={`Tell us why you'd be a great fit for ${team.rankName}…`} placeholder={t("aboutPlaceholder", { rank: team.rankName })}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }} style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/> />
<button type="submit" className="btn btn-primary" style={{ width: "100%" }}> <button type="submit" className="btn btn-primary" style={{ width: "100%" }}>
Apply for {team.rankName} {t("applyFor", { rank: team.rankName })}
</button> </button>
</form> </form>
)} )}
+6 -3
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -9,6 +10,8 @@ export const metadata = { title: "Badges" };
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function BadgesPage() { export default async function BadgesPage() {
const t = await getTranslations("pages.badges");
const badges = await prisma.websiteBadges const badges = await prisma.websiteBadges
.findMany({ .findMany({
orderBy: { badgeName: "asc" }, orderBy: { badgeName: "asc" },
@@ -19,13 +22,13 @@ export default async function BadgesPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🏅" icon="🏅"
title="Badges" title={t("title")}
subtitle="The badges you can earn and show off around the hotel" subtitle={t("subtitle")}
/> />
<ContentCard padded={badges.length === 0}> <ContentCard padded={badges.length === 0}>
{badges.length === 0 ? ( {badges.length === 0 ? (
<EmptyState icon="🏅">No badges available yet.</EmptyState> <EmptyState icon="🏅">{t("noBadges")}</EmptyState>
) : ( ) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{badges.map((badge) => ( {badges.map((badge) => (
+10 -6
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -7,6 +8,7 @@ export const dynamic = "force-dynamic";
const FAR_FUTURE = Math.floor(Date.now() / 1000) + 50 * 365 * 24 * 3600; const FAR_FUTURE = Math.floor(Date.now() / 1000) + 50 * 365 * 24 * 3600;
export default async function BannedPage() { export default async function BannedPage() {
const t = await getTranslations("pages.banned");
const session = await auth(); const session = await auth();
let reason = ""; let reason = "";
let expire = 0; let expire = 0;
@@ -31,21 +33,23 @@ export default async function BannedPage() {
expire === 0 expire === 0
? "" ? ""
: expire > FAR_FUTURE : expire > FAR_FUTURE
? "This ban is permanent." ? t("permanent")
: `Expires ${new Date(expire * 1000).toISOString().slice(0, 16).replace("T", " ")}.`; : t("expires", {
date: new Date(expire * 1000).toISOString().slice(0, 16).replace("T", " "),
});
return ( return (
<main style={{ maxWidth: 560, margin: "2rem auto" }}> <main style={{ maxWidth: 560, margin: "2rem auto" }}>
<ContentCard icon="🚫" title="You are banned" subtitle="Account suspended"> <ContentCard icon="🚫" title={t("title")} subtitle={t("subtitle")}>
<p style={{ marginTop: 0 }}>Your account has been suspended from the hotel.</p> <p style={{ marginTop: 0 }}>{t("body")}</p>
{reason ? ( {reason ? (
<p> <p>
<strong>Reason:</strong> {reason} <strong>{t("reasonLabel")}</strong> {reason}
</p> </p>
) : null} ) : null}
{expiryText ? <p className="muted">{expiryText}</p> : null} {expiryText ? <p className="muted">{expiryText}</p> : null}
<p className="muted" style={{ marginBottom: 0 }}> <p className="muted" style={{ marginBottom: 0 }}>
If you believe this is a mistake, contact the staff team. {t("contactStaff")}
</p> </p>
</ContentCard> </ContentCard>
</main> </main>
+14 -17
View File
@@ -1,3 +1,4 @@
import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
@@ -7,50 +8,46 @@ export const metadata = { title: "Community" };
const LINKS = [ const LINKS = [
{ {
key: "rankings",
href: "/rankings", href: "/rankings",
icon: "🏆", icon: "🏆",
title: "Rankings",
text: "See the wealthiest players in the hotel, ranked by credits.",
}, },
{ {
key: "staff",
href: "/staff", href: "/staff",
icon: "🛡️", icon: "🛡️",
title: "Staff",
text: "Meet the team that keeps the hotel running every day.",
}, },
{ {
key: "news",
href: "/news", href: "/news",
icon: "📰", icon: "📰",
title: "News",
text: "The latest announcements, events and updates.",
}, },
{ {
key: "photos",
href: "/photos", href: "/photos",
icon: "📸", icon: "📸",
title: "Photos",
text: "Snapshots shared by the community from around the hotel.",
}, },
{ {
key: "guilds",
href: "/guilds", href: "/guilds",
icon: "🚪", icon: "🚪",
title: "Guilds",
text: "Browse the groups players have created and joined.",
}, },
{ {
key: "leaderboards",
href: "/leaderboard", href: "/leaderboard",
icon: "📊", icon: "📊",
title: "Leaderboards",
text: "Who's on top this season across the hotel's stats.",
}, },
]; ];
export default function CommunityPage() { export default function CommunityPage() {
const t = useTranslations("pages.community");
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🌍" icon="🌍"
title="Community" title={t("title")}
subtitle="Everything happening around the hotel in one place" subtitle={t("subtitle")}
/> />
<div className="card-grid sm-2 lg-3"> <div className="card-grid sm-2 lg-3">
@@ -66,14 +63,14 @@ export default function CommunityPage() {
{l.icon} {l.icon}
</span> </span>
<div className="content-card-head-text"> <div className="content-card-head-text">
<p className="content-card-title">{l.title}</p> <p className="content-card-title">{t(`links.${l.key}.title`)}</p>
</div> </div>
</div> </div>
<div className="content-card-body"> <div className="content-card-body">
<p className="muted" style={{ margin: "0 0 0.75rem" }}> <p className="muted" style={{ margin: "0 0 0.75rem" }}>
{l.text} {t(`links.${l.key}.text`)}
</p> </p>
<span className="btn btn-outline">Open →</span> <span className="btn btn-outline">{t("open")}</span>
</div> </div>
</Link> </Link>
))} ))}
+8 -6
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { requestReset } from "@/actions/password-reset"; import { requestReset } from "@/actions/password-reset";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
@@ -7,29 +8,30 @@ export default async function ForgotPage({
}: { }: {
searchParams: Promise<{ sent?: string }>; searchParams: Promise<{ sent?: string }>;
}) { }) {
const t = await getTranslations("pages.forgot");
const { sent } = await searchParams; const { sent } = await searchParams;
return ( return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}> <main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard <ContentCard
icon="🔑" icon="🔑"
title="Reset password" title={t("title")}
subtitle="We'll email you a secure link to set a new password." subtitle={t("subtitle")}
> >
{sent ? ( {sent ? (
<p className="muted" style={{ textAlign: "center", margin: 0 }}> <p className="muted" style={{ textAlign: "center", margin: 0 }}>
If that email is registered, a reset link has been sent. Check your inbox. {t("sentNotice")}
</p> </p>
) : ( ) : (
<form action={requestReset} style={{ display: "grid", gap: "0.7rem" }}> <form action={requestReset} style={{ display: "grid", gap: "0.7rem" }}>
<input name="email" type="email" placeholder="Your email" autoComplete="email" required /> <input name="email" type="email" placeholder={t("emailPlaceholder")} autoComplete="email" required />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Send reset link {t("sendResetLink")}
</button> </button>
</form> </form>
)} )}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}> <p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
<Link href="/login">Back to login</Link> <Link href="/login">{t("backToLogin")}</Link>
</p> </p>
</ContentCard> </ContentCard>
</main> </main>
+7 -5
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
@@ -11,6 +12,7 @@ export const dynamic = "force-dynamic";
export const metadata = { title: "Friends" }; export const metadata = { title: "Friends" };
export default async function FriendsPage() { export default async function FriendsPage() {
const t = await getTranslations("pages.friends");
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
@@ -56,18 +58,18 @@ export default async function FriendsPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🤝" icon="🤝"
title="Friends" title={t("title")}
subtitle={ subtitle={
friends.length === 0 friends.length === 0
? "You have no friends added yet" ? t("emptySubtitle")
: `You have ${friends.length} friend${friends.length === 1 ? "" : "s"}` : t("subtitle", { count: friends.length })
} }
/> />
<ContentCard padded={friends.length === 0}> <ContentCard padded={friends.length === 0}>
{friends.length === 0 ? ( {friends.length === 0 ? (
<EmptyState icon="🤝"> <EmptyState icon="🤝">
Add friends in the hotel and they will show up here. {t("emptyState")}
</EmptyState> </EmptyState>
) : ( ) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
@@ -104,7 +106,7 @@ export default async function FriendsPage() {
whiteSpace: "nowrap", whiteSpace: "nowrap",
}} }}
> >
{friend.motto || "No motto"} {friend.motto || t("noMotto")}
</p> </p>
<OnlineBadge online={isOnline} /> <OnlineBadge online={isOnline} />
</div> </div>
+11 -9
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
@@ -12,6 +13,7 @@ export default async function NewThreadPage({
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const t = await getTranslations("pages.guildForumNew");
const { id } = await params; const { id } = await params;
const guildId = Number(id); const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound(); if (!Number.isInteger(guildId) || guildId <= 0) notFound();
@@ -36,13 +38,13 @@ export default async function NewThreadPage({
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
<Link href={`/guilds/${guild.id}/forum`}>← Back to forum</Link> <Link href={`/guilds/${guild.id}/forum`}>{t("backToForum")}</Link>
</p> </p>
<ContentCard <ContentCard
icon="✍️" icon="✍️"
title="New thread" title={t("title")}
subtitle={`in ${guild.name || "Unnamed guild"} forum`} subtitle={t("subtitle", { guild: guild.name || t("unnamedGuild") })}
> >
<form action={postThread}> <form action={postThread}>
{/* Guild target drives the write; the author is taken from the session {/* Guild target drives the write; the author is taken from the session
@@ -50,35 +52,35 @@ export default async function NewThreadPage({
<input type="hidden" name="guildId" value={String(guild.id)} /> <input type="hidden" name="guildId" value={String(guild.id)} />
<label htmlFor="thread-subject" className="muted"> <label htmlFor="thread-subject" className="muted">
Subject {t("subjectLabel")}
</label> </label>
<input <input
id="thread-subject" id="thread-subject"
name="subject" name="subject"
required required
maxLength={255} maxLength={255}
placeholder="Thread subject" placeholder={t("subjectPlaceholder")}
style={{ width: "100%", margin: "0.4rem 0 0.9rem" }} style={{ width: "100%", margin: "0.4rem 0 0.9rem" }}
/> />
<label htmlFor="thread-message" className="muted"> <label htmlFor="thread-message" className="muted">
Message {t("messageLabel")}
</label> </label>
<textarea <textarea
id="thread-message" id="thread-message"
name="message" name="message"
required required
rows={8} rows={8}
placeholder="Write your opening post…" placeholder={t("messagePlaceholder")}
style={{ width: "100%", margin: "0.4rem 0 1rem", resize: "vertical" }} style={{ width: "100%", margin: "0.4rem 0 1rem", resize: "vertical" }}
/> />
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}> <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Post thread {t("postThread")}
</button> </button>
<Link href={`/guilds/${guild.id}/forum`} className="btn btn-outline"> <Link href={`/guilds/${guild.id}/forum`} className="btn btn-outline">
Cancel {t("cancel")}
</Link> </Link>
</div> </div>
</form> </form>
+22 -20
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -26,6 +27,7 @@ export default async function GuildForumPage({
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const t = await getTranslations("pages.guildForum");
const { id } = await params; const { id } = await params;
const guildId = Number(id); const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound(); if (!Number.isInteger(guildId) || guildId <= 0) notFound();
@@ -92,51 +94,51 @@ export default async function GuildForumPage({
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
<Link href={`/guilds/${guild.id}`}>← Back to guild</Link> <Link href={`/guilds/${guild.id}`}>{t("backToGuild")}</Link>
</p> </p>
<ContentCard <ContentCard
icon="🗣️" icon="🗣️"
title={`${guild.name || "Unnamed guild"} — Forum`} title={t("forumTitle", { guild: guild.name || t("unnamedGuild") })}
subtitle={`${threads.length} ${threads.length === 1 ? "thread" : "threads"}`} subtitle={`${threads.length} ${threads.length === 1 ? t("threadSingular") : t("threadPlural")}`}
action={ action={
<Link href={`/guilds/${guild.id}/forum/new`} className="btn btn-primary"> <Link href={`/guilds/${guild.id}/forum/new`} className="btn btn-primary">
+ New thread {t("newThread")}
</Link> </Link>
} }
/> />
<ContentCard padded={threads.length === 0}> <ContentCard padded={threads.length === 0}>
{threads.length === 0 ? ( {threads.length === 0 ? (
<EmptyState icon="🗣️">No threads in this forum yet.</EmptyState> <EmptyState icon="🗣️">{t("empty")}</EmptyState>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th style={{ padding: "0.6rem 0.75rem" }}>Subject</th> <th style={{ padding: "0.6rem 0.75rem" }}>{t("colSubject")}</th>
<th style={{ padding: "0.6rem 0.75rem" }}>Author</th> <th style={{ padding: "0.6rem 0.75rem" }}>{t("colAuthor")}</th>
<th style={{ padding: "0.6rem 0.75rem" }}>Posts</th> <th style={{ padding: "0.6rem 0.75rem" }}>{t("colPosts")}</th>
<th style={{ padding: "0.6rem 0.75rem" }}>Date</th> <th style={{ padding: "0.6rem 0.75rem" }}>{t("colDate")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{threads.map((t) => { {threads.map((t2) => {
const opener = t.openerId ?? 0; const opener = t2.openerId ?? 0;
const username = opener > 0 ? usernameById.get(opener) : undefined; const username = opener > 0 ? usernameById.get(opener) : undefined;
return ( return (
<tr key={t.id}> <tr key={t2.id}>
<td style={{ padding: "0.6rem 0.75rem" }}> <td style={{ padding: "0.6rem 0.75rem" }}>
<span style={{ fontWeight: 700 }}> <span style={{ fontWeight: 700 }}>
{t.subject?.trim() || "(no subject)"} {t2.subject?.trim() || t("noSubject")}
</span> </span>
{t.pinned ? ( {t2.pinned ? (
<span className="online-badge online" style={{ marginLeft: "0.5rem" }}> <span className="online-badge online" style={{ marginLeft: "0.5rem" }}>
📌 Pinned 📌 {t("pinned")}
</span> </span>
) : null} ) : null}
{t.locked ? ( {t2.locked ? (
<span className="online-badge" style={{ marginLeft: "0.5rem" }}> <span className="online-badge" style={{ marginLeft: "0.5rem" }}>
🔒 Locked 🔒 {t("locked")}
</span> </span>
) : null} ) : null}
</td> </td>
@@ -145,15 +147,15 @@ export default async function GuildForumPage({
<Link href={`/u/${username}`}>{username}</Link> <Link href={`/u/${username}`}>{username}</Link>
) : ( ) : (
<span className="muted"> <span className="muted">
{opener > 0 ? `User #${opener}` : "Unknown"} {opener > 0 ? t("userNumber", { id: opener }) : t("unknown")}
</span> </span>
)} )}
</td> </td>
<td style={{ padding: "0.6rem 0.75rem" }}> <td style={{ padding: "0.6rem 0.75rem" }}>
{t.postsCount ?? 0} {t2.postsCount ?? 0}
</td> </td>
<td className="muted" style={{ padding: "0.6rem 0.75rem" }}> <td className="muted" style={{ padding: "0.6rem 0.75rem" }}>
{formatTimestamp(t.createdAt)} {formatTimestamp(t2.createdAt)}
</td> </td>
</tr> </tr>
); );
+14 -12
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
@@ -35,6 +36,7 @@ export default async function GuildPage({
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const t = await getTranslations("pages.guild");
const { id } = await params; const { id } = await params;
const guildId = Number(id); const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound(); if (!Number.isInteger(guildId) || guildId <= 0) notFound();
@@ -121,7 +123,7 @@ export default async function GuildPage({
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
<Link href="/guilds">← All guilds</Link> <Link href="/guilds">{t("allGuilds")}</Link>
</p> </p>
<ContentCard> <ContentCard>
@@ -137,9 +139,9 @@ export default async function GuildPage({
/> />
) : null} ) : null}
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
<h1 style={{ margin: "0 0 0.35rem" }}>{guild.name || "Unnamed guild"}</h1> <h1 style={{ margin: "0 0 0.35rem" }}>{guild.name || t("unnamedGuild")}</h1>
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
{guild.description || <span className="muted">No description</span>} {guild.description || <span className="muted">{t("noDescription")}</span>}
</p> </p>
</div> </div>
</div> </div>
@@ -148,29 +150,29 @@ export default async function GuildPage({
<StatBlock <StatBlock
icon="👥" icon="👥"
value={members.length} value={members.length}
label={members.length === 1 ? "Member" : "Members"} label={members.length === 1 ? t("memberSingular") : t("memberPlural")}
/> />
{created ? <StatBlock icon="📅" value={created} label="Founded" /> : null} {created ? <StatBlock icon="📅" value={created} label={t("founded")} /> : null}
<StatBlock <StatBlock
icon="🚪" icon="🚪"
value={ value={
<Link href={`/guilds/${guild.id}/forum`} style={{ textDecoration: "none" }}> <Link href={`/guilds/${guild.id}/forum`} style={{ textDecoration: "none" }}>
Forum → {t("forumLink")}
</Link> </Link>
} }
label="Discussion" label={t("discussion")}
/> />
</div> </div>
</ContentCard> </ContentCard>
<ContentCard <ContentCard
icon="👥" icon="👥"
title="Members" title={t("membersTitle")}
subtitle={`${members.length} ${members.length === 1 ? "member" : "members"}`} subtitle={`${members.length} ${members.length === 1 ? t("memberLowerSingular") : t("memberLowerPlural")}`}
padded={members.length === 0} padded={members.length === 0}
> >
{members.length === 0 ? ( {members.length === 0 ? (
<EmptyState icon="👥">No members.</EmptyState> <EmptyState icon="👥">{t("noMembers")}</EmptyState>
) : ( ) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{members.map((m) => ( {members.map((m) => (
@@ -197,11 +199,11 @@ export default async function GuildPage({
className="online-badge online" className="online-badge online"
style={{ marginBottom: "0.25rem" }} style={{ marginBottom: "0.25rem" }}
> >
👑 Owner 👑 {t("owner")}
</span> </span>
) : null} ) : null}
<p className="muted" style={{ margin: m.isOwner ? "0.25rem 0 0" : 0 }}> <p className="muted" style={{ margin: m.isOwner ? "0.25rem 0 0" : 0 }}>
{m.motto || "No motto"} {m.motto || t("noMotto")}
</p> </p>
</div> </div>
</div> </div>
+7 -5
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
@@ -28,19 +29,20 @@ async function getGuilds(): Promise<GuildCard[]> {
} }
export default async function GuildsPage() { export default async function GuildsPage() {
const t = await getTranslations("pages.guilds");
const guilds = await getGuilds(); const guilds = await getGuilds();
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🚪" icon="🚪"
title="Guilds" title={t("title")}
subtitle="Browse the groups players have created and joined" subtitle={t("subtitle")}
/> />
<ContentCard padded={guilds.length === 0}> <ContentCard padded={guilds.length === 0}>
{guilds.length === 0 ? ( {guilds.length === 0 ? (
<EmptyState icon="🚪">No guilds yet.</EmptyState> <EmptyState icon="🚪">{t("empty")}</EmptyState>
) : ( ) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{guilds.map((g) => ( {guilds.map((g) => (
@@ -68,10 +70,10 @@ export default async function GuildsPage() {
) : null} ) : null}
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}> <h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
{g.name || "Unnamed guild"} {g.name || t("unnamedGuild")}
</h3> </h3>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{g.description ? excerpt(g.description, 120) : "No description"} {g.description ? excerpt(g.description, 120) : t("noDescription")}
</p> </p>
</div> </div>
</Link> </Link>
+6 -4
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
@@ -39,6 +40,7 @@ export default async function HelpCategoryPage({
}: { }: {
params: Promise<{ category: string }>; params: Promise<{ category: string }>;
}) { }) {
const t = await getTranslations("pages.helpCategory");
const { category } = await params; const { category } = await params;
let categoryId: bigint; let categoryId: bigint;
@@ -96,10 +98,10 @@ export default async function HelpCategoryPage({
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
<Link href="/help">← Help Center</Link> <Link href="/help">{t("back")}</Link>
</p> </p>
<ContentCard icon="❓" title={title} subtitle="Help Center topic"> <ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}>
{imageSrc ? ( {imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
@@ -140,8 +142,8 @@ export default async function HelpCategoryPage({
{siblings.length > 0 ? ( {siblings.length > 0 ? (
<ContentCard <ContentCard
icon="🧭" icon="🧭"
title="More help topics" title={t("moreTitle")}
subtitle="Jump to another support topic" subtitle={t("moreSubtitle")}
> >
<div className="card-grid sm-2 lg-3"> <div className="card-grid sm-2 lg-3">
{siblings.map((s) => ( {siblings.map((s) => (
+13 -10
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -35,6 +36,8 @@ function isExternal(url: string): boolean {
} }
export default async function HelpCenterPage() { export default async function HelpCenterPage() {
const t = await getTranslations("pages.help");
let categories: HelpCategory[] = []; let categories: HelpCategory[] = [];
try { try {
categories = await prisma.websiteHelpCenterCategories.findMany({ categories = await prisma.websiteHelpCenterCategories.findMany({
@@ -91,23 +94,23 @@ export default async function HelpCenterPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="❓" icon="❓"
title="Help Center" title={t("title")}
subtitle="Find guides, support topics, and the hotel rules — everything you need to get help." subtitle={t("subtitle")}
action={ action={
<Link className="btn btn-outline" href="/help/tickets"> <Link className="btn btn-outline" href="/help/tickets">
My tickets → {t("myTickets")}
</Link> </Link>
} }
/> />
<ContentCard <ContentCard
icon="🧭" icon="🧭"
title="Support topics" title={t("topicsTitle")}
subtitle="Browse guides and answers to common questions" subtitle={t("topicsSubtitle")}
padded={categories.length === 0} padded={categories.length === 0}
> >
{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" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{categories.map((c) => ( {categories.map((c) => (
@@ -169,12 +172,12 @@ export default async function HelpCenterPage() {
<ContentCard <ContentCard
icon="📜" icon="📜"
title="Hotel rules" title={t("rulesTitle")}
subtitle="Please read these before hanging out in the hotel" subtitle={t("rulesSubtitle")}
padded={ruleCategories.length === 0} padded={ruleCategories.length === 0}
> >
{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" }}> <div style={{ display: "grid", gap: "1rem", padding: "1rem" }}>
{ruleCategories.map((cat) => { {ruleCategories.map((cat) => {
@@ -200,7 +203,7 @@ export default async function HelpCenterPage() {
{catRules.length === 0 ? ( {catRules.length === 0 ? (
<p className="muted" style={{ marginBottom: 0 }}> <p className="muted" style={{ marginBottom: 0 }}>
No rules in this category. {t("categoryEmpty")}
</p> </p>
) : ( ) : (
<ul style={{ margin: "0.85rem 0 0", paddingLeft: "1.2rem" }}> <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 { redirect } from "next/navigation";
import { createTicket } from "@/actions/help-tickets"; import { createTicket } from "@/actions/help-tickets";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
@@ -14,6 +15,8 @@ export default async function HelpTicketsPage() {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.helpTickets");
const userId = Number(session.user.id); const userId = Number(session.user.id);
let tickets: Awaited<ReturnType<typeof prisma.websiteHelpCenterTickets.findMany>> = []; let tickets: Awaited<ReturnType<typeof prisma.websiteHelpCenterTickets.findMany>> = [];
@@ -34,30 +37,30 @@ export default async function HelpTicketsPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🎫" icon="🎫"
title="Help Tickets" title={t("title")}
subtitle="Submit a ticket to staff and track your open requests." subtitle={t("subtitle")}
> >
<div className="stat-grid"> <div className="stat-grid">
<StatBlock icon="🎫" value={tickets.length} label="Total tickets" /> <StatBlock icon="🎫" value={tickets.length} label={t("statTotal")} />
<StatBlock icon="📬" value={openCount} label="Open" /> <StatBlock icon="📬" value={openCount} label={t("statOpen")} />
<StatBlock icon="✅" value={closedCount} label="Closed" /> <StatBlock icon="✅" value={closedCount} label={t("statClosed")} />
</div> </div>
</ContentCard> </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}> <form action={createTicket}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}> <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 <textarea
name="content" name="content"
placeholder="Describe your issue…" placeholder={t("contentPlaceholder")}
rows={5} rows={5}
maxLength={5000} maxLength={5000}
required required
/> />
<div> <div>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Submit ticket {t("submit")}
</button> </button>
</div> </div>
</div> </div>
@@ -66,34 +69,34 @@ export default async function HelpTicketsPage() {
<ContentCard <ContentCard
icon="📋" icon="📋"
title="Your tickets" title={t("listTitle")}
subtitle="Every request you have sent to staff" subtitle={t("listSubtitle")}
padded={tickets.length === 0} padded={tickets.length === 0}
> >
{tickets.length === 0 ? ( {tickets.length === 0 ? (
<EmptyState icon="🎫">You have no tickets yet.</EmptyState> <EmptyState icon="🎫">{t("listEmpty")}</EmptyState>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>{t("colId")}</th>
<th>Subject</th> <th>{t("colSubject")}</th>
<th>Status</th> <th>{t("colStatus")}</th>
<th>Created</th> <th>{t("colCreated")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tickets.map((t) => ( {tickets.map((t2) => (
<tr key={String(t.id)}> <tr key={String(t2.id)}>
<td>{String(t.id)}</td> <td>{String(t2.id)}</td>
<td>{t.title}</td> <td>{t2.title}</td>
<td> <td>
<span className={`online-badge${t.open ? " online" : ""}`}> <span className={`online-badge${t2.open ? " online" : ""}`}>
<span className="online-dot" aria-hidden /> <span className="online-dot" aria-hidden />
{t.open ? "Open" : "Closed"} {t2.open ? t("statusOpen") : t("statusClosed")}
</span> </span>
</td> </td>
<td className="muted">{formatDate(t.createdAt)}</td> <td className="muted">{formatDate(t2.createdAt)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+13 -11
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
@@ -71,6 +72,7 @@ export default async function LeaderboardPage({
}: { }: {
searchParams: Promise<{ type?: string }>; searchParams: Promise<{ type?: string }>;
}) { }) {
const t = await getTranslations("pages.leaderboard");
const { type } = await searchParams; const { type } = await searchParams;
const active: TabKey = TABS.some((t) => t.key === type) ? (type as TabKey) : "credits"; const active: TabKey = TABS.some((t) => t.key === type) ? (type as TabKey) : "credits";
const activeTab = TABS.find((t) => t.key === active)!; const activeTab = TABS.find((t) => t.key === active)!;
@@ -86,33 +88,33 @@ export default async function LeaderboardPage({
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="📊" icon="📊"
title="Leaderboard" title={t("title")}
subtitle={`Top 20 players by ${activeTab.label.toLowerCase()}`} subtitle={t("subtitle", { currency: t(`tab_${active}`).toLowerCase() })}
/> />
<div className="nav" style={{ gap: "0.5rem" }}> <div className="nav" style={{ gap: "0.5rem" }}>
{TABS.map((t) => ( {TABS.map((t2) => (
<Link <Link
key={t.key} key={t2.key}
href={`/leaderboard?type=${t.key}`} href={`/leaderboard?type=${t2.key}`}
className={`btn ${t.key === active ? "btn-primary" : "btn-outline"}`} className={`btn ${t2.key === active ? "btn-primary" : "btn-outline"}`}
> >
{t.label} {t(`tab_${t2.key}`)}
</Link> </Link>
))} ))}
</div> </div>
<ContentCard padded={rows.length === 0}> <ContentCard padded={rows.length === 0}>
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyState icon="📊">No rankings to show yet.</EmptyState> <EmptyState icon="📊">{t("emptyState")}</EmptyState>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th style={{ width: "3rem" }}>#</th> <th style={{ width: "3rem" }}>#</th>
<th style={{ width: "3.5rem" }}>Avatar</th> <th style={{ width: "3.5rem" }}>{t("colAvatar")}</th>
<th>Player</th> <th>{t("colPlayer")}</th>
<th style={{ textAlign: "right" }}>{activeTab.label}</th> <th style={{ textAlign: "right" }}>{t(`tab_${active}`)}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
+15 -17
View File
@@ -1,12 +1,14 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from "react";
import { precheckLogin } from "@/actions/auth-precheck"; import { precheckLogin } from "@/actions/auth-precheck";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
export default function LoginPage() { export default function LoginPage() {
const t = useTranslations("pages.login");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
@@ -22,7 +24,7 @@ export default function LoginPage() {
if (!needs2fa) { if (!needs2fa) {
const pre = await precheckLogin(username, password); const pre = await precheckLogin(username, password);
if (pre === "invalid") { if (pre === "invalid") {
setError("Invalid username or password"); setError(t("errorInvalidCredentials"));
return; return;
} }
if (pre === "twofactor") { if (pre === "twofactor") {
@@ -37,7 +39,7 @@ export default function LoginPage() {
redirect: false, redirect: false,
}); });
if (!res || res.error) { if (!res || res.error) {
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password"); setError(needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"));
return; return;
} }
window.location.href = "/"; window.location.href = "/";
@@ -50,18 +52,14 @@ export default function LoginPage() {
<main style={{ maxWidth: 420, margin: "2rem auto" }}> <main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard <ContentCard
icon={needs2fa ? "🔒" : "🔑"} icon={needs2fa ? "🔒" : "🔑"}
title="Sign in" title={t("title")}
subtitle={ subtitle={needs2fa ? t("subtitle2fa") : t("subtitle")}
needs2fa
? "Enter the 6-digit code from your authenticator."
: "Welcome back — log in to enter the hotel."
}
> >
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}> <form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}>
<input <input
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder="Username" placeholder={t("usernamePlaceholder")}
autoComplete="username" autoComplete="username"
disabled={needs2fa} disabled={needs2fa}
/> />
@@ -69,7 +67,7 @@ export default function LoginPage() {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Password" placeholder={t("passwordPlaceholder")}
autoComplete="current-password" autoComplete="current-password"
disabled={needs2fa} disabled={needs2fa}
/> />
@@ -77,7 +75,7 @@ export default function LoginPage() {
<input <input
value={code} value={code}
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value)}
placeholder="2FA code" placeholder={t("codePlaceholder")}
inputMode="numeric" inputMode="numeric"
autoComplete="one-time-code" autoComplete="one-time-code"
// biome-ignore lint/a11y/noAutofocus: focus the only relevant field in the 2FA step // biome-ignore lint/a11y/noAutofocus: focus the only relevant field in the 2FA step
@@ -85,7 +83,7 @@ export default function LoginPage() {
/> />
) : null} ) : null}
<button type="submit" className="btn btn-primary" disabled={pending}> <button type="submit" className="btn btn-primary" disabled={pending}>
{pending ? "Please wait…" : needs2fa ? "Verify" : "Sign in"} {pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")}
</button> </button>
</form> </form>
@@ -93,7 +91,7 @@ export default function LoginPage() {
<> <>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", margin: "1rem 0" }}> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", margin: "1rem 0" }}>
<span style={{ flex: 1, height: 1, background: "var(--border-color)" }} /> <span style={{ flex: 1, height: 1, background: "var(--border-color)" }} />
<span className="muted">or</span> <span className="muted">{t("or")}</span>
<span style={{ flex: 1, height: 1, background: "var(--border-color)" }} /> <span style={{ flex: 1, height: 1, background: "var(--border-color)" }} />
</div> </div>
<div style={{ display: "grid", gap: "0.5rem" }}> <div style={{ display: "grid", gap: "0.5rem" }}>
@@ -102,14 +100,14 @@ export default function LoginPage() {
className="btn btn-outline" className="btn btn-outline"
onClick={() => signIn("discord", { callbackUrl: "/" })} onClick={() => signIn("discord", { callbackUrl: "/" })}
> >
Continue with Discord {t("continueWithDiscord")}
</button> </button>
<button <button
type="button" type="button"
className="btn btn-outline" className="btn btn-outline"
onClick={() => signIn("google", { callbackUrl: "/" })} onClick={() => signIn("google", { callbackUrl: "/" })}
> >
Continue with Google {t("continueWithGoogle")}
</button> </button>
</div> </div>
</> </>
@@ -121,8 +119,8 @@ export default function LoginPage() {
</p> </p>
) : null} ) : null}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}> <p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
No account? <Link href="/register">Create one</Link> ·{" "} {t("noAccount")} <Link href="/register">{t("createOne")}</Link> ·{" "}
<Link href="/forgot">Forgot password?</Link> <Link href="/forgot">{t("forgotPassword")}</Link>
</p> </p>
</ContentCard> </ContentCard>
</main> </main>
+5 -3
View File
@@ -1,9 +1,11 @@
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function MaintenancePage() { export default async function MaintenancePage() {
const t = await getTranslations("pages.maintenance");
const [hotel, message] = await Promise.all([ const [hotel, message] = await Promise.all([
siteSettings.get("hotel_name", "Atom"), siteSettings.get("hotel_name", "Atom"),
siteSettings.get( siteSettings.get(
@@ -16,12 +18,12 @@ export default async function MaintenancePage() {
<main style={{ maxWidth: 560, margin: "2rem auto" }}> <main style={{ maxWidth: 560, margin: "2rem auto" }}>
<ContentCard <ContentCard
icon="🛠️" icon="🛠️"
title={`${hotel} is under maintenance`} title={t("title", { hotel: hotel ?? "Atom" })}
subtitle="We'll be back shortly" subtitle={t("subtitle")}
> >
<p style={{ marginTop: 0 }}>{message}</p> <p style={{ marginTop: 0 }}>{message}</p>
<p className="muted" style={{ marginBottom: 0 }}> <p className="muted" style={{ marginBottom: 0 }}>
Staff can still log in. {t("staffCanLogIn")}
</p> </p>
</ContentCard> </ContentCard>
</main> </main>
+25 -21
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -8,16 +9,18 @@ export const metadata = { title: "Marketplace" };
// state == 1 → an active, unsold offer in the Arcturus marketplace. // state == 1 → an active, unsold offer in the Arcturus marketplace.
const STATE_ACTIVE = 1; const STATE_ACTIVE = 1;
function listedAgo(ts: number): string { type RelativeTimeTranslator = (key: string, values?: Record<string, number>) => string;
function listedAgo(ts: number, t: RelativeTimeTranslator): string {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const diff = Math.max(0, now - ts); const diff = Math.max(0, now - ts);
if (diff < 60) return "just now"; if (diff < 60) return t("justNow");
const mins = Math.floor(diff / 60); const mins = Math.floor(diff / 60);
if (mins < 60) return `${mins}m ago`; if (mins < 60) return t("minutesAgo", { count: mins });
const hours = Math.floor(mins / 60); const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`; if (hours < 24) return t("hoursAgo", { count: hours });
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
return `${days}d ago`; return t("daysAgo", { count: days });
} }
function listedAt(ts: number): string { function listedAt(ts: number): string {
@@ -25,6 +28,7 @@ function listedAt(ts: number): string {
} }
export default async function MarketplacePage() { export default async function MarketplacePage() {
const t = await getTranslations("pages.marketplace");
let offers: { let offers: {
id: number; id: number;
itemId: number; itemId: number;
@@ -64,51 +68,51 @@ export default async function MarketplacePage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🛍️" icon="🛍️"
title="Marketplace" title={t("title")}
subtitle="Browse furniture and rares listed by players. Listings are placed in-game and update live — head into the hotel to make an offer." subtitle={t("subtitle")}
> >
<div className="stat-grid"> <div className="stat-grid">
<StatBlock value={offers.length.toLocaleString()} label="Active offers" icon="🏷️" /> <StatBlock value={offers.length.toLocaleString()} label={t("activeOffers")} icon="🏷️" />
<StatBlock <StatBlock
value={new Set(offers.map((o) => o.userId)).size.toLocaleString()} value={new Set(offers.map((o) => o.userId)).size.toLocaleString()}
label="Sellers" label={t("sellers")}
icon="👥" icon="👥"
/> />
<StatBlock value={`${total.toLocaleString()} cr`} label="Total value" icon="🪙" /> <StatBlock value={`${total.toLocaleString()} cr`} label={t("totalValue")} icon="🪙" />
</div> </div>
</ContentCard> </ContentCard>
<ContentCard <ContentCard
icon="📋" icon="📋"
title="Active listings" title={t("activeListings")}
subtitle={offers.length > 0 ? "Most recently listed first" : undefined} subtitle={offers.length > 0 ? t("mostRecentFirst") : undefined}
padded={offers.length === 0} padded={offers.length === 0}
> >
{offers.length === 0 ? ( {offers.length === 0 ? (
<EmptyState icon="🛍️"> <EmptyState icon="🛍️">
There are no active marketplace offers right now. Check back later. {t("empty")}
</EmptyState> </EmptyState>
) : ( ) : (
<div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}> <div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Offer</th> <th>{t("colOffer")}</th>
<th>Item</th> <th>{t("colItem")}</th>
<th>Seller</th> <th>{t("colSeller")}</th>
<th>Price</th> <th>{t("colPrice")}</th>
<th>Listed</th> <th>{t("colListed")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{offers.map((o) => ( {offers.map((o) => (
<tr key={o.id}> <tr key={o.id}>
<td className="muted">#{o.id}</td> <td className="muted">#{o.id}</td>
<td>Item #{o.itemId}</td> <td>{t("itemLabel", { id: o.itemId })}</td>
<td>{sellers.get(o.userId) ?? `User #${o.userId}`}</td> <td>{sellers.get(o.userId) ?? t("userLabel", { id: o.userId })}</td>
<td style={{ fontWeight: 700 }}>{o.price.toLocaleString()} cr</td> <td style={{ fontWeight: 700 }}>{o.price.toLocaleString()} cr</td>
<td className="muted" title={listedAt(o.timestamp)}> <td className="muted" title={listedAt(o.timestamp)}>
{listedAgo(o.timestamp)} {listedAgo(o.timestamp, t)}
</td> </td>
</tr> </tr>
))} ))}
+16 -14
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -16,6 +17,7 @@ function formatTimestamp(seconds: number): string {
} }
export default async function MessagesPage() { export default async function MessagesPage() {
const t = await getTranslations("pages.messages");
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
@@ -83,23 +85,23 @@ export default async function MessagesPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="✉️" icon="✉️"
title="Messages" title={t("title")}
subtitle="Friend requests and offline messages waiting for you" subtitle={t("subtitle")}
/> />
{/* ── Pending friend requests ───────────────────────────── */} {/* ── Pending friend requests ───────────────────────────── */}
<ContentCard <ContentCard
icon="🙋" icon="🙋"
title="Friend requests" title={t("requestsTitle")}
subtitle={ subtitle={
requests.length === 0 requests.length === 0
? "Nobody's asked to be your friend yet" ? t("requestsEmptySubtitle")
: `${requests.length} pending request${requests.length === 1 ? "" : "s"}` : t("requestsSubtitle", { count: requests.length })
} }
padded={requests.length === 0} padded={requests.length === 0}
> >
{requests.length === 0 ? ( {requests.length === 0 ? (
<EmptyState icon="🙋">No pending friend requests.</EmptyState> <EmptyState icon="🙋">{t("requestsEmptyState")}</EmptyState>
) : ( ) : (
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}> <div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
{requests.map((req) => { {requests.map((req) => {
@@ -129,17 +131,17 @@ export default async function MessagesPage() {
<strong>{from.username}</strong> <strong>{from.username}</strong>
</Link> </Link>
) : ( ) : (
<strong>User #{req.userFromId}</strong> <strong>{t("unknownUser", { id: req.userFromId })}</strong>
)} )}
</p> </p>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
wants to be your friend. {t("wantsToBeFriend")}
</p> </p>
</div> </div>
<form action={acceptFriend}> <form action={acceptFriend}>
<input type="hidden" name="requestId" value={String(req.id)} /> <input type="hidden" name="requestId" value={String(req.id)} />
<button type="submit" className="btn btn-secondary"> <button type="submit" className="btn btn-secondary">
Accept {t("accept")}
</button> </button>
</form> </form>
</div> </div>
@@ -152,16 +154,16 @@ export default async function MessagesPage() {
{/* ── Offline messages ──────────────────────────────────── */} {/* ── Offline messages ──────────────────────────────────── */}
<ContentCard <ContentCard
icon="💬" icon="💬"
title="Offline messages" title={t("offlineTitle")}
subtitle={ subtitle={
messages.length === 0 messages.length === 0
? "Your inbox is empty" ? t("offlineEmptySubtitle")
: `${messages.length} message${messages.length === 1 ? "" : "s"} received` : t("offlineSubtitle", { count: messages.length })
} }
padded={messages.length === 0} padded={messages.length === 0}
> >
{messages.length === 0 ? ( {messages.length === 0 ? (
<EmptyState icon="💬">You have no offline messages.</EmptyState> <EmptyState icon="💬">{t("offlineEmptyState")}</EmptyState>
) : ( ) : (
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}> <div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
{messages.map((msg) => { {messages.map((msg) => {
@@ -193,7 +195,7 @@ export default async function MessagesPage() {
{from ? ( {from ? (
<Link href={`/u/${from.username}`}>{from.username}</Link> <Link href={`/u/${from.username}`}>{from.username}</Link>
) : ( ) : (
<span>User #{msg.userFromId}</span> <span>{t("unknownUser", { id: msg.userFromId })}</span>
)} )}
{when ? <span> · {when}</span> : null} {when ? <span> · {when}</span> : null}
</p> </p>
+22 -19
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -25,10 +26,10 @@ export async function generateMetadata({
// The reaction set offered by the voting UI. Must stay in sync with // The reaction set offered by the voting UI. Must stay in sync with
// ALLOWED_REACTIONS in src/actions/article-reactions.ts. // ALLOWED_REACTIONS in src/actions/article-reactions.ts.
const REACTIONS: { key: string; label: string }[] = [ const REACTIONS: { key: string; emoji: string }[] = [
{ key: "like", label: "👍 Like" }, { key: "like", emoji: "👍" },
{ key: "love", label: "❤️ Love" }, { key: "love", emoji: "❤️" },
{ key: "wow", label: "😮 Wow" }, { key: "wow", emoji: "😮" },
]; ];
function formatDate(d: Date | null | undefined): string { function formatDate(d: Date | null | undefined): string {
@@ -41,6 +42,7 @@ export default async function ArticlePage({
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}) { }) {
const { slug } = await params; const { slug } = await params;
const t = await getTranslations("pages.article");
let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null; let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null;
try { try {
@@ -109,7 +111,7 @@ export default async function ArticlePage({
subtitle={article.createdAt ? article.createdAt.toISOString().slice(0, 10) : undefined} subtitle={article.createdAt ? article.createdAt.toISOString().slice(0, 10) : undefined}
action={ action={
<Link className="btn btn-outline" href="/news"> <Link className="btn btn-outline" href="/news">
← News {t("backToNews")}
</Link> </Link>
} }
> >
@@ -125,12 +127,13 @@ export default async function ArticlePage({
</ContentCard> </ContentCard>
{/* ── Reactions ─────────────────────────────────────────── */} {/* ── Reactions ─────────────────────────────────────────── */}
<ContentCard icon="💬" title="Reactions" subtitle="Tell us what you think"> <ContentCard icon="💬" title={t("reactionsTitle")} subtitle={t("reactionsSubtitle")}>
{loggedIn ? ( {loggedIn ? (
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{REACTIONS.map((r) => { {REACTIONS.map((r) => {
const active = myReaction === r.key; const active = myReaction === r.key;
const count = reactionCounts.get(r.key) ?? 0; const count = reactionCounts.get(r.key) ?? 0;
const label = `${r.emoji} ${t(`reaction.${r.key}`)}`;
return ( return (
<form key={r.key} action={toggleReaction} style={{ margin: 0 }}> <form key={r.key} action={toggleReaction} style={{ margin: 0 }}>
<input type="hidden" name="articleId" value={String(articleId)} /> <input type="hidden" name="articleId" value={String(articleId)} />
@@ -139,9 +142,9 @@ export default async function ArticlePage({
type="submit" type="submit"
aria-pressed={active} aria-pressed={active}
className={`btn ${active ? "btn-primary" : "btn-outline"}`} className={`btn ${active ? "btn-primary" : "btn-outline"}`}
title={active ? "Click to remove your reaction" : `React with ${r.key}`} title={active ? t("removeReaction") : t("reactWith", { reaction: r.key })}
> >
<span>{r.label}</span> <span>{label}</span>
<span <span
className="muted" className="muted"
style={{ fontSize: "0.85rem", color: "inherit", opacity: 0.85 }} style={{ fontSize: "0.85rem", color: "inherit", opacity: 0.85 }}
@@ -155,14 +158,14 @@ export default async function ArticlePage({
</div> </div>
) : reactionCounts.size === 0 ? ( ) : reactionCounts.size === 0 ? (
<EmptyState icon="💬"> <EmptyState icon="💬">
No reactions yet. <Link href="/login">Log in</Link> to react. {t("noReactions")} <Link href="/login">{t("logIn")}</Link> {t("toReact")}
</EmptyState> </EmptyState>
) : ( ) : (
<> <>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{REACTIONS.filter((r) => (reactionCounts.get(r.key) ?? 0) > 0).map((r) => ( {REACTIONS.filter((r) => (reactionCounts.get(r.key) ?? 0) > 0).map((r) => (
<span key={r.key} className="currency"> <span key={r.key} className="currency">
<span style={{ fontWeight: 800 }}>{r.label}</span> <span style={{ fontWeight: 800 }}>{`${r.emoji} ${t(`reaction.${r.key}`)}`}</span>
<span className="muted" style={{ fontSize: "0.85rem" }}> <span className="muted" style={{ fontSize: "0.85rem" }}>
{reactionCounts.get(r.key) ?? 0} {reactionCounts.get(r.key) ?? 0}
</span> </span>
@@ -170,7 +173,7 @@ export default async function ArticlePage({
))} ))}
</div> </div>
<p className="muted" style={{ marginTop: "0.6rem" }}> <p className="muted" style={{ marginTop: "0.6rem" }}>
<Link href="/login">Log in</Link> to react. <Link href="/login">{t("logIn")}</Link> {t("toReact")}
</p> </p>
</> </>
)} )}
@@ -179,11 +182,11 @@ export default async function ArticlePage({
{/* ── Comments ──────────────────────────────────────────── */} {/* ── Comments ──────────────────────────────────────────── */}
<ContentCard <ContentCard
icon="🗨️" icon="🗨️"
title="Comments" title={t("commentsTitle")}
subtitle={comments.length > 0 ? `${comments.length} so far` : "Join the conversation"} subtitle={comments.length > 0 ? t("commentsCount", { count: comments.length }) : t("commentsSubtitle")}
> >
{comments.length === 0 ? ( {comments.length === 0 ? (
<EmptyState icon="🗨️">No comments yet. Be the first to comment.</EmptyState> <EmptyState icon="🗨️">{t("noComments")}</EmptyState>
) : ( ) : (
<div className="grid" style={{ marginBottom: "1.5rem" }}> <div className="grid" style={{ marginBottom: "1.5rem" }}>
{comments.map((c) => { {comments.map((c) => {
@@ -194,7 +197,7 @@ export default async function ArticlePage({
{name ? ( {name ? (
<Link href={`/u/${name}`}>{name}</Link> <Link href={`/u/${name}`}>{name}</Link>
) : ( ) : (
<span>User #{c.userId}</span> <span>{t("userNumber", { id: c.userId })}</span>
)} )}
{c.createdAt ? <span> · {formatDate(c.createdAt)}</span> : null} {c.createdAt ? <span> · {formatDate(c.createdAt)}</span> : null}
</p> </p>
@@ -212,7 +215,7 @@ export default async function ArticlePage({
htmlFor="comment" htmlFor="comment"
style={{ display: "block", fontWeight: 700, marginBottom: "0.5rem" }} style={{ display: "block", fontWeight: 700, marginBottom: "0.5rem" }}
> >
Add a comment {t("addComment")}
</label> </label>
<textarea <textarea
id="comment" id="comment"
@@ -220,18 +223,18 @@ export default async function ArticlePage({
required required
maxLength={255} maxLength={255}
rows={3} rows={3}
placeholder="Write your comment…" placeholder={t("commentPlaceholder")}
style={{ width: "100%", resize: "vertical" }} style={{ width: "100%", resize: "vertical" }}
/> />
<div style={{ marginTop: "0.75rem" }}> <div style={{ marginTop: "0.75rem" }}>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Post comment {t("postComment")}
</button> </button>
</div> </div>
</form> </form>
) : ( ) : (
<p className="muted"> <p className="muted">
<Link href="/login">Log in</Link> to leave a comment. <Link href="/login">{t("logIn")}</Link> {t("toComment")}
</p> </p>
)} )}
</ContentCard> </ContentCard>
+6 -3
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
@@ -12,6 +13,8 @@ function formatDate(d: Date | null): string {
} }
export default async function NewsPage() { export default async function NewsPage() {
const t = await getTranslations("pages.news");
let articles: Awaited<ReturnType<typeof prisma.websiteArticles.findMany>> = []; let articles: Awaited<ReturnType<typeof prisma.websiteArticles.findMany>> = [];
try { try {
articles = await prisma.websiteArticles.findMany({ articles = await prisma.websiteArticles.findMany({
@@ -27,13 +30,13 @@ export default async function NewsPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="📰" icon="📰"
title="News" title={t("title")}
subtitle="The latest announcements, events and updates from the hotel" subtitle={t("subtitle")}
/> />
<ContentCard padded={articles.length === 0}> <ContentCard padded={articles.length === 0}>
{articles.length === 0 ? ( {articles.length === 0 ? (
<EmptyState icon="📰">No articles yet.</EmptyState> <EmptyState icon="📰">{t("noArticles")}</EmptyState>
) : ( ) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{articles.map((a) => ( {articles.map((a) => (
+11 -9
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
@@ -7,6 +8,7 @@ import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function HomePage() { export default async function HomePage() {
const t = await getTranslations("pages.home");
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
let articles: Array<{ slug: string; title: string; shortStory: string; image: string }> = []; let articles: Array<{ slug: string; title: string; shortStory: string; image: string }> = [];
@@ -24,34 +26,34 @@ export default async function HomePage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🏨" icon="🏨"
title={`Welcome to ${hotelName}`} title={t("welcome", { hotelName })}
subtitle="Your retro hotel, now powered by Next.js" subtitle={t("welcomeSubtitle")}
> >
<p className="muted" style={{ margin: "0 0 0.9rem" }}> <p className="muted" style={{ margin: "0 0 0.9rem" }}>
Hang out, collect furni, and meet friends. {t("welcomeBody")}
</p> </p>
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
<Link className="btn btn-primary" href="/client"> <Link className="btn btn-primary" href="/client">
Enter hotel {t("enterHotel")}
</Link> </Link>
<Link className="btn btn-outline" href="/news"> <Link className="btn btn-outline" href="/news">
Latest news {t("latestNews")}
</Link> </Link>
</div> </div>
</ContentCard> </ContentCard>
<ContentCard <ContentCard
icon="📰" icon="📰"
title="Latest news" title={t("newsTitle")}
subtitle="Fresh from the hotel" subtitle={t("newsSubtitle")}
action={ action={
<Link className="btn btn-outline" href="/news"> <Link className="btn btn-outline" href="/news">
All news → {t("allNews")}
</Link> </Link>
} }
> >
{articles.length === 0 ? ( {articles.length === 0 ? (
<EmptyState icon="📰">No articles yet.</EmptyState> <EmptyState icon="📰">{t("noArticles")}</EmptyState>
) : ( ) : (
<div className="card-grid sm-2 lg-4"> <div className="card-grid sm-2 lg-4">
{articles.map((a) => ( {articles.map((a) => (
+8 -5
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -21,6 +22,8 @@ function formatDate(timestamp: number): string {
} }
export default async function PhotosPage() { export default async function PhotosPage() {
const t = await getTranslations("pages.photos");
let photos: Photo[] = []; let photos: Photo[] = [];
try { try {
photos = await prisma.cameraWeb.findMany({ photos = await prisma.cameraWeb.findMany({
@@ -35,13 +38,13 @@ export default async function PhotosPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="📸" icon="📸"
title="Photos" title={t("title")}
subtitle="The latest snapshots taken in the hotel" subtitle={t("subtitle")}
/> />
<ContentCard padded={photos.length === 0}> <ContentCard padded={photos.length === 0}>
{photos.length === 0 ? ( {photos.length === 0 ? (
<EmptyState icon="📸">No photos yet.</EmptyState> <EmptyState icon="📸">{t("noPhotos")}</EmptyState>
) : ( ) : (
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}> <div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
{photos.map((p) => ( {photos.map((p) => (
@@ -49,7 +52,7 @@ export default async function PhotosPage() {
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={p.url} src={p.url}
alt={`Photo by user #${p.userId}`} alt={t("photoAlt", { id: p.userId })}
style={{ style={{
width: "100%", width: "100%",
height: "auto", height: "auto",
@@ -61,7 +64,7 @@ export default async function PhotosPage() {
{formatDate(p.timestamp)} {formatDate(p.timestamp)}
</p> </p>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}> <p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
by user #{p.userId} {t("byUser", { id: p.userId })}
</p> </p>
</div> </div>
</article> </article>
+22 -19
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
@@ -10,6 +11,8 @@ export default async function RadioApplyPage() {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.radioApply");
const userId = Number(session.user.id); const userId = Number(session.user.id);
// Surface the applicant's most recent application (if any) so they aren't // 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" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🎤" icon="🎤"
title="Apply to be a Radio DJ" title={t("title")}
subtitle="Tell us about yourself and why you'd be a great fit for the airwaves" subtitle={t("subtitle")}
/> />
{existing ? ( {existing ? (
<ContentCard icon="📋" title="Your latest application"> <ContentCard icon="📋" title={t("latestTitle")}>
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
<strong>Status:</strong> {existing.status} <strong>{t("statusLabel")}</strong> {existing.status}
{submittedDate ? ( {submittedDate ? (
<span className="muted"> · submitted {submittedDate}</span> <span className="muted"> {t("submittedOn", { date: submittedDate })}</span>
) : null} ) : null}
</p> </p>
<p className="muted" style={{ margin: "0.4rem 0 0" }}> <p className="muted" style={{ margin: "0.4rem 0 0" }}>
You can submit again below if your details have changed. {t("submitAgain")}
</p> </p>
</ContentCard> </ContentCard>
) : null} ) : 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}> <form action={applyDj}>
<div className="card-grid sm-2"> <div className="card-grid sm-2">
<div> <div>
<label htmlFor="realName" className="muted"> <label htmlFor="realName" className="muted">
Real name {t("realNameLabel")}
</label> </label>
<input <input
id="realName" id="realName"
@@ -61,13 +64,13 @@ export default async function RadioApplyPage() {
type="text" type="text"
required required
maxLength={255} maxLength={255}
placeholder="Your full name" placeholder={t("realNamePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }} style={{ width: "100%", marginTop: "0.3rem" }}
/> />
</div> </div>
<div> <div>
<label htmlFor="age" className="muted"> <label htmlFor="age" className="muted">
Age {t("ageLabel")}
</label> </label>
<input <input
id="age" id="age"
@@ -84,61 +87,61 @@ export default async function RadioApplyPage() {
<div style={{ marginTop: "1rem" }}> <div style={{ marginTop: "1rem" }}>
<label htmlFor="availability" className="muted"> <label htmlFor="availability" className="muted">
Availability {t("availabilityLabel")}
</label> </label>
<textarea <textarea
id="availability" id="availability"
name="availability" name="availability"
required required
rows={3} rows={3}
placeholder="Which days and times can you broadcast?" placeholder={t("availabilityPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }} style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/> />
</div> </div>
<div style={{ marginTop: "1rem" }}> <div style={{ marginTop: "1rem" }}>
<label htmlFor="musicStyle" className="muted"> <label htmlFor="musicStyle" className="muted">
Music style {t("musicStyleLabel")}
</label> </label>
<input <input
id="musicStyle" id="musicStyle"
name="musicStyle" name="musicStyle"
type="text" type="text"
maxLength={255} maxLength={255}
placeholder="e.g. House, Pop, Hip-Hop" placeholder={t("musicStylePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }} style={{ width: "100%", marginTop: "0.3rem" }}
/> />
</div> </div>
<div style={{ marginTop: "1rem" }}> <div style={{ marginTop: "1rem" }}>
<label htmlFor="experience" className="muted"> <label htmlFor="experience" className="muted">
Experience <span className="muted">(optional)</span> {t("experienceLabel")} <span className="muted">{t("optional")}</span>
</label> </label>
<textarea <textarea
id="experience" id="experience"
name="experience" name="experience"
rows={3} rows={3}
placeholder="Any prior DJ or hosting experience?" placeholder={t("experiencePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }} style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/> />
</div> </div>
<div style={{ marginTop: "1rem" }}> <div style={{ marginTop: "1rem" }}>
<label htmlFor="motivation" className="muted"> <label htmlFor="motivation" className="muted">
Motivation {t("motivationLabel")}
</label> </label>
<textarea <textarea
id="motivation" id="motivation"
name="motivation" name="motivation"
required required
rows={4} rows={4}
placeholder="Why do you want to join the radio team?" placeholder={t("motivationPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }} style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/> />
</div> </div>
<button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}> <button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}>
Submit application {t("submitButton")}
</button> </button>
</form> </form>
</ContentCard> </ContentCard>
+8 -5
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -9,6 +10,8 @@ function formatDate(d: Date | null | undefined): string {
} }
export default async function RadioContestsPage() { export default async function RadioContestsPage() {
const t = await getTranslations("pages.radioContests");
// RadioContests.id is a BigInt — stringify before use in keys/routes. // RadioContests.id is a BigInt — stringify before use in keys/routes.
const contests = await prisma.radioContests const contests = await prisma.radioContests
.findMany({ .findMany({
@@ -21,13 +24,13 @@ export default async function RadioContestsPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🎉" icon="🎉"
title="Radio Contests" title={t("title")}
subtitle="Tune in and take part in our latest on-air contests" subtitle={t("subtitle")}
/> />
<ContentCard padded={contests.length === 0}> <ContentCard padded={contests.length === 0}>
{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" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{contests.map((c) => { {contests.map((c) => {
@@ -40,10 +43,10 @@ export default async function RadioContestsPage() {
style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }} style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }}
> >
<h3 style={{ margin: 0, fontSize: "1rem" }}> <h3 style={{ margin: 0, fontSize: "1rem" }}>
<span aria-hidden>🎉</span> Contest #{id} <span aria-hidden>🎉</span> {t("contestLabel", { id })}
</h3> </h3>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{formatDate(c.createdAt) || "Date unknown"} {formatDate(c.createdAt) || t("dateUnknown")}
</p> </p>
</Link> </Link>
); );
+8 -5
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -9,6 +10,8 @@ function formatDate(d: Date | null | undefined): string {
} }
export default async function RadioGiveawaysPage() { export default async function RadioGiveawaysPage() {
const t = await getTranslations("pages.radioGiveaways");
// RadioGiveaways.id is a BigInt — stringify before use in keys/routes. // RadioGiveaways.id is a BigInt — stringify before use in keys/routes.
const giveaways = await prisma.radioGiveaways const giveaways = await prisma.radioGiveaways
.findMany({ .findMany({
@@ -21,13 +24,13 @@ export default async function RadioGiveawaysPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🎁" icon="🎁"
title="Radio Giveaways" title={t("title")}
subtitle="Listen in for your chance to win in our radio giveaways" subtitle={t("subtitle")}
/> />
<ContentCard padded={giveaways.length === 0}> <ContentCard padded={giveaways.length === 0}>
{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" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{giveaways.map((g) => { {giveaways.map((g) => {
@@ -40,10 +43,10 @@ export default async function RadioGiveawaysPage() {
style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }} style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }}
> >
<h3 style={{ margin: 0, fontSize: "1rem" }}> <h3 style={{ margin: 0, fontSize: "1rem" }}>
<span aria-hidden>🎁</span> Giveaway #{id} <span aria-hidden>🎁</span> {t("giveawayLabel", { id })}
</h3> </h3>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{formatDate(g.createdAt) || "Date unknown"} {formatDate(g.createdAt) || t("dateUnknown")}
</p> </p>
</Link> </Link>
); );
+10 -7
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
@@ -38,6 +39,8 @@ async function loadRows(): Promise<{ username: string; look: string; points: num
} }
export default async function RadioLeaderboardPage() { export default async function RadioLeaderboardPage() {
const t = await getTranslations("pages.radioLeaderboard");
const [rows, imagerBase] = await Promise.all([ const [rows, imagerBase] = await Promise.all([
loadRows(), loadRows(),
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"), 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" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🏆" icon="🏆"
title="Radio Listener Leaderboard" title={t("title")}
subtitle="Top 50 listeners by points earned tuning in to the radio" subtitle={t("subtitle")}
/> />
<ContentCard padded={rows.length === 0}> <ContentCard padded={rows.length === 0}>
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyState icon="🏆">No listener points to show yet.</EmptyState> <EmptyState icon="🏆">{t("empty")}</EmptyState>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th style={{ width: "3.5rem" }}>Rank</th> <th style={{ width: "3.5rem" }}>{t("colRank")}</th>
<th style={{ width: "3.5rem" }}>Avatar</th> <th style={{ width: "3.5rem" }}>{t("colAvatar")}</th>
<th>Listener</th> <th>{t("colListener")}</th>
<th style={{ textAlign: "right" }}>Points</th> <th style={{ textAlign: "right" }}>{t("colPoints")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
+30 -27
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; 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. // Radio config + today's schedule are read live from the DB / settings table.
export const dynamic = "force-dynamic"; 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 // Canonical weekday order so today's slots come out chronologically and we can
// match the current day against whatever casing the `day` column stores. // match the current day against whatever casing the `day` column stores.
const WEEKDAYS = [ const WEEKDAYS = [
@@ -36,6 +28,17 @@ function formatTime(d: Date | null | undefined): string {
} }
export default async function RadioPage() { 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) ── // ── Radio config from website_settings (graceful fallbacks) ──
const [streamUrl, currentDj, currentSong] = await Promise.all([ const [streamUrl, currentDj, currentSong] = await Promise.all([
siteSettings.get("radio_stream_url", ""), siteSettings.get("radio_stream_url", ""),
@@ -77,20 +80,20 @@ export default async function RadioPage() {
{/* ── Header: live stream + now playing ─────────────────────── */} {/* ── Header: live stream + now playing ─────────────────────── */}
<ContentCard <ContentCard
icon="📻" icon="📻"
title="Radio" title={t("title")}
subtitle="Tune in to the hotel radio — live DJ sets, requests and shoutouts." subtitle={t("subtitle")}
action={<OnlineBadge online={isLive} />} action={<OnlineBadge online={isLive} />}
> >
<div className="card-grid sm-2" style={{ alignItems: "stretch" }}> <div className="card-grid sm-2" style={{ alignItems: "stretch" }}>
<div className="card" style={{ display: "grid", gap: "0.35rem", alignContent: "center" }}> <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" }}> <p className="muted" style={{ margin: 0, fontSize: "0.8rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Now playing {t("nowPlaying")}
</p> </p>
<p style={{ margin: 0, fontSize: "1.1rem", fontWeight: 700 }}> <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>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{currentDj ? `On air: ${currentDj}` : "No DJ on air."} {currentDj ? t("onAir", { dj: currentDj }) : t("noDj")}
</p> </p>
</div> </div>
<div className="card" style={{ display: "flex", alignItems: "center" }}> <div className="card" style={{ display: "flex", alignItems: "center" }}>
@@ -101,11 +104,11 @@ export default async function RadioPage() {
style={{ width: "100%" }} style={{ width: "100%" }}
preload="none" preload="none"
> >
Your browser does not support the audio element. {t("audioUnsupported")}
</audio> </audio>
) : ( ) : (
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
The stream is currently offline. {t("streamOffline")}
</p> </p>
)} )}
</div> </div>
@@ -115,8 +118,8 @@ export default async function RadioPage() {
{/* ── Sub-page links ──────────────────────────────────────── */} {/* ── Sub-page links ──────────────────────────────────────── */}
<ContentCard <ContentCard
icon="🎚️" icon="🎚️"
title="Explore the radio" title={t("exploreTitle")}
subtitle="Schedule, shouts, requests and more" subtitle={t("exploreSubtitle")}
> >
<div className="card-grid sm-2 lg-3"> <div className="card-grid sm-2 lg-3">
{RADIO_LINKS.map((l) => ( {RADIO_LINKS.map((l) => (
@@ -140,24 +143,24 @@ export default async function RadioPage() {
{/* ── Today's schedule ────────────────────────────────────── */} {/* ── Today's schedule ────────────────────────────────────── */}
<ContentCard <ContentCard
icon="🗓️" icon="🗓️"
title={`Today's schedule${today ? ` · ${today}` : ""}`} title={today ? t("todayTitleWithDay", { day: today }) : t("todayTitle")}
subtitle="What's on the airwaves today" subtitle={t("todaySubtitle")}
action={ action={
<Link className="btn btn-outline" href="/radio/schedule"> <Link className="btn btn-outline" href="/radio/schedule">
Full week → {t("fullWeek")}
</Link> </Link>
} }
padded={todaysSlots.length === 0} padded={todaysSlots.length === 0}
> >
{todaysSlots.length === 0 ? ( {todaysSlots.length === 0 ? (
<EmptyState icon="📻">No shows scheduled for today.</EmptyState> <EmptyState icon="📻">{t("noShowsToday")}</EmptyState>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Time</th> <th>{t("colTime")}</th>
<th>Show</th> <th>{t("colShow")}</th>
<th>DJ</th> <th>{t("colDj")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -169,13 +172,13 @@ export default async function RadioPage() {
{formatTime(s.startTime)} &ndash; {formatTime(s.endTime)} {formatTime(s.startTime)} &ndash; {formatTime(s.endTime)}
</td> </td>
<td> <td>
{s.showName || <span className="muted">Untitled show</span>} {s.showName || <span className="muted">{t("untitledShow")}</span>}
</td> </td>
<td> <td>
{djName ? ( {djName ? (
<Link href={`/u/${djName}`}>{djName}</Link> <Link href={`/u/${djName}`}>{djName}</Link>
) : ( ) : (
<span className="muted">{`User #${s.userId}`}</span> <span className="muted">{t("userNumber", { id: String(s.userId) })}</span>
)} )}
</td> </td>
</tr> </tr>
+11 -8
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -32,6 +33,8 @@ type Slot = {
}; };
export default async function RadioSchedulePage() { export default async function RadioSchedulePage() {
const t = await getTranslations("pages.radioSchedule");
const schedules = await prisma.radioSchedules const schedules = await prisma.radioSchedules
.findMany({ .findMany({
where: { isActive: true }, where: { isActive: true },
@@ -76,21 +79,21 @@ export default async function RadioSchedulePage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🗓️" icon="🗓️"
title="Radio Schedule" title={t("title")}
subtitle="Our weekly line-up of DJs and shows" subtitle={t("subtitle")}
/> />
<ContentCard padded={!hasAny}> <ContentCard padded={!hasAny}>
{!hasAny ? ( {!hasAny ? (
<EmptyState icon="🗓️">No shows scheduled yet.</EmptyState> <EmptyState icon="🗓️">{t("empty")}</EmptyState>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Day</th> <th>{t("colDay")}</th>
<th>Time</th> <th>{t("colTime")}</th>
<th>DJ</th> <th>{t("colDj")}</th>
<th>Show</th> <th>{t("colShow")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -103,7 +106,7 @@ export default async function RadioSchedulePage() {
<strong>{day}</strong> <strong>{day}</strong>
</td> </td>
<td colSpan={3} className="muted"> <td colSpan={3} className="muted">
No shows {t("noShows")}
</td> </td>
</tr> </tr>
); );
+13 -10
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
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 { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
@@ -12,6 +13,8 @@ function formatDate(d: Date | null | undefined): string {
} }
export default async function RadioShoutsPage() { export default async function RadioShoutsPage() {
const t = await getTranslations("pages.radioShouts");
const [shouts, imagerBase, session] = await Promise.all([ const [shouts, imagerBase, session] = await Promise.all([
prisma.radioShouts prisma.radioShouts
.findMany({ .findMany({
@@ -44,17 +47,17 @@ export default async function RadioShoutsPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="📣" icon="📣"
title="Radio Shouts" title={t("title")}
subtitle="Shout-outs and requests from the community" subtitle={t("subtitle")}
/> />
{isLoggedIn ? ( {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 {/* The author is taken from the session inside the action, never from
this form — no user id is submitted. */} this form — no user id is submitted. */}
<form action={postShout}> <form action={postShout}>
<label htmlFor="shout-message" className="muted"> <label htmlFor="shout-message" className="muted">
Your message {t("messageLabel")}
</label> </label>
<textarea <textarea
id="shout-message" id="shout-message"
@@ -62,28 +65,28 @@ export default async function RadioShoutsPage() {
required required
maxLength={255} maxLength={255}
rows={3} rows={3}
placeholder="Send a shout to the DJ…" placeholder={t("messagePlaceholder")}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }} style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Shout {t("shoutButton")}
</button> </button>
</form> </form>
</ContentCard> </ContentCard>
) : ( ) : (
<ContentCard padded> <ContentCard padded>
<EmptyState icon="🔒">Log in to post a shout.</EmptyState> <EmptyState icon="🔒">{t("loginToPost")}</EmptyState>
</ContentCard> </ContentCard>
)} )}
<ContentCard <ContentCard
icon="💬" icon="💬"
title="Latest shouts" title={t("latestTitle")}
subtitle="The most recent messages from listeners" subtitle={t("latestSubtitle")}
padded={shouts.length === 0} padded={shouts.length === 0}
> >
{shouts.length === 0 ? ( {shouts.length === 0 ? (
<EmptyState icon="📣">No shouts yet. Be the first!</EmptyState> <EmptyState icon="📣">{t("empty")}</EmptyState>
) : ( ) : (
<div className="card-grid"> <div className="card-grid">
{shouts.map((s) => { {shouts.map((s) => {
+4 -2
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState, OnlineBadge, RankBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, OnlineBadge, RankBadge } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
@@ -17,6 +18,7 @@ type TopUser = {
}; };
export default async function RankingsPage() { export default async function RankingsPage() {
const t = await getTranslations("pages.rankings");
let users: TopUser[] = []; let users: TopUser[] = [];
try { try {
users = await prisma.user.findMany({ users = await prisma.user.findMany({
@@ -35,11 +37,11 @@ export default async function RankingsPage() {
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🏆" title="Rankings" subtitle="The top 12 players ranked by credits" /> <ContentCard icon="🏆" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={users.length === 0}> <ContentCard padded={users.length === 0}>
{users.length === 0 ? ( {users.length === 0 ? (
<EmptyState icon="🏆">No players to rank yet.</EmptyState> <EmptyState icon="🏆">{t("emptyState")}</EmptyState>
) : ( ) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{users.map((u, i) => { {users.map((u, i) => {
+16 -12
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -16,17 +17,19 @@ type RareRow = {
itemId: number | null; itemId: number | null;
}; };
function prettyCurrency(type: string): string { type CurrencyTranslator = (key: string) => string;
function prettyCurrency(type: string, t: CurrencyTranslator): string {
switch (type) { switch (type) {
case "0": case "0":
case "duckets": case "duckets":
return "Duckets"; return t("duckets");
case "5": case "5":
case "diamonds": case "diamonds":
return "Diamonds"; return t("diamonds");
case "-1": case "-1":
case "credits": case "credits":
return "Credits"; return t("credits");
default: default:
return type.charAt(0).toUpperCase() + type.slice(1); return type.charAt(0).toUpperCase() + type.slice(1);
} }
@@ -37,6 +40,7 @@ export default async function RareCategoryPage({
}: { }: {
params: Promise<{ category: string }>; params: Promise<{ category: string }>;
}) { }) {
const t = await getTranslations("pages.rareCategory");
const { category } = await params; const { category } = await params;
let categoryId: bigint; let categoryId: bigint;
@@ -98,25 +102,25 @@ export default async function RareCategoryPage({
{cat.name} {cat.name}
</span> </span>
} }
subtitle={`${rares.length} ${rares.length === 1 ? "rare" : "rares"} in this category`} subtitle={t("rareCount", { count: rares.length })}
action={ action={
<Link className="btn btn-outline" href="/rares"> <Link className="btn btn-outline" href="/rares">
← All rares {t("allRares")}
</Link> </Link>
} }
padded={rares.length === 0} padded={rares.length === 0}
> >
{rares.length === 0 ? ( {rares.length === 0 ? (
<EmptyState icon="💎">No rares listed in this category yet.</EmptyState> <EmptyState icon="💎">{t("empty")}</EmptyState>
) : ( ) : (
<div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}> <div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
<th style={{ width: 56 }}></th> <th style={{ width: 56 }}></th>
<th>Rare</th> <th>{t("colRare")}</th>
<th>Credits</th> <th>{t("colCredits")}</th>
<th>Currency</th> <th>{t("colCurrency")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -147,14 +151,14 @@ export default async function RareCategoryPage({
</td> </td>
<td> <td>
{r.creditValue && r.creditValue !== "" ? ( {r.creditValue && r.creditValue !== "" ? (
`${r.creditValue} credits` t("creditsValue", { value: r.creditValue })
) : ( ) : (
<span className="muted">—</span> <span className="muted">—</span>
)} )}
</td> </td>
<td> <td>
{r.currencyValue && r.currencyValue !== "" ? ( {r.currencyValue && r.currencyValue !== "" ? (
`${r.currencyValue} ${prettyCurrency(r.currencyType)}` `${r.currencyValue} ${prettyCurrency(r.currencyType, t)}`
) : ( ) : (
<span className="muted">—</span> <span className="muted">—</span>
)} )}
+7 -5
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -15,6 +16,7 @@ type CategoryRow = {
}; };
export default async function RareValuesPage() { export default async function RareValuesPage() {
const t = await getTranslations("pages.rares");
let categories: CategoryRow[] = []; let categories: CategoryRow[] = [];
try { try {
categories = await prisma.websiteRareValueCategories.findMany({ categories = await prisma.websiteRareValueCategories.findMany({
@@ -45,13 +47,13 @@ export default async function RareValuesPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="💎" icon="💎"
title="Rare values" title={t("title")}
subtitle="Get an overview of all of the rares on the hotel, grouped by category." subtitle={t("subtitle")}
/> />
{categories.length === 0 ? ( {categories.length === 0 ? (
<ContentCard padded={false}> <ContentCard padded={false}>
<EmptyState icon="💎">We currently have no rares listed here.</EmptyState> <EmptyState icon="💎">{t("empty")}</EmptyState>
</ContentCard> </ContentCard>
) : ( ) : (
<div className="card-grid sm-2 lg-3"> <div className="card-grid sm-2 lg-3">
@@ -84,12 +86,12 @@ export default async function RareValuesPage() {
<div className="content-card-head-text"> <div className="content-card-head-text">
<p className="content-card-title">{c.name}</p> <p className="content-card-title">{c.name}</p>
<p className="content-card-subtitle"> <p className="content-card-subtitle">
{count} {count === 1 ? "rare" : "rares"} {t("rareCount", { count })}
</p> </p>
</div> </div>
</div> </div>
<div className="content-card-body"> <div className="content-card-body">
<span className="btn btn-outline">View rares →</span> <span className="btn btn-outline">{t("viewRares")}</span>
</div> </div>
</Link> </Link>
); );
+13 -7
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import RedeemForm from "./RedeemForm"; import RedeemForm from "./RedeemForm";
import { ContentCard, StatBlock } from "@/components/public/ui"; import { ContentCard, StatBlock } from "@/components/public/ui";
@@ -10,6 +11,7 @@ export default async function RedeemPage() {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.redeem");
const id = Number(session.user.id); const id = Number(session.user.id);
// Show the signed-in user's current credit balance for context (fail soft). // Show the signed-in user's current credit balance for context (fail soft).
@@ -29,24 +31,28 @@ export default async function RedeemPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🎟️" icon="🎟️"
title="Redeem a voucher" title={t("title")}
subtitle="Got a voucher code? Enter it below to add the reward to your account." subtitle={t("subtitle")}
/> />
<div className="card-grid sm-2"> <div className="card-grid sm-2">
<ContentCard icon="🔑" title="Enter your code"> <ContentCard icon="🔑" title={t("enterCode")}>
<RedeemForm /> <RedeemForm />
<p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}> <p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
Each voucher can only be redeemed once per account. {t("oncePerAccount")}
</p> </p>
</ContentCard> </ContentCard>
<ContentCard icon="💰" title="Your balance" subtitle={`Signed in as ${user.username}`}> <ContentCard
icon="💰"
title={t("balanceTitle")}
subtitle={t("signedInAs", { username: user.username })}
>
<div className="stat-grid"> <div className="stat-grid">
<StatBlock value={user.credits.toLocaleString()} label="Credits" icon="🪙" /> <StatBlock value={user.credits.toLocaleString()} label={t("credits")} icon="🪙" />
</div> </div>
<p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}> <p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
Redeemed rewards are credited instantly and appear in-game the next time you log in. {t("instantCredit")}
</p> </p>
</ContentCard> </ContentCard>
</div> </div>
+9 -7
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { register } from "@/actions/register"; import { register } from "@/actions/register";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
@@ -7,27 +8,28 @@ export default async function RegisterPage({
}: { }: {
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}) { }) {
const t = await getTranslations("pages.register");
const { error } = await searchParams; const { error } = await searchParams;
return ( return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}> <main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard <ContentCard
icon="🎉" icon="🎉"
title="Create account" title={t("title")}
subtitle="Join the hotel — it only takes a moment." subtitle={t("subtitle")}
> >
<form action={register} style={{ display: "grid", gap: "0.7rem" }}> <form action={register} style={{ display: "grid", gap: "0.7rem" }}>
<input name="username" placeholder="Username" autoComplete="username" required /> <input name="username" placeholder={t("usernamePlaceholder")} autoComplete="username" required />
<input name="mail" type="email" placeholder="Email" autoComplete="email" required /> <input name="mail" type="email" placeholder={t("emailPlaceholder")} autoComplete="email" required />
<input <input
name="password" name="password"
type="password" type="password"
placeholder="Password (min 6 chars)" placeholder={t("passwordPlaceholder")}
autoComplete="new-password" autoComplete="new-password"
required required
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Register {t("register")}
</button> </button>
</form> </form>
{error ? ( {error ? (
@@ -36,7 +38,7 @@ export default async function RegisterPage({
</p> </p>
) : null} ) : null}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}> <p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
Already have an account? <Link href="/login">Sign in</Link> {t("alreadyHaveAccount")} <Link href="/login">{t("signIn")}</Link>
</p> </p>
</ContentCard> </ContentCard>
</main> </main>
+7 -5
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { resetPassword } from "@/actions/password-reset"; import { resetPassword } from "@/actions/password-reset";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
@@ -7,14 +8,15 @@ export default async function ResetPage({
}: { }: {
searchParams: Promise<{ email?: string; token?: string; error?: string }>; searchParams: Promise<{ email?: string; token?: string; error?: string }>;
}) { }) {
const t = await getTranslations("pages.reset");
const { email = "", token = "", error } = await searchParams; const { email = "", token = "", error } = await searchParams;
return ( return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}> <main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard <ContentCard
icon="🔐" icon="🔐"
title="Set a new password" title={t("title")}
subtitle="Choose a strong password you don't use elsewhere." subtitle={t("subtitle")}
> >
<form action={resetPassword} style={{ display: "grid", gap: "0.7rem" }}> <form action={resetPassword} style={{ display: "grid", gap: "0.7rem" }}>
<input type="hidden" name="email" value={email} /> <input type="hidden" name="email" value={email} />
@@ -22,12 +24,12 @@ export default async function ResetPage({
<input <input
name="password" name="password"
type="password" type="password"
placeholder="New password (min 6 chars)" placeholder={t("passwordPlaceholder")}
autoComplete="new-password" autoComplete="new-password"
required required
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Reset password {t("resetPassword")}
</button> </button>
</form> </form>
{error ? ( {error ? (
@@ -36,7 +38,7 @@ export default async function ResetPage({
</p> </p>
) : null} ) : null}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}> <p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
<Link href="/login">Back to login</Link> <Link href="/login">{t("backToLogin")}</Link>
</p> </p>
</ContentCard> </ContentCard>
</main> </main>
+19 -17
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { beginTwoFactor, confirmTwoFactor, disableTwoFactor } from "@/actions/twofactor"; import { beginTwoFactor, confirmTwoFactor, disableTwoFactor } from "@/actions/twofactor";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
@@ -16,6 +17,7 @@ export default async function TwoFactorPage({
}: { }: {
searchParams: Promise<{ error?: string; enabled?: string; disabled?: string }>; searchParams: Promise<{ error?: string; enabled?: string; disabled?: string }>;
}) { }) {
const t = await getTranslations("pages.settings2fa");
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const sp = await searchParams; const sp = await searchParams;
@@ -50,52 +52,52 @@ export default async function TwoFactorPage({
return ( return (
<main style={{ display: "grid", gap: "1.5rem", maxWidth: 560, margin: "0 auto" }}> <main style={{ display: "grid", gap: "1.5rem", maxWidth: 560, margin: "0 auto" }}>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
<Link href="/settings">← Settings</Link> <Link href="/settings">{t("backToSettings")}</Link>
</p> </p>
<ContentCard <ContentCard
icon="🔒" icon="🔒"
title="Two-factor authentication" title={t("title")}
subtitle="Add a second layer of security with an authenticator app" subtitle={t("subtitle")}
> >
{sp.enabled ? ( {sp.enabled ? (
<p style={{ color: "var(--color-accent)", marginTop: 0 }}>2FA is now enabled. 🔒</p> <p style={{ color: "var(--color-accent)", marginTop: 0 }}>{t("nowEnabled")}</p>
) : null} ) : null}
{sp.disabled ? ( {sp.disabled ? (
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
2FA has been disabled. {t("disabledNotice")}
</p> </p>
) : null} ) : null}
{sp.error === "badcode" ? ( {sp.error === "badcode" ? (
<p style={{ color: "var(--color-danger)", marginTop: 0 }}> <p style={{ color: "var(--color-danger)", marginTop: 0 }}>
That code wasn't valid — try again. {t("badCode")}
</p> </p>
) : null} ) : null}
{!hasAppKey ? ( {!hasAppKey ? (
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
2FA is unavailable until <code>APP_KEY</code> is configured (the same Laravel {t("noAppKeyPrefix")} <code>APP_KEY</code> {t("noAppKeyMiddle")}
<code> APP_KEY</code> as your AtomCMS install, so existing secrets stay readable). <code> APP_KEY</code> {t("noAppKeySuffix")}
</p> </p>
) : enabled ? ( ) : enabled ? (
<> <>
<p style={{ marginTop: 0 }}> <p style={{ marginTop: 0 }}>
<strong>2FA is enabled</strong> on your account. <strong>{t("isEnabled")}</strong> {t("onYourAccount")}
</p> </p>
<form action={disableTwoFactor}> <form action={disableTwoFactor}>
<button type="submit" className="btn btn-danger"> <button type="submit" className="btn btn-danger">
Disable 2FA {t("disable")}
</button> </button>
</form> </form>
</> </>
) : pending ? ( ) : pending ? (
<> <>
<h3 style={{ marginTop: 0 }}>Scan or enter this key</h3> <h3 style={{ marginTop: 0 }}>{t("scanOrEnter")}</h3>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Add this to Google Authenticator / Authy, then enter the 6-digit code to confirm. {t("scanInstructions")}
</p> </p>
<p> <p>
Manual key: <code style={{ userSelect: "all" }}>{secret}</code> {t("manualKey")} <code style={{ userSelect: "all" }}>{secret}</code>
</p> </p>
<p className="muted" style={{ wordBreak: "break-all", fontSize: "0.8rem" }}> <p className="muted" style={{ wordBreak: "break-all", fontSize: "0.8rem" }}>
{uri} {uri}
@@ -104,20 +106,20 @@ export default async function TwoFactorPage({
action={confirmTwoFactor} action={confirmTwoFactor}
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }} style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
> >
<input name="code" placeholder="6-digit code" inputMode="numeric" required /> <input name="code" placeholder={t("codePlaceholder")} inputMode="numeric" required />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Confirm {t("confirm")}
</button> </button>
</form> </form>
</> </>
) : ( ) : (
<> <>
<p style={{ marginTop: 0 }}> <p style={{ marginTop: 0 }}>
Add a second layer of security with an authenticator app. {t("enableIntro")}
</p> </p>
<form action={beginTwoFactor}> <form action={beginTwoFactor}>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Enable 2FA {t("enable")}
</button> </button>
</form> </form>
</> </>
+14 -14
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
@@ -27,6 +28,7 @@ const PROVIDERS = [
] as const; ] as const;
export default async function ConnectionsPage() { export default async function ConnectionsPage() {
const t = await getTranslations("pages.connections");
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
@@ -53,23 +55,22 @@ export default async function ConnectionsPage() {
return ( return (
<main style={{ display: "grid", gap: "1.5rem", maxWidth: 620, margin: "0 auto" }}> <main style={{ display: "grid", gap: "1.5rem", maxWidth: 620, margin: "0 auto" }}>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
<Link href="/settings">← Settings</Link> <Link href="/settings">{t("backToSettings")}</Link>
</p> </p>
<ContentCard <ContentCard
icon="🔗" icon="🔗"
title="Connected accounts" title={t("title")}
subtitle="Link a social account so you can sign in with one click" subtitle={t("subtitle")}
> >
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
We match it to your hotel account by e-mail, so use the address on file:{" "} {t("matchByEmail")}{" "}
<strong>{session.user.name}</strong>. <strong>{session.user.name}</strong>.
</p> </p>
{lookupFailed ? ( {lookupFailed ? (
<p className="muted" style={{ marginBottom: 0 }}> <p className="muted" style={{ marginBottom: 0 }}>
We couldn't read your linked accounts right now. You can still connect below — your {t("lookupFailed")}
status will show once it's available.
</p> </p>
) : null} ) : null}
</ContentCard> </ContentCard>
@@ -84,10 +85,10 @@ export default async function ConnectionsPage() {
title={p.label} title={p.label}
subtitle={ subtitle={
!p.available !p.available
? "Not available on this hotel." ? t("statusUnavailable")
: isLinked : isLinked
? "Connected — sign in with this account." ? t("statusConnected")
: "Not connected." : t("statusNotConnected")
} }
> >
{isLinked ? ( {isLinked ? (
@@ -96,7 +97,7 @@ export default async function ConnectionsPage() {
style={{ cursor: "default", pointerEvents: "none" }} style={{ cursor: "default", pointerEvents: "none" }}
aria-disabled="true" aria-disabled="true"
> >
Connected {t("connected")}
</span> </span>
) : p.available ? ( ) : p.available ? (
// Plain link into NextAuth's built-in sign-in handler. Re-using // Plain link into NextAuth's built-in sign-in handler. Re-using
@@ -108,7 +109,7 @@ export default async function ConnectionsPage() {
callbackUrl, callbackUrl,
)}`} )}`}
> >
Connect {p.label} {t("connectProvider", { provider: p.label })}
</a> </a>
) : ( ) : (
<span <span
@@ -116,7 +117,7 @@ export default async function ConnectionsPage() {
style={{ cursor: "not-allowed", opacity: 0.55, pointerEvents: "none" }} style={{ cursor: "not-allowed", opacity: 0.55, pointerEvents: "none" }}
aria-disabled="true" aria-disabled="true"
> >
Unavailable {t("unavailable")}
</span> </span>
)} )}
</ContentCard> </ContentCard>
@@ -125,8 +126,7 @@ export default async function ConnectionsPage() {
</div> </div>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
Connecting takes you to the provider to authorise, then back here. If your social account's {t("footerNote")}
e-mail doesn't match a hotel account, you'll be returned to the login screen with a note.
</p> </p>
</main> </main>
); );
+16 -14
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { updateMotto } from "@/actions/user-settings"; import { updateMotto } from "@/actions/user-settings";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
@@ -10,6 +11,7 @@ import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function SettingsPage() { export default async function SettingsPage() {
const t = await getTranslations("pages.settings");
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
@@ -46,11 +48,11 @@ export default async function SettingsPage() {
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="⚙️" icon="⚙️"
title="Account settings" title={t("accountSettings")}
subtitle="Manage your profile, security and connected accounts" subtitle={t("accountSettingsSubtitle")}
/> />
<ContentCard icon="🙂" title={user.username} subtitle="Your public profile"> <ContentCard icon="🙂" title={user.username} subtitle={t("publicProfile")}>
<div style={{ display: "flex", gap: "1.5rem", alignItems: "center", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "1.5rem", alignItems: "center", flexWrap: "wrap" }}>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
@@ -62,16 +64,16 @@ export default async function SettingsPage() {
/> />
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<p style={{ margin: "0 0 0.5rem", fontSize: "1.05rem" }}> <p style={{ margin: "0 0 0.5rem", fontSize: "1.05rem" }}>
{user.motto || <span className="muted">No motto</span>} {user.motto || <span className="muted">{t("noMotto")}</span>}
</p> </p>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{user.mail || "No e-mail on file"} {user.mail || t("noEmail")}
</p> </p>
</div> </div>
</div> </div>
</ContentCard> </ContentCard>
<ContentCard icon="✏️" title="Change motto" subtitle="Updates instantly in-game if you are online"> <ContentCard icon="✏️" title={t("changeMotto")} subtitle={t("changeMottoSubtitle")}>
<form <form
action={updateMotto} action={updateMotto}
style={{ display: "flex", flexDirection: "column", gap: "0.75rem", maxWidth: 520 }} style={{ display: "flex", flexDirection: "column", gap: "0.75rem", maxWidth: 520 }}
@@ -80,33 +82,33 @@ export default async function SettingsPage() {
name="motto" name="motto"
defaultValue={user.motto} defaultValue={user.motto}
maxLength={127} maxLength={127}
placeholder="Your motto" placeholder={t("mottoPlaceholder")}
aria-label="Motto" aria-label="Motto"
/> />
<div> <div>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Save motto {t("saveMotto")}
</button> </button>
</div> </div>
</form> </form>
</ContentCard> </ContentCard>
<div className="card-grid sm-2"> <div className="card-grid sm-2">
<ContentCard icon="🔒" title="Security" subtitle="Two-factor authentication"> <ContentCard icon="🔒" title={t("securityTitle")} subtitle={t("securitySubtitle")}>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Add a second layer of protection to your account. {t("securityDescription")}
</p> </p>
<Link href="/settings/2fa" className="btn btn-outline"> <Link href="/settings/2fa" className="btn btn-outline">
Two-factor authentication {t("twoFactorLink")}
</Link> </Link>
</ContentCard> </ContentCard>
<ContentCard icon="🔗" title="Connected accounts" subtitle="Sign in with one click"> <ContentCard icon="🔗" title={t("connectionsTitle")} subtitle={t("connectionsSubtitle")}>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Link Discord or Google to your hotel account. {t("connectionsDescription")}
</p> </p>
<Link href="/settings/connections" className="btn btn-outline"> <Link href="/settings/connections" className="btn btn-outline">
Manage connections {t("manageConnections")}
</Link> </Link>
</ContentCard> </ContentCard>
</div> </div>
+10 -8
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
@@ -53,6 +54,7 @@ export default async function ShopPage({
}: { }: {
searchParams: Promise<{ category?: string }>; searchParams: Promise<{ category?: string }>;
}) { }) {
const t = await getTranslations("pages.shop");
const sp = await searchParams; const sp = await searchParams;
const rawCategory = (sp.category ?? "").trim(); const rawCategory = (sp.category ?? "").trim();
const categoryId = /^\d+$/.test(rawCategory) ? rawCategory : ""; const categoryId = /^\d+$/.test(rawCategory) ? rawCategory : "";
@@ -110,11 +112,11 @@ export default async function ShopPage({
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="🛒" icon="🛒"
title="Shop" title={t("title")}
subtitle={ subtitle={
activeCategory activeCategory
? `Browsing the ${activeCategory.name} packages.` ? t("browsingCategory", { category: activeCategory.name })
: "Top up your account and grab exclusive packages, currency and badges." : t("subtitle")
} }
> >
{categories.length > 0 ? ( {categories.length > 0 ? (
@@ -126,7 +128,7 @@ export default async function ShopPage({
}} }}
> >
<Link href="/shop" className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}> <Link href="/shop" className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}>
All {t("all")}
</Link> </Link>
{categories.map((c) => ( {categories.map((c) => (
<Link <Link
@@ -143,16 +145,16 @@ export default async function ShopPage({
<ContentCard <ContentCard
icon="📦" icon="📦"
title={activeCategory ? activeCategory.name : "All packages"} title={activeCategory ? activeCategory.name : t("allPackages")}
subtitle={ subtitle={
articles.length > 0 articles.length > 0
? `${articles.length} ${articles.length === 1 ? "package" : "packages"} available` ? t("packagesAvailable", { count: articles.length })
: undefined : undefined
} }
padded={articles.length === 0} padded={articles.length === 0}
> >
{articles.length === 0 ? ( {articles.length === 0 ? (
<EmptyState icon="🛒">No packages available right now.</EmptyState> <EmptyState icon="🛒">{t("empty")}</EmptyState>
) : ( ) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{articles.map((a) => ( {articles.map((a) => (
@@ -237,7 +239,7 @@ export default async function ShopPage({
> >
<span style={{ fontWeight: 800 }}>{priceLabel(a.costs)}</span> <span style={{ fontWeight: 800 }}>{priceLabel(a.costs)}</span>
<span className="btn btn-primary" aria-disabled="true"> <span className="btn btn-primary" aria-disabled="true">
Buy {t("buy")}
</span> </span>
</div> </div>
</article> </article>
+25 -12
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import TopUpForm from "./TopUpForm"; import TopUpForm from "./TopUpForm";
import { ContentCard, StatBlock } from "@/components/public/ui"; import { ContentCard, StatBlock } from "@/components/public/ui";
@@ -15,6 +16,7 @@ export default async function TopUpPage({
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.topup");
const sp = await searchParams; const sp = await searchParams;
const id = Number(session.user.id); const id = Number(session.user.id);
@@ -37,37 +39,48 @@ export default async function TopUpPage({
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard <ContentCard
icon="💳" icon="💳"
title="Top up with PayPal" title={t("title")}
subtitle="Pay securely with PayPal and your credits are added the moment the payment clears." subtitle={t("subtitle")}
/> />
{sp.status === "cancel" ? ( {sp.status === "cancel" ? (
<div className="card" style={{ borderColor: "var(--color-danger)" }}> <div className="card" style={{ borderColor: "var(--color-danger)" }}>
<p style={{ margin: 0 }}>Checkout was cancelled — no payment was taken.</p> <p style={{ margin: 0 }}>{t("cancelled")}</p>
</div> </div>
) : null} ) : null}
<div className="card-grid sm-2"> <div className="card-grid sm-2">
<ContentCard icon="🪙" title="Choose an amount"> <ContentCard icon="🪙" title={t("chooseAmount")}>
{configured ? ( {configured ? (
<TopUpForm currency={PAYPAL_CURRENCY} creditsPerUnit={rate} /> <TopUpForm currency={PAYPAL_CURRENCY} creditsPerUnit={rate} />
) : ( ) : (
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
PayPal isn&apos;t configured yet. An administrator needs to set{" "} {t("notConfiguredPre")} <code>PAYPAL_CLIENT_ID</code>{" "}
<code>PAYPAL_CLIENT_ID</code> and <code>PAYPAL_SECRET</code> (and optionally{" "} {t("notConfiguredAnd")} <code>PAYPAL_SECRET</code>{" "}
<code>PAYPAL_API</code> for sandbox vs. live). {t("notConfiguredOptional")} <code>PAYPAL_API</code>{" "}
{t("notConfiguredPost")}
</p> </p>
)} )}
</ContentCard> </ContentCard>
<ContentCard icon="💰" title="Your balance" subtitle={`Signed in as ${user.username}`}> <ContentCard
icon="💰"
title={t("balanceTitle")}
subtitle={t("signedInAs", { username: user.username })}
>
<div className="stat-grid"> <div className="stat-grid">
<StatBlock value={user.credits.toLocaleString()} label="Credits" icon="🪙" /> <StatBlock value={user.credits.toLocaleString()} label={t("credits")} icon="🪙" />
<StatBlock value={rate.toLocaleString()} label={`Credits per 1.00 ${PAYPAL_CURRENCY}`} /> <StatBlock
value={rate.toLocaleString()}
label={t("creditsPerUnit", { currency: PAYPAL_CURRENCY })}
/>
</div> </div>
<p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}> <p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
You receive <strong>{rate.toLocaleString()}</strong> credits per 1.00 {PAYPAL_CURRENCY}. {t.rich("rateLine", {
Credits appear in-game the next time you log in. amount: rate.toLocaleString(),
currency: PAYPAL_CURRENCY,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p> </p>
</ContentCard> </ContentCard>
</div> </div>
+11 -9
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -23,6 +24,7 @@ type Team = {
}; };
export default async function StaffPage() { export default async function StaffPage() {
const t = await getTranslations("pages.staff");
const minStaffRank = Number(await siteSettings.get("min_staff_rank", "7")) || 7; const minStaffRank = Number(await siteSettings.get("min_staff_rank", "7")) || 7;
let staff: StaffMember[] = []; let staff: StaffMember[] = [];
@@ -57,10 +59,10 @@ export default async function StaffPage() {
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🛡️" title="Staff" subtitle="The team behind the hotel" /> <ContentCard icon="🛡️" title={t("title")} subtitle={t("subtitle")} />
{teams.length > 0 ? ( {teams.length > 0 ? (
<ContentCard icon="⭐" title="Ranks" subtitle="What each part of the team takes care of"> <ContentCard icon="⭐" title={t("ranksTitle")} subtitle={t("ranksSubtitle")}>
<div className="card-grid sm-2 lg-3"> <div className="card-grid sm-2 lg-3">
{teams.map((t) => ( {teams.map((t) => (
<div key={t.id} className="card"> <div key={t.id} className="card">
@@ -78,23 +80,23 @@ export default async function StaffPage() {
<ContentCard <ContentCard
icon="👥" icon="👥"
title="Team members" title={t("membersTitle")}
subtitle={ subtitle={
staff.length === 0 staff.length === 0
? "Nobody to show right now" ? t("membersEmptySubtitle")
: `${staff.length} member${staff.length === 1 ? "" : "s"} keeping things running` : t("membersSubtitle", { count: staff.length })
} }
padded={staff.length === 0} padded={staff.length === 0}
> >
{staff.length === 0 ? ( {staff.length === 0 ? (
<EmptyState icon="🛡️">No staff members to show.</EmptyState> <EmptyState icon="🛡️">{t("emptyState")}</EmptyState>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Username</th> <th>{t("colUsername")}</th>
<th>Rank</th> <th>{t("colRank")}</th>
<th>Motto</th> <th>{t("colMotto")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
+17 -15
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard, EmptyState, OnlineBadge, StatBlock } from "@/components/public/ui"; import { ContentCard, EmptyState, OnlineBadge, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
@@ -39,6 +40,7 @@ export default async function ProfilePage({
}: { }: {
params: Promise<{ username: string }>; params: Promise<{ username: string }>;
}) { }) {
const t = await getTranslations("pages.profile");
const { username } = await params; const { username } = await params;
const [user, imagerBase, session] = await Promise.all([ const [user, imagerBase, session] = await Promise.all([
@@ -115,22 +117,22 @@ export default async function ProfilePage({
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
<h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1> <h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1>
<p style={{ margin: "0 0 0.6rem" }}> <p style={{ margin: "0 0 0.6rem" }}>
{user.motto || <span className="muted">No motto</span>} {user.motto || <span className="muted">{t("noMotto")}</span>}
</p> </p>
<OnlineBadge online={user.online === "1"} /> <OnlineBadge online={user.online === "1"} />
</div> </div>
</div> </div>
<div className="stat-grid" style={{ padding: "0 1.1rem 1.1rem" }}> <div className="stat-grid" style={{ padding: "0 1.1rem 1.1rem" }}>
<StatBlock icon="🏅" value={user.rank} label="Rank" /> <StatBlock icon="🏅" value={user.rank} label={t("statRank")} />
<StatBlock icon="💰" value={user.credits.toLocaleString()} label="Credits" /> <StatBlock icon="💰" value={user.credits.toLocaleString()} label={t("statCredits")} />
<StatBlock icon="📅" value={registered} label="Registered" /> <StatBlock icon="📅" value={registered} label={t("statRegistered")} />
</div> </div>
</ContentCard> </ContentCard>
{/* ── Badges ───────────────────────────────────────────── */} {/* ── Badges ───────────────────────────────────────────── */}
<ContentCard icon="🏅" title="Badges" padded={badges.length === 0}> <ContentCard icon="🏅" title={t("badgesTitle")} padded={badges.length === 0}>
{badges.length === 0 ? ( {badges.length === 0 ? (
<EmptyState icon="🏅">No badges yet.</EmptyState> <EmptyState icon="🏅">{t("badgesEmpty")}</EmptyState>
) : ( ) : (
<div <div
style={{ style={{
@@ -157,9 +159,9 @@ export default async function ProfilePage({
</ContentCard> </ContentCard>
{/* ── Photos ───────────────────────────────────────────── */} {/* ── Photos ───────────────────────────────────────────── */}
<ContentCard icon="📸" title="Photos" padded={photos.length === 0}> <ContentCard icon="📸" title={t("photosTitle")} padded={photos.length === 0}>
{photos.length === 0 ? ( {photos.length === 0 ? (
<EmptyState icon="📸">No photos yet.</EmptyState> <EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
) : ( ) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{photos.map((p) => ( {photos.map((p) => (
@@ -180,7 +182,7 @@ export default async function ProfilePage({
</ContentCard> </ContentCard>
{/* ── Guestbook ────────────────────────────────────────── */} {/* ── Guestbook ────────────────────────────────────────── */}
<ContentCard icon="📝" title="Guestbook" subtitle={`Messages on ${user.username}'s wall`}> <ContentCard icon="📝" title={t("guestbookTitle")} subtitle={t("guestbookSubtitle", { username: user.username })}>
{isLoggedIn ? ( {isLoggedIn ? (
<form action={postGuestbook} className="card" style={{ marginBottom: "1rem" }}> <form action={postGuestbook} className="card" style={{ marginBottom: "1rem" }}>
{/* Profile owner id drives the write target; the author is taken from {/* Profile owner id drives the write target; the author is taken from
@@ -188,7 +190,7 @@ export default async function ProfilePage({
<input type="hidden" name="profileId" value={String(user.id)} /> <input type="hidden" name="profileId" value={String(user.id)} />
<input type="hidden" name="username" value={user.username} /> <input type="hidden" name="username" value={user.username} />
<label htmlFor="gb-message" className="muted"> <label htmlFor="gb-message" className="muted">
Leave a message {t("leaveMessage")}
</label> </label>
<textarea <textarea
id="gb-message" id="gb-message"
@@ -196,19 +198,19 @@ export default async function ProfilePage({
required required
maxLength={255} maxLength={255}
rows={3} rows={3}
placeholder={`Write something on ${user.username}'s guestbook…`} placeholder={t("guestbookPlaceholder", { username: user.username })}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }} style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Post {t("post")}
</button> </button>
</form> </form>
) : ( ) : (
<p className="muted">Log in to leave a guestbook message.</p> <p className="muted">{t("loginToPost")}</p>
)} )}
{guestbook.length === 0 ? ( {guestbook.length === 0 ? (
<EmptyState icon="📝">No guestbook entries yet.</EmptyState> <EmptyState icon="📝">{t("guestbookEmpty")}</EmptyState>
) : ( ) : (
<div className="grid" style={{ gap: "0.75rem" }}> <div className="grid" style={{ gap: "0.75rem" }}>
{guestbook.map((g) => { {guestbook.map((g) => {
@@ -234,7 +236,7 @@ export default async function ProfilePage({
) : null} ) : null}
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<p style={{ margin: "0 0 0.25rem" }}> <p style={{ margin: "0 0 0.25rem" }}>
<strong>{author?.username ?? `User #${g.userId}`}</strong>{" "} <strong>{author?.username ?? t("unknownUser", { id: g.userId })}</strong>{" "}
<span className="muted">{formatDate(g.createdAt)}</span> <span className="muted">{formatDate(g.createdAt)}</span>
</p> </p>
<p style={{ margin: 0 }}>{g.message}</p> <p style={{ margin: 0 }}>{g.message}</p>
+13 -12
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { isValidVerificationToken } from "@/actions/email-verify"; import { isValidVerificationToken } from "@/actions/email-verify";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -13,6 +14,7 @@ export default async function VerifyPage({
}: { }: {
searchParams: Promise<{ token?: string; email?: string }>; searchParams: Promise<{ token?: string; email?: string }>;
}) { }) {
const t = await getTranslations("pages.verify");
// Next 16: searchParams is a Promise. // Next 16: searchParams is a Promise.
const { token = "", email = "" } = await searchParams; const { token = "", email = "" } = await searchParams;
const normalisedEmail = email.trim().toLowerCase(); const normalisedEmail = email.trim().toLowerCase();
@@ -53,38 +55,37 @@ export default async function VerifyPage({
return ( return (
<main style={{ maxWidth: 480, margin: "2rem auto" }}> <main style={{ maxWidth: 480, margin: "2rem auto" }}>
{status === "verified" ? ( {status === "verified" ? (
<ContentCard icon="✅" title="You're all set" subtitle="Email verified"> <ContentCard icon="✅" title={t("verifiedTitle")} subtitle={t("verifiedSubtitle")}>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Your email address has been verified. You can now log in and play. {t("verifiedBody")}
</p> </p>
<Link href="/login" className="btn btn-primary"> <Link href="/login" className="btn btn-primary">
Go to login {t("goToLogin")}
</Link> </Link>
</ContentCard> </ContentCard>
) : status === "already" ? ( ) : status === "already" ? (
<ContentCard icon="✅" title="Already verified" subtitle="Nothing left to do"> <ContentCard icon="✅" title={t("alreadyTitle")} subtitle={t("alreadySubtitle")}>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
This email address has already been confirmed. {t("alreadyBody")}
</p> </p>
<Link href="/login" className="btn btn-primary"> <Link href="/login" className="btn btn-primary">
Go to login {t("goToLogin")}
</Link> </Link>
</ContentCard> </ContentCard>
) : status === "unavailable" ? ( ) : status === "unavailable" ? (
<ContentCard icon="⏳" title="Try again shortly" subtitle="Server unreachable"> <ContentCard icon="⏳" title={t("unavailableTitle")} subtitle={t("unavailableSubtitle")}>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
We couldn't reach the server to verify your email. Please retry the link in a moment. {t("unavailableBody")}
</p> </p>
</ContentCard> </ContentCard>
) : ( ) : (
<ContentCard icon="📧" title="Email verification" subtitle="Invalid or expired link"> <ContentCard icon="📧" title={t("invalidTitle")} subtitle={t("invalidSubtitle")}>
<EmptyState icon="🔗"> <EmptyState icon="🔗">
This verification link is not valid. Make sure you opened the full link from your email, {t("invalidBody")}
or request a new one.
</EmptyState> </EmptyState>
<div style={{ textAlign: "center" }}> <div style={{ textAlign: "center" }}>
<Link href="/login" className="btn btn-outline"> <Link href="/login" className="btn btn-outline">
Back to login {t("backToLogin")}
</Link> </Link>
</div> </div>
</ContentCard> </ContentCard>
+22 -1
View File
@@ -17,8 +17,29 @@ export default getRequestConfig(async () => {
const cookieLocale = store.get("NEXT_LOCALE")?.value; const cookieLocale = store.get("NEXT_LOCALE")?.value;
const locale: AppLocale = isSupportedLocale(cookieLocale) ? cookieLocale : DEFAULT_LOCALE; const locale: AppLocale = isSupportedLocale(cookieLocale) ? cookieLocale : DEFAULT_LOCALE;
const messages = (await import(`../messages/${locale}.json`)).default;
// English is the source of truth; fall back to it for any key missing from a
// translation so the UI never shows a raw key path.
const fallback =
locale === DEFAULT_LOCALE
? messages
: (await import(`../messages/${DEFAULT_LOCALE}.json`)).default;
return { return {
locale, locale,
messages: (await import(`../messages/${locale}.json`)).default, messages,
// Never throw on a missing message — use the English value, or a humanised
// key as a last resort.
getMessageFallback({ key }: { key: string }) {
const fromEnglish = key
.split(".")
.reduce<unknown>((o, k) => (o && typeof o === "object" ? (o as Record<string, unknown>)[k] : undefined), fallback);
if (typeof fromEnglish === "string") return fromEnglish;
const seg = key.split(".").pop() ?? key;
return seg.replace(/[._-]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
},
onError() {
// Swallow MISSING_MESSAGE etc. — getMessageFallback already degrades.
},
}; };
}); });
+571
View File
@@ -35,5 +35,576 @@
"language": "Language", "language": "Language",
"lightMode": "Light mode", "lightMode": "Light mode",
"darkMode": "Dark mode" "darkMode": "Dark mode"
},
"pages": {
"staff": {
"title": "Staff",
"subtitle": "The team behind the hotel",
"ranksTitle": "Ranks",
"ranksSubtitle": "What each part of the team takes care of",
"membersTitle": "Team members",
"membersEmptySubtitle": "Nobody to show right now",
"membersSubtitle": "{count, plural, one {# member keeping things running} other {# members keeping things running}}",
"emptyState": "No staff members to show.",
"colUsername": "Username",
"colRank": "Rank",
"colMotto": "Motto"
},
"leaderboard": {
"title": "Leaderboard",
"subtitle": "Top 20 players by {currency}",
"tab_credits": "Credits",
"tab_diamonds": "Diamonds",
"tab_duckets": "Duckets",
"emptyState": "No rankings to show yet.",
"colAvatar": "Avatar",
"colPlayer": "Player"
},
"friends": {
"title": "Friends",
"emptySubtitle": "You have no friends added yet",
"subtitle": "{count, plural, one {You have # friend} other {You have # friends}}",
"emptyState": "Add friends in the hotel and they will show up here.",
"noMotto": "No motto"
},
"messages": {
"title": "Messages",
"subtitle": "Friend requests and offline messages waiting for you",
"requestsTitle": "Friend requests",
"requestsEmptySubtitle": "Nobody's asked to be your friend yet",
"requestsSubtitle": "{count, plural, one {# pending request} other {# pending requests}}",
"requestsEmptyState": "No pending friend requests.",
"wantsToBeFriend": "wants to be your friend.",
"accept": "Accept",
"offlineTitle": "Offline messages",
"offlineEmptySubtitle": "Your inbox is empty",
"offlineSubtitle": "{count, plural, one {# message received} other {# messages received}}",
"offlineEmptyState": "You have no offline messages.",
"unknownUser": "User #{id}"
},
"profile": {
"noMotto": "No motto",
"statRank": "Rank",
"statCredits": "Credits",
"statRegistered": "Registered",
"badgesTitle": "Badges",
"badgesEmpty": "No badges yet.",
"photosTitle": "Photos",
"photosEmpty": "No photos yet.",
"guestbookTitle": "Guestbook",
"guestbookSubtitle": "Messages on {username}'s wall",
"leaveMessage": "Leave a message",
"guestbookPlaceholder": "Write something on {username}'s guestbook…",
"post": "Post",
"loginToPost": "Log in to leave a guestbook message.",
"guestbookEmpty": "No guestbook entries yet.",
"unknownUser": "User #{id}"
},
"rankings": {
"title": "Rankings",
"subtitle": "The top 12 players ranked by credits",
"emptyState": "No players to rank yet."
},
"home": {
"welcome": "Welcome to {hotelName}",
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
"welcomeBody": "Hang out, collect furni, and meet friends.",
"enterHotel": "Enter hotel",
"latestNews": "Latest news",
"newsTitle": "Latest news",
"newsSubtitle": "Fresh from the hotel",
"allNews": "All news →",
"noArticles": "No articles yet."
},
"news": {
"title": "News",
"subtitle": "The latest announcements, events and updates from the hotel",
"noArticles": "No articles yet."
},
"article": {
"backToNews": "← News",
"reactionsTitle": "Reactions",
"reactionsSubtitle": "Tell us what you think",
"reaction": {
"like": "Like",
"love": "Love",
"wow": "Wow"
},
"removeReaction": "Click to remove your reaction",
"reactWith": "React with {reaction}",
"noReactions": "No reactions yet.",
"logIn": "Log in",
"toReact": "to react.",
"commentsTitle": "Comments",
"commentsSubtitle": "Join the conversation",
"commentsCount": "{count} so far",
"noComments": "No comments yet. Be the first to comment.",
"userNumber": "User #{id}",
"addComment": "Add a comment",
"commentPlaceholder": "Write your comment…",
"postComment": "Post comment",
"toComment": "to leave a comment."
},
"photos": {
"title": "Photos",
"subtitle": "The latest snapshots taken in the hotel",
"noPhotos": "No photos yet.",
"photoAlt": "Photo by user #{id}",
"byUser": "by user #{id}"
},
"badges": {
"title": "Badges",
"subtitle": "The badges you can earn and show off around the hotel",
"noBadges": "No badges available yet."
},
"community": {
"title": "Community",
"subtitle": "Everything happening around the hotel in one place",
"open": "Open →",
"links": {
"rankings": {
"title": "Rankings",
"text": "See the wealthiest players in the hotel, ranked by credits."
},
"staff": {
"title": "Staff",
"text": "Meet the team that keeps the hotel running every day."
},
"news": {
"title": "News",
"text": "The latest announcements, events and updates."
},
"photos": {
"title": "Photos",
"text": "Snapshots shared by the community from around the hotel."
},
"guilds": {
"title": "Guilds",
"text": "Browse the groups players have created and joined."
},
"leaderboards": {
"title": "Leaderboards",
"text": "Who's on top this season across the hotel's stats."
}
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Browse the groups players have created and joined",
"empty": "No guilds yet.",
"unnamedGuild": "Unnamed guild",
"noDescription": "No description"
},
"guild": {
"allGuilds": "← All guilds",
"unnamedGuild": "Unnamed guild",
"noDescription": "No description",
"memberSingular": "Member",
"memberPlural": "Members",
"founded": "Founded",
"forumLink": "Forum →",
"discussion": "Discussion",
"membersTitle": "Members",
"memberLowerSingular": "member",
"memberLowerPlural": "members",
"noMembers": "No members.",
"owner": "Owner",
"noMotto": "No motto"
},
"guildForum": {
"backToGuild": "← Back to guild",
"unnamedGuild": "Unnamed guild",
"forumTitle": "{guild} — Forum",
"threadSingular": "thread",
"threadPlural": "threads",
"newThread": "+ New thread",
"empty": "No threads in this forum yet.",
"colSubject": "Subject",
"colAuthor": "Author",
"colPosts": "Posts",
"colDate": "Date",
"noSubject": "(no subject)",
"pinned": "Pinned",
"locked": "Locked",
"userNumber": "User #{id}",
"unknown": "Unknown"
},
"guildForumNew": {
"backToForum": "← Back to forum",
"unnamedGuild": "Unnamed guild",
"title": "New thread",
"subtitle": "in {guild} forum",
"subjectLabel": "Subject",
"subjectPlaceholder": "Thread subject",
"messageLabel": "Message",
"messagePlaceholder": "Write your opening post…",
"postThread": "Post thread",
"cancel": "Cancel"
},
"shop": {
"title": "Shop",
"subtitle": "Top up your account and grab exclusive packages, currency and badges.",
"browsingCategory": "Browsing the {category} packages.",
"all": "All",
"allPackages": "All packages",
"packagesAvailable": "{count, plural, one {# package available} other {# packages available}}",
"empty": "No packages available right now.",
"buy": "Buy"
},
"topup": {
"title": "Top up with PayPal",
"subtitle": "Pay securely with PayPal and your credits are added the moment the payment clears.",
"cancelled": "Checkout was cancelled — no payment was taken.",
"chooseAmount": "Choose an amount",
"notConfiguredPre": "PayPal isn't configured yet. An administrator needs to set",
"notConfiguredAnd": "and",
"notConfiguredOptional": "(and optionally",
"notConfiguredPost": "for sandbox vs. live).",
"balanceTitle": "Your balance",
"signedInAs": "Signed in as {username}",
"credits": "Credits",
"creditsPerUnit": "Credits per 1.00 {currency}",
"rateLine": "You receive <strong>{amount}</strong> credits per 1.00 {currency}. Credits appear in-game the next time you log in."
},
"marketplace": {
"title": "Marketplace",
"subtitle": "Browse furniture and rares listed by players. Listings are placed in-game and update live — head into the hotel to make an offer.",
"activeOffers": "Active offers",
"sellers": "Sellers",
"totalValue": "Total value",
"activeListings": "Active listings",
"mostRecentFirst": "Most recently listed first",
"empty": "There are no active marketplace offers right now. Check back later.",
"colOffer": "Offer",
"colItem": "Item",
"colSeller": "Seller",
"colPrice": "Price",
"colListed": "Listed",
"itemLabel": "Item #{id}",
"userLabel": "User #{id}",
"justNow": "just now",
"minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago",
"daysAgo": "{count}d ago"
},
"rares": {
"title": "Rare values",
"subtitle": "Get an overview of all of the rares on the hotel, grouped by category.",
"empty": "We currently have no rares listed here.",
"rareCount": "{count, plural, one {# rare} other {# rares}}",
"viewRares": "View rares →"
},
"rareCategory": {
"rareCount": "{count, plural, one {# rare in this category} other {# rares in this category}}",
"allRares": "← All rares",
"empty": "No rares listed in this category yet.",
"colRare": "Rare",
"colCredits": "Credits",
"colCurrency": "Currency",
"creditsValue": "{value} credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"credits": "Credits"
},
"redeem": {
"title": "Redeem a voucher",
"subtitle": "Got a voucher code? Enter it below to add the reward to your account.",
"enterCode": "Enter your code",
"oncePerAccount": "Each voucher can only be redeemed once per account.",
"balanceTitle": "Your balance",
"signedInAs": "Signed in as {username}",
"credits": "Credits",
"instantCredit": "Redeemed rewards are credited instantly and appear in-game the next time you log in."
},
"help": {
"title": "Help Center",
"subtitle": "Find guides, support topics, and the hotel rules — everything you need to get help.",
"myTickets": "My tickets →",
"topicsTitle": "Support topics",
"topicsSubtitle": "Browse guides and answers to common questions",
"topicsEmpty": "No help topics yet.",
"rulesTitle": "Hotel rules",
"rulesSubtitle": "Please read these before hanging out in the hotel",
"rulesEmpty": "No rules published yet.",
"categoryEmpty": "No rules in this category."
},
"helpCategory": {
"back": "← Help Center",
"topicSubtitle": "Help Center topic",
"moreTitle": "More help topics",
"moreSubtitle": "Jump to another support topic"
},
"helpTickets": {
"title": "Help Tickets",
"subtitle": "Submit a ticket to staff and track your open requests.",
"statTotal": "Total tickets",
"statOpen": "Open",
"statClosed": "Closed",
"newTitle": "New ticket",
"newSubtitle": "Describe your issue and staff will follow up",
"subjectPlaceholder": "Subject",
"contentPlaceholder": "Describe your issue…",
"submit": "Submit ticket",
"listTitle": "Your tickets",
"listSubtitle": "Every request you have sent to staff",
"listEmpty": "You have no tickets yet.",
"colId": "ID",
"colSubject": "Subject",
"colStatus": "Status",
"colCreated": "Created",
"statusOpen": "Open",
"statusClosed": "Closed"
},
"applyStaff": {
"title": "Apply for {hotel} staff",
"subtitle": "We open staff applications every now and then. If you come across a position you feel you would fit perfectly into, do not hesitate to apply for it.",
"noPositionsTitle": "No positions open",
"noPositionsBody": "There are currently no positions open. Please come back at a later time to check if we have any openings by then. Thank you for your interest.",
"noDeadline": "No deadline set",
"deadline": "Application deadline: {date}",
"alreadyApplied": "You have already applied for {rank}",
"aboutYou": "About you",
"aboutPlaceholder": "Tell us why you'd be a great {rank}…",
"applyFor": "Apply for {rank}"
},
"applyTeam": {
"title": "Apply for the {hotel} team",
"subtitle": "We open team applications periodically. If you see a team you fit, do not hesitate to apply!",
"noPositionsTitle": "No team positions open",
"noPositionsBody": "There are currently no open team positions. Please come back later to check for new openings. Thank you!",
"pending": "Your application is pending",
"aboutYou": "About you",
"aboutPlaceholder": "Tell us why you'd be a great fit for {rank}…",
"applyFor": "Apply for {rank}"
},
"radio": {
"title": "Radio",
"subtitle": "Tune in to the hotel radio — live DJ sets, requests and shoutouts.",
"nowPlaying": "Now playing",
"nothingPlaying": "Nothing playing right now.",
"onAir": "On air: {dj}",
"noDj": "No DJ on air.",
"audioUnsupported": "Your browser does not support the audio element.",
"streamOffline": "The stream is currently offline.",
"exploreTitle": "Explore the radio",
"exploreSubtitle": "Schedule, shouts, requests and more",
"linkSchedule": "Schedule",
"linkScheduleText": "Our weekly line-up of DJs and shows.",
"linkShouts": "Shouts",
"linkShoutsText": "Shout-outs and requests from the community.",
"linkRequests": "Requests",
"linkRequestsText": "Ask the DJ to spin your favourite track.",
"linkApply": "Apply as DJ",
"linkApplyText": "Join the team and take to the airwaves.",
"linkLeaderboard": "Leaderboard",
"linkLeaderboardText": "Top listeners ranked by points earned.",
"todayTitle": "Today's schedule",
"todayTitleWithDay": "Today's schedule · {day}",
"todaySubtitle": "What's on the airwaves today",
"fullWeek": "Full week →",
"noShowsToday": "No shows scheduled for today.",
"colTime": "Time",
"colShow": "Show",
"colDj": "DJ",
"untitledShow": "Untitled show",
"userNumber": "User #{id}"
},
"radioSchedule": {
"title": "Radio Schedule",
"subtitle": "Our weekly line-up of DJs and shows",
"empty": "No shows scheduled yet.",
"colDay": "Day",
"colTime": "Time",
"colDj": "DJ",
"colShow": "Show",
"noShows": "No shows"
},
"radioShouts": {
"title": "Radio Shouts",
"subtitle": "Shout-outs and requests from the community",
"postTitle": "Post a shout",
"postSubtitle": "Send a message to the DJ on air",
"messageLabel": "Your message",
"messagePlaceholder": "Send a shout to the DJ…",
"shoutButton": "Shout",
"loginToPost": "Log in to post a shout.",
"latestTitle": "Latest shouts",
"latestSubtitle": "The most recent messages from listeners",
"empty": "No shouts yet. Be the first!"
},
"radioContests": {
"title": "Radio Contests",
"subtitle": "Tune in and take part in our latest on-air contests",
"empty": "No contests yet.",
"contestLabel": "Contest #{id}",
"dateUnknown": "Date unknown"
},
"radioGiveaways": {
"title": "Radio Giveaways",
"subtitle": "Listen in for your chance to win in our radio giveaways",
"empty": "No giveaways yet.",
"giveawayLabel": "Giveaway #{id}",
"dateUnknown": "Date unknown"
},
"radioLeaderboard": {
"title": "Radio Listener Leaderboard",
"subtitle": "Top 50 listeners by points earned tuning in to the radio",
"empty": "No listener points to show yet.",
"colRank": "Rank",
"colAvatar": "Avatar",
"colListener": "Listener",
"colPoints": "Points"
},
"radioApply": {
"title": "Apply to be a Radio DJ",
"subtitle": "Tell us about yourself and why you'd be a great fit for the airwaves",
"latestTitle": "Your latest application",
"statusLabel": "Status:",
"submittedOn": "· submitted {date}",
"submitAgain": "You can submit again below if your details have changed.",
"formTitle": "Application form",
"formSubtitle": "All fields marked required must be filled in",
"realNameLabel": "Real name",
"realNamePlaceholder": "Your full name",
"ageLabel": "Age",
"availabilityLabel": "Availability",
"availabilityPlaceholder": "Which days and times can you broadcast?",
"musicStyleLabel": "Music style",
"musicStylePlaceholder": "e.g. House, Pop, Hip-Hop",
"experienceLabel": "Experience",
"optional": "(optional)",
"experiencePlaceholder": "Any prior DJ or hosting experience?",
"motivationLabel": "Motivation",
"motivationPlaceholder": "Why do you want to join the radio team?",
"submitButton": "Submit application"
},
"settings": {
"accountSettings": "Account settings",
"accountSettingsSubtitle": "Manage your profile, security and connected accounts",
"publicProfile": "Your public profile",
"noMotto": "No motto",
"noEmail": "No e-mail on file",
"changeMotto": "Change motto",
"changeMottoSubtitle": "Updates instantly in-game if you are online",
"mottoPlaceholder": "Your motto",
"saveMotto": "Save motto",
"securityTitle": "Security",
"securitySubtitle": "Two-factor authentication",
"securityDescription": "Add a second layer of protection to your account.",
"twoFactorLink": "Two-factor authentication",
"connectionsTitle": "Connected accounts",
"connectionsSubtitle": "Sign in with one click",
"connectionsDescription": "Link Discord or Google to your hotel account.",
"manageConnections": "Manage connections"
},
"settings2fa": {
"backToSettings": "← Settings",
"title": "Two-factor authentication",
"subtitle": "Add a second layer of security with an authenticator app",
"nowEnabled": "2FA is now enabled. 🔒",
"disabledNotice": "2FA has been disabled.",
"badCode": "That code wasn't valid — try again.",
"noAppKeyPrefix": "2FA is unavailable until",
"noAppKeyMiddle": "is configured (the same Laravel",
"noAppKeySuffix": "as your AtomCMS install, so existing secrets stay readable).",
"isEnabled": "2FA is enabled",
"onYourAccount": "on your account.",
"disable": "Disable 2FA",
"scanOrEnter": "Scan or enter this key",
"scanInstructions": "Add this to Google Authenticator / Authy, then enter the 6-digit code to confirm.",
"manualKey": "Manual key:",
"codePlaceholder": "6-digit code",
"confirm": "Confirm",
"enableIntro": "Add a second layer of security with an authenticator app.",
"enable": "Enable 2FA"
},
"connections": {
"backToSettings": "← Settings",
"title": "Connected accounts",
"subtitle": "Link a social account so you can sign in with one click",
"matchByEmail": "We match it to your hotel account by e-mail, so use the address on file:",
"lookupFailed": "We couldn't read your linked accounts right now. You can still connect below — your status will show once it's available.",
"statusUnavailable": "Not available on this hotel.",
"statusConnected": "Connected — sign in with this account.",
"statusNotConnected": "Not connected.",
"connected": "Connected",
"connectProvider": "Connect {provider}",
"unavailable": "Unavailable",
"footerNote": "Connecting takes you to the provider to authorise, then back here. If your social account's e-mail doesn't match a hotel account, you'll be returned to the login screen with a note."
},
"login": {
"title": "Sign in",
"subtitle": "Welcome back — log in to enter the hotel.",
"subtitle2fa": "Enter the 6-digit code from your authenticator.",
"usernamePlaceholder": "Username",
"passwordPlaceholder": "Password",
"codePlaceholder": "2FA code",
"pleaseWait": "Please wait…",
"verify": "Verify",
"signIn": "Sign in",
"or": "or",
"continueWithDiscord": "Continue with Discord",
"continueWithGoogle": "Continue with Google",
"noAccount": "No account?",
"createOne": "Create one",
"forgotPassword": "Forgot password?",
"errorInvalidCredentials": "Invalid username or password",
"errorInvalid2fa": "Invalid 2FA code"
},
"register": {
"title": "Create account",
"subtitle": "Join the hotel — it only takes a moment.",
"usernamePlaceholder": "Username",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Password (min 6 chars)",
"register": "Register",
"alreadyHaveAccount": "Already have an account?",
"signIn": "Sign in"
},
"forgot": {
"title": "Reset password",
"subtitle": "We'll email you a secure link to set a new password.",
"sentNotice": "If that email is registered, a reset link has been sent. Check your inbox.",
"emailPlaceholder": "Your email",
"sendResetLink": "Send reset link",
"backToLogin": "Back to login"
},
"reset": {
"title": "Set a new password",
"subtitle": "Choose a strong password you don't use elsewhere.",
"passwordPlaceholder": "New password (min 6 chars)",
"resetPassword": "Reset password",
"backToLogin": "Back to login"
},
"verify": {
"verifiedTitle": "You're all set",
"verifiedSubtitle": "Email verified",
"verifiedBody": "Your email address has been verified. You can now log in and play.",
"goToLogin": "Go to login",
"alreadyTitle": "Already verified",
"alreadySubtitle": "Nothing left to do",
"alreadyBody": "This email address has already been confirmed.",
"unavailableTitle": "Try again shortly",
"unavailableSubtitle": "Server unreachable",
"unavailableBody": "We couldn't reach the server to verify your email. Please retry the link in a moment.",
"invalidTitle": "Email verification",
"invalidSubtitle": "Invalid or expired link",
"invalidBody": "This verification link is not valid. Make sure you opened the full link from your email, or request a new one.",
"backToLogin": "Back to login"
},
"banned": {
"title": "You are banned",
"subtitle": "Account suspended",
"body": "Your account has been suspended from the hotel.",
"reasonLabel": "Reason:",
"permanent": "This ban is permanent.",
"expires": "Expires {date}.",
"contactStaff": "If you believe this is a mistake, contact the staff team."
},
"maintenance": {
"title": "{hotel} is under maintenance",
"subtitle": "We'll be back shortly",
"staffCanLogIn": "Staff can still log in."
}
} }
} }
+571
View File
@@ -35,5 +35,576 @@
"language": "Lingua", "language": "Lingua",
"lightMode": "Tema chiaro", "lightMode": "Tema chiaro",
"darkMode": "Tema scuro" "darkMode": "Tema scuro"
},
"pages": {
"staff": {
"title": "Staff",
"subtitle": "Il team dietro l'hotel",
"ranksTitle": "Ruoli",
"ranksSubtitle": "Di cosa si occupa ogni parte del team",
"membersTitle": "Membri del team",
"membersEmptySubtitle": "Nessuno da mostrare al momento",
"membersSubtitle": "{count, plural, one {# membro che tiene tutto in funzione} other {# membri che tengono tutto in funzione}}",
"emptyState": "Nessun membro dello staff da mostrare.",
"colUsername": "Nome utente",
"colRank": "Ruolo",
"colMotto": "Motto"
},
"leaderboard": {
"title": "Classifica",
"subtitle": "I 20 migliori giocatori per {currency}",
"tab_credits": "Crediti",
"tab_diamonds": "Diamanti",
"tab_duckets": "Duckets",
"emptyState": "Ancora nessuna classifica da mostrare.",
"colAvatar": "Avatar",
"colPlayer": "Giocatore"
},
"friends": {
"title": "Amici",
"emptySubtitle": "Non hai ancora aggiunto nessun amico",
"subtitle": "{count, plural, one {Hai # amico} other {Hai # amici}}",
"emptyState": "Aggiungi amici nell'hotel e compariranno qui.",
"noMotto": "Nessun motto"
},
"messages": {
"title": "Messaggi",
"subtitle": "Richieste di amicizia e messaggi offline in attesa per te",
"requestsTitle": "Richieste di amicizia",
"requestsEmptySubtitle": "Nessuno ti ha ancora chiesto l'amicizia",
"requestsSubtitle": "{count, plural, one {# richiesta in sospeso} other {# richieste in sospeso}}",
"requestsEmptyState": "Nessuna richiesta di amicizia in sospeso.",
"wantsToBeFriend": "vuole essere tuo amico.",
"accept": "Accetta",
"offlineTitle": "Messaggi offline",
"offlineEmptySubtitle": "La tua casella è vuota",
"offlineSubtitle": "{count, plural, one {# messaggio ricevuto} other {# messaggi ricevuti}}",
"offlineEmptyState": "Non hai messaggi offline.",
"unknownUser": "Utente #{id}"
},
"profile": {
"noMotto": "Nessun motto",
"statRank": "Ruolo",
"statCredits": "Crediti",
"statRegistered": "Registrato",
"badgesTitle": "Distintivi",
"badgesEmpty": "Ancora nessun distintivo.",
"photosTitle": "Foto",
"photosEmpty": "Ancora nessuna foto.",
"guestbookTitle": "Bacheca",
"guestbookSubtitle": "Messaggi sulla bacheca di {username}",
"leaveMessage": "Lascia un messaggio",
"guestbookPlaceholder": "Scrivi qualcosa sulla bacheca di {username}…",
"post": "Pubblica",
"loginToPost": "Accedi per lasciare un messaggio sulla bacheca.",
"guestbookEmpty": "Ancora nessun messaggio in bacheca.",
"unknownUser": "Utente #{id}"
},
"rankings": {
"title": "Classifica",
"subtitle": "I 12 migliori giocatori ordinati per crediti",
"emptyState": "Ancora nessun giocatore da classificare."
},
"home": {
"welcome": "Benvenuto su {hotelName}",
"welcomeSubtitle": "Il tuo hotel retrò, ora con tecnologia Next.js",
"welcomeBody": "Rilassati, colleziona furni e fai nuove amicizie.",
"enterHotel": "Entra nell'hotel",
"latestNews": "Ultime notizie",
"newsTitle": "Ultime notizie",
"newsSubtitle": "Appena arrivate dall'hotel",
"allNews": "Tutte le notizie →",
"noArticles": "Ancora nessun articolo."
},
"news": {
"title": "Notizie",
"subtitle": "Gli ultimi annunci, eventi e aggiornamenti dall'hotel",
"noArticles": "Ancora nessun articolo."
},
"article": {
"backToNews": "← Notizie",
"reactionsTitle": "Reazioni",
"reactionsSubtitle": "Dicci cosa ne pensi",
"reaction": {
"like": "Mi piace",
"love": "Adoro",
"wow": "Wow"
},
"removeReaction": "Clicca per rimuovere la tua reazione",
"reactWith": "Reagisci con {reaction}",
"noReactions": "Ancora nessuna reazione.",
"logIn": "Accedi",
"toReact": "per reagire.",
"commentsTitle": "Commenti",
"commentsSubtitle": "Partecipa alla conversazione",
"commentsCount": "{count} finora",
"noComments": "Ancora nessun commento. Scrivi il primo.",
"userNumber": "Utente #{id}",
"addComment": "Aggiungi un commento",
"commentPlaceholder": "Scrivi il tuo commento…",
"postComment": "Pubblica commento",
"toComment": "per lasciare un commento."
},
"photos": {
"title": "Foto",
"subtitle": "Gli ultimi scatti realizzati nell'hotel",
"noPhotos": "Ancora nessuna foto.",
"photoAlt": "Foto dell'utente #{id}",
"byUser": "dell'utente #{id}"
},
"badges": {
"title": "Distintivi",
"subtitle": "I distintivi che puoi ottenere e sfoggiare in giro per l'hotel",
"noBadges": "Ancora nessun distintivo disponibile."
},
"community": {
"title": "Community",
"subtitle": "Tutto ciò che accade nell'hotel in un unico posto",
"open": "Apri →",
"links": {
"rankings": {
"title": "Classifiche",
"text": "Scopri i giocatori più ricchi dell'hotel, ordinati per crediti."
},
"staff": {
"title": "Staff",
"text": "Conosci il team che ogni giorno fa funzionare l'hotel."
},
"news": {
"title": "Notizie",
"text": "Gli ultimi annunci, eventi e aggiornamenti."
},
"photos": {
"title": "Foto",
"text": "Scatti condivisi dalla community in giro per l'hotel."
},
"guilds": {
"title": "Gilde",
"text": "Esplora i gruppi creati e a cui si sono uniti i giocatori."
},
"leaderboards": {
"title": "Classifiche generali",
"text": "Chi è in testa questa stagione nelle statistiche dell'hotel."
}
}
},
"guilds": {
"title": "Gilde",
"subtitle": "Esplora i gruppi creati e frequentati dai giocatori",
"empty": "Ancora nessuna gilda.",
"unnamedGuild": "Gilda senza nome",
"noDescription": "Nessuna descrizione"
},
"guild": {
"allGuilds": "← Tutte le gilde",
"unnamedGuild": "Gilda senza nome",
"noDescription": "Nessuna descrizione",
"memberSingular": "Membro",
"memberPlural": "Membri",
"founded": "Fondata",
"forumLink": "Forum →",
"discussion": "Discussione",
"membersTitle": "Membri",
"memberLowerSingular": "membro",
"memberLowerPlural": "membri",
"noMembers": "Nessun membro.",
"owner": "Proprietario",
"noMotto": "Nessun motto"
},
"guildForum": {
"backToGuild": "← Torna alla gilda",
"unnamedGuild": "Gilda senza nome",
"forumTitle": "{guild} — Forum",
"threadSingular": "discussione",
"threadPlural": "discussioni",
"newThread": "+ Nuova discussione",
"empty": "Ancora nessuna discussione in questo forum.",
"colSubject": "Oggetto",
"colAuthor": "Autore",
"colPosts": "Messaggi",
"colDate": "Data",
"noSubject": "(senza oggetto)",
"pinned": "In evidenza",
"locked": "Bloccata",
"userNumber": "Utente #{id}",
"unknown": "Sconosciuto"
},
"guildForumNew": {
"backToForum": "← Torna al forum",
"unnamedGuild": "Gilda senza nome",
"title": "Nuova discussione",
"subtitle": "nel forum di {guild}",
"subjectLabel": "Oggetto",
"subjectPlaceholder": "Oggetto della discussione",
"messageLabel": "Messaggio",
"messagePlaceholder": "Scrivi il tuo primo messaggio…",
"postThread": "Pubblica discussione",
"cancel": "Annulla"
},
"shop": {
"title": "Negozio",
"subtitle": "Ricarica il tuo account e accaparrati pacchetti esclusivi, valute e distintivi.",
"browsingCategory": "Stai sfogliando i pacchetti {category}.",
"all": "Tutti",
"allPackages": "Tutti i pacchetti",
"packagesAvailable": "{count, plural, one {# pacchetto disponibile} other {# pacchetti disponibili}}",
"empty": "Al momento non ci sono pacchetti disponibili.",
"buy": "Acquista"
},
"topup": {
"title": "Ricarica con PayPal",
"subtitle": "Paga in sicurezza con PayPal e i tuoi crediti vengono aggiunti non appena il pagamento va a buon fine.",
"cancelled": "Il pagamento è stato annullato — non è stato addebitato nulla.",
"chooseAmount": "Scegli un importo",
"notConfiguredPre": "PayPal non è ancora configurato. Un amministratore deve impostare",
"notConfiguredAnd": "e",
"notConfiguredOptional": "(e facoltativamente",
"notConfiguredPost": "per la modalità sandbox o live).",
"balanceTitle": "Il tuo saldo",
"signedInAs": "Hai effettuato l'accesso come {username}",
"credits": "Crediti",
"creditsPerUnit": "Crediti per 1,00 {currency}",
"rateLine": "Ricevi <strong>{amount}</strong> crediti per 1,00 {currency}. I crediti compaiono nel gioco al tuo prossimo accesso."
},
"marketplace": {
"title": "Mercatino",
"subtitle": "Sfoglia mobili e rari messi in vendita dai giocatori. Le inserzioni vengono create nel gioco e si aggiornano in tempo reale — entra nell'hotel per fare un'offerta.",
"activeOffers": "Offerte attive",
"sellers": "Venditori",
"totalValue": "Valore totale",
"activeListings": "Inserzioni attive",
"mostRecentFirst": "Prima le più recenti",
"empty": "Al momento non ci sono offerte attive nel mercatino. Riprova più tardi.",
"colOffer": "Offerta",
"colItem": "Oggetto",
"colSeller": "Venditore",
"colPrice": "Prezzo",
"colListed": "Pubblicata",
"itemLabel": "Oggetto #{id}",
"userLabel": "Utente #{id}",
"justNow": "proprio ora",
"minutesAgo": "{count} min fa",
"hoursAgo": "{count} h fa",
"daysAgo": "{count} g fa"
},
"rares": {
"title": "Valori dei rari",
"subtitle": "Ottieni una panoramica di tutti i rari dell'hotel, raggruppati per categoria.",
"empty": "Al momento non abbiamo rari elencati qui.",
"rareCount": "{count, plural, one {# raro} other {# rari}}",
"viewRares": "Vedi i rari →"
},
"rareCategory": {
"rareCount": "{count, plural, one {# raro in questa categoria} other {# rari in questa categoria}}",
"allRares": "← Tutti i rari",
"empty": "Ancora nessun raro elencato in questa categoria.",
"colRare": "Raro",
"colCredits": "Crediti",
"colCurrency": "Valuta",
"creditsValue": "{value} crediti",
"duckets": "Duckets",
"diamonds": "Diamanti",
"credits": "Crediti"
},
"redeem": {
"title": "Riscatta un buono",
"subtitle": "Hai un codice buono? Inseriscilo qui sotto per aggiungere la ricompensa al tuo account.",
"enterCode": "Inserisci il tuo codice",
"oncePerAccount": "Ogni buono può essere riscattato una sola volta per account.",
"balanceTitle": "Il tuo saldo",
"signedInAs": "Hai effettuato l'accesso come {username}",
"credits": "Crediti",
"instantCredit": "Le ricompense riscattate vengono accreditate immediatamente e compaiono nel gioco al tuo prossimo accesso."
},
"help": {
"title": "Centro assistenza",
"subtitle": "Trova guide, argomenti di supporto e le regole dell'hotel: tutto ciò che ti serve per ricevere aiuto.",
"myTickets": "I miei ticket →",
"topicsTitle": "Argomenti di supporto",
"topicsSubtitle": "Sfoglia guide e risposte alle domande più comuni",
"topicsEmpty": "Ancora nessun argomento di aiuto.",
"rulesTitle": "Regole dell'hotel",
"rulesSubtitle": "Leggile prima di trascorrere del tempo nell'hotel",
"rulesEmpty": "Ancora nessuna regola pubblicata.",
"categoryEmpty": "Nessuna regola in questa categoria."
},
"helpCategory": {
"back": "← Centro assistenza",
"topicSubtitle": "Argomento del centro assistenza",
"moreTitle": "Altri argomenti di aiuto",
"moreSubtitle": "Passa a un altro argomento di supporto"
},
"helpTickets": {
"title": "Ticket di assistenza",
"subtitle": "Invia un ticket allo staff e tieni traccia delle tue richieste aperte.",
"statTotal": "Ticket totali",
"statOpen": "Aperti",
"statClosed": "Chiusi",
"newTitle": "Nuovo ticket",
"newSubtitle": "Descrivi il tuo problema e lo staff ti ricontatterà",
"subjectPlaceholder": "Oggetto",
"contentPlaceholder": "Descrivi il tuo problema…",
"submit": "Invia ticket",
"listTitle": "I tuoi ticket",
"listSubtitle": "Tutte le richieste che hai inviato allo staff",
"listEmpty": "Non hai ancora nessun ticket.",
"colId": "ID",
"colSubject": "Oggetto",
"colStatus": "Stato",
"colCreated": "Creato",
"statusOpen": "Aperto",
"statusClosed": "Chiuso"
},
"applyStaff": {
"title": "Candidati per lo staff di {hotel}",
"subtitle": "Apriamo le candidature per lo staff di tanto in tanto. Se trovi una posizione che senti perfetta per te, non esitare a candidarti.",
"noPositionsTitle": "Nessuna posizione aperta",
"noPositionsBody": "Al momento non ci sono posizioni aperte. Torna più tardi per verificare se nel frattempo ci sono nuove opportunità. Grazie per il tuo interesse.",
"noDeadline": "Nessuna scadenza impostata",
"deadline": "Scadenza candidatura: {date}",
"alreadyApplied": "Ti sei già candidato per {rank}",
"aboutYou": "Parlaci di te",
"aboutPlaceholder": "Raccontaci perché saresti un ottimo {rank}…",
"applyFor": "Candidati per {rank}"
},
"applyTeam": {
"title": "Candidati per il team di {hotel}",
"subtitle": "Apriamo le candidature per il team periodicamente. Se vedi un team adatto a te, non esitare a candidarti!",
"noPositionsTitle": "Nessuna posizione nel team aperta",
"noPositionsBody": "Al momento non ci sono posizioni aperte nel team. Torna più tardi per verificare se ci sono nuove opportunità. Grazie!",
"pending": "La tua candidatura è in attesa",
"aboutYou": "Parlaci di te",
"aboutPlaceholder": "Raccontaci perché saresti perfetto per {rank}…",
"applyFor": "Candidati per {rank}"
},
"radio": {
"title": "Radio",
"subtitle": "Sintonizzati sulla radio dell'hotel — set DJ in diretta, richieste e saluti.",
"nowPlaying": "In riproduzione",
"nothingPlaying": "Nessun brano in riproduzione al momento.",
"onAir": "In onda: {dj}",
"noDj": "Nessun DJ in onda.",
"audioUnsupported": "Il tuo browser non supporta l'elemento audio.",
"streamOffline": "Lo streaming è al momento offline.",
"exploreTitle": "Esplora la radio",
"exploreSubtitle": "Programmazione, saluti, richieste e altro",
"linkSchedule": "Programmazione",
"linkScheduleText": "La nostra programmazione settimanale di DJ e show.",
"linkShouts": "Saluti",
"linkShoutsText": "Saluti e richieste dalla community.",
"linkRequests": "Richieste",
"linkRequestsText": "Chiedi al DJ di mettere il tuo brano preferito.",
"linkApply": "Candidati come DJ",
"linkApplyText": "Unisciti al team e va' in onda.",
"linkLeaderboard": "Classifica",
"linkLeaderboardText": "I migliori ascoltatori in base ai punti guadagnati.",
"todayTitle": "Programmazione di oggi",
"todayTitleWithDay": "Programmazione di oggi · {day}",
"todaySubtitle": "Cosa c'è in onda oggi",
"fullWeek": "Settimana intera →",
"noShowsToday": "Nessuno show in programma per oggi.",
"colTime": "Orario",
"colShow": "Show",
"colDj": "DJ",
"untitledShow": "Show senza titolo",
"userNumber": "Utente #{id}"
},
"radioSchedule": {
"title": "Programmazione radio",
"subtitle": "La nostra programmazione settimanale di DJ e show",
"empty": "Nessuno show ancora in programma.",
"colDay": "Giorno",
"colTime": "Orario",
"colDj": "DJ",
"colShow": "Show",
"noShows": "Nessuno show"
},
"radioShouts": {
"title": "Saluti radio",
"subtitle": "Saluti e richieste dalla community",
"postTitle": "Pubblica un saluto",
"postSubtitle": "Invia un messaggio al DJ in onda",
"messageLabel": "Il tuo messaggio",
"messagePlaceholder": "Invia un saluto al DJ…",
"shoutButton": "Invia",
"loginToPost": "Accedi per pubblicare un saluto.",
"latestTitle": "Ultimi saluti",
"latestSubtitle": "I messaggi più recenti degli ascoltatori",
"empty": "Ancora nessun saluto. Sii il primo!"
},
"radioContests": {
"title": "Concorsi radio",
"subtitle": "Sintonizzati e partecipa ai nostri ultimi concorsi in onda",
"empty": "Ancora nessun concorso.",
"contestLabel": "Concorso #{id}",
"dateUnknown": "Data sconosciuta"
},
"radioGiveaways": {
"title": "Omaggi radio",
"subtitle": "Resta in ascolto per avere la possibilità di vincere ai nostri omaggi radio",
"empty": "Ancora nessun omaggio.",
"giveawayLabel": "Omaggio #{id}",
"dateUnknown": "Data sconosciuta"
},
"radioLeaderboard": {
"title": "Classifica ascoltatori radio",
"subtitle": "I 50 migliori ascoltatori per punti guadagnati sintonizzandosi sulla radio",
"empty": "Ancora nessun punto ascoltatore da mostrare.",
"colRank": "Posizione",
"colAvatar": "Avatar",
"colListener": "Ascoltatore",
"colPoints": "Punti"
},
"radioApply": {
"title": "Candidati come DJ radiofonico",
"subtitle": "Raccontaci di te e perché saresti perfetto per andare in onda",
"latestTitle": "La tua ultima candidatura",
"statusLabel": "Stato:",
"submittedOn": "· inviata il {date}",
"submitAgain": "Puoi inviare di nuovo qui sotto se i tuoi dati sono cambiati.",
"formTitle": "Modulo di candidatura",
"formSubtitle": "Tutti i campi contrassegnati come obbligatori devono essere compilati",
"realNameLabel": "Nome reale",
"realNamePlaceholder": "Il tuo nome completo",
"ageLabel": "Età",
"availabilityLabel": "Disponibilità",
"availabilityPlaceholder": "In quali giorni e orari puoi trasmettere?",
"musicStyleLabel": "Stile musicale",
"musicStylePlaceholder": "es. House, Pop, Hip-Hop",
"experienceLabel": "Esperienza",
"optional": "(facoltativo)",
"experiencePlaceholder": "Hai esperienze precedenti come DJ o conduttore?",
"motivationLabel": "Motivazione",
"motivationPlaceholder": "Perché vuoi entrare nel team della radio?",
"submitButton": "Invia candidatura"
},
"settings": {
"accountSettings": "Impostazioni account",
"accountSettingsSubtitle": "Gestisci il tuo profilo, la sicurezza e gli account collegati",
"publicProfile": "Il tuo profilo pubblico",
"noMotto": "Nessun motto",
"noEmail": "Nessuna e-mail registrata",
"changeMotto": "Cambia motto",
"changeMottoSubtitle": "Si aggiorna subito in gioco se sei online",
"mottoPlaceholder": "Il tuo motto",
"saveMotto": "Salva motto",
"securityTitle": "Sicurezza",
"securitySubtitle": "Autenticazione a due fattori",
"securityDescription": "Aggiungi un ulteriore livello di protezione al tuo account.",
"twoFactorLink": "Autenticazione a due fattori",
"connectionsTitle": "Account collegati",
"connectionsSubtitle": "Accedi con un clic",
"connectionsDescription": "Collega Discord o Google al tuo account dell'hotel.",
"manageConnections": "Gestisci collegamenti"
},
"settings2fa": {
"backToSettings": "← Impostazioni",
"title": "Autenticazione a due fattori",
"subtitle": "Aggiungi un livello di sicurezza in più con un'app di autenticazione",
"nowEnabled": "L'autenticazione a due fattori è ora attiva. 🔒",
"disabledNotice": "L'autenticazione a due fattori è stata disattivata.",
"badCode": "Codice non valido, riprova.",
"noAppKeyPrefix": "L'autenticazione a due fattori non è disponibile finché non è configurata",
"noAppKeyMiddle": "(la stessa",
"noAppKeySuffix": "Laravel del tuo AtomCMS, così i segreti esistenti restano leggibili).",
"isEnabled": "L'autenticazione a due fattori è attiva",
"onYourAccount": "sul tuo account.",
"disable": "Disattiva 2FA",
"scanOrEnter": "Scansiona o inserisci questa chiave",
"scanInstructions": "Aggiungila a Google Authenticator / Authy, poi inserisci il codice a 6 cifre per confermare.",
"manualKey": "Chiave manuale:",
"codePlaceholder": "Codice a 6 cifre",
"confirm": "Conferma",
"enableIntro": "Aggiungi un livello di sicurezza in più con un'app di autenticazione.",
"enable": "Attiva 2FA"
},
"connections": {
"backToSettings": "← Impostazioni",
"title": "Account collegati",
"subtitle": "Collega un account social per poter accedere con un clic",
"matchByEmail": "Lo abbiniamo al tuo account dell'hotel tramite e-mail, quindi usa l'indirizzo registrato:",
"lookupFailed": "Al momento non riusciamo a leggere i tuoi account collegati. Puoi comunque collegarne uno qui sotto: lo stato verrà mostrato non appena disponibile.",
"statusUnavailable": "Non disponibile su questo hotel.",
"statusConnected": "Collegato — accedi con questo account.",
"statusNotConnected": "Non collegato.",
"connected": "Collegato",
"connectProvider": "Collega {provider}",
"unavailable": "Non disponibile",
"footerNote": "Il collegamento ti porta al provider per l'autorizzazione, poi ti riporta qui. Se l'e-mail del tuo account social non corrisponde a un account dell'hotel, verrai riportato alla schermata di accesso con una nota."
},
"login": {
"title": "Accedi",
"subtitle": "Bentornato — accedi per entrare nell'hotel.",
"subtitle2fa": "Inserisci il codice a 6 cifre della tua app di autenticazione.",
"usernamePlaceholder": "Nome utente",
"passwordPlaceholder": "Password",
"codePlaceholder": "Codice 2FA",
"pleaseWait": "Attendi…",
"verify": "Verifica",
"signIn": "Accedi",
"or": "oppure",
"continueWithDiscord": "Continua con Discord",
"continueWithGoogle": "Continua con Google",
"noAccount": "Non hai un account?",
"createOne": "Creane uno",
"forgotPassword": "Password dimenticata?",
"errorInvalidCredentials": "Nome utente o password non validi",
"errorInvalid2fa": "Codice 2FA non valido"
},
"register": {
"title": "Crea un account",
"subtitle": "Entra nell'hotel — bastano pochi istanti.",
"usernamePlaceholder": "Nome utente",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Password (min 6 caratteri)",
"register": "Registrati",
"alreadyHaveAccount": "Hai già un account?",
"signIn": "Accedi"
},
"forgot": {
"title": "Reimposta password",
"subtitle": "Ti invieremo via e-mail un link sicuro per impostare una nuova password.",
"sentNotice": "Se l'indirizzo è registrato, ti abbiamo inviato un link per il reset. Controlla la posta in arrivo.",
"emailPlaceholder": "La tua email",
"sendResetLink": "Invia link di reset",
"backToLogin": "Torna all'accesso"
},
"reset": {
"title": "Imposta una nuova password",
"subtitle": "Scegli una password robusta che non usi altrove.",
"passwordPlaceholder": "Nuova password (min 6 caratteri)",
"resetPassword": "Reimposta password",
"backToLogin": "Torna all'accesso"
},
"verify": {
"verifiedTitle": "È tutto pronto",
"verifiedSubtitle": "Email verificata",
"verifiedBody": "Il tuo indirizzo email è stato verificato. Ora puoi accedere e giocare.",
"goToLogin": "Vai all'accesso",
"alreadyTitle": "Già verificata",
"alreadySubtitle": "Niente da fare",
"alreadyBody": "Questo indirizzo email è già stato confermato.",
"unavailableTitle": "Riprova tra poco",
"unavailableSubtitle": "Server irraggiungibile",
"unavailableBody": "Non siamo riusciti a raggiungere il server per verificare la tua email. Riprova il link tra un momento.",
"invalidTitle": "Verifica email",
"invalidSubtitle": "Link non valido o scaduto",
"invalidBody": "Questo link di verifica non è valido. Assicurati di aver aperto il link completo della tua email, oppure richiedine uno nuovo.",
"backToLogin": "Torna all'accesso"
},
"banned": {
"title": "Sei stato bannato",
"subtitle": "Account sospeso",
"body": "Il tuo account è stato sospeso dall'hotel.",
"reasonLabel": "Motivo:",
"permanent": "Questo ban è permanente.",
"expires": "Scade il {date}.",
"contactStaff": "Se ritieni che si tratti di un errore, contatta lo staff."
},
"maintenance": {
"title": "{hotel} è in manutenzione",
"subtitle": "Torneremo a breve",
"staffCanLogIn": "Lo staff può comunque accedere."
}
} }
} }