Merge branch 'main' into poc/react-nextjs-new-design

This commit is contained in:
Björn Meyer 2023-08-04 13:21:43 +02:00
commit 9a594a84bc
19 changed files with 202 additions and 151 deletions

2
.nvmrc
View File

@ -1 +1 @@
16 18

View File

@ -1,37 +1,8 @@
import { TAGS } from 'lib/constants'; import { revalidate } from 'lib/shopify';
import { revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'edge'; export const runtime = 'edge';
// We always need to respond with a 200 status code to Shopify, export async function POST(req: NextRequest): Promise<NextResponse> {
// otherwise it will continue to retry the request. return revalidate(req);
export async function POST(req: NextRequest): Promise<Response> {
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const topic = headers().get('x-shopify-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 200 });
}
if (!isCollectionUpdate && !isProductUpdate) {
// We don't need to revalidate anything for any other topics.
return NextResponse.json({ status: 200 });
}
if (isCollectionUpdate) {
revalidateTag(TAGS.collections);
}
if (isProductUpdate) {
revalidateTag(TAGS.products);
}
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -24,17 +24,17 @@ export async function generateMetadata({
if (!product) return notFound(); if (!product) return notFound();
const { url, width, height, altText: alt } = product.featuredImage || {}; const { url, width, height, altText: alt } = product.featuredImage || {};
const hide = !product.tags.includes(HIDDEN_PRODUCT_TAG); const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
return { return {
title: product.seo.title || product.title, title: product.seo.title || product.title,
description: product.seo.description || product.description, description: product.seo.description || product.description,
robots: { robots: {
index: hide, index: indexable,
follow: hide, follow: indexable,
googleBot: { googleBot: {
index: hide, index: indexable,
follow: hide follow: indexable
} }
}, },
openGraph: url openGraph: url
@ -83,8 +83,8 @@ export default async function ProductPage({ params }: { params: { handle: string
}} }}
/> />
<div className="mx-auto max-w-screen-2xl px-4"> <div className="mx-auto max-w-screen-2xl px-4">
<div className="rounded-lg border border-neutral-200 bg-white p-8 px-4 dark:border-neutral-800 dark:bg-black md:p-12 lg:grid lg:grid-cols-6"> <div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row">
<div className="lg:col-span-4"> <div className="h-full w-full basis-full lg:basis-4/6">
<Gallery <Gallery
images={product.images.map((image: Image) => ({ images={product.images.map((image: Image) => ({
src: image.url, src: image.url,
@ -93,7 +93,7 @@ export default async function ProductPage({ params }: { params: { handle: string
/> />
</div> </div>
<div className="py-6 pr-8 md:pr-12 lg:col-span-2"> <div className="basis-full lg:basis-2/6">
<ProductDescription product={product} /> <ProductDescription product={product} />
</div> </div>
</div> </div>
@ -116,14 +116,13 @@ async function RelatedProducts({ id }: { id: string }) {
return ( return (
<div className="py-8"> <div className="py-8">
<h2 className="mb-4 text-2xl font-bold">Related Products</h2> <h2 className="mb-4 text-2xl font-bold">Related Products</h2>
<div className="flex w-full gap-4 overflow-x-auto pt-1"> <ul className="flex w-full gap-4 overflow-x-auto pt-1">
{relatedProducts.map((product, i) => { {relatedProducts.map((product) => (
return ( <li
<Link key={product.path}
key={i} className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
className="w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5" >
href={`/product/${product.path}`} <Link className="relative h-full w-full" href={`/product/${product.path}`}>
>
<GridTileImage <GridTileImage
alt={product.title} alt={product.title}
label={{ label={{
@ -132,13 +131,13 @@ async function RelatedProducts({ id }: { id: string }) {
currencyCode: product.priceRange.maxVariantPrice.currencyCode currencyCode: product.priceRange.maxVariantPrice.currencyCode
}} }}
src={product.featuredImage?.url} src={product.featuredImage?.url}
width={500} fill
height={500} sizes="(min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, (min-width: 475px) 50vw, 100vw"
/> />
</Link> </Link>
); </li>
})} ))}
</div> </ul>
</div> </div>
); );
} }

View File

@ -26,7 +26,7 @@ export default async function SearchPage({
<> <>
{searchValue && products.length === 0 ? ( {searchValue && products.length === 0 ? (
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row"> <div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
<p> <p className="mb-4">
{'There are no products that match '} {'There are no products that match '}
<span className="font-bold">&quot;{searchValue}&quot;</span> <span className="font-bold">&quot;{searchValue}&quot;</span>
</p> </p>
@ -36,7 +36,7 @@ export default async function SearchPage({
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row"> <div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
<div className="order-first w-full flex-none md:max-w-[125px]"> <div className="order-first w-full flex-none md:max-w-[125px]">
{searchValue ? ( {searchValue ? (
<p className="text-sm text-neutral-500"> <p className="mb-4 text-sm text-neutral-500">
{`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,11 +1,16 @@
import { getProductSeoUrls } from 'lib/shopware'; import { getProductSeoUrls } from 'lib/shopware';
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
type Route = {
url: string;
lastModified: string;
};
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000'; : 'http://localhost:3000';
export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.Sitemap>>> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const routesMap = [''].map((route) => ({ const routesMap = [''].map((route) => ({
url: `${baseUrl}${route}`, url: `${baseUrl}${route}`,
lastModified: new Date().toISOString() lastModified: new Date().toISOString()
@ -19,7 +24,13 @@ export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.S
})) }))
); );
const fetchedRoutes = (await Promise.all([productsPromise])).flat(); let fetchedRoutes: Route[] = [];
try {
fetchedRoutes = (await Promise.all([productsPromise])).flat();
} catch (error) {
throw JSON.stringify(error, null, 2);
}
return [...routesMap, ...fetchedRoutes]; return [...routesMap, ...fetchedRoutes];
} }

View File

@ -13,29 +13,33 @@ export async function Carousel() {
if (!products?.length) return null; if (!products?.length) return null;
// Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
const carouselProducts = [...products, ...products, ...products];
return ( return (
<div className="w-full overflow-x-auto pb-6 pt-1"> <div className=" w-full overflow-x-auto pb-6 pt-1">
<div className="flex animate-carousel gap-4"> <ul className="flex animate-carousel gap-4">
{[...products, ...products].map((product, i) => ( {carouselProducts.map((product, i) => (
<Link <li
key={`${product.path}${i}`} key={`${product.path}${i}`}
href={`/product/${product.path}`} className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
className="h-[30vh] w-2/3 flex-none md:w-1/3"
> >
<GridTileImage <Link href={`/product/${product.path}`} className="relative h-full w-full">
alt={product.title} <GridTileImage
label={{ alt={product.title}
title: product.title, label={{
amount: product.priceRange.maxVariantPrice.amount, title: product.title,
currencyCode: product.priceRange.maxVariantPrice.currencyCode amount: product.priceRange.maxVariantPrice.amount,
}} currencyCode: product.priceRange.maxVariantPrice.currencyCode
src={product.featuredImage?.url} }}
width={600} src={product.featuredImage?.url}
height={600} fill
/> sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
</Link> />
</Link>
</li>
))} ))}
</div> </ul>
</div> </div>
); );
} }

View File

@ -25,7 +25,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
const closeCart = () => setIsOpen(false); const closeCart = () => setIsOpen(false);
useEffect(() => { useEffect(() => {
// Open cart modal when when quantity changes. // Open cart modal when quantity changes.
if (cart?.totalQuantity !== quantityRef.current) { if (cart?.totalQuantity !== quantityRef.current) {
// But only if it's not already open (quantity also changes when editing items in cart). // But only if it's not already open (quantity also changes when editing items in cart).
if (!isOpen) { if (!isOpen) {
@ -111,7 +111,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
> >
<div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800"> <div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<Image <Image
className="h-full w-full object-cover " className="h-full w-full object-cover"
width={64} width={64}
height={64} height={64}
alt={ alt={
@ -141,7 +141,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
/> />
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700"> <div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
<EditItemQuantityButton item={item} type="minus" /> <EditItemQuantityButton item={item} type="minus" />
<p className="w-6 text-center "> <p className="w-6 text-center">
<span className="w-full text-sm">{item.quantity}</span> <span className="w-full text-sm">{item.quantity}</span>
</p> </p>
<EditItemQuantityButton item={item} type="plus" /> <EditItemQuantityButton item={item} type="plus" />

View File

@ -4,17 +4,27 @@ import { isSeoUrls } from 'lib/shopware/helpers';
import type { Product } from 'lib/shopware/types'; import type { Product } from 'lib/shopware/types';
import Link from 'next/link'; import Link from 'next/link';
function ThreeItemGridItem({ item, size }: { item: Product; size: 'full' | 'half' }) { function ThreeItemGridItem({
item,
size,
priority
}: {
item: Product;
size: 'full' | 'half';
priority?: boolean;
}) {
return ( return (
<div <div
className={size === 'full' ? 'lg:col-span-4 lg:row-span-2' : 'lg:col-span-2 lg:row-span-1'} className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
> >
<Link className="block h-full" href={`/product/${item.path}`}> <Link className="relative block aspect-square h-full w-full" href={`/product/${item.path}`}>
<GridTileImage <GridTileImage
src={item.featuredImage.url} src={item.featuredImage.url}
width={size === 'full' ? 1080 : 540} fill
height={size === 'full' ? 1080 : 540} sizes={
priority={true} size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
}
priority={priority}
alt={item.title} alt={item.title}
label={{ label={{
position: size === 'full' ? 'center' : 'bottom', position: size === 'full' ? 'center' : 'bottom',
@ -42,9 +52,9 @@ export async function ThreeItemGrid() {
const [firstProduct, secondProduct, thirdProduct] = homepageItems; const [firstProduct, secondProduct, thirdProduct] = homepageItems;
return ( return (
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 lg:grid-cols-6 lg:grid-rows-2"> <section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2">
<ThreeItemGridItem size="full" item={firstProduct} /> <ThreeItemGridItem size="full" item={firstProduct} priority={true} />
<ThreeItemGridItem size="half" item={secondProduct} /> <ThreeItemGridItem size="half" item={secondProduct} priority={true} />
<ThreeItemGridItem size="half" item={thirdProduct} /> <ThreeItemGridItem size="half" item={thirdProduct} />
</section> </section>
); );

View File

@ -33,8 +33,7 @@ export function GridTileImage({
<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 hover:scale-105': isInteractive, 'transition duration-300 ease-in-out hover:scale-105': isInteractive,
'max-h-[4rem] min-h-[4rem]': props.width === 200 && props.height === 200, // this styling is for the thumbnails below gallery on product detail page 'max-h-[4rem] min-h-[4rem]': props.width === 200 && props.height === 200 // this styling is for the thumbnails below gallery on product detail page
'max-h-[20rem] min-h-[20rem]': props.width === 500 && props.height === 500 // this styling is for the gallery in recommendations on product detail page
})} })}
{...props} {...props}
/> />

View File

@ -15,11 +15,11 @@ const FooterMenuItem = ({ item }: { item: Menu }) => {
}, [pathname, item.path]); }, [pathname, item.path]);
return ( return (
<li key={item.title} className="mt-2 first:mt-1"> <li>
<Link <Link
href={item.path} href={item.path}
className={clsx( className={clsx(
'underline-offset-4 hover:text-black hover:underline dark:hover:text-neutral-300', 'block p-2 text-lg underline-offset-4 hover:text-black hover:underline dark:hover:text-neutral-300 md:inline-block md:text-sm',
{ {
'text-black dark:text-neutral-300': active 'text-black dark:text-neutral-300': active
} }

View File

@ -21,7 +21,7 @@ export default async function Footer() {
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 xl:px-0"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 xl:px-0">
<div className="grid grid-cols-1 gap-8 transition-colors duration-150 lg:grid-cols-12"> <div className="grid grid-cols-1 gap-8 transition-colors duration-150 lg:grid-cols-12">
<div className="col-span-1 lg:col-span-3"> <div className="col-span-1 lg:col-span-3">
<Link className="flex items-center gap-2 text-black dark:text-white" href="/"> <Link className="flex items-center gap-2 text-black dark:text-white md:pt-1" href="/">
<LogoSquare size="sm" /> <LogoSquare size="sm" />
<span className="uppercase">{SITE_NAME}</span> <span className="uppercase">{SITE_NAME}</span>
</Link> </Link>

View File

@ -26,12 +26,12 @@ export default async function Navbar() {
<LogoSquare /> <LogoSquare />
</Link> </Link>
{menu.length ? ( {menu.length ? (
<ul className="hidden text-sm md:flex md:items-center"> <ul className="hidden gap-6 text-sm md:flex md:items-center">
{menu.map((item: Menu) => ( {menu.map((item: Menu) => (
<li key={item.title}> <li key={item.title}>
<Link <Link
href={item.path} href={item.path}
className="mr-3 text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300 lg:mr-8" className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300"
> >
{item.title} {item.title}
</Link> </Link>

View File

@ -32,8 +32,12 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
return ( return (
<> <>
<button onClick={openMobileMenu} aria-label="Open mobile menu" className="md:hidden"> <button
<Bars3Icon className="h-6" /> onClick={openMobileMenu}
aria-label="Open mobile menu"
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white md:hidden"
>
<Bars3Icon className="h-4" />
</button> </button>
<Transition show={isOpen}> <Transition show={isOpen}>
<Dialog onClose={closeMobileMenu} className="relative z-50"> <Dialog onClose={closeMobileMenu} className="relative z-50">
@ -59,7 +63,11 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
> >
<Dialog.Panel className="fixed bottom-0 left-0 right-0 top-0 flex h-full w-full flex-col bg-white pb-6 dark:bg-black"> <Dialog.Panel className="fixed bottom-0 left-0 right-0 top-0 flex h-full w-full flex-col bg-white pb-6 dark:bg-black">
<div className="p-4"> <div className="p-4">
<button className="mb-4" onClick={closeMobileMenu} aria-label="Close mobile menu"> <button
className="mb-4 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"
onClick={closeMobileMenu}
aria-label="Close mobile menu"
>
<XMarkIcon className="h-6" /> <XMarkIcon className="h-6" />
</button> </button>
@ -67,14 +75,13 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
<Search /> <Search />
</div> </div>
{menu.length ? ( {menu.length ? (
<ul className="flex flex-col"> <ul className="flex w-full flex-col">
{menu.map((item: Menu) => ( {menu.map((item: Menu) => (
<li key={item.title}> <li
<Link className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
href={item.path} key={item.title}
className="rounded-lg py-1 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white" >
onClick={closeMobileMenu} <Link href={item.path} onClick={closeMobileMenu}>
>
{item.title} {item.title}
</Link> </Link>
</li> </li>

View File

@ -32,7 +32,7 @@ export default function Search() {
} }
return ( return (
<form onSubmit={onSubmit} className="relative w-full lg:w-[320px]"> <form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<input <input
type="text" type="text"
name="search" name="search"

View File

@ -8,7 +8,7 @@ export default function ProductGridItems({ products }: { products: Product[] })
<> <>
{products.map((product) => ( {products.map((product) => (
<Grid.Item key={product.path} className="animate-fadeIn"> <Grid.Item key={product.path} className="animate-fadeIn">
<Link className="inline-block h-full w-full" href={`/product/${product.path}`}> <Link className="relative inline-block h-full w-full" href={`/product/${product.path}`}>
<GridTileImage <GridTileImage
alt={product.title} alt={product.title}
label={{ label={{
@ -17,8 +17,8 @@ export default function ProductGridItems({ products }: { products: Product[] })
currencyCode: product.priceRange.maxVariantPrice.currencyCode currencyCode: product.priceRange.maxVariantPrice.currencyCode
}} }}
src={product.featuredImage?.url} src={product.featuredImage?.url}
width={600} fill
height={600} sizes="(min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
/> />
</Link> </Link>
</Grid.Item> </Grid.Item>

View File

@ -2,33 +2,40 @@
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 { createUrl } from 'lib/utils';
import Image from 'next/image'; import Image from 'next/image';
import { useState } from 'react'; import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
export function Gallery({ images }: { images: { src: string; altText: string }[] }) { export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
const [currentImageIndex, setCurrentImageIndex] = useState(0); const pathname = usePathname();
const searchParams = useSearchParams();
const imageSearchParam = searchParams.get('image');
const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
function handleNavigate(direction: 'next' | 'previous') { const nextSearchParams = new URLSearchParams(searchParams.toString());
if (direction === 'next') { const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
setCurrentImageIndex(currentImageIndex + 1 < images.length ? currentImageIndex + 1 : 0); nextSearchParams.set('image', nextImageIndex.toString());
} else { const nextUrl = createUrl(pathname, nextSearchParams);
setCurrentImageIndex(currentImageIndex === 0 ? images.length - 1 : currentImageIndex - 1);
} const previousSearchParams = new URLSearchParams(searchParams.toString());
} const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
previousSearchParams.set('image', previousImageIndex.toString());
const previousUrl = createUrl(pathname, previousSearchParams);
const buttonClassName = const buttonClassName =
'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white'; '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 (
<div className="mr-8 h-full"> <div className="mr-8 h-full">
<div className="relative mb-12 h-full max-h-[550px] min-h-[550px] overflow-hidden"> <div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
{images[currentImageIndex] && ( {images[imageIndex] && (
<Image <Image
className="relative h-full w-full object-contain" className="h-full w-full object-contain"
height={600} fill
width={600} sizes="(min-width: 1024px) 66vw, 100vw"
alt={images[currentImageIndex]?.altText as string} alt={images[imageIndex]?.altText as string}
src={images[currentImageIndex]?.src as string} src={images[imageIndex]?.src as string}
priority={true} priority={true}
/> />
)} )}
@ -36,48 +43,56 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
{images.length > 1 ? ( {images.length > 1 ? (
<div className="absolute bottom-[15%] flex w-full justify-center"> <div className="absolute bottom-[15%] flex w-full justify-center">
<div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur dark:border-black dark:bg-neutral-900/80"> <div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur dark:border-black dark:bg-neutral-900/80">
<button <Link
aria-label="Previous product image" aria-label="Previous product image"
onClick={() => handleNavigate('previous')} href={previousUrl}
className={buttonClassName} className={buttonClassName}
scroll={false}
> >
<ArrowLeftIcon className="h-5" /> <ArrowLeftIcon className="h-5" />
</button> </Link>
<div className="mx-1 h-6 w-px bg-neutral-500"></div> <div className="mx-1 h-6 w-px bg-neutral-500"></div>
<button <Link
aria-label="Next product image" aria-label="Next product image"
onClick={() => handleNavigate('next')} href={nextUrl}
className={buttonClassName} className={buttonClassName}
scroll={false}
> >
<ArrowRightIcon className="h-5" /> <ArrowRightIcon className="h-5" />
</button> </Link>
</div> </div>
</div> </div>
) : null} ) : null}
</div> </div>
{images.length > 1 ? ( {images.length > 1 ? (
<div className="flex items-center justify-center gap-2 overflow-auto py-1"> <ul className="my-12 flex items-center justify-center gap-2 overflow-auto py-1 lg:mb-0">
{images.map((image, index) => { {images.map((image, index) => {
const isActive = index === currentImageIndex; const isActive = index === imageIndex;
const imageSearchParams = new URLSearchParams(searchParams.toString());
imageSearchParams.set('image', index.toString());
return ( return (
<button <li key={image.src} className="h-auto w-20">
aria-label="Enlarge product image" <Link
key={image.src} aria-label="Enlarge product image"
className="h-auto w-20" href={createUrl(pathname, imageSearchParams)}
onClick={() => setCurrentImageIndex(index)} scroll={false}
> className="h-full w-full"
<GridTileImage >
alt={image.altText} <GridTileImage
src={image.src} alt={image.altText}
width={200} src={image.src}
height={200} width={200}
active={isActive} height={200}
/> active={isActive}
</button> />
</Link>
</li>
); );
})} })}
</div> </ul>
) : null} ) : null}
</div> </div>
); );

View File

@ -1,5 +1,8 @@
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants'; import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
import { isShopifyError } from 'lib/type-guards'; import { isShopifyError } from 'lib/type-guards';
import { revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { import {
addToCartMutation, addToCartMutation,
createCartMutation, createCartMutation,
@ -408,3 +411,35 @@ export async function getProducts({
return reshapeProducts(removeEdgesAndNodes(res.body.data.products)); return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
} }
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
export async function revalidate(req: NextRequest): Promise<NextResponse> {
// We always need to respond with a 200 status code to Shopify,
// otherwise it will continue to retry the request.
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const topic = headers().get('x-shopify-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 200 });
}
if (!isCollectionUpdate && !isProductUpdate) {
// We don't need to revalidate anything for any other topics.
return NextResponse.json({ status: 200 });
}
if (isCollectionUpdate) {
revalidateTag(TAGS.collections);
}
if (isProductUpdate) {
revalidateTag(TAGS.products);
}
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
}

View File

@ -3,7 +3,7 @@
"packageManager": "pnpm@8.2.0", "packageManager": "pnpm@8.2.0",
"engines": { "engines": {
"node": ">=18", "node": ">=18",
"pnpm": ">=8" "pnpm": ">=7"
}, },
"scripts": { "scripts": {
"build": "next build", "build": "next build",