Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e6525467 | ||
|
|
193739c042 | ||
|
|
12e5dfc643 | ||
|
|
325ab87729 | ||
|
|
7f074e43a6 | ||
|
|
59194349d2 | ||
|
|
90bea25338 | ||
|
|
37681aec50 | ||
|
|
9f7785e660 | ||
|
|
663d0c0309 |
+18
-3
@@ -7,20 +7,35 @@ 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
|
||||
|
||||
- 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).
|
||||
- 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.0...main
|
||||
[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,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": {
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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 }}]},
|
||||
|
||||
@@ -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,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>>>({});
|
||||
|
||||
@@ -4,7 +4,9 @@ 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)}`;
|
||||
@@ -29,6 +31,20 @@ function EventStatusBadge({ event }: { event: any }) {
|
||||
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
|
||||
// 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 [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
|
||||
@@ -629,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")}
|
||||
@@ -677,7 +697,7 @@ export default function UserDashboardPage() {
|
||||
<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];
|
||||
@@ -864,7 +884,7 @@ export default function UserDashboardPage() {
|
||||
</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>
|
||||
@@ -1004,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>
|
||||
|
||||
@@ -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 & 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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user