Fix early-bird price blending and mislabeling; add contact-only events
- Early-bird pricing: RegistrationOption now tracks each purchase as a separate price tranche instead of overwriting a single price/quantity on repeat purchases, so buying more tickets after a tier expires no longer re-prices tickets already bought at the old price. Stock-limit checks, total-due calculation, and the Finance report's revenue-by- option are all tranche-aware; pages that showed one blended price per line now render/total each tranche. Viewing a pending/partially-paid registration (dashboard, detail page, or an event's registration list) now refreshes stale pricing on the spot instead of only at payment time. - Fixed the "(early bird)" dashboard label incorrectly firing on any line priced below the base option price (e.g. a plain cheaper variant) — it now checks the real applied-tier flag. - Added contact-only events (e.g. baptism): no registration/payment flow, shown on the public site with a "Contact us" popup instead of a Register button. Configurable via the admin event wizard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -158,7 +158,9 @@ export default function AdminRegistrationsPage() {
|
||||
return "text-gray-700 bg-gray-50";
|
||||
};
|
||||
|
||||
const totalDueFor = (r: any) => (r.registrationOptions || []).reduce((sum: number, opt: any) => {
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
|
||||
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
|
||||
const totalDueFor = (r: any) => r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => {
|
||||
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price || 0);
|
||||
@@ -334,25 +336,40 @@ export default function AdminRegistrationsPage() {
|
||||
<div className="mb-3">
|
||||
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{r.registrationOptions.map((opt: any) => (
|
||||
<div key={opt.id} className="bg-white border rounded p-2 text-xs">
|
||||
<div className="font-medium">
|
||||
{opt.eventOption?.name || opt.eventOptionId}
|
||||
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{(() => {
|
||||
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
{r.registrationOptions.map((opt: any) => {
|
||||
// A line can span multiple price tranches (e.g. tickets bought
|
||||
// before and after an early-bird tier expired) — show one row per
|
||||
// tranche so its own price/tier status is accurate, not blended.
|
||||
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
|
||||
? opt.tranches
|
||||
: [{
|
||||
id: opt.id,
|
||||
quantity: opt.quantity,
|
||||
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.variant?.price ?? opt.eventOption?.price ?? 0);
|
||||
return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`;
|
||||
})()}
|
||||
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
|
||||
appliedTierId: opt.appliedTierId,
|
||||
}];
|
||||
return (
|
||||
<div key={opt.id} className="bg-white border rounded p-2 text-xs">
|
||||
<div className="font-medium">
|
||||
{opt.eventOption?.name || opt.eventOptionId}
|
||||
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
|
||||
</div>
|
||||
{tranches.map((t: any, idx: number) => {
|
||||
const unit = Number(t.priceSnapshot || 0);
|
||||
return (
|
||||
<div key={t.id || idx} className="text-gray-500">
|
||||
{`Qty: ${t.quantity} × R ${unit.toFixed(2)} = R ${(unit * (t.quantity || 0)).toFixed(2)}`}
|
||||
{t.appliedTierId && (
|
||||
<span className="text-green-700 text-[10px] ml-1">(early bird)</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{opt.appliedTierId && (
|
||||
<div className="text-green-700 text-[10px] mt-0.5">Early-bird price applied</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
@@ -608,8 +608,9 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
const options = registration.options || registration.registrationOptions || [];
|
||||
const payments = registration.payments || [];
|
||||
|
||||
const totalValue = options.reduce((sum: number, opt: any) => {
|
||||
// Use priceSnapshot (authoritative backend price, variant-aware) if available
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
||||
const totalValue = registration.totalDueComputed ?? options.reduce((sum: number, opt: any) => {
|
||||
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price ?? opt.price ?? 0);
|
||||
@@ -650,7 +651,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
const updatedOptions = updated.options || updated.registrationOptions || [];
|
||||
const updatedPayments = updated.payments || [];
|
||||
|
||||
const totalValue = updatedOptions.reduce((sum: number, opt: any) => {
|
||||
const totalValue = updated.totalDueComputed ?? updatedOptions.reduce((sum: number, opt: any) => {
|
||||
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price ?? opt.price ?? 0);
|
||||
@@ -1737,12 +1738,12 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||||
<div className="border rounded p-3 text-center">
|
||||
<div className="text-xs text-gray-500">TOTAL</div>
|
||||
<div className="font-semibold">
|
||||
R {(selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => {
|
||||
R {(selectedReg.totalDueComputed ?? (selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => {
|
||||
const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined
|
||||
? Number(o.priceSnapshot)
|
||||
: (o.eventOption?.price || o.price || 0);
|
||||
return s + price * (o.quantity || 0);
|
||||
}, 0).toFixed(2)}
|
||||
}, 0)).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 text-center bg-red-50">
|
||||
|
||||
@@ -755,12 +755,14 @@ interface EventDraft {
|
||||
title: string; description: string; startDate: string; endDate: string;
|
||||
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
|
||||
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
|
||||
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
|
||||
}
|
||||
|
||||
const blankDraft = (): EventDraft => ({
|
||||
title: "", description: "", startDate: "", endDate: "",
|
||||
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
|
||||
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
|
||||
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
|
||||
});
|
||||
|
||||
const blankOptions = (): OptionDraft[] => [
|
||||
@@ -791,6 +793,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt),
|
||||
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
|
||||
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
|
||||
requiresRegistration: ev.requiresRegistration !== false,
|
||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||
} : blankDraft());
|
||||
|
||||
// ── options (with per-variant tiers) ──
|
||||
@@ -873,6 +877,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// ── save helpers ──
|
||||
|
||||
const saveOptions = async (eventId: string) => {
|
||||
if (!draft.requiresRegistration) {
|
||||
// Contact-only events have no ticket options to save — but a form (if any) still needs
|
||||
// saving in edit mode; for create mode the form is already included in the POST body.
|
||||
if (mode === "edit") {
|
||||
await apiFetch(`/api/events/${eventId}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) } }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mode === "edit") {
|
||||
for (const opt of options) {
|
||||
// Build flat tier array: option-level + all variant tiers
|
||||
@@ -1032,7 +1047,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const handleSave = async () => {
|
||||
if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; }
|
||||
if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; }
|
||||
if (mode === "create") {
|
||||
if (mode === "create" && draft.requiresRegistration) {
|
||||
if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; }
|
||||
if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; }
|
||||
}
|
||||
@@ -1043,9 +1058,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
|
||||
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
|
||||
goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined,
|
||||
price: draft.price ? parseFloat(draft.price) : 0,
|
||||
price: draft.requiresRegistration ? (draft.price ? parseFloat(draft.price) : 0) : 0,
|
||||
picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth,
|
||||
redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined,
|
||||
requiresRegistration: draft.requiresRegistration,
|
||||
contactName: draft.contactName || undefined,
|
||||
contactPhone: draft.contactPhone || undefined,
|
||||
contactEmail: draft.contactEmail || undefined,
|
||||
};
|
||||
|
||||
if (mode === "edit") {
|
||||
@@ -1115,10 +1134,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const isOnLastSubstep = step !== 1 || pricingSubstep === 2;
|
||||
// Basic Details step requires title, start/end dates, and a base price before moving on
|
||||
// (mirrors handleSave's own checks). Base price is only compulsory when creating a new event.
|
||||
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && isPriceInvalid(draft.price)));
|
||||
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price)));
|
||||
// Items & Pricing: every option needs a valid price before leaving the step (checked across
|
||||
// all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate).
|
||||
const optionsIncomplete = mode === "create" && step === 1 && options.some(o => isPriceInvalid(o.price));
|
||||
// Not applicable to contact-only events, which have no ticket options at all.
|
||||
const optionsIncomplete = mode === "create" && draft.requiresRegistration && step === 1 && options.some(o => isPriceInvalid(o.price));
|
||||
const nextDisabled = basicDetailsIncomplete || optionsIncomplete;
|
||||
|
||||
// ── render ──
|
||||
@@ -1189,11 +1209,40 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
|
||||
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Base Price (R) {mode === "create" && <span className="text-red-500">*</span>}</label>
|
||||
<input type="number" step="1" min="0" required={mode === "create"} className="w-full border rounded px-3 py-2 text-sm" value={draft.price} onChange={e => upd({ price: e.target.value })} placeholder="0" />
|
||||
{mode === "create" && <p className="text-[10px] text-gray-400 mt-0.5">Auto-fills the first ticket option below — enter 0 for a free event.</p>}
|
||||
<div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="contact-only-toggle"
|
||||
className="mt-0.5"
|
||||
checked={!draft.requiresRegistration}
|
||||
onChange={e => upd({ requiresRegistration: !e.target.checked, price: e.target.checked ? "0" : draft.price })}
|
||||
/>
|
||||
<label htmlFor="contact-only-toggle" className="text-xs text-gray-700">
|
||||
<span className="font-medium">This is a contact-only event</span> (e.g. baptism) — no online registration or payment. Shows a "Contact us" button on the public site instead of "Register".
|
||||
</label>
|
||||
</div>
|
||||
{draft.requiresRegistration ? (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Base Price (R) {mode === "create" && <span className="text-red-500">*</span>}</label>
|
||||
<input type="number" step="1" min="0" required={mode === "create"} className="w-full border rounded px-3 py-2 text-sm" value={draft.price} onChange={e => upd({ price: e.target.value })} placeholder="0" />
|
||||
{mode === "create" && <p className="text-[10px] text-gray-400 mt-0.5">Auto-fills the first ticket option below — enter 0 for a free event.</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-3 border rounded">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Contact Name</label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.contactName} onChange={e => upd({ contactName: e.target.value })} placeholder="e.g. Pastor John" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Contact Phone</label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.contactPhone} onChange={e => upd({ contactPhone: e.target.value })} placeholder="e.g. 082 123 4567" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Contact Email</label>
|
||||
<input type="email" className="w-full border rounded px-3 py-2 text-sm" value={draft.contactEmail} onChange={e => upd({ contactEmail: e.target.value })} placeholder="e.g. info@hopefamilychurch.org" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Picture</label>
|
||||
{draft.picture && <img src={resolveToApiOrigin(draft.picture) || undefined} alt="" className="h-20 w-20 object-cover rounded border mb-2" />}
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function ManualRegistrationPage() {
|
||||
const regOutstanding = useMemo(() => {
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
for (const r of allRegistrations) {
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
|
||||
@@ -199,8 +199,9 @@ function PaymentsContent() {
|
||||
const now = new Date();
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
for (const r of list) {
|
||||
// totalDue uses priceSnapshot — not time-dependent
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
|
||||
@@ -140,12 +140,11 @@ export default function UserDashboardPage() {
|
||||
setRegistrations(myRegs);
|
||||
setTickets(myTicks);
|
||||
|
||||
// Compute totalDue from priceSnapshot (authoritative backend price, variant-aware).
|
||||
// priceSnapshot is set at registration time and refreshed before each payment.
|
||||
const now = new Date();
|
||||
// Use the backend's tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) rather than re-deriving from priceSnapshot client-side.
|
||||
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
||||
for (const r of myRegs) {
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
|
||||
}
|
||||
setBilling(totals);
|
||||
@@ -156,8 +155,8 @@ export default function UserDashboardPage() {
|
||||
try {
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
// totalDue uses priceSnapshot — not time-dependent, no need to recompute per payment time
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
// totalDueComputed is not time-dependent, no need to recompute per payment time
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
const outstanding = Math.max(0, totalDue - totalPaid);
|
||||
setBilling(prev => ({
|
||||
...prev,
|
||||
@@ -638,11 +637,10 @@ export default function UserDashboardPage() {
|
||||
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
|
||||
setTickets(myTicks);
|
||||
} catch {}
|
||||
// Recompute billing totals using priceSnapshot
|
||||
const now2 = new Date();
|
||||
// Recompute billing totals using the backend's tranche-aware totalDueComputed
|
||||
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
||||
for (const r of myRegs) {
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now2) * (opt.quantity || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
|
||||
}
|
||||
setBilling(totals);
|
||||
@@ -971,19 +969,32 @@ export default function UserDashboardPage() {
|
||||
</div>
|
||||
{!editMode ? (
|
||||
<ul className="text-sm list-disc pl-5 space-y-1">
|
||||
{(activeReg.registrationOptions || []).map((opt: any) => {
|
||||
{(activeReg.registrationOptions || []).flatMap((opt: any) => {
|
||||
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
|
||||
const unitPrice = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.variant?.price ?? opt.eventOption?.price ?? 0);
|
||||
return (
|
||||
<li key={opt.id}>
|
||||
{opt.eventOption?.name}{variantLabel} x {opt.quantity} — {formatRand(unitPrice * (opt.quantity || 0))}
|
||||
{unitPrice < (opt.eventOption?.price || 0) && (
|
||||
<span className="ml-1 text-xs text-green-700">(early bird)</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
// A line can span multiple price tranches (e.g. tickets bought before
|
||||
// and after an early-bird tier expired) — render one row per tranche so
|
||||
// each shows its own price, rather than one blended row for the line.
|
||||
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
|
||||
? opt.tranches
|
||||
: [{
|
||||
id: opt.id,
|
||||
quantity: opt.quantity,
|
||||
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
|
||||
appliedTierId: opt.appliedTierId,
|
||||
}];
|
||||
return tranches.map((t: any, idx: number) => {
|
||||
const unitPrice = Number(t.priceSnapshot || 0);
|
||||
return (
|
||||
<li key={`${opt.id}-${t.id || idx}`}>
|
||||
{opt.eventOption?.name}{variantLabel} x {t.quantity} — {formatRand(unitPrice * (t.quantity || 0))}
|
||||
{t.appliedTierId && (
|
||||
<span className="ml-1 text-xs text-green-700">(early bird)</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import ClientActions from "@/app/events/[id]/ClientActions";
|
||||
import { ContactButton } from "@/components/events/ContactButton";
|
||||
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
@@ -32,6 +33,10 @@ type Event = {
|
||||
eventOptions?: EventOption[];
|
||||
attachments?: EventAttachment[];
|
||||
requiresAuth?: boolean;
|
||||
requiresRegistration?: boolean;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
};
|
||||
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
@@ -51,6 +56,16 @@ function lowStockThreshold(stockLimit: number): number {
|
||||
}
|
||||
|
||||
function RegisterCta({ event }: { event: Event }) {
|
||||
if (event.requiresRegistration === false) {
|
||||
return (
|
||||
<ContactButton
|
||||
contactName={event.contactName}
|
||||
contactPhone={event.contactPhone}
|
||||
contactEmail={event.contactEmail}
|
||||
className="block w-full text-center bg-brand-600 text-white px-4 py-2.5 rounded-lg hover:bg-brand-700 font-medium transition-colors"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const now = new Date();
|
||||
const end = new Date(event.endDate);
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
@@ -18,6 +18,12 @@ export default async function RegisterPage({ params }: { params: Promise<{ event
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Contact-only events (e.g. baptism) have no registration flow — bounce a stale/direct
|
||||
// link back to the event detail page, which renders the Contact affordance instead.
|
||||
if (event.requiresRegistration === false) {
|
||||
redirect(`/events/${eventId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
|
||||
@@ -56,6 +56,9 @@ function RegistrationSuccessContent() {
|
||||
|
||||
const totalDue = React.useMemo(() => {
|
||||
if (!reg) return 0;
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
||||
if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed;
|
||||
try {
|
||||
return (reg.registrationOptions || []).reduce((s: number, ro: any) => {
|
||||
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Phone, Mail, User } from "lucide-react";
|
||||
|
||||
type ContactButtonProps = {
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
className?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function ContactButton({ contactName, contactPhone, contactEmail, className, label = "Contact us" }: ContactButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasDetails = !!(contactName || contactPhone || contactEmail);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={className || "flex-1 text-center text-sm text-white bg-brand-600 rounded-lg py-2 hover:bg-brand-700 transition-colors"}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full mx-4 p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold">Contact us</h3>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{hasDetails ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
{contactName && (
|
||||
<div className="flex items-center gap-2 text-gray-800">
|
||||
<User className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<span>{contactName}</span>
|
||||
</div>
|
||||
)}
|
||||
{contactPhone && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<a href={`tel:${contactPhone}`} className="text-brand-600 hover:underline">{contactPhone}</a>
|
||||
</div>
|
||||
)}
|
||||
{contactEmail && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<a href={`mailto:${contactEmail}`} className="text-brand-600 hover:underline">{contactEmail}</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No contact details have been provided for this event.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { ContactButton } from "@/components/events/ContactButton";
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
@@ -12,6 +13,10 @@ import { Calendar } from "lucide-react";
|
||||
price: number;
|
||||
picture?: string;
|
||||
isSoldOut?: boolean;
|
||||
requiresRegistration?: boolean;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
};
|
||||
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
@@ -47,6 +52,15 @@ export const EventCard = ({ event }: { event: Event }) => {
|
||||
View details
|
||||
</a>
|
||||
{(() => {
|
||||
if (event.requiresRegistration === false) {
|
||||
return (
|
||||
<ContactButton
|
||||
contactName={event.contactName}
|
||||
contactPhone={event.contactPhone}
|
||||
contactEmail={event.contactEmail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const now = new Date();
|
||||
const end = new Date(event.endDate);
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
|
||||
@@ -434,10 +434,14 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
Object.keys(registrationsByEvent).forEach(evId => {
|
||||
(registrationsByEvent[evId] || []).forEach((r: any) => {
|
||||
const lastAt = lastPaymentAtByReg.get(r.id) || null;
|
||||
const dueNow = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, now) * (ro.quantity || 0), 0);
|
||||
// Backend attaches a tranche-aware totalDueComputed, which locks each tranche's price
|
||||
// at the time it was purchased — it's already time-invariant, so the "dueNow vs
|
||||
// dueAtLast" lock-in dance below is only needed as a fallback for legacy rows without it.
|
||||
const hasComputed = r.totalDueComputed !== null && r.totalDueComputed !== undefined;
|
||||
const dueNow = hasComputed ? r.totalDueComputed : (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, now) * (ro.quantity || 0), 0);
|
||||
const paid = paidByReg.get(r.id) || 0;
|
||||
let outstanding = Math.max(dueNow - paid, 0);
|
||||
if (lastAt) {
|
||||
if (lastAt && !hasComputed) {
|
||||
const dueAtLast = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, lastAt) * (ro.quantity || 0), 0);
|
||||
if (paid >= dueAtLast) outstanding = 0;
|
||||
}
|
||||
@@ -722,10 +726,18 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const ro = (r.registrationOptions || [])
|
||||
.find((x: any) => x.eventOption?.id === opt.id);
|
||||
|
||||
const price = ro ? optionUnitPrice(ro, null, new Date()) : 0;
|
||||
// Backend attaches a tranche-aware lineTotal (exact even when this line spans
|
||||
// multiple early-bird prices) — fall back to the old blended-price estimate
|
||||
// otherwise. __prices stores an *average* unit price derived from that, purely
|
||||
// for display; __revenue carries the real total used for aggregation below.
|
||||
const lineTotal = ro
|
||||
? (ro.lineTotal !== null && ro.lineTotal !== undefined ? ro.lineTotal : optionUnitPrice(ro, null, new Date()) * qty)
|
||||
: 0;
|
||||
|
||||
baseRow.__prices[opt.name] = price; // 👈 store price
|
||||
baseRow.orderTotal += price * qty;
|
||||
baseRow.__prices[opt.name] = qty > 0 ? lineTotal / qty : 0;
|
||||
baseRow.__revenue = baseRow.__revenue || {};
|
||||
baseRow.__revenue[opt.name] = lineTotal;
|
||||
baseRow.orderTotal += lineTotal;
|
||||
});
|
||||
|
||||
rows.push(baseRow);
|
||||
@@ -768,14 +780,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const qty = row[opt.name] || 0;
|
||||
totals[opt.name] += qty;
|
||||
|
||||
// revenue per option
|
||||
const price =
|
||||
Number(
|
||||
masterRows
|
||||
.find(r => r === row)?.__prices?.[opt.name] ?? 0
|
||||
);
|
||||
|
||||
totals[`${opt.name}_revenue`] += qty * price;
|
||||
// revenue per option — use the tranche-aware per-row total computed above rather
|
||||
// than re-deriving qty*price from a blended average price.
|
||||
totals[`${opt.name}_revenue`] += Number(row.__revenue?.[opt.name] ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user