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>
This commit is contained in:
2026-10-21 15:22:48 +02:00
co-authored by Claude Opus 5.5
parent 084e3c6679
commit 92ab8065ef
4 changed files with 63 additions and 10 deletions
@@ -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 && (
+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