Skip to content

Commit

Permalink
Add support for Embedded Checkout (beta) (#440)
Browse files Browse the repository at this point in the history
* [WIP] Embedded Checkout support

* Use types

* tests

* act

* more tests

* rename and fix

* fix import

* rename demo

* rename demo
  • Loading branch information
cweiss-stripe authored Sep 12, 2023
1 parent 17df531 commit 73da52b
Show file tree
Hide file tree
Showing 11 changed files with 863 additions and 7 deletions.
75 changes: 75 additions & 0 deletions examples/hooks/12-Embedded-Checkout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// This example shows you how to set up React Stripe.js and use
// Embedded Checkout.
// Learn how to accept a payment using the official Stripe docs.
// https://stripe.com/docs/payments/accept-a-payment#web

import React from 'react';
import {loadStripe} from '@stripe/stripe-js';
import {EmbeddedCheckoutProvider, EmbeddedCheckout} from '../../src';

import '../styles/common.css';

const App = () => {
const [pk, setPK] = React.useState(
window.sessionStorage.getItem('react-stripe-js-pk') || ''
);
const [clientSecret, setClientSecret] = React.useState(
window.sessionStorage.getItem('react-stripe-js-embedded-client-secret') ||
''
);

React.useEffect(() => {
window.sessionStorage.setItem('react-stripe-js-pk', pk || '');
}, [pk]);
React.useEffect(() => {
window.sessionStorage.setItem(
'react-stripe-js-embedded-client-secret',
clientSecret || ''
);
}, [clientSecret]);

const [stripePromise, setStripePromise] = React.useState();

const handleSubmit = (e) => {
e.preventDefault();
setStripePromise(loadStripe(pk, {betas: ['embedded_checkout_beta_1']}));
};

const handleUnload = () => {
setStripePromise(null);
};

return (
<>
<form onSubmit={handleSubmit}>
<label>
CheckoutSession client_secret
<input
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
/>
</label>
<label>
Publishable key{' '}
<input value={pk} onChange={(e) => setPK(e.target.value)} />
</label>
<button style={{marginRight: 10}} type="submit">
Load
</button>
<button type="button" onClick={handleUnload}>
Unload
</button>
</form>
{stripePromise && clientSecret && (
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{clientSecret}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
)}
</>
);
};

export default App;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"@babel/preset-env": "^7.7.1",
"@babel/preset-react": "^7.7.0",
"@storybook/react": "^6.5.0-beta.8",
"@stripe/stripe-js": "2.1.1",
"@stripe/stripe-js": "^2.1.2",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1.1",
"@testing-library/react-hooks": "^8.0.0",
Expand Down
132 changes: 132 additions & 0 deletions src/components/EmbeddedCheckout.client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React from 'react';
import {render, act} from '@testing-library/react';

import * as EmbeddedCheckoutProviderModule from './EmbeddedCheckoutProvider';
import {EmbeddedCheckout} from './EmbeddedCheckout';
import * as mocks from '../../test/mocks';

const {EmbeddedCheckoutProvider} = EmbeddedCheckoutProviderModule;

describe('EmbeddedCheckout on the client', () => {
let mockStripe: any;
let mockStripePromise: any;
let mockEmbeddedCheckout: any;
let mockEmbeddedCheckoutPromise: any;
const fakeClientSecret = 'cs_123_secret_abc';
const fakeOptions = {clientSecret: fakeClientSecret};

beforeEach(() => {
mockStripe = mocks.mockStripe();
mockStripePromise = Promise.resolve(mockStripe);
mockEmbeddedCheckout = mocks.mockEmbeddedCheckout();
mockEmbeddedCheckoutPromise = Promise.resolve(mockEmbeddedCheckout);
mockStripe.initEmbeddedCheckout.mockReturnValue(
mockEmbeddedCheckoutPromise
);

jest.spyOn(React, 'useLayoutEffect');
});

afterEach(() => {
jest.restoreAllMocks();
});

it('passes id to the wrapping DOM element', async () => {
const {container} = render(
<EmbeddedCheckoutProvider
stripe={mockStripePromise}
options={fakeOptions}
>
<EmbeddedCheckout id="foo" />
</EmbeddedCheckoutProvider>
);
await act(async () => await mockStripePromise);

const embeddedCheckoutDiv = container.firstChild as Element;
expect(embeddedCheckoutDiv.id).toBe('foo');
});

it('passes className to the wrapping DOM element', async () => {
const {container} = render(
<EmbeddedCheckoutProvider
stripe={mockStripePromise}
options={fakeOptions}
>
<EmbeddedCheckout className="bar" />
</EmbeddedCheckoutProvider>
);
await act(async () => await mockStripePromise);

const embeddedCheckoutDiv = container.firstChild as Element;
expect(embeddedCheckoutDiv).toHaveClass('bar');
});

it('mounts Embedded Checkout', async () => {
const {container} = render(
<EmbeddedCheckoutProvider stripe={mockStripe} options={fakeOptions}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);

await act(() => mockEmbeddedCheckoutPromise);

expect(mockEmbeddedCheckout.mount).toBeCalledWith(container.firstChild);
});

it('does not mount until Embedded Checkouts has been initialized', async () => {
// Render with no stripe instance and client secret
const {container, rerender} = render(
<EmbeddedCheckoutProvider stripe={null} options={{clientSecret: null}}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
expect(mockEmbeddedCheckout.mount).not.toBeCalled();

// Set stripe prop
rerender(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={{clientSecret: null}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
expect(mockEmbeddedCheckout.mount).not.toBeCalled();

// Set client secret
rerender(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={{clientSecret: fakeClientSecret}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
expect(mockEmbeddedCheckout.mount).not.toBeCalled();

// Resolve initialization promise
await act(() => mockEmbeddedCheckoutPromise);

expect(mockEmbeddedCheckout.mount).toBeCalledWith(container.firstChild);
});

it('unmounts Embedded Checkout when the component unmounts', async () => {
const {container, rerender} = render(
<EmbeddedCheckoutProvider stripe={mockStripe} options={fakeOptions}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);

await act(() => mockEmbeddedCheckoutPromise);

expect(mockEmbeddedCheckout.mount).toBeCalledWith(container.firstChild);

rerender(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={fakeOptions}
></EmbeddedCheckoutProvider>
);
expect(mockEmbeddedCheckout.unmount).toBeCalled();
});
});
60 changes: 60 additions & 0 deletions src/components/EmbeddedCheckout.server.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @jest-environment node
*/

import React from 'react';
import {renderToString} from 'react-dom/server';

import * as EmbeddedCheckoutProviderModule from './EmbeddedCheckoutProvider';
import {EmbeddedCheckout} from './EmbeddedCheckout';

const {EmbeddedCheckoutProvider} = EmbeddedCheckoutProviderModule;

describe('EmbeddedCheckout on the server (without stripe and clientSecret props)', () => {
beforeEach(() => {
jest.spyOn(React, 'useLayoutEffect');
});

afterEach(() => {
jest.restoreAllMocks();
});

it('passes id to the wrapping DOM element', () => {
const result = renderToString(
<EmbeddedCheckoutProvider stripe={null} options={{clientSecret: null}}>
<EmbeddedCheckout id="foo" />
</EmbeddedCheckoutProvider>
);

expect(result).toBe('<div id="foo"></div>');
});

it('passes className to the wrapping DOM element', () => {
const result = renderToString(
<EmbeddedCheckoutProvider stripe={null} options={{clientSecret: null}}>
<EmbeddedCheckout className="bar" />
</EmbeddedCheckoutProvider>
);
expect(result).toEqual('<div class="bar"></div>');
});

it('throws when Embedded Checkout is mounted outside of EmbeddedCheckoutProvider context', () => {
// Prevent the console.errors to keep the test output clean
jest.spyOn(console, 'error');
(console.error as any).mockImplementation(() => {});

expect(() => renderToString(<EmbeddedCheckout />)).toThrow(
'<EmbeddedCheckout> must be used within <EmbeddedCheckoutProvider>'
);
});

it('does not call useLayoutEffect', () => {
renderToString(
<EmbeddedCheckoutProvider stripe={null} options={{clientSecret: null}}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);

expect(React.useLayoutEffect).not.toHaveBeenCalled();
});
});
56 changes: 56 additions & 0 deletions src/components/EmbeddedCheckout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';
import {useEmbeddedCheckoutContext} from './EmbeddedCheckoutProvider';
import {isServer} from '../utils/isServer';

interface EmbeddedCheckoutProps {
/**
* Passes through to the Embedded Checkout container.
*/
id?: string;

/**
* Passes through to the Embedded Checkout container.
*/
className?: string;
}

const EmbeddedCheckoutClientElement = ({
id,
className,
}: EmbeddedCheckoutProps) => {
const {embeddedCheckout} = useEmbeddedCheckoutContext();

const isMounted = React.useRef<boolean>(false);
const domNode = React.useRef<HTMLDivElement | null>(null);

React.useLayoutEffect(() => {
if (!isMounted.current && embeddedCheckout && domNode.current !== null) {
embeddedCheckout.mount(domNode.current);
isMounted.current = true;
}

// Clean up on unmount
return () => {
if (isMounted.current && embeddedCheckout) {
embeddedCheckout.unmount();
isMounted.current = false;
}
};
}, [embeddedCheckout]);

return <div ref={domNode} id={id} className={className} />;
};

// Only render the wrapper in a server environment.
const EmbeddedCheckoutServerElement = ({
id,
className,
}: EmbeddedCheckoutProps) => {
// Validate that we are in the right context by calling useEmbeddedCheckoutContext.
useEmbeddedCheckoutContext();
return <div id={id} className={className} />;
};

export const EmbeddedCheckout = isServer
? EmbeddedCheckoutServerElement
: EmbeddedCheckoutClientElement;
Loading

0 comments on commit 73da52b

Please sign in to comment.