import { asc, eq } from "drizzle-orm"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import type { CSSProperties } from "react"; import { buyShopArticle } from "@/actions/shop"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { auth } from "@/lib/auth"; import { db, WebsiteShopArticles, WebsiteShopCategories } from "@/lib/db"; import { excerpt } from "@/lib/format"; import { creditsPerUnit } from "@/lib/services/paypal"; // TODO: Cache Components adoption. Refactor this route so this opt-out can be removed. // See: https://nextjs.org/docs/app/guides/migrating-to-cache-components export const instant = false; export const metadata = { title: "Shop" }; function feedbackStyle(tone: "success" | "error"): CSSProperties { const accent = tone === "error" ? "var(--color-danger)" : "var(--color-primary)"; return { margin: 0, padding: "0.85rem 1rem", borderRadius: "var(--radius-md)", border: `1px solid ${accent}`, color: "var(--color-text-readable, var(--color-text))", fontSize: "0.9rem", fontWeight: 600, background: "var(--color-surface)", borderLeft: `4px solid ${accent}`, }; } // 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)}`; } function creditPriceLabel(costs: number): string { const rate = creditsPerUnit(); const dollars = costs < 100 ? 1 : costs / 100; const credits = Math.max(1, Math.floor(dollars * rate)); return credits.toLocaleString(); } 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 articleSelect = { id: WebsiteShopArticles.id, name: WebsiteShopArticles.name, info: WebsiteShopArticles.info, iconUrl: WebsiteShopArticles.iconUrl, color: WebsiteShopArticles.color, costs: WebsiteShopArticles.costs, credits: WebsiteShopArticles.credits, duckets: WebsiteShopArticles.duckets, diamonds: WebsiteShopArticles.diamonds, badges: WebsiteShopArticles.badges, position: WebsiteShopArticles.position, } as const; export default async function ShopPage({ searchParams, }: { searchParams: Promise<{ category?: string; bought?: string; error?: string; package?: string; }>; }) { const t = await getTranslations("pages.shop"); const sp = await searchParams; const rawCategory = (sp.category ?? "").trim(); const categoryId = /^\d+$/.test(rawCategory) ? rawCategory : ""; const session = await auth(); const isLoggedIn = Boolean(session?.user?.id); const errorMessage = sp.error === "invalid" ? t("errors.invalid") : sp.error === "credits" ? t("errors.credits") : sp.error === "ratelimit" ? t("errors.ratelimit") : sp.error ? t("errors.error") : null; const boughtMessage = sp.bought === "1" ? sp.package ? t("success.boughtNamed", { name: sp.package }) : t("success.bought") : null; // Categories for the filter bar. let categories: ShopCategory[] = []; try { categories = await db .select({ id: WebsiteShopCategories.id, name: WebsiteShopCategories.name, icon: WebsiteShopCategories.icon, }) .from(WebsiteShopCategories) .orderBy(asc(WebsiteShopCategories.name)); } catch { categories = []; } let articles: ShopArticle[] = []; try { if (categoryId) { articles = await db .select(articleSelect) .from(WebsiteShopArticles) .where(eq(WebsiteShopArticles.categoryId, BigInt(categoryId))) .orderBy(asc(WebsiteShopArticles.position)); } else { articles = await db .select(articleSelect) .from(WebsiteShopArticles) .orderBy(asc(WebsiteShopArticles.position)); } } catch { articles = []; } const activeCategory = categoryId ? categories.find((c) => String(c.id) === categoryId) : undefined; return (
{boughtMessage ? (
{boughtMessage}
) : null} {errorMessage ? (
{errorMessage} {sp.error === "credits" ? ( <> {" "} {t("topUpLink")} ) : null}
) : null}

{t("creditsHint")} {t("topUpLink")}

{categories.length > 0 ? (
{t("all")} {categories.map((c) => ( {c.name} ))}
) : null}
0 ? t("packagesAvailable", { count: articles.length }) : undefined } padded={articles.length === 0} > {articles.length === 0 ? ( {t("empty")} ) : (
{articles.map((a) => (
{a.iconUrl ? ( // eslint-disable-next-line @next/next/no-img-element ) : null}

{a.name}

{t("priceCredits", { credits: creditPriceLabel(a.costs), usd: priceLabel(a.costs), })}

{excerpt(a.info, 160)}

{a.credits ? ( cr {a.credits.toLocaleString()} ) : null} {a.duckets ? ( du {a.duckets.toLocaleString()} ) : null} {a.diamonds ? ( di {a.diamonds.toLocaleString()} ) : null}
{t("priceCreditsShort", { credits: creditPriceLabel(a.costs), })} {isLoggedIn ? (
) : ( {t("loginToBuy")} )}
))}
)}
); }