Skip to content

Commit 823a813

Browse files
Feature: Add Credit one time payment, save payment, and Subscription static UI buttons (#835)
* add credit one time payment static button * add credit save payment button * add subscription button and update index * chore: add changeset
1 parent 169c977 commit 823a813

File tree

9 files changed

+864
-0
lines changed

9 files changed

+864
-0
lines changed

.changeset/empty-rings-draw.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@paypal/react-paypal-js": minor
3+
---
4+
5+
Adds 3 static UI components: PayPal Credit One Time Payment, PayPal Credit Save Payment, and PayPal Subscriptions.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import React from "react";
2+
import { render, fireEvent } from "@testing-library/react";
3+
4+
import { PayPalCreditOneTimePaymentButton } from "./PayPalCreditOneTimePaymentButton";
5+
import { usePayPalCreditOneTimePaymentSession } from "../hooks/usePayPalCreditOneTimePaymentSession";
6+
import { usePayPal } from "../hooks/usePayPal";
7+
8+
jest.mock("../hooks/usePayPalCreditOneTimePaymentSession", () => ({
9+
usePayPalCreditOneTimePaymentSession: jest.fn(),
10+
}));
11+
jest.mock("../hooks/usePayPal", () => ({
12+
usePayPal: jest.fn(),
13+
}));
14+
15+
describe("PayPalCreditOneTimePaymentButton", () => {
16+
const mockHandleClick = jest.fn();
17+
const mockUsePayPalCreditOneTimePaymentSession =
18+
usePayPalCreditOneTimePaymentSession as jest.Mock;
19+
const mockUsePayPal = usePayPal as jest.Mock;
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
mockUsePayPalCreditOneTimePaymentSession.mockReturnValue({
24+
error: null,
25+
isPending: false,
26+
handleClick: mockHandleClick,
27+
});
28+
mockUsePayPal.mockReturnValue({
29+
eligiblePaymentMethods: null,
30+
isHydrated: true,
31+
});
32+
});
33+
34+
it("should render paypal-credit-button when hydrated", () => {
35+
const { container } = render(
36+
<PayPalCreditOneTimePaymentButton
37+
onApprove={() => Promise.resolve()}
38+
orderId="123"
39+
presentationMode="auto"
40+
/>,
41+
);
42+
expect(
43+
container.querySelector("paypal-credit-button"),
44+
).toBeInTheDocument();
45+
});
46+
47+
it("should render a div when not hydrated", () => {
48+
mockUsePayPal.mockReturnValue({
49+
eligiblePaymentMethods: null,
50+
isHydrated: false,
51+
});
52+
const { container } = render(
53+
<PayPalCreditOneTimePaymentButton
54+
onApprove={() => Promise.resolve()}
55+
orderId="123"
56+
presentationMode="auto"
57+
/>,
58+
);
59+
expect(
60+
container.querySelector("paypal-credit-button"),
61+
).not.toBeInTheDocument();
62+
expect(container.querySelector("div")).toBeInTheDocument();
63+
});
64+
65+
it("should call handleClick when button is clicked", () => {
66+
const { container } = render(
67+
<PayPalCreditOneTimePaymentButton
68+
onApprove={() => Promise.resolve()}
69+
orderId="123"
70+
presentationMode="auto"
71+
/>,
72+
);
73+
const button = container.querySelector("paypal-credit-button");
74+
75+
// @ts-expect-error button should be defined at this point, test will error if not
76+
fireEvent.click(button);
77+
78+
expect(mockHandleClick).toHaveBeenCalledTimes(1);
79+
});
80+
81+
it("should disable the button when disabled=true is given as a prop", () => {
82+
const { container } = render(
83+
<PayPalCreditOneTimePaymentButton
84+
onApprove={() => Promise.resolve()}
85+
orderId="123"
86+
presentationMode="auto"
87+
disabled={true}
88+
/>,
89+
);
90+
const button = container.querySelector("paypal-credit-button");
91+
expect(button).toHaveAttribute("disabled");
92+
});
93+
94+
it("should disable button when error is present", () => {
95+
jest.spyOn(console, "error").mockImplementation();
96+
mockUsePayPalCreditOneTimePaymentSession.mockReturnValue({
97+
error: new Error("Test error"),
98+
isPending: false,
99+
handleClick: mockHandleClick,
100+
});
101+
const { container } = render(
102+
<PayPalCreditOneTimePaymentButton
103+
onApprove={() => Promise.resolve()}
104+
orderId="123"
105+
presentationMode="auto"
106+
/>,
107+
);
108+
const button = container.querySelector("paypal-credit-button");
109+
expect(button).toHaveAttribute("disabled");
110+
});
111+
112+
it("should not disable button when error is null", () => {
113+
mockUsePayPalCreditOneTimePaymentSession.mockReturnValue({
114+
error: null,
115+
isPending: false,
116+
handleClick: mockHandleClick,
117+
});
118+
const { container } = render(
119+
<PayPalCreditOneTimePaymentButton
120+
onApprove={() => Promise.resolve()}
121+
orderId="123"
122+
presentationMode="auto"
123+
/>,
124+
);
125+
const button = container.querySelector("paypal-credit-button");
126+
expect(button).not.toHaveAttribute("disabled");
127+
});
128+
129+
it("should return null when isPending is true", () => {
130+
mockUsePayPalCreditOneTimePaymentSession.mockReturnValue({
131+
error: null,
132+
isPending: true,
133+
handleClick: mockHandleClick,
134+
});
135+
const { container } = render(
136+
<PayPalCreditOneTimePaymentButton
137+
onApprove={() => Promise.resolve()}
138+
orderId="123"
139+
presentationMode="auto"
140+
/>,
141+
);
142+
expect(
143+
container.querySelector("paypal-credit-button"),
144+
).not.toBeInTheDocument();
145+
expect(container.firstChild).toBeNull();
146+
});
147+
148+
it("should pass hook props to usePayPalCreditOneTimePaymentSession", () => {
149+
const hookProps = {
150+
clientToken: "test-token",
151+
amount: "10.00",
152+
currency: "USD",
153+
onApprove: () => Promise.resolve(),
154+
orderId: "123",
155+
presentationMode: "auto" as const,
156+
};
157+
render(<PayPalCreditOneTimePaymentButton {...hookProps} />);
158+
expect(mockUsePayPalCreditOneTimePaymentSession).toHaveBeenCalledWith(
159+
hookProps,
160+
);
161+
});
162+
163+
describe("auto-population from eligibility context", () => {
164+
it("should auto-populate countryCode from eligibility context", () => {
165+
mockUsePayPal.mockReturnValue({
166+
isHydrated: true,
167+
eligiblePaymentMethods: {
168+
isEligible: jest.fn().mockReturnValue(true),
169+
getDetails: jest.fn().mockReturnValue({
170+
countryCode: "US",
171+
canBeVaulted: false,
172+
}),
173+
},
174+
});
175+
176+
const { container } = render(
177+
<PayPalCreditOneTimePaymentButton
178+
onApprove={() => Promise.resolve()}
179+
orderId="123"
180+
presentationMode="auto"
181+
/>,
182+
);
183+
184+
const button = container.querySelector("paypal-credit-button");
185+
expect(button).toHaveAttribute("countrycode", "US");
186+
});
187+
188+
it("should handle when eligibility was not fetched", () => {
189+
mockUsePayPal.mockReturnValue({
190+
isHydrated: true,
191+
eligiblePaymentMethods: null,
192+
});
193+
194+
const { container } = render(
195+
<PayPalCreditOneTimePaymentButton
196+
onApprove={() => Promise.resolve()}
197+
orderId="123"
198+
presentationMode="auto"
199+
/>,
200+
);
201+
202+
const button = container.querySelector("paypal-credit-button");
203+
expect(button).not.toHaveAttribute("countrycode");
204+
});
205+
206+
it("should handle when credit details are not available", () => {
207+
mockUsePayPal.mockReturnValue({
208+
isHydrated: true,
209+
eligiblePaymentMethods: {
210+
isEligible: jest.fn().mockReturnValue(false),
211+
getDetails: jest.fn().mockReturnValue(undefined),
212+
},
213+
});
214+
215+
const { container } = render(
216+
<PayPalCreditOneTimePaymentButton
217+
onApprove={() => Promise.resolve()}
218+
orderId="123"
219+
presentationMode="auto"
220+
/>,
221+
);
222+
223+
const button = container.querySelector("paypal-credit-button");
224+
expect(button).not.toHaveAttribute("countrycode");
225+
});
226+
});
227+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React, { useEffect } from "react";
2+
3+
import { usePayPalCreditOneTimePaymentSession } from "../hooks/usePayPalCreditOneTimePaymentSession";
4+
import { usePayPal } from "../hooks/usePayPal";
5+
6+
import type { UsePayPalCreditOneTimePaymentSessionProps } from "../hooks/usePayPalCreditOneTimePaymentSession";
7+
8+
export type PayPalCreditOneTimePaymentButtonProps =
9+
UsePayPalCreditOneTimePaymentSessionProps & {
10+
autoRedirect?: never;
11+
disabled?: boolean;
12+
};
13+
14+
/**
15+
* `PayPalCreditOneTimePaymentButton` is a button that provides a PayPal Credit payment flow.
16+
*
17+
* The `countryCode` is automatically populated from the eligibility API response
18+
* (available via `usePayPal().eligiblePaymentMethods`). The button requires eligibility to be configured
19+
* in the parent `PayPalProvider`, using either the `useEligibleMethods` hook client-side or `useFetchEligibleMethods` server-side.
20+
*
21+
* Note, `autoRedirect` is not allowed because if given a `presentationMode` of `"redirect"` the button
22+
* would not be able to provide back `redirectURL` from `start`. Advanced integrations that need
23+
* `redirectURL` should use the {@link usePayPalCreditOneTimePaymentSession} hook directly.
24+
*
25+
* @example
26+
* <PayPalCreditOneTimePaymentButton
27+
* onApprove={() => {
28+
* // ... on approve logic
29+
* }}
30+
* orderId="your-order-id"
31+
* presentationMode="auto"
32+
* />
33+
*/
34+
export const PayPalCreditOneTimePaymentButton = ({
35+
disabled = false,
36+
...hookProps
37+
}: PayPalCreditOneTimePaymentButtonProps): JSX.Element | null => {
38+
const { eligiblePaymentMethods, isHydrated } = usePayPal();
39+
const { error, isPending, handleClick } =
40+
usePayPalCreditOneTimePaymentSession(hookProps);
41+
42+
const creditDetails = eligiblePaymentMethods?.getDetails("credit");
43+
const countryCode = creditDetails?.countryCode;
44+
45+
useEffect(() => {
46+
if (error) {
47+
console.error(error);
48+
}
49+
}, [error]);
50+
51+
if (isPending) {
52+
return null;
53+
}
54+
55+
return isHydrated ? (
56+
<paypal-credit-button
57+
onClick={handleClick}
58+
countryCode={countryCode}
59+
disabled={disabled || !!error || undefined}
60+
></paypal-credit-button>
61+
) : (
62+
<div />
63+
);
64+
};

0 commit comments

Comments
 (0)