Add PDF invoices/receipts with manual send, fix early-bird tier edit data loss
- 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>
This commit is contained in:
@@ -33,10 +33,35 @@ export default function UserPaymentsPage() {
|
||||
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("");
|
||||
@@ -89,6 +114,8 @@ export default function UserPaymentsPage() {
|
||||
</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">
|
||||
@@ -156,8 +183,19 @@ export default function UserPaymentsPage() {
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
|
||||
</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 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>
|
||||
);
|
||||
})}
|
||||
@@ -204,6 +242,31 @@ export default function UserPaymentsPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user