Redesign the Bundle deals page

Upcoming events only by default (with a Show past events option), bundles as cards
instead of a table, and a mobile-friendly edit form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 15:56:03 +02:00
co-authored by Claude Opus 5.5
parent 53ed72d032
commit 6ce59af0ea
2 changed files with 189 additions and 118 deletions
+6
View File
@@ -7,6 +7,12 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.28.2] - 2026-10-21
### Changed
- The Bundle deals page has a new layout that works on phones. It shows upcoming events only, soonest first, with a "Show past events" option. Bundles show as cards with their tickets, price, saving, uses and dates. On a phone, the edit form opens full-width from the bottom, and its price section shows the standard price, the bundle price and the saving side by side.
## [1.28.1] - 2026-10-21
### Added
@@ -5,8 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Gift, Plus, X, Trash2 } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import type { EventBundle } from "@/types";
@@ -48,6 +47,7 @@ const draftFromBundle = (b: EventBundle): Draft => ({
const fmtDate = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
/** Standard price of one unit of an item (variant price, else the option's). */
function unitPrice(options: OptionLite[], item: { eventOptionId: string; variantId: string | null }) {
@@ -75,7 +75,8 @@ export default function BundlesPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<EventLite[]>([]);
const [allEvents, setAllEvents] = useState<EventLite[]>([]);
const [showPast, setShowPast] = useState(false);
const [eventId, setEventId] = useState("");
const [bundles, setBundles] = useState<EventBundle[]>([]);
const [fetching, setFetching] = useState(false);
@@ -86,20 +87,27 @@ export default function BundlesPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token })
.then(list => {
const sorted = (list || []).slice().sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
setEvents(sorted);
// Start on the next upcoming event, the one most likely to need a deal.
const upcoming = sorted.filter(e => new Date(e.endDate || e.startDate).getTime() >= Date.now());
setEventId(prev => prev || (upcoming[upcoming.length - 1] || sorted[0])?.id || "");
})
.then(list => setAllEvents(list || []))
.catch(e => setError(errorMessage(e) || "Failed to load events"));
}, [token, canView, setError]);
// Upcoming events soonest first; past ones (when shown) after them, most recent first.
const events = useMemo(() => {
const upcoming = allEvents.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const past = allEvents.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
return showPast ? [...upcoming, ...past] : upcoming;
}, [allEvents, showPast]);
// Keep the picked event while it's in the list; otherwise start on the next upcoming one.
useEffect(() => {
if (!events.some(e => e.id === eventId)) setEventId(events[0]?.id || "");
}, [events, eventId]);
const event = events.find(e => e.id === eventId) || null;
const options = event?.eventOptions || [];
const load = useCallback(async () => {
if (!token || !eventId) return;
if (!token || !eventId) { setBundles([]); return; }
setFetching(true);
try {
setBundles(await apiFetch<EventBundle[]>(`/api/bundles/event/${eventId}`, { authToken: token }) || []);
@@ -133,9 +141,12 @@ export default function BundlesPage() {
}
};
const startNew = () => setEditing({ id: null, draft: blankDraft(options) });
const canAdd = !!event && options.length > 0;
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Gift className="w-5 h-5 text-brand-600" />
@@ -143,14 +154,14 @@ export default function BundlesPage() {
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Bundle deals</h1>
<p className="text-sm text-gray-500">Sell a set of tickets together for one price, e.g. 5 adults + 3 children for R500</p>
<p className="text-sm text-gray-500">Tickets sold together for one price</p>
</div>
</div>
{canView && event && (
{canView && (
<button
onClick={() => setEditing({ id: null, draft: blankDraft(event.eventOptions || []) })}
disabled={(event.eventOptions || []).length === 0}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
onClick={startNew}
disabled={!canAdd}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50 w-full sm:w-auto"
>
<Plus className="w-4 h-4" /> New bundle
</button>
@@ -165,70 +176,56 @@ export default function BundlesPage() {
{canView && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="mb-3 w-full sm:w-96">
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select className={inputCls} value={eventId} onChange={e => setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} · {fmtDate(ev.startDate)}</option>)}
</select>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 mb-4">
<div className="w-full sm:w-auto">
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm w-full sm:min-w-[20rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">{showPast ? "No events" : "No upcoming events"}</option>}
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title} ({fmtDate(ev.startDate)}){isPast(ev) ? " · past" : ""}</option>
))}
</select>
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 sm:pb-2">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
</label>
</div>
<p className="text-xs text-gray-500 mb-3">
Bundles apply automatically, as many times as someone&apos;s tickets allow, and only when they&apos;re cheaper than the tickets
at today&apos;s prices (early-bird or staff prices included). Coupon codes come off afterwards.
</p>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Bundle</TableHead>
<TableHead>Price</TableHead>
<TableHead>Used</TableHead>
<TableHead>Available</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bundles.map(b => {
const status = bundleStatus(b);
const save = b.standardValue - b.price;
return (
<TableRow key={b.id}>
<TableCell>
<div className="font-semibold text-gray-900">{b.name}</div>
<div className="text-xs text-gray-600">{b.summary}</div>
{b.description && <div className="text-xs text-gray-500">{b.description}</div>}
</TableCell>
<TableCell className="text-sm">
{fmtRand(b.price)}
<div className={`text-xs ${save > 0 ? "text-green-700" : "text-amber-700"}`}>
{save > 0 ? `Saves ${fmtRand(save)} on ${fmtRand(b.standardValue)}` : `No saving on ${fmtRand(b.standardValue)}`}
</div>
</TableCell>
<TableCell className="text-sm">
{b.uses || 0}× <span className="text-xs text-gray-500">on {b.registrations || 0} registration{b.registrations === 1 ? "" : "s"}</span>
<div className="text-xs text-gray-500">{fmtRand(b.totalDiscount || 0)} given</div>
</TableCell>
<TableCell className="text-xs text-gray-600">
{b.validFrom || b.validUntil ? `${b.validFrom ? fmtDate(b.validFrom) : "Now"} – ${b.validUntil ? fmtDate(b.validUntil) : "no end"}` : "Always"}
</TableCell>
<TableCell><span className={`text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span></TableCell>
<TableCell className="text-right whitespace-nowrap">
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => setEditing({ id: b.id, draft: draftFromBundle(b) })}>Edit</button>
<button className={`px-2 py-1 text-xs rounded text-white mr-1 ${b.isActive ? "bg-amber-500 hover:bg-amber-600" : "bg-green-600 hover:bg-green-700"}`} onClick={() => toggleActive(b)}>
{b.isActive ? "Turn off" : "Turn on"}
</button>
<button className="px-2 py-1 text-xs rounded border text-red-700 hover:bg-red-50" onClick={() => remove(b)} aria-label={`Delete ${b.name}`}><Trash2 className="w-3.5 h-3.5" /></button>
</TableCell>
</TableRow>
);
})}
{!fetching && bundles.length === 0 && (
<TableRow><TableCell colSpan={6} className="text-center text-sm text-gray-500 py-8">{event ? "No bundle deals for this event yet." : "Choose an event."}</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
{event && options.length === 0 && (
<p className="text-sm text-gray-500 py-6 text-center">This event has no tickets yet. Add ticket options to the event first.</p>
)}
{event && options.length > 0 && !fetching && bundles.length === 0 && (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-emerald-50 flex items-center justify-center mx-auto mb-3">
<Gift className="w-6 h-6 text-emerald-600" />
</div>
<p className="text-sm font-medium text-gray-900">No bundle deals for this event yet</p>
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">e.g. &quot;Family deal: 2 adults + 3 kids for R220&quot;. Buyers get it automatically when their tickets match.</p>
<button onClick={startNew} className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
<Plus className="w-4 h-4" /> Create a bundle
</button>
</div>
)}
{bundles.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{bundles.map(b => (
<BundleCard
key={b.id}
bundle={b}
onEdit={() => setEditing({ id: b.id, draft: draftFromBundle(b) })}
onToggle={() => toggleActive(b)}
onDelete={() => remove(b)}
/>
))}
</div>
)}
<p className="text-xs text-gray-500 mt-4">
Bundles apply automatically, as many times as someone&apos;s tickets allow, and only when cheaper than the tickets at the prices
actually charged (early-bird and staff prices included). Coupon codes come off afterwards.
</p>
</div>
)}
@@ -246,6 +243,60 @@ export default function BundlesPage() {
);
}
function BundleCard({ bundle: b, onEdit, onToggle, onDelete }: { bundle: EventBundle; onEdit: () => void; onToggle: () => void; onDelete: () => void }) {
const status = bundleStatus(b);
const save = b.standardValue - b.price;
const dates = b.validFrom || b.validUntil
? `${b.validFrom ? fmtDate(b.validFrom) : "Now"} – ${b.validUntil ? fmtDate(b.validUntil) : "no end date"}`
: null;
return (
<div className={`border rounded-xl p-4 flex flex-col gap-3 ${b.isActive ? "bg-white" : "bg-gray-50"}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-gray-900 break-words">{b.name}</div>
{b.description && <div className="text-xs text-gray-500 mt-0.5 break-words">{b.description}</div>}
</div>
<span className={`shrink-0 text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{b.items.map(i => (
<span key={i.id || `${i.eventOptionId}-${i.variantId}`} className="inline-flex items-center gap-1 rounded-full bg-emerald-50 text-emerald-800 border border-emerald-100 px-2.5 py-1 text-xs">
<span className="font-semibold">{i.quantity} ×</span> {i.eventOption?.name || "Ticket"}{i.variant ? ` (${i.variant.name})` : ""}
</span>
))}
</div>
<div className="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
<span className="text-xl font-semibold text-gray-900">{fmtRand(b.price)}</span>
<span className="text-sm text-gray-400 line-through">{fmtRand(b.standardValue)}</span>
<span className={`text-xs font-medium ${save > 0 ? "text-green-700" : "text-amber-700"}`}>
{save > 0 ? `saves ${fmtRand(save)}` : "no saving at the standard price"}
</span>
</div>
<div className="text-xs text-gray-600 space-y-0.5">
<div>
Used <strong>{b.uses || 0}×</strong> on {b.registrations || 0} registration{b.registrations === 1 ? "" : "s"} · {fmtRand(b.totalDiscount || 0)} given
</div>
{dates && <div className="flex items-center gap-1"><CalendarClock className="w-3.5 h-3.5" /> {dates}</div>}
</div>
<div className="flex flex-wrap gap-2 pt-1 border-t border-gray-100 mt-auto">
<button onClick={onEdit} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
<Pencil className="w-3.5 h-3.5" /> Edit
</button>
<button onClick={onToggle} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border mt-2 ${b.isActive ? "text-amber-700 border-amber-200 hover:bg-amber-50" : "text-green-700 border-green-200 hover:bg-green-50"}`}>
<Power className="w-3.5 h-3.5" /> {b.isActive ? "Turn off" : "Turn on"}
</button>
<button onClick={onDelete} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border text-red-700 border-red-200 hover:bg-red-50 mt-2 sm:ml-auto">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</div>
</div>
);
}
function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
token: string;
event: EventLite;
@@ -262,7 +313,8 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
const setItem = (i: number, patch: Partial<ItemDraft>) => set("items", d.items.map((it, j) => (j === i ? { ...it, ...patch } : it)));
const standardValue = d.items.reduce((s, it) => s + unitPrice(options, it) * (Number(it.quantity) || 0), 0);
const saving_ = standardValue - (Number(d.price) || 0);
const price = Number(d.price) || 0;
const saving_ = standardValue - price;
const save = async (e: React.FormEvent) => {
e.preventDefault();
@@ -293,14 +345,17 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
};
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">{bundleId ? "Edit bundle" : "New bundle deal"} <span className="text-sm font-normal text-gray-500">· {event.title}</span></h2>
<button type="button" onClick={onClose} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
<div className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center sm:p-4" onClick={onClose}>
<form onSubmit={save} className="bg-white w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between gap-3 p-4 border-b">
<div className="min-w-0">
<h2 className="font-semibold">{bundleId ? "Edit bundle" : "New bundle deal"}</h2>
<p className="text-xs text-gray-500 truncate">{event.title}</p>
</div>
<button type="button" onClick={onClose} aria-label="Close" className="p-1 -m-1"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 space-y-5 overflow-y-auto">
<div className="p-4 space-y-5 overflow-y-auto overscroll-contain">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
@@ -308,76 +363,86 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
<p className="text-xs text-gray-500 mt-1">Shown to buyers and on receipts.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Description <span className="font-normal text-gray-400">(optional)</span></label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Bring the whole family" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tickets in the bundle</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Tickets in the bundle</label>
<div className="space-y-2">
{d.items.map((it, i) => {
const opt = options.find(o => o.id === it.eventOptionId);
const hasVariants = (opt?.variants || []).length > 0;
return (
<div key={i} className="flex flex-wrap sm:flex-nowrap items-center gap-2">
<input type="number" min="1" step="1" className={`${inputCls} w-20 shrink-0`} value={it.quantity} onChange={e => setItem(i, { quantity: e.target.value })} aria-label="Quantity" required />
<span className="text-sm text-gray-500">×</span>
<select className={`${inputCls} min-w-0`} value={it.eventOptionId} onChange={e => setItem(i, { eventOptionId: e.target.value, variantId: null })}>
{options.map(o => <option key={o.id} value={o.id}>{o.name} ({fmtRand(o.price)})</option>)}
</select>
{(opt?.variants || []).length > 0 && (
<select className={`${inputCls} min-w-0`} value={it.variantId || ""} onChange={e => setItem(i, { variantId: e.target.value || null })} aria-label="Variant">
<option value="">Any variant</option>
<div key={i} className="rounded-lg border bg-gray-50 p-2.5">
<div className="flex items-center gap-2">
<input type="number" min="1" step="1" inputMode="numeric" className={`${inputCls} w-16 shrink-0 bg-white text-center`} value={it.quantity} onChange={e => setItem(i, { quantity: e.target.value })} aria-label="Quantity" required />
<span className="text-sm text-gray-500 shrink-0">×</span>
<select className={`${inputCls} min-w-0 flex-1 bg-white`} value={it.eventOptionId} onChange={e => setItem(i, { eventOptionId: e.target.value, variantId: null })} aria-label="Ticket">
{options.map(o => <option key={o.id} value={o.id}>{o.name} ({fmtRand(o.price)})</option>)}
</select>
<button type="button" onClick={() => set("items", d.items.filter((_, j) => j !== i))} disabled={d.items.length <= 1}
className="p-2 shrink-0 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 disabled:opacity-30 disabled:hover:bg-transparent" aria-label="Remove ticket">
<Trash2 className="w-4 h-4" />
</button>
</div>
{hasVariants && (
<select className={`${inputCls} mt-2 bg-white`} value={it.variantId || ""} onChange={e => setItem(i, { variantId: e.target.value || null })} aria-label="Variant">
<option value="">Any {opt?.name.toLowerCase()} variant</option>
{(opt?.variants || []).map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
)}
<button type="button" onClick={() => set("items", d.items.filter((_, j) => j !== i))} disabled={d.items.length <= 1}
className="p-2 rounded border text-gray-500 hover:bg-gray-50 disabled:opacity-40" aria-label="Remove ticket">
<X className="w-4 h-4" />
</button>
</div>
);
})}
</div>
<button type="button" onClick={() => set("items", [...d.items, { eventOptionId: options[0]?.id || "", variantId: null, quantity: "1" }])}
className="mt-2 inline-flex items-center gap-1 text-sm text-brand-700 hover:underline">
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-brand-700 hover:underline">
<Plus className="w-4 h-4" /> Add a ticket type
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Bundle price (R)</label>
<input type="number" min="0" step="0.01" className={inputCls} value={d.price} onChange={e => set("price", e.target.value)} required />
</div>
<div className="flex items-end">
<div className={`text-sm ${saving_ > 0 ? "text-green-700" : "text-amber-700"}`}>
Separately: {fmtRand(standardValue)}
{d.price !== "" && (saving_ > 0 ? ` · saves ${fmtRand(saving_)}` : " · no saving at the standard price")}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Bundle price (R)</label>
<input type="number" min="0" step="0.01" inputMode="decimal" className={`${inputCls} sm:max-w-[12rem]`} value={d.price} onChange={e => set("price", e.target.value)} required />
<div className="mt-2 grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg bg-gray-50 p-2">
<div className="text-[11px] uppercase tracking-wide text-gray-500">Separately</div>
<div className="text-sm font-semibold text-gray-900">{fmtRand(standardValue)}</div>
</div>
<div className="rounded-lg bg-gray-50 p-2">
<div className="text-[11px] uppercase tracking-wide text-gray-500">Bundle</div>
<div className="text-sm font-semibold text-gray-900">{d.price === "" ? "—" : fmtRand(price)}</div>
</div>
<div className={`rounded-lg p-2 ${d.price !== "" && saving_ <= 0 ? "bg-amber-50" : "bg-emerald-50"}`}>
<div className="text-[11px] uppercase tracking-wide text-gray-500">Saving</div>
<div className={`text-sm font-semibold ${d.price !== "" && saving_ <= 0 ? "text-amber-700" : "text-emerald-700"}`}>{d.price === "" ? "—" : fmtRand(Math.max(0, saving_))}</div>
</div>
</div>
{d.price !== "" && saving_ <= 0 && <p className="text-xs text-amber-700 mt-1">The bundle isn&apos;t cheaper than the standard prices, so it won&apos;t apply unless prices change.</p>}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Available from</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Available from <span className="font-normal text-gray-400">(optional)</span></label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Available until</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Available until <span className="font-normal text-gray-400">(optional)</span></label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={d.isActive} onChange={e => set("isActive", e.target.checked)} /> On sale
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.isActive} onChange={e => set("isActive", e.target.checked)} />
<span>On sale <span className="block text-xs text-gray-500">Registrations that already have this bundle keep it if you turn it off later.</span></span>
</label>
<p className="text-xs text-gray-500 -mt-3">Registrations that already have this bundle keep it if you turn it off or change it later.</p>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
<div className="flex justify-end gap-3 p-4 border-t">
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 p-4 border-t">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : bundleId ? "Save changes" : "Create bundle"}