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>
209 lines
10 KiB
TypeScript
209 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|