feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
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:
1 parent
ed7db6e048
commit
803e8f36c1
47 files changed
+2793
-358
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user