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>
1296 lines
68 KiB
TypeScript
1296 lines
68 KiB
TypeScript
"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's what'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>
|
||
);
|
||
}
|