├── .nvmrc
├── .prettierignore
├── app
├── favicon.ico
├── opengraph-image.tsx
├── globals.css
├── error.tsx
├── robots.ts
├── search
│ ├── loading.tsx
│ ├── [collection]
│ │ ├── opengraph-image.tsx
│ │ └── page.tsx
│ ├── layout.tsx
│ └── page.tsx
├── [page]
│ ├── opengraph-image.tsx
│ ├── layout.tsx
│ └── page.tsx
├── page.tsx
├── layout.tsx
├── sitemap.ts
├── api
│ └── revalidate
│ │ └── route.ts
└── product
│ └── [handle]
│ └── page.tsx
├── fonts
└── Inter-Bold.ttf
├── postcss.config.js
├── .github
├── dependabot.yml
└── workflows
│ ├── test.yml
│ └── e2e.yml
├── lib
├── shopify
│ ├── fragments
│ │ ├── seo.ts
│ │ ├── image.ts
│ │ ├── cart.ts
│ │ └── product.ts
│ ├── queries
│ │ ├── menu.ts
│ │ ├── cart.ts
│ │ ├── page.ts
│ │ ├── product.ts
│ │ └── collection.ts
│ ├── mutations
│ │ └── cart.ts
│ ├── types.ts
│ └── index.ts
├── utils.ts
├── type-guards.ts
└── constants.ts
├── .env.example
├── .vscode
├── settings.json
└── launch.json
├── prettier.config.js
├── components
├── icons
│ ├── arrow-left.tsx
│ ├── search.tsx
│ ├── minus.tsx
│ ├── menu.tsx
│ ├── plus.tsx
│ ├── close.tsx
│ ├── caret-right.tsx
│ ├── logo.tsx
│ ├── cart.tsx
│ ├── shopping-bag.tsx
│ ├── github.tsx
│ └── vercel.tsx
├── price.tsx
├── loading-dots.tsx
├── grid
│ ├── index.tsx
│ ├── three-items.tsx
│ └── tile.tsx
├── cart
│ ├── index.tsx
│ ├── delete-item-button.tsx
│ ├── actions.ts
│ ├── add-to-cart.tsx
│ ├── edit-item-quantity-button.tsx
│ └── modal.tsx
├── prose.tsx
├── layout
│ ├── product-grid-items.tsx
│ ├── search
│ │ ├── filter
│ │ │ ├── index.tsx
│ │ │ ├── dropdown.tsx
│ │ │ └── item.tsx
│ │ └── collections.tsx
│ ├── navbar
│ │ ├── search.tsx
│ │ ├── index.tsx
│ │ └── mobile-menu.tsx
│ └── footer.tsx
├── opengraph-image.tsx
├── carousel.tsx
└── product
│ ├── gallery.tsx
│ └── variant-selector.tsx
├── .eslintrc.js
├── next.config.js
├── .gitignore
├── e2e
├── mobile-menu.spec.ts
└── cart.spec.ts
├── tsconfig.json
├── playwright.config.ts
├── license.md
├── package.json
├── tailwind.config.js
└── README.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | 16
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .vercel
2 | .next
3 | pnpm-lock.yaml
4 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BuilderIO/commerce/main/app/favicon.ico
--------------------------------------------------------------------------------
/fonts/Inter-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BuilderIO/commerce/main/fonts/Inter-Bold.ttf
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {}
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: 'github-actions'
4 | directory: '/'
5 | schedule:
6 | interval: 'weekly'
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | TWITTER_CREATOR="@vercel"
2 | TWITTER_SITE="https://nextjs.org/commerce"
3 | SITE_NAME="Next.js Commerce"
4 | SHOPIFY_REVALIDATION_SECRET=
5 | SHOPIFY_STOREFRONT_ACCESS_TOKEN=
6 | SHOPIFY_STORE_DOMAIN=
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @supports (font: -apple-system-body) and (-webkit-appearance: none) {
6 | img[loading='lazy'] {
7 | clip-path: inset(0.6px);
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | export default function Error({ reset }: { reset: () => void }) {
4 | return (
5 |
6 |
Something went wrong.
7 | reset()}>Try again
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/icons/arrow-left.tsx:
--------------------------------------------------------------------------------
1 | export default function ArrowLeftIcon({ className }: { className?: string }) {
2 | return (
3 |
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/components/icons/search.tsx:
--------------------------------------------------------------------------------
1 | export default function SearchIcon({ className }: { className?: string }) {
2 | return (
3 |
4 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/icons/minus.tsx:
--------------------------------------------------------------------------------
1 | export default function MinusIcon({ className }: { className?: string }) {
2 | return (
3 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/icons/menu.tsx:
--------------------------------------------------------------------------------
1 | export default function MenuIcon({ className }: { className?: string }) {
2 | return (
3 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components/price.tsx:
--------------------------------------------------------------------------------
1 | const Price = ({
2 | amount,
3 | currencyCode = 'USD',
4 | ...props
5 | }: {
6 | amount: string;
7 | currencyCode: string;
8 | } & React.ComponentProps<'p'>) => (
9 |
10 | {`${new Intl.NumberFormat(undefined, {
11 | style: 'currency',
12 | currency: currencyCode,
13 | currencyDisplay: 'narrowSymbol'
14 | }).format(parseFloat(amount))} ${currencyCode}`}
15 |
16 | );
17 |
18 | export default Price;
19 |
--------------------------------------------------------------------------------
/components/icons/plus.tsx:
--------------------------------------------------------------------------------
1 | export default function PlusIcon({ className }: { className?: string }) {
2 | return (
3 |
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/components/icons/close.tsx:
--------------------------------------------------------------------------------
1 | export default function CloseIcon({ className }: { className?: string }) {
2 | return (
3 |
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/components/icons/caret-right.tsx:
--------------------------------------------------------------------------------
1 | export default function CaretRightIcon({ className }: { className?: string }) {
2 | return (
3 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 | };
21 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/e2e/mobile-menu.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 |
3 | test.use({ viewport: { width: 600, height: 900 } });
4 | test('should be able to open and close mobile menu', async ({ page }) => {
5 | let mobileMenu;
6 |
7 | await page.goto('/');
8 |
9 | await page.getByTestId('open-mobile-menu').click();
10 | mobileMenu = await page.getByTestId('mobile-menu');
11 | await expect(mobileMenu).toBeVisible();
12 |
13 | await page.getByTestId('close-mobile-menu').click();
14 | mobileMenu = await page.getByTestId('mobile-menu');
15 | await expect(mobileMenu).toBeHidden();
16 | });
17 |
--------------------------------------------------------------------------------
/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 |
20 | {props.children}
21 |
22 | );
23 | }
24 |
25 | Grid.Item = GridItem;
26 | export default Grid;
27 |
--------------------------------------------------------------------------------
/components/cart/index.tsx:
--------------------------------------------------------------------------------
1 | import { createCart, 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 cartIdUpdated = false;
8 | let cart;
9 |
10 | if (cartId) {
11 | cart = await getCart(cartId);
12 | }
13 |
14 | // If the `cartId` from the cookie is not set or the cart is empty
15 | // (old carts becomes `null` when you checkout), then get a new `cartId`
16 | // and re-fetch the cart.
17 | if (!cartId || !cart) {
18 | cart = await createCart();
19 | cartIdUpdated = true;
20 | }
21 |
22 | return ;
23 | }
24 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/components/icons/logo.tsx:
--------------------------------------------------------------------------------
1 | export default function LogoIcon({ className }: { className?: string }) {
2 | return (
3 |
13 |
14 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/lib/type-guards.ts:
--------------------------------------------------------------------------------
1 | export interface ShopifyErrorLike {
2 | status: number;
3 | message: Error;
4 | }
5 |
6 | export const isObject = (object: unknown): object is Record => {
7 | return typeof object === 'object' && object !== null && !Array.isArray(object);
8 | };
9 |
10 | export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
11 | if (!isObject(error)) return false;
12 |
13 | if (error instanceof Error) return true;
14 |
15 | return findError(error);
16 | };
17 |
18 | function findError(error: T): boolean {
19 | if (Object.prototype.toString.call(error) === '[object Error]') {
20 | return true;
21 | }
22 |
23 | const prototype = Object.getPrototypeOf(error) as T | null;
24 |
25 | return prototype === null ? false : findError(prototype);
26 | }
27 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/icons/cart.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import ShoppingBagIcon from './shopping-bag';
3 |
4 | export default function CartIcon({
5 | className,
6 | quantity
7 | }: {
8 | className?: string;
9 | quantity?: number;
10 | }) {
11 | return (
12 |
13 |
19 | {quantity ? (
20 |
21 | {quantity}
22 |
23 | ) : null}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/components/icons/shopping-bag.tsx:
--------------------------------------------------------------------------------
1 | export default function ShoppingBagIcon({ className }: { className?: string }) {
2 | return (
3 |
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/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 | images: [
12 | {
13 | url: `/api/og?title=${encodeURIComponent(process.env.SITE_NAME || '')}`,
14 | width: 1200,
15 | height: 630
16 | }
17 | ],
18 | type: 'website'
19 | }
20 | };
21 |
22 | export default async function HomePage() {
23 | return (
24 | <>
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | >
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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@v3
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { PlaywrightTestConfig, devices } from '@playwright/test';
2 | import path from 'path';
3 |
4 | const baseURL = `http://localhost:${process.env.PORT || 3000}`;
5 | const config: PlaywrightTestConfig = {
6 | testDir: path.join(__dirname, 'e2e'),
7 | retries: 2,
8 | outputDir: '.playwright',
9 | webServer: {
10 | command: 'pnpm build && pnpm start',
11 | url: baseURL,
12 | timeout: 120 * 1000,
13 | reuseExistingServer: !process.env.CI
14 | },
15 | use: {
16 | baseURL,
17 | trace: 'retry-with-trace'
18 | },
19 | projects: [
20 | {
21 | name: 'Desktop Chrome',
22 | use: {
23 | ...devices['Desktop Chrome']
24 | }
25 | },
26 | {
27 | name: 'Desktop Safari',
28 | use: {
29 | ...devices['Desktop Safari']
30 | }
31 | },
32 | {
33 | name: 'Mobile Chrome',
34 | use: {
35 | ...devices['Pixel 5']
36 | }
37 | },
38 | {
39 | name: 'Mobile Safari',
40 | use: devices['iPhone 12']
41 | }
42 | ]
43 | };
44 |
45 | export default config;
46 |
--------------------------------------------------------------------------------
/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: 'Trending', slug: 'trending-desc', sortKey: 'BEST_SELLING', reverse: false }, // asc
18 | { title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'CREATED_AT', reverse: true },
19 | { title: 'Price: Low to high', slug: 'price-asc', sortKey: 'PRICE', reverse: false }, // asc
20 | { title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
21 | ];
22 |
23 | export const TAGS = {
24 | collections: 'collections',
25 | products: 'products'
26 | };
27 |
28 | export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
29 | export const DEFAULT_OPTION = 'Default Title';
30 | export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
31 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 ? (
23 | {title}
24 | ) : null}
25 |
28 |
31 |
32 | >
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from 'components/layout/navbar';
2 | import { Inter } from 'next/font/google';
3 | import { ReactNode, Suspense } from 'react';
4 | import './globals.css';
5 |
6 | const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
7 |
8 | export const metadata = {
9 | title: {
10 | default: SITE_NAME,
11 | template: `%s | ${SITE_NAME}`
12 | },
13 | robots: {
14 | follow: true,
15 | index: true
16 | },
17 | ...(TWITTER_CREATOR &&
18 | TWITTER_SITE && {
19 | twitter: {
20 | card: 'summary_large_image',
21 | creator: TWITTER_CREATOR,
22 | site: TWITTER_SITE
23 | }
24 | })
25 | };
26 |
27 | const inter = Inter({
28 | subsets: ['latin'],
29 | display: 'swap',
30 | variable: '--font-inter'
31 | });
32 |
33 | export default async function RootLayout({ children }: { children: ReactNode }) {
34 | return (
35 |
36 |
37 |
38 |
39 | {children}
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import { getCollections, getPages, getProducts } from 'lib/shopify';
2 | import { MetadataRoute } from 'next';
3 |
4 | const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
5 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
6 | : 'http://localhost:3000';
7 |
8 | export default async function sitemap(): Promise>> {
9 | const routesMap = [''].map((route) => ({
10 | url: `${baseUrl}${route}`,
11 | lastModified: new Date().toISOString()
12 | }));
13 |
14 | const collectionsPromise = getCollections().then((collections) =>
15 | collections.map((collection) => ({
16 | url: `${baseUrl}${collection.path}`,
17 | lastModified: collection.updatedAt
18 | }))
19 | );
20 |
21 | const productsPromise = getProducts({}).then((products) =>
22 | products.map((product) => ({
23 | url: `${baseUrl}/product/${product.handle}`,
24 | lastModified: product.updatedAt
25 | }))
26 | );
27 |
28 | const pagesPromise = getPages().then((pages) =>
29 | pages.map((page) => ({
30 | url: `${baseUrl}/${page.handle}`,
31 | lastModified: page.updatedAt
32 | }))
33 | );
34 |
35 | const fetchedRoutes = (
36 | await Promise.all([collectionsPromise, productsPromise, pagesPromise])
37 | ).flat();
38 |
39 | return [...routesMap, ...fetchedRoutes];
40 | }
41 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/icons/github.tsx:
--------------------------------------------------------------------------------
1 | export default function GitHubIcon({ className }: { className?: string }) {
2 | return (
3 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/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-gray-800 dark:bg-gray-300';
14 | const items = 'bg-gray-400 dark:bg-gray-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/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import { ImageResponse } from 'next/server';
2 |
3 | export type Props = {
4 | title?: string;
5 | };
6 |
7 | export default async function OpengraphImage(props?: Props): Promise {
8 | const { title } = {
9 | ...{
10 | title: process.env.SITE_NAME
11 | },
12 | ...props
13 | };
14 |
15 | return new ImageResponse(
16 | (
17 |
18 |
19 |
20 |
26 |
27 |
{title}
28 |
29 | ),
30 | {
31 | width: 1200,
32 | height: 630,
33 | fonts: [
34 | {
35 | name: 'Inter',
36 | data: await fetch(new URL('../fonts/Inter-Bold.ttf', import.meta.url)).then((res) =>
37 | res.arrayBuffer()
38 | ),
39 | style: 'normal',
40 | weight: 700
41 | }
42 | ]
43 | }
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/app/search/page.tsx:
--------------------------------------------------------------------------------
1 | import Grid from 'components/grid';
2 | import ProductGridItems from 'components/layout/product-grid-items';
3 | import { defaultSort, sorting } from 'lib/constants';
4 | import { getProducts } from 'lib/shopify';
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 | const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
20 |
21 | const products = await getProducts({ sortKey, reverse, query: searchValue });
22 | const resultsText = products.length > 1 ? 'results' : 'result';
23 |
24 | return (
25 | <>
26 | {searchValue ? (
27 |
28 | {products.length === 0
29 | ? 'There are no products that match '
30 | : `Showing ${products.length} ${resultsText} for `}
31 | "{searchValue}"
32 |
33 | ) : null}
34 | {products.length > 0 ? (
35 |
36 |
37 |
38 | ) : null}
39 | >
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/components/layout/navbar/search.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useRouter, useSearchParams } from 'next/navigation';
4 |
5 | import SearchIcon from 'components/icons/search';
6 | import { createUrl } from 'lib/utils';
7 |
8 | export default function Search() {
9 | const router = useRouter();
10 | const searchParams = useSearchParams();
11 |
12 | function onSubmit(e: React.FormEvent) {
13 | e.preventDefault();
14 |
15 | const val = e.target as HTMLFormElement;
16 | const search = val.search as HTMLInputElement;
17 | const newParams = new URLSearchParams(searchParams.toString());
18 |
19 | if (search.value) {
20 | newParams.set('q', search.value);
21 | } else {
22 | newParams.delete('q');
23 | }
24 |
25 | router.push(createUrl('/search', newParams));
26 | }
27 |
28 | return (
29 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/components/carousel.tsx:
--------------------------------------------------------------------------------
1 | import { getCollectionProducts } from 'lib/shopify';
2 | import Image from 'next/image';
3 | import Link from 'next/link';
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 | return (
12 |
13 |
14 | {[...products, ...products].map((product, i) => (
15 |
20 | {product.featuredImage ? (
21 |
28 | ) : null}
29 |
30 |
31 | {product.title}
32 |
33 |
34 |
35 | ))}
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/components/cart/delete-item-button.tsx:
--------------------------------------------------------------------------------
1 | import CloseIcon from 'components/icons/close';
2 | import LoadingDots from 'components/loading-dots';
3 | import { useRouter } from 'next/navigation';
4 |
5 | import clsx from 'clsx';
6 | import type { CartItem } from 'lib/shopify/types';
7 | import { useTransition } from 'react';
8 | import { removeItem } from 'components/cart/actions';
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 | alert(error);
23 | return;
24 | }
25 |
26 | router.refresh();
27 | });
28 | }}
29 | disabled={isPending}
30 | className={clsx(
31 | 'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
32 | {
33 | 'cursor-not-allowed px-0': isPending
34 | }
35 | )}
36 | >
37 | {isPending ? (
38 |
39 | ) : (
40 |
41 | )}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/app/api/revalidate/route.ts:
--------------------------------------------------------------------------------
1 | import { TAGS } from 'lib/constants';
2 | import { revalidateTag } from 'next/cache';
3 | import { headers } from 'next/headers';
4 | import { NextRequest, NextResponse } from 'next/server';
5 |
6 | export const runtime = 'edge';
7 |
8 | // We always need to respond with a 200 status code to Shopify,
9 | // otherwise it will continue to retry the request.
10 | export async function POST(req: NextRequest): Promise {
11 | const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
12 | const productWebhooks = ['products/create', 'products/delete', 'products/update'];
13 | const topic = headers().get('x-shopify-topic') || 'unknown';
14 | const secret = req.nextUrl.searchParams.get('secret');
15 | const isCollectionUpdate = collectionWebhooks.includes(topic);
16 | const isProductUpdate = productWebhooks.includes(topic);
17 |
18 | if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
19 | console.error('Invalid revalidation secret.');
20 | return NextResponse.json({ status: 200 });
21 | }
22 |
23 | if (!isCollectionUpdate && !isProductUpdate) {
24 | // We don't need to revalidate anything for any other topics.
25 | return NextResponse.json({ status: 200 });
26 | }
27 |
28 | if (isCollectionUpdate) {
29 | revalidateTag(TAGS.collections);
30 | }
31 |
32 | if (isProductUpdate) {
33 | revalidateTag(TAGS.products);
34 | }
35 |
36 | return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
37 | }
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "engines": {
4 | "node": ">=16",
5 | "pnpm": ">=7"
6 | },
7 | "scripts": {
8 | "dev": "next dev",
9 | "build": "next build",
10 | "start": "next start",
11 | "lint": "next lint",
12 | "lint-staged": "lint-staged",
13 | "prettier": "prettier --write --ignore-unknown .",
14 | "prettier:check": "prettier --check --ignore-unknown .",
15 | "test": "pnpm lint && pnpm prettier:check",
16 | "test:e2e": "playwright test"
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 | "clsx": "^1.2.1",
27 | "next": "13.4.9-canary.2",
28 | "react": "18.2.0",
29 | "react-cookie": "^4.1.1",
30 | "react-dom": "18.2.0"
31 | },
32 | "devDependencies": {
33 | "@playwright/test": "^1.35.1",
34 | "@tailwindcss/typography": "^0.5.9",
35 | "@types/node": "20.3.2",
36 | "@types/react": "18.2.14",
37 | "@types/react-dom": "18.2.6",
38 | "@vercel/git-hooks": "^1.0.0",
39 | "autoprefixer": "^10.4.14",
40 | "eslint": "^8.44.0",
41 | "eslint-config-next": "^13.4.8",
42 | "eslint-config-prettier": "^8.8.0",
43 | "eslint-plugin-unicorn": "^47.0.0",
44 | "lint-staged": "^13.2.3",
45 | "postcss": "^8.4.24",
46 | "prettier": "^2.8.8",
47 | "prettier-plugin-tailwindcss": "^0.3.0",
48 | "tailwindcss": "^3.3.2",
49 | "typescript": "5.1.3"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/components/cart/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { addToCart, removeFromCart, updateCart } from 'lib/shopify';
4 | import { cookies } from 'next/headers';
5 |
6 | export const addItem = async (variantId: string | undefined): Promise => {
7 | const cartId = cookies().get('cartId')?.value;
8 |
9 | if (!cartId || !variantId) {
10 | return new Error('Missing cartId or variantId');
11 | }
12 | try {
13 | await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
14 | } catch (e) {
15 | return new Error('Error adding item', { cause: e });
16 | }
17 | };
18 |
19 | export const removeItem = async (lineId: string): Promise => {
20 | const cartId = cookies().get('cartId')?.value;
21 |
22 | if (!cartId) {
23 | return new Error('Missing cartId');
24 | }
25 | try {
26 | await removeFromCart(cartId, [lineId]);
27 | } catch (e) {
28 | return new Error('Error removing item', { cause: e });
29 | }
30 | };
31 |
32 | export const updateItemQuantity = async ({
33 | lineId,
34 | variantId,
35 | quantity
36 | }: {
37 | lineId: string;
38 | variantId: string;
39 | quantity: number;
40 | }): Promise => {
41 | const cartId = cookies().get('cartId')?.value;
42 |
43 | if (!cartId) {
44 | return new Error('Missing cartId');
45 | }
46 | try {
47 | await updateCart(cartId, [
48 | {
49 | id: lineId,
50 | merchandiseId: variantId,
51 | quantity
52 | }
53 | ]);
54 | } catch (e) {
55 | return new Error('Error updating item quantity', { cause: e });
56 | }
57 | };
58 |
--------------------------------------------------------------------------------
/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 | images: [
25 | {
26 | url: `/api/og?title=${encodeURIComponent(page.title)}`,
27 | width: 1200,
28 | height: 630
29 | }
30 | ],
31 | publishedTime: page.createdAt,
32 | modifiedTime: page.updatedAt,
33 | type: 'article'
34 | }
35 | };
36 | }
37 |
38 | export default async function Page({ params }: { params: { page: string } }) {
39 | const page = await getPage(params.page);
40 |
41 | if (!page) return notFound();
42 |
43 | return (
44 | <>
45 | {page.title}
46 |
47 |
48 | {`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
49 | year: 'numeric',
50 | month: 'long',
51 | day: 'numeric'
52 | }).format(new Date(page.updatedAt))}.`}
53 |
54 | >
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const plugin = require('tailwindcss/plugin');
2 | const colors = require('tailwindcss/colors');
3 |
4 | /** @type {import('tailwindcss').Config} */
5 | module.exports = {
6 | content: [
7 | './pages/**/*.{js,ts,jsx,tsx}',
8 | './components/**/*.{js,ts,jsx,tsx}',
9 | './icons/**/*.{js,ts,jsx,tsx}',
10 | './app/**/*.{js,ts,jsx,tsx}'
11 | ],
12 | theme: {
13 | extend: {
14 | fontFamily: {
15 | sans: ['var(--font-inter)']
16 | },
17 | colors: {
18 | gray: colors.neutral,
19 | hotPink: '#FF1966',
20 | dark: '#111111',
21 | light: '#FAFAFA',
22 | violetDark: '#4c2889'
23 | },
24 | keyframes: {
25 | fadeIn: {
26 | from: { opacity: 0 },
27 | to: { opacity: 1 }
28 | },
29 | marquee: {
30 | '0%': { transform: 'translateX(0%)' },
31 | '100%': { transform: 'translateX(-100%)' }
32 | },
33 | blink: {
34 | '0%': { opacity: 0.2 },
35 | '20%': { opacity: 1 },
36 | '100% ': { opacity: 0.2 }
37 | }
38 | },
39 | animation: {
40 | fadeIn: 'fadeIn .3s ease-in-out',
41 | carousel: 'marquee 60s linear infinite',
42 | blink: 'blink 1.4s both infinite'
43 | }
44 | }
45 | },
46 | future: {
47 | hoverOnlyWhenSupported: true
48 | },
49 | plugins: [
50 | require('@tailwindcss/typography'),
51 | plugin(({ matchUtilities, theme }) => {
52 | matchUtilities(
53 | {
54 | 'animation-delay': (value) => {
55 | return {
56 | 'animation-delay': value
57 | };
58 | }
59 | },
60 | {
61 | values: theme('transitionDelay')
62 | }
63 | );
64 | })
65 | ]
66 | };
67 |
--------------------------------------------------------------------------------
/components/layout/navbar/index.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { Suspense } from 'react';
3 |
4 | import Cart from 'components/cart';
5 | import CartIcon from 'components/icons/cart';
6 | import LogoIcon from 'components/icons/logo';
7 | import { getMenu } from 'lib/shopify';
8 | import { Menu } from 'lib/shopify/types';
9 | import MobileMenu from './mobile-menu';
10 | import Search from './search';
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 |
26 | {menu.length ? (
27 |
28 | {menu.map((item: Menu) => (
29 |
30 |
34 | {item.title}
35 |
36 |
37 | ))}
38 |
39 | ) : null}
40 |
41 |
42 |
43 |
44 |
45 |
46 | }>
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/.github/workflows/e2e.yml:
--------------------------------------------------------------------------------
1 | name: e2e
2 | on:
3 | schedule:
4 | # Runs "at 09:00 and 15:00, Monday through Friday" (see https://crontab.guru)
5 | - cron: '0 9,15 * * 1-5'
6 | jobs:
7 | e2e:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Cancel running workflows
11 | uses: styfle/cancel-workflow-action@0.11.0
12 | with:
13 | access_token: ${{ github.token }}
14 | - name: Checkout repo
15 | uses: actions/checkout@v3
16 | - name: Set node version
17 | uses: actions/setup-node@v3
18 | with:
19 | node-version-file: '.nvmrc'
20 | - name: Set pnpm version
21 | uses: pnpm/action-setup@v2
22 | with:
23 | run_install: false
24 | version: 7
25 | - name: Cache node_modules
26 | id: node-modules-cache
27 | uses: actions/cache@v3
28 | with:
29 | path: '**/node_modules'
30 | key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
31 | - name: Install dependencies
32 | if: steps.node-modules-cache.outputs.cache-hit != 'true'
33 | run: pnpm install
34 | - name: Get playwright version
35 | run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./node_modules/@playwright/test/package.json').version)")" >> $GITHUB_ENV
36 | - name: Cache playwright
37 | uses: actions/cache@v3
38 | id: playwright-cache
39 | with:
40 | path: '~/.cache/ms-playwright'
41 | key: playwright-cache-${{ env.PLAYWRIGHT_VERSION }}
42 | - name: Install playwright browsers
43 | if: steps.playwright-cache.outputs.cache-hit != 'true'
44 | run: npx playwright install --with-deps
45 | - name: Install playwright browser dependencies
46 | if: steps.playwright-cache.outputs.cache-hit == 'true'
47 | run: npx playwright install-deps
48 | - name: Run tests
49 | run: pnpm test:e2e
50 |
--------------------------------------------------------------------------------
/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 | background
10 | }: {
11 | item: Product;
12 | size: 'full' | 'half';
13 | background: 'white' | 'pink' | 'purple' | 'black';
14 | }) {
15 | return (
16 |
19 |
20 |
33 |
34 |
35 | );
36 | }
37 |
38 | export async function ThreeItemGrid() {
39 | // Collections that start with `hidden-*` are hidden from the search page.
40 | const homepageItems = await getCollectionProducts({
41 | collection: 'hidden-homepage-featured-items'
42 | });
43 |
44 | if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
45 |
46 | const [firstProduct, secondProduct, thirdProduct] = homepageItems;
47 |
48 | return (
49 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/components/cart/add-to-cart.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import { addItem } from 'components/cart/actions';
5 | import { useRouter, useSearchParams } from 'next/navigation';
6 | import { useEffect, useState, useTransition } from 'react';
7 |
8 | import LoadingDots from 'components/loading-dots';
9 | import { ProductVariant } from 'lib/shopify/types';
10 |
11 | export function AddToCart({
12 | variants,
13 | availableForSale
14 | }: {
15 | variants: ProductVariant[];
16 | availableForSale: boolean;
17 | }) {
18 | const [selectedVariantId, setSelectedVariantId] = useState(variants[0]?.id);
19 | const router = useRouter();
20 | const searchParams = useSearchParams();
21 | const [isPending, startTransition] = useTransition();
22 |
23 | useEffect(() => {
24 | const variant = variants.find((variant: ProductVariant) =>
25 | variant.selectedOptions.every(
26 | (option) => option.value === searchParams.get(option.name.toLowerCase())
27 | )
28 | );
29 |
30 | if (variant) {
31 | setSelectedVariantId(variant.id);
32 | }
33 | }, [searchParams, variants, setSelectedVariantId]);
34 |
35 | return (
36 | {
40 | if (!availableForSale) return;
41 | startTransition(async () => {
42 | const error = await addItem(selectedVariantId);
43 |
44 | if (error) {
45 | alert(error);
46 | return;
47 | }
48 |
49 | router.refresh();
50 | });
51 | }}
52 | className={clsx(
53 | 'flex w-full items-center justify-center bg-black p-4 text-sm uppercase tracking-wide text-white opacity-90 hover:opacity-100 dark:bg-white dark:text-black',
54 | {
55 | 'cursor-not-allowed opacity-60': !availableForSale,
56 | 'cursor-not-allowed': isPending
57 | }
58 | )}
59 | >
60 | {availableForSale ? 'Add To Cart' : 'Out Of Stock'}
61 | {isPending ? : null}
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/components/cart/edit-item-quantity-button.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/navigation';
2 | import { useTransition } from 'react';
3 |
4 | import clsx from 'clsx';
5 | import { removeItem, updateItemQuantity } from 'components/cart/actions';
6 | import MinusIcon from 'components/icons/minus';
7 | import PlusIcon from 'components/icons/plus';
8 | import LoadingDots from 'components/loading-dots';
9 | import type { CartItem } from 'lib/shopify/types';
10 |
11 | export default function EditItemQuantityButton({
12 | item,
13 | type
14 | }: {
15 | item: CartItem;
16 | type: 'plus' | 'minus';
17 | }) {
18 | const router = useRouter();
19 | const [isPending, startTransition] = useTransition();
20 |
21 | return (
22 | {
25 | startTransition(async () => {
26 | const error =
27 | type === 'minus' && item.quantity - 1 === 0
28 | ? await removeItem(item.id)
29 | : await updateItemQuantity({
30 | lineId: item.id,
31 | variantId: item.merchandise.id,
32 | quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
33 | });
34 |
35 | if (error) {
36 | alert(error);
37 | return;
38 | }
39 |
40 | router.refresh();
41 | });
42 | }}
43 | disabled={isPending}
44 | className={clsx(
45 | 'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
46 | {
47 | 'cursor-not-allowed': isPending,
48 | 'ml-auto': type === 'minus'
49 | }
50 | )}
51 | >
52 | {isPending ? (
53 |
54 | ) : type === 'plus' ? (
55 |
56 | ) : (
57 |
58 | )}
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/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 Caret from 'components/icons/caret-right';
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/icons/vercel.tsx:
--------------------------------------------------------------------------------
1 | export default function VercelIcon({ className }: { className?: string }) {
2 | return (
3 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/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 { useEffect, useState } from 'react';
9 | import type { ListItem, PathFilterItem } from '.';
10 |
11 | function PathFilterItem({ item }: { item: PathFilterItem }) {
12 | const pathname = usePathname();
13 | const searchParams = useSearchParams();
14 | const [active, setActive] = useState(pathname === item.path);
15 | const newParams = new URLSearchParams(searchParams.toString());
16 |
17 | newParams.delete('q');
18 |
19 | useEffect(() => {
20 | setActive(pathname === item.path);
21 | }, [pathname, item.path]);
22 |
23 | return (
24 |
25 |
32 | {item.title}
33 |
34 |
35 | );
36 | }
37 |
38 | function SortFilterItem({ item }: { item: SortFilterItem }) {
39 | const pathname = usePathname();
40 | const searchParams = useSearchParams();
41 | const [active, setActive] = useState(searchParams.get('sort') === item.slug);
42 | const q = searchParams.get('q');
43 |
44 | useEffect(() => {
45 | setActive(searchParams.get('sort') === item.slug);
46 | }, [searchParams, item.slug]);
47 |
48 | const href =
49 | item.slug && item.slug.length
50 | ? createUrl(
51 | pathname,
52 | new URLSearchParams({
53 | ...(q && { q }),
54 | sort: item.slug
55 | })
56 | )
57 | : pathname;
58 |
59 | return (
60 |
61 |
69 | {item.title}
70 |
71 |
72 | );
73 | }
74 |
75 | export function FilterItem({ item }: { item: ListItem }) {
76 | return 'path' in item ? : ;
77 | }
78 |
--------------------------------------------------------------------------------
/components/grid/tile.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import Image from 'next/image';
3 |
4 | import Price from 'components/price';
5 |
6 | export function GridTileImage({
7 | isInteractive = true,
8 | background,
9 | active,
10 | labels,
11 | ...props
12 | }: {
13 | isInteractive?: boolean;
14 | background?: 'white' | 'pink' | 'purple' | 'black' | 'purple-dark' | 'blue' | 'cyan' | 'gray';
15 | active?: boolean;
16 | labels?: {
17 | title: string;
18 | amount: string;
19 | currencyCode: string;
20 | isSmall?: boolean;
21 | };
22 | } & React.ComponentProps) {
23 | return (
24 |
38 | {active !== undefined && active ? (
39 |
40 | ) : null}
41 | {props.src ? (
42 |
49 | ) : null}
50 | {labels ? (
51 |
52 |
59 | {labels.title}
60 |
61 |
66 |
67 | ) : null}
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/e2e/cart.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 |
3 | const regex = (text: string) => new RegExp(text, 'gim');
4 |
5 | test('should be able to open and close cart', async ({ page }) => {
6 | let cart;
7 |
8 | await page.goto('/');
9 |
10 | await page.getByTestId('open-cart').click();
11 | cart = await page.getByTestId('cart');
12 | await expect(cart).toBeVisible();
13 | await expect(cart).toHaveText(regex('your cart is empty'));
14 |
15 | await page.getByTestId('close-cart').click();
16 | cart = await page.getByTestId('cart');
17 | await expect(cart).toBeHidden();
18 | });
19 |
20 | test('should be able to add item to cart, without selecting a variant, assuming first variant', async ({
21 | page
22 | }) => {
23 | await page.goto('/');
24 | await page.getByTestId('homepage-products').locator('a').first().click();
25 |
26 | const productName = await page.getByTestId('product-name').first().innerText();
27 | const firstVariant = await page.getByTestId('variant').first().innerText();
28 |
29 | await page.getByRole('button', { name: regex('add to cart') }).click();
30 |
31 | const cart = await page.getByTestId('cart');
32 |
33 | await expect(cart).toBeVisible();
34 |
35 | const cartItems = await page.getByTestId('cart-item').all();
36 | let isItemInCart = false;
37 |
38 | for (const item of cartItems) {
39 | const cartProductName = await item.getByTestId('cart-product-name').innerText();
40 | const cartProductVariant = await item.getByTestId('cart-product-variant').innerText();
41 |
42 | if (cartProductName === productName && cartProductVariant === firstVariant) {
43 | isItemInCart = true;
44 | break;
45 | }
46 | }
47 |
48 | await expect(isItemInCart).toBe(true);
49 | });
50 |
51 | test('should be able to add item to cart by selecting a variant', async ({ page }) => {
52 | await page.goto('/');
53 | await page.getByTestId('homepage-products').locator('a').first().click();
54 |
55 | const selectedProductName = await page.getByTestId('product-name').first().innerText();
56 | const secondVariant = await page.getByTestId('variant').nth(1);
57 |
58 | await secondVariant.click();
59 | const selectedProductVariant = await page.getByTestId('selected-variant').innerText();
60 |
61 | await page.getByRole('button', { name: regex('add to cart') }).click();
62 |
63 | const cart = await page.getByTestId('cart');
64 |
65 | await expect(cart).toBeVisible();
66 |
67 | const cartItem = await page.getByTestId('cart-item').first();
68 | const cartItemProductName = await cartItem.getByTestId('cart-product-name').innerText();
69 | const cartItemProductVariant = await cartItem.getByTestId('cart-product-variant').innerText();
70 |
71 | await expect(cartItemProductName).toBe(selectedProductName);
72 | await expect(cartItemProductVariant).toBe(selectedProductVariant);
73 | });
74 |
--------------------------------------------------------------------------------
/components/layout/footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | import GitHubIcon from 'components/icons/github';
4 | import LogoIcon from 'components/icons/logo';
5 | import VercelIcon from 'components/icons/vercel';
6 | import { getMenu } from 'lib/shopify';
7 | import { Menu } from 'lib/shopify/types';
8 |
9 | const { SITE_NAME } = process.env;
10 |
11 | export default async function Footer() {
12 | const currentYear = new Date().getFullYear();
13 | const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
14 | const menu = await getMenu('next-js-frontend-footer-menu');
15 |
16 | return (
17 |
18 |
19 |
20 |
28 | {menu.length ? (
29 |
30 |
31 | {menu.map((item: Menu) => (
32 |
33 |
37 | {item.title}
38 |
39 |
40 | ))}
41 |
42 |
43 | ) : null}
44 |
49 |
50 |
51 |
52 | © {copyrightDate} {SITE_NAME}. All rights reserved.
53 |
54 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/components/product/gallery.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 |
5 | import clsx from 'clsx';
6 | import { GridTileImage } from 'components/grid/tile';
7 | import ArrowLeftIcon from 'components/icons/arrow-left';
8 |
9 | export function Gallery({
10 | title,
11 | amount,
12 | currencyCode,
13 | images
14 | }: {
15 | title: string;
16 | amount: string;
17 | currencyCode: string;
18 | images: { src: string; altText: string }[];
19 | }) {
20 | const [currentImage, setCurrentImage] = useState(0);
21 |
22 | function handleNavigate(direction: 'next' | 'previous') {
23 | if (direction === 'next') {
24 | setCurrentImage(currentImage + 1 < images.length ? currentImage + 1 : 0);
25 | } else {
26 | setCurrentImage(currentImage === 0 ? images.length - 1 : currentImage - 1);
27 | }
28 | }
29 |
30 | const buttonClassName =
31 | 'px-9 cursor-pointer ease-in-and-out duration-200 transition-bg bg-[#7928ca] hover:bg-violetDark';
32 |
33 | return (
34 |
35 |
36 | {images[currentImage] && (
37 |
51 | )}
52 |
53 | {images.length > 1 ? (
54 |
55 |
handleNavigate('previous')}
59 | >
60 |
61 |
62 |
handleNavigate('next')}
66 | >
67 |
68 |
69 |
70 | ) : null}
71 |
72 |
73 | {images.length > 1 ? (
74 |
75 | {images.map((image, index) => {
76 | const isActive = index === currentImage;
77 | return (
78 | setCurrentImage(index)}
83 | >
84 |
92 |
93 | );
94 | })}
95 |
96 | ) : null}
97 |
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/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 CloseIcon from 'components/icons/close';
9 | import MenuIcon from 'components/icons/menu';
10 | import { Menu } from 'lib/shopify/types';
11 | import Search from './search';
12 |
13 | export default function MobileMenu({ menu }: { menu: Menu[] }) {
14 | const pathname = usePathname();
15 | const searchParams = useSearchParams();
16 | const [isOpen, setIsOpen] = useState(false);
17 | const openMobileMenu = () => setIsOpen(true);
18 | const closeMobileMenu = () => setIsOpen(false);
19 |
20 | useEffect(() => {
21 | const handleResize = () => {
22 | if (window.innerWidth > 768) {
23 | setIsOpen(false);
24 | }
25 | };
26 | window.addEventListener('resize', handleResize);
27 | return () => window.removeEventListener('resize', handleResize);
28 | }, [isOpen]);
29 |
30 | useEffect(() => {
31 | setIsOpen(false);
32 | }, [pathname, searchParams]);
33 |
34 | return (
35 | <>
36 |
42 |
43 |
44 |
45 |
46 |
55 |
56 |
57 |
66 |
67 |
68 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | {menu.length ? (
81 |
82 | {menu.map((item: Menu) => (
83 |
84 |
89 | {item.title}
90 |
91 |
92 | ))}
93 |
94 | ) : null}
95 |
96 |
97 |
98 |
99 |
100 | >
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/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 Grid from 'components/grid';
6 | import Footer from 'components/layout/footer';
7 | import ProductGridItems from 'components/layout/product-grid-items';
8 | import { AddToCart } from 'components/cart/add-to-cart';
9 | import { Gallery } from 'components/product/gallery';
10 | import { VariantSelector } from 'components/product/variant-selector';
11 | import Prose from 'components/prose';
12 | import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
13 | import { getProduct, getProductRecommendations } from 'lib/shopify';
14 | import { Image } from 'lib/shopify/types';
15 |
16 | export const runtime = 'edge';
17 |
18 | export async function generateMetadata({
19 | params
20 | }: {
21 | params: { handle: string };
22 | }): Promise {
23 | const product = await getProduct(params.handle);
24 |
25 | if (!product) return notFound();
26 |
27 | const { url, width, height, altText: alt } = product.featuredImage || {};
28 | const hide = !product.tags.includes(HIDDEN_PRODUCT_TAG);
29 |
30 | return {
31 | title: product.seo.title || product.title,
32 | description: product.seo.description || product.description,
33 | robots: {
34 | index: hide,
35 | follow: hide,
36 | googleBot: {
37 | index: hide,
38 | follow: hide
39 | }
40 | },
41 | openGraph: url
42 | ? {
43 | images: [
44 | {
45 | url,
46 | width,
47 | height,
48 | alt
49 | }
50 | ]
51 | }
52 | : null
53 | };
54 | }
55 |
56 | export default async function ProductPage({ params }: { params: { handle: string } }) {
57 | const product = await getProduct(params.handle);
58 |
59 | if (!product) return notFound();
60 |
61 | const productJsonLd = {
62 | '@context': 'https://schema.org',
63 | '@type': 'Product',
64 | name: product.title,
65 | description: product.description,
66 | image: product.featuredImage.url,
67 | offers: {
68 | '@type': 'AggregateOffer',
69 | availability: product.availableForSale
70 | ? 'https://schema.org/InStock'
71 | : 'https://schema.org/OutOfStock',
72 | priceCurrency: product.priceRange.minVariantPrice.currencyCode,
73 | highPrice: product.priceRange.maxVariantPrice.amount,
74 | lowPrice: product.priceRange.minVariantPrice.amount
75 | }
76 | };
77 |
78 | return (
79 |
80 |
86 |
87 |
88 | ({
93 | src: image.url,
94 | altText: image.altText
95 | }))}
96 | />
97 |
98 |
99 |
100 |
101 |
102 | {product.descriptionHtml ? (
103 |
104 | ) : null}
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | );
117 | }
118 |
119 | async function RelatedProducts({ id }: { id: string }) {
120 | const relatedProducts = await getProductRecommendations(id);
121 |
122 | if (!relatedProducts.length) return null;
123 |
124 | return (
125 |
126 |
Related Products
127 |
128 |
129 |
130 |
131 | );
132 | }
133 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 Link from 'next/link';
7 | import { usePathname, useRouter, useSearchParams } from 'next/navigation';
8 |
9 | type ParamsMap = {
10 | [key: string]: string; // ie. { color: 'Red', size: 'Large', ... }
11 | };
12 |
13 | type OptimizedVariant = {
14 | id: string;
15 | availableForSale: boolean;
16 | params: URLSearchParams;
17 | [key: string]: string | boolean | URLSearchParams; // ie. { color: 'Red', size: 'Large', ... }
18 | };
19 |
20 | export function VariantSelector({
21 | options,
22 | variants
23 | }: {
24 | options: ProductOption[];
25 | variants: ProductVariant[];
26 | }) {
27 | const pathname = usePathname();
28 | const currentParams = useSearchParams();
29 | const router = useRouter();
30 | const hasNoOptionsOrJustOneOption =
31 | !options.length || (options.length === 1 && options[0]?.values.length === 1);
32 |
33 | if (hasNoOptionsOrJustOneOption) {
34 | return null;
35 | }
36 |
37 | // Discard any unexpected options or values from url and create params map.
38 | const paramsMap: ParamsMap = Object.fromEntries(
39 | Array.from(currentParams.entries()).filter(([key, value]) =>
40 | options.find((option) => option.name.toLowerCase() === key && option.values.includes(value))
41 | )
42 | );
43 |
44 | // Optimize variants for easier lookups.
45 | const optimizedVariants: OptimizedVariant[] = variants.map((variant) => {
46 | const optimized: OptimizedVariant = {
47 | id: variant.id,
48 | availableForSale: variant.availableForSale,
49 | params: new URLSearchParams()
50 | };
51 |
52 | variant.selectedOptions.forEach((selectedOption) => {
53 | const name = selectedOption.name.toLowerCase();
54 | const value = selectedOption.value;
55 |
56 | optimized[name] = value;
57 | optimized.params.set(name, value);
58 | });
59 |
60 | return optimized;
61 | });
62 |
63 | // Find the first variant that is:
64 | //
65 | // 1. Available for sale
66 | // 2. Matches all options specified in the url (note that this
67 | // could be a partial match if some options are missing from the url).
68 | //
69 | // If no match (full or partial) is found, use the first variant that is
70 | // available for sale.
71 | const selectedVariant: OptimizedVariant | undefined =
72 | optimizedVariants.find(
73 | (variant) =>
74 | variant.availableForSale &&
75 | Object.entries(paramsMap).every(([key, value]) => variant[key] === value)
76 | ) || optimizedVariants.find((variant) => variant.availableForSale);
77 |
78 | const selectedVariantParams = new URLSearchParams(selectedVariant?.params);
79 | const currentUrl = createUrl(pathname, currentParams);
80 | const selectedVariantUrl = createUrl(pathname, selectedVariantParams);
81 |
82 | if (currentUrl !== selectedVariantUrl) {
83 | router.replace(selectedVariantUrl);
84 | }
85 |
86 | return options.map((option) => (
87 |
88 | {option.name}
89 |
90 | {option.values.map((value) => {
91 | // Base option params on selected variant params.
92 | const optionParams = new URLSearchParams(selectedVariantParams);
93 | // Update the params using the current option to reflect how the url would change.
94 | optionParams.set(option.name.toLowerCase(), value);
95 |
96 | const optionUrl = createUrl(pathname, optionParams);
97 |
98 | // The option is active if it in the url params.
99 | const isActive = selectedVariantParams.get(option.name.toLowerCase()) === value;
100 |
101 | // The option is available for sale if it fully matches the variant in the option's url params.
102 | // It's super important to note that this is the options params, *not* the selected variant's params.
103 | // This is the "magic" that will cross check possible future variant combinations and preemptively
104 | // disable combinations that are not possible.
105 | const isAvailableForSale = optimizedVariants.find((a) =>
106 | Array.from(optionParams.entries()).every(([key, value]) => a[key] === value)
107 | )?.availableForSale;
108 |
109 | const DynamicTag = isAvailableForSale ? Link : 'p';
110 |
111 | return (
112 |
128 | {value}
129 |
130 | );
131 | })}
132 |
133 |
134 | ));
135 | }
136 |
--------------------------------------------------------------------------------
/components/cart/modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Dialog, Transition } from '@headlessui/react';
4 | import Image from 'next/image';
5 | import Link from 'next/link';
6 |
7 | import CartIcon from 'components/icons/cart';
8 | import CloseIcon from 'components/icons/close';
9 | import ShoppingBagIcon from 'components/icons/shopping-bag';
10 | import Price from 'components/price';
11 | import { DEFAULT_OPTION } from 'lib/constants';
12 | import type { Cart } from 'lib/shopify/types';
13 | import { createUrl } from 'lib/utils';
14 | import { Fragment, useEffect, useRef, useState } from 'react';
15 | import { useCookies } from 'react-cookie';
16 | import DeleteItemButton from './delete-item-button';
17 | import EditItemQuantityButton from './edit-item-quantity-button';
18 |
19 | type MerchandiseSearchParams = {
20 | [key: string]: string;
21 | };
22 |
23 | export default function CartModal({ cart, cartIdUpdated }: { cart: Cart; cartIdUpdated: boolean }) {
24 | const [, setCookie] = useCookies(['cartId']);
25 | const [isOpen, setIsOpen] = useState(false);
26 | const quantityRef = useRef(cart.totalQuantity);
27 | const openCart = () => setIsOpen(true);
28 | const closeCart = () => setIsOpen(false);
29 |
30 | useEffect(() => {
31 | if (cartIdUpdated) {
32 | setCookie('cartId', cart.id, {
33 | path: '/',
34 | sameSite: 'strict',
35 | secure: process.env.NODE_ENV === 'production'
36 | });
37 | }
38 | return;
39 | }, [setCookie, cartIdUpdated, cart.id]);
40 |
41 | useEffect(() => {
42 | // Open cart modal when when quantity changes.
43 | if (cart.totalQuantity !== quantityRef.current) {
44 | // But only if it's not already open (quantity also changes when editing items in cart).
45 | if (!isOpen) {
46 | setIsOpen(true);
47 | }
48 |
49 | // Always update the quantity reference
50 | quantityRef.current = cart.totalQuantity;
51 | }
52 | }, [isOpen, cart.totalQuantity, quantityRef]);
53 |
54 | return (
55 | <>
56 |
57 |
58 |
59 |
60 |
61 |
70 |
71 |
72 |
81 |
82 |
83 |
My Cart
84 |
90 |
91 |
92 |
93 |
94 | {cart.lines.length === 0 ? (
95 |
96 |
97 |
Your cart is empty.
98 |
99 | ) : (
100 |
101 |
102 | {cart.lines.map((item, i) => {
103 | const merchandiseSearchParams = {} as MerchandiseSearchParams;
104 |
105 | item.merchandise.selectedOptions.forEach(({ name, value }) => {
106 | if (value !== DEFAULT_OPTION) {
107 | merchandiseSearchParams[name.toLowerCase()] = value;
108 | }
109 | });
110 |
111 | const merchandiseUrl = createUrl(
112 | `/product/${item.merchandise.product.handle}`,
113 | new URLSearchParams(merchandiseSearchParams)
114 | );
115 |
116 | return (
117 |
118 |
123 |
124 |
134 |
135 |
136 |
137 | {item.merchandise.product.title}
138 |
139 | {item.merchandise.title !== DEFAULT_OPTION ? (
140 |
141 | {item.merchandise.title}
142 |
143 | ) : null}
144 |
145 |
150 |
151 |
152 |
153 |
154 | {item.quantity}
155 |
156 |
157 |
158 |
159 |
160 | );
161 | })}
162 |
163 |
164 |
165 |
Subtotal
166 |
171 |
172 |
180 |
181 |
Shipping
182 |
Calculated at checkout
183 |
184 |
192 |
193 |
197 | Proceed to Checkout
198 |
199 |
200 | )}
201 |
202 |
203 |
204 |
205 | >
206 | );
207 | }
208 |
--------------------------------------------------------------------------------
/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 {
4 | addToCartMutation,
5 | createCartMutation,
6 | editCartItemsMutation,
7 | removeFromCartMutation
8 | } from './mutations/cart';
9 | import { getCartQuery } from './queries/cart';
10 | import {
11 | getCollectionProductsQuery,
12 | getCollectionQuery,
13 | getCollectionsQuery
14 | } from './queries/collection';
15 | import { getMenuQuery } from './queries/menu';
16 | import { getPageQuery, getPagesQuery } from './queries/page';
17 | import {
18 | getProductQuery,
19 | getProductRecommendationsQuery,
20 | getProductsQuery
21 | } from './queries/product';
22 | import {
23 | Cart,
24 | Collection,
25 | Connection,
26 | Menu,
27 | Page,
28 | Product,
29 | ShopifyAddToCartOperation,
30 | ShopifyCart,
31 | ShopifyCartOperation,
32 | ShopifyCollection,
33 | ShopifyCollectionOperation,
34 | ShopifyCollectionProductsOperation,
35 | ShopifyCollectionsOperation,
36 | ShopifyCreateCartOperation,
37 | ShopifyMenuOperation,
38 | ShopifyPageOperation,
39 | ShopifyPagesOperation,
40 | ShopifyProduct,
41 | ShopifyProductOperation,
42 | ShopifyProductRecommendationsOperation,
43 | ShopifyProductsOperation,
44 | ShopifyRemoveFromCartOperation,
45 | ShopifyUpdateCartOperation
46 | } from './types';
47 |
48 | const domain = `https://${process.env.SHOPIFY_STORE_DOMAIN!}`;
49 | const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
50 | const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
51 |
52 | type ExtractVariables = T extends { variables: object } ? T['variables'] : never;
53 |
54 | export async function shopifyFetch({
55 | cache = 'force-cache',
56 | headers,
57 | query,
58 | tags,
59 | variables
60 | }: {
61 | cache?: RequestCache;
62 | headers?: HeadersInit;
63 | query: string;
64 | tags?: string[];
65 | variables?: ExtractVariables;
66 | }): Promise<{ status: number; body: T } | never> {
67 | try {
68 | const result = await fetch(endpoint, {
69 | method: 'POST',
70 | headers: {
71 | 'Content-Type': 'application/json',
72 | 'X-Shopify-Storefront-Access-Token': key,
73 | ...headers
74 | },
75 | body: JSON.stringify({
76 | ...(query && { query }),
77 | ...(variables && { variables })
78 | }),
79 | cache,
80 | ...(tags && { next: { tags } })
81 | });
82 |
83 | const body = await result.json();
84 |
85 | if (body.errors) {
86 | throw body.errors[0];
87 | }
88 |
89 | return {
90 | status: result.status,
91 | body
92 | };
93 | } catch (e) {
94 | if (isShopifyError(e)) {
95 | throw {
96 | status: e.status || 500,
97 | message: e.message,
98 | query
99 | };
100 | }
101 |
102 | throw {
103 | error: e,
104 | query
105 | };
106 | }
107 | }
108 |
109 | const removeEdgesAndNodes = (array: Connection) => {
110 | return array.edges.map((edge) => edge?.node);
111 | };
112 |
113 | const reshapeCart = (cart: ShopifyCart): Cart => {
114 | if (!cart.cost?.totalTaxAmount) {
115 | cart.cost.totalTaxAmount = {
116 | amount: '0.0',
117 | currencyCode: 'USD'
118 | };
119 | }
120 |
121 | return {
122 | ...cart,
123 | lines: removeEdgesAndNodes(cart.lines)
124 | };
125 | };
126 |
127 | const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
128 | if (!collection) {
129 | return undefined;
130 | }
131 |
132 | return {
133 | ...collection,
134 | path: `/search/${collection.handle}`
135 | };
136 | };
137 |
138 | const reshapeCollections = (collections: ShopifyCollection[]) => {
139 | const reshapedCollections = [];
140 |
141 | for (const collection of collections) {
142 | if (collection) {
143 | const reshapedCollection = reshapeCollection(collection);
144 |
145 | if (reshapedCollection) {
146 | reshapedCollections.push(reshapedCollection);
147 | }
148 | }
149 | }
150 |
151 | return reshapedCollections;
152 | };
153 |
154 | const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
155 | if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
156 | return undefined;
157 | }
158 |
159 | const { images, variants, ...rest } = product;
160 |
161 | return {
162 | ...rest,
163 | images: removeEdgesAndNodes(images),
164 | variants: removeEdgesAndNodes(variants)
165 | };
166 | };
167 |
168 | const reshapeProducts = (products: ShopifyProduct[]) => {
169 | const reshapedProducts = [];
170 |
171 | for (const product of products) {
172 | if (product) {
173 | const reshapedProduct = reshapeProduct(product);
174 |
175 | if (reshapedProduct) {
176 | reshapedProducts.push(reshapedProduct);
177 | }
178 | }
179 | }
180 |
181 | return reshapedProducts;
182 | };
183 |
184 | export async function createCart(): Promise {
185 | const res = await shopifyFetch({
186 | query: createCartMutation,
187 | cache: 'no-store'
188 | });
189 |
190 | return reshapeCart(res.body.data.cartCreate.cart);
191 | }
192 |
193 | export async function addToCart(
194 | cartId: string,
195 | lines: { merchandiseId: string; quantity: number }[]
196 | ): Promise {
197 | const res = await shopifyFetch({
198 | query: addToCartMutation,
199 | variables: {
200 | cartId,
201 | lines
202 | },
203 | cache: 'no-store'
204 | });
205 | return reshapeCart(res.body.data.cartLinesAdd.cart);
206 | }
207 |
208 | export async function removeFromCart(cartId: string, lineIds: string[]): Promise {
209 | const res = await shopifyFetch({
210 | query: removeFromCartMutation,
211 | variables: {
212 | cartId,
213 | lineIds
214 | },
215 | cache: 'no-store'
216 | });
217 |
218 | return reshapeCart(res.body.data.cartLinesRemove.cart);
219 | }
220 |
221 | export async function updateCart(
222 | cartId: string,
223 | lines: { id: string; merchandiseId: string; quantity: number }[]
224 | ): Promise {
225 | const res = await shopifyFetch({
226 | query: editCartItemsMutation,
227 | variables: {
228 | cartId,
229 | lines
230 | },
231 | cache: 'no-store'
232 | });
233 |
234 | return reshapeCart(res.body.data.cartLinesUpdate.cart);
235 | }
236 |
237 | export async function getCart(cartId: string): Promise {
238 | const res = await shopifyFetch({
239 | query: getCartQuery,
240 | variables: { cartId },
241 | cache: 'no-store'
242 | });
243 |
244 | if (!res.body.data.cart) {
245 | return null;
246 | }
247 |
248 | return reshapeCart(res.body.data.cart);
249 | }
250 |
251 | export async function getCollection(handle: string): Promise {
252 | const res = await shopifyFetch({
253 | query: getCollectionQuery,
254 | tags: [TAGS.collections],
255 | variables: {
256 | handle
257 | }
258 | });
259 |
260 | return reshapeCollection(res.body.data.collection);
261 | }
262 |
263 | export async function getCollectionProducts({
264 | collection,
265 | reverse,
266 | sortKey
267 | }: {
268 | collection: string;
269 | reverse?: boolean;
270 | sortKey?: string;
271 | }): Promise {
272 | const res = await shopifyFetch({
273 | query: getCollectionProductsQuery,
274 | tags: [TAGS.collections, TAGS.products],
275 | variables: {
276 | handle: collection,
277 | reverse,
278 | sortKey
279 | }
280 | });
281 |
282 | if (!res.body.data.collection) {
283 | console.log(`No collection found for \`${collection}\``);
284 | return [];
285 | }
286 |
287 | return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
288 | }
289 |
290 | export async function getCollections(): Promise {
291 | const res = await shopifyFetch({
292 | query: getCollectionsQuery,
293 | tags: [TAGS.collections]
294 | });
295 | const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
296 | const collections = [
297 | {
298 | handle: '',
299 | title: 'All',
300 | description: 'All products',
301 | seo: {
302 | title: 'All',
303 | description: 'All products'
304 | },
305 | path: '/search',
306 | updatedAt: new Date().toISOString()
307 | },
308 | // Filter out the `hidden` collections.
309 | // Collections that start with `hidden-*` need to be hidden on the search page.
310 | ...reshapeCollections(shopifyCollections).filter(
311 | (collection) => !collection.handle.startsWith('hidden')
312 | )
313 | ];
314 |
315 | return collections;
316 | }
317 |
318 | export async function getMenu(handle: string): Promise {
319 | const res = await shopifyFetch({
320 | query: getMenuQuery,
321 | tags: [TAGS.collections],
322 | variables: {
323 | handle
324 | }
325 | });
326 |
327 | return (
328 | res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
329 | title: item.title,
330 | path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '')
331 | })) || []
332 | );
333 | }
334 |
335 | export async function getPage(handle: string): Promise {
336 | const res = await shopifyFetch({
337 | query: getPageQuery,
338 | variables: { handle }
339 | });
340 |
341 | return res.body.data.pageByHandle;
342 | }
343 |
344 | export async function getPages(): Promise {
345 | const res = await shopifyFetch({
346 | query: getPagesQuery
347 | });
348 |
349 | return removeEdgesAndNodes(res.body.data.pages);
350 | }
351 |
352 | export async function getProduct(handle: string): Promise {
353 | const res = await shopifyFetch({
354 | query: getProductQuery,
355 | tags: [TAGS.products],
356 | variables: {
357 | handle
358 | }
359 | });
360 |
361 | return reshapeProduct(res.body.data.product, false);
362 | }
363 |
364 | export async function getProductRecommendations(productId: string): Promise {
365 | const res = await shopifyFetch({
366 | query: getProductRecommendationsQuery,
367 | tags: [TAGS.products],
368 | variables: {
369 | productId
370 | }
371 | });
372 |
373 | return reshapeProducts(res.body.data.productRecommendations);
374 | }
375 |
376 | export async function getProducts({
377 | query,
378 | reverse,
379 | sortKey
380 | }: {
381 | query?: string;
382 | reverse?: boolean;
383 | sortKey?: string;
384 | }): Promise {
385 | const res = await shopifyFetch({
386 | query: getProductsQuery,
387 | tags: [TAGS.products],
388 | variables: {
389 | query,
390 | reverse,
391 | sortKey
392 | }
393 | });
394 |
395 | return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
396 | }
397 |
--------------------------------------------------------------------------------
/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=SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SHOPIFY_STORE_DOMAIN,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 | > 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)
19 |
20 | ## Providers
21 |
22 | 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).
23 |
24 | Vercel is more than 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.
25 |
26 | - Shopify (this repository)
27 | - [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
28 | - [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
29 | - [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
30 |
31 | ## Running locally
32 |
33 | 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.
34 |
35 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
36 |
37 | 1. Install Vercel CLI: `npm i -g vercel`
38 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
39 | 3. Download your environment variables: `vercel env pull`
40 |
41 | ```bash
42 | pnpm install
43 | pnpm dev
44 | ```
45 |
46 | Your app should now be running on [localhost:3000](http://localhost:3000/).
47 |
48 |
49 | Expand if you work at Vercel and want to run locally and / or contribute
50 |
51 | 1. Run `vc link`.
52 | 1. Select the `Vercel Solutions` scope.
53 | 1. Connect to the existing `commerce-shopify` project.
54 | 1. Run `vc env pull` to get environment variables.
55 | 1. Run `pmpm dev` to ensure everything is working correctly.
56 |
57 |
58 | ## How to configure your Shopify store for Next.js Commerce
59 |
60 | Next.js Commerce requires a [paid Shopify plan](https://www.shopify.com/pricing).
61 |
62 | > Note: Next.js Commerce will not work with a Shopify Starter plan as it does not allow installation of custom themes, which is required to run as a headless storefront.
63 |
64 | ### Add Shopify domain to an environment variable
65 |
66 | Create a `SHOPIFY_STORE_DOMAIN` environment variable and use your Shopify domain as the the value (ie. `SHOPIFY_STORE_SUBDOMAIN.myshopify.com`).
67 |
68 | > Note: Do not include the `https://`.
69 |
70 | ### Accessing the Shopify Storefront API
71 |
72 | Next.js Commerce utilizes [Shopify's Storefront API](https://shopify.dev/docs/api/storefront) to create unique customer experiences. The API offers a full range of commerce options making it possible for customers to control products, collections, menus, pages, cart, checkout, and more.
73 |
74 | In order to use the Shopify's Storefront API, you need to install the [Headless app](https://apps.shopify.com/headless) in your Shopify store.
75 |
76 | Once installed, you'll need to create a `SHOPIFY_STOREFRONT_ACCESS_TOKEN` environment variable and use the public access token as the value
77 |
78 | > Note: Shopify does offer a Node.js Storefront API SDK. We use the Storefront API via GraphQL directly instead of the Node.js SDK so we have more control over fetching and caching.
79 |
80 |
81 | Expand to view detailed walkthrough
82 |
83 | 1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/apps`.
84 | 1. Click the green `Shopify App Store` button.
85 | 
86 | 1. Search for `Headless` and click on the `Headless` app.
87 | 
88 | 1. Click the black `Add app` button.
89 | 
90 | 1. Click the green `Add sales channel` button.
91 | 
92 | 1. Click the green `Create storefront` button.
93 | 
94 | 1. Copy and paste the public access token and assign it to a `SHOPIFY_STOREFRONT_ACCESS_TOKEN` environment variable.
95 | 
96 | 1. If you ever need to reference the public access token again, you can navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/headless_storefronts`.
97 |
98 |
99 | ### Install a headless theme
100 |
101 | When using a headless Shopify setup, you normally don't want customers to access any of the theme pages except for checkout. However, you can't totally disable the theme and a lot of links will still point to the theme (e.g. links in emails, order details, plugins, checkout, etc.).
102 |
103 | To enable a seamless flow between your headless site and Shopify, you can install the [Shopify Headless Theme](https://github.com/instantcommerce/shopify-headless-theme).
104 |
105 | Follow the installation instructions and configure the theme with your headless site's values.
106 |
107 |
108 | Expand to view detailed walkthrough
109 |
110 | 1. Download [Shopify Headless Theme](https://github.com/instantcommerce/shopify-headless-theme).
111 | 
112 | 1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/themes`.
113 | 1. Click `Add theme`, then `Upload zip file`.
114 | 
115 | 1. Select the downloaded zip file from above, and click the green `Upload file` button.
116 | 
117 | 1. Click `Customize`.
118 | 
119 | 1. Click `Theme settings` (ie. the paintbrush icon), expand the `STOREFRONT` section, enter your headless store domain, click the gray `Publish` button.
120 | 
121 | 1. Confirm the theme change by clicking the green `Save and publish` button.
122 | 
123 | 1. The headless theme should now be your current active theme.
124 | 
125 |
126 |
127 | ### Branding & Design
128 |
129 | Since you're creating a headless Shopify store, you'll be in full control of your brand and design. However, there are still a few aspects we're leaving within Shopify's control.
130 |
131 | - Checkout
132 | - Emails
133 | - Order status
134 | - Order history
135 | - Favicon (for any Shopify controlled pages)
136 |
137 | You can use Shopify's admin to customize these pages to match your brand and design.
138 |
139 |
140 | Expand to view detailed walkthrough
141 |
142 | #### Checkout, order status, and order history
143 |
144 | 1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/checkout`.
145 | 1. Click the green `Customize` button.
146 | 
147 | 1. Click `Branding` (ie. the paintbrush icon) and customize your brand. Please note, there are three steps / pages to the checkout flow. Use the dropdown to change pages and adjust branding as needed on each page. Click `Save` when you are done.
148 | 
149 | 1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/branding`.
150 | 1. Customize settings to match your brand.
151 | 
152 |
153 | #### Emails
154 |
155 | 1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/email_settings`.
156 | 1. Customize settings to match your brand.
157 | 
158 |
159 | #### Favicon
160 |
161 | 1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/themes`.
162 | 1. Click the green `Customize` button.
163 | 
164 | 1. Click `Theme settings` (ie. the paintbrush icon), expand the `FAVICON` section, upload favicon, then click the `Save` button.
165 | 
166 |
167 |
168 |
169 | ### Configure webhooks for on-demand incremental static regeneration (ISR)
170 |
171 | Utilizing [Shopify's webhooks](https://shopify.dev/docs/apps/webhooks), and listening for select [Shopify webhook event topics](https://shopify.dev/docs/api/admin-rest/2022-04/resources/webhook#event-topics), we can use [Next'js on-demand revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/revalidating#using-on-demand-revalidation) to keep data fetches indefinitely cached until certain events in the Shopify store occur.
172 |
173 | Next.js is pre-configured to listen for the following Shopify webhook events and automatically revalidate fetches.
174 |
175 | - `collections/create`
176 | - `collections/delete`
177 | - `collections/update`
178 | - `products/create`
179 | - `products/delete`
180 | - `products/update` (this also includes when variants are added, updated, and removed as well as when products are purchased so inventory and out of stocks can be updated)
181 |
182 |
183 | Expand to view detailed walkthrough
184 |
185 | #### Setup secret for secure revalidation
186 |
187 | 1. Create your own secret or [generate a random UUID](https://www.uuidgenerator.net/guid).
188 | 1. Create a [Vercel Environment Variable](https://vercel.com/docs/concepts/projects/environment-variables) named `SHOPIFY_REVALIDATION_SECRET` and use the value from above.
189 |
190 | #### Configure Shopify webhooks
191 |
192 | 1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/notifications`.
193 | 1. Add webhooks for all six event topics listed above. You can add more sets for other preview urls, environments, or local development. Append `?secret=[SECRET]` to each url, where `[SECRET]` is the secret you created above.
194 | 
195 | 
196 |
197 | #### Testing webhooks during local development
198 |
199 | The easiest way to test webhooks while developing locally is to use [ngrok](https://ngrok.com).
200 |
201 | 1. [Install and configure ngrok](https://ngrok.com/download) (you will need to create an account).
202 | 1. Run your app locally, `npm run dev`.
203 | 1. In a separate terminal session, run `ngrok http 3000`.
204 | 1. Use the url generated by ngrok and add or update your webhook urls in Shopify.
205 | 
206 | 
207 | 1. You can now make changes to your store and your local app should receive updates. You can also use the `Send test notification` button to trigger a generic webhook test.
208 | 
209 |
210 |
211 |
212 | ### Using Shopify as a CMS
213 |
214 | Next.js Commerce is fully powered by Shopify in a truly headless and data driven way.
215 |
216 | #### Products
217 |
218 | `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/products`
219 |
220 | Only `Active` products are shown. `Draft` products will not be shown until they are marked as `Active`.
221 |
222 | `Active` products can still be hidden and not seen by navigating the site, by adding a `nextjs-frontend-hidden` tag on the product. This tag will also tell search engines to not index or crawl the product. The product is still directly accessible via url. This feature is great for "secret" products you only want to people you share the url with.
223 |
224 | Product options and option combinations are driven from Shopify options and variants. When selecting options on the product detail page, other option and variant combinations will be visually validated and verified for availability, like Amazon does.
225 |
226 | Products that are active and "out of stock" are still shown on the site, but the ability to add the product to the cart is disabled.
227 |
228 | #### Collections
229 |
230 | `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/collections`
231 |
232 | Create whatever collections you want and configure them however you want. All available collections will show on the search page as filters on the left, with one exception...
233 |
234 | Any collection names that start with the word "hidden" will not show up on the headless front end. The Next.js Commerce theme comes pre-configured to look for two hidden collections. Collections were chosen for this over tags so that order of products could be controlled (collections allow for manual ordering).
235 |
236 | Create the following collections:
237 |
238 | - `Hidden: Homepage Featured Items` -- Products in this collection are displayed in the three featured blocks on the homepage.
239 | - `Hidden: Homepage Carousel` -- Products in this collection are displayed in the auto-scrolling carousel section on the homepage.
240 |
241 | 
242 |
243 | 
244 |
245 | #### Pages
246 |
247 | `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/pages`
248 |
249 | Next.js Commerce contains a dynamic `[page]` route. It will use the value to look for a corresponding page in Shopify. If a page is found, it will display its rich content using Tailwind's prose. If a page is not found, a 404 page is displayed.
250 |
251 | 
252 |
253 | 
254 |
255 | #### Navigation menus
256 |
257 | `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/menus`
258 |
259 | Next.js Commerce's header and footer navigation is pre-configured to be controlled by Shopify navigation menus. This means you have full control over what links go here. They can be to collections, pages, external links, and more.
260 |
261 | Create the following navigation menus:
262 |
263 | - `Next.js Frontend Header Menu` -- Menu items to be shown in the headless frontend header.
264 | - `Next.js Frontend Footer Menu` -- Menu items to be shown in the headless frontend footer.
265 |
266 | 
267 |
268 | 
269 |
270 | #### SEO
271 |
272 | Shopify's products, collections, pages, etc. allow you to create custom SEO titles and descriptions. Next.js Commerce is pre-configured to display these custom values, but also comes with sensible default fallbacks if they are not provided.
273 |
274 | 
275 |
--------------------------------------------------------------------------------