Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
"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";
|
||||
|
||||
function MakePaymentContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const registrationId = searchParams.get("registrationId");
|
||||
const { token } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [registration, setRegistration] = useState<any | null>(null);
|
||||
const [payments, setPayments] = useState<any[]>([]);
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
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<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
const pays = await apiFetch<any[]>(`/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<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
const pays = await apiFetch<any[]>(`/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 <div className="p-6">Missing registrationId.</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto w-full p-6">
|
||||
<h1 className="text-2xl font-semibold mb-4">Make a payment</h1>
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
|
||||
{priceUpdated && (
|
||||
<div className="mb-4 p-3 border border-amber-300 bg-amber-50 rounded text-sm text-amber-800">
|
||||
<strong>Pricing has changed.</strong> {priceUpdated.message}
|
||||
<div className="mt-1">New outstanding: <strong>R {priceUpdated.newTotal.toFixed(2)}</strong></div>
|
||||
<button
|
||||
className="mt-2 text-xs underline text-amber-700 hover:text-amber-900"
|
||||
onClick={() => setPriceUpdated(null)}
|
||||
>OK, I understand — continue with new price</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registration ? (
|
||||
<div className="border rounded p-3 mb-4">
|
||||
<div className="font-medium">{registration.event?.title || registration.eventId}</div>
|
||||
<div className="text-sm text-gray-600">Registration #{registration.id.slice(0,8)}</div>
|
||||
<div className="mt-2 text-sm">
|
||||
<div>Total: R {totals.totalDue.toFixed(2)}</div>
|
||||
<div>Paid: R {totals.totalPaid.toFixed(2)}</div>
|
||||
<div>Outstanding: <span className={totals.outstanding>0?"text-red-600":"text-green-700"}>R {totals.outstanding.toFixed(2)}</span></div>
|
||||
</div>
|
||||
{(() => {
|
||||
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 <div className="mt-2 text-xs text-amber-700">Early bird pricing applies if paid before {dateStr}.</div>;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 mb-4">Loading registration...</p>
|
||||
)}
|
||||
|
||||
<label className="block text-sm font-medium mb-1">Amount (R)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={minAllowed.toFixed(2)}
|
||||
max={totals.outstanding > 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 && (
|
||||
<p className="text-xs text-gray-600 mb-3">Minimum payment {totals.outstanding >= 15 ? "is R15" : `is the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`}.</p>
|
||||
)}
|
||||
<button
|
||||
disabled={loading || totals.outstanding <= 0}
|
||||
onClick={submit}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded disabled:opacity-60"
|
||||
>{loading?"Creating checkout...": totals.outstanding <= 0 ? "No outstanding amount" : "Pay now"}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MakePaymentPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6">Loading...</div>}>
|
||||
<MakePaymentContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user