Repository: vercel/commerce
Branch: main
Commit: 1df2cf6f6c93
Files: 75
Total size: 114.8 KB
Directory structure:
gitextract_ucebgmi7/
├── .env.example
├── .gitignore
├── .vscode/
│ ├── launch.json
│ └── settings.json
├── README.md
├── app/
│ ├── [page]/
│ │ ├── layout.tsx
│ │ ├── opengraph-image.tsx
│ │ └── page.tsx
│ ├── api/
│ │ └── revalidate/
│ │ └── route.ts
│ ├── error.tsx
│ ├── globals.css
│ ├── layout.tsx
│ ├── opengraph-image.tsx
│ ├── page.tsx
│ ├── product/
│ │ └── [handle]/
│ │ └── page.tsx
│ ├── robots.ts
│ ├── search/
│ │ ├── [collection]/
│ │ │ ├── opengraph-image.tsx
│ │ │ └── page.tsx
│ │ ├── children-wrapper.tsx
│ │ ├── layout.tsx
│ │ ├── loading.tsx
│ │ └── page.tsx
│ └── sitemap.ts
├── components/
│ ├── carousel.tsx
│ ├── cart/
│ │ ├── actions.ts
│ │ ├── add-to-cart.tsx
│ │ ├── cart-context.tsx
│ │ ├── delete-item-button.tsx
│ │ ├── edit-item-quantity-button.tsx
│ │ ├── modal.tsx
│ │ └── open-cart.tsx
│ ├── grid/
│ │ ├── index.tsx
│ │ ├── three-items.tsx
│ │ └── tile.tsx
│ ├── icons/
│ │ └── logo.tsx
│ ├── label.tsx
│ ├── layout/
│ │ ├── footer-menu.tsx
│ │ ├── footer.tsx
│ │ ├── navbar/
│ │ │ ├── index.tsx
│ │ │ ├── mobile-menu.tsx
│ │ │ └── search.tsx
│ │ ├── product-grid-items.tsx
│ │ └── search/
│ │ ├── collections.tsx
│ │ └── filter/
│ │ ├── dropdown.tsx
│ │ ├── index.tsx
│ │ └── item.tsx
│ ├── loading-dots.tsx
│ ├── logo-square.tsx
│ ├── opengraph-image.tsx
│ ├── price.tsx
│ ├── product/
│ │ ├── gallery.tsx
│ │ ├── product-description.tsx
│ │ └── variant-selector.tsx
│ ├── prose.tsx
│ └── welcome-toast.tsx
├── lib/
│ ├── constants.ts
│ ├── shopify/
│ │ ├── fragments/
│ │ │ ├── cart.ts
│ │ │ ├── image.ts
│ │ │ ├── product.ts
│ │ │ └── seo.ts
│ │ ├── index.ts
│ │ ├── mutations/
│ │ │ └── cart.ts
│ │ ├── queries/
│ │ │ ├── cart.ts
│ │ │ ├── collection.ts
│ │ │ ├── menu.ts
│ │ │ ├── page.ts
│ │ │ └── product.ts
│ │ └── types.ts
│ ├── type-guards.ts
│ └── utils.ts
├── license.md
├── next.config.ts
├── package.json
├── postcss.config.mjs
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .env.example
================================================
COMPANY_NAME="Vercel Inc."
SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
.playwright
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env*.local
================================================
FILE: .vscode/launch.json
================================================
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}
================================================
FILE: .vscode/settings.json
================================================
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}
================================================
FILE: README.md
================================================
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME)
# Next.js Commerce
A high-performance, server-rendered Next.js App Router ecommerce application.
This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more.
> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1).
## Providers
Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966).
Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
- Shopify (this repository)
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
- [Ecwid by Lightspeed](https://github.com/Ecwid/ecwid-nextjs-commerce/) ([Demo](https://ecwid-nextjs-commerce.vercel.app/))
- [Geins](https://github.com/geins-io/vercel-nextjs-commerce) ([Demo](https://geins-nextjs-commerce-starter.vercel.app/))
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
- [Prodigy Commerce](https://github.com/prodigycommerce/nextjs-commerce) ([Demo](https://prodigy-nextjs-commerce.vercel.app/))
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
- [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
- [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
- [Wix](https://github.com/wix/headless-templates/tree/main/nextjs/commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
- [Fourthwall](https://github.com/FourthwallHQ/vercel-commerce) ([Demo](https://vercel-storefront.fourthwall.app/))
> Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing).
## Integrations
Integrations enable upgraded or additional functionality for Next.js Commerce
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
- [React Bricks](https://github.com/ReactBricks/nextjs-commerce-rb) ([Demo](https://nextjs-commerce.reactbricks.com/))
- Edit pages, product details, and footer content visually using [React Bricks](https://www.reactbricks.com) visual headless CMS.
## Running locally
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
1. Install Vercel CLI: `npm i -g vercel`
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
3. Download your environment variables: `vercel env pull`
```bash
pnpm install
pnpm dev
```
Your app should now be running on [localhost:3000](http://localhost:3000/).
Expand if you work at Vercel and want to run locally and / or contribute
1. Run `vc link`.
1. Select the `Vercel Solutions` scope.
1. Connect to the existing `commerce-shopify` project.
1. Run `vc env pull` to get environment variables.
1. Run `pnpm dev` to ensure everything is working correctly.
## Vercel, Next.js Commerce, and Shopify Integration Guide
You can use this comprehensive [integration guide](https://vercel.com/docs/integrations/ecommerce/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.
================================================
FILE: app/[page]/layout.tsx
================================================
import Footer from "components/layout/footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
>
);
}
================================================
FILE: app/[page]/opengraph-image.tsx
================================================
import OpengraphImage from "components/opengraph-image";
import { getPage } from "lib/shopify";
export default async function Image({ params }: { params: { page: string } }) {
const page = await getPage(params.page);
const title = page.seo?.title || page.title;
return await OpengraphImage({ title });
}
================================================
FILE: app/[page]/page.tsx
================================================
import type { Metadata } from "next";
import Prose from "components/prose";
import { getPage } from "lib/shopify";
import { notFound } from "next/navigation";
export async function generateMetadata(props: {
params: Promise<{ page: string }>;
}): Promise {
const params = await props.params;
const page = await getPage(params.page);
if (!page) return notFound();
return {
title: page.seo?.title || page.title,
description: page.seo?.description || page.bodySummary,
openGraph: {
publishedTime: page.createdAt,
modifiedTime: page.updatedAt,
type: "article",
},
};
}
export default async function Page(props: {
params: Promise<{ page: string }>;
}) {
const params = await props.params;
const page = await getPage(params.page);
if (!page) return notFound();
return (
<>
{page.title}
{`This document was last updated on ${new Intl.DateTimeFormat(
undefined,
{
year: "numeric",
month: "long",
day: "numeric",
},
).format(new Date(page.updatedAt))}.`}
>
);
}
================================================
FILE: app/api/revalidate/route.ts
================================================
import { revalidate } from "lib/shopify";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest): Promise {
return revalidate(req);
}
================================================
FILE: app/error.tsx
================================================
"use client";
export default function Error({ reset }: { reset: () => void }) {
return (
Oh no!
There was an issue with our storefront. This could be a temporary issue,
please try your action again.
reset()}
>
Try Again
);
}
================================================
FILE: app/globals.css
================================================
@import "tailwindcss";
@plugin "@tailwindcss/container-queries";
@plugin "@tailwindcss/typography";
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
img[loading="lazy"] {
clip-path: inset(0.6px);
}
}
a,
input,
button {
@apply focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
}
================================================
FILE: app/layout.tsx
================================================
import { CartProvider } from "components/cart/cart-context";
import { Navbar } from "components/layout/navbar";
import { WelcomeToast } from "components/welcome-toast";
import { GeistSans } from "geist/font/sans";
import { getCart } from "lib/shopify";
import { ReactNode } from "react";
import { Toaster } from "sonner";
import "./globals.css";
import { baseUrl } from "lib/utils";
const { SITE_NAME } = process.env;
export const metadata = {
metadataBase: new URL(baseUrl),
title: {
default: SITE_NAME!,
template: `%s | ${SITE_NAME}`,
},
robots: {
follow: true,
index: true,
},
};
export default async function RootLayout({
children,
}: {
children: ReactNode;
}) {
// Don't await the fetch, pass the Promise to the context provider
const cart = getCart();
return (
{children}
);
}
================================================
FILE: app/opengraph-image.tsx
================================================
import OpengraphImage from "components/opengraph-image";
export default async function Image() {
return await OpengraphImage();
}
================================================
FILE: app/page.tsx
================================================
import { Carousel } from "components/carousel";
import { ThreeItemGrid } from "components/grid/three-items";
import Footer from "components/layout/footer";
export const metadata = {
description:
"High-performance ecommerce store built with Next.js, Vercel, and Shopify.",
openGraph: {
type: "website",
},
};
export default function HomePage() {
return (
<>
>
);
}
================================================
FILE: app/product/[handle]/page.tsx
================================================
import { GridTileImage } from "components/grid/tile";
import Footer from "components/layout/footer";
import { Gallery } from "components/product/gallery";
import { ProductDescription } from "components/product/product-description";
import { HIDDEN_PRODUCT_TAG } from "lib/constants";
import { getProduct, getProductRecommendations } from "lib/shopify";
import type { Image } from "lib/shopify/types";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";
export async function generateMetadata(props: {
params: Promise<{ handle: string }>;
}): Promise {
const params = await props.params;
const product = await getProduct(params.handle);
if (!product) return notFound();
const { url, width, height, altText: alt } = product.featuredImage || {};
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
return {
title: product.seo.title || product.title,
description: product.seo.description || product.description,
robots: {
index: indexable,
follow: indexable,
googleBot: {
index: indexable,
follow: indexable,
},
},
openGraph: url
? {
images: [
{
url,
width,
height,
alt,
},
],
}
: null,
};
}
export default async function ProductPage(props: {
params: Promise<{ handle: string }>;
}) {
const params = await props.params;
const product = await getProduct(params.handle);
if (!product) return notFound();
const productJsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.title,
description: product.description,
image: product.featuredImage.url,
offers: {
"@type": "AggregateOffer",
availability: product.availableForSale
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
highPrice: product.priceRange.maxVariantPrice.amount,
lowPrice: product.priceRange.minVariantPrice.amount,
},
};
return (
<>
}
>
({
src: image.url,
altText: image.altText,
}))}
/>
>
);
}
async function RelatedProducts({ id }: { id: string }) {
const relatedProducts = await getProductRecommendations(id);
if (!relatedProducts.length) return null;
return (
Related Products
{relatedProducts.map((product) => (
))}
);
}
================================================
FILE: app/robots.ts
================================================
import { baseUrl } from "lib/utils";
export default function robots() {
return {
rules: [
{
userAgent: "*",
},
],
sitemap: `${baseUrl}/sitemap.xml`,
host: baseUrl,
};
}
================================================
FILE: app/search/[collection]/opengraph-image.tsx
================================================
import OpengraphImage from "components/opengraph-image";
import { getCollection } from "lib/shopify";
export default async function Image({
params,
}: {
params: { collection: string };
}) {
const collection = await getCollection(params.collection);
const title = collection?.seo?.title || collection?.title;
return await OpengraphImage({ title });
}
================================================
FILE: app/search/[collection]/page.tsx
================================================
import { getCollection, getCollectionProducts } from "lib/shopify";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import Grid from "components/grid";
import ProductGridItems from "components/layout/product-grid-items";
import { defaultSort, sorting } from "lib/constants";
export async function generateMetadata(props: {
params: Promise<{ collection: string }>;
}): Promise {
const params = await props.params;
const collection = await getCollection(params.collection);
if (!collection) return notFound();
return {
title: collection.seo?.title || collection.title,
description:
collection.seo?.description ||
collection.description ||
`${collection.title} products`,
};
}
export default async function CategoryPage(props: {
params: Promise<{ collection: string }>;
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParams = await props.searchParams;
const params = await props.params;
const { sort } = searchParams as { [key: string]: string };
const { sortKey, reverse } =
sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getCollectionProducts({
collection: params.collection,
sortKey,
reverse,
});
return (
{products.length === 0 ? (
{`No products found in this collection`}
) : (
)}
);
}
================================================
FILE: app/search/children-wrapper.tsx
================================================
"use client";
import { useSearchParams } from "next/navigation";
import { Fragment } from "react";
// Ensure children are re-rendered when the search query changes
export default function ChildrenWrapper({
children,
}: {
children: React.ReactNode;
}) {
const searchParams = useSearchParams();
return {children} ;
}
================================================
FILE: app/search/layout.tsx
================================================
import Footer from "components/layout/footer";
import Collections from "components/layout/search/collections";
import FilterList from "components/layout/search/filter";
import { sorting } from "lib/constants";
import ChildrenWrapper from "./children-wrapper";
import { Suspense } from "react";
export default function SearchLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
>
);
}
================================================
FILE: app/search/loading.tsx
================================================
import Grid from "components/grid";
export default function Loading() {
return (
<>
{Array(12)
.fill(0)
.map((_, index) => {
return (
);
})}
>
);
}
================================================
FILE: app/search/page.tsx
================================================
import Grid from "components/grid";
import ProductGridItems from "components/layout/product-grid-items";
import { defaultSort, sorting } from "lib/constants";
import { getProducts } from "lib/shopify";
export const metadata = {
title: "Search",
description: "Search for products in the store.",
};
export default async function SearchPage(props: {
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParams = await props.searchParams;
const { sort, q: searchValue } = searchParams as { [key: string]: string };
const { sortKey, reverse } =
sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getProducts({ sortKey, reverse, query: searchValue });
const resultsText = products.length > 1 ? "results" : "result";
return (
<>
{searchValue ? (
{products.length === 0
? "There are no products that match "
: `Showing ${products.length} ${resultsText} for `}
"{searchValue}"
) : null}
{products.length > 0 ? (
) : null}
>
);
}
================================================
FILE: app/sitemap.ts
================================================
import { getCollections, getPages, getProducts } from "lib/shopify";
import { baseUrl, validateEnvironmentVariables } from "lib/utils";
import { MetadataRoute } from "next";
type Route = {
url: string;
lastModified: string;
};
export const dynamic = "force-dynamic";
export default async function sitemap(): Promise {
validateEnvironmentVariables();
const routesMap = [""].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date().toISOString(),
}));
const collectionsPromise = getCollections().then((collections) =>
collections.map((collection) => ({
url: `${baseUrl}${collection.path}`,
lastModified: collection.updatedAt,
})),
);
const productsPromise = getProducts({}).then((products) =>
products.map((product) => ({
url: `${baseUrl}/product/${product.handle}`,
lastModified: product.updatedAt,
})),
);
const pagesPromise = getPages().then((pages) =>
pages.map((page) => ({
url: `${baseUrl}/${page.handle}`,
lastModified: page.updatedAt,
})),
);
let fetchedRoutes: Route[] = [];
try {
fetchedRoutes = (
await Promise.all([collectionsPromise, productsPromise, pagesPromise])
).flat();
} catch (error) {
throw JSON.stringify(error, null, 2);
}
return [...routesMap, ...fetchedRoutes];
}
================================================
FILE: components/carousel.tsx
================================================
import { getCollectionProducts } from "lib/shopify";
import Link from "next/link";
import { GridTileImage } from "./grid/tile";
export async function Carousel() {
// Collections that start with `hidden-*` are hidden from the search page.
const products = await getCollectionProducts({
collection: "hidden-homepage-carousel",
});
if (!products?.length) return null;
// Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
const carouselProducts = [...products, ...products, ...products];
return (
{carouselProducts.map((product, i) => (
))}
);
}
================================================
FILE: components/cart/actions.ts
================================================
"use server";
import { TAGS } from "lib/constants";
import {
addToCart,
createCart,
getCart,
removeFromCart,
updateCart,
} from "lib/shopify";
import { updateTag } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export async function addItem(
prevState: any,
selectedVariantId: string | undefined
) {
if (!selectedVariantId) {
return "Error adding item to cart";
}
try {
await addToCart([{ merchandiseId: selectedVariantId, quantity: 1 }]);
updateTag(TAGS.cart);
} catch (e) {
return "Error adding item to cart";
}
}
export async function removeItem(prevState: any, merchandiseId: string) {
try {
const cart = await getCart();
if (!cart) {
return "Error fetching cart";
}
const lineItem = cart.lines.find(
(line) => line.merchandise.id === merchandiseId
);
if (lineItem && lineItem.id) {
await removeFromCart([lineItem.id]);
updateTag(TAGS.cart);
} else {
return "Item not found in cart";
}
} catch (e) {
return "Error removing item from cart";
}
}
export async function updateItemQuantity(
prevState: any,
payload: {
merchandiseId: string;
quantity: number;
}
) {
const { merchandiseId, quantity } = payload;
try {
const cart = await getCart();
if (!cart) {
return "Error fetching cart";
}
const lineItem = cart.lines.find(
(line) => line.merchandise.id === merchandiseId
);
if (lineItem && lineItem.id) {
if (quantity === 0) {
await removeFromCart([lineItem.id]);
} else {
await updateCart([
{
id: lineItem.id,
merchandiseId,
quantity,
},
]);
}
} else if (quantity > 0) {
// If the item doesn't exist in the cart and quantity > 0, add it
await addToCart([{ merchandiseId, quantity }]);
}
updateTag(TAGS.cart);
} catch (e) {
console.error(e);
return "Error updating item quantity";
}
}
export async function redirectToCheckout() {
let cart = await getCart();
redirect(cart!.checkoutUrl);
}
export async function createCartAndSetCookie() {
let cart = await createCart();
(await cookies()).set("cartId", cart.id!);
}
================================================
FILE: components/cart/add-to-cart.tsx
================================================
"use client";
import { PlusIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { addItem } from "components/cart/actions";
import { Product, ProductVariant } from "lib/shopify/types";
import { useSearchParams } from "next/navigation";
import { useActionState } from "react";
import { useCart } from "./cart-context";
function SubmitButton({
availableForSale,
selectedVariantId,
}: {
availableForSale: boolean;
selectedVariantId: string | undefined;
}) {
const buttonClasses =
"relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white";
const disabledClasses = "cursor-not-allowed opacity-60 hover:opacity-60";
if (!availableForSale) {
return (
Out Of Stock
);
}
if (!selectedVariantId) {
return (
Add To Cart
);
}
return (
Add To Cart
);
}
export function AddToCart({ product }: { product: Product }) {
const { variants, availableForSale } = product;
const { addCartItem } = useCart();
const searchParams = useSearchParams();
const [message, formAction] = useActionState(addItem, null);
const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every(
(option) => option.value === searchParams.get(option.name.toLowerCase()),
),
);
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const selectedVariantId = variant?.id || defaultVariantId;
const addItemAction = formAction.bind(null, selectedVariantId);
const finalVariant = variants.find(
(variant) => variant.id === selectedVariantId,
)!;
return (
);
}
================================================
FILE: components/cart/cart-context.tsx
================================================
"use client";
import type {
Cart,
CartItem,
Product,
ProductVariant,
} from "lib/shopify/types";
import React, {
createContext,
use,
useContext,
useMemo,
useOptimistic,
} from "react";
type UpdateType = "plus" | "minus" | "delete";
type CartAction =
| {
type: "UPDATE_ITEM";
payload: { merchandiseId: string; updateType: UpdateType };
}
| {
type: "ADD_ITEM";
payload: { variant: ProductVariant; product: Product };
};
type CartContextType = {
cartPromise: Promise;
};
const CartContext = createContext(undefined);
function calculateItemCost(quantity: number, price: string): string {
return (Number(price) * quantity).toString();
}
function updateCartItem(
item: CartItem,
updateType: UpdateType,
): CartItem | null {
if (updateType === "delete") return null;
const newQuantity =
updateType === "plus" ? item.quantity + 1 : item.quantity - 1;
if (newQuantity === 0) return null;
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
const newTotalAmount = calculateItemCost(
newQuantity,
singleItemAmount.toString(),
);
return {
...item,
quantity: newQuantity,
cost: {
...item.cost,
totalAmount: {
...item.cost.totalAmount,
amount: newTotalAmount,
},
},
};
}
function createOrUpdateCartItem(
existingItem: CartItem | undefined,
variant: ProductVariant,
product: Product,
): CartItem {
const quantity = existingItem ? existingItem.quantity + 1 : 1;
const totalAmount = calculateItemCost(quantity, variant.price.amount);
return {
id: existingItem?.id,
quantity,
cost: {
totalAmount: {
amount: totalAmount,
currencyCode: variant.price.currencyCode,
},
},
merchandise: {
id: variant.id,
title: variant.title,
selectedOptions: variant.selectedOptions,
product: {
id: product.id,
handle: product.handle,
title: product.title,
featuredImage: product.featuredImage,
},
},
};
}
function updateCartTotals(
lines: CartItem[],
): Pick {
const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = lines.reduce(
(sum, item) => sum + Number(item.cost.totalAmount.amount),
0,
);
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? "USD";
return {
totalQuantity,
cost: {
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
totalAmount: { amount: totalAmount.toString(), currencyCode },
totalTaxAmount: { amount: "0", currencyCode },
},
};
}
function createEmptyCart(): Cart {
return {
id: undefined,
checkoutUrl: "",
totalQuantity: 0,
lines: [],
cost: {
subtotalAmount: { amount: "0", currencyCode: "USD" },
totalAmount: { amount: "0", currencyCode: "USD" },
totalTaxAmount: { amount: "0", currencyCode: "USD" },
},
};
}
function cartReducer(state: Cart | undefined, action: CartAction): Cart {
const currentCart = state || createEmptyCart();
switch (action.type) {
case "UPDATE_ITEM": {
const { merchandiseId, updateType } = action.payload;
const updatedLines = currentCart.lines
.map((item) =>
item.merchandise.id === merchandiseId
? updateCartItem(item, updateType)
: item,
)
.filter(Boolean) as CartItem[];
if (updatedLines.length === 0) {
return {
...currentCart,
lines: [],
totalQuantity: 0,
cost: {
...currentCart.cost,
totalAmount: { ...currentCart.cost.totalAmount, amount: "0" },
},
};
}
return {
...currentCart,
...updateCartTotals(updatedLines),
lines: updatedLines,
};
}
case "ADD_ITEM": {
const { variant, product } = action.payload;
const existingItem = currentCart.lines.find(
(item) => item.merchandise.id === variant.id,
);
const updatedItem = createOrUpdateCartItem(
existingItem,
variant,
product,
);
const updatedLines = existingItem
? currentCart.lines.map((item) =>
item.merchandise.id === variant.id ? updatedItem : item,
)
: [...currentCart.lines, updatedItem];
return {
...currentCart,
...updateCartTotals(updatedLines),
lines: updatedLines,
};
}
default:
return currentCart;
}
}
export function CartProvider({
children,
cartPromise,
}: {
children: React.ReactNode;
cartPromise: Promise;
}) {
return (
{children}
);
}
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error("useCart must be used within a CartProvider");
}
const initialCart = use(context.cartPromise);
const [optimisticCart, updateOptimisticCart] = useOptimistic(
initialCart,
cartReducer,
);
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
updateOptimisticCart({
type: "UPDATE_ITEM",
payload: { merchandiseId, updateType },
});
};
const addCartItem = (variant: ProductVariant, product: Product) => {
updateOptimisticCart({ type: "ADD_ITEM", payload: { variant, product } });
};
return useMemo(
() => ({
cart: optimisticCart,
updateCartItem,
addCartItem,
}),
[optimisticCart],
);
}
================================================
FILE: components/cart/delete-item-button.tsx
================================================
"use client";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { removeItem } from "components/cart/actions";
import type { CartItem } from "lib/shopify/types";
import { useActionState } from "react";
export function DeleteItemButton({
item,
optimisticUpdate,
}: {
item: CartItem;
optimisticUpdate: any;
}) {
const [message, formAction] = useActionState(removeItem, null);
const merchandiseId = item.merchandise.id;
const removeItemAction = formAction.bind(null, merchandiseId);
return (
);
}
================================================
FILE: components/cart/edit-item-quantity-button.tsx
================================================
"use client";
import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { updateItemQuantity } from "components/cart/actions";
import type { CartItem } from "lib/shopify/types";
import { useActionState } from "react";
function SubmitButton({ type }: { type: "plus" | "minus" }) {
return (
{type === "plus" ? (
) : (
)}
);
}
export function EditItemQuantityButton({
item,
type,
optimisticUpdate,
}: {
item: CartItem;
type: "plus" | "minus";
optimisticUpdate: any;
}) {
const [message, formAction] = useActionState(updateItemQuantity, null);
const payload = {
merchandiseId: item.merchandise.id,
quantity: type === "plus" ? item.quantity + 1 : item.quantity - 1,
};
const updateItemQuantityAction = formAction.bind(null, payload);
return (
);
}
================================================
FILE: components/cart/modal.tsx
================================================
"use client";
import clsx from "clsx";
import { Dialog, Transition } from "@headlessui/react";
import { ShoppingCartIcon, XMarkIcon } from "@heroicons/react/24/outline";
import LoadingDots from "components/loading-dots";
import Price from "components/price";
import { DEFAULT_OPTION } from "lib/constants";
import { createUrl } from "lib/utils";
import Image from "next/image";
import Link from "next/link";
import { Fragment, useEffect, useRef, useState } from "react";
import { useFormStatus } from "react-dom";
import { createCartAndSetCookie, redirectToCheckout } from "./actions";
import { useCart } from "./cart-context";
import { DeleteItemButton } from "./delete-item-button";
import { EditItemQuantityButton } from "./edit-item-quantity-button";
import OpenCart from "./open-cart";
type MerchandiseSearchParams = {
[key: string]: string;
};
export default function CartModal() {
const { cart, updateCartItem } = useCart();
const [isOpen, setIsOpen] = useState(false);
const quantityRef = useRef(cart?.totalQuantity);
const openCart = () => setIsOpen(true);
const closeCart = () => setIsOpen(false);
useEffect(() => {
if (!cart) {
createCartAndSetCookie();
}
}, [cart]);
useEffect(() => {
if (
cart?.totalQuantity &&
cart?.totalQuantity !== quantityRef.current &&
cart?.totalQuantity > 0
) {
if (!isOpen) {
setIsOpen(true);
}
quantityRef.current = cart?.totalQuantity;
}
}, [isOpen, cart?.totalQuantity, quantityRef]);
return (
<>
{!cart || cart.lines.length === 0 ? (
) : (
{cart.lines
.sort((a, b) =>
a.merchandise.product.title.localeCompare(
b.merchandise.product.title,
),
)
.map((item, i) => {
const merchandiseSearchParams =
{} as MerchandiseSearchParams;
item.merchandise.selectedOptions.forEach(
({ name, value }) => {
if (value !== DEFAULT_OPTION) {
merchandiseSearchParams[name.toLowerCase()] =
value;
}
},
);
const merchandiseUrl = createUrl(
`/product/${item.merchandise.product.handle}`,
new URLSearchParams(merchandiseSearchParams),
);
return (
{item.merchandise.product.title}
{item.merchandise.title !==
DEFAULT_OPTION ? (
{item.merchandise.title}
) : null}
);
})}
Shipping
Calculated at checkout
)}
>
);
}
function CloseCart({ className }: { className?: string }) {
return (
);
}
function CheckoutButton() {
const { pending } = useFormStatus();
return (
{pending ? : "Proceed to Checkout"}
);
}
================================================
FILE: components/cart/open-cart.tsx
================================================
import { ShoppingCartIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
export default function OpenCart({
className,
quantity,
}: {
className?: string;
quantity?: number;
}) {
return (
{quantity ? (
{quantity}
) : null}
);
}
================================================
FILE: components/grid/index.tsx
================================================
import clsx from "clsx";
function Grid(props: React.ComponentProps<"ul">) {
return (
);
}
function GridItem(props: React.ComponentProps<"li">) {
return (
{props.children}
);
}
Grid.Item = GridItem;
export default Grid;
================================================
FILE: components/grid/three-items.tsx
================================================
import { GridTileImage } from "components/grid/tile";
import { getCollectionProducts } from "lib/shopify";
import type { Product } from "lib/shopify/types";
import Link from "next/link";
function ThreeItemGridItem({
item,
size,
priority,
}: {
item: Product;
size: "full" | "half";
priority?: boolean;
}) {
return (
);
}
export async function ThreeItemGrid() {
// Collections that start with `hidden-*` are hidden from the search page.
const homepageItems = await getCollectionProducts({
collection: "hidden-homepage-featured-items",
});
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
return (
);
}
================================================
FILE: components/grid/tile.tsx
================================================
import clsx from "clsx";
import Image from "next/image";
import Label from "../label";
export function GridTileImage({
isInteractive = true,
active,
label,
...props
}: {
isInteractive?: boolean;
active?: boolean;
label?: {
title: string;
amount: string;
currencyCode: string;
position?: "bottom" | "center";
};
} & React.ComponentProps) {
return (
{props.src ? (
) : null}
{label ? (
) : null}
);
}
================================================
FILE: components/icons/logo.tsx
================================================
import clsx from "clsx";
export default function LogoIcon(props: React.ComponentProps<"svg">) {
return (
);
}
================================================
FILE: components/label.tsx
================================================
import clsx from "clsx";
import Price from "./price";
const Label = ({
title,
amount,
currencyCode,
position = "bottom",
}: {
title: string;
amount: string;
currencyCode: string;
position?: "bottom" | "center";
}) => {
return (
);
};
export default Label;
================================================
FILE: components/layout/footer-menu.tsx
================================================
"use client";
import clsx from "clsx";
import { Menu } from "lib/shopify/types";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
export function FooterMenuItem({ item }: { item: Menu }) {
const pathname = usePathname();
const [active, setActive] = useState(pathname === item.path);
useEffect(() => {
setActive(pathname === item.path);
}, [pathname, item.path]);
return (
{item.title}
);
}
export default function FooterMenu({ menu }: { menu: Menu[] }) {
if (!menu.length) return null;
return (
{menu.map((item: Menu) => {
return ;
})}
);
}
================================================
FILE: components/layout/footer.tsx
================================================
import Link from "next/link";
import FooterMenu from "components/layout/footer-menu";
import LogoSquare from "components/logo-square";
import { getMenu } from "lib/shopify";
import { Suspense } from "react";
const { COMPANY_NAME, SITE_NAME } = process.env;
export default async function Footer() {
const currentYear = new Date().getFullYear();
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : "");
const skeleton =
"w-full h-6 animate-pulse rounded-sm bg-neutral-200 dark:bg-neutral-700";
const menu = await getMenu("next-js-frontend-footer-menu");
const copyrightName = COMPANY_NAME || SITE_NAME || "";
return (
);
}
================================================
FILE: components/layout/navbar/index.tsx
================================================
import CartModal from "components/cart/modal";
import LogoSquare from "components/logo-square";
import { getMenu } from "lib/shopify";
import { Menu } from "lib/shopify/types";
import Link from "next/link";
import { Suspense } from "react";
import MobileMenu from "./mobile-menu";
import Search, { SearchSkeleton } from "./search";
const { SITE_NAME } = process.env;
export async function Navbar() {
const menu = await getMenu("next-js-frontend-header-menu");
return (
{SITE_NAME}
{menu.length ? (
{menu.map((item: Menu) => (
{item.title}
))}
) : null}
}>
);
}
================================================
FILE: components/layout/navbar/mobile-menu.tsx
================================================
"use client";
import { Dialog, Transition } from "@headlessui/react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Fragment, Suspense, useEffect, useState } from "react";
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
import { Menu } from "lib/shopify/types";
import Search, { SearchSkeleton } from "./search";
export default function MobileMenu({ menu }: { menu: Menu[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const openMobileMenu = () => setIsOpen(true);
const closeMobileMenu = () => setIsOpen(false);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth > 768) {
setIsOpen(false);
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [isOpen]);
useEffect(() => {
setIsOpen(false);
}, [pathname, searchParams]);
return (
<>
}>
{menu.length ? (
{menu.map((item: Menu) => (
{item.title}
))}
) : null}
>
);
}
================================================
FILE: components/layout/navbar/search.tsx
================================================
"use client";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import Form from "next/form";
import { useSearchParams } from "next/navigation";
export default function Search() {
const searchParams = useSearchParams();
return (
);
}
export function SearchSkeleton() {
return (
);
}
================================================
FILE: components/layout/product-grid-items.tsx
================================================
import Grid from "components/grid";
import { GridTileImage } from "components/grid/tile";
import { Product } from "lib/shopify/types";
import Link from "next/link";
export default function ProductGridItems({
products,
}: {
products: Product[];
}) {
return (
<>
{products.map((product) => (
))}
>
);
}
================================================
FILE: components/layout/search/collections.tsx
================================================
import clsx from "clsx";
import { Suspense } from "react";
import { getCollections } from "lib/shopify";
import FilterList from "./filter";
async function CollectionList() {
const collections = await getCollections();
return ;
}
const skeleton = "mb-3 h-4 w-5/6 animate-pulse rounded-sm";
const activeAndTitles = "bg-neutral-800 dark:bg-neutral-300";
const items = "bg-neutral-400 dark:bg-neutral-700";
export default function Collections() {
return (
}
>
);
}
================================================
FILE: components/layout/search/filter/dropdown.tsx
================================================
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import type { ListItem } from ".";
import { FilterItem } from "./item";
export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState("");
const [openSelect, setOpenSelect] = useState(false);
const ref = useRef(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpenSelect(false);
}
};
window.addEventListener("click", handleClickOutside);
return () => window.removeEventListener("click", handleClickOutside);
}, []);
useEffect(() => {
list.forEach((listItem: ListItem) => {
if (
("path" in listItem && pathname === listItem.path) ||
("slug" in listItem && searchParams.get("sort") === listItem.slug)
) {
setActive(listItem.title);
}
});
}, [pathname, list, searchParams]);
return (
{
setOpenSelect(!openSelect);
}}
className="flex w-full items-center justify-between rounded-sm border border-black/30 px-4 py-2 text-sm dark:border-white/30"
>
{active}
{openSelect && (
{
setOpenSelect(false);
}}
className="absolute z-40 w-full rounded-b-md bg-white p-4 shadow-md dark:bg-black"
>
{list.map((item: ListItem, i) => (
))}
)}
);
}
================================================
FILE: components/layout/search/filter/index.tsx
================================================
import { SortFilterItem } from "lib/constants";
import { Suspense } from "react";
import FilterItemDropdown from "./dropdown";
import { FilterItem } from "./item";
export type ListItem = SortFilterItem | PathFilterItem;
export type PathFilterItem = { title: string; path: string };
function FilterItemList({ list }: { list: ListItem[] }) {
return (
<>
{list.map((item: ListItem, i) => (
))}
>
);
}
export default function FilterList({
list,
title,
}: {
list: ListItem[];
title?: string;
}) {
return (
<>
{title ? (
{title}
) : null}
>
);
}
================================================
FILE: components/layout/search/filter/item.tsx
================================================
"use client";
import clsx from "clsx";
import type { SortFilterItem } from "lib/constants";
import { createUrl } from "lib/utils";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import type { ListItem, PathFilterItem } from ".";
function PathFilterItem({ item }: { item: PathFilterItem }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const active = pathname === item.path;
const newParams = new URLSearchParams(searchParams.toString());
const DynamicTag = active ? "p" : Link;
newParams.delete("q");
return (
{item.title}
);
}
function SortFilterItem({ item }: { item: SortFilterItem }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const active = searchParams.get("sort") === item.slug;
const q = searchParams.get("q");
const href = createUrl(
pathname,
new URLSearchParams({
...(q && { q }),
...(item.slug && item.slug.length && { sort: item.slug }),
}),
);
const DynamicTag = active ? "p" : Link;
return (
{item.title}
);
}
export function FilterItem({ item }: { item: ListItem }) {
return "path" in item ? (
) : (
);
}
================================================
FILE: components/loading-dots.tsx
================================================
import clsx from "clsx";
const dots = "mx-[1px] inline-block h-1 w-1 animate-blink rounded-md";
const LoadingDots = ({ className }: { className: string }) => {
return (
);
};
export default LoadingDots;
================================================
FILE: components/logo-square.tsx
================================================
import clsx from "clsx";
import LogoIcon from "./icons/logo";
export default function LogoSquare({ size }: { size?: "sm" | undefined }) {
return (
);
}
================================================
FILE: components/opengraph-image.tsx
================================================
import { ImageResponse } from "next/og";
import LogoIcon from "./icons/logo";
import { join } from "path";
import { readFile } from "fs/promises";
export type Props = {
title?: string;
};
export default async function OpengraphImage(
props?: Props,
): Promise {
const { title } = {
...{
title: process.env.SITE_NAME,
},
...props,
};
const file = await readFile(join(process.cwd(), "./fonts/Inter-Bold.ttf"));
const font = Uint8Array.from(file).buffer;
return new ImageResponse(
(
),
{
width: 1200,
height: 630,
fonts: [
{
name: "Inter",
data: font,
style: "normal",
weight: 700,
},
],
},
);
}
================================================
FILE: components/price.tsx
================================================
import clsx from "clsx";
const Price = ({
amount,
className,
currencyCode = "USD",
currencyCodeClassName,
}: {
amount: string;
className?: string;
currencyCode: string;
currencyCodeClassName?: string;
} & React.ComponentProps<"p">) => (
{`${new Intl.NumberFormat(undefined, {
style: "currency",
currency: currencyCode,
currencyDisplay: "narrowSymbol",
}).format(parseFloat(amount))}`}
{`${currencyCode}`}
);
export default Price;
================================================
FILE: components/product/gallery.tsx
================================================
"use client";
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { GridTileImage } from "components/grid/tile";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
export function Gallery({
images,
}: {
images: { src: string; altText: string }[];
}) {
const router = useRouter();
const searchParams = useSearchParams();
const imageIndex = searchParams.has("image")
? parseInt(searchParams.get("image")!)
: 0;
const updateImage = (index: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("image", index);
router.replace(`?${params.toString()}`, { scroll: false });
};
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
const previousImageIndex =
imageIndex === 0 ? images.length - 1 : imageIndex - 1;
const buttonClassName =
"h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center";
return (
);
}
================================================
FILE: components/product/product-description.tsx
================================================
import { AddToCart } from "components/cart/add-to-cart";
import Price from "components/price";
import Prose from "components/prose";
import { Product } from "lib/shopify/types";
import { VariantSelector } from "./variant-selector";
export function ProductDescription({ product }: { product: Product }) {
return (
<>
{product.descriptionHtml ? (
) : null}
>
);
}
================================================
FILE: components/product/variant-selector.tsx
================================================
"use client";
import clsx from "clsx";
import { ProductOption, ProductVariant } from "lib/shopify/types";
import { useRouter, useSearchParams } from "next/navigation";
type Combination = {
id: string;
availableForSale: boolean;
[key: string]: string | boolean;
};
export function VariantSelector({
options,
variants,
}: {
options: ProductOption[];
variants: ProductVariant[];
}) {
const router = useRouter();
const searchParams = useSearchParams();
const hasNoOptionsOrJustOneOption =
!options.length ||
(options.length === 1 && options[0]?.values.length === 1);
if (hasNoOptionsOrJustOneOption) {
return null;
}
const combinations: Combination[] = variants.map((variant) => ({
id: variant.id,
availableForSale: variant.availableForSale,
...variant.selectedOptions.reduce(
(accumulator, option) => ({
...accumulator,
[option.name.toLowerCase()]: option.value,
}),
{},
),
}));
const updateOption = (name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(name, value);
router.replace(`?${params.toString()}`, { scroll: false });
};
return options.map((option) => (
));
}
================================================
FILE: components/prose.tsx
================================================
import clsx from "clsx";
const Prose = ({ html, className }: { html: string; className?: string }) => {
return (
);
};
export default Prose;
================================================
FILE: components/welcome-toast.tsx
================================================
"use client";
import { useEffect } from "react";
import { toast } from "sonner";
export function WelcomeToast() {
useEffect(() => {
// ignore if screen height is too small
if (window.innerHeight < 650) return;
if (!document.cookie.includes("welcome-toast=2")) {
toast("🛍️ Welcome to Next.js Commerce!", {
id: "welcome-toast",
duration: Infinity,
onDismiss: () => {
document.cookie = "welcome-toast=2; max-age=31536000; path=/";
},
description: (
<>
This is a high-performance, SSR storefront powered by Shopify,
Next.js, and Vercel.{" "}
Deploy your own
.
>
),
});
}
}, []);
return null;
}
================================================
FILE: lib/constants.ts
================================================
export type SortFilterItem = {
title: string;
slug: string | null;
sortKey: "RELEVANCE" | "BEST_SELLING" | "CREATED_AT" | "PRICE";
reverse: boolean;
};
export const defaultSort: SortFilterItem = {
title: "Relevance",
slug: null,
sortKey: "RELEVANCE",
reverse: false,
};
export const sorting: SortFilterItem[] = [
defaultSort,
{
title: "Trending",
slug: "trending-desc",
sortKey: "BEST_SELLING",
reverse: false,
}, // asc
{
title: "Latest arrivals",
slug: "latest-desc",
sortKey: "CREATED_AT",
reverse: true,
},
{
title: "Price: Low to high",
slug: "price-asc",
sortKey: "PRICE",
reverse: false,
}, // asc
{
title: "Price: High to low",
slug: "price-desc",
sortKey: "PRICE",
reverse: true,
},
];
export const TAGS = {
collections: "collections",
products: "products",
cart: "cart",
};
export const HIDDEN_PRODUCT_TAG = "nextjs-frontend-hidden";
export const DEFAULT_OPTION = "Default Title";
export const SHOPIFY_GRAPHQL_API_ENDPOINT = "/api/2023-01/graphql.json";
================================================
FILE: lib/shopify/fragments/cart.ts
================================================
import productFragment from "./product";
const cartFragment = /* GraphQL */ `
fragment cart on Cart {
id
checkoutUrl
cost {
subtotalAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
currencyCode
}
}
merchandise {
... on ProductVariant {
id
title
selectedOptions {
name
value
}
product {
...product
}
}
}
}
}
}
totalQuantity
}
${productFragment}
`;
export default cartFragment;
================================================
FILE: lib/shopify/fragments/image.ts
================================================
const imageFragment = /* GraphQL */ `
fragment image on Image {
url
altText
width
height
}
`;
export default imageFragment;
================================================
FILE: lib/shopify/fragments/product.ts
================================================
import imageFragment from "./image";
import seoFragment from "./seo";
const productFragment = /* GraphQL */ `
fragment product on Product {
id
handle
availableForSale
title
description
descriptionHtml
options {
id
name
values
}
priceRange {
maxVariantPrice {
amount
currencyCode
}
minVariantPrice {
amount
currencyCode
}
}
variants(first: 250) {
edges {
node {
id
title
availableForSale
selectedOptions {
name
value
}
price {
amount
currencyCode
}
}
}
}
featuredImage {
...image
}
images(first: 20) {
edges {
node {
...image
}
}
}
seo {
...seo
}
tags
updatedAt
}
${imageFragment}
${seoFragment}
`;
export default productFragment;
================================================
FILE: lib/shopify/fragments/seo.ts
================================================
const seoFragment = /* GraphQL */ `
fragment seo on SEO {
description
title
}
`;
export default seoFragment;
================================================
FILE: lib/shopify/index.ts
================================================
import {
HIDDEN_PRODUCT_TAG,
SHOPIFY_GRAPHQL_API_ENDPOINT,
TAGS,
} from "lib/constants";
import { isShopifyError } from "lib/type-guards";
import { ensureStartsWith } from "lib/utils";
import {
unstable_cacheLife as cacheLife,
unstable_cacheTag as cacheTag,
revalidateTag,
} from "next/cache";
import { cookies, headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import {
addToCartMutation,
createCartMutation,
editCartItemsMutation,
removeFromCartMutation,
} from "./mutations/cart";
import { getCartQuery } from "./queries/cart";
import {
getCollectionProductsQuery,
getCollectionQuery,
getCollectionsQuery,
} from "./queries/collection";
import { getMenuQuery } from "./queries/menu";
import { getPageQuery, getPagesQuery } from "./queries/page";
import {
getProductQuery,
getProductRecommendationsQuery,
getProductsQuery,
} from "./queries/product";
import {
Cart,
Collection,
Connection,
Image,
Menu,
Page,
Product,
ShopifyAddToCartOperation,
ShopifyCart,
ShopifyCartOperation,
ShopifyCollection,
ShopifyCollectionOperation,
ShopifyCollectionProductsOperation,
ShopifyCollectionsOperation,
ShopifyCreateCartOperation,
ShopifyMenuOperation,
ShopifyPageOperation,
ShopifyPagesOperation,
ShopifyProduct,
ShopifyProductOperation,
ShopifyProductRecommendationsOperation,
ShopifyProductsOperation,
ShopifyRemoveFromCartOperation,
ShopifyUpdateCartOperation,
} from "./types";
const domain = process.env.SHOPIFY_STORE_DOMAIN
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, "https://")
: "";
const endpoint = domain ? `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}` : "";
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables = T extends { variables: object }
? T["variables"]
: never;
export async function shopifyFetch({
headers,
query,
variables,
}: {
headers?: HeadersInit;
query: string;
variables?: ExtractVariables;
}): Promise<{ status: number; body: T } | never> {
try {
if (!endpoint) {
throw new Error("SHOPIFY_STORE_DOMAIN environment variable is not set");
}
const result = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Storefront-Access-Token": key,
...headers,
},
body: JSON.stringify({
...(query && { query }),
...(variables && { variables }),
}),
});
const body = await result.json();
if (body.errors) {
throw body.errors[0];
}
return {
status: result.status,
body,
};
} catch (e) {
if (isShopifyError(e)) {
throw {
cause: e.cause?.toString() || "unknown",
status: e.status || 500,
message: e.message,
query,
};
}
throw {
error: e,
query,
};
}
}
const removeEdgesAndNodes = (array: Connection): T[] => {
return array.edges.map((edge) => edge?.node);
};
const reshapeCart = (cart: ShopifyCart): Cart => {
if (!cart.cost?.totalTaxAmount) {
cart.cost.totalTaxAmount = {
amount: "0.0",
currencyCode: cart.cost.totalAmount.currencyCode,
};
}
return {
...cart,
lines: removeEdgesAndNodes(cart.lines),
};
};
const reshapeCollection = (
collection: ShopifyCollection
): Collection | undefined => {
if (!collection) {
return undefined;
}
return {
...collection,
path: `/search/${collection.handle}`,
};
};
const reshapeCollections = (collections: ShopifyCollection[]) => {
const reshapedCollections = [];
for (const collection of collections) {
if (collection) {
const reshapedCollection = reshapeCollection(collection);
if (reshapedCollection) {
reshapedCollections.push(reshapedCollection);
}
}
}
return reshapedCollections;
};
const reshapeImages = (images: Connection, productTitle: string) => {
const flattened = removeEdgesAndNodes(images);
return flattened.map((image) => {
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
return {
...image,
altText: image.altText || `${productTitle} - ${filename}`,
};
});
};
const reshapeProduct = (
product: ShopifyProduct,
filterHiddenProducts: boolean = true
) => {
if (
!product ||
(filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))
) {
return undefined;
}
const { images, variants, ...rest } = product;
return {
...rest,
images: reshapeImages(images, product.title),
variants: removeEdgesAndNodes(variants),
};
};
const reshapeProducts = (products: ShopifyProduct[]) => {
const reshapedProducts = [];
for (const product of products) {
if (product) {
const reshapedProduct = reshapeProduct(product);
if (reshapedProduct) {
reshapedProducts.push(reshapedProduct);
}
}
}
return reshapedProducts;
};
export async function createCart(): Promise {
const res = await shopifyFetch({
query: createCartMutation,
});
return reshapeCart(res.body.data.cartCreate.cart);
}
export async function addToCart(
lines: { merchandiseId: string; quantity: number }[]
): Promise {
const cartId = (await cookies()).get("cartId")?.value!;
const res = await shopifyFetch({
query: addToCartMutation,
variables: {
cartId,
lines,
},
});
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(lineIds: string[]): Promise {
const cartId = (await cookies()).get("cartId")?.value!;
const res = await shopifyFetch({
query: removeFromCartMutation,
variables: {
cartId,
lineIds,
},
});
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
export async function updateCart(
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise {
const cartId = (await cookies()).get("cartId")?.value!;
const res = await shopifyFetch({
query: editCartItemsMutation,
variables: {
cartId,
lines,
},
});
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(): Promise {
"use cache: private";
cacheTag(TAGS.cart);
cacheLife("seconds");
const cartId = (await cookies()).get("cartId")?.value;
if (!cartId) {
return undefined;
}
const res = await shopifyFetch({
query: getCartQuery,
variables: { cartId },
});
// Old carts becomes `null` when you checkout.
if (!res.body.data.cart) {
return undefined;
}
return reshapeCart(res.body.data.cart);
}
export async function getCollection(
handle: string
): Promise {
"use cache";
cacheTag(TAGS.collections);
cacheLife("days");
const res = await shopifyFetch({
query: getCollectionQuery,
variables: {
handle,
},
});
return reshapeCollection(res.body.data.collection);
}
export async function getCollectionProducts({
collection,
reverse,
sortKey,
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise {
"use cache";
cacheTag(TAGS.collections, TAGS.products);
cacheLife("days");
if (!endpoint) {
console.log(
`Skipping getCollectionProducts for '${collection}' - Shopify not configured`
);
return [];
}
const res = await shopifyFetch({
query: getCollectionProductsQuery,
variables: {
handle: collection,
reverse,
sortKey: sortKey === "CREATED_AT" ? "CREATED" : sortKey,
},
});
if (!res.body.data.collection) {
console.log(`No collection found for \`${collection}\``);
return [];
}
return reshapeProducts(
removeEdgesAndNodes(res.body.data.collection.products)
);
}
export async function getCollections(): Promise {
"use cache";
cacheTag(TAGS.collections);
cacheLife("days");
if (!endpoint) {
console.log("Skipping getCollections - Shopify not configured");
return [
{
handle: "",
title: "All",
description: "All products",
seo: {
title: "All",
description: "All products",
},
path: "/search",
updatedAt: new Date().toISOString(),
},
];
}
const res = await shopifyFetch({
query: getCollectionsQuery,
});
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [
{
handle: "",
title: "All",
description: "All products",
seo: {
title: "All",
description: "All products",
},
path: "/search",
updatedAt: new Date().toISOString(),
},
// Filter out the `hidden` collections.
// Collections that start with `hidden-*` need to be hidden on the search page.
...reshapeCollections(shopifyCollections).filter(
(collection) => !collection.handle.startsWith("hidden")
),
];
return collections;
}
export async function getMenu(handle: string): Promise {
"use cache";
cacheTag(TAGS.collections);
cacheLife("days");
if (!endpoint) {
console.log(`Skipping getMenu for '${handle}' - Shopify not configured`);
return [];
}
const res = await shopifyFetch({
query: getMenuQuery,
variables: {
handle,
},
});
return (
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
title: item.title,
path: item.url
.replace(domain, "")
.replace("/collections", "/search")
.replace("/pages", ""),
})) || []
);
}
export async function getPage(handle: string): Promise {
const res = await shopifyFetch({
query: getPageQuery,
variables: { handle },
});
return res.body.data.pageByHandle;
}
export async function getPages(): Promise {
const res = await shopifyFetch({
query: getPagesQuery,
});
return removeEdgesAndNodes(res.body.data.pages);
}
export async function getProduct(handle: string): Promise {
"use cache";
cacheTag(TAGS.products);
cacheLife("days");
if (!endpoint) {
console.log(`Skipping getProduct for '${handle}' - Shopify not configured`);
return undefined;
}
const res = await shopifyFetch({
query: getProductQuery,
variables: {
handle,
},
});
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(
productId: string
): Promise {
"use cache";
cacheTag(TAGS.products);
cacheLife("days");
const res = await shopifyFetch({
query: getProductRecommendationsQuery,
variables: {
productId,
},
});
return reshapeProducts(res.body.data.productRecommendations);
}
export async function getProducts({
query,
reverse,
sortKey,
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise {
"use cache";
cacheTag(TAGS.products);
cacheLife("days");
const res = await shopifyFetch({
query: getProductsQuery,
variables: {
query,
reverse,
sortKey,
},
});
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
}
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
export async function revalidate(req: NextRequest): Promise {
// We always need to respond with a 200 status code to Shopify,
// otherwise it will continue to retry the request.
const collectionWebhooks = [
"collections/create",
"collections/delete",
"collections/update",
];
const productWebhooks = [
"products/create",
"products/delete",
"products/update",
];
const topic = (await headers()).get("x-shopify-topic") || "unknown";
const secret = req.nextUrl.searchParams.get("secret");
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error("Invalid revalidation secret.");
return NextResponse.json({ status: 401 });
}
if (!isCollectionUpdate && !isProductUpdate) {
// We don't need to revalidate anything for any other topics.
return NextResponse.json({ status: 200 });
}
if (isCollectionUpdate) {
revalidateTag(TAGS.collections, "seconds");
}
if (isProductUpdate) {
revalidateTag(TAGS.products, "seconds");
}
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
}
================================================
FILE: lib/shopify/mutations/cart.ts
================================================
import cartFragment from "../fragments/cart";
export const addToCartMutation = /* GraphQL */ `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const createCartMutation = /* GraphQL */ `
mutation createCart($lineItems: [CartLineInput!]) {
cartCreate(input: { lines: $lineItems }) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const editCartItemsMutation = /* GraphQL */ `
mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const removeFromCartMutation = /* GraphQL */ `
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...cart
}
}
}
${cartFragment}
`;
================================================
FILE: lib/shopify/queries/cart.ts
================================================
import cartFragment from "../fragments/cart";
export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) {
cart(id: $cartId) {
...cart
}
}
${cartFragment}
`;
================================================
FILE: lib/shopify/queries/collection.ts
================================================
import productFragment from "../fragments/product";
import seoFragment from "../fragments/seo";
const collectionFragment = /* GraphQL */ `
fragment collection on Collection {
handle
title
description
seo {
...seo
}
updatedAt
}
${seoFragment}
`;
export const getCollectionQuery = /* GraphQL */ `
query getCollection($handle: String!) {
collection(handle: $handle) {
...collection
}
}
${collectionFragment}
`;
export const getCollectionsQuery = /* GraphQL */ `
query getCollections {
collections(first: 100, sortKey: TITLE) {
edges {
node {
...collection
}
}
}
}
${collectionFragment}
`;
export const getCollectionProductsQuery = /* GraphQL */ `
query getCollectionProducts(
$handle: String!
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
) {
collection(handle: $handle) {
products(sortKey: $sortKey, reverse: $reverse, first: 100) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;
================================================
FILE: lib/shopify/queries/menu.ts
================================================
export const getMenuQuery = /* GraphQL */ `
query getMenu($handle: String!) {
menu(handle: $handle) {
items {
title
url
}
}
}
`;
================================================
FILE: lib/shopify/queries/page.ts
================================================
import seoFragment from "../fragments/seo";
const pageFragment = /* GraphQL */ `
fragment page on Page {
... on Page {
id
title
handle
body
bodySummary
seo {
...seo
}
createdAt
updatedAt
}
}
${seoFragment}
`;
export const getPageQuery = /* GraphQL */ `
query getPage($handle: String!) {
pageByHandle(handle: $handle) {
...page
}
}
${pageFragment}
`;
export const getPagesQuery = /* GraphQL */ `
query getPages {
pages(first: 100) {
edges {
node {
...page
}
}
}
}
${pageFragment}
`;
================================================
FILE: lib/shopify/queries/product.ts
================================================
import productFragment from "../fragments/product";
export const getProductQuery = /* GraphQL */ `
query getProduct($handle: String!) {
product(handle: $handle) {
...product
}
}
${productFragment}
`;
export const getProductsQuery = /* GraphQL */ `
query getProducts(
$sortKey: ProductSortKeys
$reverse: Boolean
$query: String
) {
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
edges {
node {
...product
}
}
}
}
${productFragment}
`;
export const getProductRecommendationsQuery = /* GraphQL */ `
query getProductRecommendations($productId: ID!) {
productRecommendations(productId: $productId) {
...product
}
}
${productFragment}
`;
================================================
FILE: lib/shopify/types.ts
================================================
export type Maybe = T | null;
export type Connection = {
edges: Array>;
};
export type Edge = {
node: T;
};
export type Cart = Omit & {
lines: CartItem[];
};
export type CartProduct = {
id: string;
handle: string;
title: string;
featuredImage: Image;
};
export type CartItem = {
id: string | undefined;
quantity: number;
cost: {
totalAmount: Money;
};
merchandise: {
id: string;
title: string;
selectedOptions: {
name: string;
value: string;
}[];
product: CartProduct;
};
};
export type Collection = ShopifyCollection & {
path: string;
};
export type Image = {
url: string;
altText: string;
width: number;
height: number;
};
export type Menu = {
title: string;
path: string;
};
export type Money = {
amount: string;
currencyCode: string;
};
export type Page = {
id: string;
title: string;
handle: string;
body: string;
bodySummary: string;
seo?: SEO;
createdAt: string;
updatedAt: string;
};
export type Product = Omit & {
variants: ProductVariant[];
images: Image[];
};
export type ProductOption = {
id: string;
name: string;
values: string[];
};
export type ProductVariant = {
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: Money;
};
export type SEO = {
title: string;
description: string;
};
export type ShopifyCart = {
id: string | undefined;
checkoutUrl: string;
cost: {
subtotalAmount: Money;
totalAmount: Money;
totalTaxAmount: Money;
};
lines: Connection;
totalQuantity: number;
};
export type ShopifyCollection = {
handle: string;
title: string;
description: string;
seo: SEO;
updatedAt: string;
};
export type ShopifyProduct = {
id: string;
handle: string;
availableForSale: boolean;
title: string;
description: string;
descriptionHtml: string;
options: ProductOption[];
priceRange: {
maxVariantPrice: Money;
minVariantPrice: Money;
};
variants: Connection;
featuredImage: Image;
images: Connection;
seo: SEO;
tags: string[];
updatedAt: string;
};
export type ShopifyCartOperation = {
data: {
cart: ShopifyCart;
};
variables: {
cartId: string;
};
};
export type ShopifyCreateCartOperation = {
data: { cartCreate: { cart: ShopifyCart } };
};
export type ShopifyAddToCartOperation = {
data: {
cartLinesAdd: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyRemoveFromCartOperation = {
data: {
cartLinesRemove: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lineIds: string[];
};
};
export type ShopifyUpdateCartOperation = {
data: {
cartLinesUpdate: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
id: string;
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyCollectionOperation = {
data: {
collection: ShopifyCollection;
};
variables: {
handle: string;
};
};
export type ShopifyCollectionProductsOperation = {
data: {
collection: {
products: Connection;
};
};
variables: {
handle: string;
reverse?: boolean;
sortKey?: string;
};
};
export type ShopifyCollectionsOperation = {
data: {
collections: Connection;
};
};
export type ShopifyMenuOperation = {
data: {
menu?: {
items: {
title: string;
url: string;
}[];
};
};
variables: {
handle: string;
};
};
export type ShopifyPageOperation = {
data: { pageByHandle: Page };
variables: { handle: string };
};
export type ShopifyPagesOperation = {
data: {
pages: Connection;
};
};
export type ShopifyProductOperation = {
data: { product: ShopifyProduct };
variables: {
handle: string;
};
};
export type ShopifyProductRecommendationsOperation = {
data: {
productRecommendations: ShopifyProduct[];
};
variables: {
productId: string;
};
};
export type ShopifyProductsOperation = {
data: {
products: Connection;
};
variables: {
query?: string;
reverse?: boolean;
sortKey?: string;
};
};
================================================
FILE: lib/type-guards.ts
================================================
export interface ShopifyErrorLike {
status: number;
message: Error;
cause?: Error;
}
export const isObject = (
object: unknown,
): object is Record => {
return (
typeof object === "object" && object !== null && !Array.isArray(object)
);
};
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
if (!isObject(error)) return false;
if (error instanceof Error) return true;
return findError(error);
};
function findError(error: T): boolean {
if (Object.prototype.toString.call(error) === "[object Error]") {
return true;
}
const prototype = Object.getPrototypeOf(error) as T | null;
return prototype === null ? false : findError(prototype);
}
================================================
FILE: lib/utils.ts
================================================
import { ReadonlyURLSearchParams } from "next/navigation";
export const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: "http://localhost:3000";
export const createUrl = (
pathname: string,
params: URLSearchParams | ReadonlyURLSearchParams,
) => {
const paramsString = params.toString();
const queryString = `${paramsString.length ? "?" : ""}${paramsString}`;
return `${pathname}${queryString}`;
};
export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
stringToCheck.startsWith(startsWith)
? stringToCheck
: `${startsWith}${stringToCheck}`;
export const validateEnvironmentVariables = () => {
const requiredEnvironmentVariables = [
"SHOPIFY_STORE_DOMAIN",
"SHOPIFY_STOREFRONT_ACCESS_TOKEN",
];
const missingEnvironmentVariables = [] as string[];
requiredEnvironmentVariables.forEach((envVar) => {
if (!process.env[envVar]) {
missingEnvironmentVariables.push(envVar);
}
});
if (missingEnvironmentVariables.length) {
throw new Error(
`The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
"\n",
)}\n`,
);
}
if (
process.env.SHOPIFY_STORE_DOMAIN?.includes("[") ||
process.env.SHOPIFY_STORE_DOMAIN?.includes("]")
) {
throw new Error(
"Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.",
);
}
};
================================================
FILE: license.md
================================================
The MIT License (MIT)
Copyright (c) 2025 Vercel, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: next.config.ts
================================================
export default {
experimental: {
ppr: true,
inlineCss: true,
useCache: true,
},
images: {
formats: ["image/avif", "image/webp"],
remotePatterns: [
{
protocol: "https",
hostname: "cdn.shopify.com",
pathname: "/s/files/**",
},
],
},
};
================================================
FILE: package.json
================================================
{
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"prettier": "prettier --write --ignore-unknown .",
"prettier:check": "prettier --check --ignore-unknown .",
"test": "pnpm prettier:check"
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@heroicons/react": "^2.2.0",
"clsx": "^2.1.1",
"geist": "^1.3.1",
"next": "15.6.0-canary.60",
"react": "19.0.0",
"react-dom": "19.0.0",
"sonner": "^2.0.1"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "^4.0.14",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "22.13.10",
"@types/react": "19.0.12",
"@types/react-dom": "19.0.4",
"postcss": "^8.5.3",
"prettier": "3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.14",
"typescript": "5.8.2"
}
}
================================================
FILE: postcss.config.mjs
================================================
/** @type {import('postcss-load-config').Config} */
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es2015",
"lib": ["dom", "dom.iterable", "esnext"],
"downlevelIteration": true,
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"noUncheckedIndexedAccess": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}