fix:manage edit for extra bezoekers payments
This commit is contained in:
@@ -5,8 +5,20 @@ import {
|
|||||||
redirect,
|
redirect,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { Check, Download, LogOut, Search, Users, X } from "lucide-react";
|
import {
|
||||||
import { useState } from "react";
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronsUpDown,
|
||||||
|
ChevronUp,
|
||||||
|
Clipboard,
|
||||||
|
ClipboardCheck,
|
||||||
|
Download,
|
||||||
|
LogOut,
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +58,38 @@ function AdminPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 20;
|
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 statsQuery = useQuery(orpc.getRegistrationStats.queryOptions());
|
||||||
|
|
||||||
const registrationsQuery = useQuery(
|
const registrationsQuery = useQuery(
|
||||||
@@ -147,6 +191,76 @@ function AdminPage() {
|
|||||||
.filter((r) => r.registrationType === "watcher")
|
.filter((r) => r.registrationType === "watcher")
|
||||||
.reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0);
|
.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 (
|
return (
|
||||||
<div className="min-h-screen bg-[#214e51]">
|
<div className="min-h-screen bg-[#214e51]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -453,38 +567,41 @@ function AdminPage() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-white/10 border-b">
|
<tr className="border-white/10 border-b">
|
||||||
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
<th className={thClass} onClick={() => handleSort("naam")}>
|
||||||
Naam
|
Naam <SortIcon col="naam" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
<th className={thClass} onClick={() => handleSort("email")}>
|
||||||
Email
|
Email <SortIcon col="email" />
|
||||||
</th>
|
</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
|
Telefoon
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
<th className={thClass} onClick={() => handleSort("type")}>
|
||||||
Type
|
Type <SortIcon col="type" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
<th
|
||||||
Kunstvorm / Drinkkaart
|
className={thClass}
|
||||||
|
onClick={() => handleSort("details")}
|
||||||
|
>
|
||||||
|
Details <SortIcon col="details" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
<th
|
||||||
Gezelschap
|
className={thClass}
|
||||||
|
onClick={() => handleSort("gasten")}
|
||||||
|
>
|
||||||
|
Gasten <SortIcon col="gasten" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
<th
|
||||||
Ervaring
|
className={thClass}
|
||||||
|
onClick={() => handleSort("betaling")}
|
||||||
|
>
|
||||||
|
Betaling <SortIcon col="betaling" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
<th className={thClass} onClick={() => handleSort("datum")}>
|
||||||
16+
|
Datum <SortIcon col="datum" />
|
||||||
</th>
|
</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">
|
||||||
Betaald
|
Link
|
||||||
</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>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -492,128 +609,128 @@ function AdminPage() {
|
|||||||
{registrationsQuery.isLoading ? (
|
{registrationsQuery.isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={10}
|
colSpan={9}
|
||||||
className="px-6 py-8 text-center text-white/60"
|
className="px-4 py-8 text-center text-white/60"
|
||||||
>
|
>
|
||||||
Laden...
|
Laden...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : registrations.length === 0 ? (
|
) : sortedRegistrations.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={10}
|
colSpan={9}
|
||||||
className="px-6 py-8 text-center text-white/60"
|
className="px-4 py-8 text-center text-white/60"
|
||||||
>
|
>
|
||||||
Geen registraties gevonden
|
Geen registraties gevonden
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
registrations.map((reg) => {
|
sortedRegistrations.map((reg) => {
|
||||||
const isPerformer = reg.registrationType === "performer";
|
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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={reg.id}
|
key={reg.id}
|
||||||
className="border-white/5 border-b hover:bg-white/5"
|
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}
|
{reg.firstName} {reg.lastName}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-white/80">
|
<td className="px-4 py-3 text-sm text-white/70">
|
||||||
{reg.email}
|
{reg.email}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-white/80">
|
<td className="px-4 py-3 text-sm text-white/60">
|
||||||
{reg.phone || "-"}
|
{reg.phone || "-"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<span
|
<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"}
|
{isPerformer ? "Artiest" : "Bezoeker"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-white/80">
|
<td className="px-4 py-3 text-sm text-white/70">
|
||||||
{isPerformer
|
{detailLabel}
|
||||||
? reg.artForm || "-"
|
|
||||||
: `€${reg.drinkCardValue ?? 5} drinkkaart`}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-white/80">
|
<td className="px-4 py-3 text-sm text-white/70">
|
||||||
{isPerformer
|
{guestCount > 0
|
||||||
? "-"
|
? `${guestCount} gast${guestCount === 1 ? "" : "en"}`
|
||||||
: (() => {
|
: "-"}
|
||||||
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>
|
</td>
|
||||||
<td className="px-6 py-4 text-white/80">
|
<td className="px-4 py-3">
|
||||||
{isPerformer ? reg.experience || "-" : "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-white/80">
|
|
||||||
{isPerformer ? (
|
{isPerformer ? (
|
||||||
reg.isOver16 ? (
|
<span className="text-sm text-white/30">-</span>
|
||||||
<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>
|
|
||||||
) : reg.paymentStatus === "paid" ? (
|
) : reg.paymentStatus === "paid" ? (
|
||||||
<span className="inline-flex items-center gap-1 text-green-400 text-sm">
|
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm">
|
||||||
<svg
|
<Check className="h-3.5 w-3.5" />
|
||||||
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>
|
|
||||||
Betaald
|
Betaald
|
||||||
</span>
|
</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">
|
<span className="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>
|
|
||||||
Open
|
Open
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-white/60">
|
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
|
||||||
{new Date(reg.createdAt).toLocaleDateString(
|
{dateLabel}
|
||||||
"nl-BE",
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -77,11 +77,14 @@ async function handleWebhook({ request }: { request: Request }) {
|
|||||||
const orderId = event.data.id;
|
const orderId = event.data.id;
|
||||||
const customerId = String(event.data.attributes.customer_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
|
await db
|
||||||
.update(registration)
|
.update(registration)
|
||||||
.set({
|
.set({
|
||||||
paymentStatus: "paid",
|
paymentStatus: "paid",
|
||||||
|
paymentAmount: 0, // delta has been settled
|
||||||
lemonsqueezyOrderId: orderId,
|
lemonsqueezyOrderId: orderId,
|
||||||
lemonsqueezyCustomerId: customerId,
|
lemonsqueezyCustomerId: customerId,
|
||||||
paidAt: new Date(),
|
paidAt: new Date(),
|
||||||
|
|||||||
@@ -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)
|
// Edit form (inline on the manage page)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -139,7 +162,9 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
|
|||||||
const isWatcher = formData.registrationType === "watcher";
|
const isWatcher = formData.registrationType === "watcher";
|
||||||
const totalDrinkCard = isWatcher ? calculateDrinkCard(formGuests.length) : 0;
|
const totalDrinkCard = isWatcher ? calculateDrinkCard(formGuests.length) : 0;
|
||||||
const originalGuestCount = parseGuests(initialData.guests).length;
|
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;
|
const extraGuests = formGuests.length - originalGuestCount;
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
@@ -546,7 +571,13 @@ function ManageRegistrationPage() {
|
|||||||
{/* Payment status */}
|
{/* Payment status */}
|
||||||
{!isPerformer && (
|
{!isPerformer && (
|
||||||
<div className="mb-6">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -625,14 +656,20 @@ function ManageRegistrationPage() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="mt-8 flex flex-wrap items-center gap-4">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => checkoutMutation.mutate({ token })}
|
onClick={() => checkoutMutation.mutate({ token })}
|
||||||
disabled={checkoutMutation.isPending}
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { env } from "@kk/env/server";
|
import { env } from "@kk/env/server";
|
||||||
import nodemailer from "nodemailer";
|
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) {
|
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
|
||||||
|
_transport = null;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return nodemailer.createTransport({
|
_transport = nodemailer.createTransport({
|
||||||
host: env.SMTP_HOST,
|
host: env.SMTP_HOST,
|
||||||
port: env.SMTP_PORT,
|
port: env.SMTP_PORT,
|
||||||
secure: env.SMTP_PORT === 465,
|
secure: env.SMTP_PORT === 465,
|
||||||
@@ -14,6 +21,7 @@ function createTransport() {
|
|||||||
pass: env.SMTP_PASS,
|
pass: env.SMTP_PASS,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return _transport;
|
||||||
}
|
}
|
||||||
|
|
||||||
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
|
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
|
||||||
@@ -223,7 +231,7 @@ export async function sendConfirmationEmail(params: {
|
|||||||
wantsToPerform: boolean;
|
wantsToPerform: boolean;
|
||||||
artForm?: string | null;
|
artForm?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const transport = createTransport();
|
const transport = getTransport();
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
console.warn("SMTP not configured — skipping confirmation email");
|
console.warn("SMTP not configured — skipping confirmation email");
|
||||||
return;
|
return;
|
||||||
@@ -249,7 +257,7 @@ export async function sendUpdateEmail(params: {
|
|||||||
wantsToPerform: boolean;
|
wantsToPerform: boolean;
|
||||||
artForm?: string | null;
|
artForm?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const transport = createTransport();
|
const transport = getTransport();
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
console.warn("SMTP not configured — skipping update email");
|
console.warn("SMTP not configured — skipping update email");
|
||||||
return;
|
return;
|
||||||
@@ -272,7 +280,7 @@ export async function sendCancellationEmail(params: {
|
|||||||
to: string;
|
to: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
}) {
|
}) {
|
||||||
const transport = createTransport();
|
const transport = getTransport();
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
console.warn("SMTP not configured — skipping cancellation email");
|
console.warn("SMTP not configured — skipping cancellation email");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -172,11 +172,28 @@ export const appRouter = {
|
|||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
const row = await getActiveRegistration(input.token);
|
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 isPerformer = input.registrationType === "performer";
|
||||||
const guests = isPerformer ? [] : (input.guests ?? []);
|
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
|
await db
|
||||||
.update(registration)
|
.update(registration)
|
||||||
.set({
|
.set({
|
||||||
@@ -191,6 +208,8 @@ export const appRouter = {
|
|||||||
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
||||||
guests: guests.length > 0 ? JSON.stringify(guests) : null,
|
guests: guests.length > 0 ? JSON.stringify(guests) : null,
|
||||||
extraQuestions: input.extraQuestions || null,
|
extraQuestions: input.extraQuestions || null,
|
||||||
|
paymentStatus: newPaymentStatus,
|
||||||
|
paymentAmount: newPaymentAmount,
|
||||||
})
|
})
|
||||||
.where(eq(registration.managementToken, input.token));
|
.where(eq(registration.managementToken, input.token));
|
||||||
|
|
||||||
@@ -202,9 +221,6 @@ export const appRouter = {
|
|||||||
artForm: input.artForm,
|
artForm: input.artForm,
|
||||||
}).catch((err) => console.error("Failed to send update email:", err));
|
}).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 };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -540,11 +556,27 @@ export const appRouter = {
|
|||||||
if (!row) throw new Error("Inschrijving niet gevonden");
|
if (!row) throw new Error("Inschrijving niet gevonden");
|
||||||
if (row.paymentStatus === "paid")
|
if (row.paymentStatus === "paid")
|
||||||
throw new Error("Betaling is al voltooid");
|
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")
|
if (row.registrationType === "performer")
|
||||||
throw new Error("Artiesten hoeven niet te betalen");
|
throw new Error("Artiesten hoeven niet te betalen");
|
||||||
|
|
||||||
const guests = parseGuestsJson(row.guests);
|
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(
|
const response = await fetch(
|
||||||
"https://api.lemonsqueezy.com/v1/checkouts",
|
"https://api.lemonsqueezy.com/v1/checkouts",
|
||||||
@@ -562,7 +594,7 @@ export const appRouter = {
|
|||||||
custom_price: amountInCents,
|
custom_price: amountInCents,
|
||||||
product_options: {
|
product_options: {
|
||||||
name: "Kunstenkamp Evenement",
|
name: "Kunstenkamp Evenement",
|
||||||
description: `Toegangskaart voor ${1 + guests.length} perso(o)n(en)`,
|
description: productDescription,
|
||||||
redirect_url: `${env.CORS_ORIGIN}/manage/${input.token}`,
|
redirect_url: `${env.CORS_ORIGIN}/manage/${input.token}`,
|
||||||
},
|
},
|
||||||
checkout_data: {
|
checkout_data: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ config({ path: "../env/.env" });
|
|||||||
|
|
||||||
const app = await alchemy("kk");
|
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 {
|
function getEnvVar(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -19,15 +19,22 @@ function getEnvVar(name: string): string {
|
|||||||
export const web = await TanStackStart("web", {
|
export const web = await TanStackStart("web", {
|
||||||
cwd: "../../apps/web",
|
cwd: "../../apps/web",
|
||||||
bindings: {
|
bindings: {
|
||||||
|
// Core
|
||||||
DATABASE_URL: getEnvVar("DATABASE_URL"),
|
DATABASE_URL: getEnvVar("DATABASE_URL"),
|
||||||
CORS_ORIGIN: getEnvVar("CORS_ORIGIN"),
|
CORS_ORIGIN: getEnvVar("CORS_ORIGIN"),
|
||||||
BETTER_AUTH_SECRET: getEnvVar("BETTER_AUTH_SECRET"),
|
BETTER_AUTH_SECRET: getEnvVar("BETTER_AUTH_SECRET"),
|
||||||
BETTER_AUTH_URL: getEnvVar("BETTER_AUTH_URL"),
|
BETTER_AUTH_URL: getEnvVar("BETTER_AUTH_URL"),
|
||||||
|
// Email (SMTP)
|
||||||
SMTP_HOST: getEnvVar("SMTP_HOST"),
|
SMTP_HOST: getEnvVar("SMTP_HOST"),
|
||||||
SMTP_PORT: getEnvVar("SMTP_PORT"),
|
SMTP_PORT: getEnvVar("SMTP_PORT"),
|
||||||
SMTP_USER: getEnvVar("SMTP_USER"),
|
SMTP_USER: getEnvVar("SMTP_USER"),
|
||||||
SMTP_PASS: getEnvVar("SMTP_PASS"),
|
SMTP_PASS: getEnvVar("SMTP_PASS"),
|
||||||
SMTP_FROM: getEnvVar("SMTP_FROM"),
|
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"],
|
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user