feat:gifts
This commit is contained in:
164
apps/web/src/components/registration/GiftSelector.tsx
Normal file
164
apps/web/src/components/registration/GiftSelector.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface GiftSelectorProps {
|
||||||
|
value: number;
|
||||||
|
onChange: (cents: number) => void;
|
||||||
|
id?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_AMOUNTS = [
|
||||||
|
{ label: "€5", cents: 500 },
|
||||||
|
{ label: "€10", cents: 1000 },
|
||||||
|
{ label: "€25", cents: 2500 },
|
||||||
|
{ label: "€50", cents: 5000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GiftSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
id,
|
||||||
|
disabled,
|
||||||
|
}: GiftSelectorProps) {
|
||||||
|
const [customInput, setCustomInput] = useState("");
|
||||||
|
const [isCustom, setIsCustom] = useState(false);
|
||||||
|
|
||||||
|
// Check if current value matches a preset
|
||||||
|
const isPresetSelected = PRESET_AMOUNTS.some((p) => p.cents === value);
|
||||||
|
const showCustomInput = isCustom || (!isPresetSelected && value > 0);
|
||||||
|
|
||||||
|
const handlePresetClick = (cents: number) => {
|
||||||
|
onChange(cents);
|
||||||
|
setIsCustom(false);
|
||||||
|
setCustomInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const input = e.target.value.replace(/[^0-9,]/g, "");
|
||||||
|
setCustomInput(input);
|
||||||
|
|
||||||
|
// Parse euros to cents
|
||||||
|
if (input) {
|
||||||
|
const normalized = input.replace(",", ".");
|
||||||
|
const euros = Number.parseFloat(normalized);
|
||||||
|
if (!Number.isNaN(euros) && euros >= 0) {
|
||||||
|
onChange(Math.round(euros * 100));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onChange(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomClick = () => {
|
||||||
|
setIsCustom(true);
|
||||||
|
if (value > 0 && !isPresetSelected) {
|
||||||
|
// Keep existing custom value
|
||||||
|
setCustomInput((value / 100).toFixed(2).replace(".", ","));
|
||||||
|
} else {
|
||||||
|
setCustomInput("");
|
||||||
|
onChange(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
onChange(0);
|
||||||
|
setIsCustom(false);
|
||||||
|
setCustomInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// When disabled, show read-only view
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<div id={id} className="space-y-4">
|
||||||
|
<p className="mb-3 text-sm text-white/60">
|
||||||
|
Deelname is gratis, maar een vrijwillige gift wordt zeer gewaardeerd.
|
||||||
|
</p>
|
||||||
|
{value > 0 ? (
|
||||||
|
<div className="rounded border border-pink-400/30 bg-pink-400/10 px-4 py-3">
|
||||||
|
<p className="font-medium text-pink-300">
|
||||||
|
€{(value / 100).toFixed(2).replace(".", ",")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-pink-300/60 text-xs">
|
||||||
|
Gift is verwerkt en kan niet meer worden aangepast
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-white/40">Geen gift toegevoegd</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id={id} className="space-y-4">
|
||||||
|
<p className="mb-3 text-sm text-white/60">
|
||||||
|
Deelname is gratis, maar een vrijwillige gift wordt zeer gewaardeerd.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Preset amounts */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRESET_AMOUNTS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.cents}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePresetClick(preset.cents)}
|
||||||
|
className={`rounded px-4 py-2 font-semibold text-sm transition-all ${
|
||||||
|
value === preset.cents
|
||||||
|
? "bg-white text-[#214e51]"
|
||||||
|
: "border border-white/30 bg-white/5 text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCustomClick}
|
||||||
|
className={`rounded px-4 py-2 font-semibold text-sm transition-all ${
|
||||||
|
showCustomInput && !isPresetSelected
|
||||||
|
? "bg-white text-[#214e51]"
|
||||||
|
: "border border-white/30 bg-white/5 text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Anders
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom amount input */}
|
||||||
|
{showCustomInput && (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<span className="text-lg text-white">€</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="0,00"
|
||||||
|
value={customInput}
|
||||||
|
onChange={handleCustomInputChange}
|
||||||
|
className="w-24 rounded border border-white/30 bg-white/10 px-3 py-2 text-lg text-white placeholder:text-white/40 focus:border-white/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skip option */}
|
||||||
|
{value > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSkip}
|
||||||
|
className="text-sm text-white/50 underline underline-offset-2 hover:text-white/70"
|
||||||
|
>
|
||||||
|
Liever geen gift
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected amount display */}
|
||||||
|
{value > 0 && (
|
||||||
|
<p className="text-sm text-white/80">
|
||||||
|
Geselecteerd:{" "}
|
||||||
|
<span className="font-semibold text-white">
|
||||||
|
€{(value / 100).toFixed(2).replace(".", ",")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
type GuestEntry,
|
type GuestEntry,
|
||||||
type GuestErrors,
|
type GuestErrors,
|
||||||
@@ -13,6 +14,8 @@ interface Props {
|
|||||||
onRemove: (index: number) => void;
|
onRemove: (index: number) => void;
|
||||||
/** Optional suffix rendered after the guest count header (e.g. price warning) */
|
/** Optional suffix rendered after the guest count header (e.g. price warning) */
|
||||||
headerNote?: React.ReactNode;
|
headerNote?: React.ReactNode;
|
||||||
|
/** Whether payment has already been made (disables removal without warning) */
|
||||||
|
isPaid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GuestList({
|
export function GuestList({
|
||||||
@@ -22,7 +25,29 @@ export function GuestList({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onRemove,
|
onRemove,
|
||||||
headerNote,
|
headerNote,
|
||||||
|
isPaid,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [removingIndex, setRemovingIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleRemoveClick = (index: number) => {
|
||||||
|
if (isPaid) {
|
||||||
|
setRemovingIndex(index);
|
||||||
|
} else {
|
||||||
|
onRemove(index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRemove = () => {
|
||||||
|
if (removingIndex !== null) {
|
||||||
|
onRemove(removingIndex);
|
||||||
|
setRemovingIndex(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRemove = () => {
|
||||||
|
setRemovingIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-teal-400/20 bg-teal-400/5 p-6">
|
<div className="border border-teal-400/20 bg-teal-400/5 p-6">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
@@ -60,6 +85,35 @@ export function GuestList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Removal confirmation modal for paid registrations */}
|
||||||
|
{removingIndex !== null && (
|
||||||
|
<div className="mb-4 rounded border border-orange-400/30 bg-orange-400/10 p-4">
|
||||||
|
<p className="mb-2 font-medium text-orange-300 text-sm">
|
||||||
|
Let op: gast verwijderen
|
||||||
|
</p>
|
||||||
|
<p className="mb-4 text-sm text-white/70">
|
||||||
|
Als je al betaald hebt voor deze gast krijg je geen terugbetaling
|
||||||
|
bij het verwijderen.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={confirmRemove}
|
||||||
|
className="rounded bg-orange-400/20 px-3 py-1.5 text-orange-300 text-sm hover:bg-orange-400/30"
|
||||||
|
>
|
||||||
|
Toch verwijderen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelRemove}
|
||||||
|
className="rounded border border-white/20 px-3 py-1.5 text-sm text-white/60 hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{guests.length === 0 && (
|
{guests.length === 0 && (
|
||||||
<p className="text-sm text-white/40">
|
<p className="text-sm text-white/40">
|
||||||
Kom je met iemand mee? Voeg je medebezoekers toe. Elke extra persoon
|
Kom je met iemand mee? Voeg je medebezoekers toe. Elke extra persoon
|
||||||
@@ -82,7 +136,7 @@ export function GuestList({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemove(idx)}
|
onClick={() => handleRemoveClick(idx)}
|
||||||
className="flex items-center gap-1 text-red-400/70 text-sm transition-colors hover:text-red-300"
|
className="flex items-center gap-1 text-red-400/70 text-sm transition-colors hover:text-red-300"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
validateTextField,
|
validateTextField,
|
||||||
} from "@/lib/registration";
|
} from "@/lib/registration";
|
||||||
import { orpc } from "@/utils/orpc";
|
import { orpc } from "@/utils/orpc";
|
||||||
|
import { GiftSelector } from "./GiftSelector";
|
||||||
|
|
||||||
interface PerformerErrors {
|
interface PerformerErrors {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
@@ -34,6 +35,7 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
|
|||||||
isOver16: false,
|
isOver16: false,
|
||||||
extraQuestions: "",
|
extraQuestions: "",
|
||||||
});
|
});
|
||||||
|
const [giftAmount, setGiftAmount] = useState(0);
|
||||||
const [errors, setErrors] = useState<PerformerErrors>({});
|
const [errors, setErrors] = useState<PerformerErrors>({});
|
||||||
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
@@ -132,6 +134,7 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
|
|||||||
experience: data.experience.trim() || undefined,
|
experience: data.experience.trim() || undefined,
|
||||||
isOver16: data.isOver16,
|
isOver16: data.isOver16,
|
||||||
extraQuestions: data.extraQuestions.trim() || undefined,
|
extraQuestions: data.extraQuestions.trim() || undefined,
|
||||||
|
giftAmount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +417,14 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gift selector */}
|
||||||
|
<div className="border border-white/10 p-6">
|
||||||
|
<h3 className="mb-4 font-['Intro',sans-serif] text-white text-xl">
|
||||||
|
Vrijwillige Gift
|
||||||
|
</h3>
|
||||||
|
<GiftSelector value={giftAmount} onChange={setGiftAmount} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-4 pt-4">
|
<div className="flex flex-col items-center gap-4 pt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function TypeSelector({ onSelect }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-teal-400/40 bg-teal-400/10 px-4 py-1.5">
|
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-teal-400/40 bg-teal-400/10 px-4 py-1.5">
|
||||||
<span className="font-semibold text-sm text-teal-300">
|
<span className="font-semibold text-sm text-teal-300">
|
||||||
Drinkkaart 5 EUR inbegrepen bij inkom.
|
Drinkkaart 5 EUR te betalen bij registratie
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto flex items-center gap-2 font-medium text-sm text-teal-300">
|
<div className="mt-auto flex items-center gap-2 font-medium text-sm text-teal-300">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
validateTextField,
|
validateTextField,
|
||||||
} from "@/lib/registration";
|
} from "@/lib/registration";
|
||||||
import { client, orpc } from "@/utils/orpc";
|
import { client, orpc } from "@/utils/orpc";
|
||||||
|
import { GiftSelector } from "./GiftSelector";
|
||||||
import { GuestList } from "./GuestList";
|
import { GuestList } from "./GuestList";
|
||||||
|
|
||||||
interface WatcherErrors {
|
interface WatcherErrors {
|
||||||
@@ -37,6 +38,7 @@ export function WatcherForm({ onBack }: Props) {
|
|||||||
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
||||||
const [guests, setGuests] = useState<GuestEntry[]>([]);
|
const [guests, setGuests] = useState<GuestEntry[]>([]);
|
||||||
const [guestErrors, setGuestErrors] = useState<GuestErrors[]>([]);
|
const [guestErrors, setGuestErrors] = useState<GuestErrors[]>([]);
|
||||||
|
const [giftAmount, setGiftAmount] = useState(0);
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
...orpc.submitRegistration.mutationOptions(),
|
...orpc.submitRegistration.mutationOptions(),
|
||||||
@@ -152,6 +154,7 @@ export function WatcherForm({ onBack }: Props) {
|
|||||||
phone: g.phone.trim() || undefined,
|
phone: g.phone.trim() || undefined,
|
||||||
})),
|
})),
|
||||||
extraQuestions: data.extraQuestions.trim() || undefined,
|
extraQuestions: data.extraQuestions.trim() || undefined,
|
||||||
|
giftAmount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +217,9 @@ export function WatcherForm({ onBack }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-white">Drinkkaart inbegrepen</p>
|
<p className="font-semibold text-white">Drinkkaart inbegrepen</p>
|
||||||
<p className="text-sm text-white/70">
|
<p className="text-sm text-white/70">
|
||||||
Je betaald bij inkom <strong className="text-teal-300">€5</strong>{" "}
|
Je betaald bij registratie{" "}
|
||||||
(+ €2 per medebezoeker) dat gaat naar je drinkkaart.
|
<strong className="text-teal-300">€5</strong> (+ €2 per
|
||||||
|
medebezoeker) dat gaat naar je drinkkaart.
|
||||||
{guests.length > 0 && (
|
{guests.length > 0 && (
|
||||||
<span className="ml-1 font-semibold text-teal-300">
|
<span className="ml-1 font-semibold text-teal-300">
|
||||||
Totaal: €{totalPrice} voor {1 + guests.length} personen.
|
Totaal: €{totalPrice} voor {1 + guests.length} personen.
|
||||||
@@ -354,6 +358,14 @@ export function WatcherForm({ onBack }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gift selector */}
|
||||||
|
<div className="border border-white/10 p-6">
|
||||||
|
<h3 className="mb-4 font-['Intro',sans-serif] text-white text-xl">
|
||||||
|
Vrijwillige Gift
|
||||||
|
</h3>
|
||||||
|
<GiftSelector value={giftAmount} onChange={setGiftAmount} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-4 pt-4">
|
<div className="flex flex-col items-center gap-4 pt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ function AdminPage() {
|
|||||||
| "type"
|
| "type"
|
||||||
| "details"
|
| "details"
|
||||||
| "gasten"
|
| "gasten"
|
||||||
|
| "gift"
|
||||||
| "betaling"
|
| "betaling"
|
||||||
| "datum";
|
| "datum";
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
@@ -190,6 +191,7 @@ function AdminPage() {
|
|||||||
const totalDrinkCardValue = totalRegistrations
|
const totalDrinkCardValue = totalRegistrations
|
||||||
.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 totalGiftRevenue = (stats?.totalGiftRevenue as number | undefined) ?? 0;
|
||||||
|
|
||||||
const sortedRegistrations = useMemo(() => {
|
const sortedRegistrations = useMemo(() => {
|
||||||
const sorted = [...registrations].sort((a, b) => {
|
const sorted = [...registrations].sort((a, b) => {
|
||||||
@@ -232,6 +234,10 @@ function AdminPage() {
|
|||||||
valB = countGuests(b.guests as string | null);
|
valB = countGuests(b.guests as string | null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "gift":
|
||||||
|
valA = a.giftAmount ?? 0;
|
||||||
|
valB = b.giftAmount ?? 0;
|
||||||
|
break;
|
||||||
case "betaling":
|
case "betaling":
|
||||||
valA = a.paymentStatus ?? "pending";
|
valA = a.paymentStatus ?? "pending";
|
||||||
valB = b.paymentStatus ?? "pending";
|
valB = b.paymentStatus ?? "pending";
|
||||||
@@ -261,6 +267,12 @@ function AdminPage() {
|
|||||||
const thClass =
|
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";
|
"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";
|
||||||
|
|
||||||
|
const formatCents = (cents: number | null | undefined) => {
|
||||||
|
if (!cents || cents <= 0) return "-";
|
||||||
|
const euros = cents / 100;
|
||||||
|
return `€${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#214e51]">
|
<div className="min-h-screen bg-[#214e51]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -435,6 +447,22 @@ function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-pink-400/20 bg-pink-400/5">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription className="text-pink-300/70">
|
||||||
|
Vrijwillige Gifts
|
||||||
|
</CardDescription>
|
||||||
|
<CardTitle className="font-['Intro',sans-serif] text-4xl text-pink-300">
|
||||||
|
€{Math.round(totalGiftRevenue / 100)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span className="text-sm text-white/40">
|
||||||
|
Totale gift opbrengst
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -591,6 +619,9 @@ function AdminPage() {
|
|||||||
>
|
>
|
||||||
Gasten <SortIcon col="gasten" />
|
Gasten <SortIcon col="gasten" />
|
||||||
</th>
|
</th>
|
||||||
|
<th className={thClass} onClick={() => handleSort("gift")}>
|
||||||
|
Gift <SortIcon col="gift" />
|
||||||
|
</th>
|
||||||
<th
|
<th
|
||||||
className={thClass}
|
className={thClass}
|
||||||
onClick={() => handleSort("betaling")}
|
onClick={() => handleSort("betaling")}
|
||||||
@@ -609,7 +640,7 @@ function AdminPage() {
|
|||||||
{registrationsQuery.isLoading ? (
|
{registrationsQuery.isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={9}
|
colSpan={10}
|
||||||
className="px-4 py-8 text-center text-white/60"
|
className="px-4 py-8 text-center text-white/60"
|
||||||
>
|
>
|
||||||
Laden...
|
Laden...
|
||||||
@@ -618,7 +649,7 @@ function AdminPage() {
|
|||||||
) : sortedRegistrations.length === 0 ? (
|
) : sortedRegistrations.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={9}
|
colSpan={10}
|
||||||
className="px-4 py-8 text-center text-white/60"
|
className="px-4 py-8 text-center text-white/60"
|
||||||
>
|
>
|
||||||
Geen registraties gevonden
|
Geen registraties gevonden
|
||||||
@@ -686,6 +717,9 @@ function AdminPage() {
|
|||||||
? `${guestCount} gast${guestCount === 1 ? "" : "en"}`
|
? `${guestCount} gast${guestCount === 1 ? "" : "en"}`
|
||||||
: "-"}
|
: "-"}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-pink-300/70 text-sm">
|
||||||
|
{formatCents(reg.giftAmount)}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{isPerformer ? (
|
{isPerformer ? (
|
||||||
<span className="text-sm text-white/30">-</span>
|
<span className="text-sm text-white/30">-</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { createFileRoute, Link, useParams } from "@tanstack/react-router";
|
import { createFileRoute, Link, useParams } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { GiftSelector } from "@/components/registration/GiftSelector";
|
||||||
import { GuestList } from "@/components/registration/GuestList";
|
import { GuestList } from "@/components/registration/GuestList";
|
||||||
import {
|
import {
|
||||||
calculateDrinkCard,
|
calculateDrinkCard,
|
||||||
@@ -123,6 +124,7 @@ interface EditFormProps {
|
|||||||
extraQuestions: string | null;
|
extraQuestions: string | null;
|
||||||
guests: string | null;
|
guests: string | null;
|
||||||
paymentStatus: string | null;
|
paymentStatus: string | null;
|
||||||
|
giftAmount: number | null;
|
||||||
};
|
};
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
@@ -147,6 +149,7 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
|
|||||||
const [formGuests, setFormGuests] = useState<GuestEntry[]>(
|
const [formGuests, setFormGuests] = useState<GuestEntry[]>(
|
||||||
parseGuests(initialData.guests),
|
parseGuests(initialData.guests),
|
||||||
);
|
);
|
||||||
|
const [giftAmount, setGiftAmount] = useState(initialData.giftAmount ?? 0);
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
...orpc.updateRegistration.mutationOptions(),
|
...orpc.updateRegistration.mutationOptions(),
|
||||||
@@ -191,6 +194,7 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
|
|||||||
phone: g.phone.trim() || undefined,
|
phone: g.phone.trim() || undefined,
|
||||||
})),
|
})),
|
||||||
extraQuestions: formData.extraQuestions.trim() || undefined,
|
extraQuestions: formData.extraQuestions.trim() || undefined,
|
||||||
|
giftAmount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +411,7 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
isPaid={isPaid}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -426,6 +431,18 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gift selector */}
|
||||||
|
<div className="border-white/10 border-t pt-6">
|
||||||
|
<h3 className="mb-4 font-['Intro',sans-serif] text-white text-xl">
|
||||||
|
Vrijwillige Gift
|
||||||
|
</h3>
|
||||||
|
<GiftSelector
|
||||||
|
value={giftAmount}
|
||||||
|
onChange={setGiftAmount}
|
||||||
|
disabled={isPaid && (initialData.giftAmount ?? 0) > 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4 pt-4">
|
<div className="flex flex-wrap items-center gap-4 pt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -568,8 +585,8 @@ function ManageRegistrationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment status */}
|
{/* Payment status - shown for everyone with pending/extra payment or gift */}
|
||||||
{!isPerformer && (
|
{(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
{data.paymentStatus === "paid" ? (
|
{data.paymentStatus === "paid" ? (
|
||||||
<PaidBadge />
|
<PaidBadge />
|
||||||
@@ -581,6 +598,15 @@ function ManageRegistrationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Gift display */}
|
||||||
|
{(data.giftAmount ?? 0) > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-pink-400/40 bg-pink-400/10 px-4 py-1.5 font-semibold text-pink-300 text-sm">
|
||||||
|
<span>Vrijwillige gift: €{(data.giftAmount ?? 0) / 100}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Registration details */}
|
{/* Registration details */}
|
||||||
<div className="space-y-6 rounded-lg border border-white/20 bg-white/5 p-6 md:p-8">
|
<div className="space-y-6 rounded-lg border border-white/20 bg-white/5 p-6 md:p-8">
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
@@ -656,20 +682,23 @@ 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 &&
|
{/* Show pay button if there's something to pay (for watchers: drink card, for performers: gift) */}
|
||||||
(data.paymentStatus === "pending" ||
|
{(data.paymentStatus === "pending" ||
|
||||||
data.paymentStatus === "extra_payment_pending") && (
|
data.paymentStatus === "extra_payment_pending") &&
|
||||||
|
(!isPerformer || (data.giftAmount ?? 0) > 0) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => checkoutMutation.mutate({ token })}
|
onClick={() => checkoutMutation.mutate({ token })}
|
||||||
disabled={checkoutMutation.isPending}
|
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"}`}
|
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" : isPerformer ? "bg-pink-400 text-white hover:bg-pink-300" : "bg-teal-400 text-[#214e51] hover:bg-teal-300"}`}
|
||||||
>
|
>
|
||||||
{checkoutMutation.isPending
|
{checkoutMutation.isPending
|
||||||
? "Laden..."
|
? "Laden..."
|
||||||
: data.paymentStatus === "extra_payment_pending"
|
: data.paymentStatus === "extra_payment_pending"
|
||||||
? `Extra betalen (€${((data.paymentAmount ?? 0) / 100).toFixed(0)})`
|
? `Extra betalen (€${((data.paymentAmount ?? 0) / 100).toFixed(0)})`
|
||||||
: "Nu betalen"}
|
: isPerformer
|
||||||
|
? `Gift betalen (€${((data.giftAmount ?? 0) / 100).toFixed(0)})`
|
||||||
|
: "Nu betalen"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -32,11 +32,34 @@ function registrationConfirmationHtml(params: {
|
|||||||
manageUrl: string;
|
manageUrl: string;
|
||||||
wantsToPerform: boolean;
|
wantsToPerform: boolean;
|
||||||
artForm?: string | null;
|
artForm?: string | null;
|
||||||
|
giftAmount?: number;
|
||||||
|
drinkCardValue?: number;
|
||||||
}) {
|
}) {
|
||||||
const role = params.wantsToPerform
|
const role = params.wantsToPerform
|
||||||
? `Optreden${params.artForm ? ` — ${params.artForm}` : ""}`
|
? `Optreden${params.artForm ? ` — ${params.artForm}` : ""}`
|
||||||
: "Toeschouwer";
|
: "Toeschouwer";
|
||||||
|
|
||||||
|
const giftCents = params.giftAmount ?? 0;
|
||||||
|
// drinkCardValue is stored in euros (e.g., 5), giftAmount in cents (e.g., 5000)
|
||||||
|
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
|
||||||
|
const hasPayment = drinkCardCents > 0 || giftCents > 0;
|
||||||
|
|
||||||
|
const formatEuro = (cents: number) =>
|
||||||
|
`€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
|
||||||
|
|
||||||
|
const paymentSummary = hasPayment
|
||||||
|
? `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:16px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 24px;">
|
||||||
|
<p style="margin:0 0 12px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Betaling</p>
|
||||||
|
${drinkCardCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Drinkkaart: <span style="color:#ffffff;font-weight:500;">${formatEuro(drinkCardCents)}</span></p>` : ""}
|
||||||
|
${giftCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Vrijwillige gift: <span style="color:#ffffff;font-weight:500;">${formatEuro(giftCents)}</span></p>` : ""}
|
||||||
|
<p style="margin:16px 0 0;padding-top:12px;border-top:1px solid rgba(255,255,255,0.1);font-size:18px;color:#ffffff;font-weight:600;">Totaal: ${formatEuro(drinkCardCents + giftCents)}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>`
|
||||||
|
: "";
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="nl">
|
<html lang="nl">
|
||||||
<head>
|
<head>
|
||||||
@@ -74,6 +97,7 @@ function registrationConfirmationHtml(params: {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
${paymentSummary}
|
||||||
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
|
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
|
||||||
Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet.
|
Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet.
|
||||||
</p>
|
</p>
|
||||||
@@ -114,11 +138,34 @@ function updateConfirmationHtml(params: {
|
|||||||
manageUrl: string;
|
manageUrl: string;
|
||||||
wantsToPerform: boolean;
|
wantsToPerform: boolean;
|
||||||
artForm?: string | null;
|
artForm?: string | null;
|
||||||
|
giftAmount?: number;
|
||||||
|
drinkCardValue?: number;
|
||||||
}) {
|
}) {
|
||||||
const role = params.wantsToPerform
|
const role = params.wantsToPerform
|
||||||
? `Optreden${params.artForm ? ` — ${params.artForm}` : ""}`
|
? `Optreden${params.artForm ? ` — ${params.artForm}` : ""}`
|
||||||
: "Toeschouwer";
|
: "Toeschouwer";
|
||||||
|
|
||||||
|
const giftCents = params.giftAmount ?? 0;
|
||||||
|
// drinkCardValue is stored in euros (e.g., 5), giftAmount in cents (e.g., 5000)
|
||||||
|
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
|
||||||
|
const hasPayment = drinkCardCents > 0 || giftCents > 0;
|
||||||
|
|
||||||
|
const formatEuro = (cents: number) =>
|
||||||
|
`€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
|
||||||
|
|
||||||
|
const paymentSummary = hasPayment
|
||||||
|
? `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:16px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 24px;">
|
||||||
|
<p style="margin:0 0 12px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Betaling</p>
|
||||||
|
${drinkCardCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Drinkkaart: <span style="color:#ffffff;font-weight:500;">${formatEuro(drinkCardCents)}</span></p>` : ""}
|
||||||
|
${giftCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Vrijwillige gift: <span style="color:#ffffff;font-weight:500;">${formatEuro(giftCents)}</span></p>` : ""}
|
||||||
|
<p style="margin:16px 0 0;padding-top:12px;border-top:1px solid rgba(255,255,255,0.1);font-size:18px;color:#ffffff;font-weight:600;">Totaal: ${formatEuro(drinkCardCents + giftCents)}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>`
|
||||||
|
: "";
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="nl">
|
<html lang="nl">
|
||||||
<head>
|
<head>
|
||||||
@@ -152,6 +199,7 @@ function updateConfirmationHtml(params: {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
${paymentSummary}
|
||||||
<table cellpadding="0" cellspacing="0">
|
<table cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="border-radius:2px;background:#ffffff;">
|
<td style="border-radius:2px;background:#ffffff;">
|
||||||
@@ -230,6 +278,8 @@ export async function sendConfirmationEmail(params: {
|
|||||||
managementToken: string;
|
managementToken: string;
|
||||||
wantsToPerform: boolean;
|
wantsToPerform: boolean;
|
||||||
artForm?: string | null;
|
artForm?: string | null;
|
||||||
|
giftAmount?: number;
|
||||||
|
drinkCardValue?: number;
|
||||||
}) {
|
}) {
|
||||||
const transport = getTransport();
|
const transport = getTransport();
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
@@ -246,6 +296,8 @@ export async function sendConfirmationEmail(params: {
|
|||||||
manageUrl,
|
manageUrl,
|
||||||
wantsToPerform: params.wantsToPerform,
|
wantsToPerform: params.wantsToPerform,
|
||||||
artForm: params.artForm,
|
artForm: params.artForm,
|
||||||
|
giftAmount: params.giftAmount,
|
||||||
|
drinkCardValue: params.drinkCardValue,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -256,6 +308,8 @@ export async function sendUpdateEmail(params: {
|
|||||||
managementToken: string;
|
managementToken: string;
|
||||||
wantsToPerform: boolean;
|
wantsToPerform: boolean;
|
||||||
artForm?: string | null;
|
artForm?: string | null;
|
||||||
|
giftAmount?: number;
|
||||||
|
drinkCardValue?: number;
|
||||||
}) {
|
}) {
|
||||||
const transport = getTransport();
|
const transport = getTransport();
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
@@ -272,6 +326,8 @@ export async function sendUpdateEmail(params: {
|
|||||||
manageUrl,
|
manageUrl,
|
||||||
wantsToPerform: params.wantsToPerform,
|
wantsToPerform: params.wantsToPerform,
|
||||||
artForm: params.artForm,
|
artForm: params.artForm,
|
||||||
|
giftAmount: params.giftAmount,
|
||||||
|
drinkCardValue: params.drinkCardValue,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { adminRequest, registration } from "@kk/db/schema";
|
|||||||
import { user } from "@kk/db/schema/auth";
|
import { user } from "@kk/db/schema/auth";
|
||||||
import { env } from "@kk/env/server";
|
import { env } from "@kk/env/server";
|
||||||
import type { RouterClient } from "@orpc/server";
|
import type { RouterClient } from "@orpc/server";
|
||||||
import { and, count, desc, eq, gte, isNull, like, lte } from "drizzle-orm";
|
import { and, count, desc, eq, gte, isNull, like, lte, sum } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
sendCancellationEmail,
|
sendCancellationEmail,
|
||||||
@@ -84,6 +84,7 @@ const coreRegistrationFields = {
|
|||||||
isOver16: z.boolean().optional(),
|
isOver16: z.boolean().optional(),
|
||||||
guests: z.array(guestSchema).max(9).optional(),
|
guests: z.array(guestSchema).max(9).optional(),
|
||||||
extraQuestions: z.string().optional(),
|
extraQuestions: z.string().optional(),
|
||||||
|
giftAmount: z.number().int().min(0).default(0),
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitRegistrationSchema = z.object(coreRegistrationFields);
|
const submitRegistrationSchema = z.object(coreRegistrationFields);
|
||||||
@@ -135,6 +136,7 @@ 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,
|
||||||
|
giftAmount: input.giftAmount,
|
||||||
managementToken,
|
managementToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,6 +146,8 @@ export const appRouter = {
|
|||||||
managementToken,
|
managementToken,
|
||||||
wantsToPerform: isPerformer,
|
wantsToPerform: isPerformer,
|
||||||
artForm: input.artForm,
|
artForm: input.artForm,
|
||||||
|
giftAmount: input.giftAmount,
|
||||||
|
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
||||||
}).catch((err) =>
|
}).catch((err) =>
|
||||||
console.error("Failed to send confirmation email:", err),
|
console.error("Failed to send confirmation email:", err),
|
||||||
);
|
);
|
||||||
@@ -175,6 +179,17 @@ export const appRouter = {
|
|||||||
const isPerformer = input.registrationType === "performer";
|
const isPerformer = input.registrationType === "performer";
|
||||||
const guests = isPerformer ? [] : (input.guests ?? []);
|
const guests = isPerformer ? [] : (input.guests ?? []);
|
||||||
|
|
||||||
|
// Validate gift amount cannot be changed after payment
|
||||||
|
const hasPaidGift =
|
||||||
|
(row.paymentStatus === "paid" ||
|
||||||
|
row.paymentStatus === "extra_payment_pending") &&
|
||||||
|
(row.giftAmount ?? 0) > 0;
|
||||||
|
if (hasPaidGift && input.giftAmount !== row.giftAmount) {
|
||||||
|
throw new Error(
|
||||||
|
"Gift kan niet worden aangepast na betaling. Neem contact op met de organisatie.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Detect whether an already-paid watcher is adding extra guests so we
|
// Detect whether an already-paid watcher is adding extra guests so we
|
||||||
// can charge them the delta instead of the full amount.
|
// can charge them the delta instead of the full amount.
|
||||||
const isPaid = row.paymentStatus === "paid";
|
const isPaid = row.paymentStatus === "paid";
|
||||||
@@ -208,6 +223,7 @@ 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,
|
||||||
|
giftAmount: input.giftAmount,
|
||||||
paymentStatus: newPaymentStatus,
|
paymentStatus: newPaymentStatus,
|
||||||
paymentAmount: newPaymentAmount,
|
paymentAmount: newPaymentAmount,
|
||||||
})
|
})
|
||||||
@@ -219,6 +235,8 @@ export const appRouter = {
|
|||||||
managementToken: input.token,
|
managementToken: input.token,
|
||||||
wantsToPerform: isPerformer,
|
wantsToPerform: isPerformer,
|
||||||
artForm: input.artForm,
|
artForm: input.artForm,
|
||||||
|
giftAmount: input.giftAmount,
|
||||||
|
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
||||||
}).catch((err) => console.error("Failed to send update email:", err));
|
}).catch((err) => console.error("Failed to send update email:", err));
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -300,7 +318,7 @@ export const appRouter = {
|
|||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const [totalResult, todayResult, artFormResult, typeResult] =
|
const [totalResult, todayResult, artFormResult, typeResult, giftResult] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
db.select({ count: count() }).from(registration),
|
db.select({ count: count() }).from(registration),
|
||||||
db
|
db
|
||||||
@@ -319,6 +337,7 @@ export const appRouter = {
|
|||||||
})
|
})
|
||||||
.from(registration)
|
.from(registration)
|
||||||
.groupBy(registration.registrationType),
|
.groupBy(registration.registrationType),
|
||||||
|
db.select({ total: sum(registration.giftAmount) }).from(registration),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -332,6 +351,7 @@ export const appRouter = {
|
|||||||
registrationType: r.registrationType,
|
registrationType: r.registrationType,
|
||||||
count: r.count,
|
count: r.count,
|
||||||
})),
|
})),
|
||||||
|
totalGiftRevenue: giftResult[0]?.total ?? 0,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -352,6 +372,7 @@ export const appRouter = {
|
|||||||
"Experience",
|
"Experience",
|
||||||
"Is Over 16",
|
"Is Over 16",
|
||||||
"Drink Card Value",
|
"Drink Card Value",
|
||||||
|
"Gift Amount",
|
||||||
"Guest Count",
|
"Guest Count",
|
||||||
"Guests",
|
"Guests",
|
||||||
"Payment Status",
|
"Payment Status",
|
||||||
@@ -379,6 +400,7 @@ export const appRouter = {
|
|||||||
r.experience || "",
|
r.experience || "",
|
||||||
r.isOver16 ? "Yes" : "No",
|
r.isOver16 ? "Yes" : "No",
|
||||||
String(r.drinkCardValue ?? 0),
|
String(r.drinkCardValue ?? 0),
|
||||||
|
String(r.giftAmount ?? 0),
|
||||||
String(guests.length),
|
String(guests.length),
|
||||||
guestSummary,
|
guestSummary,
|
||||||
r.paymentStatus === "paid" ? "Paid" : "Pending",
|
r.paymentStatus === "paid" ? "Paid" : "Pending",
|
||||||
@@ -561,22 +583,32 @@ export const appRouter = {
|
|||||||
row.paymentStatus !== "extra_payment_pending"
|
row.paymentStatus !== "extra_payment_pending"
|
||||||
)
|
)
|
||||||
throw new Error("Onverwachte betalingsstatus");
|
throw new Error("Onverwachte betalingsstatus");
|
||||||
if (row.registrationType === "performer")
|
|
||||||
throw new Error("Artiesten hoeven niet te betalen");
|
|
||||||
|
|
||||||
|
const isPerformer = row.registrationType === "performer";
|
||||||
const guests = parseGuestsJson(row.guests);
|
const guests = parseGuestsJson(row.guests);
|
||||||
|
const giftTotal = row.giftAmount ?? 0;
|
||||||
|
|
||||||
|
// Performers only pay if they have a gift; watchers pay drink card + gift
|
||||||
|
if (isPerformer && giftTotal === 0) {
|
||||||
|
throw new Error("Artiesten hoeven niet te betalen");
|
||||||
|
}
|
||||||
|
|
||||||
// For an extra_payment_pending registration, charge only the delta
|
// For an extra_payment_pending registration, charge only the delta
|
||||||
// (stored in paymentAmount in cents). For a fresh pending registration
|
// (stored in paymentAmount in cents). For a fresh pending registration
|
||||||
// charge the full drink card price.
|
// charge the full drink card price. Always add any gift amount.
|
||||||
const isExtraPayment = row.paymentStatus === "extra_payment_pending";
|
const isExtraPayment = row.paymentStatus === "extra_payment_pending";
|
||||||
const amountInCents = isExtraPayment
|
const drinkCardTotal = isExtraPayment
|
||||||
? (row.paymentAmount ?? 0)
|
? (row.paymentAmount ?? 0)
|
||||||
: drinkCardCents(guests.length);
|
: isPerformer
|
||||||
|
? 0
|
||||||
|
: drinkCardCents(guests.length);
|
||||||
|
const amountInCents = drinkCardTotal + giftTotal;
|
||||||
|
|
||||||
const productDescription = isExtraPayment
|
const productDescription = isExtraPayment
|
||||||
? `Extra bijdrage voor ${row.paymentAmount != null ? row.paymentAmount / 200 : 0} extra gast(en)`
|
? `Extra bijdrage${giftTotal > 0 ? " + gift" : ""}`
|
||||||
: `Toegangskaart voor ${1 + guests.length} perso(o)n(en)`;
|
: isPerformer
|
||||||
|
? `Vrijwillige gift${drinkCardTotal > 0 ? " + drinkkaart" : ""}`
|
||||||
|
: `Toegangskaart${giftTotal > 0 ? " + gift" : ""}`;
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"https://api.lemonsqueezy.com/v1/checkouts",
|
"https://api.lemonsqueezy.com/v1/checkouts",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const registration = sqliteTable(
|
|||||||
// Payment fields
|
// Payment fields
|
||||||
paymentStatus: text("payment_status").notNull().default("pending"),
|
paymentStatus: text("payment_status").notNull().default("pending"),
|
||||||
paymentAmount: integer("payment_amount").default(0),
|
paymentAmount: integer("payment_amount").default(0),
|
||||||
|
giftAmount: integer("gift_amount").default(0),
|
||||||
lemonsqueezyOrderId: text("lemonsqueezy_order_id"),
|
lemonsqueezyOrderId: text("lemonsqueezy_order_id"),
|
||||||
lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"),
|
lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"),
|
||||||
paidAt: integer("paid_at", { mode: "timestamp_ms" }),
|
paidAt: integer("paid_at", { mode: "timestamp_ms" }),
|
||||||
@@ -42,6 +43,7 @@ export const registration = sqliteTable(
|
|||||||
index("registration_createdAt_idx").on(table.createdAt),
|
index("registration_createdAt_idx").on(table.createdAt),
|
||||||
index("registration_managementToken_idx").on(table.managementToken),
|
index("registration_managementToken_idx").on(table.managementToken),
|
||||||
index("registration_paymentStatus_idx").on(table.paymentStatus),
|
index("registration_paymentStatus_idx").on(table.paymentStatus),
|
||||||
|
index("registration_giftAmount_idx").on(table.giftAmount),
|
||||||
index("registration_lemonsqueezyOrderId_idx").on(table.lemonsqueezyOrderId),
|
index("registration_lemonsqueezyOrderId_idx").on(table.lemonsqueezyOrderId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user