feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 4m23s

Complete remaining product gaps: credit-based shop purchases, guild thread replies, help ticket detail/reply/close, offline message compose, sign-out-everywhere via JWT version, ads delete confirm, soft-fail feedback, rate limits, loading states, and single auth() in site layout.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 21:21:02 +02:00
1 parent ed7db6e048
commit 803e8f36c1
47 files changed
+2793 -358

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
export default function ShopLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
}
+93 -7
View File
@@ -1,13 +1,33 @@
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 { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { creditsPerUnit } from "@/lib/services/paypal";
export const revalidate = 300;
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 {
@@ -15,6 +35,13 @@ function priceLabel(costs: number): string {
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;
@@ -52,13 +79,34 @@ const ARTICLE_SELECT = {
export default async function ShopPage({
searchParams,
}: {
searchParams: Promise<{ category?: string }>;
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 {
@@ -110,6 +158,23 @@ export default async function ShopPage({
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{boughtMessage ? (
<div role="status" style={feedbackStyle("success")}>
{boughtMessage}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
{sp.error === "credits" ? (
<>
{" "}
<Link href="/shop/topup">{t("topUpLink")}</Link>
</>
) : null}
</div>
) : null}
<ContentCard
icon="🛒"
title={t("title")}
@@ -119,6 +184,10 @@ export default async function ShopPage({
: t("subtitle")
}
>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
{t("creditsHint")}{" "}
<Link href="/shop/topup">{t("topUpLink")}</Link>
</p>
{categories.length > 0 ? (
<div
style={{
@@ -184,8 +253,6 @@ export default async function ShopPage({
}}
>
{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}
@@ -198,7 +265,12 @@ export default async function ShopPage({
</div>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: 0 }}>{a.name}</h3>
<span className="muted">{priceLabel(a.costs)}</span>
<span className="muted">
{t("priceCredits", {
credits: creditPriceLabel(a.costs),
usd: priceLabel(a.costs),
})}
</span>
</div>
</div>
@@ -240,10 +312,24 @@ export default async function ShopPage({
gap: "0.5rem",
}}
>
<span style={{ fontWeight: 800 }}>{priceLabel(a.costs)}</span>
<span className="btn btn-primary" aria-disabled="true">
{t("buy")}
<span style={{ fontWeight: 800 }}>
{t("priceCreditsShort", {
credits: creditPriceLabel(a.costs),
})}
</span>
{isLoggedIn ? (
<form action={buyShopArticle} style={{ margin: 0 }}>
<input type="hidden" name="articleId" value={String(a.id)} />
<input type="hidden" name="categoryId" value={categoryId} />
<button type="submit" className="btn btn-primary">
{t("buy")}
</button>
</form>
) : (
<Link href="/login" className="btn btn-primary">
{t("loginToBuy")}
</Link>
)}
</div>
</article>
))}