From 6ce59af0ea97e0bbd35cec704c18d05caef38a83 Mon Sep 17 00:00:00 2001 From: joshua Date: Wed, 21 Oct 2026 15:56:03 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 6 + .../app/dashboard/supervisor/bundles/page.tsx | 301 +++++++++++------- 2 files changed, 189 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dea28f4..31e2e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/frontend/src/app/dashboard/supervisor/bundles/page.tsx b/frontend/src/app/dashboard/supervisor/bundles/page.tsx index 352d18a..1a0c63d 100644 --- a/frontend/src/app/dashboard/supervisor/bundles/page.tsx +++ b/frontend/src/app/dashboard/supervisor/bundles/page.tsx @@ -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([]); + const [allEvents, setAllEvents] = useState([]); + const [showPast, setShowPast] = useState(false); const [eventId, setEventId] = useState(""); const [bundles, setBundles] = useState([]); const [fetching, setFetching] = useState(false); @@ -86,20 +87,27 @@ export default function BundlesPage() { useEffect(() => { if (!token || !canView) return; apiFetch("/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(`/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 ( -
-
+
+
@@ -143,14 +154,14 @@ export default function BundlesPage() {

Bundle deals

-

Sell a set of tickets together for one price, e.g. 5 adults + 3 children for R500

+

Tickets sold together for one price

- {canView && event && ( + {canView && ( @@ -165,70 +176,56 @@ export default function BundlesPage() { {canView && (
-
- - +
+
+ + +
+
-

- Bundles apply automatically, as many times as someone's tickets allow, and only when they're cheaper than the tickets - at today's prices (early-bird or staff prices included). Coupon codes come off afterwards. -

-
- - - - Bundle - Price - Used - Available - Status - Actions - - - - {bundles.map(b => { - const status = bundleStatus(b); - const save = b.standardValue - b.price; - return ( - - -
{b.name}
-
{b.summary}
- {b.description &&
{b.description}
} -
- - {fmtRand(b.price)} -
0 ? "text-green-700" : "text-amber-700"}`}> - {save > 0 ? `Saves ${fmtRand(save)} on ${fmtRand(b.standardValue)}` : `No saving on ${fmtRand(b.standardValue)}`} -
-
- - {b.uses || 0}× on {b.registrations || 0} registration{b.registrations === 1 ? "" : "s"} -
{fmtRand(b.totalDiscount || 0)} given
-
- - {b.validFrom || b.validUntil ? `${b.validFrom ? fmtDate(b.validFrom) : "Now"} – ${b.validUntil ? fmtDate(b.validUntil) : "no end"}` : "Always"} - - {status.label} - - - - - -
- ); - })} - {!fetching && bundles.length === 0 && ( - {event ? "No bundle deals for this event yet." : "Choose an event."} - )} -
-
-
+ {event && options.length === 0 && ( +

This event has no tickets yet. Add ticket options to the event first.

+ )} + + {event && options.length > 0 && !fetching && bundles.length === 0 && ( +
+
+ +
+

No bundle deals for this event yet

+

e.g. "Family deal: 2 adults + 3 kids for R220". Buyers get it automatically when their tickets match.

+ +
+ )} + + {bundles.length > 0 && ( +
+ {bundles.map(b => ( + setEditing({ id: b.id, draft: draftFromBundle(b) })} + onToggle={() => toggleActive(b)} + onDelete={() => remove(b)} + /> + ))} +
+ )} + +

+ Bundles apply automatically, as many times as someone'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. +

)} @@ -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 ( +
+
+
+
{b.name}
+ {b.description &&
{b.description}
} +
+ {status.label} +
+ +
+ {b.items.map(i => ( + + {i.quantity} × {i.eventOption?.name || "Ticket"}{i.variant ? ` (${i.variant.name})` : ""} + + ))} +
+ +
+ {fmtRand(b.price)} + {fmtRand(b.standardValue)} + 0 ? "text-green-700" : "text-amber-700"}`}> + {save > 0 ? `saves ${fmtRand(save)}` : "no saving at the standard price"} + +
+ +
+
+ Used {b.uses || 0}× on {b.registrations || 0} registration{b.registrations === 1 ? "" : "s"} · {fmtRand(b.totalDiscount || 0)} given +
+ {dates &&
{dates}
} +
+ +
+ + + +
+
+ ); +} + 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) => 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 ( -
-
e.stopPropagation()}> -
-

{bundleId ? "Edit bundle" : "New bundle deal"} · {event.title}

- +
+ e.stopPropagation()}> +
+
+

{bundleId ? "Edit bundle" : "New bundle deal"}

+

{event.title}

+
+
-
+
@@ -308,76 +363,86 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {

Shown to buyers and on receipts.

- + set("description", e.target.value)} placeholder="Bring the whole family" />
- +
{d.items.map((it, i) => { const opt = options.find(o => o.id === it.eventOptionId); + const hasVariants = (opt?.variants || []).length > 0; return ( -
- setItem(i, { quantity: e.target.value })} aria-label="Quantity" required /> - × - - {(opt?.variants || []).length > 0 && ( - setItem(i, { quantity: e.target.value })} aria-label="Quantity" required /> + × + + +
+ {hasVariants && ( + )} -
); })}
-
-
- - set("price", e.target.value)} required /> -
-
-
0 ? "text-green-700" : "text-amber-700"}`}> - Separately: {fmtRand(standardValue)} - {d.price !== "" && (saving_ > 0 ? ` · saves ${fmtRand(saving_)}` : " · no saving at the standard price")} +
+ + set("price", e.target.value)} required /> +
+
+
Separately
+
{fmtRand(standardValue)}
+
+
+
Bundle
+
{d.price === "" ? "—" : fmtRand(price)}
+
+
+
Saving
+
{d.price === "" ? "—" : fmtRand(Math.max(0, saving_))}
+ {d.price !== "" && saving_ <= 0 &&

The bundle isn't cheaper than the standard prices, so it won't apply unless prices change.

}
- + set("validFrom", e.target.value)} />
- + set("validUntil", e.target.value)} />
-
-
+