Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb512a7d9 | ||
|
|
ad9ead807d | ||
|
|
36b61d968c | ||
|
|
c710da4dff | ||
|
|
f3e6525467 | ||
|
|
193739c042 | ||
|
|
12e5dfc643 | ||
|
|
325ab87729 | ||
|
|
7f074e43a6 | ||
|
|
59194349d2 | ||
|
|
90bea25338 | ||
|
|
37681aec50 | ||
|
|
9f7785e660 | ||
|
|
663d0c0309 |
+41
-3
@@ -7,20 +7,58 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
### Fixed
|
||||||
|
|
||||||
- User dashboard: registration-edit errors now show inside the edit popup instead of being hidden behind it.
|
- `/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: closed events are now hidden by default alongside past events (revealed via "Show past events"), and the Edit, Make payment, and Cancel registration actions no longer appear for closed or past registrations (also enforced server-side).
|
- 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
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- User dashboard: event titles now show a status badge (Closed / Past / Inactive, in that precedence) wherever they're listed.
|
- User dashboard: event titles now show a status badge (Closed / Past / Inactive, in that precedence) wherever they're listed.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- User dashboard: registration-edit errors now show inside the edit popup instead of being hidden behind it.
|
||||||
|
- User dashboard: closed events are now hidden by default alongside past events (revealed via "Show past events"), and the Edit, Make payment, and Cancel registration actions no longer appear for closed or past registrations (also enforced server-side).
|
||||||
|
|
||||||
## [1.0.0] - 2026-07-23
|
## [1.0.0] - 2026-07-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 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.0...main
|
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...main
|
||||||
|
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
|
||||||
|
[1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
|
||||||
|
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
|
||||||
[1.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0
|
[1.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.0",
|
"version": "1.2.0",
|
||||||
"description": "Event Management System Backend",
|
"description": "Event Management System Backend",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.0",
|
"version": "1.2.0",
|
||||||
"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,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 { scoreUser } from "@/lib/fuzzyMatch";
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||||
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
type Mode = "registration" | "payment" | "tickets" | "refund";
|
type Mode = "registration" | "payment" | "tickets" | "refund";
|
||||||
|
|
||||||
@@ -154,30 +155,8 @@ 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);
|
||||||
@@ -1332,7 +1311,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 +1348,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 +1440,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',
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && npm run dev",
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user