Skip to content

Commit

Permalink
Buy now button (#62)
Browse files Browse the repository at this point in the history
* buy now button

* minor fixusps and change set
  • Loading branch information
cartogram authored Nov 10, 2022
1 parent 6c9d0f7 commit f1cb723
Show file tree
Hide file tree
Showing 4 changed files with 217 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/large-cobras-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/hydrogen-react': patch
---

Adds BuyNowButton that adds an item to the cart and redirects the customer to checkout.
149 changes: 149 additions & 0 deletions packages/react/src/BuyNowButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import {CartProvider, useCart} from './CartProvider.js';
import {render, screen} from '@testing-library/react';
import {vi} from 'vitest';
import userEvent from '@testing-library/user-event';
import {BuyNowButton} from './BuyNowButton.js';

vi.mock('./CartProvider');

const defaultCart = {
buyerIdentityUpdate: vi.fn(),
cartAttributesUpdate: vi.fn(),
cartCreate: vi.fn(),
cartFragment: '',
checkoutUrl: '',
discountCodesUpdate: vi.fn(),
linesAdd: vi.fn(),
linesRemove: vi.fn(),
linesUpdate: vi.fn(),
noteUpdate: vi.fn(),
status: 'idle' as const,
totalQuantity: 0,
};

describe('<BuyNowButton/>', () => {
it('renders a button', () => {
render(<BuyNowButton variantId="1">Buy now</BuyNowButton>, {
wrapper: CartProvider,
});
expect(screen.getByRole('button')).toHaveTextContent('Buy now');
});

it('can optionally disable the button', () => {
render(
<BuyNowButton disabled variantId="1">
Buy now
</BuyNowButton>,
{
wrapper: CartProvider,
}
);

expect(screen.getByRole('button')).toBeDisabled();
});

it('allows pass-through props', () => {
render(
<BuyNowButton className="fancy-button" variantId="1">
Buy now
</BuyNowButton>,
{
wrapper: CartProvider,
}
);

expect(screen.getByRole('button')).toHaveClass('fancy-button');
});

describe('when the button is clicked', () => {
it('uses useCartCreateCallback with the correct arguments', async () => {
const mockCartCreate = vi.fn();

vi.mocked(useCart).mockImplementation(() => ({
...defaultCart,
cartCreate: mockCartCreate,
}));

const user = userEvent.setup();

render(
<BuyNowButton
attributes={[
{key: 'color', value: 'blue'},
{key: 'size', value: 'large'},
]}
quantity={4}
variantId="SKU123"
>
Buy now
</BuyNowButton>,
{
wrapper: CartProvider,
}
);

await user.click(screen.getByRole('button'));

expect(mockCartCreate).toHaveBeenCalledTimes(1);
expect(mockCartCreate).toHaveBeenCalledWith({
lines: [
{
quantity: 4,
merchandiseId: 'SKU123',
attributes: [
{key: 'color', value: 'blue'},
{key: 'size', value: 'large'},
],
},
],
});
});

it('disables the button', async () => {
const user = userEvent.setup();

render(<BuyNowButton variantId="1">Buy now</BuyNowButton>, {
wrapper: CartProvider,
});

const button = screen.getByRole('button');

expect(button).not.toBeDisabled();

await user.click(button);

expect(button).toBeDisabled();
});
});

describe('when a checkout URL is available', () => {
const {location} = window;
const mockSetHref = vi.fn((href) => href);

beforeEach(() => {
delete (window as Partial<Window>).location;
window.location = {...window.location};
Object.defineProperty(window.location, 'href', {
set: mockSetHref,
});
});

afterEach(() => {
window.location = location;
});

it('redirects to checkout', () => {
vi.mocked(useCart).mockImplementation(() => ({
...defaultCart,
checkoutUrl: '/checkout?id=123',
}));

render(<BuyNowButton variantId="1">Buy now</BuyNowButton>, {
wrapper: CartProvider,
});

expect(mockSetHref).toHaveBeenCalledTimes(1);
expect(mockSetHref).toHaveBeenCalledWith('/checkout?id=123');
});
});
});
62 changes: 62 additions & 0 deletions packages/react/src/BuyNowButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {useEffect, useState, useCallback} from 'react';
import {useCart} from './CartProvider.js';
import {BaseButton, BaseButtonProps} from './BaseButton.js';

interface BuyNowButtonProps {
/** The item quantity. Defaults to 1. */
quantity?: number;
/** The ID of the variant. */
variantId: string;
/** An array of cart line attributes that belong to the item being added to the cart. */
attributes?: {
key: string;
value: string;
}[];
}

/** The `BuyNowButton` component renders a button that adds an item to the cart and redirects the customer to checkout. */
export function BuyNowButton<AsType extends React.ElementType = 'button'>(
props: BuyNowButtonProps & BaseButtonProps<AsType>
) {
const {cartCreate, checkoutUrl} = useCart();
const [loading, setLoading] = useState<boolean>(false);

const {
quantity,
variantId,
onClick,
attributes,
children,
...passthroughProps
} = props;

useEffect(() => {
if (checkoutUrl) {
window.location.href = checkoutUrl;
}
}, [checkoutUrl]);

const handleBuyNow = useCallback(() => {
setLoading(true);
cartCreate({
lines: [
{
quantity: quantity ?? 1,
merchandiseId: variantId,
attributes,
},
],
});
}, [cartCreate, quantity, variantId, attributes]);

return (
<BaseButton
disabled={loading ?? passthroughProps.disabled}
{...passthroughProps}
onClick={onClick}
defaultOnClick={handleBuyNow}
>
{children}
</BaseButton>
);
}
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {AddToCartButton} from './AddToCartButton.js';
export {BuyNowButton} from './BuyNowButton.js';
export type {
CartState,
CartStatus,
Expand Down

0 comments on commit f1cb723

Please sign in to comment.