- Registration confirmations attach an invoice PDF (itemized breakdown, early-bird discount, balance due, Yoco pay-now link/QR) whenever a balance is outstanding; payment/donation confirmations attach a payment receipt PDF. Sent as an email attachment and, over WhatsApp, as the PDF itself with the existing message as its caption. - Users can also (re)send either document on demand: an "Invoice" button on the registration detail popup, and a "Receipt" button next to each payment there and on the Payment history page, each opening an Email/WhatsApp choice popup, via two new endpoints restricted to the registration/payment's own owner. - Fix: editing an event option's early-bird tiers deleted and recreated every tier for that option with brand-new ids, silently severing the appliedTierId link on all historical purchases (losing early-bird attribution and undercounting stock-limit usage) even for tiers the admin didn't touch. Tiers are now upserted by id. - Update the "My Events" help content and the API docs index for the new endpoints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
"use client";
|
|
import React, { useCallback, useEffect, useState } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useRouter } from "next/navigation";
|
|
import { apiFetch } from "@/lib/api";
|
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
|
import { formatDateTime } from "@/lib/date";
|
|
import { formatPaymentMethod } from "@/lib/paymentMethod";
|
|
import { Receipt } from "lucide-react";
|
|
|
|
interface PaymentItem {
|
|
id: string;
|
|
amount: number;
|
|
method: string | null;
|
|
createdAt: string;
|
|
registrationId: string | null;
|
|
eventId: string | null;
|
|
registration?: { event?: { title?: string } | null } | null;
|
|
event?: { title?: string } | null;
|
|
}
|
|
|
|
const formatRand = (n: number) => `R ${Math.abs(n).toFixed(2)}`;
|
|
|
|
export default function UserPaymentsPage() {
|
|
const { user, loading, token } = useAuth();
|
|
const router = useRouter();
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (!user) router.replace("/login");
|
|
}, [user, loading, router]);
|
|
|
|
const [payments, setPayments] = useState<PaymentItem[]>([]);
|
|
const [fetching, setFetching] = useState(false);
|
|
const [error, setError] = useDismissingState<string | null>(null);
|
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
|
|
// Receipt send: which payment's channel-choice popup is open, and whether a send is in flight.
|
|
const [receiptPickerId, setReceiptPickerId] = useState<string | null>(null);
|
|
const [sendingReceipt, setSendingReceipt] = useState(false);
|
|
|
|
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
|
|
if (!token) return;
|
|
setReceiptPickerId(null);
|
|
setError(null);
|
|
setInfo(null);
|
|
setSendingReceipt(true);
|
|
try {
|
|
const res: any = await apiFetch<any>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
|
method: "POST",
|
|
body: { channel },
|
|
authToken: token,
|
|
});
|
|
setInfo((res && res.message) || "Receipt sent.");
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to send receipt");
|
|
} finally {
|
|
setSendingReceipt(false);
|
|
}
|
|
};
|
|
|
|
// Filters
|
|
const [startDate, setStartDate] = useState("");
|
|
const [endDate, setEndDate] = useState("");
|
|
const [method, setMethod] = useState("");
|
|
const [kind, setKind] = useState<"" | "payment" | "refund">("");
|
|
|
|
const buildQuery = useCallback((p: number) => {
|
|
const qs = new URLSearchParams({ page: String(p), limit: "25" });
|
|
if (startDate) qs.set("startDate", startDate);
|
|
if (endDate) qs.set("endDate", endDate);
|
|
if (method) qs.set("method", method);
|
|
if (kind) qs.set("kind", kind);
|
|
return `/api/payments/mypayments?${qs.toString()}`;
|
|
}, [startDate, endDate, method, kind]);
|
|
|
|
const loadPayments = useCallback(async (p = 1) => {
|
|
if (!token) return;
|
|
setError(null);
|
|
setFetching(true);
|
|
try {
|
|
const res = await apiFetch<any>(buildQuery(p), { authToken: token });
|
|
setPayments(Array.isArray(res?.data) ? res.data : []);
|
|
setTotal(res?.total ?? 0);
|
|
setTotalPages(res?.pages ?? 1);
|
|
setPage(p);
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to load payments");
|
|
} finally {
|
|
setFetching(false);
|
|
}
|
|
}, [token, buildQuery]);
|
|
|
|
useEffect(() => { if (token) loadPayments(1); }, [token, startDate, endDate, method, kind]);
|
|
|
|
const goToPage = (p: number) => loadPayments(p);
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto w-full px-4 py-6 sm:px-6">
|
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
|
<Receipt className="w-5 h-5 text-brand-600" />
|
|
</div>
|
|
<h1 className="text-2xl font-semibold text-gray-900">My Payments</h1>
|
|
</div>
|
|
<button
|
|
className="px-2.5 py-1 text-xs rounded-lg 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")}
|
|
>Back to dashboard</button>
|
|
</div>
|
|
|
|
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
|
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
|
|
{sendingReceipt && <p className="text-gray-500 text-sm mb-3">Sending receipt…</p>}
|
|
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
|
<div className="flex flex-wrap items-end gap-3 mb-4">
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">From</label>
|
|
<input
|
|
type="date"
|
|
className="border rounded px-2 py-1.5 text-sm"
|
|
value={startDate}
|
|
onChange={e => setStartDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">To</label>
|
|
<input
|
|
type="date"
|
|
className="border rounded px-2 py-1.5 text-sm"
|
|
value={endDate}
|
|
onChange={e => setEndDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Method</label>
|
|
<select className="border rounded px-2 py-1.5 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
|
|
<option value="">All methods</option>
|
|
<option value="cash">Cash</option>
|
|
<option value="card">Card</option>
|
|
<option value="eft">EFT</option>
|
|
<option value="voucher">Voucher</option>
|
|
<option value="other">Other</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Type</label>
|
|
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as any)}>
|
|
<option value="">Payments & refunds</option>
|
|
<option value="payment">Payments only</option>
|
|
<option value="refund">Refunds only</option>
|
|
</select>
|
|
</div>
|
|
{(startDate || endDate || method || kind) && (
|
|
<button
|
|
className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200"
|
|
onClick={() => { setStartDate(""); setEndDate(""); setMethod(""); setKind(""); }}
|
|
>Clear filters</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-xs text-gray-500 mb-2">
|
|
{total} payment{total !== 1 ? "s" : ""} total
|
|
{total > 0 && ` — page ${page} of ${totalPages}`}
|
|
</div>
|
|
|
|
<ul className="text-sm space-y-2">
|
|
{payments.map(p => {
|
|
const amt = p.amount || 0;
|
|
const isRefund = amt < 0;
|
|
const eventTitle = p.registration?.event?.title || p.event?.title;
|
|
return (
|
|
<li key={p.id} className={`border rounded p-2 ${isRefund ? "bg-red-50" : ""}`}>
|
|
<div className="flex justify-between">
|
|
<div className={`font-medium ${isRefund ? "text-red-700" : ""}`}>
|
|
{isRefund ? "-" : ""}{formatRand(amt)}
|
|
{isRefund && <span className="text-xs text-red-700 ml-1">(refund)</span>}
|
|
</div>
|
|
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div>
|
|
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
|
|
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
|
|
</div>
|
|
{!isRefund && (
|
|
<button
|
|
className="text-xs text-brand-700 hover:underline shrink-0"
|
|
disabled={sendingReceipt}
|
|
onClick={() => setReceiptPickerId(p.id)}
|
|
>Receipt</button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
{payments.length === 0 && !fetching && (
|
|
<li className="text-gray-500">No payments found.</li>
|
|
)}
|
|
{fetching && <li className="text-gray-400">Loading…</li>}
|
|
</ul>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-4 text-sm">
|
|
<span className="text-gray-500">Page {page} of {totalPages}</span>
|
|
<div className="flex gap-1">
|
|
<button
|
|
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
|
disabled={page <= 1 || fetching}
|
|
onClick={() => goToPage(page - 1)}
|
|
>← Prev</button>
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
|
|
.reduce<(number | "…")[]>((acc, p, i, arr) => {
|
|
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
|
|
acc.push(p);
|
|
return acc;
|
|
}, [])
|
|
.map((p, i) =>
|
|
p === "…" ? (
|
|
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-400">…</span>
|
|
) : (
|
|
<button
|
|
key={p}
|
|
className={`px-2 py-1 rounded ${page === p ? "bg-brand-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
|
|
disabled={fetching}
|
|
onClick={() => goToPage(p as number)}
|
|
>{p}</button>
|
|
)
|
|
)}
|
|
<button
|
|
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
|
disabled={page >= totalPages || fetching}
|
|
onClick={() => goToPage(page + 1)}
|
|
>Next →</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{receiptPickerId && (
|
|
<div
|
|
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
|
onClick={() => setReceiptPickerId(null)}
|
|
>
|
|
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
|
|
<div className="text-base font-semibold mb-1">Send receipt</div>
|
|
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
|
|
<div className="flex flex-col gap-2">
|
|
<button
|
|
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
|
onClick={() => sendReceipt(receiptPickerId, 'email')}
|
|
>Email</button>
|
|
{user?.phoneNumber && (
|
|
<button
|
|
className="px-3 py-2 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={() => sendReceipt(receiptPickerId, 'whatsapp')}
|
|
>WhatsApp</button>
|
|
)}
|
|
</div>
|
|
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setReceiptPickerId(null)}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|