Compare commits

..
6 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 428b438251 Bump to 1.28.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:56:04 +02:00
joshuaandClaude Opus 5.5 6ce59af0ea 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>
2026-10-21 15:56:03 +02:00
joshuaandClaude Opus 5.5 53ed72d032 Add bundle deals to manual registration
Lists the event's deals and shows the Complete bundle / + cards, as at the door and
on the kiosk. Part of 1.28.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:32:17 +02:00
joshuaandClaude Opus 5.5 35d02c243b Bump to 1.28.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:49 +02:00
joshuaandClaude Opus 5.5 e2e6ec0e97 Merge branch 'feat/bundles-door-kiosk'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:48 +02:00
joshuaandClaude Opus 5.5 92ab8065ef Add bundle deals to at the door and the kiosk
Both list the event's deals and show the Complete bundle / + cards from the register
form. At the door gets the standard Back to dashboard link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:48 +02:00
12 changed files with 282 additions and 139 deletions
+17
View File
@@ -7,6 +7,23 @@ 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
- At the door, manual registration and the self-service kiosk now list the event's bundle deals and show the same bundle cards as the register form: once a ticket from a bundle is picked, **Complete bundle** tops up the ticket numbers, and **+** adds another full set.
### Fixed
- At the door now has the same "← Back to dashboard" link as the other dashboard pages.
- At the door's "less than already paid" warning shows the total after bundle deals.
## [1.28.0] - 2026-10-21
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.28.0",
"version": "1.28.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.28.0",
"version": "1.28.2",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.28.0",
"version": "1.28.2",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.28.0",
"version": "1.28.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.28.0",
"version": "1.28.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.28.0",
"version": "1.28.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -7,13 +7,14 @@ import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import { BackLink } from "@/components/shared/BackLink";
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import type { Event, EventOption, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -105,6 +106,7 @@ export default function AtTheDoorPage() {
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
const [doorCollectGender, setDoorCollectGender] = useState(false);
const [doorBundles, setDoorBundles] = useState<PublicBundle[]>([]);
const [doorNaming, setDoorNaming] = useState(false);
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
@@ -164,8 +166,10 @@ export default function AtTheDoorPage() {
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
setDoorCollectGender(!!ev.collectGender);
setDoorBundles(ev.bundles || []);
} catch {
setEventOptions([]);
setDoorBundles([]);
}
})();
}, [eventId, token]);
@@ -395,7 +399,10 @@ export default function AtTheDoorPage() {
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<DoorOpen className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">At The Door</h1>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">At The Door</h1>
</div>
</div>
<select
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
@@ -507,6 +514,7 @@ export default function AtTheDoorPage() {
setCoupon={setDoorCoupon}
couponForUserId={pendingEditReg ? pendingEditReg.userId : null}
existingCode={(pendingEditReg?.couponRedemptions || [])[0]?.coupon?.code || null}
bundles={doorBundles}
names={doorAllowsNames && !pendingEditReg ? {
enabled: doorNaming, onEnabledChange: setDoorNaming,
entries: doorEntries, onEntriesChange: setDoorEntries,
@@ -1226,11 +1234,12 @@ type OptionsModalProps = {
setCoupon: (c: AppliedCoupon | null) => void;
couponForUserId: string | null;
existingCode: string | null;
bundles: PublicBundle[];
// Named-attendee editor settings, or null when the event doesn't take names.
names: Omit<React.ComponentProps<typeof AttendeeNamesEditor>, "options" | "qtyFor" | "payerLabel"> | null;
};
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, names }: OptionsModalProps) {
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, bundles, names }: OptionsModalProps) {
const basket = Object.entries(quantities)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
@@ -1279,6 +1288,13 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-3">
<BundleOffers bundles={bundles} />
<BundleBuilder
bundles={bundles}
options={safeOptions}
qty={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
onSetQuantity={(o, v, n) => setQuantities(q => ({ ...q, [v ? `${o}::${v}` : o]: n }))}
/>
{safeOptions.map((opt) => {
const hasVariants = (opt.variants || []).length > 0;
if (hasVariants) {
@@ -1339,7 +1355,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div className="p-4 border-t flex-shrink-0 bg-white">
{belowPaid && (
<div className="mb-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
New total (R {total.toFixed(2)}) is less than amount already paid (R {Number(totalPaid).toFixed(2)}). Please increase the selection.
New total (R {afterBundles.toFixed(2)}) is less than amount already paid (R {Number(totalPaid).toFixed(2)}). Please increase the selection.
</div>
)}
{belowIssued && (
@@ -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"}
@@ -7,12 +7,12 @@ import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { UserPlus, CreditCard } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import type { Event, EventOption, Registration, RegistrationOption, User } from "@/types";
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -60,6 +60,7 @@ export default function ManualRegistrationPage() {
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [quantities, setQuantities] = useState<Record<string, number>>({});
const [bundles, setBundles] = useState<PublicBundle[]>([]);
// User search state
const [userQuery, setUserQuery] = useState("");
@@ -267,6 +268,16 @@ export default function ManualRegistrationPage() {
}
}, [selectedEventId, events]);
// The events list doesn't carry bundle deals; the single-event response does.
useEffect(() => {
if (!selectedEventId) { setBundles([]); return; }
let cancelled = false;
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: token || undefined })
.then(ev => { if (!cancelled) setBundles(ev?.bundles || []); })
.catch(() => { if (!cancelled) setBundles([]); });
return () => { cancelled = true; };
}, [selectedEventId, token]);
const totalDue = useMemo(() => {
return options.reduce((sum, o) => {
if ((o.variants || []).length > 0) {
@@ -535,6 +546,7 @@ export default function ManualRegistrationPage() {
<div className="mt-6 border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">3) Select ticket options</div>
{options.length > 0 && <BundleOffers bundles={bundles} className="mb-3" />}
{options.length === 0 ? (
<div className="text-sm text-gray-500">Select an event to view options.</div>
) : (
@@ -598,6 +610,13 @@ export default function ManualRegistrationPage() {
})}
</div>
)}
<BundleBuilder
className="mt-3"
bundles={bundles}
options={options}
qty={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
onSetQuantity={(o, v, n) => setQuantities(q => ({ ...q, [v ? `${o}::${v}` : o]: n }))}
/>
</div>
{selectedEventAllowsNames && (
+21 -2
View File
@@ -5,10 +5,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { apiFetch } from "@/lib/api";
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types";
import type { PublicBundle, Registration } from "@/types";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -144,6 +144,7 @@ export default function SelfServicePage() {
const [setupError, setSetupError] = useState<string | null>(null);
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
const [kioskCoupon, setKioskCoupon] = useState<AppliedCoupon | null>(null);
const [kioskBundles, setKioskBundles] = useState<PublicBundle[]>([]);
const [namingOthers, setNamingOthers] = useState(false);
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
@@ -218,6 +219,16 @@ export default function SelfServicePage() {
}, 0);
}, [selectedEvent, quantities]);
// The events list doesn't carry bundle deals; the single-event response does.
useEffect(() => {
if (!selectedEventId) { setKioskBundles([]); return; }
let cancelled = false;
apiFetch<{ bundles?: PublicBundle[] }>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
.then(ev => { if (!cancelled) setKioskBundles(ev?.bundles || []); })
.catch(() => { if (!cancelled) setKioskBundles([]); });
return () => { cancelled = true; };
}, [selectedEventId, supervisorToken]);
const kioskBasket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
@@ -946,6 +957,7 @@ export default function SelfServicePage() {
{(selectedEvent.eventOptions || []).length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Ticket Options</label>
<BundleOffers bundles={kioskBundles} className="mb-3" />
<div className="space-y-3">
{(selectedEvent.eventOptions || []).map((opt) => {
const hasVariants = (opt.variants || []).length > 0;
@@ -1021,6 +1033,13 @@ export default function SelfServicePage() {
);
})}
</div>
<BundleBuilder
className="mt-3"
bundles={kioskBundles}
options={selectedEvent.eventOptions || []}
qty={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
onSetQuantity={(o, v, n) => setQuantities(q => ({ ...q, [v ? `${o}::${v}` : o]: n }))}
/>
{(selectedEvent?.allowNamedAttendees || selectedEvent?.requiresParentalConsent) && (
<div className="mt-4">
<AttendeeNamesEditor
+10 -3
View File
@@ -3,7 +3,7 @@
import React, { useEffect, useState } from "react";
import { Gift } from "lucide-react";
import { apiFetch } from "@/lib/api";
import type { BundleItem, BundleQuote, EventOption, PublicBundle } from "@/types";
import type { BundleItem, BundleQuote, PublicBundle } from "@/types";
import type { BasketItem } from "@/components/shared/CouponCodeField";
const fmt = (n: number) => `R${n.toFixed(2)}`;
@@ -90,7 +90,14 @@ export function BundleSavings({ applications, className = "" }: {
);
}
type BuilderOption = Pick<EventOption, "id" | "name" | "stockLimit" | "availableCount" | "variants">;
// Just what the builder reads, so pages with their own option types can pass them in.
type BuilderOption = {
id: string;
name: string;
stockLimit?: number | null;
availableCount?: number | null;
variants?: { id: string; name: string; stockLimit?: number | null; availableCount?: number | null }[] | null;
};
/** Tickets of this item already in the basket. An "any variant" item counts every variant. */
function itemHave(item: BundleItem, opt: BuilderOption | undefined, qty: (optionId: string, variantId: string | null) => number) {
@@ -100,7 +107,7 @@ function itemHave(item: BundleItem, opt: BuilderOption | undefined, qty: (option
return variants.length > 0 ? variants.reduce((s, v) => s + qty(opt.id, v.id), 0) : qty(opt.id, null);
}
const leftOf = (stockLimit?: number, availableCount?: number) => ((stockLimit ?? 0) > 0 && availableCount !== undefined ? availableCount : Infinity);
const leftOf = (stockLimit?: number | null, availableCount?: number | null) => ((stockLimit ?? 0) > 0 && availableCount !== undefined && availableCount !== null ? availableCount : Infinity);
/**
* Quantities that make up `sets` full bundles, or null when there aren't enough tickets
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.28.0",
"version": "1.28.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.28.0",
"version": "1.28.2",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.28.0",
"version": "1.28.2",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",