UNPKG

@churchapps/apphelper-donations

Version:

Donation components for ChurchApps AppHelper

372 lines 25.1 kB
"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