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

+12 -11
View File
@@ -1,9 +1,11 @@
import { Megaphone } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { deleteAd, updateAd } from "@/actions/admin-ads";
import { getTranslations } from "next-intl/server";
import { updateAd } from "@/actions/admin-ads";
import { prisma } from "@/lib/prisma";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EditAdDeleteButton } from "../ads-table";
export const dynamic = "force-dynamic";
@@ -26,6 +28,8 @@ export default async function EditAd({
}
if (!ad) notFound();
const t = await getTranslations("pages.admin.ads");
return (
<main>
<div className="flex items-center gap-3 mb-6">
@@ -34,10 +38,10 @@ export default async function EditAd({
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
Edit advertisement
{t("edit")} #{String(ad.id)}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/ads">← Advertisements</Link>
<Link href="/admin/ads">← {t("title")}</Link>
</p>
</div>
</div>
@@ -46,20 +50,17 @@ export default async function EditAd({
<input
name="image"
defaultValue={ad.image}
placeholder="Image URL"
placeholder={t("form.imagePlaceholder")}
required
maxLength={255}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteAd} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(ad.id)} />
<button type="submit" className="btn btn-danger">
Delete advertisement
{t("form.save")}
</button>
</form>
<div style={{ marginTop: "1rem" }}>
<EditAdDeleteButton id={String(ad.id)} />
</div>
</main>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { Trash2 } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { deleteAd } from "@/actions/admin-ads";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { useServerAction } from "@/hooks/use-server-action";
export type AdRow = {
id: string;
image: string;
createdAt: string | null;
};
export function AdsTable({ ads }: { ads: AdRow[] }) {
const t = useTranslations("pages.admin.ads");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleDelete(row: AdRow) {
const ok = await confirm({
title: t("delete"),
description: t("confirmDelete"),
confirmLabel: t("delete"),
cancelLabel: t("form.cancel"),
});
if (!ok) return;
run(() => deleteAd({ id: row.id }), {
successMessage: t("deleteSuccess"),
errorMessage: t("deleteError"),
});
}
return (
<>
{confirmDialog}
<table>
<thead>
<tr>
<th>{t("colPreview")}</th>
<th>{t("colImage")}</th>
<th>{t("colCreated")}</th>
<th>{t("colActions")}</th>
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={ad.id}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image}
alt=""
style={{ maxHeight: 40, maxWidth: 120 }}
/>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
<code>{ad.image}</code>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{ad.createdAt ?? "—"}
</td>
<td>
<div className="flex items-center gap-2">
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
<button
type="button"
className="btn btn-danger btn-sm inline-flex items-center gap-1"
disabled={isPending}
onClick={() => handleDelete(ad)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("delete")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}
export function EditAdDeleteButton({ id }: { id: string }) {
const t = useTranslations("pages.admin.ads");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleDelete() {
const ok = await confirm({
title: t("delete"),
description: t("confirmDelete"),
confirmLabel: t("delete"),
cancelLabel: t("form.cancel"),
});
if (!ok) return;
run(() => deleteAd({ id }), {
successMessage: t("deleteSuccess"),
errorMessage: t("deleteError"),
redirectTo: "/admin/ads",
});
}
return (
<>
{confirmDialog}
<button
type="button"
className="btn btn-danger"
disabled={isPending}
onClick={handleDelete}
>
{t("form.deleteAd")}
</button>
</>
);
}
+10 -35
View File
@@ -4,6 +4,7 @@ import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { AdsTable } from "./ads-table";
export const dynamic = "force-dynamic";
@@ -51,41 +52,15 @@ export default async function AdminAds() {
{ads.length === 0 ? (
<div className="admin-empty">{t("noAds")}</div>
) : (
<table>
<thead>
<tr>
<th>{t("colPreview")}</th>
<th>{t("colImage")}</th>
<th>{t("colCreated")}</th>
<th />
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={String(ad.id)}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image}
alt=""
style={{ maxHeight: 40, maxWidth: 120 }}
/>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
<code>{ad.image}</code>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{ad.createdAt
? ad.createdAt.toISOString().slice(0, 10)
: "—"}
</td>
<td>
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
</td>
</tr>
))}
</tbody>
</table>
<AdsTable
ads={ads.map((ad) => ({
id: String(ad.id),
image: ad.image,
createdAt: ad.createdAt
? ad.createdAt.toISOString().slice(0, 10)
: null,
}))}
/>
)}
</section>
</main>