update format

This commit is contained in:
Rob 2025-07-06 23:37:24 -05:00
parent fa1306916c
commit 763c40600c
66 changed files with 1598 additions and 1105 deletions

View File

@ -1,4 +1,4 @@
import Footer from 'components/layout/footer'; import Footer from "components/layout/footer";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (

View File

@ -1,5 +1,5 @@
import OpengraphImage from 'components/opengraph-image'; import OpengraphImage from "components/opengraph-image";
import { getPage } from 'lib/shopify'; import { getPage } from "lib/shopify";
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);

View File

@ -1,8 +1,8 @@
import type { Metadata } from 'next'; import type { Metadata } from "next";
import Prose from 'components/prose'; 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(props: {
params: Promise<{ page: string }>; params: Promise<{ page: string }>;
@ -18,12 +18,14 @@ export async function generateMetadata(props: {
openGraph: { openGraph: {
publishedTime: page.createdAt, publishedTime: page.createdAt,
modifiedTime: page.updatedAt, modifiedTime: page.updatedAt,
type: 'article' type: "article",
} },
}; };
} }
export default async function Page(props: { params: Promise<{ page: string }> }) { export default async function Page(props: {
params: Promise<{ page: string }>;
}) {
const params = await props.params; const params = await props.params;
const page = await getPage(params.page); const page = await getPage(params.page);
@ -34,11 +36,14 @@ export default async function Page(props: { params: Promise<{ page: string }> })
<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} />
<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(
year: 'numeric', undefined,
month: 'long', {
day: 'numeric' year: "numeric",
}).format(new Date(page.updatedAt))}.`} month: "long",
day: "numeric",
},
).format(new Date(page.updatedAt))}.`}
</p> </p>
</> </>
); );

View File

@ -1,5 +1,5 @@
import { revalidate } from 'lib/shopify'; import { revalidate } from "lib/shopify";
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest): Promise<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {
return revalidate(req); return revalidate(req);

View File

@ -1,12 +1,12 @@
'use client'; "use client";
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 md:p-12 dark:border-neutral-800 dark:bg-black">
<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,
action again. please try your action again.
</p> </p>
<button <button
className="mx-auto mt-4 flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90" className="mx-auto mt-4 flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90"

View File

@ -1,4 +1,4 @@
@import 'tailwindcss'; @import "tailwindcss";
@plugin "@tailwindcss/container-queries"; @plugin "@tailwindcss/container-queries";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@ -20,7 +20,7 @@
} }
@supports (font: -apple-system-body) and (-webkit-appearance: none) { @supports (font: -apple-system-body) and (-webkit-appearance: none) {
img[loading='lazy'] { img[loading="lazy"] {
clip-path: inset(0.6px); clip-path: inset(0.6px);
} }
} }

View File

@ -1,12 +1,12 @@
import { CartProvider } from 'components/cart/cart-context'; import { CartProvider } from "components/cart/cart-context";
import { Navbar } from 'components/layout/navbar'; 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 { 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'; import { baseUrl } from "lib/utils";
const { SITE_NAME } = process.env; const { SITE_NAME } = process.env;
@ -14,16 +14,16 @@ export const metadata = {
metadataBase: new URL(baseUrl), metadataBase: new URL(baseUrl),
title: { title: {
default: SITE_NAME!, default: SITE_NAME!,
template: `%s | ${SITE_NAME}` template: `%s | ${SITE_NAME}`,
}, },
robots: { robots: {
follow: true, follow: true,
index: true index: true,
} },
}; };
export default async function RootLayout({ export default async function RootLayout({
children children,
}: { }: {
children: ReactNode; children: ReactNode;
}) { }) {

View File

@ -1,4 +1,4 @@
import OpengraphImage from 'components/opengraph-image'; import OpengraphImage from "components/opengraph-image";
export default async function Image() { export default async function Image() {
return await OpengraphImage(); return await OpengraphImage();

View File

@ -1,13 +1,13 @@
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";
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 function HomePage() {

View File

@ -1,16 +1,16 @@
import type { Metadata } from 'next'; import type { Metadata } from "next";
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation";
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 { 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'; import { Suspense } from "react";
export async function generateMetadata(props: { export async function generateMetadata(props: {
params: Promise<{ handle: string }>; params: Promise<{ handle: string }>;
@ -31,8 +31,8 @@ export async function generateMetadata(props: {
follow: indexable, follow: indexable,
googleBot: { googleBot: {
index: indexable, index: indexable,
follow: indexable follow: indexable,
} },
}, },
openGraph: url openGraph: url
? { ? {
@ -41,35 +41,37 @@ export async function generateMetadata(props: {
url, url,
width, width,
height, height,
alt alt,
} },
] ],
} }
: null : null,
}; };
} }
export default async function ProductPage(props: { params: Promise<{ handle: string }> }) { export default async function ProductPage(props: {
params: Promise<{ handle: string }>;
}) {
const params = await props.params; 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();
const productJsonLd = { const productJsonLd = {
'@context': 'https://schema.org', "@context": "https://schema.org",
'@type': 'Product', "@type": "Product",
name: product.title, name: product.title,
description: product.description, description: product.description,
image: product.featuredImage.url, image: product.featuredImage.url,
offers: { offers: {
'@type': 'AggregateOffer', "@type": "AggregateOffer",
availability: product.availableForSale availability: product.availableForSale
? 'https://schema.org/InStock' ? "https://schema.org/InStock"
: 'https://schema.org/OutOfStock', : "https://schema.org/OutOfStock",
priceCurrency: product.priceRange.minVariantPrice.currencyCode, priceCurrency: product.priceRange.minVariantPrice.currencyCode,
highPrice: product.priceRange.maxVariantPrice.amount, highPrice: product.priceRange.maxVariantPrice.amount,
lowPrice: product.priceRange.minVariantPrice.amount lowPrice: product.priceRange.minVariantPrice.amount,
} },
}; };
return ( return (
@ -77,7 +79,7 @@ export default async function ProductPage(props: { params: Promise<{ handle: str
<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-(--breakpoint-2xl) px-4">
@ -91,7 +93,7 @@ export default async function ProductPage(props: { params: Promise<{ handle: str
<Gallery <Gallery
images={product.images.slice(0, 5).map((image: Image) => ({ images={product.images.slice(0, 5).map((image: Image) => ({
src: image.url, src: image.url,
altText: image.altText altText: image.altText,
}))} }))}
/> />
</Suspense> </Suspense>
@ -134,7 +136,7 @@ async function RelatedProducts({ id }: { id: string }) {
label={{ label={{
title: product.title, title: product.title,
amount: product.priceRange.maxVariantPrice.amount, amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode currencyCode: product.priceRange.maxVariantPrice.currencyCode,
}} }}
src={product.featuredImage?.url} src={product.featuredImage?.url}
fill fill

View File

@ -1,13 +1,13 @@
import { baseUrl } from 'lib/utils'; import { baseUrl } from "lib/utils";
export default function robots() { export default function robots() {
return { return {
rules: [ rules: [
{ {
userAgent: '*' userAgent: "*",
} },
], ],
sitemap: `${baseUrl}/sitemap.xml`, sitemap: `${baseUrl}/sitemap.xml`,
host: baseUrl host: baseUrl,
}; };
} }

View File

@ -1,8 +1,8 @@
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 default async function Image({
params params,
}: { }: {
params: { collection: string }; params: { collection: string };
}) { }) {

View File

@ -1,10 +1,10 @@
import { getCollection, getCollectionProducts } from 'lib/shopify'; import { getCollection, getCollectionProducts } from "lib/shopify";
import { Metadata } from 'next'; import { Metadata } from "next";
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation";
import Grid from 'components/grid'; 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(props: {
params: Promise<{ collection: string }>; params: Promise<{ collection: string }>;
@ -17,7 +17,9 @@ export async function generateMetadata(props: {
return { return {
title: collection.seo?.title || collection.title, title: collection.seo?.title || collection.title,
description: description:
collection.seo?.description || collection.description || `${collection.title} products` collection.seo?.description ||
collection.description ||
`${collection.title} products`,
}; };
} }
@ -28,8 +30,13 @@ export default async function CategoryPage(props: {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const params = await props.params; 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 } =
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse }); sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getCollectionProducts({
collection: params.collection,
sortKey,
reverse,
});
return ( return (
<section> <section>

View File

@ -1,10 +1,14 @@
'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 // 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

@ -1,12 +1,12 @@
import Footer from 'components/layout/footer'; 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 ChildrenWrapper from "./children-wrapper";
import { Suspense } from 'react'; import { Suspense } from "react";
export default function SearchLayout({ export default function SearchLayout({
children children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {

View File

@ -1,4 +1,4 @@
import Grid from 'components/grid'; import Grid from "components/grid";
export default function Loading() { export default function Loading() {
return ( return (
@ -9,7 +9,10 @@ export default function Loading() {
.fill(0) .fill(0)
.map((_, index) => { .map((_, index) => {
return ( return (
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" /> <Grid.Item
key={index}
className="animate-pulse bg-neutral-100 dark:bg-neutral-800"
/>
); );
})} })}
</Grid> </Grid>

View File

@ -1,11 +1,11 @@
import Grid from 'components/grid'; 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";
import { getProducts } from 'lib/shopify'; import { getProducts } from "lib/shopify";
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(props: {
@ -13,17 +13,18 @@ export default async function SearchPage(props: {
}) { }) {
const searchParams = await props.searchParams; 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;
const products = await getProducts({ sortKey, reverse, query: searchValue }); const products = await getProducts({ sortKey, reverse, query: searchValue });
const resultsText = products.length > 1 ? 'results' : 'result'; const resultsText = products.length > 1 ? "results" : "result";
return ( return (
<> <>
{searchValue ? ( {searchValue ? (
<p className="mb-4"> <p className="mb-4">
{products.length === 0 {products.length === 0
? 'There are no products that match ' ? "There are no products that match "
: `Showing ${products.length} ${resultsText} for `} : `Showing ${products.length} ${resultsText} for `}
<span className="font-bold">&quot;{searchValue}&quot;</span> <span className="font-bold">&quot;{searchValue}&quot;</span>
</p> </p>

View File

@ -1,41 +1,41 @@
import { getCollections, getPages, getProducts } from 'lib/shopify'; import { getCollections, getPages, getProducts } from "lib/shopify";
import { baseUrl, validateEnvironmentVariables } from 'lib/utils'; import { baseUrl, validateEnvironmentVariables } from "lib/utils";
import { MetadataRoute } from 'next'; import { MetadataRoute } from "next";
type Route = { type Route = {
url: string; url: string;
lastModified: string; lastModified: string;
}; };
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> {
validateEnvironmentVariables(); validateEnvironmentVariables();
const routesMap = [''].map((route) => ({ const routesMap = [""].map((route) => ({
url: `${baseUrl}${route}`, url: `${baseUrl}${route}`,
lastModified: new Date().toISOString() lastModified: new Date().toISOString(),
})); }));
const collectionsPromise = getCollections().then((collections) => const collectionsPromise = getCollections().then((collections) =>
collections.map((collection) => ({ collections.map((collection) => ({
url: `${baseUrl}${collection.path}`, url: `${baseUrl}${collection.path}`,
lastModified: collection.updatedAt lastModified: collection.updatedAt,
})) })),
); );
const productsPromise = getProducts({}).then((products) => const productsPromise = getProducts({}).then((products) =>
products.map((product) => ({ products.map((product) => ({
url: `${baseUrl}/product/${product.handle}`, url: `${baseUrl}/product/${product.handle}`,
lastModified: product.updatedAt lastModified: product.updatedAt,
})) })),
); );
const pagesPromise = getPages().then((pages) => const pagesPromise = getPages().then((pages) =>
pages.map((page) => ({ pages.map((page) => ({
url: `${baseUrl}/${page.handle}`, url: `${baseUrl}/${page.handle}`,
lastModified: page.updatedAt lastModified: page.updatedAt,
})) })),
); );
let fetchedRoutes: Route[] = []; let fetchedRoutes: Route[] = [];

View File

@ -1,10 +1,12 @@
import { getCollectionProducts } from 'lib/shopify'; import { getCollectionProducts } from "lib/shopify";
import Link from 'next/link'; import Link from "next/link";
import { GridTileImage } from './grid/tile'; import { GridTileImage } from "./grid/tile";
export async function Carousel() { export async function Carousel() {
// Collections that start with `hidden-*` are hidden from the search page. // Collections that start with `hidden-*` are hidden from the search page.
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' }); const products = await getCollectionProducts({
collection: "hidden-homepage-carousel",
});
if (!products?.length) return null; if (!products?.length) return null;
@ -19,13 +21,16 @@ export async function Carousel() {
key={`${product.handle}${i}`} key={`${product.handle}${i}`}
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3" className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
> >
<Link href={`/product/${product.handle}`} className="relative h-full w-full"> <Link
href={`/product/${product.handle}`}
className="relative h-full w-full"
>
<GridTileImage <GridTileImage
alt={product.title} alt={product.title}
label={{ label={{
title: product.title, title: product.title,
amount: product.priceRange.maxVariantPrice.amount, amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode currencyCode: product.priceRange.maxVariantPrice.currencyCode,
}} }}
src={product.featuredImage?.url} src={product.featuredImage?.url}
fill fill

View File

@ -1,30 +1,30 @@
'use server'; "use server";
import { TAGS } from 'lib/constants'; import { TAGS } from "lib/constants";
import { import {
addToCart, addToCart,
createCart, createCart,
getCart, getCart,
removeFromCart, removeFromCart,
updateCart updateCart,
} from 'lib/shopify'; } 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, prevState: any,
selectedVariantId: string | undefined selectedVariantId: string | undefined,
) { ) {
if (!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([{ 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";
} }
} }
@ -33,21 +33,21 @@ export async function removeItem(prevState: any, merchandiseId: string) {
const cart = await getCart(); const cart = await getCart();
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([lineItem.id]);
revalidateTag(TAGS.cart); revalidateTag(TAGS.cart);
} else { } else {
return 'Item not found in cart'; return "Item not found in cart";
} }
} catch (e) { } catch (e) {
return 'Error removing item from cart'; return "Error removing item from cart";
} }
} }
@ -56,7 +56,7 @@ export async function updateItemQuantity(
payload: { payload: {
merchandiseId: string; merchandiseId: string;
quantity: number; quantity: number;
} },
) { ) {
const { merchandiseId, quantity } = payload; const { merchandiseId, quantity } = payload;
@ -64,11 +64,11 @@ export async function updateItemQuantity(
const cart = await getCart(); const cart = await getCart();
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) {
@ -79,8 +79,8 @@ export async function updateItemQuantity(
{ {
id: lineItem.id, id: lineItem.id,
merchandiseId, merchandiseId,
quantity quantity,
} },
]); ]);
} }
} else if (quantity > 0) { } else if (quantity > 0) {
@ -91,7 +91,7 @@ export async function updateItemQuantity(
revalidateTag(TAGS.cart); revalidateTag(TAGS.cart);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return 'Error updating item quantity'; return "Error updating item quantity";
} }
} }
@ -102,5 +102,5 @@ export async function redirectToCheckout() {
export async function createCartAndSetCookie() { export async function createCartAndSetCookie() {
let cart = await createCart(); let cart = await createCart();
(await cookies()).set('cartId', cart.id!); (await cookies()).set("cartId", cart.id!);
} }

View File

@ -1,23 +1,23 @@
'use client'; "use client";
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 { 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 { useActionState } from "react";
import { useCart } from './cart-context'; import { useCart } from "./cart-context";
function SubmitButton({ function SubmitButton({
availableForSale, availableForSale,
selectedVariantId selectedVariantId,
}: { }: {
availableForSale: boolean; availableForSale: boolean;
selectedVariantId: string | undefined; selectedVariantId: string | undefined;
}) { }) {
const buttonClasses = const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white'; "relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white";
const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60'; const disabledClasses = "cursor-not-allowed opacity-60 hover:opacity-60";
if (!availableForSale) { if (!availableForSale) {
return ( return (
@ -46,7 +46,7 @@ function SubmitButton({
<button <button
aria-label="Add to cart" aria-label="Add to cart"
className={clsx(buttonClasses, { className={clsx(buttonClasses, {
'hover:opacity-90': true "hover:opacity-90": true,
})} })}
> >
<div className="absolute left-0 ml-4"> <div className="absolute left-0 ml-4">
@ -65,14 +65,14 @@ export function AddToCart({ product }: { product: Product }) {
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 addItemAction = formAction.bind(null, selectedVariantId);
const finalVariant = variants.find( const finalVariant = variants.find(
(variant) => variant.id === selectedVariantId (variant) => variant.id === selectedVariantId,
)!; )!;
return ( return (

View File

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

View File

@ -1,13 +1,13 @@
'use client'; "use client";
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 { useActionState } from "react";
export function DeleteItemButton({ export function DeleteItemButton({
item, item,
optimisticUpdate optimisticUpdate,
}: { }: {
item: CartItem; item: CartItem;
optimisticUpdate: any; optimisticUpdate: any;
@ -19,7 +19,7 @@ export function DeleteItemButton({
return ( return (
<form <form
action={async () => { action={async () => {
optimisticUpdate(merchandiseId, 'delete'); optimisticUpdate(merchandiseId, "delete");
removeItemAction(); removeItemAction();
}} }}
> >

View File

@ -1,26 +1,26 @@
'use client'; "use client";
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 { updateItemQuantity } from "components/cart/actions";
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from "lib/shopify/types";
import { useActionState } from 'react'; import { useActionState } from "react";
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",
{ {
'ml-auto': type === 'minus' "ml-auto": type === "minus",
} },
)} )}
> >
{type === 'plus' ? ( {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" />
@ -32,16 +32,16 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
export function EditItemQuantityButton({ export function EditItemQuantityButton({
item, item,
type, type,
optimisticUpdate optimisticUpdate,
}: { }: {
item: CartItem; item: CartItem;
type: 'plus' | 'minus'; type: "plus" | "minus";
optimisticUpdate: any; optimisticUpdate: any;
}) { }) {
const [message, formAction] = useActionState(updateItemQuantity, null); const [message, formAction] = useActionState(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 updateItemQuantityAction = formAction.bind(null, payload);

View File

@ -1,21 +1,21 @@
'use client'; "use client";
import clsx from 'clsx'; 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, XMarkIcon } 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";
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 { 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 { 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";
type MerchandiseSearchParams = { type MerchandiseSearchParams = {
[key: string]: string; [key: string]: string;
@ -95,8 +95,8 @@ export default function CartModal() {
{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 =
@ -108,12 +108,12 @@ export default function CartModal() {
merchandiseSearchParams[name.toLowerCase()] = merchandiseSearchParams[name.toLowerCase()] =
value; 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 (
@ -233,8 +233,8 @@ function CloseCart({ className }: { className?: string }) {
<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">
<XMarkIcon <XMarkIcon
className={clsx( className={clsx(
'h-6 transition-all ease-in-out hover:scale-110', "h-6 transition-all ease-in-out hover:scale-110",
className className,
)} )}
/> />
</div> </div>
@ -250,7 +250,7 @@ function CheckoutButton() {
type="submit" type="submit"
disabled={pending} disabled={pending}
> >
{pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'} {pending ? <LoadingDots className="bg-white" /> : "Proceed to Checkout"}
</button> </button>
); );
} }

View File

@ -1,9 +1,9 @@
import { ShoppingCartIcon } from '@heroicons/react/24/outline'; import { ShoppingCartIcon } from "@heroicons/react/24/outline";
import clsx from 'clsx'; import clsx from "clsx";
export default function OpenCart({ export default function OpenCart({
className, className,
quantity quantity,
}: { }: {
className?: string; className?: string;
quantity?: number; quantity?: number;
@ -11,7 +11,10 @@ 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 ? (

View File

@ -1,16 +1,22 @@
import clsx from 'clsx'; import clsx from "clsx";
function Grid(props: React.ComponentProps<'ul'>) { function Grid(props: React.ComponentProps<"ul">) {
return ( return (
<ul {...props} className={clsx('grid grid-flow-row gap-4', props.className)}> <ul
{...props}
className={clsx("grid grid-flow-row gap-4", props.className)}
>
{props.children} {props.children}
</ul> </ul>
); );
} }
function GridItem(props: React.ComponentProps<'li'>) { function GridItem(props: React.ComponentProps<"li">) {
return ( return (
<li {...props} className={clsx('aspect-square transition-opacity', props.className)}> <li
{...props}
className={clsx("aspect-square transition-opacity", props.className)}
>
{props.children} {props.children}
</li> </li>
); );

View File

@ -1,20 +1,24 @@
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from "components/grid/tile";
import { getCollectionProducts } from 'lib/shopify'; import { getCollectionProducts } from "lib/shopify";
import type { Product } from 'lib/shopify/types'; import type { Product } from "lib/shopify/types";
import Link from 'next/link'; import Link from "next/link";
function ThreeItemGridItem({ function ThreeItemGridItem({
item, item,
size, size,
priority priority,
}: { }: {
item: Product; item: Product;
size: 'full' | 'half'; size: "full" | "half";
priority?: boolean; priority?: boolean;
}) { }) {
return ( return (
<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" className="relative block aspect-square h-full w-full"
@ -25,15 +29,17 @@ function ThreeItemGridItem({
src={item.featuredImage.url} src={item.featuredImage.url}
fill fill
sizes={ sizes={
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw' size === "full"
? "(min-width: 768px) 66vw, 100vw"
: "(min-width: 768px) 33vw, 100vw"
} }
priority={priority} priority={priority}
alt={item.title} alt={item.title}
label={{ label={{
position: size === 'full' ? 'center' : 'bottom', position: size === "full" ? "center" : "bottom",
title: item.title as string, title: item.title as string,
amount: item.priceRange.maxVariantPrice.amount, amount: item.priceRange.maxVariantPrice.amount,
currencyCode: item.priceRange.maxVariantPrice.currencyCode currencyCode: item.priceRange.maxVariantPrice.currencyCode,
}} }}
/> />
</Link> </Link>
@ -44,7 +50,7 @@ function ThreeItemGridItem({
export async function ThreeItemGrid() { export async function ThreeItemGrid() {
// Collections that start with `hidden-*` are hidden from the search page. // Collections that start with `hidden-*` are hidden from the search page.
const homepageItems = await getCollectionProducts({ const homepageItems = await getCollectionProducts({
collection: 'hidden-homepage-featured-items' collection: "hidden-homepage-featured-items",
}); });
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null; if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'; import clsx from "clsx";
import Image from 'next/image'; import Image from "next/image";
import Label from '../label'; import Label from "../label";
export function GridTileImage({ export function GridTileImage({
isInteractive = true, isInteractive = true,
@ -14,24 +14,25 @@ export function GridTileImage({
title: string; title: string;
amount: string; amount: string;
currencyCode: string; currencyCode: string;
position?: 'bottom' | 'center'; position?: "bottom" | "center";
}; };
} & React.ComponentProps<typeof Image>) { } & React.ComponentProps<typeof Image>) {
return ( return (
<div <div
className={clsx( className={clsx(
'group flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black', "group flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black",
{ {
relative: label, relative: label,
'border-2 border-blue-600': active, "border-2 border-blue-600": active,
'border-neutral-200 dark:border-neutral-800': !active "border-neutral-200 dark:border-neutral-800": !active,
} },
)} )}
> >
{props.src ? ( {props.src ? (
<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,
})} })}
{...props} {...props}
/> />

View File

@ -1,13 +1,13 @@
import clsx from 'clsx'; import clsx from "clsx";
export default function LogoIcon(props: React.ComponentProps<'svg'>) { export default function LogoIcon(props: React.ComponentProps<"svg">) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-label={`${process.env.SITE_NAME} logo`} aria-label={`${process.env.SITE_NAME} logo`}
viewBox="0 0 32 28" viewBox="0 0 32 28"
{...props} {...props}
className={clsx('h-4 w-4 fill-black dark:fill-white', props.className)} className={clsx("h-4 w-4 fill-black dark:fill-white", props.className)}
> >
<path d="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" /> <path d="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" />
<path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" /> <path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" />

View File

@ -1,25 +1,30 @@
import clsx from 'clsx'; import clsx from "clsx";
import Price from './price'; import Price from "./price";
const Label = ({ const Label = ({
title, title,
amount, amount,
currencyCode, currencyCode,
position = 'bottom' position = "bottom",
}: { }: {
title: string; title: string;
amount: string; amount: string;
currencyCode: string; currencyCode: string;
position?: 'bottom' | 'center'; position?: "bottom" | "center";
}) => { }) => {
return ( return (
<div <div
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', { className={clsx(
'lg:px-20 lg:pb-[35%]': position === 'center' "absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label",
})} {
"lg:px-20 lg:pb-[35%]": position === "center",
},
)}
> >
<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 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

@ -1,10 +1,10 @@
'use client'; "use client";
import clsx from 'clsx'; import clsx from "clsx";
import { Menu } from 'lib/shopify/types'; import { Menu } from "lib/shopify/types";
import Link from 'next/link'; 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 }) { export function FooterMenuItem({ item }: { item: Menu }) {
const pathname = usePathname(); const pathname = usePathname();
@ -19,10 +19,10 @@ 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 md:inline-block md:text-sm dark:hover:text-neutral-300",
{ {
'text-black dark:text-neutral-300': active "text-black dark:text-neutral-300": active,
} },
)} )}
> >
{item.title} {item.title}

View File

@ -1,24 +1,28 @@
import Link from 'next/link'; import Link from "next/link";
import FooterMenu from 'components/layout/footer-menu'; import FooterMenu from "components/layout/footer-menu";
import LogoSquare from 'components/logo-square'; import LogoSquare from "components/logo-square";
import { getMenu } from 'lib/shopify'; import { getMenu } from "lib/shopify";
import { Suspense } from 'react'; import { Suspense } from "react";
const { COMPANY_NAME, SITE_NAME } = process.env; 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 =
const menu = await getMenu('next-js-frontend-footer-menu'); "w-full h-6 animate-pulse rounded-sm bg-neutral-200 dark:bg-neutral-700";
const copyrightName = COMPANY_NAME || SITE_NAME || ''; const menu = await getMenu("next-js-frontend-footer-menu");
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 md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0 dark:border-neutral-700">
<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 md:pt-1 dark:text-white"
href="/"
>
<LogoSquare size="sm" /> <LogoSquare size="sm" />
<span className="uppercase">{SITE_NAME}</span> <span className="uppercase">{SITE_NAME}</span>
</Link> </Link>
@ -53,7 +57,10 @@ export default async function Footer() {
<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 min-[1320px]: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>

View File

@ -1,16 +1,16 @@
import CartModal from 'components/cart/modal'; import CartModal from "components/cart/modal";
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, { SearchSkeleton } from "./search";
const { SITE_NAME } = process.env; const { SITE_NAME } = process.env;
export async function Navbar() { export 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">

View File

@ -1,13 +1,13 @@
'use client'; "use client";
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, Suspense, 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, { SearchSkeleton } from "./search";
export default function MobileMenu({ menu }: { menu: Menu[] }) { export default function MobileMenu({ menu }: { menu: Menu[] }) {
const pathname = usePathname(); const pathname = usePathname();
@ -22,8 +22,8 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
setIsOpen(false); setIsOpen(false);
} }
}; };
window.addEventListener('resize', handleResize); window.addEventListener("resize", handleResize);
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener("resize", handleResize);
}, [isOpen]); }, [isOpen]);
useEffect(() => { useEffect(() => {
@ -83,7 +83,11 @@ 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}
prefetch={true}
onClick={closeMobileMenu}
>
{item.title} {item.title}
</Link> </Link>
</li> </li>

View File

@ -1,21 +1,24 @@
'use client'; "use client";
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import Form from 'next/form'; import Form from "next/form";
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from "next/navigation";
export default function Search() { export default function Search() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
return ( return (
<Form action="/search" className="w-max-[550px] relative w-full lg:w-80 xl:w-full"> <Form
action="/search"
className="w-max-[550px] relative w-full lg:w-80 xl:w-full"
>
<input <input
key={searchParams?.get('q')} key={searchParams?.get("q")}
type="text" type="text"
name="q" name="q"
placeholder="Search for products..." placeholder="Search for products..."
autoComplete="off" autoComplete="off"
defaultValue={searchParams?.get('q') || ''} defaultValue={searchParams?.get("q") || ""}
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" 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"
/> />
<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

@ -1,9 +1,13 @@
import Grid from 'components/grid'; import Grid from "components/grid";
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from "components/grid/tile";
import { Product } from 'lib/shopify/types'; import { Product } from "lib/shopify/types";
import Link from 'next/link'; import Link from "next/link";
export default function ProductGridItems({ products }: { products: Product[] }) { export default function ProductGridItems({
products,
}: {
products: Product[];
}) {
return ( return (
<> <>
{products.map((product) => ( {products.map((product) => (
@ -18,7 +22,7 @@ export default function ProductGridItems({ products }: { products: Product[] })
label={{ label={{
title: product.title, title: product.title,
amount: product.priceRange.maxVariantPrice.amount, amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode currencyCode: product.priceRange.maxVariantPrice.currencyCode,
}} }}
src={product.featuredImage?.url} src={product.featuredImage?.url}
fill fill

View File

@ -1,17 +1,17 @@
import clsx from 'clsx'; import clsx from "clsx";
import { Suspense } from 'react'; import { Suspense } from "react";
import { getCollections } from 'lib/shopify'; import { getCollections } from "lib/shopify";
import FilterList from './filter'; import FilterList from "./filter";
async function CollectionList() { async function CollectionList() {
const collections = await getCollections(); const collections = await getCollections();
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-sm";
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";
export default function Collections() { export default function Collections() {
return ( return (

View File

@ -1,16 +1,16 @@
'use client'; "use client";
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from '@heroicons/react/24/outline'; import { ChevronDownIcon } from "@heroicons/react/24/outline";
import type { ListItem } from '.'; import type { ListItem } from ".";
import { FilterItem } from './item'; import { FilterItem } from "./item";
export default function FilterItemDropdown({ list }: { list: ListItem[] }) { export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [active, setActive] = useState(''); const [active, setActive] = useState("");
const [openSelect, setOpenSelect] = useState(false); const [openSelect, setOpenSelect] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -21,15 +21,15 @@ export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
} }
}; };
window.addEventListener('click', handleClickOutside); window.addEventListener("click", handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside); return () => window.removeEventListener("click", handleClickOutside);
}, []); }, []);
useEffect(() => { useEffect(() => {
list.forEach((listItem: ListItem) => { list.forEach((listItem: ListItem) => {
if ( if (
('path' in listItem && pathname === listItem.path) || ("path" in listItem && pathname === listItem.path) ||
('slug' in listItem && searchParams.get('sort') === listItem.slug) ("slug" in listItem && searchParams.get("sort") === listItem.slug)
) { ) {
setActive(listItem.title); setActive(listItem.title);
} }

View File

@ -1,7 +1,7 @@
import { SortFilterItem } from 'lib/constants'; import { SortFilterItem } from "lib/constants";
import { Suspense } from 'react'; import { Suspense } from "react";
import FilterItemDropdown from './dropdown'; import FilterItemDropdown from "./dropdown";
import { FilterItem } from './item'; import { FilterItem } from "./item";
export type ListItem = SortFilterItem | PathFilterItem; export type ListItem = SortFilterItem | PathFilterItem;
export type PathFilterItem = { title: string; path: string }; export type PathFilterItem = { title: string; path: string };
@ -16,7 +16,13 @@ function FilterItemList({ list }: { list: ListItem[] }) {
); );
} }
export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) { export default function FilterList({
list,
title,
}: {
list: ListItem[];
title?: string;
}) {
return ( return (
<> <>
<nav> <nav>

View File

@ -1,30 +1,30 @@
'use client'; "use client";
import clsx from 'clsx'; import clsx from "clsx";
import type { SortFilterItem } from 'lib/constants'; import type { 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";
import type { ListItem, PathFilterItem } from '.'; import type { ListItem, PathFilterItem } from ".";
function PathFilterItem({ item }: { item: PathFilterItem }) { function PathFilterItem({ item }: { item: PathFilterItem }) {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const active = pathname === item.path; const active = pathname === item.path;
const newParams = new URLSearchParams(searchParams.toString()); const newParams = new URLSearchParams(searchParams.toString());
const DynamicTag = active ? 'p' : Link; const DynamicTag = active ? "p" : Link;
newParams.delete('q'); newParams.delete("q");
return ( return (
<li className="mt-2 flex text-black dark:text-white" key={item.title}> <li className="mt-2 flex text-black dark:text-white" key={item.title}>
<DynamicTag <DynamicTag
href={createUrl(item.path, newParams)} href={createUrl(item.path, newParams)}
className={clsx( className={clsx(
'w-full text-sm underline-offset-4 hover:underline dark:hover:text-neutral-100', "w-full text-sm underline-offset-4 hover:underline dark:hover:text-neutral-100",
{ {
'underline underline-offset-4': active "underline underline-offset-4": active,
} },
)} )}
> >
{item.title} {item.title}
@ -36,24 +36,27 @@ function PathFilterItem({ item }: { item: PathFilterItem }) {
function SortFilterItem({ item }: { item: SortFilterItem }) { function SortFilterItem({ item }: { item: SortFilterItem }) {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const active = searchParams.get('sort') === item.slug; const active = searchParams.get("sort") === item.slug;
const q = searchParams.get('q'); const q = searchParams.get("q");
const href = createUrl( const href = createUrl(
pathname, pathname,
new URLSearchParams({ new URLSearchParams({
...(q && { q }), ...(q && { q }),
...(item.slug && item.slug.length && { sort: item.slug }) ...(item.slug && item.slug.length && { sort: item.slug }),
}) }),
); );
const DynamicTag = active ? 'p' : Link; const DynamicTag = active ? "p" : Link;
return ( return (
<li className="mt-2 flex text-sm text-black dark:text-white" key={item.title}> <li
className="mt-2 flex text-sm text-black dark:text-white"
key={item.title}
>
<DynamicTag <DynamicTag
prefetch={!active ? false : undefined} prefetch={!active ? false : undefined}
href={href} href={href}
className={clsx('w-full hover:underline hover:underline-offset-4', { className={clsx("w-full hover:underline hover:underline-offset-4", {
'underline underline-offset-4': active "underline underline-offset-4": active,
})} })}
> >
{item.title} {item.title}
@ -63,5 +66,9 @@ function SortFilterItem({ item }: { item: SortFilterItem }) {
} }
export function FilterItem({ item }: { item: ListItem }) { export function FilterItem({ item }: { item: ListItem }) {
return 'path' in item ? <PathFilterItem item={item} /> : <SortFilterItem item={item} />; return "path" in item ? (
<PathFilterItem item={item} />
) : (
<SortFilterItem item={item} />
);
} }

View File

@ -1,13 +1,13 @@
import clsx from 'clsx'; import clsx from "clsx";
const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md'; const dots = "mx-[1px] inline-block h-1 w-1 animate-blink rounded-md";
const LoadingDots = ({ className }: { className: string }) => { const LoadingDots = ({ className }: { className: string }) => {
return ( return (
<span className="mx-2 inline-flex items-center"> <span className="mx-2 inline-flex items-center">
<span className={clsx(dots, className)} /> <span className={clsx(dots, className)} />
<span className={clsx(dots, 'animation-delay-[200ms]', className)} /> <span className={clsx(dots, "animation-delay-[200ms]", className)} />
<span className={clsx(dots, 'animation-delay-[400ms]', className)} /> <span className={clsx(dots, "animation-delay-[400ms]", className)} />
</span> </span>
); );
}; };

View File

@ -1,21 +1,21 @@
import clsx from 'clsx'; import clsx from "clsx";
import LogoIcon from './icons/logo'; import LogoIcon from "./icons/logo";
export default function LogoSquare({ size }: { size?: 'sm' | undefined }) { export default function LogoSquare({ size }: { size?: "sm" | undefined }) {
return ( return (
<div <div
className={clsx( className={clsx(
'flex flex-none items-center justify-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-black', "flex flex-none items-center justify-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-black",
{ {
'h-[40px] w-[40px] rounded-xl': !size, "h-[40px] w-[40px] rounded-xl": !size,
'h-[30px] w-[30px] rounded-lg': size === 'sm' "h-[30px] w-[30px] rounded-lg": size === "sm",
} },
)} )}
> >
<LogoIcon <LogoIcon
className={clsx({ className={clsx({
'h-[16px] w-[16px]': !size, "h-[16px] w-[16px]": !size,
'h-[10px] w-[10px]': size === 'sm' "h-[10px] w-[10px]": size === "sm",
})} })}
/> />
</div> </div>

View File

@ -1,23 +1,23 @@
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 { join } from "path";
import { readFile } from 'fs/promises'; 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 props?: Props,
): Promise<ImageResponse> { ): Promise<ImageResponse> {
const { title } = { const { title } = {
...{ ...{
title: process.env.SITE_NAME title: process.env.SITE_NAME,
}, },
...props ...props,
}; };
const file = await readFile(join(process.cwd(), './fonts/Inter-Bold.ttf')); const file = await readFile(join(process.cwd(), "./fonts/Inter-Bold.ttf"));
const font = Uint8Array.from(file).buffer; const font = Uint8Array.from(file).buffer;
return new ImageResponse( return new ImageResponse(
@ -34,12 +34,12 @@ export default async function OpengraphImage(
height: 630, height: 630,
fonts: [ fonts: [
{ {
name: 'Inter', name: "Inter",
data: font, data: font,
style: 'normal', style: "normal",
weight: 700 weight: 700,
} },
] ],
} },
); );
} }

View File

@ -1,23 +1,25 @@
import clsx from 'clsx'; import clsx from "clsx";
const Price = ({ const Price = ({
amount, amount,
className, className,
currencyCode = 'USD', currencyCode = "USD",
currencyCodeClassName currencyCodeClassName,
}: { }: {
amount: string; amount: string;
className?: string; className?: string;
currencyCode: string; currencyCode: string;
currencyCodeClassName?: string; currencyCodeClassName?: string;
} & React.ComponentProps<'p'>) => ( } & React.ComponentProps<"p">) => (
<p suppressHydrationWarning={true} className={className}> <p suppressHydrationWarning={true} className={className}>
{`${new Intl.NumberFormat(undefined, { {`${new Intl.NumberFormat(undefined, {
style: 'currency', style: "currency",
currency: currencyCode, currency: currencyCode,
currencyDisplay: 'narrowSymbol' currencyDisplay: "narrowSymbol",
}).format(parseFloat(amount))}`} }).format(parseFloat(amount))}`}
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span> <span
className={clsx("ml-1 inline", currencyCodeClassName)}
>{`${currencyCode}`}</span>
</p> </p>
); );

View File

@ -1,20 +1,25 @@
'use client'; "use client";
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 { useProduct, useUpdateURL } from "components/product/product-context";
import Image from 'next/image'; import Image from "next/image";
export function Gallery({ images }: { images: { src: string; altText: string }[] }) { export function Gallery({
images,
}: {
images: { src: string; altText: string }[];
}) {
const { state, updateImage } = useProduct(); const { state, updateImage } = useProduct();
const updateURL = useUpdateURL(); const updateURL = useUpdateURL();
const imageIndex = state.image ? parseInt(state.image) : 0; const imageIndex = state.image ? parseInt(state.image) : 0;
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0; const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1; const previousImageIndex =
imageIndex === 0 ? images.length - 1 : imageIndex - 1;
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> <form>

View File

@ -1,7 +1,12 @@
'use client'; "use client";
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from "next/navigation";
import React, { createContext, useContext, useMemo, useOptimistic } from 'react'; import React, {
createContext,
useContext,
useMemo,
useOptimistic,
} from "react";
type ProductState = { type ProductState = {
[key: string]: string; [key: string]: string;
@ -32,8 +37,8 @@ export function ProductProvider({ children }: { children: React.ReactNode }) {
getInitialState(), getInitialState(),
(prevState: ProductState, update: ProductState) => ({ (prevState: ProductState, update: ProductState) => ({
...prevState, ...prevState,
...update ...update,
}) }),
); );
const updateOption = (name: string, value: string) => { const updateOption = (name: string, value: string) => {
@ -52,18 +57,20 @@ export function ProductProvider({ children }: { children: React.ReactNode }) {
() => ({ () => ({
state, state,
updateOption, updateOption,
updateImage updateImage,
}), }),
[state] [state],
); );
return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>; return (
<ProductContext.Provider value={value}>{children}</ProductContext.Provider>
);
} }
export function useProduct() { export function useProduct() {
const context = useContext(ProductContext); const context = useContext(ProductContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useProduct must be used within a ProductProvider'); throw new Error("useProduct must be used within a ProductProvider");
} }
return context; return context;
} }

View File

@ -1,8 +1,8 @@
import { AddToCart } from 'components/cart/add-to-cart'; import { AddToCart } from "components/cart/add-to-cart";
import Price from 'components/price'; import Price from "components/price";
import Prose from 'components/prose'; import Prose from "components/prose";
import { Product } from 'lib/shopify/types'; import { Product } from "lib/shopify/types";
import { VariantSelector } from './variant-selector'; import { VariantSelector } from "./variant-selector";
export function ProductDescription({ product }: { product: Product }) { export function ProductDescription({ product }: { product: Product }) {
return ( return (

View File

@ -1,8 +1,8 @@
'use client'; "use client";
import clsx from 'clsx'; import clsx from "clsx";
import { useProduct, useUpdateURL } from 'components/product/product-context'; import { useProduct, useUpdateURL } from "components/product/product-context";
import { ProductOption, ProductVariant } from 'lib/shopify/types'; import { ProductOption, ProductVariant } from "lib/shopify/types";
type Combination = { type Combination = {
id: string; id: string;
@ -12,7 +12,7 @@ type Combination = {
export function VariantSelector({ export function VariantSelector({
options, options,
variants variants,
}: { }: {
options: ProductOption[]; options: ProductOption[];
variants: ProductVariant[]; variants: ProductVariant[];
@ -20,7 +20,8 @@ export function VariantSelector({
const { state, updateOption } = useProduct(); const { state, updateOption } = useProduct();
const updateURL = useUpdateURL(); const updateURL = useUpdateURL();
const hasNoOptionsOrJustOneOption = const hasNoOptionsOrJustOneOption =
!options.length || (options.length === 1 && options[0]?.values.length === 1); !options.length ||
(options.length === 1 && options[0]?.values.length === 1);
if (hasNoOptionsOrJustOneOption) { if (hasNoOptionsOrJustOneOption) {
return null; return null;
@ -30,9 +31,12 @@ export function VariantSelector({
id: variant.id, id: variant.id,
availableForSale: variant.availableForSale, availableForSale: variant.availableForSale,
...variant.selectedOptions.reduce( ...variant.selectedOptions.reduce(
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }), (accumulator, option) => ({
{} ...accumulator,
) [option.name.toLowerCase()]: option.value,
}),
{},
),
})); }));
return options.map((option) => ( return options.map((option) => (
@ -47,15 +51,19 @@ export function VariantSelector({
const optionParams = { ...state, [optionNameLowerCase]: value }; const optionParams = { ...state, [optionNameLowerCase]: value };
// Filter out invalid options and check if the option combination is available for sale. // Filter out invalid options and check if the option combination is available for sale.
const filtered = Object.entries(optionParams).filter(([key, value]) => const filtered = Object.entries(optionParams).filter(
options.find( ([key, value]) =>
(option) => option.name.toLowerCase() === key && option.values.includes(value) options.find(
) (option) =>
option.name.toLowerCase() === key &&
option.values.includes(value),
),
); );
const isAvailableForSale = combinations.find((combination) => const isAvailableForSale = combinations.find((combination) =>
filtered.every( filtered.every(
([key, value]) => combination[key] === value && combination.availableForSale ([key, value]) =>
) combination[key] === value && combination.availableForSale,
),
); );
// The option is active if it's in the selected options. // The option is active if it's in the selected options.
@ -70,16 +78,16 @@ export function VariantSelector({
key={value} key={value}
aria-disabled={!isAvailableForSale} aria-disabled={!isAvailableForSale}
disabled={!isAvailableForSale} disabled={!isAvailableForSale}
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`} title={`${option.name} ${value}${!isAvailableForSale ? " (Out of Stock)" : ""}`}
className={clsx( 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', "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",
{ {
'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 dark:before:bg-neutral-700":
!isAvailableForSale !isAvailableForSale,
} },
)} )}
> >
{value} {value}

View File

@ -1,11 +1,11 @@
import clsx from 'clsx'; import clsx from "clsx";
const Prose = ({ html, className }: { html: string; className?: string }) => { const Prose = ({ html, className }: { html: string; className?: string }) => {
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 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",
className className,
)} )}
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />

View File

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

View File

@ -1,31 +1,51 @@
export type SortFilterItem = { export type SortFilterItem = {
title: string; title: string;
slug: string | null; slug: string | null;
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE'; sortKey: "RELEVANCE" | "BEST_SELLING" | "CREATED_AT" | "PRICE";
reverse: boolean; reverse: boolean;
}; };
export const defaultSort: SortFilterItem = { export const defaultSort: SortFilterItem = {
title: 'Relevance', title: "Relevance",
slug: null, slug: null,
sortKey: 'RELEVANCE', sortKey: "RELEVANCE",
reverse: false reverse: false,
}; };
export const sorting: SortFilterItem[] = [ export const sorting: SortFilterItem[] = [
defaultSort, defaultSort,
{ title: 'Trending', slug: 'trending-desc', sortKey: 'BEST_SELLING', reverse: false }, // asc {
{ title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'CREATED_AT', reverse: true }, title: "Trending",
{ title: 'Price: Low to high', slug: 'price-asc', sortKey: 'PRICE', reverse: false }, // asc slug: "trending-desc",
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true } sortKey: "BEST_SELLING",
reverse: false,
}, // asc
{
title: "Latest arrivals",
slug: "latest-desc",
sortKey: "CREATED_AT",
reverse: true,
},
{
title: "Price: Low to high",
slug: "price-asc",
sortKey: "PRICE",
reverse: false,
}, // asc
{
title: "Price: High to low",
slug: "price-desc",
sortKey: "PRICE",
reverse: true,
},
]; ];
export const TAGS = { export const TAGS = {
collections: 'collections', collections: "collections",
products: 'products', products: "products",
cart: 'cart' cart: "cart",
}; };
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden'; export const HIDDEN_PRODUCT_TAG = "nextjs-frontend-hidden";
export const DEFAULT_OPTION = 'Default Title'; export const DEFAULT_OPTION = "Default Title";
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json'; export const SHOPIFY_GRAPHQL_API_ENDPOINT = "/api/2023-01/graphql.json";

View File

@ -1,4 +1,4 @@
import productFragment from './product'; import productFragment from "./product";
const cartFragment = /* GraphQL */ ` const cartFragment = /* GraphQL */ `
fragment cart on Cart { fragment cart on Cart {

View File

@ -1,5 +1,5 @@
import imageFragment from './image'; import imageFragment from "./image";
import seoFragment from './seo'; import seoFragment from "./seo";
const productFragment = /* GraphQL */ ` const productFragment = /* GraphQL */ `
fragment product on Product { fragment product on Product {

View File

@ -1,36 +1,36 @@
import { import {
HIDDEN_PRODUCT_TAG, HIDDEN_PRODUCT_TAG,
SHOPIFY_GRAPHQL_API_ENDPOINT, SHOPIFY_GRAPHQL_API_ENDPOINT,
TAGS TAGS,
} from 'lib/constants'; } 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, revalidateTag,
unstable_cacheTag as cacheTag, unstable_cacheTag as cacheTag,
unstable_cacheLife as cacheLife unstable_cacheLife as cacheLife,
} from 'next/cache'; } from "next/cache";
import { cookies, headers } from 'next/headers'; import { cookies, headers } from "next/headers";
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import { import {
addToCartMutation, addToCartMutation,
createCartMutation, createCartMutation,
editCartItemsMutation, editCartItemsMutation,
removeFromCartMutation removeFromCartMutation,
} from './mutations/cart'; } from "./mutations/cart";
import { getCartQuery } from './queries/cart'; import { getCartQuery } from "./queries/cart";
import { import {
getCollectionProductsQuery, getCollectionProductsQuery,
getCollectionQuery, getCollectionQuery,
getCollectionsQuery getCollectionsQuery,
} from './queries/collection'; } from "./queries/collection";
import { getMenuQuery } from './queries/menu'; import { getMenuQuery } from "./queries/menu";
import { getPageQuery, getPagesQuery } from './queries/page'; import { getPageQuery, getPagesQuery } from "./queries/page";
import { import {
getProductQuery, getProductQuery,
getProductRecommendationsQuery, getProductRecommendationsQuery,
getProductsQuery getProductsQuery,
} from './queries/product'; } from "./queries/product";
import { import {
Cart, Cart,
Collection, Collection,
@ -55,23 +55,23 @@ import {
ShopifyProductRecommendationsOperation, ShopifyProductRecommendationsOperation,
ShopifyProductsOperation, ShopifyProductsOperation,
ShopifyRemoveFromCartOperation, ShopifyRemoveFromCartOperation,
ShopifyUpdateCartOperation ShopifyUpdateCartOperation,
} from './types'; } from "./types";
const domain = process.env.SHOPIFY_STORE_DOMAIN const domain = process.env.SHOPIFY_STORE_DOMAIN
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://') ? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, "https://")
: ''; : "";
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'] ? T["variables"]
: never; : never;
export async function shopifyFetch<T>({ export async function shopifyFetch<T>({
headers, headers,
query, query,
variables variables,
}: { }: {
headers?: HeadersInit; headers?: HeadersInit;
query: string; query: string;
@ -79,16 +79,16 @@ export async function shopifyFetch<T>({
}): Promise<{ status: number; body: T } | never> { }): Promise<{ status: number; body: T } | never> {
try { try {
const result = await fetch(endpoint, { const result = await fetch(endpoint, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'X-Shopify-Storefront-Access-Token': key, "X-Shopify-Storefront-Access-Token": key,
...headers ...headers,
}, },
body: JSON.stringify({ body: JSON.stringify({
...(query && { query }), ...(query && { query }),
...(variables && { variables }) ...(variables && { variables }),
}) }),
}); });
const body = await result.json(); const body = await result.json();
@ -99,21 +99,21 @@ export async function shopifyFetch<T>({
return { return {
status: result.status, status: result.status,
body body,
}; };
} catch (e) { } catch (e) {
if (isShopifyError(e)) { if (isShopifyError(e)) {
throw { throw {
cause: e.cause?.toString() || 'unknown', cause: e.cause?.toString() || "unknown",
status: e.status || 500, status: e.status || 500,
message: e.message, message: e.message,
query query,
}; };
} }
throw { throw {
error: e, error: e,
query query,
}; };
} }
} }
@ -125,19 +125,19 @@ const removeEdgesAndNodes = <T>(array: Connection<T>): T[] => {
const reshapeCart = (cart: ShopifyCart): Cart => { 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: cart.cost.totalAmount.currencyCode,
}; };
} }
return { return {
...cart, ...cart,
lines: removeEdgesAndNodes(cart.lines) lines: removeEdgesAndNodes(cart.lines),
}; };
}; };
const reshapeCollection = ( const reshapeCollection = (
collection: ShopifyCollection collection: ShopifyCollection,
): Collection | undefined => { ): Collection | undefined => {
if (!collection) { if (!collection) {
return undefined; return undefined;
@ -145,7 +145,7 @@ const reshapeCollection = (
return { return {
...collection, ...collection,
path: `/search/${collection.handle}` path: `/search/${collection.handle}`,
}; };
}; };
@ -172,14 +172,14 @@ const reshapeImages = (images: Connection<Image>, productTitle: string) => {
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}`,
}; };
}); });
}; };
const reshapeProduct = ( const reshapeProduct = (
product: ShopifyProduct, product: ShopifyProduct,
filterHiddenProducts: boolean = true filterHiddenProducts: boolean = true,
) => { ) => {
if ( if (
!product || !product ||
@ -193,7 +193,7 @@ const reshapeProduct = (
return { return {
...rest, ...rest,
images: reshapeImages(images, product.title), images: reshapeImages(images, product.title),
variants: removeEdgesAndNodes(variants) variants: removeEdgesAndNodes(variants),
}; };
}; };
@ -215,56 +215,56 @@ 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,
}); });
return reshapeCart(res.body.data.cartCreate.cart); return reshapeCart(res.body.data.cartCreate.cart);
} }
export async function addToCart( export async function addToCart(
lines: { merchandiseId: string; quantity: number }[] lines: { merchandiseId: string; quantity: number }[],
): Promise<Cart> { ): Promise<Cart> {
const cartId = (await cookies()).get('cartId')?.value!; 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,
} },
}); });
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(lineIds: string[]): Promise<Cart> {
const cartId = (await cookies()).get('cartId')?.value!; 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,
} },
}); });
return reshapeCart(res.body.data.cartLinesRemove.cart); return reshapeCart(res.body.data.cartLinesRemove.cart);
} }
export async function updateCart( export async function updateCart(
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 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,
} },
}); });
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(): Promise<Cart | undefined> {
const cartId = (await cookies()).get('cartId')?.value; const cartId = (await cookies()).get("cartId")?.value;
if (!cartId) { if (!cartId) {
return undefined; return undefined;
@ -272,7 +272,7 @@ export async function getCart(): Promise<Cart | undefined> {
const res = await shopifyFetch<ShopifyCartOperation>({ const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery, query: getCartQuery,
variables: { cartId } variables: { cartId },
}); });
// Old carts becomes `null` when you checkout. // Old carts becomes `null` when you checkout.
@ -284,17 +284,17 @@ export async function getCart(): Promise<Cart | undefined> {
} }
export async function getCollection( export async function getCollection(
handle: string handle: string,
): Promise<Collection | undefined> { ): Promise<Collection | undefined> {
'use cache'; "use cache";
cacheTag(TAGS.collections); cacheTag(TAGS.collections);
cacheLife('days'); cacheLife("days");
const res = await shopifyFetch<ShopifyCollectionOperation>({ const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery, query: getCollectionQuery,
variables: { variables: {
handle handle,
} },
}); });
return reshapeCollection(res.body.data.collection); return reshapeCollection(res.body.data.collection);
@ -303,23 +303,23 @@ export async function getCollection(
export async function getCollectionProducts({ export async function getCollectionProducts({
collection, collection,
reverse, reverse,
sortKey sortKey,
}: { }: {
collection: string; collection: string;
reverse?: boolean; reverse?: boolean;
sortKey?: string; sortKey?: string;
}): Promise<Product[]> { }): Promise<Product[]> {
'use cache'; "use cache";
cacheTag(TAGS.collections, TAGS.products); cacheTag(TAGS.collections, TAGS.products);
cacheLife('days'); cacheLife("days");
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({ const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
query: getCollectionProductsQuery, query: getCollectionProductsQuery,
variables: { variables: {
handle: collection, handle: collection,
reverse, reverse,
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey sortKey: sortKey === "CREATED_AT" ? "CREATED" : sortKey,
} },
}); });
if (!res.body.data.collection) { if (!res.body.data.collection) {
@ -328,60 +328,60 @@ export async function getCollectionProducts({
} }
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'; "use cache";
cacheTag(TAGS.collections); cacheTag(TAGS.collections);
cacheLife('days'); cacheLife("days");
const res = await shopifyFetch<ShopifyCollectionsOperation>({ const res = await shopifyFetch<ShopifyCollectionsOperation>({
query: getCollectionsQuery query: getCollectionsQuery,
}); });
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections); const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [ const collections = [
{ {
handle: '', handle: "",
title: 'All', title: "All",
description: 'All products', description: "All products",
seo: { seo: {
title: 'All', title: "All",
description: 'All products' description: "All products",
}, },
path: '/search', path: "/search",
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString(),
}, },
// Filter out the `hidden` collections. // Filter out the `hidden` collections.
// Collections that start with `hidden-*` need to be hidden on the search page. // Collections that start with `hidden-*` need to be hidden on the search page.
...reshapeCollections(shopifyCollections).filter( ...reshapeCollections(shopifyCollections).filter(
(collection) => !collection.handle.startsWith('hidden') (collection) => !collection.handle.startsWith("hidden"),
) ),
]; ];
return collections; return collections;
} }
export async function getMenu(handle: string): Promise<Menu[]> { export async function getMenu(handle: string): Promise<Menu[]> {
'use cache'; "use cache";
cacheTag(TAGS.collections); cacheTag(TAGS.collections);
cacheLife('days'); cacheLife("days");
const res = await shopifyFetch<ShopifyMenuOperation>({ const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery, query: getMenuQuery,
variables: { variables: {
handle handle,
} },
}); });
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(domain, "")
.replace('/collections', '/search') .replace("/collections", "/search")
.replace('/pages', '') .replace("/pages", ""),
})) || [] })) || []
); );
} }
@ -389,7 +389,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,
variables: { handle } variables: { handle },
}); });
return res.body.data.pageByHandle; return res.body.data.pageByHandle;
@ -397,39 +397,39 @@ 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,
}); });
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'; "use cache";
cacheTag(TAGS.products); cacheTag(TAGS.products);
cacheLife('days'); cacheLife("days");
const res = await shopifyFetch<ShopifyProductOperation>({ const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery, query: getProductQuery,
variables: { variables: {
handle handle,
} },
}); });
return reshapeProduct(res.body.data.product, false); return reshapeProduct(res.body.data.product, false);
} }
export async function getProductRecommendations( export async function getProductRecommendations(
productId: string productId: string,
): Promise<Product[]> { ): Promise<Product[]> {
'use cache'; "use cache";
cacheTag(TAGS.products); cacheTag(TAGS.products);
cacheLife('days'); cacheLife("days");
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({ const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery, query: getProductRecommendationsQuery,
variables: { variables: {
productId productId,
} },
}); });
return reshapeProducts(res.body.data.productRecommendations); return reshapeProducts(res.body.data.productRecommendations);
@ -438,23 +438,23 @@ export async function getProductRecommendations(
export async function getProducts({ export async function getProducts({
query, query,
reverse, reverse,
sortKey sortKey,
}: { }: {
query?: string; query?: string;
reverse?: boolean; reverse?: boolean;
sortKey?: string; sortKey?: string;
}): Promise<Product[]> { }): Promise<Product[]> {
'use cache'; "use cache";
cacheTag(TAGS.products); cacheTag(TAGS.products);
cacheLife('days'); cacheLife("days");
const res = await shopifyFetch<ShopifyProductsOperation>({ const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery, query: getProductsQuery,
variables: { variables: {
query, query,
reverse, reverse,
sortKey sortKey,
} },
}); });
return reshapeProducts(removeEdgesAndNodes(res.body.data.products)); return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
@ -465,22 +465,22 @@ 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/create",
'collections/delete', "collections/delete",
'collections/update' "collections/update",
]; ];
const productWebhooks = [ const productWebhooks = [
'products/create', "products/create",
'products/delete', "products/delete",
'products/update' "products/update",
]; ];
const topic = (await headers()).get('x-shopify-topic') || 'unknown'; 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: 401 });
} }

View File

@ -1,4 +1,4 @@
import cartFragment from '../fragments/cart'; import cartFragment from "../fragments/cart";
export const addToCartMutation = /* GraphQL */ ` export const addToCartMutation = /* GraphQL */ `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) { mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {

View File

@ -1,4 +1,4 @@
import cartFragment from '../fragments/cart'; import cartFragment from "../fragments/cart";
export const getCartQuery = /* GraphQL */ ` export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) { query getCart($cartId: ID!) {

View File

@ -1,5 +1,5 @@
import productFragment from '../fragments/product'; import productFragment from "../fragments/product";
import seoFragment from '../fragments/seo'; import seoFragment from "../fragments/seo";
const collectionFragment = /* GraphQL */ ` const collectionFragment = /* GraphQL */ `
fragment collection on Collection { fragment collection on Collection {

View File

@ -1,4 +1,4 @@
import seoFragment from '../fragments/seo'; import seoFragment from "../fragments/seo";
const pageFragment = /* GraphQL */ ` const pageFragment = /* GraphQL */ `
fragment page on Page { fragment page on Page {

View File

@ -1,4 +1,4 @@
import productFragment from '../fragments/product'; import productFragment from "../fragments/product";
export const getProductQuery = /* GraphQL */ ` export const getProductQuery = /* GraphQL */ `
query getProduct($handle: String!) { query getProduct($handle: String!) {
@ -10,7 +10,11 @@ export const getProductQuery = /* GraphQL */ `
`; `;
export const getProductsQuery = /* GraphQL */ ` export const getProductsQuery = /* GraphQL */ `
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) { query getProducts(
$sortKey: ProductSortKeys
$reverse: Boolean
$query: String
) {
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) { products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
edges { edges {
node { node {

View File

@ -8,7 +8,7 @@ export type Edge<T> = {
node: T; node: T;
}; };
export type Cart = Omit<ShopifyCart, 'lines'> & { export type Cart = Omit<ShopifyCart, "lines"> & {
lines: CartItem[]; lines: CartItem[];
}; };
@ -68,7 +68,7 @@ export type Page = {
updatedAt: string; updatedAt: string;
}; };
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & { export type Product = Omit<ShopifyProduct, "variants" | "images"> & {
variants: ProductVariant[]; variants: ProductVariant[];
images: Image[]; images: Image[];
}; };

View File

@ -4,8 +4,12 @@ export interface ShopifyErrorLike {
cause?: Error; cause?: Error;
} }
export const isObject = (object: unknown): object is Record<string, unknown> => { export const isObject = (
return typeof object === 'object' && object !== null && !Array.isArray(object); object: unknown,
): object is Record<string, unknown> => {
return (
typeof object === "object" && object !== null && !Array.isArray(object)
);
}; };
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => { export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
@ -17,7 +21,7 @@ export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
}; };
function findError<T extends object>(error: T): boolean { function findError<T extends object>(error: T): boolean {
if (Object.prototype.toString.call(error) === '[object Error]') { if (Object.prototype.toString.call(error) === "[object Error]") {
return true; return true;
} }

View File

@ -1,15 +1,15 @@
import { ReadonlyURLSearchParams } from 'next/navigation'; import { ReadonlyURLSearchParams } from "next/navigation";
export const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL export const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: 'http://localhost:3000'; : "http://localhost:3000";
export const createUrl = ( export const createUrl = (
pathname: string, pathname: string,
params: URLSearchParams | ReadonlyURLSearchParams params: URLSearchParams | ReadonlyURLSearchParams,
) => { ) => {
const paramsString = params.toString(); const paramsString = params.toString();
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`; const queryString = `${paramsString.length ? "?" : ""}${paramsString}`;
return `${pathname}${queryString}`; return `${pathname}${queryString}`;
}; };
@ -21,8 +21,8 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
export const validateEnvironmentVariables = () => { export const validateEnvironmentVariables = () => {
const requiredEnvironmentVariables = [ const requiredEnvironmentVariables = [
'SHOPIFY_STORE_DOMAIN', "SHOPIFY_STORE_DOMAIN",
'SHOPIFY_STOREFRONT_ACCESS_TOKEN' "SHOPIFY_STOREFRONT_ACCESS_TOKEN",
]; ];
const missingEnvironmentVariables = [] as string[]; const missingEnvironmentVariables = [] as string[];
@ -35,17 +35,17 @@ export const validateEnvironmentVariables = () => {
if (missingEnvironmentVariables.length) { if (missingEnvironmentVariables.length) {
throw new Error( 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( `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",
)}\n` )}\n`,
); );
} }
if ( if (
process.env.SHOPIFY_STORE_DOMAIN?.includes('[') || process.env.SHOPIFY_STORE_DOMAIN?.includes("[") ||
process.env.SHOPIFY_STORE_DOMAIN?.includes(']') process.env.SHOPIFY_STORE_DOMAIN?.includes("]")
) { ) {
throw new Error( 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.' "Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.",
); );
} }
}; };

View File

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

1277
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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