feat:gifts

This commit is contained in:
2026-03-03 16:26:37 +01:00
parent e7b3d7260f
commit b8cefd373d
10 changed files with 416 additions and 22 deletions

View 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>
);
}

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import {
type GuestEntry,
type GuestErrors,
@@ -13,6 +14,8 @@ interface Props {
onRemove: (index: number) => void;
/** Optional suffix rendered after the guest count header (e.g. price warning) */
headerNote?: React.ReactNode;
/** Whether payment has already been made (disables removal without warning) */
isPaid?: boolean;
}
export function GuestList({
@@ -22,7 +25,29 @@ export function GuestList({
onAdd,
onRemove,
headerNote,
isPaid,
}: 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 (
<div className="border border-teal-400/20 bg-teal-400/5 p-6">
<div className="mb-4 flex items-center justify-between">
@@ -60,6 +85,35 @@ export function GuestList({
)}
</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 && (
<p className="text-sm text-white/40">
Kom je met iemand mee? Voeg je medebezoekers toe. Elke extra persoon
@@ -82,7 +136,7 @@ export function GuestList({
</span>
<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"
>
<svg

View File

@@ -8,6 +8,7 @@ import {
validateTextField,
} from "@/lib/registration";
import { orpc } from "@/utils/orpc";
import { GiftSelector } from "./GiftSelector";
interface PerformerErrors {
firstName?: string;
@@ -34,6 +35,7 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
isOver16: false,
extraQuestions: "",
});
const [giftAmount, setGiftAmount] = useState(0);
const [errors, setErrors] = useState<PerformerErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
@@ -132,6 +134,7 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
experience: data.experience.trim() || undefined,
isOver16: data.isOver16,
extraQuestions: data.extraQuestions.trim() || undefined,
giftAmount,
});
}
@@ -414,6 +417,14 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
/>
</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">
<button
type="submit"

View File

@@ -88,7 +88,7 @@ export function TypeSelector({ onSelect }: Props) {
</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">
<span className="font-semibold text-sm text-teal-300">
Drinkkaart 5 EUR inbegrepen bij inkom.
Drinkkaart 5 EUR te betalen bij registratie
</span>
</div>
<div className="mt-auto flex items-center gap-2 font-medium text-sm text-teal-300">

View File

@@ -12,6 +12,7 @@ import {
validateTextField,
} from "@/lib/registration";
import { client, orpc } from "@/utils/orpc";
import { GiftSelector } from "./GiftSelector";
import { GuestList } from "./GuestList";
interface WatcherErrors {
@@ -37,6 +38,7 @@ export function WatcherForm({ onBack }: Props) {
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [guests, setGuests] = useState<GuestEntry[]>([]);
const [guestErrors, setGuestErrors] = useState<GuestErrors[]>([]);
const [giftAmount, setGiftAmount] = useState(0);
const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(),
@@ -152,6 +154,7 @@ export function WatcherForm({ onBack }: Props) {
phone: g.phone.trim() || undefined,
})),
extraQuestions: data.extraQuestions.trim() || undefined,
giftAmount,
});
}
@@ -214,8 +217,9 @@ export function WatcherForm({ onBack }: Props) {
<div>
<p className="font-semibold text-white">Drinkkaart inbegrepen</p>
<p className="text-sm text-white/70">
Je betaald bij inkom <strong className="text-teal-300">5</strong>{" "}
(+ 2 per medebezoeker) dat gaat naar je drinkkaart.
Je betaald bij registratie{" "}
<strong className="text-teal-300">5</strong> (+ 2 per
medebezoeker) dat gaat naar je drinkkaart.
{guests.length > 0 && (
<span className="ml-1 font-semibold text-teal-300">
Totaal: {totalPrice} voor {1 + guests.length} personen.
@@ -354,6 +358,14 @@ export function WatcherForm({ onBack }: Props) {
/>
</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">
<button
type="submit"

View File

@@ -75,6 +75,7 @@ function AdminPage() {
| "type"
| "details"
| "gasten"
| "gift"
| "betaling"
| "datum";
type SortDir = "asc" | "desc";
@@ -190,6 +191,7 @@ function AdminPage() {
const totalDrinkCardValue = totalRegistrations
.filter((r) => r.registrationType === "watcher")
.reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0);
const totalGiftRevenue = (stats?.totalGiftRevenue as number | undefined) ?? 0;
const sortedRegistrations = useMemo(() => {
const sorted = [...registrations].sort((a, b) => {
@@ -232,6 +234,10 @@ function AdminPage() {
valB = countGuests(b.guests as string | null);
break;
}
case "gift":
valA = a.giftAmount ?? 0;
valB = b.giftAmount ?? 0;
break;
case "betaling":
valA = a.paymentStatus ?? "pending";
valB = b.paymentStatus ?? "pending";
@@ -261,6 +267,12 @@ function AdminPage() {
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";
const formatCents = (cents: number | null | undefined) => {
if (!cents || cents <= 0) return "-";
const euros = cents / 100;
return `${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`;
};
return (
<div className="min-h-screen bg-[#214e51]">
{/* Header */}
@@ -435,6 +447,22 @@ function AdminPage() {
</div>
</CardContent>
</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>
{/* Filters */}
@@ -591,6 +619,9 @@ function AdminPage() {
>
Gasten <SortIcon col="gasten" />
</th>
<th className={thClass} onClick={() => handleSort("gift")}>
Gift <SortIcon col="gift" />
</th>
<th
className={thClass}
onClick={() => handleSort("betaling")}
@@ -609,7 +640,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? (
<tr>
<td
colSpan={9}
colSpan={10}
className="px-4 py-8 text-center text-white/60"
>
Laden...
@@ -618,7 +649,7 @@ function AdminPage() {
) : sortedRegistrations.length === 0 ? (
<tr>
<td
colSpan={9}
colSpan={10}
className="px-4 py-8 text-center text-white/60"
>
Geen registraties gevonden
@@ -686,6 +717,9 @@ function AdminPage() {
? `${guestCount} gast${guestCount === 1 ? "" : "en"}`
: "-"}
</td>
<td className="px-4 py-3 text-pink-300/70 text-sm">
{formatCents(reg.giftAmount)}
</td>
<td className="px-4 py-3">
{isPerformer ? (
<span className="text-sm text-white/30">-</span>

View File

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useParams } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
import { GiftSelector } from "@/components/registration/GiftSelector";
import { GuestList } from "@/components/registration/GuestList";
import {
calculateDrinkCard,
@@ -123,6 +124,7 @@ interface EditFormProps {
extraQuestions: string | null;
guests: string | null;
paymentStatus: string | null;
giftAmount: number | null;
};
onCancel: () => void;
onSaved: () => void;
@@ -147,6 +149,7 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
const [formGuests, setFormGuests] = useState<GuestEntry[]>(
parseGuests(initialData.guests),
);
const [giftAmount, setGiftAmount] = useState(initialData.giftAmount ?? 0);
const updateMutation = useMutation({
...orpc.updateRegistration.mutationOptions(),
@@ -191,6 +194,7 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
phone: g.phone.trim() || undefined,
})),
extraQuestions: formData.extraQuestions.trim() || undefined,
giftAmount,
});
}
@@ -407,6 +411,7 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
)}
</p>
}
isPaid={isPaid}
/>
)}
@@ -426,6 +431,18 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
/>
</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">
<button
type="submit"
@@ -568,8 +585,8 @@ function ManageRegistrationPage() {
)}
</div>
{/* Payment status */}
{!isPerformer && (
{/* Payment status - shown for everyone with pending/extra payment or gift */}
{(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
<div className="mb-6">
{data.paymentStatus === "paid" ? (
<PaidBadge />
@@ -581,6 +598,15 @@ function ManageRegistrationPage() {
</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 */}
<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">
@@ -656,20 +682,23 @@ function ManageRegistrationPage() {
{/* Actions */}
<div className="mt-8 flex flex-wrap items-center gap-4">
{!isPerformer &&
(data.paymentStatus === "pending" ||
data.paymentStatus === "extra_payment_pending") && (
{/* Show pay button if there's something to pay (for watchers: drink card, for performers: gift) */}
{(data.paymentStatus === "pending" ||
data.paymentStatus === "extra_payment_pending") &&
(!isPerformer || (data.giftAmount ?? 0) > 0) && (
<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"}`}
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
? "Laden..."
: data.paymentStatus === "extra_payment_pending"
? `Extra betalen (€${((data.paymentAmount ?? 0) / 100).toFixed(0)})`
: "Nu betalen"}
: isPerformer
? `Gift betalen (€${((data.giftAmount ?? 0) / 100).toFixed(0)})`
: "Nu betalen"}
</button>
)}
<button