├── .env.example
├── .eslintrc.js
├── .github
├── dependabot.yml
└── workflows
│ └── test.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .vscode
├── launch.json
└── settings.json
├── README.md
├── app
├── [page]
│ ├── layout.tsx
│ ├── opengraph-image.tsx
│ └── page.tsx
├── api
│ └── revalidate
│ │ └── route.ts
├── error.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
├── opengraph-image.tsx
├── page.tsx
├── product
│ └── [handle]
│ │ └── page.tsx
├── robots.ts
├── search
│ ├── [collection]
│ │ ├── opengraph-image.tsx
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── loading.tsx
│ └── page.tsx
└── sitemap.ts
├── components
├── carousel.tsx
├── cart
│ ├── actions.ts
│ ├── add-to-cart.tsx
│ ├── close-cart.tsx
│ ├── delete-item-button.tsx
│ ├── edit-item-quantity-button.tsx
│ ├── index.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
│ │ └── useOutsideClick.ts
│ ├── 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
├── fonts
└── Inter-Bold.ttf
├── lib
├── constants.ts
├── orama
│ └── index.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.js
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── prettier.config.js
├── tailwind.config.js
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | COMPANY_NAME="Vercel Inc."
2 | TWITTER_CREATOR="@vercel"
3 | TWITTER_SITE="https://nextjs.org/commerce"
4 | SITE_NAME="Next.js Commerce"
5 | SHOPIFY_REVALIDATION_SECRET=""
6 | SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
7 | SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
8 | NEXT_PUBLIC_ORAMA_API_KEY=""
9 | NEXT_PUBLIC_ORAMA_ENDPOINT=""
10 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['next', 'prettier'],
3 | plugins: ['unicorn'],
4 | rules: {
5 | 'no-unused-vars': [
6 | 'error',
7 | {
8 | args: 'after-used',
9 | caughtErrors: 'none',
10 | ignoreRestSiblings: true,
11 | vars: 'all'
12 | }
13 | ],
14 | 'prefer-const': 'error',
15 | 'react-hooks/exhaustive-deps': 'error',
16 | 'unicorn/filename-case': [
17 | 'error',
18 | {
19 | case: 'kebabCase'
20 | }
21 | ]
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: 'github-actions'
4 | directory: '/'
5 | schedule:
6 | interval: 'weekly'
7 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | on: pull_request
3 | jobs:
4 | test:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - name: Cancel running workflows
8 | uses: styfle/cancel-workflow-action@0.11.0
9 | with:
10 | access_token: ${{ github.token }}
11 | - name: Checkout repo
12 | uses: actions/checkout@v4
13 | - name: Set node version
14 | uses: actions/setup-node@v3
15 | with:
16 | node-version-file: '.nvmrc'
17 | - name: Set pnpm version
18 | uses: pnpm/action-setup@v2
19 | with:
20 | run_install: false
21 | version: 7
22 | - name: Cache node_modules
23 | id: node-modules-cache
24 | uses: actions/cache@v3
25 | with:
26 | path: '**/node_modules'
27 | key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
28 | - name: Install dependencies
29 | if: steps.node-modules-cache.outputs.cache-hit != 'true'
30 | run: pnpm install
31 | - name: Run tests
32 | run: pnpm test
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 | .playwright
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 | .pnpm-debug.log*
28 |
29 | # local env files
30 | .env*
31 | !.env.example
32 |
33 | # vercel
34 | .vercel
35 |
36 | # typescript
37 | *.tsbuildinfo
38 | next-env.d.ts
39 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 18
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .vercel
2 | .next
3 | pnpm-lock.yaml
4 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Next.js: debug server-side",
6 | "type": "node-terminal",
7 | "request": "launch",
8 | "command": "pnpm dev"
9 | },
10 | {
11 | "name": "Next.js: debug client-side",
12 | "type": "chrome",
13 | "request": "launch",
14 | "url": "http://localhost:3000"
15 | },
16 | {
17 | "name": "Next.js: debug full stack",
18 | "type": "node-terminal",
19 | "request": "launch",
20 | "command": "pnpm dev",
21 | "serverReadyAction": {
22 | "pattern": "started server on .+, url: (https?://.+)",
23 | "uriFormat": "%s",
24 | "action": "debugWithChrome"
25 | }
26 | }
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib",
3 | "typescript.enablePromptUseWorkspaceTsdk": true,
4 | "editor.codeActionsOnSave": {
5 | "source.fixAll": true,
6 | "source.organizeImports": true,
7 | "source.sortMembers": true
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](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,TWITTER_CREATOR,TWITTER_SITE)
2 |
3 | # Next.js Commerce
4 |
5 | A Next.js 13 and App Router-ready ecommerce template featuring:
6 |
7 | - Next.js App Router
8 | - Optimized for SEO using Next.js's Metadata
9 | - React Server Components (RSCs) and Suspense
10 | - Server Actions for mutations
11 | - Edge Runtime
12 | - New fetching and caching paradigms
13 | - Dynamic OG images
14 | - Styling with Tailwind CSS
15 | - Checkout and payments with Shopify
16 | - Automatic light/dark mode based on system settings
17 |
18 |
19 |
20 | > 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).
21 |
22 | ## Providers
23 |
24 | 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).
25 |
26 | 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.
27 |
28 | - Shopify (this repository)
29 | - [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
30 | - [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
31 | - [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
32 | - [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
33 | - [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
34 | - [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
35 | - [Wix](https://github.com/wix/nextjs-commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
36 |
37 | > 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).
38 |
39 | ## Integrations
40 |
41 | Integrations enable upgraded or additional functionality for Next.js Commerce
42 |
43 | - [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
44 | - Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
45 | - Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
46 |
47 | ## Running locally
48 |
49 | 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.
50 |
51 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
52 |
53 | 1. Install Vercel CLI: `npm i -g vercel`
54 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
55 | 3. Download your environment variables: `vercel env pull`
56 |
57 | ```bash
58 | pnpm install
59 | pnpm dev
60 | ```
61 |
62 | Your app should now be running on [localhost:3000](http://localhost:3000/).
63 |
64 |
65 | Expand if you work at Vercel and want to run locally and / or contribute
66 |
67 | 1. Run `vc link`.
68 | 1. Select the `Vercel Solutions` scope.
69 | 1. Connect to the existing `commerce-shopify` project.
70 | 1. Run `vc env pull` to get environment variables.
71 | 1. Run `pmpm dev` to ensure everything is working correctly.
72 |
73 |
74 | ## Vercel, Next.js Commerce, and Shopify Integration Guide
75 |
76 | You can use this comprehensive [integration guide](http://vercel.com/docs/integrations/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.
77 |
--------------------------------------------------------------------------------
/app/[page]/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from 'components/layout/footer';
2 | import { Suspense } from 'react';
3 |
4 | export default function Layout({ children }: { children: React.ReactNode }) {
5 | return (
6 |
7 |
8 |
9 | {children}
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/app/[page]/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import OpengraphImage from 'components/opengraph-image';
2 | import { getPage } from 'lib/shopify';
3 |
4 | export const runtime = 'edge';
5 |
6 | export default async function Image({ params }: { params: { page: string } }) {
7 | const page = await getPage(params.page);
8 | const title = page.seo?.title || page.title;
9 |
10 | return await OpengraphImage({ title });
11 | }
12 |
--------------------------------------------------------------------------------
/app/[page]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 |
3 | import Prose from 'components/prose';
4 | import { getPage } from 'lib/shopify';
5 | import { notFound } from 'next/navigation';
6 |
7 | export const runtime = 'edge';
8 |
9 | export const revalidate = 43200; // 12 hours in seconds
10 |
11 | export async function generateMetadata({
12 | params
13 | }: {
14 | params: { page: string };
15 | }): Promise {
16 | const page = await getPage(params.page);
17 |
18 | if (!page) return notFound();
19 |
20 | return {
21 | title: page.seo?.title || page.title,
22 | description: page.seo?.description || page.bodySummary,
23 | openGraph: {
24 | publishedTime: page.createdAt,
25 | modifiedTime: page.updatedAt,
26 | type: 'article'
27 | }
28 | };
29 | }
30 |
31 | export default async function Page({ params }: { params: { page: string } }) {
32 | const page = await getPage(params.page);
33 |
34 | if (!page) return notFound();
35 |
36 | return (
37 | <>
38 | {page.title}
39 |
40 |
41 | {`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
42 | year: 'numeric',
43 | month: 'long',
44 | day: 'numeric'
45 | }).format(new Date(page.updatedAt))}.`}
46 |
47 | >
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/app/api/revalidate/route.ts:
--------------------------------------------------------------------------------
1 | import { revalidate } from 'lib/shopify';
2 | import { NextRequest, NextResponse } from 'next/server';
3 |
4 | export const runtime = 'edge';
5 |
6 | export async function POST(req: NextRequest): Promise {
7 | return revalidate(req);
8 | }
9 |
--------------------------------------------------------------------------------
/app/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | export default function Error({ reset }: { reset: () => void }) {
4 | return (
5 |
6 |
Oh no!
7 |
8 | There was an issue with our storefront. This could be a temporary issue, please try your
9 | action again.
10 |
11 |
reset()}
14 | >
15 | Try Again
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oramasearch/nextjs-commerce/7e11ee3d470ea1d7698c0fea2473981ce9e3add9/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @media (prefers-color-scheme: dark) {
6 | html {
7 | color-scheme: dark;
8 | }
9 | }
10 |
11 | @supports (font: -apple-system-body) and (-webkit-appearance: none) {
12 | img[loading='lazy'] {
13 | clip-path: inset(0.6px);
14 | }
15 | }
16 |
17 | a,
18 | input,
19 | button {
20 | @apply focus-visible:outline-none 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;
21 | }
22 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from 'components/layout/navbar';
2 | import { ensureStartsWith } from 'lib/utils';
3 | import { Inter } from 'next/font/google';
4 | import { ReactNode, Suspense } from 'react';
5 | import './globals.css';
6 |
7 | const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
8 | const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
9 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
10 | : 'http://localhost:3000';
11 | const twitterCreator = TWITTER_CREATOR ? ensureStartsWith(TWITTER_CREATOR, '@') : undefined;
12 | const twitterSite = TWITTER_SITE ? ensureStartsWith(TWITTER_SITE, 'https://') : undefined;
13 |
14 | export const metadata = {
15 | metadataBase: new URL(baseUrl),
16 | title: {
17 | default: SITE_NAME!,
18 | template: `%s | ${SITE_NAME}`
19 | },
20 | robots: {
21 | follow: true,
22 | index: true
23 | },
24 | ...(twitterCreator &&
25 | twitterSite && {
26 | twitter: {
27 | card: 'summary_large_image',
28 | creator: twitterCreator,
29 | site: twitterSite
30 | }
31 | })
32 | };
33 |
34 | const inter = Inter({
35 | subsets: ['latin'],
36 | display: 'swap',
37 | variable: '--font-inter'
38 | });
39 |
40 | export default async function RootLayout({ children }: { children: ReactNode }) {
41 | return (
42 |
43 |
44 |
45 |
46 | {children}
47 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/app/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import OpengraphImage from 'components/opengraph-image';
2 |
3 | export const runtime = 'edge';
4 |
5 | export default async function Image() {
6 | return await OpengraphImage();
7 | }
8 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Carousel } from 'components/carousel';
2 | import { ThreeItemGrid } from 'components/grid/three-items';
3 | import Footer from 'components/layout/footer';
4 | import { Suspense } from 'react';
5 |
6 | export const runtime = 'edge';
7 |
8 | export const metadata = {
9 | description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
10 | openGraph: {
11 | type: 'website'
12 | }
13 | };
14 |
15 | export default async function HomePage() {
16 | return (
17 | <>
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | >
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/app/product/[handle]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { notFound } from 'next/navigation';
3 | import { Suspense } from 'react';
4 |
5 | import { GridTileImage } from 'components/grid/tile';
6 | import Footer from 'components/layout/footer';
7 | import { Gallery } from 'components/product/gallery';
8 | import { ProductDescription } from 'components/product/product-description';
9 | import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
10 | import { getProduct, getProductRecommendations } from 'lib/shopify';
11 | import { Image } from 'lib/shopify/types';
12 | import Link from 'next/link';
13 |
14 | export const runtime = 'edge';
15 |
16 | export async function generateMetadata({
17 | params
18 | }: {
19 | params: { handle: string };
20 | }): Promise {
21 | const product = await getProduct(params.handle);
22 |
23 | if (!product) return notFound();
24 |
25 | const { url, width, height, altText: alt } = product.featuredImage || {};
26 | const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
27 |
28 | return {
29 | title: product.seo.title || product.title,
30 | description: product.seo.description || product.description,
31 | robots: {
32 | index: indexable,
33 | follow: indexable,
34 | googleBot: {
35 | index: indexable,
36 | follow: indexable
37 | }
38 | },
39 | openGraph: url
40 | ? {
41 | images: [
42 | {
43 | url,
44 | width,
45 | height,
46 | alt
47 | }
48 | ]
49 | }
50 | : null
51 | };
52 | }
53 |
54 | export default async function ProductPage({ params }: { params: { handle: string } }) {
55 | const product = await getProduct(params.handle);
56 |
57 | if (!product) return notFound();
58 |
59 | const productJsonLd = {
60 | '@context': 'https://schema.org',
61 | '@type': 'Product',
62 | name: product.title,
63 | description: product.description,
64 | image: product.featuredImage.url,
65 | offers: {
66 | '@type': 'AggregateOffer',
67 | availability: product.availableForSale
68 | ? 'https://schema.org/InStock'
69 | : 'https://schema.org/OutOfStock',
70 | priceCurrency: product.priceRange.minVariantPrice.currencyCode,
71 | highPrice: product.priceRange.maxVariantPrice.amount,
72 | lowPrice: product.priceRange.minVariantPrice.amount
73 | }
74 | };
75 |
76 | return (
77 | <>
78 |
84 |
85 |
86 |
87 | ({
89 | src: image.url,
90 | altText: image.altText
91 | }))}
92 | />
93 |
94 |
95 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | >
107 | );
108 | }
109 |
110 | async function RelatedProducts({ id }: { id: string }) {
111 | const relatedProducts = await getProductRecommendations(id);
112 |
113 | if (!relatedProducts.length) return null;
114 |
115 | return (
116 |
117 |
Related Products
118 |
119 | {relatedProducts.map((product) => (
120 |
124 |
125 |
136 |
137 |
138 | ))}
139 |
140 |
141 | );
142 | }
143 |
--------------------------------------------------------------------------------
/app/robots.ts:
--------------------------------------------------------------------------------
1 | const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
2 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
3 | : 'http://localhost:3000';
4 |
5 | export default function robots() {
6 | return {
7 | rules: [
8 | {
9 | userAgent: '*'
10 | }
11 | ],
12 | sitemap: `${baseUrl}/sitemap.xml`,
13 | host: baseUrl
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/app/search/[collection]/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import OpengraphImage from 'components/opengraph-image';
2 | import { getCollection } from 'lib/shopify';
3 |
4 | export const runtime = 'edge';
5 |
6 | export default async function Image({ params }: { params: { collection: string } }) {
7 | const collection = await getCollection(params.collection);
8 | const title = collection?.seo?.title || collection?.title;
9 |
10 | return await OpengraphImage({ title });
11 | }
12 |
--------------------------------------------------------------------------------
/app/search/[collection]/page.tsx:
--------------------------------------------------------------------------------
1 | import { getCollection, getCollectionProducts } from 'lib/shopify';
2 | import { Metadata } from 'next';
3 | import { notFound } from 'next/navigation';
4 |
5 | import Grid from 'components/grid';
6 | import ProductGridItems from 'components/layout/product-grid-items';
7 | import { defaultSort, sorting } from 'lib/constants';
8 |
9 | export const runtime = 'edge';
10 |
11 | export async function generateMetadata({
12 | params
13 | }: {
14 | params: { collection: string };
15 | }): Promise {
16 | const collection = await getCollection(params.collection);
17 |
18 | if (!collection) return notFound();
19 |
20 | return {
21 | title: collection.seo?.title || collection.title,
22 | description:
23 | collection.seo?.description || collection.description || `${collection.title} products`
24 | };
25 | }
26 |
27 | export default async function CategoryPage({
28 | params,
29 | searchParams
30 | }: {
31 | params: { collection: string };
32 | searchParams?: { [key: string]: string | string[] | undefined };
33 | }) {
34 | const { sort } = searchParams as { [key: string]: string };
35 | const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
36 | const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
37 |
38 | return (
39 |
40 | {products.length === 0 ? (
41 | {`No products found in this collection`}
42 | ) : (
43 |
44 |
45 |
46 | )}
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/app/search/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from 'components/layout/footer';
2 | import Collections from 'components/layout/search/collections';
3 | import FilterList from 'components/layout/search/filter';
4 | import { sorting } from 'lib/constants';
5 | import { Suspense } from 'react';
6 |
7 | export default function SearchLayout({ children }: { children: React.ReactNode }) {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
{children}
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/app/search/loading.tsx:
--------------------------------------------------------------------------------
1 | import Grid from 'components/grid';
2 |
3 | export default function Loading() {
4 | return (
5 |
6 | {Array(12)
7 | .fill(0)
8 | .map((_, index) => {
9 | return (
10 |
11 | );
12 | })}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/app/search/page.tsx:
--------------------------------------------------------------------------------
1 | import Grid from 'components/grid';
2 | import ProductGridItems from 'components/layout/product-grid-items';
3 | import { orama, parseSorting } from 'lib/orama';
4 | import { Product } from 'lib/shopify/types';
5 |
6 | export const runtime = 'edge';
7 |
8 | export const metadata = {
9 | title: 'Search',
10 | description: 'Search for products in the store.'
11 | };
12 |
13 | export default async function SearchPage({
14 | searchParams
15 | }: {
16 | searchParams?: { [key: string]: string | string[] | undefined };
17 | }) {
18 | const { sort, q: searchValue } = searchParams as { [key: string]: string };
19 |
20 | const products = await orama.search({
21 | term: searchValue,
22 | boost: {
23 | title: 2
24 | },
25 | sortBy: parseSorting(sort),
26 | limit: 50,
27 | })
28 |
29 | const resultsText = products.count > 1 ? 'results' : 'result';
30 | const docs = products.hits.map((hit: any) => hit.document) as Product[];
31 |
32 | return (
33 | <>
34 | {searchValue ? (
35 |
36 | {products.count === 0
37 | ? 'There are no products that match '
38 | : `Showing ${products.count} ${resultsText} for `}
39 | "{searchValue}"
40 |
41 | ) : null}
42 | {products.count > 0 ? (
43 |
44 |
45 |
46 | ) : null}
47 | >
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import { getCollections, getPages, getProducts } from 'lib/shopify';
2 | import { validateEnvironmentVariables } from 'lib/utils';
3 | import { MetadataRoute } from 'next';
4 |
5 | type Route = {
6 | url: string;
7 | lastModified: string;
8 | };
9 |
10 | const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
11 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
12 | : 'http://localhost:3000';
13 |
14 | export default async function sitemap(): Promise {
15 | validateEnvironmentVariables();
16 |
17 | const routesMap = [''].map((route) => ({
18 | url: `${baseUrl}${route}`,
19 | lastModified: new Date().toISOString()
20 | }));
21 |
22 | const collectionsPromise = getCollections().then((collections) =>
23 | collections.map((collection) => ({
24 | url: `${baseUrl}${collection.path}`,
25 | lastModified: collection.updatedAt
26 | }))
27 | );
28 |
29 | const productsPromise = getProducts({}).then((products) =>
30 | products.map((product) => ({
31 | url: `${baseUrl}/product/${product.handle}`,
32 | lastModified: product.updatedAt
33 | }))
34 | );
35 |
36 | const pagesPromise = getPages().then((pages) =>
37 | pages.map((page) => ({
38 | url: `${baseUrl}/${page.handle}`,
39 | lastModified: page.updatedAt
40 | }))
41 | );
42 |
43 | let fetchedRoutes: Route[] = [];
44 |
45 | try {
46 | fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
47 | } catch (error) {
48 | throw JSON.stringify(error, null, 2);
49 | }
50 |
51 | return [...routesMap, ...fetchedRoutes];
52 | }
53 |
--------------------------------------------------------------------------------
/components/carousel.tsx:
--------------------------------------------------------------------------------
1 | import { getCollectionProducts } from 'lib/shopify';
2 | import Link from 'next/link';
3 | import { GridTileImage } from './grid/tile';
4 |
5 | export async function Carousel() {
6 | // Collections that start with `hidden-*` are hidden from the search page.
7 | const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
8 |
9 | if (!products?.length) return null;
10 |
11 | // Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
12 | const carouselProducts = [...products, ...products, ...products];
13 |
14 | return (
15 |
16 |
17 | {carouselProducts.map((product, i) => (
18 |
22 |
23 |
34 |
35 |
36 | ))}
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/components/cart/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
4 | import { cookies } from 'next/headers';
5 |
6 | export const addItem = async (variantId: string | undefined): Promise => {
7 | let cartId = cookies().get('cartId')?.value;
8 | let cart;
9 |
10 | if (cartId) {
11 | cart = await getCart(cartId);
12 | }
13 |
14 | if (!cartId || !cart) {
15 | cart = await createCart();
16 | cartId = cart.id;
17 | cookies().set('cartId', cartId);
18 | }
19 |
20 | if (!variantId) {
21 | return 'Missing product variant ID';
22 | }
23 |
24 | try {
25 | await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
26 | } catch (e) {
27 | return 'Error adding item to cart';
28 | }
29 | };
30 |
31 | export const removeItem = async (lineId: string): Promise => {
32 | const cartId = cookies().get('cartId')?.value;
33 |
34 | if (!cartId) {
35 | return 'Missing cart ID';
36 | }
37 | try {
38 | await removeFromCart(cartId, [lineId]);
39 | } catch (e) {
40 | return 'Error removing item from cart';
41 | }
42 | };
43 |
44 | export const updateItemQuantity = async ({
45 | lineId,
46 | variantId,
47 | quantity
48 | }: {
49 | lineId: string;
50 | variantId: string;
51 | quantity: number;
52 | }): Promise => {
53 | const cartId = cookies().get('cartId')?.value;
54 |
55 | if (!cartId) {
56 | return 'Missing cart ID';
57 | }
58 | try {
59 | await updateCart(cartId, [
60 | {
61 | id: lineId,
62 | merchandiseId: variantId,
63 | quantity
64 | }
65 | ]);
66 | } catch (e) {
67 | return 'Error updating item quantity';
68 | }
69 | };
70 |
--------------------------------------------------------------------------------
/components/cart/add-to-cart.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { PlusIcon } from '@heroicons/react/24/outline';
4 | import clsx from 'clsx';
5 | import { addItem } from 'components/cart/actions';
6 | import LoadingDots from 'components/loading-dots';
7 | import { ProductVariant } from 'lib/shopify/types';
8 | import { useRouter, useSearchParams } from 'next/navigation';
9 | import { useTransition } from 'react';
10 |
11 | export function AddToCart({
12 | variants,
13 | availableForSale
14 | }: {
15 | variants: ProductVariant[];
16 | availableForSale: boolean;
17 | }) {
18 | const router = useRouter();
19 | const searchParams = useSearchParams();
20 | const [isPending, startTransition] = useTransition();
21 | const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
22 | const variant = variants.find((variant: ProductVariant) =>
23 | variant.selectedOptions.every(
24 | (option) => option.value === searchParams.get(option.name.toLowerCase())
25 | )
26 | );
27 | const selectedVariantId = variant?.id || defaultVariantId;
28 | const title = !availableForSale
29 | ? 'Out of stock'
30 | : !selectedVariantId
31 | ? 'Please select options'
32 | : undefined;
33 |
34 | return (
35 | {
40 | // Safeguard in case someone messes with `disabled` in devtools.
41 | if (!availableForSale || !selectedVariantId) return;
42 |
43 | startTransition(async () => {
44 | const error = await addItem(selectedVariantId);
45 |
46 | if (error) {
47 | // Trigger the error boundary in the root error.js
48 | throw new Error(error.toString());
49 | }
50 |
51 | router.refresh();
52 | });
53 | }}
54 | className={clsx(
55 | 'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90',
56 | {
57 | 'cursor-not-allowed opacity-60 hover:opacity-60': !availableForSale || !selectedVariantId,
58 | 'cursor-not-allowed': isPending
59 | }
60 | )}
61 | >
62 |
63 | {!isPending ?
:
}
64 |
65 | {availableForSale ? 'Add To Cart' : 'Out Of Stock'}
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/components/cart/close-cart.tsx:
--------------------------------------------------------------------------------
1 | import { XMarkIcon } from '@heroicons/react/24/outline';
2 | import clsx from 'clsx';
3 |
4 | export default function CloseCart({ className }: { className?: string }) {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/components/cart/delete-item-button.tsx:
--------------------------------------------------------------------------------
1 | import { XMarkIcon } from '@heroicons/react/24/outline';
2 | import LoadingDots from 'components/loading-dots';
3 | import { useRouter } from 'next/navigation';
4 |
5 | import clsx from 'clsx';
6 | import { removeItem } from 'components/cart/actions';
7 | import type { CartItem } from 'lib/shopify/types';
8 | import { useTransition } from 'react';
9 |
10 | export default function DeleteItemButton({ item }: { item: CartItem }) {
11 | const router = useRouter();
12 | const [isPending, startTransition] = useTransition();
13 |
14 | return (
15 | {
18 | startTransition(async () => {
19 | const error = await removeItem(item.id);
20 |
21 | if (error) {
22 | // Trigger the error boundary in the root error.js
23 | throw new Error(error.toString());
24 | }
25 |
26 | router.refresh();
27 | });
28 | }}
29 | disabled={isPending}
30 | className={clsx(
31 | 'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200',
32 | {
33 | 'cursor-not-allowed px-0': isPending
34 | }
35 | )}
36 | >
37 | {isPending ? (
38 |
39 | ) : (
40 |
41 | )}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/components/cart/edit-item-quantity-button.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/navigation';
2 | import { useTransition } from 'react';
3 |
4 | import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
5 | import clsx from 'clsx';
6 | import { removeItem, updateItemQuantity } from 'components/cart/actions';
7 | import LoadingDots from 'components/loading-dots';
8 | import type { CartItem } from 'lib/shopify/types';
9 |
10 | export default function EditItemQuantityButton({
11 | item,
12 | type
13 | }: {
14 | item: CartItem;
15 | type: 'plus' | 'minus';
16 | }) {
17 | const router = useRouter();
18 | const [isPending, startTransition] = useTransition();
19 |
20 | return (
21 | {
24 | startTransition(async () => {
25 | const error =
26 | type === 'minus' && item.quantity - 1 === 0
27 | ? await removeItem(item.id)
28 | : await updateItemQuantity({
29 | lineId: item.id,
30 | variantId: item.merchandise.id,
31 | quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
32 | });
33 |
34 | if (error) {
35 | // Trigger the error boundary in the root error.js
36 | throw new Error(error.toString());
37 | }
38 |
39 | router.refresh();
40 | });
41 | }}
42 | disabled={isPending}
43 | className={clsx(
44 | 'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
45 | {
46 | 'cursor-not-allowed': isPending,
47 | 'ml-auto': type === 'minus'
48 | }
49 | )}
50 | >
51 | {isPending ? (
52 |
53 | ) : type === 'plus' ? (
54 |
55 | ) : (
56 |
57 | )}
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/components/cart/index.tsx:
--------------------------------------------------------------------------------
1 | import { getCart } from 'lib/shopify';
2 | import { cookies } from 'next/headers';
3 | import CartModal from './modal';
4 |
5 | export default async function Cart() {
6 | const cartId = cookies().get('cartId')?.value;
7 | let cart;
8 |
9 | if (cartId) {
10 | cart = await getCart(cartId);
11 | }
12 |
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/components/cart/modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Dialog, Transition } from '@headlessui/react';
4 | import { ShoppingCartIcon } from '@heroicons/react/24/outline';
5 | import Price from 'components/price';
6 | import { DEFAULT_OPTION } from 'lib/constants';
7 | import type { Cart } from 'lib/shopify/types';
8 | import { createUrl } from 'lib/utils';
9 | import Image from 'next/image';
10 | import Link from 'next/link';
11 | import { Fragment, useEffect, useRef, useState } from 'react';
12 | import CloseCart from './close-cart';
13 | import DeleteItemButton from './delete-item-button';
14 | import EditItemQuantityButton from './edit-item-quantity-button';
15 | import OpenCart from './open-cart';
16 |
17 | type MerchandiseSearchParams = {
18 | [key: string]: string;
19 | };
20 |
21 | export default function CartModal({ cart }: { cart: Cart | undefined }) {
22 | const [isOpen, setIsOpen] = useState(false);
23 | const quantityRef = useRef(cart?.totalQuantity);
24 | const openCart = () => setIsOpen(true);
25 | const closeCart = () => setIsOpen(false);
26 |
27 | useEffect(() => {
28 | // Open cart modal when quantity changes.
29 | if (cart?.totalQuantity !== quantityRef.current) {
30 | // But only if it's not already open (quantity also changes when editing items in cart).
31 | if (!isOpen) {
32 | setIsOpen(true);
33 | }
34 |
35 | // Always update the quantity reference
36 | quantityRef.current = cart?.totalQuantity;
37 | }
38 | }, [isOpen, cart?.totalQuantity, quantityRef]);
39 |
40 | return (
41 | <>
42 |
43 |
44 |
45 |
46 |
47 |
56 |
57 |
58 |
67 |
68 |
69 |
My Cart
70 |
71 |
72 |
73 |
74 |
75 |
76 | {!cart || cart.lines.length === 0 ? (
77 |
78 |
79 |
Your cart is empty.
80 |
81 | ) : (
82 |
83 |
84 | {cart.lines.map((item, i) => {
85 | const merchandiseSearchParams = {} as MerchandiseSearchParams;
86 |
87 | item.merchandise.selectedOptions.forEach(({ name, value }) => {
88 | if (value !== DEFAULT_OPTION) {
89 | merchandiseSearchParams[name.toLowerCase()] = value;
90 | }
91 | });
92 |
93 | const merchandiseUrl = createUrl(
94 | `/product/${item.merchandise.product.handle}`,
95 | new URLSearchParams(merchandiseSearchParams)
96 | );
97 |
98 | return (
99 |
103 |
104 |
105 |
106 |
107 |
112 |
113 |
123 |
124 |
125 |
126 |
127 | {item.merchandise.product.title}
128 |
129 | {item.merchandise.title !== DEFAULT_OPTION ? (
130 |
131 | {item.merchandise.title}
132 |
133 | ) : null}
134 |
135 |
136 |
137 |
142 |
143 |
144 |
145 | {item.quantity}
146 |
147 |
148 |
149 |
150 |
151 |
152 | );
153 | })}
154 |
155 |
156 |
164 |
165 |
Shipping
166 |
Calculated at checkout
167 |
168 |
176 |
177 |
181 | Proceed to Checkout
182 |
183 |
184 | )}
185 |
186 |
187 |
188 |
189 | >
190 | );
191 | }
192 |
--------------------------------------------------------------------------------
/components/cart/open-cart.tsx:
--------------------------------------------------------------------------------
1 | import { ShoppingCartIcon } from '@heroicons/react/24/outline';
2 | import clsx from 'clsx';
3 |
4 | export default function OpenCart({
5 | className,
6 | quantity
7 | }: {
8 | className?: string;
9 | quantity?: number;
10 | }) {
11 | return (
12 |
13 |
16 |
17 | {quantity ? (
18 |
19 | {quantity}
20 |
21 | ) : null}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/components/grid/index.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | function Grid(props: React.ComponentProps<'ul'>) {
4 | return (
5 |
8 | );
9 | }
10 |
11 | function GridItem(props: React.ComponentProps<'li'>) {
12 | return (
13 |
14 | {props.children}
15 |
16 | );
17 | }
18 |
19 | Grid.Item = GridItem;
20 |
21 | export default Grid;
22 |
--------------------------------------------------------------------------------
/components/grid/three-items.tsx:
--------------------------------------------------------------------------------
1 | import { GridTileImage } from 'components/grid/tile';
2 | import { getCollectionProducts } from 'lib/shopify';
3 | import type { Product } from 'lib/shopify/types';
4 | import Link from 'next/link';
5 |
6 | function ThreeItemGridItem({
7 | item,
8 | size,
9 | priority
10 | }: {
11 | item: Product;
12 | size: 'full' | 'half';
13 | priority?: boolean;
14 | }) {
15 | return (
16 |
19 |
20 |
35 |
36 |
37 | );
38 | }
39 |
40 | export async function ThreeItemGrid() {
41 | // Collections that start with `hidden-*` are hidden from the search page.
42 | const homepageItems = await getCollectionProducts({
43 | collection: 'hidden-homepage-featured-items'
44 | });
45 |
46 | if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
47 |
48 | const [firstProduct, secondProduct, thirdProduct] = homepageItems;
49 |
50 | return (
51 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/components/grid/tile.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import Image from 'next/image';
3 | import Label from '../label';
4 |
5 | export function GridTileImage({
6 | isInteractive = true,
7 | active,
8 | label,
9 | ...props
10 | }: {
11 | isInteractive?: boolean;
12 | active?: boolean;
13 | label?: {
14 | title: string;
15 | amount: string;
16 | currencyCode: string;
17 | position?: 'bottom' | 'center';
18 | };
19 | } & React.ComponentProps) {
20 | return (
21 |
31 | {props.src ? (
32 | // eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is being enforced with TypeScript
33 |
39 | ) : null}
40 | {label ? (
41 |
47 | ) : null}
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/components/icons/logo.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | export default function LogoIcon(props: React.ComponentProps<'svg'>) {
4 | return (
5 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components/label.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import Price from './price';
3 |
4 | const Label = ({
5 | title,
6 | amount,
7 | currencyCode,
8 | position = 'bottom'
9 | }: {
10 | title: string;
11 | amount: string;
12 | currencyCode: string;
13 | position?: 'bottom' | 'center';
14 | }) => {
15 | return (
16 |
31 | );
32 | };
33 |
34 | export default Label;
35 |
--------------------------------------------------------------------------------
/components/layout/footer-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import { Menu } from 'lib/shopify/types';
5 | import Link from 'next/link';
6 | import { usePathname } from 'next/navigation';
7 | import { useEffect, useState } from 'react';
8 |
9 | const FooterMenuItem = ({ item }: { item: Menu }) => {
10 | const pathname = usePathname();
11 | const [active, setActive] = useState(pathname === item.path);
12 |
13 | useEffect(() => {
14 | setActive(pathname === item.path);
15 | }, [pathname, item.path]);
16 |
17 | return (
18 |
19 |
28 | {item.title}
29 |
30 |
31 | );
32 | };
33 |
34 | export default function FooterMenu({ menu }: { menu: Menu[] }) {
35 | if (!menu.length) return null;
36 |
37 | return (
38 |
39 |
40 | {menu.map((item: Menu) => {
41 | return ;
42 | })}
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/components/layout/footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | import FooterMenu from 'components/layout/footer-menu';
4 | import LogoSquare from 'components/logo-square';
5 | import { getMenu } from 'lib/shopify';
6 | import { Suspense } from 'react';
7 |
8 | const { COMPANY_NAME, SITE_NAME } = process.env;
9 |
10 | export default async function Footer() {
11 | const currentYear = new Date().getFullYear();
12 | const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
13 | const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700';
14 | const menu = await getMenu('next-js-frontend-footer-menu');
15 | const copyrightName = COMPANY_NAME || SITE_NAME || '';
16 |
17 | return (
18 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/components/layout/navbar/index.tsx:
--------------------------------------------------------------------------------
1 | import Cart from 'components/cart';
2 | import OpenCart from 'components/cart/open-cart';
3 | import LogoSquare from 'components/logo-square';
4 | import { getMenu } from 'lib/shopify';
5 | import { Menu } from 'lib/shopify/types';
6 | import Link from 'next/link';
7 | import { Suspense } from 'react';
8 | import MobileMenu from './mobile-menu';
9 | import Search from './search';
10 | const { SITE_NAME } = process.env;
11 |
12 | export default async function Navbar() {
13 | const menu = await getMenu('next-js-frontend-header-menu');
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {SITE_NAME}
26 |
27 |
28 | {menu.length ? (
29 |
30 | {menu.map((item: Menu) => (
31 |
32 |
36 | {item.title}
37 |
38 |
39 | ))}
40 |
41 | ) : null}
42 |
43 |
44 |
45 |
46 |
47 | }>
48 |
49 |
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/components/layout/navbar/mobile-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Dialog, Transition } from '@headlessui/react';
4 | import Link from 'next/link';
5 | import { usePathname, useSearchParams } from 'next/navigation';
6 | import { Fragment, useEffect, useState } from 'react';
7 |
8 | import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
9 | import { Menu } from 'lib/shopify/types';
10 | import Search from './search';
11 |
12 | export default function MobileMenu({ menu }: { menu: Menu[] }) {
13 | const pathname = usePathname();
14 | const searchParams = useSearchParams();
15 | const [isOpen, setIsOpen] = useState(false);
16 | const openMobileMenu = () => setIsOpen(true);
17 | const closeMobileMenu = () => setIsOpen(false);
18 |
19 | useEffect(() => {
20 | const handleResize = () => {
21 | if (window.innerWidth > 768) {
22 | setIsOpen(false);
23 | }
24 | };
25 | window.addEventListener('resize', handleResize);
26 | return () => window.removeEventListener('resize', handleResize);
27 | }, [isOpen]);
28 |
29 | useEffect(() => {
30 | setIsOpen(false);
31 | }, [pathname, searchParams]);
32 |
33 | return (
34 | <>
35 |
40 |
41 |
42 |
43 |
44 |
53 |
54 |
55 |
64 |
65 |
66 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | {menu.length ? (
78 |
79 | {menu.map((item: Menu) => (
80 |
84 |
85 | {item.title}
86 |
87 |
88 | ))}
89 |
90 | ) : null}
91 |
92 |
93 |
94 |
95 |
96 | >
97 | );
98 | }
99 |
--------------------------------------------------------------------------------
/components/layout/navbar/search.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5 | import { useEffect, useRef, useState } from 'react';
6 |
7 | import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
8 | import { Results } from '@orama/orama';
9 | import { orama, trimDescription } from 'lib/orama';
10 | import { createUrl } from 'lib/utils';
11 | import { useOutsideClick } from './useOutsideClick';
12 |
13 | export default function Search() {
14 | const router = useRouter();
15 | const searchParams = useSearchParams();
16 | const [searchValue, setSearchValue] = useState('');
17 | const [searchResults, setSearchResults] = useState>();
18 | const isSearchPage = usePathname() === '/search'
19 |
20 |
21 | useEffect(() => {
22 | setSearchValue(searchParams?.get('q') || '');
23 | }, [searchParams, setSearchValue]);
24 |
25 | useEffect(() => {
26 |
27 | if (isSearchPage) {
28 | router.push(createUrl('/search', new URLSearchParams({ q: searchValue })))
29 | } else {
30 | orama.search({
31 | term: searchValue,
32 | limit: 5,
33 | threshold: 0,
34 | boost: {
35 | title: 2,
36 | }
37 | })
38 | .then(setSearchResults)
39 | .catch(console.log);
40 | }
41 |
42 | }, [searchValue]);
43 |
44 | function onSubmit(e: React.FormEvent) {
45 | e.preventDefault();
46 |
47 | const val = e.target as HTMLFormElement;
48 | const search = val.search as HTMLInputElement;
49 | const newParams = new URLSearchParams(searchParams.toString());
50 |
51 | if (search.value) {
52 | newParams.set('q', search.value);
53 | } else {
54 | newParams.delete('q');
55 | }
56 |
57 | router.push(createUrl('/search', newParams));
58 | }
59 |
60 | const searchResultsRef = useRef(null);
61 |
62 | useOutsideClick(searchResultsRef.current, () => {
63 | setSearchValue('');
64 | });
65 |
66 | const showSearchResults = searchValue.length > 0 && !!searchResults && !isSearchPage;
67 |
68 | return (
69 |
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/components/layout/navbar/useOutsideClick.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | export function useOutsideClick(ref: any, onClickOut: () => void, deps = []){
4 | useEffect(() => {
5 | const onClick = ({target}: any) => !ref?.contains(target) && onClickOut?.()
6 | document.addEventListener("click", onClick);
7 | return () => document.removeEventListener("click", onClick);
8 | }, deps);
9 | }
--------------------------------------------------------------------------------
/components/layout/product-grid-items.tsx:
--------------------------------------------------------------------------------
1 | import Grid from 'components/grid';
2 | import { GridTileImage } from 'components/grid/tile';
3 | import { Product } from 'lib/shopify/types';
4 | import Link from 'next/link';
5 |
6 | export default function ProductGridItems({ products }: { products: Product[] }) {
7 | return (
8 | <>
9 | {products.map((product) => (
10 |
11 |
12 |
24 |
25 |
26 | ))}
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/components/layout/search/collections.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { Suspense } from 'react';
3 |
4 | import { getCollections } from 'lib/shopify';
5 | import FilterList from './filter';
6 |
7 | async function CollectionList() {
8 | const collections = await getCollections();
9 | return ;
10 | }
11 |
12 | const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
13 | const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
14 | const items = 'bg-neutral-400 dark:bg-neutral-700';
15 |
16 | export default function Collections() {
17 | return (
18 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | }
33 | >
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/components/layout/search/filter/dropdown.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname, useSearchParams } from 'next/navigation';
4 | import { useEffect, useRef, useState } from 'react';
5 |
6 | import { ChevronDownIcon } from '@heroicons/react/24/outline';
7 | import type { ListItem } from '.';
8 | import { FilterItem } from './item';
9 |
10 | export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
11 | const pathname = usePathname();
12 | const searchParams = useSearchParams();
13 | const [active, setActive] = useState('');
14 | const [openSelect, setOpenSelect] = useState(false);
15 | const ref = useRef(null);
16 |
17 | useEffect(() => {
18 | const handleClickOutside = (event: MouseEvent) => {
19 | if (ref.current && !ref.current.contains(event.target as Node)) {
20 | setOpenSelect(false);
21 | }
22 | };
23 |
24 | window.addEventListener('click', handleClickOutside);
25 | return () => window.removeEventListener('click', handleClickOutside);
26 | }, []);
27 |
28 | useEffect(() => {
29 | list.forEach((listItem: ListItem) => {
30 | if (
31 | ('path' in listItem && pathname === listItem.path) ||
32 | ('slug' in listItem && searchParams.get('sort') === listItem.slug)
33 | ) {
34 | setActive(listItem.title);
35 | }
36 | });
37 | }, [pathname, list, searchParams]);
38 |
39 | return (
40 |
41 |
{
43 | setOpenSelect(!openSelect);
44 | }}
45 | className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"
46 | >
47 |
{active}
48 |
49 |
50 | {openSelect && (
51 |
{
53 | setOpenSelect(false);
54 | }}
55 | className="absolute z-40 w-full rounded-b-md bg-white p-4 shadow-md dark:bg-black"
56 | >
57 | {list.map((item: ListItem, i) => (
58 |
59 | ))}
60 |
61 | )}
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/components/layout/search/filter/index.tsx:
--------------------------------------------------------------------------------
1 | import { SortFilterItem } from 'lib/constants';
2 | import FilterItemDropdown from './dropdown';
3 | import { FilterItem } from './item';
4 |
5 | export type ListItem = SortFilterItem | PathFilterItem;
6 | export type PathFilterItem = { title: string; path: string };
7 |
8 | function FilterItemList({ list }: { list: ListItem[] }) {
9 | return (
10 | <>
11 | {list.map((item: ListItem, i) => (
12 |
13 | ))}
14 | >
15 | );
16 | }
17 |
18 | export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
19 | return (
20 | <>
21 |
22 | {title ? {title} : null}
23 |
26 |
29 |
30 | >
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/components/layout/search/filter/item.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import { SortFilterItem } from 'lib/constants';
5 | import { createUrl } from 'lib/utils';
6 | import Link from 'next/link';
7 | import { usePathname, useSearchParams } from 'next/navigation';
8 | import type { ListItem, PathFilterItem } from '.';
9 |
10 | function PathFilterItem({ item }: { item: PathFilterItem }) {
11 | const pathname = usePathname();
12 | const searchParams = useSearchParams();
13 | const active = pathname === item.path;
14 | const newParams = new URLSearchParams(searchParams.toString());
15 | const DynamicTag = active ? 'p' : Link;
16 |
17 | newParams.delete('q');
18 |
19 | return (
20 |
21 |
30 | {item.title}
31 |
32 |
33 | );
34 | }
35 |
36 | function SortFilterItem({ item }: { item: SortFilterItem }) {
37 | const pathname = usePathname();
38 | const searchParams = useSearchParams();
39 | const active = searchParams.get('sort') === item.slug;
40 | const q = searchParams.get('q');
41 | const href = createUrl(
42 | pathname,
43 | new URLSearchParams({
44 | ...(q && { q }),
45 | ...(item.slug && item.slug.length && { sort: item.slug })
46 | })
47 | );
48 | const DynamicTag = active ? 'p' : Link;
49 |
50 | return (
51 |
52 |
59 | {item.title}
60 |
61 |
62 | );
63 | }
64 |
65 | export function FilterItem({ item }: { item: ListItem }) {
66 | return 'path' in item ? : ;
67 | }
68 |
--------------------------------------------------------------------------------
/components/loading-dots.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md';
4 |
5 | const LoadingDots = ({ className }: { className: string }) => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | );
13 | };
14 |
15 | export default LoadingDots;
16 |
--------------------------------------------------------------------------------
/components/logo-square.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import LogoIcon from './icons/logo';
3 |
4 | export default function LogoSquare({ size }: { size?: 'sm' | undefined }) {
5 | return (
6 |
15 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/components/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import { ImageResponse } from 'next/server';
2 | import LogoIcon from './icons/logo';
3 |
4 | export type Props = {
5 | title?: string;
6 | };
7 |
8 | export default async function OpengraphImage(props?: Props): Promise {
9 | const { title } = {
10 | ...{
11 | title: process.env.SITE_NAME
12 | },
13 | ...props
14 | };
15 |
16 | return new ImageResponse(
17 | (
18 |
19 |
20 |
21 |
22 |
{title}
23 |
24 | ),
25 | {
26 | width: 1200,
27 | height: 630,
28 | fonts: [
29 | {
30 | name: 'Inter',
31 | data: await fetch(new URL('../fonts/Inter-Bold.ttf', import.meta.url)).then((res) =>
32 | res.arrayBuffer()
33 | ),
34 | style: 'normal',
35 | weight: 700
36 | }
37 | ]
38 | }
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/components/price.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | const Price = ({
4 | amount,
5 | className,
6 | currencyCode = 'USD',
7 | currencyCodeClassName
8 | }: {
9 | amount: string;
10 | className?: string;
11 | currencyCode: string;
12 | currencyCodeClassName?: string;
13 | } & React.ComponentProps<'p'>) => (
14 |
15 | {`${new Intl.NumberFormat(undefined, {
16 | style: 'currency',
17 | currency: currencyCode,
18 | currencyDisplay: 'narrowSymbol'
19 | }).format(parseFloat(amount))}`}
20 | {`${currencyCode}`}
21 |
22 | );
23 |
24 | export default Price;
25 |
--------------------------------------------------------------------------------
/components/product/gallery.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
4 | import { GridTileImage } from 'components/grid/tile';
5 | import { createUrl } from 'lib/utils';
6 | import Image from 'next/image';
7 | import Link from 'next/link';
8 | import { usePathname, useSearchParams } from 'next/navigation';
9 |
10 | export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
11 | const pathname = usePathname();
12 | const searchParams = useSearchParams();
13 | const imageSearchParam = searchParams.get('image');
14 | const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
15 |
16 | const nextSearchParams = new URLSearchParams(searchParams.toString());
17 | const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
18 | nextSearchParams.set('image', nextImageIndex.toString());
19 | const nextUrl = createUrl(pathname, nextSearchParams);
20 |
21 | const previousSearchParams = new URLSearchParams(searchParams.toString());
22 | const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
23 | previousSearchParams.set('image', previousImageIndex.toString());
24 | const previousUrl = createUrl(pathname, previousSearchParams);
25 |
26 | const buttonClassName =
27 | 'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center';
28 |
29 | return (
30 | <>
31 |
32 | {images[imageIndex] && (
33 |
41 | )}
42 |
43 | {images.length > 1 ? (
44 |
45 |
46 |
52 |
53 |
54 |
55 |
61 |
62 |
63 |
64 |
65 | ) : null}
66 |
67 |
68 | {images.length > 1 ? (
69 |
70 | {images.map((image, index) => {
71 | const isActive = index === imageIndex;
72 | const imageSearchParams = new URLSearchParams(searchParams.toString());
73 |
74 | imageSearchParams.set('image', index.toString());
75 |
76 | return (
77 |
78 |
84 |
91 |
92 |
93 | );
94 | })}
95 |
96 | ) : null}
97 | >
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/components/product/product-description.tsx:
--------------------------------------------------------------------------------
1 | import { AddToCart } from 'components/cart/add-to-cart';
2 | import Price from 'components/price';
3 | import Prose from 'components/prose';
4 | import { Product } from 'lib/shopify/types';
5 | import { VariantSelector } from './variant-selector';
6 |
7 | export function ProductDescription({ product }: { product: Product }) {
8 | return (
9 | <>
10 |
11 |
{product.title}
12 |
18 |
19 |
20 |
21 | {product.descriptionHtml ? (
22 |
26 | ) : null}
27 |
28 |
29 | >
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/components/product/variant-selector.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import { ProductOption, ProductVariant } from 'lib/shopify/types';
5 | import { createUrl } from 'lib/utils';
6 | import { usePathname, useRouter, useSearchParams } from 'next/navigation';
7 |
8 | type Combination = {
9 | id: string;
10 | availableForSale: boolean;
11 | [key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
12 | };
13 |
14 | export function VariantSelector({
15 | options,
16 | variants
17 | }: {
18 | options: ProductOption[];
19 | variants: ProductVariant[];
20 | }) {
21 | const router = useRouter();
22 | const pathname = usePathname();
23 | const searchParams = useSearchParams();
24 | const hasNoOptionsOrJustOneOption =
25 | !options.length || (options.length === 1 && options[0]?.values.length === 1);
26 |
27 | if (hasNoOptionsOrJustOneOption) {
28 | return null;
29 | }
30 |
31 | const combinations: Combination[] = variants.map((variant) => ({
32 | id: variant.id,
33 | availableForSale: variant.availableForSale,
34 | // Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M").
35 | ...variant.selectedOptions.reduce(
36 | (accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
37 | {}
38 | )
39 | }));
40 |
41 | return options.map((option) => (
42 |
43 | {option.name}
44 |
45 | {option.values.map((value) => {
46 | const optionNameLowerCase = option.name.toLowerCase();
47 |
48 | // Base option params on current params so we can preserve any other param state in the url.
49 | const optionSearchParams = new URLSearchParams(searchParams.toString());
50 |
51 | // Update the option params using the current option to reflect how the url *would* change,
52 | // if the option was clicked.
53 | optionSearchParams.set(optionNameLowerCase, value);
54 | const optionUrl = createUrl(pathname, optionSearchParams);
55 |
56 | // In order to determine if an option is available for sale, we need to:
57 | //
58 | // 1. Filter out all other param state
59 | // 2. Filter out invalid options
60 | // 3. Check if the option combination is available for sale
61 | //
62 | // This is the "magic" that will cross check possible variant combinations and preemptively
63 | // disable combinations that are not available. For example, if the color gray is only available in size medium,
64 | // then all other sizes should be disabled.
65 | const filtered = Array.from(optionSearchParams.entries()).filter(([key, value]) =>
66 | options.find(
67 | (option) => option.name.toLowerCase() === key && option.values.includes(value)
68 | )
69 | );
70 | const isAvailableForSale = combinations.find((combination) =>
71 | filtered.every(
72 | ([key, value]) => combination[key] === value && combination.availableForSale
73 | )
74 | );
75 |
76 | // The option is active if it's in the url params.
77 | const isActive = searchParams.get(optionNameLowerCase) === value;
78 |
79 | return (
80 | {
85 | router.replace(optionUrl, { scroll: false });
86 | }}
87 | title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
88 | className={clsx(
89 | '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',
90 | {
91 | 'cursor-default ring-2 ring-blue-600': isActive,
92 | 'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-blue-600 ':
93 | !isActive && isAvailableForSale,
94 | '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 before:dark:bg-neutral-700':
95 | !isAvailableForSale
96 | }
97 | )}
98 | >
99 | {value}
100 |
101 | );
102 | })}
103 |
104 |
105 | ));
106 | }
107 |
--------------------------------------------------------------------------------
/components/prose.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import type { FunctionComponent } from 'react';
3 |
4 | interface TextProps {
5 | html: string;
6 | className?: string;
7 | }
8 |
9 | const Prose: FunctionComponent = ({ html, className }) => {
10 | return (
11 |
18 | );
19 | };
20 |
21 | export default Prose;
22 |
--------------------------------------------------------------------------------
/fonts/Inter-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oramasearch/nextjs-commerce/7e11ee3d470ea1d7698c0fea2473981ce9e3add9/fonts/Inter-Bold.ttf
--------------------------------------------------------------------------------
/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export type SortFilterItem = {
2 | title: string;
3 | slug: string | null;
4 | sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE';
5 | reverse: boolean;
6 | };
7 |
8 | export const defaultSort: SortFilterItem = {
9 | title: 'Relevance',
10 | slug: null,
11 | sortKey: 'RELEVANCE',
12 | reverse: false
13 | };
14 |
15 | export const sorting: SortFilterItem[] = [
16 | defaultSort,
17 | { title: 'Price: Low to high', slug: 'price-asc', sortKey: 'PRICE', reverse: false }, // asc
18 | { title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
19 | ];
20 |
21 | export const TAGS = {
22 | collections: 'collections',
23 | products: 'products'
24 | };
25 |
26 | export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
27 | export const DEFAULT_OPTION = 'Default Title';
28 | export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
29 |
--------------------------------------------------------------------------------
/lib/orama/index.ts:
--------------------------------------------------------------------------------
1 | import type { SorterParams } from '@orama/orama'
2 | import { OramaClient } from '@oramacloud/client'
3 |
4 | const ORAMA_API_KEY = process.env.NEXT_PUBLIC_ORAMA_API_KEY!
5 | const ORAMA_ENDPOINT = process.env.NEXT_PUBLIC_ORAMA_ENDPOINT!
6 |
7 | export const orama = new OramaClient({
8 | endpoint: ORAMA_ENDPOINT,
9 | api_key: ORAMA_API_KEY
10 | })
11 |
12 | export function trimDescription(description: string, maxSize = 80) {
13 | if (description.length > maxSize) {
14 | return `${description.substring(0, maxSize)}...`
15 | }
16 | return description
17 | }
18 |
19 | export function parseSorting(sorting: string | undefined): SorterParams | undefined {
20 | switch (sorting) {
21 | case 'price-asc':
22 | return {
23 | property: 'priceRange.max',
24 | order: 'ASC'
25 | }
26 | case 'price-desc':
27 | return {
28 | property: 'priceRange.max',
29 | order: 'DESC'
30 | }
31 | default:
32 | return undefined
33 | }
34 | }
--------------------------------------------------------------------------------
/lib/shopify/fragments/cart.ts:
--------------------------------------------------------------------------------
1 | import productFragment from './product';
2 |
3 | const cartFragment = /* GraphQL */ `
4 | fragment cart on Cart {
5 | id
6 | checkoutUrl
7 | cost {
8 | subtotalAmount {
9 | amount
10 | currencyCode
11 | }
12 | totalAmount {
13 | amount
14 | currencyCode
15 | }
16 | totalTaxAmount {
17 | amount
18 | currencyCode
19 | }
20 | }
21 | lines(first: 100) {
22 | edges {
23 | node {
24 | id
25 | quantity
26 | cost {
27 | totalAmount {
28 | amount
29 | currencyCode
30 | }
31 | }
32 | merchandise {
33 | ... on ProductVariant {
34 | id
35 | title
36 | selectedOptions {
37 | name
38 | value
39 | }
40 | product {
41 | ...product
42 | }
43 | }
44 | }
45 | }
46 | }
47 | }
48 | totalQuantity
49 | }
50 | ${productFragment}
51 | `;
52 |
53 | export default cartFragment;
54 |
--------------------------------------------------------------------------------
/lib/shopify/fragments/image.ts:
--------------------------------------------------------------------------------
1 | const imageFragment = /* GraphQL */ `
2 | fragment image on Image {
3 | url
4 | altText
5 | width
6 | height
7 | }
8 | `;
9 |
10 | export default imageFragment;
11 |
--------------------------------------------------------------------------------
/lib/shopify/fragments/product.ts:
--------------------------------------------------------------------------------
1 | import imageFragment from './image';
2 | import seoFragment from './seo';
3 |
4 | const productFragment = /* GraphQL */ `
5 | fragment product on Product {
6 | id
7 | handle
8 | availableForSale
9 | title
10 | description
11 | descriptionHtml
12 | options {
13 | id
14 | name
15 | values
16 | }
17 | priceRange {
18 | maxVariantPrice {
19 | amount
20 | currencyCode
21 | }
22 | minVariantPrice {
23 | amount
24 | currencyCode
25 | }
26 | }
27 | variants(first: 250) {
28 | edges {
29 | node {
30 | id
31 | title
32 | availableForSale
33 | selectedOptions {
34 | name
35 | value
36 | }
37 | price {
38 | amount
39 | currencyCode
40 | }
41 | }
42 | }
43 | }
44 | featuredImage {
45 | ...image
46 | }
47 | images(first: 20) {
48 | edges {
49 | node {
50 | ...image
51 | }
52 | }
53 | }
54 | seo {
55 | ...seo
56 | }
57 | tags
58 | updatedAt
59 | }
60 | ${imageFragment}
61 | ${seoFragment}
62 | `;
63 |
64 | export default productFragment;
65 |
--------------------------------------------------------------------------------
/lib/shopify/fragments/seo.ts:
--------------------------------------------------------------------------------
1 | const seoFragment = /* GraphQL */ `
2 | fragment seo on SEO {
3 | description
4 | title
5 | }
6 | `;
7 |
8 | export default seoFragment;
9 |
--------------------------------------------------------------------------------
/lib/shopify/index.ts:
--------------------------------------------------------------------------------
1 | import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
2 | import { isShopifyError } from 'lib/type-guards';
3 | import { ensureStartsWith } from 'lib/utils';
4 | import { revalidateTag } from 'next/cache';
5 | import { headers } from 'next/headers';
6 | import { NextRequest, NextResponse } from 'next/server';
7 | import {
8 | addToCartMutation,
9 | createCartMutation,
10 | editCartItemsMutation,
11 | removeFromCartMutation
12 | } from './mutations/cart';
13 | import { getCartQuery } from './queries/cart';
14 | import {
15 | getCollectionProductsQuery,
16 | getCollectionQuery,
17 | getCollectionsQuery
18 | } from './queries/collection';
19 | import { getMenuQuery } from './queries/menu';
20 | import { getPageQuery, getPagesQuery } from './queries/page';
21 | import {
22 | getProductQuery,
23 | getProductRecommendationsQuery,
24 | getProductsQuery
25 | } from './queries/product';
26 | import {
27 | Cart,
28 | Collection,
29 | Connection,
30 | Image,
31 | Menu,
32 | Page,
33 | Product,
34 | ShopifyAddToCartOperation,
35 | ShopifyCart,
36 | ShopifyCartOperation,
37 | ShopifyCollection,
38 | ShopifyCollectionOperation,
39 | ShopifyCollectionProductsOperation,
40 | ShopifyCollectionsOperation,
41 | ShopifyCreateCartOperation,
42 | ShopifyMenuOperation,
43 | ShopifyPageOperation,
44 | ShopifyPagesOperation,
45 | ShopifyProduct,
46 | ShopifyProductOperation,
47 | ShopifyProductRecommendationsOperation,
48 | ShopifyProductsOperation,
49 | ShopifyRemoveFromCartOperation,
50 | ShopifyUpdateCartOperation
51 | } from './types';
52 |
53 | const domain = process.env.SHOPIFY_STORE_DOMAIN
54 | ? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
55 | : '';
56 | const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
57 | const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
58 |
59 | type ExtractVariables = T extends { variables: object } ? T['variables'] : never;
60 |
61 | export async function shopifyFetch({
62 | cache = 'force-cache',
63 | headers,
64 | query,
65 | tags,
66 | variables
67 | }: {
68 | cache?: RequestCache;
69 | headers?: HeadersInit;
70 | query: string;
71 | tags?: string[];
72 | variables?: ExtractVariables;
73 | }): Promise<{ status: number; body: T } | never> {
74 | try {
75 | const result = await fetch(endpoint, {
76 | method: 'POST',
77 | headers: {
78 | 'Content-Type': 'application/json',
79 | 'X-Shopify-Storefront-Access-Token': key,
80 | ...headers
81 | },
82 | body: JSON.stringify({
83 | ...(query && { query }),
84 | ...(variables && { variables })
85 | }),
86 | cache,
87 | ...(tags && { next: { tags } })
88 | });
89 |
90 | const body = await result.json();
91 |
92 | if (body.errors) {
93 | throw body.errors[0];
94 | }
95 |
96 | return {
97 | status: result.status,
98 | body
99 | };
100 | } catch (e) {
101 | if (isShopifyError(e)) {
102 | throw {
103 | cause: e.cause?.toString() || 'unknown',
104 | status: e.status || 500,
105 | message: e.message,
106 | query
107 | };
108 | }
109 |
110 | throw {
111 | error: e,
112 | query
113 | };
114 | }
115 | }
116 |
117 | const removeEdgesAndNodes = (array: Connection) => {
118 | return array.edges.map((edge) => edge?.node);
119 | };
120 |
121 | const reshapeCart = (cart: ShopifyCart): Cart => {
122 | if (!cart.cost?.totalTaxAmount) {
123 | cart.cost.totalTaxAmount = {
124 | amount: '0.0',
125 | currencyCode: 'USD'
126 | };
127 | }
128 |
129 | return {
130 | ...cart,
131 | lines: removeEdgesAndNodes(cart.lines)
132 | };
133 | };
134 |
135 | const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
136 | if (!collection) {
137 | return undefined;
138 | }
139 |
140 | return {
141 | ...collection,
142 | path: `/search/${collection.handle}`
143 | };
144 | };
145 |
146 | const reshapeCollections = (collections: ShopifyCollection[]) => {
147 | const reshapedCollections = [];
148 |
149 | for (const collection of collections) {
150 | if (collection) {
151 | const reshapedCollection = reshapeCollection(collection);
152 |
153 | if (reshapedCollection) {
154 | reshapedCollections.push(reshapedCollection);
155 | }
156 | }
157 | }
158 |
159 | return reshapedCollections;
160 | };
161 |
162 | const reshapeImages = (images: Connection, productTitle: string) => {
163 | const flattened = removeEdgesAndNodes(images);
164 |
165 | return flattened.map((image) => {
166 | const filename = image.url.match(/.*\/(.*)\..*/)[1];
167 | return {
168 | ...image,
169 | altText: image.altText || `${productTitle} - ${filename}`
170 | };
171 | });
172 | };
173 |
174 | const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
175 | if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
176 | return undefined;
177 | }
178 |
179 | const { images, variants, ...rest } = product;
180 |
181 | return {
182 | ...rest,
183 | images: reshapeImages(images, product.title),
184 | variants: removeEdgesAndNodes(variants)
185 | };
186 | };
187 |
188 | const reshapeProducts = (products: ShopifyProduct[]) => {
189 | const reshapedProducts = [];
190 |
191 | for (const product of products) {
192 | if (product) {
193 | const reshapedProduct = reshapeProduct(product);
194 |
195 | if (reshapedProduct) {
196 | reshapedProducts.push(reshapedProduct);
197 | }
198 | }
199 | }
200 |
201 | return reshapedProducts;
202 | };
203 |
204 | export async function createCart(): Promise {
205 | const res = await shopifyFetch({
206 | query: createCartMutation,
207 | cache: 'no-store'
208 | });
209 |
210 | return reshapeCart(res.body.data.cartCreate.cart);
211 | }
212 |
213 | export async function addToCart(
214 | cartId: string,
215 | lines: { merchandiseId: string; quantity: number }[]
216 | ): Promise {
217 | const res = await shopifyFetch({
218 | query: addToCartMutation,
219 | variables: {
220 | cartId,
221 | lines
222 | },
223 | cache: 'no-store'
224 | });
225 | return reshapeCart(res.body.data.cartLinesAdd.cart);
226 | }
227 |
228 | export async function removeFromCart(cartId: string, lineIds: string[]): Promise {
229 | const res = await shopifyFetch({
230 | query: removeFromCartMutation,
231 | variables: {
232 | cartId,
233 | lineIds
234 | },
235 | cache: 'no-store'
236 | });
237 |
238 | return reshapeCart(res.body.data.cartLinesRemove.cart);
239 | }
240 |
241 | export async function updateCart(
242 | cartId: string,
243 | lines: { id: string; merchandiseId: string; quantity: number }[]
244 | ): Promise {
245 | const res = await shopifyFetch({
246 | query: editCartItemsMutation,
247 | variables: {
248 | cartId,
249 | lines
250 | },
251 | cache: 'no-store'
252 | });
253 |
254 | return reshapeCart(res.body.data.cartLinesUpdate.cart);
255 | }
256 |
257 | export async function getCart(cartId: string): Promise {
258 | const res = await shopifyFetch({
259 | query: getCartQuery,
260 | variables: { cartId },
261 | cache: 'no-store'
262 | });
263 |
264 | // Old carts becomes `null` when you checkout.
265 | if (!res.body.data.cart) {
266 | return undefined;
267 | }
268 |
269 | return reshapeCart(res.body.data.cart);
270 | }
271 |
272 | export async function getCollection(handle: string): Promise {
273 | const res = await shopifyFetch({
274 | query: getCollectionQuery,
275 | tags: [TAGS.collections],
276 | variables: {
277 | handle
278 | }
279 | });
280 |
281 | return reshapeCollection(res.body.data.collection);
282 | }
283 |
284 | export async function getCollectionProducts({
285 | collection,
286 | reverse,
287 | sortKey
288 | }: {
289 | collection: string;
290 | reverse?: boolean;
291 | sortKey?: string;
292 | }): Promise {
293 | const res = await shopifyFetch({
294 | query: getCollectionProductsQuery,
295 | tags: [TAGS.collections, TAGS.products],
296 | variables: {
297 | handle: collection,
298 | reverse,
299 | sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
300 | }
301 | });
302 |
303 | if (!res.body.data.collection) {
304 | console.log(`No collection found for \`${collection}\``);
305 | return [];
306 | }
307 |
308 | return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
309 | }
310 |
311 | export async function getCollections(): Promise {
312 | const res = await shopifyFetch({
313 | query: getCollectionsQuery,
314 | tags: [TAGS.collections]
315 | });
316 | const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
317 | const collections = [
318 | {
319 | handle: '',
320 | title: 'All',
321 | description: 'All products',
322 | seo: {
323 | title: 'All',
324 | description: 'All products'
325 | },
326 | path: '/search',
327 | updatedAt: new Date().toISOString()
328 | },
329 | // Filter out the `hidden` collections.
330 | // Collections that start with `hidden-*` need to be hidden on the search page.
331 | ...reshapeCollections(shopifyCollections).filter(
332 | (collection) => !collection.handle.startsWith('hidden')
333 | )
334 | ];
335 |
336 | return collections;
337 | }
338 |
339 | export async function getMenu(handle: string): Promise {
340 | const res = await shopifyFetch({
341 | query: getMenuQuery,
342 | tags: [TAGS.collections],
343 | variables: {
344 | handle
345 | }
346 | });
347 |
348 | return (
349 | res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
350 | title: item.title,
351 | path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '')
352 | })) || []
353 | );
354 | }
355 |
356 | export async function getPage(handle: string): Promise {
357 | const res = await shopifyFetch({
358 | query: getPageQuery,
359 | variables: { handle }
360 | });
361 |
362 | return res.body.data.pageByHandle;
363 | }
364 |
365 | export async function getPages(): Promise {
366 | const res = await shopifyFetch({
367 | query: getPagesQuery
368 | });
369 |
370 | return removeEdgesAndNodes(res.body.data.pages);
371 | }
372 |
373 | export async function getProduct(handle: string): Promise {
374 | const res = await shopifyFetch({
375 | query: getProductQuery,
376 | tags: [TAGS.products],
377 | variables: {
378 | handle
379 | }
380 | });
381 |
382 | return reshapeProduct(res.body.data.product, false);
383 | }
384 |
385 | export async function getProductRecommendations(productId: string): Promise {
386 | const res = await shopifyFetch({
387 | query: getProductRecommendationsQuery,
388 | tags: [TAGS.products],
389 | variables: {
390 | productId
391 | }
392 | });
393 |
394 | return reshapeProducts(res.body.data.productRecommendations);
395 | }
396 |
397 | export async function getProducts({
398 | query,
399 | reverse,
400 | sortKey
401 | }: {
402 | query?: string;
403 | reverse?: boolean;
404 | sortKey?: string;
405 | }): Promise {
406 | const res = await shopifyFetch({
407 | query: getProductsQuery,
408 | tags: [TAGS.products],
409 | variables: {
410 | query,
411 | reverse,
412 | sortKey
413 | }
414 | });
415 |
416 | return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
417 | }
418 |
419 | // This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
420 | export async function revalidate(req: NextRequest): Promise {
421 | // We always need to respond with a 200 status code to Shopify,
422 | // otherwise it will continue to retry the request.
423 | const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
424 | const productWebhooks = ['products/create', 'products/delete', 'products/update'];
425 | const topic = headers().get('x-shopify-topic') || 'unknown';
426 | const secret = req.nextUrl.searchParams.get('secret');
427 | const isCollectionUpdate = collectionWebhooks.includes(topic);
428 | const isProductUpdate = productWebhooks.includes(topic);
429 |
430 | if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
431 | console.error('Invalid revalidation secret.');
432 | return NextResponse.json({ status: 200 });
433 | }
434 |
435 | if (!isCollectionUpdate && !isProductUpdate) {
436 | // We don't need to revalidate anything for any other topics.
437 | return NextResponse.json({ status: 200 });
438 | }
439 |
440 | if (isCollectionUpdate) {
441 | revalidateTag(TAGS.collections);
442 | }
443 |
444 | if (isProductUpdate) {
445 | revalidateTag(TAGS.products);
446 | }
447 |
448 | return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
449 | }
450 |
--------------------------------------------------------------------------------
/lib/shopify/mutations/cart.ts:
--------------------------------------------------------------------------------
1 | import cartFragment from '../fragments/cart';
2 |
3 | export const addToCartMutation = /* GraphQL */ `
4 | mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
5 | cartLinesAdd(cartId: $cartId, lines: $lines) {
6 | cart {
7 | ...cart
8 | }
9 | }
10 | }
11 | ${cartFragment}
12 | `;
13 |
14 | export const createCartMutation = /* GraphQL */ `
15 | mutation createCart($lineItems: [CartLineInput!]) {
16 | cartCreate(input: { lines: $lineItems }) {
17 | cart {
18 | ...cart
19 | }
20 | }
21 | }
22 | ${cartFragment}
23 | `;
24 |
25 | export const editCartItemsMutation = /* GraphQL */ `
26 | mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
27 | cartLinesUpdate(cartId: $cartId, lines: $lines) {
28 | cart {
29 | ...cart
30 | }
31 | }
32 | }
33 | ${cartFragment}
34 | `;
35 |
36 | export const removeFromCartMutation = /* GraphQL */ `
37 | mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
38 | cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
39 | cart {
40 | ...cart
41 | }
42 | }
43 | }
44 | ${cartFragment}
45 | `;
46 |
--------------------------------------------------------------------------------
/lib/shopify/queries/cart.ts:
--------------------------------------------------------------------------------
1 | import cartFragment from '../fragments/cart';
2 |
3 | export const getCartQuery = /* GraphQL */ `
4 | query getCart($cartId: ID!) {
5 | cart(id: $cartId) {
6 | ...cart
7 | }
8 | }
9 | ${cartFragment}
10 | `;
11 |
--------------------------------------------------------------------------------
/lib/shopify/queries/collection.ts:
--------------------------------------------------------------------------------
1 | import productFragment from '../fragments/product';
2 | import seoFragment from '../fragments/seo';
3 |
4 | const collectionFragment = /* GraphQL */ `
5 | fragment collection on Collection {
6 | handle
7 | title
8 | description
9 | seo {
10 | ...seo
11 | }
12 | updatedAt
13 | }
14 | ${seoFragment}
15 | `;
16 |
17 | export const getCollectionQuery = /* GraphQL */ `
18 | query getCollection($handle: String!) {
19 | collection(handle: $handle) {
20 | ...collection
21 | }
22 | }
23 | ${collectionFragment}
24 | `;
25 |
26 | export const getCollectionsQuery = /* GraphQL */ `
27 | query getCollections {
28 | collections(first: 100, sortKey: TITLE) {
29 | edges {
30 | node {
31 | ...collection
32 | }
33 | }
34 | }
35 | }
36 | ${collectionFragment}
37 | `;
38 |
39 | export const getCollectionProductsQuery = /* GraphQL */ `
40 | query getCollectionProducts(
41 | $handle: String!
42 | $sortKey: ProductCollectionSortKeys
43 | $reverse: Boolean
44 | ) {
45 | collection(handle: $handle) {
46 | products(sortKey: $sortKey, reverse: $reverse, first: 100) {
47 | edges {
48 | node {
49 | ...product
50 | }
51 | }
52 | }
53 | }
54 | }
55 | ${productFragment}
56 | `;
57 |
--------------------------------------------------------------------------------
/lib/shopify/queries/menu.ts:
--------------------------------------------------------------------------------
1 | export const getMenuQuery = /* GraphQL */ `
2 | query getMenu($handle: String!) {
3 | menu(handle: $handle) {
4 | items {
5 | title
6 | url
7 | }
8 | }
9 | }
10 | `;
11 |
--------------------------------------------------------------------------------
/lib/shopify/queries/page.ts:
--------------------------------------------------------------------------------
1 | import seoFragment from '../fragments/seo';
2 |
3 | const pageFragment = /* GraphQL */ `
4 | fragment page on Page {
5 | ... on Page {
6 | id
7 | title
8 | handle
9 | body
10 | bodySummary
11 | seo {
12 | ...seo
13 | }
14 | createdAt
15 | updatedAt
16 | }
17 | }
18 | ${seoFragment}
19 | `;
20 |
21 | export const getPageQuery = /* GraphQL */ `
22 | query getPage($handle: String!) {
23 | pageByHandle(handle: $handle) {
24 | ...page
25 | }
26 | }
27 | ${pageFragment}
28 | `;
29 |
30 | export const getPagesQuery = /* GraphQL */ `
31 | query getPages {
32 | pages(first: 100) {
33 | edges {
34 | node {
35 | ...page
36 | }
37 | }
38 | }
39 | }
40 | ${pageFragment}
41 | `;
42 |
--------------------------------------------------------------------------------
/lib/shopify/queries/product.ts:
--------------------------------------------------------------------------------
1 | import productFragment from '../fragments/product';
2 |
3 | export const getProductQuery = /* GraphQL */ `
4 | query getProduct($handle: String!) {
5 | product(handle: $handle) {
6 | ...product
7 | }
8 | }
9 | ${productFragment}
10 | `;
11 |
12 | export const getProductsQuery = /* GraphQL */ `
13 | query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
14 | products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
15 | edges {
16 | node {
17 | ...product
18 | }
19 | }
20 | }
21 | }
22 | ${productFragment}
23 | `;
24 |
25 | export const getProductRecommendationsQuery = /* GraphQL */ `
26 | query getProductRecommendations($productId: ID!) {
27 | productRecommendations(productId: $productId) {
28 | ...product
29 | }
30 | }
31 | ${productFragment}
32 | `;
33 |
--------------------------------------------------------------------------------
/lib/shopify/types.ts:
--------------------------------------------------------------------------------
1 | export type Maybe = T | null;
2 |
3 | export type Connection = {
4 | edges: Array>;
5 | };
6 |
7 | export type Edge = {
8 | node: T;
9 | };
10 |
11 | export type Cart = Omit & {
12 | lines: CartItem[];
13 | };
14 |
15 | export type CartItem = {
16 | id: string;
17 | quantity: number;
18 | cost: {
19 | totalAmount: Money;
20 | };
21 | merchandise: {
22 | id: string;
23 | title: string;
24 | selectedOptions: {
25 | name: string;
26 | value: string;
27 | }[];
28 | product: Product;
29 | };
30 | };
31 |
32 | export type Collection = ShopifyCollection & {
33 | path: string;
34 | };
35 |
36 | export type Image = {
37 | url: string;
38 | altText: string;
39 | width: number;
40 | height: number;
41 | };
42 |
43 | export type Menu = {
44 | title: string;
45 | path: string;
46 | };
47 |
48 | export type Money = {
49 | amount: string;
50 | currencyCode: string;
51 | };
52 |
53 | export type Page = {
54 | id: string;
55 | title: string;
56 | handle: string;
57 | body: string;
58 | bodySummary: string;
59 | seo?: SEO;
60 | createdAt: string;
61 | updatedAt: string;
62 | };
63 |
64 | export type Product = Omit & {
65 | variants: ProductVariant[];
66 | images: Image[];
67 | };
68 |
69 | export type ProductOption = {
70 | id: string;
71 | name: string;
72 | values: string[];
73 | };
74 |
75 | export type ProductVariant = {
76 | id: string;
77 | title: string;
78 | availableForSale: boolean;
79 | selectedOptions: {
80 | name: string;
81 | value: string;
82 | }[];
83 | price: Money;
84 | };
85 |
86 | export type SEO = {
87 | title: string;
88 | description: string;
89 | };
90 |
91 | export type ShopifyCart = {
92 | id: string;
93 | checkoutUrl: string;
94 | cost: {
95 | subtotalAmount: Money;
96 | totalAmount: Money;
97 | totalTaxAmount: Money;
98 | };
99 | lines: Connection;
100 | totalQuantity: number;
101 | };
102 |
103 | export type ShopifyCollection = {
104 | handle: string;
105 | title: string;
106 | description: string;
107 | seo: SEO;
108 | updatedAt: string;
109 | };
110 |
111 | export type ShopifyProduct = {
112 | id: string;
113 | handle: string;
114 | availableForSale: boolean;
115 | title: string;
116 | description: string;
117 | descriptionHtml: string;
118 | options: ProductOption[];
119 | priceRange: {
120 | maxVariantPrice: Money;
121 | minVariantPrice: Money;
122 | };
123 | variants: Connection;
124 | featuredImage: Image;
125 | images: Connection;
126 | seo: SEO;
127 | tags: string[];
128 | updatedAt: string;
129 | };
130 |
131 | export type ShopifyCartOperation = {
132 | data: {
133 | cart: ShopifyCart;
134 | };
135 | variables: {
136 | cartId: string;
137 | };
138 | };
139 |
140 | export type ShopifyCreateCartOperation = {
141 | data: { cartCreate: { cart: ShopifyCart } };
142 | };
143 |
144 | export type ShopifyAddToCartOperation = {
145 | data: {
146 | cartLinesAdd: {
147 | cart: ShopifyCart;
148 | };
149 | };
150 | variables: {
151 | cartId: string;
152 | lines: {
153 | merchandiseId: string;
154 | quantity: number;
155 | }[];
156 | };
157 | };
158 |
159 | export type ShopifyRemoveFromCartOperation = {
160 | data: {
161 | cartLinesRemove: {
162 | cart: ShopifyCart;
163 | };
164 | };
165 | variables: {
166 | cartId: string;
167 | lineIds: string[];
168 | };
169 | };
170 |
171 | export type ShopifyUpdateCartOperation = {
172 | data: {
173 | cartLinesUpdate: {
174 | cart: ShopifyCart;
175 | };
176 | };
177 | variables: {
178 | cartId: string;
179 | lines: {
180 | id: string;
181 | merchandiseId: string;
182 | quantity: number;
183 | }[];
184 | };
185 | };
186 |
187 | export type ShopifyCollectionOperation = {
188 | data: {
189 | collection: ShopifyCollection;
190 | };
191 | variables: {
192 | handle: string;
193 | };
194 | };
195 |
196 | export type ShopifyCollectionProductsOperation = {
197 | data: {
198 | collection: {
199 | products: Connection;
200 | };
201 | };
202 | variables: {
203 | handle: string;
204 | reverse?: boolean;
205 | sortKey?: string;
206 | };
207 | };
208 |
209 | export type ShopifyCollectionsOperation = {
210 | data: {
211 | collections: Connection;
212 | };
213 | };
214 |
215 | export type ShopifyMenuOperation = {
216 | data: {
217 | menu?: {
218 | items: {
219 | title: string;
220 | url: string;
221 | }[];
222 | };
223 | };
224 | variables: {
225 | handle: string;
226 | };
227 | };
228 |
229 | export type ShopifyPageOperation = {
230 | data: { pageByHandle: Page };
231 | variables: { handle: string };
232 | };
233 |
234 | export type ShopifyPagesOperation = {
235 | data: {
236 | pages: Connection;
237 | };
238 | };
239 |
240 | export type ShopifyProductOperation = {
241 | data: { product: ShopifyProduct };
242 | variables: {
243 | handle: string;
244 | };
245 | };
246 |
247 | export type ShopifyProductRecommendationsOperation = {
248 | data: {
249 | productRecommendations: ShopifyProduct[];
250 | };
251 | variables: {
252 | productId: string;
253 | };
254 | };
255 |
256 | export type ShopifyProductsOperation = {
257 | data: {
258 | products: Connection;
259 | };
260 | variables: {
261 | query?: string;
262 | reverse?: boolean;
263 | sortKey?: string;
264 | };
265 | };
266 |
--------------------------------------------------------------------------------
/lib/type-guards.ts:
--------------------------------------------------------------------------------
1 | export interface ShopifyErrorLike {
2 | status: number;
3 | message: Error;
4 | cause?: Error;
5 | }
6 |
7 | export const isObject = (object: unknown): object is Record => {
8 | return typeof object === 'object' && object !== null && !Array.isArray(object);
9 | };
10 |
11 | export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
12 | if (!isObject(error)) return false;
13 |
14 | if (error instanceof Error) return true;
15 |
16 | return findError(error);
17 | };
18 |
19 | function findError(error: T): boolean {
20 | if (Object.prototype.toString.call(error) === '[object Error]') {
21 | return true;
22 | }
23 |
24 | const prototype = Object.getPrototypeOf(error) as T | null;
25 |
26 | return prototype === null ? false : findError(prototype);
27 | }
28 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { ReadonlyURLSearchParams } from 'next/navigation';
2 |
3 | export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
4 | const paramsString = params.toString();
5 | const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
6 |
7 | return `${pathname}${queryString}`;
8 | };
9 |
10 | export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
11 | stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
12 |
13 | export const validateEnvironmentVariables = () => {
14 | const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN'];
15 | const missingEnvironmentVariables = [] as string[];
16 |
17 | requiredEnvironmentVariables.forEach((envVar) => {
18 | if (!process.env[envVar]) {
19 | missingEnvironmentVariables.push(envVar);
20 | }
21 | });
22 |
23 | if (missingEnvironmentVariables.length) {
24 | throw new Error(
25 | `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(
26 | '\n'
27 | )}\n`
28 | );
29 | }
30 |
31 | if (
32 | process.env.SHOPIFY_STORE_DOMAIN?.includes('[') ||
33 | process.env.SHOPIFY_STORE_DOMAIN?.includes(']')
34 | ) {
35 | throw new Error(
36 | 'Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
37 | );
38 | }
39 | };
40 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2023 Vercel, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | module.exports = {
3 | eslint: {
4 | // Disabling on production builds because we're running checks on PRs via GitHub Actions.
5 | ignoreDuringBuilds: true
6 | },
7 | experimental: {
8 | serverActions: true
9 | },
10 | images: {
11 | formats: ['image/avif', 'image/webp'],
12 | remotePatterns: [
13 | {
14 | protocol: 'https',
15 | hostname: 'cdn.shopify.com',
16 | pathname: '/s/files/**'
17 | }
18 | ]
19 | },
20 | async redirects() {
21 | return [
22 | {
23 | source: '/password',
24 | destination: '/',
25 | permanent: true
26 | }
27 | ];
28 | }
29 | };
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "packageManager": "pnpm@8.2.0",
4 | "engines": {
5 | "node": ">=18",
6 | "pnpm": ">=7"
7 | },
8 | "scripts": {
9 | "dev": "next dev",
10 | "build": "next build",
11 | "start": "next start",
12 | "lint": "next lint",
13 | "lint-staged": "lint-staged",
14 | "prettier": "prettier --write --ignore-unknown .",
15 | "prettier:check": "prettier --check --ignore-unknown .",
16 | "test": "pnpm lint && pnpm prettier:check"
17 | },
18 | "git": {
19 | "pre-commit": "lint-staged"
20 | },
21 | "lint-staged": {
22 | "*": "prettier --write --ignore-unknown"
23 | },
24 | "dependencies": {
25 | "@headlessui/react": "^1.7.15",
26 | "@heroicons/react": "^2.0.18",
27 | "@orama/orama": "^1.2.9",
28 | "@oramacloud/client": "1.0.0-beta.21",
29 | "clsx": "^2.0.0",
30 | "next": "13.4.13-canary.15",
31 | "react": "18.2.0",
32 | "react-dom": "18.2.0"
33 | },
34 | "devDependencies": {
35 | "@tailwindcss/container-queries": "^0.1.1",
36 | "@tailwindcss/typography": "^0.5.9",
37 | "@types/node": "20.4.4",
38 | "@types/react": "18.2.16",
39 | "@types/react-dom": "18.2.7",
40 | "@vercel/git-hooks": "^1.0.0",
41 | "autoprefixer": "^10.4.14",
42 | "eslint": "^8.45.0",
43 | "eslint-config-next": "^13.4.12",
44 | "eslint-config-prettier": "^8.8.0",
45 | "eslint-plugin-unicorn": "^48.0.0",
46 | "lint-staged": "^13.2.3",
47 | "postcss": "^8.4.27",
48 | "prettier": "3.0.1",
49 | "prettier-plugin-tailwindcss": "^0.4.1",
50 | "tailwindcss": "^3.3.3",
51 | "typescript": "5.1.6"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {}
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | arrowParens: 'always',
4 | trailingComma: 'none',
5 | printWidth: 100,
6 | tabWidth: 2,
7 | // pnpm doesn't support plugin autoloading
8 | // https://github.com/tailwindlabs/prettier-plugin-tailwindcss#installation
9 | plugins: [require('prettier-plugin-tailwindcss')]
10 | };
11 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const plugin = require('tailwindcss/plugin');
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
6 | theme: {
7 | extend: {
8 | fontFamily: {
9 | sans: ['var(--font-inter)']
10 | },
11 | keyframes: {
12 | fadeIn: {
13 | from: { opacity: 0 },
14 | to: { opacity: 1 }
15 | },
16 | marquee: {
17 | '0%': { transform: 'translateX(0%)' },
18 | '100%': { transform: 'translateX(-100%)' }
19 | },
20 | blink: {
21 | '0%': { opacity: 0.2 },
22 | '20%': { opacity: 1 },
23 | '100% ': { opacity: 0.2 }
24 | }
25 | },
26 | animation: {
27 | fadeIn: 'fadeIn .3s ease-in-out',
28 | carousel: 'marquee 60s linear infinite',
29 | blink: 'blink 1.4s both infinite'
30 | }
31 | }
32 | },
33 | future: {
34 | hoverOnlyWhenSupported: true
35 | },
36 | plugins: [
37 | require('@tailwindcss/container-queries'),
38 | require('@tailwindcss/typography'),
39 | plugin(({ matchUtilities, theme }) => {
40 | matchUtilities(
41 | {
42 | 'animation-delay': (value) => {
43 | return {
44 | 'animation-delay': value
45 | };
46 | }
47 | },
48 | {
49 | values: theme('transitionDelay')
50 | }
51 | );
52 | })
53 | ]
54 | };
55 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "noUncheckedIndexedAccess": true,
19 | "plugins": [
20 | {
21 | "name": "next"
22 | }
23 | ]
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------