Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/giving-paypal-buttons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@churchapps/apphelper": minor
---

Add PayPal Smart Buttons (PayPal + Venmo) above the Hosted Fields card form on the PayPal guest form and member entry, for one-time gifts only. The SDK now loads `components=buttons,hosted-fields&enable-funding=venmo` once for both widgets, and an approved order is charged through the existing `/donate/charge` capture path.
Binary file modified .pr-screenshots/after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .pr-screenshots/before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions apphelper/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@
"noMethod": "No Method",
"verify": "Verify Account"
},
"paypal": {
"approved": "PayPal payment approved. Choose Donate to finish your gift.",
"captchaRequired": "Please complete the reCAPTCHA verification"
},
"paystack": {
"emailRequired": "An email address is required to pay with Paystack.",
"paymentFailed": "Payment was not completed.",
Expand Down
98 changes: 98 additions & 0 deletions apphelper/src/donations/__tests__/paypalButtons.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

const dir = dirname(fileURLToPath(import.meta.url));
const read = (rel: string) => readFileSync(join(dir, rel), "utf8");

const slice = (source: string, start: string, end: string) => {
const from = source.indexOf(start);
expect(from, `expected to find ${JSON.stringify(start)}`).toBeGreaterThan(-1);
const to = source.indexOf(end, from + start.length);
expect(to, `expected to find ${JSON.stringify(end)} after ${JSON.stringify(start)}`).toBeGreaterThan(-1);
return source.slice(from, to);
};

describe("paypal smart buttons", () => {
const sdk = read("../providers/paypal/paypalSdk.ts");
const guest = read("../providers/paypal/PayPalNonAuthDonationInner.tsx");
const provider = read("../providers/paypal/PayPalProvider.tsx");
const donationForm = read("../components/MultiGatewayDonationForm.tsx");

describe("sdk loading", () => {
it("injects the buttons and hosted-fields SDK with venmo funding", () => {
expect(sdk).toContain("https://www.paypal.com/sdk/js");
expect(sdk).toContain("client-id=${encodeURIComponent(clientId)}");
expect(sdk).toContain("components=buttons,hosted-fields");
expect(sdk).toContain("enable-funding=venmo");
});

it("shares a single script tag between Buttons and Hosted Fields", () => {
// The client token has to be on the tag before it loads, so the first caller wins
// and every later caller gets the same promise back.
expect(sdk).toContain("if (!sdkPromise || sdkClientId !== clientId)");
expect(sdk).toContain("return sdkPromise;");
expect(sdk).toContain("if (window.paypal) { resolve(window.paypal); return; }");
});
});

describe("guest donation form", () => {
it("shows the buttons only for one-time gifts when PayPal is configured", () => {
expect(guest).toContain('{props.paypalClientId && donationType === "once" && (');
});

it("gates the order behind validation that does not require a card, and the captcha", () => {
const startOrder = slice(guest, "const startWalletOrder", "const handleWalletApproval");
expect(startOrder).toContain("if (!validate(false)) return \"\";");
expect(startOrder).toContain("if (_captchaResponse !== \"success\") {");
expect(startOrder).toContain("return \"\";");
});

it("saves an anonymous wallet gift without creating a user or person", () => {
const approval = slice(guest, "const handleWalletApproval", "const validate =");
const anonBranch = slice(approval, "if (anonymous) {", "try {");
expect(anonBranch).toContain("savePayPalDonation(undefined, orderId)");
expect(anonBranch).not.toContain("/users/loadOrCreate");
expect(anonBranch).not.toContain("/people/loadOrCreate");
});

it("still creates the user and person for a named wallet gift", () => {
const approval = slice(guest, "const handleWalletApproval", "const validate =");
const namedBranch = approval.slice(approval.indexOf("try {"));
expect(namedBranch).toContain('ApiHelper.post("/users/loadOrCreate"');
expect(namedBranch).toContain('ApiHelper.post("/people/loadOrCreate"');
expect(namedBranch).toContain("savePayPalDonation(person, orderId)");
});

it("skips the Hosted Fields submit when the wallet already approved an order", () => {
const save = slice(guest, "const savePayPalDonation", "const createPayPalOrder");
expect(save).toContain("let hostedOrderId: string | undefined = approvedOrderId;");
expect(save).toContain("if (!hostedOrderId && props.paypalClientId && useHostedFields) {");
});
});

describe("member entry", () => {
it("hides the buttons for recurring gifts", () => {
expect(provider).toContain("{!getContext?.().recurring && (");
});

it("tokenizes the approved order instead of submitting Hosted Fields", () => {
const tokenize = slice(provider, "tokenize: async ()", "const getClientToken");
expect(tokenize).toContain("if (approvedOrderRef.current) {");
expect(tokenize).toContain('return { id: orderId, type: "paypal" };');
expect(tokenize).toContain("await hostedRef.current?.submit()");
});

it("confirms approval with the shared locale label", () => {
expect(provider).toContain('Locale.label("donation.paypal.approved")');
});
});

describe("donation form", () => {
it("treats a captured PayPal order as a successful gift", () => {
const statuses = slice(donationForm, "const okStatuses", ";");
expect(statuses).toContain('"COMPLETED"');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ const MultiGatewayDonationInner: React.FC<Props> = (props) => {

// Always close modal to prevent Donate button hanging with no feedback on unrecognized shapes.
setShowDonationPreviewModal(false);
const okStatuses = ["succeeded", "pending", "active", "processing", "CREATED", "Approved"];
const okStatuses = ["succeeded", "pending", "active", "processing", "CREATED", "COMPLETED", "Approved"];
if (results?.status && okStatuses.includes(results.status)) {
setDonationType(undefined);
props.donationSuccess(message);
Expand Down
56 changes: 56 additions & 0 deletions apphelper/src/donations/providers/paypal/PayPalButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { loadPayPalSdk } from "./paypalSdk";

// createOrder must reject to stop the PayPal window, but the caller has already shown why.
const ABORTED = "paypal-order-aborted";

interface Props {
clientId: string;
getClientToken?: () => Promise<string>;
createOrder: () => Promise<string>;
onApprove: (orderId: string) => void | Promise<void>;
onError?: (message: string) => void;
}

export const PayPalButtons: React.FC<Props> = (props) => {
const containerRef = useRef<HTMLDivElement>(null);
const callbacks = useRef(props);
callbacks.current = props;
const [rendered, setRendered] = useState(false);

useEffect(() => {
let cancelled = false;
let instance: any;
(async () => {
try {
const paypal = await loadPayPalSdk(props.clientId, props.getClientToken);
if (cancelled || !paypal?.Buttons || !containerRef.current) return;
instance = paypal.Buttons({
style: { layout: "vertical", height: 45, tagline: false },
createOrder: async () => {
const orderId = await callbacks.current.createOrder();
if (!orderId) throw new Error(ABORTED);
return orderId;
},
onApprove: async (data: any) => { await callbacks.current.onApprove(data?.orderID || ""); },
onError: (e: any) => { if (e?.message !== ABORTED) callbacks.current.onError?.(e?.message || "PayPal checkout failed"); }
});
if (instance.isEligible && !instance.isEligible()) return;
await instance.render(containerRef.current);
if (!cancelled) setRendered(true);
} catch (e: any) {
callbacks.current.onError?.(e?.message || "PayPal checkout unavailable");
}
})();
return () => {
cancelled = true;
try { instance?.close?.(); } catch { /* already torn down */ }
};
}, [props.clientId]);

return <div ref={containerRef} data-testid="paypal-buttons" style={{ marginBottom: rendered ? 12 : 0 }} />;
};

export default PayPalButtons;
36 changes: 2 additions & 34 deletions apphelper/src/donations/providers/paypal/PayPalHostedFields.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
"use client";

import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";

declare global {
interface Window { paypal?: any }
}
import { loadPayPalSdk } from "./paypalSdk";

export interface PayPalHostedFieldsHandle {
submit: () => Promise<any>;
Expand All @@ -19,30 +16,6 @@ interface Props {
onIneligible?: (reason: string) => void;
}

function loadPayPalSdk(clientId: string, clientToken?: string): Promise<any> {
return new Promise((resolve, reject) => {
if (typeof window === "undefined") { reject(new Error("Window not available")); return; }
if (window.paypal && window.paypal.HostedFields) { resolve(window.paypal); return; }

// Avoid adding script multiple times
const existing = document.querySelector<HTMLScriptElement>('script[data-apphelper-paypal-sdk="true"]');
if (existing) {
existing.addEventListener("load", () => resolve(window.paypal));
existing.addEventListener("error", (e) => reject(e));
return;
}

const script = document.createElement("script");
script.src = `https://www.paypal.com/sdk/js?client-id=${encodeURIComponent(clientId)}&components=hosted-fields&intent=capture&commit=true`;
script.async = true;
script.dataset.apphelperPaypalSdk = "true";
if (clientToken) (script as any).dataset.clientToken = clientToken;
script.addEventListener("load", () => resolve(window.paypal));
script.addEventListener("error", (e) => reject(e));
document.body.appendChild(script);
});
}

export const PayPalHostedFields = forwardRef<PayPalHostedFieldsHandle, Props>((props, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const hostedFieldsRef = useRef<any>(null);
Expand Down Expand Up @@ -71,12 +44,7 @@ export const PayPalHostedFields = forwardRef<PayPalHostedFieldsHandle, Props>((p
}
}

let clientToken: string | undefined;
if (props.getClientToken) {
try { clientToken = await props.getClientToken(); } catch { /* ignore */ }
}

const paypal = await loadPayPalSdk(props.clientId, clientToken);
const paypal = await loadPayPalSdk(props.clientId, props.getClientToken);
if (cancelled) return;
if (!paypal || !paypal.HostedFields) {
throw new Error("PayPal HostedFields unavailable");
Expand Down
Loading
Loading