Files
hope-events/frontend/src/components/shared/BundleDeals.tsx
T
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

209 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useEffect, useState } from "react";
import { Gift } from "lucide-react";
import { apiFetch } from "@/lib/api";
import type { BundleItem, BundleQuote, PublicBundle } from "@/types";
import type { BasketItem } from "@/components/shared/CouponCodeField";
const fmt = (n: number) => `R${n.toFixed(2)}`;
/**
* The bundle deals a basket gets, from POST /api/bundles/quote (the server prices the
* basket the same way registration will). Null while there's nothing to show. Pass
* `enabled={false}` when the event has no bundles to skip the request.
*
* Staff pricing on someone's behalf pass `forUserId` (the attendee), or `null` for a
* walk-in with no account yet.
*/
export function useBundleQuote({ eventId, basket, token, forUserId, enabled = true }: {
eventId: string;
basket: BasketItem[];
token?: string | null;
forUserId?: string | null;
enabled?: boolean;
}): BundleQuote | null {
const [quote, setQuote] = useState<BundleQuote | null>(null);
const items = basket.filter(b => b.quantity > 0);
const key = JSON.stringify(items);
useEffect(() => {
const options = JSON.parse(key) as BasketItem[];
if (!enabled || !eventId || options.length === 0) { setQuote(null); return; }
let cancelled = false;
const timer = setTimeout(async () => {
try {
const body: Record<string, unknown> = { eventId, options };
if (forUserId !== undefined) body.forUserId = forUserId;
const res = await apiFetch<BundleQuote>("/api/bundles/quote", { method: "POST", authToken: token || undefined, body });
if (!cancelled) setQuote(res.bundleDiscount > 0 ? res : null);
} catch {
if (!cancelled) setQuote(null);
}
}, 300);
return () => { cancelled = true; clearTimeout(timer); };
}, [eventId, key, token, forUserId, enabled]);
return quote;
}
/** The event's deals, so buyers know what to pick. Applied automatically. */
export function BundleOffers({ bundles, className = "" }: { bundles?: PublicBundle[] | null; className?: string }) {
if (!bundles || bundles.length === 0) return null;
return (
<div className={`rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900 ${className}`}>
<div className="flex items-center gap-1.5 font-semibold"><Gift className="h-4 w-4" /> Bundle deals</div>
<ul className="mt-1.5 space-y-1">
{bundles.map(b => {
const save = b.standardValue - b.price;
return (
<li key={b.id}>
<span className="font-medium">{b.name}:</span> {b.summary} for <span className="font-semibold">{fmt(b.price)}</span>
{save > 0.005 && <span className="text-emerald-700"> (save {fmt(save)})</span>}
{b.description && <span className="block text-xs text-emerald-800/80">{b.description}</span>}
</li>
);
})}
</ul>
<p className="mt-1.5 text-xs text-emerald-800/80">Picked automatically when your tickets match, as many times as they fit.</p>
</div>
);
}
/** "Family deal ×2 −R100.00" lines for a quote or a saved registration's bundles. */
export function BundleSavings({ applications, className = "" }: {
applications?: { bundleId: string; name?: string; count: number; discount: number; bundle?: { name: string } | null }[] | null;
className?: string;
}) {
const rows = (applications || []).filter(a => a.discount > 0);
if (rows.length === 0) return null;
return (
<div className={`text-sm text-emerald-700 ${className}`}>
{rows.map(a => (
<div key={a.bundleId} className="flex items-center gap-1.5">
<Gift className="h-3.5 w-3.5 shrink-0" />
<span>{a.name || a.bundle?.name || "Bundle deal"}{a.count > 1 ? ` ×${a.count}` : ""}</span>
<span className="font-medium">−{fmt(a.discount)}</span>
</div>
))}
</div>
);
}
// 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) {
if (!opt) return 0;
if (item.variantId) return qty(opt.id, item.variantId);
const variants = opt.variants || [];
return variants.length > 0 ? variants.reduce((s, v) => s + qty(opt.id, v.id), 0) : qty(opt.id, null);
}
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
* left. Only ever raises quantities. An "any variant" item tops up the variant already
* picked most (or the first one with places left).
*/
function quantitiesForSets(bundle: PublicBundle, options: BuilderOption[], qty: (o: string, v: string | null) => number, sets: number) {
const changes: { optionId: string; variantId: string | null; quantity: number }[] = [];
for (const item of bundle.items) {
const opt = options.find(o => o.id === item.eventOptionId);
if (!opt) return null;
const short = sets * item.quantity - itemHave(item, opt, qty);
if (short <= 0) continue;
let variantId = item.variantId || null;
if (!variantId && (opt.variants || []).length > 0) {
const withRoom = (opt.variants || []).filter(v => leftOf(v.stockLimit, v.availableCount) - qty(opt.id, v.id) >= short);
const pick = withRoom.sort((a, b) => qty(opt.id, b.id) - qty(opt.id, a.id))[0];
if (!pick) return null;
variantId = pick.id;
}
const current = qty(opt.id, variantId);
const variant = variantId ? (opt.variants || []).find(v => v.id === variantId) : null;
if (current + short > Math.min(leftOf(opt.stockLimit, opt.availableCount), variant ? leftOf(variant.stockLimit, variant.availableCount) : Infinity)) return null;
changes.push({ optionId: opt.id, variantId, quantity: current + short });
}
return changes;
}
/**
* On the register form: each bundle the basket has started on, with what's missing and a
* "Complete bundle" button, then "+" to add another full set. Buttons set the ticket
* quantities through `onSetQuantity`.
*/
export function BundleBuilder({ bundles, options, qty, onSetQuantity, className = "" }: {
bundles?: PublicBundle[] | null;
options: BuilderOption[];
qty: (optionId: string, variantId: string | null) => number;
onSetQuantity: (optionId: string, variantId: string | null, quantity: number) => void;
className?: string;
}) {
const rows = (bundles || []).map(bundle => {
const have = bundle.items.map(item => ({ item, opt: options.find(o => o.id === item.eventOptionId), n: itemHave(item, options.find(o => o.id === item.eventOptionId), qty) }));
const sets = Math.min(...have.map(h => Math.floor(h.n / h.item.quantity)));
const touched = have.some(h => h.n > 0);
// Tickets beyond the full sets mean the next set has been started.
const partial = have.some(h => h.n > sets * h.item.quantity);
const missing = partial ? have.filter(h => h.n < (sets + 1) * h.item.quantity).map(h => ({ ...h, short: (sets + 1) * h.item.quantity - h.n })) : [];
const next = quantitiesForSets(bundle, options, qty, sets + 1);
return { bundle, touched, sets, partial, missing, next };
}).filter(r => r.touched);
if (rows.length === 0) return null;
const apply = (changes: NonNullable<ReturnType<typeof quantitiesForSets>>) => changes.forEach(c => onSetQuantity(c.optionId, c.variantId, c.quantity));
const itemName = (item: BundleItem, opt?: BuilderOption) => {
const v = item.variantId ? (opt?.variants || []).find(x => x.id === item.variantId) : null;
return `${opt?.name || "ticket"}${v ? ` (${v.name})` : ""}`;
};
return (
<div className={`space-y-2 ${className}`}>
{rows.map(({ bundle, sets, partial, missing, next }) => {
const save = bundle.standardValue - bundle.price;
return (
<div key={bundle.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
<div className="min-w-0">
<div className="flex items-center gap-1.5 font-semibold"><Gift className="h-4 w-4 shrink-0" /> {bundle.name}</div>
<div className="text-xs text-emerald-800">
{bundle.summary} for {fmt(bundle.price)}{save > 0.005 ? ` (save ${fmt(save)})` : ""}
</div>
{partial ? (
<div className="text-xs text-emerald-800 mt-0.5">
Add {missing.map(m => `${m.short} × ${itemName(m.item, m.opt)}`).join(" and ")} to {sets > 0 ? "complete another bundle" : "complete it"}.
</div>
) : (
<div className="text-xs font-medium text-emerald-700 mt-0.5">✓ {sets > 1 ? `${sets} bundles` : "Bundle"} in your basket</div>
)}
{!next && <div className="text-xs text-amber-700 mt-0.5">Not enough tickets left for {partial ? "this bundle" : "another one"}.</div>}
</div>
{partial ? (
<button type="button" onClick={() => next && apply(next)} disabled={!next}
className="shrink-0 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50">
Complete bundle
</button>
) : (
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs font-medium">×{sets}</span>
<button type="button" onClick={() => next && apply(next)} disabled={!next} aria-label={`Add another ${bundle.name}`} title="Add another bundle"
className="h-7 w-7 rounded border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-100 disabled:opacity-50">
+
</button>
</div>
)}
</div>
);
})}
</div>
);
}