@churchapps/apphelper-donations
Version:
Donation components for ChurchApps AppHelper
372 lines • 25.1 kB
JavaScript
"use client";
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useCallback, useState, useEffect, useMemo, useRef } from "react";
import { useStripe } from "@stripe/react-stripe-js";
import { InputBox, ErrorMessages } from "@churchapps/apphelper";
import { FundDonations } from ".";
import { PayPalHostedFields } from "./PayPalHostedFields";
import { DonationPreviewModal } from "../modals/DonationPreviewModal";
import { ApiHelper, CurrencyHelper, DateHelper } from "@churchapps/helpers";
import { Locale, DonationHelper } from "../helpers";
import { Grid, InputLabel, MenuItem, Select, TextField, FormControl, Button, FormControlLabel, Checkbox, FormGroup, Typography, Alert } from "@mui/material";
export const MultiGatewayDonationForm = (props) => {
const stripe = useStripe();
const [errorMessage, setErrorMessage] = useState();
const [fundDonations, setFundDonations] = useState();
const [funds, setFunds] = useState([]);
const [fundsLoaded, setFundsLoaded] = useState(false);
const [fundsTotal, setFundsTotal] = useState(0);
const [transactionFee, setTransactionFee] = useState(0);
const [payFee, setPayFee] = useState(0);
const [total, setTotal] = useState(0);
const [paymentMethodName, setPaymentMethodName] = useState(props?.paymentMethods?.length > 0 ? `${props.paymentMethods[0].name} ${props.paymentMethods[0].last4 ? `****${props.paymentMethods[0].last4}` : props.paymentMethods[0].email || ''}` : "");
const [selectedGateway, setSelectedGateway] = useState(DonationHelper.normalizeProvider(props?.paymentGateways?.find(g => g.enabled !== false)?.provider || "stripe"));
const selectedGatewayObj = useMemo(() => {
return (props.paymentGateways.find((g) => DonationHelper.normalizeProvider(g.provider) === selectedGateway) || null);
}, [props.paymentGateways, selectedGateway]);
const [donationType, setDonationType] = useState("once");
const [showDonationPreviewModal, setShowDonationPreviewModal] = useState(false);
const [interval, setInterval] = useState("one_month");
const [gateway, setGateway] = useState(selectedGatewayObj);
const paypalClientId = useMemo(() => {
const gw = props.paymentGateways.find(g => DonationHelper.isProvider(g.provider, "paypal"));
return gw?.publicKey || "";
}, [props.paymentGateways]);
const hostedRef = useRef(null);
const feeTimeoutRef = useRef(null);
const [donation, setDonation] = useState({
id: props?.paymentMethods?.length > 0 ? props.paymentMethods[0].id : "",
type: props?.paymentMethods?.length > 0 ? props.paymentMethods[0].type : "card",
provider: props?.paymentMethods?.length > 0
? DonationHelper.normalizeProvider(props.paymentMethods[0].provider)
: selectedGateway,
customerId: props.customerId,
person: {
id: props.person?.id || "",
email: props.person?.contactInfo?.email || "",
name: props.person?.name?.display || ""
},
amount: 0,
billing_cycle_anchor: +new Date(),
interval: {
interval_count: 1,
interval: "month"
},
funds: [],
gatewayId: props?.paymentMethods?.length > 0 ? props.paymentMethods[0].gatewayId : selectedGatewayObj?.id,
currency: selectedGatewayObj?.currency || "usd"
});
const loadFunds = useCallback(async () => {
setFundsLoaded(false);
try {
const data = await ApiHelper.get("/funds", "GivingApi");
const fundList = Array.isArray(data) ? data : [];
setFunds(fundList);
if (fundList.length)
setFundDonations([{ fundId: fundList[0].id }]);
else
setFundDonations([]);
}
catch (_error) {
setFunds([]);
setFundDonations([]);
}
finally {
setFundsLoaded(true);
}
}, []);
const loadGateway = useCallback(async () => {
try {
const response = await ApiHelper.get(`/donate/gateways/${props?.church?.id || ""}`, "GivingApi");
const gateways = Array.isArray(response?.gateways) ? response.gateways : [];
const primaryGateway = gateways.find((g) => DonationHelper.normalizeProvider(g.provider) === selectedGateway);
if (primaryGateway)
setGateway(primaryGateway);
}
catch (_error) {
// ignore gateway load errors; component will handle gracefully
}
}, [props?.church?.id, selectedGateway]);
const handleSave = useCallback(() => {
if (donation.amount < .5)
setErrorMessage(Locale.label("donation.donationForm.tooLow"));
else
setShowDonationPreviewModal(true);
}, [donation.amount]);
const handleKeyDown = useCallback((e) => { if (e.key === "Enter") {
e.preventDefault();
handleSave();
} }, [handleSave]);
const handleCheckChange = useCallback((_e, checked) => {
const d = { ...donation };
d.amount = checked ? fundsTotal + transactionFee : fundsTotal;
const showFee = checked ? transactionFee : 0;
setTotal(d.amount);
setPayFee(showFee);
setDonation(d);
}, [donation, fundsTotal, transactionFee]);
const handleChange = useCallback((e) => {
setErrorMessage(undefined);
const d = { ...donation };
const value = e.target.value;
switch (e.target.name) {
case "gateway":
setSelectedGateway(value);
d.provider = value;
const matchedGateway = props.paymentGateways.find(g => DonationHelper.normalizeProvider(g.provider) === value);
d.gatewayId = matchedGateway?.id;
d.currency = matchedGateway?.currency || "usd";
// Reset payment method when changing gateways
const availableMethods = props.paymentMethods.filter(pm => DonationHelper.normalizeProvider(pm.provider) === value);
if (availableMethods.length > 0) {
d.id = availableMethods[0].id;
d.type = availableMethods[0].type;
setPaymentMethodName(`${availableMethods[0].name} ${availableMethods[0].last4 ? `****${availableMethods[0].last4}` : availableMethods[0].email || ''}`);
}
else {
d.id = "";
if (value === "paypal")
d.type = "paypal";
}
break;
case "method":
d.id = value;
const pm = props.paymentMethods.find(pm => pm.id === value);
if (pm) {
d.type = pm.type;
d.provider = DonationHelper.normalizeProvider(pm.provider);
d.gatewayId = pm.gatewayId || gateway?.id || selectedGatewayObj?.id;
setPaymentMethodName(`${pm.name} ${pm.last4 ? `****${pm.last4}` : pm.email || ''}`);
}
break;
case "type":
setDonationType(value);
break;
case "date":
d.billing_cycle_anchor = value ? +new Date(value) : +new Date();
break;
case "interval":
setInterval(value);
d.interval = DonationHelper.getInterval(value);
break;
case "notes":
d.notes = value;
break;
case "transaction-fee":
const element = e.target;
d.amount = element.checked ? fundsTotal + transactionFee : fundsTotal;
const showFee = element.checked ? transactionFee : 0;
setTotal(d.amount);
setPayFee(showFee);
}
setDonation(d);
}, [donation, props.paymentMethods, fundsTotal, transactionFee, gateway?.id, props.paymentGateways, selectedGatewayObj?.id]);
const handleCancel = useCallback(() => { setDonationType(undefined); }, []);
const handleDonationSelect = useCallback((type) => {
const dt = donationType === type ? undefined : type;
setDonationType(dt);
}, [donationType]);
const handleSingleDonationClick = useCallback(() => handleDonationSelect("once"), [handleDonationSelect]);
const handleRecurringDonationClick = useCallback(() => handleDonationSelect("recurring"), [handleDonationSelect]);
const makeDonation = useCallback(async (message) => {
let results;
const churchObj = {
name: props?.church?.name || "",
subDomain: props?.church?.subDomain || "",
churchURL: typeof window !== "undefined" ? window.location.origin : "",
logo: props?.churchLogo || ""
};
// If using PayPal without a saved method, try Hosted Fields
if (selectedGateway === "paypal" && (!donation.id || donation.id === "") && paypalClientId) {
try {
const payload = await hostedRef.current?.submit();
const orderId = payload?.orderId || payload?.id || "";
if (orderId) {
// Capture and persist via unified /donate/charge endpoint for PayPal
const compactFunds = (donation.funds || []).map((f) => ({ id: f.id, amount: f.amount }));
results = await ApiHelper.post("/donate/charge", {
provider: "paypal",
gatewayId: selectedGatewayObj?.id,
id: orderId,
churchId: props?.church?.id || "",
amount: total,
funds: compactFunds,
person: donation.person,
notes: donation?.notes || ""
}, "GivingApi");
}
}
catch (e) {
console.warn("Hosted Fields submit failed, falling back to standard flow.", e);
}
}
// Standard flow (Stripe or saved payment method)
if (!results) {
const payload = {
...donation,
provider: donation.provider || selectedGateway,
gatewayId: donation.gatewayId || gateway?.id || selectedGatewayObj?.id,
church: churchObj
};
if (donationType === "once")
results = await ApiHelper.post("/donate/charge", payload, "GivingApi");
if (donationType === "recurring")
results = await ApiHelper.post("/donate/subscribe", payload, "GivingApi");
}
// Handle 3D Secure authentication if required (Stripe only)
if (selectedGateway === "stripe") {
const threeDSResult = await DonationHelper.handle3DSIfRequired(results, stripe);
if (threeDSResult.requiresAction) {
setShowDonationPreviewModal(false);
if (threeDSResult.success) {
setDonationType(undefined);
props.donationSuccess(message);
}
else {
setErrorMessage(Locale.label("donation.common.error") + ": " + threeDSResult.error);
}
return;
}
}
if (results?.status === "succeeded" || results?.status === "pending" || results?.status === "active" || results?.status === "processing" || results?.status === "CREATED") {
setShowDonationPreviewModal(false);
setDonationType(undefined);
props.donationSuccess(message);
}
if (results?.raw?.message || results?.message) {
setShowDonationPreviewModal(false);
setErrorMessage(Locale.label("donation.common.error") + ": " + (results?.raw?.message || results?.message));
}
}, [donation, donationType, gateway?.id, paypalClientId, props.church?.name, props.church?.subDomain, props.churchLogo, props.donationSuccess, selectedGateway, selectedGatewayObj?.id, total, stripe]);
const getTransactionFee = useCallback(async (amount, activeGatewayId, provider = "stripe") => {
if (amount > 0) {
try {
const response = await ApiHelper.post("/donate/fee?churchId=" + (props?.church?.id || ""), { amount, provider, gatewayId: activeGatewayId, currency: gateway?.currency || "USD" }, "GivingApi");
return response.calculatedFee;
}
catch (error) {
console.log("Error calculating transaction fee: ", error);
return 0;
}
}
else {
return 0;
}
}, [props?.church?.id]);
const handleFundDonationsChange = useCallback((fd) => {
setErrorMessage(undefined);
setFundDonations(fd);
let totalAmount = 0;
const selectedFunds = [];
for (const fundDonation of fd) {
totalAmount += fundDonation.amount || 0;
const fund = funds.find((fund) => fund.id === fundDonation.fundId);
if (fund) {
selectedFunds.push({ id: fundDonation.fundId, amount: fundDonation.amount || 0, name: fund.name });
}
}
const d = { ...donation };
d.amount = totalAmount;
d.funds = selectedFunds;
setFundsTotal(totalAmount);
// Clear existing timeout
if (feeTimeoutRef.current) {
window.clearTimeout(feeTimeoutRef.current);
}
// Set initial totals without fee for immediate UI update
setTotal(totalAmount);
setDonation(d);
// Debounce fee calculation to prevent excessive API calls
feeTimeoutRef.current = window.setTimeout(async () => {
const fee = await getTransactionFee(totalAmount, d.gatewayId || gateway?.id || selectedGatewayObj?.id, d.provider || selectedGateway);
setTransactionFee(fee);
if (gateway && gateway.payFees === true) {
const updatedAmount = totalAmount + fee;
setTotal(updatedAmount);
setPayFee(fee);
setDonation(prev => ({ ...prev, amount: updatedAmount }));
}
}, 500);
}, [donation, funds, gateway, selectedGatewayObj?.id, selectedGateway, getTransactionFee]);
useEffect(() => {
loadFunds();
}, [loadFunds]);
useEffect(() => {
if (props?.church?.id) {
loadGateway();
}
}, [loadGateway]);
useEffect(() => {
if (selectedGatewayObj && (gateway?.id !== selectedGatewayObj.id)) {
setGateway(selectedGatewayObj);
}
}, [selectedGatewayObj, gateway?.id]);
useEffect(() => {
setDonation((prev) => {
const nextProvider = prev.provider || selectedGateway;
const nextGatewayId = selectedGatewayObj?.id || prev.gatewayId;
if (nextProvider === prev.provider && nextGatewayId === prev.gatewayId)
return prev;
return { ...prev, provider: nextProvider, gatewayId: nextGatewayId, currency: selectedGatewayObj?.currency || "usd" };
});
}, [selectedGateway, selectedGatewayObj?.id, selectedGatewayObj?.currency]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (feeTimeoutRef.current) {
window.clearTimeout(feeTimeoutRef.current);
}
};
}, []);
const availablePaymentMethods = props.paymentMethods.filter(pm => DonationHelper.normalizeProvider(pm.provider) === selectedGateway);
const availableGateways = props.paymentGateways.filter(g => g.enabled !== false);
if (!fundsLoaded) {
return _jsx(Alert, { severity: "info", children: "Loading donation settings\u2026" });
}
if (!funds.length) {
return (_jsx(Alert, { severity: "warning", children: "No donation funds have been configured for this church. Please contact your administrator." }));
}
else {
return (_jsxs(_Fragment, { children: [_jsx(DonationPreviewModal, { show: showDonationPreviewModal, onHide: () => setShowDonationPreviewModal(false), handleDonate: makeDonation, donation: {
...donation,
person: {
id: donation.person?.id || "",
email: donation.person?.email || "",
name: donation.person?.name || ""
}
}, donationType: donationType || "", payFee: payFee, paymentMethodName: paymentMethodName, funds: funds }), _jsxs(InputBox, { id: "donation-form", "aria-label": "donation-box", headerIcon: "volunteer_activism", headerText: Locale.label("donation.donationForm.donate"), ariaLabelSave: "save-button", cancelFunction: donationType ? handleCancel : undefined, saveFunction: donationType ? handleSave : undefined, saveText: Locale.label("donation.donationForm.preview"), children: [_jsxs(Grid, { id: "donation-type-selector", container: true, spacing: 3, children: [_jsx(Grid, { size: { xs: 12, md: 6 }, children: _jsx(Button, { id: "single-donation-button", "aria-label": "single-donation", size: "small", fullWidth: true, style: { minHeight: "50px" }, variant: donationType === "once" ? "contained" : "outlined", onClick: handleSingleDonationClick, children: Locale.label("donation.donationForm.make") }) }), _jsx(Grid, { size: { xs: 12, md: 6 }, children: _jsx(Button, { id: "recurring-donation-button", "aria-label": "recurring-donation", size: "small", fullWidth: true, style: { minHeight: "50px" }, variant: donationType === "recurring" ? "contained" : "outlined", onClick: handleRecurringDonationClick, children: Locale.label("donation.donationForm.makeRecurring") }) })] }), donationType && (_jsxs("div", { id: "donation-details", style: { marginTop: "20px" }, children: [_jsxs(Grid, { container: true, spacing: 3, children: [availableGateways.length > 1 && (_jsx(Grid, { size: { xs: 12 }, children: _jsxs(FormControl, { fullWidth: true, children: [_jsx(InputLabel, { children: "Payment Provider" }), _jsx(Select, { id: "gateway-select", label: "Payment Provider", name: "gateway", "aria-label": "gateway", value: selectedGateway, onChange: handleChange, children: availableGateways.map((gw) => (_jsx(MenuItem, { value: DonationHelper.normalizeProvider(gw.provider), children: DonationHelper.isProvider(gw.provider, "stripe") ? "Stripe" : "PayPal" }, gw.provider))) })] }) })), selectedGateway !== "paypal" || availablePaymentMethods.length > 0 ? (_jsx(Grid, { size: { xs: 12 }, children: _jsxs(FormControl, { fullWidth: true, children: [_jsx(InputLabel, { children: Locale.label("donation.donationForm.method") }), _jsx(Select, { id: "payment-method-select", label: Locale.label("donation.donationForm.method"), name: "method", "aria-label": "method", value: donation.id, className: "capitalize", onChange: handleChange, children: availablePaymentMethods.map((paymentMethod) => (_jsxs(MenuItem, { value: paymentMethod.id, children: [paymentMethod.name, " ", paymentMethod.last4 ? `****${paymentMethod.last4}` : paymentMethod.email || ''] }, paymentMethod.id))) })] }) })) : (_jsxs(Grid, { size: { xs: 12 }, children: [_jsx(Typography, { variant: "subtitle1", sx: { mb: 1 }, children: "Enter card details (PayPal Hosted Fields)" }), _jsx(PayPalHostedFields, { ref: hostedRef, clientId: paypalClientId, getClientToken: async () => {
try {
const resp = await ApiHelper.post("/donate/client-token", {
churchId: props?.church?.id || "",
provider: "paypal",
gatewayId: selectedGatewayObj?.id || gateway?.id
}, "GivingApi");
const token = resp?.clientToken || resp?.token || resp?.result || resp;
return typeof token === "string" && token.length > 0 ? token : "";
}
catch {
return "";
}
}, createOrder: async () => {
try {
const fundsPayload = (donation?.funds || [])
.filter((f) => (f.amount || 0) > 0 && f.id)
.map((f) => ({ id: f.id, amount: f.amount || 0 }));
const response = await ApiHelper.post("/donate/create-order", {
churchId: props?.church?.id || "",
provider: "paypal",
gatewayId: selectedGatewayObj?.id || gateway?.id,
amount: total,
currency: "USD",
funds: fundsPayload,
notes: donation?.notes || ""
}, "GivingApi");
return response?.id || response?.orderId || "";
}
catch (_e) {
return "";
}
} })] }))] }), donationType === "recurring" && (_jsxs(Grid, { container: true, spacing: 3, style: { marginTop: 10 }, children: [_jsx(Grid, { size: { xs: 12, md: 6 }, children: _jsx(TextField, { id: "start-date-field", fullWidth: true, name: "date", type: "date", "aria-label": "date", label: Locale.label("donation.donationForm.startDate"), value: DateHelper.formatHtml5Date(new Date(donation.billing_cycle_anchor || Date.now())), onChange: handleChange, onKeyDown: handleKeyDown }) }), _jsx(Grid, { size: { xs: 12, md: 6 }, children: _jsxs(FormControl, { fullWidth: true, children: [_jsx(InputLabel, { children: Locale.label("donation.donationForm.frequency") }), _jsxs(Select, { id: "frequency-select", label: Locale.label("donation.donationForm.frequency"), name: "interval", "aria-label": "interval", value: interval, onChange: handleChange, children: [_jsx(MenuItem, { value: "one_week", children: Locale.label("donation.donationForm.weekly") }), _jsx(MenuItem, { value: "two_week", children: Locale.label("donation.donationForm.biWeekly") }), _jsx(MenuItem, { value: "one_month", children: Locale.label("donation.donationForm.monthly") }), _jsx(MenuItem, { value: "three_month", children: Locale.label("donation.donationForm.quarterly") }), _jsx(MenuItem, { value: "one_year", children: Locale.label("donation.donationForm.annually") })] })] }) })] })), _jsxs("div", { id: "fund-selection", className: "form-group", children: [funds && fundDonations && (_jsxs(_Fragment, { children: [_jsx("h4", { children: Locale.label("donation.donationForm.fund") }), _jsx(FundDonations, { fundDonations: fundDonations, funds: funds, updatedFunction: handleFundDonationsChange, currency: gateway?.currency })] })), fundsTotal > 0 && (_jsxs(_Fragment, { children: [(gateway?.payFees === true) ? (_jsxs(Typography, { fontSize: 14, fontStyle: "italic", children: ["*", Locale.label("donation.donationForm.fees").replace("{}", CurrencyHelper.formatCurrencyWithLocale(transactionFee, gateway?.currency || "USD"))] })) : (_jsx(FormGroup, { children: _jsx(FormControlLabel, { control: _jsx(Checkbox, {}), name: "transaction-fee", label: Locale.label("donation.donationForm.cover").replace("{}", CurrencyHelper.formatCurrencyWithLocale(transactionFee, gateway?.currency || "USD")), onChange: handleCheckChange }) })), _jsxs("p", { children: [Locale.label("donation.donationForm.total"), ": ", CurrencyHelper.formatCurrencyWithLocale(total, gateway?.currency || "USD")] })] })), _jsx(TextField, { id: "donation-notes", fullWidth: true, label: "Memo (optional)", multiline: true, "aria-label": "note", name: "notes", value: donation.notes || "", onChange: handleChange, onKeyDown: handleKeyDown })] }), errorMessage && _jsx(ErrorMessages, { errors: [errorMessage] })] }))] })] }));
}
};
//# sourceMappingURL=MultiGatewayDonationForm.js.map