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:
@@ -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