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.
<h3 id="v1-note"></h3>
> 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/).
<details>
<summary>Expand if you work at Vercel and want to run locally and / or contribute</summary>
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.
</details>
## 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 (
<>
<div className="w-full">
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">{children}</div>
</div>
<Footer />
</>
);
}
================================================
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<Metadata> {
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 (
<>
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
<Prose className="mb-8" html={page.body} />
<p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(
undefined,
{
year: "numeric",
month: "long",
day: "numeric",
},
).format(new Date(page.updatedAt))}.`}
</p>
</>
);
}
================================================
FILE: app/api/revalidate/route.ts
================================================
import { revalidate } from "lib/shopify";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest): Promise<NextResponse> {
return revalidate(req);
}
================================================
FILE: app/error.tsx
================================================
"use client";
export default function Error({ reset }: { reset: () => void }) {
return (
<div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 dark:border-neutral-800 dark:bg-black">
<h2 className="text-xl font-bold">Oh no!</h2>
<p className="my-2">
There was an issue with our storefront. This could be a temporary issue,
please try your action again.
</p>
<button
className="mx-auto mt-4 flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90"
onClick={() => reset()}
>
Try Again
</button>
</div>
);
}
================================================
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 (
<html lang="en" className={GeistSans.variable}>
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<CartProvider cartPromise={cart}>
<Navbar />
<main>
{children}
<Toaster closeButton />
<WelcomeToast />
</main>
</CartProvider>
</body>
</html>
);
}
================================================
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 (
<>
<ThreeItemGrid />
<Carousel />
<Footer />
</>
);
}
================================================
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<Metadata> {
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 (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(productJsonLd),
}}
/>
<div className="mx-auto max-w-(--breakpoint-2xl) px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black">
<div className="h-full w-full basis-full lg:basis-4/6">
<Suspense
fallback={
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
}
>
<Gallery
images={product.images.slice(0, 5).map((image: Image) => ({
src: image.url,
altText: image.altText,
}))}
/>
</Suspense>
</div>
<div className="basis-full lg:basis-2/6">
<Suspense fallback={null}>
<ProductDescription product={product} />
</Suspense>
</div>
</div>
<RelatedProducts id={product.id} />
</div>
<Footer />
</>
);
}
async function RelatedProducts({ id }: { id: string }) {
const relatedProducts = await getProductRecommendations(id);
if (!relatedProducts.length) return null;
return (
<div className="py-8">
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
{relatedProducts.map((product) => (
<li
key={product.handle}
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
>
<Link
className="relative h-full w-full"
href={`/product/${product.handle}`}
prefetch={true}
>
<GridTileImage
alt={product.title}
label={{
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode,
}}
src={product.featuredImage?.url}
fill
sizes="(min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, (min-width: 475px) 50vw, 100vw"
/>
</Link>
</li>
))}
</ul>
</div>
);
}
================================================
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<Metadata> {
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 (
<section>
{products.length === 0 ? (
<p className="py-3 text-lg">{`No products found in this collection`}</p>
) : (
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
)}
</section>
);
}
================================================
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 <Fragment key={searchParams.get("q")}>{children}</Fragment>;
}
================================================
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 (
<>
<div className="mx-auto flex max-w-(--breakpoint-2xl) flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white">
<div className="order-first w-full flex-none md:max-w-[125px]">
<Collections />
</div>
<div className="order-last min-h-screen w-full md:order-none">
<Suspense fallback={null}>
<ChildrenWrapper>{children}</ChildrenWrapper>
</Suspense>
</div>
<div className="order-none flex-none md:order-last md:w-[125px]">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
<Footer />
</>
);
}
================================================
FILE: app/search/loading.tsx
================================================
import Grid from "components/grid";
export default function Loading() {
return (
<>
<div className="mb-4 h-6" />
<Grid className="grid-cols-2 lg:grid-cols-3">
{Array(12)
.fill(0)
.map((_, index) => {
return (
<Grid.Item
key={index}
className="animate-pulse bg-neutral-100 dark:bg-neutral-800"
/>
);
})}
</Grid>
</>
);
}
================================================
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 ? (
<p className="mb-4">
{products.length === 0
? "There are no products that match "
: `Showing ${products.length} ${resultsText} for `}
<span className="font-bold">"{searchValue}"</span>
</p>
) : null}
{products.length > 0 ? (
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
) : 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<MetadataRoute.Sitemap> {
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 (
<div className="w-full overflow-x-auto pb-6 pt-1">
<ul className="flex animate-carousel gap-4">
{carouselProducts.map((product, i) => (
<li
key={`${product.handle}${i}`}
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
>
<Link
href={`/product/${product.handle}`}
className="relative h-full w-full"
>
<GridTileImage
alt={product.title}
label={{
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode,
}}
src={product.featuredImage?.url}
fill
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
/>
</Link>
</li>
))}
</ul>
</div>
);
}
================================================
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 (
<button disabled className={clsx(buttonClasses, disabledClasses)}>
Out Of Stock
</button>
);
}
if (!selectedVariantId) {
return (
<button
aria-label="Please select an option"
disabled
className={clsx(buttonClasses, disabledClasses)}
>
<div className="absolute left-0 ml-4">
<PlusIcon className="h-5" />
</div>
Add To Cart
</button>
);
}
return (
<button
aria-label="Add to cart"
className={clsx(buttonClasses, {
"hover:opacity-90": true,
})}
>
<div className="absolute left-0 ml-4">
<PlusIcon className="h-5" />
</div>
Add To Cart
</button>
);
}
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 (
<form
action={async () => {
addCartItem(finalVariant, product);
addItemAction();
}}
>
<SubmitButton
availableForSale={availableForSale}
selectedVariantId={selectedVariantId}
/>
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}
================================================
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<Cart | undefined>;
};
const CartContext = createContext<CartContextType | undefined>(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<Cart, "totalQuantity" | "cost"> {
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<Cart | undefined>;
}) {
return (
<CartContext.Provider value={{ cartPromise }}>
{children}
</CartContext.Provider>
);
}
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 (
<form
action={async () => {
optimisticUpdate(merchandiseId, "delete");
removeItemAction();
}}
>
<button
type="submit"
aria-label="Remove cart item"
className="flex h-[24px] w-[24px] items-center justify-center rounded-full bg-neutral-500"
>
<XMarkIcon className="mx-[1px] h-4 w-4 text-white dark:text-black" />
</button>
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}
================================================
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 (
<button
type="submit"
aria-label={
type === "plus" ? "Increase item quantity" : "Reduce item quantity"
}
className={clsx(
"ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full p-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80",
{
"ml-auto": type === "minus",
},
)}
>
{type === "plus" ? (
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
) : (
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
)}
</button>
);
}
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 (
<form
action={async () => {
optimisticUpdate(payload.merchandiseId, type);
updateItemQuantityAction();
}}
>
<SubmitButton type={type} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}
================================================
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 (
<>
<button aria-label="Open cart" onClick={openCart}>
<OpenCart quantity={cart?.totalQuantity} />
</button>
<Transition show={isOpen}>
<Dialog onClose={closeCart} className="relative z-50">
<Transition.Child
as={Fragment}
enter="transition-all ease-in-out duration-300"
enterFrom="opacity-0 backdrop-blur-none"
enterTo="opacity-100 backdrop-blur-[.5px]"
leave="transition-all ease-in-out duration-200"
leaveFrom="opacity-100 backdrop-blur-[.5px]"
leaveTo="opacity-0 backdrop-blur-none"
>
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="transition-all ease-in-out duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-all ease-in-out duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl md:w-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white">
<div className="flex items-center justify-between">
<p className="text-lg font-semibold">My Cart</p>
<button aria-label="Close cart" onClick={closeCart}>
<CloseCart />
</button>
</div>
{!cart || cart.lines.length === 0 ? (
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
<ShoppingCartIcon className="h-16" />
<p className="mt-6 text-center text-2xl font-bold">
Your cart is empty.
</p>
</div>
) : (
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
<ul className="grow overflow-auto py-4">
{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 (
<li
key={i}
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
>
<div className="relative flex w-full flex-row justify-between px-1 py-4">
<div className="absolute z-40 -ml-1 -mt-2">
<DeleteItemButton
item={item}
optimisticUpdate={updateCartItem}
/>
</div>
<div className="flex flex-row">
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<Image
className="h-full w-full object-cover"
width={64}
height={64}
alt={
item.merchandise.product.featuredImage
.altText ||
item.merchandise.product.title
}
src={
item.merchandise.product.featuredImage.url
}
/>
</div>
<Link
href={merchandiseUrl}
onClick={closeCart}
className="z-30 ml-2 flex flex-row space-x-4"
>
<div className="flex flex-1 flex-col text-base">
<span className="leading-tight">
{item.merchandise.product.title}
</span>
{item.merchandise.title !==
DEFAULT_OPTION ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{item.merchandise.title}
</p>
) : null}
</div>
</Link>
</div>
<div className="flex h-16 flex-col justify-between">
<Price
className="flex justify-end space-y-2 text-right text-sm"
amount={item.cost.totalAmount.amount}
currencyCode={
item.cost.totalAmount.currencyCode
}
/>
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
<EditItemQuantityButton
item={item}
type="minus"
optimisticUpdate={updateCartItem}
/>
<p className="w-6 text-center">
<span className="w-full text-sm">
{item.quantity}
</span>
</p>
<EditItemQuantityButton
item={item}
type="plus"
optimisticUpdate={updateCartItem}
/>
</div>
</div>
</div>
</li>
);
})}
</ul>
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
<p>Taxes</p>
<Price
className="text-right text-base text-black dark:text-white"
amount={cart.cost.totalTaxAmount.amount}
currencyCode={cart.cost.totalTaxAmount.currencyCode}
/>
</div>
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
<p>Shipping</p>
<p className="text-right">Calculated at checkout</p>
</div>
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
<p>Total</p>
<Price
className="text-right text-base text-black dark:text-white"
amount={cart.cost.totalAmount.amount}
currencyCode={cart.cost.totalAmount.currencyCode}
/>
</div>
</div>
<form action={redirectToCheckout}>
<CheckoutButton />
</form>
</div>
)}
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition>
</>
);
}
function CloseCart({ className }: { className?: string }) {
return (
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
<XMarkIcon
className={clsx(
"h-6 transition-all ease-in-out hover:scale-110",
className,
)}
/>
</div>
);
}
function CheckoutButton() {
const { pending } = useFormStatus();
return (
<button
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
type="submit"
disabled={pending}
>
{pending ? <LoadingDots className="bg-white" /> : "Proceed to Checkout"}
</button>
);
}
================================================
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 (
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
<ShoppingCartIcon
className={clsx(
"h-4 transition-all ease-in-out hover:scale-110",
className,
)}
/>
{quantity ? (
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded-sm bg-blue-600 text-[11px] font-medium text-white">
{quantity}
</div>
) : null}
</div>
);
}
================================================
FILE: components/grid/index.tsx
================================================
import clsx from "clsx";
function Grid(props: React.ComponentProps<"ul">) {
return (
<ul
{...props}
className={clsx("grid grid-flow-row gap-4", props.className)}
>
{props.children}
</ul>
);
}
function GridItem(props: React.ComponentProps<"li">) {
return (
<li
{...props}
className={clsx("aspect-square transition-opacity", props.className)}
>
{props.children}
</li>
);
}
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 (
<div
className={
size === "full"
? "md:col-span-4 md:row-span-2"
: "md:col-span-2 md:row-span-1"
}
>
<Link
className="relative block aspect-square h-full w-full"
href={`/product/${item.handle}`}
prefetch={true}
>
<GridTileImage
src={item.featuredImage.url}
fill
sizes={
size === "full"
? "(min-width: 768px) 66vw, 100vw"
: "(min-width: 768px) 33vw, 100vw"
}
priority={priority}
alt={item.title}
label={{
position: size === "full" ? "center" : "bottom",
title: item.title as string,
amount: item.priceRange.maxVariantPrice.amount,
currencyCode: item.priceRange.maxVariantPrice.currencyCode,
}}
/>
</Link>
</div>
);
}
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 (
<section className="mx-auto grid max-w-(--breakpoint-2xl) gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]">
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
<ThreeItemGridItem size="half" item={thirdProduct} />
</section>
);
}
================================================
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<typeof Image>) {
return (
<div
className={clsx(
"group flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black",
{
relative: label,
"border-2 border-blue-600": active,
"border-neutral-200 dark:border-neutral-800": !active,
},
)}
>
{props.src ? (
<Image
className={clsx("relative h-full w-full object-contain", {
"transition duration-300 ease-in-out group-hover:scale-105":
isInteractive,
})}
{...props}
/>
) : null}
{label ? (
<Label
title={label.title}
amount={label.amount}
currencyCode={label.currencyCode}
position={label.position}
/>
) : null}
</div>
);
}
================================================
FILE: components/icons/logo.tsx
================================================
import clsx from "clsx";
export default function LogoIcon(props: React.ComponentProps<"svg">) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-label={`${process.env.SITE_NAME} logo`}
viewBox="0 0 32 28"
{...props}
className={clsx("h-4 w-4 fill-black dark:fill-white", props.className)}
>
<path d="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" />
<path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" />
</svg>
);
}
================================================
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 (
<div
className={clsx(
"absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label",
{
"lg:px-20 lg:pb-[35%]": position === "center",
},
)}
>
<div className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white">
<h3 className="mr-4 line-clamp-2 grow pl-2 leading-none tracking-tight">
{title}
</h3>
<Price
className="flex-none rounded-full bg-blue-600 p-2 text-white"
amount={amount}
currencyCode={currencyCode}
currencyCodeClassName="hidden @[275px]/label:inline"
/>
</div>
</div>
);
};
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 (
<li>
<Link
href={item.path}
className={clsx(
"block p-2 text-lg underline-offset-4 hover:text-black hover:underline md:inline-block md:text-sm dark:hover:text-neutral-300",
{
"text-black dark:text-neutral-300": active,
},
)}
>
{item.title}
</Link>
</li>
);
}
export default function FooterMenu({ menu }: { menu: Menu[] }) {
if (!menu.length) return null;
return (
<nav>
<ul>
{menu.map((item: Menu) => {
return <FooterMenuItem key={item.title} item={item} />;
})}
</ul>
</nav>
);
}
================================================
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 (
<footer className="text-sm text-neutral-500 dark:text-neutral-400">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0 dark:border-neutral-700">
<div>
<Link
className="flex items-center gap-2 text-black md:pt-1 dark:text-white"
href="/"
>
<LogoSquare size="sm" />
<span className="uppercase">{SITE_NAME}</span>
</Link>
</div>
<Suspense
fallback={
<div className="flex h-[188px] w-[200px] flex-col gap-2">
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
</div>
}
>
<FooterMenu menu={menu} />
</Suspense>
<div className="md:ml-auto">
<a
className="flex h-8 w-max flex-none items-center justify-center rounded-md border border-neutral-200 bg-white text-xs text-black dark:border-neutral-700 dark:bg-black dark:text-white"
aria-label="Deploy on Vercel"
href="https://vercel.com/templates/next.js/nextjs-commerce"
>
<span className="px-3">▲</span>
<hr className="h-full border-r border-neutral-200 dark:border-neutral-700" />
<span className="px-3">Deploy</span>
</a>
</div>
</div>
<div className="border-t border-neutral-200 py-6 text-sm dark:border-neutral-700">
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-1 px-4 md:flex-row md:gap-0 md:px-4 min-[1320px]:px-0">
<p>
© {copyrightDate} {copyrightName}
{copyrightName.length && !copyrightName.endsWith(".")
? "."
: ""}{" "}
All rights reserved.
</p>
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
<p>
<a href="https://github.com/vercel/commerce">View the source</a>
</p>
<p className="md:ml-auto">
<a href="https://vercel.com" className="text-black dark:text-white">
Created by ▲ Vercel
</a>
</p>
</div>
</div>
</footer>
);
}
================================================
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 (
<nav className="relative flex items-center justify-between p-4 lg:px-6">
<div className="block flex-none md:hidden">
<Suspense fallback={null}>
<MobileMenu menu={menu} />
</Suspense>
</div>
<div className="flex w-full items-center">
<div className="flex w-full md:w-1/3">
<Link
href="/"
prefetch={true}
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
>
<LogoSquare />
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
{SITE_NAME}
</div>
</Link>
{menu.length ? (
<ul className="hidden gap-6 text-sm md:flex md:items-center">
{menu.map((item: Menu) => (
<li key={item.title}>
<Link
href={item.path}
prefetch={true}
className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300"
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
<div className="hidden justify-center md:flex md:w-1/3">
<Suspense fallback={<SearchSkeleton />}>
<Search />
</Suspense>
</div>
<div className="flex justify-end md:w-1/3">
<CartModal />
</div>
</div>
</nav>
);
}
================================================
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 (
<>
<button
onClick={openMobileMenu}
aria-label="Open mobile menu"
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors md:hidden dark:border-neutral-700 dark:text-white"
>
<Bars3Icon className="h-4" />
</button>
<Transition show={isOpen}>
<Dialog onClose={closeMobileMenu} className="relative z-50">
<Transition.Child
as={Fragment}
enter="transition-all ease-in-out duration-300"
enterFrom="opacity-0 backdrop-blur-none"
enterTo="opacity-100 backdrop-blur-[.5px]"
leave="transition-all ease-in-out duration-200"
leaveFrom="opacity-100 backdrop-blur-[.5px]"
leaveTo="opacity-0 backdrop-blur-none"
>
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="transition-all ease-in-out duration-300"
enterFrom="translate-x-[-100%]"
enterTo="translate-x-0"
leave="transition-all ease-in-out duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-[-100%]"
>
<Dialog.Panel className="fixed bottom-0 left-0 right-0 top-0 flex h-full w-full flex-col bg-white pb-6 dark:bg-black">
<div className="p-4">
<button
className="mb-4 flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white"
onClick={closeMobileMenu}
aria-label="Close mobile menu"
>
<XMarkIcon className="h-6" />
</button>
<div className="mb-4 w-full">
<Suspense fallback={<SearchSkeleton />}>
<Search />
</Suspense>
</div>
{menu.length ? (
<ul className="flex w-full flex-col">
{menu.map((item: Menu) => (
<li
className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
key={item.title}
>
<Link
href={item.path}
prefetch={true}
onClick={closeMobileMenu}
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition>
</>
);
}
================================================
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 (
<Form
action="/search"
className="w-max-[550px] relative w-full lg:w-80 xl:w-full"
>
<input
key={searchParams?.get("q")}
type="text"
name="q"
placeholder="Search for products..."
autoComplete="off"
defaultValue={searchParams?.get("q") || ""}
className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
/>
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
<MagnifyingGlassIcon className="h-4" />
</div>
</Form>
);
}
export function SearchSkeleton() {
return (
<form className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<input
placeholder="Search for products..."
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
/>
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
<MagnifyingGlassIcon className="h-4" />
</div>
</form>
);
}
================================================
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) => (
<Grid.Item key={product.handle} className="animate-fadeIn">
<Link
className="relative inline-block h-full w-full"
href={`/product/${product.handle}`}
prefetch={true}
>
<GridTileImage
alt={product.title}
label={{
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode,
}}
src={product.featuredImage?.url}
fill
sizes="(min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
/>
</Link>
</Grid.Item>
))}
</>
);
}
================================================
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 <FilterList list={collections} title="Collections" />;
}
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 (
<Suspense
fallback={
<div className="col-span-2 hidden h-[400px] w-full flex-none py-4 lg:block">
<div className={clsx(skeleton, activeAndTitles)} />
<div className={clsx(skeleton, activeAndTitles)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
</div>
}
>
<CollectionList />
</Suspense>
);
}
================================================
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<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<div
onClick={() => {
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"
>
<div>{active}</div>
<ChevronDownIcon className="h-4" />
</div>
{openSelect && (
<div
onClick={() => {
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) => (
<FilterItem key={i} item={item} />
))}
</div>
)}
</div>
);
}
================================================
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) => (
<FilterItem key={i} item={item} />
))}
</>
);
}
export default function FilterList({
list,
title,
}: {
list: ListItem[];
title?: string;
}) {
return (
<>
<nav>
{title ? (
<h3 className="hidden text-xs text-neutral-500 md:block dark:text-neutral-400">
{title}
</h3>
) : null}
<ul className="hidden md:block">
<Suspense fallback={null}>
<FilterItemList list={list} />
</Suspense>
</ul>
<ul className="md:hidden">
<Suspense fallback={null}>
<FilterItemDropdown list={list} />
</Suspense>
</ul>
</nav>
</>
);
}
================================================
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 (
<li className="mt-2 flex text-black dark:text-white" key={item.title}>
<DynamicTag
href={createUrl(item.path, newParams)}
className={clsx(
"w-full text-sm underline-offset-4 hover:underline dark:hover:text-neutral-100",
{
"underline underline-offset-4": active,
},
)}
>
{item.title}
</DynamicTag>
</li>
);
}
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 (
<li
className="mt-2 flex text-sm text-black dark:text-white"
key={item.title}
>
<DynamicTag
prefetch={!active ? false : undefined}
href={href}
className={clsx("w-full hover:underline hover:underline-offset-4", {
"underline underline-offset-4": active,
})}
>
{item.title}
</DynamicTag>
</li>
);
}
export function FilterItem({ item }: { item: ListItem }) {
return "path" in item ? (
<PathFilterItem item={item} />
) : (
<SortFilterItem item={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 (
<span className="mx-2 inline-flex items-center">
<span className={clsx(dots, className)} />
<span className={clsx(dots, "animation-delay-[200ms]", className)} />
<span className={clsx(dots, "animation-delay-[400ms]", className)} />
</span>
);
};
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 (
<div
className={clsx(
"flex flex-none items-center justify-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-black",
{
"h-[40px] w-[40px] rounded-xl": !size,
"h-[30px] w-[30px] rounded-lg": size === "sm",
},
)}
>
<LogoIcon
className={clsx({
"h-[16px] w-[16px]": !size,
"h-[10px] w-[10px]": size === "sm",
})}
/>
</div>
);
}
================================================
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<ImageResponse> {
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(
(
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
<div tw="flex flex-none items-center justify-center border border-neutral-700 h-[160px] w-[160px] rounded-3xl">
<LogoIcon width="64" height="58" fill="white" />
</div>
<p tw="mt-12 text-6xl font-bold text-white">{title}</p>
</div>
),
{
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">) => (
<p suppressHydrationWarning={true} className={className}>
{`${new Intl.NumberFormat(undefined, {
style: "currency",
currency: currencyCode,
currencyDisplay: "narrowSymbol",
}).format(parseFloat(amount))}`}
<span
className={clsx("ml-1 inline", currencyCodeClassName)}
>{`${currencyCode}`}</span>
</p>
);
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 (
<form>
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
{images[imageIndex] && (
<Image
className="h-full w-full object-contain"
fill
sizes="(min-width: 1024px) 66vw, 100vw"
alt={images[imageIndex]?.altText as string}
src={images[imageIndex]?.src as string}
priority={true}
/>
)}
{images.length > 1 ? (
<div className="absolute bottom-[15%] flex w-full justify-center">
<div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur-sm dark:border-black dark:bg-neutral-900/80">
<button
formAction={() => updateImage(previousImageIndex.toString())}
aria-label="Previous product image"
className={buttonClassName}
>
<ArrowLeftIcon className="h-5" />
</button>
<div className="mx-1 h-6 w-px bg-neutral-500"></div>
<button
formAction={() => updateImage(nextImageIndex.toString())}
aria-label="Next product image"
className={buttonClassName}
>
<ArrowRightIcon className="h-5" />
</button>
</div>
</div>
) : null}
</div>
{images.length > 1 ? (
<ul className="my-12 flex items-center flex-wrap justify-center gap-2 overflow-auto py-1 lg:mb-0">
{images.map((image, index) => {
const isActive = index === imageIndex;
return (
<li key={image.src} className="h-20 w-20">
<button
formAction={() => updateImage(index.toString())}
aria-label="Select product image"
className="h-full w-full"
>
<GridTileImage
alt={image.altText}
src={image.src}
width={80}
height={80}
active={isActive}
/>
</button>
</li>
);
})}
</ul>
) : null}
</form>
);
}
================================================
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 (
<>
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
<h1 className="mb-2 text-5xl font-medium">{product.title}</h1>
<div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white">
<Price
amount={product.priceRange.maxVariantPrice.amount}
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
/>
</div>
</div>
<VariantSelector options={product.options} variants={product.variants} />
{product.descriptionHtml ? (
<Prose
className="mb-6 text-sm leading-tight dark:text-white/[60%]"
html={product.descriptionHtml}
/>
) : null}
<AddToCart product={product} />
</>
);
}
================================================
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) => (
<form key={option.id}>
<dl className="mb-8">
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
<dd className="flex flex-wrap gap-3">
{option.values.map((value) => {
const optionNameLowerCase = option.name.toLowerCase();
// Base option params on current searchParams so we can preserve any other param state.
const optionParams: Record<string, string> = {};
searchParams.forEach((v, k) => (optionParams[k] = v));
optionParams[optionNameLowerCase] = value;
// Filter out invalid options and check if the option combination is available for sale.
const filtered = Object.entries(optionParams).filter(
([key, value]) =>
options.find(
(option) =>
option.name.toLowerCase() === key &&
option.values.includes(value),
),
);
const isAvailableForSale = combinations.find((combination) =>
filtered.every(
([key, value]) =>
combination[key] === value && combination.availableForSale,
),
);
// The option is active if it's in the selected options.
const isActive = searchParams.get(optionNameLowerCase) === value;
return (
<button
formAction={() => updateOption(optionNameLowerCase, value)}
key={value}
aria-disabled={!isAvailableForSale}
disabled={!isAvailableForSale}
title={`${option.name} ${value}${!isAvailableForSale ? " (Out of Stock)" : ""}`}
className={clsx(
"flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900",
{
"cursor-default ring-2 ring-blue-600": isActive,
"ring-1 ring-transparent transition duration-300 ease-in-out hover:ring-blue-600":
!isActive && isAvailableForSale,
"relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 dark:before:bg-neutral-700":
!isAvailableForSale,
},
)}
>
{value}
</button>
);
})}
</dd>
</dl>
</form>
));
}
================================================
FILE: components/prose.tsx
================================================
import clsx from "clsx";
const Prose = ({ html, className }: { html: string; className?: string }) => {
return (
<div
className={clsx(
"prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline prose-a:hover:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white",
className,
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
};
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.{" "}
<a
href="https://vercel.com/templates/next.js/nextjs-commerce"
className="text-blue-600 hover:underline"
target="_blank"
>
Deploy your own
</a>
.
</>
),
});
}
}, []);
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> = T extends { variables: object }
? T["variables"]
: never;
export async function shopifyFetch<T>({
headers,
query,
variables,
}: {
headers?: HeadersInit;
query: string;
variables?: ExtractVariables<T>;
}): 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 = <T>(array: Connection<T>): 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<Image>, 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<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation,
});
return reshapeCart(res.body.data.cartCreate.cart);
}
export async function addToCart(
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const cartId = (await cookies()).get("cartId")?.value!;
const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation,
variables: {
cartId,
lines,
},
});
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(lineIds: string[]): Promise<Cart> {
const cartId = (await cookies()).get("cartId")?.value!;
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation,
variables: {
cartId,
lineIds,
},
});
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
export async function updateCart(
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const cartId = (await cookies()).get("cartId")?.value!;
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
query: editCartItemsMutation,
variables: {
cartId,
lines,
},
});
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(): Promise<Cart | undefined> {
"use cache: private";
cacheTag(TAGS.cart);
cacheLife("seconds");
const cartId = (await cookies()).get("cartId")?.value;
if (!cartId) {
return undefined;
}
const res = await shopifyFetch<ShopifyCartOperation>({
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<Collection | undefined> {
"use cache";
cacheTag(TAGS.collections);
cacheLife("days");
const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery,
variables: {
handle,
},
});
return reshapeCollection(res.body.data.collection);
}
export async function getCollectionProducts({
collection,
reverse,
sortKey,
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
"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<ShopifyCollectionProductsOperation>({
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<Collection[]> {
"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<ShopifyCollectionsOperation>({
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<Menu[]> {
"use cache";
cacheTag(TAGS.collections);
cacheLife("days");
if (!endpoint) {
console.log(`Skipping getMenu for '${handle}' - Shopify not configured`);
return [];
}
const res = await shopifyFetch<ShopifyMenuOperation>({
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<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery,
variables: { handle },
});
return res.body.data.pageByHandle;
}
export async function getPages(): Promise<Page[]> {
const res = await shopifyFetch<ShopifyPagesOperation>({
query: getPagesQuery,
});
return removeEdgesAndNodes(res.body.data.pages);
}
export async function getProduct(handle: string): Promise<Product | undefined> {
"use cache";
cacheTag(TAGS.products);
cacheLife("days");
if (!endpoint) {
console.log(`Skipping getProduct for '${handle}' - Shopify not configured`);
return undefined;
}
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
variables: {
handle,
},
});
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(
productId: string
): Promise<Product[]> {
"use cache";
cacheTag(TAGS.products);
cacheLife("days");
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
variables: {
productId,
},
});
return reshapeProducts(res.body.data.productRecommendations);
}
export async function getProducts({
query,
reverse,
sortKey,
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
"use cache";
cacheTag(TAGS.products);
cacheLife("days");
const res = await shopifyFetch<ShopifyProductsOperation>({
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<NextResponse> {
// 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> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type Cart = Omit<ShopifyCart, "lines"> & {
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<ShopifyProduct, "variants" | "images"> & {
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<CartItem>;
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<ProductVariant>;
featuredImage: Image;
images: Connection<Image>;
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<ShopifyProduct>;
};
};
variables: {
handle: string;
reverse?: boolean;
sortKey?: string;
};
};
export type ShopifyCollectionsOperation = {
data: {
collections: Connection<ShopifyCollection>;
};
};
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<Page>;
};
};
export type ShopifyProductOperation = {
data: { product: ShopifyProduct };
variables: {
handle: string;
};
};
export type ShopifyProductRecommendationsOperation = {
data: {
productRecommendations: ShopifyProduct[];
};
variables: {
productId: string;
};
};
export type ShopifyProductsOperation = {
data: {
products: Connection<ShopifyProduct>;
};
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<string, unknown> => {
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<T extends object>(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"]
}
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
SYMBOL INDEX (137 symbols across 49 files)
FILE: app/[page]/layout.tsx
function Layout (line 3) | function Layout({ children }: { children: React.ReactNode }) {
FILE: app/[page]/opengraph-image.tsx
function Image (line 4) | async function Image({ params }: { params: { page: string } }) {
FILE: app/[page]/page.tsx
function generateMetadata (line 7) | async function generateMetadata(props: {
function Page (line 26) | async function Page(props: {
FILE: app/api/revalidate/route.ts
function POST (line 4) | async function POST(req: NextRequest): Promise<NextResponse> {
FILE: app/error.tsx
function Error (line 3) | function Error({ reset }: { reset: () => void }) {
FILE: app/layout.tsx
function RootLayout (line 25) | async function RootLayout({
FILE: app/opengraph-image.tsx
function Image (line 3) | async function Image() {
FILE: app/page.tsx
function HomePage (line 13) | function HomePage() {
FILE: app/product/[handle]/page.tsx
function generateMetadata (line 13) | async function generateMetadata(props: {
function ProductPage (line 50) | async function ProductPage(props: {
function RelatedProducts (line 113) | async function RelatedProducts({ id }: { id: string }) {
FILE: app/robots.ts
function robots (line 3) | function robots() {
FILE: app/search/[collection]/opengraph-image.tsx
function Image (line 4) | async function Image({
FILE: app/search/[collection]/page.tsx
function generateMetadata (line 9) | async function generateMetadata(props: {
function CategoryPage (line 26) | async function CategoryPage(props: {
FILE: app/search/children-wrapper.tsx
function ChildrenWrapper (line 7) | function ChildrenWrapper({
FILE: app/search/layout.tsx
function SearchLayout (line 8) | function SearchLayout({
FILE: app/search/loading.tsx
function Loading (line 3) | function Loading() {
FILE: app/search/page.tsx
function SearchPage (line 11) | async function SearchPage(props: {
FILE: app/sitemap.ts
type Route (line 5) | type Route = {
function sitemap (line 12) | async function sitemap(): Promise<MetadataRoute.Sitemap> {
FILE: components/carousel.tsx
function Carousel (line 5) | async function Carousel() {
FILE: components/cart/actions.ts
function addItem (line 15) | async function addItem(
function removeItem (line 31) | async function removeItem(prevState: any, merchandiseId: string) {
function updateItemQuantity (line 54) | async function updateItemQuantity(
function redirectToCheckout (line 98) | async function redirectToCheckout() {
function createCartAndSetCookie (line 103) | async function createCartAndSetCookie() {
FILE: components/cart/add-to-cart.tsx
function SubmitButton (line 11) | function SubmitButton({
function AddToCart (line 60) | function AddToCart({ product }: { product: Product }) {
FILE: components/cart/cart-context.tsx
type UpdateType (line 17) | type UpdateType = "plus" | "minus" | "delete";
type CartAction (line 19) | type CartAction =
type CartContextType (line 29) | type CartContextType = {
function calculateItemCost (line 35) | function calculateItemCost(quantity: number, price: string): string {
function updateCartItem (line 39) | function updateCartItem(
function createOrUpdateCartItem (line 68) | function createOrUpdateCartItem(
function updateCartTotals (line 99) | function updateCartTotals(
function createEmptyCart (line 119) | function createEmptyCart(): Cart {
function cartReducer (line 133) | function cartReducer(state: Cart | undefined, action: CartAction): Cart {
function CartProvider (line 193) | function CartProvider({
function useCart (line 207) | function useCart() {
FILE: components/cart/delete-item-button.tsx
function DeleteItemButton (line 8) | function DeleteItemButton({
FILE: components/cart/edit-item-quantity-button.tsx
function SubmitButton (line 9) | function SubmitButton({ type }: { type: "plus" | "minus" }) {
function EditItemQuantityButton (line 32) | function EditItemQuantityButton({
FILE: components/cart/modal.tsx
type MerchandiseSearchParams (line 20) | type MerchandiseSearchParams = {
function CartModal (line 24) | function CartModal() {
function CloseCart (line 231) | function CloseCart({ className }: { className?: string }) {
function CheckoutButton (line 244) | function CheckoutButton() {
FILE: components/cart/open-cart.tsx
function OpenCart (line 4) | function OpenCart({
FILE: components/grid/index.tsx
function Grid (line 3) | function Grid(props: React.ComponentProps<"ul">) {
function GridItem (line 14) | function GridItem(props: React.ComponentProps<"li">) {
FILE: components/grid/three-items.tsx
function ThreeItemGridItem (line 6) | function ThreeItemGridItem({
function ThreeItemGrid (line 50) | async function ThreeItemGrid() {
FILE: components/grid/tile.tsx
function GridTileImage (line 5) | function GridTileImage({
FILE: components/icons/logo.tsx
function LogoIcon (line 3) | function LogoIcon(props: React.ComponentProps<"svg">) {
FILE: components/layout/footer-menu.tsx
function FooterMenuItem (line 9) | function FooterMenuItem({ item }: { item: Menu }) {
function FooterMenu (line 34) | function FooterMenu({ menu }: { menu: Menu[] }) {
FILE: components/layout/footer.tsx
function Footer (line 10) | async function Footer() {
FILE: components/layout/navbar/index.tsx
function Navbar (line 12) | async function Navbar() {
FILE: components/layout/navbar/mobile-menu.tsx
function MobileMenu (line 12) | function MobileMenu({ menu }: { menu: Menu[] }) {
FILE: components/layout/navbar/search.tsx
function Search (line 7) | function Search() {
function SearchSkeleton (line 31) | function SearchSkeleton() {
FILE: components/layout/product-grid-items.tsx
function ProductGridItems (line 6) | function ProductGridItems({
FILE: components/layout/search/collections.tsx
function CollectionList (line 7) | async function CollectionList() {
function Collections (line 16) | function Collections() {
FILE: components/layout/search/filter/dropdown.tsx
function FilterItemDropdown (line 10) | function FilterItemDropdown({ list }: { list: ListItem[] }) {
FILE: components/layout/search/filter/index.tsx
type ListItem (line 6) | type ListItem = SortFilterItem | PathFilterItem;
type PathFilterItem (line 7) | type PathFilterItem = { title: string; path: string };
function FilterItemList (line 9) | function FilterItemList({ list }: { list: ListItem[] }) {
function FilterList (line 19) | function FilterList({
FILE: components/layout/search/filter/item.tsx
function PathFilterItem (line 10) | function PathFilterItem({ item }: { item: PathFilterItem }) {
function SortFilterItem (line 36) | function SortFilterItem({ item }: { item: SortFilterItem }) {
function FilterItem (line 68) | function FilterItem({ item }: { item: ListItem }) {
FILE: components/logo-square.tsx
function LogoSquare (line 4) | function LogoSquare({ size }: { size?: "sm" | undefined }) {
FILE: components/opengraph-image.tsx
type Props (line 6) | type Props = {
function OpengraphImage (line 10) | async function OpengraphImage(
FILE: components/product/gallery.tsx
function Gallery (line 8) | function Gallery({
FILE: components/product/product-description.tsx
function ProductDescription (line 7) | function ProductDescription({ product }: { product: Product }) {
FILE: components/product/variant-selector.tsx
type Combination (line 7) | type Combination = {
function VariantSelector (line 13) | function VariantSelector({
FILE: components/welcome-toast.tsx
function WelcomeToast (line 6) | function WelcomeToast() {
FILE: lib/constants.ts
type SortFilterItem (line 1) | type SortFilterItem = {
constant TAGS (line 43) | const TAGS = {
constant HIDDEN_PRODUCT_TAG (line 49) | const HIDDEN_PRODUCT_TAG = "nextjs-frontend-hidden";
constant DEFAULT_OPTION (line 50) | const DEFAULT_OPTION = "Default Title";
constant SHOPIFY_GRAPHQL_API_ENDPOINT (line 51) | const SHOPIFY_GRAPHQL_API_ENDPOINT = "/api/2023-01/graphql.json";
FILE: lib/shopify/index.ts
type ExtractVariables (line 67) | type ExtractVariables<T> = T extends { variables: object }
function shopifyFetch (line 71) | async function shopifyFetch<T>({
function createCart (line 220) | async function createCart(): Promise<Cart> {
function addToCart (line 228) | async function addToCart(
function removeFromCart (line 242) | async function removeFromCart(lineIds: string[]): Promise<Cart> {
function updateCart (line 255) | async function updateCart(
function getCart (line 270) | async function getCart(): Promise<Cart | undefined> {
function getCollection (line 294) | async function getCollection(
function getCollectionProducts (line 311) | async function getCollectionProducts({
function getCollections (line 350) | async function getCollections(): Promise<Collection[]> {
function getMenu (line 398) | async function getMenu(handle: string): Promise<Menu[]> {
function getPage (line 426) | async function getPage(handle: string): Promise<Page> {
function getPages (line 435) | async function getPages(): Promise<Page[]> {
function getProduct (line 443) | async function getProduct(handle: string): Promise<Product | undefined> {
function getProductRecommendations (line 463) | async function getProductRecommendations(
function getProducts (line 480) | async function getProducts({
function revalidate (line 506) | async function revalidate(req: NextRequest): Promise<NextResponse> {
FILE: lib/shopify/types.ts
type Maybe (line 1) | type Maybe<T> = T | null;
type Connection (line 3) | type Connection<T> = {
type Edge (line 7) | type Edge<T> = {
type Cart (line 11) | type Cart = Omit<ShopifyCart, "lines"> & {
type CartProduct (line 15) | type CartProduct = {
type CartItem (line 22) | type CartItem = {
type Collection (line 39) | type Collection = ShopifyCollection & {
type Image (line 43) | type Image = {
type Menu (line 50) | type Menu = {
type Money (line 55) | type Money = {
type Page (line 60) | type Page = {
type Product (line 71) | type Product = Omit<ShopifyProduct, "variants" | "images"> & {
type ProductOption (line 76) | type ProductOption = {
type ProductVariant (line 82) | type ProductVariant = {
type SEO (line 93) | type SEO = {
type ShopifyCart (line 98) | type ShopifyCart = {
type ShopifyCollection (line 110) | type ShopifyCollection = {
type ShopifyProduct (line 118) | type ShopifyProduct = {
type ShopifyCartOperation (line 138) | type ShopifyCartOperation = {
type ShopifyCreateCartOperation (line 147) | type ShopifyCreateCartOperation = {
type ShopifyAddToCartOperation (line 151) | type ShopifyAddToCartOperation = {
type ShopifyRemoveFromCartOperation (line 166) | type ShopifyRemoveFromCartOperation = {
type ShopifyUpdateCartOperation (line 178) | type ShopifyUpdateCartOperation = {
type ShopifyCollectionOperation (line 194) | type ShopifyCollectionOperation = {
type ShopifyCollectionProductsOperation (line 203) | type ShopifyCollectionProductsOperation = {
type ShopifyCollectionsOperation (line 216) | type ShopifyCollectionsOperation = {
type ShopifyMenuOperation (line 222) | type ShopifyMenuOperation = {
type ShopifyPageOperation (line 236) | type ShopifyPageOperation = {
type ShopifyPagesOperation (line 241) | type ShopifyPagesOperation = {
type ShopifyProductOperation (line 247) | type ShopifyProductOperation = {
type ShopifyProductRecommendationsOperation (line 254) | type ShopifyProductRecommendationsOperation = {
type ShopifyProductsOperation (line 263) | type ShopifyProductsOperation = {
FILE: lib/type-guards.ts
type ShopifyErrorLike (line 1) | interface ShopifyErrorLike {
function findError (line 23) | function findError<T extends object>(error: T): boolean {
Condensed preview — 75 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (128K chars).
[
{
"path": ".env.example",
"chars": 190,
"preview": "COMPANY_NAME=\"Vercel Inc.\"\nSITE_NAME=\"Next.js Commerce\"\nSHOPIFY_REVALIDATION_SECRET=\"\"\nSHOPIFY_STOREFRONT_ACCESS_TOKEN=\""
},
{
"path": ".gitignore",
"chars": 417,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": ".vscode/launch.json",
"chars": 645,
"preview": "{\n \"version\": \"0.2.0\",\n \"configurations\": [\n {\n \"name\": \"Next.js: debug server-side\",\n \"type\": \"node-term"
},
{
"path": ".vscode/settings.json",
"chars": 255,
"preview": "{\n \"typescript.tsdk\": \"node_modules/typescript/lib\",\n \"typescript.enablePromptUseWorkspaceTsdk\": true,\n \"editor.codeA"
},
{
"path": "README.md",
"chars": 4979,
"preview": "[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%"
},
{
"path": "app/[page]/layout.tsx",
"chars": 294,
"preview": "import Footer from \"components/layout/footer\";\n\nexport default function Layout({ children }: { children: React.ReactNode"
},
{
"path": "app/[page]/opengraph-image.tsx",
"chars": 312,
"preview": "import OpengraphImage from \"components/opengraph-image\";\nimport { getPage } from \"lib/shopify\";\n\nexport default async fu"
},
{
"path": "app/[page]/page.tsx",
"chars": 1268,
"preview": "import type { Metadata } from \"next\";\n\nimport Prose from \"components/prose\";\nimport { getPage } from \"lib/shopify\";\nimpo"
},
{
"path": "app/api/revalidate/route.ts",
"chars": 198,
"preview": "import { revalidate } from \"lib/shopify\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport async functio"
},
{
"path": "app/error.tsx",
"chars": 705,
"preview": "\"use client\";\n\nexport default function Error({ reset }: { reset: () => void }) {\n return (\n <div className=\"mx-auto "
},
{
"path": "app/globals.css",
"chars": 711,
"preview": "@import \"tailwindcss\";\n\n@plugin \"@tailwindcss/container-queries\";\n@plugin \"@tailwindcss/typography\";\n\n@layer base {\n *,"
},
{
"path": "app/layout.tsx",
"chars": 1263,
"preview": "import { CartProvider } from \"components/cart/cart-context\";\nimport { Navbar } from \"components/layout/navbar\";\nimport {"
},
{
"path": "app/opengraph-image.tsx",
"chars": 133,
"preview": "import OpengraphImage from \"components/opengraph-image\";\n\nexport default async function Image() {\n return await Opengra"
},
{
"path": "app/page.tsx",
"chars": 454,
"preview": "import { Carousel } from \"components/carousel\";\nimport { ThreeItemGrid } from \"components/grid/three-items\";\nimport Foot"
},
{
"path": "app/product/[handle]/page.tsx",
"chars": 4664,
"preview": "import { GridTileImage } from \"components/grid/tile\";\nimport Footer from \"components/layout/footer\";\nimport { Gallery } "
},
{
"path": "app/robots.ts",
"chars": 210,
"preview": "import { baseUrl } from \"lib/utils\";\n\nexport default function robots() {\n return {\n rules: [\n {\n userAge"
},
{
"path": "app/search/[collection]/opengraph-image.tsx",
"chars": 362,
"preview": "import OpengraphImage from \"components/opengraph-image\";\nimport { getCollection } from \"lib/shopify\";\n\nexport default as"
},
{
"path": "app/search/[collection]/page.tsx",
"chars": 1609,
"preview": "import { getCollection, getCollectionProducts } from \"lib/shopify\";\nimport { Metadata } from \"next\";\nimport { notFound }"
},
{
"path": "app/search/children-wrapper.tsx",
"chars": 373,
"preview": "\"use client\";\n\nimport { useSearchParams } from \"next/navigation\";\nimport { Fragment } from \"react\";\n\n// Ensure children "
},
{
"path": "app/search/layout.tsx",
"chars": 1035,
"preview": "import Footer from \"components/layout/footer\";\nimport Collections from \"components/layout/search/collections\";\nimport Fi"
},
{
"path": "app/search/loading.tsx",
"chars": 473,
"preview": "import Grid from \"components/grid\";\n\nexport default function Loading() {\n return (\n <>\n <div className=\"mb-4 h-"
},
{
"path": "app/search/page.tsx",
"chars": 1319,
"preview": "import Grid from \"components/grid\";\nimport ProductGridItems from \"components/layout/product-grid-items\";\nimport { defaul"
},
{
"path": "app/sitemap.ts",
"chars": 1352,
"preview": "import { getCollections, getPages, getProducts } from \"lib/shopify\";\nimport { baseUrl, validateEnvironmentVariables } fr"
},
{
"path": "components/carousel.tsx",
"chars": 1576,
"preview": "import { getCollectionProducts } from \"lib/shopify\";\nimport Link from \"next/link\";\nimport { GridTileImage } from \"./grid"
},
{
"path": "components/cart/actions.ts",
"chars": 2294,
"preview": "\"use server\";\n\nimport { TAGS } from \"lib/constants\";\nimport {\n addToCart,\n createCart,\n getCart,\n removeFromCart,\n "
},
{
"path": "components/cart/add-to-cart.tsx",
"chars": 2579,
"preview": "\"use client\";\n\nimport { PlusIcon } from \"@heroicons/react/24/outline\";\nimport clsx from \"clsx\";\nimport { addItem } from "
},
{
"path": "components/cart/cart-context.tsx",
"chars": 5690,
"preview": "\"use client\";\n\nimport type {\n Cart,\n CartItem,\n Product,\n ProductVariant,\n} from \"lib/shopify/types\";\nimport React, "
},
{
"path": "components/cart/delete-item-button.tsx",
"chars": 1040,
"preview": "\"use client\";\n\nimport { XMarkIcon } from \"@heroicons/react/24/outline\";\nimport { removeItem } from \"components/cart/acti"
},
{
"path": "components/cart/edit-item-quantity-button.tsx",
"chars": 1678,
"preview": "\"use client\";\n\nimport { MinusIcon, PlusIcon } from \"@heroicons/react/24/outline\";\nimport clsx from \"clsx\";\nimport { upda"
},
{
"path": "components/cart/modal.tsx",
"chars": 11388,
"preview": "\"use client\";\n\nimport clsx from \"clsx\";\nimport { Dialog, Transition } from \"@headlessui/react\";\nimport { ShoppingCartIco"
},
{
"path": "components/cart/open-cart.tsx",
"chars": 762,
"preview": "import { ShoppingCartIcon } from \"@heroicons/react/24/outline\";\nimport clsx from \"clsx\";\n\nexport default function OpenCa"
},
{
"path": "components/grid/index.tsx",
"chars": 486,
"preview": "import clsx from \"clsx\";\n\nfunction Grid(props: React.ComponentProps<\"ul\">) {\n return (\n <ul\n {...props}\n c"
},
{
"path": "components/grid/three-items.tsx",
"chars": 1991,
"preview": "import { GridTileImage } from \"components/grid/tile\";\nimport { getCollectionProducts } from \"lib/shopify\";\nimport type {"
},
{
"path": "components/grid/tile.tsx",
"chars": 1234,
"preview": "import clsx from \"clsx\";\nimport Image from \"next/image\";\nimport Label from \"../label\";\n\nexport function GridTileImage({\n"
},
{
"path": "components/icons/logo.tsx",
"chars": 492,
"preview": "import clsx from \"clsx\";\n\nexport default function LogoIcon(props: React.ComponentProps<\"svg\">) {\n return (\n <svg\n "
},
{
"path": "components/label.tsx",
"chars": 1025,
"preview": "import clsx from \"clsx\";\nimport Price from \"./price\";\n\nconst Label = ({\n title,\n amount,\n currencyCode,\n position = "
},
{
"path": "components/layout/footer-menu.tsx",
"chars": 1100,
"preview": "\"use client\";\n\nimport clsx from \"clsx\";\nimport { Menu } from \"lib/shopify/types\";\nimport Link from \"next/link\";\nimport {"
},
{
"path": "components/layout/footer.tsx",
"chars": 3138,
"preview": "import Link from \"next/link\";\n\nimport FooterMenu from \"components/layout/footer-menu\";\nimport LogoSquare from \"component"
},
{
"path": "components/layout/navbar/index.tsx",
"chars": 2051,
"preview": "import CartModal from \"components/cart/modal\";\nimport LogoSquare from \"components/logo-square\";\nimport { getMenu } from "
},
{
"path": "components/layout/navbar/mobile-menu.tsx",
"chars": 3919,
"preview": "\"use client\";\n\nimport { Dialog, Transition } from \"@headlessui/react\";\nimport Link from \"next/link\";\nimport { usePathnam"
},
{
"path": "components/layout/navbar/search.tsx",
"chars": 1500,
"preview": "\"use client\";\n\nimport { MagnifyingGlassIcon } from \"@heroicons/react/24/outline\";\nimport Form from \"next/form\";\nimport {"
},
{
"path": "components/layout/product-grid-items.tsx",
"chars": 1047,
"preview": "import Grid from \"components/grid\";\nimport { GridTileImage } from \"components/grid/tile\";\nimport { Product } from \"lib/s"
},
{
"path": "components/layout/search/collections.tsx",
"chars": 1248,
"preview": "import clsx from \"clsx\";\nimport { Suspense } from \"react\";\n\nimport { getCollections } from \"lib/shopify\";\nimport FilterL"
},
{
"path": "components/layout/search/filter/dropdown.tsx",
"chars": 1939,
"preview": "\"use client\";\n\nimport { usePathname, useSearchParams } from \"next/navigation\";\nimport { useEffect, useRef, useState } fr"
},
{
"path": "components/layout/search/filter/index.tsx",
"chars": 1105,
"preview": "import { SortFilterItem } from \"lib/constants\";\nimport { Suspense } from \"react\";\nimport FilterItemDropdown from \"./drop"
},
{
"path": "components/layout/search/filter/item.tsx",
"chars": 2028,
"preview": "\"use client\";\n\nimport clsx from \"clsx\";\nimport type { SortFilterItem } from \"lib/constants\";\nimport { createUrl } from \""
},
{
"path": "components/loading-dots.tsx",
"chars": 476,
"preview": "import clsx from \"clsx\";\n\nconst dots = \"mx-[1px] inline-block h-1 w-1 animate-blink rounded-md\";\n\nconst LoadingDots = ({"
},
{
"path": "components/logo-square.tsx",
"chars": 616,
"preview": "import clsx from \"clsx\";\nimport LogoIcon from \"./icons/logo\";\n\nexport default function LogoSquare({ size }: { size?: \"sm"
},
{
"path": "components/opengraph-image.tsx",
"chars": 1095,
"preview": "import { ImageResponse } from \"next/og\";\nimport LogoIcon from \"./icons/logo\";\nimport { join } from \"path\";\nimport { read"
},
{
"path": "components/price.tsx",
"chars": 624,
"preview": "import clsx from \"clsx\";\n\nconst Price = ({\n amount,\n className,\n currencyCode = \"USD\",\n currencyCodeClassName,\n}: {\n"
},
{
"path": "components/product/gallery.tsx",
"chars": 3351,
"preview": "\"use client\";\n\nimport { ArrowLeftIcon, ArrowRightIcon } from \"@heroicons/react/24/outline\";\nimport { GridTileImage } fro"
},
{
"path": "components/product/product-description.tsx",
"chars": 1082,
"preview": "import { AddToCart } from \"components/cart/add-to-cart\";\nimport Price from \"components/price\";\nimport Prose from \"compon"
},
{
"path": "components/product/variant-selector.tsx",
"chars": 3956,
"preview": "\"use client\";\n\nimport clsx from \"clsx\";\nimport { ProductOption, ProductVariant } from \"lib/shopify/types\";\nimport { useR"
},
{
"path": "components/prose.tsx",
"chars": 828,
"preview": "import clsx from \"clsx\";\n\nconst Prose = ({ html, className }: { html: string; className?: string }) => {\n return (\n "
},
{
"path": "components/welcome-toast.tsx",
"chars": 963,
"preview": "\"use client\";\n\nimport { useEffect } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function WelcomeToast() {\n us"
},
{
"path": "lib/constants.ts",
"chars": 1072,
"preview": "export type SortFilterItem = {\n title: string;\n slug: string | null;\n sortKey: \"RELEVANCE\" | \"BEST_SELLING\" | \"CREATE"
},
{
"path": "lib/shopify/fragments/cart.ts",
"chars": 926,
"preview": "import productFragment from \"./product\";\n\nconst cartFragment = /* GraphQL */ `\n fragment cart on Cart {\n id\n chec"
},
{
"path": "lib/shopify/fragments/image.ts",
"chars": 145,
"preview": "const imageFragment = /* GraphQL */ `\n fragment image on Image {\n url\n altText\n width\n height\n }\n`;\n\nexpor"
},
{
"path": "lib/shopify/fragments/product.ts",
"chars": 990,
"preview": "import imageFragment from \"./image\";\nimport seoFragment from \"./seo\";\n\nconst productFragment = /* GraphQL */ `\n fragmen"
},
{
"path": "lib/shopify/fragments/seo.ts",
"chars": 122,
"preview": "const seoFragment = /* GraphQL */ `\n fragment seo on SEO {\n description\n title\n }\n`;\n\nexport default seoFragment"
},
{
"path": "lib/shopify/index.ts",
"chars": 12925,
"preview": "import {\n HIDDEN_PRODUCT_TAG,\n SHOPIFY_GRAPHQL_API_ENDPOINT,\n TAGS,\n} from \"lib/constants\";\nimport { isShopifyError }"
},
{
"path": "lib/shopify/mutations/cart.ts",
"chars": 990,
"preview": "import cartFragment from \"../fragments/cart\";\n\nexport const addToCartMutation = /* GraphQL */ `\n mutation addToCart($ca"
},
{
"path": "lib/shopify/queries/cart.ts",
"chars": 192,
"preview": "import cartFragment from \"../fragments/cart\";\n\nexport const getCartQuery = /* GraphQL */ `\n query getCart($cartId: ID!)"
},
{
"path": "lib/shopify/queries/collection.ts",
"chars": 1097,
"preview": "import productFragment from \"../fragments/product\";\nimport seoFragment from \"../fragments/seo\";\n\nconst collectionFragmen"
},
{
"path": "lib/shopify/queries/menu.ts",
"chars": 169,
"preview": "export const getMenuQuery = /* GraphQL */ `\n query getMenu($handle: String!) {\n menu(handle: $handle) {\n items "
},
{
"path": "lib/shopify/queries/page.ts",
"chars": 633,
"preview": "import seoFragment from \"../fragments/seo\";\n\nconst pageFragment = /* GraphQL */ `\n fragment page on Page {\n ... on P"
},
{
"path": "lib/shopify/queries/product.ts",
"chars": 769,
"preview": "import productFragment from \"../fragments/product\";\n\nexport const getProductQuery = /* GraphQL */ `\n query getProduct($"
},
{
"path": "lib/shopify/types.ts",
"chars": 4451,
"preview": "export type Maybe<T> = T | null;\n\nexport type Connection<T> = {\n edges: Array<Edge<T>>;\n};\n\nexport type Edge<T> = {\n n"
},
{
"path": "lib/type-guards.ts",
"chars": 741,
"preview": "export interface ShopifyErrorLike {\n status: number;\n message: Error;\n cause?: Error;\n}\n\nexport const isObject = (\n "
},
{
"path": "lib/utils.ts",
"chars": 1665,
"preview": "import { ReadonlyURLSearchParams } from \"next/navigation\";\n\nexport const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION"
},
{
"path": "license.md",
"chars": 1079,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2025 Vercel, Inc.\n\nPermission is hereby granted, free of charge, to any person obta"
},
{
"path": "next.config.ts",
"chars": 302,
"preview": "export default {\n experimental: {\n ppr: true,\n inlineCss: true,\n useCache: true,\n },\n images: {\n formats:"
},
{
"path": "package.json",
"chars": 930,
"preview": "{\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next dev --turbopack\",\n \"build\": \"next build\",\n \"start\": \"next sta"
},
{
"path": "postcss.config.mjs",
"chars": 122,
"preview": "/** @type {import('postcss-load-config').Config} */\nexport default {\n plugins: {\n \"@tailwindcss/postcss\": {},\n },\n}"
},
{
"path": "tsconfig.json",
"chars": 740,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es2015\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"downlevelIteration\":"
}
]
About this extraction
This page contains the full source code of the vercel/commerce GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 75 files (114.8 KB), approximately 30.1k tokens, and a symbol index with 137 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.