├── .babelrc ├── .chec.json ├── .editorconfig ├── .env.example ├── .gitignore ├── LICENSE.md ├── README.md ├── components ├── Breadcrumbs.js ├── Button.js ├── Cart.js ├── CartItem.js ├── CartSummary.js ├── Checkout │ ├── AddressFields.js │ ├── BillingForm.js │ ├── Checkout.js │ ├── CheckoutSummary.js │ ├── ExtraFieldsForm.js │ ├── OrderSummary.js │ ├── ShippingForm.js │ ├── Success.js │ └── index.js ├── Footer.js ├── Form │ ├── FormCheckbox.js │ ├── FormError.js │ ├── FormInput.js │ ├── FormSelect.js │ ├── FormTextarea.js │ └── index.js ├── Header.js ├── Layout.js ├── Modal.js ├── Product.js ├── ProductAttributes.js ├── ProductGrid.js ├── ProductImages.js ├── ProductList.js ├── RelatedProducts.js └── VariantPicker.js ├── context ├── cart.js ├── checkout.js ├── modal.js └── theme.js ├── lib ├── commerce.js └── gtag.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── _document.js ├── index.js └── products │ └── [permalink].js ├── postcss.config.js ├── public ├── checkout │ └── doesntexist.svg └── product-attributes │ ├── 2-lovely-hours.svg │ ├── 41-delicious-recipes.svg │ ├── 5-fine-pieces.svg │ ├── 6-piece-set.svg │ ├── 77-pages.svg │ ├── actual-magick.svg │ ├── an-actually-good-bread-knife.svg │ ├── cap.svg │ ├── clean-easily.svg │ ├── diam.svg │ ├── dishwasher-safe.svg │ ├── free-book-mark.svg │ ├── glossy-paper.svg │ ├── hand-bound.svg │ ├── hand-carved-walnut.svg │ ├── high-carbon-steel.svg │ ├── high.svg │ ├── hot-tips.svg │ ├── learn-knife-skills.svg │ ├── made-in-france.svg │ ├── made-in-germany.svg │ ├── made-in-new-york.svg │ ├── magnetic-holder.svg │ ├── pdf-included.svg │ ├── quality-scoops.svg │ ├── stain-resistant.svg │ ├── stop-crying-over-onions.svg │ ├── tappered-grip.svg │ ├── thanks.svg │ ├── url-or-irl.svg │ └── will-outlive-you.svg ├── seeds ├── assets.json ├── categories.json └── products.json ├── svg ├── chevron.svg ├── commercejs.svg ├── loading.svg ├── logo.svg └── quality-badge.svg └── tailwind.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": ["inline-react-svg"] 4 | } -------------------------------------------------------------------------------- /.chec.json: -------------------------------------------------------------------------------- 1 | { 2 | "npm": "npm", 3 | "buildScripts": ["seed", "dev"], 4 | "dotenv": { 5 | "NODE_ENV": "development", 6 | "NEXT_PUBLIC_CHEC_PUBLIC_API_KEY": "%chec_pkey%", 7 | "CHEC_API_URL": "%chec_api_url%", 8 | "CHEC_SECRET_KEY": "%chec_skey%" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information about the properties used in this file, 2 | # please see the EditorConfig documentation: 3 | # http://editorconfig.org 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.{yml,js,json,css,scss,feature,eslintrc}] 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_CHEC_PUBLIC_API_KEY=pk_test_193037b865ee81e89ff82cb93aa448630d5614b9b0098 2 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_mvn44h2BYsLB1qwhqRAXMkOT 3 | NEXT_PUBLIC_GA_TRACKING_ID= 4 | # Secret key is used with chec/seeder to access your Chec account to seed it with sample data 5 | CHEC_SECRET_KEY= 6 | CHEC_API_URL=https://api.chec.io 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | .next 3 | 4 | # Environment variables 5 | .env 6 | 7 | # Dependency directories 8 | node_modules 9 | 10 | # Logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Misc 16 | .vercel 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Chec Platform LLC, All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1) Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 2) Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 7 | 3) Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

5 | A Next.js, Commerce.js, Stripe, and Vercel powered, open source storefront, cart and checkout experience. 6 |

7 | 8 |

9 | 10 | License 11 | 12 |
13 | commercejs.com | @commercejs | Slack 14 |
15 |
16 | 17 | View demo 18 | 19 |
20 |
21 | 22 | View demo 23 | 24 |

25 | 26 | ## Introduction 27 | 28 | ChopChop is our beautifully designed, elegantly developed demo store and starter kit that sells fine tools for thoughtful cooks. We’ve created a premium brand with a commerce experience to match. Read more about this resource on the [Commerce.js blog](https://commercejs.com/blog/chopchop-nextjs-starter-commerce/). 29 | 30 | 31 | ## 🥞 ChopChop Stack 32 | 33 | * [Next.js](https://nextjs.org/) 34 | * [Commerce.js](https://commercejs.com) 35 | * [Tailwind CSS](https://tailwindcss.com/) 36 | * [Stripe](https://stripe.com) 37 | * [Vercel](https://vercel.com/) 38 | 39 | ## Live demo 40 | 41 | Check out https://commercejs-chopchop-demo.vercel.app to see this project in action. 42 | 43 | ## Getting started 44 | 45 | ### Prerequisites 46 | 47 | - IDE or code editor of your choice 48 | - Node (v12 or higher) 49 | - NPM or Yarn 50 | - Optional: [Chec CLI](https://github.com/chec/cli) 51 | 52 | ### Use the Chec CLI 53 | 54 | You can use the [Chec CLI](https://github.com/chec/cli) to quickly and easily install demo stores like this, and also 55 | to install sample data into your account. To install the Chec CLI, run `npm install -g @chec/cli` (or `yarn global add @chec/cli`). 56 | 57 | * Navigate to your projects folder: `cd ~/Projects` 58 | * Install the ChopChop demo store: `chec demo-store` 59 | * Choose "Chop Chop demo store (Next.js)" from the list 60 | * This will install dependencies and sample data, then start your dev server 61 | * Stop the server, open `.env` and add your `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` for using Stripe, then re-run `npm run dev` 62 | * Open [http://localhost:3000](http://localhost:3000) and get started! 63 | 64 | ### Manual installation 65 | 66 | Clone the project, then get started by installing the dependencies, creating a `.env` file, and starting the dev server. 67 | 68 | ``` 69 | npm install 70 | cp .env.example .env 71 | npm run dev 72 | ``` 73 | 74 | Once the server is running, open it up in your browser, start editing the code, and enjoy! 75 | 76 | ### Sample data 77 | 78 | This repository comes with some sample products and images for you to use if you want to get up and running quickly. 79 | 80 | To install sample data, first copy `.env.example` to `.env`, then edit `.env` and fill out the 81 | following variables: 82 | 83 | * `NEXT_PUBLIC_CHEC_PUBLIC_API_KEY`: Your Chec public/sandbox API key, available from the Chec Dashboard under 84 | Developers > API keys 85 | * `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`: Your Stripe test publishable key, available from the Stripe dashboard 86 | * `CHEC_SECRET_KEY`: Your Chec secret API key, used for seeding 87 | * `NEXT_PUBLIC_GA_TRACKING_ID`: Set this with your Google Analytics ID if you want to enable GA. 88 | 89 | Once this is done, save and close your file. You can now run the seeder to install sample data: 90 | 91 | ``` 92 | npm run seed 93 | ... 94 | ✔ Completed seeding 95 | Added: 96 | 3 categories 97 | 6 products 98 | 9 assets 99 | ``` 100 | 101 | And you're ready to go! 102 | 103 | ### Deploying to Vercel (with one click) 104 | 105 | The one-click deploy allows you to add the Vercel application to your GitHub account to clone this repository and deploy it automatically. Be sure to go to [Vercel](https://vercel.com/signup) and sign up for an account with Github, GitLab, or GitBucket before clicking the deploy button. 106 | 107 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/project?template=https://github.com/chec/commercejs-chopchop-demo) 108 | 109 | Please make sure that you enter the required environment variables listed above during deployment. 110 | 111 | #### Caveats for sample data 112 | 113 | To make your ChopChop experience even better, there are a couple of things you can do that are not included with 114 | the sample data: 115 | 116 | * **Add related products:** Go into the [Chec Dashboard](https://dashboard.chec.io) and set related products for each 117 | of your new products. This helps to provide upsell suggestions on your website. 118 | * **Set up shipping rates:** Also in the dashboard, set up some shipping zones and rates in Settings > Shipping, then 119 | enable them on each of your products. This will enable the "Shipping" checkout screen, and allow you to charge 120 | shipping for your customers as well. 121 | 122 | ## Customizations and Extendability 123 | 124 | - Integrate another payment gateway, either one of our supported gateways or your own with our [manual gateway API](https://commercejs.com/docs/guides/manual-payment-integration) 125 | - Integrate with the Google Calendar API to automatically add ticketed items to a customer’s calendars 126 | - Suggest products from other sources based on items purchased, i.e. a book on knife skills if you buy the knife set 127 | - Add [Algolia](https://www.algolia.com/) for integrated search 128 | - Add additional modules to the checkout flow to handle other content types, like booking a time to pickup in-store purchases 129 | - Integrate with a headless CMS to make the content editable 130 | - Create a customers login section using our [customers endpoint](https://commercejs.com/docs/api/#customers) 131 | - Use webhooks to deliver SMS notifications about orders 132 | 133 | ## License 134 | 135 | This project is licensed under [BSD-3-Clause](LICENSE.md). 136 | 137 | ## ⚠️ Note 138 | 139 | ### This repository is no longer maintained 140 | However, we will accept issue reports and contributions for this repository. See the [contribute to the commerce community](https://commercejs.com/docs/community/contribute) page for more information on how to contribute to our open source projects. For update-to-date APIs, please check the latest version of the [API documentation](https://commercejs.com/docs/api/). 141 | -------------------------------------------------------------------------------- /components/Breadcrumbs.js: -------------------------------------------------------------------------------- 1 | import { useCheckoutState } from "../context/checkout"; 2 | 3 | // TODO: Build array of crumbs dynamically from available steps 4 | 5 | function Breadcrumbs({ inCart }) { 6 | const { currentStep, extrafields } = useCheckoutState(); 7 | 8 | if (inCart) { 9 | return Shopping Bag; 10 | } 11 | 12 | if (currentStep === "success") { 13 | return Order received; 14 | } 15 | 16 | return ( 17 |
18 | {currentStep === "extrafields" && ( 19 | <> 20 | Shopping Bag 21 | 22 | Booking 23 | 24 | Shipping 25 | 26 | Payment 27 | 28 | )} 29 | {currentStep === "shipping" && ( 30 | <> 31 | Shopping Bag 32 | 33 | {extrafields.length > 0 && ( 34 | <> 35 | 36 | Booking 37 | 38 | )} 39 | 40 | Shipping 41 | 42 | Payment 43 | 44 | )} 45 | {currentStep === "billing" && ( 46 | <> 47 | Shopping Bag 48 | {extrafields.length > 0 && ( 49 | <> 50 | 51 | Booking 52 | 53 | )} 54 | 55 | Shipping 56 | 57 | Payment 58 | 59 | )} 60 |
61 | ); 62 | } 63 | 64 | export default Breadcrumbs; 65 | -------------------------------------------------------------------------------- /components/Button.js: -------------------------------------------------------------------------------- 1 | import cc from "classcat"; 2 | 3 | import { useThemeState } from "../context/theme"; 4 | 5 | const buttonStyle = (theme) => { 6 | switch (theme) { 7 | case "kitchen-sink-journal-chopchop-shop": 8 | return "bg-clementine text-black"; 9 | case "walnut-cooks-tools-chopchop-shop": 10 | return "bg-tumbleweed text-black"; 11 | case "essential-knife-set-chopchop-shop": 12 | return "bg-hawkes-blue text-black"; 13 | case "private-cooking-class-chopchop-shop": 14 | return "bg-asparagus text-black"; 15 | case "ceramic-dutch-oven-chopchop-shop": 16 | return "bg-goldenrod text-black"; 17 | default: 18 | return "bg-white-rock"; 19 | } 20 | }; 21 | 22 | function Button({ className, ...props }) { 23 | const theme = useThemeState(); 24 | 25 | const buttonClass = cc([ 26 | "appearance-none border-none py-0.5 px-1.5 md:px-2 text-lg md:text-xl rounded transition focus:outline-none", 27 | buttonStyle(theme), 28 | className, 29 | ]); 30 | 31 | if (props.href) return ; 32 | 33 | return 37 | 38 | 39 | )} 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/CartItem.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Image from "next/image"; 3 | import { toast } from "react-toastify"; 4 | 5 | import { commerce } from "../lib/commerce"; 6 | import { useCartDispatch } from "../context/cart"; 7 | 8 | function CartItem({ id, media, name, quantity, line_total, selected_options }) { 9 | const { setCart } = useCartDispatch(); 10 | const hasVariants = selected_options.length >= 1; 11 | 12 | const handleUpdateCart = ({ cart }) => { 13 | setCart(cart); 14 | 15 | return cart; 16 | }; 17 | 18 | const handleRemoveItem = () => 19 | commerce.cart 20 | .remove(id) 21 | .then(handleUpdateCart) 22 | .then(({ subtotal }) => 23 | toast( 24 | `${name} has been removed from your cart. Your new subtotal is now ${subtotal.formatted_with_symbol}` 25 | ) 26 | ); 27 | 28 | const decrementQuantity = () => { 29 | quantity > 1 30 | ? commerce.cart 31 | .update(id, { quantity: quantity - 1 }) 32 | .then(handleUpdateCart) 33 | .then(({ subtotal }) => 34 | toast( 35 | `1 "${name}" has been removed from your cart. Your new subtotal is now ${subtotal.formatted_with_symbol}` 36 | ) 37 | ) 38 | : handleRemoveItem(); 39 | }; 40 | 41 | const incrementQuantity = () => 42 | commerce.cart 43 | .update(id, { quantity: quantity + 1 }) 44 | .then(handleUpdateCart) 45 | .then(({ subtotal }) => 46 | toast( 47 | `Another "${name}" has been added to your cart. Your new subtotal is now ${subtotal.formatted_with_symbol}` 48 | ) 49 | ); 50 | 51 | return ( 52 |
53 |
54 | {name} 62 |
63 |
64 |
65 |

66 | {name} 67 |

68 | {hasVariants && ( 69 |

70 | {selected_options.map(({ option_name }, index) => ( 71 | 72 | {index ? `, ${option_name}` : option_name} 73 | 74 | ))} 75 |

76 | )} 77 |
78 |
79 |
80 | {line_total.formatted_with_symbol} 81 |
82 |
83 |
84 | Quantity: 85 | 91 | {quantity} 92 | 98 |
99 |
100 | 106 |
107 |
108 |
109 |
110 |
111 | ); 112 | } 113 | 114 | export default CartItem; 115 | -------------------------------------------------------------------------------- /components/CartSummary.js: -------------------------------------------------------------------------------- 1 | import { useCartState } from "../context/cart"; 2 | import { useModalDispatch } from "../context/modal"; 3 | 4 | function CartSummary() { 5 | const { total_unique_items } = useCartState(); 6 | const { openModal } = useModalDispatch(); 7 | 8 | return ( 9 | 12 | ); 13 | } 14 | 15 | export default CartSummary; 16 | -------------------------------------------------------------------------------- /components/Checkout/AddressFields.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { FormInput, FormSelect } from "../Form"; 4 | 5 | function AddressFields({ prefix = "", countries = {}, subdivisions = {} }) { 6 | const reducer = ([code, name]) => ({ 7 | value: code, 8 | label: name, 9 | }); 10 | 11 | const formattedCountries = subdivisions 12 | ? Object.entries(countries).map(reducer) 13 | : []; 14 | 15 | const formattedSubdivisions = subdivisions 16 | ? Object.entries(subdivisions).map(reducer) 17 | : []; 18 | 19 | return ( 20 | 21 |
22 |
23 | 29 |
30 |
31 | 37 |
38 |
39 | 40 | 46 | 52 | 53 |
54 |
55 | 63 |
64 |
65 | 73 |
74 |
75 | 81 |
82 |
83 |
84 | ); 85 | } 86 | 87 | export default AddressFields; 88 | -------------------------------------------------------------------------------- /components/Checkout/BillingForm.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useFormContext } from "react-hook-form"; 3 | import { useDebounce } from "use-debounce"; 4 | import { 5 | CardNumberElement, 6 | CardExpiryElement, 7 | CardCvcElement, 8 | } from "@stripe/react-stripe-js"; 9 | 10 | import { commerce } from "../../lib/commerce"; 11 | 12 | import { useCheckoutState, useCheckoutDispatch } from "../../context/checkout"; 13 | 14 | import { FormCheckbox, FormInput, FormError } from "../Form"; 15 | import AddressFields from "./AddressFields"; 16 | 17 | const style = { 18 | base: { 19 | "::placeholder": { 20 | color: "rgba(21,7,3,0.3)", 21 | }, 22 | color: "#150703", 23 | fontSize: "16px", 24 | fontFamily: `Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"`, 25 | iconColor: "#6B7280", 26 | }, 27 | }; 28 | 29 | function BillingForm() { 30 | const [countries, setCountries] = useState(); 31 | const [subdivisions, setSubdivisions] = useState(); 32 | const methods = useFormContext(); 33 | const { collects } = useCheckoutState(); 34 | const { setError } = useCheckoutDispatch(); 35 | 36 | const { watch, setValue, clearErrors } = methods; 37 | 38 | const shipping = watch("shipping"); 39 | const [watchCountry] = useDebounce(watch("billing.country"), 600); 40 | 41 | useEffect(() => { 42 | fetchCountries(); 43 | }, []); 44 | 45 | useEffect(() => { 46 | watchCountry && fetchSubdivisions(watchCountry); 47 | }, [watchCountry]); 48 | 49 | const fetchCountries = async () => { 50 | try { 51 | const { countries } = await commerce.services.localeListCountries(); 52 | 53 | setCountries(countries); 54 | } catch (err) { 55 | // noop 56 | } 57 | }; 58 | 59 | const fetchSubdivisions = async (country) => { 60 | try { 61 | const { subdivisions } = await commerce.services.localeListSubdivisions( 62 | country 63 | ); 64 | 65 | setSubdivisions(subdivisions); 66 | } catch (err) { 67 | // noop 68 | } 69 | }; 70 | 71 | const onStripeChange = () => { 72 | clearErrors("stripe"); 73 | setError(null); 74 | }; 75 | 76 | return ( 77 |
78 |
79 |
80 | 81 | Billing address 82 | 83 | 84 | {collects?.shipping_address && ( 85 | 89 | checked && setValue("billing", shipping) 90 | } 91 | /> 92 | )} 93 | 94 | 99 |
100 |
101 | 102 |
103 |
104 | 105 | Payment 106 | 107 | 108 | 121 | 122 |
123 |
124 | 129 |
130 | 131 |
132 |
133 | 139 |
140 |
141 | 146 |
147 |
148 |
149 | 150 |
151 |
152 |
153 | ); 154 | } 155 | 156 | export default BillingForm; 157 | -------------------------------------------------------------------------------- /components/Checkout/Checkout.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useForm, FormProvider } from "react-hook-form"; 3 | import { useStripe, useElements } from "@stripe/react-stripe-js"; 4 | 5 | import { useCartDispatch } from "../../context/cart"; 6 | import { useCheckoutState, useCheckoutDispatch } from "../../context/checkout"; 7 | 8 | import ExtraFieldsForm from "./ExtraFieldsForm"; 9 | import ShippingForm from "./ShippingForm"; 10 | import BillingForm from "./BillingForm"; 11 | import Success from "./Success"; 12 | import CheckoutSummary from "./CheckoutSummary"; 13 | import OrderSummary from "./OrderSummary"; 14 | 15 | import LoadingSVG from "../../svg/loading.svg"; 16 | 17 | function Checkout({ cartId }) { 18 | const [order, setOrder] = useState(); 19 | const { reset: resetCart } = useCartDispatch(); 20 | const { currentStep, id, live } = useCheckoutState(); 21 | const { 22 | generateToken, 23 | setCurrentStep, 24 | nextStepFrom, 25 | capture, 26 | setProcessing, 27 | setError: setCheckoutError, 28 | } = useCheckoutDispatch(); 29 | const methods = useForm({ 30 | shouldUnregister: false, 31 | }); 32 | const { handleSubmit, setError } = methods; 33 | 34 | const stripe = useStripe(); 35 | const elements = useElements(); 36 | 37 | useEffect(() => { 38 | generateToken(cartId); 39 | }, [cartId]); 40 | 41 | const captureOrder = async (values) => { 42 | setProcessing(true); 43 | 44 | const { 45 | customer, 46 | shipping, 47 | billing: { firstname, lastname, region: county_state, ...billing }, 48 | ...data 49 | } = values; 50 | 51 | const { error, paymentMethod } = await stripe.createPaymentMethod({ 52 | type: "card", 53 | card: elements.getElement("cardNumber"), 54 | billing_details: { 55 | name: `${billing.firstname} ${billing.lastname}`, 56 | email: customer.email, 57 | }, 58 | }); 59 | 60 | if (error) { 61 | setError("stripe", { type: "manual", message: error.message }); 62 | setProcessing(false); 63 | return; 64 | } 65 | 66 | const checkoutPayload = { 67 | ...data, 68 | customer: { 69 | ...customer, 70 | firstname, 71 | lastname, 72 | }, 73 | ...(shipping && { 74 | shipping: { 75 | ...shipping, 76 | name: `${shipping.firstname} ${shipping.lastname}`, 77 | }, 78 | }), 79 | billing: { 80 | ...billing, 81 | name: `${firstname} ${lastname}`, 82 | county_state, 83 | }, 84 | }; 85 | 86 | try { 87 | const newOrder = await capture({ 88 | ...checkoutPayload, 89 | payment: { 90 | gateway: "stripe", 91 | stripe: { 92 | payment_method_id: paymentMethod.id, 93 | }, 94 | }, 95 | }); 96 | 97 | handleOrderSuccess(newOrder); 98 | setProcessing(false); 99 | } catch (res) { 100 | if ( 101 | res.statusCode !== 402 || 102 | res.data.error.type !== "requires_verification" 103 | ) { 104 | setCheckoutError(res.data.error.message); 105 | setProcessing(false); 106 | return; 107 | } 108 | 109 | const { error, paymentIntent } = await stripe.handleCardAction( 110 | res.data.error.param 111 | ); 112 | 113 | if (error) { 114 | setError("stripe", { type: "manual", message: error.message }); 115 | setProcessing(false); 116 | return; 117 | } 118 | 119 | try { 120 | const newOrder = await capture({ 121 | ...checkoutPayload, 122 | payment: { 123 | gateway: "stripe", 124 | stripe: { 125 | payment_intent_id: paymentIntent.id, 126 | }, 127 | }, 128 | }); 129 | 130 | handleOrderSuccess(newOrder); 131 | setProcessing(false); 132 | } catch (err) { 133 | setError("stripe", { type: "manual", message: error.message }); 134 | setProcessing(false); 135 | } 136 | } 137 | }; 138 | 139 | const handleOrderSuccess = (order) => { 140 | setOrder(order); 141 | setCurrentStep("success"); 142 | resetCart(); 143 | }; 144 | 145 | const onSubmit = (values) => { 146 | if (currentStep === "billing") return captureOrder(values); 147 | 148 | return setCurrentStep(nextStepFrom(currentStep)); 149 | }; 150 | 151 | if (!id) 152 | return ( 153 |
154 | 155 |

Preparing checkout

156 |
157 | ); 158 | 159 | return ( 160 | 161 |
165 | {currentStep === "extrafields" && } 166 | {currentStep === "shipping" && } 167 | {currentStep === "billing" && } 168 | {currentStep === "success" && } 169 | 170 | {order ? : } 171 | 172 |
173 | ); 174 | } 175 | 176 | export default Checkout; 177 | -------------------------------------------------------------------------------- /components/Checkout/CheckoutSummary.js: -------------------------------------------------------------------------------- 1 | import cc from "classcat"; 2 | 3 | import { useCheckoutState } from "../../context/checkout"; 4 | 5 | import Button from "../Button"; 6 | 7 | function CheckoutSummary({ subtotal, tax, shipping, line_items = [], total }) { 8 | const { processing, error } = useCheckoutState(); 9 | const count = line_items.length; 10 | 11 | return ( 12 |
13 |
14 |
15 |
    16 | {subtotal &&
  1. Subtotal: {subtotal.formatted_with_symbol}
  2. } 17 | {tax &&
  3. Tax: {tax.amount.formatted_with_symbol}
  4. } 18 | {shipping && ( 19 |
  5. Shipping: {shipping.price.formatted_with_symbol}
  6. 20 | )} 21 | {total && ( 22 |
  7. 23 | Total: {total.formatted_with_symbol}, {count}{" "} 24 | {count === 1 ? "item" : "items"} 25 |
  8. 26 | )} 27 |
28 |
29 |
30 |
31 | {error && {error}} 32 | 44 |
45 |
46 |
47 |
48 | ); 49 | } 50 | 51 | export default CheckoutSummary; 52 | -------------------------------------------------------------------------------- /components/Checkout/ExtraFieldsForm.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useCheckoutState, useCheckoutDispatch } from "../../context/checkout"; 3 | 4 | import { FormInput, FormCheckbox, FormTextarea } from "../Form"; 5 | 6 | // TODO: Update the UI to be built from the API 7 | // once products have extrafields that can be of 8 | // any type. E.g. "date", "textarea" 9 | 10 | // const fields = { 11 | // BookingDate: (props) => ( 12 | // <> 13 | // 14 | // 15 | // ), 16 | // }; 17 | 18 | function ExtraFieldsForm() { 19 | const { extrafields } = useCheckoutState(); 20 | const { setCurrentStep, nextStepFrom } = useCheckoutDispatch(); 21 | 22 | useEffect(() => { 23 | if (extrafields.length === 0) { 24 | setCurrentStep(nextStepFrom("extrafields")); 25 | } 26 | return null; 27 | }, [extrafields]); 28 | 29 | return ( 30 |
31 |
32 |
33 | 34 | Booking 35 | 36 | 37 | 38 | 39 | 43 | 44 | 45 |
46 |
47 | 48 |
49 |
50 | 51 | Lesson Plan 52 | 53 | 54 |

55 | Thanks for joining us for a lesson! Let us know what you might like 56 | to learn or cook below. 57 |

58 | 59 | {extrafields.map(({ id }) => { 60 | const computedFieldName = `extrafields.${id}`; 61 | 62 | return ( 63 | 70 | ); 71 | })} 72 |
73 |
74 |
75 | ); 76 | } 77 | 78 | export default ExtraFieldsForm; 79 | -------------------------------------------------------------------------------- /components/Checkout/OrderSummary.js: -------------------------------------------------------------------------------- 1 | import Button from "../Button"; 2 | 3 | function CheckoutSummary({ has, fulfillment, order }) { 4 | const { subtotal, tax, shipping, line_items, total } = order; 5 | 6 | const count = line_items.length; 7 | 8 | return ( 9 |
10 |
11 |
12 |
    13 |
  1. Subtotal: {subtotal.formatted_with_symbol}
  2. 14 | {tax &&
  3. Tax: {tax.amount.formatted_with_symbol}
  4. } 15 | {shipping && ( 16 |
  5. Shipping: {shipping.price.formatted_with_symbol}
  6. 17 | )} 18 | {total && ( 19 |
  7. 20 | Total: {total.formatted_with_symbol}, {count}{" "} 21 | {count === 1 ? "item" : "items"} 22 |
  8. 23 | )} 24 |
25 |
26 | {has.digital_fulfillment && ( 27 |
28 | {fulfillment.digital.downloads.map((download, index) => ( 29 |
33 | {download.packages.map(({ access_link, name }, index) => ( 34 | 42 | ))} 43 |
44 | ))} 45 |
46 | )} 47 |
48 |
49 | ); 50 | } 51 | 52 | export default CheckoutSummary; 53 | -------------------------------------------------------------------------------- /components/Checkout/ShippingForm.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useFormContext } from "react-hook-form"; 3 | import { useDebounce } from "use-debounce"; 4 | 5 | import { commerce } from "../../lib/commerce"; 6 | 7 | import { useCheckoutState, useCheckoutDispatch } from "../../context/checkout"; 8 | 9 | import AddressFields from "./AddressFields"; 10 | import { FormCheckbox as FormRadio, FormError } from "../Form"; 11 | 12 | function ShippingForm() { 13 | const { id } = useCheckoutState(); 14 | const { setShippingMethod } = useCheckoutDispatch(); 15 | const [countries, setCountries] = useState(); 16 | const [subdivisions, setSubdivisions] = useState(); 17 | const [shippingOptions, setShippingOptions] = useState([]); 18 | const methods = useFormContext(); 19 | const { watch, setValue } = methods; 20 | 21 | const [watchCountry] = useDebounce(watch("shipping.country"), 600); 22 | const watchSubdivision = watch("shipping.region"); 23 | 24 | useEffect(() => { 25 | fetchCountries(id); 26 | }, []); 27 | 28 | useEffect(() => { 29 | setValue("shipping.region", ""); 30 | 31 | if (watchCountry) { 32 | fetchSubdivisions(id, watchCountry); 33 | fetchShippingOptions(id, watchCountry); 34 | } 35 | }, [watchCountry]); 36 | 37 | useEffect(() => { 38 | if (watchSubdivision) { 39 | fetchShippingOptions(id, watchCountry, watchSubdivision); 40 | } 41 | }, [watchSubdivision]); 42 | 43 | const fetchCountries = async (checkoutId) => { 44 | try { 45 | const { countries } = await commerce.services.localeListShippingCountries( 46 | checkoutId 47 | ); 48 | 49 | setCountries(countries); 50 | } catch (err) { 51 | // noop 52 | } 53 | }; 54 | 55 | const fetchSubdivisions = async (checkoutId, countryCode) => { 56 | try { 57 | const { 58 | subdivisions, 59 | } = await commerce.services.localeListShippingSubdivisions( 60 | checkoutId, 61 | countryCode 62 | ); 63 | 64 | setSubdivisions(subdivisions); 65 | } catch (err) { 66 | // noop 67 | } 68 | }; 69 | 70 | const fetchShippingOptions = async (checkoutId, country, region) => { 71 | if (!checkoutId && !country) return; 72 | 73 | setValue("fulfillment.shipping_method", null); 74 | 75 | try { 76 | const shippingOptions = await commerce.checkout.getShippingOptions( 77 | checkoutId, 78 | { 79 | country, 80 | ...(region && { region }), 81 | } 82 | ); 83 | 84 | setShippingOptions(shippingOptions); 85 | 86 | if (shippingOptions.length === 1) { 87 | const [shippingOption] = shippingOptions; 88 | 89 | setValue("fulfillment.shipping_method", shippingOption.id); 90 | selectShippingMethod(shippingOption.id); 91 | } 92 | } catch (err) { 93 | // noop 94 | } 95 | }; 96 | 97 | const onShippingSelect = ({ target: { value } }) => 98 | selectShippingMethod(value); 99 | 100 | const selectShippingMethod = async (optionId) => { 101 | try { 102 | await setShippingMethod(optionId, watchCountry, watchSubdivision); 103 | } catch (err) { 104 | // noop 105 | } 106 | }; 107 | 108 | return ( 109 |
110 |
111 |
112 | 113 | Shipping address 114 | 115 | 116 | 121 |
122 |
123 |
124 |
125 | 126 | Shipping 127 | 128 |
129 | {watchCountry ? ( 130 | <> 131 |
132 | {shippingOptions.map(({ id, description, price }) => ( 133 |
134 | 143 |
144 | ))} 145 |
146 | 147 | 148 | 149 | ) : ( 150 |

151 | Please enter your address to fetch shipping options 152 |

153 | )} 154 |
155 |
156 |
157 |
158 | ); 159 | } 160 | 161 | export default ShippingForm; 162 | -------------------------------------------------------------------------------- /components/Checkout/Success.js: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | 3 | function Success({ has }) { 4 | return ( 5 |
6 |
7 |

8 | Thanks! 9 |

10 |

11 | {has.digital_fulfillment 12 | ? "You’ll receive an email with your receipt, and a backup link to re-download your purchase" 13 | : "You’ll receive an email with your receipt, and tracking information."} 14 |

15 |
16 |
17 |
18 |
19 | ChopChop doesn't exist! 26 |
27 | 28 |
29 |

...if it did, we'd offer you a 100% real store credit, but since it doesn't, we'd love for you to check out commercejs.com and the repo for this store instead.

30 |
31 | Thanks for visiting 37 | 'Chop chop' what are you waiting for 38 |
39 |
40 |
41 |
42 |
43 | ); 44 | } 45 | 46 | export default Success; 47 | -------------------------------------------------------------------------------- /components/Checkout/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./Checkout"; 2 | -------------------------------------------------------------------------------- /components/Footer.js: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import LogoSVG from "../svg/logo.svg"; 4 | import CommerceJsSVG from "../svg/commercejs.svg"; 5 | 6 | function Footer() { 7 | return ( 8 | 70 | ); 71 | } 72 | 73 | export default Footer; 74 | -------------------------------------------------------------------------------- /components/Form/FormCheckbox.js: -------------------------------------------------------------------------------- 1 | import { useFormContext } from "react-hook-form"; 2 | 3 | function FormCheckbox({ 4 | label, 5 | children, 6 | name, 7 | required = false, 8 | validation = {}, 9 | 10 | ...props 11 | }) { 12 | const { register } = useFormContext(); 13 | 14 | const isRequired = required 15 | ? typeof required === "boolean" 16 | ? `${label || name} is required` 17 | : required 18 | : false; 19 | 20 | return ( 21 |
22 | 39 |
40 | ); 41 | } 42 | 43 | export default FormCheckbox; 44 | -------------------------------------------------------------------------------- /components/Form/FormError.js: -------------------------------------------------------------------------------- 1 | import cc from "classcat"; 2 | import { ErrorMessage } from "@hookform/error-message"; 3 | 4 | function FormError({ className, ...props }) { 5 | return ( 6 |
7 | ( 10 | 11 | {message} 12 | 13 | )} 14 | /> 15 |
16 | ); 17 | } 18 | 19 | export default FormError; 20 | -------------------------------------------------------------------------------- /components/Form/FormInput.js: -------------------------------------------------------------------------------- 1 | import { useFormContext } from "react-hook-form"; 2 | 3 | import FormError from "./FormError"; 4 | 5 | function FormInput({ 6 | label, 7 | name, 8 | type = "text", 9 | required = false, 10 | validation = {}, 11 | ...props 12 | }) { 13 | const { register } = useFormContext(); 14 | 15 | const isRequired = required ? `${label || name} is required` : false; 16 | 17 | return ( 18 |
19 | 27 | 28 |
29 | ); 30 | } 31 | 32 | export default FormInput; 33 | -------------------------------------------------------------------------------- /components/Form/FormSelect.js: -------------------------------------------------------------------------------- 1 | import { useFormContext } from "react-hook-form"; 2 | 3 | import Chevron from "../../svg/chevron.svg"; 4 | 5 | import FormError from "./FormError"; 6 | 7 | function FormSelect({ 8 | label, 9 | name, 10 | options, 11 | required = false, 12 | validation = {}, 13 | placeholder, 14 | ...props 15 | }) { 16 | const { register } = useFormContext(); 17 | 18 | const isRequired = required ? `${label || name} is required` : false; 19 | 20 | return ( 21 |
22 |
23 | 41 | 42 |
43 | 44 |
45 |
46 | 47 | 48 |
49 | ); 50 | } 51 | 52 | export default FormSelect; 53 | -------------------------------------------------------------------------------- /components/Form/FormTextarea.js: -------------------------------------------------------------------------------- 1 | import { useFormContext } from "react-hook-form"; 2 | 3 | import FormError from "./FormError"; 4 | 5 | function FormTextarea({ 6 | label, 7 | name, 8 | required = false, 9 | validation = {}, 10 | ...props 11 | }) { 12 | const { register } = useFormContext(); 13 | 14 | const isRequired = required ? `${label || name} is required` : false; 15 | 16 | return ( 17 |
18 |