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:
2026-08-20 17:26:35 +02:00
co-authored by Claude Sonnet 5
parent f2c3172e16
commit f0f8d4c242
22 changed files with 804 additions and 225 deletions
@@ -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">