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:
@@ -7,6 +7,17 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.28.1] - 2026-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- At the door 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
|
## [1.28.0] - 2026-10-21
|
||||||
|
|
||||||
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
|
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { apiFetch } from "@/lib/api";
|
|||||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
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 { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
|
||||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||||
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
|
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
|
||||||
import { errorMessage } from "@/lib/utils";
|
import { errorMessage } from "@/lib/utils";
|
||||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
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" };
|
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 [doorAllowsNames, setDoorAllowsNames] = useState(false);
|
||||||
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
|
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
|
||||||
const [doorCollectGender, setDoorCollectGender] = useState(false);
|
const [doorCollectGender, setDoorCollectGender] = useState(false);
|
||||||
|
const [doorBundles, setDoorBundles] = useState<PublicBundle[]>([]);
|
||||||
const [doorNaming, setDoorNaming] = useState(false);
|
const [doorNaming, setDoorNaming] = useState(false);
|
||||||
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
|
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
|
||||||
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
|
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
|
||||||
@@ -164,8 +166,10 @@ export default function AtTheDoorPage() {
|
|||||||
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
|
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
|
||||||
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
|
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
|
||||||
setDoorCollectGender(!!ev.collectGender);
|
setDoorCollectGender(!!ev.collectGender);
|
||||||
|
setDoorBundles(ev.bundles || []);
|
||||||
} catch {
|
} catch {
|
||||||
setEventOptions([]);
|
setEventOptions([]);
|
||||||
|
setDoorBundles([]);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [eventId, token]);
|
}, [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">
|
<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" />
|
<DoorOpen className="w-5 h-5 text-brand-600" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<select
|
<select
|
||||||
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
|
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}
|
setCoupon={setDoorCoupon}
|
||||||
couponForUserId={pendingEditReg ? pendingEditReg.userId : null}
|
couponForUserId={pendingEditReg ? pendingEditReg.userId : null}
|
||||||
existingCode={(pendingEditReg?.couponRedemptions || [])[0]?.coupon?.code || null}
|
existingCode={(pendingEditReg?.couponRedemptions || [])[0]?.coupon?.code || null}
|
||||||
|
bundles={doorBundles}
|
||||||
names={doorAllowsNames && !pendingEditReg ? {
|
names={doorAllowsNames && !pendingEditReg ? {
|
||||||
enabled: doorNaming, onEnabledChange: setDoorNaming,
|
enabled: doorNaming, onEnabledChange: setDoorNaming,
|
||||||
entries: doorEntries, onEntriesChange: setDoorEntries,
|
entries: doorEntries, onEntriesChange: setDoorEntries,
|
||||||
@@ -1226,11 +1234,12 @@ type OptionsModalProps = {
|
|||||||
setCoupon: (c: AppliedCoupon | null) => void;
|
setCoupon: (c: AppliedCoupon | null) => void;
|
||||||
couponForUserId: string | null;
|
couponForUserId: string | null;
|
||||||
existingCode: string | null;
|
existingCode: string | null;
|
||||||
|
bundles: PublicBundle[];
|
||||||
// Named-attendee editor settings, or null when the event doesn't take names.
|
// Named-attendee editor settings, or null when the event doesn't take names.
|
||||||
names: Omit<React.ComponentProps<typeof AttendeeNamesEditor>, "options" | "qtyFor" | "payerLabel"> | null;
|
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)
|
const basket = Object.entries(quantities)
|
||||||
.filter(([, qty]) => qty > 0)
|
.filter(([, qty]) => qty > 0)
|
||||||
.map(([key, quantity]) => {
|
.map(([key, quantity]) => {
|
||||||
@@ -1279,6 +1288,13 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
|||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-3">
|
<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) => {
|
{safeOptions.map((opt) => {
|
||||||
const hasVariants = (opt.variants || []).length > 0;
|
const hasVariants = (opt.variants || []).length > 0;
|
||||||
if (hasVariants) {
|
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">
|
<div className="p-4 border-t flex-shrink-0 bg-white">
|
||||||
{belowPaid && (
|
{belowPaid && (
|
||||||
<div className="mb-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{belowIssued && (
|
{belowIssued && (
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
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 { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||||
import { errorMessage } from "@/lib/utils";
|
import { errorMessage } from "@/lib/utils";
|
||||||
import type { Registration } from "@/types";
|
import type { PublicBundle, Registration } from "@/types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
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 [setupError, setSetupError] = useState<string | null>(null);
|
||||||
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
|
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
|
||||||
const [kioskCoupon, setKioskCoupon] = useState<AppliedCoupon | null>(null);
|
const [kioskCoupon, setKioskCoupon] = useState<AppliedCoupon | null>(null);
|
||||||
|
const [kioskBundles, setKioskBundles] = useState<PublicBundle[]>([]);
|
||||||
const [namingOthers, setNamingOthers] = useState(false);
|
const [namingOthers, setNamingOthers] = useState(false);
|
||||||
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
|
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
|
||||||
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
|
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
|
||||||
@@ -218,6 +219,16 @@ export default function SelfServicePage() {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, [selectedEvent, quantities]);
|
}, [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 kioskBasket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
|
||||||
const [eventOptionId, variantId] = key.split("::");
|
const [eventOptionId, variantId] = key.split("::");
|
||||||
return { eventOptionId, quantity, variantId: variantId || null };
|
return { eventOptionId, quantity, variantId: variantId || null };
|
||||||
@@ -946,6 +957,7 @@ export default function SelfServicePage() {
|
|||||||
{(selectedEvent.eventOptions || []).length > 0 && (
|
{(selectedEvent.eventOptions || []).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">Ticket Options</label>
|
<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">
|
<div className="space-y-3">
|
||||||
{(selectedEvent.eventOptions || []).map((opt) => {
|
{(selectedEvent.eventOptions || []).map((opt) => {
|
||||||
const hasVariants = (opt.variants || []).length > 0;
|
const hasVariants = (opt.variants || []).length > 0;
|
||||||
@@ -1021,6 +1033,13 @@ export default function SelfServicePage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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) && (
|
{(selectedEvent?.allowNamedAttendees || selectedEvent?.requiresParentalConsent) && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<AttendeeNamesEditor
|
<AttendeeNamesEditor
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Gift } from "lucide-react";
|
import { Gift } from "lucide-react";
|
||||||
import { apiFetch } from "@/lib/api";
|
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";
|
import type { BasketItem } from "@/components/shared/CouponCodeField";
|
||||||
|
|
||||||
const fmt = (n: number) => `R${n.toFixed(2)}`;
|
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. */
|
/** 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) {
|
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);
|
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
|
* Quantities that make up `sets` full bundles, or null when there aren't enough tickets
|
||||||
|
|||||||
Reference in New Issue
Block a user