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,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user