fix:manage edit for extra bezoekers payments
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,16 +656,22 @@ function ManageRegistrationPage() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-8 flex flex-wrap items-center gap-4">
|
||||
{!isPerformer && data.paymentStatus !== "paid" && (
|
||||
<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"
|
||||
>
|
||||
{checkoutMutation.isPending ? "Laden..." : "Nu betalen"}
|
||||
</button>
|
||||
)}
|
||||
{!isPerformer &&
|
||||
(data.paymentStatus === "pending" ||
|
||||
data.paymentStatus === "extra_payment_pending") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => checkoutMutation.mutate({ token })}
|
||||
disabled={checkoutMutation.isPending}
|
||||
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..."
|
||||
: data.paymentStatus === "extra_payment_pending"
|
||||
? `Extra betalen (€${((data.paymentAmount ?? 0) / 100).toFixed(0)})`
|
||||
: "Nu betalen"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user