4
0
forked from crowetic/commerce

Compare commits

..

3 Commits

Author SHA1 Message Date
Michael Novotny
eb9b7732b5
Lowercase 2023-08-14 16:14:39 -05:00
Michael Novotny
a8bb9e7915
Fixes url 2023-08-14 16:11:52 -05:00
Michael Novotny
3c3e44a157
Replaces README content with Vercel and Shopify integration guide 2023-08-14 16:11:34 -05:00
71 changed files with 4114 additions and 2331 deletions

View File

@ -1,4 +1,6 @@
COMPANY_NAME="Vercel Inc." COMPANY_NAME="Vercel Inc."
TWITTER_CREATOR="@vercel"
TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Next.js Commerce" SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET="" SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN="" SHOPIFY_STOREFRONT_ACCESS_TOKEN=""

23
.eslintrc.js Normal file
View File

@ -0,0 +1,23 @@
module.exports = {
extends: ['next', 'prettier'],
plugins: ['unicorn'],
rules: {
'no-unused-vars': [
'error',
{
args: 'after-used',
caughtErrors: 'none',
ignoreRestSiblings: true,
vars: 'all'
}
],
'prefer-const': 'error',
'react-hooks/exhaustive-deps': 'error',
'unicorn/filename-case': [
'error',
{
case: 'kebabCase'
}
]
}
};

6
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'weekly'

32
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: test
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Cancel running workflows
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Checkout repo
uses: actions/checkout@v3
- name: Set node version
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- name: Set pnpm version
uses: pnpm/action-setup@v2
with:
run_install: false
version: 7
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: pnpm install
- name: Run tests
run: pnpm test

1
.gitignore vendored
View File

@ -36,4 +36,3 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.env*.local

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
18

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
.vercel
.next
pnpm-lock.yaml

View File

@ -2,8 +2,8 @@
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true, "typescript.enablePromptUseWorkspaceTsdk": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll": "explicit", "source.fixAll": true,
"source.organizeImports": "explicit", "source.organizeImports": true,
"source.sortMembers": "explicit" "source.sortMembers": true
} }
} }

View File

@ -1,10 +1,19 @@
[![Deploy with Vercel](https://vercel.com/button)](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) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME,TWITTER_CREATOR,TWITTER_SITE)
# Next.js Commerce # Next.js Commerce
A high-performance, server-rendered Next.js App Router ecommerce application. A Next.js 13 and App Router-ready ecommerce template featuring:
This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more. - Next.js App Router
- Optimized for SEO using Next.js's Metadata
- React Server Components (RSCs) and Suspense
- Server Actions for mutations
- Edge Runtime
- New fetching and caching paradigms
- Dynamic OG images
- Styling with Tailwind CSS
- Checkout and payments with Shopify
- Automatic light/dark mode based on system settings
<h3 id="v1-note"></h3> <h3 id="v1-note"></h3>
@ -14,34 +23,14 @@ This template uses React Server Components, Server Actions, `Suspense`, `useOpti
Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966). Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966).
Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged. Vercel is more than happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
- Shopify (this repository) - Shopify (this repository)
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/)) - [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
- [Ecwid by Lightspeed](https://github.com/Ecwid/ecwid-nextjs-commerce/) ([Demo](https://ecwid-nextjs-commerce.vercel.app/))
- [Geins](https://github.com/geins-io/vercel-nextjs-commerce) ([Demo](https://geins-nextjs-commerce-starter.vercel.app/))
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/)) - [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
- [Prodigy Commerce](https://github.com/prodigycommerce/nextjs-commerce) ([Demo](https://prodigy-nextjs-commerce.vercel.app/))
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/)) - [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
- [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/)) - [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/)) - [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
- [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
- [Wix](https://github.com/wix/headless-templates/tree/main/nextjs/commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
- [Fourthwall](https://github.com/FourthwallHQ/vercel-commerce) ([Demo](https://vercel-storefront.fourthwall.app/))
> Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing).
## Integrations
Integrations enable upgraded or additional functionality for Next.js Commerce
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
- [React Bricks](https://github.com/ReactBricks/nextjs-commerce-rb) ([Demo](https://nextjs-commerce.reactbricks.com/))
- Edit pages, product details, and footer content visually using [React Bricks](https://www.reactbricks.com) visual headless CMS.
## Running locally ## Running locally
@ -67,9 +56,9 @@ Your app should now be running on [localhost:3000](http://localhost:3000/).
1. Select the `Vercel Solutions` scope. 1. Select the `Vercel Solutions` scope.
1. Connect to the existing `commerce-shopify` project. 1. Connect to the existing `commerce-shopify` project.
1. Run `vc env pull` to get environment variables. 1. Run `vc env pull` to get environment variables.
1. Run `pnpm dev` to ensure everything is working correctly. 1. Run `pmpm dev` to ensure everything is working correctly.
</details> </details>
## Vercel, Next.js Commerce, and Shopify Integration Guide ## Vercel, Next.js Commerce, and Shopify integration guide
You can use this comprehensive [integration guide](https://vercel.com/docs/integrations/ecommerce/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel. You can use this comprehensive [integration guide](http://vercel.com/docs/integrations/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.

View File

@ -1,12 +1,15 @@
import Footer from 'components/layout/footer'; import Footer from 'components/layout/footer';
import { Suspense } from 'react';
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<> <Suspense>
<div className="w-full"> <div className="w-full">
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">{children}</div> <div className="mx-8 max-w-2xl py-20 sm:mx-auto">
<Suspense>{children}</Suspense>
</div>
</div> </div>
<Footer /> <Footer />
</> </Suspense>
); );
} }

View File

@ -1,6 +1,8 @@
import OpengraphImage from 'components/opengraph-image'; import OpengraphImage from 'components/opengraph-image';
import { getPage } from 'lib/shopify'; import { getPage } from 'lib/shopify';
export const runtime = 'edge';
export default async function Image({ params }: { params: { page: string } }) { export default async function Image({ params }: { params: { page: string } }) {
const page = await getPage(params.page); const page = await getPage(params.page);
const title = page.seo?.title || page.title; const title = page.seo?.title || page.title;

View File

@ -4,10 +4,15 @@ import Prose from 'components/prose';
import { getPage } from 'lib/shopify'; import { getPage } from 'lib/shopify';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
export async function generateMetadata(props: { export const runtime = 'edge';
params: Promise<{ page: string }>;
export const revalidate = 43200; // 12 hours in seconds
export async function generateMetadata({
params
}: {
params: { page: string };
}): Promise<Metadata> { }): Promise<Metadata> {
const params = await props.params;
const page = await getPage(params.page); const page = await getPage(params.page);
if (!page) return notFound(); if (!page) return notFound();
@ -23,8 +28,7 @@ export async function generateMetadata(props: {
}; };
} }
export default async function Page(props: { params: Promise<{ page: string }> }) { export default async function Page({ params }: { params: { page: string } }) {
const params = await props.params;
const page = await getPage(params.page); const page = await getPage(params.page);
if (!page) return notFound(); if (!page) return notFound();
@ -32,7 +36,7 @@ export default async function Page(props: { params: Promise<{ page: string }> })
return ( return (
<> <>
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1> <h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
<Prose className="mb-8" html={page.body} /> <Prose className="mb-8" html={page.body as string} />
<p className="text-sm italic"> <p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, { {`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric', year: 'numeric',

View File

@ -1,6 +1,8 @@
import { revalidate } from 'lib/shopify'; import { revalidate } from 'lib/shopify';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'edge';
export async function POST(req: NextRequest): Promise<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {
return revalidate(req); return revalidate(req);
} }

View File

@ -2,7 +2,7 @@
export default function Error({ reset }: { reset: () => void }) { export default function Error({ reset }: { reset: () => void }) {
return ( return (
<div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 dark:border-neutral-800 dark:bg-black"> <div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12">
<h2 className="text-xl font-bold">Oh no!</h2> <h2 className="text-xl font-bold">Oh no!</h2>
<p className="my-2"> <p className="my-2">
There was an issue with our storefront. This could be a temporary issue, please try your There was an issue with our storefront. This could be a temporary issue, please try your

View File

@ -1,17 +1,6 @@
@import 'tailwindcss'; @tailwind base;
@tailwind components;
@plugin "@tailwindcss/container-queries"; @tailwind utilities;
@plugin "@tailwindcss/typography";
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html { html {
@ -28,5 +17,5 @@
a, a,
input, input,
button { button {
@apply focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900; @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
} }

View File

@ -1,14 +1,15 @@
import { CartProvider } from 'components/cart/cart-context'; import Navbar from 'components/layout/navbar';
import { Navbar } from 'components/layout/navbar'; import { ensureStartsWith } from 'lib/utils';
import { WelcomeToast } from 'components/welcome-toast'; import { Inter } from 'next/font/google';
import { GeistSans } from 'geist/font/sans'; import { ReactNode, Suspense } from 'react';
import { getCart } from 'lib/shopify';
import { ReactNode } from 'react';
import { Toaster } from 'sonner';
import './globals.css'; import './globals.css';
import { baseUrl } from 'lib/utils';
const { SITE_NAME } = process.env; const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
const twitterCreator = TWITTER_CREATOR ? ensureStartsWith(TWITTER_CREATOR, '@') : undefined;
const twitterSite = TWITTER_SITE ? ensureStartsWith(TWITTER_SITE, 'https://') : undefined;
export const metadata = { export const metadata = {
metadataBase: new URL(baseUrl), metadataBase: new URL(baseUrl),
@ -19,28 +20,31 @@ export const metadata = {
robots: { robots: {
follow: true, follow: true,
index: true index: true
} },
...(twitterCreator &&
twitterSite && {
twitter: {
card: 'summary_large_image',
creator: twitterCreator,
site: twitterSite
}
})
}; };
export default async function RootLayout({ const inter = Inter({
children subsets: ['latin'],
}: { display: 'swap',
children: ReactNode; variable: '--font-inter'
}) { });
// Don't await the fetch, pass the Promise to the context provider
const cart = getCart();
export default async function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en" className={GeistSans.variable}> <html lang="en" className={inter.variable}>
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white"> <body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<CartProvider cartPromise={cart}> <Navbar />
<Navbar /> <Suspense>
<main> <main>{children}</main>
{children} </Suspense>
<Toaster closeButton />
<WelcomeToast />
</main>
</CartProvider>
</body> </body>
</html> </html>
); );

View File

@ -1,5 +1,7 @@
import OpengraphImage from 'components/opengraph-image'; import OpengraphImage from 'components/opengraph-image';
export const runtime = 'edge';
export default async function Image() { export default async function Image() {
return await OpengraphImage(); return await OpengraphImage();
} }

View File

@ -1,21 +1,27 @@
import { Carousel } from 'components/carousel'; import { Carousel } from 'components/carousel';
import { ThreeItemGrid } from 'components/grid/three-items'; import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer'; import Footer from 'components/layout/footer';
import { Suspense } from 'react';
export const runtime = 'edge';
export const metadata = { export const metadata = {
description: description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
openGraph: { openGraph: {
type: 'website' type: 'website'
} }
}; };
export default function HomePage() { export default async function HomePage() {
return ( return (
<> <>
<ThreeItemGrid /> <ThreeItemGrid />
<Carousel /> <Suspense>
<Footer /> <Carousel />
<Suspense>
<Footer />
</Suspense>
</Suspense>
</> </>
); );
} }

View File

@ -1,21 +1,23 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from 'components/grid/tile';
import Footer from 'components/layout/footer'; import Footer from 'components/layout/footer';
import { Gallery } from 'components/product/gallery'; import { Gallery } from 'components/product/gallery';
import { ProductProvider } from 'components/product/product-context';
import { ProductDescription } from 'components/product/product-description'; import { ProductDescription } from 'components/product/product-description';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants'; import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify'; import { getProduct, getProductRecommendations } from 'lib/shopify';
import { Image } from 'lib/shopify/types'; import { Image } from 'lib/shopify/types';
import Link from 'next/link'; import Link from 'next/link';
import { Suspense } from 'react';
export async function generateMetadata(props: { export const runtime = 'edge';
params: Promise<{ handle: string }>;
export async function generateMetadata({
params
}: {
params: { handle: string };
}): Promise<Metadata> { }): Promise<Metadata> {
const params = await props.params;
const product = await getProduct(params.handle); const product = await getProduct(params.handle);
if (!product) return notFound(); if (!product) return notFound();
@ -49,8 +51,7 @@ export async function generateMetadata(props: {
}; };
} }
export default async function ProductPage(props: { params: Promise<{ handle: string }> }) { export default async function ProductPage({ params }: { params: { handle: string } }) {
const params = await props.params;
const product = await getProduct(params.handle); const product = await getProduct(params.handle);
if (!product) return notFound(); if (!product) return notFound();
@ -73,40 +74,36 @@ export default async function ProductPage(props: { params: Promise<{ handle: str
}; };
return ( return (
<ProductProvider> <>
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: JSON.stringify(productJsonLd) __html: JSON.stringify(productJsonLd)
}} }}
/> />
<div className="mx-auto max-w-(--breakpoint-2xl) px-4"> <div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black"> <div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
<div className="h-full w-full basis-full lg:basis-4/6"> <div className="h-full w-full basis-full lg:basis-4/6">
<Suspense <Gallery
fallback={ images={product.images.map((image: Image) => ({
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" /> src: image.url,
} altText: image.altText
> }))}
<Gallery />
images={product.images.slice(0, 5).map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
/>
</Suspense>
</div> </div>
<div className="basis-full lg:basis-2/6"> <div className="basis-full lg:basis-2/6">
<Suspense fallback={null}> <ProductDescription product={product} />
<ProductDescription product={product} />
</Suspense>
</div> </div>
</div> </div>
<RelatedProducts id={product.id} /> <Suspense>
<RelatedProducts id={product.id} />
</Suspense>
</div> </div>
<Footer /> <Suspense>
</ProductProvider> <Footer />
</Suspense>
</>
); );
} }
@ -124,11 +121,7 @@ async function RelatedProducts({ id }: { id: string }) {
key={product.handle} key={product.handle}
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5" className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
> >
<Link <Link className="relative h-full w-full" href={`/product/${product.handle}`}>
className="relative h-full w-full"
href={`/product/${product.handle}`}
prefetch={true}
>
<GridTileImage <GridTileImage
alt={product.title} alt={product.title}
label={{ label={{

View File

@ -1,4 +1,6 @@
import { baseUrl } from 'lib/utils'; const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
export default function robots() { export default function robots() {
return { return {

View File

@ -1,11 +1,9 @@
import OpengraphImage from 'components/opengraph-image'; import OpengraphImage from 'components/opengraph-image';
import { getCollection } from 'lib/shopify'; import { getCollection } from 'lib/shopify';
export default async function Image({ export const runtime = 'edge';
params
}: { export default async function Image({ params }: { params: { collection: string } }) {
params: { collection: string };
}) {
const collection = await getCollection(params.collection); const collection = await getCollection(params.collection);
const title = collection?.seo?.title || collection?.title; const title = collection?.seo?.title || collection?.title;

View File

@ -6,10 +6,13 @@ import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items'; import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants'; import { defaultSort, sorting } from 'lib/constants';
export async function generateMetadata(props: { export const runtime = 'edge';
params: Promise<{ collection: string }>;
export async function generateMetadata({
params
}: {
params: { collection: string };
}): Promise<Metadata> { }): Promise<Metadata> {
const params = await props.params;
const collection = await getCollection(params.collection); const collection = await getCollection(params.collection);
if (!collection) return notFound(); if (!collection) return notFound();
@ -21,12 +24,13 @@ export async function generateMetadata(props: {
}; };
} }
export default async function CategoryPage(props: { export default async function CategoryPage({
params: Promise<{ collection: string }>; params,
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; searchParams
}: {
params: { collection: string };
searchParams?: { [key: string]: string | string[] | undefined };
}) { }) {
const searchParams = await props.searchParams;
const params = await props.params;
const { sort } = searchParams as { [key: string]: string }; const { sort } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort; const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse }); const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });

View File

@ -1,10 +0,0 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { Fragment } from 'react';
// Ensure children are re-rendered when the search query changes
export default function ChildrenWrapper({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams();
return <Fragment key={searchParams.get('q')}>{children}</Fragment>;
}

View File

@ -2,30 +2,21 @@ import Footer from 'components/layout/footer';
import Collections from 'components/layout/search/collections'; import Collections from 'components/layout/search/collections';
import FilterList from 'components/layout/search/filter'; import FilterList from 'components/layout/search/filter';
import { sorting } from 'lib/constants'; import { sorting } from 'lib/constants';
import ChildrenWrapper from './children-wrapper';
import { Suspense } from 'react'; import { Suspense } from 'react';
export default function SearchLayout({ export default function SearchLayout({ children }: { children: React.ReactNode }) {
children
}: {
children: React.ReactNode;
}) {
return ( return (
<> <Suspense>
<div className="mx-auto flex max-w-(--breakpoint-2xl) flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white"> <div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
<div className="order-first w-full flex-none md:max-w-[125px]"> <div className="order-first w-full flex-none md:max-w-[125px]">
<Collections /> <Collections />
</div> </div>
<div className="order-last min-h-screen w-full md:order-none"> <div className="order-last min-h-screen w-full md:order-none">{children}</div>
<Suspense fallback={null}>
<ChildrenWrapper>{children}</ChildrenWrapper>
</Suspense>
</div>
<div className="order-none flex-none md:order-last md:w-[125px]"> <div className="order-none flex-none md:order-last md:w-[125px]">
<FilterList list={sorting} title="Sort by" /> <FilterList list={sorting} title="Sort by" />
</div> </div>
</div> </div>
<Footer /> <Footer />
</> </Suspense>
); );
} }

View File

@ -2,17 +2,14 @@ import Grid from 'components/grid';
export default function Loading() { export default function Loading() {
return ( return (
<> <Grid className="grid-cols-2 lg:grid-cols-3">
<div className="mb-4 h-6" /> {Array(12)
<Grid className="grid-cols-2 lg:grid-cols-3"> .fill(0)
{Array(12) .map((_, index) => {
.fill(0) return (
.map((_, index) => { <Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-900" />
return ( );
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" /> })}
); </Grid>
})}
</Grid>
</>
); );
} }

View File

@ -3,15 +3,18 @@ import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants'; import { defaultSort, sorting } from 'lib/constants';
import { getProducts } from 'lib/shopify'; import { getProducts } from 'lib/shopify';
export const runtime = 'edge';
export const metadata = { export const metadata = {
title: 'Search', title: 'Search',
description: 'Search for products in the store.' description: 'Search for products in the store.'
}; };
export default async function SearchPage(props: { export default async function SearchPage({
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; searchParams
}: {
searchParams?: { [key: string]: string | string[] | undefined };
}) { }) {
const searchParams = await props.searchParams;
const { sort, q: searchValue } = searchParams as { [key: string]: string }; const { sort, q: searchValue } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort; const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;

View File

@ -1,5 +1,4 @@
import { getCollections, getPages, getProducts } from 'lib/shopify'; import { getCollections, getPages, getProducts } from 'lib/shopify';
import { baseUrl, validateEnvironmentVariables } from 'lib/utils';
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
type Route = { type Route = {
@ -7,11 +6,11 @@ type Route = {
lastModified: string; lastModified: string;
}; };
export const dynamic = 'force-dynamic'; const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
validateEnvironmentVariables();
const routesMap = [''].map((route) => ({ const routesMap = [''].map((route) => ({
url: `${baseUrl}${route}`, url: `${baseUrl}${route}`,
lastModified: new Date().toISOString() lastModified: new Date().toISOString()
@ -41,9 +40,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
let fetchedRoutes: Route[] = []; let fetchedRoutes: Route[] = [];
try { try {
fetchedRoutes = ( fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
await Promise.all([collectionsPromise, productsPromise, pagesPromise])
).flat();
} catch (error) { } catch (error) {
throw JSON.stringify(error, null, 2); throw JSON.stringify(error, null, 2);
} }

View File

@ -12,7 +12,7 @@ export async function Carousel() {
const carouselProducts = [...products, ...products, ...products]; const carouselProducts = [...products, ...products, ...products];
return ( return (
<div className="w-full overflow-x-auto pb-6 pt-1"> <div className=" w-full overflow-x-auto pb-6 pt-1">
<ul className="flex animate-carousel gap-4"> <ul className="flex animate-carousel gap-4">
{carouselProducts.map((product, i) => ( {carouselProducts.map((product, i) => (
<li <li

View File

@ -1,106 +1,69 @@
'use server'; 'use server';
import { TAGS } from 'lib/constants'; import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
import {
addToCart,
createCart,
getCart,
removeFromCart,
updateCart
} from 'lib/shopify';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export async function addItem( export const addItem = async (variantId: string | undefined): Promise<String | undefined> => {
prevState: any, let cartId = cookies().get('cartId')?.value;
selectedVariantId: string | undefined let cart;
) {
if (!selectedVariantId) { if (cartId) {
return 'Error adding item to cart'; cart = await getCart(cartId);
}
if (!cartId || !cart) {
cart = await createCart();
cartId = cart.id;
cookies().set('cartId', cartId);
}
if (!variantId) {
return 'Missing product variant ID';
} }
try { try {
await addToCart([{ merchandiseId: selectedVariantId, quantity: 1 }]); await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
revalidateTag(TAGS.cart);
} catch (e) { } catch (e) {
return 'Error adding item to cart'; return 'Error adding item to cart';
} }
} };
export async function removeItem(prevState: any, merchandiseId: string) { export const removeItem = async (lineId: string): Promise<String | undefined> => {
const cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
try { try {
const cart = await getCart(); await removeFromCart(cartId, [lineId]);
if (!cart) {
return 'Error fetching cart';
}
const lineItem = cart.lines.find(
(line) => line.merchandise.id === merchandiseId
);
if (lineItem && lineItem.id) {
await removeFromCart([lineItem.id]);
revalidateTag(TAGS.cart);
} else {
return 'Item not found in cart';
}
} catch (e) { } catch (e) {
return 'Error removing item from cart'; return 'Error removing item from cart';
} }
} };
export async function updateItemQuantity( export const updateItemQuantity = async ({
prevState: any, lineId,
payload: { variantId,
merchandiseId: string; quantity
quantity: number; }: {
lineId: string;
variantId: string;
quantity: number;
}): Promise<String | undefined> => {
const cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
} }
) {
const { merchandiseId, quantity } = payload;
try { try {
const cart = await getCart(); await updateCart(cartId, [
{
if (!cart) { id: lineId,
return 'Error fetching cart'; merchandiseId: variantId,
} quantity
const lineItem = cart.lines.find(
(line) => line.merchandise.id === merchandiseId
);
if (lineItem && lineItem.id) {
if (quantity === 0) {
await removeFromCart([lineItem.id]);
} else {
await updateCart([
{
id: lineItem.id,
merchandiseId,
quantity
}
]);
} }
} else if (quantity > 0) { ]);
// If the item doesn't exist in the cart and quantity > 0, add it
await addToCart([{ merchandiseId, quantity }]);
}
revalidateTag(TAGS.cart);
} catch (e) { } catch (e) {
console.error(e);
return 'Error updating item quantity'; return 'Error updating item quantity';
} }
} };
export async function redirectToCheckout() {
let cart = await getCart();
redirect(cart!.checkoutUrl);
}
export async function createCartAndSetCookie() {
let cart = await createCart();
(await cookies()).set('cartId', cart.id!);
}

View File

@ -3,92 +3,66 @@
import { PlusIcon } from '@heroicons/react/24/outline'; import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { addItem } from 'components/cart/actions'; import { addItem } from 'components/cart/actions';
import { useProduct } from 'components/product/product-context'; import LoadingDots from 'components/loading-dots';
import { Product, ProductVariant } from 'lib/shopify/types'; import { ProductVariant } from 'lib/shopify/types';
import { useActionState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation';
import { useCart } from './cart-context'; import { useTransition } from 'react';
function SubmitButton({ export function AddToCart({
availableForSale, variants,
selectedVariantId availableForSale
}: { }: {
variants: ProductVariant[];
availableForSale: boolean; availableForSale: boolean;
selectedVariantId: string | undefined;
}) { }) {
const buttonClasses = const router = useRouter();
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white'; const searchParams = useSearchParams();
const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60'; const [isPending, startTransition] = useTransition();
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
if (!availableForSale) { const variant = variants.find((variant: ProductVariant) =>
return ( variant.selectedOptions.every(
<button disabled className={clsx(buttonClasses, disabledClasses)}> (option) => option.value === searchParams.get(option.name.toLowerCase())
Out Of Stock )
</button> );
); const selectedVariantId = variant?.id || defaultVariantId;
} const title = !availableForSale
? 'Out of stock'
if (!selectedVariantId) { : !selectedVariantId
return ( ? 'Please select options'
<button : undefined;
aria-label="Please select an option"
disabled
className={clsx(buttonClasses, disabledClasses)}
>
<div className="absolute left-0 ml-4">
<PlusIcon className="h-5" />
</div>
Add To Cart
</button>
);
}
return ( return (
<button <button
aria-label="Add to cart" aria-label="Add item to cart"
className={clsx(buttonClasses, { disabled={isPending || !availableForSale || !selectedVariantId}
'hover:opacity-90': true title={title}
})} onClick={() => {
// Safeguard in case someone messes with `disabled` in devtools.
if (!availableForSale || !selectedVariantId) return;
startTransition(async () => {
const error = await addItem(selectedVariantId);
if (error) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
router.refresh();
});
}}
className={clsx(
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90',
{
'cursor-not-allowed opacity-60 hover:opacity-60': !availableForSale || !selectedVariantId,
'cursor-not-allowed': isPending
}
)}
> >
<div className="absolute left-0 ml-4"> <div className="absolute left-0 ml-4">
<PlusIcon className="h-5" /> {!isPending ? <PlusIcon className="h-5" /> : <LoadingDots className="mb-3 bg-white" />}
</div> </div>
Add To Cart <span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
</button> </button>
); );
} }
export function AddToCart({ product }: { product: Product }) {
const { variants, availableForSale } = product;
const { addCartItem } = useCart();
const { state } = useProduct();
const [message, formAction] = useActionState(addItem, null);
const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every(
(option) => option.value === state[option.name.toLowerCase()]
)
);
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const selectedVariantId = variant?.id || defaultVariantId;
const addItemAction = formAction.bind(null, selectedVariantId);
const finalVariant = variants.find(
(variant) => variant.id === selectedVariantId
)!;
return (
<form
action={async () => {
addCartItem(finalVariant, product);
addItemAction();
}}
>
<SubmitButton
availableForSale={availableForSale}
selectedVariantId={selectedVariantId}
/>
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

View File

@ -1,238 +0,0 @@
'use client';
import type {
Cart,
CartItem,
Product,
ProductVariant
} from 'lib/shopify/types';
import React, {
createContext,
use,
useContext,
useMemo,
useOptimistic
} from 'react';
type UpdateType = 'plus' | 'minus' | 'delete';
type CartAction =
| {
type: 'UPDATE_ITEM';
payload: { merchandiseId: string; updateType: UpdateType };
}
| {
type: 'ADD_ITEM';
payload: { variant: ProductVariant; product: Product };
};
type CartContextType = {
cartPromise: Promise<Cart | undefined>;
};
const CartContext = createContext<CartContextType | undefined>(undefined);
function calculateItemCost(quantity: number, price: string): string {
return (Number(price) * quantity).toString();
}
function updateCartItem(
item: CartItem,
updateType: UpdateType
): CartItem | null {
if (updateType === 'delete') return null;
const newQuantity =
updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
if (newQuantity === 0) return null;
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
const newTotalAmount = calculateItemCost(
newQuantity,
singleItemAmount.toString()
);
return {
...item,
quantity: newQuantity,
cost: {
...item.cost,
totalAmount: {
...item.cost.totalAmount,
amount: newTotalAmount
}
}
};
}
function createOrUpdateCartItem(
existingItem: CartItem | undefined,
variant: ProductVariant,
product: Product
): CartItem {
const quantity = existingItem ? existingItem.quantity + 1 : 1;
const totalAmount = calculateItemCost(quantity, variant.price.amount);
return {
id: existingItem?.id,
quantity,
cost: {
totalAmount: {
amount: totalAmount,
currencyCode: variant.price.currencyCode
}
},
merchandise: {
id: variant.id,
title: variant.title,
selectedOptions: variant.selectedOptions,
product: {
id: product.id,
handle: product.handle,
title: product.title,
featuredImage: product.featuredImage
}
}
};
}
function updateCartTotals(
lines: CartItem[]
): Pick<Cart, 'totalQuantity' | 'cost'> {
const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = lines.reduce(
(sum, item) => sum + Number(item.cost.totalAmount.amount),
0
);
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD';
return {
totalQuantity,
cost: {
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
totalAmount: { amount: totalAmount.toString(), currencyCode },
totalTaxAmount: { amount: '0', currencyCode }
}
};
}
function createEmptyCart(): Cart {
return {
id: undefined,
checkoutUrl: '',
totalQuantity: 0,
lines: [],
cost: {
subtotalAmount: { amount: '0', currencyCode: 'USD' },
totalAmount: { amount: '0', currencyCode: 'USD' },
totalTaxAmount: { amount: '0', currencyCode: 'USD' }
}
};
}
function cartReducer(state: Cart | undefined, action: CartAction): Cart {
const currentCart = state || createEmptyCart();
switch (action.type) {
case 'UPDATE_ITEM': {
const { merchandiseId, updateType } = action.payload;
const updatedLines = currentCart.lines
.map((item) =>
item.merchandise.id === merchandiseId
? updateCartItem(item, updateType)
: item
)
.filter(Boolean) as CartItem[];
if (updatedLines.length === 0) {
return {
...currentCart,
lines: [],
totalQuantity: 0,
cost: {
...currentCart.cost,
totalAmount: { ...currentCart.cost.totalAmount, amount: '0' }
}
};
}
return {
...currentCart,
...updateCartTotals(updatedLines),
lines: updatedLines
};
}
case 'ADD_ITEM': {
const { variant, product } = action.payload;
const existingItem = currentCart.lines.find(
(item) => item.merchandise.id === variant.id
);
const updatedItem = createOrUpdateCartItem(
existingItem,
variant,
product
);
const updatedLines = existingItem
? currentCart.lines.map((item) =>
item.merchandise.id === variant.id ? updatedItem : item
)
: [...currentCart.lines, updatedItem];
return {
...currentCart,
...updateCartTotals(updatedLines),
lines: updatedLines
};
}
default:
return currentCart;
}
}
export function CartProvider({
children,
cartPromise
}: {
children: React.ReactNode;
cartPromise: Promise<Cart | undefined>;
}) {
return (
<CartContext.Provider value={{ cartPromise }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
const initialCart = use(context.cartPromise);
const [optimisticCart, updateOptimisticCart] = useOptimistic(
initialCart,
cartReducer
);
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
updateOptimisticCart({
type: 'UPDATE_ITEM',
payload: { merchandiseId, updateType }
});
};
const addCartItem = (variant: ProductVariant, product: Product) => {
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
};
return useMemo(
() => ({
cart: optimisticCart,
updateCartItem,
addCartItem
}),
[optimisticCart]
);
}

View File

@ -0,0 +1,10 @@
import { XMarkIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
export default function CloseCart({ className }: { className?: string }) {
return (
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
<XMarkIcon className={clsx('h-6 transition-all ease-in-out hover:scale-110 ', className)} />
</div>
);
}

View File

@ -1,38 +1,44 @@
'use client';
import { XMarkIcon } from '@heroicons/react/24/outline'; import { XMarkIcon } from '@heroicons/react/24/outline';
import LoadingDots from 'components/loading-dots';
import { useRouter } from 'next/navigation';
import clsx from 'clsx';
import { removeItem } from 'components/cart/actions'; import { removeItem } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from 'lib/shopify/types';
import { useActionState } from 'react'; import { useTransition } from 'react';
export function DeleteItemButton({ export default function DeleteItemButton({ item }: { item: CartItem }) {
item, const router = useRouter();
optimisticUpdate const [isPending, startTransition] = useTransition();
}: {
item: CartItem;
optimisticUpdate: any;
}) {
const [message, formAction] = useActionState(removeItem, null);
const merchandiseId = item.merchandise.id;
const removeItemAction = formAction.bind(null, merchandiseId);
return ( return (
<form <button
action={async () => { aria-label="Remove cart item"
optimisticUpdate(merchandiseId, 'delete'); onClick={() => {
removeItemAction(); startTransition(async () => {
const error = await removeItem(item.id);
if (error) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
router.refresh();
});
}} }}
disabled={isPending}
className={clsx(
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200',
{
'cursor-not-allowed px-0': isPending
}
)}
> >
<button {isPending ? (
type="submit" <LoadingDots className="bg-white" />
aria-label="Remove cart item" ) : (
className="flex h-[24px] w-[24px] items-center justify-center rounded-full bg-neutral-500" <XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
> )}
<XMarkIcon className="mx-[1px] h-4 w-4 text-white dark:text-black" /> </button>
</button>
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
); );
} }

View File

@ -1,26 +1,56 @@
'use client'; import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline'; import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions'; import { removeItem, updateItemQuantity } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from 'lib/shopify/types';
import { useActionState } from 'react';
function SubmitButton({ type }: { type: 'plus' | 'minus' }) { export default function EditItemQuantityButton({
item,
type
}: {
item: CartItem;
type: 'plus' | 'minus';
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return ( return (
<button <button
type="submit" aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
aria-label={ onClick={() => {
type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity' startTransition(async () => {
} const error =
type === 'minus' && item.quantity - 1 === 0
? await removeItem(item.id)
: await updateItemQuantity({
lineId: item.id,
variantId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
});
if (error) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
router.refresh();
});
}}
disabled={isPending}
className={clsx( className={clsx(
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full p-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80', 'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
{ {
'cursor-not-allowed': isPending,
'ml-auto': type === 'minus' 'ml-auto': type === 'minus'
} }
)} )}
> >
{type === 'plus' ? ( {isPending ? (
<LoadingDots className="bg-black dark:bg-white" />
) : type === 'plus' ? (
<PlusIcon className="h-4 w-4 dark:text-neutral-500" /> <PlusIcon className="h-4 w-4 dark:text-neutral-500" />
) : ( ) : (
<MinusIcon className="h-4 w-4 dark:text-neutral-500" /> <MinusIcon className="h-4 w-4 dark:text-neutral-500" />
@ -28,34 +58,3 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
</button> </button>
); );
} }
export function EditItemQuantityButton({
item,
type,
optimisticUpdate
}: {
item: CartItem;
type: 'plus' | 'minus';
optimisticUpdate: any;
}) {
const [message, formAction] = useActionState(updateItemQuantity, null);
const payload = {
merchandiseId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
};
const updateItemQuantityAction = formAction.bind(null, payload);
return (
<form
action={async () => {
optimisticUpdate(payload.merchandiseId, type);
updateItemQuantityAction();
}}
>
<SubmitButton type={type} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

14
components/cart/index.tsx Normal file
View File

@ -0,0 +1,14 @@
import { getCart } from 'lib/shopify';
import { cookies } from 'next/headers';
import CartModal from './modal';
export default async function Cart() {
const cartId = cookies().get('cartId')?.value;
let cart;
if (cartId) {
cart = await getCart(cartId);
}
return <CartModal cart={cart} />;
}

View File

@ -1,48 +1,38 @@
'use client'; 'use client';
import clsx from 'clsx';
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from '@headlessui/react';
import { ShoppingCartIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { ShoppingCartIcon } from '@heroicons/react/24/outline';
import LoadingDots from 'components/loading-dots';
import Price from 'components/price'; import Price from 'components/price';
import { DEFAULT_OPTION } from 'lib/constants'; import { DEFAULT_OPTION } from 'lib/constants';
import type { Cart } from 'lib/shopify/types';
import { createUrl } from 'lib/utils'; import { createUrl } from 'lib/utils';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Fragment, useEffect, useRef, useState } from 'react'; import { Fragment, useEffect, useRef, useState } from 'react';
import { useFormStatus } from 'react-dom'; import CloseCart from './close-cart';
import { createCartAndSetCookie, redirectToCheckout } from './actions'; import DeleteItemButton from './delete-item-button';
import { useCart } from './cart-context'; import EditItemQuantityButton from './edit-item-quantity-button';
import { DeleteItemButton } from './delete-item-button';
import { EditItemQuantityButton } from './edit-item-quantity-button';
import OpenCart from './open-cart'; import OpenCart from './open-cart';
type MerchandiseSearchParams = { type MerchandiseSearchParams = {
[key: string]: string; [key: string]: string;
}; };
export default function CartModal() { export default function CartModal({ cart }: { cart: Cart | undefined }) {
const { cart, updateCartItem } = useCart();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const quantityRef = useRef(cart?.totalQuantity); const quantityRef = useRef(cart?.totalQuantity);
const openCart = () => setIsOpen(true); const openCart = () => setIsOpen(true);
const closeCart = () => setIsOpen(false); const closeCart = () => setIsOpen(false);
useEffect(() => { useEffect(() => {
if (!cart) { // Open cart modal when quantity changes.
createCartAndSetCookie(); if (cart?.totalQuantity !== quantityRef.current) {
} // But only if it's not already open (quantity also changes when editing items in cart).
}, [cart]);
useEffect(() => {
if (
cart?.totalQuantity &&
cart?.totalQuantity !== quantityRef.current &&
cart?.totalQuantity > 0
) {
if (!isOpen) { if (!isOpen) {
setIsOpen(true); setIsOpen(true);
} }
// Always update the quantity reference
quantityRef.current = cart?.totalQuantity; quantityRef.current = cart?.totalQuantity;
} }
}, [isOpen, cart?.totalQuantity, quantityRef]); }, [isOpen, cart?.totalQuantity, quantityRef]);
@ -74,9 +64,10 @@ export default function CartModal() {
leaveFrom="translate-x-0" leaveFrom="translate-x-0"
leaveTo="translate-x-full" leaveTo="translate-x-full"
> >
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl md:w-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white"> <Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl dark:border-neutral-700 dark:bg-black/80 dark:text-white md:w-[390px]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-lg font-semibold">My Cart</p> <p className="text-lg font-semibold">My Cart</p>
<button aria-label="Close cart" onClick={closeCart}> <button aria-label="Close cart" onClick={closeCart}>
<CloseCart /> <CloseCart />
</button> </button>
@ -85,113 +76,81 @@ export default function CartModal() {
{!cart || cart.lines.length === 0 ? ( {!cart || cart.lines.length === 0 ? (
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden"> <div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
<ShoppingCartIcon className="h-16" /> <ShoppingCartIcon className="h-16" />
<p className="mt-6 text-center text-2xl font-bold"> <p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
Your cart is empty.
</p>
</div> </div>
) : ( ) : (
<div className="flex h-full flex-col justify-between overflow-hidden p-1"> <div className="flex h-full flex-col justify-between overflow-hidden p-1">
<ul className="grow overflow-auto py-4"> <ul className="flex-grow overflow-auto py-4">
{cart.lines {cart.lines.map((item, i) => {
.sort((a, b) => const merchandiseSearchParams = {} as MerchandiseSearchParams;
a.merchandise.product.title.localeCompare(
b.merchandise.product.title
)
)
.map((item, i) => {
const merchandiseSearchParams =
{} as MerchandiseSearchParams;
item.merchandise.selectedOptions.forEach( item.merchandise.selectedOptions.forEach(({ name, value }) => {
({ name, value }) => { if (value !== DEFAULT_OPTION) {
if (value !== DEFAULT_OPTION) { merchandiseSearchParams[name.toLowerCase()] = value;
merchandiseSearchParams[name.toLowerCase()] = }
value; });
}
}
);
const merchandiseUrl = createUrl( const merchandiseUrl = createUrl(
`/product/${item.merchandise.product.handle}`, `/product/${item.merchandise.product.handle}`,
new URLSearchParams(merchandiseSearchParams) new URLSearchParams(merchandiseSearchParams)
); );
return ( return (
<li <li
key={i} key={i}
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700" className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
> >
<div className="relative flex w-full flex-row justify-between px-1 py-4"> <div className="relative flex w-full flex-row justify-between px-1 py-4">
<div className="absolute z-40 -ml-1 -mt-2"> <div className="absolute z-40 -mt-2 ml-[55px]">
<DeleteItemButton <DeleteItemButton item={item} />
item={item} </div>
optimisticUpdate={updateCartItem} <Link
/> href={merchandiseUrl}
</div> onClick={closeCart}
<div className="flex flex-row"> className="z-30 flex flex-row space-x-4"
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800"> >
<Image <div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
className="h-full w-full object-cover" <Image
width={64} className="h-full w-full object-cover"
height={64} width={64}
alt={ height={64}
item.merchandise.product.featuredImage alt={
.altText || item.merchandise.product.featuredImage.altText ||
item.merchandise.product.title item.merchandise.product.title
}
src={
item.merchandise.product.featuredImage.url
}
/>
</div>
<Link
href={merchandiseUrl}
onClick={closeCart}
className="z-30 ml-2 flex flex-row space-x-4"
>
<div className="flex flex-1 flex-col text-base">
<span className="leading-tight">
{item.merchandise.product.title}
</span>
{item.merchandise.title !==
DEFAULT_OPTION ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{item.merchandise.title}
</p>
) : null}
</div>
</Link>
</div>
<div className="flex h-16 flex-col justify-between">
<Price
className="flex justify-end space-y-2 text-right text-sm"
amount={item.cost.totalAmount.amount}
currencyCode={
item.cost.totalAmount.currencyCode
} }
src={item.merchandise.product.featuredImage.url}
/> />
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700"> </div>
<EditItemQuantityButton
item={item} <div className="flex flex-1 flex-col text-base">
type="minus" <span className="leading-tight">
optimisticUpdate={updateCartItem} {item.merchandise.product.title}
/> </span>
<p className="w-6 text-center"> {item.merchandise.title !== DEFAULT_OPTION ? (
<span className="w-full text-sm"> <p className="text-sm text-neutral-500 dark:text-neutral-400">
{item.quantity} {item.merchandise.title}
</span>
</p> </p>
<EditItemQuantityButton ) : null}
item={item} </div>
type="plus" </Link>
optimisticUpdate={updateCartItem} <div className="flex h-16 flex-col justify-between">
/> <Price
</div> className="flex justify-end space-y-2 text-right text-sm"
amount={item.cost.totalAmount.amount}
currencyCode={item.cost.totalAmount.currencyCode}
/>
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
<EditItemQuantityButton item={item} type="minus" />
<p className="w-6 text-center">
<span className="w-full text-sm">{item.quantity}</span>
</p>
<EditItemQuantityButton item={item} type="plus" />
</div> </div>
</div> </div>
</li> </div>
); </li>
})} );
})}
</ul> </ul>
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400"> <div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700"> <div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
@ -215,9 +174,12 @@ export default function CartModal() {
/> />
</div> </div>
</div> </div>
<form action={redirectToCheckout}> <a
<CheckoutButton /> href={cart.checkoutUrl}
</form> className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
>
Proceed to Checkout
</a>
</div> </div>
)} )}
</Dialog.Panel> </Dialog.Panel>
@ -227,30 +189,3 @@ export default function CartModal() {
</> </>
); );
} }
function CloseCart({ className }: { className?: string }) {
return (
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
<XMarkIcon
className={clsx(
'h-6 transition-all ease-in-out hover:scale-110',
className
)}
/>
</div>
);
}
function CheckoutButton() {
const { pending } = useFormStatus();
return (
<button
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
type="submit"
disabled={pending}
>
{pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'}
</button>
);
}

View File

@ -11,11 +11,11 @@ export default function OpenCart({
return ( return (
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white"> <div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
<ShoppingCartIcon <ShoppingCartIcon
className={clsx('h-4 transition-all ease-in-out hover:scale-110', className)} className={clsx('h-4 transition-all ease-in-out hover:scale-110 ', className)}
/> />
{quantity ? ( {quantity ? (
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded-sm bg-blue-600 text-[11px] font-medium text-white"> <div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded bg-blue-600 text-[11px] font-medium text-white">
{quantity} {quantity}
</div> </div>
) : null} ) : null}

View File

@ -16,11 +16,7 @@ function ThreeItemGridItem({
<div <div
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'} className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
> >
<Link <Link className="relative block aspect-square h-full w-full" href={`/product/${item.handle}`}>
className="relative block aspect-square h-full w-full"
href={`/product/${item.handle}`}
prefetch={true}
>
<GridTileImage <GridTileImage
src={item.featuredImage.url} src={item.featuredImage.url}
fill fill
@ -52,7 +48,7 @@ export async function ThreeItemGrid() {
const [firstProduct, secondProduct, thirdProduct] = homepageItems; const [firstProduct, secondProduct, thirdProduct] = homepageItems;
return ( return (
<section className="mx-auto grid max-w-(--breakpoint-2xl) gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]"> <section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2">
<ThreeItemGridItem size="full" item={firstProduct} priority={true} /> <ThreeItemGridItem size="full" item={firstProduct} priority={true} />
<ThreeItemGridItem size="half" item={secondProduct} priority={true} /> <ThreeItemGridItem size="half" item={secondProduct} priority={true} />
<ThreeItemGridItem size="half" item={thirdProduct} /> <ThreeItemGridItem size="half" item={thirdProduct} />

View File

@ -29,6 +29,7 @@ export function GridTileImage({
)} )}
> >
{props.src ? ( {props.src ? (
// eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is being enforced with TypeScript
<Image <Image
className={clsx('relative h-full w-full object-contain', { className={clsx('relative h-full w-full object-contain', {
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive 'transition duration-300 ease-in-out group-hover:scale-105': isInteractive

View File

@ -19,7 +19,7 @@ const Label = ({
})} })}
> >
<div className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white"> <div className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white">
<h3 className="mr-4 line-clamp-2 grow pl-2 leading-none tracking-tight">{title}</h3> <h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
<Price <Price
className="flex-none rounded-full bg-blue-600 p-2 text-white" className="flex-none rounded-full bg-blue-600 p-2 text-white"
amount={amount} amount={amount}

View File

@ -6,7 +6,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export function FooterMenuItem({ item }: { item: Menu }) { const FooterMenuItem = ({ item }: { item: Menu }) => {
const pathname = usePathname(); const pathname = usePathname();
const [active, setActive] = useState(pathname === item.path); const [active, setActive] = useState(pathname === item.path);
@ -19,7 +19,7 @@ export function FooterMenuItem({ item }: { item: Menu }) {
<Link <Link
href={item.path} href={item.path}
className={clsx( className={clsx(
'block p-2 text-lg underline-offset-4 hover:text-black hover:underline md:inline-block md:text-sm dark:hover:text-neutral-300', 'block p-2 text-lg underline-offset-4 hover:text-black hover:underline dark:hover:text-neutral-300 md:inline-block md:text-sm',
{ {
'text-black dark:text-neutral-300': active 'text-black dark:text-neutral-300': active
} }
@ -29,7 +29,7 @@ export function FooterMenuItem({ item }: { item: Menu }) {
</Link> </Link>
</li> </li>
); );
} };
export default function FooterMenu({ menu }: { menu: Menu[] }) { export default function FooterMenu({ menu }: { menu: Menu[] }) {
if (!menu.length) return null; if (!menu.length) return null;

View File

@ -10,15 +10,15 @@ const { COMPANY_NAME, SITE_NAME } = process.env;
export default async function Footer() { export default async function Footer() {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : ''); const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
const skeleton = 'w-full h-6 animate-pulse rounded-sm bg-neutral-200 dark:bg-neutral-700'; const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700';
const menu = await getMenu('next-js-frontend-footer-menu'); const menu = await getMenu('next-js-frontend-footer-menu');
const copyrightName = COMPANY_NAME || SITE_NAME || ''; const copyrightName = COMPANY_NAME || SITE_NAME || '';
return ( return (
<footer className="text-sm text-neutral-500 dark:text-neutral-400"> <footer className="text-sm text-neutral-500 dark:text-neutral-400">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0 dark:border-neutral-700"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 xl:px-0">
<div> <div>
<Link className="flex items-center gap-2 text-black md:pt-1 dark:text-white" href="/"> <Link className="flex items-center gap-2 text-black dark:text-white md:pt-1" href="/">
<LogoSquare size="sm" /> <LogoSquare size="sm" />
<span className="uppercase">{SITE_NAME}</span> <span className="uppercase">{SITE_NAME}</span>
</Link> </Link>
@ -50,18 +50,17 @@ export default async function Footer() {
</div> </div>
</div> </div>
<div className="border-t border-neutral-200 py-6 text-sm dark:border-neutral-700"> <div className="border-t border-neutral-200 py-6 text-sm dark:border-neutral-700">
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-1 px-4 md:flex-row md:gap-0 md:px-4 min-[1320px]:px-0"> <div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-1 px-4 md:flex-row md:gap-0 md:px-4 xl:px-0">
<p> <p>
&copy; {copyrightDate} {copyrightName} &copy; {copyrightDate} {copyrightName}
{copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved. {copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved.
</p> </p>
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" /> <hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
<p> <p>Designed in California</p>
<a href="https://github.com/vercel/commerce">View the source</a>
</p>
<p className="md:ml-auto"> <p className="md:ml-auto">
Crafted by{' '}
<a href="https://vercel.com" className="text-black dark:text-white"> <a href="https://vercel.com" className="text-black dark:text-white">
Created by Vercel Vercel
</a> </a>
</p> </p>
</div> </div>

View File

@ -1,31 +1,25 @@
import CartModal from 'components/cart/modal'; import Cart from 'components/cart';
import OpenCart from 'components/cart/open-cart';
import LogoSquare from 'components/logo-square'; import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify'; import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types'; import { Menu } from 'lib/shopify/types';
import Link from 'next/link'; import Link from 'next/link';
import { Suspense } from 'react'; import { Suspense } from 'react';
import MobileMenu from './mobile-menu'; import MobileMenu from './mobile-menu';
import Search, { SearchSkeleton } from './search'; import Search from './search';
const { SITE_NAME } = process.env; const { SITE_NAME } = process.env;
export async function Navbar() { export default async function Navbar() {
const menu = await getMenu('next-js-frontend-header-menu'); const menu = await getMenu('next-js-frontend-header-menu');
return ( return (
<nav className="relative flex items-center justify-between p-4 lg:px-6"> <nav className="relative flex items-center justify-between p-4 lg:px-6">
<div className="block flex-none md:hidden"> <div className="block flex-none md:hidden">
<Suspense fallback={null}> <MobileMenu menu={menu} />
<MobileMenu menu={menu} />
</Suspense>
</div> </div>
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<div className="flex w-full md:w-1/3"> <div className="flex w-full md:w-1/3">
<Link <Link href="/" className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6">
href="/"
prefetch={true}
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
>
<LogoSquare /> <LogoSquare />
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block"> <div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
{SITE_NAME} {SITE_NAME}
@ -37,7 +31,6 @@ export async function Navbar() {
<li key={item.title}> <li key={item.title}>
<Link <Link
href={item.path} href={item.path}
prefetch={true}
className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300" className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300"
> >
{item.title} {item.title}
@ -48,12 +41,12 @@ export async function Navbar() {
) : null} ) : null}
</div> </div>
<div className="hidden justify-center md:flex md:w-1/3"> <div className="hidden justify-center md:flex md:w-1/3">
<Suspense fallback={<SearchSkeleton />}> <Search />
<Search />
</Suspense>
</div> </div>
<div className="flex justify-end md:w-1/3"> <div className="flex justify-end md:w-1/3">
<CartModal /> <Suspense fallback={<OpenCart />}>
<Cart />
</Suspense>
</div> </div>
</div> </div>
</nav> </nav>

View File

@ -3,11 +3,11 @@
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from '@headlessui/react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from 'next/navigation';
import { Fragment, Suspense, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import { Menu } from 'lib/shopify/types'; import { Menu } from 'lib/shopify/types';
import Search, { SearchSkeleton } from './search'; import Search from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) { export default function MobileMenu({ menu }: { menu: Menu[] }) {
const pathname = usePathname(); const pathname = usePathname();
@ -35,7 +35,7 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
<button <button
onClick={openMobileMenu} onClick={openMobileMenu}
aria-label="Open mobile menu" aria-label="Open mobile menu"
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors md:hidden dark:border-neutral-700 dark:text-white" className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white md:hidden"
> >
<Bars3Icon className="h-4" /> <Bars3Icon className="h-4" />
</button> </button>
@ -72,9 +72,7 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
</button> </button>
<div className="mb-4 w-full"> <div className="mb-4 w-full">
<Suspense fallback={<SearchSkeleton />}> <Search />
<Search />
</Suspense>
</div> </div>
{menu.length ? ( {menu.length ? (
<ul className="flex w-full flex-col"> <ul className="flex w-full flex-col">
@ -83,7 +81,7 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white" className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
key={item.title} key={item.title}
> >
<Link href={item.path} prefetch={true} onClick={closeMobileMenu}> <Link href={item.path} onClick={closeMobileMenu}>
{item.title} {item.title}
</Link> </Link>
</li> </li>

View File

@ -1,35 +1,45 @@
'use client'; 'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import Form from 'next/form'; import { createUrl } from 'lib/utils';
import { useSearchParams } from 'next/navigation';
export default function Search() { export default function Search() {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [searchValue, setSearchValue] = useState('');
useEffect(() => {
setSearchValue(searchParams?.get('q') || '');
}, [searchParams, setSearchValue]);
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const val = e.target as HTMLFormElement;
const search = val.search as HTMLInputElement;
const newParams = new URLSearchParams(searchParams.toString());
if (search.value) {
newParams.set('q', search.value);
} else {
newParams.delete('q');
}
router.push(createUrl('/search', newParams));
}
return ( return (
<Form action="/search" className="w-max-[550px] relative w-full lg:w-80 xl:w-full"> <form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<input <input
key={searchParams?.get('q')}
type="text" type="text"
name="q" name="search"
placeholder="Search for products..." placeholder="Search for products..."
autoComplete="off" autoComplete="off"
defaultValue={searchParams?.get('q') || ''} value={searchValue}
className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400" onChange={(e) => setSearchValue(e.target.value)}
/>
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
<MagnifyingGlassIcon className="h-4" />
</div>
</Form>
);
}
export function SearchSkeleton() {
return (
<form className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<input
placeholder="Search for products..."
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400" className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
/> />
<div className="absolute right-0 top-0 mr-3 flex h-full items-center"> <div className="absolute right-0 top-0 mr-3 flex h-full items-center">

View File

@ -8,11 +8,7 @@ export default function ProductGridItems({ products }: { products: Product[] })
<> <>
{products.map((product) => ( {products.map((product) => (
<Grid.Item key={product.handle} className="animate-fadeIn"> <Grid.Item key={product.handle} className="animate-fadeIn">
<Link <Link className="relative inline-block h-full w-full" href={`/product/${product.handle}`}>
className="relative inline-block h-full w-full"
href={`/product/${product.handle}`}
prefetch={true}
>
<GridTileImage <GridTileImage
alt={product.title} alt={product.title}
label={{ label={{

View File

@ -9,7 +9,7 @@ async function CollectionList() {
return <FilterList list={collections} title="Collections" />; return <FilterList list={collections} title="Collections" />;
} }
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded-sm'; const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300'; const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
const items = 'bg-neutral-400 dark:bg-neutral-700'; const items = 'bg-neutral-400 dark:bg-neutral-700';

View File

@ -42,7 +42,7 @@ export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
onClick={() => { onClick={() => {
setOpenSelect(!openSelect); setOpenSelect(!openSelect);
}} }}
className="flex w-full items-center justify-between rounded-sm border border-black/30 px-4 py-2 text-sm dark:border-white/30" className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"
> >
<div>{active}</div> <div>{active}</div>
<ChevronDownIcon className="h-4" /> <ChevronDownIcon className="h-4" />

View File

@ -1,5 +1,4 @@
import { SortFilterItem } from 'lib/constants'; import { SortFilterItem } from 'lib/constants';
import { Suspense } from 'react';
import FilterItemDropdown from './dropdown'; import FilterItemDropdown from './dropdown';
import { FilterItem } from './item'; import { FilterItem } from './item';
@ -20,20 +19,12 @@ export default function FilterList({ list, title }: { list: ListItem[]; title?:
return ( return (
<> <>
<nav> <nav>
{title ? ( {title ? <h3 className="hidden text-xs text-neutral-500 md:block">{title}</h3> : null}
<h3 className="hidden text-xs text-neutral-500 md:block dark:text-neutral-400">
{title}
</h3>
) : null}
<ul className="hidden md:block"> <ul className="hidden md:block">
<Suspense fallback={null}> <FilterItemList list={list} />
<FilterItemList list={list} />
</Suspense>
</ul> </ul>
<ul className="md:hidden"> <ul className="md:hidden">
<Suspense fallback={null}> <FilterItemDropdown list={list} />
<FilterItemDropdown list={list} />
</Suspense>
</ul> </ul>
</nav> </nav>
</> </>

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import type { SortFilterItem } from 'lib/constants'; import { SortFilterItem } from 'lib/constants';
import { createUrl } from 'lib/utils'; import { createUrl } from 'lib/utils';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from 'next/navigation';

View File

@ -1,15 +1,11 @@
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/server';
import LogoIcon from './icons/logo'; import LogoIcon from './icons/logo';
import { join } from 'path';
import { readFile } from 'fs/promises';
export type Props = { export type Props = {
title?: string; title?: string;
}; };
export default async function OpengraphImage( export default async function OpengraphImage(props?: Props): Promise<ImageResponse> {
props?: Props
): Promise<ImageResponse> {
const { title } = { const { title } = {
...{ ...{
title: process.env.SITE_NAME title: process.env.SITE_NAME
@ -17,9 +13,6 @@ export default async function OpengraphImage(
...props ...props
}; };
const file = await readFile(join(process.cwd(), './fonts/Inter-Bold.ttf'));
const font = Uint8Array.from(file).buffer;
return new ImageResponse( return new ImageResponse(
( (
<div tw="flex h-full w-full flex-col items-center justify-center bg-black"> <div tw="flex h-full w-full flex-col items-center justify-center bg-black">
@ -35,7 +28,9 @@ export default async function OpengraphImage(
fonts: [ fonts: [
{ {
name: 'Inter', name: 'Inter',
data: font, data: await fetch(new URL('../fonts/Inter-Bold.ttf', import.meta.url)).then((res) =>
res.arrayBuffer()
),
style: 'normal', style: 'normal',
weight: 700 weight: 700
} }

View File

@ -2,22 +2,32 @@
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from 'components/grid/tile';
import { useProduct, useUpdateURL } from 'components/product/product-context'; import { createUrl } from 'lib/utils';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
export function Gallery({ images }: { images: { src: string; altText: string }[] }) { export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
const { state, updateImage } = useProduct(); const pathname = usePathname();
const updateURL = useUpdateURL(); const searchParams = useSearchParams();
const imageIndex = state.image ? parseInt(state.image) : 0; const imageSearchParam = searchParams.get('image');
const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
const nextSearchParams = new URLSearchParams(searchParams.toString());
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0; const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
nextSearchParams.set('image', nextImageIndex.toString());
const nextUrl = createUrl(pathname, nextSearchParams);
const previousSearchParams = new URLSearchParams(searchParams.toString());
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1; const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
previousSearchParams.set('image', previousImageIndex.toString());
const previousUrl = createUrl(pathname, previousSearchParams);
const buttonClassName = const buttonClassName =
'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center'; 'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center';
return ( return (
<form> <>
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden"> <div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
{images[imageIndex] && ( {images[imageIndex] && (
<Image <Image
@ -32,46 +42,43 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
{images.length > 1 ? ( {images.length > 1 ? (
<div className="absolute bottom-[15%] flex w-full justify-center"> <div className="absolute bottom-[15%] flex w-full justify-center">
<div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur-sm dark:border-black dark:bg-neutral-900/80"> <div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur dark:border-black dark:bg-neutral-900/80">
<button <Link
formAction={() => {
const newState = updateImage(previousImageIndex.toString());
updateURL(newState);
}}
aria-label="Previous product image" aria-label="Previous product image"
href={previousUrl}
className={buttonClassName} className={buttonClassName}
scroll={false}
> >
<ArrowLeftIcon className="h-5" /> <ArrowLeftIcon className="h-5" />
</button> </Link>
<div className="mx-1 h-6 w-px bg-neutral-500"></div> <div className="mx-1 h-6 w-px bg-neutral-500"></div>
<button <Link
formAction={() => {
const newState = updateImage(nextImageIndex.toString());
updateURL(newState);
}}
aria-label="Next product image" aria-label="Next product image"
href={nextUrl}
className={buttonClassName} className={buttonClassName}
scroll={false}
> >
<ArrowRightIcon className="h-5" /> <ArrowRightIcon className="h-5" />
</button> </Link>
</div> </div>
</div> </div>
) : null} ) : null}
</div> </div>
{images.length > 1 ? ( {images.length > 1 ? (
<ul className="my-12 flex items-center flex-wrap justify-center gap-2 overflow-auto py-1 lg:mb-0"> <ul className="my-12 flex items-center justify-center gap-2 overflow-auto py-1 lg:mb-0">
{images.map((image, index) => { {images.map((image, index) => {
const isActive = index === imageIndex; const isActive = index === imageIndex;
const imageSearchParams = new URLSearchParams(searchParams.toString());
imageSearchParams.set('image', index.toString());
return ( return (
<li key={image.src} className="h-20 w-20"> <li key={image.src} className="h-20 w-20">
<button <Link
formAction={() => { aria-label="Enlarge product image"
const newState = updateImage(index.toString()); href={createUrl(pathname, imageSearchParams)}
updateURL(newState); scroll={false}
}}
aria-label="Select product image"
className="h-full w-full" className="h-full w-full"
> >
<GridTileImage <GridTileImage
@ -81,12 +88,12 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
height={80} height={80}
active={isActive} active={isActive}
/> />
</button> </Link>
</li> </li>
); );
})} })}
</ul> </ul>
) : null} ) : null}
</form> </>
); );
} }

View File

@ -1,81 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { createContext, useContext, useMemo, useOptimistic } from 'react';
type ProductState = {
[key: string]: string;
} & {
image?: string;
};
type ProductContextType = {
state: ProductState;
updateOption: (name: string, value: string) => ProductState;
updateImage: (index: string) => ProductState;
};
const ProductContext = createContext<ProductContextType | undefined>(undefined);
export function ProductProvider({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams();
const getInitialState = () => {
const params: ProductState = {};
for (const [key, value] of searchParams.entries()) {
params[key] = value;
}
return params;
};
const [state, setOptimisticState] = useOptimistic(
getInitialState(),
(prevState: ProductState, update: ProductState) => ({
...prevState,
...update
})
);
const updateOption = (name: string, value: string) => {
const newState = { [name]: value };
setOptimisticState(newState);
return { ...state, ...newState };
};
const updateImage = (index: string) => {
const newState = { image: index };
setOptimisticState(newState);
return { ...state, ...newState };
};
const value = useMemo(
() => ({
state,
updateOption,
updateImage
}),
[state]
);
return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>;
}
export function useProduct() {
const context = useContext(ProductContext);
if (context === undefined) {
throw new Error('useProduct must be used within a ProductProvider');
}
return context;
}
export function useUpdateURL() {
const router = useRouter();
return (state: ProductState) => {
const newParams = new URLSearchParams(window.location.search);
Object.entries(state).forEach(([key, value]) => {
newParams.set(key, value);
});
router.push(`?${newParams.toString()}`, { scroll: false });
};
}

View File

@ -17,13 +17,15 @@ export function ProductDescription({ product }: { product: Product }) {
</div> </div>
</div> </div>
<VariantSelector options={product.options} variants={product.variants} /> <VariantSelector options={product.options} variants={product.variants} />
{product.descriptionHtml ? ( {product.descriptionHtml ? (
<Prose <Prose
className="mb-6 text-sm leading-tight dark:text-white/[60%]" className="mb-6 text-sm leading-tight dark:text-white/[60%]"
html={product.descriptionHtml} html={product.descriptionHtml}
/> />
) : null} ) : null}
<AddToCart product={product} />
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
</> </>
); );
} }

View File

@ -1,13 +1,14 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { useProduct, useUpdateURL } from 'components/product/product-context';
import { ProductOption, ProductVariant } from 'lib/shopify/types'; import { ProductOption, ProductVariant } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
type Combination = { type Combination = {
id: string; id: string;
availableForSale: boolean; availableForSale: boolean;
[key: string]: string | boolean; [key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
}; };
export function VariantSelector({ export function VariantSelector({
@ -17,8 +18,9 @@ export function VariantSelector({
options: ProductOption[]; options: ProductOption[];
variants: ProductVariant[]; variants: ProductVariant[];
}) { }) {
const { state, updateOption } = useProduct(); const router = useRouter();
const updateURL = useUpdateURL(); const pathname = usePathname();
const searchParams = useSearchParams();
const hasNoOptionsOrJustOneOption = const hasNoOptionsOrJustOneOption =
!options.length || (options.length === 1 && options[0]?.values.length === 1); !options.length || (options.length === 1 && options[0]?.values.length === 1);
@ -29,6 +31,7 @@ export function VariantSelector({
const combinations: Combination[] = variants.map((variant) => ({ const combinations: Combination[] = variants.map((variant) => ({
id: variant.id, id: variant.id,
availableForSale: variant.availableForSale, availableForSale: variant.availableForSale,
// Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M").
...variant.selectedOptions.reduce( ...variant.selectedOptions.reduce(
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }), (accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
{} {}
@ -36,58 +39,68 @@ export function VariantSelector({
})); }));
return options.map((option) => ( return options.map((option) => (
<form key={option.id}> <dl className="mb-8" key={option.id}>
<dl className="mb-8"> <dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt> <dd className="flex flex-wrap gap-3">
<dd className="flex flex-wrap gap-3"> {option.values.map((value) => {
{option.values.map((value) => { const optionNameLowerCase = option.name.toLowerCase();
const optionNameLowerCase = option.name.toLowerCase();
// Base option params on current selectedOptions so we can preserve any other param state. // Base option params on current params so we can preserve any other param state in the url.
const optionParams = { ...state, [optionNameLowerCase]: value }; const optionSearchParams = new URLSearchParams(searchParams.toString());
// Filter out invalid options and check if the option combination is available for sale. // Update the option params using the current option to reflect how the url *would* change,
const filtered = Object.entries(optionParams).filter(([key, value]) => // if the option was clicked.
options.find( optionSearchParams.set(optionNameLowerCase, value);
(option) => option.name.toLowerCase() === key && option.values.includes(value) const optionUrl = createUrl(pathname, optionSearchParams);
)
);
const isAvailableForSale = combinations.find((combination) =>
filtered.every(
([key, value]) => combination[key] === value && combination.availableForSale
)
);
// The option is active if it's in the selected options. // In order to determine if an option is available for sale, we need to:
const isActive = state[optionNameLowerCase] === value; //
// 1. Filter out all other param state
// 2. Filter out invalid options
// 3. Check if the option combination is available for sale
//
// This is the "magic" that will cross check possible variant combinations and preemptively
// disable combinations that are not available. For example, if the color gray is only available in size medium,
// then all other sizes should be disabled.
const filtered = Array.from(optionSearchParams.entries()).filter(([key, value]) =>
options.find(
(option) => option.name.toLowerCase() === key && option.values.includes(value)
)
);
const isAvailableForSale = combinations.find((combination) =>
filtered.every(
([key, value]) => combination[key] === value && combination.availableForSale
)
);
return ( // The option is active if it's in the url params.
<button const isActive = searchParams.get(optionNameLowerCase) === value;
formAction={() => {
const newState = updateOption(optionNameLowerCase, value); return (
updateURL(newState); <button
}} key={value}
key={value} aria-disabled={!isAvailableForSale}
aria-disabled={!isAvailableForSale} disabled={!isAvailableForSale}
disabled={!isAvailableForSale} onClick={() => {
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`} router.replace(optionUrl, { scroll: false });
className={clsx( }}
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900', title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
{ className={clsx(
'cursor-default ring-2 ring-blue-600': isActive, 'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
'ring-1 ring-transparent transition duration-300 ease-in-out hover:ring-blue-600': {
!isActive && isAvailableForSale, 'cursor-default ring-2 ring-blue-600': isActive,
'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 dark:before:bg-neutral-700': 'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-blue-600 ':
!isAvailableForSale !isActive && isAvailableForSale,
} 'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 before:dark:bg-neutral-700':
)} !isAvailableForSale
> }
{value} )}
</button> >
); {value}
})} </button>
</dd> );
</dl> })}
</form> </dd>
</dl>
)); ));
} }

View File

@ -1,13 +1,19 @@
import clsx from 'clsx'; import clsx from 'clsx';
import type { FunctionComponent } from 'react';
const Prose = ({ html, className }: { html: string; className?: string }) => { interface TextProps {
html: string;
className?: string;
}
const Prose: FunctionComponent<TextProps> = ({ html, className }) => {
return ( return (
<div <div
className={clsx( className={clsx(
'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline prose-a:hover:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white', 'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
className className
)} )}
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html as string }}
/> />
); );
}; };

View File

@ -1,35 +0,0 @@
'use client';
import { useEffect } from 'react';
import { toast } from 'sonner';
export function WelcomeToast() {
useEffect(() => {
// ignore if screen height is too small
if (window.innerHeight < 650) return;
if (!document.cookie.includes('welcome-toast=2')) {
toast('🛍️ Welcome to Next.js Commerce!', {
id: 'welcome-toast',
duration: Infinity,
onDismiss: () => {
document.cookie = 'welcome-toast=2; max-age=31536000; path=/';
},
description: (
<>
This is a high-performance, SSR storefront powered by Shopify, Next.js, and Vercel.{' '}
<a
href="https://vercel.com/templates/next.js/nextjs-commerce"
className="text-blue-600 hover:underline"
target="_blank"
>
Deploy your own
</a>
.
</>
)
});
}
}, []);
return null;
}

View File

@ -22,8 +22,7 @@ export const sorting: SortFilterItem[] = [
export const TAGS = { export const TAGS = {
collections: 'collections', collections: 'collections',
products: 'products', products: 'products'
cart: 'cart'
}; };
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden'; export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';

View File

@ -1,16 +1,8 @@
import { import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
HIDDEN_PRODUCT_TAG,
SHOPIFY_GRAPHQL_API_ENDPOINT,
TAGS
} from 'lib/constants';
import { isShopifyError } from 'lib/type-guards'; import { isShopifyError } from 'lib/type-guards';
import { ensureStartsWith } from 'lib/utils'; import { ensureStartsWith } from 'lib/utils';
import { import { revalidateTag } from 'next/cache';
revalidateTag, import { headers } from 'next/headers';
unstable_cacheTag as cacheTag,
unstable_cacheLife as cacheLife
} from 'next/cache';
import { cookies, headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { import {
addToCartMutation, addToCartMutation,
@ -64,17 +56,19 @@ const domain = process.env.SHOPIFY_STORE_DOMAIN
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`; const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!; const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables<T> = T extends { variables: object } type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
? T['variables']
: never;
export async function shopifyFetch<T>({ export async function shopifyFetch<T>({
cache = 'force-cache',
headers, headers,
query, query,
tags,
variables variables
}: { }: {
cache?: RequestCache;
headers?: HeadersInit; headers?: HeadersInit;
query: string; query: string;
tags?: string[];
variables?: ExtractVariables<T>; variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> { }): Promise<{ status: number; body: T } | never> {
try { try {
@ -88,7 +82,9 @@ export async function shopifyFetch<T>({
body: JSON.stringify({ body: JSON.stringify({
...(query && { query }), ...(query && { query }),
...(variables && { variables }) ...(variables && { variables })
}) }),
cache,
...(tags && { next: { tags } })
}); });
const body = await result.json(); const body = await result.json();
@ -118,7 +114,7 @@ export async function shopifyFetch<T>({
} }
} }
const removeEdgesAndNodes = <T>(array: Connection<T>): T[] => { const removeEdgesAndNodes = (array: Connection<any>) => {
return array.edges.map((edge) => edge?.node); return array.edges.map((edge) => edge?.node);
}; };
@ -126,7 +122,7 @@ const reshapeCart = (cart: ShopifyCart): Cart => {
if (!cart.cost?.totalTaxAmount) { if (!cart.cost?.totalTaxAmount) {
cart.cost.totalTaxAmount = { cart.cost.totalTaxAmount = {
amount: '0.0', amount: '0.0',
currencyCode: cart.cost.totalAmount.currencyCode currencyCode: 'USD'
}; };
} }
@ -136,9 +132,7 @@ const reshapeCart = (cart: ShopifyCart): Cart => {
}; };
}; };
const reshapeCollection = ( const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
collection: ShopifyCollection
): Collection | undefined => {
if (!collection) { if (!collection) {
return undefined; return undefined;
} }
@ -169,7 +163,7 @@ const reshapeImages = (images: Connection<Image>, productTitle: string) => {
const flattened = removeEdgesAndNodes(images); const flattened = removeEdgesAndNodes(images);
return flattened.map((image) => { return flattened.map((image) => {
const filename = image.url.match(/.*\/(.*)\..*/)?.[1]; const filename = image.url.match(/.*\/(.*)\..*/)[1];
return { return {
...image, ...image,
altText: image.altText || `${productTitle} - ${filename}` altText: image.altText || `${productTitle} - ${filename}`
@ -177,14 +171,8 @@ const reshapeImages = (images: Connection<Image>, productTitle: string) => {
}); });
}; };
const reshapeProduct = ( const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
product: ShopifyProduct, if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
filterHiddenProducts: boolean = true
) => {
if (
!product ||
(filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))
) {
return undefined; return undefined;
} }
@ -215,64 +203,62 @@ const reshapeProducts = (products: ShopifyProduct[]) => {
export async function createCart(): Promise<Cart> { export async function createCart(): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({ const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation query: createCartMutation,
cache: 'no-store'
}); });
return reshapeCart(res.body.data.cartCreate.cart); return reshapeCart(res.body.data.cartCreate.cart);
} }
export async function addToCart( export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[] lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> { ): Promise<Cart> {
const cartId = (await cookies()).get('cartId')?.value!;
const res = await shopifyFetch<ShopifyAddToCartOperation>({ const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation, query: addToCartMutation,
variables: { variables: {
cartId, cartId,
lines lines
} },
cache: 'no-store'
}); });
return reshapeCart(res.body.data.cartLinesAdd.cart); return reshapeCart(res.body.data.cartLinesAdd.cart);
} }
export async function removeFromCart(lineIds: string[]): Promise<Cart> { export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const cartId = (await cookies()).get('cartId')?.value!;
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({ const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation, query: removeFromCartMutation,
variables: { variables: {
cartId, cartId,
lineIds lineIds
} },
cache: 'no-store'
}); });
return reshapeCart(res.body.data.cartLinesRemove.cart); return reshapeCart(res.body.data.cartLinesRemove.cart);
} }
export async function updateCart( export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[] lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> { ): Promise<Cart> {
const cartId = (await cookies()).get('cartId')?.value!;
const res = await shopifyFetch<ShopifyUpdateCartOperation>({ const res = await shopifyFetch<ShopifyUpdateCartOperation>({
query: editCartItemsMutation, query: editCartItemsMutation,
variables: { variables: {
cartId, cartId,
lines lines
} },
cache: 'no-store'
}); });
return reshapeCart(res.body.data.cartLinesUpdate.cart); return reshapeCart(res.body.data.cartLinesUpdate.cart);
} }
export async function getCart(): Promise<Cart | undefined> { export async function getCart(cartId: string): Promise<Cart | undefined> {
const cartId = (await cookies()).get('cartId')?.value;
if (!cartId) {
return undefined;
}
const res = await shopifyFetch<ShopifyCartOperation>({ const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery, query: getCartQuery,
variables: { cartId } variables: { cartId },
cache: 'no-store'
}); });
// Old carts becomes `null` when you checkout. // Old carts becomes `null` when you checkout.
@ -283,15 +269,10 @@ export async function getCart(): Promise<Cart | undefined> {
return reshapeCart(res.body.data.cart); return reshapeCart(res.body.data.cart);
} }
export async function getCollection( export async function getCollection(handle: string): Promise<Collection | undefined> {
handle: string
): Promise<Collection | undefined> {
'use cache';
cacheTag(TAGS.collections);
cacheLife('days');
const res = await shopifyFetch<ShopifyCollectionOperation>({ const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery, query: getCollectionQuery,
tags: [TAGS.collections],
variables: { variables: {
handle handle
} }
@ -309,12 +290,9 @@ export async function getCollectionProducts({
reverse?: boolean; reverse?: boolean;
sortKey?: string; sortKey?: string;
}): Promise<Product[]> { }): Promise<Product[]> {
'use cache';
cacheTag(TAGS.collections, TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({ const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
query: getCollectionProductsQuery, query: getCollectionProductsQuery,
tags: [TAGS.collections, TAGS.products],
variables: { variables: {
handle: collection, handle: collection,
reverse, reverse,
@ -327,18 +305,13 @@ export async function getCollectionProducts({
return []; return [];
} }
return reshapeProducts( return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
removeEdgesAndNodes(res.body.data.collection.products)
);
} }
export async function getCollections(): Promise<Collection[]> { export async function getCollections(): Promise<Collection[]> {
'use cache';
cacheTag(TAGS.collections);
cacheLife('days');
const res = await shopifyFetch<ShopifyCollectionsOperation>({ const res = await shopifyFetch<ShopifyCollectionsOperation>({
query: getCollectionsQuery query: getCollectionsQuery,
tags: [TAGS.collections]
}); });
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections); const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [ const collections = [
@ -364,12 +337,9 @@ export async function getCollections(): Promise<Collection[]> {
} }
export async function getMenu(handle: string): Promise<Menu[]> { export async function getMenu(handle: string): Promise<Menu[]> {
'use cache';
cacheTag(TAGS.collections);
cacheLife('days');
const res = await shopifyFetch<ShopifyMenuOperation>({ const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery, query: getMenuQuery,
tags: [TAGS.collections],
variables: { variables: {
handle handle
} }
@ -378,10 +348,7 @@ export async function getMenu(handle: string): Promise<Menu[]> {
return ( return (
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({ res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
title: item.title, title: item.title,
path: item.url path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '')
.replace(domain, '')
.replace('/collections', '/search')
.replace('/pages', '')
})) || [] })) || []
); );
} }
@ -404,12 +371,9 @@ export async function getPages(): Promise<Page[]> {
} }
export async function getProduct(handle: string): Promise<Product | undefined> { export async function getProduct(handle: string): Promise<Product | undefined> {
'use cache';
cacheTag(TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyProductOperation>({ const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery, query: getProductQuery,
tags: [TAGS.products],
variables: { variables: {
handle handle
} }
@ -418,15 +382,10 @@ export async function getProduct(handle: string): Promise<Product | undefined> {
return reshapeProduct(res.body.data.product, false); return reshapeProduct(res.body.data.product, false);
} }
export async function getProductRecommendations( export async function getProductRecommendations(productId: string): Promise<Product[]> {
productId: string
): Promise<Product[]> {
'use cache';
cacheTag(TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({ const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery, query: getProductRecommendationsQuery,
tags: [TAGS.products],
variables: { variables: {
productId productId
} }
@ -444,12 +403,9 @@ export async function getProducts({
reverse?: boolean; reverse?: boolean;
sortKey?: string; sortKey?: string;
}): Promise<Product[]> { }): Promise<Product[]> {
'use cache';
cacheTag(TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyProductsOperation>({ const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery, query: getProductsQuery,
tags: [TAGS.products],
variables: { variables: {
query, query,
reverse, reverse,
@ -464,24 +420,16 @@ export async function getProducts({
export async function revalidate(req: NextRequest): Promise<NextResponse> { export async function revalidate(req: NextRequest): Promise<NextResponse> {
// We always need to respond with a 200 status code to Shopify, // We always need to respond with a 200 status code to Shopify,
// otherwise it will continue to retry the request. // otherwise it will continue to retry the request.
const collectionWebhooks = [ const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
'collections/create', const productWebhooks = ['products/create', 'products/delete', 'products/update'];
'collections/delete', const topic = headers().get('x-shopify-topic') || 'unknown';
'collections/update'
];
const productWebhooks = [
'products/create',
'products/delete',
'products/update'
];
const topic = (await headers()).get('x-shopify-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret'); const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic); const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic); const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) { if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.'); console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 401 }); return NextResponse.json({ status: 200 });
} }
if (!isCollectionUpdate && !isProductUpdate) { if (!isCollectionUpdate && !isProductUpdate) {

View File

@ -12,15 +12,8 @@ export type Cart = Omit<ShopifyCart, 'lines'> & {
lines: CartItem[]; lines: CartItem[];
}; };
export type CartProduct = {
id: string;
handle: string;
title: string;
featuredImage: Image;
};
export type CartItem = { export type CartItem = {
id: string | undefined; id: string;
quantity: number; quantity: number;
cost: { cost: {
totalAmount: Money; totalAmount: Money;
@ -32,7 +25,7 @@ export type CartItem = {
name: string; name: string;
value: string; value: string;
}[]; }[];
product: CartProduct; product: Product;
}; };
}; };
@ -96,7 +89,7 @@ export type SEO = {
}; };
export type ShopifyCart = { export type ShopifyCart = {
id: string | undefined; id: string;
checkoutUrl: string; checkoutUrl: string;
cost: { cost: {
subtotalAmount: Money; subtotalAmount: Money;

View File

@ -1,13 +1,6 @@
import { ReadonlyURLSearchParams } from 'next/navigation'; import { ReadonlyURLSearchParams } from 'next/navigation';
export const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: 'http://localhost:3000';
export const createUrl = (
pathname: string,
params: URLSearchParams | ReadonlyURLSearchParams
) => {
const paramsString = params.toString(); const paramsString = params.toString();
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`; const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
@ -15,37 +8,4 @@ export const createUrl = (
}; };
export const ensureStartsWith = (stringToCheck: string, startsWith: string) => export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
stringToCheck.startsWith(startsWith) stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
? stringToCheck
: `${startsWith}${stringToCheck}`;
export const validateEnvironmentVariables = () => {
const requiredEnvironmentVariables = [
'SHOPIFY_STORE_DOMAIN',
'SHOPIFY_STOREFRONT_ACCESS_TOKEN'
];
const missingEnvironmentVariables = [] as string[];
requiredEnvironmentVariables.forEach((envVar) => {
if (!process.env[envVar]) {
missingEnvironmentVariables.push(envVar);
}
});
if (missingEnvironmentVariables.length) {
throw new Error(
`The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
'\n'
)}\n`
);
}
if (
process.env.SHOPIFY_STORE_DOMAIN?.includes('[') ||
process.env.SHOPIFY_STORE_DOMAIN?.includes(']')
) {
throw new Error(
'Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
);
}
};

View File

@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2025 Vercel, Inc. Copyright (c) 2023 Vercel, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

29
next.config.js Normal file
View File

@ -0,0 +1,29 @@
/** @type {import('next').NextConfig} */
module.exports = {
eslint: {
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
ignoreDuringBuilds: true
},
experimental: {
serverActions: true
},
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
}
]
},
async redirects() {
return [
{
source: '/password',
destination: '/',
permanent: true
}
];
}
};

View File

@ -1,19 +0,0 @@
export default {
experimental: {
ppr: true,
inlineCss: true,
useCache: true,
reactOwnerStack: true,
newDevOverlay: true
},
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
}
]
}
};

View File

@ -1,34 +1,51 @@
{ {
"private": true, "private": true,
"packageManager": "pnpm@8.2.0",
"engines": {
"node": ">=18",
"pnpm": ">=7"
},
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint",
"lint-staged": "lint-staged",
"prettier": "prettier --write --ignore-unknown .", "prettier": "prettier --write --ignore-unknown .",
"prettier:check": "prettier --check --ignore-unknown .", "prettier:check": "prettier --check --ignore-unknown .",
"test": "pnpm prettier:check" "test": "pnpm lint && pnpm prettier:check"
},
"git": {
"pre-commit": "lint-staged"
},
"lint-staged": {
"*": "prettier --write --ignore-unknown"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0", "@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.0.18",
"clsx": "^2.1.1", "clsx": "^2.0.0",
"geist": "^1.3.1", "next": "13.4.13-canary.15",
"next": "15.2.0-canary.67", "react": "18.2.0",
"react": "19.0.0", "react-dom": "18.2.0"
"react-dom": "19.0.0",
"sonner": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "^4.0.8", "@tailwindcss/typography": "^0.5.9",
"@tailwindcss/typography": "^0.5.16", "@types/node": "20.4.4",
"@types/node": "22.13.4", "@types/react": "18.2.16",
"@types/react": "19.0.10", "@types/react-dom": "18.2.7",
"@types/react-dom": "19.0.4", "@vercel/git-hooks": "^1.0.0",
"postcss": "^8.5.3", "autoprefixer": "^10.4.14",
"prettier": "3.5.1", "eslint": "^8.45.0",
"prettier-plugin-tailwindcss": "^0.6.11", "eslint-config-next": "^13.4.12",
"tailwindcss": "^4.0.8", "eslint-config-prettier": "^8.8.0",
"typescript": "5.7.3" "eslint-plugin-unicorn": "^48.0.0",
"lint-staged": "^13.2.3",
"postcss": "^8.4.27",
"prettier": "3.0.1",
"prettier-plugin-tailwindcss": "^0.4.1",
"tailwindcss": "^3.3.3",
"typescript": "5.1.6"
} }
} }

4277
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@ -1,6 +0,0 @@
/** @type {import('postcss-load-config').Config} */
export default {
plugins: {
'@tailwindcss/postcss': {},
}
};

10
prettier.config.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
singleQuote: true,
arrowParens: 'always',
trailingComma: 'none',
printWidth: 100,
tabWidth: 2,
// pnpm doesn't support plugin autoloading
// https://github.com/tailwindlabs/prettier-plugin-tailwindcss#installation
plugins: [require('prettier-plugin-tailwindcss')]
};

54
tailwind.config.js Normal file
View File

@ -0,0 +1,54 @@
const plugin = require('tailwindcss/plugin');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)']
},
keyframes: {
fadeIn: {
from: { opacity: 0 },
to: { opacity: 1 }
},
marquee: {
'0%': { transform: 'translateX(0%)' },
'100%': { transform: 'translateX(-100%)' }
},
blink: {
'0%': { opacity: 0.2 },
'20%': { opacity: 1 },
'100% ': { opacity: 0.2 }
}
},
animation: {
fadeIn: 'fadeIn .3s ease-in-out',
carousel: 'marquee 60s linear infinite',
blink: 'blink 1.4s both infinite'
}
}
},
future: {
hoverOnlyWhenSupported: true
},
plugins: [
require('@tailwindcss/container-queries'),
require('@tailwindcss/typography'),
plugin(({ matchUtilities, theme }) => {
matchUtilities(
{
'animation-delay': (value) => {
return {
'animation-delay': value
};
}
},
{
values: theme('transitionDelay')
}
);
})
]
};

View File

@ -1,8 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2015", "target": "es5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"downlevelIteration": true,
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,