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 {
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user