Files
hope-events/frontend/src/app/dashboard/user/page.tsx
T
joshuaandClaude Opus 5.5 6010641629 Pick sessions (frontend), roadmap and handover docs
Event wizard: how people book (whole course / pick sessions), capacity
per session and per-option session mode. Session picker on the public
registration page, Manual registration and the kiosk; at the door points
to Manual registration. Register, tickets and My Events handle picked
sessions. Adds docs/ROADMAP-v2.md and docs/HANDOVER.md so work can carry
on from another machine, and a pick-sessions e2e test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:25:21 +02:00

1296 lines
68 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, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date";
import { printTicketCards } from "@/lib/ticketPrint";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { QrImage } from "@/components/shared/QrImage";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
import { MyWaitlists } from "@/components/shared/MyWaitlists";
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import { BundleSavings } from "@/components/shared/BundleDeals";
import { MyCertificates } from "@/components/events/MyCertificates";
import { isPickOption } from "@/lib/sessions";
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
// Helper formatters
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
const isFuture = (d: string | Date) => new Date(d).getTime() > Date.now();
type EventStatusFields = Pick<Event, "endDate" | "cashupStatus" | "isActive">;
type Billing = { totalDue: number; totalPaid: number; outstanding: number; payments: Payment[] };
const isEventOver = (ev: EventStatusFields | null | undefined) =>
!!ev && ((ev.endDate && new Date(ev.endDate).getTime() < Date.now()) || ev.cashupStatus === 'closed');
// Status badge for an event, shown wherever an event title is displayed on this page.
// Precedence when more than one applies: Closed > Past > Inactive.
function EventStatusBadge({ event }: { event: EventStatusFields | null | undefined }) {
if (!event) return null;
let label: string | null = null;
let className = '';
if (event.cashupStatus === 'closed') {
label = 'Closed'; className = 'bg-rose-50 text-rose-700';
} else if (event.endDate && new Date(event.endDate).getTime() < Date.now()) {
label = 'Past'; className = 'bg-amber-50 text-amber-700';
} else if (event.isActive === false) {
label = 'Inactive'; className = 'bg-gray-100 text-gray-500';
}
if (!label) return null;
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${className}`}>{label}</span>;
}
// Status badge for a registration, shown wherever registration.status is displayed on this
// page. Colors match the status coloring already used on dashboard/admin/registrations.
const REGISTRATION_STATUS_STYLES: Record<string, { label: string; className: string }> = {
pending: { label: 'Pending', className: 'bg-gray-100 text-gray-600' },
confirmed: { label: 'Confirmed', className: 'bg-blue-50 text-blue-700' },
partial_paid: { label: 'Partially Paid', className: 'bg-amber-50 text-amber-700' },
paid: { label: 'Paid', className: 'bg-green-50 text-green-700' },
cancelled: { label: 'Cancelled', className: 'bg-red-50 text-red-700' },
};
function RegistrationStatusBadge({ status }: { status: string }) {
const s = REGISTRATION_STATUS_STYLES[status] || { label: status, className: 'bg-gray-100 text-gray-500' };
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${s.className}`}>{s.label}</span>;
}
// Effective unit price for an event option (or one of its variants), early-bird aware.
// Used by the registration editor, which works off raw /api/events/:id data rather than
// a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx.
function effectiveEventOptionUnitPrice(eo: EventOption, variantId: string | null): number {
const variant = variantId ? (eo?.variants || []).find((v) => v.id === variantId) : null;
const base = (variant && variant.price !== null && variant.price !== undefined)
? Number(variant.price)
: Number(eo?.price || 0);
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers : [];
const tiers = variantId ? allTiers.filter((t) => t.variantId === variantId) : allTiers.filter((t) => !t.variantId);
if (tiers.length === 0) return base;
const now = new Date();
const applicable = tiers
.map((t) => ({ ...t, _d: new Date(t.deadline) }))
.filter((t) => now < t._d)
.sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime());
return applicable.length > 0 ? applicable[0].price : base;
}
export default function UserDashboardPage() {
const { token, user } = useAuth();
const [registrations, setRegistrations] = useState<Registration[]>([]);
const [tickets, setTickets] = useState<TicketRecord[]>([]);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
// Filters
const [showPast, setShowPast] = useState<boolean>(false);
// Billing map: regId -> { totalDue, totalPaid, outstanding, payments }
const [billing, setBilling] = useState<Record<string, Billing>>({});
// Forms status cache for registrations
// regId -> { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }
const [formStatuses, setFormStatuses] = useState<Record<string, { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }>>({});
// Ticket selection
const [selected, setSelected] = useState<Record<string, boolean>>({});
// Registration details modal
const [activeRegId, setActiveRegId] = useState<string | null>(null);
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string; title?: string }>({ open: false, message: "", loading: false });
// Channel-choice popup shown by the single "Invoice"/"Receipt" buttons — asks Email or WhatsApp.
const [channelPicker, setChannelPicker] = useState<{ kind: 'invoice' | 'receipt'; id: string } | null>(null);
// Track whether the active registration's event has attendee forms
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
useEffect(() => {
(async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
// Tickets don't depend on registrations, so fetch both at once instead of
// waterfalling — this was previously fetched only after registrations + all
// their payments had already resolved.
const [myRegs, myTicks] = await Promise.all([
apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token }),
]);
setRegistrations(myRegs);
setTickets(myTicks);
// 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, Billing> = {};
for (const r of myRegs) {
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
}
setBilling(totals);
// Fetch payments per registration to compute outstanding
await Promise.all(
myRegs.map(async (r) => {
try {
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
// totalDueComputed is not time-dependent, no need to recompute per payment time
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const outstanding = Math.max(0, totalDue - totalPaid);
setBilling(prev => ({
...prev,
[r.id]: {
totalDue,
totalPaid,
outstanding,
payments: pays,
},
}));
} catch (e) {
// Ignore per-reg payment errors but log
console.warn("Failed to load payments for", r.id, e);
}
})
);
} catch (e) {
setError(errorMessage(e) || "Failed to load data");
} finally {
setLoading(false);
}
})();
}, [token, showPast, setError]);
// After registrations load, compute forms status for each registration.
// `myregistrations` now embeds event.form (with fields) directly, so this no longer
// needs a GET /api/events/:id per registration — only the registration-detail fetch
// (to get submittedCount) remains, and only for registrations that actually have a form.
useEffect(() => {
(async () => {
if (!token || registrations.length === 0) return;
const next: Record<string, { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }> = {};
await Promise.all(
registrations.map(async (r) => {
try {
const ev = r.event;
const hasForm = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
if (!hasForm) {
next[r.id] = { hasForm: false };
return;
}
// Load registration to get number of submitted responses
const regFull = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(r.id)}`, { authToken: token });
const submittedCount = (regFull?.formResponses || []).length;
// Required count equals the number of main tickets purchased
const requiredCount = (r.registrationOptions || [])
.filter((ro) => ro.eventOption?.isMainTicket)
.reduce((s, ro) => s + (ro.quantity || 0), 0);
const complete = submittedCount >= requiredCount && requiredCount > 0 ? true : (submittedCount >= requiredCount);
next[r.id] = {
hasForm: true,
isRequired: !!ev?.form?.isRequired,
submittedCount,
requiredCount,
complete,
};
} catch {
// On any error, do not show forms status to avoid noisy UI
next[r.id] = { hasForm: false };
}
})
);
setFormStatuses(next);
})();
}, [token, registrations]);
const toggleSelect = (id: string, checked?: boolean) => {
setSelected(prev => ({ ...prev, [id]: checked ?? !prev[id] }));
};
const selectAll = (on: boolean) => {
const next: Record<string, boolean> = {};
tickets.forEach(t => (next[t.id] = on));
setSelected(next);
};
const selectedIds = useMemo(() => Object.entries(selected).filter(([, v]) => v).map(([k]) => k), [selected]);
const emailTickets = async (ticketIds: string[]) => {
if (!token || ticketIds.length === 0) return;
setError(null);
setInfo(null);
// Open loading dialog immediately
setDialog({ open: true, message: "Sending tickets…", loading: true });
try {
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
method: "POST",
body: { ticketIds },
authToken: token,
});
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) emailed successfully.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e) {
setDialog({ open: false, message: "", loading: false });
setError(errorMessage(e) || "Failed to email tickets");
}
};
const whatsappTickets = async (ticketIds: string[]) => {
if (!token || ticketIds.length === 0) return;
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
setError(null);
setInfo(null);
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true });
try {
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
method: "POST",
body: { ticketIds, channel: "whatsapp" },
authToken: token,
});
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) sent to WhatsApp.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e) {
setDialog({ open: false, message: "", loading: false });
setError(errorMessage(e) || "Failed to send tickets to WhatsApp");
}
};
const sendInvoice = async (registrationId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
setError(null);
setInfo(null);
setDialog({
open: true, loading: true, message: "",
loadingTitle: channel === 'whatsapp' ? "Sending invoice to WhatsApp…" : "Sending invoice…",
loadingSubtitle: "Please wait while we prepare your invoice.",
});
try {
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
method: "POST",
body: { channel },
authToken: token,
});
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
} catch (e) {
setDialog({ open: false, message: "", loading: false });
setError(errorMessage(e) || "Failed to send invoice");
}
};
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
setError(null);
setInfo(null);
setDialog({
open: true, loading: true, message: "",
loadingTitle: channel === 'whatsapp' ? "Sending receipt to WhatsApp…" : "Sending receipt…",
loadingSubtitle: "Please wait while we prepare your receipt.",
});
try {
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
body: { channel },
authToken: token,
});
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
} catch (e) {
setDialog({ open: false, message: "", loading: false });
setError(errorMessage(e) || "Failed to send receipt");
}
};
// Resolves the channel-picker popup: dispatches to the invoice or receipt sender for
// whichever id it was opened with, then closes the popup.
const chooseChannel = (channel: 'email' | 'whatsapp') => {
if (!channelPicker) return;
const { kind, id } = channelPicker;
setChannelPicker(null);
if (kind === 'invoice') sendInvoice(id, channel);
else sendReceipt(id, channel);
};
// Creates a Yoco checkout for the full outstanding balance and redirects there directly —
// choosing a partial amount is only available from the supervisor payments dashboard.
const payNow = async (registrationId: string) => {
if (!token) return;
setError(null);
setInfo(null);
setDialog({ open: true, message: "Creating payment link…", loading: true, loadingTitle: "Creating payment link…", loadingSubtitle: "Please wait while we redirect you to Yoco." });
try {
const res = await createFullPaymentCheckout(token, registrationId);
if (res.priceUpdated) {
setDialog({ open: false, message: "", loading: false });
setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`);
// Refresh this registration's billing so the displayed outstanding reflects the new price
try {
const now = new Date();
const r = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
setBilling(prev => ({ ...prev, [registrationId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
} catch {}
return;
}
if (!res.redirectUrl) {
setDialog({ open: false, message: "", loading: false });
setError("Failed to create checkout");
return;
}
window.location.href = res.redirectUrl;
} catch (e) {
setDialog({ open: false, message: "", loading: false });
setError(errorMessage(e) || "Failed to create checkout");
}
};
const printTickets = (ticketList: TicketRecord[]) => printTicketCards(ticketList, () => user?.name);
// Upcoming events derived from registrations
const upcomingEvents = useMemo(() => {
const map = new Map<string, Event>();
for (const r of registrations) {
if (r.event && isFuture(r.event.startDate)) {
if (!map.has(r.event.id)) map.set(r.event.id, r.event);
}
}
return Array.from(map.values()).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
}, [registrations]);
// Registration click -> open modal
const activeReg = useMemo(() => registrations.find(r => r.id === activeRegId) || null, [registrations, activeRegId]);
const activeBill = activeRegId ? billing[activeRegId] : undefined;
// When a registration is opened, determine whether its event has attendee forms
useEffect(() => {
setActiveEventHasForm(null);
if (!activeRegId) return;
const ar = registrations.find(r => r.id === activeRegId);
if (!ar) return;
const evForm = ar.event?.form;
if (evForm && Array.isArray(evForm.fields) && evForm.fields.length > 0) {
setActiveEventHasForm(true);
return;
}
(async () => {
try {
const ev = await apiFetch<Event>(`/api/events/${encodeURIComponent(ar.eventId)}`);
const has = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
setActiveEventHasForm(has);
} catch {
setActiveEventHasForm(false);
}
})();
}, [activeRegId, registrations]);
const showFormsButton = useMemo(() => {
const evForm = activeReg?.event?.form;
if (evForm && Array.isArray(evForm.fields)) return evForm.fields.length > 0;
if (activeEventHasForm !== null) return activeEventHasForm;
return false;
}, [activeReg, activeEventHasForm]);
// Discount code on an existing registration
const [codeInput, setCodeInput] = useState("");
const [codeBusy, setCodeBusy] = useState(false);
const [codeError, setCodeError] = useState<string | null>(null);
const applyCodeToActive = async () => {
if (!token || !activeRegId || !codeInput.trim()) return;
setCodeBusy(true);
setCodeError(null);
try {
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(activeRegId)}/coupon`, {
method: 'POST',
authToken: token,
body: { code: codeInput.trim() },
});
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const updated = myRegs.find(r => r.id === activeRegId);
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = updated?.totalDueComputed ?? 0;
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
try {
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
setCodeInput("");
setInfo(res?.message || 'Code applied.');
} catch (e) {
setCodeError(errorMessage(e) || "Couldn't apply that code");
} finally {
setCodeBusy(false);
}
};
const [transferTarget, setTransferTarget] = useState<TicketRecord | null>(null);
// Re-fetch registrations and tickets after something on one changes (attendee names).
const reloadAfterAttendees = async () => {
if (!token) return;
try {
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
};
// Cancel registration state
const [cancelLoading, setCancelLoading] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const cancelRegistration = async () => {
if (!token || !activeRegId) return;
setCancelLoading(true);
try {
await apiFetch(`/api/registrations/${encodeURIComponent(activeRegId)}`, {
method: 'DELETE',
authToken: token,
});
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
setActiveRegId(null);
setShowCancelConfirm(false);
setInfo('Registration cancelled.');
} catch (e) {
setError(errorMessage(e) || 'Failed to cancel registration');
setShowCancelConfirm(false);
} finally {
setCancelLoading(false);
}
};
// Editor state for registration items. Keyed like register/[eventId]/RegisterForm.tsx:
// "eventOptionId" for plain options, "eventOptionId:variantId" for variant options.
const [editMode, setEditMode] = useState<boolean>(false);
const [editLoading, setEditLoading] = useState<boolean>(false);
const [editEventOptions, setEditEventOptions] = useState<EventOption[]>([]);
const [editQuantities, setEditQuantities] = useState<Record<string, number>>({});
const [editBaseQty, setEditBaseQty] = useState<Record<string, number>>({});
const [editMinQty, setEditMinQty] = useState<Record<string, number>>({});
const [editError, setEditError] = useState<string | null>(null);
// Whether the active registration's event still permits self-service changes
// (edit items, pay, cancel) — false once the event is past or its cashup is closed.
const canModifyActive = useMemo(() => {
if (!activeReg) return false;
return !isEventOver(activeReg.event);
}, [activeReg]);
// Picked-session lines can't be edited here (book more by registering), but still count.
const pickedLinesTotal = useMemo(() => (activeReg?.registrationOptions || [])
.filter(ro => ro.sessionId)
.reduce((s, ro) => s + (ro.lineTotal ?? (ro.quantity || 0) * (ro.priceSnapshot || 0)), 0), [activeReg]);
const editTotal = useMemo(() => {
return pickedLinesTotal + editEventOptions.reduce((sum, eo) => {
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
return sum + eo.variants.reduce((vsum, v) => {
const qty = editQuantities[`${eo.id}:${v.id}`] || 0;
return vsum + qty * effectiveEventOptionUnitPrice(eo, v.id);
}, 0);
}
return sum + (editQuantities[eo.id] || 0) * effectiveEventOptionUnitPrice(eo, null);
}, 0);
}, [editEventOptions, editQuantities, pickedLinesTotal]);
const beginEdit = async () => {
if (!activeReg || !token) return;
setEditError(null);
setEditLoading(true);
try {
// Load event options (with variants) for this event
const eventData = await apiFetch<Event>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
const eventOptions = (eventData?.eventOptions || []).filter((eo) => !isPickOption(eventData, eo));
const regOptById = new Map<string, RegistrationOption>((activeReg.registrationOptions || []).map((o) => [o.id, o]));
// Prefill quantities from the current registration
const qtyMap: Record<string, number> = {};
for (const ro of (activeReg.registrationOptions || []).filter(r => !r.sessionId)) {
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
qtyMap[key] = (qtyMap[key] || 0) + (ro.quantity || 0);
}
// Already-issued ticket quantity per option/variant — can't reduce below this
// (tickets are never deleted or shrunk, only ever grown, once paid).
const minQtyMap: Record<string, number> = {};
for (const t of tickets) {
const ro = t.registrationOptionId ? regOptById.get(t.registrationOptionId) : undefined;
if (!ro || ro.sessionId) continue;
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
minQtyMap[key] = (minQtyMap[key] || 0) + (t.quantity || 0);
}
// Ensure every option/variant has a (possibly zero) entry so they all render
eventOptions.forEach((eo) => {
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
eo.variants.forEach((v) => {
const key = `${eo.id}:${v.id}`;
if (!(key in qtyMap)) qtyMap[key] = 0;
});
} else if (!(eo.id in qtyMap)) {
qtyMap[eo.id] = 0;
}
});
setEditEventOptions(eventOptions);
setEditQuantities(qtyMap);
setEditBaseQty({ ...qtyMap });
setEditMinQty(minQtyMap);
setEditMode(true);
} catch (e) {
setEditError(errorMessage(e) || 'Failed to load event options');
} finally {
setEditLoading(false);
}
};
const saveEdit = async () => {
if (!activeReg || !token) return;
setEditError(null);
for (const [key, minQty] of Object.entries(editMinQty)) {
if (minQty <= 0) continue;
if ((editQuantities[key] || 0) < minQty) {
const [eventOptionId, variantId] = key.includes(':') ? key.split(':') : [key, undefined];
const eo = editEventOptions.find((o) => o.id === eventOptionId);
const variant = variantId ? (eo?.variants || []).find((v) => v.id === variantId) : null;
const label = variant ? `${eo?.name || 'item'} (${variant.name})` : (eo?.name || 'item');
setEditError(`Cannot reduce "${label}" below the ${minQty} already issued`);
return;
}
}
const payloadOptions: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
for (const [key, qty] of Object.entries(editQuantities)) {
if (qty <= 0) continue;
if (key.includes(':')) {
const [eventOptionId, variantId] = key.split(':');
payloadOptions.push({ eventOptionId, quantity: qty, variantId });
} else {
payloadOptions.push({ eventOptionId: key, quantity: qty });
}
}
if (payloadOptions.length === 0) {
setEditError('Please select at least one item');
return;
}
// Enforce client-side: do not reduce below paid
const paid = activeBill?.totalPaid || 0;
if (editTotal < paid) {
setEditError('Cannot reduce items below the amount already paid');
return;
}
setLoading(true);
try {
await apiFetch(`/api/registrations/${encodeURIComponent(activeReg.id)}/options`, {
method: 'PUT',
authToken: token,
body: { options: payloadOptions },
});
// Reload registrations to reflect changes
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
try {
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
// Recompute billing totals using the backend's tranche-aware totalDueComputed
const totals: Record<string, Billing> = {};
for (const r of myRegs) {
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
}
setBilling(totals);
// Payments will be refreshed lazily or on next open; fetch for this one immediately
try {
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeReg.id)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = totals[activeReg.id]?.totalDue ?? 0;
setBilling(prev => ({
...prev,
[activeReg.id]: {
totalDue,
totalPaid,
outstanding: Math.max(0, totalDue - totalPaid),
payments: pays,
}
}));
} catch {}
setInfo('Registration updated successfully');
setEditMode(false);
} catch (e) {
setEditError(errorMessage(e) || 'Failed to update registration');
} finally {
setLoading(false);
}
};
const router = useRouter();
return (
<div className="max-w-6xl mx-auto w-full">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Welcome{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/reset-password")}
>Reset password</button>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/payments")}
>Payment history</button>
<button
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/donate")}
>Make donation</button>
</div>
</div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
{loading && <p className="text-gray-500 text-sm mb-3">Loading…</p>}
<section className="mb-6 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<ClipboardList className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">My Registrations</h2>
</div>
<label className="text-sm flex items-center gap-2 text-gray-600">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} />
<span>Show past events</span>
</label>
</div>
<p className="text-sm text-gray-500 mb-3">
Click on a registration to edit or make a payment
</p>
{registrations.length === 0 ? (
<p className="text-gray-600 text-sm">No registrations yet.</p>
) : (
<ul className="space-y-2">
{registrations
// Hide cancelled always; hide past unless showPast
.filter((r) => r.status !== 'cancelled')
.filter((r) => showPast || !isEventOver(r.event))
.map((r) => {
const bill = billing[r.id];
const isCancelled = r.status === 'cancelled';
const outstanding = isCancelled ? 0 : (bill?.outstanding ?? 0);
const totalDue = bill?.totalDue ?? 0;
return (
<li
key={r.id}
className={"border rounded-xl p-3 cursor-pointer flex items-start gap-3 " + (isCancelled ? "opacity-60 bg-gray-50" : "hover:bg-gray-50 hover:border-brand-200")}
onClick={() => setActiveRegId(r.id)}
>
<ApiImage src={r.event?.picture} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0 hidden sm:block" />
<div className="flex items-start justify-between gap-3 flex-1 min-w-0">
<div className="min-w-0">
<div className="font-medium flex items-center gap-2">
<span className="truncate">{r.event?.title || r.eventId}</span>
<EventStatusBadge event={r.event} />
</div>
{r.event?.startDate && (
<div className="text-xs text-gray-500 mt-0.5">{formatDate(new Date(r.event.startDate))}</div>
)}
<div className="text-xs text-gray-600 mt-1">
Status: <RegistrationStatusBadge status={r.status} />
</div>
{(() => {
const fs = formStatuses[r.id];
if (!fs || !fs.hasForm) return null;
const label = fs.complete ? 'Complete' : 'Incomplete';
const colorClass = fs.complete
? 'bg-green-100 text-green-800'
: (fs.isRequired ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800');
return (
<div className="mt-1 text-xs">
<span className={`inline-block px-1.5 py-0.5 rounded ${colorClass}`}>Forms: {label}</span>
</div>
);
})()}
{(() => {
if (r.status == 'paid') return null;
// Early-bird notice: only for events with tiers that haven't passed
const allTiers = (r.registrationOptions || []).flatMap((ro) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
const upcoming = allTiers
.map((t) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t) => new Date() < t.deadline)
.sort((a, b) => a.deadline.getTime() - b.deadline.getTime());
if (upcoming.length === 0) return null;
const nextDeadline = upcoming[0].deadline;
const pays = (billing[r.id]?.payments || []);
const lastPaymentAt = pays.length > 0 ? new Date(Math.max(...pays.map((p) => new Date(p.createdAt).getTime()))) : null;
const now = new Date();
const priceNow = (r.registrationOptions || []).reduce((s, ro) => s + registrationOptionUnitPrice(ro, lastPaymentAt, now) * (ro.quantity || 0), 0);
const afterPrice = (r.registrationOptions || []).reduce((s, ro) => s + registrationOptionUnitPrice(ro, lastPaymentAt, new Date(nextDeadline.getTime() + 1000)) * (ro.quantity || 0), 0);
const dateStr = nextDeadline.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
if (afterPrice > priceNow) {
return (
<div className="mt-1 text-xs text-amber-700">
Early bird: R {priceNow.toFixed(2)} if paid before {dateStr}. Price increases to R {afterPrice.toFixed(2)} thereafter.
</div>
);
}
return (
<div className="mt-1 text-xs text-amber-700">
Early bird pricing in effect until {dateStr}.
</div>
);
})()}
</div>
<div className="text-right">
{isCancelled ? (
<div className="text-xs text-gray-500">No outstanding — registration cancelled</div>
) : (
<>
<div className="text-sm">Outstanding: <span className={outstanding > 0 ? "text-red-600" : "text-green-700"}>{formatRand(outstanding)}</span></div>
<div className="text-xs text-gray-500">Total: {formatRand(totalDue)}</div>
</>
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 self-center hidden sm:block" />
</li>
);
})}
</ul>
)}
</section>
{token && (
<div className="mb-6">
<MyWaitlists token={token} onMessage={setInfo} />
</div>
)}
<MyCertificates token={token} />
<div className="grid lg:grid-cols-2 gap-6">
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<Calendar className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
</div>
{upcomingEvents.length === 0 ? (
<p className="text-gray-600 text-sm">No upcoming events.</p>
) : (
<ul className="space-y-3">
{upcomingEvents.map(ev => (
<li key={ev.id} className="border rounded-xl p-3 flex gap-3">
<ApiImage src={ev.picture} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0 hidden sm:block" />
<div className="min-w-0 flex-1">
<div className="font-medium flex items-center gap-2">
<span className="truncate">{ev.title}</span>
<EventStatusBadge event={ev} />
</div>
<div className="text-sm text-gray-600">{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
<div className="flex flex-wrap gap-2 mt-2">
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => {
const list = tickets.filter(t => t.eventId === ev.id);
printTickets(list);
}}
>Print all tickets</button>
<button
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => {
const ids = tickets.filter(t => t.eventId === ev.id).map(t => t.id);
emailTickets(ids);
}}
>Email all tickets</button>
{user?.phoneNumber && (
<button
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => {
const ids = tickets.filter(t => t.eventId === ev.id).map(t => t.id);
whatsappTickets(ids);
}}
>WhatsApp tickets</button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-3">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">My Tickets</h2>
</div>
{tickets.length > 0 && (
<div className="flex flex-wrap gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => selectAll(true)}>Select all</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => selectAll(false)}>Clear</button>
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
disabled={selectedIds.length === 0}
onClick={() => printTickets(tickets.filter(t => selectedIds.includes(t.id)))}
>Print selected</button>
<button
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
disabled={selectedIds.length === 0}
onClick={() => emailTickets(selectedIds)}
>Email selected</button>
{user?.phoneNumber && (
<button
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
disabled={selectedIds.length === 0}
onClick={() => whatsappTickets(selectedIds)}
>WhatsApp selected</button>
)}
</div>
)}
</div>
{tickets.filter(t => showPast || !isEventOver(t.event)).length === 0 ? (
<p className="text-gray-600 text-sm">No tickets yet.</p>
) : (
<ul className="grid sm:grid-cols-2 gap-3">
{tickets
.filter((t) => showPast || !isEventOver(t.event))
.map((t) => {
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
const variantName = t.registrationOption?.variant?.name;
const type = variantName ? `${optionName} — ${variantName}` : optionName;
return (
<li key={t.id} className="border rounded-xl p-3 space-y-2 bg-gray-50">
<div className="flex items-center gap-2">
<ApiImage src={t.event?.picture} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" />
<span className="font-medium text-sm truncate flex-1">{t.event?.title || t.eventId}</span>
</div>
<div className="flex items-start justify-between">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={selected[t.id]} onChange={e => toggleSelect(t.id, e.target.checked)} />
<span className="text-xs text-gray-500">Select</span>
</label>
<EventStatusBadge event={t.event} />
{eventDate && <span className="text-xs text-gray-500">{formatDate(eventDate)}</span>}
</div>
<div className="text-sm text-gray-700">{type}</div>
<div className="text-xs font-medium text-blue-700 bg-blue-50 rounded px-2 py-0.5 inline-block">
{t.attendee?.name ? <>For: {t.attendee.name}</> : <>Qty: {t.quantity || 1}</>}
</div>
{(t.attendee?.allocations || []).length > 0 && (
<div className="text-xs text-violet-800 bg-violet-50 rounded px-2 py-0.5 inline-block ml-1">
{[...(t.attendee?.allocations || [])].sort((a, b) => (a.set?.order || 0) - (b.set?.order || 0)).map((a) => `${a.set.name}: ${a.group.name}`).join(" · ")}
</div>
)}
{t.userId && user?.id && t.userId !== user.id && t.user?.name && (
<div className="text-xs text-gray-500">Booked by {t.user.name}</div>
)}
<div className="flex justify-center"><QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" /></div>
<div className="text-center text-xs text-gray-500">#{t.id.slice(0, 8)}</div>
<div className="flex gap-2 pt-1 flex-wrap">
<button onClick={() => emailTickets([t.id])} className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1">Email</button>
{user?.phoneNumber && (
<button onClick={() => whatsappTickets([t.id])} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1">WhatsApp</button>
)}
<button onClick={() => printTickets([t])} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Print</button>
{(t.transferRequests || [])[0] && (
<span className="w-full text-xs text-violet-800 bg-violet-50 rounded px-2 py-1 flex items-center justify-between gap-2">
<span>Waiting for {t.transferRequests?.[0]?.toName} to accept</span>
<button className="underline" onClick={async () => {
try {
const r = await apiFetch<{ message?: string }>(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
setInfo(r?.message || "Transfer cancelled");
reloadAfterAttendees();
} catch (e) { setError(errorMessage(e) || "Couldn't cancel"); }
}}>Cancel</button>
</span>
)}
{canOfferTransfer(t, t.event) && (
<button onClick={() => setTransferTarget(t)} className="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-100 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" title="Give this ticket to someone else">Transfer</button>
)}
</div>
</li>
);
})}
</ul>
)}
</section>
</div>
{transferTarget && token && (
<TransferTicketModal
ticket={transferTarget}
event={transferTarget.event}
token={token}
onClose={() => setTransferTarget(null)}
onDone={(msg) => { setTransferTarget(null); setInfo(msg); reloadAfterAttendees(); }}
/>
)}
{activeReg && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
<div className="bg-white rounded-lg shadow-lg max-w-xl w-full mx-4 p-5 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold flex items-center gap-2">
<span>{activeReg.event?.title || activeReg.eventId}</span>
<EventStatusBadge event={activeReg.event} />
</h3>
<button className="px-2.5 py-1 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>Close</button>
</div>
<div className="space-y-3">
<div>
<div className="text-sm text-gray-600">Status: <RegistrationStatusBadge status={activeReg.status} /></div>
{activeBill && (
<div className="text-sm">
<div>Total: {formatRand(activeBill.totalDue)}</div>
<div>Paid: {formatRand(activeBill.totalPaid)}</div>
<div>Outstanding: <span className={activeBill.outstanding > 0 ? "text-red-600" : "text-green-700"}>{formatRand(activeBill.outstanding)}</span></div>
</div>
)}
</div>
<div>
<div className="font-medium mb-1 flex items-center justify-between">
<span>Registration items</span>
{!editMode && canModifyActive && (
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50">Edit</button>
)}
</div>
{!editMode ? (() => {
// A line can span multiple price tranches (e.g. tickets bought before
// and after an early-bird tier expired, or across repeat registrations).
// Merge tranches that share a name/price/tier into one row, then group
// early-bird rows separately from standard-price rows so repeat purchases
// of the same item don't show as a wall of near-duplicate lines.
const rows: { key: string; label: string; quantity: number; unitPrice: number; isEarlyBird: boolean }[] = [];
(activeReg.registrationOptions || []).forEach((opt) => {
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
const label = `${opt.eventOption?.name || ''}${variantLabel}`;
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
? opt.tranches
: [{
quantity: opt.quantity,
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
appliedTierId: opt.appliedTierId,
}];
tranches.forEach((t) => {
const unitPrice = Number(t.priceSnapshot || 0);
const isEarlyBird = !!t.appliedTierId;
const key = `${label}__${isEarlyBird}__${unitPrice}`;
const existing = rows.find(r => r.key === key);
if (existing) {
existing.quantity += (t.quantity || 0);
} else {
rows.push({ key, label, quantity: t.quantity || 0, unitPrice, isEarlyBird });
}
});
});
const earlyBirdRows = rows.filter(r => r.isEarlyBird);
const standardRows = rows.filter(r => !r.isEarlyBird);
const showGroupLabels = earlyBirdRows.length > 0 && standardRows.length > 0;
const renderGroup = (heading: string, groupRows: typeof rows) => groupRows.length > 0 && (
<div key={heading}>
{showGroupLabels && (
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mt-2 first:mt-0">{heading}</div>
)}
<ul className="text-sm list-disc pl-5 space-y-1">
{groupRows.map(r => (
<li key={r.key}>
{r.label} x {r.quantity} — {formatRand(r.unitPrice * r.quantity)}
{r.isEarlyBird && (
<span className="ml-1 text-xs text-green-700">(early bird)</span>
)}
</li>
))}
</ul>
</div>
);
const activeCoupon = (activeReg.couponRedemptions || [])[0];
return (
<div>
{renderGroup('Early bird', earlyBirdRows)}
{renderGroup('Standard price', standardRows)}
<BundleSavings applications={activeReg.bundleApplications} className="mt-2" />
{Number(activeReg.discountTotal || 0) - Number(activeReg.bundleDiscount || 0) > 0.005 && (
<div className="text-sm text-green-700 mt-2">
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal) - Number(activeReg.bundleDiscount || 0))}
</div>
)}
{canModifyActive && activeBill && activeBill.outstanding > 0 && !activeCoupon && (
<div className="mt-3">
<div className="flex gap-2">
<input
value={codeInput}
onChange={e => { setCodeInput(e.target.value.toUpperCase()); setCodeError(null); }}
placeholder="Have a discount code?"
className="flex-1 min-w-0 border rounded px-2 py-1 text-sm font-mono uppercase"
aria-label="Discount code"
/>
<button
onClick={applyCodeToActive}
disabled={codeBusy || !codeInput.trim()}
className="px-3 py-1 text-sm rounded border border-brand-600 text-brand-700 hover:bg-brand-50 disabled:opacity-50"
>{codeBusy ? 'Applying…' : 'Apply'}</button>
</div>
{codeError && <p className="text-xs text-red-600 mt-1">{codeError}</p>}
</div>
)}
</div>
);
})() : (
<div className="border rounded p-3 space-y-2 bg-gray-50">
{editError && <p className="text-xs text-red-600">{editError}</p>}
{editLoading ? (
<div className="text-sm text-gray-600">Loading options…</div>
) : (
<>
<div className="space-y-2">
{editEventOptions.map((eo) => {
const hasVariants = Array.isArray(eo.variants) && eo.variants.length > 0;
const renderQtyInput = (key: string, unitPrice: number, stockLimit?: number, availableCount?: number) => {
const qty = editQuantities[key] || 0;
const minQty = editMinQty[key] || 0;
const baseQty = editBaseQty[key] || 0;
const maxQty = ((stockLimit ?? 0) > 0 && availableCount !== undefined)
? availableCount + baseQty
: undefined;
return (
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600">Qty</label>
<input type="number" min={minQty} max={maxQty} value={qty}
onChange={e => {
let v = Math.max(minQty, parseInt(e.target.value || '0', 10) || 0);
if (maxQty !== undefined) v = Math.min(v, maxQty);
setEditQuantities(prev => ({ ...prev, [key]: v }));
}}
className="w-20 border rounded px-2 py-1 text-sm" />
</div>
);
};
if (hasVariants) {
const variants = (eo.variants || []).slice().sort((a, b) => (a.order || 0) - (b.order || 0));
return (
<div key={eo.id} className="border rounded bg-white overflow-hidden">
<div className="px-3 py-2 bg-gray-100 text-sm font-medium">{eo.name}</div>
<ul className="divide-y">
{variants.map((v) => {
const key = `${eo.id}:${v.id}`;
const minQty = editMinQty[key] || 0;
const unitPrice = effectiveEventOptionUnitPrice(eo, v.id);
return (
<li key={v.id} className="flex items-center justify-between gap-2 px-3 py-2">
<div className="text-sm">
<div>{v.name}</div>
<div className="text-gray-600 text-xs">{formatRand(unitPrice)}</div>
{minQty > 0 && (
<div className="text-gray-500 text-[11px]">{minQty} already issued</div>
)}
</div>
{renderQtyInput(key, unitPrice, v.stockLimit, v.availableCount)}
</li>
);
})}
</ul>
</div>
);
}
const key = eo.id;
const minQty = editMinQty[key] || 0;
const unitPrice = effectiveEventOptionUnitPrice(eo, null);
return (
<div key={eo.id} className="flex items-center justify-between gap-2 border rounded bg-white px-3 py-2">
<div className="text-sm">
<div className="font-medium">{eo.name}</div>
<div className="text-gray-600 text-xs">{formatRand(unitPrice)}</div>
{minQty > 0 && (
<div className="text-gray-500 text-[11px]">{minQty} already issued</div>
)}
</div>
{renderQtyInput(key, unitPrice, eo.stockLimit, eo.availableCount)}
</div>
);
})}
</div>
<div className="flex items-center justify-between pt-2">
<div className="text-sm">
{(() => {
const paid = activeBill?.totalPaid || 0;
const hasAnyQty = Object.values(editQuantities).some(q => q > 0);
const invalid = editTotal < paid || !hasAnyQty;
return (
<>
<div>New total: {formatRand(editTotal)}</div>
{paid > 0 && <div className="text-xs text-gray-600">Paid: {formatRand(paid)}</div>}
{invalid && <div className="text-xs text-red-600">Total cannot be below amount already paid and at least one item is required.</div>}
</>
);
})()}
</div>
<div className="flex gap-2">
<button onClick={() => { setEditMode(false); setEditError(null); }} className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
<button onClick={saveEdit} className="px-2.5 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700">Save</button>
</div>
</div>
</>
)}
</div>
)}
</div>
{token && (
<AttendeesPanel
registration={activeReg}
token={token}
canEdit={canModifyActive}
hasForm={!!(activeReg.event?.form?.fields || []).length}
onSaved={reloadAfterAttendees}
onMessage={setInfo}
/>
)}
{activeBill && activeBill.payments.length > 0 && (
<div>
<div className="font-medium mb-1">Payments</div>
<ul className="text-sm space-y-1">
{activeBill.payments.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-2 py-0.5">
<span>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</span>
<button className="text-xs text-brand-700 hover:underline shrink-0" onClick={() => setChannelPicker({ kind: 'receipt', id: p.id })}>Receipt</button>
</li>
))}
</ul>
</div>
)}
<div className="flex flex-wrap gap-2 pt-2">
{showFormsButton && (
<button
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
>Attendee forms</button>
)}
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
>Invoice</button>
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
<button
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => payNow(activeReg.id)}
>Make payment</button>
)}
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
const regOptIds = new Set((activeReg.registrationOptions || []).map((o) => o.id));
const list = tickets.filter(t => !!t.registrationOptionId && regOptIds.has(t.registrationOptionId));
printTickets(list);
}}>Print all tickets</button>
</div>
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
{canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
<div className="pt-3 border-t mt-1">
{!showCancelConfirm ? (
<button
className="text-sm text-red-600 hover:text-red-800 underline underline-offset-2"
onClick={() => setShowCancelConfirm(true)}
>
Cancel registration
</button>
) : (
<div className="flex items-center gap-2 p-2 rounded border border-red-200 bg-red-50">
<span className="text-sm text-red-700 flex-1">Are you sure you want to cancel? This cannot be undone.</span>
<button
disabled={cancelLoading}
className="px-2.5 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
onClick={cancelRegistration}
>{cancelLoading ? 'Cancelling…' : 'Yes, cancel'}</button>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-200 hover:bg-gray-300"
onClick={() => setShowCancelConfirm(false)}
>No</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
{channelPicker && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60]"
onClick={() => setChannelPicker(null)}
>
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="text-base font-semibold mb-1">Send {channelPicker.kind === 'invoice' ? 'invoice' : 'receipt'}</div>
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
<div className="flex flex-col gap-2">
<button
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => chooseChannel('email')}
>Email</button>
{user?.phoneNumber && (
<button
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => chooseChannel('whatsapp')}
>WhatsApp</button>
)}
</div>
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setChannelPicker(null)}>Cancel</button>
</div>
</div>
)}
{dialog.open && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={() => { if (!dialog.loading) setDialog({ open: false, message: "", loading: false }); }}
>
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full p-5" onClick={e => e.stopPropagation()}>
{dialog.loading ? (
<div className="flex flex-col items-center">
<div className="w-10 h-10 mb-3 border-4 border-brand-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
<div className="text-base font-medium">{dialog.loadingTitle || "Sending tickets…"}</div>
<p className="text-sm text-gray-600 mt-1">{dialog.loadingSubtitle || "Please wait while we send your tickets."}</p>
</div>
) : (
<>
<div className="text-lg font-semibold mb-2">{dialog.title || "Tickets sent"}</div>
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
<div className="flex justify-end">
<button
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => setDialog({ open: false, message: "", loading: false })}
>OK</button>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}