Two consistency fixes requested after the payment-method work: 1. Registration status (pending/confirmed/partial_paid/paid/cancelled) was printed as a raw string on the user dashboard. Added RegistrationStatusBadge mirroring the existing EventStatusBadge pattern, using the same status colors already established on dashboard/admin/registrations. 2. Inline success/error banners across dashboard pages persisted indefinitely. Added a shared useDismissingState hook (drop-in useState replacement that auto-clears a truthy value after 7s, resetting the timer on each update) and swapped it in across ~24 dashboard files. Excluded: message-only modal dialogs (ticket- scanning's success/error confirmations) and two states that mix live form-validation feedback with async results inside actively- open forms (the registration-edit modal's editError, the event create/edit modal's error) - those keep persisting until the user acts, since auto-hiding a "fix this field" message mid-edit would be a regression. Also fixed at-the-door's existing bespoke auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same consistent 7s, and removed admin/settings' manual x dismiss button in favor of the same auto-only behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
209 lines
9.5 KiB
TypeScript
209 lines
9.5 KiB
TypeScript
"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<string | null>(null);
|
|
const [info, setInfo] = useDismissingState<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>
|
|
);
|
|
}
|