Adapt + improve all public pages to the atom design system

Extended the same design-system treatment to the public site, faithful
to AtomCMS's "atom" theme. New src/components/public/ui.tsx provides the
signature atom building blocks + a scoped CSS layer:
- ContentCard: surface card with a primary-tinted header (icon circle +
  title + subtitle) and padded body — the atom content-card, used as
  every page's header and section wrapper.
- StatBlock / stat-grid, EmptyState, OnlineBadge (online/offline pill),
  RankBadge (medals for the top 3), and responsive .card-grid helpers.

Swept ~45 public pages (home/community/rankings hand-built as the
reference; the rest via parallel agents that read the schema and
preserved every query, server action, auth gate and field name):
heroes → ContentCard headers, lists → card-grids in ContentCards,
"no X" → EmptyState, status → OnlineBadge/RankBadge. The leaderboard
gained Credits/Diamonds/Duckets tabs (usersCurrency).

Behaviour unchanged. Verified on the prod server against amx_test: 15+
public pages render the content-cards/grids with real data, no errors;
computed styles confirm the tinted header, icon circle, medal + online
pills. Fixed an undefined --color-golden ref. tsc 0, vitest 49/49,
next build 0.
This commit is contained in:
Simo committed 2026-06-28 19:28:36 +02:00
1 parent 0de0ac6a3c
commit e9ea19795a
47 files changed
+2264 -1714

No files matched your search

+13 -12
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentCard } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
@@ -93,16 +94,12 @@ export default async function HelpCategoryPage({
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
return (
<main>
<p style={{ margin: "0 0 0.75rem" }}>
<main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: 0 }}>
<Link href="/help">← Help Center</Link>
</p>
<div className="hero">
<h1 style={{ margin: 0 }}>{title}</h1>
</div>
<article className="card">
<ContentCard icon="❓" title={title} subtitle="Help Center topic">
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
@@ -120,6 +117,7 @@ export default async function HelpCategoryPage({
{/* content is author-supplied HTML in AtomCMS (rendered raw with {!! !!}). */}
<div
style={{ lineHeight: 1.7 }}
// biome-ignore lint/security/noDangerouslySetInnerHtml: author-supplied help content, matches AtomCMS Blade
dangerouslySetInnerHTML={{ __html: content }}
/>
@@ -137,12 +135,15 @@ export default async function HelpCategoryPage({
</a>
</div>
) : null}
</article>
</ContentCard>
{siblings.length > 0 ? (
<section style={{ marginTop: "2rem" }}>
<h2>More help topics</h2>
<div className="grid cols-2">
<ContentCard
icon="🧭"
title="More help topics"
subtitle="Jump to another support topic"
>
<div className="card-grid sm-2 lg-3">
{siblings.map((s) => (
<Link
key={String(s.id)}
@@ -154,7 +155,7 @@ export default async function HelpCategoryPage({
</Link>
))}
</div>
</section>
</ContentCard>
) : null}
</main>
);
+129 -112
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -87,122 +88,138 @@ export default async function HelpCenterPage() {
}
return (
<main>
<section className="hero">
<h1 style={{ marginTop: 0 }}>Help Center</h1>
<p className="muted" style={{ fontSize: 16, margin: 0 }}>
Find guides, support topics, and the hotel rules — everything you need to get help.
</p>
</section>
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="❓"
title="Help Center"
subtitle="Find guides, support topics, and the hotel rules — everything you need to get help."
action={
<Link className="btn btn-outline" href="/help/tickets">
My tickets →
</Link>
}
/>
<h2>Support topics</h2>
{categories.length === 0 ? (
<p className="muted">No help topics yet.</p>
) : (
<div className="grid cols-2">
{categories.map((c) => (
<article key={String(c.id)} className="card hover">
<div style={{ display: "flex", gap: "0.85rem", alignItems: "flex-start" }}>
{c.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={c.imageUrl}
alt=""
width={48}
height={48}
style={{ flexShrink: 0, objectFit: "contain" }}
/>
) : null}
<div style={{ flex: 1 }}>
<h3 style={{ margin: "0 0 0.35rem" }}>{c.name}</h3>
<p className="muted" style={{ margin: 0 }}>
{c.content}
</p>
{c.buttonUrl && c.buttonText ? (
<div style={{ marginTop: "0.85rem" }}>
{isExternal(c.buttonUrl) ? (
<a
className="btn btn-outline"
href={c.buttonUrl}
target="_blank"
rel="noreferrer"
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</a>
) : (
<Link
className="btn btn-outline"
href={c.buttonUrl}
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</Link>
)}
</div>
<ContentCard
icon="🧭"
title="Support topics"
subtitle="Browse guides and answers to common questions"
padded={categories.length === 0}
>
{categories.length === 0 ? (
<EmptyState icon="🧭">No help topics yet.</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{categories.map((c) => (
<article key={String(c.id)} className="card hover">
<div style={{ display: "flex", gap: "0.85rem", alignItems: "flex-start" }}>
{c.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={c.imageUrl}
alt=""
width={48}
height={48}
style={{ flexShrink: 0, objectFit: "contain" }}
/>
) : null}
<div style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.35rem", fontSize: "1rem" }}>{c.name}</h3>
<p className="muted" style={{ margin: 0 }}>
{c.content}
</p>
{c.buttonUrl && c.buttonText ? (
<div style={{ marginTop: "0.85rem" }}>
{isExternal(c.buttonUrl) ? (
<a
className="btn btn-outline"
href={c.buttonUrl}
target="_blank"
rel="noreferrer"
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</a>
) : (
<Link
className="btn btn-outline"
href={c.buttonUrl}
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</Link>
)}
</div>
) : null}
</div>
</div>
</div>
</article>
))}
</div>
)}
<h2 style={{ marginTop: "2rem" }}>Hotel rules</h2>
{ruleCategories.length === 0 ? (
<p className="muted">No rules published yet.</p>
) : (
<div className="grid">
{ruleCategories.map((cat) => {
const catRules = rulesByCategory.get(String(cat.id)) ?? [];
return (
<article key={String(cat.id)} className="card">
<div
style={{
display: "flex",
alignItems: "baseline",
gap: "0.6rem",
flexWrap: "wrap",
}}
>
<h3 style={{ margin: 0 }}>{cat.name}</h3>
{cat.badge ? <span className="muted">{cat.badge}</span> : null}
</div>
{cat.description ? (
<p className="muted" style={{ margin: "0.35rem 0 0" }}>
{cat.description}
</p>
) : null}
{catRules.length === 0 ? (
<p className="muted" style={{ marginBottom: 0 }}>
No rules in this category.
</p>
) : (
<ul style={{ margin: "0.85rem 0 0", paddingLeft: "1.2rem" }}>
{catRules.map((r) => (
<li key={String(r.id)} style={{ marginBottom: "0.5rem" }}>
{r.paragraph ? (
<strong style={{ marginRight: "0.4rem" }}>{r.paragraph}</strong>
) : null}
{r.rule}
</li>
))}
</ul>
)}
</article>
);
})}
</div>
)}
))}
</div>
)}
</ContentCard>
<ContentCard
icon="📜"
title="Hotel rules"
subtitle="Please read these before hanging out in the hotel"
padded={ruleCategories.length === 0}
>
{ruleCategories.length === 0 ? (
<EmptyState icon="📜">No rules published yet.</EmptyState>
) : (
<div style={{ display: "grid", gap: "1rem", padding: "1rem" }}>
{ruleCategories.map((cat) => {
const catRules = rulesByCategory.get(String(cat.id)) ?? [];
return (
<article key={String(cat.id)} className="card">
<div
style={{
display: "flex",
alignItems: "baseline",
gap: "0.6rem",
flexWrap: "wrap",
}}
>
<h3 style={{ margin: 0, fontSize: "1.05rem" }}>{cat.name}</h3>
{cat.badge ? <span className="muted">{cat.badge}</span> : null}
</div>
{cat.description ? (
<p className="muted" style={{ margin: "0.35rem 0 0" }}>
{cat.description}
</p>
) : null}
{catRules.length === 0 ? (
<p className="muted" style={{ marginBottom: 0 }}>
No rules in this category.
</p>
) : (
<ul style={{ margin: "0.85rem 0 0", paddingLeft: "1.2rem" }}>
{catRules.map((r) => (
<li key={String(r.id)} style={{ marginBottom: "0.5rem" }}>
{r.paragraph ? (
<strong style={{ marginRight: "0.4rem" }}>{r.paragraph}</strong>
) : null}
{r.rule}
</li>
))}
</ul>
)}
</article>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
+71 -42
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { createTicket } from "@/actions/help-tickets";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
@@ -26,51 +27,79 @@ export default async function HelpTicketsPage() {
tickets = [];
}
const openCount = tickets.filter((t) => t.open).length;
const closedCount = tickets.length - openCount;
return (
<main>
<h1>Help Tickets</h1>
<p className="muted">Submit a ticket to staff and track your open requests.</p>
<form action={createTicket} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>New ticket</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}>
<input name="title" placeholder="Subject" maxLength={255} required />
<textarea
name="content"
placeholder="Describe your issue…"
rows={5}
maxLength={5000}
required
/>
<div>
<button type="submit" className="btn btn-primary">
Submit ticket
</button>
</div>
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🎫"
title="Help Tickets"
subtitle="Submit a ticket to staff and track your open requests."
>
<div className="stat-grid">
<StatBlock icon="🎫" value={tickets.length} label="Total tickets" />
<StatBlock icon="📬" value={openCount} label="Open" />
<StatBlock icon="✅" value={closedCount} label="Closed" />
</div>
</form>
</ContentCard>
<table>
<thead>
<tr>
<th>ID</th>
<th>Subject</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{tickets.map((t) => (
<tr key={String(t.id)}>
<td>{String(t.id)}</td>
<td>{t.title}</td>
<td>{t.open ? "Open" : "Closed"}</td>
<td className="muted">{formatDate(t.createdAt)}</td>
</tr>
))}
</tbody>
</table>
{tickets.length === 0 ? <p className="muted">You have no tickets yet.</p> : null}
<ContentCard icon="✏️" title="New ticket" subtitle="Describe your issue and staff will follow up">
<form action={createTicket}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}>
<input name="title" placeholder="Subject" maxLength={255} required />
<textarea
name="content"
placeholder="Describe your issue…"
rows={5}
maxLength={5000}
required
/>
<div>
<button type="submit" className="btn btn-primary">
Submit ticket
</button>
</div>
</div>
</form>
</ContentCard>
<ContentCard
icon="📋"
title="Your tickets"
subtitle="Every request you have sent to staff"
padded={tickets.length === 0}
>
{tickets.length === 0 ? (
<EmptyState icon="🎫">You have no tickets yet.</EmptyState>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Subject</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{tickets.map((t) => (
<tr key={String(t.id)}>
<td>{String(t.id)}</td>
<td>{t.title}</td>
<td>
<span className={`online-badge${t.open ? " online" : ""}`}>
<span className="online-dot" aria-hidden />
{t.open ? "Open" : "Closed"}
</span>
</td>
<td className="muted">{formatDate(t.createdAt)}</td>
</tr>
))}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}