Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a75c9155b | ||
|
|
8850984055 | ||
|
|
21176e1e0b | ||
|
|
b4cd140168 | ||
|
|
5167706d1b | ||
|
|
047f61b627 | ||
|
|
1815f78c85 | ||
|
|
29036d0612 | ||
|
|
c07e9c928a | ||
|
|
adb512a7d9 | ||
|
|
ad9ead807d | ||
|
|
36b61d968c | ||
|
|
c710da4dff | ||
|
|
f3e6525467 | ||
|
|
193739c042 | ||
|
|
12e5dfc643 | ||
|
|
325ab87729 | ||
|
|
7f074e43a6 | ||
|
|
59194349d2 | ||
|
|
90bea25338 | ||
|
|
37681aec50 |
+58
-1
@@ -7,6 +7,59 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- User dashboard: new "Payment history" page listing the user's own payments (donations excluded), with server-side pagination (25 per page), date range, method, and payment/refund filters.
|
||||||
|
- User dashboard: registration status (Pending/Confirmed/Partially Paid/Paid/Cancelled) is now shown as a colored badge, matching the existing event Closed/Past/Inactive badge convention, instead of a raw status string.
|
||||||
|
- Dashboard-wide: inline success/error/confirmation messages (e.g. after creating a manual registration on `/dashboard/supervisor/manual`) now auto-dismiss after 7 seconds instead of persisting indefinitely, via a new shared `useDismissingState` hook. Applied consistently across all dashboard pages with this pattern; excluded are message-only modal dialogs (e.g. ticket-scanning's success/error confirmations, which still require a manual OK) and a couple of mixed validation/async error states shown inside actively-open forms (the registration-edit modal and the event create/edit modal), which continue to persist until the user acts.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Cashup/reports: payments tagged with a digital wallet method (e.g. `apple_pay`, `google_pay` from online checkouts) are now bucketed as "card" for reconciliation instead of silently falling into "other".
|
||||||
|
- User dashboard payment history: `GET /api/payments/mypayments` now normalizes `method` to the fixed set cash/card/eft/voucher/other. Card-network wallet payments (`apple_pay`, `google_pay`) are reported and filterable as "card"; any other gateway-reported value falls under "other" — instead of exposing raw, inconsistent gateway strings the filter dropdown didn't know about.
|
||||||
|
- User dashboard: the registration payment list (shown on the main dashboard when viewing a registration's bill) applies the same cash/card/eft/voucher/other normalization client-side, so it no longer shows a raw `apple_pay`/`google_pay` string. Staff-facing payment views (supervisor payments, reports, cashup) are unaffected — they still show the raw method, which is what reconciliation needs.
|
||||||
|
|
||||||
## [1.0.1] - 2026-07-23
|
## [1.0.1] - 2026-07-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -24,6 +77,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.0.1...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.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.0.1",
|
"version": "1.3.1",
|
||||||
"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');
|
||||||
|
|||||||
@@ -349,30 +349,89 @@ const getPayments = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// @desc Get user payments
|
// Payment.method is free-text — online checkouts get tagged with whatever wallet type the
|
||||||
|
// gateway reports (apple_pay, google_pay, ...), not just the manual-entry methods below.
|
||||||
|
// For the user-facing dashboard: card-network wallets count as "card" (same settlement, no
|
||||||
|
// separate float); anything else unrecognized falls into "other".
|
||||||
|
const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher'];
|
||||||
|
const CARD_ALIASES = ['apple_pay', 'google_pay'];
|
||||||
|
const KNOWN_METHODS = [...USER_FACING_METHODS, ...CARD_ALIASES];
|
||||||
|
|
||||||
|
function normalizeUserMethod(method) {
|
||||||
|
const m = String(method || '').toLowerCase();
|
||||||
|
if (USER_FACING_METHODS.includes(m)) return m;
|
||||||
|
if (CARD_ALIASES.includes(m)) return 'card';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
// @desc Get user payments (paginated, excludes donations, supports date range/method/kind filters)
|
||||||
// @route GET /api/payments/mypayments
|
// @route GET /api/payments/mypayments
|
||||||
// @access Private
|
// @access Private
|
||||||
const getUserPayments = async (req, res) => {
|
const getUserPayments = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Users should see payments they made (donations) and payments tied to their registrations
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||||
const payments = await prisma.payment.findMany({
|
const limit = Math.min(25, Math.max(1, parseInt(req.query.limit) || 25));
|
||||||
where: {
|
const skip = (page - 1) * limit;
|
||||||
OR: [
|
|
||||||
{ userId: req.user.id }, // payments made by the user (includes donations)
|
|
||||||
{ registration: { userId: req.user.id } } // payments tied to the user's registrations
|
|
||||||
]
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
registration: {
|
|
||||||
include: {
|
|
||||||
event: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
event: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(payments);
|
// Users should see payments they made and payments tied to their registrations,
|
||||||
|
// excluding donations.
|
||||||
|
const where = {
|
||||||
|
isDonation: false,
|
||||||
|
OR: [
|
||||||
|
{ userId: req.user.id },
|
||||||
|
{ registration: { userId: req.user.id } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.query.method) {
|
||||||
|
const requested = String(req.query.method).toLowerCase();
|
||||||
|
if (requested === 'card') {
|
||||||
|
// "Card" also covers card-network wallet types (apple_pay, google_pay) — same
|
||||||
|
// settlement as a card payment, no separate float to reconcile.
|
||||||
|
where.AND = [{
|
||||||
|
OR: ['card', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
|
||||||
|
}];
|
||||||
|
} else if (requested === 'other') {
|
||||||
|
// "Other" covers every method that isn't one of the recognized buckets above.
|
||||||
|
where.NOT = {
|
||||||
|
OR: KNOWN_METHODS.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
|
||||||
|
};
|
||||||
|
} else if (USER_FACING_METHODS.includes(requested)) {
|
||||||
|
where.method = { equals: requested, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.kind === 'refund') {
|
||||||
|
where.amount = { lt: 0 };
|
||||||
|
} else if (req.query.kind === 'payment') {
|
||||||
|
where.amount = { gte: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.startDate || req.query.endDate) {
|
||||||
|
where.createdAt = {
|
||||||
|
...(req.query.startDate && { gte: new Date(req.query.startDate) }),
|
||||||
|
...(req.query.endDate && { lte: new Date(req.query.endDate) })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [payments, total] = await prisma.$transaction([
|
||||||
|
prisma.payment.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
registration: { include: { event: true } },
|
||||||
|
event: true
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit
|
||||||
|
}),
|
||||||
|
prisma.payment.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Normalize the displayed method so the dashboard never shows a raw gateway string.
|
||||||
|
const normalizedPayments = payments.map(p => ({ ...p, method: normalizeUserMethod(p.method) }));
|
||||||
|
|
||||||
|
res.json({ data: normalizedPayments, total, page, limit, pages: Math.ceil(total / limit) });
|
||||||
} 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) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,24 @@ 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: { 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.
|
||||||
|
user: existingUser ? {
|
||||||
|
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) });
|
||||||
|
|||||||
@@ -543,8 +543,9 @@ app.get('/docs', async (req, res) => {
|
|||||||
responses:[
|
responses:[
|
||||||
{ status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }},
|
{ status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }},
|
||||||
]},
|
]},
|
||||||
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history',
|
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"',
|
||||||
responses:[{ status:200, desc:'Success', body:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }]}]},
|
queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' },
|
||||||
|
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]},
|
||||||
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
|
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
|
||||||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
|
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
|
||||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
|
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ function emptyByMethod(fill = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normalizes a free-text Payment.method into one of the fixed cashup buckets.
|
// Normalizes a free-text Payment.method into one of the fixed cashup buckets.
|
||||||
|
// Card-network wallets (Apple Pay, Google Pay, etc.) settle exactly like a card payment —
|
||||||
|
// no separate float to reconcile — so they belong in the 'card' bucket, not 'other'.
|
||||||
function bucketForMethod(method) {
|
function bucketForMethod(method) {
|
||||||
const m = String(method || '').toLowerCase();
|
const m = String(method || '').toLowerCase();
|
||||||
if (m.includes('cash')) return 'cash';
|
if (m.includes('cash')) return 'cash';
|
||||||
if (m.includes('eft')) return 'eft';
|
if (m.includes('eft')) return 'eft';
|
||||||
if (m.includes('card') || m.includes('yoco')) return 'card';
|
if (m.includes('card') || m.includes('yoco') || m.includes('pay')) return 'card';
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.0.1",
|
"version": "1.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
|
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
|
||||||
|
|
||||||
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||||
@@ -29,7 +30,7 @@ export default function EventCashupPage() {
|
|||||||
const [data, setData] = useState<EventFinancials | null>(null);
|
const [data, setData] = useState<EventFinancials | null>(null);
|
||||||
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
|
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const isClosed = data?.event?.cashupStatus === "closed";
|
const isClosed = data?.event?.cashupStatus === "closed";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
export default function CashupLandingPage() {
|
export default function CashupLandingPage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
@@ -11,7 +12,7 @@ export default function CashupLandingPage() {
|
|||||||
|
|
||||||
const [events, setEvents] = useState<any[]>([]);
|
const [events, setEvents] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [showPast, setShowPast] = useState(true);
|
const [showPast, setShowPast] = useState(true);
|
||||||
const [showInactive, setShowInactive] = useState(false);
|
const [showInactive, setShowInactive] = useState(false);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||||
|
|
||||||
@@ -29,8 +30,8 @@ export default function AdminRegistrationsPage() {
|
|||||||
const [registrations, setRegistrations] = useState<any[]>([]);
|
const [registrations, setRegistrations] = useState<any[]>([]);
|
||||||
const [events, setEvents] = useState<any[]>([]);
|
const [events, setEvents] = useState<any[]>([]);
|
||||||
const [loadingRegs, setLoadingRegs] = useState(false);
|
const [loadingRegs, setLoadingRegs] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
|
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
|
||||||
|
|
||||||
@@ -36,19 +37,17 @@ function Field({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SaveBar({
|
function SaveBar({
|
||||||
saving, onSave, result, onDismiss,
|
saving, onSave, result,
|
||||||
}: {
|
}: {
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
result: { ok: boolean; message: string } | null;
|
result: { ok: boolean; message: string } | null;
|
||||||
onDismiss: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
|
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
|
||||||
{result ? (
|
{result ? (
|
||||||
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
|
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
|
||||||
{result.ok ? "✓" : "✗"} {result.message}
|
{result.ok ? "✓" : "✗"} {result.message}
|
||||||
<button type="button" onClick={onDismiss} className="ml-1 text-gray-400 hover:text-gray-600 text-xs">×</button>
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
@@ -75,7 +74,7 @@ export default function SiteSettingsPage() {
|
|||||||
|
|
||||||
// Per-tab save state
|
// Per-tab save state
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
const [result, setResult] = useDismissingState<{ ok: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
// ── Organisation ──
|
// ── Organisation ──
|
||||||
const [orgName, setOrgName] = useState("");
|
const [orgName, setOrgName] = useState("");
|
||||||
@@ -104,7 +103,7 @@ export default function SiteSettingsPage() {
|
|||||||
const [smtpPass, setSmtpPass] = useState("");
|
const [smtpPass, setSmtpPass] = useState("");
|
||||||
const [smtpPassSet, setSmtpPassSet] = useState(false);
|
const [smtpPassSet, setSmtpPassSet] = useState(false);
|
||||||
const [smtpTesting, setSmtpTesting] = useState(false);
|
const [smtpTesting, setSmtpTesting] = useState(false);
|
||||||
const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; message: string; raw?: string } | null>(null);
|
const [smtpTestResult, setSmtpTestResult] = useDismissingState<{ ok: boolean; message: string; raw?: string } | null>(null);
|
||||||
|
|
||||||
// ── Legal ──
|
// ── Legal ──
|
||||||
const [legalOperatorName, setLegalOperatorName] = useState("");
|
const [legalOperatorName, setLegalOperatorName] = useState("");
|
||||||
@@ -332,7 +331,7 @@ export default function SiteSettingsPage() {
|
|||||||
<input className={inputCls} placeholder="https://events.yourchurch.org"
|
<input className={inputCls} placeholder="https://events.yourchurch.org"
|
||||||
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
|
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
<SaveBar saving={saving} onSave={saveOrganisation} result={result} onDismiss={() => setResult(null)} />
|
<SaveBar saving={saving} onSave={saveOrganisation} result={result} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -371,7 +370,7 @@ export default function SiteSettingsPage() {
|
|||||||
}} className="text-sm" />
|
}} className="text-sm" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<SaveBar saving={saving} onSave={saveBranding} result={result} onDismiss={() => setResult(null)} />
|
<SaveBar saving={saving} onSave={saveBranding} result={result} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -385,7 +384,7 @@ export default function SiteSettingsPage() {
|
|||||||
<input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org"
|
<input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org"
|
||||||
value={notifEmails} onChange={e => setNotifEmails(e.target.value)} />
|
value={notifEmails} onChange={e => setNotifEmails(e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
<SaveBar saving={saving} onSave={saveNotifications} result={result} onDismiss={() => setResult(null)} />
|
<SaveBar saving={saving} onSave={saveNotifications} result={result} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -464,7 +463,7 @@ export default function SiteSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SaveBar saving={saving} onSave={saveSmtp} result={result} onDismiss={() => setResult(null)} />
|
<SaveBar saving={saving} onSave={saveSmtp} result={result} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -503,7 +502,7 @@ export default function SiteSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SaveBar saving={saving} onSave={saveLegal} result={result} onDismiss={() => setResult(null)} />
|
<SaveBar saving={saving} onSave={saveLegal} result={result} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
interface UserItem {
|
interface UserItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,7 +44,7 @@ export default function AdminUsersPage() {
|
|||||||
// Data state
|
// Data state
|
||||||
const [users, setUsers] = useState<UserItem[]>([]);
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@ export default function WhatsAppAdminPage() {
|
|||||||
const [pairingPhone, setPairingPhone] = useState("");
|
const [pairingPhone, setPairingPhone] = useState("");
|
||||||
|
|
||||||
// ── Shared action feedback ───────────────────────────────────────────────────
|
// ── Shared action feedback ───────────────────────────────────────────────────
|
||||||
const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── Load config ──────────────────────────────────────────────────────────────
|
// ── Load config ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { formatDate } from "@/lib/date";
|
import { formatDate } from "@/lib/date";
|
||||||
import { QrImage } from "@/components/shared/QrImage";
|
import { QrImage } from "@/components/shared/QrImage";
|
||||||
@@ -18,7 +19,7 @@ function EventTicketsContent() {
|
|||||||
const [events, setEvents] = useState<any[]>([]);
|
const [events, setEvents] = useState<any[]>([]);
|
||||||
const [eventId, setEventId] = useState<string>(paramEventId);
|
const [eventId, setEventId] = useState<string>(paramEventId);
|
||||||
const [tickets, setTickets] = useState<any[]>([]);
|
const [tickets, setTickets] = useState<any[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId);
|
const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId);
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
|
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function TicketScanningPage() {
|
export default function TicketScanningPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const scannerRef = useRef<QRScannerHandle>(null);
|
const scannerRef = useRef<QRScannerHandle>(null);
|
||||||
const [lastResult, setLastResult] = useState<string | null>(null);
|
const [lastResult, setLastResult] = useState<string | null>(null);
|
||||||
const [scanInfo, setScanInfo] = useState<string | null>(null);
|
const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
|
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
|
||||||
const [errorModal, setErrorModal] = useState<string | null>(null);
|
const [errorModal, setErrorModal] = useState<string | null>(null);
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
type Mode = "registration" | "payment" | "tickets" | "refund";
|
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<Mode, string> = {
|
||||||
|
registration: "REGISTRATION",
|
||||||
|
payment: "PAYMENT",
|
||||||
|
checkin: "CHECK-IN",
|
||||||
|
tickets: "TICKETS",
|
||||||
|
refund: "REFUND",
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -154,34 +163,11 @@ export default function AtTheDoorPage() {
|
|||||||
const [mode, setMode] = useState<Mode>("registration");
|
const [mode, setMode] = useState<Mode>("registration");
|
||||||
|
|
||||||
const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
|
const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!info) return;
|
|
||||||
|
|
||||||
const id = setTimeout(() => {
|
|
||||||
setInfo(null);
|
|
||||||
}, 10000); // ⏱ disappears after 5s
|
|
||||||
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
|
|
||||||
}, [info]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!error) return;
|
|
||||||
|
|
||||||
const id = setTimeout(() => {
|
|
||||||
setError(null);
|
|
||||||
}, 15000); // errors linger slightly longer
|
|
||||||
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
|
|
||||||
}, [error]);
|
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -192,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);
|
||||||
@@ -338,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) => {
|
||||||
@@ -367,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">
|
||||||
|
|
||||||
@@ -503,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)}
|
||||||
@@ -515,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>
|
||||||
@@ -543,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} />
|
||||||
)}
|
)}
|
||||||
@@ -1102,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;
|
||||||
|
|
||||||
@@ -1332,7 +1359,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
|||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [localError, setLocalError] = useState("");
|
const [localError, setLocalError] = useDismissingState("");
|
||||||
const [localInfo, setLocalInfo] = useState("");
|
const [localInfo, setLocalInfo] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1369,7 +1396,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
setLocalInfo("Tickets sent successfully.");
|
setLocalInfo("Tickets sent successfully.");
|
||||||
setTimeout(() => { setLocalInfo(""); onClose(); }, 2000);
|
setTimeout(() => { setLocalInfo(""); onClose(); }, 7000);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setLocalError(e?.message || "Failed to send tickets");
|
setLocalError(e?.message || "Failed to send tickets");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1461,7 +1488,7 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
|
|||||||
const [method, setMethod] = useState("cash");
|
const [method, setMethod] = useState("cash");
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [localError, setLocalError] = useState("");
|
const [localError, setLocalError] = useDismissingState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) { setAmount(""); setReason(""); setLocalError(""); }
|
if (open) { setAmount(""); setReason(""); setLocalError(""); }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||||
|
|
||||||
@@ -171,8 +172,8 @@ function EmailAttendeesPageInner() {
|
|||||||
|
|
||||||
// Load events for selection
|
// Load events for selection
|
||||||
const [loadingEvents, setLoadingEvents] = useState(false);
|
const [loadingEvents, setLoadingEvents] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
// Tabs: attendees (current), automations (coming soon), broadcasts
|
// Tabs: attendees (current), automations (coming soon), broadcasts
|
||||||
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
|
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
|
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
|
||||||
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
|
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
|
||||||
@@ -112,8 +113,8 @@ function EventOptionsContent() {
|
|||||||
const [selectedEventId, setSelectedEventId] = useState<string>("");
|
const [selectedEventId, setSelectedEventId] = useState<string>("");
|
||||||
const [options, setOptions] = useState<any[]>([]);
|
const [options, setOptions] = useState<any[]>([]);
|
||||||
const [loadingEv, setLoadingEv] = useState(false);
|
const [loadingEv, setLoadingEv] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
const loadEvents = async () => {
|
const loadEvents = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -690,7 +691,7 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
|
|||||||
const [selected, setSelected] = useState<NotifyUser[]>(initialRecipients || []);
|
const [selected, setSelected] = useState<NotifyUser[]>(initialRecipients || []);
|
||||||
const [loading, setLoading] = useState(!hasInitial);
|
const [loading, setLoading] = useState(!hasInitial);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasInitial) return; // already have the data — skip the network round trip entirely
|
if (hasInitial) return; // already have the data — skip the network round trip entirely
|
||||||
@@ -1415,7 +1416,7 @@ export default function ManageEventsPage() {
|
|||||||
|
|
||||||
const [events, setEvents] = useState<any[]>([]);
|
const [events, setEvents] = useState<any[]>([]);
|
||||||
const [loadingEvents, setLoadingEvents] = useState(false);
|
const [loadingEvents, setLoadingEvents] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
const loadEvents = async () => {
|
const loadEvents = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
|
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
|
||||||
|
|
||||||
@@ -144,8 +145,8 @@ export default function FormsBrowserPage() {
|
|||||||
const [items, setItems] = useState<any[]>([]);
|
const [items, setItems] = useState<any[]>([]);
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
const [loadingList, setLoadingList] = useState(false);
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
const loadEvents = async () => {
|
const loadEvents = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
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 };
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export default function ManualRegistrationPage() {
|
|||||||
const [phoneNumber, setPhoneNumber] = useState("");
|
const [phoneNumber, setPhoneNumber] = useState("");
|
||||||
const [registerAsGuest, setRegisterAsGuest] = useState(false);
|
const [registerAsGuest, setRegisterAsGuest] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [createdReg, setCreatedReg] = useState<any | null>(null);
|
const [createdReg, setCreatedReg] = useState<any | null>(null);
|
||||||
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||||
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
|
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
|
||||||
@@ -110,8 +111,8 @@ export default function ManualRegistrationPage() {
|
|||||||
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
|
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||||
const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
|
const mainTickets = (registration?.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);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -38,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() {
|
||||||
@@ -72,8 +85,25 @@ export default function ManualRegistrationPage() {
|
|||||||
const searchRef = useRef<HTMLDivElement>(null);
|
const searchRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useDismissingState<string | null>(null);
|
||||||
const [error, setError] = useState<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(() => {
|
||||||
@@ -83,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 [];
|
||||||
@@ -192,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);
|
||||||
@@ -219,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>}
|
||||||
|
|
||||||
@@ -446,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
|
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||||
|
|
||||||
function RegistrationOptions({ regs, regOutstanding }: {
|
function RegistrationOptions({ regs, regOutstanding }: {
|
||||||
@@ -115,8 +116,8 @@ function PaymentsContent() {
|
|||||||
|
|
||||||
const [payments, setPayments] = useState<any[]>([]);
|
const [payments, setPayments] = useState<any[]>([]);
|
||||||
const [loadingList, setLoadingList] = useState(false);
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||||
const [registrations, setRegistrations] = useState<any[]>([]);
|
const [registrations, setRegistrations] = useState<any[]>([]);
|
||||||
const [loadingRegs, setLoadingRegs] = useState(false);
|
const [loadingRegs, setLoadingRegs] = useState(false);
|
||||||
const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({});
|
const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
// Attendee with preference info
|
// Attendee with preference info
|
||||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||||
@@ -358,8 +359,8 @@ function WhatsAppAttendeesPageInner() {
|
|||||||
if (!user) router.replace("/login");
|
if (!user) router.replace("/login");
|
||||||
}, [user, loading, router]);
|
}, [user, loading, router]);
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||||
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
|
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
|
||||||
const [loadingEvents, setLoadingEvents] = useState(false);
|
const [loadingEvents, setLoadingEvents] = useState(false);
|
||||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
export default function DonatePage() {
|
export default function DonatePage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
@@ -9,8 +10,8 @@ export default function DonatePage() {
|
|||||||
const [eventId, setEventId] = useState<string>("");
|
const [eventId, setEventId] = useState<string>("");
|
||||||
const [amount, setAmount] = useState<string>("");
|
const [amount, setAmount] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
|
|||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
// Types for form fields
|
// Types for form fields
|
||||||
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 };
|
||||||
@@ -16,8 +17,8 @@ function FormsContent() {
|
|||||||
const [registration, setRegistration] = useState<any | null>(null);
|
const [registration, setRegistration] = useState<any | null>(null);
|
||||||
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
// Local entry state for new responses
|
// Local entry state for new responses
|
||||||
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
|
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"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 { QrImage } from "@/components/shared/QrImage";
|
import { QrImage } from "@/components/shared/QrImage";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
// Helper formatters
|
// Helper formatters
|
||||||
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
|
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
|
||||||
@@ -29,6 +31,20 @@ function EventStatusBadge({ event }: { event: any }) {
|
|||||||
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${className}`}>{label}</span>;
|
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${className}`}>{label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status badge for a registration, shown wherever registration.status is displayed on this
|
||||||
|
// page. Colors match the status coloring already used on dashboard/admin/registrations.
|
||||||
|
const REGISTRATION_STATUS_STYLES: Record<string, { label: string; className: string }> = {
|
||||||
|
pending: { label: 'Pending', className: 'bg-gray-100 text-gray-600' },
|
||||||
|
confirmed: { label: 'Confirmed', className: 'bg-blue-50 text-blue-700' },
|
||||||
|
partial_paid: { label: 'Partially Paid', className: 'bg-amber-50 text-amber-700' },
|
||||||
|
paid: { label: 'Paid', className: 'bg-green-50 text-green-700' },
|
||||||
|
cancelled: { label: 'Cancelled', className: 'bg-red-50 text-red-700' },
|
||||||
|
};
|
||||||
|
function RegistrationStatusBadge({ status }: { status: string }) {
|
||||||
|
const s = REGISTRATION_STATUS_STYLES[status] || { label: status, className: 'bg-gray-100 text-gray-500' };
|
||||||
|
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${s.className}`}>{s.label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
// Effective unit price for an event option (or one of its variants), early-bird aware.
|
// Effective unit price for an event option (or one of its variants), early-bird aware.
|
||||||
// Used by the registration editor, which works off raw /api/events/:id data rather than
|
// Used by the registration editor, which works off raw /api/events/:id data rather than
|
||||||
// a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx.
|
// a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx.
|
||||||
@@ -52,8 +68,8 @@ export default function UserDashboardPage() {
|
|||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
const [registrations, setRegistrations] = useState<any[]>([]);
|
const [registrations, setRegistrations] = useState<any[]>([]);
|
||||||
const [tickets, setTickets] = useState<any[]>([]);
|
const [tickets, setTickets] = useState<any[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
@@ -71,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);
|
||||||
@@ -296,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";
|
||||||
@@ -629,6 +680,10 @@ export default function UserDashboardPage() {
|
|||||||
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||||
onClick={() => router.push("/dashboard/user/reset-password")}
|
onClick={() => router.push("/dashboard/user/reset-password")}
|
||||||
>Reset password</button>
|
>Reset password</button>
|
||||||
|
<button
|
||||||
|
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||||
|
onClick={() => router.push("/dashboard/user/payments")}
|
||||||
|
>Payment history</button>
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||||
onClick={() => router.push("/dashboard/user/donate")}
|
onClick={() => router.push("/dashboard/user/donate")}
|
||||||
@@ -677,7 +732,7 @@ export default function UserDashboardPage() {
|
|||||||
<EventStatusBadge event={r.event} />
|
<EventStatusBadge event={r.event} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600">
|
<div className="text-xs text-gray-600">
|
||||||
Status: {r.status}{isCancelled && <span className="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-700">cancelled</span>}
|
Status: <RegistrationStatusBadge status={r.status} />
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const fs = formStatuses[r.id];
|
const fs = formStatuses[r.id];
|
||||||
@@ -864,7 +919,7 @@ export default function UserDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-gray-600">Status: {activeReg.status}</div>
|
<div className="text-sm text-gray-600">Status: <RegistrationStatusBadge status={activeReg.status} /></div>
|
||||||
{activeBill && (
|
{activeBill && (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div>Total: {formatRand(activeBill.totalDue)}</div>
|
<div>Total: {formatRand(activeBill.totalDue)}</div>
|
||||||
@@ -1004,7 +1059,7 @@ export default function UserDashboardPage() {
|
|||||||
<div className="font-medium mb-1">Payments</div>
|
<div className="font-medium mb-1">Payments</div>
|
||||||
<ul className="text-sm list-disc pl-5 space-y-1">
|
<ul className="text-sm list-disc pl-5 space-y-1">
|
||||||
{activeBill.payments.map((p: any) => (
|
{activeBill.payments.map((p: any) => (
|
||||||
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({p.method || "Payment"})</li>
|
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -1019,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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1076,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,207 +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";
|
|
||||||
|
|
||||||
function MakePaymentContent() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const registrationId = searchParams.get("registrationId");
|
|
||||||
const { token } = useAuth();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [info, setInfo] = useState<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
import { formatDateTime } from "@/lib/date";
|
||||||
|
import { formatPaymentMethod } from "@/lib/paymentMethod";
|
||||||
|
|
||||||
|
interface PaymentItem {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
method: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
registrationId: string | null;
|
||||||
|
eventId: string | null;
|
||||||
|
registration?: { event?: { title?: string } | null } | null;
|
||||||
|
event?: { title?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRand = (n: number) => `R ${Math.abs(n).toFixed(2)}`;
|
||||||
|
|
||||||
|
export default function UserPaymentsPage() {
|
||||||
|
const { user, loading, token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
if (!user) router.replace("/login");
|
||||||
|
}, [user, loading, router]);
|
||||||
|
|
||||||
|
const [payments, setPayments] = useState<PaymentItem[]>([]);
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [startDate, setStartDate] = useState("");
|
||||||
|
const [endDate, setEndDate] = useState("");
|
||||||
|
const [method, setMethod] = useState("");
|
||||||
|
const [kind, setKind] = useState<"" | "payment" | "refund">("");
|
||||||
|
|
||||||
|
const buildQuery = useCallback((p: number) => {
|
||||||
|
const qs = new URLSearchParams({ page: String(p), limit: "25" });
|
||||||
|
if (startDate) qs.set("startDate", startDate);
|
||||||
|
if (endDate) qs.set("endDate", endDate);
|
||||||
|
if (method) qs.set("method", method);
|
||||||
|
if (kind) qs.set("kind", kind);
|
||||||
|
return `/api/payments/mypayments?${qs.toString()}`;
|
||||||
|
}, [startDate, endDate, method, kind]);
|
||||||
|
|
||||||
|
const loadPayments = useCallback(async (p = 1) => {
|
||||||
|
if (!token) return;
|
||||||
|
setError(null);
|
||||||
|
setFetching(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<any>(buildQuery(p), { authToken: token });
|
||||||
|
setPayments(Array.isArray(res?.data) ? res.data : []);
|
||||||
|
setTotal(res?.total ?? 0);
|
||||||
|
setTotalPages(res?.pages ?? 1);
|
||||||
|
setPage(p);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || "Failed to load payments");
|
||||||
|
} finally {
|
||||||
|
setFetching(false);
|
||||||
|
}
|
||||||
|
}, [token, buildQuery]);
|
||||||
|
|
||||||
|
useEffect(() => { if (token) loadPayments(1); }, [token, startDate, endDate, method, kind]);
|
||||||
|
|
||||||
|
const goToPage = (p: number) => loadPayments(p);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto w-full px-4 py-6 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h1 className="text-2xl font-semibold">My Payments</h1>
|
||||||
|
<button
|
||||||
|
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||||
|
onClick={() => router.push("/dashboard/user")}
|
||||||
|
>Back to dashboard</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||||
|
|
||||||
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">From</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="border rounded px-2 py-1.5 text-sm"
|
||||||
|
value={startDate}
|
||||||
|
onChange={e => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">To</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="border rounded px-2 py-1.5 text-sm"
|
||||||
|
value={endDate}
|
||||||
|
onChange={e => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Method</label>
|
||||||
|
<select className="border rounded px-2 py-1.5 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
|
||||||
|
<option value="">All methods</option>
|
||||||
|
<option value="cash">Cash</option>
|
||||||
|
<option value="card">Card</option>
|
||||||
|
<option value="eft">EFT</option>
|
||||||
|
<option value="voucher">Voucher</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Type</label>
|
||||||
|
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as any)}>
|
||||||
|
<option value="">Payments & refunds</option>
|
||||||
|
<option value="payment">Payments only</option>
|
||||||
|
<option value="refund">Refunds only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{(startDate || endDate || method || kind) && (
|
||||||
|
<button
|
||||||
|
className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200"
|
||||||
|
onClick={() => { setStartDate(""); setEndDate(""); setMethod(""); setKind(""); }}
|
||||||
|
>Clear filters</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500 mb-2">
|
||||||
|
{total} payment{total !== 1 ? "s" : ""} total
|
||||||
|
{total > 0 && ` — page ${page} of ${totalPages}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="text-sm space-y-2">
|
||||||
|
{payments.map(p => {
|
||||||
|
const amt = p.amount || 0;
|
||||||
|
const isRefund = amt < 0;
|
||||||
|
const eventTitle = p.registration?.event?.title || p.event?.title;
|
||||||
|
return (
|
||||||
|
<li key={p.id} className={`border rounded p-2 ${isRefund ? "bg-red-50" : ""}`}>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className={`font-medium ${isRefund ? "text-red-700" : ""}`}>
|
||||||
|
{isRefund ? "-" : ""}{formatRand(amt)}
|
||||||
|
{isRefund && <span className="text-xs text-red-700 ml-1">(refund)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
|
||||||
|
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{payments.length === 0 && !fetching && (
|
||||||
|
<li className="text-gray-500">No payments found.</li>
|
||||||
|
)}
|
||||||
|
{fetching && <li className="text-gray-400">Loading…</li>}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4 text-sm">
|
||||||
|
<span className="text-gray-500">Page {page} of {totalPages}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
||||||
|
disabled={page <= 1 || fetching}
|
||||||
|
onClick={() => goToPage(page - 1)}
|
||||||
|
>← Prev</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
|
||||||
|
.reduce<(number | "…")[]>((acc, p, i, arr) => {
|
||||||
|
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
|
||||||
|
acc.push(p);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.map((p, i) =>
|
||||||
|
p === "…" ? (
|
||||||
|
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-400">…</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
|
||||||
|
disabled={fetching}
|
||||||
|
onClick={() => goToPage(p as number)}
|
||||||
|
>{p}</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
||||||
|
disabled={page >= totalPages || fetching}
|
||||||
|
onClick={() => goToPage(page + 1)}
|
||||||
|
>Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { isValidZAPhone } from "@/lib/phone";
|
import { isValidZAPhone } from "@/lib/phone";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
export default function UserProfilePage() {
|
export default function UserProfilePage() {
|
||||||
const { user, token, logout, updateToken } = useAuth();
|
const { user, token, logout, updateToken } = useAuth();
|
||||||
@@ -19,7 +20,7 @@ export default function UserProfilePage() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||||
const [profileMsg, setProfileMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
const [profileMsg, setProfileMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||||
const [savingProfile, setSavingProfile] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
|
||||||
const hasValidPhone = isValidZAPhone(phone);
|
const hasValidPhone = isValidZAPhone(phone);
|
||||||
@@ -63,7 +64,7 @@ export default function UserProfilePage() {
|
|||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [pwMsg, setPwMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
const [pwMsg, setPwMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||||
const [savingPw, setSavingPw] = useState(false);
|
const [savingPw, setSavingPw] = useState(false);
|
||||||
|
|
||||||
const changePassword = async (e: React.FormEvent) => {
|
const changePassword = async (e: React.FormEvent) => {
|
||||||
@@ -98,7 +99,7 @@ export default function UserProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Revoke sessions ───────────────────────────────────────────────────────
|
// ── Revoke sessions ───────────────────────────────────────────────────────
|
||||||
const [revokeMsg, setRevokeMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
const [revokeMsg, setRevokeMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||||
const [revoking, setRevoking] = useState(false);
|
const [revoking, setRevoking] = useState(false);
|
||||||
|
|
||||||
const revokeSessions = async () => {
|
const revokeSessions = async () => {
|
||||||
@@ -126,7 +127,7 @@ export default function UserProfilePage() {
|
|||||||
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
|
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
|
||||||
const [deleteData, setDeleteData] = useState(false);
|
const [deleteData, setDeleteData] = useState(false);
|
||||||
const [closePassword, setClosePassword] = useState("");
|
const [closePassword, setClosePassword] = useState("");
|
||||||
const [closeMsg, setCloseMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
const [closeMsg, setCloseMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
|
|
||||||
const submitAccountClosure = async (e: React.FormEvent) => {
|
const submitAccountClosure = async (e: React.FormEvent) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
@@ -11,7 +12,7 @@ export default function ResetPasswordPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirm, setConfirm] = useState("");
|
const [confirm, setConfirm] = useState("");
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]);
|
const canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]);
|
||||||
|
|||||||
@@ -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,13 @@ function fmtCurrency(val: number) {
|
|||||||
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
|
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
|
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
|
||||||
export default function SelfServicePage() {
|
export default function SelfServicePage() {
|
||||||
const [screen, setScreen] = useState<Screen>("setup");
|
const [screen, setScreen] = useState<Screen>("setup");
|
||||||
@@ -113,8 +120,12 @@ export default function SelfServicePage() {
|
|||||||
const [createAccount, setCreateAccount] = useState(false);
|
const [createAccount, setCreateAccount] = useState(false);
|
||||||
const [visitorPassword, setVisitorPassword] = useState("");
|
const [visitorPassword, setVisitorPassword] = useState("");
|
||||||
const [accountExists, setAccountExists] = useState(false);
|
const [accountExists, setAccountExists] = useState(false);
|
||||||
const [checkingAccount, setCheckingAccount] = useState(false);
|
|
||||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
// ── Account lookup (search-by-email/phone) state ──────────────────
|
||||||
|
const [lookupQuery, setLookupQuery] = useState("");
|
||||||
|
const [lookupLoading, setLookupLoading] = useState(false);
|
||||||
|
const [lookupMessage, setLookupMessage] = useState<string | null>(null);
|
||||||
const [formLoading, setFormLoading] = useState(false);
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -180,7 +191,7 @@ export default function SelfServicePage() {
|
|||||||
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);
|
||||||
@@ -416,6 +427,8 @@ export default function SelfServicePage() {
|
|||||||
setCreateAccount(false);
|
setCreateAccount(false);
|
||||||
setVisitorPassword("");
|
setVisitorPassword("");
|
||||||
setAccountExists(false);
|
setAccountExists(false);
|
||||||
|
setLookupQuery("");
|
||||||
|
setLookupMessage(null);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setCurrentRegistrationId(null);
|
setCurrentRegistrationId(null);
|
||||||
setCurrentUserId(null);
|
setCurrentUserId(null);
|
||||||
@@ -431,33 +444,66 @@ export default function SelfServicePage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─── Check whether an account already exists for the entered email/phone ──
|
// ─── Explicit account lookup — only runs when the operator submits the search
|
||||||
useEffect(() => {
|
// (Enter or the Search button), never on keystroke. Matches exactly against a
|
||||||
const email = visitorEmail.trim();
|
// single email or phone value and only ever returns that one matched account
|
||||||
const phone = visitorPhone.trim();
|
// (or nothing) — never a broader/fuzzy match. ──
|
||||||
if (!supervisorToken || (!email && !phone)) {
|
async function handleLookup() {
|
||||||
setAccountExists(false);
|
const q = lookupQuery.trim();
|
||||||
return;
|
if (!q || !supervisorToken) return;
|
||||||
}
|
setLookupLoading(true);
|
||||||
const handle = setTimeout(async () => {
|
setLookupMessage(null);
|
||||||
setCheckingAccount(true);
|
try {
|
||||||
try {
|
// Classify the query as email- or phone-shaped and send it as only that param —
|
||||||
const params = new URLSearchParams();
|
// sending the same raw string as both could let digits embedded in an email
|
||||||
if (email) params.set("email", email);
|
// (e.g. a numeric local-part) get misread as an unrelated phone number.
|
||||||
if (phone) params.set("phone", phone);
|
const isEmailLike = q.includes("@");
|
||||||
const data = await apiFetch<{ exists: boolean }>(
|
const params = new URLSearchParams();
|
||||||
`/api/users/check-exists?${params.toString()}`,
|
if (isEmailLike) params.set("email", q);
|
||||||
{ authToken: supervisorToken }
|
else params.set("phone", q);
|
||||||
);
|
const data = await apiFetch<{
|
||||||
setAccountExists(!!data?.exists);
|
exists: boolean;
|
||||||
} catch {
|
user?: { name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" } | null;
|
||||||
|
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
|
||||||
|
if (data?.exists && data.user) {
|
||||||
|
const found = data.user;
|
||||||
|
setVisitorName(found.name);
|
||||||
|
setVisitorEmail(found.email || "");
|
||||||
|
setVisitorPhone(found.phoneNumber || "");
|
||||||
|
setNotificationPref(found.notificationPreference);
|
||||||
|
setAccountExists(true);
|
||||||
|
setLookupMessage("Account found — details filled in below.");
|
||||||
|
} else {
|
||||||
|
// No match — still save the retype: carry the query into whichever field it
|
||||||
|
// resembles, and leave everything else blank for a fresh entry.
|
||||||
setAccountExists(false);
|
setAccountExists(false);
|
||||||
} finally {
|
setVisitorName("");
|
||||||
setCheckingAccount(false);
|
setNotificationPref("email");
|
||||||
|
if (isEmailLike) {
|
||||||
|
setVisitorEmail(q);
|
||||||
|
setVisitorPhone("");
|
||||||
|
} else if (looksLikePhone(q)) {
|
||||||
|
setVisitorPhone(q);
|
||||||
|
setVisitorEmail("");
|
||||||
|
} else {
|
||||||
|
setVisitorEmail("");
|
||||||
|
setVisitorPhone("");
|
||||||
|
}
|
||||||
|
setLookupMessage("No matching account found.");
|
||||||
}
|
}
|
||||||
}, 400);
|
} catch {
|
||||||
return () => clearTimeout(handle);
|
setLookupMessage("Lookup failed. Please try again.");
|
||||||
}, [visitorEmail, visitorPhone, supervisorToken]);
|
} finally {
|
||||||
|
setLookupLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLookupKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleLookup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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(() => {
|
||||||
@@ -628,6 +674,34 @@ 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>
|
||||||
|
|
||||||
|
<div className="mb-5 border border-gray-200 rounded-xl p-4 bg-gray-50">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Look up existing account</label>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Search by email or phone number to fill in a returning visitor's details.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lookupQuery}
|
||||||
|
onChange={(e) => setLookupQuery(e.target.value)}
|
||||||
|
onKeyDown={handleLookupKeyDown}
|
||||||
|
className="flex-1 border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Email or phone number"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLookup}
|
||||||
|
disabled={lookupLoading || !lookupQuery.trim()}
|
||||||
|
className="px-5 rounded-xl bg-gray-700 text-white font-medium hover:bg-gray-800 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{lookupLoading ? "Searching…" : "Search"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{lookupMessage && (
|
||||||
|
<p className={`text-sm mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
|
||||||
|
{lookupMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleRegister} className="space-y-5">
|
<form onSubmit={handleRegister} className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
||||||
@@ -808,9 +882,7 @@ 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 && (
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like useState, but a truthy value auto-clears back to the initial falsy value after
|
||||||
|
* `ms` milliseconds. Every call to the setter cancels any pending timer and (if the new
|
||||||
|
* value is truthy) arms a fresh one, so rapid-fire updates reset the countdown instead of
|
||||||
|
* cutting it short. Meant for post-action confirmation/error banners that shouldn't linger.
|
||||||
|
*/
|
||||||
|
export function useDismissingState<T>(initial: T, ms = 7000) {
|
||||||
|
const [value, setValue] = useState<T>(initial);
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const set = useCallback((next: T) => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
setValue(next);
|
||||||
|
if (next) {
|
||||||
|
timeoutRef.current = setTimeout(() => setValue(initial), ms);
|
||||||
|
}
|
||||||
|
}, [ms, initial]);
|
||||||
|
|
||||||
|
useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []);
|
||||||
|
|
||||||
|
return [value, set] as const;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Payment.method is free-text — online checkouts get tagged with whatever wallet type the
|
||||||
|
// gateway reports (apple_pay, google_pay, ...), not just the manual-entry methods below.
|
||||||
|
// Mirrors normalizeUserMethod in backend/src/controllers/paymentController.js: card-network
|
||||||
|
// wallets count as "card" (same settlement, no separate float); anything else is "other".
|
||||||
|
const USER_FACING_METHODS = ["cash", "card", "eft", "voucher"];
|
||||||
|
const CARD_ALIASES = ["apple_pay", "google_pay"];
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<string, string> = {
|
||||||
|
cash: "Cash",
|
||||||
|
card: "Card",
|
||||||
|
eft: "EFT",
|
||||||
|
voucher: "Voucher",
|
||||||
|
other: "Other",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizePaymentMethod(method: string | null | undefined): string {
|
||||||
|
const m = String(method || "").toLowerCase();
|
||||||
|
if (USER_FACING_METHODS.includes(m)) return m;
|
||||||
|
if (CARD_ALIASES.includes(m)) return "card";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPaymentMethod(method: string | null | undefined): string {
|
||||||
|
if (!method) return "Payment";
|
||||||
|
return METHOD_LABELS[normalizePaymentMethod(method)];
|
||||||
|
}
|
||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.0.1",
|
"version": "1.3.1",
|
||||||
"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