Decouple admin from public max-w-7xl via (site) route group.
Local Build and Deploy / deploy (push) Successful in 54s
Local Build and Deploy / deploy (push) Successful in 54s
Public chrome and width live only under (site). /admin and /client inherit the root layout alone, so housekeeping width is no longer tied to the public template. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
a1dc7ff0a7
commit
367f69a439
60 files changed
+29
-30
No files matched your search
@@ -0,0 +1,255 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user