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>
182 lines
8.0 KiB
TypeScript
182 lines
8.0 KiB
TypeScript
"use client";
|
|
import React, { Suspense } from "react";
|
|
import { Navbar } from "@/components/layout/Navbar";
|
|
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";
|
|
|
|
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
|
|
|
function RegistrationSuccessContent() {
|
|
const search = useSearchParams();
|
|
const router = useRouter();
|
|
const { token } = useAuth();
|
|
const registrationId = search.get("registrationId") || search.get("id");
|
|
const fallbackTotalParam = search.get("totalDue");
|
|
const fallbackTotalDue = React.useMemo(() => {
|
|
const n = fallbackTotalParam ? Number(fallbackTotalParam) : 0;
|
|
return isNaN(n) ? 0 : n;
|
|
}, [fallbackTotalParam]);
|
|
|
|
// 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;
|
|
return isNaN(n) || n < 15 ? 0 : Math.round(n * 100) / 100;
|
|
}, [donationParam]);
|
|
|
|
const [reg, setReg] = React.useState<Registration | null>(null);
|
|
const [form, setForm] = React.useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [payLoading, setPayLoading] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
(async () => {
|
|
setError(null);
|
|
if (!registrationId) return;
|
|
try {
|
|
// Try to fetch registration and event form
|
|
const r = token ? await (await import('@/lib/api')).apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
|
|
if (r) {
|
|
setReg(r);
|
|
const ev = await (await import('@/lib/api')).apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(r.eventId)}`);
|
|
if (ev?.form) setForm(ev.form);
|
|
}
|
|
} catch (e) {
|
|
setError(errorMessage(e) || 'Failed to load details');
|
|
}
|
|
})();
|
|
}, [registrationId, token]);
|
|
|
|
const totalDue = React.useMemo(() => {
|
|
if (!reg) return 0;
|
|
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
|
|
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
|
if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed;
|
|
try {
|
|
return (reg.registrationOptions || []).reduce((s, ro) => {
|
|
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
|
|
? Number(ro.priceSnapshot)
|
|
: (ro.variant?.price ?? ro.eventOption?.price ?? 0);
|
|
return s + unit * (ro.quantity || 0);
|
|
}, 0);
|
|
} catch { return 0; }
|
|
}, [reg]);
|
|
|
|
React.useEffect(() => {
|
|
// 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 () => {
|
|
try {
|
|
if (token && reg?.id) {
|
|
await (await import('@/lib/api')).apiFetch('/api/tickets/generate', { method: 'POST', authToken: token, body: { registrationId: reg.id } });
|
|
}
|
|
} catch {}
|
|
setTimeout(() => router.replace('/dashboard'), 600);
|
|
})();
|
|
}
|
|
}, [reg, totalDue, router, token]);
|
|
|
|
const goPay = async () => {
|
|
if (!registrationId) return;
|
|
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, donation);
|
|
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<Registration>(`/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) {
|
|
setError(errorMessage(e) || 'Failed to create checkout');
|
|
} finally {
|
|
setPayLoading(false);
|
|
}
|
|
};
|
|
|
|
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");
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col">
|
|
<Navbar />
|
|
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
|
|
<div className="border rounded-xl p-6 bg-white shadow-sm">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-10 h-10 rounded-xl bg-green-50 flex items-center justify-center shrink-0">
|
|
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
|
</div>
|
|
<h1 className="text-2xl font-semibold text-gray-900">Registration successful</h1>
|
|
</div>
|
|
<p className="text-gray-700 mb-4">Thank you! Your registration has been created{registrationId ? ` (#${registrationId.slice(0,8)})` : ""}.</p>
|
|
{form?.isRequired && reg ? (
|
|
<div className="mb-6 p-3 border rounded bg-yellow-50 text-yellow-800">
|
|
<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>
|
|
) : payOnline ? (
|
|
<>
|
|
{donation > 0 && (
|
|
<p className="text-gray-700 mb-2">
|
|
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 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">
|
|
{payOnline && (
|
|
<button
|
|
onClick={goPay}
|
|
disabled={!registrationId || payLoading}
|
|
className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60"
|
|
>{payLoading ? "Creating checkout..." : "Pay online"}</button>
|
|
)}
|
|
<button
|
|
onClick={goDashboard}
|
|
className="px-4 py-2 rounded bg-gray-100 text-gray-800 hover:bg-gray-200"
|
|
>{payOnline ? "Go to Dashboard (Pay Later)" : "Go to Dashboard"}</button>
|
|
</div>
|
|
{error && <p className="text-sm text-red-600 mt-3">{error}</p>}
|
|
{!registrationId && (
|
|
<p className="text-sm text-red-600 mt-4">Missing registration reference. You can still go to your Dashboard to view registrations.</p>
|
|
)}
|
|
</div>
|
|
</main>
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function RegistrationSuccessPage() {
|
|
return (
|
|
<Suspense fallback={<div className="p-6">Loading...</div>}>
|
|
<RegistrationSuccessContent />
|
|
</Suspense>
|
|
);
|
|
}
|