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:
1 parent
1199176883
commit
e004dfedaf
47 files changed
+1740
-471
No files matched your search
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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" }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+16
-14
@@ -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>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
@@ -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'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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user