Files
EpicNext-Cms/src/app/shop/page.tsx
T
Simo e96b606e1e Add remaining public + admin pages (parallel build, 26 files)
Built via two parallel agent workflows reading the real Prisma schema, then
integrated + verified.

Public: /shop (categories + storefront), /community hub, /rankings (top by
credits), /staff, /photos (camera_web gallery), /help (categories + rules),
/help/tickets (auth-gated user tickets + create), /settings (auth-gated, update
motto via RCON).

Admin: /admin/catalog (+[id] items), /admin/radio (shouts/apps/schedules +
delete shout), /admin/teams (CRUD), /admin/permissions (read), /admin/wordfilter
(CRUD + RCON push), /admin/ip (whitelist/blacklist CRUD), /admin/applications
(list + dismiss), /admin/logs (chat/command/alert read), /admin/achievements
(read). Server actions all staff-gated.

Header + admin nav extended. Verified: tsc exit 0, vitest 48/48, next build
exit 0 (33 routes); curl-probed every route — public 200/<main>, auth+admin
correctly 307-redirect when unauthorized.
2026-06-28 13:24:55 +02:00

241 lines
6.9 KiB
TypeScript

import Link from "next/link";
import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// Faithful to AtomCMS WebsiteShopArticle::price(): costs are stored in cents,
// the displayed price is costs/100 with a floor of 1.
function priceLabel(costs: number): string {
const price = costs < 100 ? 1 : costs / 100;
return `$${price.toFixed(2)}`;
}
type ShopArticle = {
id: bigint;
name: string;
info: string;
icon: string;
color: string;
costs: number;
credits: number | null;
duckets: number | null;
diamonds: number | null;
badges: string | null;
position: number;
};
type ShopCategory = {
id: bigint;
name: string;
slug: string;
icon: string | null;
};
const ARTICLE_SELECT = {
id: true,
name: true,
info: true,
icon: true,
color: true,
costs: true,
credits: true,
duckets: true,
diamonds: true,
badges: true,
position: true,
} as const;
export default async function ShopPage({
searchParams,
}: {
searchParams: Promise<{ category?: string }>;
}) {
const sp = await searchParams;
const rawCategory = (sp.category ?? "").trim();
const categoryId = /^\d+$/.test(rawCategory) ? rawCategory : "";
// Categories for the filter bar.
let categories: ShopCategory[] = [];
try {
categories = await prisma.websiteShopCategories.findMany({
select: { id: true, name: true, slug: true, icon: true },
orderBy: { name: "asc" },
});
} catch {
categories = [];
}
// Articles. The category foreign key (website_shop_category_id) is not modelled
// in the typed Prisma client, so when filtering we resolve the matching ids via
// a guarded raw query, then load the rows through the typed client.
let articles: ShopArticle[] = [];
try {
if (categoryId) {
let ids: bigint[] = [];
try {
const rows = await prisma.$queryRawUnsafe<{ id: bigint }[]>(
"SELECT id FROM website_shop_articles WHERE website_shop_category_id = ?",
BigInt(categoryId),
);
ids = rows.map((r) => r.id);
} catch {
ids = [];
}
articles = ids.length
? await prisma.websiteShopArticles.findMany({
where: { id: { in: ids } },
select: ARTICLE_SELECT,
orderBy: { position: "asc" },
})
: [];
} else {
articles = await prisma.websiteShopArticles.findMany({
select: ARTICLE_SELECT,
orderBy: { position: "asc" },
});
}
} catch {
articles = [];
}
const activeCategory = categoryId
? categories.find((c) => String(c.id) === categoryId)
: undefined;
return (
<main>
<div className="hero">
<h1 style={{ marginBottom: "0.25rem" }}>Shop</h1>
<p className="muted" style={{ margin: 0 }}>
{activeCategory
? `Browsing the ${activeCategory.name} packages.`
: "Top up your account and grab exclusive packages, currency and badges."}
</p>
</div>
{categories.length > 0 ? (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
marginBottom: "1.5rem",
}}
>
<Link
href="/shop"
className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}
>
All
</Link>
{categories.map((c) => (
<Link
key={String(c.id)}
href={`/shop?category=${String(c.id)}`}
className={`btn ${categoryId === String(c.id) ? "btn-primary" : "btn-outline"}`}
>
{c.name}
</Link>
))}
</div>
) : null}
{articles.length === 0 ? (
<p className="muted">No packages available right now.</p>
) : (
<div className="grid cols-3">
{articles.map((a) => (
<article key={String(a.id)} className="card hover">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
marginBottom: "0.6rem",
}}
>
<div
style={{
width: 56,
height: 56,
borderRadius: "var(--radius-sm)",
display: "grid",
placeItems: "center",
background: "#f8fafc",
border: `2px solid ${a.color || "var(--color-golden)"}`,
flexShrink: 0,
overflow: "hidden",
}}
>
{a.icon ? (
// Article icons are arbitrary external/Imgur URLs from the
// emulator data, so a plain <img> is correct here.
// eslint-disable-next-line @next/next/no-img-element
<img
src={a.icon}
alt=""
width={48}
height={48}
style={{ objectFit: "contain", display: "block" }}
/>
) : null}
</div>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: 0 }}>{a.name}</h3>
<span className="muted">{priceLabel(a.costs)}</span>
</div>
</div>
<p style={{ margin: "0 0 0.75rem" }}>{excerpt(a.info, 160)}</p>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
marginBottom: "0.85rem",
}}
>
{a.credits ? (
<span className="currency">
<span className="coin credits">cr</span>
{a.credits.toLocaleString()}
</span>
) : null}
{a.duckets ? (
<span className="currency">
<span className="coin duckets">du</span>
{a.duckets.toLocaleString()}
</span>
) : null}
{a.diamonds ? (
<span className="currency">
<span className="coin diamonds">di</span>
{a.diamonds.toLocaleString()}
</span>
) : null}
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
}}
>
<span style={{ fontWeight: 800 }}>{priceLabel(a.costs)}</span>
<span className="btn btn-primary" aria-disabled="true">
Buy
</span>
</div>
</article>
))}
</div>
)}
</main>
);
}