Skip to content

Commit ff815fc

Browse files
feat: Venmo One Time Payment button (#755)
* initial addition of v6 venmo button component * add vscode to gitignore and remove autoRedirect from venmo session hook * add ai tests * add the button to exports * chore: add changeset * update is server condition to return an empty div server side * add empty line * clean up eligibility hook test console.errors
1 parent ff8c3e6 commit ff815fc

File tree

8 files changed

+226
-4
lines changed

8 files changed

+226
-4
lines changed

.changeset/silver-sheep-like.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": patch
3+
---
4+
5+
Adds v6 venmo ui component.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ node_modules
1111
/playwright/.cache/
1212
.idea
1313
.rollup.cache
14+
15+
.vscode
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import React from "react";
2+
import { render, fireEvent } from "@testing-library/react";
3+
4+
import { VenmoOneTimePaymentButton } from "./VenmoOneTimePaymentButton";
5+
import { useVenmoOneTimePaymentSession } from "../hooks/useVenmoOneTimePaymentSession";
6+
import { isServer } from "../utils";
7+
8+
jest.mock("../hooks/useVenmoOneTimePaymentSession", () => ({
9+
useVenmoOneTimePaymentSession: jest.fn(),
10+
}));
11+
jest.mock("../utils", () => ({
12+
isServer: jest.fn().mockReturnValue(false),
13+
}));
14+
15+
describe("VenmoOneTimePaymentButton", () => {
16+
const mockHandleClick = jest.fn();
17+
const mockUseVenmoOneTimePaymentSession =
18+
useVenmoOneTimePaymentSession as jest.Mock;
19+
const mockIsServer = isServer as jest.Mock;
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
mockUseVenmoOneTimePaymentSession.mockReturnValue({
24+
error: null,
25+
handleClick: mockHandleClick,
26+
});
27+
mockIsServer.mockReturnValue(false);
28+
});
29+
30+
it("should render venmo-button when not on server side", () => {
31+
const { container } = render(
32+
<VenmoOneTimePaymentButton
33+
onApprove={() => Promise.resolve()}
34+
orderId="123"
35+
presentationMode="auto"
36+
/>,
37+
);
38+
expect(container.querySelector("venmo-button")).toBeInTheDocument();
39+
});
40+
41+
it("should render a div when on server side", () => {
42+
mockIsServer.mockReturnValue(true);
43+
const { container } = render(
44+
<VenmoOneTimePaymentButton
45+
onApprove={() => Promise.resolve()}
46+
orderId="123"
47+
presentationMode="auto"
48+
/>,
49+
);
50+
expect(container.querySelector("venmo-button")).not.toBeInTheDocument();
51+
expect(container.querySelector("div")).toBeInTheDocument();
52+
});
53+
54+
it("should call handleClick when button is clicked", () => {
55+
const { container } = render(
56+
<VenmoOneTimePaymentButton
57+
onApprove={() => Promise.resolve()}
58+
orderId="123"
59+
presentationMode="auto"
60+
/>,
61+
);
62+
const button = container.querySelector("venmo-button");
63+
64+
// @ts-expect-error button should be defined at this point, test will error if not
65+
fireEvent.click(button);
66+
67+
expect(mockHandleClick).toHaveBeenCalledTimes(1);
68+
});
69+
70+
it("should disable the button when disabled=true is given as a prop", () => {
71+
const { container } = render(
72+
<VenmoOneTimePaymentButton
73+
onApprove={() => Promise.resolve()}
74+
orderId="123"
75+
presentationMode="auto"
76+
disabled={true}
77+
/>,
78+
);
79+
const button = container.querySelector("venmo-button");
80+
expect(button).toHaveAttribute("disabled");
81+
});
82+
83+
it("should disable button when error is present", () => {
84+
jest.spyOn(console, "error").mockImplementation();
85+
mockUseVenmoOneTimePaymentSession.mockReturnValue({
86+
error: new Error("Test error"),
87+
handleClick: mockHandleClick,
88+
});
89+
const { container } = render(
90+
<VenmoOneTimePaymentButton
91+
onApprove={() => Promise.resolve()}
92+
orderId="123"
93+
presentationMode="auto"
94+
/>,
95+
);
96+
const button = container.querySelector("venmo-button");
97+
expect(button).toHaveAttribute("disabled");
98+
});
99+
100+
it("should not disable button when error is null", () => {
101+
mockUseVenmoOneTimePaymentSession.mockReturnValue({
102+
error: null,
103+
handleClick: mockHandleClick,
104+
});
105+
const { container } = render(
106+
<VenmoOneTimePaymentButton
107+
onApprove={() => Promise.resolve()}
108+
orderId="123"
109+
presentationMode="auto"
110+
/>,
111+
);
112+
const button = container.querySelector("venmo-button");
113+
expect(button).not.toHaveAttribute("disabled");
114+
});
115+
116+
it("should pass type prop to venmo-button", () => {
117+
const { container } = render(
118+
<VenmoOneTimePaymentButton
119+
onApprove={() => Promise.resolve()}
120+
orderId="123"
121+
presentationMode="auto"
122+
type="subscribe"
123+
/>,
124+
);
125+
const button = container.querySelector("venmo-button");
126+
expect(button).toHaveAttribute("type", "subscribe");
127+
});
128+
129+
it("should pass hook props to useVenmoOneTimePaymentSession", () => {
130+
const hookProps = {
131+
clientToken: "test-token",
132+
amount: "10.00",
133+
currency: "USD",
134+
onApprove: () => Promise.resolve(),
135+
orderId: "123",
136+
presentationMode: "auto" as const,
137+
};
138+
render(<VenmoOneTimePaymentButton {...hookProps} />);
139+
expect(mockUseVenmoOneTimePaymentSession).toHaveBeenCalledWith(
140+
hookProps,
141+
);
142+
});
143+
144+
it("should log error to console when an error from the hook is present", () => {
145+
const consoleErrorSpy = jest
146+
.spyOn(console, "error")
147+
.mockImplementation();
148+
const testError = new Error("Test error");
149+
mockUseVenmoOneTimePaymentSession.mockReturnValue({
150+
error: testError,
151+
handleClick: mockHandleClick,
152+
});
153+
render(
154+
<VenmoOneTimePaymentButton
155+
onApprove={() => Promise.resolve()}
156+
orderId="123"
157+
presentationMode="auto"
158+
/>,
159+
);
160+
expect(consoleErrorSpy).toHaveBeenCalledWith(testError);
161+
consoleErrorSpy.mockRestore();
162+
});
163+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React, { useEffect } from "react";
2+
3+
import { useVenmoOneTimePaymentSession } from "../hooks/useVenmoOneTimePaymentSession";
4+
import { isServer } from "../utils";
5+
6+
import type { ButtonProps } from "../types";
7+
import type { UseVenmoOneTimePaymentSessionProps } from "../hooks/useVenmoOneTimePaymentSession";
8+
9+
type VenmoOneTimePaymentButtonProps = UseVenmoOneTimePaymentSessionProps &
10+
ButtonProps & {
11+
autoRedirect?: never;
12+
};
13+
14+
/**
15+
* `VenmoOneTimePaymentButton` is a button that provides a standard Venmo payment flow.
16+
*
17+
* `VenmoOneTimePaymentButtonProps` combines the arguments for {@link UseVenmoOneTimePaymentSessionProps}
18+
* and {@link ButtonProps}.
19+
*
20+
* @example
21+
* <VenmoOneTimePaymentButton
22+
* onApprove={() => {
23+
* // ... on approve logic
24+
* }}
25+
* orderId="your-order-id"
26+
* presentationMode="auto"
27+
* />
28+
*/
29+
export const VenmoOneTimePaymentButton = ({
30+
type = "pay",
31+
disabled = false,
32+
...hookProps
33+
}: VenmoOneTimePaymentButtonProps): JSX.Element | null => {
34+
const { error, handleClick } = useVenmoOneTimePaymentSession(hookProps);
35+
const isServerSide = isServer();
36+
37+
useEffect(() => {
38+
if (error) {
39+
console.error(error);
40+
}
41+
}, [error]);
42+
43+
return isServerSide ? (
44+
<div />
45+
) : (
46+
<venmo-button
47+
onClick={handleClick}
48+
type={type}
49+
disabled={disabled || error !== null ? true : undefined}
50+
></venmo-button>
51+
);
52+
};

packages/react-paypal-js/src/v6/hooks/useEligibleMethods.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { renderHook } from "@testing-library/react-hooks";
22

3+
import { expectCurrentErrorValue } from "./useErrorTestUtil";
34
import {
45
useEligibleMethods,
56
fetchEligibleMethods,
@@ -366,7 +367,7 @@ describe("useEligibleMethods", () => {
366367
environment: "sandbox",
367368
}),
368369
);
369-
370+
expectCurrentErrorValue(result.current.error);
370371
expect(result.current.isLoading).toBe(false);
371372
expect(result.current.error).toEqual(
372373
new Error(

packages/react-paypal-js/src/v6/hooks/useVenmoOneTimePaymentSession.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ export type UseVenmoOneTimePaymentSessionProps = (
2929
export function useVenmoOneTimePaymentSession({
3030
presentationMode,
3131
fullPageOverlay,
32-
autoRedirect,
3332
createOrder,
3433
orderId,
3534
...callbacks
@@ -85,15 +84,13 @@ export function useVenmoOneTimePaymentSession({
8584
const startOptions = {
8685
presentationMode,
8786
fullPageOverlay,
88-
autoRedirect,
8987
} as VenmoPresentationModeOptions;
9088

9189
await sessionRef.current.start(startOptions, createOrder?.());
9290
}, [
9391
isMountedRef,
9492
presentationMode,
9593
fullPageOverlay,
96-
autoRedirect,
9794
createOrder,
9895
setError,
9996
]);

packages/react-paypal-js/src/v6/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./types";
22
export { PayPalOneTimePaymentButton } from "./components/PayPalOneTimePaymentButton";
3+
export { VenmoOneTimePaymentButton } from "./components/VenmoOneTimePaymentButton";
34
export { PayPalProvider } from "./components/PayPalProvider";
45
export { usePayPal } from "./hooks/usePayPal";
56
export { usePayLaterOneTimePaymentSession } from "./hooks/usePayLaterOneTimePaymentSession";

packages/react-paypal-js/src/v6/types/sdkWebComponents.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ declare module "react" {
1212
namespace JSX {
1313
interface IntrinsicElements {
1414
"paypal-button": IntrinsicButtonProps;
15+
"venmo-button": IntrinsicButtonProps;
1516
}
1617
}
1718
}

0 commit comments

Comments
 (0)