Files
Epicnabbo-Catalogus-Updated…/src/app/admin/calendar/[id]/page.tsx
T
simoleoandCursor 0e89d03940 Finish fine-grained ACL across remaining admin pages and actions.
Replace leftover requireStaff gates with module PERMS, drop hardcoded room rank thresholds, and expand contract tests so admin mutations cannot regress to dashboard-only checks.

Co-authored-by: Cursor <[email protected]>
2026-07-15 20:15:22 +02:00

212 lines
5.3 KiB
TypeScript

import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { prisma } from "@/lib/prisma";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
type Campaign = {
id: number;
name: string;
image: string;
startTimestamp: number;
totalDays: number;
lockExpired: string;
enabled: string;
};
type Reward = {
id: number;
campaignId: number;
productName: string;
customImage: string;
credits: number;
pixels: number;
points: number;
pointsType: number;
badge: string;
itemId: number;
subscriptionType: string | null;
subscriptionDays: number;
};
function fmtDate(ts: number): string {
if (!ts) return "—";
return new Date(ts * 1000).toISOString().slice(0, 10);
}
export default async function AdminCalendarCampaign({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.calendar");
const { id } = await params;
const campaignId = Number(id);
if (!Number.isInteger(campaignId)) notFound();
let campaign: Campaign | null = null;
try {
campaign = await prisma.calendarCampaigns.findUnique({
where: { id: campaignId },
select: {
id: true,
name: true,
image: true,
startTimestamp: true,
totalDays: true,
lockExpired: true,
enabled: true,
},
});
} catch {
campaign = null;
}
if (!campaign) notFound();
let rewards: Reward[] = [];
try {
rewards = await prisma.calendarRewards.findMany({
where: { campaignId },
select: {
id: true,
campaignId: true,
productName: true,
customImage: true,
credits: true,
pixels: true,
points: true,
pointsType: true,
badge: true,
itemId: true,
subscriptionType: true,
subscriptionDays: true,
},
orderBy: { id: "asc" },
take: 500,
});
} catch {
rewards = [];
}
const disabled = campaign.enabled !== "1";
return (
<main>
<p className="text-xs theme-text-muted dark:theme-text-muted mb-4">
<Link href="/admin/calendar">← {t("title")}</Link>
</p>
<div className="admin-card flex items-center gap-5 mb-6">
{campaign.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
className="w-24 h-24 object-cover"
src={campaign.image}
alt={campaign.name}
/>
) : null}
<div>
<h1 className="m-0 mb-1">{campaign.name || `#${campaign.id}`}</h1>
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
{t("detail.campaignMeta", {
id: campaign.id,
days: campaign.totalDays,
date: fmtDate(campaign.startTimestamp),
})}
{disabled
? ` · ${t("detail.disabled")}`
: ` · ${t("detail.enabled")}`}
{campaign.lockExpired === "1"
? ` · ${t("detail.locksExpired")}`
: ""}
</p>
</div>
</div>
<h2 className="admin-section-title">{t("detail.rewards")}</h2>
{rewards.length === 0 ? (
<div className="admin-empty">{t("detail.noRewards")}</div>
) : (
<table className="w-full">
<thead>
<tr className="text-left text-[var(--muted)]">
<th>{t("detail.colId")}</th>
<th>{t("detail.colProduct")}</th>
<th>{t("detail.colItem")}</th>
<th>{t("detail.colCredits")}</th>
<th>{t("detail.colPixels")}</th>
<th>{t("detail.colPoints")}</th>
<th>{t("detail.colBadge")}</th>
<th>{t("detail.colSubscription")}</th>
</tr>
</thead>
<tbody>
{rewards.map((r) => (
<tr key={r.id} className="border-t border-[var(--admin-border)]">
<td>{r.id}</td>
<td>
{r.productName || (
<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">
{r.itemId > 0 ? r.itemId : "—"}
</td>
<td>
{r.credits > 0 ? (
r.credits
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td>
{r.pixels > 0 ? (
r.pixels
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td>
{r.points > 0 ? (
`${r.points} (${t("detail.typeLabel", { type: r.pointsType })})`
) : (
<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">
{r.badge || "—"}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{r.subscriptionType
? `${r.subscriptionType} · ${r.subscriptionDays}d`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
<p className="text-xs theme-text-muted dark:theme-text-muted mt-4">
{t("detail.rewardCount", { count: rewards.length })}
</p>
</main>
);
}