PayFast provider, Pay online page, amount checks on payments
- PayFast (custom integration): signed form (PHP-style encoding, checked against a real sandbox), ITN checks (signature over all posted fields, PayFast host, VALID from /eng/query/validate, amount vs checkout). - ProviderCheckout records every checkout (amount, metadata, request); webhooks match payments to it and hold a different amount for review (ProviderTransaction.note). - /pay/<id>: payers choose a provider when several are on, and the browser posts PayFast's form from there, so payment links work for every provider. - Site settings → Payments: PayFast card (merchant ID/key, encrypted passphrase, sandbox/live) while PayFast is enabled. - Payer-facing wording no longer names Yoco; legal pages name both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -587,7 +587,7 @@ function EmailAttendeesPageInner() {
|
||||
<li><code>{'{{event.title}}'}</code> — the event title.</li>
|
||||
<li><code>{'{{event.start}}'}</code> — the event start date/time (local).</li>
|
||||
<li><code>{'{{balance}}'}</code> — outstanding amount across the attendee’s registrations for the selected event.</li>
|
||||
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
<li><code>{'{{payment.link}}'}</code> — a direct online payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
</ul>
|
||||
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>. Pay here: <code>{'{{payment.link}}'}</code></p>
|
||||
<p className="text-[11px] text-gray-500">To add new placeholders, extend <span className="font-mono">backend/src/utils/placeholders.js</span> and update this help.</p>
|
||||
|
||||
@@ -47,6 +47,8 @@ type ProviderTx = {
|
||||
methodType?: string | null;
|
||||
createdDate?: string | null;
|
||||
metadata?: { userId?: string; eventId?: string } | null;
|
||||
// Why it wasn't recorded automatically (e.g. paid a different amount from the checkout)
|
||||
note?: string | null;
|
||||
};
|
||||
|
||||
function RegistrationOptions({ regs, regOutstanding }: {
|
||||
@@ -577,7 +579,10 @@ function PaymentsContent() {
|
||||
<React.Fragment key={tx.id}>
|
||||
<tr className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 pr-3 whitespace-nowrap">{tx.createdDate ? new Date(tx.createdDate).toLocaleString() : '-'}</td>
|
||||
<td className="py-2 pr-3">{tx.providerLabel}</td>
|
||||
<td className="py-2 pr-3">
|
||||
{tx.providerLabel}
|
||||
{tx.note && <div className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded px-1.5 py-0.5 mt-1 max-w-[16rem]">{tx.note}</div>}
|
||||
</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs break-all max-w-[10rem]">{tx.externalId}</td>
|
||||
<td className="py-2 pr-3">R {(Number(tx.amount || 0)/100).toFixed(2)}</td>
|
||||
<td className="py-2 pr-3 break-all max-w-[8rem]">{tx.checkoutId || '-'}</td>
|
||||
|
||||
@@ -699,7 +699,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
<li><code>{"{{event.title}}"}</code> — the event title.</li>
|
||||
<li><code>{"{{event.start}}"}</code> — the event start date/time.</li>
|
||||
<li><code>{"{{balance}}"}</code> — outstanding balance for the event.</li>
|
||||
<li><code>{"{{payment.link}}"}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
<li><code>{"{{payment.link}}"}</code> — a direct online payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
</ul>
|
||||
<p className="text-[11px] text-gray-500 mt-2">
|
||||
Preference indicators: <span className="text-green-700 bg-green-50 px-1 rounded">WA</span> = WhatsApp only,{" "}
|
||||
|
||||
@@ -313,13 +313,13 @@ export default function UserDashboardPage() {
|
||||
else sendReceipt(id, channel);
|
||||
};
|
||||
|
||||
// Creates a Yoco checkout for the full outstanding balance and redirects there directly —
|
||||
// Creates an online checkout for the full outstanding balance and redirects there directly —
|
||||
// choosing a partial amount is only available from the supervisor payments dashboard.
|
||||
const payNow = async (registrationId: string) => {
|
||||
if (!token) return;
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setDialog({ open: true, message: "Creating payment link…", loading: true, loadingTitle: "Creating payment link…", loadingSubtitle: "Please wait while we redirect you to Yoco." });
|
||||
setDialog({ open: true, message: "Creating payment link…", loading: true, loadingTitle: "Creating payment link…", loadingSubtitle: "Please wait while we take you to the secure payment page." });
|
||||
try {
|
||||
const res = await createFullPaymentCheckout(token, registrationId);
|
||||
if (res.priceUpdated) {
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function PrivacyPolicyPage() {
|
||||
<li>We don't sell your data, and only share it with the services that process payments and deliver messages.</li>
|
||||
<li>If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.</li>
|
||||
<li>For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.</li>
|
||||
<li>Payments go through Yoco; we never see your card details.</li>
|
||||
<li>Online payments go through Yoco or PayFast; we never see your card details.</li>
|
||||
<li>We send tickets, updates and security alerts by email and/or WhatsApp (powered by WAWP).</li>
|
||||
<li>Visit statistics are only collected if you click "Allow" on the cookie notice — no third-party analytics.</li>
|
||||
<li>You can ask what we hold about you, or delete your account, at any time. We keep data for up to 5 years after your last event registration.</li>
|
||||
@@ -135,7 +135,7 @@ function Section({
|
||||
"• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.",
|
||||
"• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.",
|
||||
"• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).",
|
||||
"• Payment information: processed by Yoco; we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.",
|
||||
"• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.",
|
||||
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
|
||||
"• Communication data: emails or messages exchanged with our team.",
|
||||
"• Visit statistics: only if you allow them on the cookie notice (see section 11).",
|
||||
@@ -198,7 +198,7 @@ function Section({
|
||||
],
|
||||
sharing: [
|
||||
"We do not sell or rent your personal information. Outside our organisation, it is shared only with:",
|
||||
"• Yoco (a South African payment processor), to process payments securely.",
|
||||
"• Yoco and PayFast (South African payment processors), to process payments securely — your name and email may be passed to PayFast to fill in its payment page.",
|
||||
"• WAWP (wawp.net), a WhatsApp Business API provider, to deliver WhatsApp messages — only your mobile number and the message content, solely for delivery.",
|
||||
"• Our email delivery provider, to deliver emails.",
|
||||
"• Anyone we are required to share it with under South African law.",
|
||||
@@ -236,7 +236,7 @@ function Section({
|
||||
"• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose \"No thanks\", nothing is counted and no visit id is created.",
|
||||
"Third parties:",
|
||||
"• Event pages with a location show an embedded Google Maps map. Google may set its own cookies when the map loads (see Google's privacy policy).",
|
||||
"• Payments open Yoco's secure checkout page, which uses its own cookies on Yoco's site.",
|
||||
"• Payments open the payment provider's secure checkout page (Yoco or PayFast), which uses its own cookies on the provider's site.",
|
||||
"You can change your cookie choice at any time with \"Cookie settings\" at the bottom of every page, or with the button above. You can also clear your browser's storage in your browser settings; this signs you out.",
|
||||
],
|
||||
security: [
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function TermsOfUsePage() {
|
||||
<li>Provide accurate information during registration and payments.</li>
|
||||
<li>Keep your login details safe.</li>
|
||||
<li>Respect that under 18s need parental permission to register.</li>
|
||||
<li>Understand that all payments go through Yoco, and we don't store your card details.</li>
|
||||
<li>Understand that online payments go through our payment providers (Yoco or PayFast), and we don't store your card details.</li>
|
||||
<li>Accept that we may contact you via email and/or WhatsApp (based on your preference) about events you register for, your tickets, and account security alerts.</li>
|
||||
<li>Only enter other people's details (when booking for them or passing on a ticket) if you're allowed to.</li>
|
||||
<li>Where an event allows it, you can pass your ticket to someone else before the event — it moves to them once they accept, with a new QR code.</li>
|
||||
@@ -140,7 +140,7 @@ function Section({
|
||||
`Accounts may also be created on your behalf — by an administrator (e.g. for walk-in event registration), or when someone registers you for an event or transfers a ticket to you. You will receive an activation link by email or WhatsApp, and can choose whether to activate the account.`,
|
||||
],
|
||||
payments: [
|
||||
"Payments are processed securely through Yoco, a third-party payment processor.",
|
||||
"Online payments are processed securely by third-party payment processors (Yoco or PayFast).",
|
||||
`${orgName} does not store or process credit card information.`,
|
||||
"Payment confirmations and receipts are issued electronically.",
|
||||
"All prices are listed in South African Rand (ZAR) unless stated otherwise.",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { CreditCard } from "lucide-react";
|
||||
|
||||
type ProviderForm = { action: string; fields: Record<string, string> };
|
||||
type PayPage = {
|
||||
id: string;
|
||||
status: "open" | "paid";
|
||||
amount: number;
|
||||
description: string;
|
||||
provider: string | null;
|
||||
providers: { id: string; label: string }[];
|
||||
cancelUrl: string | null;
|
||||
redirectUrl: string | null;
|
||||
form: ProviderForm | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The payer's page for an online checkout (/pay/<id>): choose how to pay when several payment
|
||||
* providers are enabled, or — for a provider that takes a signed form (PayFast) — post that
|
||||
* form. Payment links in emails, WhatsApp and invoice QR codes can point here.
|
||||
*/
|
||||
export default function PayPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params?.id;
|
||||
const [page, setPage] = useState<PayPage | null>(null);
|
||||
const [form, setForm] = useState<ProviderForm | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const go = useCallback((res: { redirectUrl: string | null; form: ProviderForm | null }) => {
|
||||
if (res.form) setForm(res.form);
|
||||
else if (res.redirectUrl) window.location.href = res.redirectUrl;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
apiFetch<PayPage>(`/api/payments/pay/${encodeURIComponent(id)}`)
|
||||
.then(p => {
|
||||
setPage(p);
|
||||
if (p.status === "open") go(p);
|
||||
})
|
||||
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this payment."));
|
||||
}, [id, go]);
|
||||
|
||||
// Once a provider's form is known, the browser posts it to the provider.
|
||||
useEffect(() => { if (form) formRef.current?.submit(); }, [form]);
|
||||
|
||||
const choose = async (provider: string) => {
|
||||
if (!id) return;
|
||||
setBusy(provider);
|
||||
setError(null);
|
||||
try {
|
||||
go(await apiFetch<{ redirectUrl: string | null; form: ProviderForm | null }>(`/api/payments/pay/${encodeURIComponent(id)}`, { method: "POST", body: { provider } }));
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Couldn't start the payment. Please try again.");
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const choosing = page?.status === "open" && !page.provider && !form;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 px-4 py-10 max-w-xl mx-auto w-full">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<CreditCard className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Pay online</h1>
|
||||
</div>
|
||||
{error && <div className="border rounded-xl p-4 mb-4 bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{!page && !error && <div className="text-sm text-gray-600">Loading…</div>}
|
||||
{page && (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<div>
|
||||
{page.description && <div className="font-semibold text-gray-900">{page.description}</div>}
|
||||
<div className="text-2xl font-semibold mt-1">R{page.amount.toFixed(2)}</div>
|
||||
</div>
|
||||
{page.status === "paid" ? (
|
||||
<p className="text-sm text-green-800 bg-green-50 border border-green-200 rounded-lg p-3">This has been paid. Thank you!</p>
|
||||
) : choosing ? (
|
||||
page.providers.length === 0 ? (
|
||||
<p className="text-sm text-amber-900 bg-amber-50 border border-amber-200 rounded-lg p-3">Online payments aren't available right now. Please pay at the door or contact the organiser.</p>
|
||||
) : (
|
||||
<div className="space-y-2" role="group" aria-label="How would you like to pay?">
|
||||
<p className="text-sm text-gray-600">How would you like to pay?</p>
|
||||
{page.providers.map(p => (
|
||||
<button key={p.id} type="button" disabled={!!busy} onClick={() => choose(p.id)}
|
||||
className="w-full px-4 py-3 rounded-lg border border-brand-300 bg-white hover:bg-brand-50 text-left font-medium disabled:opacity-50">
|
||||
{busy === p.id ? `Taking you to ${p.label}…` : `Pay with ${p.label}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">Taking you to the payment page…</p>
|
||||
)}
|
||||
{form && (
|
||||
<form ref={formRef} method="POST" action={form.action}>
|
||||
{Object.entries(form.fields).map(([name, value]) => <input key={name} type="hidden" name={name} value={value} />)}
|
||||
<noscript><button type="submit" className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium">Continue to payment</button></noscript>
|
||||
<button type="submit" className="text-sm text-brand-700 underline">Not moving? Continue to payment</button>
|
||||
</form>
|
||||
)}
|
||||
{page.status === "open" && page.cancelUrl && (
|
||||
<a href={page.cancelUrl} className="block text-sm text-gray-500 hover:underline">Cancel</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -502,7 +502,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
// a code, so its total wins.
|
||||
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : total;
|
||||
|
||||
// Optional donation on top of the tickets — paid in the same Yoco checkout and recorded
|
||||
// Optional donation on top of the tickets — paid in the same online checkout and recorded
|
||||
// as a separate donation payment. Only offered when there's something to pay online.
|
||||
const [donationChoice, setDonationChoice] = useState<DonationChoice>({ kind: "none" });
|
||||
const [customDonation, setCustomDonation] = useState("");
|
||||
|
||||
@@ -21,7 +21,7 @@ function RegistrationSuccessContent() {
|
||||
return isNaN(n) ? 0 : n;
|
||||
}, [fallbackTotalParam]);
|
||||
|
||||
// Optional donation picked on the register form; added to the Yoco checkout as its own line.
|
||||
// Optional donation picked on the register form; added to the online checkout as its own line.
|
||||
const donationParam = search.get("donation");
|
||||
const donation = React.useMemo(() => {
|
||||
const n = donationParam ? Number(donationParam) : 0;
|
||||
@@ -67,7 +67,7 @@ function RegistrationSuccessContent() {
|
||||
}, [reg]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// If free registration, do not show Yoco and redirect to dashboard after a short delay
|
||||
// If free registration, don't offer online payment and redirect to dashboard after a short delay
|
||||
if (reg && totalDue === 0) {
|
||||
// attempt ticket generation if status is paid (may fail if required forms aren't completed)
|
||||
(async () => {
|
||||
@@ -136,7 +136,7 @@ function RegistrationSuccessContent() {
|
||||
Thank you for adding a donation of <strong>R{donation.toFixed(2)}</strong>. It's included when you pay now.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-gray-700 mb-6">Would you like to pay with Yoco now?</p>
|
||||
<p className="text-gray-700 mb-6">Would you like to pay online now?</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -146,7 +146,7 @@ function RegistrationSuccessContent() {
|
||||
onClick={goPay}
|
||||
disabled={!registrationId || payLoading}
|
||||
className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60"
|
||||
>{payLoading ? "Creating checkout..." : "Pay with Yoco"}</button>
|
||||
>{payLoading ? "Creating checkout..." : "Pay online"}</button>
|
||||
)}
|
||||
<button
|
||||
onClick={goDashboard}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Wallet } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
interface PayFastConfig {
|
||||
merchantId: string;
|
||||
merchantKey: string;
|
||||
passphraseMasked: string;
|
||||
hasPassphrase: boolean;
|
||||
mode: "sandbox" | "live";
|
||||
notifyUrl: string;
|
||||
notifyUrlProblem: string | null;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
const primaryBtn = "px-3 py-1.5 text-sm rounded-lg bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white font-medium";
|
||||
|
||||
/**
|
||||
* PayFast settings (Site settings → Payments, while PayFast is enabled): merchant ID and key,
|
||||
* the passphrase that signs payments (stored encrypted), and sandbox or live.
|
||||
* onChanged: called after a save, so the Payments tab can refresh whether PayFast is set up.
|
||||
*/
|
||||
export function PayFastSettings({ active, onChanged }: { active: boolean; onChanged?: () => void }) {
|
||||
const { token } = useAuth();
|
||||
const [cfg, setCfg] = useState<PayFastConfig | null>(null);
|
||||
const [merchantId, setMerchantId] = useState("");
|
||||
const [merchantKey, setMerchantKey] = useState("");
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [mode, setMode] = useState<"sandbox" | "live">("sandbox");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const c = await apiFetch<PayFastConfig>("/api/payfast/config", { authToken: token });
|
||||
setCfg(c);
|
||||
setMerchantId(c.merchantId);
|
||||
setMerchantKey(c.merchantKey);
|
||||
setMode(c.mode);
|
||||
setPassphrase("");
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to load PayFast settings" });
|
||||
}
|
||||
}, [token, setMessage]);
|
||||
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiFetch("/api/payfast/config", {
|
||||
method: "PUT", authToken: token,
|
||||
// An empty passphrase box keeps the saved one.
|
||||
body: { merchantId, merchantKey, mode, ...(passphrase ? { passphrase } : {}) },
|
||||
});
|
||||
setMessage({ type: "ok", text: "PayFast settings saved." });
|
||||
await load();
|
||||
onChanged?.();
|
||||
} catch (err) {
|
||||
setMessage({ type: "err", text: errorMessage(err) || "Could not save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removePassphrase = async () => {
|
||||
if (!token || !window.confirm("Remove the passphrase? Only do this if it's also removed on your PayFast dashboard.")) return;
|
||||
try {
|
||||
await apiFetch("/api/payfast/config", { method: "PUT", authToken: token, body: { passphrase: "" } });
|
||||
setMessage({ type: "ok", text: "Passphrase removed." });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setMessage({ type: "err", text: errorMessage(err) || "Could not remove it" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Wallet className="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">PayFast payments</h2>
|
||||
<p className="text-xs text-gray-500">Card, instant EFT and more through PayFast. Find these on your PayFast (or Sandbox) dashboard under Settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm p-2.5 rounded-lg ${message.type === "ok" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>{message.text}</div>
|
||||
)}
|
||||
|
||||
{!cfg ? (
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
) : (
|
||||
<form onSubmit={save} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1" htmlFor="pf-merchant-id">Merchant ID</label>
|
||||
<input id="pf-merchant-id" className={inputCls} value={merchantId} onChange={e => setMerchantId(e.target.value)} inputMode="numeric" placeholder="From your PayFast dashboard" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1" htmlFor="pf-merchant-key">Merchant key</label>
|
||||
<input id="pf-merchant-key" className={inputCls} value={merchantKey} onChange={e => setMerchantKey(e.target.value)} placeholder="From your PayFast dashboard" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1" htmlFor="pf-passphrase">Salt passphrase</label>
|
||||
<input id="pf-passphrase" type="password" autoComplete="off" className={inputCls} value={passphrase} onChange={e => setPassphrase(e.target.value)}
|
||||
placeholder={cfg.hasPassphrase ? `Saved (${cfg.passphraseMasked}) — type to replace` : "The passphrase set on your PayFast dashboard"} />
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Must match the one on your PayFast dashboard exactly, or payments are refused. Stored encrypted.
|
||||
{cfg.hasPassphrase && <> <button type="button" className="underline" onClick={removePassphrase}>Remove</button></>}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs font-medium text-gray-700 mb-1">Mode</span>
|
||||
<div className="flex gap-4 text-sm" role="radiogroup" aria-label="PayFast mode">
|
||||
<label className="flex items-center gap-1.5"><input type="radio" name="pf-mode" checked={mode === "sandbox"} onChange={() => setMode("sandbox")} /> Sandbox (testing)</label>
|
||||
<label className="flex items-center gap-1.5"><input type="radio" name="pf-mode" checked={mode === "live"} onChange={() => setMode("live")} /> Live</label>
|
||||
</div>
|
||||
{mode === "live" && <p className="text-xs text-amber-700 mt-1">Live takes real money. Use your live merchant ID, key and passphrase — the sandbox ones won't work.</p>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
Payment notifications go to <span className="font-mono break-all">{cfg.notifyUrl || "(not set)"}</span>.
|
||||
{cfg.notifyUrlProblem && <p className="text-amber-700 mt-1">{cfg.notifyUrlProblem}</p>}
|
||||
</div>
|
||||
<button type="submit" className={primaryBtn} disabled={saving}>{saving ? "Saving…" : "Save PayFast settings"}</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { YocoTab } from "@/components/admin/YocoTab";
|
||||
import { PayFastSettings } from "@/components/admin/PayFastSettings";
|
||||
|
||||
type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean };
|
||||
|
||||
@@ -119,6 +120,11 @@ export function PaymentsTab({ active }: { active: boolean }) {
|
||||
<YocoTab active={active} onChanged={load} />
|
||||
</div>
|
||||
)}
|
||||
{providers?.some(p => p.id === "payfast" && p.enabled) && (
|
||||
<div className="border-t pt-5">
|
||||
<PayFastSettings active={active} onChanged={load} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const rand = (n: number) => `R${n.toLocaleString("en-ZA", { minimumFractionDigit
|
||||
/**
|
||||
* "Sponsor a spot": progress towards the event's sponsorship goal, and a form to fund
|
||||
* one or more spots (or any amount) for people who can't afford to come. Payment is
|
||||
* through Yoco; the money lands in the event's sponsorship pool.
|
||||
* online; the money lands in the event's sponsorship pool.
|
||||
*/
|
||||
export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTitle: string }) {
|
||||
const { token, user } = useAuth();
|
||||
|
||||
@@ -17,7 +17,7 @@ export const dashboardUserDonateHelpContent: HelpContent = {
|
||||
icon: HandHeart,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>Pick an event to support and an amount (minimum R15), then you'll be redirected to a secure Yoco checkout to complete payment.</p>
|
||||
<p>Pick an event to support and an amount (minimum R15), then you'll be taken to a secure online checkout to complete payment.</p>
|
||||
<GuideItem icon={HandHeart} title="Not tied to a registration" tone="rose">
|
||||
A donation is separate from paying for your own ticket — it's a direct contribution, and it isn't required to register for or attend an event.
|
||||
</GuideItem>
|
||||
|
||||
@@ -26,7 +26,7 @@ export const eventDetailHelpContent: HelpContent = {
|
||||
"N remaining" appears once stock runs low, and "Sold out" once an option is fully booked. Some events have a waitlist: tap "Join waitlist" and you'll be emailed or WhatsApped a link to claim a place if one opens up (it's held for you for a limited time).
|
||||
</GuideItem>
|
||||
<GuideItem icon={HeartHandshake} title="Sponsor a spot" tone="rose">
|
||||
Some events let you pay for a place for someone who can't afford it. Tap "Sponsor a spot", choose one or more spots (or any amount), and pay securely with Yoco. You can give anonymously or add a name and a message for the organisers. You'll get a receipt, and who is helped is never shared.
|
||||
Some events let you pay for a place for someone who can't afford it. Tap "Sponsor a spot", choose one or more spots (or any amount), and pay securely online. You can give anonymously or add a name and a message for the organisers. You'll get a receipt, and who is helped is never shared.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="Registering for other people?" tone="violet">
|
||||
Some events let you name each person you're booking for. Each named person gets their own ticket and their own attendee form, and you can choose to have every ticket sent to you or each person their own. On youth events, you'll give each person's date of birth; for anyone under 18, their parent or guardian is asked to give consent by email and WhatsApp before they can be checked in.
|
||||
|
||||
@@ -110,6 +110,9 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
<GuideItem icon={KeyRound} title="Yoco" tone="green">
|
||||
Shown while Yoco is ticked. Paste the secret key, then generate the webhook (or enter its secret) so payments are confirmed automatically. Secrets are stored encrypted and never shown in full.
|
||||
</GuideItem>
|
||||
<GuideItem icon={KeyRound} title="PayFast" tone="green">
|
||||
Shown while PayFast is ticked. Enter the merchant ID, merchant key and salt passphrase from your PayFast dashboard (Settings), and choose Sandbox to test or Live for real payments. The passphrase must match the dashboard exactly. When more than one provider is ticked, payers choose how to pay.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CreditCard} title="Minimum and fees" tone="gray">
|
||||
Online payments must be at least R15. Payments are recorded at the full amount paid; provider fees aren't deducted or added to the payer's total — deal with them at cashup.
|
||||
</GuideItem>
|
||||
|
||||
@@ -25,7 +25,7 @@ export const supervisorPaymentsHelpContent: HelpContent = {
|
||||
Assign an unassigned donation to a registration with an outstanding balance. Donations can be partially allocated, leaving the remainder available for another registration. Money from "Sponsor a spot" is marked as a sponsorship. Tick "Only the sponsorship pool" to assign just those, and you'll see the sponsor's message. The Donations breakdown report shows each event's pool: received, assigned and remaining.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ScanLine} title="Reconcile" tone="green">
|
||||
Match unreconciled online payments (from Yoco, or another payment provider) to a registration, log them as a donation, or ignore them.
|
||||
Match unreconciled online payments (from Yoco, PayFast…) to a registration, log them as a donation, or ignore them. A payment of a different amount from its checkout lands here with a note — check it in the provider's dashboard first.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Link2} title="Payment Link" tone="violet">
|
||||
Generate an online payment link for a user — copy it or send by email/WhatsApp. It uses the default payment provider (Site settings → Payments). Online payments must be at least R15. Generating a new link invalidates the previous one.
|
||||
|
||||
Reference in New Issue
Block a user