Compare commits

..
Author SHA1 Message Date
joshuaandClaude Sonnet 5 c07e9c928a Add door check-in flow for Main Tickets
Lets staff redeem a registration's Main Tickets by quantity at the door
(via the Payment/registration flow) instead of scanning each QR code,
and automatically emails/WhatsApps a check-in confirmation to the
attendee. Also routes the At The Door "Open" button and post-payment
flow dynamically: paid registrations jump straight to Check-In instead
of a forced ticket print, since tickets are already sent automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:56:31 +02:00
joshuaandClaude Sonnet 5 adb512a7d9 Bump version to 1.2.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 10:35:21 +02:00
joshua ad9ead807d Fix wrong auth token key and mislabeled loading dialog on payment flows
registration/success read the token from the wrong localStorage key
(token instead of hope_events_token), which broke registration/form
loading there and made the new "Pay with Yoco" checkout call fail with
"Not authorized, token failed" — switched to the shared auth context
like the rest of the app.

dashboard/user's "Make payment" reused the ticket-email dialog, which
hardcoded "Sending tickets…" while loading — the dialog now takes an
optional loading title/subtitle so payNow can show its own message.
2026-07-27 10:29:42 +02:00
joshua 36b61d968c Simplify self-service payments to full-amount checkout, add manual-page payment tab
Self-service "pay now" flows (registration success + user dashboard) now go
straight to a Yoco checkout for the full outstanding balance instead of
prompting for a partial amount; that page has been removed. Partial-amount
payment links remain supervisor/admin-only via the Payments dashboard.

Also adds a "Record Payment" tab to the supervisor manual registration page,
pre-filled with the most recently created registration, so staff can capture
a payment right after registering someone without leaving the page.
2026-07-27 10:14:49 +02:00
joshuaandClaude Sonnet 5 c710da4dff Bump version to 1.1.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:36:38 +02:00
14 changed files with 617 additions and 349 deletions
+34 -1
View File
@@ -9,6 +9,37 @@ and this project follows [Semantic Versioning](https://semver.org/).
### Added
- At The Door: new "Check-In" tab (before "Tickets" in the tab order) for quickly redeeming a registration's Main Tickets by quantity (e.g. checking in 2 of 3 people on a booking) without scanning each QR code individually. Like the Payment tab, it only ever shows the currently opened registration — reached via the "Open" button, not a general search.
- Checking in a Main Ticket (via the new Check-In tab or the existing QR camera scanner) now automatically sends the ticket holder a confirmation email and WhatsApp message (respecting their notification preference) stating how many were checked in and how many remain.
### Changed
- At The Door: the "Open" button on a registration now jumps to the Payment tab only when a balance is still outstanding; fully paid registrations jump straight to the new Check-In tab instead.
- At The Door: recording a payment that fully settles a registration's balance now jumps straight to the Check-In tab instead of generating and print-previewing a paper ticket — tickets are already emailed/WhatsApped to the attendee automatically once the registration is paid, so a physical print is no longer forced on this path.
## [1.2.0] - 2026-07-27
### Added
- Supervisor dashboard: `/dashboard/supervisor/manual` now has a "Record Payment" tab (alongside "Register"), for capturing a payment without leaving the page. It pre-fills with the user and registration from the most recently created manual registration, but only switches tabs when a supervisor/admin clicks it themselves.
### Changed
- Self-service payments ("Pay with Yoco" after registering, and "Make payment" on the user dashboard) now go straight to a Yoco checkout for the full remaining outstanding balance, instead of first showing a page to choose a custom/partial amount. Generating a partial-amount payment link remains available only from the supervisor/admin Payments dashboard.
### Removed
- `/dashboard/user/pay` — the self-service partial-payment page — has been removed; it's no longer linked to from anywhere in the app.
### Fixed
- `/registration/success` read the auth token from the wrong `localStorage` key (`token` instead of `hope_events_token`), which silently broke loading the registration/attendee-form data on that page and made the "Pay with Yoco" button fail with "Not authorized, token failed". Now uses the shared auth context, like the rest of the app.
- User dashboard: clicking "Make payment" briefly showed a "Sending tickets…" loading dialog (borrowed from the ticket-email flow) instead of a payment-specific message.
## [1.1.0] - 2026-07-24
### Added
- User dashboard: new "Payment history" page listing the user's own payments (donations excluded), with server-side pagination (25 per page), date range, method, and payment/refund filters.
- User dashboard: registration status (Pending/Confirmed/Partially Paid/Paid/Cancelled) is now shown as a colored badge, matching the existing event Closed/Past/Inactive badge convention, instead of a raw status string.
- Dashboard-wide: inline success/error/confirmation messages (e.g. after creating a manual registration on `/dashboard/supervisor/manual`) now auto-dismiss after 7 seconds instead of persisting indefinitely, via a new shared `useDismissingState` hook. Applied consistently across all dashboard pages with this pattern; excluded are message-only modal dialogs (e.g. ticket-scanning's success/error confirmations, which still require a manual OK) and a couple of mixed validation/async error states shown inside actively-open forms (the registration-edit modal and the event create/edit modal), which continue to persist until the user acts.
@@ -36,6 +67,8 @@ and this project follows [Semantic Versioning](https://semver.org/).
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...main
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...main
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
[1.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.0.1",
"version": "1.2.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+10 -1
View File
@@ -321,6 +321,7 @@ const scanTicket = async (req, res) => {
e.title AS "eventTitle",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
COALESCE(
json_agg(
json_build_object(
@@ -352,7 +353,7 @@ const scanTicket = async (req, res) => {
isUsed: r.isUsed,
eventId: r.eventId,
event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || []
};
@@ -398,6 +399,14 @@ const scanTicket = async (req, res) => {
})
]);
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
// the QR camera flow are unaffected)
if (ticket.registrationOption?.eventOption?.isMainTicket) {
require('../utils/notifications')
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
res.json({
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
ticketUsage,
+66
View File
@@ -80,6 +80,17 @@ async function loadRegistrationFull(registrationId) {
});
}
async function loadTicketFull(ticketId) {
return prisma.ticket.findUnique({
where: { id: ticketId },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
event: true,
registrationOption: { include: { eventOption: true, variant: true } },
},
});
}
async function loadPaymentFull(paymentId) {
return prisma.payment.findUnique({
where: { id: paymentId },
@@ -488,6 +499,33 @@ function buildPaymentReceipt(payment) {
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const optionName = ticket.registrationOption?.eventOption?.name || 'Main Ticket';
const fullyCheckedIn = remaining <= 0;
const subject = `Checked in ${eventTitle}`;
const preheader = `${qtyRedeemed} checked in for ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">You're checked in! ✅</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">See you inside</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${ticket.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
<strong>${qtyRedeemed}</strong> ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for <strong>${eventTitle}</strong>.
</p>
${callout(`<strong style="font-size:15px">${totalRedeemed} of ${ticket.quantity || 1} checked in</strong>${
fullyCheckedIn ? '' : `<br/><span style="font-size:13px">${remaining} remaining on this ticket</span>`
}`, fullyCheckedIn ? 'success' : 'info')}`;
const text = `You're checked in!\n\nHi ${ticket.user?.name || 'there'},\n\n${qtyRedeemed} ${optionName}(s) just checked in for ${eventTitle}.\n\n${totalRedeemed} of ${ticket.quantity || 1} checked in${fullyCheckedIn ? '' : `, ${remaining} remaining`}.\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Donation applied to a registration ────────────────────────────────────────
//
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
@@ -893,6 +931,32 @@ async function sendRefundEmail(refundPaymentId) {
}
}
async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining) {
try {
const ticket = await loadTicketFull(ticketId);
if (!ticket) return;
const user = ticket.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWACheckIn } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail && shouldEmail(user)) {
const msg = buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining);
sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
const waMsg = buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining);
if (hasValidEmail) {
sends.push(waText(user, waMsg));
} else {
sends.push(waTextAny(user, waMsg));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send check-in emails:', e);
}
}
// Sent when staff apply a donation to someone's registration. Only the registrant is
// notified (anonymously, per design) — the donor already received their donation-received
// notification when the donation was originally made, so they are deliberately not emailed
@@ -981,4 +1045,6 @@ module.exports = {
sendRefundEmail,
sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails,
sendCheckInEmails,
buildCheckInConfirmation,
};
+21
View File
@@ -107,6 +107,26 @@ function buildWAPayment(payment) {
].join('\n');
}
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const name = ticket.user?.name || 'there';
const fullyCheckedIn = remaining <= 0;
return [
`✅ *Checked In*`,
'',
`Hi ${name},`,
'',
`*${qtyRedeemed}* checked in for *${eventTitle}*.`,
`*${totalRedeemed} of ${ticket.quantity || 1}* checked in${fullyCheckedIn ? '' : `, *${remaining}* remaining`}.`,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// ─── Login notification ───────────────────────────────────────────────────────
function buildWALogin({ name, when, location, userAgent }) {
@@ -255,4 +275,5 @@ module.exports = {
buildWAWelcome,
buildWAAccountClosed,
buildWATicketCaption,
buildWACheckIn,
};
+1 -2
View File
@@ -164,7 +164,6 @@ frontend/src/
|-------|-------------|
| `/dashboard/user` | My registrations + tickets overview |
| `/dashboard/user/profile` | Edit profile, notification preferences |
| `/dashboard/user/pay` | Pay outstanding balance |
| `/dashboard/user/donate` | Make a donation |
| `/dashboard/user/forms` | Complete registration forms |
| `/dashboard/user/reset-password` | Change password (authenticated) |
@@ -255,7 +254,7 @@ A "Select Email/both" or "Select WhatsApp/both" quick-select button is available
5. Backend reconciles payment, updates registration status, generates and emails tickets
6. User sees `/payment/success` and tickets appear in their dashboard
Outstanding balances can be paid at any time from `/dashboard/user/pay`.
Outstanding balances can be paid at any time from the "Make payment" button on `/dashboard/user` — this always creates a checkout for the full remaining balance. Partial-amount payment links can only be generated by a supervisor/admin from `/dashboard/supervisor/payments`.
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.0.1",
"version": "1.2.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -7,7 +7,15 @@ import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
type Mode = "registration" | "payment" | "tickets" | "refund";
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
const MODE_LABELS: Record<Mode, string> = {
registration: "REGISTRATION",
payment: "PAYMENT",
checkin: "CHECK-IN",
tickets: "TICKETS",
refund: "REFUND",
};
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
@@ -159,8 +167,7 @@ export default function AtTheDoorPage() {
const [error, setError] = useDismissingState<string | null>(null);
const handleRegistrationCreated = (registration: any) => {
setActiveRegistration(registration);
setMode("payment"); // 🚀 Jump automatically
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
};
const handleRegistrationSelected = (registration: any) => {
@@ -171,7 +178,9 @@ export default function AtTheDoorPage() {
if (!activeRegistration) return;
const id = setTimeout(() => {
setMode("payment");
// Fully paid → head straight to check-in; otherwise there's still a
// balance to collect, so go capture payment first.
setMode(activeRegistration.status === "paid" ? "checkin" : "payment");
}, 0);
return () => clearTimeout(id);
@@ -317,28 +326,12 @@ export default function AtTheDoorPage() {
const reg = registration || activeRegistration;
try {
const res = await apiFetch<any>("/api/tickets/generate", {
method: "POST",
authToken: token,
body: {
registrationId: reg.id
}
});
const tickets = res?.tickets || [];
if (tickets.length) {
printTickets(tickets);
}
setInfo("Payment recorded & tickets printed");
setActiveRegistration(null);
setMode("registration");
} catch (e: any) {
setError(e?.message || "Tickets failed to generate");
}
// Tickets are generated and emailed/WhatsApped automatically server-side
// once a registration reaches "paid" (see paymentController) — no need to
// fetch/print them here, just move straight to checking the attendee in.
setActiveRegistration(reg);
setInfo("Payment recorded — tickets sent. Ready to check in.");
setMode("checkin");
};
const handleDonation = (user?: any) => {
@@ -346,97 +339,6 @@ export default function AtTheDoorPage() {
setShowDonationModal(true);
};
const buildTicketHtmlCard = (t: any) => {
const eventTitle =
t.event?.title ||
t.registrationOption?.registration?.event?.title ||
"Event";
const ticketType = ticketLabel(t);
const qty = t.quantity || 1;
const holder =
t.user?.name ||
t.registrationOption?.registration?.user?.name ||
"";
const qrValue = t.qrCode || t.id;
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrValue)}`;
return `<div class="ticket">
<div class="top">
<div class="evt">${eventTitle}</div>
<div class="type">${ticketType}</div>
${holder ? `<div class="holder">${holder}</div>` : ""}
<div class="qty">Qty: <strong>${qty}</strong></div>
</div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">${t.id}</div>
</div>`;
};
const openPrintWindow = (cardsHtml: string) => {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!doctype html><html><head><style>
@page { size: A4; margin: 8mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; background: #fff; }
.page {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 194mm;
height: 281mm;
gap: 4mm;
page-break-after: always;
break-after: page;
}
.page:last-child { page-break-after: avoid; break-after: avoid; }
.ticket {
border: 1.5px solid #222;
border-radius: 3mm;
padding: 5mm;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
min-height: 0;
}
.top { flex: 0 0 auto; }
.evt { font-weight: bold; font-size: 13pt; line-height: 1.2; }
.type { font-size: 11pt; margin-top: 2mm; color: #333; }
.holder { font-size: 10pt; margin-top: 1mm; color: #555; }
.qty { font-size: 10pt; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; align-items: center; flex: 1 1 auto; padding: 3mm 0; }
.qrwrap img { width: 48mm; height: 48mm; display: block; }
.meta { font-size: 7pt; color: #777; text-align: center; flex: 0 0 auto; word-break: break-all; }
</style></head><body>${cardsHtml}<script>
(function(){
function go(){
var imgs=Array.prototype.slice.call(document.images);
if(!imgs.length){window.print();return;}
var n=imgs.length;
function done(){if(--n<=0)setTimeout(function(){window.print();},150);}
imgs.forEach(function(i){if(i.complete)done();else{i.addEventListener('load',done,{once:true});i.addEventListener('error',done,{once:true});}});
}
if(document.readyState==='complete')go();else window.addEventListener('load',go);
})();
</script></body></html>`);
w.document.close();
};
const printTickets = (tickets: any[]) => {
if (!tickets?.length) return;
// 4 per A4 page (2 columns × 2 rows)
const PAGE_SIZE = 4;
const pages: string[] = [];
for (let i = 0; i < tickets.length; i += PAGE_SIZE) {
const chunk = tickets.slice(i, i + PAGE_SIZE);
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
}
openPrintWindow(pages.join(""));
};
return (
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
@@ -482,7 +384,7 @@ export default function AtTheDoorPage() {
{/* Mode Buttons */}
<div className="flex gap-2 mb-4 flex-wrap">
{(["registration", "payment", "tickets", "refund"] as Mode[]).map(m => (
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
<button
key={m}
onClick={() => setMode(m)}
@@ -494,7 +396,7 @@ export default function AtTheDoorPage() {
: "bg-white hover:bg-gray-50"
}`}
>
{m.toUpperCase()}
{MODE_LABELS[m]}
</button>
))}
</div>
@@ -522,6 +424,10 @@ export default function AtTheDoorPage() {
/>
)}
{mode === "checkin" && (
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
)}
{mode === "tickets" && (
<DoorTicketsPanel token={token} eventId={eventId} />
)}
@@ -1081,6 +987,148 @@ function DoorTicketsPanel({ token, eventId }: any) {
);
}
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
const [submittingId, setSubmittingId] = useState<string | null>(null);
const load = async () => {
if (!token || !eventId) return;
try {
setLoading(true);
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []);
setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket));
} catch {
setTicketsForEvent([]);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [token, eventId]);
if (!registration) {
return (
<div className="border rounded-xl p-6 bg-white shadow-sm text-sm text-gray-500">
No registration selected
</div>
);
}
const myTickets = ticketsForEvent
.filter(t => t.registrationOption?.registration?.id === registration.id)
.map(t => {
const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
const remaining = (t.quantity || 1) - totalRedeemed;
return { ...t, totalRedeemed, remaining };
});
const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
const setQtyFor = (t: any, n: number) =>
setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) }));
const commit = async (ticket: any) => {
const qty = qtyFor(ticket);
try {
setSubmittingId(ticket.id);
const res = await apiFetch<any>(
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
{ method: "POST", authToken: token, body: { qty } }
);
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
res?.remaining > 0 ? `${res.remaining} remaining` : " — fully checked in"
}. Confirmation sent.`);
await load();
} catch (e: any) {
setError(e?.message || "Check-in failed");
} finally {
setSubmittingId(null);
}
};
return (
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-xl font-semibold mb-4">Check-In</div>
{/* ✅ User */}
<div className="mb-4">
<div className="text-lg font-semibold">
{registration.user?.name || "Guest"}
</div>
</div>
{loading && (
<div className="text-sm text-gray-500">Loading</div>
)}
{!loading && myTickets.length === 0 && (
<div className="text-sm text-gray-500">
No Main Tickets on this registration
</div>
)}
<div className="space-y-4">
{myTickets.map(t => {
const qty = qtyFor(t);
return (
<div key={t.id} className="border rounded-lg p-4">
<div className="text-sm font-medium mb-3">{ticketLabel(t)}</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div>
<div className="text-lg font-semibold">{t.quantity || 1}</div>
</div>
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">CHECKED IN</div>
<div className="text-lg font-semibold">{t.totalRedeemed}</div>
</div>
<div className="border rounded-lg p-3 text-center bg-emerald-50">
<div className="text-xs text-gray-500">REMAINING</div>
<div className="text-2xl font-bold text-emerald-600">{t.remaining}</div>
</div>
</div>
{t.remaining > 0 ? (
<div className="flex items-center gap-3">
<button
onClick={() => setQtyFor(t, qty - 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold"
>
</button>
<div className="w-10 text-center text-lg font-semibold">{qty}</div>
<button
onClick={() => setQtyFor(t, qty + 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700"
>
+
</button>
<button
onClick={() => commit(t)}
disabled={submittingId === t.id}
className="ml-auto px-4 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
>
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
</button>
</div>
) : (
<div className="text-sm text-emerald-600 font-medium">Fully checked in </div>
)}
</div>
);
})}
</div>
</div>
);
}
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
if (!open) return null;
@@ -39,6 +39,18 @@ function fmtPrice(n: number) {
return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
}
// Effective unit price for an existing registration option (as returned by /api/registrations).
// Prefers priceSnapshot (authoritative price captured at registration time) and only falls
// back to a live early-bird calculation for legacy rows without a snapshot.
function optionUnitPrice(opt: any): number {
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
return Number(opt.priceSnapshot);
}
const eo = opt.eventOption;
if (opt.variant) return effectiveVariantUnit(eo, opt.variant);
return effectiveOptionUnit(eo);
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function ManualRegistrationPage() {
@@ -76,6 +88,23 @@ export default function ManualRegistrationPage() {
const [message, setMessage] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
// ── Tabs ──────────────────────────────────────────────────────────────────
const [tab, setTab] = useState<"register" | "payment">("register");
// ── Record Payment tab state ─────────────────────────────────────────────
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
const [payUserId, setPayUserId] = useState<string>("");
const [payUserQuery, setPayUserQuery] = useState("");
const [payDropdownOpen, setPayDropdownOpen] = useState(false);
const paySearchRef = useRef<HTMLDivElement>(null);
const [payRegistrationId, setPayRegistrationId] = useState<string>("");
const [payAmount, setPayAmount] = useState<string>("");
const [payMethod, setPayMethod] = useState<string>("cash");
const [payPaidAtLocal, setPayPaidAtLocal] = useState<string>("");
const [paySubmitting, setPaySubmitting] = useState(false);
const [payMessage, setPayMessage] = useDismissingState<string | null>(null);
const [payError, setPayError] = useDismissingState<string | null>(null);
// Load all users for client-side fuzzy matching
useEffect(() => {
if (!token) return;
@@ -84,6 +113,97 @@ export default function ManualRegistrationPage() {
.catch(() => {});
}, [token]);
// Load all registrations for the Record Payment tab (embeds payments, so outstanding
// balances can be computed without a per-registration fetch loop).
useEffect(() => {
if (!token) return;
apiFetch<any[]>("/api/registrations", { authToken: token })
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
.catch(() => {});
}, [token]);
const regOutstanding = useMemo(() => {
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of allRegistrations) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
return map;
}, [allRegistrations]);
const payMatchedUsers = useMemo(() => {
if (payUserQuery.trim().length < 2) return [];
return allUsers
.map(u => ({ u, score: scoreUser(u, payUserQuery) }))
.filter(x => x.score >= 0.45)
.sort((a, b) => b.score - a.score)
.slice(0, 6)
.map(x => x.u);
}, [payUserQuery, allUsers]);
const selectPayUser = (u: any) => {
setPayUserId(u.id);
setPayUserQuery(u.name || "");
setPayDropdownOpen(false);
setPayRegistrationId("");
};
// Close payment-tab user dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (paySearchRef.current && !paySearchRef.current.contains(e.target as Node)) {
setPayDropdownOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const regsForPayUser = useMemo(() => {
if (!payUserId) return [];
return allRegistrations
.filter((r: any) => String(r.userId || r.user?.id) === String(payUserId))
.filter((r: any) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001)
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [allRegistrations, payUserId, regOutstanding]);
const createManualPayment = async () => {
if (!token) return;
setPayError(null);
setPayMessage(null);
const amt = parseFloat(payAmount || "0");
if (!amt || amt <= 0) { setPayError("Enter a valid amount"); return; }
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
try {
setPaySubmitting(true);
await apiFetch<any>("/api/payments", {
method: "POST",
authToken: token,
body: {
amount: amt,
method: payMethod,
userId: payUserId || undefined,
registrationId: payRegistrationId,
isDonation: false,
paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined,
}
});
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`);
setPayAmount("");
setPayPaidAtLocal("");
// Refresh registrations so the displayed outstanding balance updates
try {
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
setAllRegistrations(Array.isArray(regs) ? regs : []);
} catch {}
} catch (e: any) {
setPayError(e?.message || "Failed to record payment");
} finally {
setPaySubmitting(false);
}
};
// Fuzzy match results (top 6, score threshold 0.45)
const matchedUsers = useMemo(() => {
if (userQuery.trim().length < 2) return [];
@@ -193,6 +313,14 @@ export default function ManualRegistrationPage() {
});
setMessage("Manual registration created successfully.");
// Pre-load the Record Payment tab with this registration so it's ready to go the
// moment a supervisor clicks over — the tab itself is never switched to automatically.
setAllRegistrations(prev => [res, ...prev.filter((r: any) => r.id !== res.id)]);
setPayUserId(res.userId || res.user?.id || "");
setPayUserQuery(res.user?.name || guest.name || "");
setPayRegistrationId(res.id);
setPayDropdownOpen(false);
// Reset guest/ticket fields so the next registration starts from a clean slate
setGuest({ name: "", email: "", phoneNumber: "" });
setRegisterAsGuest(false);
@@ -220,6 +348,19 @@ export default function ManualRegistrationPage() {
</div>
)}
<div className="mb-4 flex items-center gap-2">
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="tab" value="register" className="hidden" checked={tab==='register'} onChange={() => setTab('register')} />
Register
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="tab" value="payment" className="hidden" checked={tab==='payment'} onChange={() => setTab('payment')} />
Record Payment
</label>
</div>
{tab === 'register' && (
<>
{message && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{message}</div>}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
@@ -447,6 +588,92 @@ export default function ManualRegistrationPage() {
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div>
</>
)}
{tab === 'payment' && (
<div className="max-w-xl">
{payMessage && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{payMessage}</div>}
{payError && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{payError}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Record a payment</div>
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Amount</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="number" step="0.01" value={payAmount} onChange={e => setPayAmount(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={payMethod} onChange={e => setPayMethod(e.target.value)}>
<option value="cash">Cash</option>
<option value="card">Card</option>
<option value="eft">EFT</option>
<option value="voucher">Voucher</option>
</select>
</div>
<div ref={paySearchRef} className="relative">
<label className="block text-xs text-gray-600 mb-1">User</label>
<input
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Search by name, email or phone…"
value={payUserQuery}
autoComplete="off"
onChange={e => { setPayUserQuery(e.target.value); setPayDropdownOpen(true); if (!e.target.value) { setPayUserId(""); setPayRegistrationId(""); } }}
onFocus={() => { if (payUserQuery.length >= 2) setPayDropdownOpen(true); }}
/>
{payDropdownOpen && payUserQuery.trim().length >= 2 && (
<div className="absolute z-30 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
{payMatchedUsers.length > 0 ? (
<>
<div className="px-3 py-1.5 text-[11px] text-gray-400 bg-gray-50 border-b">
{payMatchedUsers.length} match{payMatchedUsers.length !== 1 ? "es" : ""}
</div>
{payMatchedUsers.map(u => (
<button
key={u.id}
type="button"
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
onClick={() => selectPayUser(u)}
>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
{u.email && <span>{u.email}</span>}
{u.phoneNumber && <span>· {u.phoneNumber}</span>}
</div>
</button>
))}
</>
) : (
<div className="px-3 py-3 text-sm text-gray-500 italic">No matching users found.</div>
)}
</div>
)}
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Registration</label>
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={payRegistrationId} onChange={e => setPayRegistrationId(e.target.value)} disabled={!payUserId}>
<option value="">Select registration</option>
{regsForPayUser.map((r: any) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
return <option key={r.id} value={r.id} title={label}>{label}</option>;
})}
</select>
{!payUserId && <div className="text-xs text-gray-500 mt-1">Search for a user above to see their registrations.</div>}
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Paid at (optional)</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={payPaidAtLocal} onChange={e => setPayPaidAtLocal(e.target.value)} max={new Date().toISOString().slice(0,16)} />
<div className="text-[10px] text-gray-500 mt-1">Leave blank to use current time</div>
</div>
</div>
<div className="mt-3">
<button disabled={paySubmitting} onClick={createManualPayment} className="w-full px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shadow-sm">{paySubmitting ? 'Recording…' : 'Record payment'}</button>
</div>
</div>
</div>
)}
</div>
);
}
+40 -5
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
@@ -87,7 +87,7 @@ export default function UserDashboardPage() {
// 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 });
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string }>({ open: false, message: "", loading: false });
// Track whether the active registration's event has attendee forms
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
@@ -312,6 +312,41 @@ export default function UserDashboardPage() {
}
};
// 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<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(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: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to create checkout");
}
};
// Print helpers
const buildTicketHtmlCard = (t: any, buyerName?: string) => {
const eventTitle = t.event?.title || t.eventId || "Event";
@@ -1039,7 +1074,7 @@ export default function UserDashboardPage() {
{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)}`)}
onClick={() => payNow(activeReg.id)}
>Make payment</button>
) : (
<>
@@ -1096,8 +1131,8 @@ export default function UserDashboardPage() {
{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 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>
) : (
<>
@@ -1,208 +0,0 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
function MakePaymentContent() {
const searchParams = useSearchParams();
const registrationId = searchParams.get("registrationId");
const { token } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [registration, setRegistration] = useState<any | null>(null);
const [payments, setPayments] = useState<any[]>([]);
const [amount, setAmount] = useState<string>("");
const [priceUpdated, setPriceUpdated] = useState<{ newTotal: number; message: string } | 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;
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() : [];
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;
};
const totals = useMemo(() => {
if (!registration) return { totalDue: 0, totalPaid: 0, outstanding: 0 };
const now = new Date();
// totalDue uses priceSnapshot — not time-dependent, consistent with what the backend charges
const totalDue = (registration.registrationOptions || []).reduce((s: number, opt: any) => s + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = payments.reduce((s: number, p: any) => s + (p.amount || 0), 0);
const outstanding = Math.max(0, totalDue - totalPaid);
return { totalDue, totalPaid, outstanding };
}, [registration, payments]);
const minAllowed = useMemo(() => {
if (totals.outstanding <= 0) return 0;
return totals.outstanding >= 15 ? 15 : totals.outstanding;
}, [totals.outstanding]);
useEffect(() => {
(async () => {
if (!token || !registrationId) return;
try {
setError(null);
setLoading(true);
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
setPayments(pays);
} catch (e: any) {
setError(e?.message || "Failed to load registration");
} finally {
setLoading(false);
}
})();
}, [token, registrationId]);
useEffect(() => {
if (totals.outstanding <= 0) {
setAmount("");
}
}, [totals.outstanding]);
const submit = async () => {
if (!token || !registrationId) return;
const amt = parseFloat(amount);
if (!(amt > 0)) { setError("Enter a valid amount"); return; }
if (amt > totals.outstanding) { setError(`Amount cannot exceed outstanding (R ${totals.outstanding.toFixed(2)})`); return; }
if (amt < minAllowed) {
if (totals.outstanding >= 15) {
setError("Minimum payment is R15");
} else {
setError(`Please pay the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`);
}
return;
}
try {
setError(null);
setInfo(null);
setPriceUpdated(null);
setLoading(true);
const res = await apiFetch<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }>("/api/payments/yoco-checkout", {
method: "POST",
body: {
registrationId,
amount: amt,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
authToken: token,
});
if (res.priceUpdated) {
// Early-bird price changed — show warning and reload registration data
setPriceUpdated({ newTotal: res.newTotal ?? 0, message: res.message ?? 'Prices have changed.' });
// Reload registration so totals reflect updated priceSnapshot
try {
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
setPayments(pays);
} catch {}
setAmount("");
return;
}
setInfo("Redirecting to payment...");
window.location.href = res.redirectUrl!;
} catch (e: any) {
setError(e?.message || "Failed to create checkout");
} finally {
setLoading(false);
}
};
if (!registrationId) return <div className="p-6">Missing registrationId.</div>;
return (
<div className="max-w-xl mx-auto w-full p-6">
<h1 className="text-2xl font-semibold mb-4">Make a payment</h1>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
{priceUpdated && (
<div className="mb-4 p-3 border border-amber-300 bg-amber-50 rounded text-sm text-amber-800">
<strong>Pricing has changed.</strong> {priceUpdated.message}
<div className="mt-1">New outstanding: <strong>R {priceUpdated.newTotal.toFixed(2)}</strong></div>
<button
className="mt-2 text-xs underline text-amber-700 hover:text-amber-900"
onClick={() => setPriceUpdated(null)}
>OK, I understand continue with new price</button>
</div>
)}
{registration ? (
<div className="border rounded p-3 mb-4">
<div className="font-medium">{registration.event?.title || registration.eventId}</div>
<div className="text-sm text-gray-600">Registration #{registration.id.slice(0,8)}</div>
<div className="mt-2 text-sm">
<div>Total: R {totals.totalDue.toFixed(2)}</div>
<div>Paid: R {totals.totalPaid.toFixed(2)}</div>
<div>Outstanding: <span className={totals.outstanding>0?"text-red-600":"text-green-700"}>R {totals.outstanding.toFixed(2)}</span></div>
</div>
{(() => {
const tiers = (registration.registrationOptions || []).flatMap((ro: any) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
const upcoming = tiers.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 d = upcoming[0].deadline as Date;
const dateStr = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
return <div className="mt-2 text-xs text-amber-700">Early bird pricing applies if paid before {dateStr}.</div>;
})()}
</div>
) : (
<p className="text-sm text-gray-600 mb-4">Loading registration...</p>
)}
<label className="block text-sm font-medium mb-1">Amount (R)</label>
<input
type="number"
min={minAllowed.toFixed(2)}
max={totals.outstanding > 0 ? totals.outstanding.toFixed(2) : "0"}
step="1"
placeholder={totals.outstanding >= 15 ? "Enter amount (min R15)" : `Enter amount (max R ${totals.outstanding.toFixed(2)})`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={totals.outstanding <= 0}
className="w-full border rounded px-3 py-2 mb-1 disabled:opacity-60"
/>
{totals.outstanding > 0 && (
<p className="text-xs text-gray-600 mb-3">Minimum payment {totals.outstanding >= 15 ? "is R15" : `is the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`}.</p>
)}
<button
disabled={loading || totals.outstanding <= 0}
onClick={submit}
className="bg-green-600 text-white px-4 py-2 rounded disabled:opacity-60"
>{loading?"Creating checkout...": totals.outstanding <= 0 ? "No outstanding amount" : "Pay now"}</button>
</div>
);
}
export default function MakePaymentPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<MakePaymentContent />
</Suspense>
);
}
+32 -10
View File
@@ -3,12 +3,14 @@ import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
function RegistrationSuccessContent() {
const search = useSearchParams();
const router = useRouter();
const { token } = useAuth();
const registrationId = search.get("registrationId") || search.get("id");
const fallbackTotalParam = search.get("totalDue");
const fallbackTotalDue = React.useMemo(() => {
@@ -22,6 +24,7 @@ function RegistrationSuccessContent() {
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [info, setInfo] = React.useState<string | null>(null);
const [payLoading, setPayLoading] = React.useState(false);
React.useEffect(() => {
(async () => {
@@ -31,7 +34,6 @@ function RegistrationSuccessContent() {
try {
setLoading(true);
// Try to fetch registration and event form
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
const r = token ? await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
if (r) {
setReg(r);
@@ -49,7 +51,7 @@ function RegistrationSuccessContent() {
setLoading(false);
}
})();
}, [registrationId]);
}, [registrationId, token]);
const totalDue = React.useMemo(() => {
if (!reg) return 0;
@@ -69,7 +71,6 @@ function RegistrationSuccessContent() {
// attempt ticket generation if status is paid (may fail if required forms aren't completed)
(async () => {
try {
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (token && reg?.id) {
await (await import('@/lib/api')).apiFetch('/api/tickets/generate', { method: 'POST', authToken: token, body: { registrationId: reg.id } });
}
@@ -77,11 +78,33 @@ function RegistrationSuccessContent() {
setTimeout(() => router.replace('/dashboard'), 600);
})();
}
}, [reg, totalDue, router]);
}, [reg, totalDue, router, token]);
const goPay = () => {
const goPay = async () => {
if (!registrationId) return;
router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(registrationId)}`);
if (!token) { setError('Please login to pay.'); return; }
try {
setError(null);
setPayLoading(true);
const { createFullPaymentCheckout } = await import('@/lib/api');
const res = await createFullPaymentCheckout(token, registrationId);
if (res.priceUpdated) {
// Early-bird price changed since registration — surface the new total and
// reload the registration so the displayed totalDue reflects it, instead of redirecting.
setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`);
try {
const r = await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
if (r) setReg(r);
} catch {}
return;
}
if (!res.redirectUrl) { setError('Failed to create checkout'); return; }
window.location.href = res.redirectUrl;
} catch (e: any) {
setError(e?.message || 'Failed to create checkout');
} finally {
setPayLoading(false);
}
};
const goDashboard = () => {
@@ -108,9 +131,9 @@ function RegistrationSuccessContent() {
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && (
<button
onClick={goPay}
disabled={!registrationId}
disabled={!registrationId || payLoading}
className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60"
>Pay with Yoco</button>
>{payLoading ? "Creating checkout..." : "Pay with Yoco"}</button>
)}
<button
onClick={goDashboard}
@@ -129,6 +152,7 @@ function RegistrationSuccessContent() {
}
function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }: { reg: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; setError: any; setInfo: any; }) {
const { token } = useAuth();
const registrationId = reg?.id;
const mainTickets = (reg?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
const count = Math.max(0, mainTickets);
@@ -157,7 +181,6 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
const submit = async () => {
try {
setError(null); setInfo(null);
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (!token) { setError('Please login to submit forms.'); return; }
const payload = [] as any[];
for (let i = 0; i < count; i++) {
@@ -177,7 +200,6 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
const saveDraft = async () => {
try {
setError(null); setInfo(null);
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (!token) { setError('Please login to save drafts.'); return; }
await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
method: 'PUT',
+16
View File
@@ -155,3 +155,19 @@ export function fetchAllUsers(token: string, params: Record<string, string> = {}
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> {
return fetchAllPages("/api/payments", token, params);
}
// Creates a Yoco checkout for a registration's full remaining outstanding balance.
// Omitting `amount` makes the backend charge the full remainder rather than a partial amount —
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
export function createFullPaymentCheckout(token: string, registrationId: string): Promise<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }> {
return apiFetch("/api/payments/yoco-checkout", {
method: "POST",
authToken: token,
body: {
registrationId,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.0.1",
"version": "1.2.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",