Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b081ed3c8b | ||
|
|
8a75c9155b | ||
|
|
8850984055 | ||
|
|
21176e1e0b | ||
|
|
b4cd140168 | ||
|
|
5167706d1b | ||
|
|
047f61b627 | ||
|
|
1815f78c85 | ||
|
|
29036d0612 | ||
|
|
c07e9c928a | ||
|
|
adb512a7d9 | ||
|
|
ad9ead807d | ||
|
|
36b61d968c |
+59
-1
@@ -7,6 +7,61 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.3.2] - 2026-08-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Self-service kiosk: all password fields (supervisor sign-in, change event, and the visitor "Choose a password" field) now have a show/hide toggle button, so staff can verify what they've typed on the touchscreen instead of typing blind.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Self-service kiosk: removed the separate "Look up existing account" search field — for privacy, staff no longer type a visitor's email/phone into a dedicated search box. Instead, entering an email or phone number in the registration form itself (Email and Cell Number are now the first two fields, followed by Name) automatically checks for a matching account once that field is left.
|
||||||
|
- Self-service kiosk: matched accounts are no longer updated silently. If the operator's typed Name, Email, Cell Number, or "Send tickets via" preference differs from what's on file, a confirmation dialog now lists exactly what will change (old value → new value) and requires the operator to confirm before the account is updated.
|
||||||
|
- Self-service kiosk / manual registration: an existing account's name is now actually updated when confirmed changed (previously silently discarded), and email/phone corrections are applied even when the account already had a real value on file (previously only blank phone numbers or guest-placeholder emails could be replaced).
|
||||||
|
- Self-service kiosk: fixed a bug where changing the phone number to one belonging to a different account would silently replace the Name/Email fields with that other account's details, and re-editing the email back to the original value afterward would not re-check it — together this could result in a registration being (or looking like it would be) saved under the wrong account. Email and phone matches are now tracked independently; if they resolve to two different existing accounts, the kiosk shows a clear warning naming both accounts and blocks registration until the operator corrects one of the fields, instead of silently merging or overwriting details.
|
||||||
|
- Manual registration API: added a server-side check, independent of the kiosk UI, that rejects (`409`) a registration whose submitted email and phone number belong to two different existing accounts — a defense-in-depth safeguard against one account's contact details being overwritten with, or hijacked by, another's.
|
||||||
|
- Manual registration API: an existing account's notification preference is now validated against its final email/phone after any confirmed update (e.g. falls back off "WhatsApp"/"Both" if no valid phone remains, or onto "WhatsApp" if the email was cleared in favor of a real phone), instead of persisting a preference that no longer matches the account's actual contact info.
|
||||||
|
- Self-service kiosk: tapping anywhere else on the page (e.g. a ticket quantity +/− button) while Email or Cell Number was focused blurred that field and silently re-ran its account lookup; even though the match hadn't changed, this reset Name/Email/Phone/preference back to the matched account's original values, discarding any edits the operator had just made. The autofill now only applies once per distinct matched account instead of on every re-check.
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Self-service kiosk: closed events no longer appear in the event picker — only open events are selectable.
|
||||||
|
- Self-service kiosk: added a "Look up existing account" search field (by email or phone, triggered only by Enter or the Search button — never as-you-type) that autofills a returning visitor's name, contact details, and notification preference from their exact matching account, instead of requiring staff to re-enter details already on file.
|
||||||
|
- Self-service kiosk: when the account lookup finds no match, the typed query now carries over into whichever of Email/Phone it resembles (instead of being discarded), while Name and everything else resets blank for a fresh entry.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-07-27
|
||||||
|
|
||||||
|
### 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
|
## [1.1.0] - 2026-07-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -38,7 +93,10 @@ 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).
|
- 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.1.0...main
|
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.1...main
|
||||||
|
[1.3.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...v1.3.1
|
||||||
|
[1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0
|
||||||
|
[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.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.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.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "event-management-backend",
|
"name": "event-management-backend",
|
||||||
"version": "1.1.0",
|
"version": "1.3.2",
|
||||||
"description": "Event Management System Backend",
|
"description": "Event Management System Backend",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -241,10 +241,12 @@ const getEventsAll = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const includePast = req.query.includePast === 'true';
|
const includePast = req.query.includePast === 'true';
|
||||||
const includeInactive = req.query.includeInactive === 'true';
|
const includeInactive = req.query.includeInactive === 'true';
|
||||||
|
const excludeClosed = req.query.excludeClosed === 'true';
|
||||||
|
|
||||||
const where = {};
|
const where = {};
|
||||||
if (!includeInactive) where.isActive = true;
|
if (!includeInactive) where.isActive = true;
|
||||||
if (!includePast) where.endDate = { gte: new Date() };
|
if (!includePast) where.endDate = { gte: new Date() };
|
||||||
|
if (excludeClosed) where.cashupStatus = { not: 'closed' };
|
||||||
|
|
||||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||||
|
|||||||
@@ -764,32 +764,66 @@ const createManualRegistration = async (req, res) => {
|
|||||||
? prefFromBody
|
? prefFromBody
|
||||||
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
|
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
|
||||||
|
|
||||||
// Always search by email AND/OR phone regardless of guestOnly
|
// Always search by email AND/OR phone regardless of guestOnly.
|
||||||
// Also try the alternate format (27xxx ↔ 0xxx) so both representations match
|
// Resolve each channel independently (rather than a single findFirst with an OR
|
||||||
|
// across both) so that an email belonging to one account and a phone number
|
||||||
|
// belonging to a *different* account can never be silently collapsed into
|
||||||
|
// whichever record happens to match first — that would let a registration
|
||||||
|
// hijack or corrupt someone else's account. Also try the alternate phone
|
||||||
|
// format (27xxx ↔ 0xxx) so both representations match.
|
||||||
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
|
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
|
||||||
const searchClauses = [
|
const emailUser = hasValidEmail
|
||||||
...(hasValidEmail ? [{ email: user.email }] : []),
|
? await prisma.user.findUnique({ where: { email: user.email } })
|
||||||
...(phone ? [{ phoneNumber: phone }] : []),
|
|
||||||
...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []),
|
|
||||||
];
|
|
||||||
const existingUser = searchClauses.length > 0
|
|
||||||
? await prisma.user.findFirst({ where: { OR: searchClauses } })
|
|
||||||
: null;
|
: null;
|
||||||
|
const phoneUser = phone
|
||||||
|
? await prisma.user.findFirst({ where: { OR: [{ phoneNumber: phone }, ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : [])] } })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (emailUser && phoneUser && emailUser.id !== phoneUser.id) {
|
||||||
|
res.status(409);
|
||||||
|
throw new Error(
|
||||||
|
`This email and phone number belong to two different existing accounts (${emailUser.name} vs ${phoneUser.name}). Please verify the visitor's details before registering.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = emailUser || phoneUser || null;
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
userId = existingUser.id;
|
userId = existingUser.id;
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
// Update preference only when the caller explicitly specified one
|
// The kiosk shows the operator a diff of name/email/phone against the matched
|
||||||
if (prefFromBody && validPrefs.includes(prefFromBody)) {
|
// account and requires explicit confirmation before submitting, so any
|
||||||
updateData.notificationPreference = prefFromBody;
|
// difference reaching this point is an already-confirmed correction — apply
|
||||||
|
// it as a full overwrite rather than only filling in blanks.
|
||||||
|
if (user.name && user.name.trim() && user.name.trim() !== existingUser.name) {
|
||||||
|
updateData.name = user.name.trim();
|
||||||
}
|
}
|
||||||
// Fill in a missing contact channel with the newly supplied value, without overwriting an existing one
|
if (phone && phone !== existingUser.phoneNumber) {
|
||||||
if (phone && !existingUser.phoneNumber) {
|
|
||||||
updateData.phoneNumber = phone;
|
updateData.phoneNumber = phone;
|
||||||
}
|
}
|
||||||
if (hasValidEmail && existingUser.email !== user.email && existingUser.email.endsWith('@guest.local')) {
|
if (hasValidEmail && existingUser.email !== user.email) {
|
||||||
updateData.email = user.email;
|
updateData.email = user.email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update preference only when the caller explicitly specified one, but validate it
|
||||||
|
// against the contact info that will actually be on the account after this update —
|
||||||
|
// a stale "whatsapp"/"both" preference must not survive a phone number being
|
||||||
|
// removed, nor "email" survive an email being cleared in favor of a real phone.
|
||||||
|
if (prefFromBody && validPrefs.includes(prefFromBody)) {
|
||||||
|
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||||
|
const finalPhone = updateData.phoneNumber !== undefined ? updateData.phoneNumber : existingUser.phoneNumber;
|
||||||
|
const finalEmail = updateData.email !== undefined ? updateData.email : existingUser.email;
|
||||||
|
const finalEmailValid = !!(finalEmail && !finalEmail.endsWith('@guest.local'));
|
||||||
|
let candidatePref = prefFromBody;
|
||||||
|
if ((candidatePref === 'whatsapp' || candidatePref === 'both') && !isValidZAPhone(finalPhone)) {
|
||||||
|
candidatePref = finalEmailValid ? 'email' : candidatePref;
|
||||||
|
}
|
||||||
|
if (candidatePref === 'email' && !finalEmailValid && isValidZAPhone(finalPhone)) {
|
||||||
|
candidatePref = 'whatsapp';
|
||||||
|
}
|
||||||
|
updateData.notificationPreference = candidatePref;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updateData).length > 0) {
|
if (Object.keys(updateData).length > 0) {
|
||||||
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
|
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ const scanTicket = async (req, res) => {
|
|||||||
e.title AS "eventTitle",
|
e.title AS "eventTitle",
|
||||||
eo.id AS "eventOptionId",
|
eo.id AS "eventOptionId",
|
||||||
eo.name AS "eventOptionName",
|
eo.name AS "eventOptionName",
|
||||||
|
eo."isMainTicket" AS "isMainTicket",
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
@@ -352,7 +353,7 @@ const scanTicket = async (req, res) => {
|
|||||||
isUsed: r.isUsed,
|
isUsed: r.isUsed,
|
||||||
eventId: r.eventId,
|
eventId: r.eventId,
|
||||||
event: { title: r.eventTitle },
|
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 || []
|
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({
|
res.json({
|
||||||
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
||||||
ticketUsage,
|
ticketUsage,
|
||||||
|
|||||||
@@ -482,13 +482,28 @@ const checkUserExists = async (req, res) => {
|
|||||||
|
|
||||||
const existingUser = await prisma.user.findFirst({
|
const existingUser = await prisma.user.findFirst({
|
||||||
where: { OR: searchClauses },
|
where: { OR: searchClauses },
|
||||||
select: { email: true, phoneNumber: true },
|
select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
|
||||||
|
const hasPhone = !!existingUser?.phoneNumber;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
exists: !!existingUser,
|
exists: !!existingUser,
|
||||||
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'),
|
hasEmail,
|
||||||
hasPhone: !!existingUser?.phoneNumber,
|
hasPhone,
|
||||||
|
// Safe-to-display fields only, for autofilling a lookup form — never the password.
|
||||||
|
// Guest placeholder emails are withheld the same way hasEmail already treats them.
|
||||||
|
// `id` lets the kiosk tell two different matched accounts apart (e.g. when the
|
||||||
|
// typed email and phone number resolve to different people) — it's never shown,
|
||||||
|
// only compared client-side, and this endpoint is already Private/Supervisor.
|
||||||
|
user: existingUser ? {
|
||||||
|
id: existingUser.id,
|
||||||
|
name: existingUser.name,
|
||||||
|
email: hasEmail ? existingUser.email : null,
|
||||||
|
phoneNumber: hasPhone ? existingUser.phoneNumber : null,
|
||||||
|
notificationPreference: existingUser.notificationPreference,
|
||||||
|
} : null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||||
|
|||||||
@@ -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) {
|
async function loadPaymentFull(paymentId) {
|
||||||
return prisma.payment.findUnique({
|
return prisma.payment.findUnique({
|
||||||
where: { id: paymentId },
|
where: { id: paymentId },
|
||||||
@@ -488,6 +499,33 @@ function buildPaymentReceipt(payment) {
|
|||||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
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 ────────────────────────────────────────
|
// ─── Donation applied to a registration ────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
|
// 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
|
// 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
|
// notified (anonymously, per design) — the donor already received their donation-received
|
||||||
// notification when the donation was originally made, so they are deliberately not emailed
|
// notification when the donation was originally made, so they are deliberately not emailed
|
||||||
@@ -981,4 +1045,6 @@ module.exports = {
|
|||||||
sendRefundEmail,
|
sendRefundEmail,
|
||||||
sendSelfServiceRegistrationEmails,
|
sendSelfServiceRegistrationEmails,
|
||||||
sendDonationAssignmentEmails,
|
sendDonationAssignmentEmails,
|
||||||
|
sendCheckInEmails,
|
||||||
|
buildCheckInConfirmation,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,6 +107,26 @@ function buildWAPayment(payment) {
|
|||||||
].join('\n');
|
].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 ───────────────────────────────────────────────────────
|
// ─── Login notification ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildWALogin({ name, when, location, userAgent }) {
|
function buildWALogin({ name, when, location, userAgent }) {
|
||||||
@@ -255,4 +275,5 @@ module.exports = {
|
|||||||
buildWAWelcome,
|
buildWAWelcome,
|
||||||
buildWAAccountClosed,
|
buildWAAccountClosed,
|
||||||
buildWATicketCaption,
|
buildWATicketCaption,
|
||||||
|
buildWACheckIn,
|
||||||
};
|
};
|
||||||
+1
-2
@@ -164,7 +164,6 @@ frontend/src/
|
|||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `/dashboard/user` | My registrations + tickets overview |
|
| `/dashboard/user` | My registrations + tickets overview |
|
||||||
| `/dashboard/user/profile` | Edit profile, notification preferences |
|
| `/dashboard/user/profile` | Edit profile, notification preferences |
|
||||||
| `/dashboard/user/pay` | Pay outstanding balance |
|
|
||||||
| `/dashboard/user/donate` | Make a donation |
|
| `/dashboard/user/donate` | Make a donation |
|
||||||
| `/dashboard/user/forms` | Complete registration forms |
|
| `/dashboard/user/forms` | Complete registration forms |
|
||||||
| `/dashboard/user/reset-password` | Change password (authenticated) |
|
| `/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
|
5. Backend reconciles payment, updates registration status, generates and emails tickets
|
||||||
6. User sees `/payment/success` and tickets appear in their dashboard
|
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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.1.0",
|
"version": "1.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import { apiFetch } from "@/lib/api";
|
|||||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
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 ─────────────────────────────────────────────────────
|
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -159,8 +167,7 @@ export default function AtTheDoorPage() {
|
|||||||
const [error, setError] = useDismissingState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
const handleRegistrationCreated = (registration: any) => {
|
const handleRegistrationCreated = (registration: any) => {
|
||||||
setActiveRegistration(registration);
|
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
|
||||||
setMode("payment"); // 🚀 Jump automatically
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegistrationSelected = (registration: any) => {
|
const handleRegistrationSelected = (registration: any) => {
|
||||||
@@ -171,7 +178,9 @@ export default function AtTheDoorPage() {
|
|||||||
if (!activeRegistration) return;
|
if (!activeRegistration) return;
|
||||||
|
|
||||||
const id = setTimeout(() => {
|
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);
|
}, 0);
|
||||||
|
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
@@ -317,28 +326,12 @@ export default function AtTheDoorPage() {
|
|||||||
|
|
||||||
const reg = registration || activeRegistration;
|
const reg = registration || activeRegistration;
|
||||||
|
|
||||||
try {
|
// Tickets are generated and emailed/WhatsApped automatically server-side
|
||||||
const res = await apiFetch<any>("/api/tickets/generate", {
|
// once a registration reaches "paid" (see paymentController) — no need to
|
||||||
method: "POST",
|
// fetch/print them here, just move straight to checking the attendee in.
|
||||||
authToken: token,
|
setActiveRegistration(reg);
|
||||||
body: {
|
setInfo("Payment recorded — tickets sent. Ready to check in.");
|
||||||
registrationId: reg.id
|
setMode("checkin");
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDonation = (user?: any) => {
|
const handleDonation = (user?: any) => {
|
||||||
@@ -346,97 +339,6 @@ export default function AtTheDoorPage() {
|
|||||||
setShowDonationModal(true);
|
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 (
|
return (
|
||||||
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
||||||
|
|
||||||
@@ -482,7 +384,7 @@ export default function AtTheDoorPage() {
|
|||||||
|
|
||||||
{/* Mode Buttons */}
|
{/* Mode Buttons */}
|
||||||
<div className="flex gap-2 mb-4 flex-wrap">
|
<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
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
onClick={() => setMode(m)}
|
onClick={() => setMode(m)}
|
||||||
@@ -494,7 +396,7 @@ export default function AtTheDoorPage() {
|
|||||||
: "bg-white hover:bg-gray-50"
|
: "bg-white hover:bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{m.toUpperCase()}
|
{MODE_LABELS[m]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -522,6 +424,10 @@ export default function AtTheDoorPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === "checkin" && (
|
||||||
|
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === "tickets" && (
|
{mode === "tickets" && (
|
||||||
<DoorTicketsPanel token={token} eventId={eventId} />
|
<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) {
|
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ function fmtPrice(n: number) {
|
|||||||
return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ManualRegistrationPage() {
|
export default function ManualRegistrationPage() {
|
||||||
@@ -76,6 +88,23 @@ export default function ManualRegistrationPage() {
|
|||||||
const [message, setMessage] = useDismissingState<string | null>(null);
|
const [message, setMessage] = useDismissingState<string | null>(null);
|
||||||
const [error, setError] = 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
|
// Load all users for client-side fuzzy matching
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -84,6 +113,97 @@ export default function ManualRegistrationPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [token]);
|
}, [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)
|
// Fuzzy match results (top 6, score threshold 0.45)
|
||||||
const matchedUsers = useMemo(() => {
|
const matchedUsers = useMemo(() => {
|
||||||
if (userQuery.trim().length < 2) return [];
|
if (userQuery.trim().length < 2) return [];
|
||||||
@@ -193,6 +313,14 @@ export default function ManualRegistrationPage() {
|
|||||||
});
|
});
|
||||||
setMessage("Manual registration created successfully.");
|
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
|
// Reset guest/ticket fields so the next registration starts from a clean slate
|
||||||
setGuest({ name: "", email: "", phoneNumber: "" });
|
setGuest({ name: "", email: "", phoneNumber: "" });
|
||||||
setRegisterAsGuest(false);
|
setRegisterAsGuest(false);
|
||||||
@@ -220,6 +348,19 @@ export default function ManualRegistrationPage() {
|
|||||||
</div>
|
</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>}
|
{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>}
|
{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>
|
<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>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { formatDate } from "@/lib/date";
|
import { formatDate } from "@/lib/date";
|
||||||
import { formatPaymentMethod } from "@/lib/paymentMethod";
|
import { formatPaymentMethod } from "@/lib/paymentMethod";
|
||||||
@@ -87,7 +87,7 @@ export default function UserDashboardPage() {
|
|||||||
|
|
||||||
// Registration details modal
|
// Registration details modal
|
||||||
const [activeRegId, setActiveRegId] = useState<string | null>(null);
|
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
|
// Track whether the active registration's event has attendee forms
|
||||||
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
|
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
|
// Print helpers
|
||||||
const buildTicketHtmlCard = (t: any, buyerName?: string) => {
|
const buildTicketHtmlCard = (t: any, buyerName?: string) => {
|
||||||
const eventTitle = t.event?.title || t.eventId || "Event";
|
const eventTitle = t.event?.title || t.eventId || "Event";
|
||||||
@@ -1039,7 +1074,7 @@ export default function UserDashboardPage() {
|
|||||||
{canModifyActive && activeBill && activeBill.outstanding > 0 ? (
|
{canModifyActive && activeBill && activeBill.outstanding > 0 ? (
|
||||||
<button
|
<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"
|
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>
|
>Make payment</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1096,8 +1131,8 @@ export default function UserDashboardPage() {
|
|||||||
{dialog.loading ? (
|
{dialog.loading ? (
|
||||||
<div className="flex flex-col items-center">
|
<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="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>
|
<div className="text-base font-medium">{dialog.loadingTitle || "Sending tickets…"}</div>
|
||||||
<p className="text-sm text-gray-600 mt-1">Please wait while we send your tickets.</p>
|
<p className="text-sm text-gray-600 mt-1">{dialog.loadingSubtitle || "Please wait while we send your tickets."}</p>
|
||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,14 @@ import React, { Suspense } from "react";
|
|||||||
import { Navbar } from "@/components/layout/Navbar";
|
import { Navbar } from "@/components/layout/Navbar";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
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 };
|
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
|
||||||
|
|
||||||
function RegistrationSuccessContent() {
|
function RegistrationSuccessContent() {
|
||||||
const search = useSearchParams();
|
const search = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { token } = useAuth();
|
||||||
const registrationId = search.get("registrationId") || search.get("id");
|
const registrationId = search.get("registrationId") || search.get("id");
|
||||||
const fallbackTotalParam = search.get("totalDue");
|
const fallbackTotalParam = search.get("totalDue");
|
||||||
const fallbackTotalDue = React.useMemo(() => {
|
const fallbackTotalDue = React.useMemo(() => {
|
||||||
@@ -22,6 +24,7 @@ function RegistrationSuccessContent() {
|
|||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [info, setInfo] = React.useState<string | null>(null);
|
const [info, setInfo] = React.useState<string | null>(null);
|
||||||
|
const [payLoading, setPayLoading] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -31,7 +34,6 @@ function RegistrationSuccessContent() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// Try to fetch registration and event form
|
// 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;
|
const r = token ? await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
|
||||||
if (r) {
|
if (r) {
|
||||||
setReg(r);
|
setReg(r);
|
||||||
@@ -49,7 +51,7 @@ function RegistrationSuccessContent() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [registrationId]);
|
}, [registrationId, token]);
|
||||||
|
|
||||||
const totalDue = React.useMemo(() => {
|
const totalDue = React.useMemo(() => {
|
||||||
if (!reg) return 0;
|
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)
|
// attempt ticket generation if status is paid (may fail if required forms aren't completed)
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
|
|
||||||
if (token && reg?.id) {
|
if (token && reg?.id) {
|
||||||
await (await import('@/lib/api')).apiFetch('/api/tickets/generate', { method: 'POST', authToken: token, body: { registrationId: 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);
|
setTimeout(() => router.replace('/dashboard'), 600);
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
}, [reg, totalDue, router]);
|
}, [reg, totalDue, router, token]);
|
||||||
|
|
||||||
const goPay = () => {
|
const goPay = async () => {
|
||||||
if (!registrationId) return;
|
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 = () => {
|
const goDashboard = () => {
|
||||||
@@ -108,9 +131,9 @@ function RegistrationSuccessContent() {
|
|||||||
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && (
|
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && (
|
||||||
<button
|
<button
|
||||||
onClick={goPay}
|
onClick={goPay}
|
||||||
disabled={!registrationId}
|
disabled={!registrationId || payLoading}
|
||||||
className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60"
|
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
|
<button
|
||||||
onClick={goDashboard}
|
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; }) {
|
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 registrationId = reg?.id;
|
||||||
const mainTickets = (reg?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
|
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);
|
const count = Math.max(0, mainTickets);
|
||||||
@@ -157,7 +181,6 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null); setInfo(null);
|
setError(null); setInfo(null);
|
||||||
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
|
|
||||||
if (!token) { setError('Please login to submit forms.'); return; }
|
if (!token) { setError('Please login to submit forms.'); return; }
|
||||||
const payload = [] as any[];
|
const payload = [] as any[];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -177,7 +200,6 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
|
|||||||
const saveDraft = async () => {
|
const saveDraft = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null); setInfo(null);
|
setError(null); setInfo(null);
|
||||||
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
|
|
||||||
if (!token) { setError('Please login to save drafts.'); return; }
|
if (!token) { setError('Please login to save drafts.'); return; }
|
||||||
await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
|
await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -85,6 +85,43 @@ function fmtCurrency(val: number) {
|
|||||||
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
|
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prefLabel(pref: "email" | "whatsapp" | "both") {
|
||||||
|
return pref === "whatsapp" ? "WhatsApp" : pref === "both" ? "Email & WhatsApp" : "Email";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loose "does this look like a mobile number" check — covers 0821234567 (10, leading 0),
|
||||||
|
// 821234567 (9, no leading 0), and 27821234567 / +27821234567 (11 digits, country code).
|
||||||
|
function looksLikePhone(s: string): boolean {
|
||||||
|
const digits = s.replace(/\D/g, "");
|
||||||
|
return digits.length >= 9 && digits.length <= 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide toggle rendered inside a password input — absolutely positioned on its
|
||||||
|
// right edge, so callers must wrap the input in a `relative` container and add
|
||||||
|
// enough right padding (`pr-12`) for it not to overlap the typed text.
|
||||||
|
function PasswordToggleButton({ shown, onToggle }: { shown: boolean; onToggle: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center px-4 text-gray-400 hover:text-gray-600"
|
||||||
|
aria-label={shown ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{shown ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c1.563 0 3.042-.34 4.377-.955M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
|
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
|
||||||
export default function SelfServicePage() {
|
export default function SelfServicePage() {
|
||||||
const [screen, setScreen] = useState<Screen>("setup");
|
const [screen, setScreen] = useState<Screen>("setup");
|
||||||
@@ -92,6 +129,7 @@ export default function SelfServicePage() {
|
|||||||
// ── Setup state ─────────────────────────────────────────────────
|
// ── Setup state ─────────────────────────────────────────────────
|
||||||
const [setupEmail, setSetupEmail] = useState("");
|
const [setupEmail, setSetupEmail] = useState("");
|
||||||
const [setupPassword, setSetupPassword] = useState("");
|
const [setupPassword, setSetupPassword] = useState("");
|
||||||
|
const [showSetupPassword, setShowSetupPassword] = useState(false);
|
||||||
const [setupLoading, setSetupLoading] = useState(false);
|
const [setupLoading, setSetupLoading] = useState(false);
|
||||||
const [setupError, setSetupError] = useState<string | null>(null);
|
const [setupError, setSetupError] = useState<string | null>(null);
|
||||||
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
|
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
|
||||||
@@ -102,6 +140,7 @@ export default function SelfServicePage() {
|
|||||||
// ── Change-event modal ───────────────────────────────────────────
|
// ── Change-event modal ───────────────────────────────────────────
|
||||||
const [showChangeModal, setShowChangeModal] = useState(false);
|
const [showChangeModal, setShowChangeModal] = useState(false);
|
||||||
const [changePassword, setChangePassword] = useState("");
|
const [changePassword, setChangePassword] = useState("");
|
||||||
|
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||||
const [changeError, setChangeError] = useState<string | null>(null);
|
const [changeError, setChangeError] = useState<string | null>(null);
|
||||||
const [changeLoading, setChangeLoading] = useState(false);
|
const [changeLoading, setChangeLoading] = useState(false);
|
||||||
|
|
||||||
@@ -112,9 +151,23 @@ export default function SelfServicePage() {
|
|||||||
const [notificationPref, setNotificationPref] = useState<"email" | "whatsapp" | "both">("email");
|
const [notificationPref, setNotificationPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||||
const [createAccount, setCreateAccount] = useState(false);
|
const [createAccount, setCreateAccount] = useState(false);
|
||||||
const [visitorPassword, setVisitorPassword] = useState("");
|
const [visitorPassword, setVisitorPassword] = useState("");
|
||||||
const [accountExists, setAccountExists] = useState(false);
|
const [showVisitorPassword, setShowVisitorPassword] = useState(false);
|
||||||
const [checkingAccount, setCheckingAccount] = useState(false);
|
|
||||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
// ── Account lookup (automatic, triggered when email/phone is entered) ──
|
||||||
|
// Email and phone are resolved to an existing account independently. If they
|
||||||
|
// resolve to the SAME account, that account is "matched". If they resolve to
|
||||||
|
// two DIFFERENT accounts, that's a conflict — surfaced to the operator instead
|
||||||
|
// of silently overwriting one field's details with the other's account.
|
||||||
|
type MatchedAccount = { id: string; name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" };
|
||||||
|
const [emailMatch, setEmailMatch] = useState<MatchedAccount | null>(null);
|
||||||
|
const [phoneMatch, setPhoneMatch] = useState<MatchedAccount | null>(null);
|
||||||
|
const accountConflict = !!(emailMatch && phoneMatch && emailMatch.id !== phoneMatch.id);
|
||||||
|
const matchedAccount = accountConflict ? null : (emailMatch || phoneMatch);
|
||||||
|
const accountExists = !!matchedAccount;
|
||||||
|
const [lookupLoading, setLookupLoading] = useState(false);
|
||||||
|
const [lookupMessage, setLookupMessage] = useState<string | null>(null);
|
||||||
|
const [showUpdateConfirm, setShowUpdateConfirm] = useState(false);
|
||||||
const [formLoading, setFormLoading] = useState(false);
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -175,12 +228,39 @@ export default function SelfServicePage() {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, [selectedEvent, quantities]);
|
}, [selectedEvent, quantities]);
|
||||||
|
|
||||||
|
// Differences between what's on file for the matched account and what's currently
|
||||||
|
// typed in the form — shown to the operator for confirmation before anything is saved.
|
||||||
|
const pendingChanges = useMemo(() => {
|
||||||
|
if (!matchedAccount) return [];
|
||||||
|
const changes: { field: string; from: string; to: string }[] = [];
|
||||||
|
const name = visitorName.trim();
|
||||||
|
const email = visitorEmail.trim();
|
||||||
|
const phone = visitorPhone.trim();
|
||||||
|
if (name && name !== matchedAccount.name) {
|
||||||
|
changes.push({ field: "Name", from: matchedAccount.name, to: name });
|
||||||
|
}
|
||||||
|
if (email && email !== (matchedAccount.email || "")) {
|
||||||
|
changes.push({ field: "Email", from: matchedAccount.email || "(none on file)", to: email });
|
||||||
|
}
|
||||||
|
if (phone && phone !== (matchedAccount.phoneNumber || "")) {
|
||||||
|
changes.push({ field: "Phone", from: matchedAccount.phoneNumber || "(none on file)", to: phone });
|
||||||
|
}
|
||||||
|
if (notificationPref !== matchedAccount.notificationPreference) {
|
||||||
|
changes.push({
|
||||||
|
field: "Send tickets via",
|
||||||
|
from: prefLabel(matchedAccount.notificationPreference),
|
||||||
|
to: prefLabel(notificationPref),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}, [matchedAccount, visitorName, visitorEmail, visitorPhone, notificationPref]);
|
||||||
|
|
||||||
// ─── Load events after supervisor login ─────────────────────────
|
// ─── Load events after supervisor login ─────────────────────────
|
||||||
const loadEvents = useCallback(async (token: string) => {
|
const loadEvents = useCallback(async (token: string) => {
|
||||||
setEventsLoading(true);
|
setEventsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data: KioskEvent[] = await apiFetch(
|
const data: KioskEvent[] = await apiFetch(
|
||||||
"/api/events/all?includePast=false&includeInactive=false",
|
"/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
|
||||||
{ authToken: token }
|
{ authToken: token }
|
||||||
);
|
);
|
||||||
setEvents(data);
|
setEvents(data);
|
||||||
@@ -262,7 +342,9 @@ export default function SelfServicePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Visitor registration ────────────────────────────────────────
|
// ─── Visitor registration ────────────────────────────────────────
|
||||||
async function handleRegister(e: React.FormEvent) {
|
// Validates the form and, if the typed name/email/phone differ from the matched
|
||||||
|
// account's details, shows a confirmation modal before anything is saved.
|
||||||
|
function handleRegisterSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
if (!visitorName.trim() || (!visitorEmail.trim() && !visitorPhone.trim())) {
|
if (!visitorName.trim() || (!visitorEmail.trim() && !visitorPhone.trim())) {
|
||||||
@@ -277,7 +359,18 @@ export default function SelfServicePage() {
|
|||||||
setFormError("No event selected.");
|
setFormError("No event selected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (accountConflict) {
|
||||||
|
setFormError("The email and phone number entered belong to two different existing accounts. Please check and correct one of them before continuing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingChanges.length > 0) {
|
||||||
|
setShowUpdateConfirm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleRegister();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
setFormLoading(true);
|
setFormLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
@@ -415,7 +508,12 @@ export default function SelfServicePage() {
|
|||||||
setNotificationPref("email");
|
setNotificationPref("email");
|
||||||
setCreateAccount(false);
|
setCreateAccount(false);
|
||||||
setVisitorPassword("");
|
setVisitorPassword("");
|
||||||
setAccountExists(false);
|
setShowVisitorPassword(false);
|
||||||
|
setEmailMatch(null);
|
||||||
|
setPhoneMatch(null);
|
||||||
|
appliedMatchIdRef.current = null;
|
||||||
|
setShowUpdateConfirm(false);
|
||||||
|
setLookupMessage(null);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setCurrentRegistrationId(null);
|
setCurrentRegistrationId(null);
|
||||||
setCurrentUserId(null);
|
setCurrentUserId(null);
|
||||||
@@ -431,33 +529,67 @@ export default function SelfServicePage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─── Check whether an account already exists for the entered email/phone ──
|
// ─── Automatic account lookup — runs once the operator finishes entering the
|
||||||
useEffect(() => {
|
// email or phone field (on blur), never on keystroke. Matches exactly against
|
||||||
const email = visitorEmail.trim();
|
// that one value and only ever returns that one matched account (or nothing) —
|
||||||
const phone = visitorPhone.trim();
|
// never a broader/fuzzy match. Email and phone are tracked as two independent
|
||||||
if (!supervisorToken || (!email && !phone)) {
|
// matches (emailMatch/phoneMatch, above) rather than being merged into a single
|
||||||
setAccountExists(false);
|
// "last found account" — that's what previously let changing the phone number
|
||||||
return;
|
// silently pull in and overwrite the form with an unrelated account's details. ──
|
||||||
}
|
async function performLookup(kind: "email" | "phone", rawValue: string) {
|
||||||
const handle = setTimeout(async () => {
|
const value = rawValue.trim();
|
||||||
setCheckingAccount(true);
|
const setMatch = kind === "email" ? setEmailMatch : setPhoneMatch;
|
||||||
|
if (!value || !supervisorToken) { setMatch(null); return; }
|
||||||
|
if (kind === "email" ? !value.includes("@") : !looksLikePhone(value)) { setMatch(null); return; }
|
||||||
|
|
||||||
|
setLookupLoading(true);
|
||||||
|
setLookupMessage(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (email) params.set("email", email);
|
if (kind === "email") params.set("email", value);
|
||||||
if (phone) params.set("phone", phone);
|
else params.set("phone", value);
|
||||||
const data = await apiFetch<{ exists: boolean }>(
|
const data = await apiFetch<{
|
||||||
`/api/users/check-exists?${params.toString()}`,
|
exists: boolean;
|
||||||
{ authToken: supervisorToken }
|
user?: MatchedAccount | null;
|
||||||
);
|
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
|
||||||
setAccountExists(!!data?.exists);
|
if (data?.exists && data.user) {
|
||||||
} catch {
|
setMatch(data.user);
|
||||||
setAccountExists(false);
|
setLookupMessage("Account found — details filled in below.");
|
||||||
} finally {
|
} else {
|
||||||
setCheckingAccount(false);
|
setMatch(null);
|
||||||
|
setLookupMessage(null);
|
||||||
}
|
}
|
||||||
}, 400);
|
} catch {
|
||||||
return () => clearTimeout(handle);
|
setLookupMessage("Lookup failed. Please try again.");
|
||||||
}, [visitorEmail, visitorPhone, supervisorToken]);
|
} finally {
|
||||||
|
setLookupLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmailBlur() {
|
||||||
|
performLookup("email", visitorEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePhoneBlur() {
|
||||||
|
performLookup("phone", visitorPhone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autofill Name/Email/Phone/preference from the matched account — but only once per
|
||||||
|
// distinct account id. Re-focusing elsewhere on the page (e.g. tapping a ticket
|
||||||
|
// quantity button) blurs whatever field was last active and re-runs its lookup;
|
||||||
|
// that returns the same account as a new object each time, and keying this off
|
||||||
|
// object identity instead of id made it re-fire and stomp every field — including
|
||||||
|
// ones the operator had already deliberately edited — back to the account's
|
||||||
|
// original values on every unrelated tap.
|
||||||
|
const appliedMatchIdRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!matchedAccount || appliedMatchIdRef.current === matchedAccount.id) return;
|
||||||
|
appliedMatchIdRef.current = matchedAccount.id;
|
||||||
|
setVisitorName(matchedAccount.name);
|
||||||
|
if (matchedAccount.email) setVisitorEmail(matchedAccount.email);
|
||||||
|
if (matchedAccount.phoneNumber) setVisitorPhone(matchedAccount.phoneNumber);
|
||||||
|
setNotificationPref(matchedAccount.notificationPreference);
|
||||||
|
}, [matchedAccount]);
|
||||||
|
|
||||||
// Existing accounts are linked automatically — never show the "create account" toggle for them
|
// Existing accounts are linked automatically — never show the "create account" toggle for them
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -494,15 +626,18 @@ export default function SelfServicePage() {
|
|||||||
<form onSubmit={handleChangeEvent} className="space-y-4">
|
<form onSubmit={handleChangeEvent} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showChangePassword ? "text" : "password"}
|
||||||
value={changePassword}
|
value={changePassword}
|
||||||
onChange={(e) => setChangePassword(e.target.value)}
|
onChange={(e) => setChangePassword(e.target.value)}
|
||||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="Your password"
|
placeholder="Your password"
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<PasswordToggleButton shown={showChangePassword} onToggle={() => setShowChangePassword((v) => !v)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{changeError && <p className="text-red-600 text-sm">{changeError}</p>}
|
{changeError && <p className="text-red-600 text-sm">{changeError}</p>}
|
||||||
<div className="flex gap-3 pt-1">
|
<div className="flex gap-3 pt-1">
|
||||||
@@ -551,15 +686,18 @@ export default function SelfServicePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showSetupPassword ? "text" : "password"}
|
||||||
value={setupPassword}
|
value={setupPassword}
|
||||||
onChange={(e) => setSetupPassword(e.target.value)}
|
onChange={(e) => setSetupPassword(e.target.value)}
|
||||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
|
<PasswordToggleButton shown={showSetupPassword} onToggle={() => setShowSetupPassword((v) => !v)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
|
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
|
||||||
<button
|
<button
|
||||||
@@ -628,18 +766,7 @@ export default function SelfServicePage() {
|
|||||||
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
|
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleRegister} className="space-y-5">
|
<form onSubmit={handleRegisterSubmit} className="space-y-5">
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={visitorName}
|
|
||||||
onChange={(e) => setVisitorName(e.target.value)}
|
|
||||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="John Smith"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Email Address
|
Email Address
|
||||||
@@ -656,6 +783,7 @@ export default function SelfServicePage() {
|
|||||||
if (v.trim() && !visitorPhone.trim()) setNotificationPref("email");
|
if (v.trim() && !visitorPhone.trim()) setNotificationPref("email");
|
||||||
else if (!v.trim() && visitorPhone.trim()) setNotificationPref("whatsapp");
|
else if (!v.trim() && visitorPhone.trim()) setNotificationPref("whatsapp");
|
||||||
}}
|
}}
|
||||||
|
onBlur={handleEmailBlur}
|
||||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="john@example.com"
|
placeholder="john@example.com"
|
||||||
/>
|
/>
|
||||||
@@ -675,11 +803,40 @@ export default function SelfServicePage() {
|
|||||||
if (v.trim() && !visitorEmail.trim()) setNotificationPref("whatsapp");
|
if (v.trim() && !visitorEmail.trim()) setNotificationPref("whatsapp");
|
||||||
else if (!v.trim() && visitorEmail.trim()) setNotificationPref("email");
|
else if (!v.trim() && visitorEmail.trim()) setNotificationPref("email");
|
||||||
}}
|
}}
|
||||||
|
onBlur={handlePhoneBlur}
|
||||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="+27 82 000 0000"
|
placeholder="+27 82 000 0000"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{lookupLoading && (
|
||||||
|
<p className="text-sm text-gray-500 -mt-2">Checking for an existing account…</p>
|
||||||
|
)}
|
||||||
|
{!lookupLoading && accountConflict && emailMatch && phoneMatch && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm -mt-2">
|
||||||
|
This email matches an existing account for <strong>{emailMatch.name}</strong>, but this phone number
|
||||||
|
matches a different existing account for <strong>{phoneMatch.name}</strong>. Please check and correct
|
||||||
|
one of these fields before continuing.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!lookupLoading && !accountConflict && lookupMessage && (
|
||||||
|
<p className={`text-sm -mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
|
||||||
|
{lookupMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={visitorName}
|
||||||
|
onChange={(e) => setVisitorName(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="John Smith"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Preference selector — only shown when both channels are available */}
|
{/* Preference selector — only shown when both channels are available */}
|
||||||
{visitorEmail.trim() && visitorPhone.trim() && (
|
{visitorEmail.trim() && visitorPhone.trim() && (
|
||||||
<div>
|
<div>
|
||||||
@@ -808,22 +965,23 @@ export default function SelfServicePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-800">Create an account</p>
|
<p className="font-medium text-gray-800">Create an account</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">Save your details for future events</p>
|
||||||
{checkingAccount ? "Checking for an existing account…" : "Save your details for future events"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
{createAccount && (
|
{createAccount && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showVisitorPassword ? "text" : "password"}
|
||||||
value={visitorPassword}
|
value={visitorPassword}
|
||||||
onChange={(e) => setVisitorPassword(e.target.value)}
|
onChange={(e) => setVisitorPassword(e.target.value)}
|
||||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="Min. 6 characters"
|
placeholder="Min. 6 characters"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
|
<PasswordToggleButton shown={showVisitorPassword} onToggle={() => setShowVisitorPassword((v) => !v)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -837,7 +995,7 @@ export default function SelfServicePage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={formLoading}
|
disabled={formLoading || accountConflict}
|
||||||
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
|
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
|
||||||
>
|
>
|
||||||
{formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"}
|
{formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"}
|
||||||
@@ -847,6 +1005,43 @@ export default function SelfServicePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Confirm Account Changes Modal ──────────────────────────── */}
|
||||||
|
{showUpdateConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-800 mb-1">Confirm account changes</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-5">
|
||||||
|
These details differ from what's on file for this account. Confirm to update them.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
{pendingChanges.map((c) => (
|
||||||
|
<div key={c.field} className="border border-gray-200 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{c.field}</p>
|
||||||
|
<p className="text-sm text-gray-400 line-through">{c.from}</p>
|
||||||
|
<p className="text-sm text-green-700 font-medium">{c.to}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUpdateConfirm(false)}
|
||||||
|
className="flex-1 border border-gray-300 text-gray-700 rounded-lg py-3 text-base font-medium hover:bg-gray-50 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowUpdateConfirm(false); handleRegister(); }}
|
||||||
|
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 transition"
|
||||||
|
>
|
||||||
|
Confirm & Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Event Form Screen ──────────────────────────────────────── */}
|
{/* ── Event Form Screen ──────────────────────────────────────── */}
|
||||||
{screen === "eventform" && selectedEvent?.form && (
|
{screen === "eventform" && selectedEvent?.form && (
|
||||||
<div className="flex-1 flex items-center justify-center p-6">
|
<div className="flex-1 flex items-center justify-center p-6">
|
||||||
|
|||||||
@@ -155,3 +155,19 @@ export function fetchAllUsers(token: string, params: Record<string, string> = {}
|
|||||||
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> {
|
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> {
|
||||||
return fetchAllPages("/api/payments", token, params);
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.1.0",
|
"version": "1.3.2",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && npm run dev",
|
"dev:backend": "cd backend && npm run dev",
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"start:backend": "cd backend && npm run start",
|
"start:backend": "cd backend && npm run start",
|
||||||
"start:frontend": "cd frontend && npm run start",
|
"start:frontend": "cd frontend && npm run start",
|
||||||
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
|
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
|
||||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\""
|
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||||
|
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user