diff --git a/.changeset/large-cobras-fold.md b/.changeset/large-cobras-fold.md new file mode 100644 index 0000000000..31c8c96dab --- /dev/null +++ b/.changeset/large-cobras-fold.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen-react': patch +--- + +Adds BuyNowButton that adds an item to the cart and redirects the customer to checkout. diff --git a/packages/react/src/BuyNowButton.test.tsx b/packages/react/src/BuyNowButton.test.tsx new file mode 100644 index 0000000000..328bb04217 --- /dev/null +++ b/packages/react/src/BuyNowButton.test.tsx @@ -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('', () => { + it('renders a button', () => { + render(Buy now, { + wrapper: CartProvider, + }); + expect(screen.getByRole('button')).toHaveTextContent('Buy now'); + }); + + it('can optionally disable the button', () => { + render( + + Buy now + , + { + wrapper: CartProvider, + } + ); + + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('allows pass-through props', () => { + render( + + Buy now + , + { + 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( + + Buy now + , + { + 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(Buy now, { + 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).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(Buy now, { + wrapper: CartProvider, + }); + + expect(mockSetHref).toHaveBeenCalledTimes(1); + expect(mockSetHref).toHaveBeenCalledWith('/checkout?id=123'); + }); + }); +}); diff --git a/packages/react/src/BuyNowButton.tsx b/packages/react/src/BuyNowButton.tsx new file mode 100644 index 0000000000..f105b10c8c --- /dev/null +++ b/packages/react/src/BuyNowButton.tsx @@ -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( + props: BuyNowButtonProps & BaseButtonProps +) { + const {cartCreate, checkoutUrl} = useCart(); + const [loading, setLoading] = useState(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 ( + + {children} + + ); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index cbeed5e866..6e8815fae5 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,5 @@ export {AddToCartButton} from './AddToCartButton.js'; +export {BuyNowButton} from './BuyNowButton.js'; export type { CartState, CartStatus,