Repository: chec/commercejs-chopchop-demo
Branch: main
Commit: 9ff079217bdc
Files: 54
Total size: 81.5 KB
Directory structure:
gitextract_jvvou8o_/
├── .babelrc
├── .chec.json
├── .editorconfig
├── .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.json
├── pages/
│ ├── _app.js
│ ├── _document.js
│ ├── index.js
│ └── products/
│ └── [permalink].js
├── postcss.config.js
├── seeds/
│ ├── assets.json
│ ├── categories.json
│ └── products.json
└── tailwind.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .babelrc
================================================
{
"presets": ["next/babel"],
"plugins": ["inline-react-svg"]
}
================================================
FILE: .chec.json
================================================
{
"npm": "npm",
"buildScripts": ["seed", "dev"],
"dotenv": {
"NODE_ENV": "development",
"NEXT_PUBLIC_CHEC_PUBLIC_API_KEY": "%chec_pkey%",
"CHEC_API_URL": "%chec_api_url%",
"CHEC_SECRET_KEY": "%chec_skey%"
}
}
================================================
FILE: .editorconfig
================================================
# For more information about the properties used in this file,
# please see the EditorConfig documentation:
# http://editorconfig.org
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{yml,js,json,css,scss,feature,eslintrc}]
indent_size = 2
================================================
FILE: .gitignore
================================================
# Build output
.next
# Environment variables
.env
# Dependency directories
node_modules
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.vercel
================================================
FILE: LICENSE.md
================================================
Copyright (c) 2021 Chec Platform LLC, All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1) Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
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.
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.
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.
================================================
FILE: README.md
================================================
<p align="center">
<img src="https://raw.githubusercontent.com/chec/commercejs-examples/master/assets/logo.svg" width="380" height="100" />
</p>
<p align="center">
A Next.js, Commerce.js, Stripe, and Vercel powered, open source storefront, cart and checkout experience.
</p>
<p align="center">
<a href="https://github.com/chec/commercejs-chopchop-demo/blob/main/LICENSE.md">
<img src="https://img.shields.io/npm/l/@chec/commerce.js.svg" alt="License" />
</a>
<br>
<a href="https://commercejs.com">commercejs.com</a> | <a href="https://twitter.com/commercejs">@commercejs</a> | <a href="http://slack.commercejs.com">Slack</a>
<br />
<br />
<a href="https://commercejs-chopchop-demo.vercel.app">
<img src="https://cdn.chec.io/email/assets/marketing/chec-demo-btn_gray.svg" alt="View demo" />
</a>
<br />
<br />
<a href="https://commercejs-chopchop-demo.vercel.app">
<img src="https://images.ctfassets.net/u77gi3ejnmxq/60D21gkBJHgH9YI3bizA3Q/c81183ac0cccb0ece6547da5021dc8b9/Group_558.png" alt="View demo" width="600" />
</a>
</p>
## Introduction
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/).
## 🥞 ChopChop Stack
* [Next.js](https://nextjs.org/)
* [Commerce.js](https://commercejs.com)
* [Tailwind CSS](https://tailwindcss.com/)
* [Stripe](https://stripe.com)
* [Vercel](https://vercel.com/)
## Live demo
Check out https://commercejs-chopchop-demo.vercel.app to see this project in action.
## Getting started
### Prerequisites
- IDE or code editor of your choice
- Node (v12 or higher)
- NPM or Yarn
- Optional: [Chec CLI](https://github.com/chec/cli)
### Use the Chec CLI
You can use the [Chec CLI](https://github.com/chec/cli) to quickly and easily install demo stores like this, and also
to install sample data into your account. To install the Chec CLI, run `npm install -g @chec/cli` (or `yarn global add @chec/cli`).
* Navigate to your projects folder: `cd ~/Projects`
* Install the ChopChop demo store: `chec demo-store`
* Choose "Chop Chop demo store (Next.js)" from the list
* This will install dependencies and sample data, then start your dev server
* Stop the server, open `.env` and add your `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` for using Stripe, then re-run `npm run dev`
* Open [http://localhost:3000](http://localhost:3000) and get started!
### Manual installation
Clone the project, then get started by installing the dependencies, creating a `.env` file, and starting the dev server.
```
npm install
cp .env.example .env
npm run dev
```
Once the server is running, open it up in your browser, start editing the code, and enjoy!
### Sample data
This repository comes with some sample products and images for you to use if you want to get up and running quickly.
To install sample data, first copy `.env.example` to `.env`, then edit `.env` and fill out the
following variables:
* `NEXT_PUBLIC_CHEC_PUBLIC_API_KEY`: Your Chec public/sandbox API key, available from the Chec Dashboard under
Developers > API keys
* `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`: Your Stripe test publishable key, available from the Stripe dashboard
* `CHEC_SECRET_KEY`: Your Chec secret API key, used for seeding
* `NEXT_PUBLIC_GA_TRACKING_ID`: Set this with your Google Analytics ID if you want to enable GA.
Once this is done, save and close your file. You can now run the seeder to install sample data:
```
npm run seed
...
✔ Completed seeding
Added:
3 categories
6 products
9 assets
```
And you're ready to go!
### Deploying to Vercel (with one click)
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.
[](https://vercel.com/new/project?template=https://github.com/chec/commercejs-chopchop-demo)
Please make sure that you enter the required environment variables listed above during deployment.
#### Caveats for sample data
To make your ChopChop experience even better, there are a couple of things you can do that are not included with
the sample data:
* **Add related products:** Go into the [Chec Dashboard](https://dashboard.chec.io) and set related products for each
of your new products. This helps to provide upsell suggestions on your website.
* **Set up shipping rates:** Also in the dashboard, set up some shipping zones and rates in Settings > Shipping, then
enable them on each of your products. This will enable the "Shipping" checkout screen, and allow you to charge
shipping for your customers as well.
## Customizations and Extendability
- 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)
- Integrate with the Google Calendar API to automatically add ticketed items to a customer’s calendars
- Suggest products from other sources based on items purchased, i.e. a book on knife skills if you buy the knife set
- Add [Algolia](https://www.algolia.com/) for integrated search
- Add additional modules to the checkout flow to handle other content types, like booking a time to pickup in-store purchases
- Integrate with a headless CMS to make the content editable
- Create a customers login section using our [customers endpoint](https://commercejs.com/docs/api/#customers)
- Use webhooks to deliver SMS notifications about orders
## License
This project is licensed under [BSD-3-Clause](LICENSE.md).
## ⚠️ Note
### This repository is no longer maintained
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/).
================================================
FILE: components/Breadcrumbs.js
================================================
import { useCheckoutState } from "../context/checkout";
// TODO: Build array of crumbs dynamically from available steps
function Breadcrumbs({ inCart }) {
const { currentStep, extrafields } = useCheckoutState();
if (inCart) {
return <span className="text-lg md:text-xl">Shopping Bag</span>;
}
if (currentStep === "success") {
return <span className="text-lg md:text-xl">Order received</span>;
}
return (
<div className="space-x-3">
{currentStep === "extrafields" && (
<>
<span className="text-lg md:text-xl">Shopping Bag</span>
<span className="text-lg md:text-xl">→</span>
<span className="text-lg md:text-xl">Booking</span>
<span className="text-lg md:text-xl opacity-50">→</span>
<span className="text-lg md:text-xl opacity-50">Shipping</span>
<span className="text-lg md:text-xl opacity-50">→</span>
<span className="text-lg md:text-xl opacity-50">Payment</span>
</>
)}
{currentStep === "shipping" && (
<>
<span className="text-lg md:text-xl">Shopping Bag</span>
{extrafields.length > 0 && (
<>
<span className="text-lg md:text-xl">→</span>
<span className="text-lg md:text-xl">Booking</span>
</>
)}
<span className="text-lg md:text-xl">→</span>
<span className="text-lg md:text-xl">Shipping</span>
<span className="text-lg md:text-xl opacity-50">→</span>
<span className="text-lg md:text-xl opacity-50">Payment</span>
</>
)}
{currentStep === "billing" && (
<>
<span className="text-lg md:text-xl">Shopping Bag</span>
{extrafields.length > 0 && (
<>
<span className="text-lg md:text-xl">→</span>
<span className="text-lg md:text-xl">Booking</span>
</>
)}
<span className="text-lg md:text-xl">→</span>
<span className="text-lg md:text-xl">Shipping</span>
<span className="text-lg md:text-xl">→</span>
<span className="text-lg md:text-xl">Payment</span>
</>
)}
</div>
);
}
export default Breadcrumbs;
================================================
FILE: components/Button.js
================================================
import cc from "classcat";
import { useThemeState } from "../context/theme";
const buttonStyle = (theme) => {
switch (theme) {
case "kitchen-sink-journal-chopchop-shop":
return "bg-clementine text-black";
case "walnut-cooks-tools-chopchop-shop":
return "bg-tumbleweed text-black";
case "essential-knife-set-chopchop-shop":
return "bg-hawkes-blue text-black";
case "private-cooking-class-chopchop-shop":
return "bg-asparagus text-black";
case "ceramic-dutch-oven-chopchop-shop":
return "bg-goldenrod text-black";
default:
return "bg-white-rock";
}
};
function Button({ className, ...props }) {
const theme = useThemeState();
const buttonClass = cc([
"appearance-none border-none py-0.5 px-1.5 md:px-2 text-lg md:text-xl rounded transition focus:outline-none",
buttonStyle(theme),
className,
]);
if (props.href) return <a {...props} className={buttonClass} />;
return <button {...props} className={buttonClass} />;
}
export default Button;
================================================
FILE: components/Cart.js
================================================
import { useCartState } from "../context/cart";
import { useModalDispatch } from "../context/modal";
import Button from "./Button";
import CartItem from "./CartItem";
export default function Cart() {
const { line_items, subtotal, total_unique_items } = useCartState();
const { showCheckout } = useModalDispatch();
const isEmpty = line_items.length === 0;
return (
<div className="h-full flex flex-col justify-between">
<div>
{line_items.map((item) => (
<CartItem key={item.id} {...item} />
))}
</div>
<div className="flex items-center justify-between py-3 md:py-4 lg:py-5">
{isEmpty ? (
<p>Your cart is empty.</p>
) : (
<>
<div className="text-lg md:text-xl">
Total: {subtotal?.formatted_with_symbol}, {total_unique_items}{" "}
{total_unique_items === 1 ? "item" : "items"}
</div>
<div>
<Button
className="appearance-none leading-none p-1 md:p-1.5 lg:px-3.5 text-lg md:text-xl"
onClick={showCheckout}
>
Check Out
</Button>
</div>
</>
)}
</div>
</div>
);
}
================================================
FILE: components/CartItem.js
================================================
import React from "react";
import Image from "next/image";
import { toast } from "react-toastify";
import { commerce } from "../lib/commerce";
import { useCartDispatch } from "../context/cart";
function CartItem({ id, media, name, quantity, line_total, selected_options }) {
const { setCart } = useCartDispatch();
const hasVariants = selected_options.length >= 1;
const handleUpdateCart = ({ cart }) => {
setCart(cart);
return cart;
};
const handleRemoveItem = () =>
commerce.cart
.remove(id)
.then(handleUpdateCart)
.then(({ subtotal }) =>
toast(
`${name} has been removed from your cart. Your new subtotal is now ${subtotal.formatted_with_symbol}`
)
);
const decrementQuantity = () => {
quantity > 1
? commerce.cart
.update(id, { quantity: quantity - 1 })
.then(handleUpdateCart)
.then(({ subtotal }) =>
toast(
`1 "${name}" has been removed from your cart. Your new subtotal is now ${subtotal.formatted_with_symbol}`
)
)
: handleRemoveItem();
};
const incrementQuantity = () =>
commerce.cart
.update(id, { quantity: quantity + 1 })
.then(handleUpdateCart)
.then(({ subtotal }) =>
toast(
`Another "${name}" has been added to your cart. Your new subtotal is now ${subtotal.formatted_with_symbol}`
)
);
return (
<div className="py-3 md:py-4 lg:py-5 flex md:items-end space-x-3 md:space-x-4 lg:space-x-5 border-b border-black">
<div className="w-24 h-24 md:w-48 md:h-48 lg:w-56 lg:h-56 rounded relative">
<Image
src={media.source}
alt={name}
layout="fill"
className="object-cover rounded-lg hover:rounded-none transition-all"
loading="eager"
priority={true}
/>
</div>
<div className="flex flex-col md:flex-row md:items-end flex-grow">
<div className="md:flex-grow">
<p className="font-serif text-xl md:text-2xl lg:text-3xl italic leading-none">
{name}
</p>
{hasVariants && (
<p>
{selected_options.map(({ option_name }, index) => (
<React.Fragment key={index}>
{index ? `, ${option_name}` : option_name}
</React.Fragment>
))}
</p>
)}
</div>
<div className="flex flex-col items-start md:items-end justify-between flex-grow">
<div className="text-lg md:text-xl lg:text-2xl">
{line_total.formatted_with_symbol}
</div>
<div className="w-full flex md:flex-col items-center md:items-end justify-between">
<div className="md:pb-4 lg:pb-5 inline-flex items-center">
<span className="pr-2">Quantity:</span>
<button
onClick={decrementQuantity}
className="appearance-none inline-flex items-center justify-center rounded-lg border border-black w-5 h-5 text-xs text-black focus:outline-none hover:bg-black hover:text-white transition"
>
-
</button>
<span className="px-2 md:text-lg">{quantity}</span>
<button
onClick={incrementQuantity}
className="appearance-none inline-flex items-center justify-center rounded-lg border border-black w-5 h-5 text-xs text-black focus:outline-none hover:bg-black hover:text-white transition"
>
+
</button>
</div>
<div>
<button
onClick={handleRemoveItem}
className="appearance-none inline-flex items-center justify-center rounded-lg border border-black text-xs text-black px-1 h-5 opacity-50 hover:opacity-100 focus:opacity-100 focus:outline-none transition"
>
Remove
</button>
</div>
</div>
</div>
</div>
</div>
);
}
export default CartItem;
================================================
FILE: components/CartSummary.js
================================================
import { useCartState } from "../context/cart";
import { useModalDispatch } from "../context/modal";
function CartSummary() {
const { total_unique_items } = useCartState();
const { openModal } = useModalDispatch();
return (
<button className="appearance-none focus:outline-none" onClick={openModal}>
Shopping Bag ({total_unique_items})
</button>
);
}
export default CartSummary;
================================================
FILE: components/Checkout/AddressFields.js
================================================
import React from "react";
import { FormInput, FormSelect } from "../Form";
function AddressFields({ prefix = "", countries = {}, subdivisions = {} }) {
const reducer = ([code, name]) => ({
value: code,
label: name,
});
const formattedCountries = subdivisions
? Object.entries(countries).map(reducer)
: [];
const formattedSubdivisions = subdivisions
? Object.entries(subdivisions).map(reducer)
: [];
return (
<React.Fragment>
<div className="md:flex md:items-start md:space-x-4">
<div className="md:w-1/2">
<FormInput
label="First name"
name={`${prefix}.firstname`}
placeholder="First name"
required
/>
</div>
<div className="md:w-1/2">
<FormInput
label="Last name"
name={`${prefix}.lastname`}
placeholder="Last name"
required
/>
</div>
</div>
<FormInput
label="Address"
name={`${prefix}.street`}
placeholder="Address"
required
/>
<FormInput
label="Town / City"
name={`${prefix}.town_city`}
placeholder="City"
required
/>
<div className="md:flex md:items-start md:space-x-4">
<div className="md:w-1/3">
<FormSelect
label="Country"
name={`${prefix}.country`}
options={formattedCountries}
placeholder="Select country"
required
disabled={formattedCountries.length === 0}
/>
</div>
<div className="md:w-1/3">
<FormSelect
label="County / State"
name={`${prefix}.region`}
options={formattedSubdivisions}
placeholder="Select region"
required
disabled={formattedSubdivisions.length === 0}
/>
</div>
<div className="md:w-1/3">
<FormInput
label="ZIP / Postcode"
name={`${prefix}.postal_zip_code`}
placeholder="ZIP"
required
/>
</div>
</div>
</React.Fragment>
);
}
export default AddressFields;
================================================
FILE: components/Checkout/BillingForm.js
================================================
import { useState, useEffect } from "react";
import { useFormContext } from "react-hook-form";
import { useDebounce } from "use-debounce";
import {
CardNumberElement,
CardExpiryElement,
CardCvcElement,
} from "@stripe/react-stripe-js";
import { commerce } from "../../lib/commerce";
import { useCheckoutState, useCheckoutDispatch } from "../../context/checkout";
import { FormCheckbox, FormInput, FormError } from "../Form";
import AddressFields from "./AddressFields";
const style = {
base: {
"::placeholder": {
color: "rgba(21,7,3,0.3)",
},
color: "#150703",
fontSize: "16px",
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"`,
iconColor: "#6B7280",
},
};
function BillingForm() {
const [countries, setCountries] = useState();
const [subdivisions, setSubdivisions] = useState();
const methods = useFormContext();
const { collects } = useCheckoutState();
const { setError } = useCheckoutDispatch();
const { watch, setValue, clearErrors } = methods;
const shipping = watch("shipping");
const [watchCountry] = useDebounce(watch("billing.country"), 600);
useEffect(() => {
fetchCountries();
}, []);
useEffect(() => {
watchCountry && fetchSubdivisions(watchCountry);
}, [watchCountry]);
const fetchCountries = async () => {
try {
const { countries } = await commerce.services.localeListCountries();
setCountries(countries);
} catch (err) {
// noop
}
};
const fetchSubdivisions = async (country) => {
try {
const { subdivisions } = await commerce.services.localeListSubdivisions(
country
);
setSubdivisions(subdivisions);
} catch (err) {
// noop
}
};
const onStripeChange = () => {
clearErrors("stripe");
setError(null);
};
return (
<div className="md:flex md:space-x-12 lg:space-x-24">
<div className="md:w-1/2">
<fieldset className="mb-3 md:mb-4">
<legend className="text-black font-medium text-lg md:text-xl py-3 block">
Billing address
</legend>
{collects?.shipping_address && (
<FormCheckbox
label="Same as shipping address"
name="billingIsShipping"
onChange={({ target: { checked } }) =>
checked && setValue("billing", shipping)
}
/>
)}
<AddressFields
prefix="billing"
countries={countries}
subdivisions={subdivisions}
/>
</fieldset>
</div>
<div className="md:w-1/2">
<fieldset>
<legend className="text-black font-medium text-lg md:text-xl py-3">
Payment
</legend>
<FormInput
type="email"
label="Email"
name="customer.email"
placeholder="Receipt email"
required
validation={{
pattern: {
value: /^\S+@\S+$/i,
message: "You must enter a valid email",
},
}}
/>
<div className="space-y-3">
<div>
<CardNumberElement
options={{ style }}
className="appearance-none bg-transparent placeholder-faded-black border border-faded-black focus:border-black focus:outline-none rounded-md w-full p-1.5"
onChange={onStripeChange}
/>
</div>
<div className="flex space-x-4">
<div className="w-1/2">
<CardExpiryElement
options={{ style }}
placeholder="Expiry"
className="appearance-none bg-transparent placeholder-faded-black border border-faded-black focus:border-black focus:outline-none rounded-md w-full p-1.5"
onChange={onStripeChange}
/>
</div>
<div className="w-1/2">
<CardCvcElement
options={{ style }}
className="appearance-none bg-transparent placeholder-faded-black border border-faded-black focus:border-black focus:outline-none rounded-md w-full p-1.5"
onChange={onStripeChange}
/>
</div>
</div>
</div>
<FormError name="stripe" />
</fieldset>
</div>
</div>
);
}
export default BillingForm;
================================================
FILE: components/Checkout/Checkout.js
================================================
import { useState, useEffect } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { useStripe, useElements } from "@stripe/react-stripe-js";
import { useCartDispatch } from "../../context/cart";
import { useCheckoutState, useCheckoutDispatch } from "../../context/checkout";
import ExtraFieldsForm from "./ExtraFieldsForm";
import ShippingForm from "./ShippingForm";
import BillingForm from "./BillingForm";
import Success from "./Success";
import CheckoutSummary from "./CheckoutSummary";
import OrderSummary from "./OrderSummary";
import LoadingSVG from "../../svg/loading.svg";
function Checkout({ cartId }) {
const [order, setOrder] = useState();
const { reset: resetCart } = useCartDispatch();
const { currentStep, id, live } = useCheckoutState();
const {
generateToken,
setCurrentStep,
nextStepFrom,
capture,
setProcessing,
setError: setCheckoutError,
} = useCheckoutDispatch();
const methods = useForm({
shouldUnregister: false,
});
const { handleSubmit, setError } = methods;
const stripe = useStripe();
const elements = useElements();
useEffect(() => {
generateToken(cartId);
}, [cartId]);
const captureOrder = async (values) => {
setProcessing(true);
const {
customer,
shipping,
billing: { firstname, lastname, region: county_state, ...billing },
...data
} = values;
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: "card",
card: elements.getElement("cardNumber"),
billing_details: {
name: `${billing.firstname} ${billing.lastname}`,
email: customer.email,
},
});
if (error) {
setError("stripe", { type: "manual", message: error.message });
setProcessing(false);
return;
}
const checkoutPayload = {
...data,
customer: {
...customer,
firstname,
lastname,
},
...(shipping && {
shipping: {
...shipping,
name: `${shipping.firstname} ${shipping.lastname}`,
},
}),
billing: {
...billing,
name: `${firstname} ${lastname}`,
county_state,
},
};
try {
const newOrder = await capture({
...checkoutPayload,
payment: {
gateway: "stripe",
stripe: {
payment_method_id: paymentMethod.id,
},
},
});
handleOrderSuccess(newOrder);
setProcessing(false);
} catch (res) {
if (
res.statusCode !== 402 ||
res.data.error.type !== "requires_verification"
) {
setCheckoutError(res.data.error.message);
setProcessing(false);
return;
}
const { error, paymentIntent } = await stripe.handleCardAction(
res.data.error.param
);
if (error) {
setError("stripe", { type: "manual", message: error.message });
setProcessing(false);
return;
}
try {
const newOrder = await capture({
...checkoutPayload,
payment: {
gateway: "stripe",
stripe: {
payment_intent_id: paymentIntent.id,
},
},
});
handleOrderSuccess(newOrder);
setProcessing(false);
} catch (err) {
setError("stripe", { type: "manual", message: error.message });
setProcessing(false);
}
}
};
const handleOrderSuccess = (order) => {
setOrder(order);
setCurrentStep("success");
resetCart();
};
const onSubmit = (values) => {
if (currentStep === "billing") return captureOrder(values);
return setCurrentStep(nextStepFrom(currentStep));
};
if (!id)
return (
<div className="h-full flex flex-col items-center justify-center space-y-6">
<LoadingSVG className="w-10 md:w-16 fill-current" />
<p className="text-black">Preparing checkout</p>
</div>
);
return (
<FormProvider {...methods}>
<form
onSubmit={handleSubmit(onSubmit)}
className="h-full flex flex-col justify-between pt-6 md:pt-12"
>
{currentStep === "extrafields" && <ExtraFieldsForm />}
{currentStep === "shipping" && <ShippingForm />}
{currentStep === "billing" && <BillingForm />}
{currentStep === "success" && <Success {...order} />}
{order ? <OrderSummary {...order} /> : <CheckoutSummary {...live} />}
</form>
</FormProvider>
);
}
export default Checkout;
================================================
FILE: components/Checkout/CheckoutSummary.js
================================================
import cc from "classcat";
import { useCheckoutState } from "../../context/checkout";
import Button from "../Button";
function CheckoutSummary({ subtotal, tax, shipping, line_items = [], total }) {
const { processing, error } = useCheckoutState();
const count = line_items.length;
return (
<div className="py-6">
<div className="md:flex md:justify-between md:space-x-6">
<div className="w-full md:w-1/2">
<ol>
{subtotal && <li>Subtotal: {subtotal.formatted_with_symbol}</li>}
{tax && <li>Tax: {tax.amount.formatted_with_symbol}</li>}
{shipping && (
<li>Shipping: {shipping.price.formatted_with_symbol}</li>
)}
{total && (
<li className="text-lg md:text-xl py-3">
Total: {total.formatted_with_symbol}, {count}{" "}
{count === 1 ? "item" : "items"}
</li>
)}
</ol>
</div>
<div className="w-full md:w-1/2 md:flex md:items-end md:justify-end">
<div className="flex items-center space-x-3">
{error && <span className="text-red-500 text-sm">{error}</span>}
<Button
type="submit"
className={cc([
"appearance-none leading-none p-1 md:p-2 lg:p-3 text-lg md:text-xl",
{
"opacity-75 cursor-not-allowed": processing,
},
])}
disabled={processing}
>
{processing ? "Processing order" : "Continue"}
</Button>
</div>
</div>
</div>
</div>
);
}
export default CheckoutSummary;
================================================
FILE: components/Checkout/ExtraFieldsForm.js
================================================
import { useEffect } from "react";
import { useCheckoutState, useCheckoutDispatch } from "../../context/checkout";
import { FormInput, FormCheckbox, FormTextarea } from "../Form";
// TODO: Update the UI to be built from the API
// once products have extrafields that can be of
// any type. E.g. "date", "textarea"
// const fields = {
// BookingDate: (props) => (
// <>
// <FormInput {...props} />
// </>
// ),
// };
function ExtraFieldsForm() {
const { extrafields } = useCheckoutState();
const { setCurrentStep, nextStepFrom } = useCheckoutDispatch();
useEffect(() => {
if (extrafields.length === 0) {
setCurrentStep(nextStepFrom("extrafields"));
}
return null;
}, [extrafields]);
return (
<div className="md:flex md:space-x-12 lg:space-x-24">
<div className="md:w-1/2">
<fieldset className="mb-3 md:mb-4">
<legend className="text-black font-medium text-lg md:text-xl py-3 block">
Booking
</legend>
<FormInput name="bookingDate" type="date" />
<FormCheckbox
name="takeClassInBrooklyn"
label="Take the class at our space in Brooklyn"
/>
<FormCheckbox name="takeClassOnline" label="Take the class online" />
</fieldset>
</div>
<div className="md:w-1/2">
<fieldset className="mb-3 md:mb-4">
<legend className="text-black font-medium text-lg md:text-xl py-3 block">
Lesson Plan
</legend>
<p className="text-black text-sm italic font-serif font-medium mb-3">
Thanks for joining us for a lesson! Let us know what you might like
to learn or cook below.
</p>
{extrafields.map(({ id }) => {
const computedFieldName = `extrafields.${id}`;
return (
<FormTextarea
key={id}
id={computedFieldName}
name={computedFieldName}
placeholder="I'm interested in learning..."
rows={7}
/>
);
})}
</fieldset>
</div>
</div>
);
}
export default ExtraFieldsForm;
================================================
FILE: components/Checkout/OrderSummary.js
================================================
import Button from "../Button";
function CheckoutSummary({ has, fulfillment, order }) {
const { subtotal, tax, shipping, line_items, total } = order;
const count = line_items.length;
return (
<div className="py-6">
<div className="md:flex md:justify-between md:space-x-6">
<div className="w-full md:w-1/2">
<ol>
<li>Subtotal: {subtotal.formatted_with_symbol}</li>
{tax && <li>Tax: {tax.amount.formatted_with_symbol}</li>}
{shipping && (
<li>Shipping: {shipping.price.formatted_with_symbol}</li>
)}
{total && (
<li className="text-lg md:text-xl py-3">
Total: {total.formatted_with_symbol}, {count}{" "}
{count === 1 ? "item" : "items"}
</li>
)}
</ol>
</div>
{has.digital_fulfillment && (
<div className="w-full md:w-1/2 md:flex md:items-end md:justify-end space-y-3 md:space-y-0 md:space-x-3">
{fulfillment.digital.downloads.map((download, index) => (
<div
className="md:flex space-y-3 md:space-y-0 md:space-x-3"
key={index}
>
{download.packages.map(({ access_link, name }, index) => (
<Button
key={index}
href={access_link}
target="_blank"
rel="noopener noreferrer"
>
Download {name}
</Button>
))}
</div>
))}
</div>
)}
</div>
</div>
);
}
export default CheckoutSummary;
================================================
FILE: components/Checkout/ShippingForm.js
================================================
import { useState, useEffect } from "react";
import { useFormContext } from "react-hook-form";
import { useDebounce } from "use-debounce";
import { commerce } from "../../lib/commerce";
import { useCheckoutState, useCheckoutDispatch } from "../../context/checkout";
import AddressFields from "./AddressFields";
import { FormCheckbox as FormRadio, FormError } from "../Form";
function ShippingForm() {
const { id } = useCheckoutState();
const { setShippingMethod } = useCheckoutDispatch();
const [countries, setCountries] = useState();
const [subdivisions, setSubdivisions] = useState();
const [shippingOptions, setShippingOptions] = useState([]);
const methods = useFormContext();
const { watch, setValue } = methods;
const [watchCountry] = useDebounce(watch("shipping.country"), 600);
const watchSubdivision = watch("shipping.region");
useEffect(() => {
fetchCountries(id);
}, []);
useEffect(() => {
setValue("shipping.region", "");
if (watchCountry) {
fetchSubdivisions(id, watchCountry);
fetchShippingOptions(id, watchCountry);
}
}, [watchCountry]);
useEffect(() => {
if (watchSubdivision) {
fetchShippingOptions(id, watchCountry, watchSubdivision);
}
}, [watchSubdivision]);
const fetchCountries = async (checkoutId) => {
try {
const { countries } = await commerce.services.localeListShippingCountries(
checkoutId
);
setCountries(countries);
} catch (err) {
// noop
}
};
const fetchSubdivisions = async (checkoutId, countryCode) => {
try {
const {
subdivisions,
} = await commerce.services.localeListShippingSubdivisions(
checkoutId,
countryCode
);
setSubdivisions(subdivisions);
} catch (err) {
// noop
}
};
const fetchShippingOptions = async (checkoutId, country, region) => {
if (!checkoutId && !country) return;
setValue("fulfillment.shipping_method", null);
try {
const shippingOptions = await commerce.checkout.getShippingOptions(
checkoutId,
{
country,
...(region && { region }),
}
);
setShippingOptions(shippingOptions);
if (shippingOptions.length === 1) {
const [shippingOption] = shippingOptions;
setValue("fulfillment.shipping_method", shippingOption.id);
selectShippingMethod(shippingOption.id);
}
} catch (err) {
// noop
}
};
const onShippingSelect = ({ target: { value } }) =>
selectShippingMethod(value);
const selectShippingMethod = async (optionId) => {
try {
await setShippingMethod(optionId, watchCountry, watchSubdivision);
} catch (err) {
// noop
}
};
return (
<div className="md:flex md:space-x-12 lg:space-x-24">
<div className="md:w-1/2">
<fieldset className="mb-3 md:mb-4">
<legend className="text-black font-medium text-lg md:text-xl py-3 block">
Shipping address
</legend>
<AddressFields
prefix="shipping"
countries={countries}
subdivisions={subdivisions}
/>
</fieldset>
</div>
<div className="md:w-1/2">
<fieldset className="mb-3 md:mb-4">
<legend className="text-black font-medium text-lg md:text-xl py-3 block">
Shipping
</legend>
<div>
{watchCountry ? (
<>
<div className="-space-y-1">
{shippingOptions.map(({ id, description, price }) => (
<div key={id}>
<FormRadio
id={id}
type="radio"
name="fulfillment.shipping_method"
value={id}
label={`${description}: ${price.formatted_with_symbol}`}
onChange={onShippingSelect}
required="You must select a shipping option"
/>
</div>
))}
</div>
<FormError name="fulfillment.shipping_method" />
</>
) : (
<p className="text-sm text-black">
Please enter your address to fetch shipping options
</p>
)}
</div>
</fieldset>
</div>
</div>
);
}
export default ShippingForm;
================================================
FILE: components/Checkout/Success.js
================================================
import Image from 'next/image';
function Success({ has }) {
return (
<div className="h-full lg:flex lg:items-center lg:space-x-12 lg:space-x-24">
<div className="lg:w-1/2 ">
<h1 className="font-serif font-medium italic text-2xl md:text-4xl lg:text-5xl xl:text-6xl">
Thanks!
</h1>
<p className="mt-3 text-lg md:text-xl font-sans">
{has.digital_fulfillment
? "You’ll receive an email with your receipt, and a backup link to re-download your purchase"
: "You’ll receive an email with your receipt, and tracking information."}
</p>
</div>
<div className="lg:w-1/2 lg:flex lg:items-center lg:justify-center">
<div className="bg-white shadow-thank-you transform -rotate-25 skew-y-12 mx-auto my-24 lg:mt-48 max-w-lg">
<div className="ml-4">
<Image
src="/checkout/doesntexist.svg"
width={384}
height={126}
alt="ChopChop doesn't exist!"
layout="responsive"
/>
</div>
<div className="p-4 pt-0 -mt-4 leading-tight font-sans">
<p>...if it did, we'd offer you a <span className="font-serif italic">100% real store credit</span>, but since it doesn't, we'd love for you to check out <a href="https://commercejs.com" target="_blank" rel="noopener nofollow" className="font-serif italic underline">commercejs.com</a> and <a href="https://github.com/chec/commercejs-chopchop-demo" target="_blank" rel="noopener nofollow" className="font-serif italic underline">the repo</a> for this store instead.</p>
<div className="mt-6 mb-1 font-serif flex justify-between items-end">
<Image
src="/product-attributes/thanks.svg"
width={110}
height={48}
alt="Thanks for visiting"
/>
<span className="ml-4 italic text-sm">'Chop chop' what are you waiting for</span>
</div>
</div>
</div>
</div>
</div>
);
}
export default Success;
================================================
FILE: components/Checkout/index.js
================================================
export { default } from "./Checkout";
================================================
FILE: components/Footer.js
================================================
import Link from "next/link";
import LogoSVG from "../svg/logo.svg";
import CommerceJsSVG from "../svg/commercejs.svg";
function Footer() {
return (
<footer className="py-6 lg:py-12">
<div className="container mx-auto px-3 md:px-4 lg:px-5 md:flex md:items-center space-y-6 md:space-y-0">
<div className="w-full md:w-1/3">
<Link href="/">
<a title="Return to ChopChop">
<LogoSVG className="w-full md:w-auto md:h-8" />
</a>
</Link>
</div>
<div className="w-full md:w-1/3 flex items-center md:justify-center">
<a
href="https://commercejs.com"
title="Visit Commerce.js website"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center space-x-1 text-faded-black hover:text-black transition-colors"
>
<span>Powered by</span>
<CommerceJsSVG className="h-4" />
</a>
</div>
<div className="w-full md:w-1/3">
<div className="md:text-right space-x-1">
<Link href="/">
<a className="text-black">Shop</a>
</Link>
,
<a
href="https://github.com/chec/commercejs-chopchop-demo"
target="_blank"
rel="noopener noreferrer"
className="text-black"
>
About
</a>
,
<a
href="https://twitter.com/commercejs"
target="_blank"
rel="noopener noreferrer"
className="text-black"
>
Contact
</a>
,
<Link href="/">
<a className="text-black">Legal</a>
</Link>
,
<Link href="/">
<a className="text-black">Privacy</a>
</Link>
,<span>© 2021</span>
<p className="hidden md:block italic font-serif">
Fine tools for thoughtful cooks
</p>
</div>
</div>
</div>
</footer>
);
}
export default Footer;
================================================
FILE: components/Form/FormCheckbox.js
================================================
import { useFormContext } from "react-hook-form";
function FormCheckbox({
label,
children,
name,
required = false,
validation = {},
...props
}) {
const { register } = useFormContext();
const isRequired = required
? typeof required === "boolean"
? `${label || name} is required`
: required
: false;
return (
<div className="py-1 md:py-2">
<label
htmlFor={props.id || name}
className="flex items-center cursor-pointer w-full"
>
<input
ref={register({ required: isRequired, ...validation })}
id={props.id || name}
name={name}
type="checkbox"
className="appearance-none bg-transparent checked:bg-black border border-faded-black checked:border-black hover:border-black focus:border-black focus:checked:outline-none focus:outline-none text-black rounded w-5 h-5 cursor-pointer"
{...props}
/>
{(children || label) && (
<span className="ml-2">{children || label}</span>
)}
</label>
</div>
);
}
export default FormCheckbox;
================================================
FILE: components/Form/FormError.js
================================================
import cc from "classcat";
import { ErrorMessage } from "@hookform/error-message";
function FormError({ className, ...props }) {
return (
<div className="pt-1">
<ErrorMessage
{...props}
render={({ message }) => (
<span className={cc(["text-red-500 text-sm", className])} {...props}>
{message}
</span>
)}
/>
</div>
);
}
export default FormError;
================================================
FILE: components/Form/FormInput.js
================================================
import { useFormContext } from "react-hook-form";
import FormError from "./FormError";
function FormInput({
label,
name,
type = "text",
required = false,
validation = {},
...props
}) {
const { register } = useFormContext();
const isRequired = required ? `${label || name} is required` : false;
return (
<div className="py-2">
<input
ref={register({ required: isRequired, ...validation })}
id={name}
name={name}
type={type}
className="appearance-none bg-transparent placeholder-faded-black border border-faded-black focus:border-black focus:outline-none rounded-md w-full text-base px-1.5 py-1"
{...props}
/>
<FormError name={name} />
</div>
);
}
export default FormInput;
================================================
FILE: components/Form/FormSelect.js
================================================
import { useFormContext } from "react-hook-form";
import Chevron from "../../svg/chevron.svg";
import FormError from "./FormError";
function FormSelect({
label,
name,
options,
required = false,
validation = {},
placeholder,
...props
}) {
const { register } = useFormContext();
const isRequired = required ? `${label || name} is required` : false;
return (
<div className="py-2">
<div className="relative overflow-hidden border border-faded-black focus:border-black focus:outline-none rounded-md w-full">
<select
ref={register({ required: isRequired, ...validation })}
id={name}
name={name}
className="appearance-none bg-transparent w-full py-1 pr-6 pl-1.5 text-base placeholder-faded-black focus:outline-none"
defaultValue=""
{...props}
>
<option disabled value="">
{placeholder || `Select a ${label}`}
</option>
{options.map(({ value, label }) => (
<option key={value} value={value}>
{label || value}
</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-black">
<Chevron />
</div>
</div>
<FormError name={name} />
</div>
);
}
export default FormSelect;
================================================
FILE: components/Form/FormTextarea.js
================================================
import { useFormContext } from "react-hook-form";
import FormError from "./FormError";
function FormTextarea({
label,
name,
required = false,
validation = {},
...props
}) {
const { register } = useFormContext();
const isRequired = required ? `${label || name} is required` : false;
return (
<div className="py-2">
<textarea
ref={register({ required: isRequired, ...validation })}
id={name}
name={name}
className="appearance-none bg-transparent placeholder-faded-black border border-faded-black focus:border-black focus:outline-none rounded-md w-full text-base px-1.5 py-1"
{...props}
/>
<FormError name={name} />
</div>
);
}
export default FormTextarea;
================================================
FILE: components/Form/index.js
================================================
export { default as FormInput } from "./FormInput";
export { default as FormTextarea } from "./FormTextarea";
export { default as FormCheckbox } from "./FormCheckbox";
export { default as FormSelect } from "./FormSelect";
export { default as FormError } from "./FormError";
================================================
FILE: components/Header.js
================================================
import Link from "next/link";
import CartSummary from "./CartSummary";
import LogoSVG from "../svg/logo.svg";
function Header() {
return (
<header className="md:absolute md:left-0 md:top-0 w-full z-10">
<div className="py-3 lg:py-5 flex items-center">
<Link href="/">
<a title="Return to ChopChop">Shop</a>
</Link>
<span className="pr-1">,</span>
<CartSummary />
</div>
<Link href="/">
<a title="Return to ChopChop">
<LogoSVG className="w-full" />
</a>
</Link>
</header>
);
}
export default Header;
================================================
FILE: components/Layout.js
================================================
import Footer from "./Footer";
function Layout({ children }) {
return (
<>
<div className="shadow-md">
<div className="md:relative container mx-auto px-3">{children}</div>
</div>
<Footer />
</>
);
}
export default Layout;
================================================
FILE: components/Modal.js
================================================
import { useEffect } from "react";
import { useRouter } from "next/router";
import { AnimatePresence, motion } from "framer-motion";
import { useModalState, useModalDispatch } from "../context/modal";
import { useCheckoutDispatch } from "../context/checkout";
import { useCartState } from "../context/cart";
import Breadcrumbs from "./Breadcrumbs";
import Cart from "./Cart";
import Checkout from "./Checkout";
function CurrentStep({ step }) {
const { id } = useCartState();
switch (step) {
case "cart":
return <Cart />;
case "checkout":
return <Checkout cartId={id} />;
default:
return null;
}
}
function Modal() {
const { open, step } = useModalState();
const { closeModal } = useModalDispatch();
const { reset: resetCheckout } = useCheckoutDispatch();
const router = useRouter();
useEffect(() => {
router.events.on("routeChangeStart", closeModal);
return () => {
router.events.off("routeChangeStart", closeModal);
};
}, []);
const closeAndResetModal = () => {
closeModal();
resetCheckout();
};
return (
<AnimatePresence>
{open && (
<motion.div
className="bg-ecru-white z-50 fixed overflow-scroll inset-0"
initial={{ opacity: 0, y: -50 }}
animate={{
opacity: 1,
y: 0,
}}
exit={{ opacity: 0, y: -50 }}
>
<div className="h-full container mx-auto px-3 md:px-4 lg:px-5 flex flex-col justify-between">
<div>
<div className="py-3 md:py-4 lg:py-5 flex items-center justify-between">
<Breadcrumbs inCart={step === "cart"} />
<button
className="appearance-none leading-none text-black p-1 -mr-1 focus:outline-none"
onClick={closeAndResetModal}
>
Close
</button>
</div>
</div>
<CurrentStep step={step} />
</div>
</motion.div>
)}
</AnimatePresence>
);
}
export default Modal;
================================================
FILE: components/Product.js
================================================
import Image from "next/image";
import Link from "next/link";
import cc from "classcat";
function Product({ media, name, permalink, price, className }) {
const imageClass = cc([
"relative rounded-lg hover:rounded-none overflow-hidden w-full transition-all",
className,
]);
return (
<Link href={`/products/${permalink}`}>
<a className="group relative">
{media?.source && (
<div className={imageClass}>
<Image
src={media.source}
alt={Product.name}
layout="fill"
sizes="616px, (min-width: 768px): 352px, (min-width: 1024px): 232px, (min-width: 1280px): 288px"
className="object-cover"
priority={true}
/>
</div>
)}
<div className="flex justify-between py-2 md:py-3 space-x-1">
<span className="text-sm md:text-base lg:text-lg">{name}</span>
<span className="text-sm md:text-base lg:text-lg">
{price.formatted_with_symbol}
</span>
</div>
</a>
</Link>
);
}
export default Product;
================================================
FILE: components/ProductAttributes.js
================================================
function ProductAttributes({ attributes = [] }) {
if (!attributes || attributes.length === 0) return null;
return (
<div className="py-4 hidden md:grid grid-cols-1 md:grid-cols-2 gap-4">
{attributes.map((fileName) => (
<div
key={fileName}
className="w-full h-24 flex items-center justify-center"
>
<img
src={`/product-attributes/${fileName}`}
className="inline-block"
/>
</div>
))}
</div>
);
}
export default ProductAttributes;
================================================
FILE: components/ProductGrid.js
================================================
import Product from "./Product";
function ProductGrid({ products, ...props }) {
if (!products || products.length === 0) return null;
return (
<div className="w-full grid lg:grid-cols-2 gap-4 xl:gap-8">
{products.map((product) => (
<Product key={product.id} {...product} {...props} />
))}
</div>
);
}
export default ProductGrid;
================================================
FILE: components/ProductImages.js
================================================
import Image from "next/image";
function ProductImages({ images = [] }) {
if (!images || images.length === 0) return null;
return images.map(({ id, url, image_dimensions }) => (
<div key={id} className="md:py-3">
<Image
key={id}
src={url}
width={image_dimensions.width}
height={image_dimensions.height}
className="rounded-lg hover:rounded-none transition-all"
quality={100}
alt=""
/>
</div>
));
}
export default ProductImages;
================================================
FILE: components/ProductList.js
================================================
import Link from "next/link";
function ProductList({ products }) {
if (!products || products.length === 0) return null;
return products.map(({ name, permalink }, index) => (
<span key={permalink}>
{index ? ", " : ""}
<Link href={`/products/${permalink}`}>
<a className="text-lg md:text-xl lg:text-2xl hover:italic">{name}</a>
</Link>
</span>
));
}
export default ProductList;
================================================
FILE: components/RelatedProducts.js
================================================
import Product from "./Product";
function RelatedProducts({ products }) {
if (!products || products.length === 0) return null;
return (
<div>
<h3 className="w-1/3 md:w-full leading-tight md:leading-normal font-serif text-xl md:text-3xl">
Some other things you might like
</h3>
<div className="w-full grid grid-cols-2 xl:grid-cols-4 gap-4 md:gap-8 pt-4 md:pt-8">
{products.map((product) => (
<Product
key={product.id}
{...product}
className="h-72 md:h-96 lg:h-112"
/>
))}
</div>
</div>
);
}
export default RelatedProducts;
================================================
FILE: components/VariantPicker.js
================================================
import React from "react";
import Chevron from "../svg/chevron.svg";
function VariantPicker({ variantGroups = [], defaultValues = {}, ...props }) {
if (!variantGroups || variantGroups.length === 0) return null;
return (
<div className="space-x-2 md:flex">
{variantGroups.map(({ options, ...group }) => (
<div
key={group.id}
className="rounded border border-black relative w-32 overflow-hidden"
>
<label htmlFor={group.id} className="sr-only">
{group.name}:
</label>
<select
id={group.id}
defaultValue={defaultValues[group.id]}
className="appearance-none leading-none block w-full py-1 pr-6 pl-2"
{...props}
>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-black">
<Chevron />
</div>
</div>
))}
</div>
);
}
export default VariantPicker;
================================================
FILE: context/cart.js
================================================
import { createContext, useReducer, useEffect, useContext } from "react";
import { useCycle } from "framer-motion";
import { commerce } from "../lib/commerce";
const CartStateContext = createContext();
const CartDispatchContext = createContext();
const SET_CART = "SET_CART";
const RESET = "RESET";
const initialState = {
total_items: 0,
total_unique_items: 0,
line_items: [],
};
const reducer = (state, action) => {
switch (action.type) {
case SET_CART:
return { ...state, ...action.payload };
case RESET:
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
};
export const CartProvider = ({ children }) => {
const [open, toggle] = useCycle(false, true);
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
getCart();
}, []);
const getCart = async () => {
try {
const cart = await commerce.cart.retrieve();
dispatch({ type: SET_CART, payload: cart });
} catch (err) {
// noop
}
};
const setCart = async (payload) => dispatch({ type: SET_CART, payload });
const showCart = () => {
toggle();
document.body.classList.add("overflow-hidden");
};
const closeCart = () => {
toggle();
document.body.classList.remove("overflow-hidden");
};
const reset = async () => dispatch({ type: RESET });
return (
<CartDispatchContext.Provider
value={{ setCart, showCart, closeCart, reset }}
>
<CartStateContext.Provider value={{ open, ...state }}>
{children}
</CartStateContext.Provider>
</CartDispatchContext.Provider>
);
};
export const useCartState = () => useContext(CartStateContext);
export const useCartDispatch = () => useContext(CartDispatchContext);
================================================
FILE: context/checkout.js
================================================
import { createContext, useReducer, useContext } from "react";
import { commerce } from "../lib/commerce";
const CheckoutStateContext = createContext();
const CheckoutDispatchContext = createContext();
const SET_CURRENT_STEP = "SET_CURRENT_STEP";
const SET_CHECKOUT = "SET_CHECKOUT";
const SET_LIVE = "SET_LIVE";
const SET_PROCESSING = "SET_PROCESSING";
const SET_ERROR = "SET_ERROR";
const RESET = "RESET";
const initialState = {
currentStep: "extrafields",
processing: false,
error: null,
};
const reducer = (state, action) => {
switch (action.type) {
case SET_CURRENT_STEP:
return {
...state,
currentStep: action.payload,
};
case SET_CHECKOUT:
return {
...state,
...action.payload,
};
case SET_LIVE:
return { ...state, live: { ...state.live, ...action.payload } };
case SET_PROCESSING:
return { ...state, processing: action.payload };
case SET_ERROR:
return { ...state, error: action.payload };
case RESET:
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
};
export const CheckoutProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const generateToken = async (cartId) => {
if (!cartId) return;
try {
const payload = await commerce.checkout.generateToken(cartId, {
type: "cart",
});
dispatch({ type: SET_CHECKOUT, payload });
} catch (err) {
// noop
}
};
const setShippingMethod = async (shipping_option_id, country, region) => {
try {
const { live } = await commerce.checkout.checkShippingOption(state.id, {
shipping_option_id,
country,
...(region && { region }),
});
dispatch({ type: SET_LIVE, payload: live });
} catch (err) {
// noop
}
};
const setCurrentStep = (step) =>
dispatch({ type: SET_CURRENT_STEP, payload: step });
const nextStepFrom = (currentStep) => {
switch (currentStep) {
case "extrafields":
return state.collects.shipping_address ? "shipping" : "billing";
case "shipping":
default:
return "billing";
}
};
const capture = (values) => commerce.checkout.capture(state.id, values);
const setProcessing = (payload) =>
dispatch({ type: SET_PROCESSING, payload });
const setError = (payload) => dispatch({ type: SET_ERROR, payload });
const reset = () => dispatch({ type: RESET });
return (
<CheckoutDispatchContext.Provider
value={{
generateToken,
setShippingMethod,
setCurrentStep,
nextStepFrom,
capture,
setProcessing,
setError,
reset,
}}
>
<CheckoutStateContext.Provider value={state}>
{children}
</CheckoutStateContext.Provider>
</CheckoutDispatchContext.Provider>
);
};
export const useCheckoutState = () => useContext(CheckoutStateContext);
export const useCheckoutDispatch = () => useContext(CheckoutDispatchContext);
================================================
FILE: context/modal.js
================================================
import { createContext, useReducer, useContext } from "react";
import { useCycle } from "framer-motion";
const ModalStateContext = createContext();
const ModalDispatchContext = createContext();
const SHOW_CART = "SHOW_CART";
const SHOW_CHECKOUT = "SHOW_CHECKOUT";
const initialState = {
step: "cart",
};
const reducer = (state, action) => {
switch (action.type) {
case SHOW_CART:
return { ...state, step: "cart" };
case SHOW_CHECKOUT:
return { ...state, step: "checkout" };
default:
throw new Error(`Unknown action: ${action.type}`);
}
};
export const ModalProvider = ({ children }) => {
const [open, toggle] = useCycle(false, true);
const [state, dispatch] = useReducer(reducer, initialState);
const openModal = () => {
toggle();
document.body.classList.add("overflow-hidden");
};
const closeModal = () => {
toggle(0);
document.body.classList.remove("overflow-hidden");
dispatch({ type: "SHOW_CART" });
};
const showCart = () => dispatch({ type: "SHOW_CART" });
const showCheckout = () => dispatch({ type: "SHOW_CHECKOUT" });
return (
<ModalDispatchContext.Provider
value={{ openModal, closeModal, showCart, showCheckout }}
>
<ModalStateContext.Provider value={{ open, ...state }}>
{children}
</ModalStateContext.Provider>
</ModalDispatchContext.Provider>
);
};
export const useModalState = () => useContext(ModalStateContext);
export const useModalDispatch = () => useContext(ModalDispatchContext);
================================================
FILE: context/theme.js
================================================
import * as React from "react";
const ThemeStateContext = React.createContext();
const ThemeDispatchContext = React.createContext();
const initialState = null;
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = React.useState(initialState);
return (
<ThemeDispatchContext.Provider value={setTheme}>
<ThemeStateContext.Provider value={theme}>
{children}
</ThemeStateContext.Provider>
</ThemeDispatchContext.Provider>
);
};
export const useThemeState = () => React.useContext(ThemeStateContext);
export const useThemeDispatch = () => React.useContext(ThemeDispatchContext);
================================================
FILE: lib/commerce.js
================================================
import CommerceSDK from "@chec/commerce.js";
const checAPIKey = process.env.NEXT_PUBLIC_CHEC_PUBLIC_API_KEY;
const devEnvironment = process.env.NODE_ENV === 'development';
// Commerce.js constructor options
const commerceConfig = {
axiosConfig: {
headers: {
'X-Chec-Agent': 'commerce.js/v2',
'Chec-Version': '2021-03-10',
},
},
};
if (devEnvironment && !checAPIKey) {
throw Error('Your public API key must be provided as an environment variable named `NEXT_PUBLIC_CHEC_PUBLIC_API_KEY`. Obtain your Chec public key by logging into your Chec account and navigate to Setup > Developer, or can be obtained with the Chec CLI via with the command chec whoami');
}
export const commerce = new CommerceSDK(
checAPIKey,
devEnvironment,
commerceConfig,
);
================================================
FILE: lib/gtag.js
================================================
export const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GA_TRACKING_ID;
export const pageview = (url) => {
if (!GA_TRACKING_ID) return;
window.gtag("config", GA_TRACKING_ID, {
page_path: url,
});
};
export const event = ({ action, category, label, value }) => {
if (!GA_TRACKING_ID) return;
window.gtag("event", action, {
event_category: category,
event_label: label,
value: value,
});
};
================================================
FILE: next.config.js
================================================
module.exports = {
i18n: {
locales: ['en-US'],
defaultLocale: 'en-US',
},
images: {
domains: ["cdn.chec.io"],
},
};
================================================
FILE: package.json
================================================
{
"private": true,
"name": "chopchop",
"version": "1.0.0",
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start",
"seed": "chec-seed seeds"
},
"dependencies": {
"@chec/commerce.js": "2.2.0",
"@hookform/error-message": "0.0.5",
"@stripe/react-stripe-js": "1.1.2",
"@stripe/stripe-js": "1.11.0",
"autoprefixer": "10.0.4",
"classcat": "4.1.0",
"framer-motion": "2.9.4",
"next": "10.0.2",
"next-google-fonts": "1.2.1",
"postcss": "8.1.14",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-hook-form": "6.11.5",
"react-toastify": "6.1.0",
"use-debounce": "^7.0.0"
},
"devDependencies": {
"@chec/seeder": "^1.1.0",
"babel-plugin-inline-react-svg": "1.1.2",
"tailwindcss": "2.0.1"
}
}
================================================
FILE: pages/_app.js
================================================
import "react-toastify/dist/ReactToastify.css";
import "tailwindcss/tailwind.css";
import { useEffect } from "react";
import { AnimatePresence } from "framer-motion";
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { ToastContainer } from "react-toastify";
import * as gtag from "../lib/gtag";
import { ThemeProvider } from "../context/theme";
import { ModalProvider } from "../context/modal";
import { CartProvider } from "../context/cart";
import { CheckoutProvider } from "../context/checkout";
import Layout from "../components/Layout";
import Modal from "../components/Modal";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
);
const toastOptions = {
position: "bottom-center",
draggable: false,
hideProgressBar: true,
className: "w-full md:max-w-xl",
toastClassName: "bg-ecru-white rounded-lg text-black px-3 shadow-md",
};
function MyApp({ Component, pageProps, router }) {
useEffect(() => {
const handleRouteChange = (url) => {
gtag.pageview(url);
};
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}, [router.events]);
return (
<>
<Elements
stripe={stripePromise}
options={{
fonts: [
{
cssSrc:
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap",
},
],
}}
>
<ThemeProvider>
<ModalProvider>
<CartProvider>
<CheckoutProvider>
<Modal />
<Layout>
<AnimatePresence initial={false} exitBeforeEnter>
<Component {...pageProps} key={router.route} />
</AnimatePresence>
<ToastContainer {...toastOptions} />
</Layout>
</CheckoutProvider>
</CartProvider>
</ModalProvider>
</ThemeProvider>
</Elements>
</>
);
}
export default MyApp;
================================================
FILE: pages/_document.js
================================================
import Document, { Html, Head, Main, NextScript } from "next/document";
import GoogleFonts from "next-google-fonts";
import { GA_TRACKING_ID } from "../lib/gtag";
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<meta name="title" content="Headless Commerce example with Vercel" key="title" />
<meta name="description" content="An open source headless commerce example powered by Commerce.js and Vercel. Start your headless commerce application now!" />
<GoogleFonts href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" />
<GoogleFonts href="https://fonts.googleapis.com/css2?family=EB+Garamond:wght@500&display=swap" />
{GA_TRACKING_ID && (
<>
<script
async
src={`https://www.googletagmanager.com/gtag/js?id=${GA_TRACKING_ID}`}
/>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_TRACKING_ID}', {
page_path: window.location.pathname,
});
`,
}}
/>
</>
)}
</Head>
<body className="antialiased">
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;
================================================
FILE: pages/index.js
================================================
import Head from "next/head";
import { motion } from "framer-motion";
import { commerce } from "../lib/commerce";
import Header from "../components/Header";
import ProductList from "../components/ProductList";
import ProductGrid from "../components/ProductGrid";
export async function getStaticProps() {
const { data } = await commerce.products.list();
const products = data.filter(({ active }) => active);
return {
props: {
products,
},
revalidate: 60,
};
}
function IndexPage({ products }) {
return (
<>
<Head>
<title>ChopChop</title>
</Head>
<div className="md:min-h-screen md:flex md:items-center">
<div className="flex flex-col md:flex-row space-y-3 md:space-y-0 md:space-x-10">
<div className="md:max-h-screen md:w-1/2 flex items-end justify-between md:sticky md:top-0">
<Header />
<motion.div
className="md:py-12 hidden md:block md:sticky md:top-0"
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
transition: {
delay: 0.25,
},
}}
exit={{ opacity: 0, y: -50 }}
>
<h1 className="font-serif italic text-xl md:text-3xl">Shop:</h1>
<div className="pt-3">
<ProductList products={products} />
</div>
</motion.div>
</div>
<motion.div
className="md:min-h-screen py-6 md:py-12 flex items-center md:w-1/2 md:z-40"
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
>
<ProductGrid
products={products}
className="h-112 md:h-96 xl:h-112"
/>
</motion.div>
</div>
</div>
</>
);
}
export default IndexPage;
================================================
FILE: pages/products/[permalink].js
================================================
import React from "react";
import Head from "next/head";
import { motion } from "framer-motion";
import { toast } from "react-toastify";
import { commerce } from "../../lib/commerce";
import { useCartDispatch } from "../../context/cart";
import { useThemeDispatch } from "../../context/theme";
import { useModalDispatch } from "../../context/modal";
import Header from "../../components/Header";
import Button from "../../components/Button";
import VariantPicker from "../../components/VariantPicker";
import ProductImages from "../../components/ProductImages";
import ProductAttributes from "../../components/ProductAttributes";
import RelatedProducts from "../../components/RelatedProducts";
export async function getStaticProps({ params }) {
const { permalink } = params;
const product = await commerce.products.retrieve(permalink, {
type: "permalink",
});
return {
props: {
product,
},
revalidate: 60,
};
}
export async function getStaticPaths() {
const { data: products } = await commerce.products.list();
return {
paths: products.map(({ permalink }) => ({
params: {
permalink,
},
})),
fallback: false,
};
}
function ProductPage({ product }) {
const { setCart } = useCartDispatch();
const {
variant_groups: variantGroups,
assets,
meta,
related_products: relatedProducts,
} = product;
const images = assets.filter(({ is_image }) => is_image);
const setTheme = useThemeDispatch();
const { openModal } = useModalDispatch();
const initialVariants = React.useMemo(
() =>
variantGroups.reduce((all, { id, options }) => {
const [firstOption] = options;
return { ...all, [id]: firstOption.id };
}, {}),
[product.permalink]
);
const [selectedVariants, setSelectedVariants] = React.useState(
initialVariants
);
React.useEffect(() => {
setSelectedVariants(initialVariants);
setTheme(product.permalink);
return () => setTheme("default");
}, [product.permalink]);
const handleVariantChange = ({ target: { id, value } }) =>
setSelectedVariants({
...selectedVariants,
[id]: value,
});
const addToCart = () =>
commerce.cart
.add(product.id, 1, selectedVariants)
.then(({ cart }) => {
setCart(cart);
return cart;
})
.then(({ subtotal }) =>
toast(
`${product.name} is now in your cart. Your subtotal is now ${subtotal.formatted_with_symbol}. Click to view what's in your cart.`,
{
onClick: openModal,
}
)
)
.catch(() => {
toast.error("Please try again.");
});
return (
<React.Fragment>
<Head>
<title>{product.seo.title}</title>
<meta name="description" content={product.seo.description}></meta>
</Head>
<div className="md:hidden">
<Header />
</div>
<div className="md:min-h-screen md:flex md:items-center">
<div className="flex flex-col-reverse md:flex-row space-y-3 md:space-y-0 md:space-x-10">
<div className="md:max-h-screen md:w-1/2 flex flex-col md:flex-row items-end justify-between md:sticky md:top-0">
<div className="hidden md:block">
<Header />
</div>
<motion.div
className="py-6 md:py-12 sticky top-0"
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
transition: {
delay: 0.25,
},
}}
exit={{ opacity: 0, y: -50 }}
>
<h1 className="font-serif font-medium italic text-2xl md:text-4xl lg:text-5xl">
{product.name}
</h1>
<div className="flex items-center justify-between pt-3">
<div className="flex items-center">
<div className="pr-2">
<p className="text-lg md:text-xl lg:text-2xl font-sans">
{product.price.formatted_with_symbol}
</p>
</div>
<VariantPicker
variantGroups={variantGroups}
defaultValues={initialVariants}
onChange={handleVariantChange}
/>
</div>
<Button onClick={addToCart}>Add to Bag</Button>
</div>
<div
className="pt-5 md:pt-8 lg:pt-10 md:leading-relaxed lg:leading-loose lg:text-lg"
dangerouslySetInnerHTML={{ __html: product.description }}
/>
</motion.div>
</div>
<div className="md:min-h-screen md:py-12 flex items-center md:w-1/2 md:z-40">
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
>
<ProductImages images={images} />
<ProductAttributes {...meta} />
</motion.div>
</div>
</div>
</div>
<div className="py-3 md:py-4 lg:py-8">
<RelatedProducts products={relatedProducts} />
</div>
</React.Fragment>
);
}
export default ProductPage;
================================================
FILE: postcss.config.js
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
================================================
FILE: seeds/assets.json
================================================
[
{
"link": "products[5].id",
"filename": "ChopChop_Tote.jpg",
"url": "https://cdn.chec.io/merchants/19303/assets/X1HxOcELkPfQBY22_ChopChop_Tote.jpg"
},
{
"link": "products[4].id",
"filename": "Kitchen-Sink-Journal-1.jpg",
"url": "https://cdn.chec.io/merchants/19303/assets/9yI6YD9osPkZqmXC_Kitchen-Sink-Journal-1.jpg"
},
{
"link": "products[3].id",
"filename": "italic-rqWlTD5GwKc-unsplash-1.jpg",
"url": "https://cdn.chec.io/merchants/19303/assets/italic-rqWlTD5GwKc-unsplash%201.jpg"
},
{
"link": "products[3].id",
"filename": "dutch-oven-recolor-green.jpg",
"url": "https://cdn.chec.io/merchants/19303/assets/fhEBFIGPSx4jXhEJ_dutch-oven-recolor-green.jpg"
},
{
"link": "products[3].id",
"filename": "dutch-oven-recolor-red.jpg",
"url": "https://cdn.chec.io/merchants/19303/assets/VvLWyLKtYa17nQEo_dutch-oven-recolor-red.jpg"
},
{
"link": "products[2].id",
"filename": "italic--wPo52T1z-8-unsplash-1.jpg",
"url": "https://cdn.chec.io/merchants/19303/assets/italic--wPo52T1z-8-unsplash%201.jpg"
},
{
"link": "products[2].id",
"filename": "italic-dGoB5OrHDS0-unsplash-1.jpg",
"url": "https://cdn.chec.io/merchants/19303/assets/italic-dGoB5OrHDS0-unsplash%201.jpg"
},
{
"link": "products[1].id",
"filename": "emy-kOtEYRJspm8-unsplash.jpg",
"url": "https://cdn.chec.io/merchants/19303/assets/emy-kOtEYRJspm8-unsplash.jpg"
},
{
"link": "products[0].id",
"filename": "photo-1594761077961-cadd185540a4-1.jpg",
"url": "https://cdn.chec.io/merchants/19303/assets/photo-1594761077961-cadd185540a4%201.jpg"
}
]
================================================
FILE: seeds/categories.json
================================================
[
{
"slug": "journal",
"name": "Journal"
},
{
"slug": "cooking-class",
"name": "Cooking Class"
},
{
"slug": "cookware",
"name": "Cookware"
}
]
================================================
FILE: seeds/products.json
================================================
[
{
"product": {
"name": "Walnut Cook's Tools",
"description": "Carved by our friends at Mason St. Workshop, these cook’s tools are made to last. We went back and forth for months deliberating on woods, handles and the exact tools to include before landing on the current set of five which includes a spatula, three different spoons and a large ladle.",
"price": "40.00",
"category_id": "categories[2].id"
},
"collect": {
"billing": true,
"fullname": true
},
"seo": {
"title": "Walnut Cook's Tools | ChopChop",
"description": "Carved by our friends at Mason St. Workshop, these cook’s tools are made to last. A set of five which includes a spatula, three different spoons and a large ladle."
}
},
{
"product": {
"name": "Private Cooking Class",
"description": "Learn core skills or advanced techniques in our private cooking classes. Classes run two hours (online or in person at our Brooklyn storefront) and cover a range of recipes, approaches and techniques. Tell us what you want to tackle or learn a classic recipe. Just let us know in the form at checkout.",
"price": "120.00",
"category_id": "categories[1].id"
},
"extra_field": [
{
"name": "Lesson Plan",
"required": false
}
],
"collect": {
"fullname": true
},
"seo": {
"title": "Private Cooking Class | ChopChop",
"description": "Learn core skills or advanced techniques in our private cooking classes. Classes run two hours (online or in person at our Brooklyn storefront) and cover a range of recipes, approaches and techniques."
}
},
{
"product": {
"name": "Essential Knife Set",
"description": "There are a lot of knife sets out there, a lot of them are fine, but they also have a bunch of stuff you probably don’t need. We put together the essential knife set so you can snag exactly what you need to get cooking, no more no less. If you want a slightly different variation just get in touch and let us know, we’re happy to put something custom together for your needs.",
"price": "120.00",
"category_id": "categories[2].id"
},
"collect": {
"billing": true,
"fullname": true
},
"seo": {
"title": "Essential Knife Set | ChopChop",
"description": "We put together the essential knife set so you can snag exactly what you need to get cooking, no more no less."
}
},
{
"product": {
"name": "Ceramic Dutch Oven",
"description": "A colorful, stovetop multi-tool that will outlive you with even the most minimal amount of care, the ceramic coated, cast iron dutch oven is the Coach duffle of stovetop cooking - gorgeous, functional and the envy of literally every penny pinching home cook alive today.",
"price": "250.00",
"category_id": "categories[2].id"
},
"variant": [
{
"name": "Color",
"options": [
{
"description": "Yellow",
"quantity": "0",
"price": "0.00"
},
{
"description": "Green",
"price": "0.00"
},
{
"description": "Red",
"price": "0.00"
}
]
}
],
"collect": {
"billing": true,
"fullname": true
},
"seo": {
"title": "Ceramic Dutch Oven | ChopChop",
"description": "The ceramic coated, cast iron dutch oven is the Coach duffle of stovetop cooking - gorgeous, functional and the envy of literally every penny pinching home cook alive today."
}
},
{
"product": {
"name": "Kitchen Sink Journal",
"description": "Kitchen Sink Journal, our first publication, documents a year of culinary experiments by the Chop Chop team and some friends of the shop. While it includes 41 detailed recipes, in practice we use it more as a reference on how to tackle a given flavor, texture or ingredient. Hopefully you’ll find it just as useful!",
"price": "35.00",
"category_id": "categories[0].id"
},
"collect": {
"billing": true,
"fullname": true
},
"seo": {
"title": "Kitchen Sink Journal | ChopChop",
"description": "Our first publication documents a year of culinary experiments by the Chop Chop team and some friends of the shop. While it includes 41 detailed recipes, in practice we use it more as a reference on how to tackle a given flavor, texture or ingredient."
}
},
{
"product": {
"name": "Tote bag",
"price": "0.00",
"active": false
}
}
]
================================================
FILE: tailwind.config.js
================================================
const defaultTheme = require("tailwindcss/defaultTheme");
module.exports = {
purge: ["./{components,pages}/**/*.js"],
theme: {
extend: {
colors: {
clementine: "#EF7300",
tumbleweed: "#D9A876",
"hawkes-blue": "#C7DDFD",
asparagus: "#789750",
goldenrod: "#FFCE70",
black: "#150703",
"faded-black": "rgba(21,7,3,0.6)",
"ecru-white": "#FAF8F3",
"white-rock": "#E8E0CF",
},
height: {
112: "28rem",
},
rotate: {
'-25': '-25deg',
},
boxShadow: {
'thank-you': '-2.63365px 5.92572px 8.55938px rgba(0, 0, 0, 0.25)',
},
},
fontFamily: {
sans: ["Inter", ...defaultTheme.fontFamily.sans],
serif: ["'EB Garamond'", ...defaultTheme.fontFamily.serif],
},
},
variants: {
extend: {
backgroundColor: ["checked"],
borderColor: ["checked"],
borderRadius: ["hover"],
fontStyle: ["hover"],
textColor: ["checked"],
},
},
};
gitextract_jvvou8o_/ ├── .babelrc ├── .chec.json ├── .editorconfig ├── .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.json ├── pages/ │ ├── _app.js │ ├── _document.js │ ├── index.js │ └── products/ │ └── [permalink].js ├── postcss.config.js ├── seeds/ │ ├── assets.json │ ├── categories.json │ └── products.json └── tailwind.config.js
SYMBOL INDEX (49 symbols across 37 files)
FILE: components/Breadcrumbs.js
function Breadcrumbs (line 5) | function Breadcrumbs({ inCart }) {
FILE: components/Button.js
function Button (line 22) | function Button({ className, ...props }) {
FILE: components/Cart.js
function Cart (line 7) | function Cart() {
FILE: components/CartItem.js
function CartItem (line 8) | function CartItem({ id, media, name, quantity, line_total, selected_opti...
FILE: components/CartSummary.js
function CartSummary (line 4) | function CartSummary() {
FILE: components/Checkout/AddressFields.js
function AddressFields (line 5) | function AddressFields({ prefix = "", countries = {}, subdivisions = {} ...
FILE: components/Checkout/BillingForm.js
function BillingForm (line 29) | function BillingForm() {
FILE: components/Checkout/Checkout.js
function Checkout (line 17) | function Checkout({ cartId }) {
FILE: components/Checkout/CheckoutSummary.js
function CheckoutSummary (line 7) | function CheckoutSummary({ subtotal, tax, shipping, line_items = [], tot...
FILE: components/Checkout/ExtraFieldsForm.js
function ExtraFieldsForm (line 18) | function ExtraFieldsForm() {
FILE: components/Checkout/OrderSummary.js
function CheckoutSummary (line 3) | function CheckoutSummary({ has, fulfillment, order }) {
FILE: components/Checkout/ShippingForm.js
function ShippingForm (line 12) | function ShippingForm() {
FILE: components/Checkout/Success.js
function Success (line 3) | function Success({ has }) {
FILE: components/Footer.js
function Footer (line 6) | function Footer() {
FILE: components/Form/FormCheckbox.js
function FormCheckbox (line 3) | function FormCheckbox({
FILE: components/Form/FormError.js
function FormError (line 4) | function FormError({ className, ...props }) {
FILE: components/Form/FormInput.js
function FormInput (line 5) | function FormInput({
FILE: components/Form/FormSelect.js
function FormSelect (line 7) | function FormSelect({
FILE: components/Form/FormTextarea.js
function FormTextarea (line 5) | function FormTextarea({
FILE: components/Header.js
function Header (line 7) | function Header() {
FILE: components/Layout.js
function Layout (line 3) | function Layout({ children }) {
FILE: components/Modal.js
function CurrentStep (line 13) | function CurrentStep({ step }) {
function Modal (line 26) | function Modal() {
FILE: components/Product.js
function Product (line 5) | function Product({ media, name, permalink, price, className }) {
FILE: components/ProductAttributes.js
function ProductAttributes (line 1) | function ProductAttributes({ attributes = [] }) {
FILE: components/ProductGrid.js
function ProductGrid (line 3) | function ProductGrid({ products, ...props }) {
FILE: components/ProductImages.js
function ProductImages (line 3) | function ProductImages({ images = [] }) {
FILE: components/ProductList.js
function ProductList (line 3) | function ProductList({ products }) {
FILE: components/RelatedProducts.js
function RelatedProducts (line 3) | function RelatedProducts({ products }) {
FILE: components/VariantPicker.js
function VariantPicker (line 5) | function VariantPicker({ variantGroups = [], defaultValues = {}, ...prop...
FILE: context/cart.js
constant SET_CART (line 9) | const SET_CART = "SET_CART";
constant RESET (line 10) | const RESET = "RESET";
FILE: context/checkout.js
constant SET_CURRENT_STEP (line 8) | const SET_CURRENT_STEP = "SET_CURRENT_STEP";
constant SET_CHECKOUT (line 9) | const SET_CHECKOUT = "SET_CHECKOUT";
constant SET_LIVE (line 10) | const SET_LIVE = "SET_LIVE";
constant SET_PROCESSING (line 11) | const SET_PROCESSING = "SET_PROCESSING";
constant SET_ERROR (line 12) | const SET_ERROR = "SET_ERROR";
constant RESET (line 13) | const RESET = "RESET";
FILE: context/modal.js
constant SHOW_CART (line 7) | const SHOW_CART = "SHOW_CART";
constant SHOW_CHECKOUT (line 8) | const SHOW_CHECKOUT = "SHOW_CHECKOUT";
FILE: lib/gtag.js
constant GA_TRACKING_ID (line 1) | const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GA_TRACKING_ID;
FILE: pages/_app.js
function MyApp (line 32) | function MyApp({ Component, pageProps, router }) {
FILE: pages/_document.js
class MyDocument (line 6) | class MyDocument extends Document {
method render (line 7) | render() {
FILE: pages/index.js
function getStaticProps (line 10) | async function getStaticProps() {
function IndexPage (line 23) | function IndexPage({ products }) {
FILE: pages/products/[permalink].js
function getStaticProps (line 18) | async function getStaticProps({ params }) {
function getStaticPaths (line 33) | async function getStaticPaths() {
function ProductPage (line 46) | function ProductPage({ product }) {
Condensed preview — 54 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (91K chars).
[
{
"path": ".babelrc",
"chars": 66,
"preview": "{\n \"presets\": [\"next/babel\"],\n \"plugins\": [\"inline-react-svg\"]\n}"
},
{
"path": ".chec.json",
"chars": 233,
"preview": "{\n \"npm\": \"npm\",\n \"buildScripts\": [\"seed\", \"dev\"],\n \"dotenv\": {\n \"NODE_ENV\": \"development\",\n \"NEXT_PUBLIC_CHEC_"
},
{
"path": ".editorconfig",
"chars": 330,
"preview": "# For more information about the properties used in this file,\n# please see the EditorConfig documentation:\n# http://edi"
},
{
"path": ".gitignore",
"chars": 161,
"preview": "# Build output\n.next\n\n# Environment variables\n.env\n\n# Dependency directories\nnode_modules\n\n# Logs\nnpm-debug.log*\nyarn-de"
},
{
"path": "LICENSE.md",
"chars": 1487,
"preview": "Copyright (c) 2021 Chec Platform LLC, All rights reserved.\n\nRedistribution and use in source and binary forms, with or w"
},
{
"path": "README.md",
"chars": 6307,
"preview": "<p align=\"center\">\n <img src=\"https://raw.githubusercontent.com/chec/commercejs-examples/master/assets/logo.svg\" width="
},
{
"path": "components/Breadcrumbs.js",
"chars": 2283,
"preview": "import { useCheckoutState } from \"../context/checkout\";\n\n// TODO: Build array of crumbs dynamically from available steps"
},
{
"path": "components/Button.js",
"chars": 1031,
"preview": "import cc from \"classcat\";\n\nimport { useThemeState } from \"../context/theme\";\n\nconst buttonStyle = (theme) => {\n switch"
},
{
"path": "components/Cart.js",
"chars": 1249,
"preview": "import { useCartState } from \"../context/cart\";\nimport { useModalDispatch } from \"../context/modal\";\n\nimport Button from"
},
{
"path": "components/CartItem.js",
"chars": 4090,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\nimport { toast } from \"react-toastify\";\n\nimport { commerce } "
},
{
"path": "components/CartSummary.js",
"chars": 404,
"preview": "import { useCartState } from \"../context/cart\";\nimport { useModalDispatch } from \"../context/modal\";\n\nfunction CartSumma"
},
{
"path": "components/Checkout/AddressFields.js",
"chars": 2216,
"preview": "import React from \"react\";\n\nimport { FormInput, FormSelect } from \"../Form\";\n\nfunction AddressFields({ prefix = \"\", coun"
},
{
"path": "components/Checkout/BillingForm.js",
"chars": 4619,
"preview": "import { useState, useEffect } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { useDebounce } fr"
},
{
"path": "components/Checkout/Checkout.js",
"chars": 4537,
"preview": "import { useState, useEffect } from \"react\";\nimport { useForm, FormProvider } from \"react-hook-form\";\nimport { useStripe"
},
{
"path": "components/Checkout/CheckoutSummary.js",
"chars": 1692,
"preview": "import cc from \"classcat\";\n\nimport { useCheckoutState } from \"../../context/checkout\";\n\nimport Button from \"../Button\";\n"
},
{
"path": "components/Checkout/ExtraFieldsForm.js",
"chars": 2198,
"preview": "import { useEffect } from \"react\";\nimport { useCheckoutState, useCheckoutDispatch } from \"../../context/checkout\";\n\nimpo"
},
{
"path": "components/Checkout/OrderSummary.js",
"chars": 1704,
"preview": "import Button from \"../Button\";\n\nfunction CheckoutSummary({ has, fulfillment, order }) {\n const { subtotal, tax, shippi"
},
{
"path": "components/Checkout/ShippingForm.js",
"chars": 4470,
"preview": "import { useState, useEffect } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { useDebounce } fr"
},
{
"path": "components/Checkout/Success.js",
"chars": 2105,
"preview": "import Image from 'next/image';\n\nfunction Success({ has }) {\n return (\n <div className=\"h-full lg:flex lg:items-cent"
},
{
"path": "components/Checkout/index.js",
"chars": 38,
"preview": "export { default } from \"./Checkout\";\n"
},
{
"path": "components/Footer.js",
"chars": 2175,
"preview": "import Link from \"next/link\";\n\nimport LogoSVG from \"../svg/logo.svg\";\nimport CommerceJsSVG from \"../svg/commercejs.svg\";"
},
{
"path": "components/Form/FormCheckbox.js",
"chars": 1105,
"preview": "import { useFormContext } from \"react-hook-form\";\n\nfunction FormCheckbox({\n label,\n children,\n name,\n required = fal"
},
{
"path": "components/Form/FormError.js",
"chars": 427,
"preview": "import cc from \"classcat\";\nimport { ErrorMessage } from \"@hookform/error-message\";\n\nfunction FormError({ className, ...p"
},
{
"path": "components/Form/FormInput.js",
"chars": 771,
"preview": "import { useFormContext } from \"react-hook-form\";\n\nimport FormError from \"./FormError\";\n\nfunction FormInput({\n label,\n "
},
{
"path": "components/Form/FormSelect.js",
"chars": 1373,
"preview": "import { useFormContext } from \"react-hook-form\";\n\nimport Chevron from \"../../svg/chevron.svg\";\n\nimport FormError from \""
},
{
"path": "components/Form/FormTextarea.js",
"chars": 743,
"preview": "import { useFormContext } from \"react-hook-form\";\n\nimport FormError from \"./FormError\";\n\nfunction FormTextarea({\n label"
},
{
"path": "components/Form/index.js",
"chars": 274,
"preview": "export { default as FormInput } from \"./FormInput\";\nexport { default as FormTextarea } from \"./FormTextarea\";\nexport { d"
},
{
"path": "components/Header.js",
"chars": 608,
"preview": "import Link from \"next/link\";\n\nimport CartSummary from \"./CartSummary\";\n\nimport LogoSVG from \"../svg/logo.svg\";\n\nfunctio"
},
{
"path": "components/Layout.js",
"chars": 262,
"preview": "import Footer from \"./Footer\";\n\nfunction Layout({ children }) {\n return (\n <>\n <div className=\"shadow-md\">\n "
},
{
"path": "components/Modal.js",
"chars": 2080,
"preview": "import { useEffect } from \"react\";\nimport { useRouter } from \"next/router\";\nimport { AnimatePresence, motion } from \"fra"
},
{
"path": "components/Product.js",
"chars": 1116,
"preview": "import Image from \"next/image\";\nimport Link from \"next/link\";\nimport cc from \"classcat\";\n\nfunction Product({ media, name"
},
{
"path": "components/ProductAttributes.js",
"chars": 544,
"preview": "function ProductAttributes({ attributes = [] }) {\n if (!attributes || attributes.length === 0) return null;\n\n return ("
},
{
"path": "components/ProductGrid.js",
"chars": 365,
"preview": "import Product from \"./Product\";\n\nfunction ProductGrid({ products, ...props }) {\n if (!products || products.length === "
},
{
"path": "components/ProductImages.js",
"chars": 512,
"preview": "import Image from \"next/image\";\n\nfunction ProductImages({ images = [] }) {\n if (!images || images.length === 0) return "
},
{
"path": "components/ProductList.js",
"chars": 419,
"preview": "import Link from \"next/link\";\n\nfunction ProductList({ products }) {\n if (!products || products.length === 0) return nul"
},
{
"path": "components/RelatedProducts.js",
"chars": 644,
"preview": "import Product from \"./Product\";\n\nfunction RelatedProducts({ products }) {\n if (!products || products.length === 0) ret"
},
{
"path": "components/VariantPicker.js",
"chars": 1174,
"preview": "import React from \"react\";\n\nimport Chevron from \"../svg/chevron.svg\";\n\nfunction VariantPicker({ variantGroups = [], defa"
},
{
"path": "context/cart.js",
"chars": 1770,
"preview": "import { createContext, useReducer, useEffect, useContext } from \"react\";\nimport { useCycle } from \"framer-motion\";\n\nimp"
},
{
"path": "context/checkout.js",
"chars": 3054,
"preview": "import { createContext, useReducer, useContext } from \"react\";\n\nimport { commerce } from \"../lib/commerce\";\n\nconst Check"
},
{
"path": "context/modal.js",
"chars": 1526,
"preview": "import { createContext, useReducer, useContext } from \"react\";\nimport { useCycle } from \"framer-motion\";\n\nconst ModalSta"
},
{
"path": "context/theme.js",
"chars": 635,
"preview": "import * as React from \"react\";\n\nconst ThemeStateContext = React.createContext();\nconst ThemeDispatchContext = React.cre"
},
{
"path": "lib/commerce.js",
"chars": 782,
"preview": "import CommerceSDK from \"@chec/commerce.js\";\n\nconst checAPIKey = process.env.NEXT_PUBLIC_CHEC_PUBLIC_API_KEY;\nconst devE"
},
{
"path": "lib/gtag.js",
"chars": 419,
"preview": "export const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GA_TRACKING_ID;\n\nexport const pageview = (url) => {\n if (!GA_TRAC"
},
{
"path": "next.config.js",
"chars": 136,
"preview": "module.exports = {\n i18n: {\n locales: ['en-US'],\n defaultLocale: 'en-US',\n },\n images: {\n domains: [\"cdn.che"
},
{
"path": "package.json",
"chars": 803,
"preview": "{\n \"private\": true,\n \"name\": \"chopchop\",\n \"version\": \"1.0.0\",\n \"scripts\": {\n \"dev\": \"next\",\n \"build\": \"next bu"
},
{
"path": "pages/_app.js",
"chars": 2132,
"preview": "import \"react-toastify/dist/ReactToastify.css\";\nimport \"tailwindcss/tailwind.css\";\n\nimport { useEffect } from \"react\";\ni"
},
{
"path": "pages/_document.js",
"chars": 1529,
"preview": "import Document, { Html, Head, Main, NextScript } from \"next/document\";\nimport GoogleFonts from \"next-google-fonts\";\n\nim"
},
{
"path": "pages/index.js",
"chars": 1956,
"preview": "import Head from \"next/head\";\nimport { motion } from \"framer-motion\";\n\nimport { commerce } from \"../lib/commerce\";\n\nimpo"
},
{
"path": "pages/products/[permalink].js",
"chars": 5315,
"preview": "import React from \"react\";\nimport Head from \"next/head\";\nimport { motion } from \"framer-motion\";\nimport { toast } from \""
},
{
"path": "postcss.config.js",
"chars": 83,
"preview": "module.exports = {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n"
},
{
"path": "seeds/assets.json",
"chars": 1647,
"preview": "[\n {\n \"link\": \"products[5].id\",\n \"filename\": \"ChopChop_Tote.jpg\",\n \"url\": \"https://cdn.chec.io/merchants/19303"
},
{
"path": "seeds/categories.json",
"chars": 179,
"preview": "[\n {\n \"slug\": \"journal\",\n \"name\": \"Journal\"\n },\n {\n \"slug\": \"cooking-class\",\n \"name\": \"Cooking Class\"\n }"
},
{
"path": "seeds/products.json",
"chars": 4631,
"preview": "[\n {\n \"product\": {\n \"name\": \"Walnut Cook's Tools\",\n \"description\": \"Carved by our friends at Mason St. Wor"
},
{
"path": "tailwind.config.js",
"chars": 1021,
"preview": "const defaultTheme = require(\"tailwindcss/defaultTheme\");\n\nmodule.exports = {\n purge: [\"./{components,pages}/**/*.js\"],"
}
]
About this extraction
This page contains the full source code of the chec/commercejs-chopchop-demo GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 54 files (81.5 KB), approximately 21.2k tokens, and a symbol index with 49 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.