fix:manage edit for extra bezoekers payments

This commit is contained in:
2026-03-03 14:28:59 +01:00
parent 0a1d1db9ec
commit e6f52e6a73
6 changed files with 338 additions and 134 deletions

View File

@@ -5,8 +5,20 @@ import {
redirect,
useNavigate,
} from "@tanstack/react-router";
import { Check, Download, LogOut, Search, Users, X } from "lucide-react";
import { useState } from "react";
import {
Check,
ChevronDown,
ChevronsUpDown,
ChevronUp,
Clipboard,
ClipboardCheck,
Download,
LogOut,
Search,
Users,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -46,6 +58,38 @@ function AdminPage() {
const [page, setPage] = useState(1);
const pageSize = 20;
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleCopyManageUrl = (token: string, id: string) => {
const url = `${window.location.origin}/manage/${token}`;
navigator.clipboard.writeText(url).then(() => {
setCopiedId(id);
toast.success("Beheerlink gekopieerd");
setTimeout(() => setCopiedId(null), 2000);
});
};
type SortKey =
| "naam"
| "email"
| "type"
| "details"
| "gasten"
| "betaling"
| "datum";
type SortDir = "asc" | "desc";
const [sortKey, setSortKey] = useState<SortKey>("datum");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
};
const statsQuery = useQuery(orpc.getRegistrationStats.queryOptions());
const registrationsQuery = useQuery(
@@ -147,6 +191,76 @@ function AdminPage() {
.filter((r) => r.registrationType === "watcher")
.reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0);
const sortedRegistrations = useMemo(() => {
const sorted = [...registrations].sort((a, b) => {
let valA: string | number = 0;
let valB: string | number = 0;
switch (sortKey) {
case "naam":
valA = `${a.firstName} ${a.lastName}`.toLowerCase();
valB = `${b.firstName} ${b.lastName}`.toLowerCase();
break;
case "email":
valA = a.email.toLowerCase();
valB = b.email.toLowerCase();
break;
case "type":
valA = a.registrationType ?? "";
valB = b.registrationType ?? "";
break;
case "details":
valA =
(a.registrationType === "performer"
? a.artForm
: `${a.drinkCardValue ?? 5}`) ?? "";
valB =
(b.registrationType === "performer"
? b.artForm
: `${b.drinkCardValue ?? 5}`) ?? "";
break;
case "gasten": {
const countGuests = (g: string | null) => {
if (!g) return 0;
try {
const p = JSON.parse(g);
return Array.isArray(p) ? p.length : 0;
} catch {
return 0;
}
};
valA = countGuests(a.guests as string | null);
valB = countGuests(b.guests as string | null);
break;
}
case "betaling":
valA = a.paymentStatus ?? "pending";
valB = b.paymentStatus ?? "pending";
break;
case "datum":
valA = new Date(a.createdAt).getTime();
valB = new Date(b.createdAt).getTime();
break;
}
if (valA < valB) return sortDir === "asc" ? -1 : 1;
if (valA > valB) return sortDir === "asc" ? 1 : -1;
return 0;
});
return sorted;
}, [registrations, sortKey, sortDir]);
const SortIcon = ({ col }: { col: SortKey }) => {
if (sortKey !== col)
return <ChevronsUpDown className="ml-1 inline h-3.5 w-3.5 opacity-40" />;
return sortDir === "asc" ? (
<ChevronUp className="ml-1 inline h-3.5 w-3.5" />
) : (
<ChevronDown className="ml-1 inline h-3.5 w-3.5" />
);
};
const thClass =
"px-4 py-3 text-left font-medium text-xs text-white/60 uppercase tracking-wider cursor-pointer select-none hover:text-white/90 whitespace-nowrap";
return (
<div className="min-h-screen bg-[#214e51]">
{/* Header */}
@@ -453,38 +567,41 @@ function AdminPage() {
<table className="w-full">
<thead>
<tr className="border-white/10 border-b">
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Naam
<th className={thClass} onClick={() => handleSort("naam")}>
Naam <SortIcon col="naam" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Email
<th className={thClass} onClick={() => handleSort("email")}>
Email <SortIcon col="email" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Telefoon
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Type
<th className={thClass} onClick={() => handleSort("type")}>
Type <SortIcon col="type" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Kunstvorm / Drinkkaart
<th
className={thClass}
onClick={() => handleSort("details")}
>
Details <SortIcon col="details" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Gezelschap
<th
className={thClass}
onClick={() => handleSort("gasten")}
>
Gasten <SortIcon col="gasten" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Ervaring
<th
className={thClass}
onClick={() => handleSort("betaling")}
>
Betaling <SortIcon col="betaling" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
16+
<th className={thClass} onClick={() => handleSort("datum")}>
Datum <SortIcon col="datum" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Betaald
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Betaald
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Datum
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Link
</th>
</tr>
</thead>
@@ -492,128 +609,128 @@ function AdminPage() {
{registrationsQuery.isLoading ? (
<tr>
<td
colSpan={10}
className="px-6 py-8 text-center text-white/60"
colSpan={9}
className="px-4 py-8 text-center text-white/60"
>
Laden...
</td>
</tr>
) : registrations.length === 0 ? (
) : sortedRegistrations.length === 0 ? (
<tr>
<td
colSpan={10}
className="px-6 py-8 text-center text-white/60"
colSpan={9}
className="px-4 py-8 text-center text-white/60"
>
Geen registraties gevonden
</td>
</tr>
) : (
registrations.map((reg) => {
sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer";
const guestCount = (() => {
if (!reg.guests) return 0;
try {
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
const detailLabel = isPerformer
? reg.artForm || "-"
: `${reg.drinkCardValue ?? 5} drinkkaart`;
const dateLabel = (() => {
try {
return new Date(reg.createdAt).toLocaleDateString(
"nl-BE",
{
day: "2-digit",
month: "2-digit",
year: "numeric",
},
);
} catch {
return "-";
}
})();
return (
<tr
key={reg.id}
className="border-white/5 border-b hover:bg-white/5"
>
<td className="px-6 py-4 text-white">
<td className="px-4 py-3 font-medium text-white">
{reg.firstName} {reg.lastName}
</td>
<td className="px-6 py-4 text-white/80">
<td className="px-4 py-3 text-sm text-white/70">
{reg.email}
</td>
<td className="px-6 py-4 text-white/80">
<td className="px-4 py-3 text-sm text-white/60">
{reg.phone || "-"}
</td>
<td className="px-6 py-4">
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
className={`inline-flex items-center rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
</span>
</td>
<td className="px-6 py-4 text-white/80">
{isPerformer
? reg.artForm || "-"
: `${reg.drinkCardValue ?? 5} drinkkaart`}
<td className="px-4 py-3 text-sm text-white/70">
{detailLabel}
</td>
<td className="px-6 py-4 text-white/80">
{isPerformer
? "-"
: (() => {
if (!reg.guests) return "-";
try {
const guests = JSON.parse(
reg.guests as string,
);
const count = Array.isArray(guests)
? guests.length
: 0;
return count > 0
? `${count} gast${count === 1 ? "" : "en"}`
: "-";
} catch {
return "-";
}
})()}
<td className="px-4 py-3 text-sm text-white/70">
{guestCount > 0
? `${guestCount} gast${guestCount === 1 ? "" : "en"}`
: "-"}
</td>
<td className="px-6 py-4 text-white/80">
{isPerformer ? reg.experience || "-" : "-"}
</td>
<td className="px-6 py-4 text-white/80">
<td className="px-4 py-3">
{isPerformer ? (
reg.isOver16 ? (
<span className="text-green-400"></span>
) : (
<span className="text-red-400"></span>
)
) : (
"-"
)}
</td>
<td className="px-6 py-4">
{isPerformer ? (
<span className="text-white/40">-</span>
<span className="text-sm text-white/30">-</span>
) : reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 text-green-400 text-sm">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<title>Betaald icoon</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm">
<Check className="h-3.5 w-3.5" />
Betaald
</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400 text-sm">
<span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
</span>
) : (
<span className="inline-flex items-center gap-1 text-sm text-yellow-400">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<title>In afwachting icoon</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-sm text-yellow-400">
Open
</span>
)}
</td>
<td className="px-6 py-4 text-white/60">
{new Date(reg.createdAt).toLocaleDateString(
"nl-BE",
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
{dateLabel}
</td>
<td className="px-4 py-3">
{reg.managementToken ? (
<button
type="button"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="inline-flex items-center justify-center rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
>
{copiedId === reg.id ? (
<ClipboardCheck className="h-4 w-4 text-green-400" />
) : (
<Clipboard className="h-4 w-4" />
)}
</button>
) : (
<span className="text-sm text-white/20"></span>
)}
</td>
</tr>

View File

@@ -77,11 +77,14 @@ async function handleWebhook({ request }: { request: Request }) {
const orderId = event.data.id;
const customerId = String(event.data.attributes.customer_id);
// Update registration in database
// Update registration in database.
// Covers both "pending" (initial payment) and "extra_payment_pending"
// (delta payment after adding guests to an already-paid registration).
await db
.update(registration)
.set({
paymentStatus: "paid",
paymentAmount: 0, // delta has been settled
lemonsqueezyOrderId: orderId,
lemonsqueezyCustomerId: customerId,
paidAt: new Date(),

View File

@@ -82,6 +82,29 @@ function PendingBadge() {
);
}
function ExtraPaymentBadge({ amountCents }: { amountCents: number }) {
const euros = amountCents / 100;
return (
<div className="inline-flex items-center gap-2 rounded-full border border-orange-400/40 bg-orange-400/10 px-4 py-1.5 font-semibold text-orange-400 text-sm">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-label="Extra betaling vereist"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
Extra betaling vereist {euros.toFixed(euros % 1 === 0 ? 0 : 2)}
</div>
);
}
// ---------------------------------------------------------------------------
// Edit form (inline on the manage page)
// ---------------------------------------------------------------------------
@@ -139,7 +162,9 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
const isWatcher = formData.registrationType === "watcher";
const totalDrinkCard = isWatcher ? calculateDrinkCard(formGuests.length) : 0;
const originalGuestCount = parseGuests(initialData.guests).length;
const isPaid = initialData.paymentStatus === "paid";
const isPaid =
initialData.paymentStatus === "paid" ||
initialData.paymentStatus === "extra_payment_pending";
const extraGuests = formGuests.length - originalGuestCount;
function handleSubmit(e: React.FormEvent) {
@@ -546,7 +571,13 @@ function ManageRegistrationPage() {
{/* Payment status */}
{!isPerformer && (
<div className="mb-6">
{data.paymentStatus === "paid" ? <PaidBadge /> : <PendingBadge />}
{data.paymentStatus === "paid" ? (
<PaidBadge />
) : data.paymentStatus === "extra_payment_pending" ? (
<ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} />
) : (
<PendingBadge />
)}
</div>
)}
@@ -625,14 +656,20 @@ function ManageRegistrationPage() {
{/* Actions */}
<div className="mt-8 flex flex-wrap items-center gap-4">
{!isPerformer && data.paymentStatus !== "paid" && (
{!isPerformer &&
(data.paymentStatus === "pending" ||
data.paymentStatus === "extra_payment_pending") && (
<button
type="button"
onClick={() => checkoutMutation.mutate({ token })}
disabled={checkoutMutation.isPending}
className="bg-teal-400 px-8 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-teal-300 disabled:opacity-50"
className={`px-8 py-3 font-['Intro',sans-serif] text-lg transition-all hover:scale-105 disabled:opacity-50 ${data.paymentStatus === "extra_payment_pending" ? "bg-orange-400 text-white hover:bg-orange-300" : "bg-teal-400 text-[#214e51] hover:bg-teal-300"}`}
>
{checkoutMutation.isPending ? "Laden..." : "Nu betalen"}
{checkoutMutation.isPending
? "Laden..."
: data.paymentStatus === "extra_payment_pending"
? `Extra betalen (€${((data.paymentAmount ?? 0) / 100).toFixed(0)})`
: "Nu betalen"}
</button>
)}
<button

View File

@@ -1,11 +1,18 @@
import { env } from "@kk/env/server";
import nodemailer from "nodemailer";
function createTransport() {
// Singleton transport — created once per module, reused across all email sends.
// Re-creating it on every call causes EventEmitter listener accumulation in
// long-lived Cloudflare Worker processes, triggering memory leak warnings.
let _transport: nodemailer.Transporter | null | undefined;
function getTransport(): nodemailer.Transporter | null {
if (_transport !== undefined) return _transport;
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
_transport = null;
return null;
}
return nodemailer.createTransport({
_transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
@@ -14,6 +21,7 @@ function createTransport() {
pass: env.SMTP_PASS,
},
});
return _transport;
}
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
@@ -223,7 +231,7 @@ export async function sendConfirmationEmail(params: {
wantsToPerform: boolean;
artForm?: string | null;
}) {
const transport = createTransport();
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping confirmation email");
return;
@@ -249,7 +257,7 @@ export async function sendUpdateEmail(params: {
wantsToPerform: boolean;
artForm?: string | null;
}) {
const transport = createTransport();
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping update email");
return;
@@ -272,7 +280,7 @@ export async function sendCancellationEmail(params: {
to: string;
firstName: string;
}) {
const transport = createTransport();
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping cancellation email");
return;

View File

@@ -172,11 +172,28 @@ export const appRouter = {
.handler(async ({ input }) => {
const row = await getActiveRegistration(input.token);
// If already paid and switching to a watcher with fewer guests, we
// still allow the update — payment adjustments are handled manually.
const isPerformer = input.registrationType === "performer";
const guests = isPerformer ? [] : (input.guests ?? []);
// Detect whether an already-paid watcher is adding extra guests so we
// can charge them the delta instead of the full amount.
const isPaid = row.paymentStatus === "paid";
const oldGuestCount = isPerformer
? 0
: parseGuestsJson(row.guests).length;
const newGuestCount = guests.length;
const extraGuests =
!isPerformer && isPaid ? newGuestCount - oldGuestCount : 0;
// Determine the new paymentStatus and paymentAmount (delta in cents).
// Only flag extra_payment_pending when the watcher genuinely owes more.
const newPaymentStatus =
isPaid && extraGuests > 0 ? "extra_payment_pending" : row.paymentStatus;
const newPaymentAmount =
newPaymentStatus === "extra_payment_pending"
? extraGuests * 2 * 100 // €2 per extra guest, in cents
: row.paymentAmount;
await db
.update(registration)
.set({
@@ -191,6 +208,8 @@ export const appRouter = {
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
guests: guests.length > 0 ? JSON.stringify(guests) : null,
extraQuestions: input.extraQuestions || null,
paymentStatus: newPaymentStatus,
paymentAmount: newPaymentAmount,
})
.where(eq(registration.managementToken, input.token));
@@ -202,9 +221,6 @@ export const appRouter = {
artForm: input.artForm,
}).catch((err) => console.error("Failed to send update email:", err));
// Satisfy the linter — row is used to confirm the record existed.
void row;
return { success: true };
}),
@@ -540,11 +556,27 @@ export const appRouter = {
if (!row) throw new Error("Inschrijving niet gevonden");
if (row.paymentStatus === "paid")
throw new Error("Betaling is al voltooid");
if (
row.paymentStatus !== "pending" &&
row.paymentStatus !== "extra_payment_pending"
)
throw new Error("Onverwachte betalingsstatus");
if (row.registrationType === "performer")
throw new Error("Artiesten hoeven niet te betalen");
const guests = parseGuestsJson(row.guests);
const amountInCents = drinkCardCents(guests.length);
// For an extra_payment_pending registration, charge only the delta
// (stored in paymentAmount in cents). For a fresh pending registration
// charge the full drink card price.
const isExtraPayment = row.paymentStatus === "extra_payment_pending";
const amountInCents = isExtraPayment
? (row.paymentAmount ?? 0)
: drinkCardCents(guests.length);
const productDescription = isExtraPayment
? `Extra bijdrage voor ${row.paymentAmount != null ? row.paymentAmount / 200 : 0} extra gast(en)`
: `Toegangskaart voor ${1 + guests.length} perso(o)n(en)`;
const response = await fetch(
"https://api.lemonsqueezy.com/v1/checkouts",
@@ -562,7 +594,7 @@ export const appRouter = {
custom_price: amountInCents,
product_options: {
name: "Kunstenkamp Evenement",
description: `Toegangskaart voor ${1 + guests.length} perso(o)n(en)`,
description: productDescription,
redirect_url: `${env.CORS_ORIGIN}/manage/${input.token}`,
},
checkout_data: {

View File

@@ -7,7 +7,7 @@ config({ path: "../env/.env" });
const app = await alchemy("kk");
// Helper function to get required env var
/** Throws at deploy time if a required variable is missing from the environment. */
function getEnvVar(name: string): string {
const value = process.env[name];
if (!value) {
@@ -19,15 +19,22 @@ function getEnvVar(name: string): string {
export const web = await TanStackStart("web", {
cwd: "../../apps/web",
bindings: {
// Core
DATABASE_URL: getEnvVar("DATABASE_URL"),
CORS_ORIGIN: getEnvVar("CORS_ORIGIN"),
BETTER_AUTH_SECRET: getEnvVar("BETTER_AUTH_SECRET"),
BETTER_AUTH_URL: getEnvVar("BETTER_AUTH_URL"),
// Email (SMTP)
SMTP_HOST: getEnvVar("SMTP_HOST"),
SMTP_PORT: getEnvVar("SMTP_PORT"),
SMTP_USER: getEnvVar("SMTP_USER"),
SMTP_PASS: getEnvVar("SMTP_PASS"),
SMTP_FROM: getEnvVar("SMTP_FROM"),
// Payments (Lemon Squeezy)
LEMON_SQUEEZY_API_KEY: getEnvVar("LEMON_SQUEEZY_API_KEY"),
LEMON_SQUEEZY_STORE_ID: getEnvVar("LEMON_SQUEEZY_STORE_ID"),
LEMON_SQUEEZY_VARIANT_ID: getEnvVar("LEMON_SQUEEZY_VARIANT_ID"),
LEMON_SQUEEZY_WEBHOOK_SECRET: getEnvVar("LEMON_SQUEEZY_WEBHOOK_SECRET"),
},
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
});