Compare commits

...
3 Commits
Author SHA1 Message Date
joshua 55a7ee2970 Merge branch 'feat/hide-pay-online'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-23 15:05:01 +02:00
joshuaandClaude Opus 5.5 4bc7dbc989 Bump to 1.37.1
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:05:01 +02:00
joshuaandClaude Opus 5.5 71b9da2458 Hide "Pay online" when no provider is on or the amount is under R15
A shared useOnlinePayments hook (GET /api/payments/providers) gates the
registration success page, My Events (Make payment, Make donation), the
donate page, Sponsor a spot, the register form's donation box and the
supervisor Payment Link tab; they explain why where it helps.

Roadmap: SnapScan and Zapper dropped; later versions move up by two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:04:58 +02:00
18 changed files with 173 additions and 56 deletions
+7
View File
@@ -7,6 +7,13 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.37.1] - 2026-10-23
### Changed
- **No "Pay online" when it can't be used.** When no payment provider is switched on, or the amount is under the R15 minimum, the registration success page, My Events ("Make payment" and "Make donation"), the donate page, "Sponsor a spot", the donation box on the register form and the supervisor Payment Link tab no longer offer online payment. Where it helps, they say why and to pay at the event instead.
- Roadmap: SnapScan and Zapper are dropped (Yoco and PayFast are enough); later releases move up two versions — POPIA toolkit is next as 1.38.0.
## [1.37.0] - 2026-10-23
### Added
+2 -1
View File
@@ -206,7 +206,8 @@ Feedback → Attendance**.
- **PayFast** — the second online provider (card, instant EFT…), with sandbox and live
modes. When several providers are on, payers choose on the "Pay online" page; a
payment of a different amount from the checkout is held for review instead of being
recorded.
recorded. When no provider is on (or the amount is under R15), nothing offers to pay
online.
- **Payment links** — supervisors can generate and send a Yoco link for any
amount (including partial).
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.37.0",
"version": "1.37.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.37.0",
"version": "1.37.1",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.37.0",
"version": "1.37.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+6 -12
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-23, after **1.37.0**. The plan itself is in
Last updated: 2026-10-23, after **1.37.1**. The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -12,23 +12,17 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.37.1 | "Pay online" hidden when no provider is on or under R15; SnapScan/Zapper dropped from the roadmap |
| 1.37.0 | PayFast (signed form, ITN checks), `ProviderCheckout` with amount checks, the "Pay online" page (`/pay/<id>`) where payers choose a provider |
| 1.36.0 | Payment provider interface (`backend/src/payments`) with Yoco behind it, `ProviderTransaction`, `Payment.provider`, Site settings → Payments (on/off and order per provider), R15 minimum |
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
## Next up
1. **1.38.0 SnapScan → 1.39.0 Zapper** (one release each), each as a provider in
`backend/src/payments` (see "Where the payment code lives" below). Get the current API
documentation links from Joshua before building each one, and don't guess at an API —
sandbox/test credentials needed. They're QR/app payments: the `/pay/<id>` page is where the
QR shows (desktop) or the app deep link opens (mobile), polling until the webhook lands.
Still open from the roadmap: hide "pay online" buttons below R15 / when no provider is on
(today the server refuses with a clear message), and a "check payment status" /
reconcile job for webhooks that never arrive. Decisions: fees are **never** deducted or
passed on (people handle fees at cashup); minimum online payment **R15** for all providers.
2. **1.40.0–1.41.0 — POPIA toolkit** (spec §3 in the roadmap).
3. **1.42.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
1. **1.37.2 — payment status checks** for notifications that never arrive (see the
roadmap's payment edge cases).
2. **1.38.0–1.39.0 — POPIA toolkit** (spec §3 in the roadmap).
3. **1.40.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
`joshua/hope-events-hub`).
## Working on this repo
+23 -22
View File
@@ -17,8 +17,10 @@ reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gite
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page.
**Next: 1.38.0 SnapScan, then Zapper.**
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23).
**Next: 1.38.0 POPIA toolkit.**
**Repositories:**
@@ -32,7 +34,7 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
## Contents
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
2. [Payment providers: PayFast, SnapScan, Zapper](#2-payment-providers-payfast-snapscan-zapper)
2. [Payment providers: PayFast](#2-payment-providers-payfast)
3. [POPIA toolkit](#3-popia-toolkit)
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
@@ -267,11 +269,12 @@ it's not used for check-in decisions; "checked in" is per session
---
## 2. Payment providers: PayFast, SnapScan, Zapper
## 2. Payment providers: PayFast
Today Yoco is the only online provider. Add **PayFast**, **SnapScan** and
**Zapper**, and let the admin **enable one or more** in Site settings. At
checkout the user picks from the enabled ones.
Add **PayFast** next to Yoco, and let the admin **enable one or both** in Site
settings. At checkout the user picks from the enabled ones. (Shipped in
1.36.0–1.37.2. SnapScan and Zapper were planned too, but dropped on 2026-10-23:
two providers are enough.)
**Who:** Admins configure providers. Users choose at payment. Supervisors
reconcile.
@@ -280,10 +283,10 @@ reconcile.
| Setting | Options |
|---------|---------|
| Providers | Yoco · PayFast · SnapScan · Zapper — each with an **Enabled** toggle |
| Providers | Yoco · PayFast — each with an **Enabled** toggle |
| Credentials | Per provider (merchant id/key, API key, passphrase, webhook secret…), stored encrypted (`utils/encryption.js`, same as WhatsApp) |
| Mode | Live / Sandbox per provider (where supported) |
| Display order / default | Which one is shown first. Default order: Yoco · PayFast · SnapScan · Zapper |
| Display order / default | Which one is shown first. Default order: Yoco · PayFast |
| Minimum amount | **R15 for all providers** (one site-wide minimum for online payments) |
**Fees are not handled automatically.** Payments are recorded at the full
@@ -300,10 +303,7 @@ total. Fees are dealt with by people during **cashup**, as today. There is no
`getTransaction`, `refund` (if the provider supports it via API),
`listTransactions` (for reconciliation).
- Yoco is moved behind the same interface first (no behaviour change), then
PayFast, SnapScan and Zapper are added one release each.
- SnapScan and Zapper are QR/app-based: on desktop show the QR, on mobile
deep-link into the app; the page polls/waits for the webhook and moves on
automatically when payment lands.
PayFast is added.
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to
`ProviderTransaction` (`provider` column) so the existing reconciliation page
works for all providers.
@@ -726,14 +726,14 @@ keep adding to.
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
| 4 | 1.36.0 ✅ – 1.39.0 | Payment provider interface (Yoco moved behind it) → PayFast → SnapScan → Zapper, one release each | Payment code gets split out of the big controller as part of this |
| 5 | 1.40.0 – 1.41.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
| 6 | 1.42.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.43.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
| 8 | 1.44.0 – 1.45.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.46.0 – 1.48.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
| 5 | 1.38.0 – 1.39.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
| 6 | 1.40.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.41.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
| 8 | 1.42.0 – 1.43.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.44.0 – 1.46.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
| 10 | 1.47.0 – 1.49.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.50.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
Each phase should add matching help content (`frontend/src/content/help/`),
@@ -756,7 +756,8 @@ feature.
| — | Pro-rata rounding | Nearest R10 |
| — | Multi-campus | In scope (§12), inside one install |
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
| 7 | Payment provider order | Yoco · PayFast · SnapScan · Zapper. Joshua can supply documentation links |
| 7 | Payment provider order | Yoco · PayFast. Joshua can supply documentation links |
| — | SnapScan, Zapper | Dropped (2026-10-23): Yoco and PayFast are enough. Later versions moved up by two |
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
| — | Minimum online payment | R15 for all providers |
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
+30 -1
View File
@@ -1,4 +1,4 @@
import { test, expect, loginAs, api } from "./fixtures";
import { test, expect, seed, loginAs, api } from "./fixtures";
type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean };
@@ -34,4 +34,33 @@ test.describe("payment providers", () => {
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }] });
}
});
test("with no provider on, nothing offers to pay online", async ({ page }) => {
type EventWithOptions = { eventOptions: { id: string; isMainTicket: boolean }[] };
const event = await api<EventWithOptions>(page, "admin", "GET", `/api/events/${seed().events.paid}`);
const option = event.eventOptions.find((o) => o.isMainTicket)!;
const reg = await api<{ id: string }>(page, "admin", "POST", "/api/registrations", { eventId: seed().events.paid, options: [{ eventOptionId: option.id, quantity: 1 }] });
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: false }, { id: "payfast", enabled: false }] });
try {
await loginAs(page, "admin");
await page.goto(`/registration/success?registrationId=${reg.id}`);
await expect(page.getByText(/Online payment isn.t available/)).toBeVisible();
await expect(page.getByRole("button", { name: "Pay online" })).toHaveCount(0);
await page.goto("/dashboard/user");
await expect(page.getByRole("button", { name: "Make donation" })).toHaveCount(0);
await page.goto("/dashboard/user/donate");
await expect(page.getByText(/Online donations aren.t available/)).toBeVisible();
} finally {
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: false }] });
}
// Back on (when Yoco is set up in this environment), the button returns
const on = await api<{ providers: unknown[] }>(page, "admin", "GET", "/api/payments/providers");
if (on.providers.length > 0) {
await page.goto(`/registration/success?registrationId=${reg.id}`);
await expect(page.getByRole("button", { name: "Pay online" })).toBeVisible();
}
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.37.0",
"version": "1.37.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.37.0",
"version": "1.37.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.37.0",
"version": "1.37.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import type { Event, Payment, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useOnlinePayments } from "@/lib/onlinePayments";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
@@ -145,6 +146,7 @@ function UserSearchField({ allUsers, value, onChange, placeholder = "Search by n
function PaymentsContent() {
const { user, loading, token } = useAuth();
const online = useOnlinePayments();
const router = useRouter();
const search = useSearchParams();
@@ -541,11 +543,14 @@ function PaymentsContent() {
<ScanLine className="w-4 h-4" />
Reconcile
</label>
{/* Payment links are paid online, so only while a payment provider is on */}
{online.available && (
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
<Link2 className="w-4 h-4" />
Payment Link
</label>
)}
</div>
@@ -758,7 +763,7 @@ function PaymentsContent() {
</>
)}
{mode === 'link' && (
{mode === 'link' && online.available && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Create payment link</div>
<div className="grid sm:grid-cols-2 gap-3">
@@ -7,9 +7,11 @@ import { HandHeart } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useOnlinePayments } from "@/lib/onlinePayments";
export default function DonatePage() {
const { token } = useAuth();
const online = useOnlinePayments();
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState<string>("");
const [amount, setAmount] = useState<string>("");
@@ -72,6 +74,11 @@ export default function DonatePage() {
</div>
</div>
{online.ready && !online.available ? (
<div className="border rounded-xl p-5 bg-white shadow-sm text-sm text-gray-700">
Online donations aren&apos;t available at the moment. You can give at an event, or contact the organiser.
</div>
) : (
<div className="border rounded-xl p-5 bg-white shadow-sm">
{error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>}
@@ -99,6 +106,7 @@ export default function DonatePage() {
className="w-full bg-brand-600 hover:bg-brand-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-60 font-medium transition-colors"
>{loading?"Starting checkout...":"Donate"}</button>
</div>
)}
</div>
);
}
+13 -5
View File
@@ -2,6 +2,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date";
import { printTicketCards } from "@/lib/ticketPrint";
@@ -81,6 +82,7 @@ function effectiveEventOptionUnitPrice(eo: EventOption, variantId: string | null
export default function UserDashboardPage() {
const { token, user } = useAuth();
const online = useOnlinePayments();
const [registrations, setRegistrations] = useState<Registration[]>([]);
const [tickets, setTickets] = useState<TicketRecord[]>([]);
const [error, setError] = useDismissingState<string | null>(null);
@@ -654,10 +656,12 @@ 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-brand-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-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/donate")}
>Make donation</button>
{online.available && (
<button
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/donate")}
>Make donation</button>
)}
</div>
</div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
@@ -1194,7 +1198,7 @@ export default function UserDashboardPage() {
className="px-3 py-1.5 text-sm 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={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
>Invoice</button>
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
{canModifyActive && activeBill && online.canPay(activeBill.outstanding) && (
<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={() => payNow(activeReg.id)}
@@ -1207,6 +1211,10 @@ export default function UserDashboardPage() {
}}>Print all tickets</button>
</div>
{canModifyActive && activeBill && activeBill.outstanding > 0 && online.reason(activeBill.outstanding) && (
<p className="text-xs text-gray-500">{online.reason(activeBill.outstanding)}</p>
)}
{/* 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">
@@ -3,6 +3,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
@@ -375,6 +376,7 @@ function DonationPicker({
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
const { token, user } = useAuth();
const online = useOnlinePayments();
const eventId = baseEvent.id;
// A waitlist claim link (?waitlist=<token>): the places held for this person are
@@ -507,7 +509,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
const [donationChoice, setDonationChoice] = useState<DonationChoice>({ kind: "none" });
const [customDonation, setCustomDonation] = useState("");
const presets = useMemo(() => donationPresets(total), [total]);
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut;
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut && online.available;
const donation = !showDonation ? 0
: donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0)
: donationChoice.kind === "custom" ? Math.max(0, Math.round((parseFloat(customDonation) || 0) * 100) / 100)
+11 -3
View File
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { CheckCircle2 } from "lucide-react";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types";
@@ -108,6 +109,11 @@ function RegistrationSuccessContent() {
}
};
const online = useOnlinePayments();
const owed = totalDue > 0 || (!reg && fallbackTotalDue > 0);
const payAmount = (reg ? totalDue : fallbackTotalDue) + donation;
const payOnline = owed && online.canPay(payAmount);
const goDashboard = () => {
router.push("/dashboard/user");
};
@@ -129,7 +135,7 @@ function RegistrationSuccessContent() {
<div className="font-medium">This event requires attendee details.</div>
<div className="text-sm">Please complete one form per main ticket to receive tickets. You can also do this later from your dashboard, but tickets cannot be generated until completed.</div>
</div>
) : (totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? (
) : payOnline ? (
<>
{donation > 0 && (
<p className="text-gray-700 mb-2">
@@ -138,10 +144,12 @@ function RegistrationSuccessContent() {
)}
<p className="text-gray-700 mb-6">Would you like to pay online now?</p>
</>
) : owed && online.reason(payAmount) ? (
<p className="text-gray-700 mb-6">{online.reason(payAmount)}</p>
) : null}
<div className="flex flex-col sm:flex-row gap-3 mt-4">
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && (
{payOnline && (
<button
onClick={goPay}
disabled={!registrationId || payLoading}
@@ -151,7 +159,7 @@ function RegistrationSuccessContent() {
<button
onClick={goDashboard}
className="px-4 py-2 rounded bg-gray-100 text-gray-800 hover:bg-gray-200"
>{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? "Go to Dashboard (Pay Later)" : "Go to Dashboard"}</button>
>{payOnline ? "Go to Dashboard (Pay Later)" : "Go to Dashboard"}</button>
</div>
{error && <p className="text-sm text-red-600 mt-3">{error}</p>}
{!registrationId && (
@@ -5,6 +5,7 @@ import { HeartHandshake, X } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils";
import { useOnlinePayments } from "@/lib/onlinePayments";
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
@@ -30,6 +31,7 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [returned, setReturned] = useState<string | null>(null);
const online = useOnlinePayments();
useEffect(() => {
apiFetch<Sponsorship>(`/api/sponsorships/event/${encodeURIComponent(eventId)}`).then(setData).catch(() => setData(null));
@@ -39,7 +41,8 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
} catch { /* no window */ }
}, [eventId]);
if (!data?.enabled) return null;
// Sponsoring is paid online, so it's offered only when online payment is possible.
if (!data?.enabled || (!online.available && !returned)) return null;
const price = data.spotPrice || null;
const loggedIn = !!(token && user);
const total = mode === "spots" && price ? spots * price : Number(amount) || 0;
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
type ProvidersResponse = { providers: { id: string; label: string }[]; minimumAmount: number };
export type OnlinePayments = {
/** The answer has arrived (until then, nothing offers online payment). */
ready: boolean;
/** At least one payment provider is switched on and set up. */
available: boolean;
/** Online payments must be at least this much (R15). */
minimum: number;
/** Whether `amount` can be paid online now. */
canPay: (amount: number) => boolean;
/** Why `amount` can't be paid online, for showing instead of the button; null when it can. */
reason: (amount: number) => string | null;
};
// One request per page load, shared by every component that asks.
let pending: Promise<ProvidersResponse | null> | null = null;
function load() {
if (!pending) pending = apiFetch<ProvidersResponse>("/api/payments/providers").catch(() => null);
return pending;
}
/**
* Whether people can pay online: only when a payment provider is enabled (Site settings →
* Payments) and the amount is at least the online minimum. "Pay online" buttons use this to
* stay hidden otherwise.
*/
export function useOnlinePayments(): OnlinePayments {
const [state, setState] = useState<{ ready: boolean; available: boolean; minimum: number }>({ ready: false, available: false, minimum: 15 });
useEffect(() => {
let cancelled = false;
load().then(res => {
if (!cancelled) setState({ ready: true, available: !!res && res.providers.length > 0, minimum: res?.minimumAmount ?? 15 });
});
return () => { cancelled = true; };
}, []);
const canPay = (amount: number) => state.ready && state.available && amount >= state.minimum;
const reason = (amount: number) => {
if (!state.ready || canPay(amount)) return null;
if (!state.available) return "Online payment isn't available at the moment. You can pay at the event, or contact the organiser.";
return `Online payments must be at least R${state.minimum}. You can pay this at the event.`;
};
return { ...state, canPay, reason };
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.37.0",
"version": "1.37.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.37.0",
"version": "1.37.1",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.37.0",
"version": "1.37.1",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",