Compare commits

...
Author SHA1 Message Date
joshuaandClaude Sonnet 5 f3e6525467 Add registration status badges and auto-dismissing dashboard messages
Two consistency fixes requested after the payment-method work:

1. Registration status (pending/confirmed/partial_paid/paid/cancelled)
   was printed as a raw string on the user dashboard. Added
   RegistrationStatusBadge mirroring the existing EventStatusBadge
   pattern, using the same status colors already established on
   dashboard/admin/registrations.

2. Inline success/error banners across dashboard pages persisted
   indefinitely. Added a shared useDismissingState hook (drop-in
   useState replacement that auto-clears a truthy value after 7s,
   resetting the timer on each update) and swapped it in across ~24
   dashboard files. Excluded: message-only modal dialogs (ticket-
   scanning's success/error confirmations) and two states that mix
   live form-validation feedback with async results inside actively-
   open forms (the registration-edit modal's editError, the event
   create/edit modal's error) - those keep persisting until the user
   acts, since auto-hiding a "fix this field" message mid-edit would
   be a regression. Also fixed at-the-door's existing bespoke
   auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same
   consistent 7s, and removed admin/settings' manual x dismiss button
   in favor of the same auto-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:17:53 +02:00
joshuaandClaude Sonnet 5 193739c042 Apply payment method normalization consistently across user-facing views
Audited every place Payment.method reaches the UI. The main user
dashboard's per-registration payment list (fed by GET
/api/payments/registration/:id) was still showing the raw gateway
string, since that endpoint is shared with the staff-facing supervisor
payments page and wasn't touched by the earlier /mypayments fix.

Extracted the cash/card/eft/voucher/other normalization (matching
normalizeUserMethod on the backend) into frontend/src/lib/paymentMethod.ts
and applied it to both user-facing payment displays. Staff-facing views
(supervisor payments, at-the-door, reports, cashup) intentionally keep
showing the raw method for reconciliation and were left unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:41:43 +02:00
joshuaandClaude Sonnet 5 12e5dfc643 Fold apple_pay/google_pay into the card bucket, keep everything else under other
Per feedback: card-network wallet payments (Apple Pay, Google Pay) should
report and filter as "card" on the user payment history page rather than
"other", since they settle the same way as a card payment. Any other
gateway-reported method still falls under "other".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:27:52 +02:00
joshuaandClaude Sonnet 5 325ab87729 Switch payment method filter to a static list with an "other" bucket
Per feedback, drop the dynamic /mypayments/methods lookup (wasn't
loading reliably) in favor of a static cash/card/eft/voucher/other
dropdown. Server-side normalization now folds any gateway-reported
method outside those four manual-entry values (apple_pay, google_pay,
yoco, etc.) into "other" instead of "card", both in the returned data
and in the filter query.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:24:53 +02:00
joshuaandClaude Sonnet 5 7f074e43a6 Normalize user-facing payment method to cash/card/eft/voucher
Per feedback, stop trying to surface every gateway-reported wallet
type (apple_pay, google_pay, yoco, ...) as its own filter/display
value on the user payment history page. Both /mypayments and
/mypayments/methods now fold anything outside the four manual-entry
methods into "card", both in the returned data and in the filter
query, so Apple Pay/Google Pay payments show up under Card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:02:59 +02:00
joshuaandClaude Sonnet 5 59194349d2 Populate payment method filter from real data instead of a guessed list
The previous fix hardcoded apple_pay/google_pay as extra filter options,
but Payment.method is free-text set by whatever the gateway reports, so
guessing at literal values was fragile and still didn't surface them for
this user. Add GET /api/payments/mypayments/methods returning the
distinct method values actually present in the user's payments, and have
the dashboard filter build its options from that instead. Also switch
the method filter from a startsWith match to an exact match, since the
values now come straight from the same column being filtered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 08:55:58 +02:00
joshuaandClaude Sonnet 5 90bea25338 Fix payment method handling for non-default types (apple_pay, google_pay)
Yoco webhook payments are tagged with whatever wallet type the gateway
reports, not just cash/card/eft/voucher. Cashup/report totals were
silently dropping those into the "other" bucket instead of "card", and
the new user payment history page couldn't filter by them and showed
raw snake_case values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 08:28:59 +02:00
joshua 37681aec50 Add paginated payment history to user dashboard
Users can now view their own payments (excluding donations) with
server-side pagination (25/page), date range, method, and
payment/refund filters, both on the API and the new client page.
2026-07-23 18:02:53 +02:00
joshuaandClaude Sonnet 5 9f7785e660 Bump version to 1.0.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 17:08:28 +02:00
joshua 663d0c0309 Merge branch 'bug/user-dash-closed-events-editable' 2026-07-23 17:07:05 +02:00
joshuaandClaude Sonnet 5 91b352de18 Add closed/past/inactive status badges to user dashboard event titles
Adds an EventStatusBadge component shown next to every event title on
the dashboard (registrations list, upcoming events, tickets, and the
registration modal), with precedence Closed > Past > Inactive when
more than one applies — reusing the badge styling already established
in the supervisor events list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:56:32 +02:00
joshuaandClaude Sonnet 5 349686e182 Block self-service payment and cancellation on closed/past registrations
Extends the earlier user-dashboard fix: canEditActive is renamed
canModifyActive and now also gates the "Make payment" and "Cancel
registration" actions, not just editing. Backend enforcement added to
cancelRegistration and createYocoCheckout to block past-event
self-service payment/cancellation server-side (cashup-closed events
were already blocked via assertEventOpen; admins/supervisors are
exempt from the past-date check, consistent with existing overrides).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:51:10 +02:00
joshuaandClaude Sonnet 5 eb46ed8264 Fix user dashboard: buried edit errors, closed events not hidden, closed/past registrations editable
Registration-edit errors were set into a page-level error state rendered
behind the edit modal overlay; they now render inside the modal. The
"Show past events" toggle only hid date-based past events, letting
cashup-closed events leak through by default; a shared isEventOver()
check now covers both, applied to registrations, tickets, and the
Edit button visibility (mirroring the backend's own edit block).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:43:33 +02:00
joshua 890970fffe Add CHANGELOG.md 2026-07-23 15:38:39 +02:00
34 changed files with 547 additions and 132 deletions
+41
View File
@@ -0,0 +1,41 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
### 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
- 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
### Added
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...main
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
[1.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.0.0",
"version": "1.0.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+83 -20
View File
@@ -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
// @access Private
const getUserPayments = async (req, res) => {
try {
// Users should see payments they made (donations) and payments tied to their registrations
const payments = await prisma.payment.findMany({
where: {
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
}
});
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(25, Math.max(1, parseInt(req.query.limit) || 25));
const skip = (page - 1) * limit;
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) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
@@ -788,11 +847,15 @@ const createYocoCheckout = async (req, res) => {
// Branch 1: Registration payment — delegate to internal helper
if (registrationId) {
// Authorisation check (the internal helper skips this)
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true } });
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
if (!reg) { res.status(404); throw new Error('Registration not found'); }
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
res.status(403); throw new Error('Not authorized to create checkout for this registration');
}
// If event is in the past, block self-service payment (mirrors the registration-edit block)
if (req.user.role !== 'admin' && req.user.role !== 'supervisor' && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
res.status(400); throw new Error('This event has already ended; payment can no longer be made for this registration');
}
await assertEventOpen(reg.eventId, res);
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
@@ -415,7 +415,7 @@ const getUserRegistrations = async (req, res) => {
const whereClause = showPast ? { userId: req.user.id } : {
userId: req.user.id,
status: { not: 'cancelled' },
event: { endDate: { gte: now } }
event: { endDate: { gte: now }, cashupStatus: { not: 'closed' } }
};
const registrations = await prisma.registration.findMany({
@@ -583,7 +583,8 @@ const updateRegistrationStatus = async (req, res) => {
const cancelRegistration = async (req, res) => {
try {
const registration = await prisma.registration.findUnique({
where: { id: req.params.id }
where: { id: req.params.id },
include: { event: { select: { endDate: true } } }
});
if (!registration) {
@@ -597,6 +598,12 @@ const cancelRegistration = async (req, res) => {
throw new Error('Not authorized to cancel this registration');
}
// If event is in the past, block self-cancellation (mirrors the edit-block above)
if (req.user.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
res.status(400);
throw new Error('This event has already ended; registration can no longer be cancelled');
}
await assertEventOpen(registration.eventId, res);
// Check if registration can be cancelled
+3 -2
View File
@@ -543,8 +543,9 @@ app.get('/docs', async (req, res) => {
responses:[
{ 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',
responses:[{ status:200, desc:'Success', body:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }]}]},
{ 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"',
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',
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 }}]},
+3 -1
View File
@@ -11,11 +11,13 @@ function emptyByMethod(fill = 0) {
}
// 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) {
const m = String(method || '').toLowerCase();
if (m.includes('cash')) return 'cash';
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';
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.0.0",
"version": "1.0.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
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 [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
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 isClosed = data?.event?.cashupStatus === "closed";
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function CashupLandingPage() {
const { token } = useAuth();
@@ -11,7 +12,7 @@ export default function CashupLandingPage() {
const [events, setEvents] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [search, setSearch] = useState("");
const [showPast, setShowPast] = useState(true);
const [showInactive, setShowInactive] = useState(false);
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
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 [events, setEvents] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
// Filters
const [query, setQuery] = useState("");
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
@@ -36,19 +37,17 @@ function Field({
}
function SaveBar({
saving, onSave, result, onDismiss,
saving, onSave, result,
}: {
saving: boolean;
onSave: () => void;
result: { ok: boolean; message: string } | null;
onDismiss: () => void;
}) {
return (
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
{result ? (
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
{result.ok ? "✓" : "✗"} {result.message}
<button type="button" onClick={onDismiss} className="ml-1 text-gray-400 hover:text-gray-600 text-xs">×</button>
</span>
) : (
<span />
@@ -75,7 +74,7 @@ export default function SiteSettingsPage() {
// Per-tab save state
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 ──
const [orgName, setOrgName] = useState("");
@@ -104,7 +103,7 @@ export default function SiteSettingsPage() {
const [smtpPass, setSmtpPass] = useState("");
const [smtpPassSet, setSmtpPassSet] = 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 ──
const [legalOperatorName, setLegalOperatorName] = useState("");
@@ -332,7 +331,7 @@ export default function SiteSettingsPage() {
<input className={inputCls} placeholder="https://events.yourchurch.org"
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
</Field>
<SaveBar saving={saving} onSave={saveOrganisation} result={result} onDismiss={() => setResult(null)} />
<SaveBar saving={saving} onSave={saveOrganisation} result={result} />
</div>
)}
@@ -371,7 +370,7 @@ export default function SiteSettingsPage() {
}} className="text-sm" />
</Field>
<SaveBar saving={saving} onSave={saveBranding} result={result} onDismiss={() => setResult(null)} />
<SaveBar saving={saving} onSave={saveBranding} result={result} />
</div>
)}
@@ -385,7 +384,7 @@ export default function SiteSettingsPage() {
<input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org"
value={notifEmails} onChange={e => setNotifEmails(e.target.value)} />
</Field>
<SaveBar saving={saving} onSave={saveNotifications} result={result} onDismiss={() => setResult(null)} />
<SaveBar saving={saving} onSave={saveNotifications} result={result} />
</div>
)}
@@ -464,7 +463,7 @@ export default function SiteSettingsPage() {
)}
</div>
<SaveBar saving={saving} onSave={saveSmtp} result={result} onDismiss={() => setResult(null)} />
<SaveBar saving={saving} onSave={saveSmtp} result={result} />
</div>
)}
@@ -503,7 +502,7 @@ export default function SiteSettingsPage() {
</div>
</div>
<SaveBar saving={saving} onSave={saveLegal} result={result} onDismiss={() => setResult(null)} />
<SaveBar saving={saving} onSave={saveLegal} result={result} />
</div>
)}
</div>
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
interface UserItem {
id: string;
@@ -43,7 +44,7 @@ export default function AdminUsersPage() {
// Data state
const [users, setUsers] = useState<UserItem[]>([]);
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 [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -123,7 +124,7 @@ export default function WhatsAppAdminPage() {
const [pairingPhone, setPairingPhone] = useState("");
// ── 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);
// ── Load config ──────────────────────────────────────────────────────────────
@@ -3,6 +3,7 @@
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date";
import { QrImage } from "@/components/shared/QrImage";
@@ -18,7 +19,7 @@ function EventTicketsContent() {
const [events, setEvents] = useState<any[]>([]);
const [eventId, setEventId] = useState<string>(paramEventId);
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 [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 { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
export default function TicketScanningPage() {
const router = useRouter();
const scannerRef = useRef<QRScannerHandle>(null);
const [lastResult, setLastResult] = useState<string | null>(null);
const [scanInfo, setScanInfo] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
const [errorModal, setErrorModal] = useState<string | null>(null);
const { token, user } = useAuth();
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
type Mode = "registration" | "payment" | "tickets" | "refund";
@@ -154,30 +155,8 @@ export default function AtTheDoorPage() {
const [mode, setMode] = useState<Mode>("registration");
const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useState<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 [info, setInfo] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const handleRegistrationCreated = (registration: any) => {
setActiveRegistration(registration);
@@ -1332,7 +1311,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
const [phone, setPhone] = useState("");
const [email, setEmail] = useState("");
const [sending, setSending] = useState(false);
const [localError, setLocalError] = useState("");
const [localError, setLocalError] = useDismissingState("");
const [localInfo, setLocalInfo] = useState("");
useEffect(() => {
@@ -1369,7 +1348,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
},
});
setLocalInfo("Tickets sent successfully.");
setTimeout(() => { setLocalInfo(""); onClose(); }, 2000);
setTimeout(() => { setLocalInfo(""); onClose(); }, 7000);
} catch (e: any) {
setLocalError(e?.message || "Failed to send tickets");
} finally {
@@ -1461,7 +1440,7 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
const [method, setMethod] = useState("cash");
const [reason, setReason] = useState("");
const [saving, setSaving] = useState(false);
const [localError, setLocalError] = useState("");
const [localError, setLocalError] = useDismissingState("");
useEffect(() => {
if (open) { setAmount(""); setReason(""); setLocalError(""); }
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
type Attendee = { id: string; name: string; email: string; pref: string };
@@ -171,8 +172,8 @@ function EmailAttendeesPageInner() {
// Load events for selection
const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
// Tabs: attendees (current), automations (coming soon), broadcasts
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 { useRouter, useSearchParams } from "next/navigation";
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">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
@@ -112,8 +113,8 @@ function EventOptionsContent() {
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]);
const [loadingEv, setLoadingEv] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => {
if (!token) return;
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -690,7 +691,7 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
const [selected, setSelected] = useState<NotifyUser[]>(initialRecipients || []);
const [loading, setLoading] = useState(!hasInitial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
useEffect(() => {
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 [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const loadEvents = async () => {
if (!token) return;
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
@@ -144,8 +145,8 @@ export default function FormsBrowserPage() {
const [items, setItems] = useState<any[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => {
if (!token) return;
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
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 };
@@ -18,7 +19,7 @@ export default function ManualRegistrationPage() {
const [phoneNumber, setPhoneNumber] = useState("");
const [registerAsGuest, setRegisterAsGuest] = 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 [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
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; }) {
const { token } = useAuth();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<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 count = Math.max(0, mainTickets);
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -72,8 +73,8 @@ export default function ManualRegistrationPage() {
const searchRef = useRef<HTMLDivElement>(null);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
// Load all users for client-side fuzzy matching
useEffect(() => {
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
function RegistrationOptions({ regs, regOutstanding }: {
@@ -115,8 +116,8 @@ function PaymentsContent() {
const [payments, setPayments] = useState<any[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [registrations, setRegistrations] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
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 { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
@@ -358,8 +359,8 @@ function WhatsAppAttendeesPageInner() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
const [loadingEvents, setLoadingEvents] = useState(false);
const [allEvents, setAllEvents] = useState<any[]>([]);
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function DonatePage() {
const { token } = useAuth();
@@ -9,8 +10,8 @@ export default function DonatePage() {
const [eventId, setEventId] = useState<string>("");
const [amount, setAmount] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
useEffect(() => {
(async () => {
@@ -3,6 +3,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// Types for form fields
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 [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
// Local entry state for new responses
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
+80 -30
View File
@@ -4,11 +4,46 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { QrImage } from "@/components/shared/QrImage";
import { useDismissingState } from "@/hooks/useDismissingState";
// Helper formatters
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
const isFuture = (d: string | Date) => new Date(d).getTime() > Date.now();
const isEventOver = (ev: any) =>
!!ev && ((ev.endDate && new Date(ev.endDate).getTime() < Date.now()) || ev.cashupStatus === 'closed');
// Status badge for an event, shown wherever an event title is displayed on this page.
// Precedence when more than one applies: Closed > Past > Inactive.
function EventStatusBadge({ event }: { event: any }) {
if (!event) return null;
let label: string | null = null;
let className = '';
if (event.cashupStatus === 'closed') {
label = 'Closed'; className = 'bg-rose-50 text-rose-700';
} else if (event.endDate && new Date(event.endDate).getTime() < Date.now()) {
label = 'Past'; className = 'bg-amber-50 text-amber-700';
} else if (event.isActive === false) {
label = 'Inactive'; className = 'bg-gray-100 text-gray-500';
}
if (!label) return null;
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.
// Used by the registration editor, which works off raw /api/events/:id data rather than
@@ -33,8 +68,8 @@ export default function UserDashboardPage() {
const { token, user } = useAuth();
const [registrations, setRegistrations] = useState<any[]>([]);
const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
// Filters
@@ -443,11 +478,13 @@ export default function UserDashboardPage() {
const [editQuantities, setEditQuantities] = useState<Record<string, number>>({});
const [editBaseQty, setEditBaseQty] = useState<Record<string, number>>({});
const [editMinQty, setEditMinQty] = useState<Record<string, number>>({});
const [editError, setEditError] = useState<string | null>(null);
const canEditActive = useMemo(() => {
// Whether the active registration's event still permits self-service changes
// (edit items, pay, cancel) — false once the event is past or its cashup is closed.
const canModifyActive = useMemo(() => {
if (!activeReg) return false;
const end = activeReg.event?.endDate ? new Date(activeReg.event.endDate).getTime() : Infinity;
return end >= Date.now();
return !isEventOver(activeReg.event);
}, [activeReg]);
const editTotal = useMemo(() => {
@@ -464,6 +501,7 @@ export default function UserDashboardPage() {
const beginEdit = async () => {
if (!activeReg || !token) return;
setEditError(null);
setEditLoading(true);
try {
// Load event options (with variants) for this event
@@ -507,7 +545,7 @@ export default function UserDashboardPage() {
setEditMinQty(minQtyMap);
setEditMode(true);
} catch (e) {
setError((e as any)?.message || 'Failed to load event options');
setEditError((e as any)?.message || 'Failed to load event options');
} finally {
setEditLoading(false);
}
@@ -515,6 +553,7 @@ export default function UserDashboardPage() {
const saveEdit = async () => {
if (!activeReg || !token) return;
setEditError(null);
for (const [key, minQty] of Object.entries(editMinQty)) {
if (minQty <= 0) continue;
@@ -523,7 +562,7 @@ export default function UserDashboardPage() {
const eo = editEventOptions.find((o: any) => o.id === eventOptionId);
const variant = variantId ? (eo?.variants || []).find((v: any) => v.id === variantId) : null;
const label = variant ? `${eo?.name || 'item'} (${variant.name})` : (eo?.name || 'item');
setError(`Cannot reduce "${label}" below the ${minQty} already issued`);
setEditError(`Cannot reduce "${label}" below the ${minQty} already issued`);
return;
}
}
@@ -539,13 +578,13 @@ export default function UserDashboardPage() {
}
}
if (payloadOptions.length === 0) {
setError('Please select at least one item');
setEditError('Please select at least one item');
return;
}
// Enforce client-side: do not reduce below paid
const paid = activeBill?.totalPaid || 0;
if (editTotal < paid) {
setError('Cannot reduce items below the amount already paid');
setEditError('Cannot reduce items below the amount already paid');
return;
}
setLoading(true);
@@ -588,7 +627,7 @@ export default function UserDashboardPage() {
setInfo('Registration updated successfully');
setEditMode(false);
} catch (e: any) {
setError(e?.message || 'Failed to update registration');
setEditError(e?.message || 'Failed to update registration');
} finally {
setLoading(false);
}
@@ -606,6 +645,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"
onClick={() => router.push("/dashboard/user/reset-password")}
>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
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")}
@@ -635,11 +678,7 @@ export default function UserDashboardPage() {
{registrations
// Hide cancelled always; hide past unless showPast
.filter((r: any) => r.status !== 'cancelled')
.filter((r: any) => {
if (showPast) return true;
const end = r.event?.endDate ? new Date(r.event.endDate).getTime() : Infinity;
return end >= Date.now();
})
.filter((r: any) => showPast || !isEventOver(r.event))
.map((r: any) => {
const bill = billing[r.id];
const isCancelled = r.status === 'cancelled';
@@ -653,9 +692,12 @@ export default function UserDashboardPage() {
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium">{r.event?.title || r.eventId}</div>
<div className="font-medium flex items-center gap-2">
<span>{r.event?.title || r.eventId}</span>
<EventStatusBadge event={r.event} />
</div>
<div className="text-xs text-gray-600">
Status: {r.status}{isCancelled && <span className="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-700">cancelled</span>}
Status: <RegistrationStatusBadge status={r.status} />
</div>
{(() => {
const fs = formStatuses[r.id];
@@ -726,7 +768,10 @@ export default function UserDashboardPage() {
<ul className="grid md:grid-cols-2 gap-3">
{upcomingEvents.map(ev => (
<li key={ev.id} className="border rounded p-4">
<div className="font-medium">{ev.title}</div>
<div className="font-medium flex items-center gap-2">
<span>{ev.title}</span>
<EventStatusBadge event={ev} />
</div>
<div className="text-sm text-gray-600">{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
<div className="flex flex-wrap gap-2 mt-2">
<button
@@ -786,12 +831,12 @@ export default function UserDashboardPage() {
</div>
)}
</div>
{tickets.filter(t => showPast || (t.event?.endDate ? new Date(t.event.endDate).getTime() >= Date.now() : true)).length === 0 ? (
{tickets.filter(t => showPast || !isEventOver(t.event)).length === 0 ? (
<p className="text-gray-600 text-sm">No tickets yet.</p>
) : (
<ul className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{tickets
.filter((t: any) => showPast || (t.event?.endDate ? new Date(t.event.endDate).getTime() >= Date.now() : true))
.filter((t: any) => showPast || !isEventOver(t.event))
.map((t: any) => {
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
@@ -804,6 +849,7 @@ export default function UserDashboardPage() {
<input type="checkbox" checked={selected[t.id]} onChange={e => toggleSelect(t.id, e.target.checked)} />
<span className="font-medium">{t.event?.title || t.eventId}</span>
</label>
<EventStatusBadge event={t.event} />
{eventDate && <span className="text-xs text-gray-500">{formatDate(eventDate)}</span>}
</div>
<div className="text-sm text-gray-700">{type}</div>
@@ -827,15 +873,18 @@ export default function UserDashboardPage() {
</section>
{activeReg && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setActiveRegId(null)}>
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
<div className="bg-white rounded-lg shadow-lg max-w-xl w-full mx-4 p-5 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">{activeReg.event?.title || activeReg.eventId}</h3>
<button className="px-2.5 py-1 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => { setEditMode(false); setShowCancelConfirm(false); setActiveRegId(null); }}>Close</button>
<h3 className="text-lg font-semibold flex items-center gap-2">
<span>{activeReg.event?.title || activeReg.eventId}</span>
<EventStatusBadge event={activeReg.event} />
</h3>
<button className="px-2.5 py-1 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>Close</button>
</div>
<div className="space-y-3">
<div>
<div className="text-sm text-gray-600">Status: {activeReg.status}</div>
<div className="text-sm text-gray-600">Status: <RegistrationStatusBadge status={activeReg.status} /></div>
{activeBill && (
<div className="text-sm">
<div>Total: {formatRand(activeBill.totalDue)}</div>
@@ -847,7 +896,7 @@ export default function UserDashboardPage() {
<div>
<div className="font-medium mb-1 flex items-center justify-between">
<span>Registration items</span>
{!editMode && canEditActive && (
{!editMode && canModifyActive && (
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">Edit</button>
)}
</div>
@@ -870,6 +919,7 @@ export default function UserDashboardPage() {
</ul>
) : (
<div className="border rounded p-3 space-y-2 bg-gray-50">
{editError && <p className="text-xs text-red-600">{editError}</p>}
{editLoading ? (
<div className="text-sm text-gray-600">Loading options</div>
) : (
@@ -960,7 +1010,7 @@ export default function UserDashboardPage() {
})()}
</div>
<div className="flex gap-2">
<button onClick={() => setEditMode(false)} className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
<button onClick={() => { setEditMode(false); setEditError(null); }} className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
<button onClick={saveEdit} className="px-2.5 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700">Save</button>
</div>
</div>
@@ -974,7 +1024,7 @@ export default function UserDashboardPage() {
<div className="font-medium mb-1">Payments</div>
<ul className="text-sm list-disc pl-5 space-y-1">
{activeBill.payments.map((p: any) => (
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({p.method || "Payment"})</li>
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</li>
))}
</ul>
</div>
@@ -986,7 +1036,7 @@ export default function UserDashboardPage() {
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
>Attendee forms</button>
)}
{activeBill && activeBill.outstanding > 0 ? (
{canModifyActive && activeBill && activeBill.outstanding > 0 ? (
<button
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(activeReg.id)}`)}
@@ -1006,8 +1056,8 @@ export default function UserDashboardPage() {
)}
</div>
{/* Cancel registration — only shown when no payments have been made */}
{activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
{canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
<div className="pt-3 border-t mt-1">
{!showCancelConfirm ? (
<button
+3 -2
View File
@@ -3,14 +3,15 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
function MakePaymentContent() {
const searchParams = useSearchParams();
const registrationId = searchParams.get("registrationId");
const { token } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [registration, setRegistration] = useState<any | null>(null);
const [payments, setPayments] = useState<any[]>([]);
const [amount, setAmount] = useState<string>("");
@@ -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 &amp; 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 { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function UserProfilePage() {
const { user, token, logout, updateToken } = useAuth();
@@ -19,7 +20,7 @@ export default function UserProfilePage() {
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
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 hasValidPhone = isValidZAPhone(phone);
@@ -63,7 +64,7 @@ export default function UserProfilePage() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = 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 changePassword = async (e: React.FormEvent) => {
@@ -98,7 +99,7 @@ export default function UserProfilePage() {
};
// ── 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 revokeSessions = async () => {
@@ -126,7 +127,7 @@ export default function UserProfilePage() {
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
const [deleteData, setDeleteData] = useState(false);
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 submitAccountClosure = async (e: React.FormEvent) => {
@@ -2,6 +2,7 @@
import React, { useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
export default function ResetPasswordPage() {
@@ -11,7 +12,7 @@ export default function ResetPasswordPage() {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
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 canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]);
+24
View File
@@ -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;
}
+26
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.0.0",
"version": "1.0.1",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",