├── .env.example
├── .gitignore
├── .vscode
├── launch.json
└── settings.json
├── README.md
├── app
├── [page]
│ ├── layout.tsx
│ ├── opengraph-image.tsx
│ └── page.tsx
├── api
│ └── revalidate
│ │ └── route.ts
├── error.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
├── opengraph-image.tsx
├── page.tsx
├── product
│ └── [handle]
│ │ └── page.tsx
├── robots.ts
├── search
│ ├── [collection]
│ │ ├── opengraph-image.tsx
│ │ └── page.tsx
│ ├── children-wrapper.tsx
│ ├── layout.tsx
│ ├── loading.tsx
│ └── page.tsx
└── sitemap.ts
├── components
├── carousel.tsx
├── cart
│ ├── actions.ts
│ ├── add-to-cart.tsx
│ ├── cart-context.tsx
│ ├── delete-item-button.tsx
│ ├── edit-item-quantity-button.tsx
│ ├── modal.tsx
│ └── open-cart.tsx
├── grid
│ ├── index.tsx
│ ├── three-items.tsx
│ └── tile.tsx
├── icons
│ └── logo.tsx
├── label.tsx
├── layout
│ ├── footer-menu.tsx
│ ├── footer.tsx
│ ├── navbar
│ │ ├── index.tsx
│ │ ├── mobile-menu.tsx
│ │ └── search.tsx
│ ├── product-grid-items.tsx
│ └── search
│ │ ├── collections.tsx
│ │ └── filter
│ │ ├── dropdown.tsx
│ │ ├── index.tsx
│ │ └── item.tsx
├── loading-dots.tsx
├── logo-square.tsx
├── opengraph-image.tsx
├── price.tsx
├── product
│ ├── gallery.tsx
│ ├── product-context.tsx
│ ├── product-description.tsx
│ └── variant-selector.tsx
├── prose.tsx
└── welcome-toast.tsx
├── fonts
└── Inter-Bold.ttf
├── lib
├── constants.ts
├── shopify
│ ├── fragments
│ │ ├── cart.ts
│ │ ├── image.ts
│ │ ├── product.ts
│ │ └── seo.ts
│ ├── index.ts
│ ├── mutations
│ │ └── cart.ts
│ ├── queries
│ │ ├── cart.ts
│ │ ├── collection.ts
│ │ ├── menu.ts
│ │ ├── page.ts
│ │ └── product.ts
│ └── types.ts
├── type-guards.ts
└── utils.ts
├── license.md
├── next.config.ts
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | COMPANY_NAME="Vercel Inc."
2 | SITE_NAME="Next.js Commerce"
3 | SHOPIFY_REVALIDATION_SECRET=""
4 | SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
5 | SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
6 |
--------------------------------------------------------------------------------
/.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 | .env*.local
40 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Next.js: debug server-side",
6 | "type": "node-terminal",
7 | "request": "launch",
8 | "command": "pnpm dev"
9 | },
10 | {
11 | "name": "Next.js: debug client-side",
12 | "type": "chrome",
13 | "request": "launch",
14 | "url": "http://localhost:3000"
15 | },
16 | {
17 | "name": "Next.js: debug full stack",
18 | "type": "node-terminal",
19 | "request": "launch",
20 | "command": "pnpm dev",
21 | "serverReadyAction": {
22 | "pattern": "started server on .+, url: (https?://.+)",
23 | "uriFormat": "%s",
24 | "action": "debugWithChrome"
25 | }
26 | }
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib",
3 | "typescript.enablePromptUseWorkspaceTsdk": true,
4 | "editor.codeActionsOnSave": {
5 | "source.fixAll": "explicit",
6 | "source.organizeImports": "explicit",
7 | "source.sortMembers": "explicit"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME)
2 |
3 | # Next.js Commerce
4 |
5 | A high-performance, server-rendered Next.js App Router ecommerce application.
6 |
7 | This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more.
8 |
9 |
10 |
11 | > 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).
12 |
13 | ## Providers
14 |
15 | 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).
16 |
17 | Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
18 |
19 | - Shopify (this repository)
20 | - [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
21 | - [Ecwid by Lightspeed](https://github.com/Ecwid/ecwid-nextjs-commerce/) ([Demo](https://ecwid-nextjs-commerce.vercel.app/))
22 | - [Geins](https://github.com/geins-io/vercel-nextjs-commerce) ([Demo](https://geins-nextjs-commerce-starter.vercel.app/))
23 | - [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
24 | - [Prodigy Commerce](https://github.com/prodigycommerce/nextjs-commerce) ([Demo](https://prodigy-nextjs-commerce.vercel.app/))
25 | - [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
26 | - [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
27 | - [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
28 | - [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
29 | - [Wix](https://github.com/wix/headless-templates/tree/main/nextjs/commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
30 | - [Fourthwall](https://github.com/FourthwallHQ/vercel-commerce) ([Demo](https://vercel-storefront.fourthwall.app/))
31 |
32 | > Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing).
33 |
34 | ## Integrations
35 |
36 | Integrations enable upgraded or additional functionality for Next.js Commerce
37 |
38 | - [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
39 |
40 | - Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
41 | - Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
42 |
43 | - [React Bricks](https://github.com/ReactBricks/nextjs-commerce-rb) ([Demo](https://nextjs-commerce.reactbricks.com/))
44 | - Edit pages, product details, and footer content visually using [React Bricks](https://www.reactbricks.com) visual headless CMS.
45 |
46 | ## Running locally
47 |
48 | 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.
49 |
50 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
51 |
52 | 1. Install Vercel CLI: `npm i -g vercel`
53 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
54 | 3. Download your environment variables: `vercel env pull`
55 |
56 | ```bash
57 | pnpm install
58 | pnpm dev
59 | ```
60 |
61 | Your app should now be running on [localhost:3000](http://localhost:3000/).
62 |
63 |
64 | Expand if you work at Vercel and want to run locally and / or contribute
65 |
66 | 1. Run `vc link`.
67 | 1. Select the `Vercel Solutions` scope.
68 | 1. Connect to the existing `commerce-shopify` project.
69 | 1. Run `vc env pull` to get environment variables.
70 | 1. Run `pnpm dev` to ensure everything is working correctly.
71 |
72 |
73 | ## Vercel, Next.js Commerce, and Shopify Integration Guide
74 |
75 | You can use this comprehensive [integration guide](https://vercel.com/docs/integrations/ecommerce/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.
76 |
--------------------------------------------------------------------------------
/app/[page]/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from 'components/layout/footer';
2 |
3 | export default function Layout({ children }: { children: React.ReactNode }) {
4 | return (
5 | <>
6 |
9 |
10 | >
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/app/[page]/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import OpengraphImage from 'components/opengraph-image';
2 | import { getPage } from 'lib/shopify';
3 |
4 | export default async function Image({ params }: { params: { page: string } }) {
5 | const page = await getPage(params.page);
6 | const title = page.seo?.title || page.title;
7 |
8 | return await OpengraphImage({ title });
9 | }
10 |
--------------------------------------------------------------------------------
/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 async function generateMetadata(props: {
8 | params: Promise<{ page: string }>;
9 | }): Promise {
10 | const params = await props.params;
11 | const page = await getPage(params.page);
12 |
13 | if (!page) return notFound();
14 |
15 | return {
16 | title: page.seo?.title || page.title,
17 | description: page.seo?.description || page.bodySummary,
18 | openGraph: {
19 | publishedTime: page.createdAt,
20 | modifiedTime: page.updatedAt,
21 | type: 'article'
22 | }
23 | };
24 | }
25 |
26 | export default async function Page(props: { params: Promise<{ page: string }> }) {
27 | const params = await props.params;
28 | const page = await getPage(params.page);
29 |
30 | if (!page) return notFound();
31 |
32 | return (
33 | <>
34 | {page.title}
35 |
36 |
37 | {`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
38 | year: 'numeric',
39 | month: 'long',
40 | day: 'numeric'
41 | }).format(new Date(page.updatedAt))}.`}
42 |
43 | >
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/app/api/revalidate/route.ts:
--------------------------------------------------------------------------------
1 | import { revalidate } from 'lib/shopify';
2 | import { NextRequest, NextResponse } from 'next/server';
3 |
4 | export async function POST(req: NextRequest): Promise {
5 | return revalidate(req);
6 | }
7 |
--------------------------------------------------------------------------------
/app/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | export default function Error({ reset }: { reset: () => void }) {
4 | return (
5 |
6 |
Oh no!
7 |
8 | There was an issue with our storefront. This could be a temporary issue, please try your
9 | action again.
10 |
11 |
reset()}
14 | >
15 | Try Again
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vercel/commerce/fa1306916c652ea5f820d5b400087bece13460fd/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @plugin "@tailwindcss/container-queries";
4 | @plugin "@tailwindcss/typography";
5 |
6 | @layer base {
7 | *,
8 | ::after,
9 | ::before,
10 | ::backdrop,
11 | ::file-selector-button {
12 | border-color: var(--color-gray-200, currentColor);
13 | }
14 | }
15 |
16 | @media (prefers-color-scheme: dark) {
17 | html {
18 | color-scheme: dark;
19 | }
20 | }
21 |
22 | @supports (font: -apple-system-body) and (-webkit-appearance: none) {
23 | img[loading='lazy'] {
24 | clip-path: inset(0.6px);
25 | }
26 | }
27 |
28 | a,
29 | input,
30 | button {
31 | @apply focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
32 | }
33 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { CartProvider } from 'components/cart/cart-context';
2 | import { Navbar } from 'components/layout/navbar';
3 | import { WelcomeToast } from 'components/welcome-toast';
4 | import { GeistSans } from 'geist/font/sans';
5 | import { getCart } from 'lib/shopify';
6 | import { ReactNode } from 'react';
7 | import { Toaster } from 'sonner';
8 | import './globals.css';
9 | import { baseUrl } from 'lib/utils';
10 |
11 | const { SITE_NAME } = process.env;
12 |
13 | export const metadata = {
14 | metadataBase: new URL(baseUrl),
15 | title: {
16 | default: SITE_NAME!,
17 | template: `%s | ${SITE_NAME}`
18 | },
19 | robots: {
20 | follow: true,
21 | index: true
22 | }
23 | };
24 |
25 | export default async function RootLayout({
26 | children
27 | }: {
28 | children: ReactNode;
29 | }) {
30 | // Don't await the fetch, pass the Promise to the context provider
31 | const cart = getCart();
32 |
33 | return (
34 |
35 |
36 |
37 |
38 |
39 | {children}
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/app/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import OpengraphImage from 'components/opengraph-image';
2 |
3 | export default async function Image() {
4 | return await OpengraphImage();
5 | }
6 |
--------------------------------------------------------------------------------
/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 |
5 | export const metadata = {
6 | description:
7 | 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
8 | openGraph: {
9 | type: 'website'
10 | }
11 | };
12 |
13 | export default function HomePage() {
14 | return (
15 | <>
16 |
17 |
18 |
19 | >
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/app/product/[handle]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { notFound } from 'next/navigation';
3 |
4 | import { GridTileImage } from 'components/grid/tile';
5 | import Footer from 'components/layout/footer';
6 | import { Gallery } from 'components/product/gallery';
7 | import { ProductProvider } from 'components/product/product-context';
8 | import { ProductDescription } from 'components/product/product-description';
9 | import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
10 | import { getProduct, getProductRecommendations } from 'lib/shopify';
11 | import { Image } from 'lib/shopify/types';
12 | import Link from 'next/link';
13 | import { Suspense } from 'react';
14 |
15 | export async function generateMetadata(props: {
16 | params: Promise<{ handle: string }>;
17 | }): Promise {
18 | const params = await props.params;
19 | const product = await getProduct(params.handle);
20 |
21 | if (!product) return notFound();
22 |
23 | const { url, width, height, altText: alt } = product.featuredImage || {};
24 | const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
25 |
26 | return {
27 | title: product.seo.title || product.title,
28 | description: product.seo.description || product.description,
29 | robots: {
30 | index: indexable,
31 | follow: indexable,
32 | googleBot: {
33 | index: indexable,
34 | follow: indexable
35 | }
36 | },
37 | openGraph: url
38 | ? {
39 | images: [
40 | {
41 | url,
42 | width,
43 | height,
44 | alt
45 | }
46 | ]
47 | }
48 | : null
49 | };
50 | }
51 |
52 | export default async function ProductPage(props: { params: Promise<{ handle: string }> }) {
53 | const params = await props.params;
54 | const product = await getProduct(params.handle);
55 |
56 | if (!product) return notFound();
57 |
58 | const productJsonLd = {
59 | '@context': 'https://schema.org',
60 | '@type': 'Product',
61 | name: product.title,
62 | description: product.description,
63 | image: product.featuredImage.url,
64 | offers: {
65 | '@type': 'AggregateOffer',
66 | availability: product.availableForSale
67 | ? 'https://schema.org/InStock'
68 | : 'https://schema.org/OutOfStock',
69 | priceCurrency: product.priceRange.minVariantPrice.currencyCode,
70 | highPrice: product.priceRange.maxVariantPrice.amount,
71 | lowPrice: product.priceRange.minVariantPrice.amount
72 | }
73 | };
74 |
75 | return (
76 |
77 |
83 |
84 |
85 |
86 |
89 | }
90 | >
91 | ({
93 | src: image.url,
94 | altText: image.altText
95 | }))}
96 | />
97 |
98 |
99 |
100 |
105 |
106 |
107 |
108 |
109 |
110 | );
111 | }
112 |
113 | async function RelatedProducts({ id }: { id: string }) {
114 | const relatedProducts = await getProductRecommendations(id);
115 |
116 | if (!relatedProducts.length) return null;
117 |
118 | return (
119 |
120 |
Related Products
121 |
122 | {relatedProducts.map((product) => (
123 |
127 |
132 |
143 |
144 |
145 | ))}
146 |
147 |
148 | );
149 | }
150 |
--------------------------------------------------------------------------------
/app/robots.ts:
--------------------------------------------------------------------------------
1 | import { baseUrl } from 'lib/utils';
2 |
3 | export default function robots() {
4 | return {
5 | rules: [
6 | {
7 | userAgent: '*'
8 | }
9 | ],
10 | sitemap: `${baseUrl}/sitemap.xml`,
11 | host: baseUrl
12 | };
13 | }
14 |
--------------------------------------------------------------------------------
/app/search/[collection]/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import OpengraphImage from 'components/opengraph-image';
2 | import { getCollection } from 'lib/shopify';
3 |
4 | export default async function Image({
5 | params
6 | }: {
7 | params: { collection: string };
8 | }) {
9 | const collection = await getCollection(params.collection);
10 | const title = collection?.seo?.title || collection?.title;
11 |
12 | return await OpengraphImage({ title });
13 | }
14 |
--------------------------------------------------------------------------------
/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 async function generateMetadata(props: {
10 | params: Promise<{ collection: string }>;
11 | }): Promise {
12 | const params = await props.params;
13 | const collection = await getCollection(params.collection);
14 |
15 | if (!collection) return notFound();
16 |
17 | return {
18 | title: collection.seo?.title || collection.title,
19 | description:
20 | collection.seo?.description || collection.description || `${collection.title} products`
21 | };
22 | }
23 |
24 | export default async function CategoryPage(props: {
25 | params: Promise<{ collection: string }>;
26 | searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
27 | }) {
28 | const searchParams = await props.searchParams;
29 | const params = await props.params;
30 | const { sort } = searchParams as { [key: string]: string };
31 | const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
32 | const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
33 |
34 | return (
35 |
36 | {products.length === 0 ? (
37 | {`No products found in this collection`}
38 | ) : (
39 |
40 |
41 |
42 | )}
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/app/search/children-wrapper.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useSearchParams } from 'next/navigation';
4 | import { Fragment } from 'react';
5 |
6 | // Ensure children are re-rendered when the search query changes
7 | export default function ChildrenWrapper({ children }: { children: React.ReactNode }) {
8 | const searchParams = useSearchParams();
9 | return {children} ;
10 | }
11 |
--------------------------------------------------------------------------------
/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 ChildrenWrapper from './children-wrapper';
6 | import { Suspense } from 'react';
7 |
8 | export default function SearchLayout({
9 | children
10 | }: {
11 | children: React.ReactNode;
12 | }) {
13 | return (
14 | <>
15 |
16 |
17 |
18 |
19 |
20 |
21 | {children}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | >
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/app/search/loading.tsx:
--------------------------------------------------------------------------------
1 | import Grid from 'components/grid';
2 |
3 | export default function Loading() {
4 | return (
5 | <>
6 |
7 |
8 | {Array(12)
9 | .fill(0)
10 | .map((_, index) => {
11 | return (
12 |
13 | );
14 | })}
15 |
16 | >
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/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 metadata = {
7 | title: 'Search',
8 | description: 'Search for products in the store.'
9 | };
10 |
11 | export default async function SearchPage(props: {
12 | searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
13 | }) {
14 | const searchParams = await props.searchParams;
15 | const { sort, q: searchValue } = searchParams as { [key: string]: string };
16 | const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
17 |
18 | const products = await getProducts({ sortKey, reverse, query: searchValue });
19 | const resultsText = products.length > 1 ? 'results' : 'result';
20 |
21 | return (
22 | <>
23 | {searchValue ? (
24 |
25 | {products.length === 0
26 | ? 'There are no products that match '
27 | : `Showing ${products.length} ${resultsText} for `}
28 | "{searchValue}"
29 |
30 | ) : null}
31 | {products.length > 0 ? (
32 |
33 |
34 |
35 | ) : null}
36 | >
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import { getCollections, getPages, getProducts } from 'lib/shopify';
2 | import { baseUrl, validateEnvironmentVariables } from 'lib/utils';
3 | import { MetadataRoute } from 'next';
4 |
5 | type Route = {
6 | url: string;
7 | lastModified: string;
8 | };
9 |
10 | export const dynamic = 'force-dynamic';
11 |
12 | export default async function sitemap(): Promise {
13 | validateEnvironmentVariables();
14 |
15 | const routesMap = [''].map((route) => ({
16 | url: `${baseUrl}${route}`,
17 | lastModified: new Date().toISOString()
18 | }));
19 |
20 | const collectionsPromise = getCollections().then((collections) =>
21 | collections.map((collection) => ({
22 | url: `${baseUrl}${collection.path}`,
23 | lastModified: collection.updatedAt
24 | }))
25 | );
26 |
27 | const productsPromise = getProducts({}).then((products) =>
28 | products.map((product) => ({
29 | url: `${baseUrl}/product/${product.handle}`,
30 | lastModified: product.updatedAt
31 | }))
32 | );
33 |
34 | const pagesPromise = getPages().then((pages) =>
35 | pages.map((page) => ({
36 | url: `${baseUrl}/${page.handle}`,
37 | lastModified: page.updatedAt
38 | }))
39 | );
40 |
41 | let fetchedRoutes: Route[] = [];
42 |
43 | try {
44 | fetchedRoutes = (
45 | await Promise.all([collectionsPromise, productsPromise, pagesPromise])
46 | ).flat();
47 | } catch (error) {
48 | throw JSON.stringify(error, null, 2);
49 | }
50 |
51 | return [...routesMap, ...fetchedRoutes];
52 | }
53 |
--------------------------------------------------------------------------------
/components/carousel.tsx:
--------------------------------------------------------------------------------
1 | import { getCollectionProducts } from 'lib/shopify';
2 | import Link from 'next/link';
3 | import { GridTileImage } from './grid/tile';
4 |
5 | export async function Carousel() {
6 | // Collections that start with `hidden-*` are hidden from the search page.
7 | const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
8 |
9 | if (!products?.length) return null;
10 |
11 | // Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
12 | const carouselProducts = [...products, ...products, ...products];
13 |
14 | return (
15 |
16 |
17 | {carouselProducts.map((product, i) => (
18 |
22 |
23 |
34 |
35 |
36 | ))}
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/components/cart/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { TAGS } from 'lib/constants';
4 | import {
5 | addToCart,
6 | createCart,
7 | getCart,
8 | removeFromCart,
9 | updateCart
10 | } from 'lib/shopify';
11 | import { revalidateTag } from 'next/cache';
12 | import { cookies } from 'next/headers';
13 | import { redirect } from 'next/navigation';
14 |
15 | export async function addItem(
16 | prevState: any,
17 | selectedVariantId: string | undefined
18 | ) {
19 | if (!selectedVariantId) {
20 | return 'Error adding item to cart';
21 | }
22 |
23 | try {
24 | await addToCart([{ merchandiseId: selectedVariantId, quantity: 1 }]);
25 | revalidateTag(TAGS.cart);
26 | } catch (e) {
27 | return 'Error adding item to cart';
28 | }
29 | }
30 |
31 | export async function removeItem(prevState: any, merchandiseId: string) {
32 | try {
33 | const cart = await getCart();
34 |
35 | if (!cart) {
36 | return 'Error fetching cart';
37 | }
38 |
39 | const lineItem = cart.lines.find(
40 | (line) => line.merchandise.id === merchandiseId
41 | );
42 |
43 | if (lineItem && lineItem.id) {
44 | await removeFromCart([lineItem.id]);
45 | revalidateTag(TAGS.cart);
46 | } else {
47 | return 'Item not found in cart';
48 | }
49 | } catch (e) {
50 | return 'Error removing item from cart';
51 | }
52 | }
53 |
54 | export async function updateItemQuantity(
55 | prevState: any,
56 | payload: {
57 | merchandiseId: string;
58 | quantity: number;
59 | }
60 | ) {
61 | const { merchandiseId, quantity } = payload;
62 |
63 | try {
64 | const cart = await getCart();
65 |
66 | if (!cart) {
67 | return 'Error fetching cart';
68 | }
69 |
70 | const lineItem = cart.lines.find(
71 | (line) => line.merchandise.id === merchandiseId
72 | );
73 |
74 | if (lineItem && lineItem.id) {
75 | if (quantity === 0) {
76 | await removeFromCart([lineItem.id]);
77 | } else {
78 | await updateCart([
79 | {
80 | id: lineItem.id,
81 | merchandiseId,
82 | quantity
83 | }
84 | ]);
85 | }
86 | } else if (quantity > 0) {
87 | // If the item doesn't exist in the cart and quantity > 0, add it
88 | await addToCart([{ merchandiseId, quantity }]);
89 | }
90 |
91 | revalidateTag(TAGS.cart);
92 | } catch (e) {
93 | console.error(e);
94 | return 'Error updating item quantity';
95 | }
96 | }
97 |
98 | export async function redirectToCheckout() {
99 | let cart = await getCart();
100 | redirect(cart!.checkoutUrl);
101 | }
102 |
103 | export async function createCartAndSetCookie() {
104 | let cart = await createCart();
105 | (await cookies()).set('cartId', cart.id!);
106 | }
107 |
--------------------------------------------------------------------------------
/components/cart/add-to-cart.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { PlusIcon } from '@heroicons/react/24/outline';
4 | import clsx from 'clsx';
5 | import { addItem } from 'components/cart/actions';
6 | import { useProduct } from 'components/product/product-context';
7 | import { Product, ProductVariant } from 'lib/shopify/types';
8 | import { useActionState } from 'react';
9 | import { useCart } from './cart-context';
10 |
11 | function SubmitButton({
12 | availableForSale,
13 | selectedVariantId
14 | }: {
15 | availableForSale: boolean;
16 | selectedVariantId: string | undefined;
17 | }) {
18 | const buttonClasses =
19 | 'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
20 | const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
21 |
22 | if (!availableForSale) {
23 | return (
24 |
25 | Out Of Stock
26 |
27 | );
28 | }
29 |
30 | if (!selectedVariantId) {
31 | return (
32 |
37 |
40 | Add To Cart
41 |
42 | );
43 | }
44 |
45 | return (
46 |
52 |
55 | Add To Cart
56 |
57 | );
58 | }
59 |
60 | export function AddToCart({ product }: { product: Product }) {
61 | const { variants, availableForSale } = product;
62 | const { addCartItem } = useCart();
63 | const { state } = useProduct();
64 | const [message, formAction] = useActionState(addItem, null);
65 |
66 | const variant = variants.find((variant: ProductVariant) =>
67 | variant.selectedOptions.every(
68 | (option) => option.value === state[option.name.toLowerCase()]
69 | )
70 | );
71 | const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
72 | const selectedVariantId = variant?.id || defaultVariantId;
73 | const addItemAction = formAction.bind(null, selectedVariantId);
74 | const finalVariant = variants.find(
75 | (variant) => variant.id === selectedVariantId
76 | )!;
77 |
78 | return (
79 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/components/cart/cart-context.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type {
4 | Cart,
5 | CartItem,
6 | Product,
7 | ProductVariant
8 | } from 'lib/shopify/types';
9 | import React, {
10 | createContext,
11 | use,
12 | useContext,
13 | useMemo,
14 | useOptimistic
15 | } from 'react';
16 |
17 | type UpdateType = 'plus' | 'minus' | 'delete';
18 |
19 | type CartAction =
20 | | {
21 | type: 'UPDATE_ITEM';
22 | payload: { merchandiseId: string; updateType: UpdateType };
23 | }
24 | | {
25 | type: 'ADD_ITEM';
26 | payload: { variant: ProductVariant; product: Product };
27 | };
28 |
29 | type CartContextType = {
30 | cartPromise: Promise;
31 | };
32 |
33 | const CartContext = createContext(undefined);
34 |
35 | function calculateItemCost(quantity: number, price: string): string {
36 | return (Number(price) * quantity).toString();
37 | }
38 |
39 | function updateCartItem(
40 | item: CartItem,
41 | updateType: UpdateType
42 | ): CartItem | null {
43 | if (updateType === 'delete') return null;
44 |
45 | const newQuantity =
46 | updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
47 | if (newQuantity === 0) return null;
48 |
49 | const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
50 | const newTotalAmount = calculateItemCost(
51 | newQuantity,
52 | singleItemAmount.toString()
53 | );
54 |
55 | return {
56 | ...item,
57 | quantity: newQuantity,
58 | cost: {
59 | ...item.cost,
60 | totalAmount: {
61 | ...item.cost.totalAmount,
62 | amount: newTotalAmount
63 | }
64 | }
65 | };
66 | }
67 |
68 | function createOrUpdateCartItem(
69 | existingItem: CartItem | undefined,
70 | variant: ProductVariant,
71 | product: Product
72 | ): CartItem {
73 | const quantity = existingItem ? existingItem.quantity + 1 : 1;
74 | const totalAmount = calculateItemCost(quantity, variant.price.amount);
75 |
76 | return {
77 | id: existingItem?.id,
78 | quantity,
79 | cost: {
80 | totalAmount: {
81 | amount: totalAmount,
82 | currencyCode: variant.price.currencyCode
83 | }
84 | },
85 | merchandise: {
86 | id: variant.id,
87 | title: variant.title,
88 | selectedOptions: variant.selectedOptions,
89 | product: {
90 | id: product.id,
91 | handle: product.handle,
92 | title: product.title,
93 | featuredImage: product.featuredImage
94 | }
95 | }
96 | };
97 | }
98 |
99 | function updateCartTotals(
100 | lines: CartItem[]
101 | ): Pick {
102 | const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
103 | const totalAmount = lines.reduce(
104 | (sum, item) => sum + Number(item.cost.totalAmount.amount),
105 | 0
106 | );
107 | const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD';
108 |
109 | return {
110 | totalQuantity,
111 | cost: {
112 | subtotalAmount: { amount: totalAmount.toString(), currencyCode },
113 | totalAmount: { amount: totalAmount.toString(), currencyCode },
114 | totalTaxAmount: { amount: '0', currencyCode }
115 | }
116 | };
117 | }
118 |
119 | function createEmptyCart(): Cart {
120 | return {
121 | id: undefined,
122 | checkoutUrl: '',
123 | totalQuantity: 0,
124 | lines: [],
125 | cost: {
126 | subtotalAmount: { amount: '0', currencyCode: 'USD' },
127 | totalAmount: { amount: '0', currencyCode: 'USD' },
128 | totalTaxAmount: { amount: '0', currencyCode: 'USD' }
129 | }
130 | };
131 | }
132 |
133 | function cartReducer(state: Cart | undefined, action: CartAction): Cart {
134 | const currentCart = state || createEmptyCart();
135 |
136 | switch (action.type) {
137 | case 'UPDATE_ITEM': {
138 | const { merchandiseId, updateType } = action.payload;
139 | const updatedLines = currentCart.lines
140 | .map((item) =>
141 | item.merchandise.id === merchandiseId
142 | ? updateCartItem(item, updateType)
143 | : item
144 | )
145 | .filter(Boolean) as CartItem[];
146 |
147 | if (updatedLines.length === 0) {
148 | return {
149 | ...currentCart,
150 | lines: [],
151 | totalQuantity: 0,
152 | cost: {
153 | ...currentCart.cost,
154 | totalAmount: { ...currentCart.cost.totalAmount, amount: '0' }
155 | }
156 | };
157 | }
158 |
159 | return {
160 | ...currentCart,
161 | ...updateCartTotals(updatedLines),
162 | lines: updatedLines
163 | };
164 | }
165 | case 'ADD_ITEM': {
166 | const { variant, product } = action.payload;
167 | const existingItem = currentCart.lines.find(
168 | (item) => item.merchandise.id === variant.id
169 | );
170 | const updatedItem = createOrUpdateCartItem(
171 | existingItem,
172 | variant,
173 | product
174 | );
175 |
176 | const updatedLines = existingItem
177 | ? currentCart.lines.map((item) =>
178 | item.merchandise.id === variant.id ? updatedItem : item
179 | )
180 | : [...currentCart.lines, updatedItem];
181 |
182 | return {
183 | ...currentCart,
184 | ...updateCartTotals(updatedLines),
185 | lines: updatedLines
186 | };
187 | }
188 | default:
189 | return currentCart;
190 | }
191 | }
192 |
193 | export function CartProvider({
194 | children,
195 | cartPromise
196 | }: {
197 | children: React.ReactNode;
198 | cartPromise: Promise;
199 | }) {
200 | return (
201 |
202 | {children}
203 |
204 | );
205 | }
206 |
207 | export function useCart() {
208 | const context = useContext(CartContext);
209 | if (context === undefined) {
210 | throw new Error('useCart must be used within a CartProvider');
211 | }
212 |
213 | const initialCart = use(context.cartPromise);
214 | const [optimisticCart, updateOptimisticCart] = useOptimistic(
215 | initialCart,
216 | cartReducer
217 | );
218 |
219 | const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
220 | updateOptimisticCart({
221 | type: 'UPDATE_ITEM',
222 | payload: { merchandiseId, updateType }
223 | });
224 | };
225 |
226 | const addCartItem = (variant: ProductVariant, product: Product) => {
227 | updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
228 | };
229 |
230 | return useMemo(
231 | () => ({
232 | cart: optimisticCart,
233 | updateCartItem,
234 | addCartItem
235 | }),
236 | [optimisticCart]
237 | );
238 | }
239 |
--------------------------------------------------------------------------------
/components/cart/delete-item-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { XMarkIcon } from '@heroicons/react/24/outline';
4 | import { removeItem } from 'components/cart/actions';
5 | import type { CartItem } from 'lib/shopify/types';
6 | import { useActionState } from 'react';
7 |
8 | export function DeleteItemButton({
9 | item,
10 | optimisticUpdate
11 | }: {
12 | item: CartItem;
13 | optimisticUpdate: any;
14 | }) {
15 | const [message, formAction] = useActionState(removeItem, null);
16 | const merchandiseId = item.merchandise.id;
17 | const removeItemAction = formAction.bind(null, merchandiseId);
18 |
19 | return (
20 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/components/cart/edit-item-quantity-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
4 | import clsx from 'clsx';
5 | import { updateItemQuantity } from 'components/cart/actions';
6 | import type { CartItem } from 'lib/shopify/types';
7 | import { useActionState } from 'react';
8 |
9 | function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
10 | return (
11 |
23 | {type === 'plus' ? (
24 |
25 | ) : (
26 |
27 | )}
28 |
29 | );
30 | }
31 |
32 | export function EditItemQuantityButton({
33 | item,
34 | type,
35 | optimisticUpdate
36 | }: {
37 | item: CartItem;
38 | type: 'plus' | 'minus';
39 | optimisticUpdate: any;
40 | }) {
41 | const [message, formAction] = useActionState(updateItemQuantity, null);
42 | const payload = {
43 | merchandiseId: item.merchandise.id,
44 | quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
45 | };
46 | const updateItemQuantityAction = formAction.bind(null, payload);
47 |
48 | return (
49 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/components/cart/modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import { Dialog, Transition } from '@headlessui/react';
5 | import { ShoppingCartIcon, XMarkIcon } from '@heroicons/react/24/outline';
6 | import LoadingDots from 'components/loading-dots';
7 | import Price from 'components/price';
8 | import { DEFAULT_OPTION } from 'lib/constants';
9 | import { createUrl } from 'lib/utils';
10 | import Image from 'next/image';
11 | import Link from 'next/link';
12 | import { Fragment, useEffect, useRef, useState } from 'react';
13 | import { useFormStatus } from 'react-dom';
14 | import { createCartAndSetCookie, redirectToCheckout } from './actions';
15 | import { useCart } from './cart-context';
16 | import { DeleteItemButton } from './delete-item-button';
17 | import { EditItemQuantityButton } from './edit-item-quantity-button';
18 | import OpenCart from './open-cart';
19 |
20 | type MerchandiseSearchParams = {
21 | [key: string]: string;
22 | };
23 |
24 | export default function CartModal() {
25 | const { cart, updateCartItem } = useCart();
26 | const [isOpen, setIsOpen] = useState(false);
27 | const quantityRef = useRef(cart?.totalQuantity);
28 | const openCart = () => setIsOpen(true);
29 | const closeCart = () => setIsOpen(false);
30 |
31 | useEffect(() => {
32 | if (!cart) {
33 | createCartAndSetCookie();
34 | }
35 | }, [cart]);
36 |
37 | useEffect(() => {
38 | if (
39 | cart?.totalQuantity &&
40 | cart?.totalQuantity !== quantityRef.current &&
41 | cart?.totalQuantity > 0
42 | ) {
43 | if (!isOpen) {
44 | setIsOpen(true);
45 | }
46 | quantityRef.current = cart?.totalQuantity;
47 | }
48 | }, [isOpen, cart?.totalQuantity, quantityRef]);
49 |
50 | return (
51 | <>
52 |
53 |
54 |
55 |
56 |
57 |
66 |
67 |
68 |
77 |
78 |
79 |
My Cart
80 |
81 |
82 |
83 |
84 |
85 | {!cart || cart.lines.length === 0 ? (
86 |
87 |
88 |
89 | Your cart is empty.
90 |
91 |
92 | ) : (
93 |
94 |
95 | {cart.lines
96 | .sort((a, b) =>
97 | a.merchandise.product.title.localeCompare(
98 | b.merchandise.product.title
99 | )
100 | )
101 | .map((item, i) => {
102 | const merchandiseSearchParams =
103 | {} as MerchandiseSearchParams;
104 |
105 | item.merchandise.selectedOptions.forEach(
106 | ({ name, value }) => {
107 | if (value !== DEFAULT_OPTION) {
108 | merchandiseSearchParams[name.toLowerCase()] =
109 | value;
110 | }
111 | }
112 | );
113 |
114 | const merchandiseUrl = createUrl(
115 | `/product/${item.merchandise.product.handle}`,
116 | new URLSearchParams(merchandiseSearchParams)
117 | );
118 |
119 | return (
120 |
124 |
125 |
126 |
130 |
131 |
132 |
133 |
146 |
147 |
152 |
153 |
154 | {item.merchandise.product.title}
155 |
156 | {item.merchandise.title !==
157 | DEFAULT_OPTION ? (
158 |
159 | {item.merchandise.title}
160 |
161 | ) : null}
162 |
163 |
164 |
165 |
166 |
173 |
174 |
179 |
180 |
181 | {item.quantity}
182 |
183 |
184 |
189 |
190 |
191 |
192 |
193 | );
194 | })}
195 |
196 |
197 |
205 |
206 |
Shipping
207 |
Calculated at checkout
208 |
209 |
217 |
218 |
221 |
222 | )}
223 |
224 |
225 |
226 |
227 | >
228 | );
229 | }
230 |
231 | function CloseCart({ className }: { className?: string }) {
232 | return (
233 |
234 |
240 |
241 | );
242 | }
243 |
244 | function CheckoutButton() {
245 | const { pending } = useFormStatus();
246 |
247 | return (
248 |
253 | {pending ? : 'Proceed to Checkout'}
254 |
255 | );
256 | }
257 |
--------------------------------------------------------------------------------
/components/cart/open-cart.tsx:
--------------------------------------------------------------------------------
1 | import { ShoppingCartIcon } from '@heroicons/react/24/outline';
2 | import clsx from 'clsx';
3 |
4 | export default function OpenCart({
5 | className,
6 | quantity
7 | }: {
8 | className?: string;
9 | quantity?: number;
10 | }) {
11 | return (
12 |
13 |
16 |
17 | {quantity ? (
18 |
19 | {quantity}
20 |
21 | ) : null}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/components/grid/index.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | function Grid(props: React.ComponentProps<'ul'>) {
4 | return (
5 |
8 | );
9 | }
10 |
11 | function GridItem(props: React.ComponentProps<'li'>) {
12 | return (
13 |
14 | {props.children}
15 |
16 | );
17 | }
18 |
19 | Grid.Item = GridItem;
20 |
21 | export default Grid;
22 |
--------------------------------------------------------------------------------
/components/grid/three-items.tsx:
--------------------------------------------------------------------------------
1 | import { GridTileImage } from 'components/grid/tile';
2 | import { getCollectionProducts } from 'lib/shopify';
3 | import type { Product } from 'lib/shopify/types';
4 | import Link from 'next/link';
5 |
6 | function ThreeItemGridItem({
7 | item,
8 | size,
9 | priority
10 | }: {
11 | item: Product;
12 | size: 'full' | 'half';
13 | priority?: boolean;
14 | }) {
15 | return (
16 |
19 |
24 |
39 |
40 |
41 | );
42 | }
43 |
44 | export async function ThreeItemGrid() {
45 | // Collections that start with `hidden-*` are hidden from the search page.
46 | const homepageItems = await getCollectionProducts({
47 | collection: 'hidden-homepage-featured-items'
48 | });
49 |
50 | if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
51 |
52 | const [firstProduct, secondProduct, thirdProduct] = homepageItems;
53 |
54 | return (
55 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/components/grid/tile.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import Image from 'next/image';
3 | import Label from '../label';
4 |
5 | export function GridTileImage({
6 | isInteractive = true,
7 | active,
8 | label,
9 | ...props
10 | }: {
11 | isInteractive?: boolean;
12 | active?: boolean;
13 | label?: {
14 | title: string;
15 | amount: string;
16 | currencyCode: string;
17 | position?: 'bottom' | 'center';
18 | };
19 | } & React.ComponentProps) {
20 | return (
21 |
31 | {props.src ? (
32 |
38 | ) : null}
39 | {label ? (
40 |
46 | ) : null}
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/components/icons/logo.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | export default function LogoIcon(props: React.ComponentProps<'svg'>) {
4 | return (
5 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components/label.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import Price from './price';
3 |
4 | const Label = ({
5 | title,
6 | amount,
7 | currencyCode,
8 | position = 'bottom'
9 | }: {
10 | title: string;
11 | amount: string;
12 | currencyCode: string;
13 | position?: 'bottom' | 'center';
14 | }) => {
15 | return (
16 |
31 | );
32 | };
33 |
34 | export default Label;
35 |
--------------------------------------------------------------------------------
/components/layout/footer-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import { Menu } from 'lib/shopify/types';
5 | import Link from 'next/link';
6 | import { usePathname } from 'next/navigation';
7 | import { useEffect, useState } from 'react';
8 |
9 | export function FooterMenuItem({ item }: { item: Menu }) {
10 | const pathname = usePathname();
11 | const [active, setActive] = useState(pathname === item.path);
12 |
13 | useEffect(() => {
14 | setActive(pathname === item.path);
15 | }, [pathname, item.path]);
16 |
17 | return (
18 |
19 |
28 | {item.title}
29 |
30 |
31 | );
32 | }
33 |
34 | export default function FooterMenu({ menu }: { menu: Menu[] }) {
35 | if (!menu.length) return null;
36 |
37 | return (
38 |
39 |
40 | {menu.map((item: Menu) => {
41 | return ;
42 | })}
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/components/layout/footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | import FooterMenu from 'components/layout/footer-menu';
4 | import LogoSquare from 'components/logo-square';
5 | import { getMenu } from 'lib/shopify';
6 | import { Suspense } from 'react';
7 |
8 | const { COMPANY_NAME, SITE_NAME } = process.env;
9 |
10 | export default async function Footer() {
11 | const currentYear = new Date().getFullYear();
12 | const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
13 | const skeleton = 'w-full h-6 animate-pulse rounded-sm bg-neutral-200 dark:bg-neutral-700';
14 | const menu = await getMenu('next-js-frontend-footer-menu');
15 | const copyrightName = COMPANY_NAME || SITE_NAME || '';
16 |
17 | return (
18 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/components/layout/navbar/index.tsx:
--------------------------------------------------------------------------------
1 | import CartModal from 'components/cart/modal';
2 | import LogoSquare from 'components/logo-square';
3 | import { getMenu } from 'lib/shopify';
4 | import { Menu } from 'lib/shopify/types';
5 | import Link from 'next/link';
6 | import { Suspense } from 'react';
7 | import MobileMenu from './mobile-menu';
8 | import Search, { SearchSkeleton } from './search';
9 |
10 | const { SITE_NAME } = process.env;
11 |
12 | export 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 |
29 |
30 |
31 | {SITE_NAME}
32 |
33 |
34 | {menu.length ? (
35 |
36 | {menu.map((item: Menu) => (
37 |
38 |
43 | {item.title}
44 |
45 |
46 | ))}
47 |
48 | ) : null}
49 |
50 |
51 | }>
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/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, Suspense, useEffect, useState } from 'react';
7 |
8 | import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
9 | import { Menu } from 'lib/shopify/types';
10 | import Search, { SearchSkeleton } from './search';
11 |
12 | export default function MobileMenu({ menu }: { menu: Menu[] }) {
13 | const pathname = usePathname();
14 | const searchParams = useSearchParams();
15 | const [isOpen, setIsOpen] = useState(false);
16 | const openMobileMenu = () => setIsOpen(true);
17 | const closeMobileMenu = () => setIsOpen(false);
18 |
19 | useEffect(() => {
20 | const handleResize = () => {
21 | if (window.innerWidth > 768) {
22 | setIsOpen(false);
23 | }
24 | };
25 | window.addEventListener('resize', handleResize);
26 | return () => window.removeEventListener('resize', handleResize);
27 | }, [isOpen]);
28 |
29 | useEffect(() => {
30 | setIsOpen(false);
31 | }, [pathname, searchParams]);
32 |
33 | return (
34 | <>
35 |
40 |
41 |
42 |
43 |
44 |
53 |
54 |
55 |
64 |
65 |
66 |
71 |
72 |
73 |
74 |
75 | }>
76 |
77 |
78 |
79 | {menu.length ? (
80 |
81 | {menu.map((item: Menu) => (
82 |
86 |
87 | {item.title}
88 |
89 |
90 | ))}
91 |
92 | ) : null}
93 |
94 |
95 |
96 |
97 |
98 | >
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/components/layout/navbar/search.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
4 | import Form from 'next/form';
5 | import { useSearchParams } from 'next/navigation';
6 |
7 | export default function Search() {
8 | const searchParams = useSearchParams();
9 |
10 | return (
11 |
25 | );
26 | }
27 |
28 | export function SearchSkeleton() {
29 | return (
30 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/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 |
16 |
27 |
28 |
29 | ))}
30 | >
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/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-sm';
13 | const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
14 | const items = 'bg-neutral-400 dark:bg-neutral-700';
15 |
16 | export default function Collections() {
17 | return (
18 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | }
33 | >
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/components/layout/search/filter/dropdown.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname, useSearchParams } from 'next/navigation';
4 | import { useEffect, useRef, useState } from 'react';
5 |
6 | import { ChevronDownIcon } from '@heroicons/react/24/outline';
7 | import type { ListItem } from '.';
8 | import { FilterItem } from './item';
9 |
10 | export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
11 | const pathname = usePathname();
12 | const searchParams = useSearchParams();
13 | const [active, setActive] = useState('');
14 | const [openSelect, setOpenSelect] = useState(false);
15 | const ref = useRef(null);
16 |
17 | useEffect(() => {
18 | const handleClickOutside = (event: MouseEvent) => {
19 | if (ref.current && !ref.current.contains(event.target as Node)) {
20 | setOpenSelect(false);
21 | }
22 | };
23 |
24 | window.addEventListener('click', handleClickOutside);
25 | return () => window.removeEventListener('click', handleClickOutside);
26 | }, []);
27 |
28 | useEffect(() => {
29 | list.forEach((listItem: ListItem) => {
30 | if (
31 | ('path' in listItem && pathname === listItem.path) ||
32 | ('slug' in listItem && searchParams.get('sort') === listItem.slug)
33 | ) {
34 | setActive(listItem.title);
35 | }
36 | });
37 | }, [pathname, list, searchParams]);
38 |
39 | return (
40 |
41 |
{
43 | setOpenSelect(!openSelect);
44 | }}
45 | className="flex w-full items-center justify-between rounded-sm border border-black/30 px-4 py-2 text-sm dark:border-white/30"
46 | >
47 |
{active}
48 |
49 |
50 | {openSelect && (
51 |
{
53 | setOpenSelect(false);
54 | }}
55 | className="absolute z-40 w-full rounded-b-md bg-white p-4 shadow-md dark:bg-black"
56 | >
57 | {list.map((item: ListItem, i) => (
58 |
59 | ))}
60 |
61 | )}
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/components/layout/search/filter/index.tsx:
--------------------------------------------------------------------------------
1 | import { SortFilterItem } from 'lib/constants';
2 | import { Suspense } from 'react';
3 | import FilterItemDropdown from './dropdown';
4 | import { FilterItem } from './item';
5 |
6 | export type ListItem = SortFilterItem | PathFilterItem;
7 | export type PathFilterItem = { title: string; path: string };
8 |
9 | function FilterItemList({ list }: { list: ListItem[] }) {
10 | return (
11 | <>
12 | {list.map((item: ListItem, i) => (
13 |
14 | ))}
15 | >
16 | );
17 | }
18 |
19 | export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
20 | return (
21 | <>
22 |
23 | {title ? (
24 |
25 | {title}
26 |
27 | ) : null}
28 |
33 |
38 |
39 | >
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/components/layout/search/filter/item.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import type { SortFilterItem } from 'lib/constants';
5 | import { createUrl } from 'lib/utils';
6 | import Link from 'next/link';
7 | import { usePathname, useSearchParams } from 'next/navigation';
8 | import type { ListItem, PathFilterItem } from '.';
9 |
10 | function PathFilterItem({ item }: { item: PathFilterItem }) {
11 | const pathname = usePathname();
12 | const searchParams = useSearchParams();
13 | const active = pathname === item.path;
14 | const newParams = new URLSearchParams(searchParams.toString());
15 | const DynamicTag = active ? 'p' : Link;
16 |
17 | newParams.delete('q');
18 |
19 | return (
20 |
21 |
30 | {item.title}
31 |
32 |
33 | );
34 | }
35 |
36 | function SortFilterItem({ item }: { item: SortFilterItem }) {
37 | const pathname = usePathname();
38 | const searchParams = useSearchParams();
39 | const active = searchParams.get('sort') === item.slug;
40 | const q = searchParams.get('q');
41 | const href = createUrl(
42 | pathname,
43 | new URLSearchParams({
44 | ...(q && { q }),
45 | ...(item.slug && item.slug.length && { sort: item.slug })
46 | })
47 | );
48 | const DynamicTag = active ? 'p' : Link;
49 |
50 | return (
51 |
52 |
59 | {item.title}
60 |
61 |
62 | );
63 | }
64 |
65 | export function FilterItem({ item }: { item: ListItem }) {
66 | return 'path' in item ? : ;
67 | }
68 |
--------------------------------------------------------------------------------
/components/loading-dots.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md';
4 |
5 | const LoadingDots = ({ className }: { className: string }) => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | );
13 | };
14 |
15 | export default LoadingDots;
16 |
--------------------------------------------------------------------------------
/components/logo-square.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import LogoIcon from './icons/logo';
3 |
4 | export default function LogoSquare({ size }: { size?: 'sm' | undefined }) {
5 | return (
6 |
15 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/components/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import { ImageResponse } from 'next/og';
2 | import LogoIcon from './icons/logo';
3 | import { join } from 'path';
4 | import { readFile } from 'fs/promises';
5 |
6 | export type Props = {
7 | title?: string;
8 | };
9 |
10 | export default async function OpengraphImage(
11 | props?: Props
12 | ): Promise {
13 | const { title } = {
14 | ...{
15 | title: process.env.SITE_NAME
16 | },
17 | ...props
18 | };
19 |
20 | const file = await readFile(join(process.cwd(), './fonts/Inter-Bold.ttf'));
21 | const font = Uint8Array.from(file).buffer;
22 |
23 | return new ImageResponse(
24 | (
25 |
26 |
27 |
28 |
29 |
{title}
30 |
31 | ),
32 | {
33 | width: 1200,
34 | height: 630,
35 | fonts: [
36 | {
37 | name: 'Inter',
38 | data: font,
39 | style: 'normal',
40 | weight: 700
41 | }
42 | ]
43 | }
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/components/price.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | const Price = ({
4 | amount,
5 | className,
6 | currencyCode = 'USD',
7 | currencyCodeClassName
8 | }: {
9 | amount: string;
10 | className?: string;
11 | currencyCode: string;
12 | currencyCodeClassName?: string;
13 | } & React.ComponentProps<'p'>) => (
14 |
15 | {`${new Intl.NumberFormat(undefined, {
16 | style: 'currency',
17 | currency: currencyCode,
18 | currencyDisplay: 'narrowSymbol'
19 | }).format(parseFloat(amount))}`}
20 | {`${currencyCode}`}
21 |
22 | );
23 |
24 | export default Price;
25 |
--------------------------------------------------------------------------------
/components/product/gallery.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
4 | import { GridTileImage } from 'components/grid/tile';
5 | import { useProduct, useUpdateURL } from 'components/product/product-context';
6 | import Image from 'next/image';
7 |
8 | export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
9 | const { state, updateImage } = useProduct();
10 | const updateURL = useUpdateURL();
11 | const imageIndex = state.image ? parseInt(state.image) : 0;
12 |
13 | const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
14 | const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
15 |
16 | const buttonClassName =
17 | 'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center';
18 |
19 | return (
20 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/components/product/product-context.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useRouter, useSearchParams } from 'next/navigation';
4 | import React, { createContext, useContext, useMemo, useOptimistic } from 'react';
5 |
6 | type ProductState = {
7 | [key: string]: string;
8 | } & {
9 | image?: string;
10 | };
11 |
12 | type ProductContextType = {
13 | state: ProductState;
14 | updateOption: (name: string, value: string) => ProductState;
15 | updateImage: (index: string) => ProductState;
16 | };
17 |
18 | const ProductContext = createContext(undefined);
19 |
20 | export function ProductProvider({ children }: { children: React.ReactNode }) {
21 | const searchParams = useSearchParams();
22 |
23 | const getInitialState = () => {
24 | const params: ProductState = {};
25 | for (const [key, value] of searchParams.entries()) {
26 | params[key] = value;
27 | }
28 | return params;
29 | };
30 |
31 | const [state, setOptimisticState] = useOptimistic(
32 | getInitialState(),
33 | (prevState: ProductState, update: ProductState) => ({
34 | ...prevState,
35 | ...update
36 | })
37 | );
38 |
39 | const updateOption = (name: string, value: string) => {
40 | const newState = { [name]: value };
41 | setOptimisticState(newState);
42 | return { ...state, ...newState };
43 | };
44 |
45 | const updateImage = (index: string) => {
46 | const newState = { image: index };
47 | setOptimisticState(newState);
48 | return { ...state, ...newState };
49 | };
50 |
51 | const value = useMemo(
52 | () => ({
53 | state,
54 | updateOption,
55 | updateImage
56 | }),
57 | [state]
58 | );
59 |
60 | return {children} ;
61 | }
62 |
63 | export function useProduct() {
64 | const context = useContext(ProductContext);
65 | if (context === undefined) {
66 | throw new Error('useProduct must be used within a ProductProvider');
67 | }
68 | return context;
69 | }
70 |
71 | export function useUpdateURL() {
72 | const router = useRouter();
73 |
74 | return (state: ProductState) => {
75 | const newParams = new URLSearchParams(window.location.search);
76 | Object.entries(state).forEach(([key, value]) => {
77 | newParams.set(key, value);
78 | });
79 | router.push(`?${newParams.toString()}`, { scroll: false });
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/components/product/product-description.tsx:
--------------------------------------------------------------------------------
1 | import { AddToCart } from 'components/cart/add-to-cart';
2 | import Price from 'components/price';
3 | import Prose from 'components/prose';
4 | import { Product } from 'lib/shopify/types';
5 | import { VariantSelector } from './variant-selector';
6 |
7 | export function ProductDescription({ product }: { product: Product }) {
8 | return (
9 | <>
10 |
11 |
{product.title}
12 |
18 |
19 |
20 | {product.descriptionHtml ? (
21 |
25 | ) : null}
26 |
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/components/product/variant-selector.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import { useProduct, useUpdateURL } from 'components/product/product-context';
5 | import { ProductOption, ProductVariant } from 'lib/shopify/types';
6 |
7 | type Combination = {
8 | id: string;
9 | availableForSale: boolean;
10 | [key: string]: string | boolean;
11 | };
12 |
13 | export function VariantSelector({
14 | options,
15 | variants
16 | }: {
17 | options: ProductOption[];
18 | variants: ProductVariant[];
19 | }) {
20 | const { state, updateOption } = useProduct();
21 | const updateURL = useUpdateURL();
22 | const hasNoOptionsOrJustOneOption =
23 | !options.length || (options.length === 1 && options[0]?.values.length === 1);
24 |
25 | if (hasNoOptionsOrJustOneOption) {
26 | return null;
27 | }
28 |
29 | const combinations: Combination[] = variants.map((variant) => ({
30 | id: variant.id,
31 | availableForSale: variant.availableForSale,
32 | ...variant.selectedOptions.reduce(
33 | (accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
34 | {}
35 | )
36 | }));
37 |
38 | return options.map((option) => (
39 |
92 | ));
93 | }
94 |
--------------------------------------------------------------------------------
/components/prose.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | const Prose = ({ html, className }: { html: string; className?: string }) => {
4 | return (
5 |
12 | );
13 | };
14 |
15 | export default Prose;
16 |
--------------------------------------------------------------------------------
/components/welcome-toast.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import { toast } from 'sonner';
5 |
6 | export function WelcomeToast() {
7 | useEffect(() => {
8 | // ignore if screen height is too small
9 | if (window.innerHeight < 650) return;
10 | if (!document.cookie.includes('welcome-toast=2')) {
11 | toast('🛍️ Welcome to Next.js Commerce!', {
12 | id: 'welcome-toast',
13 | duration: Infinity,
14 | onDismiss: () => {
15 | document.cookie = 'welcome-toast=2; max-age=31536000; path=/';
16 | },
17 | description: (
18 | <>
19 | This is a high-performance, SSR storefront powered by Shopify, Next.js, and Vercel.{' '}
20 |
25 | Deploy your own
26 |
27 | .
28 | >
29 | )
30 | });
31 | }
32 | }, []);
33 |
34 | return null;
35 | }
36 |
--------------------------------------------------------------------------------
/fonts/Inter-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vercel/commerce/fa1306916c652ea5f820d5b400087bece13460fd/fonts/Inter-Bold.ttf
--------------------------------------------------------------------------------
/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export type SortFilterItem = {
2 | title: string;
3 | slug: string | null;
4 | sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE';
5 | reverse: boolean;
6 | };
7 |
8 | export const defaultSort: SortFilterItem = {
9 | title: 'Relevance',
10 | slug: null,
11 | sortKey: 'RELEVANCE',
12 | reverse: false
13 | };
14 |
15 | export const sorting: SortFilterItem[] = [
16 | defaultSort,
17 | { title: '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 | cart: 'cart'
27 | };
28 |
29 | export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
30 | export const DEFAULT_OPTION = 'Default Title';
31 | export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
32 |
--------------------------------------------------------------------------------
/lib/shopify/fragments/cart.ts:
--------------------------------------------------------------------------------
1 | import productFragment from './product';
2 |
3 | const cartFragment = /* GraphQL */ `
4 | fragment cart on Cart {
5 | id
6 | checkoutUrl
7 | cost {
8 | subtotalAmount {
9 | amount
10 | currencyCode
11 | }
12 | totalAmount {
13 | amount
14 | currencyCode
15 | }
16 | totalTaxAmount {
17 | amount
18 | currencyCode
19 | }
20 | }
21 | lines(first: 100) {
22 | edges {
23 | node {
24 | id
25 | quantity
26 | cost {
27 | totalAmount {
28 | amount
29 | currencyCode
30 | }
31 | }
32 | merchandise {
33 | ... on ProductVariant {
34 | id
35 | title
36 | selectedOptions {
37 | name
38 | value
39 | }
40 | product {
41 | ...product
42 | }
43 | }
44 | }
45 | }
46 | }
47 | }
48 | totalQuantity
49 | }
50 | ${productFragment}
51 | `;
52 |
53 | export default cartFragment;
54 |
--------------------------------------------------------------------------------
/lib/shopify/fragments/image.ts:
--------------------------------------------------------------------------------
1 | const imageFragment = /* GraphQL */ `
2 | fragment image on Image {
3 | url
4 | altText
5 | width
6 | height
7 | }
8 | `;
9 |
10 | export default imageFragment;
11 |
--------------------------------------------------------------------------------
/lib/shopify/fragments/product.ts:
--------------------------------------------------------------------------------
1 | import imageFragment from './image';
2 | import seoFragment from './seo';
3 |
4 | const productFragment = /* GraphQL */ `
5 | fragment product on Product {
6 | id
7 | handle
8 | availableForSale
9 | title
10 | description
11 | descriptionHtml
12 | options {
13 | id
14 | name
15 | values
16 | }
17 | priceRange {
18 | maxVariantPrice {
19 | amount
20 | currencyCode
21 | }
22 | minVariantPrice {
23 | amount
24 | currencyCode
25 | }
26 | }
27 | variants(first: 250) {
28 | edges {
29 | node {
30 | id
31 | title
32 | availableForSale
33 | selectedOptions {
34 | name
35 | value
36 | }
37 | price {
38 | amount
39 | currencyCode
40 | }
41 | }
42 | }
43 | }
44 | featuredImage {
45 | ...image
46 | }
47 | images(first: 20) {
48 | edges {
49 | node {
50 | ...image
51 | }
52 | }
53 | }
54 | seo {
55 | ...seo
56 | }
57 | tags
58 | updatedAt
59 | }
60 | ${imageFragment}
61 | ${seoFragment}
62 | `;
63 |
64 | export default productFragment;
65 |
--------------------------------------------------------------------------------
/lib/shopify/fragments/seo.ts:
--------------------------------------------------------------------------------
1 | const seoFragment = /* GraphQL */ `
2 | fragment seo on SEO {
3 | description
4 | title
5 | }
6 | `;
7 |
8 | export default seoFragment;
9 |
--------------------------------------------------------------------------------
/lib/shopify/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | HIDDEN_PRODUCT_TAG,
3 | SHOPIFY_GRAPHQL_API_ENDPOINT,
4 | TAGS
5 | } from 'lib/constants';
6 | import { isShopifyError } from 'lib/type-guards';
7 | import { ensureStartsWith } from 'lib/utils';
8 | import {
9 | revalidateTag,
10 | unstable_cacheTag as cacheTag,
11 | unstable_cacheLife as cacheLife
12 | } from 'next/cache';
13 | import { cookies, headers } from 'next/headers';
14 | import { NextRequest, NextResponse } from 'next/server';
15 | import {
16 | addToCartMutation,
17 | createCartMutation,
18 | editCartItemsMutation,
19 | removeFromCartMutation
20 | } from './mutations/cart';
21 | import { getCartQuery } from './queries/cart';
22 | import {
23 | getCollectionProductsQuery,
24 | getCollectionQuery,
25 | getCollectionsQuery
26 | } from './queries/collection';
27 | import { getMenuQuery } from './queries/menu';
28 | import { getPageQuery, getPagesQuery } from './queries/page';
29 | import {
30 | getProductQuery,
31 | getProductRecommendationsQuery,
32 | getProductsQuery
33 | } from './queries/product';
34 | import {
35 | Cart,
36 | Collection,
37 | Connection,
38 | Image,
39 | Menu,
40 | Page,
41 | Product,
42 | ShopifyAddToCartOperation,
43 | ShopifyCart,
44 | ShopifyCartOperation,
45 | ShopifyCollection,
46 | ShopifyCollectionOperation,
47 | ShopifyCollectionProductsOperation,
48 | ShopifyCollectionsOperation,
49 | ShopifyCreateCartOperation,
50 | ShopifyMenuOperation,
51 | ShopifyPageOperation,
52 | ShopifyPagesOperation,
53 | ShopifyProduct,
54 | ShopifyProductOperation,
55 | ShopifyProductRecommendationsOperation,
56 | ShopifyProductsOperation,
57 | ShopifyRemoveFromCartOperation,
58 | ShopifyUpdateCartOperation
59 | } from './types';
60 |
61 | const domain = process.env.SHOPIFY_STORE_DOMAIN
62 | ? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
63 | : '';
64 | const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
65 | const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
66 |
67 | type ExtractVariables = T extends { variables: object }
68 | ? T['variables']
69 | : never;
70 |
71 | export async function shopifyFetch({
72 | headers,
73 | query,
74 | variables
75 | }: {
76 | headers?: HeadersInit;
77 | query: string;
78 | variables?: ExtractVariables;
79 | }): Promise<{ status: number; body: T } | never> {
80 | try {
81 | const result = await fetch(endpoint, {
82 | method: 'POST',
83 | headers: {
84 | 'Content-Type': 'application/json',
85 | 'X-Shopify-Storefront-Access-Token': key,
86 | ...headers
87 | },
88 | body: JSON.stringify({
89 | ...(query && { query }),
90 | ...(variables && { variables })
91 | })
92 | });
93 |
94 | const body = await result.json();
95 |
96 | if (body.errors) {
97 | throw body.errors[0];
98 | }
99 |
100 | return {
101 | status: result.status,
102 | body
103 | };
104 | } catch (e) {
105 | if (isShopifyError(e)) {
106 | throw {
107 | cause: e.cause?.toString() || 'unknown',
108 | status: e.status || 500,
109 | message: e.message,
110 | query
111 | };
112 | }
113 |
114 | throw {
115 | error: e,
116 | query
117 | };
118 | }
119 | }
120 |
121 | const removeEdgesAndNodes = (array: Connection): T[] => {
122 | return array.edges.map((edge) => edge?.node);
123 | };
124 |
125 | const reshapeCart = (cart: ShopifyCart): Cart => {
126 | if (!cart.cost?.totalTaxAmount) {
127 | cart.cost.totalTaxAmount = {
128 | amount: '0.0',
129 | currencyCode: cart.cost.totalAmount.currencyCode
130 | };
131 | }
132 |
133 | return {
134 | ...cart,
135 | lines: removeEdgesAndNodes(cart.lines)
136 | };
137 | };
138 |
139 | const reshapeCollection = (
140 | collection: ShopifyCollection
141 | ): Collection | undefined => {
142 | if (!collection) {
143 | return undefined;
144 | }
145 |
146 | return {
147 | ...collection,
148 | path: `/search/${collection.handle}`
149 | };
150 | };
151 |
152 | const reshapeCollections = (collections: ShopifyCollection[]) => {
153 | const reshapedCollections = [];
154 |
155 | for (const collection of collections) {
156 | if (collection) {
157 | const reshapedCollection = reshapeCollection(collection);
158 |
159 | if (reshapedCollection) {
160 | reshapedCollections.push(reshapedCollection);
161 | }
162 | }
163 | }
164 |
165 | return reshapedCollections;
166 | };
167 |
168 | const reshapeImages = (images: Connection, productTitle: string) => {
169 | const flattened = removeEdgesAndNodes(images);
170 |
171 | return flattened.map((image) => {
172 | const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
173 | return {
174 | ...image,
175 | altText: image.altText || `${productTitle} - ${filename}`
176 | };
177 | });
178 | };
179 |
180 | const reshapeProduct = (
181 | product: ShopifyProduct,
182 | filterHiddenProducts: boolean = true
183 | ) => {
184 | if (
185 | !product ||
186 | (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))
187 | ) {
188 | return undefined;
189 | }
190 |
191 | const { images, variants, ...rest } = product;
192 |
193 | return {
194 | ...rest,
195 | images: reshapeImages(images, product.title),
196 | variants: removeEdgesAndNodes(variants)
197 | };
198 | };
199 |
200 | const reshapeProducts = (products: ShopifyProduct[]) => {
201 | const reshapedProducts = [];
202 |
203 | for (const product of products) {
204 | if (product) {
205 | const reshapedProduct = reshapeProduct(product);
206 |
207 | if (reshapedProduct) {
208 | reshapedProducts.push(reshapedProduct);
209 | }
210 | }
211 | }
212 |
213 | return reshapedProducts;
214 | };
215 |
216 | export async function createCart(): Promise {
217 | const res = await shopifyFetch({
218 | query: createCartMutation
219 | });
220 |
221 | return reshapeCart(res.body.data.cartCreate.cart);
222 | }
223 |
224 | export async function addToCart(
225 | lines: { merchandiseId: string; quantity: number }[]
226 | ): Promise {
227 | const cartId = (await cookies()).get('cartId')?.value!;
228 | const res = await shopifyFetch({
229 | query: addToCartMutation,
230 | variables: {
231 | cartId,
232 | lines
233 | }
234 | });
235 | return reshapeCart(res.body.data.cartLinesAdd.cart);
236 | }
237 |
238 | export async function removeFromCart(lineIds: string[]): Promise {
239 | const cartId = (await cookies()).get('cartId')?.value!;
240 | const res = await shopifyFetch({
241 | query: removeFromCartMutation,
242 | variables: {
243 | cartId,
244 | lineIds
245 | }
246 | });
247 |
248 | return reshapeCart(res.body.data.cartLinesRemove.cart);
249 | }
250 |
251 | export async function updateCart(
252 | lines: { id: string; merchandiseId: string; quantity: number }[]
253 | ): Promise {
254 | const cartId = (await cookies()).get('cartId')?.value!;
255 | const res = await shopifyFetch({
256 | query: editCartItemsMutation,
257 | variables: {
258 | cartId,
259 | lines
260 | }
261 | });
262 |
263 | return reshapeCart(res.body.data.cartLinesUpdate.cart);
264 | }
265 |
266 | export async function getCart(): Promise {
267 | const cartId = (await cookies()).get('cartId')?.value;
268 |
269 | if (!cartId) {
270 | return undefined;
271 | }
272 |
273 | const res = await shopifyFetch({
274 | query: getCartQuery,
275 | variables: { cartId }
276 | });
277 |
278 | // Old carts becomes `null` when you checkout.
279 | if (!res.body.data.cart) {
280 | return undefined;
281 | }
282 |
283 | return reshapeCart(res.body.data.cart);
284 | }
285 |
286 | export async function getCollection(
287 | handle: string
288 | ): Promise {
289 | 'use cache';
290 | cacheTag(TAGS.collections);
291 | cacheLife('days');
292 |
293 | const res = await shopifyFetch({
294 | query: getCollectionQuery,
295 | variables: {
296 | handle
297 | }
298 | });
299 |
300 | return reshapeCollection(res.body.data.collection);
301 | }
302 |
303 | export async function getCollectionProducts({
304 | collection,
305 | reverse,
306 | sortKey
307 | }: {
308 | collection: string;
309 | reverse?: boolean;
310 | sortKey?: string;
311 | }): Promise {
312 | 'use cache';
313 | cacheTag(TAGS.collections, TAGS.products);
314 | cacheLife('days');
315 |
316 | const res = await shopifyFetch({
317 | query: getCollectionProductsQuery,
318 | variables: {
319 | handle: collection,
320 | reverse,
321 | sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
322 | }
323 | });
324 |
325 | if (!res.body.data.collection) {
326 | console.log(`No collection found for \`${collection}\``);
327 | return [];
328 | }
329 |
330 | return reshapeProducts(
331 | removeEdgesAndNodes(res.body.data.collection.products)
332 | );
333 | }
334 |
335 | export async function getCollections(): Promise {
336 | 'use cache';
337 | cacheTag(TAGS.collections);
338 | cacheLife('days');
339 |
340 | const res = await shopifyFetch({
341 | query: getCollectionsQuery
342 | });
343 | const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
344 | const collections = [
345 | {
346 | handle: '',
347 | title: 'All',
348 | description: 'All products',
349 | seo: {
350 | title: 'All',
351 | description: 'All products'
352 | },
353 | path: '/search',
354 | updatedAt: new Date().toISOString()
355 | },
356 | // Filter out the `hidden` collections.
357 | // Collections that start with `hidden-*` need to be hidden on the search page.
358 | ...reshapeCollections(shopifyCollections).filter(
359 | (collection) => !collection.handle.startsWith('hidden')
360 | )
361 | ];
362 |
363 | return collections;
364 | }
365 |
366 | export async function getMenu(handle: string): Promise {
367 | 'use cache';
368 | cacheTag(TAGS.collections);
369 | cacheLife('days');
370 |
371 | const res = await shopifyFetch({
372 | query: getMenuQuery,
373 | variables: {
374 | handle
375 | }
376 | });
377 |
378 | return (
379 | res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
380 | title: item.title,
381 | path: item.url
382 | .replace(domain, '')
383 | .replace('/collections', '/search')
384 | .replace('/pages', '')
385 | })) || []
386 | );
387 | }
388 |
389 | export async function getPage(handle: string): Promise {
390 | const res = await shopifyFetch({
391 | query: getPageQuery,
392 | variables: { handle }
393 | });
394 |
395 | return res.body.data.pageByHandle;
396 | }
397 |
398 | export async function getPages(): Promise {
399 | const res = await shopifyFetch({
400 | query: getPagesQuery
401 | });
402 |
403 | return removeEdgesAndNodes(res.body.data.pages);
404 | }
405 |
406 | export async function getProduct(handle: string): Promise {
407 | 'use cache';
408 | cacheTag(TAGS.products);
409 | cacheLife('days');
410 |
411 | const res = await shopifyFetch({
412 | query: getProductQuery,
413 | variables: {
414 | handle
415 | }
416 | });
417 |
418 | return reshapeProduct(res.body.data.product, false);
419 | }
420 |
421 | export async function getProductRecommendations(
422 | productId: string
423 | ): Promise {
424 | 'use cache';
425 | cacheTag(TAGS.products);
426 | cacheLife('days');
427 |
428 | const res = await shopifyFetch({
429 | query: getProductRecommendationsQuery,
430 | variables: {
431 | productId
432 | }
433 | });
434 |
435 | return reshapeProducts(res.body.data.productRecommendations);
436 | }
437 |
438 | export async function getProducts({
439 | query,
440 | reverse,
441 | sortKey
442 | }: {
443 | query?: string;
444 | reverse?: boolean;
445 | sortKey?: string;
446 | }): Promise {
447 | 'use cache';
448 | cacheTag(TAGS.products);
449 | cacheLife('days');
450 |
451 | const res = await shopifyFetch({
452 | query: getProductsQuery,
453 | variables: {
454 | query,
455 | reverse,
456 | sortKey
457 | }
458 | });
459 |
460 | return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
461 | }
462 |
463 | // This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
464 | export async function revalidate(req: NextRequest): Promise {
465 | // We always need to respond with a 200 status code to Shopify,
466 | // otherwise it will continue to retry the request.
467 | const collectionWebhooks = [
468 | 'collections/create',
469 | 'collections/delete',
470 | 'collections/update'
471 | ];
472 | const productWebhooks = [
473 | 'products/create',
474 | 'products/delete',
475 | 'products/update'
476 | ];
477 | const topic = (await headers()).get('x-shopify-topic') || 'unknown';
478 | const secret = req.nextUrl.searchParams.get('secret');
479 | const isCollectionUpdate = collectionWebhooks.includes(topic);
480 | const isProductUpdate = productWebhooks.includes(topic);
481 |
482 | if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
483 | console.error('Invalid revalidation secret.');
484 | return NextResponse.json({ status: 401 });
485 | }
486 |
487 | if (!isCollectionUpdate && !isProductUpdate) {
488 | // We don't need to revalidate anything for any other topics.
489 | return NextResponse.json({ status: 200 });
490 | }
491 |
492 | if (isCollectionUpdate) {
493 | revalidateTag(TAGS.collections);
494 | }
495 |
496 | if (isProductUpdate) {
497 | revalidateTag(TAGS.products);
498 | }
499 |
500 | return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
501 | }
502 |
--------------------------------------------------------------------------------
/lib/shopify/mutations/cart.ts:
--------------------------------------------------------------------------------
1 | import cartFragment from '../fragments/cart';
2 |
3 | export const addToCartMutation = /* GraphQL */ `
4 | mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
5 | cartLinesAdd(cartId: $cartId, lines: $lines) {
6 | cart {
7 | ...cart
8 | }
9 | }
10 | }
11 | ${cartFragment}
12 | `;
13 |
14 | export const createCartMutation = /* GraphQL */ `
15 | mutation createCart($lineItems: [CartLineInput!]) {
16 | cartCreate(input: { lines: $lineItems }) {
17 | cart {
18 | ...cart
19 | }
20 | }
21 | }
22 | ${cartFragment}
23 | `;
24 |
25 | export const editCartItemsMutation = /* GraphQL */ `
26 | mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
27 | cartLinesUpdate(cartId: $cartId, lines: $lines) {
28 | cart {
29 | ...cart
30 | }
31 | }
32 | }
33 | ${cartFragment}
34 | `;
35 |
36 | export const removeFromCartMutation = /* GraphQL */ `
37 | mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
38 | cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
39 | cart {
40 | ...cart
41 | }
42 | }
43 | }
44 | ${cartFragment}
45 | `;
46 |
--------------------------------------------------------------------------------
/lib/shopify/queries/cart.ts:
--------------------------------------------------------------------------------
1 | import cartFragment from '../fragments/cart';
2 |
3 | export const getCartQuery = /* GraphQL */ `
4 | query getCart($cartId: ID!) {
5 | cart(id: $cartId) {
6 | ...cart
7 | }
8 | }
9 | ${cartFragment}
10 | `;
11 |
--------------------------------------------------------------------------------
/lib/shopify/queries/collection.ts:
--------------------------------------------------------------------------------
1 | import productFragment from '../fragments/product';
2 | import seoFragment from '../fragments/seo';
3 |
4 | const collectionFragment = /* GraphQL */ `
5 | fragment collection on Collection {
6 | handle
7 | title
8 | description
9 | seo {
10 | ...seo
11 | }
12 | updatedAt
13 | }
14 | ${seoFragment}
15 | `;
16 |
17 | export const getCollectionQuery = /* GraphQL */ `
18 | query getCollection($handle: String!) {
19 | collection(handle: $handle) {
20 | ...collection
21 | }
22 | }
23 | ${collectionFragment}
24 | `;
25 |
26 | export const getCollectionsQuery = /* GraphQL */ `
27 | query getCollections {
28 | collections(first: 100, sortKey: TITLE) {
29 | edges {
30 | node {
31 | ...collection
32 | }
33 | }
34 | }
35 | }
36 | ${collectionFragment}
37 | `;
38 |
39 | export const getCollectionProductsQuery = /* GraphQL */ `
40 | query getCollectionProducts(
41 | $handle: String!
42 | $sortKey: ProductCollectionSortKeys
43 | $reverse: Boolean
44 | ) {
45 | collection(handle: $handle) {
46 | products(sortKey: $sortKey, reverse: $reverse, first: 100) {
47 | edges {
48 | node {
49 | ...product
50 | }
51 | }
52 | }
53 | }
54 | }
55 | ${productFragment}
56 | `;
57 |
--------------------------------------------------------------------------------
/lib/shopify/queries/menu.ts:
--------------------------------------------------------------------------------
1 | export const getMenuQuery = /* GraphQL */ `
2 | query getMenu($handle: String!) {
3 | menu(handle: $handle) {
4 | items {
5 | title
6 | url
7 | }
8 | }
9 | }
10 | `;
11 |
--------------------------------------------------------------------------------
/lib/shopify/queries/page.ts:
--------------------------------------------------------------------------------
1 | import seoFragment from '../fragments/seo';
2 |
3 | const pageFragment = /* GraphQL */ `
4 | fragment page on Page {
5 | ... on Page {
6 | id
7 | title
8 | handle
9 | body
10 | bodySummary
11 | seo {
12 | ...seo
13 | }
14 | createdAt
15 | updatedAt
16 | }
17 | }
18 | ${seoFragment}
19 | `;
20 |
21 | export const getPageQuery = /* GraphQL */ `
22 | query getPage($handle: String!) {
23 | pageByHandle(handle: $handle) {
24 | ...page
25 | }
26 | }
27 | ${pageFragment}
28 | `;
29 |
30 | export const getPagesQuery = /* GraphQL */ `
31 | query getPages {
32 | pages(first: 100) {
33 | edges {
34 | node {
35 | ...page
36 | }
37 | }
38 | }
39 | }
40 | ${pageFragment}
41 | `;
42 |
--------------------------------------------------------------------------------
/lib/shopify/queries/product.ts:
--------------------------------------------------------------------------------
1 | import productFragment from '../fragments/product';
2 |
3 | export const getProductQuery = /* GraphQL */ `
4 | query getProduct($handle: String!) {
5 | product(handle: $handle) {
6 | ...product
7 | }
8 | }
9 | ${productFragment}
10 | `;
11 |
12 | export const getProductsQuery = /* GraphQL */ `
13 | query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
14 | products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
15 | edges {
16 | node {
17 | ...product
18 | }
19 | }
20 | }
21 | }
22 | ${productFragment}
23 | `;
24 |
25 | export const getProductRecommendationsQuery = /* GraphQL */ `
26 | query getProductRecommendations($productId: ID!) {
27 | productRecommendations(productId: $productId) {
28 | ...product
29 | }
30 | }
31 | ${productFragment}
32 | `;
33 |
--------------------------------------------------------------------------------
/lib/shopify/types.ts:
--------------------------------------------------------------------------------
1 | export type Maybe = T | null;
2 |
3 | export type Connection = {
4 | edges: Array>;
5 | };
6 |
7 | export type Edge = {
8 | node: T;
9 | };
10 |
11 | export type Cart = Omit & {
12 | lines: CartItem[];
13 | };
14 |
15 | export type CartProduct = {
16 | id: string;
17 | handle: string;
18 | title: string;
19 | featuredImage: Image;
20 | };
21 |
22 | export type CartItem = {
23 | id: string | undefined;
24 | quantity: number;
25 | cost: {
26 | totalAmount: Money;
27 | };
28 | merchandise: {
29 | id: string;
30 | title: string;
31 | selectedOptions: {
32 | name: string;
33 | value: string;
34 | }[];
35 | product: CartProduct;
36 | };
37 | };
38 |
39 | export type Collection = ShopifyCollection & {
40 | path: string;
41 | };
42 |
43 | export type Image = {
44 | url: string;
45 | altText: string;
46 | width: number;
47 | height: number;
48 | };
49 |
50 | export type Menu = {
51 | title: string;
52 | path: string;
53 | };
54 |
55 | export type Money = {
56 | amount: string;
57 | currencyCode: string;
58 | };
59 |
60 | export type Page = {
61 | id: string;
62 | title: string;
63 | handle: string;
64 | body: string;
65 | bodySummary: string;
66 | seo?: SEO;
67 | createdAt: string;
68 | updatedAt: string;
69 | };
70 |
71 | export type Product = Omit & {
72 | variants: ProductVariant[];
73 | images: Image[];
74 | };
75 |
76 | export type ProductOption = {
77 | id: string;
78 | name: string;
79 | values: string[];
80 | };
81 |
82 | export type ProductVariant = {
83 | id: string;
84 | title: string;
85 | availableForSale: boolean;
86 | selectedOptions: {
87 | name: string;
88 | value: string;
89 | }[];
90 | price: Money;
91 | };
92 |
93 | export type SEO = {
94 | title: string;
95 | description: string;
96 | };
97 |
98 | export type ShopifyCart = {
99 | id: string | undefined;
100 | checkoutUrl: string;
101 | cost: {
102 | subtotalAmount: Money;
103 | totalAmount: Money;
104 | totalTaxAmount: Money;
105 | };
106 | lines: Connection;
107 | totalQuantity: number;
108 | };
109 |
110 | export type ShopifyCollection = {
111 | handle: string;
112 | title: string;
113 | description: string;
114 | seo: SEO;
115 | updatedAt: string;
116 | };
117 |
118 | export type ShopifyProduct = {
119 | id: string;
120 | handle: string;
121 | availableForSale: boolean;
122 | title: string;
123 | description: string;
124 | descriptionHtml: string;
125 | options: ProductOption[];
126 | priceRange: {
127 | maxVariantPrice: Money;
128 | minVariantPrice: Money;
129 | };
130 | variants: Connection;
131 | featuredImage: Image;
132 | images: Connection;
133 | seo: SEO;
134 | tags: string[];
135 | updatedAt: string;
136 | };
137 |
138 | export type ShopifyCartOperation = {
139 | data: {
140 | cart: ShopifyCart;
141 | };
142 | variables: {
143 | cartId: string;
144 | };
145 | };
146 |
147 | export type ShopifyCreateCartOperation = {
148 | data: { cartCreate: { cart: ShopifyCart } };
149 | };
150 |
151 | export type ShopifyAddToCartOperation = {
152 | data: {
153 | cartLinesAdd: {
154 | cart: ShopifyCart;
155 | };
156 | };
157 | variables: {
158 | cartId: string;
159 | lines: {
160 | merchandiseId: string;
161 | quantity: number;
162 | }[];
163 | };
164 | };
165 |
166 | export type ShopifyRemoveFromCartOperation = {
167 | data: {
168 | cartLinesRemove: {
169 | cart: ShopifyCart;
170 | };
171 | };
172 | variables: {
173 | cartId: string;
174 | lineIds: string[];
175 | };
176 | };
177 |
178 | export type ShopifyUpdateCartOperation = {
179 | data: {
180 | cartLinesUpdate: {
181 | cart: ShopifyCart;
182 | };
183 | };
184 | variables: {
185 | cartId: string;
186 | lines: {
187 | id: string;
188 | merchandiseId: string;
189 | quantity: number;
190 | }[];
191 | };
192 | };
193 |
194 | export type ShopifyCollectionOperation = {
195 | data: {
196 | collection: ShopifyCollection;
197 | };
198 | variables: {
199 | handle: string;
200 | };
201 | };
202 |
203 | export type ShopifyCollectionProductsOperation = {
204 | data: {
205 | collection: {
206 | products: Connection;
207 | };
208 | };
209 | variables: {
210 | handle: string;
211 | reverse?: boolean;
212 | sortKey?: string;
213 | };
214 | };
215 |
216 | export type ShopifyCollectionsOperation = {
217 | data: {
218 | collections: Connection;
219 | };
220 | };
221 |
222 | export type ShopifyMenuOperation = {
223 | data: {
224 | menu?: {
225 | items: {
226 | title: string;
227 | url: string;
228 | }[];
229 | };
230 | };
231 | variables: {
232 | handle: string;
233 | };
234 | };
235 |
236 | export type ShopifyPageOperation = {
237 | data: { pageByHandle: Page };
238 | variables: { handle: string };
239 | };
240 |
241 | export type ShopifyPagesOperation = {
242 | data: {
243 | pages: Connection;
244 | };
245 | };
246 |
247 | export type ShopifyProductOperation = {
248 | data: { product: ShopifyProduct };
249 | variables: {
250 | handle: string;
251 | };
252 | };
253 |
254 | export type ShopifyProductRecommendationsOperation = {
255 | data: {
256 | productRecommendations: ShopifyProduct[];
257 | };
258 | variables: {
259 | productId: string;
260 | };
261 | };
262 |
263 | export type ShopifyProductsOperation = {
264 | data: {
265 | products: Connection;
266 | };
267 | variables: {
268 | query?: string;
269 | reverse?: boolean;
270 | sortKey?: string;
271 | };
272 | };
273 |
--------------------------------------------------------------------------------
/lib/type-guards.ts:
--------------------------------------------------------------------------------
1 | export interface ShopifyErrorLike {
2 | status: number;
3 | message: Error;
4 | cause?: Error;
5 | }
6 |
7 | export const isObject = (object: unknown): object is Record => {
8 | return typeof object === 'object' && object !== null && !Array.isArray(object);
9 | };
10 |
11 | export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
12 | if (!isObject(error)) return false;
13 |
14 | if (error instanceof Error) return true;
15 |
16 | return findError(error);
17 | };
18 |
19 | function findError(error: T): boolean {
20 | if (Object.prototype.toString.call(error) === '[object Error]') {
21 | return true;
22 | }
23 |
24 | const prototype = Object.getPrototypeOf(error) as T | null;
25 |
26 | return prototype === null ? false : findError(prototype);
27 | }
28 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { ReadonlyURLSearchParams } from 'next/navigation';
2 |
3 | export const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL
4 | ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
5 | : 'http://localhost:3000';
6 |
7 | export const createUrl = (
8 | pathname: string,
9 | params: URLSearchParams | ReadonlyURLSearchParams
10 | ) => {
11 | const paramsString = params.toString();
12 | const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
13 |
14 | return `${pathname}${queryString}`;
15 | };
16 |
17 | export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
18 | stringToCheck.startsWith(startsWith)
19 | ? stringToCheck
20 | : `${startsWith}${stringToCheck}`;
21 |
22 | export const validateEnvironmentVariables = () => {
23 | const requiredEnvironmentVariables = [
24 | 'SHOPIFY_STORE_DOMAIN',
25 | 'SHOPIFY_STOREFRONT_ACCESS_TOKEN'
26 | ];
27 | const missingEnvironmentVariables = [] as string[];
28 |
29 | requiredEnvironmentVariables.forEach((envVar) => {
30 | if (!process.env[envVar]) {
31 | missingEnvironmentVariables.push(envVar);
32 | }
33 | });
34 |
35 | if (missingEnvironmentVariables.length) {
36 | throw new Error(
37 | `The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
38 | '\n'
39 | )}\n`
40 | );
41 | }
42 |
43 | if (
44 | process.env.SHOPIFY_STORE_DOMAIN?.includes('[') ||
45 | process.env.SHOPIFY_STORE_DOMAIN?.includes(']')
46 | ) {
47 | throw new Error(
48 | 'Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
49 | );
50 | }
51 | };
52 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2025 Vercel, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | experimental: {
3 | ppr: true,
4 | inlineCss: true,
5 | useCache: true
6 | },
7 | images: {
8 | formats: ['image/avif', 'image/webp'],
9 | remotePatterns: [
10 | {
11 | protocol: 'https',
12 | hostname: 'cdn.shopify.com',
13 | pathname: '/s/files/**'
14 | }
15 | ]
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "dev": "next dev --turbopack",
5 | "build": "next build",
6 | "start": "next start",
7 | "prettier": "prettier --write --ignore-unknown .",
8 | "prettier:check": "prettier --check --ignore-unknown .",
9 | "test": "pnpm prettier:check"
10 | },
11 | "dependencies": {
12 | "@headlessui/react": "^2.2.0",
13 | "@heroicons/react": "^2.2.0",
14 | "clsx": "^2.1.1",
15 | "geist": "^1.3.1",
16 | "next": "15.3.0-canary.13",
17 | "react": "19.0.0",
18 | "react-dom": "19.0.0",
19 | "sonner": "^2.0.1"
20 | },
21 | "devDependencies": {
22 | "@tailwindcss/container-queries": "^0.1.1",
23 | "@tailwindcss/postcss": "^4.0.14",
24 | "@tailwindcss/typography": "^0.5.16",
25 | "@types/node": "22.13.10",
26 | "@types/react": "19.0.12",
27 | "@types/react-dom": "19.0.4",
28 | "postcss": "^8.5.3",
29 | "prettier": "3.5.3",
30 | "prettier-plugin-tailwindcss": "^0.6.11",
31 | "tailwindcss": "^4.0.14",
32 | "typescript": "5.8.2"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: '9.0'
2 |
3 | settings:
4 | autoInstallPeers: true
5 | excludeLinksFromLockfile: false
6 |
7 | importers:
8 |
9 | .:
10 | dependencies:
11 | '@headlessui/react':
12 | specifier: ^2.2.0
13 | version: 2.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
14 | '@heroicons/react':
15 | specifier: ^2.2.0
16 | version: 2.2.0(react@19.0.0)
17 | clsx:
18 | specifier: ^2.1.1
19 | version: 2.1.1
20 | geist:
21 | specifier: ^1.3.1
22 | version: 1.3.1(next@15.3.0-canary.13(react-dom@19.0.0(react@19.0.0))(react@19.0.0))
23 | next:
24 | specifier: 15.3.0-canary.13
25 | version: 15.3.0-canary.13(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
26 | react:
27 | specifier: 19.0.0
28 | version: 19.0.0
29 | react-dom:
30 | specifier: 19.0.0
31 | version: 19.0.0(react@19.0.0)
32 | sonner:
33 | specifier: ^2.0.1
34 | version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
35 | devDependencies:
36 | '@tailwindcss/container-queries':
37 | specifier: ^0.1.1
38 | version: 0.1.1(tailwindcss@4.0.14)
39 | '@tailwindcss/postcss':
40 | specifier: ^4.0.14
41 | version: 4.0.14
42 | '@tailwindcss/typography':
43 | specifier: ^0.5.16
44 | version: 0.5.16(tailwindcss@4.0.14)
45 | '@types/node':
46 | specifier: 22.13.10
47 | version: 22.13.10
48 | '@types/react':
49 | specifier: 19.0.12
50 | version: 19.0.12
51 | '@types/react-dom':
52 | specifier: 19.0.4
53 | version: 19.0.4(@types/react@19.0.12)
54 | postcss:
55 | specifier: ^8.5.3
56 | version: 8.5.3
57 | prettier:
58 | specifier: 3.5.3
59 | version: 3.5.3
60 | prettier-plugin-tailwindcss:
61 | specifier: ^0.6.11
62 | version: 0.6.11(prettier@3.5.3)
63 | tailwindcss:
64 | specifier: ^4.0.14
65 | version: 4.0.14
66 | typescript:
67 | specifier: 5.8.2
68 | version: 5.8.2
69 |
70 | packages:
71 |
72 | '@alloc/quick-lru@5.2.0':
73 | resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
74 | engines: {node: '>=10'}
75 |
76 | '@emnapi/runtime@1.3.1':
77 | resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==}
78 |
79 | '@floating-ui/core@1.6.9':
80 | resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==}
81 |
82 | '@floating-ui/dom@1.6.13':
83 | resolution: {integrity: sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==}
84 |
85 | '@floating-ui/react-dom@2.1.2':
86 | resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==}
87 | peerDependencies:
88 | react: '>=16.8.0'
89 | react-dom: '>=16.8.0'
90 |
91 | '@floating-ui/react@0.26.28':
92 | resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
93 | peerDependencies:
94 | react: '>=16.8.0'
95 | react-dom: '>=16.8.0'
96 |
97 | '@floating-ui/utils@0.2.9':
98 | resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
99 |
100 | '@headlessui/react@2.2.0':
101 | resolution: {integrity: sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==}
102 | engines: {node: '>=10'}
103 | peerDependencies:
104 | react: ^18 || ^19 || ^19.0.0-rc
105 | react-dom: ^18 || ^19 || ^19.0.0-rc
106 |
107 | '@heroicons/react@2.2.0':
108 | resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
109 | peerDependencies:
110 | react: '>= 16 || ^19.0.0-rc'
111 |
112 | '@img/sharp-darwin-arm64@0.33.5':
113 | resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
114 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
115 | cpu: [arm64]
116 | os: [darwin]
117 |
118 | '@img/sharp-darwin-x64@0.33.5':
119 | resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
120 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
121 | cpu: [x64]
122 | os: [darwin]
123 |
124 | '@img/sharp-libvips-darwin-arm64@1.0.4':
125 | resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
126 | cpu: [arm64]
127 | os: [darwin]
128 |
129 | '@img/sharp-libvips-darwin-x64@1.0.4':
130 | resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
131 | cpu: [x64]
132 | os: [darwin]
133 |
134 | '@img/sharp-libvips-linux-arm64@1.0.4':
135 | resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
136 | cpu: [arm64]
137 | os: [linux]
138 |
139 | '@img/sharp-libvips-linux-arm@1.0.5':
140 | resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
141 | cpu: [arm]
142 | os: [linux]
143 |
144 | '@img/sharp-libvips-linux-s390x@1.0.4':
145 | resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
146 | cpu: [s390x]
147 | os: [linux]
148 |
149 | '@img/sharp-libvips-linux-x64@1.0.4':
150 | resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
151 | cpu: [x64]
152 | os: [linux]
153 |
154 | '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
155 | resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
156 | cpu: [arm64]
157 | os: [linux]
158 |
159 | '@img/sharp-libvips-linuxmusl-x64@1.0.4':
160 | resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
161 | cpu: [x64]
162 | os: [linux]
163 |
164 | '@img/sharp-linux-arm64@0.33.5':
165 | resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
166 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
167 | cpu: [arm64]
168 | os: [linux]
169 |
170 | '@img/sharp-linux-arm@0.33.5':
171 | resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
172 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
173 | cpu: [arm]
174 | os: [linux]
175 |
176 | '@img/sharp-linux-s390x@0.33.5':
177 | resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
178 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
179 | cpu: [s390x]
180 | os: [linux]
181 |
182 | '@img/sharp-linux-x64@0.33.5':
183 | resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
184 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
185 | cpu: [x64]
186 | os: [linux]
187 |
188 | '@img/sharp-linuxmusl-arm64@0.33.5':
189 | resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
190 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
191 | cpu: [arm64]
192 | os: [linux]
193 |
194 | '@img/sharp-linuxmusl-x64@0.33.5':
195 | resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
196 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
197 | cpu: [x64]
198 | os: [linux]
199 |
200 | '@img/sharp-wasm32@0.33.5':
201 | resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
202 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
203 | cpu: [wasm32]
204 |
205 | '@img/sharp-win32-ia32@0.33.5':
206 | resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
207 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
208 | cpu: [ia32]
209 | os: [win32]
210 |
211 | '@img/sharp-win32-x64@0.33.5':
212 | resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
213 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
214 | cpu: [x64]
215 | os: [win32]
216 |
217 | '@next/env@15.3.0-canary.13':
218 | resolution: {integrity: sha512-JSc7jRSVdstjZ0bfxKMFeYM+gVRgUbPpGSWq9JLDQDH/mYHMN+LMNR8CafQCKjoSL7tzkBpH9Ug6r9WaIescCw==}
219 |
220 | '@next/swc-darwin-arm64@15.3.0-canary.13':
221 | resolution: {integrity: sha512-A1EiOZHBTFF3Asyb+h4R0/IuOFEx+HN/0ek9BwR7g4neqZunAMU0LaGeExhxX7eUDJR4NWV16HEQq6nBcJB/UA==}
222 | engines: {node: '>= 10'}
223 | cpu: [arm64]
224 | os: [darwin]
225 |
226 | '@next/swc-darwin-x64@15.3.0-canary.13':
227 | resolution: {integrity: sha512-ojmJVrcv571Q893G0EZGgnYJOGjxYTYSvrNiXMaY2gz9W8p1G+wY/Fc6f2Vm5c2GQcjUdmJOb57x3Ujdxi3szw==}
228 | engines: {node: '>= 10'}
229 | cpu: [x64]
230 | os: [darwin]
231 |
232 | '@next/swc-linux-arm64-gnu@15.3.0-canary.13':
233 | resolution: {integrity: sha512-k4dEOZZ9x8PtHH8HtD/3h/epDBRqWOf13UOE3JY/NH60pY5t4uXG3JEj9tcKnezhv0/Q5eT9c6WiydXdjs2YvQ==}
234 | engines: {node: '>= 10'}
235 | cpu: [arm64]
236 | os: [linux]
237 |
238 | '@next/swc-linux-arm64-musl@15.3.0-canary.13':
239 | resolution: {integrity: sha512-Ms7b0OF05Q2qpo90ih/cVhviNrEatVZtsobBVyoXGfWxv/gOrhXoxuzROFGNdGXRZNJ7EgUaWmO4pZGjfUhEWw==}
240 | engines: {node: '>= 10'}
241 | cpu: [arm64]
242 | os: [linux]
243 |
244 | '@next/swc-linux-x64-gnu@15.3.0-canary.13':
245 | resolution: {integrity: sha512-id/4NWejJpglZiY/PLpV0H675bITfo0QrUNjZtRuKfphJNkPoRGsMXdaZ3mSpFscTqofyaINQ3fis0D4sSmJUw==}
246 | engines: {node: '>= 10'}
247 | cpu: [x64]
248 | os: [linux]
249 |
250 | '@next/swc-linux-x64-musl@15.3.0-canary.13':
251 | resolution: {integrity: sha512-9eE2E6KN01yxwE9H2fWaQA6PRvfjuY+lvadGBpub/pf710kdWFe9VYb8zECT492Vw90axHmktFZDTXuf2WaVTA==}
252 | engines: {node: '>= 10'}
253 | cpu: [x64]
254 | os: [linux]
255 |
256 | '@next/swc-win32-arm64-msvc@15.3.0-canary.13':
257 | resolution: {integrity: sha512-PbJ/yFCUBxhLr6wKoaC+CQebzeaiqrYOJXEMb9O1XFWp2te8okLjF2BihSziFVLtoA4m2one56pG5jU7W9GUzg==}
258 | engines: {node: '>= 10'}
259 | cpu: [arm64]
260 | os: [win32]
261 |
262 | '@next/swc-win32-x64-msvc@15.3.0-canary.13':
263 | resolution: {integrity: sha512-6dUpH6huWVS0uBObUWBTolu/lZIP99oD1TdgjGt3S2te+OjXAlza8ERgR8mGTV04hpRZFv7tUivISaGlkYE+Bw==}
264 | engines: {node: '>= 10'}
265 | cpu: [x64]
266 | os: [win32]
267 |
268 | '@react-aria/focus@3.20.1':
269 | resolution: {integrity: sha512-lgYs+sQ1TtBrAXnAdRBQrBo0/7o5H6IrfDxec1j+VRpcXL0xyk0xPq+m3lZp8typzIghqDgpnKkJ5Jf4OrzPIw==}
270 | peerDependencies:
271 | react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
272 | react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
273 |
274 | '@react-aria/interactions@3.24.1':
275 | resolution: {integrity: sha512-OWEcIC6UQfWq4Td5Ptuh4PZQ4LHLJr/JL2jGYvuNL6EgL3bWvzPrRYIF/R64YbfVxIC7FeZpPSkS07sZ93/NoA==}
276 | peerDependencies:
277 | react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
278 | react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
279 |
280 | '@react-aria/ssr@3.9.7':
281 | resolution: {integrity: sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==}
282 | engines: {node: '>= 12'}
283 | peerDependencies:
284 | react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
285 |
286 | '@react-aria/utils@3.28.1':
287 | resolution: {integrity: sha512-mnHFF4YOVu9BRFQ1SZSKfPhg3z+lBRYoW5mLcYTQihbKhz48+I1sqRkP7ahMITr8ANH3nb34YaMME4XWmK2Mgg==}
288 | peerDependencies:
289 | react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
290 | react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
291 |
292 | '@react-stately/flags@3.1.0':
293 | resolution: {integrity: sha512-KSHOCxTFpBtxhIRcKwsD1YDTaNxFtCYuAUb0KEihc16QwqZViq4hasgPBs2gYm7fHRbw7WYzWKf6ZSo/+YsFlg==}
294 |
295 | '@react-stately/utils@3.10.5':
296 | resolution: {integrity: sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==}
297 | peerDependencies:
298 | react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
299 |
300 | '@react-types/shared@3.28.0':
301 | resolution: {integrity: sha512-9oMEYIDc3sk0G5rysnYvdNrkSg7B04yTKl50HHSZVbokeHpnU0yRmsDaWb9B/5RprcKj8XszEk5guBO8Sa/Q+Q==}
302 | peerDependencies:
303 | react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
304 |
305 | '@swc/counter@0.1.3':
306 | resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
307 |
308 | '@swc/helpers@0.5.15':
309 | resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
310 |
311 | '@tailwindcss/container-queries@0.1.1':
312 | resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
313 | peerDependencies:
314 | tailwindcss: '>=3.2.0'
315 |
316 | '@tailwindcss/node@4.0.14':
317 | resolution: {integrity: sha512-Ux9NbFkKWYE4rfUFz6M5JFLs/GEYP6ysxT8uSyPn6aTbh2K3xDE1zz++eVK4Vwx799fzMF8CID9sdHn4j/Ab8w==}
318 |
319 | '@tailwindcss/oxide-android-arm64@4.0.14':
320 | resolution: {integrity: sha512-VBFKC2rFyfJ5J8lRwjy6ub3rgpY186kAcYgiUr8ArR8BAZzMruyeKJ6mlsD22Zp5ZLcPW/FXMasJiJBx0WsdQg==}
321 | engines: {node: '>= 10'}
322 | cpu: [arm64]
323 | os: [android]
324 |
325 | '@tailwindcss/oxide-darwin-arm64@4.0.14':
326 | resolution: {integrity: sha512-U3XOwLrefGr2YQZ9DXasDSNWGPZBCh8F62+AExBEDMLDfvLLgI/HDzY8Oq8p/JtqkAY38sWPOaNnRwEGKU5Zmg==}
327 | engines: {node: '>= 10'}
328 | cpu: [arm64]
329 | os: [darwin]
330 |
331 | '@tailwindcss/oxide-darwin-x64@4.0.14':
332 | resolution: {integrity: sha512-V5AjFuc3ndWGnOi1d379UsODb0TzAS2DYIP/lwEbfvafUaD2aNZIcbwJtYu2DQqO2+s/XBvDVA+w4yUyaewRwg==}
333 | engines: {node: '>= 10'}
334 | cpu: [x64]
335 | os: [darwin]
336 |
337 | '@tailwindcss/oxide-freebsd-x64@4.0.14':
338 | resolution: {integrity: sha512-tXvtxbaZfcPfqBwW3f53lTcyH6EDT+1eT7yabwcfcxTs+8yTPqxsDUhrqe9MrnEzpNkd+R/QAjJapfd4tjWdLg==}
339 | engines: {node: '>= 10'}
340 | cpu: [x64]
341 | os: [freebsd]
342 |
343 | '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.14':
344 | resolution: {integrity: sha512-cSeLNWWqIWeSTmBntQvyY2/2gcLX8rkPFfDDTQVF8qbRcRMVPLxBvFVJyfSAYRNch6ZyVH2GI6dtgALOBDpdNA==}
345 | engines: {node: '>= 10'}
346 | cpu: [arm]
347 | os: [linux]
348 |
349 | '@tailwindcss/oxide-linux-arm64-gnu@4.0.14':
350 | resolution: {integrity: sha512-bwDWLBalXFMDItcSXzFk6y7QKvj6oFlaY9vM+agTlwFL1n1OhDHYLZkSjaYsh6KCeG0VB0r7H8PUJVOM1LRZyg==}
351 | engines: {node: '>= 10'}
352 | cpu: [arm64]
353 | os: [linux]
354 |
355 | '@tailwindcss/oxide-linux-arm64-musl@4.0.14':
356 | resolution: {integrity: sha512-gVkJdnR/L6iIcGYXx64HGJRmlme2FGr/aZH0W6u4A3RgPMAb+6ELRLi+UBiH83RXBm9vwCfkIC/q8T51h8vUJQ==}
357 | engines: {node: '>= 10'}
358 | cpu: [arm64]
359 | os: [linux]
360 |
361 | '@tailwindcss/oxide-linux-x64-gnu@4.0.14':
362 | resolution: {integrity: sha512-EE+EQ+c6tTpzsg+LGO1uuusjXxYx0Q00JE5ubcIGfsogSKth8n8i2BcS2wYTQe4jXGs+BQs35l78BIPzgwLddw==}
363 | engines: {node: '>= 10'}
364 | cpu: [x64]
365 | os: [linux]
366 |
367 | '@tailwindcss/oxide-linux-x64-musl@4.0.14':
368 | resolution: {integrity: sha512-KCCOzo+L6XPT0oUp2Jwh233ETRQ/F6cwUnMnR0FvMUCbkDAzHbcyOgpfuAtRa5HD0WbTbH4pVD+S0pn1EhNfbw==}
369 | engines: {node: '>= 10'}
370 | cpu: [x64]
371 | os: [linux]
372 |
373 | '@tailwindcss/oxide-win32-arm64-msvc@4.0.14':
374 | resolution: {integrity: sha512-AHObFiFL9lNYcm3tZSPqa/cHGpM5wOrNmM2uOMoKppp+0Hom5uuyRh0QkOp7jftsHZdrZUpmoz0Mp6vhh2XtUg==}
375 | engines: {node: '>= 10'}
376 | cpu: [arm64]
377 | os: [win32]
378 |
379 | '@tailwindcss/oxide-win32-x64-msvc@4.0.14':
380 | resolution: {integrity: sha512-rNXXMDJfCJLw/ZaFTOLOHoGULxyXfh2iXTGiChFiYTSgKBKQHIGEpV0yn5N25WGzJJ+VBnRjHzlmDqRV+d//oQ==}
381 | engines: {node: '>= 10'}
382 | cpu: [x64]
383 | os: [win32]
384 |
385 | '@tailwindcss/oxide@4.0.14':
386 | resolution: {integrity: sha512-M8VCNyO/NBi5vJ2cRcI9u8w7Si+i76a7o1vveoGtbbjpEYJZYiyc7f2VGps/DqawO56l3tImIbq2OT/533jcrA==}
387 | engines: {node: '>= 10'}
388 |
389 | '@tailwindcss/postcss@4.0.14':
390 | resolution: {integrity: sha512-+uIR6KtKhla1XeIanF27KtrfYy+PX+R679v5LxbkmEZlhQe3g8rk+wKj7Xgt++rWGRuFLGMXY80Ek8JNn+kN/g==}
391 |
392 | '@tailwindcss/typography@0.5.16':
393 | resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==}
394 | peerDependencies:
395 | tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
396 |
397 | '@tanstack/react-virtual@3.13.4':
398 | resolution: {integrity: sha512-jPWC3BXvVLHsMX67NEHpJaZ+/FySoNxFfBEiF4GBc1+/nVwdRm+UcSCYnKP3pXQr0eEsDpXi/PQZhNfJNopH0g==}
399 | peerDependencies:
400 | react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
401 | react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
402 |
403 | '@tanstack/virtual-core@3.13.4':
404 | resolution: {integrity: sha512-fNGO9fjjSLns87tlcto106enQQLycCKR4DPNpgq3djP5IdcPFdPAmaKjsgzIeRhH7hWrELgW12hYnRthS5kLUw==}
405 |
406 | '@types/node@22.13.10':
407 | resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==}
408 |
409 | '@types/react-dom@19.0.4':
410 | resolution: {integrity: sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==}
411 | peerDependencies:
412 | '@types/react': ^19.0.0
413 |
414 | '@types/react@19.0.12':
415 | resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==}
416 |
417 | busboy@1.6.0:
418 | resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
419 | engines: {node: '>=10.16.0'}
420 |
421 | caniuse-lite@1.0.30001706:
422 | resolution: {integrity: sha512-3ZczoTApMAZwPKYWmwVbQMFpXBDds3/0VciVoUwPUbldlYyVLmRVuRs/PcUZtHpbLRpzzDvrvnFuREsGt6lUug==}
423 |
424 | client-only@0.0.1:
425 | resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
426 |
427 | clsx@2.1.1:
428 | resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
429 | engines: {node: '>=6'}
430 |
431 | color-convert@2.0.1:
432 | resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
433 | engines: {node: '>=7.0.0'}
434 |
435 | color-name@1.1.4:
436 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
437 |
438 | color-string@1.9.1:
439 | resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
440 |
441 | color@4.2.3:
442 | resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
443 | engines: {node: '>=12.5.0'}
444 |
445 | cssesc@3.0.0:
446 | resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
447 | engines: {node: '>=4'}
448 | hasBin: true
449 |
450 | csstype@3.1.3:
451 | resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
452 |
453 | detect-libc@2.0.3:
454 | resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
455 | engines: {node: '>=8'}
456 |
457 | enhanced-resolve@5.18.1:
458 | resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
459 | engines: {node: '>=10.13.0'}
460 |
461 | geist@1.3.1:
462 | resolution: {integrity: sha512-Q4gC1pBVPN+D579pBaz0TRRnGA4p9UK6elDY/xizXdFk/g4EKR5g0I+4p/Kj6gM0SajDBZ/0FvDV9ey9ud7BWw==}
463 | peerDependencies:
464 | next: '>=13.2.0'
465 |
466 | graceful-fs@4.2.11:
467 | resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
468 |
469 | is-arrayish@0.3.2:
470 | resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
471 |
472 | jiti@2.4.2:
473 | resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
474 | hasBin: true
475 |
476 | lightningcss-darwin-arm64@1.29.2:
477 | resolution: {integrity: sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==}
478 | engines: {node: '>= 12.0.0'}
479 | cpu: [arm64]
480 | os: [darwin]
481 |
482 | lightningcss-darwin-x64@1.29.2:
483 | resolution: {integrity: sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==}
484 | engines: {node: '>= 12.0.0'}
485 | cpu: [x64]
486 | os: [darwin]
487 |
488 | lightningcss-freebsd-x64@1.29.2:
489 | resolution: {integrity: sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==}
490 | engines: {node: '>= 12.0.0'}
491 | cpu: [x64]
492 | os: [freebsd]
493 |
494 | lightningcss-linux-arm-gnueabihf@1.29.2:
495 | resolution: {integrity: sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==}
496 | engines: {node: '>= 12.0.0'}
497 | cpu: [arm]
498 | os: [linux]
499 |
500 | lightningcss-linux-arm64-gnu@1.29.2:
501 | resolution: {integrity: sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==}
502 | engines: {node: '>= 12.0.0'}
503 | cpu: [arm64]
504 | os: [linux]
505 |
506 | lightningcss-linux-arm64-musl@1.29.2:
507 | resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==}
508 | engines: {node: '>= 12.0.0'}
509 | cpu: [arm64]
510 | os: [linux]
511 |
512 | lightningcss-linux-x64-gnu@1.29.2:
513 | resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==}
514 | engines: {node: '>= 12.0.0'}
515 | cpu: [x64]
516 | os: [linux]
517 |
518 | lightningcss-linux-x64-musl@1.29.2:
519 | resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==}
520 | engines: {node: '>= 12.0.0'}
521 | cpu: [x64]
522 | os: [linux]
523 |
524 | lightningcss-win32-arm64-msvc@1.29.2:
525 | resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==}
526 | engines: {node: '>= 12.0.0'}
527 | cpu: [arm64]
528 | os: [win32]
529 |
530 | lightningcss-win32-x64-msvc@1.29.2:
531 | resolution: {integrity: sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==}
532 | engines: {node: '>= 12.0.0'}
533 | cpu: [x64]
534 | os: [win32]
535 |
536 | lightningcss@1.29.2:
537 | resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==}
538 | engines: {node: '>= 12.0.0'}
539 |
540 | lodash.castarray@4.4.0:
541 | resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
542 |
543 | lodash.isplainobject@4.0.6:
544 | resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
545 |
546 | lodash.merge@4.6.2:
547 | resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
548 |
549 | nanoid@3.3.11:
550 | resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
551 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
552 | hasBin: true
553 |
554 | next@15.3.0-canary.13:
555 | resolution: {integrity: sha512-c8BO/c1FjV/jY4OmlBTKaeI0YYDIsakkmJQFgpjq9RzoBetoi/VLAloZMDpsrfSFIhHDHhraLMxzSvS6mFKeuA==}
556 | engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
557 | hasBin: true
558 | peerDependencies:
559 | '@opentelemetry/api': ^1.1.0
560 | '@playwright/test': ^1.41.2
561 | babel-plugin-react-compiler: '*'
562 | react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
563 | react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
564 | sass: ^1.3.0
565 | peerDependenciesMeta:
566 | '@opentelemetry/api':
567 | optional: true
568 | '@playwright/test':
569 | optional: true
570 | babel-plugin-react-compiler:
571 | optional: true
572 | sass:
573 | optional: true
574 |
575 | picocolors@1.1.1:
576 | resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
577 |
578 | postcss-selector-parser@6.0.10:
579 | resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
580 | engines: {node: '>=4'}
581 |
582 | postcss@8.4.31:
583 | resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
584 | engines: {node: ^10 || ^12 || >=14}
585 |
586 | postcss@8.5.3:
587 | resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
588 | engines: {node: ^10 || ^12 || >=14}
589 |
590 | prettier-plugin-tailwindcss@0.6.11:
591 | resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==}
592 | engines: {node: '>=14.21.3'}
593 | peerDependencies:
594 | '@ianvs/prettier-plugin-sort-imports': '*'
595 | '@prettier/plugin-pug': '*'
596 | '@shopify/prettier-plugin-liquid': '*'
597 | '@trivago/prettier-plugin-sort-imports': '*'
598 | '@zackad/prettier-plugin-twig': '*'
599 | prettier: ^3.0
600 | prettier-plugin-astro: '*'
601 | prettier-plugin-css-order: '*'
602 | prettier-plugin-import-sort: '*'
603 | prettier-plugin-jsdoc: '*'
604 | prettier-plugin-marko: '*'
605 | prettier-plugin-multiline-arrays: '*'
606 | prettier-plugin-organize-attributes: '*'
607 | prettier-plugin-organize-imports: '*'
608 | prettier-plugin-sort-imports: '*'
609 | prettier-plugin-style-order: '*'
610 | prettier-plugin-svelte: '*'
611 | peerDependenciesMeta:
612 | '@ianvs/prettier-plugin-sort-imports':
613 | optional: true
614 | '@prettier/plugin-pug':
615 | optional: true
616 | '@shopify/prettier-plugin-liquid':
617 | optional: true
618 | '@trivago/prettier-plugin-sort-imports':
619 | optional: true
620 | '@zackad/prettier-plugin-twig':
621 | optional: true
622 | prettier-plugin-astro:
623 | optional: true
624 | prettier-plugin-css-order:
625 | optional: true
626 | prettier-plugin-import-sort:
627 | optional: true
628 | prettier-plugin-jsdoc:
629 | optional: true
630 | prettier-plugin-marko:
631 | optional: true
632 | prettier-plugin-multiline-arrays:
633 | optional: true
634 | prettier-plugin-organize-attributes:
635 | optional: true
636 | prettier-plugin-organize-imports:
637 | optional: true
638 | prettier-plugin-sort-imports:
639 | optional: true
640 | prettier-plugin-style-order:
641 | optional: true
642 | prettier-plugin-svelte:
643 | optional: true
644 |
645 | prettier@3.5.3:
646 | resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
647 | engines: {node: '>=14'}
648 | hasBin: true
649 |
650 | react-dom@19.0.0:
651 | resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==}
652 | peerDependencies:
653 | react: ^19.0.0
654 |
655 | react@19.0.0:
656 | resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
657 | engines: {node: '>=0.10.0'}
658 |
659 | scheduler@0.25.0:
660 | resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
661 |
662 | semver@7.7.1:
663 | resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==}
664 | engines: {node: '>=10'}
665 | hasBin: true
666 |
667 | sharp@0.33.5:
668 | resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
669 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
670 |
671 | simple-swizzle@0.2.2:
672 | resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
673 |
674 | sonner@2.0.1:
675 | resolution: {integrity: sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==}
676 | peerDependencies:
677 | react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
678 | react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
679 |
680 | source-map-js@1.2.1:
681 | resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
682 | engines: {node: '>=0.10.0'}
683 |
684 | streamsearch@1.1.0:
685 | resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
686 | engines: {node: '>=10.0.0'}
687 |
688 | styled-jsx@5.1.6:
689 | resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
690 | engines: {node: '>= 12.0.0'}
691 | peerDependencies:
692 | '@babel/core': '*'
693 | babel-plugin-macros: '*'
694 | react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
695 | peerDependenciesMeta:
696 | '@babel/core':
697 | optional: true
698 | babel-plugin-macros:
699 | optional: true
700 |
701 | tabbable@6.2.0:
702 | resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
703 |
704 | tailwindcss@4.0.14:
705 | resolution: {integrity: sha512-92YT2dpt671tFiHH/e1ok9D987N9fHD5VWoly1CdPD/Cd1HMglvZwP3nx2yTj2lbXDAHt8QssZkxTLCCTNL+xw==}
706 |
707 | tapable@2.2.1:
708 | resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
709 | engines: {node: '>=6'}
710 |
711 | tslib@2.8.1:
712 | resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
713 |
714 | typescript@5.8.2:
715 | resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==}
716 | engines: {node: '>=14.17'}
717 | hasBin: true
718 |
719 | undici-types@6.20.0:
720 | resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
721 |
722 | util-deprecate@1.0.2:
723 | resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
724 |
725 | snapshots:
726 |
727 | '@alloc/quick-lru@5.2.0': {}
728 |
729 | '@emnapi/runtime@1.3.1':
730 | dependencies:
731 | tslib: 2.8.1
732 | optional: true
733 |
734 | '@floating-ui/core@1.6.9':
735 | dependencies:
736 | '@floating-ui/utils': 0.2.9
737 |
738 | '@floating-ui/dom@1.6.13':
739 | dependencies:
740 | '@floating-ui/core': 1.6.9
741 | '@floating-ui/utils': 0.2.9
742 |
743 | '@floating-ui/react-dom@2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
744 | dependencies:
745 | '@floating-ui/dom': 1.6.13
746 | react: 19.0.0
747 | react-dom: 19.0.0(react@19.0.0)
748 |
749 | '@floating-ui/react@0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
750 | dependencies:
751 | '@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
752 | '@floating-ui/utils': 0.2.9
753 | react: 19.0.0
754 | react-dom: 19.0.0(react@19.0.0)
755 | tabbable: 6.2.0
756 |
757 | '@floating-ui/utils@0.2.9': {}
758 |
759 | '@headlessui/react@2.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
760 | dependencies:
761 | '@floating-ui/react': 0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
762 | '@react-aria/focus': 3.20.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
763 | '@react-aria/interactions': 3.24.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
764 | '@tanstack/react-virtual': 3.13.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
765 | react: 19.0.0
766 | react-dom: 19.0.0(react@19.0.0)
767 |
768 | '@heroicons/react@2.2.0(react@19.0.0)':
769 | dependencies:
770 | react: 19.0.0
771 |
772 | '@img/sharp-darwin-arm64@0.33.5':
773 | optionalDependencies:
774 | '@img/sharp-libvips-darwin-arm64': 1.0.4
775 | optional: true
776 |
777 | '@img/sharp-darwin-x64@0.33.5':
778 | optionalDependencies:
779 | '@img/sharp-libvips-darwin-x64': 1.0.4
780 | optional: true
781 |
782 | '@img/sharp-libvips-darwin-arm64@1.0.4':
783 | optional: true
784 |
785 | '@img/sharp-libvips-darwin-x64@1.0.4':
786 | optional: true
787 |
788 | '@img/sharp-libvips-linux-arm64@1.0.4':
789 | optional: true
790 |
791 | '@img/sharp-libvips-linux-arm@1.0.5':
792 | optional: true
793 |
794 | '@img/sharp-libvips-linux-s390x@1.0.4':
795 | optional: true
796 |
797 | '@img/sharp-libvips-linux-x64@1.0.4':
798 | optional: true
799 |
800 | '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
801 | optional: true
802 |
803 | '@img/sharp-libvips-linuxmusl-x64@1.0.4':
804 | optional: true
805 |
806 | '@img/sharp-linux-arm64@0.33.5':
807 | optionalDependencies:
808 | '@img/sharp-libvips-linux-arm64': 1.0.4
809 | optional: true
810 |
811 | '@img/sharp-linux-arm@0.33.5':
812 | optionalDependencies:
813 | '@img/sharp-libvips-linux-arm': 1.0.5
814 | optional: true
815 |
816 | '@img/sharp-linux-s390x@0.33.5':
817 | optionalDependencies:
818 | '@img/sharp-libvips-linux-s390x': 1.0.4
819 | optional: true
820 |
821 | '@img/sharp-linux-x64@0.33.5':
822 | optionalDependencies:
823 | '@img/sharp-libvips-linux-x64': 1.0.4
824 | optional: true
825 |
826 | '@img/sharp-linuxmusl-arm64@0.33.5':
827 | optionalDependencies:
828 | '@img/sharp-libvips-linuxmusl-arm64': 1.0.4
829 | optional: true
830 |
831 | '@img/sharp-linuxmusl-x64@0.33.5':
832 | optionalDependencies:
833 | '@img/sharp-libvips-linuxmusl-x64': 1.0.4
834 | optional: true
835 |
836 | '@img/sharp-wasm32@0.33.5':
837 | dependencies:
838 | '@emnapi/runtime': 1.3.1
839 | optional: true
840 |
841 | '@img/sharp-win32-ia32@0.33.5':
842 | optional: true
843 |
844 | '@img/sharp-win32-x64@0.33.5':
845 | optional: true
846 |
847 | '@next/env@15.3.0-canary.13': {}
848 |
849 | '@next/swc-darwin-arm64@15.3.0-canary.13':
850 | optional: true
851 |
852 | '@next/swc-darwin-x64@15.3.0-canary.13':
853 | optional: true
854 |
855 | '@next/swc-linux-arm64-gnu@15.3.0-canary.13':
856 | optional: true
857 |
858 | '@next/swc-linux-arm64-musl@15.3.0-canary.13':
859 | optional: true
860 |
861 | '@next/swc-linux-x64-gnu@15.3.0-canary.13':
862 | optional: true
863 |
864 | '@next/swc-linux-x64-musl@15.3.0-canary.13':
865 | optional: true
866 |
867 | '@next/swc-win32-arm64-msvc@15.3.0-canary.13':
868 | optional: true
869 |
870 | '@next/swc-win32-x64-msvc@15.3.0-canary.13':
871 | optional: true
872 |
873 | '@react-aria/focus@3.20.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
874 | dependencies:
875 | '@react-aria/interactions': 3.24.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
876 | '@react-aria/utils': 3.28.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
877 | '@react-types/shared': 3.28.0(react@19.0.0)
878 | '@swc/helpers': 0.5.15
879 | clsx: 2.1.1
880 | react: 19.0.0
881 | react-dom: 19.0.0(react@19.0.0)
882 |
883 | '@react-aria/interactions@3.24.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
884 | dependencies:
885 | '@react-aria/ssr': 3.9.7(react@19.0.0)
886 | '@react-aria/utils': 3.28.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
887 | '@react-stately/flags': 3.1.0
888 | '@react-types/shared': 3.28.0(react@19.0.0)
889 | '@swc/helpers': 0.5.15
890 | react: 19.0.0
891 | react-dom: 19.0.0(react@19.0.0)
892 |
893 | '@react-aria/ssr@3.9.7(react@19.0.0)':
894 | dependencies:
895 | '@swc/helpers': 0.5.15
896 | react: 19.0.0
897 |
898 | '@react-aria/utils@3.28.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
899 | dependencies:
900 | '@react-aria/ssr': 3.9.7(react@19.0.0)
901 | '@react-stately/flags': 3.1.0
902 | '@react-stately/utils': 3.10.5(react@19.0.0)
903 | '@react-types/shared': 3.28.0(react@19.0.0)
904 | '@swc/helpers': 0.5.15
905 | clsx: 2.1.1
906 | react: 19.0.0
907 | react-dom: 19.0.0(react@19.0.0)
908 |
909 | '@react-stately/flags@3.1.0':
910 | dependencies:
911 | '@swc/helpers': 0.5.15
912 |
913 | '@react-stately/utils@3.10.5(react@19.0.0)':
914 | dependencies:
915 | '@swc/helpers': 0.5.15
916 | react: 19.0.0
917 |
918 | '@react-types/shared@3.28.0(react@19.0.0)':
919 | dependencies:
920 | react: 19.0.0
921 |
922 | '@swc/counter@0.1.3': {}
923 |
924 | '@swc/helpers@0.5.15':
925 | dependencies:
926 | tslib: 2.8.1
927 |
928 | '@tailwindcss/container-queries@0.1.1(tailwindcss@4.0.14)':
929 | dependencies:
930 | tailwindcss: 4.0.14
931 |
932 | '@tailwindcss/node@4.0.14':
933 | dependencies:
934 | enhanced-resolve: 5.18.1
935 | jiti: 2.4.2
936 | tailwindcss: 4.0.14
937 |
938 | '@tailwindcss/oxide-android-arm64@4.0.14':
939 | optional: true
940 |
941 | '@tailwindcss/oxide-darwin-arm64@4.0.14':
942 | optional: true
943 |
944 | '@tailwindcss/oxide-darwin-x64@4.0.14':
945 | optional: true
946 |
947 | '@tailwindcss/oxide-freebsd-x64@4.0.14':
948 | optional: true
949 |
950 | '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.14':
951 | optional: true
952 |
953 | '@tailwindcss/oxide-linux-arm64-gnu@4.0.14':
954 | optional: true
955 |
956 | '@tailwindcss/oxide-linux-arm64-musl@4.0.14':
957 | optional: true
958 |
959 | '@tailwindcss/oxide-linux-x64-gnu@4.0.14':
960 | optional: true
961 |
962 | '@tailwindcss/oxide-linux-x64-musl@4.0.14':
963 | optional: true
964 |
965 | '@tailwindcss/oxide-win32-arm64-msvc@4.0.14':
966 | optional: true
967 |
968 | '@tailwindcss/oxide-win32-x64-msvc@4.0.14':
969 | optional: true
970 |
971 | '@tailwindcss/oxide@4.0.14':
972 | optionalDependencies:
973 | '@tailwindcss/oxide-android-arm64': 4.0.14
974 | '@tailwindcss/oxide-darwin-arm64': 4.0.14
975 | '@tailwindcss/oxide-darwin-x64': 4.0.14
976 | '@tailwindcss/oxide-freebsd-x64': 4.0.14
977 | '@tailwindcss/oxide-linux-arm-gnueabihf': 4.0.14
978 | '@tailwindcss/oxide-linux-arm64-gnu': 4.0.14
979 | '@tailwindcss/oxide-linux-arm64-musl': 4.0.14
980 | '@tailwindcss/oxide-linux-x64-gnu': 4.0.14
981 | '@tailwindcss/oxide-linux-x64-musl': 4.0.14
982 | '@tailwindcss/oxide-win32-arm64-msvc': 4.0.14
983 | '@tailwindcss/oxide-win32-x64-msvc': 4.0.14
984 |
985 | '@tailwindcss/postcss@4.0.14':
986 | dependencies:
987 | '@alloc/quick-lru': 5.2.0
988 | '@tailwindcss/node': 4.0.14
989 | '@tailwindcss/oxide': 4.0.14
990 | lightningcss: 1.29.2
991 | postcss: 8.5.3
992 | tailwindcss: 4.0.14
993 |
994 | '@tailwindcss/typography@0.5.16(tailwindcss@4.0.14)':
995 | dependencies:
996 | lodash.castarray: 4.4.0
997 | lodash.isplainobject: 4.0.6
998 | lodash.merge: 4.6.2
999 | postcss-selector-parser: 6.0.10
1000 | tailwindcss: 4.0.14
1001 |
1002 | '@tanstack/react-virtual@3.13.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
1003 | dependencies:
1004 | '@tanstack/virtual-core': 3.13.4
1005 | react: 19.0.0
1006 | react-dom: 19.0.0(react@19.0.0)
1007 |
1008 | '@tanstack/virtual-core@3.13.4': {}
1009 |
1010 | '@types/node@22.13.10':
1011 | dependencies:
1012 | undici-types: 6.20.0
1013 |
1014 | '@types/react-dom@19.0.4(@types/react@19.0.12)':
1015 | dependencies:
1016 | '@types/react': 19.0.12
1017 |
1018 | '@types/react@19.0.12':
1019 | dependencies:
1020 | csstype: 3.1.3
1021 |
1022 | busboy@1.6.0:
1023 | dependencies:
1024 | streamsearch: 1.1.0
1025 |
1026 | caniuse-lite@1.0.30001706: {}
1027 |
1028 | client-only@0.0.1: {}
1029 |
1030 | clsx@2.1.1: {}
1031 |
1032 | color-convert@2.0.1:
1033 | dependencies:
1034 | color-name: 1.1.4
1035 | optional: true
1036 |
1037 | color-name@1.1.4:
1038 | optional: true
1039 |
1040 | color-string@1.9.1:
1041 | dependencies:
1042 | color-name: 1.1.4
1043 | simple-swizzle: 0.2.2
1044 | optional: true
1045 |
1046 | color@4.2.3:
1047 | dependencies:
1048 | color-convert: 2.0.1
1049 | color-string: 1.9.1
1050 | optional: true
1051 |
1052 | cssesc@3.0.0: {}
1053 |
1054 | csstype@3.1.3: {}
1055 |
1056 | detect-libc@2.0.3: {}
1057 |
1058 | enhanced-resolve@5.18.1:
1059 | dependencies:
1060 | graceful-fs: 4.2.11
1061 | tapable: 2.2.1
1062 |
1063 | geist@1.3.1(next@15.3.0-canary.13(react-dom@19.0.0(react@19.0.0))(react@19.0.0)):
1064 | dependencies:
1065 | next: 15.3.0-canary.13(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
1066 |
1067 | graceful-fs@4.2.11: {}
1068 |
1069 | is-arrayish@0.3.2:
1070 | optional: true
1071 |
1072 | jiti@2.4.2: {}
1073 |
1074 | lightningcss-darwin-arm64@1.29.2:
1075 | optional: true
1076 |
1077 | lightningcss-darwin-x64@1.29.2:
1078 | optional: true
1079 |
1080 | lightningcss-freebsd-x64@1.29.2:
1081 | optional: true
1082 |
1083 | lightningcss-linux-arm-gnueabihf@1.29.2:
1084 | optional: true
1085 |
1086 | lightningcss-linux-arm64-gnu@1.29.2:
1087 | optional: true
1088 |
1089 | lightningcss-linux-arm64-musl@1.29.2:
1090 | optional: true
1091 |
1092 | lightningcss-linux-x64-gnu@1.29.2:
1093 | optional: true
1094 |
1095 | lightningcss-linux-x64-musl@1.29.2:
1096 | optional: true
1097 |
1098 | lightningcss-win32-arm64-msvc@1.29.2:
1099 | optional: true
1100 |
1101 | lightningcss-win32-x64-msvc@1.29.2:
1102 | optional: true
1103 |
1104 | lightningcss@1.29.2:
1105 | dependencies:
1106 | detect-libc: 2.0.3
1107 | optionalDependencies:
1108 | lightningcss-darwin-arm64: 1.29.2
1109 | lightningcss-darwin-x64: 1.29.2
1110 | lightningcss-freebsd-x64: 1.29.2
1111 | lightningcss-linux-arm-gnueabihf: 1.29.2
1112 | lightningcss-linux-arm64-gnu: 1.29.2
1113 | lightningcss-linux-arm64-musl: 1.29.2
1114 | lightningcss-linux-x64-gnu: 1.29.2
1115 | lightningcss-linux-x64-musl: 1.29.2
1116 | lightningcss-win32-arm64-msvc: 1.29.2
1117 | lightningcss-win32-x64-msvc: 1.29.2
1118 |
1119 | lodash.castarray@4.4.0: {}
1120 |
1121 | lodash.isplainobject@4.0.6: {}
1122 |
1123 | lodash.merge@4.6.2: {}
1124 |
1125 | nanoid@3.3.11: {}
1126 |
1127 | next@15.3.0-canary.13(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
1128 | dependencies:
1129 | '@next/env': 15.3.0-canary.13
1130 | '@swc/counter': 0.1.3
1131 | '@swc/helpers': 0.5.15
1132 | busboy: 1.6.0
1133 | caniuse-lite: 1.0.30001706
1134 | postcss: 8.4.31
1135 | react: 19.0.0
1136 | react-dom: 19.0.0(react@19.0.0)
1137 | styled-jsx: 5.1.6(react@19.0.0)
1138 | optionalDependencies:
1139 | '@next/swc-darwin-arm64': 15.3.0-canary.13
1140 | '@next/swc-darwin-x64': 15.3.0-canary.13
1141 | '@next/swc-linux-arm64-gnu': 15.3.0-canary.13
1142 | '@next/swc-linux-arm64-musl': 15.3.0-canary.13
1143 | '@next/swc-linux-x64-gnu': 15.3.0-canary.13
1144 | '@next/swc-linux-x64-musl': 15.3.0-canary.13
1145 | '@next/swc-win32-arm64-msvc': 15.3.0-canary.13
1146 | '@next/swc-win32-x64-msvc': 15.3.0-canary.13
1147 | sharp: 0.33.5
1148 | transitivePeerDependencies:
1149 | - '@babel/core'
1150 | - babel-plugin-macros
1151 |
1152 | picocolors@1.1.1: {}
1153 |
1154 | postcss-selector-parser@6.0.10:
1155 | dependencies:
1156 | cssesc: 3.0.0
1157 | util-deprecate: 1.0.2
1158 |
1159 | postcss@8.4.31:
1160 | dependencies:
1161 | nanoid: 3.3.11
1162 | picocolors: 1.1.1
1163 | source-map-js: 1.2.1
1164 |
1165 | postcss@8.5.3:
1166 | dependencies:
1167 | nanoid: 3.3.11
1168 | picocolors: 1.1.1
1169 | source-map-js: 1.2.1
1170 |
1171 | prettier-plugin-tailwindcss@0.6.11(prettier@3.5.3):
1172 | dependencies:
1173 | prettier: 3.5.3
1174 |
1175 | prettier@3.5.3: {}
1176 |
1177 | react-dom@19.0.0(react@19.0.0):
1178 | dependencies:
1179 | react: 19.0.0
1180 | scheduler: 0.25.0
1181 |
1182 | react@19.0.0: {}
1183 |
1184 | scheduler@0.25.0: {}
1185 |
1186 | semver@7.7.1:
1187 | optional: true
1188 |
1189 | sharp@0.33.5:
1190 | dependencies:
1191 | color: 4.2.3
1192 | detect-libc: 2.0.3
1193 | semver: 7.7.1
1194 | optionalDependencies:
1195 | '@img/sharp-darwin-arm64': 0.33.5
1196 | '@img/sharp-darwin-x64': 0.33.5
1197 | '@img/sharp-libvips-darwin-arm64': 1.0.4
1198 | '@img/sharp-libvips-darwin-x64': 1.0.4
1199 | '@img/sharp-libvips-linux-arm': 1.0.5
1200 | '@img/sharp-libvips-linux-arm64': 1.0.4
1201 | '@img/sharp-libvips-linux-s390x': 1.0.4
1202 | '@img/sharp-libvips-linux-x64': 1.0.4
1203 | '@img/sharp-libvips-linuxmusl-arm64': 1.0.4
1204 | '@img/sharp-libvips-linuxmusl-x64': 1.0.4
1205 | '@img/sharp-linux-arm': 0.33.5
1206 | '@img/sharp-linux-arm64': 0.33.5
1207 | '@img/sharp-linux-s390x': 0.33.5
1208 | '@img/sharp-linux-x64': 0.33.5
1209 | '@img/sharp-linuxmusl-arm64': 0.33.5
1210 | '@img/sharp-linuxmusl-x64': 0.33.5
1211 | '@img/sharp-wasm32': 0.33.5
1212 | '@img/sharp-win32-ia32': 0.33.5
1213 | '@img/sharp-win32-x64': 0.33.5
1214 | optional: true
1215 |
1216 | simple-swizzle@0.2.2:
1217 | dependencies:
1218 | is-arrayish: 0.3.2
1219 | optional: true
1220 |
1221 | sonner@2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
1222 | dependencies:
1223 | react: 19.0.0
1224 | react-dom: 19.0.0(react@19.0.0)
1225 |
1226 | source-map-js@1.2.1: {}
1227 |
1228 | streamsearch@1.1.0: {}
1229 |
1230 | styled-jsx@5.1.6(react@19.0.0):
1231 | dependencies:
1232 | client-only: 0.0.1
1233 | react: 19.0.0
1234 |
1235 | tabbable@6.2.0: {}
1236 |
1237 | tailwindcss@4.0.14: {}
1238 |
1239 | tapable@2.2.1: {}
1240 |
1241 | tslib@2.8.1: {}
1242 |
1243 | typescript@5.8.2: {}
1244 |
1245 | undici-types@6.20.0: {}
1246 |
1247 | util-deprecate@1.0.2: {}
1248 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | export default {
3 | plugins: {
4 | '@tailwindcss/postcss': {},
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2015",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "downlevelIteration": true,
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "incremental": true,
18 | "baseUrl": ".",
19 | "noUncheckedIndexedAccess": true,
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ]
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------