CI / check (push) Has been cancelled
1. Performance: 25 pages switched from force-dynamic to revalidate=300 (ISR); 2 pages (community, developers) now fully static (SSG) 2. DB indexes: Added @@index on foreign keys for WebsiteArticles, WebsiteArticleReactions, WebsiteArticleComments, WebsiteHelpCenterTickets, WebsiteShopArticles, RadioSongRequests, StaffActivities 3. SEO: Added robots.ts, sitemap.ts, canonical URLs, Open Graph + Twitter Card metadata on root layout and news articles 4. A11Y: Replaced <details>/<summary> dropdowns with accessible button-based NavDropdown (aria-expanded, aria-haspopup, role=menu). MobileNav now uses translated aria-label, aria-expanded, aria-controls, role=menu 5. Code quality: Added try/catch to updateArticle/deleteArticle; deleteArticle now uses prisma. for atomicity 6. CI/CD: Added GitHub Actions workflow (typecheck + test) 7. i18n: Added openMenu/closeMenu keys to all 6 locales 8. Observability: Health endpoint now checks SMTP reachability when configured 9. Loading states: Added loading.tsx for root, admin, and news sections 10. Word filter cache: Added 60s TTL auto-refresh instead of manual cache bust
253 lines
7.5 KiB
TypeScript
253 lines
7.5 KiB
TypeScript
import { getTranslations } from "next-intl/server";
|
|
import Link from "next/link";
|
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
|
import { excerpt } from "@/lib/format";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
export const revalidate = 300;
|
|
|
|
export const metadata = { title: "Shop" };
|
|
|
|
// 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;
|
|
iconUrl: 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;
|
|
icon: string | null;
|
|
};
|
|
|
|
const ARTICLE_SELECT = {
|
|
id: true,
|
|
name: true,
|
|
info: true,
|
|
iconUrl: 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 t = await getTranslations("pages.shop");
|
|
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, 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 style={{ display: "grid", gap: "1.5rem" }}>
|
|
<ContentCard
|
|
icon="🛒"
|
|
title={t("title")}
|
|
subtitle={
|
|
activeCategory
|
|
? t("browsingCategory", { category: activeCategory.name })
|
|
: t("subtitle")
|
|
}
|
|
>
|
|
{categories.length > 0 ? (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: "0.5rem",
|
|
}}
|
|
>
|
|
<Link href="/shop" className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}>
|
|
{t("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}
|
|
</ContentCard>
|
|
|
|
<ContentCard
|
|
icon="📦"
|
|
title={activeCategory ? activeCategory.name : t("allPackages")}
|
|
subtitle={
|
|
articles.length > 0
|
|
? t("packagesAvailable", { count: articles.length })
|
|
: undefined
|
|
}
|
|
padded={articles.length === 0}
|
|
>
|
|
{articles.length === 0 ? (
|
|
<EmptyState icon="🛒">{t("empty")}</EmptyState>
|
|
) : (
|
|
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
|
{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(--border-color)"}`,
|
|
flexShrink: 0,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{a.iconUrl ? (
|
|
// 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.iconUrl}
|
|
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">
|
|
{t("buy")}
|
|
</span>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</ContentCard>
|
|
</main>
|
|
);
|
|
}
|