Files
hope-events/frontend/src/app/dashboard/user/page.tsx
T
joshua 349686e182 Block self-service payment and cancellation on closed/past registrations
Extends the earlier user-dashboard fix: canEditActive is renamed
canModifyActive and now also gates the "Make payment" and "Cancel
registration" actions, not just editing. Backend enforcement added to
cancelRegistration and createYocoCheckout to block past-event
self-service payment/cancellation server-side (cashup-closed events
were already blocked via assertEventOpen; admins/supervisors are
exempt from the past-date check, consistent with existing overrides).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:51:10 +02:00

1073 lines
55 KiB
TypeScript

"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date";
import { QrImage } from "@/components/shared/QrImage";
// Helper formatters
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
const isFuture = (d: string | Date) => new Date(d).getTime() > Date.now();
const isEventOver = (ev: any) =>
!!ev && ((ev.endDate && new Date(ev.endDate).getTime() < Date.now()) || ev.cashupStatus === 'closed');
// 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: any, variantId: string | null): number {
const variant = variantId ? (eo?.variants || []).find((v: any) => 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: any) => t.variantId === variantId) : allTiers.filter((t: any) => !t.variantId);
if (tiers.length === 0) return base;
const now = new Date();
const applicable = tiers
.map((t: any) => ({ ...t, _d: new Date(t.deadline) }))
.filter((t: any) => now < t._d)
.sort((a: any, b: any) => 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<any[]>([]);
const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<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, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }>>({});
// 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 }>({ open: false, message: "", loading: false });
// Track whether the active registration's event has attendee forms
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
// Compute effective unit price for a registration option.
// Uses priceSnapshot when available (authoritative backend price, variant-aware).
// Falls back to deadline-only early-bird calculation for legacy rows without a snapshot.
const optionUnitPrice = (opt: any, referenceTime: any, atTime: Date): number => {
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
return Number(opt.priceSnapshot);
}
const eo = opt.eventOption;
const variantId: string | null = opt.variantId || null;
// Use variant price as base if available, otherwise option base
const base = (opt.variant?.price !== null && opt.variant?.price !== undefined)
? Number(opt.variant.price)
: Number(eo?.price || 0);
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : [];
// Filter: variant options use only their variant-specific tiers; plain options use option-level tiers
const tiers = variantId
? allTiers.filter((t: any) => t.variantId === variantId)
: allTiers.filter((t: any) => !t.variantId);
if (tiers.length === 0) return base;
const t = atTime ? new Date(atTime) : new Date();
const ref = referenceTime ? new Date(referenceTime) : t;
const applicable = tiers
.map((x: any) => ({ ...x, deadline: new Date(x.deadline) }))
.filter((x: any) => (ref < x.deadline) && (t < x.deadline))
.sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order||0) - (b.order||0) || a.price - b.price);
if (applicable.length === 0) return base;
const price = Number(applicable[0].price);
return (price >= 0) ? price : base;
};
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<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
apiFetch<any[]>("/api/tickets/mytickets", { authToken: token }),
]);
setRegistrations(myRegs);
setTickets(myTicks);
// Compute totalDue from priceSnapshot (authoritative backend price, variant-aware).
// priceSnapshot is set at registration time and refreshed before each payment.
const now = new Date();
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
for (const r of myRegs) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
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: any) => {
try {
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
// totalDue uses priceSnapshot — not time-dependent, no need to recompute per payment time
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
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: any) {
setError(e?.message || "Failed to load data");
} finally {
setLoading(false);
}
})();
}, [token, showPast]);
// 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: any) => {
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<any>(`/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: any) => ro.eventOption?.isMainTicket)
.reduce((s: number, ro: any) => 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 (e) {
// 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: any = await apiFetch("/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: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to email tickets");
}
};
const emailRegistration = async (registrationId: string) => {
if (!token) return;
setError(null);
setInfo(null);
// Show loading dialog while sending
setDialog({ open: true, message: "Sending tickets…", loading: true });
try {
const res: any = await apiFetch("/api/tickets/email", {
method: "POST",
body: { registrationId },
authToken: token,
});
const msg = (res && res.message) ? res.message : `Tickets for registration emailed successfully.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to email registration 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: any = await apiFetch("/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: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send tickets to WhatsApp");
}
};
const whatsappRegistration = async (registrationId: string) => {
if (!token) 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: any = await apiFetch("/api/tickets/email", {
method: "POST",
body: { registrationId, channel: "whatsapp" },
authToken: token,
});
const msg = (res && res.message) ? res.message : `Tickets sent to WhatsApp.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send tickets to WhatsApp");
}
};
// Print helpers
const buildTicketHtmlCard = (t: any, buyerName?: string) => {
const eventTitle = t.event?.title || t.eventId || "Event";
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;
const qty = t.quantity || 1;
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(t.qrCode || t.id)}`;
const dateStr = eventDate ? formatDate(eventDate) : '';
return `
<div class="ticket">
<div class="row between">
<div class="evt">${eventTitle}</div>
${dateStr ? `<div class="date">${dateStr}</div>` : ''}
</div>
<div class="type">${type}</div>
<div class="qty">Qty: <strong>${qty}</strong></div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">Ticket ID: ${t.id}</div>
${buyerName ? `<div class="meta">Purchased by: ${buyerName}</div>` : ''}
</div>
`;
};
const openPrintWindow = (title: string, cardsHtml: string) => {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!doctype html><html><head><title>${title}</title>
<style>
*{box-sizing:border-box}
body{font-family:Arial,Helvetica,sans-serif;margin:0;padding:16px;background:#f7f7f7}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px}
.ticket{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px;box-shadow:0 1px 3px rgba(0,0,0,0.06);break-inside:avoid;page-break-inside:avoid}
.evt{font-weight:700;font-size:14px}
.type{margin-top:4px;color:#374151}
.qty{margin-top:2px;font-size:12px;color:#374151}
.date{color:#6b7280;font-size:12px}
.row{display:flex;gap:8px;align-items:center}
.between{justify-content:space-between}
.qrwrap{display:flex;justify-content:center;align-items:center;margin:10px 0}
.qrwrap img{width:160px;height:160px}
.meta{font-size:12px;color:#6b7280;text-align:center;margin-top:6px}
@media print { body{background:#fff} .print-hidden{display:none !important} }
</style>
</head><body>
<h2 class="print-hidden" style="margin:8px 0 16px 0;font-size:18px">${title}</h2>
<div class="grid">${cardsHtml}</div>
<script>
(function(){
function printWhenReady(){
var imgs = Array.prototype.slice.call(document.images);
if(imgs.length === 0){ window.print(); return; }
var remaining = imgs.length;
function done(){ remaining--; if(remaining <= 0){ setTimeout(function(){ window.print(); }, 200); } }
imgs.forEach(function(img){
if (img.complete) { done(); }
else {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}
});
}
if (document.readyState === 'complete') printWhenReady();
else window.addEventListener('load', printWhenReady);
})();
</script>
</body></html>`);
w.document.close();
w.focus();
};
const printTickets = (ticketList: any[]) => {
if (!ticketList || ticketList.length === 0) return;
const cards = ticketList.map(t => buildTicketHtmlCard(t, user?.name)).join("");
openPrintWindow(`Tickets (${ticketList.length})`, cards);
};
// Upcoming events derived from registrations
const upcomingEvents = useMemo(() => {
const map = new Map<string, any>();
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]);
// Tickets by grouping
const ticketsByEvent = useMemo(() => {
const m: Record<string, any[]> = {};
for (const t of tickets) {
const eid = t.eventId;
if (!m[eid]) m[eid] = [];
m[eid].push(t);
}
return m;
}, [tickets]);
// 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<any>(`/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]);
// 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<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
setActiveRegId(null);
setShowCancelConfirm(false);
setInfo('Registration cancelled.');
} catch (e: any) {
setError(e?.message || '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<any[]>([]);
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]);
const editTotal = useMemo(() => {
return editEventOptions.reduce((sum: number, eo: any) => {
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
return sum + eo.variants.reduce((vsum: number, v: any) => {
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]);
const beginEdit = async () => {
if (!activeReg || !token) return;
setEditError(null);
setEditLoading(true);
try {
// Load event options (with variants) for this event
const eventData = await apiFetch<any>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
const eventOptions = (eventData?.eventOptions || []) as any[];
const regOptById = new Map<string, any>((activeReg.registrationOptions || []).map((o: any) => [o.id, o]));
// Prefill quantities from the current registration
const qtyMap: Record<string, number> = {};
for (const ro of (activeReg.registrationOptions || [])) {
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 = regOptById.get(t.registrationOptionId);
if (!ro) 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: any) => {
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
eo.variants.forEach((v: any) => {
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((e as any)?.message || '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: any) => o.id === eventOptionId);
const variant = variantId ? (eo?.variants || []).find((v: any) => 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<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
try {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
// Recompute billing totals using priceSnapshot
const now2 = new Date();
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
for (const r of myRegs) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now2) * (opt.quantity || 0), 0);
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<any[]>(`/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: any) {
setEditError(e?.message || 'Failed to update registration');
} finally {
setLoading(false);
}
};
const router = useRouter();
return (
<div className="max-w-6xl mx-auto w-full px-4 py-6 sm:px-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<h1 className="text-2xl font-semibold">Welcome{user ? `, ${user.name}` : ""}</h1>
<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-blue-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/reset-password")}
>Reset password</button>
<button
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-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-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold">My Registrations</h2>
<label className="text-sm flex items-center gap-2">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} />
<span>Show past events</span>
</label>
</div>
<p className="text-sm text-gray-600 mb-3 italic">
Click on registration to edit or make 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: any) => r.status !== 'cancelled')
.filter((r: any) => showPast || !isEventOver(r.event))
.map((r: any) => {
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 p-3 cursor-pointer " + (isCancelled ? "opacity-60 bg-gray-50" : "hover:bg-gray-50")}
onClick={() => setActiveRegId(r.id)}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium">{r.event?.title || r.eventId}</div>
<div className="text-xs text-gray-600">
Status: {r.status}{isCancelled && <span className="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-700">cancelled</span>}
</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: any) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
const upcoming = allTiers
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t: any) => new Date() < t.deadline)
.sort((a: any, b: any) => 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: any) => new Date(p.createdAt).getTime()))) : null;
const now = new Date();
const priceNow = (r.registrationOptions || []).reduce((s: number, ro: any) => s + optionUnitPrice(ro, lastPaymentAt, now) * (ro.quantity || 0), 0);
const afterPrice = (r.registrationOptions || []).reduce((s: number, ro: any) => s + optionUnitPrice(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>
</li>
);
})}
</ul>
)}
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold mb-2">Upcoming Events</h2>
{upcomingEvents.length === 0 ? (
<p className="text-gray-600 text-sm">No upcoming events.</p>
) : (
<ul className="grid md:grid-cols-2 gap-3">
{upcomingEvents.map(ev => (
<li key={ev.id} className="border rounded p-4">
<div className="font-medium">{ev.title}</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-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-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>
</li>
))}
</ul>
)}
</section>
<section>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
<h2 className="text-xl font-semibold">My Tickets</h2>
{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-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-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 lg:grid-cols-3 gap-3">
{tickets
.filter((t: any) => showPast || !isEventOver(t.event))
.map((t: any) => {
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-white shadow-sm">
<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="font-medium">{t.event?.title || t.eventId}</span>
</label>
{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">
Qty: {t.quantity || 1}
</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-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-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>
</div>
</li>
);
})}
</ul>
)}
</section>
{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">{activeReg.event?.title || activeReg.eventId}</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: {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-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">Edit</button>
)}
</div>
{!editMode ? (
<ul className="text-sm list-disc pl-5 space-y-1">
{(activeReg.registrationOptions || []).map((opt: any) => {
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
const unitPrice = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.variant?.price ?? opt.eventOption?.price ?? 0);
return (
<li key={opt.id}>
{opt.eventOption?.name}{variantLabel} x {opt.quantity} {formatRand(unitPrice * (opt.quantity || 0))}
{unitPrice < (opt.eventOption?.price || 0) && (
<span className="ml-1 text-xs text-green-700">(early bird)</span>
)}
</li>
);
})}
</ul>
) : (
<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: any) => {
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: any, b: any) => (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: any) => {
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>
{activeBill && activeBill.payments.length > 0 && (
<div>
<div className="font-medium mb-1">Payments</div>
<ul className="text-sm list-disc pl-5 space-y-1">
{activeBill.payments.map((p: any) => (
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({p.method || "Payment"})</li>
))}
</ul>
</div>
)}
<div className="flex flex-wrap gap-2 pt-2">
{showFormsButton && (
<button
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1"
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
>Attendee forms</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={() => router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(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: any) => o.id));
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
printTickets(list);
}}>Print all tickets</button>
<button className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>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={() => whatsappRegistration(activeReg.id)}>WhatsApp 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>
)}
{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-blue-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
<div className="text-base font-medium">Sending tickets</div>
<p className="text-sm text-gray-600 mt-1">Please wait while we send your tickets.</p>
</div>
) : (
<>
<div className="text-lg font-semibold mb-2">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-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => setDialog({ open: false, message: "", loading: false })}
>OK</button>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}