From 36b61d968c5690790cee8dffb64b257bb321295b Mon Sep 17 00:00:00 2001 From: joshua Date: Mon, 27 Jul 2026 10:14:49 +0200 Subject: [PATCH] Simplify self-service payments to full-amount checkout, add manual-page payment tab Self-service "pay now" flows (registration success + user dashboard) now go straight to a Yoco checkout for the full outstanding balance instead of prompting for a partial amount; that page has been removed. Partial-amount payment links remain supervisor/admin-only via the Payments dashboard. Also adds a "Record Payment" tab to the supervisor manual registration page, pre-filled with the most recently created registration, so staff can capture a payment right after registering someone without leaving the page. --- CHANGELOG.md | 12 + frontend/README.md | 3 +- .../app/dashboard/supervisor/manual/page.tsx | 227 ++++++++++++++++++ frontend/src/app/dashboard/user/page.tsx | 39 ++- frontend/src/app/dashboard/user/pay/page.tsx | 208 ---------------- .../src/app/registration/success/page.tsx | 32 ++- frontend/src/lib/api.ts | 16 ++ 7 files changed, 321 insertions(+), 216 deletions(-) delete mode 100644 frontend/src/app/dashboard/user/pay/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c4503c3..207b6f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Supervisor dashboard: `/dashboard/supervisor/manual` now has a "Record Payment" tab (alongside "Register"), for capturing a payment without leaving the page. It pre-fills with the user and registration from the most recently created manual registration, but only switches tabs when a supervisor/admin clicks it themselves. + +### Changed + +- Self-service payments ("Pay with Yoco" after registering, and "Make payment" on the user dashboard) now go straight to a Yoco checkout for the full remaining outstanding balance, instead of first showing a page to choose a custom/partial amount. Generating a partial-amount payment link remains available only from the supervisor/admin Payments dashboard. + +### Removed + +- `/dashboard/user/pay` — the self-service partial-payment page — has been removed; it's no longer linked to from anywhere in the app. + ## [1.1.0] - 2026-07-24 ### Added diff --git a/frontend/README.md b/frontend/README.md index f3bb132..4d23adb 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -164,7 +164,6 @@ frontend/src/ |-------|-------------| | `/dashboard/user` | My registrations + tickets overview | | `/dashboard/user/profile` | Edit profile, notification preferences | -| `/dashboard/user/pay` | Pay outstanding balance | | `/dashboard/user/donate` | Make a donation | | `/dashboard/user/forms` | Complete registration forms | | `/dashboard/user/reset-password` | Change password (authenticated) | @@ -255,7 +254,7 @@ A "Select Email/both" or "Select WhatsApp/both" quick-select button is available 5. Backend reconciles payment, updates registration status, generates and emails tickets 6. User sees `/payment/success` and tickets appear in their dashboard -Outstanding balances can be paid at any time from `/dashboard/user/pay`. +Outstanding balances can be paid at any time from the "Make payment" button on `/dashboard/user` — this always creates a checkout for the full remaining balance. Partial-amount payment links can only be generated by a supervisor/admin from `/dashboard/supervisor/payments`. --- diff --git a/frontend/src/app/dashboard/supervisor/manual/page.tsx b/frontend/src/app/dashboard/supervisor/manual/page.tsx index dcac4a0..67df94d 100644 --- a/frontend/src/app/dashboard/supervisor/manual/page.tsx +++ b/frontend/src/app/dashboard/supervisor/manual/page.tsx @@ -39,6 +39,18 @@ function fmtPrice(n: number) { return n === 0 ? "Free" : `R ${n.toFixed(2)}`; } +// Effective unit price for an existing registration option (as returned by /api/registrations). +// Prefers priceSnapshot (authoritative price captured at registration time) and only falls +// back to a live early-bird calculation for legacy rows without a snapshot. +function optionUnitPrice(opt: any): number { + if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) { + return Number(opt.priceSnapshot); + } + const eo = opt.eventOption; + if (opt.variant) return effectiveVariantUnit(eo, opt.variant); + return effectiveOptionUnit(eo); +} + // ─── Component ─────────────────────────────────────────────────────────────── export default function ManualRegistrationPage() { @@ -76,6 +88,23 @@ export default function ManualRegistrationPage() { const [message, setMessage] = useDismissingState(null); const [error, setError] = useDismissingState(null); + // ── Tabs ────────────────────────────────────────────────────────────────── + const [tab, setTab] = useState<"register" | "payment">("register"); + + // ── Record Payment tab state ───────────────────────────────────────────── + const [allRegistrations, setAllRegistrations] = useState([]); + const [payUserId, setPayUserId] = useState(""); + const [payUserQuery, setPayUserQuery] = useState(""); + const [payDropdownOpen, setPayDropdownOpen] = useState(false); + const paySearchRef = useRef(null); + const [payRegistrationId, setPayRegistrationId] = useState(""); + const [payAmount, setPayAmount] = useState(""); + const [payMethod, setPayMethod] = useState("cash"); + const [payPaidAtLocal, setPayPaidAtLocal] = useState(""); + const [paySubmitting, setPaySubmitting] = useState(false); + const [payMessage, setPayMessage] = useDismissingState(null); + const [payError, setPayError] = useDismissingState(null); + // Load all users for client-side fuzzy matching useEffect(() => { if (!token) return; @@ -84,6 +113,97 @@ export default function ManualRegistrationPage() { .catch(() => {}); }, [token]); + // Load all registrations for the Record Payment tab (embeds payments, so outstanding + // balances can be computed without a per-registration fetch loop). + useEffect(() => { + if (!token) return; + apiFetch("/api/registrations", { authToken: token }) + .then(regs => setAllRegistrations(Array.isArray(regs) ? regs : [])) + .catch(() => {}); + }, [token]); + + const regOutstanding = useMemo(() => { + const map: Record = {}; + for (const r of allRegistrations) { + const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0); + const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0); + map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) }; + } + return map; + }, [allRegistrations]); + + const payMatchedUsers = useMemo(() => { + if (payUserQuery.trim().length < 2) return []; + return allUsers + .map(u => ({ u, score: scoreUser(u, payUserQuery) })) + .filter(x => x.score >= 0.45) + .sort((a, b) => b.score - a.score) + .slice(0, 6) + .map(x => x.u); + }, [payUserQuery, allUsers]); + + const selectPayUser = (u: any) => { + setPayUserId(u.id); + setPayUserQuery(u.name || ""); + setPayDropdownOpen(false); + setPayRegistrationId(""); + }; + + // Close payment-tab user dropdown on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (paySearchRef.current && !paySearchRef.current.contains(e.target as Node)) { + setPayDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const regsForPayUser = useMemo(() => { + if (!payUserId) return []; + return allRegistrations + .filter((r: any) => String(r.userId || r.user?.id) === String(payUserId)) + .filter((r: any) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001) + .sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }, [allRegistrations, payUserId, regOutstanding]); + + const createManualPayment = async () => { + if (!token) return; + setPayError(null); + setPayMessage(null); + const amt = parseFloat(payAmount || "0"); + if (!amt || amt <= 0) { setPayError("Enter a valid amount"); return; } + if (!payRegistrationId) { setPayError("Please select a registration"); return; } + try { + setPaySubmitting(true); + await apiFetch("/api/payments", { + method: "POST", + authToken: token, + body: { + amount: amt, + method: payMethod, + userId: payUserId || undefined, + registrationId: payRegistrationId, + isDonation: false, + paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined, + } + }); + setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`); + setPayAmount(""); + setPayPaidAtLocal(""); + // Refresh registrations so the displayed outstanding balance updates + try { + const regs = await apiFetch("/api/registrations", { authToken: token }); + setAllRegistrations(Array.isArray(regs) ? regs : []); + } catch {} + } catch (e: any) { + setPayError(e?.message || "Failed to record payment"); + } finally { + setPaySubmitting(false); + } + }; + // Fuzzy match results (top 6, score threshold 0.45) const matchedUsers = useMemo(() => { if (userQuery.trim().length < 2) return []; @@ -193,6 +313,14 @@ export default function ManualRegistrationPage() { }); setMessage("Manual registration created successfully."); + // Pre-load the Record Payment tab with this registration so it's ready to go the + // moment a supervisor clicks over — the tab itself is never switched to automatically. + setAllRegistrations(prev => [res, ...prev.filter((r: any) => r.id !== res.id)]); + setPayUserId(res.userId || res.user?.id || ""); + setPayUserQuery(res.user?.name || guest.name || ""); + setPayRegistrationId(res.id); + setPayDropdownOpen(false); + // Reset guest/ticket fields so the next registration starts from a clean slate setGuest({ name: "", email: "", phoneNumber: "" }); setRegisterAsGuest(false); @@ -220,6 +348,19 @@ export default function ManualRegistrationPage() { )} +
+ + +
+ + {tab === 'register' && ( + <> {message &&
{message}
} {error &&
{error}
} @@ -447,6 +588,92 @@ export default function ManualRegistrationPage() {
Total due: R {totalDue.toFixed(2)}
+ + )} + + {tab === 'payment' && ( +
+ {payMessage &&
{payMessage}
} + {payError &&
{payError}
} + +
+
Record a payment
+
+
+ + setPayAmount(e.target.value)} /> +
+
+ + +
+
+ + { setPayUserQuery(e.target.value); setPayDropdownOpen(true); if (!e.target.value) { setPayUserId(""); setPayRegistrationId(""); } }} + onFocus={() => { if (payUserQuery.length >= 2) setPayDropdownOpen(true); }} + /> + {payDropdownOpen && payUserQuery.trim().length >= 2 && ( +
+ {payMatchedUsers.length > 0 ? ( + <> +
+ {payMatchedUsers.length} match{payMatchedUsers.length !== 1 ? "es" : ""} +
+ {payMatchedUsers.map(u => ( + + ))} + + ) : ( +
No matching users found.
+ )} +
+ )} +
+
+ + + {!payUserId &&
Search for a user above to see their registrations.
} +
+
+ + setPayPaidAtLocal(e.target.value)} max={new Date().toISOString().slice(0,16)} /> +
Leave blank to use current time
+
+
+
+ +
+
+
+ )} ); } diff --git a/frontend/src/app/dashboard/user/page.tsx b/frontend/src/app/dashboard/user/page.tsx index 9c7868a..0c737fe 100644 --- a/frontend/src/app/dashboard/user/page.tsx +++ b/frontend/src/app/dashboard/user/page.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; -import { apiFetch } from "@/lib/api"; +import { apiFetch, createFullPaymentCheckout } from "@/lib/api"; import { useRouter } from "next/navigation"; import { formatDate } from "@/lib/date"; import { formatPaymentMethod } from "@/lib/paymentMethod"; @@ -312,6 +312,41 @@ export default function UserDashboardPage() { } }; + // Creates a Yoco checkout for the full outstanding balance and redirects there directly — + // choosing a partial amount is only available from the supervisor payments dashboard. + const payNow = async (registrationId: string) => { + if (!token) return; + setError(null); + setInfo(null); + setDialog({ open: true, message: "Creating payment link…", loading: true }); + try { + const res = await createFullPaymentCheckout(token, registrationId); + if (res.priceUpdated) { + setDialog({ open: false, message: "", loading: false }); + setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`); + // Refresh this registration's billing so the displayed outstanding reflects the new price + try { + const now = new Date(); + const r = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }); + const pays = await apiFetch(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token }); + const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0); + const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); + setBilling(prev => ({ ...prev, [registrationId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } })); + } catch {} + return; + } + if (!res.redirectUrl) { + setDialog({ open: false, message: "", loading: false }); + setError("Failed to create checkout"); + return; + } + window.location.href = res.redirectUrl; + } catch (e: any) { + setDialog({ open: false, message: "", loading: false }); + setError(e?.message || "Failed to create checkout"); + } + }; + // Print helpers const buildTicketHtmlCard = (t: any, buyerName?: string) => { const eventTitle = t.event?.title || t.eventId || "Event"; @@ -1039,7 +1074,7 @@ export default function UserDashboardPage() { {canModifyActive && activeBill && activeBill.outstanding > 0 ? ( ) : ( <> diff --git a/frontend/src/app/dashboard/user/pay/page.tsx b/frontend/src/app/dashboard/user/pay/page.tsx deleted file mode 100644 index d69327e..0000000 --- a/frontend/src/app/dashboard/user/pay/page.tsx +++ /dev/null @@ -1,208 +0,0 @@ -"use client"; -import React, { Suspense, useEffect, useMemo, useState } from "react"; -import { useSearchParams } from "next/navigation"; -import { useAuth } from "@/hooks/useAuth"; -import { apiFetch } from "@/lib/api"; -import { useDismissingState } from "@/hooks/useDismissingState"; - -function MakePaymentContent() { - const searchParams = useSearchParams(); - const registrationId = searchParams.get("registrationId"); - const { token } = useAuth(); - const [loading, setLoading] = useState(false); - const [error, setError] = useDismissingState(null); - const [info, setInfo] = useDismissingState(null); - const [registration, setRegistration] = useState(null); - const [payments, setPayments] = useState([]); - const [amount, setAmount] = useState(""); - const [priceUpdated, setPriceUpdated] = useState<{ newTotal: number; message: string } | null>(null); - - // Compute effective unit price for a registration option. - // Uses priceSnapshot when available (authoritative backend price, variant-aware). - // Falls back to deadline-only early-bird calculation for legacy rows without a snapshot. - const optionUnitPrice = (opt: any, referenceTime: any, atTime: Date): number => { - if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) { - return Number(opt.priceSnapshot); - } - const eo = opt.eventOption; - const variantId: string | null = opt.variantId || null; - const base = (opt.variant?.price !== null && opt.variant?.price !== undefined) - ? Number(opt.variant.price) - : Number(eo?.price || 0); - const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : []; - const tiers = variantId - ? allTiers.filter((t: any) => t.variantId === variantId) - : allTiers.filter((t: any) => !t.variantId); - if (tiers.length === 0) return base; - const t = atTime ? new Date(atTime) : new Date(); - const ref = referenceTime ? new Date(referenceTime) : t; - const applicable = tiers - .map((x: any) => ({ ...x, deadline: new Date(x.deadline) })) - .filter((x: any) => (ref < x.deadline) && (t < x.deadline)) - .sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order||0) - (b.order||0) || a.price - b.price); - if (applicable.length === 0) return base; - const price = Number(applicable[0].price); - return (price >= 0) ? price : base; - }; - - const totals = useMemo(() => { - if (!registration) return { totalDue: 0, totalPaid: 0, outstanding: 0 }; - const now = new Date(); - // totalDue uses priceSnapshot — not time-dependent, consistent with what the backend charges - const totalDue = (registration.registrationOptions || []).reduce((s: number, opt: any) => s + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); - const totalPaid = payments.reduce((s: number, p: any) => s + (p.amount || 0), 0); - const outstanding = Math.max(0, totalDue - totalPaid); - return { totalDue, totalPaid, outstanding }; - }, [registration, payments]); - - const minAllowed = useMemo(() => { - if (totals.outstanding <= 0) return 0; - return totals.outstanding >= 15 ? 15 : totals.outstanding; - }, [totals.outstanding]); - - useEffect(() => { - (async () => { - if (!token || !registrationId) return; - try { - setError(null); - setLoading(true); - const reg = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }); - setRegistration(reg); - const pays = await apiFetch(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token }); - setPayments(pays); - } catch (e: any) { - setError(e?.message || "Failed to load registration"); - } finally { - setLoading(false); - } - })(); - }, [token, registrationId]); - - useEffect(() => { - if (totals.outstanding <= 0) { - setAmount(""); - } - }, [totals.outstanding]); - - const submit = async () => { - if (!token || !registrationId) return; - const amt = parseFloat(amount); - if (!(amt > 0)) { setError("Enter a valid amount"); return; } - if (amt > totals.outstanding) { setError(`Amount cannot exceed outstanding (R ${totals.outstanding.toFixed(2)})`); return; } - if (amt < minAllowed) { - if (totals.outstanding >= 15) { - setError("Minimum payment is R15"); - } else { - setError(`Please pay the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`); - } - return; - } - try { - setError(null); - setInfo(null); - setPriceUpdated(null); - setLoading(true); - const res = await apiFetch<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }>("/api/payments/yoco-checkout", { - method: "POST", - body: { - registrationId, - amount: amt, - successUrl: window.location.origin + "/payment/success", - cancelUrl: window.location.origin + "/payment/cancel", - failureUrl: window.location.origin + "/payment/failure", - }, - authToken: token, - }); - if (res.priceUpdated) { - // Early-bird price changed — show warning and reload registration data - setPriceUpdated({ newTotal: res.newTotal ?? 0, message: res.message ?? 'Prices have changed.' }); - // Reload registration so totals reflect updated priceSnapshot - try { - const reg = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }); - setRegistration(reg); - const pays = await apiFetch(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token }); - setPayments(pays); - } catch {} - setAmount(""); - return; - } - setInfo("Redirecting to payment..."); - window.location.href = res.redirectUrl!; - } catch (e: any) { - setError(e?.message || "Failed to create checkout"); - } finally { - setLoading(false); - } - }; - - if (!registrationId) return
Missing registrationId.
; - - return ( -
-

Make a payment

- {error &&

{error}

} - {info &&

{info}

} - {priceUpdated && ( -
- Pricing has changed. {priceUpdated.message} -
New outstanding: R {priceUpdated.newTotal.toFixed(2)}
- -
- )} - - {registration ? ( -
-
{registration.event?.title || registration.eventId}
-
Registration #{registration.id.slice(0,8)}
-
-
Total: R {totals.totalDue.toFixed(2)}
-
Paid: R {totals.totalPaid.toFixed(2)}
-
Outstanding: 0?"text-red-600":"text-green-700"}>R {totals.outstanding.toFixed(2)}
-
- {(() => { - const tiers = (registration.registrationOptions || []).flatMap((ro: any) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []); - const upcoming = tiers.map((t: any) => ({ ...t, deadline: new Date(t.deadline) })).filter((t: any) => new Date() < t.deadline).sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime()); - if (upcoming.length === 0) return null; - const d = upcoming[0].deadline as Date; - const dateStr = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' }); - return
Early bird pricing applies if paid before {dateStr}.
; - })()} -
- ) : ( -

Loading registration...

- )} - - - 0 ? totals.outstanding.toFixed(2) : "0"} - step="1" - placeholder={totals.outstanding >= 15 ? "Enter amount (min R15)" : `Enter amount (max R ${totals.outstanding.toFixed(2)})`} - value={amount} - onChange={(e) => setAmount(e.target.value)} - disabled={totals.outstanding <= 0} - className="w-full border rounded px-3 py-2 mb-1 disabled:opacity-60" - /> - {totals.outstanding > 0 && ( -

Minimum payment {totals.outstanding >= 15 ? "is R15" : `is the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`}.

- )} - -
- ); -} - -export default function MakePaymentPage() { - return ( - Loading...}> - - - ); -} diff --git a/frontend/src/app/registration/success/page.tsx b/frontend/src/app/registration/success/page.tsx index 89c85cb..f04c19f 100644 --- a/frontend/src/app/registration/success/page.tsx +++ b/frontend/src/app/registration/success/page.tsx @@ -22,6 +22,7 @@ function RegistrationSuccessContent() { const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [info, setInfo] = React.useState(null); + const [payLoading, setPayLoading] = React.useState(false); React.useEffect(() => { (async () => { @@ -79,9 +80,32 @@ function RegistrationSuccessContent() { } }, [reg, totalDue, router]); - const goPay = () => { + const goPay = async () => { if (!registrationId) return; - router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(registrationId)}`); + const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null; + if (!token) { setError('Please login to pay.'); return; } + try { + setError(null); + setPayLoading(true); + const { createFullPaymentCheckout } = await import('@/lib/api'); + const res = await createFullPaymentCheckout(token, registrationId); + if (res.priceUpdated) { + // Early-bird price changed since registration — surface the new total and + // reload the registration so the displayed totalDue reflects it, instead of redirecting. + setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`); + try { + const r = await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }); + if (r) setReg(r); + } catch {} + return; + } + if (!res.redirectUrl) { setError('Failed to create checkout'); return; } + window.location.href = res.redirectUrl; + } catch (e: any) { + setError(e?.message || 'Failed to create checkout'); + } finally { + setPayLoading(false); + } }; const goDashboard = () => { @@ -108,9 +132,9 @@ function RegistrationSuccessContent() { {(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && ( + >{payLoading ? "Creating checkout..." : "Pay with Yoco"} )}