4
0
forked from crowetic/commerce

Compare commits

..

4 Commits

Author SHA1 Message Date
Zack Tanner
f29a16de5c add layout keying 2024-08-01 10:24:50 -07:00
Zack Tanner
6bfd0b1bd9 remove full prefetch on search page 2024-08-01 10:14:47 -07:00
Janka Uryga
6e85b80158 update next to https://github.com/vercel/next.js/pull/68340/ 2024-07-31 16:57:21 +02:00
Lee Robinson
8c5f8d8d2a next/form 2024-07-30 17:19:38 -05:00
46 changed files with 1580 additions and 1129 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=""

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

View File

@ -1,8 +1,8 @@
[![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 high-perfomance, server-rendered Next.js App Router ecommerce application.
This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more. This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more.
@ -19,15 +19,12 @@ Vercel is happy to partner and work with any commerce provider to help them get
- 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/)) - [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/)) - [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/)) - [Wix](https://github.com/wix/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). > 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).
@ -36,13 +33,9 @@ Vercel is happy to partner and work with any commerce provider to help them get
Integrations enable upgraded or additional functionality for Next.js Commerce Integrations enable upgraded or additional functionality for Next.js Commerce
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/)) - [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. - 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. - 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
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary. You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.

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,11 @@ 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 async function generateMetadata({
params: Promise<{ page: string }>; 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 +24,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 +32,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,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

@ -3,12 +3,18 @@ import { Navbar } from 'components/layout/navbar';
import { WelcomeToast } from 'components/welcome-toast'; import { WelcomeToast } from 'components/welcome-toast';
import { GeistSans } from 'geist/font/sans'; import { GeistSans } from 'geist/font/sans';
import { getCart } from 'lib/shopify'; import { getCart } from 'lib/shopify';
import { ensureStartsWith } from 'lib/utils';
import { cookies } from 'next/headers';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Toaster } from 'sonner'; 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,16 +25,21 @@ 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({ export default async function RootLayout({ children }: { children: ReactNode }) {
children const cartId = cookies().get('cartId')?.value;
}: {
children: ReactNode;
}) {
// Don't await the fetch, pass the Promise to the context provider // Don't await the fetch, pass the Promise to the context provider
const cart = getCart(); const cart = getCart(cartId);
return ( return (
<html lang="en" className={GeistSans.variable}> <html lang="en" className={GeistSans.variable}>

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

@ -3,8 +3,7 @@ import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer'; import Footer from 'components/layout/footer';
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'
} }

View File

@ -12,10 +12,11 @@ import { Image } from 'lib/shopify/types';
import Link from 'next/link'; import Link from 'next/link';
import { Suspense } from 'react'; import { Suspense } from 'react';
export async function generateMetadata(props: { export async function generateMetadata({
params: Promise<{ handle: string }>; 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 +50,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();
@ -80,7 +80,7 @@ export default async function ProductPage(props: { params: Promise<{ handle: str
__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 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black">
<div className="h-full w-full basis-full lg:basis-4/6"> <div className="h-full w-full basis-full lg:basis-4/6">
<Suspense <Suspense

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,11 @@ 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 async function generateMetadata({
params: Promise<{ collection: string }>; 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 +22,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,9 +1,7 @@
'use client'; 'use client';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Fragment } from 'react'; import { Fragment } from 'react';
// Ensure children are re-rendered when the search query changes
export default function ChildrenWrapper({ children }: { children: React.ReactNode }) { export default function ChildrenWrapper({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
return <Fragment key={searchParams.get('q')}>{children}</Fragment>; return <Fragment key={searchParams.get('q')}>{children}</Fragment>;

View File

@ -3,23 +3,16 @@ 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 ChildrenWrapper from './children-wrapper';
import { Suspense } from 'react';
export default function SearchLayout({ export default function SearchLayout({ children }: { children: React.ReactNode }) {
children
}: {
children: React.ReactNode;
}) {
return ( return (
<> <>
<div className="mx-auto flex max-w-(--breakpoint-2xl) flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white"> <div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white">
<div className="order-first w-full flex-none md:max-w-[125px]"> <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">
<Suspense fallback={null}> <ChildrenWrapper>{children}</ChildrenWrapper>
<ChildrenWrapper>{children}</ChildrenWrapper>
</Suspense>
</div> </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" />

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-800" />
return ( );
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" /> })}
); </Grid>
})}
</Grid>
</>
); );
} }

View File

@ -8,10 +8,11 @@ export const metadata = {
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,5 @@
import { getCollections, getPages, getProducts } from 'lib/shopify'; import { getCollections, getPages, getProducts } from 'lib/shopify';
import { baseUrl, validateEnvironmentVariables } from 'lib/utils'; import { validateEnvironmentVariables } from 'lib/utils';
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
type Route = { type Route = {
@ -7,6 +7,10 @@ type Route = {
lastModified: string; lastModified: string;
}; };
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
@ -41,9 +45,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

@ -1,27 +1,20 @@
'use server'; 'use server';
import { TAGS } from 'lib/constants'; import { TAGS } from 'lib/constants';
import { import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
addToCart,
createCart,
getCart,
removeFromCart,
updateCart
} from 'lib/shopify';
import { revalidateTag } from 'next/cache'; import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export async function addItem( export async function addItem(prevState: any, selectedVariantId: string | undefined) {
prevState: any, let cartId = cookies().get('cartId')?.value;
selectedVariantId: string | undefined
) { if (!cartId || !selectedVariantId) {
if (!selectedVariantId) {
return 'Error adding item to cart'; return 'Error adding item to cart';
} }
try { try {
await addToCart([{ merchandiseId: selectedVariantId, quantity: 1 }]); await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
revalidateTag(TAGS.cart); revalidateTag(TAGS.cart);
} catch (e) { } catch (e) {
return 'Error adding item to cart'; return 'Error adding item to cart';
@ -29,19 +22,23 @@ export async function addItem(
} }
export async function removeItem(prevState: any, merchandiseId: string) { export async function removeItem(prevState: any, merchandiseId: string) {
let cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
try { try {
const cart = await getCart(); const cart = await getCart(cartId);
if (!cart) { if (!cart) {
return 'Error fetching cart'; return 'Error fetching cart';
} }
const lineItem = cart.lines.find( const lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);
(line) => line.merchandise.id === merchandiseId
);
if (lineItem && lineItem.id) { if (lineItem && lineItem.id) {
await removeFromCart([lineItem.id]); await removeFromCart(cartId, [lineItem.id]);
revalidateTag(TAGS.cart); revalidateTag(TAGS.cart);
} else { } else {
return 'Item not found in cart'; return 'Item not found in cart';
@ -58,24 +55,28 @@ export async function updateItemQuantity(
quantity: number; quantity: number;
} }
) { ) {
let cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
const { merchandiseId, quantity } = payload; const { merchandiseId, quantity } = payload;
try { try {
const cart = await getCart(); const cart = await getCart(cartId);
if (!cart) { if (!cart) {
return 'Error fetching cart'; return 'Error fetching cart';
} }
const lineItem = cart.lines.find( const lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);
(line) => line.merchandise.id === merchandiseId
);
if (lineItem && lineItem.id) { if (lineItem && lineItem.id) {
if (quantity === 0) { if (quantity === 0) {
await removeFromCart([lineItem.id]); await removeFromCart(cartId, [lineItem.id]);
} else { } else {
await updateCart([ await updateCart(cartId, [
{ {
id: lineItem.id, id: lineItem.id,
merchandiseId, merchandiseId,
@ -85,7 +86,7 @@ export async function updateItemQuantity(
} }
} else if (quantity > 0) { } else if (quantity > 0) {
// If the item doesn't exist in the cart and quantity > 0, add it // If the item doesn't exist in the cart and quantity > 0, add it
await addToCart([{ merchandiseId, quantity }]); await addToCart(cartId, [{ merchandiseId, quantity }]);
} }
revalidateTag(TAGS.cart); revalidateTag(TAGS.cart);
@ -96,11 +97,22 @@ export async function updateItemQuantity(
} }
export async function redirectToCheckout() { export async function redirectToCheckout() {
let cart = await getCart(); let cartId = cookies().get('cartId')?.value;
redirect(cart!.checkoutUrl);
if (!cartId) {
return 'Missing cart ID';
}
let cart = await getCart(cartId);
if (!cart) {
return 'Error fetching cart';
}
redirect(cart.checkoutUrl);
} }
export async function createCartAndSetCookie() { export async function createCartAndSetCookie() {
let cart = await createCart(); let cart = await createCart();
(await cookies()).set('cartId', cart.id!); cookies().set('cartId', cart.id!);
} }

View File

@ -5,7 +5,7 @@ import clsx from 'clsx';
import { addItem } from 'components/cart/actions'; import { addItem } from 'components/cart/actions';
import { useProduct } from 'components/product/product-context'; import { useProduct } from 'components/product/product-context';
import { Product, ProductVariant } from 'lib/shopify/types'; import { Product, ProductVariant } from 'lib/shopify/types';
import { useActionState } from 'react'; import { useFormState } from 'react-dom';
import { useCart } from './cart-context'; import { useCart } from './cart-context';
function SubmitButton({ function SubmitButton({
@ -27,6 +27,7 @@ function SubmitButton({
); );
} }
console.log(selectedVariantId);
if (!selectedVariantId) { if (!selectedVariantId) {
return ( return (
<button <button
@ -61,31 +62,24 @@ export function AddToCart({ product }: { product: Product }) {
const { variants, availableForSale } = product; const { variants, availableForSale } = product;
const { addCartItem } = useCart(); const { addCartItem } = useCart();
const { state } = useProduct(); const { state } = useProduct();
const [message, formAction] = useActionState(addItem, null); const [message, formAction] = useFormState(addItem, null);
const variant = variants.find((variant: ProductVariant) => const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every( variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
(option) => option.value === state[option.name.toLowerCase()]
)
); );
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined; const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const selectedVariantId = variant?.id || defaultVariantId; const selectedVariantId = variant?.id || defaultVariantId;
const addItemAction = formAction.bind(null, selectedVariantId); const actionWithVariant = formAction.bind(null, selectedVariantId);
const finalVariant = variants.find( const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;
(variant) => variant.id === selectedVariantId
)!;
return ( return (
<form <form
action={async () => { action={async () => {
addCartItem(finalVariant, product); addCartItem(finalVariant, product);
addItemAction(); await actionWithVariant();
}} }}
> >
<SubmitButton <SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
availableForSale={availableForSale}
selectedVariantId={selectedVariantId}
/>
<p aria-live="polite" className="sr-only" role="status"> <p aria-live="polite" className="sr-only" role="status">
{message} {message}
</p> </p>

View File

@ -1,33 +1,18 @@
'use client'; 'use client';
import type { import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types';
Cart, import React, { createContext, use, useContext, useMemo, useOptimistic } from 'react';
CartItem,
Product,
ProductVariant
} from 'lib/shopify/types';
import React, {
createContext,
use,
useContext,
useMemo,
useOptimistic
} from 'react';
type UpdateType = 'plus' | 'minus' | 'delete'; type UpdateType = 'plus' | 'minus' | 'delete';
type CartAction = type CartAction =
| { | { type: 'UPDATE_ITEM'; payload: { merchandiseId: string; updateType: UpdateType } }
type: 'UPDATE_ITEM'; | { type: 'ADD_ITEM'; payload: { variant: ProductVariant; product: Product } };
payload: { merchandiseId: string; updateType: UpdateType };
}
| {
type: 'ADD_ITEM';
payload: { variant: ProductVariant; product: Product };
};
type CartContextType = { type CartContextType = {
cartPromise: Promise<Cart | undefined>; cart: Cart | undefined;
updateCartItem: (merchandiseId: string, updateType: UpdateType) => void;
addCartItem: (variant: ProductVariant, product: Product) => void;
}; };
const CartContext = createContext<CartContextType | undefined>(undefined); const CartContext = createContext<CartContextType | undefined>(undefined);
@ -36,21 +21,14 @@ function calculateItemCost(quantity: number, price: string): string {
return (Number(price) * quantity).toString(); return (Number(price) * quantity).toString();
} }
function updateCartItem( function updateCartItem(item: CartItem, updateType: UpdateType): CartItem | null {
item: CartItem,
updateType: UpdateType
): CartItem | null {
if (updateType === 'delete') return null; if (updateType === 'delete') return null;
const newQuantity = const newQuantity = updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
if (newQuantity === 0) return null; if (newQuantity === 0) return null;
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity; const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
const newTotalAmount = calculateItemCost( const newTotalAmount = calculateItemCost(newQuantity, singleItemAmount.toString());
newQuantity,
singleItemAmount.toString()
);
return { return {
...item, ...item,
@ -96,14 +74,9 @@ function createOrUpdateCartItem(
}; };
} }
function updateCartTotals( function updateCartTotals(lines: CartItem[]): Pick<Cart, 'totalQuantity' | 'cost'> {
lines: CartItem[]
): Pick<Cart, 'totalQuantity' | 'cost'> {
const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0); const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = lines.reduce( const totalAmount = lines.reduce((sum, item) => sum + Number(item.cost.totalAmount.amount), 0);
(sum, item) => sum + Number(item.cost.totalAmount.amount),
0
);
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD'; const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD';
return { return {
@ -138,9 +111,7 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
const { merchandiseId, updateType } = action.payload; const { merchandiseId, updateType } = action.payload;
const updatedLines = currentCart.lines const updatedLines = currentCart.lines
.map((item) => .map((item) =>
item.merchandise.id === merchandiseId item.merchandise.id === merchandiseId ? updateCartItem(item, updateType) : item
? updateCartItem(item, updateType)
: item
) )
.filter(Boolean) as CartItem[]; .filter(Boolean) as CartItem[];
@ -156,34 +127,18 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
}; };
} }
return { return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
...currentCart,
...updateCartTotals(updatedLines),
lines: updatedLines
};
} }
case 'ADD_ITEM': { case 'ADD_ITEM': {
const { variant, product } = action.payload; const { variant, product } = action.payload;
const existingItem = currentCart.lines.find( const existingItem = currentCart.lines.find((item) => item.merchandise.id === variant.id);
(item) => item.merchandise.id === variant.id const updatedItem = createOrUpdateCartItem(existingItem, variant, product);
);
const updatedItem = createOrUpdateCartItem(
existingItem,
variant,
product
);
const updatedLines = existingItem const updatedLines = existingItem
? currentCart.lines.map((item) => ? currentCart.lines.map((item) => (item.merchandise.id === variant.id ? updatedItem : item))
item.merchandise.id === variant.id ? updatedItem : item
)
: [...currentCart.lines, updatedItem]; : [...currentCart.lines, updatedItem];
return { return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
...currentCart,
...updateCartTotals(updatedLines),
lines: updatedLines
};
} }
default: default:
return currentCart; return currentCart;
@ -197,37 +152,18 @@ export function CartProvider({
children: React.ReactNode; children: React.ReactNode;
cartPromise: Promise<Cart | undefined>; cartPromise: Promise<Cart | undefined>;
}) { }) {
return ( const initialCart = use(cartPromise);
<CartContext.Provider value={{ cartPromise }}> const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer);
{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) => { const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
updateOptimisticCart({ updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType } });
type: 'UPDATE_ITEM',
payload: { merchandiseId, updateType }
});
}; };
const addCartItem = (variant: ProductVariant, product: Product) => { const addCartItem = (variant: ProductVariant, product: Product) => {
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } }); updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
}; };
return useMemo( const value = useMemo(
() => ({ () => ({
cart: optimisticCart, cart: optimisticCart,
updateCartItem, updateCartItem,
@ -235,4 +171,14 @@ export function useCart() {
}), }),
[optimisticCart] [optimisticCart]
); );
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
} }

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

@ -3,7 +3,7 @@
import { XMarkIcon } from '@heroicons/react/24/outline'; import { XMarkIcon } from '@heroicons/react/24/outline';
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 { useFormState } from 'react-dom';
export function DeleteItemButton({ export function DeleteItemButton({
item, item,
@ -12,15 +12,15 @@ export function DeleteItemButton({
item: CartItem; item: CartItem;
optimisticUpdate: any; optimisticUpdate: any;
}) { }) {
const [message, formAction] = useActionState(removeItem, null); const [message, formAction] = useFormState(removeItem, null);
const merchandiseId = item.merchandise.id; const merchandiseId = item.merchandise.id;
const removeItemAction = formAction.bind(null, merchandiseId); const actionWithVariant = formAction.bind(null, merchandiseId);
return ( return (
<form <form
action={async () => { action={async () => {
optimisticUpdate(merchandiseId, 'delete'); optimisticUpdate(merchandiseId, 'delete');
removeItemAction(); await actionWithVariant();
}} }}
> >
<button <button

View File

@ -4,15 +4,13 @@ import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions'; import { updateItemQuantity } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from 'lib/shopify/types';
import { useActionState } from 'react'; import { useFormState } from 'react-dom';
function SubmitButton({ type }: { type: 'plus' | 'minus' }) { function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
return ( return (
<button <button
type="submit" type="submit"
aria-label={ aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'
}
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 p-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
{ {
@ -38,18 +36,18 @@ export function EditItemQuantityButton({
type: 'plus' | 'minus'; type: 'plus' | 'minus';
optimisticUpdate: any; optimisticUpdate: any;
}) { }) {
const [message, formAction] = useActionState(updateItemQuantity, null); const [message, formAction] = useFormState(updateItemQuantity, null);
const payload = { const payload = {
merchandiseId: item.merchandise.id, merchandiseId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1 quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
}; };
const updateItemQuantityAction = formAction.bind(null, payload); const actionWithVariant = formAction.bind(null, payload);
return ( return (
<form <form
action={async () => { action={async () => {
optimisticUpdate(payload.merchandiseId, type); optimisticUpdate(payload.merchandiseId, type);
updateItemQuantityAction(); await actionWithVariant();
}} }}
> >
<SubmitButton type={type} /> <SubmitButton type={type} />

View File

@ -1,8 +1,7 @@
'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 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';
@ -13,6 +12,7 @@ import { Fragment, useEffect, useRef, useState } from 'react';
import { useFormStatus } from 'react-dom'; import { useFormStatus } from 'react-dom';
import { createCartAndSetCookie, redirectToCheckout } from './actions'; import { createCartAndSetCookie, redirectToCheckout } from './actions';
import { useCart } from './cart-context'; import { useCart } from './cart-context';
import CloseCart from './close-cart';
import { DeleteItemButton } from './delete-item-button'; import { DeleteItemButton } from './delete-item-button';
import { EditItemQuantityButton } from './edit-item-quantity-button'; import { EditItemQuantityButton } from './edit-item-quantity-button';
import OpenCart from './open-cart'; import OpenCart from './open-cart';
@ -85,31 +85,23 @@ 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
.sort((a, b) => .sort((a, b) =>
a.merchandise.product.title.localeCompare( a.merchandise.product.title.localeCompare(b.merchandise.product.title)
b.merchandise.product.title
)
) )
.map((item, i) => { .map((item, i) => {
const merchandiseSearchParams = const merchandiseSearchParams = {} as 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}`,
@ -123,10 +115,7 @@ export default function CartModal() {
> >
<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 -ml-1 -mt-2">
<DeleteItemButton <DeleteItemButton item={item} optimisticUpdate={updateCartItem} />
item={item}
optimisticUpdate={updateCartItem}
/>
</div> </div>
<div className="flex flex-row"> <div className="flex flex-row">
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800"> <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">
@ -135,13 +124,10 @@ export default function CartModal() {
width={64} width={64}
height={64} height={64}
alt={ alt={
item.merchandise.product.featuredImage item.merchandise.product.featuredImage.altText ||
.altText ||
item.merchandise.product.title item.merchandise.product.title
} }
src={ src={item.merchandise.product.featuredImage.url}
item.merchandise.product.featuredImage.url
}
/> />
</div> </div>
<Link <Link
@ -153,8 +139,7 @@ export default function CartModal() {
<span className="leading-tight"> <span className="leading-tight">
{item.merchandise.product.title} {item.merchandise.product.title}
</span> </span>
{item.merchandise.title !== {item.merchandise.title !== DEFAULT_OPTION ? (
DEFAULT_OPTION ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-500 dark:text-neutral-400">
{item.merchandise.title} {item.merchandise.title}
</p> </p>
@ -166,9 +151,7 @@ export default function CartModal() {
<Price <Price
className="flex justify-end space-y-2 text-right text-sm" className="flex justify-end space-y-2 text-right text-sm"
amount={item.cost.totalAmount.amount} amount={item.cost.totalAmount.amount}
currencyCode={ currencyCode={item.cost.totalAmount.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"> <div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
<EditItemQuantityButton <EditItemQuantityButton
@ -177,9 +160,7 @@ export default function CartModal() {
optimisticUpdate={updateCartItem} optimisticUpdate={updateCartItem}
/> />
<p className="w-6 text-center"> <p className="w-6 text-center">
<span className="w-full text-sm"> <span className="w-full text-sm">{item.quantity}</span>
{item.quantity}
</span>
</p> </p>
<EditItemQuantityButton <EditItemQuantityButton
item={item} item={item}
@ -228,19 +209,6 @@ 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() { function CheckoutButton() {
const { pending } = useFormStatus(); const { pending } = useFormStatus();

View File

@ -15,7 +15,7 @@ export default function OpenCart({
/> />
{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

@ -52,7 +52,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 lg:max-h-[calc(100vh-200px)]">
<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

@ -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

@ -10,7 +10,7 @@ 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 || '';

View File

@ -37,7 +37,7 @@ export async function Navbar() {
<li key={item.title}> <li key={item.title}>
<Link <Link
href={item.path} href={item.path}
prefetch={true} prefetch={item.path === '/search' ? undefined : 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}

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,15 +1,11 @@
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
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

@ -32,7 +32,7 @@ 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 <button
formAction={() => { formAction={() => {
const newState = updateImage(previousImageIndex.toString()); const newState = updateImage(previousImageIndex.toString());
@ -60,7 +60,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
</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;

View File

@ -77,7 +77,7 @@ export function VariantSelector({
'cursor-default ring-2 ring-blue-600': isActive, 'cursor-default ring-2 ring-blue-600': isActive,
'ring-1 ring-transparent transition duration-300 ease-in-out hover:ring-blue-600': 'ring-1 ring-transparent transition duration-300 ease-in-out hover:ring-blue-600':
!isActive && 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 dark:before:bg-neutral-700': '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 !isAvailableForSale
} }
)} )}

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,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();
@ -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;
} }
@ -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,66 @@ 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 | undefined): Promise<Cart | undefined> {
const cartId = (await cookies()).get('cartId')?.value;
if (!cartId) { if (!cartId) {
return undefined; return undefined;
} }
const res = await shopifyFetch<ShopifyCartOperation>({ const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery, query: getCartQuery,
variables: { cartId } variables: { cartId },
tags: [TAGS.cart]
}); });
// Old carts becomes `null` when you checkout. // Old carts becomes `null` when you checkout.
@ -283,15 +273,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 +294,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 +309,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 +341,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 +352,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', '')
})) || [] })) || []
); );
} }
@ -389,6 +360,7 @@ export async function getMenu(handle: string): Promise<Menu[]> {
export async function getPage(handle: string): Promise<Page> { export async function getPage(handle: string): Promise<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({ const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery, query: getPageQuery,
cache: 'no-store',
variables: { handle } variables: { handle }
}); });
@ -397,19 +369,17 @@ export async function getPage(handle: string): Promise<Page> {
export async function getPages(): Promise<Page[]> { export async function getPages(): Promise<Page[]> {
const res = await shopifyFetch<ShopifyPagesOperation>({ const res = await shopifyFetch<ShopifyPagesOperation>({
query: getPagesQuery query: getPagesQuery,
cache: 'no-store'
}); });
return removeEdgesAndNodes(res.body.data.pages); return removeEdgesAndNodes(res.body.data.pages);
} }
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 +388,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 +409,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 +426,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

@ -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,15 +8,10 @@ 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 = () => { export const validateEnvironmentVariables = () => {
const requiredEnvironmentVariables = [ const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN'];
'SHOPIFY_STORE_DOMAIN',
'SHOPIFY_STOREFRONT_ACCESS_TOKEN'
];
const missingEnvironmentVariables = [] as string[]; const missingEnvironmentVariables = [] as string[];
requiredEnvironmentVariables.forEach((envVar) => { requiredEnvironmentVariables.forEach((envVar) => {

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

View File

@ -1,11 +1,5 @@
export default { /** @type {import('next').NextConfig} */
experimental: { module.exports = {
ppr: true,
inlineCss: true,
useCache: true,
reactOwnerStack: true,
newDevOverlay: true
},
images: { images: {
formats: ['image/avif', 'image/webp'], formats: ['image/avif', 'image/webp'],
remotePatterns: [ remotePatterns: [

View File

@ -1,7 +1,11 @@
{ {
"private": true, "private": true,
"engines": {
"node": ">=20",
"pnpm": ">=9"
},
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbo",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"prettier": "prettier --write --ignore-unknown .", "prettier": "prettier --write --ignore-unknown .",
@ -9,26 +13,26 @@
"test": "pnpm prettier:check" "test": "pnpm prettier:check"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.1.2",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.1.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"geist": "^1.3.1", "geist": "^1.3.1",
"next": "15.2.0-canary.67", "next": "https://vercel-packages.vercel.app/next/prs/68340/next",
"react": "19.0.0", "react": "19.0.0-rc-3208e73e-20240730",
"react-dom": "19.0.0", "react-dom": "19.0.0-rc-3208e73e-20240730",
"sonner": "^2.0.1" "sonner": "^1.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "^4.0.8", "@tailwindcss/typography": "^0.5.13",
"@tailwindcss/typography": "^0.5.16", "@types/node": "20.14.12",
"@types/node": "22.13.4", "@types/react": "18.3.3",
"@types/react": "19.0.10", "@types/react-dom": "18.3.0",
"@types/react-dom": "19.0.4", "autoprefixer": "^10.4.19",
"postcss": "^8.5.3", "postcss": "^8.4.39",
"prettier": "3.5.1", "prettier": "3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^4.0.8", "tailwindcss": "^3.4.6",
"typescript": "5.7.3" "typescript": "5.5.4"
} }
} }

1899
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': {},
}
};

9
prettier.config.js Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('prettier').Config} */
module.exports = {
singleQuote: true,
arrowParens: 'always',
trailingComma: 'none',
printWidth: 100,
tabWidth: 2,
plugins: ['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-geist-sans)']
},
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')
}
);
})
]
};