Improve shop hub: CurrencyIcon on packages/vouchers, DataTable for transactions.
Local Build and Deploy / deploy (push) Successful in 1m3s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 20:32:45 +02:00
1 parent a2ca9b3c23
commit cbdea70f83
8 files changed
+657 -349

No files matched your search

+32 -19
View File
@@ -1,30 +1,42 @@
"use server";
import { revalidatePath } from "next/cache";
import { positiveBigInt } from "@/lib/api";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
import {
actionError,
actionOk,
type ActionResult,
} from "@/lib/safe-action-shared";
import { logServerError } from "@/lib/server-log";
export async function createVoucher(formData: FormData): Promise<void> {
export async function createVoucher(input: {
code: string;
amount: number;
maxUses: number;
expiresAt?: string;
}): Promise<ActionResult<{ id: string }>> {
await requirePermission(PERMS.SHOP_EDIT);
const code = String(formData.get("code") ?? "")
const code = String(input.code ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const amount = Number(formData.get("amount"));
const maxUsesRaw = Number(formData.get("maxUses"));
const amount = Number(input.amount);
const maxUsesRaw = Number(input.maxUses);
const maxUses =
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
if (!code || !(amount > 0)) return;
if (!code || !(amount > 0)) {
return actionError("Code and a positive amount are required");
}
const expiresRaw = String(formData.get("expiresAt") ?? "")
let expiresAt: Date | null = null;
const expiresRaw = String(input.expiresAt ?? "")
.normalize("NFC")
.trim();
let expiresAt: Date | null = null;
if (expiresRaw) {
const parsed = new Date(expiresRaw);
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
@@ -33,7 +45,7 @@ export async function createVoucher(formData: FormData): Promise<void> {
const now = new Date();
try {
await prisma.websiteShopVouchers.create({
const created = await prisma.websiteShopVouchers.create({
data: {
code,
amount: Math.floor(amount),
@@ -44,29 +56,30 @@ export async function createVoucher(formData: FormData): Promise<void> {
updatedAt: now,
},
});
revalidatePath("/admin/vouchers");
return actionOk({ id: String(created.id) });
} catch (error) {
logServerError("admin.voucher_create_failed", error);
// Unique constraint on `code` (or DB unavailable) — swallow and re-render.
return;
return actionError("Could not create voucher (code may already exist)");
}
revalidatePath("/admin/vouchers");
}
export async function deleteVoucher(formData: FormData): Promise<void> {
export async function deleteVoucher(input: {
id: string;
}): Promise<ActionResult> {
await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
const id = positiveBigInt(String(input.id ?? "").trim());
if (!id) return actionError("Missing voucher id");
try {
await prisma.websiteShopVouchers.delete({ where: { id } });
revalidatePath("/admin/vouchers");
return actionOk();
} catch (error) {
logServerError("admin.voucher_delete_failed", error, {
voucherId: String(id),
});
return;
return actionError("Could not delete voucher");
}
revalidatePath("/admin/vouchers");
}
+41 -14
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
@@ -26,6 +27,24 @@ function statusState(
return undefined;
}
function CurrencyCell({
kind,
value,
}: {
kind: "credits" | "duckets" | "diamonds";
value: number | null | undefined;
}) {
if (value == null) {
return <span className="muted">—</span>;
}
return (
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
<CurrencyIcon kind={kind} size={14} />
{value}
</span>
);
}
export default async function AdminShop() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
@@ -64,7 +83,7 @@ export default async function AdminShop() {
return (
<main>
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "1rem" }}>
<div className="flex items-center justify-end gap-4">
<Link href="/admin/shop/new" className="btn btn-primary">
{t("newPackage")}
</Link>
@@ -82,13 +101,13 @@ export default async function AdminShop() {
</div>
<section className="mt-6">
<h2>
<h2 className="admin-section-title">
{t("packages")} ({packages.length})
</h2>
{packages.length === 0 ? (
<p className="muted">{t("noPackages")}</p>
<div className="admin-empty">{t("noPackages")}</div>
) : (
<div style={{ overflowX: "auto" }}>
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
<table>
<thead>
<tr>
@@ -110,9 +129,15 @@ export default async function AdminShop() {
<Link href={`/admin/shop/${p.id}`}>{p.name}</Link>
</td>
<td>{p.costs}</td>
<td className="muted">{p.credits ?? "—"}</td>
<td className="muted">{p.duckets ?? "—"}</td>
<td className="muted">{p.diamonds ?? "—"}</td>
<td>
<CurrencyCell kind="credits" value={p.credits} />
</td>
<td>
<CurrencyCell kind="duckets" value={p.duckets} />
</td>
<td>
<CurrencyCell kind="diamonds" value={p.diamonds} />
</td>
<td className="muted">{p.giveRank ?? "—"}</td>
<td className="muted">{p.position}</td>
</tr>
@@ -124,16 +149,14 @@ export default async function AdminShop() {
</section>
<section className="mt-6">
<h2>
<h2 className="admin-section-title">
{t("recentOrders")} ({orders.length})
</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
{t("ordersHint")} (<code>website_paypal_transactions</code>).
</p>
<p className="muted -mt-1 mb-3 text-sm">{t("ordersHint")}</p>
{orders.length === 0 ? (
<p className="muted">{t("noOrders")}</p>
<div className="admin-empty">{t("noOrders")}</div>
) : (
<div style={{ overflowX: "auto" }}>
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
<table>
<thead>
<tr>
@@ -153,7 +176,11 @@ export default async function AdminShop() {
<td>
<code>{o.transactionId}</code>
</td>
<td className="muted">{o.userId}</td>
<td className="muted">
<Link href={`/admin/users/${o.userId}`}>
#{o.userId}
</Link>
</td>
<td className="muted">{o.description ?? "—"}</td>
<td>
{o.amount} {o.currency}
+128 -122
View File
@@ -1,35 +1,35 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import type { Prisma } from "@/generated/prisma/client";
import { StatusCard } from "@/components/admin/dashboard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import {
TransactionsTable,
type TransactionRow,
} from "./transactions-table";
export const dynamic = "force-dynamic";
const PER_PAGE = 25;
type Txn = Awaited<
ReturnType<typeof prisma.websitePaypalTransactions.findMany>
>[number];
const PAID_STATUS = new Set(["completed", "paid", "success", "approved"]);
const FAILED_STATUS = new Set([
const PAID_STATUS = ["completed", "paid", "success", "approved"] as const;
const FAILED_STATUS = [
"failed",
"denied",
"refunded",
"cancelled",
"canceled",
]);
] as const;
const PENDING_STATUS = ["pending", "created", "processing"] as const;
function statusState(
function statusKind(
status: string | null | undefined,
): "ok" | "danger" | undefined {
if (!status) return undefined;
): TransactionRow["statusKind"] {
if (!status) return "neutral";
const s = status.toLowerCase();
if (PAID_STATUS.has(s)) return "ok";
if (FAILED_STATUS.has(s)) return "danger";
return undefined;
if ((PAID_STATUS as readonly string[]).includes(s)) return "ok";
if ((FAILED_STATUS as readonly string[]).includes(s)) return "danger";
return "neutral";
}
function fromDate(d: Date | null | undefined): string {
@@ -42,10 +42,22 @@ function formatAmount(amount: number, currency: string): string {
return `${value} ${currency}`;
}
const SORT_MAP: Record<
string,
Prisma.WebsitePaypalTransactionsOrderByWithRelationInput
> = {
id: { id: "desc" },
amount: { amount: "desc" },
date: { createdAt: "desc" },
status: { status: "desc" },
package: { description: "desc" },
payer: { userId: "desc" },
};
export default async function AdminTransactions({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
@@ -53,46 +65,106 @@ export default async function AdminTransactions({
}
const t = await getTranslations("pages.admin.transactions");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const statusFilter = raw.filter_status ?? "";
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1") || 1);
const where: Prisma.WebsitePaypalTransactionsWhereInput = {};
if (statusFilter === "paid") {
where.status = { in: [...PAID_STATUS] };
} else if (statusFilter === "failed") {
where.status = { in: [...FAILED_STATUS] };
} else if (statusFilter === "pending") {
where.status = { in: [...PENDING_STATUS] };
}
if (parsed.search.trim()) {
const q = parsed.search.trim();
const asId = Number(q);
let userIds: number[] = [];
try {
const users = await prisma.user.findMany({
where: { username: { contains: q } },
select: { id: true },
take: 50,
});
userIds = users.map((u) => u.id);
} catch {
userIds = [];
}
where.AND = [
...(where.status ? [{ status: where.status }] : []),
{
OR: [
{ transactionId: { contains: q } },
{ description: { contains: q } },
...(Number.isFinite(asId) && asId > 0 ? [{ userId: asId }] : []),
...(userIds.length ? [{ userId: { in: userIds } }] : []),
],
},
];
delete where.status;
}
let transactions: Txn[] = [];
let total = 0;
let names = new Map<number, string>();
try {
[transactions, total] = await Promise.all([
prisma.websitePaypalTransactions.findMany({
orderBy: { id: "desc" },
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
}),
prisma.websitePaypalTransactions.count(),
]);
total = await prisma.websitePaypalTransactions.count({ where });
} catch {
total = 0;
}
const userIds = [...new Set(transactions.map((t) => t.userId))];
if (userIds.length > 0) {
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const sortKey = parsed.sort ?? "id";
const baseOrder = SORT_MAP[sortKey] ?? { id: "desc" as const };
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
const orderBy = {
[orderField]: parsed.order,
} as Prisma.WebsitePaypalTransactionsOrderByWithRelationInput;
let transactions: Awaited<
ReturnType<typeof prisma.websitePaypalTransactions.findMany>
> = [];
try {
transactions = await prisma.websitePaypalTransactions.findMany({
where,
orderBy,
skip: pagination.offset,
take: pagination.perPage,
});
} catch {
transactions = [];
}
const userIds = [...new Set(transactions.map((x) => x.userId))];
let names = new Map<number, string>();
if (userIds.length > 0) {
try {
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
});
names = new Map(users.map((u) => [u.id, u.username]));
} catch {
names = new Map();
}
} catch {
transactions = [];
total = 0;
names = new Map();
}
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const rows: TransactionRow[] = transactions.map((x) => ({
id: Number(x.id),
payer: names.get(x.userId) ?? `#${x.userId}`,
payerId: x.userId,
amount: formatAmount(x.amount, x.currency),
package: x.description ?? "—",
status: x.status ?? "",
statusKind: statusKind(x.status),
transactionId: x.transactionId,
date: fromDate(x.createdAt),
}));
const pagePaid = transactions.filter(
(t) => statusState(t.status) === "ok",
).length;
const pageFailed = transactions.filter(
(t) => statusState(t.status) === "danger",
).length;
const pagePaid = rows.filter((r) => r.statusKind === "ok").length;
const pageFailed = rows.filter((r) => r.statusKind === "danger").length;
return (
<main>
@@ -113,87 +185,21 @@ export default async function AdminTransactions({
</div>
<section className="mt-6">
<h2 className="admin-section-title">
{t("title")} ({total})
</h2>
{transactions.length === 0 ? (
<h2 className="admin-section-title">{t("title")}</h2>
{total === 0 && !parsed.search && !statusFilter ? (
<div className="admin-empty">{t("noTransactions")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colId")}</th>
<th>{t("colPayer")}</th>
<th>{t("colAmount")}</th>
<th>{t("colPackage")}</th>
<th>{t("colStatus")}</th>
<th>{t("colTransaction")}</th>
<th>{t("colDate")}</th>
</tr>
</thead>
<tbody>
{transactions.map((x) => {
const state = statusState(x.status);
return (
<tr key={String(x.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{String(x.id)}
</td>
<td>
<Link href={`/admin/users/${x.userId}`}>
{names.get(x.userId) ?? `#${x.userId}`}
</Link>
</td>
<td>{formatAmount(x.amount, x.currency)}</td>
<td>{x.description ?? "—"}</td>
<td>
{x.status ? (
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${state === "ok" ? "bg-[var(--admin-success-subtle)] text-[var(--admin-text)]" : state === "danger" ? "bg-[var(--admin-error-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-accent)]/18 text-[var(--admin-text)]"}`}
>
{x.status}
</span>
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{x.transactionId}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fromDate(x.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TransactionsTable
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
/>
)}
</section>
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t("pagination", { page, pages, total })}
{page > 1 ? (
<>
{" · "}
<Link href={`/admin/transactions?page=${page - 1}`}>
← {t("prev")}
</Link>
</>
) : null}
{page < pages ? (
<>
{" · "}
<Link href={`/admin/transactions?page=${page + 1}`}>
{t("next")} →
</Link>
</>
) : null}
</p>
</main>
);
}
@@ -0,0 +1,90 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import type { DataTableColumn, PaginatedResult } from "@/types";
export interface TransactionRow {
id: number;
payer: string;
payerId: number;
amount: string;
package: string;
status: string;
statusKind: "ok" | "danger" | "neutral";
transactionId: string;
date: string;
}
function statusVariant(
kind: TransactionRow["statusKind"],
): "default" | "destructive" | "secondary" {
if (kind === "ok") return "default";
if (kind === "danger") return "destructive";
return "secondary";
}
export function TransactionsTable({
data,
}: {
data: PaginatedResult<TransactionRow>;
}) {
const t = useTranslations("pages.admin.transactions");
const columns: DataTableColumn<TransactionRow>[] = [
{ key: "id", label: t("colId"), sortable: true },
{
key: "payer",
label: t("colPayer"),
sortable: true,
render: (_value, row) => (
<Link
href={`/admin/users/${row.payerId}`}
className="font-medium hover:underline"
>
{row.payer}
</Link>
),
},
{ key: "amount", label: t("colAmount"), sortable: true },
{ key: "package", label: t("colPackage"), sortable: true },
{
key: "status",
label: t("colStatus"),
sortable: true,
filterKey: "filter_status",
filterOptions: [
{ label: t("statusCompleted"), value: "paid" },
{ label: t("statusPending"), value: "pending" },
{ label: t("statusFailed"), value: "failed" },
],
render: (_value, row) =>
row.status ? (
<Badge
variant={statusVariant(row.statusKind)}
className="font-mono text-[0.7rem]"
>
{row.status}
</Badge>
) : (
<span className="text-muted-foreground">—</span>
),
},
{ key: "transactionId", label: t("colTransaction") },
{ key: "date", label: t("colDate"), sortable: true },
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
presets={[
{ label: t("presetPaid"), params: { filter_status: "paid" } },
{ label: t("presetFailed"), params: { filter_status: "failed" } },
]}
/>
);
}
+32 -194
View File
@@ -1,39 +1,22 @@
import { getTranslations } from "next-intl/server";
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { VouchersClient, type VoucherRow } from "./vouchers-client";
export const dynamic = "force-dynamic";
type VoucherRow = {
id: bigint;
code: string;
amount: number;
maxUses: number;
useCount: number;
expiresAt: Date | null;
createdAt: Date | null;
};
function fmtDate(d: Date | null): string {
if (!d) return "—";
return d.toISOString().slice(0, 16).replace("T", " ");
}
export default async function AdminVouchers() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.vouchers");
const canEdit = canAccess(permissions, PERMS.SHOP_EDIT, session.user.rank);
const now = Date.now();
let vouchers: VoucherRow[] = [];
let rows: VoucherRow[] = [];
try {
vouchers = await prisma.websiteShopVouchers.findMany({
const vouchers = await prisma.websiteShopVouchers.findMany({
select: {
id: true,
code: true,
@@ -46,182 +29,37 @@ export default async function AdminVouchers() {
orderBy: { id: "desc" },
take: 200,
});
rows = vouchers.map((v) => {
const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false;
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
const status: VoucherRow["status"] = expired
? "expired"
: usedUp
? "usedUp"
: "active";
return {
id: String(v.id),
code: v.code,
amount: v.amount,
maxUses: v.maxUses,
useCount: v.useCount,
expiresAt: v.expiresAt?.toISOString() ?? null,
createdAt: v.createdAt?.toISOString() ?? null,
status,
};
});
} catch {
vouchers = [];
rows = [];
}
const active = vouchers.filter((v) => {
const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false;
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
return !expired && !usedUp;
}).length;
const expiredCount = vouchers.filter(
(v) => v.expiresAt && v.expiresAt.getTime() <= now,
).length;
const usedUpCount = vouchers.filter(
(v) => v.maxUses > 0 && v.useCount >= v.maxUses,
).length;
const stats = {
total: rows.length,
active: rows.filter((v) => v.status === "active").length,
usedUp: rows.filter((v) => v.status === "usedUp").length,
expired: rows.filter((v) => v.status === "expired").length,
};
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("vouchers")} value={vouchers.length} icon="🎟️" />
<StatusCard
label={t("active")}
value={active}
state={active > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard
label={t("usedUp")}
value={usedUpCount}
state={usedUpCount > 0 ? "warn" : "neutral"}
icon="📉"
/>
<StatusCard
label={t("expired")}
value={expiredCount}
state={expiredCount > 0 ? "danger" : "neutral"}
icon="⏰"
/>
</div>
<form action={createVoucher} className="admin-card mb-6">
<h3 style={{ marginTop: 0 }}>{t("newVoucher")}</h3>
<div
className="grid grid-cols-1 gap-4 md:grid-cols-2"
style={{ marginBottom: "0.75rem" }}
>
<label
style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}
>
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.code")}
</span>
<input
name="code"
placeholder={t("form.codePlaceholder")}
maxLength={255}
required
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}
>
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.amount")}
</span>
<input
name="amount"
type="number"
min={1}
placeholder={t("form.amountPlaceholder")}
required
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}
>
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.maxUses")}
</span>
<input
name="maxUses"
type="number"
min={1}
defaultValue={1}
required
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}
>
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.expiresAt")}
</span>
<input name="expiresAt" type="datetime-local" />
</label>
</div>
<button type="submit" className="btn btn-primary">
{t("form.create")}
</button>
</form>
<section className="mt-6">
<h2 className="admin-section-title">
{t("title")} ({vouchers.length})
</h2>
<div className="admin-card">
<table>
<thead>
<tr>
<th>{t("colCode")}</th>
<th>{t("colAmount")}</th>
<th>{t("colUses")}</th>
<th>{t("colStatus")}</th>
<th>{t("colExpires")}</th>
<th>{t("colCreated")}</th>
<th />
</tr>
</thead>
<tbody>
{vouchers.map((v) => {
const expired = v.expiresAt
? v.expiresAt.getTime() <= now
: false;
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
return (
<tr key={v.id.toString()}>
<td>
<code>{v.code}</code>
</td>
<td>{v.amount}</td>
<td>
{v.useCount} / {v.maxUses}
</td>
<td>
{expired ? (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
{t("statusExpired")}
</span>
) : usedUp ? (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{t("statusUsedUp")}
</span>
) : (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-success-subtle)] text-[var(--admin-text)]">
{t("statusActive")}
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fmtDate(v.expiresAt)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fmtDate(v.createdAt)}
</td>
<td>
<form action={deleteVoucher}>
<input
type="hidden"
name="id"
value={v.id.toString()}
/>
<button type="submit" className="btn btn-danger">
{t("delete")}
</button>
</form>
</td>
</tr>
);
})}
</tbody>
</table>
{vouchers.length === 0 ? (
<div className="admin-empty">{t("noVouchers")}</div>
) : null}
</div>
</section>
</main>
<VouchersClient vouchers={rows} canEdit={canEdit} stats={stats} />
);
}
+262
View File
@@ -0,0 +1,262 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
import { StatusCard } from "@/components/admin/dashboard";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { useServerAction } from "@/hooks/use-server-action";
export type VoucherRow = {
id: string;
code: string;
amount: number;
maxUses: number;
useCount: number;
expiresAt: string | null;
createdAt: string | null;
status: "active" | "expired" | "usedUp";
};
function fmtDate(iso: string | null): string {
if (!iso) return "—";
return iso.slice(0, 16).replace("T", " ");
}
export function VouchersClient({
vouchers,
canEdit,
stats,
}: {
vouchers: VoucherRow[];
canEdit: boolean;
stats: { total: number; active: number; usedUp: number; expired: number };
}) {
const t = useTranslations("pages.admin.vouchers");
const { run, isPending } = useServerAction();
const [pendingDelete, setPendingDelete] = useState<VoucherRow | null>(null);
const [code, setCode] = useState("");
const [amount, setAmount] = useState("");
const [maxUses, setMaxUses] = useState("1");
const [expiresAt, setExpiresAt] = useState("");
function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!canEdit) return;
run(
() =>
createVoucher({
code,
amount: Number(amount),
maxUses: Number(maxUses),
expiresAt: expiresAt || undefined,
}),
{
successMessage: t("created"),
errorMessage: t("createError"),
onSuccess: () => {
setCode("");
setAmount("");
setMaxUses("1");
setExpiresAt("");
},
},
);
}
function handleConfirmDelete() {
if (!pendingDelete) return;
const id = pendingDelete.id;
run(() => deleteVoucher({ id }), {
successMessage: t("deleted"),
errorMessage: t("deleteError"),
onSuccess: () => setPendingDelete(null),
});
}
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("vouchers")} value={stats.total} icon="🎟️" />
<StatusCard
label={t("active")}
value={stats.active}
state={stats.active > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard
label={t("usedUp")}
value={stats.usedUp}
state={stats.usedUp > 0 ? "warn" : "neutral"}
icon="📉"
/>
<StatusCard
label={t("expired")}
value={stats.expired}
state={stats.expired > 0 ? "danger" : "neutral"}
icon="⏰"
/>
</div>
{canEdit && (
<form onSubmit={handleCreate} className="admin-card mb-6">
<h3 style={{ marginTop: 0 }}>{t("newVoucher")}</h3>
<div className="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.code")}
</span>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("form.codePlaceholder")}
maxLength={255}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.amount")}
</span>
<input
value={amount}
onChange={(e) => setAmount(e.target.value)}
type="number"
min={1}
placeholder={t("form.amountPlaceholder")}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.maxUses")}
</span>
<input
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
type="number"
min={1}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.expiresAt")}
</span>
<input
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
type="datetime-local"
disabled={isPending}
/>
</label>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={isPending}
>
{isPending ? t("form.saving") : t("form.create")}
</button>
</form>
)}
<section className="mt-6">
<h2 className="admin-section-title">
{t("title")} ({vouchers.length})
</h2>
<div className="admin-card" style={{ padding: 0, overflowX: "auto" }}>
{vouchers.length === 0 ? (
<div className="admin-empty p-4">{t("noVouchers")}</div>
) : (
<table>
<thead>
<tr>
<th>{t("colCode")}</th>
<th>{t("colAmount")}</th>
<th>{t("colUses")}</th>
<th>{t("colStatus")}</th>
<th>{t("colExpires")}</th>
<th>{t("colCreated")}</th>
{canEdit && <th>{t("colActions")}</th>}
</tr>
</thead>
<tbody>
{vouchers.map((v) => (
<tr key={v.id}>
<td>
<code>{v.code}</code>
</td>
<td>
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
<CurrencyIcon kind="credits" size={14} />
{v.amount}
</span>
</td>
<td>
{v.useCount} / {v.maxUses}
</td>
<td>
{v.status === "expired" ? (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
{t("statusExpired")}
</span>
) : v.status === "usedUp" ? (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{t("statusUsedUp")}
</span>
) : (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-success-subtle)] text-[var(--admin-text)]">
{t("statusActive")}
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fmtDate(v.expiresAt)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fmtDate(v.createdAt)}
</td>
{canEdit && (
<td>
<button
type="button"
className="btn btn-danger"
disabled={isPending}
onClick={() => setPendingDelete(v)}
>
{t("delete")}
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(open) => {
if (!open) setPendingDelete(null);
}}
title={t("confirmDeleteTitle")}
description={
pendingDelete
? t("confirmDelete", { code: pendingDelete.code })
: ""
}
confirmLabel={t("delete")}
cancelLabel={t("form.cancel")}
loading={isPending}
onConfirm={handleConfirmDelete}
/>
</main>
);
}
+36
View File
@@ -1229,6 +1229,9 @@
"subtitle": "Manage packages and orders",
"packages": "Packages",
"orders": "Orders",
"recentOrders": "Recent orders",
"paidOrders": "Paid orders",
"ordersHint": "Latest PayPal transactions",
"newPackage": "+ New package",
"edit": "Edit",
"delete": "Delete",
@@ -1239,6 +1242,11 @@
"colId": "ID",
"colName": "Name",
"colPrice": "Price",
"colCredits": "Credits",
"colDuckets": "Duckets",
"colDiamonds": "Diamonds",
"colRank": "Rank",
"colPosition": "Position",
"colCurrency": "Currency",
"colType": "Type",
"colSold": "Sold",
@@ -1247,6 +1255,9 @@
"colOrderId": "Order ID",
"colCustomer": "Customer",
"colPackage": "Package",
"colTransaction": "Transaction",
"colUser": "User",
"colDescription": "Description",
"colAmount": "Amount",
"colStatus": "Status",
"colDate": "Date",
@@ -1283,12 +1294,20 @@
"title": "Transactions",
"subtitle": "PayPal payment log",
"noTransactions": "No transactions yet",
"totalTransactions": "Total transactions",
"pagePaid": "Paid (this page)",
"pageFailed": "Failed (this page)",
"searchPlaceholder": "Search by user, transaction ID or package…",
"presetPaid": "Paid",
"presetFailed": "Failed",
"colId": "ID",
"colUser": "User",
"colPayer": "Payer",
"colAmount": "Amount",
"colCurrency": "Currency",
"colPackage": "Package",
"colStatus": "Status",
"colTransaction": "Transaction ID",
"colDate": "Date",
"colActions": "Actions",
"statusCompleted": "Completed",
@@ -1307,16 +1326,30 @@
"vouchers": {
"title": "Shop Vouchers",
"subtitle": "Manage redeemable voucher codes",
"vouchers": "Vouchers",
"active": "Active",
"usedUp": "Used up",
"expired": "Expired",
"newVoucher": "+ New voucher",
"edit": "Edit",
"delete": "Delete",
"noVouchers": "No vouchers yet",
"confirmDeleteTitle": "Delete voucher",
"confirmDelete": "Delete voucher \"{code}\"?",
"created": "Voucher created",
"deleted": "Voucher deleted",
"createError": "Could not create voucher",
"deleteError": "Could not delete voucher",
"statusActive": "Active",
"statusUsedUp": "Used up",
"statusExpired": "Expired",
"colId": "ID",
"colCode": "Code",
"colValue": "Value",
"colAmount": "Amount",
"colType": "Type",
"colUses": "Uses / Max",
"colStatus": "Status",
"colExpires": "Expires",
"colCreated": "Created",
"colActions": "Actions",
@@ -1324,6 +1357,8 @@
"code": "Code",
"codePlaceholder": "e.g. SUMMER2025",
"value": "Value",
"amount": "Credits amount",
"amountPlaceholder": "e.g. 100",
"type": "Type",
"typeCredits": "Credits",
"typeDuckets": "Duckets",
@@ -1338,6 +1373,7 @@
"maxUsesPlaceholder": "0 = unlimited",
"expiresAt": "Expires at",
"enabled": "Enabled",
"create": "Create voucher",
"save": "Save voucher",
"saving": "Saving…",
"cancel": "Cancel"
+36
View File
@@ -1193,6 +1193,9 @@
"subtitle": "Gestisci pacchetti e ordini",
"packages": "Pacchetti",
"orders": "Ordini",
"recentOrders": "Ordini recenti",
"paidOrders": "Ordini pagati",
"ordersHint": "Ultime transazioni PayPal",
"newPackage": "+ Nuovo pacchetto",
"edit": "Modifica",
"delete": "Elimina",
@@ -1203,6 +1206,11 @@
"colId": "ID",
"colName": "Nome",
"colPrice": "Prezzo",
"colCredits": "Crediti",
"colDuckets": "Duckets",
"colDiamonds": "Diamanti",
"colRank": "Grado",
"colPosition": "Posizione",
"colCurrency": "Valuta",
"colType": "Tipo",
"colSold": "Venduti",
@@ -1211,6 +1219,9 @@
"colOrderId": "ID ordine",
"colCustomer": "Cliente",
"colPackage": "Pacchetto",
"colTransaction": "Transazione",
"colUser": "Utente",
"colDescription": "Descrizione",
"colAmount": "Importo",
"colStatus": "Stato",
"colDate": "Data",
@@ -1247,12 +1258,20 @@
"title": "Transazioni",
"subtitle": "Registro pagamenti PayPal",
"noTransactions": "Ancora nessuna transazione",
"totalTransactions": "Transazioni totali",
"pagePaid": "Pagate (questa pagina)",
"pageFailed": "Fallite (questa pagina)",
"searchPlaceholder": "Cerca per utente, ID transazione o pacchetto…",
"presetPaid": "Pagate",
"presetFailed": "Fallite",
"colId": "ID",
"colUser": "Utente",
"colPayer": "Pagatore",
"colAmount": "Importo",
"colCurrency": "Valuta",
"colPackage": "Pacchetto",
"colStatus": "Stato",
"colTransaction": "ID transazione",
"colDate": "Data",
"colActions": "Azioni",
"statusCompleted": "Completato",
@@ -1271,16 +1290,30 @@
"vouchers": {
"title": "Buoni negozio",
"subtitle": "Gestisci codici buono riscattabili",
"vouchers": "Buoni",
"active": "Attivi",
"usedUp": "Esauriti",
"expired": "Scaduti",
"newVoucher": "+ Nuovo buono",
"edit": "Modifica",
"delete": "Elimina",
"noVouchers": "Ancora nessun buono",
"confirmDeleteTitle": "Elimina buono",
"confirmDelete": "Eliminare il buono \"{code}\"?",
"created": "Buono creato",
"deleted": "Buono eliminato",
"createError": "Impossibile creare il buono",
"deleteError": "Impossibile eliminare il buono",
"statusActive": "Attivo",
"statusUsedUp": "Esaurito",
"statusExpired": "Scaduto",
"colId": "ID",
"colCode": "Codice",
"colValue": "Valore",
"colAmount": "Importo",
"colType": "Tipo",
"colUses": "Usi / Max",
"colStatus": "Stato",
"colExpires": "Scade",
"colCreated": "Creato",
"colActions": "Azioni",
@@ -1288,6 +1321,8 @@
"code": "Codice",
"codePlaceholder": "es. ESTATE2025",
"value": "Valore",
"amount": "Crediti",
"amountPlaceholder": "es. 100",
"type": "Tipo",
"typeCredits": "Crediti",
"typeDuckets": "Duckets",
@@ -1302,6 +1337,7 @@
"maxUsesPlaceholder": "0 = illimitato",
"expiresAt": "Scade il",
"enabled": "Attivo",
"create": "Crea buono",
"save": "Salva buono",
"saving": "Salvataggio…",
"cancel": "Annulla"