├── .gitignore ├── CHANGELOG.md ├── README.md ├── __tests__ └── all.test.tsx ├── babel.config.js ├── fixtures ├── checkout-create-fixture.js ├── checkout-line-items-add-fixture.js ├── checkout-line-items-remove-fixture.js ├── checkout-line-items-update-fixture.js └── shop-info-fixture.js ├── package.json ├── setupJest.js ├── src ├── api │ ├── get-all-collections.ts │ ├── get-all-pages.ts │ ├── get-all-products.ts │ └── get-product.ts ├── commerce.tsx ├── index.tsx ├── use-add-item.tsx ├── use-price.tsx ├── use-remove-item.tsx ├── use-update-item.tsx └── utils │ └── types.ts ├── support ├── components.tsx └── mock.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.1] - 2021-01-10 4 | 5 | - `usePrice`: Use `locale` from configuration. 6 | 7 | ## [1.1.0] - 2021-01-10 8 | 9 | - `CommerceProvider`: `currencyCode` now optional. 10 | - `CommerceProvider`: Added `locale` config to be used in currency code format. 11 | - `useAddItem`: Convert from array of items to single item add. To match API documentation. 12 | - `usePrice`: Avoid possibility of a negative value. 13 | - Added tests. 14 | 15 | ## [1.0.1] - 2021-01-07 16 | 17 | - Add typing for `shopify-buy` dependency. 18 | 19 | ## [1.0.0] - 2021-01-06 20 | 21 | - First release 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **[Next.js Commerce](https://github.com/vercel/commerce) now supports Shopify. This package is not required if you're looking for Shopify integration for your Next.js Commerce application.** 2 | 3 | ## Table of Contents 4 | 5 | - [Getting Started](#getting-started) 6 | - [Installation](#installation) 7 | - [CommerceProvider](#commerceprovider) 8 | - [useCommerce](#usecommerce) 9 | - [Hooks](#hooks) 10 | - [usePrice](#useprice) 11 | - [useAddItem](#useadditem) 12 | - [useRemoveItem](#useremoveitem) 13 | - [useUpdateItem](#useupdateitem) 14 | - [APIs](#apis) 15 | - [getProduct](#getproduct) 16 | - [getAllProducts](#getallproducts) 17 | - [getAllCollections](#getallcollections) 18 | - [getAllPages](#getallpages) 19 | 20 | # nextjs-commerce-shopify 21 | 22 | Collection of hooks and data fetching functions to integrate Shopify in a React application. Designed to work with [Next.js Commerce](https://demo.vercel.store/) and can be used for other React commerce applications that need Shopify integration. 23 | 24 | **This package requires modifications to your Next.js Commerce application. For a smoother integration, use their own Shopify support.** 25 | 26 | ## Getting Started 27 | 28 | ### Installation 29 | 30 | ``` 31 | yarn install nextjs-commerce-shopify 32 | ``` 33 | 34 | ### CommerceProvider 35 | 36 | Provider component that creates the commerce context for children. 37 | 38 | ```js 39 | import { CommerceProvider } from 'nextjs-commerce-shopify'; 40 | 41 | const App = ({ children }) => { 42 | return ( 43 | 50 | {children} 51 | 52 | ); 53 | }; 54 | 55 | export default App; 56 | ``` 57 | 58 | The `config` takes: 59 | 60 | - `domain`: Shopify domain. This is **required**. 61 | - `token`: Shopify Storefront API Access Token. This is **required**. 62 | - `currencyCode`: Currency code to use in store. Defaults to your Shopify default currency. 63 | - `locale`: Used for currency format and if your Shopify supports translated content. Defaults to `en-US`. 64 | 65 | ### useCommerce 66 | 67 | Returns the configs that are defined in the nearest `CommerceProvider`. Also provides access to Shopify's `checkout` and `shop`. 68 | 69 | ```js 70 | import { useCommerce } from 'nextjs-commerce-shopify'; 71 | 72 | const { checkout, shop } = useCommerce(); 73 | ``` 74 | 75 | - `checkout`: The information required to checkout items and pay ([Documentation](https://shopify.dev/docs/storefront-api/reference/checkouts/checkout)). 76 | - `shop`: Represents a collection of the general settings and information about the shop ([Documentation](https://shopify.dev/docs/storefront-api/reference/online-store/shop/index)). 77 | 78 | ## Hooks 79 | 80 | ### usePrice 81 | 82 | Display the product variant price according to currency and locale. 83 | 84 | ```js 85 | import { usePrice } from 'nextjs-commerce-shopify'; 86 | 87 | const { price } = usePrice({ 88 | amount 89 | }); 90 | ``` 91 | 92 | Takes in either `amount` or `variant`: 93 | 94 | - `amount`: A price value for a particular item if the amount is known. 95 | - `variant`: A shopify product variant. Price will be extracted from the variant. 96 | 97 | ### useAddItem 98 | 99 | ```js 100 | import { useAddItem } from 'nextjs-commerce-shopify'; 101 | 102 | const AddToCartButton = ({ variantId, quantity }) => { 103 | const addItem = useAddItem(); 104 | 105 | const addToCart = async () => { 106 | await addItem({ 107 | variantId, 108 | quantity 109 | }); 110 | }; 111 | 112 | return ; 113 | }; 114 | ``` 115 | 116 | ### useRemoveItem 117 | 118 | ```js 119 | import { useRemoveItem } from 'nextjs-commerce-shopify'; 120 | 121 | const RemoveButton = ({ item }) => { 122 | const removeItem = useRemoveItem(); 123 | 124 | const handleRemove = async () => { 125 | await removeItem({ id: item.id }); 126 | }; 127 | 128 | return ; 129 | }; 130 | ``` 131 | 132 | ### useUpdateItem 133 | 134 | ```js 135 | import { useUpdateItem } from 'nextjs-commerce-shopify'; 136 | 137 | const CartItem = ({ item }) => { 138 | const [quantity, setQuantity] = useState(item.quantity); 139 | const updateItem = useUpdateItem(item); 140 | 141 | const updateQuantity = async (e) => { 142 | const val = e.target.value; 143 | await updateItem({ quantity: val }); 144 | }; 145 | 146 | return ( 147 | 154 | ); 155 | }; 156 | ``` 157 | 158 | ## APIs 159 | 160 | Collections of APIs to fetch data from a Shopify store. 161 | 162 | The data is fetched using the [Shopify JavaScript Buy SDK](https://github.com/Shopify/js-buy-sdk#readme). Read the [Shopify Storefront API reference](https://shopify.dev/docs/storefront-api/reference) for more information. 163 | 164 | ### getProduct 165 | 166 | Get a single product by its `handle`. 167 | 168 | ```js 169 | import { getProduct } from 'nextjs-commerce-shopify'; 170 | 171 | const product = await getProduct({ 172 | domain, 173 | token, 174 | handle 175 | }); 176 | ``` 177 | 178 | ### getAllProducts 179 | 180 | ```js 181 | import { getAllProducts } from 'nextjs-commerce-shopify'; 182 | 183 | const products = await getAllProducts({ 184 | domain, 185 | token 186 | }); 187 | ``` 188 | 189 | ### getAllCollections 190 | 191 | ```js 192 | import { getAllCollections } from 'nextjs-commerce-shopify'; 193 | 194 | const collections = await getAllCollections({ 195 | domain, 196 | token 197 | }); 198 | ``` 199 | 200 | ### getAllPages 201 | 202 | ```js 203 | import { getAllPages } from 'nextjs-commerce-shopify'; 204 | 205 | const pages = await getAllPages({ 206 | domain, 207 | token 208 | }); 209 | ``` 210 | -------------------------------------------------------------------------------- /__tests__/all.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import mock from '../support/mock'; 3 | import { render, act, fireEvent } from '@testing-library/react'; 4 | 5 | import fixtureCheckoutCreate from '../fixtures/checkout-create-fixture'; 6 | import fixtureShopInfo from '../fixtures/shop-info-fixture'; 7 | import fixtureCheckoutLineItemsAdd from '../fixtures/checkout-line-items-add-fixture'; 8 | import fixtureCheckoutLineItemsRemove from '../fixtures/checkout-line-items-remove-fixture'; 9 | import fixtureCheckoutLineItemsUpdate from '../fixtures/checkout-line-items-update-fixture'; 10 | 11 | import { Wrapper, App } from '../support/components'; 12 | 13 | describe('nextjs-commerce-shopify', () => { 14 | test('should add/remove/update items in shopify', async () => { 15 | mock(fixtureShopInfo); 16 | mock(fixtureCheckoutCreate); 17 | mock(fixtureCheckoutLineItemsAdd); 18 | mock(fixtureCheckoutLineItemsUpdate); 19 | mock(fixtureCheckoutLineItemsRemove); 20 | 21 | const { getByText, getByTestId, getAllByText } = render( 22 | 23 | 24 | 25 | ); 26 | 27 | await act(async () => {}); 28 | 29 | expect(getByText('SGD 10.00')); 30 | expect(getByTestId('total-items').textContent).toBe('5'); 31 | 32 | act(() => { 33 | fireEvent.click(getByText('Add Item')); 34 | }); 35 | 36 | await act(async () => {}); 37 | 38 | expect(getByTestId('total-items').textContent).toBe('15'); 39 | 40 | act(() => { 41 | fireEvent.click(getAllByText('Update Item')[0]); 42 | }); 43 | 44 | await act(async () => {}); 45 | 46 | expect(getByTestId('total-items').textContent).toBe('2'); 47 | 48 | act(() => { 49 | fireEvent.click(getAllByText('Remove Item')[0]); 50 | }); 51 | 52 | await act(async () => {}); 53 | 54 | expect(getByTestId('total-items').textContent).toBe('0'); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | '@babel/preset-react' 6 | ] 7 | }; 8 | -------------------------------------------------------------------------------- /fixtures/checkout-create-fixture.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: { 3 | checkoutCreate: { 4 | checkoutUserErrors: [], 5 | userErrors: [], 6 | checkout: { 7 | id: 8 | 'Z2lkOi8vc2hvcGlmeS9DaGVja291dC9lM2JkNzFmNzI0OGM4MDZmMzM3MjVhNTNlMzM5MzFlZj9rZXk9NDcwOTJlNDQ4NTI5MDY4ZDFiZTUyZTUwNTE2MDNhZjg=', 9 | ready: true, 10 | lineItems: { 11 | pageInfo: { 12 | hasNextPage: false, 13 | hasPreviousPage: false 14 | }, 15 | edges: [ 16 | { 17 | cursor: 18 | 'eyJsYXN0X2lkIjoiZDUyZWU5ZTEwYmQxMWE0NDlkNmQzMWNkMzBhMGFjNzMifQ==', 19 | node: { 20 | title: 'Intelligent Granite Table', 21 | variant: { 22 | id: 23 | 'Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0VmFyaWFudC8yOTEwNjA2NDU4NA==', 24 | title: 'Awesome Copper Bench', 25 | price: '64.99', 26 | compareAtPrice: null, 27 | weight: 4.5, 28 | image: null, 29 | selectedOptions: [ 30 | { 31 | name: 'Color or something', 32 | value: 'Awesome Copper Bench' 33 | } 34 | ], 35 | presentmentPrices: { 36 | pageInfo: { 37 | hasNextPage: false, 38 | hasPreviousPage: false 39 | }, 40 | edges: [ 41 | { 42 | node: { 43 | price: { 44 | amount: '64.99', 45 | currencyCode: 'USD' 46 | }, 47 | compareAtPrice: null 48 | } 49 | } 50 | ] 51 | } 52 | }, 53 | quantity: 5, 54 | customAttributes: [] 55 | } 56 | } 57 | ] 58 | }, 59 | shippingAddress: { 60 | address1: '123 Cat Road', 61 | address2: null, 62 | city: 'Cat Land', 63 | company: 'Catmart', 64 | country: 'Canada', 65 | firstName: 'Meow', 66 | formatted: [ 67 | 'Catmart', 68 | '123 Cat Road', 69 | 'Cat Land ON M3O 0W1', 70 | 'Canada' 71 | ], 72 | lastName: 'Meowington', 73 | latitude: null, 74 | longitude: null, 75 | phone: '4161234566', 76 | province: 'Ontario', 77 | zip: 'M3O 0W1', 78 | name: 'Meow Meowington', 79 | countryCode: 'CA', 80 | provinceCode: 'ON', 81 | id: 'Z2lkOi8vc2hvcGlmeS9QcmdfnAU8nakdWMnAbh890hyOTEwNjA2NDU4NA==' 82 | }, 83 | shippingLine: null, 84 | requiresShipping: true, 85 | customAttributes: [], 86 | note: null, 87 | paymentDue: '367.19', 88 | webUrl: 89 | 'https://checkout.myshopify.io/1/checkouts/c4abf4bf036239ab5e3d0bf93c642c96', 90 | orderStatusUrl: null, 91 | taxExempt: false, 92 | taxesIncluded: false, 93 | currencyCode: 'CAD', 94 | totalTax: '42.24', 95 | lineItemsSubtotalPrice: { 96 | amount: '324.95', 97 | currencyCode: 'CAD' 98 | }, 99 | subtotalPrice: '324.95', 100 | totalPrice: '367.19', 101 | completedAt: null, 102 | createdAt: '2017-03-28T16:58:31Z', 103 | updatedAt: '2017-03-28T16:58:31Z' 104 | } 105 | } 106 | } 107 | }; 108 | -------------------------------------------------------------------------------- /fixtures/checkout-line-items-add-fixture.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: { 3 | checkoutLineItemsAdd: { 4 | userErrors: [], 5 | checkoutUserErrors: [], 6 | checkout: { 7 | id: 8 | 'Z2lkOi8vc2hvcGlmeS9DaGVja291dC9lM2JkNzFmNzI0OGM4MDZmMzM3MjVhNTNlMzM5MzFlZj9rZXk9NDcwOTJlNDQ4NTI5MDY4ZDFiZTUyZTUwNTE2MDNhZjg=', 9 | createdAt: '2017-03-17T16:00:40Z', 10 | updatedAt: '2017-03-17T16:00:40Z', 11 | requiresShipping: true, 12 | shippingLine: null, 13 | shippingAddress: { 14 | address1: '123 Cat Road', 15 | address2: null, 16 | city: 'Cat Land', 17 | company: 'Catmart', 18 | country: 'Canada', 19 | firstName: 'Meow', 20 | formatted: [ 21 | 'Catmart', 22 | '123 Cat Road', 23 | 'Cat Land ON M3O 0W1', 24 | 'Canada' 25 | ], 26 | lastName: 'Meowington', 27 | latitude: null, 28 | longitude: null, 29 | phone: '4161234566', 30 | province: 'Ontario', 31 | zip: 'M3O 0W1', 32 | name: 'Meow Meowington', 33 | countryCode: 'CA', 34 | provinceCode: 'ON', 35 | id: '291dC9lM2JkNzHJnnf8a89njNJNKAhu1gn7lMzM5MzFlZj9rZXk9NDcwOTJ==' 36 | }, 37 | lineItems: { 38 | pageInfo: { 39 | hasNextPage: false, 40 | hasPreviousPage: false 41 | }, 42 | edges: [ 43 | { 44 | cursor: 45 | 'eyJsYXN0X2lkIjoiZDUyZWU5ZTEwYmQxMWE0NDlkNmQzMWNkMzBhMGFjNzMifQ==', 46 | node: { 47 | title: 'Intelligent Granite Table', 48 | variant: { 49 | id: 'Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0VmFyaWFudC8yOTEwNjA2NDU4NA==' 50 | }, 51 | quantity: 5, 52 | customAttributes: [] 53 | } 54 | }, 55 | { 56 | cursor: 57 | 'eyJsYXN0X2lkfsI01DUyZWU5ZTEwYmQxMWE0NDlkNmQzMknK1DKhMGFjNzfAxQ=', 58 | node: { 59 | title: 'Intelligent Marble Table', 60 | variant: { 61 | id: 'ZNc0vnIOijnJabh4873nNQnfb9B0QhnFyvk9Wfh87oNBeqBHGQNA5a==' 62 | }, 63 | quantity: 5, 64 | customAttributes: [] 65 | } 66 | }, 67 | { 68 | cursor: 69 | 'eyJsYXf3X2dm9aQI01PLqZbU5ZfSEYmQxNWE0NDlkNmQzMknK1DKhMGFj9afKqP=', 70 | node: { 71 | title: 'Intelligent Wooden Table', 72 | variant: { 73 | id: 'Zad7JHnbf32JHna087juBQn8faB84Ba28VnqjF87Qynaw8MnDhNA3W==' 74 | }, 75 | quantity: 5, 76 | customAttributes: [] 77 | } 78 | } 79 | ] 80 | } 81 | } 82 | } 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /fixtures/checkout-line-items-remove-fixture.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: { 3 | checkoutLineItemsRemove: { 4 | userErrors: [], 5 | checkoutUserErrors: [], 6 | checkout: { 7 | id: 8 | 'Z2lkOi8vc2hvcGlmeS9DaGVja291dC9lM2JkNzFmNzI0OGM4MDZmMzM3MjVhNTNlMzM5MzFlZj9rZXk9NDcwOTJlNDQ4NTI5MDY4ZDFiZTUyZTUwNTE2MDNhZjg=', 9 | ready: true, 10 | lineItems: { 11 | pageInfo: { 12 | hasNextPage: false, 13 | hasPreviousPage: false 14 | }, 15 | edges: [] 16 | }, 17 | shippingAddress: { 18 | address1: '123 Cat Road', 19 | address2: null, 20 | city: 'Cat Land', 21 | company: 'Catmart', 22 | country: 'Canada', 23 | firstName: 'Meow', 24 | formatted: [ 25 | 'Catmart', 26 | '123 Cat Road', 27 | 'Cat Land ON M3O 0W1', 28 | 'Canada' 29 | ], 30 | lastName: 'Meowington', 31 | latitude: null, 32 | longitude: null, 33 | phone: '4161234566', 34 | province: 'Ontario', 35 | zip: 'M3O 0W1', 36 | name: 'Meow Meowington', 37 | countryCode: 'CA', 38 | provinceCode: 'ON', 39 | id: 40 | 'Z2lkOi8vc2hvcGlmeSsiujh8aQJbnkl9Qcm9kdWN0VmaJKN8flqAnq8TEwNjA2NDU4NA==' 41 | }, 42 | shippingLine: null, 43 | requiresShipping: true, 44 | customAttributes: [], 45 | note: null, 46 | paymentDue: '367.19', 47 | webUrl: 48 | 'https://checkout.myshopify.io/1/checkouts/c4abf4bf036239ab5e3d0bf93c642c96', 49 | orderStatusUrl: null, 50 | taxExempt: false, 51 | taxesIncluded: false, 52 | currencyCode: 'CAD', 53 | totalTax: '42.24', 54 | lineItemsSubtotalPrice: { 55 | amount: '0.00', 56 | currencyCode: 'CAD' 57 | }, 58 | subtotalPrice: '324.95', 59 | totalPrice: '367.19', 60 | completedAt: null, 61 | createdAt: '2017-03-28T16:58:31Z', 62 | updatedAt: '2017-03-28T16:58:31Z' 63 | } 64 | } 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /fixtures/checkout-line-items-update-fixture.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: { 3 | checkoutLineItemsUpdate: { 4 | userErrors: [], 5 | checkoutUserErrors: [], 6 | checkout: { 7 | id: 8 | 'Z2lkOi8vc2hvcGlmeS9DaGVja291dC9lM2JkNzFmNzI0OGM4MDZmMzM3MjVhNTNlMzM5MzFlZj9rZXk9NDcwOTJlNDQ4NTI5MDY4ZDFiZTUyZTUwNTE2MDNhZjg=', 9 | ready: true, 10 | lineItems: { 11 | pageInfo: { 12 | hasNextPage: false, 13 | hasPreviousPage: false 14 | }, 15 | edges: [ 16 | { 17 | cursor: 18 | 'eyJsYXN0X2lkIjoiZmI3MTEwMmYwZDM4ZGU0NmUwMzdiMzBmODE3ZTlkYjUifQ==', 19 | node: { 20 | id: 21 | 'zUzNzQ1ZjU0OTVlZjIyYzIxYzVkZj9rZXk9MTlkMjljZDgwYjg3MGMxNmRmNjNjM2JjODUzYjY3MTY=', 22 | title: 'Arena Zip Boot', 23 | variant: { 24 | id: 25 | 'Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0VmFyaWFudC8yOTEwNjA2NDU4NA==', 26 | title: 'Black / 8', 27 | price: '188.00', 28 | compareAtPrice: '190.00', 29 | weight: 0, 30 | image: { 31 | id: 'Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0SW1hZ2UvMTgyMTc3OTA2NjQ=', 32 | src: 33 | 'https://cdn.shopify.com/s/files/1/1312/0893/products/003_3e206539-20d3-49c0-8bff-006e449906ca.jpg?v=1491850970', 34 | altText: null 35 | }, 36 | selectedOptions: [ 37 | { 38 | name: 'Color', 39 | value: 'Black' 40 | }, 41 | { 42 | name: 'Size', 43 | value: '8' 44 | } 45 | ], 46 | presentmentPrices: { 47 | pageInfo: { 48 | hasNextPage: false, 49 | hasPreviousPage: false 50 | }, 51 | edges: [ 52 | { 53 | node: { 54 | price: { 55 | amount: '188.00', 56 | currencyCode: 'USD' 57 | }, 58 | compareAtPrice: { 59 | amount: '190.00', 60 | currencyCode: 'USD' 61 | } 62 | } 63 | } 64 | ] 65 | } 66 | }, 67 | quantity: 2, 68 | customAttributes: [] 69 | } 70 | } 71 | ] 72 | }, 73 | shippingAddress: null, 74 | shippingLine: null, 75 | requiresShipping: true, 76 | customAttributes: [], 77 | note: null, 78 | paymentDue: '376.00', 79 | webUrl: 80 | 'https://checkout.shopify.com/13120893/checkouts/e28b55a3205f8d129a9b7223287ec95a?key=191add76e8eba90b93cfe4d5d261c4cb', 81 | order: null, 82 | orderStatusUrl: null, 83 | taxExempt: false, 84 | taxesIncluded: false, 85 | currencyCode: 'CAD', 86 | totalTax: '0.00', 87 | lineItemsSubtotalPrice: { 88 | amount: '376.00', 89 | currencyCode: 'CAD' 90 | }, 91 | subtotalPrice: '376.00', 92 | totalPrice: '376.00', 93 | completedAt: null, 94 | createdAt: '2017-04-13T21:54:16Z', 95 | updatedAt: '2017-04-13T21:54:17Z' 96 | } 97 | } 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /fixtures/shop-info-fixture.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: { 3 | shop: { 4 | currencyCode: 'CAD', 5 | description: 'pls send me cats', 6 | moneyFormat: '${{amount}}', 7 | name: 'sendmecats', 8 | primaryDomain: { 9 | host: 'sendmecats.myshopify.com', 10 | sslEnabled: true, 11 | url: 'https://sendmecats.myshopify.com' 12 | } 13 | } 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-commerce-shopify", 3 | "version": "1.1.1", 4 | "description": "Collection of hooks and data fetching functions to integrate Shopify in a React application.", 5 | "main": "./dist/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "test": "jest", 9 | "prepublishOnly": "yarn build" 10 | }, 11 | "files": [ 12 | "dist" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/petermekhaeil/nextjs-commerce-shopify.git" 17 | }, 18 | "keywords": [ 19 | "nextjs", 20 | "commerce", 21 | "shopify" 22 | ], 23 | "author": "Peter Mekhaeil ", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/petermekhaeil/nextjs-commerce-shopify/issues" 27 | }, 28 | "homepage": "https://github.com/petermekhaeil/nextjs-commerce-shopify#readme", 29 | "peerDependencies": { 30 | "react": "^16.14.0" 31 | }, 32 | "dependencies": { 33 | "@types/shopify-buy": "^2.10.3", 34 | "shopify-buy": "^2.11.0" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.12.10", 38 | "@babel/preset-env": "^7.12.11", 39 | "@babel/preset-react": "^7.12.10", 40 | "@babel/preset-typescript": "^7.12.7", 41 | "@testing-library/react": "^11.2.3", 42 | "@types/jest": "^26.0.20", 43 | "@types/react": "^17.0.0", 44 | "babel-jest": "^26.6.3", 45 | "jest": "^26.6.3", 46 | "jest-fetch-mock": "^3.0.3", 47 | "react": "^16.9.0", 48 | "react-dom": "^16.9.0", 49 | "react-test-renderer": "^16.9.0", 50 | "typescript": "^4.1.3" 51 | }, 52 | "jest": { 53 | "automock": false, 54 | "setupFiles": [ 55 | "./setupJest.js" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /setupJest.js: -------------------------------------------------------------------------------- 1 | require('jest-fetch-mock').enableMocks(); 2 | -------------------------------------------------------------------------------- /src/api/get-all-collections.ts: -------------------------------------------------------------------------------- 1 | import Client from 'shopify-buy'; 2 | 3 | type Options = { 4 | domain: string; 5 | token: string; 6 | }; 7 | 8 | const getAllCollections = async (options: Options) => { 9 | const { domain, token } = options; 10 | 11 | const client = Client.buildClient({ 12 | storefrontAccessToken: token, 13 | domain: domain 14 | }); 15 | 16 | const res = await client.collection.fetchAllWithProducts(); 17 | 18 | return JSON.parse(JSON.stringify(res)); 19 | }; 20 | 21 | export default getAllCollections; 22 | -------------------------------------------------------------------------------- /src/api/get-all-pages.ts: -------------------------------------------------------------------------------- 1 | import { Page, PageEdge } from '../utils/types'; 2 | 3 | type Options = { 4 | domain: string; 5 | token: string; 6 | }; 7 | 8 | const getAllPages = async (options: Options): Promise => { 9 | const { domain, token } = options; 10 | 11 | const url = `https://${domain}/api/2020-07/graphql.json`; 12 | 13 | const query = ` 14 | { 15 | pages(first: 100) { 16 | edges { 17 | node { 18 | id 19 | title 20 | handle 21 | body 22 | bodySummary 23 | url 24 | } 25 | } 26 | } 27 | } 28 | `; 29 | 30 | const opts = { 31 | method: 'POST', 32 | headers: { 33 | 'Content-Type': 'application/json', 34 | 'X-Shopify-Storefront-Access-Token': token 35 | }, 36 | body: JSON.stringify({ query }) 37 | }; 38 | 39 | const res = await fetch(url, opts).then((res) => res.json()); 40 | 41 | return res.data.pages.edges.map(({ node }: PageEdge) => node); 42 | }; 43 | 44 | export default getAllPages; 45 | -------------------------------------------------------------------------------- /src/api/get-all-products.ts: -------------------------------------------------------------------------------- 1 | import Client from 'shopify-buy'; 2 | import { Product } from '../utils/types'; 3 | 4 | type Options = { 5 | domain: string; 6 | token: string; 7 | }; 8 | 9 | const getAllProducts = async (options: Options): Promise => { 10 | const { domain, token } = options; 11 | 12 | const client = Client.buildClient({ 13 | storefrontAccessToken: token, 14 | domain: domain 15 | }); 16 | 17 | const res = await client.product.fetchAll(); 18 | 19 | return JSON.parse(JSON.stringify(res)); 20 | }; 21 | 22 | export default getAllProducts; 23 | -------------------------------------------------------------------------------- /src/api/get-product.ts: -------------------------------------------------------------------------------- 1 | import Client from 'shopify-buy'; 2 | import { Product } from '../utils/types'; 3 | 4 | type Options = { 5 | domain: string; 6 | token: string; 7 | handle: string; 8 | }; 9 | 10 | const getProduct = async (options: Options): Promise => { 11 | const { domain, token, handle } = options; 12 | 13 | const client = Client.buildClient({ 14 | storefrontAccessToken: token, 15 | domain: domain 16 | }); 17 | 18 | const res = await client.product.fetchByHandle(handle); 19 | 20 | return JSON.parse(JSON.stringify(res)); 21 | }; 22 | 23 | export default getProduct; 24 | -------------------------------------------------------------------------------- /src/commerce.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ReactNode, 3 | createContext, 4 | useContext, 5 | useMemo, 6 | useState, 7 | useEffect 8 | } from 'react'; 9 | import Client from 'shopify-buy'; 10 | import { Shop, Cart, Client as ClientType } from './utils/types'; 11 | 12 | const Commerce = createContext({}); 13 | 14 | type CommerceProps = { 15 | children?: ReactNode; 16 | config: CommerceConfig; 17 | }; 18 | 19 | type CommerceConfig = { 20 | token: string; 21 | domain: string; 22 | currencyCode?: string; 23 | locale?: string; 24 | sessionToken?: string; 25 | }; 26 | 27 | type CommerceContextValue = { 28 | client: ClientType; 29 | shop: Shop; 30 | checkout: Cart; 31 | updateCheckout: (cart: Cart | undefined) => void; 32 | currencyCode: string; 33 | locale: string; 34 | sessionToken: string; 35 | }; 36 | 37 | const getCheckoutIdFromStorage = (token: string) => { 38 | if (window && window.sessionStorage) { 39 | return window.sessionStorage.getItem(token); 40 | } 41 | 42 | return null; 43 | }; 44 | 45 | const setCheckoutIdInStorage = (token: string, id: string | number) => { 46 | if (window && window.sessionStorage) { 47 | return window.sessionStorage.setItem(token, id + ''); 48 | } 49 | }; 50 | 51 | const defaults = { 52 | locale: 'en-US', 53 | sessionToken: 'nextjs-commerce-shopify-token' 54 | }; 55 | 56 | export function CommerceProvider({ children, config }: CommerceProps) { 57 | const sessionToken = config.sessionToken || defaults.sessionToken; 58 | const locale = config.locale || defaults.locale; 59 | 60 | const client = Client.buildClient({ 61 | storefrontAccessToken: config.token, 62 | domain: config.domain, 63 | language: locale 64 | }) as ClientType; 65 | 66 | const [shop, setShop] = useState(); 67 | const [checkout, setCheckout] = useState(); 68 | 69 | const fetchShopify = async () => { 70 | const shopInfo: Shop = await client.shop.fetchInfo(); 71 | let checkoutResource: Cart; 72 | 73 | const checkoutOptions = { 74 | presentmentCurrencyCode: config.currencyCode || shopInfo?.currencyCode 75 | }; 76 | 77 | let checkoutId = getCheckoutIdFromStorage(sessionToken); 78 | 79 | // we could have a cart id stored in session storage 80 | // user could be refreshing or navigating back and forth 81 | if (checkoutId) { 82 | checkoutResource = await client.checkout.fetch(checkoutId); 83 | 84 | // could be expired order - we will create a new order 85 | if (checkoutResource.completedAt) { 86 | checkoutResource = await client.checkout.create(checkoutOptions); 87 | } 88 | } else { 89 | checkoutResource = await client.checkout.create(checkoutOptions); 90 | } 91 | 92 | setCheckoutIdInStorage(sessionToken, checkoutResource.id); 93 | 94 | setShop(shopInfo); 95 | setCheckout(checkoutResource); 96 | }; 97 | 98 | useEffect(() => { 99 | fetchShopify(); 100 | }, []); 101 | 102 | const updateCheckout = (newCheckout: Cart) => { 103 | setCheckout(newCheckout); 104 | }; 105 | 106 | // Because the config is an object, if the parent re-renders this provider 107 | // will re-render every consumer unless we memoize the config 108 | const cfg = useMemo( 109 | () => ({ 110 | client, 111 | checkout, 112 | shop, 113 | updateCheckout: updateCheckout, 114 | currencyCode: config.currencyCode || checkout?.currencyCode, 115 | locale, 116 | sessionToken 117 | }), 118 | [client] 119 | ); 120 | 121 | return {children}; 122 | } 123 | 124 | export function useCommerce() { 125 | return useContext(Commerce) as T; 126 | } 127 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './commerce'; 2 | 3 | export { default as useAddItem } from './use-add-item'; 4 | export { default as usePrice } from './use-price'; 5 | export { default as useRemoveItem } from './use-remove-item'; 6 | export { default as useUpdateItem } from './use-update-item'; 7 | 8 | export { default as getAllPages } from './api/get-all-pages'; 9 | export { default as getAllProducts } from './api/get-all-products'; 10 | export { default as getAllCollections } from './api/get-all-collections'; 11 | export { default as getProduct } from './api/get-product'; 12 | 13 | export * from './utils/types'; 14 | -------------------------------------------------------------------------------- /src/use-add-item.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { LineItemToAdd } from 'shopify-buy'; 3 | import { useCommerce } from './index'; 4 | 5 | const useAddItem = () => { 6 | const { checkout, client, updateCheckout } = useCommerce(); 7 | 8 | return useCallback( 9 | async function addItem(lineItem: LineItemToAdd) { 10 | const cart = await client?.checkout.addLineItems(checkout.id, [lineItem]); 11 | updateCheckout(cart); 12 | return cart; 13 | }, 14 | [checkout, client] 15 | ); 16 | }; 17 | 18 | export default useAddItem; 19 | -------------------------------------------------------------------------------- /src/use-price.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useCommerce } from './commerce'; 3 | import { ProductVariant } from './utils/types'; 4 | 5 | export function formatPrice({ 6 | amount, 7 | currencyCode, 8 | locale 9 | }: { 10 | amount: number; 11 | currencyCode: string; 12 | locale: string; 13 | }) { 14 | const formatCurrency = new Intl.NumberFormat(locale, { 15 | style: 'currency', 16 | currency: currencyCode 17 | }); 18 | 19 | return formatCurrency.format(amount); 20 | } 21 | 22 | export default function usePrice( 23 | data?: { 24 | amount?: number; 25 | variant?: ProductVariant; 26 | } | null 27 | ) { 28 | const { currencyCode, locale } = useCommerce(); 29 | const { amount, variant } = data ?? {}; 30 | let variantPriceInCurrency = -1; 31 | 32 | if (variant && variant.presentmentPrices) { 33 | const pricePresentmentInCurrency = variant.presentmentPrices.find( 34 | (presentmentPrice) => { 35 | return presentmentPrice.price.currencyCode === currencyCode; 36 | } 37 | ); 38 | 39 | if (pricePresentmentInCurrency) { 40 | variantPriceInCurrency = pricePresentmentInCurrency.price.amount; 41 | } 42 | } 43 | 44 | const amountToUse = amount || variantPriceInCurrency; 45 | 46 | // best to return empty string than an incorrect value 47 | if (amountToUse < 0) { 48 | return { price: '' }; 49 | } 50 | 51 | const value = useMemo(() => { 52 | return formatPrice({ amount: amountToUse, currencyCode, locale }); 53 | }, [amount, currencyCode]); 54 | 55 | return { price: value }; 56 | } 57 | -------------------------------------------------------------------------------- /src/use-remove-item.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useCommerce } from './index'; 3 | 4 | const useRemoveItem = () => { 5 | const { checkout, client, updateCheckout } = useCommerce(); 6 | 7 | return useCallback( 8 | async function removeItem({ id }: { id: string }) { 9 | const cart = await client?.checkout.removeLineItems(checkout.id, [id]); 10 | updateCheckout(cart); 11 | return cart; 12 | }, 13 | [checkout, client] 14 | ); 15 | }; 16 | 17 | export default useRemoveItem; 18 | -------------------------------------------------------------------------------- /src/use-update-item.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useCommerce } from './index'; 3 | import { Product } from './utils/types'; 4 | 5 | const useUpdateItem = (item: Product) => { 6 | const { checkout, client, updateCheckout } = useCommerce(); 7 | 8 | return useCallback( 9 | async function updateItem({ quantity }: { quantity: number }) { 10 | const lineItemsToUpdate = [{ id: item.id, quantity }]; 11 | 12 | const cart = await client?.checkout.updateLineItems( 13 | checkout.id, 14 | lineItemsToUpdate 15 | ); 16 | 17 | updateCheckout(cart); 18 | 19 | return cart; 20 | }, 21 | [checkout, client] 22 | ); 23 | }; 24 | 25 | export default useUpdateItem; 26 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Product as BaseProduct, 3 | ProductVariant as BaseProductVariant, 4 | Cart as BaseCart, 5 | CheckoutResource as BaseCheckoutResource, 6 | AttributeInput, 7 | Client as BaseClient, 8 | Shop as BaseShop 9 | } from 'shopify-buy'; 10 | 11 | export type SelectedOptions = { 12 | id: string; 13 | name: string; 14 | value: string; 15 | }; 16 | 17 | export type PresentmentPrice = { 18 | price: PriceV2; 19 | }; 20 | 21 | export type ProductVariant = BaseProductVariant & { 22 | selectedOptions: Array; 23 | presentmentPrices: Array; 24 | }; 25 | 26 | export type Product = BaseProduct & { 27 | handle: string; 28 | descriptionHtml: string; 29 | variants: Array; 30 | }; 31 | 32 | export type PriceV2 = { 33 | amount: number; 34 | currencyCode: string; 35 | }; 36 | 37 | export type Cart = BaseCart & { 38 | webUrl?: string; 39 | currencyCode?: string; 40 | lineItemsSubtotalPrice?: PriceV2; 41 | totalPriceV2?: PriceV2; 42 | }; 43 | 44 | export type Shop = BaseShop & { 45 | currencyCode?: string; 46 | }; 47 | 48 | export type Create = { 49 | presentmentCurrencyCode?: string; 50 | }; 51 | 52 | export type CheckoutResource = BaseCheckoutResource & { 53 | updateLineItems( 54 | checkoutId: string | number, 55 | lineItems: AttributeInput[] 56 | ): Promise; 57 | 58 | create: (input: Create) => Promise; 59 | }; 60 | 61 | export type Client = BaseClient & { 62 | checkout: CheckoutResource; 63 | }; 64 | 65 | export type Page = { 66 | id: string; 67 | title: string; 68 | handle: string; 69 | body: string; 70 | bodySummary: string; 71 | url: string; 72 | }; 73 | 74 | export type PageEdge = { 75 | node: Page; 76 | }; 77 | -------------------------------------------------------------------------------- /support/components.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRemoveItem, useUpdateItem, usePrice, useAddItem } from '../src'; 3 | import { CommerceProvider, useCommerce } from '../src/commerce'; 4 | 5 | export const Wrapper = ({ children }) => { 6 | return ( 7 | 10 | {children} 11 | 12 | ); 13 | }; 14 | 15 | export const CartItem = ({ item }) => { 16 | const removeItem = useRemoveItem(); 17 | const updateItem = useUpdateItem(item); 18 | 19 | const { price } = usePrice({ 20 | variant: item.variant 21 | }); 22 | 23 | const handleRemove = async () => { 24 | await removeItem({ id: 'variant-id' }); 25 | }; 26 | 27 | const handleUpdate = async () => { 28 | await updateItem({ quantity: 2 }); 29 | }; 30 | 31 | return ( 32 |
33 | {item.title} {price} 34 | 35 |
36 | ); 37 | }; 38 | 39 | export const App = () => { 40 | const { checkout } = useCommerce(); 41 | const { price } = usePrice({ amount: 10 }); 42 | const addItem = useAddItem(); 43 | 44 | const itemsCount = checkout?.lineItems.reduce( 45 | (count, item) => count + item.quantity, 46 | 0 47 | ); 48 | 49 | const items = checkout?.lineItems ?? []; 50 | 51 | const addToCart = async () => { 52 | await addItem({ 53 | variantId: 'variant-id', 54 | quantity: 1 55 | }); 56 | }; 57 | 58 | return ( 59 | <> 60 |
{price}
61 | 62 |
{itemsCount}
63 | {items.map((item, index) => ( 64 | 65 | ))} 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /support/mock.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'jest-fetch-mock'; 2 | 3 | const mock = (fixture: any) => 4 | fetch.mockResponseOnce(JSON.stringify(fixture), { 5 | status: 200, 6 | headers: { 'content-type': 'application/json' } 7 | }); 8 | 9 | export default mock; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "esModuleInterop": true, 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "react", 16 | "declaration": true, 17 | "outDir": "./dist" 18 | }, 19 | "include": ["src"], 20 | "exclude": ["node_nodules"] 21 | } 22 | --------------------------------------------------------------------------------