diff --git a/.nvmrc b/.nvmrc index b6a7d89c6..3c032078a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16 +18 diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts index 94ddfff9b..47af2a4a4 100644 --- a/app/api/revalidate/route.ts +++ b/app/api/revalidate/route.ts @@ -1,37 +1,8 @@ -import { TAGS } from 'lib/constants'; -import { revalidateTag } from 'next/cache'; -import { headers } from 'next/headers'; +import { revalidate } from 'lib/shopify'; import { NextRequest, NextResponse } from 'next/server'; export const runtime = 'edge'; -// We always need to respond with a 200 status code to Shopify, -// otherwise it will continue to retry the request. -export async function POST(req: NextRequest): Promise { - 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() }); +export async function POST(req: NextRequest): Promise { + return revalidate(req); } diff --git a/app/favicon.ico b/app/favicon.ico index c4826c947..dc7d8431e 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/product/[...handle]/page.tsx b/app/product/[...handle]/page.tsx index dec1d895f..c476022c9 100644 --- a/app/product/[...handle]/page.tsx +++ b/app/product/[...handle]/page.tsx @@ -24,17 +24,17 @@ export async function generateMetadata({ if (!product) return notFound(); 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 { title: product.seo.title || product.title, description: product.seo.description || product.description, robots: { - index: hide, - follow: hide, + index: indexable, + follow: indexable, googleBot: { - index: hide, - follow: hide + index: indexable, + follow: indexable } }, openGraph: url @@ -83,8 +83,8 @@ export default async function ProductPage({ params }: { params: { handle: string }} />
-
-
+
+
({ src: image.url, @@ -93,7 +93,7 @@ export default async function ProductPage({ params }: { params: { handle: string />
-
+
@@ -116,14 +116,13 @@ async function RelatedProducts({ id }: { id: string }) { return (

Related Products

-
- {relatedProducts.map((product, i) => { - return ( - +
    + {relatedProducts.map((product) => ( +
  • + - ); - })} -
+ + ))} +
); } diff --git a/app/search/page.tsx b/app/search/page.tsx index ae3fb916b..962f4fbc0 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -26,7 +26,7 @@ export default async function SearchPage({ <> {searchValue && products.length === 0 ? (
-

+

{'There are no products that match '} "{searchValue}"

@@ -36,7 +36,7 @@ export default async function SearchPage({
{searchValue ? ( -

+

{`Showing ${products.length} ${resultsText} for `} "{searchValue}"

diff --git a/app/sitemap.ts b/app/sitemap.ts index e1196555b..06fe7306a 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,11 +1,16 @@ import { getProductSeoUrls } from 'lib/shopware'; import { MetadataRoute } from 'next'; +type Route = { + url: string; + lastModified: string; +}; + const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` : 'http://localhost:3000'; -export default async function sitemap(): Promise>> { +export default async function sitemap(): Promise { const routesMap = [''].map((route) => ({ url: `${baseUrl}${route}`, lastModified: new Date().toISOString() @@ -19,7 +24,13 @@ export default async function sitemap(): Promise -
- {[...products, ...products].map((product, i) => ( - +
    + {carouselProducts.map((product, i) => ( +
  • - - + + + +
  • ))} -
+
); } diff --git a/components/cart/modal.tsx b/components/cart/modal.tsx index 7423bcbfd..98d7d5191 100644 --- a/components/cart/modal.tsx +++ b/components/cart/modal.tsx @@ -25,7 +25,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) { const closeCart = () => setIsOpen(false); useEffect(() => { - // Open cart modal when when quantity changes. + // Open cart modal when quantity changes. if (cart?.totalQuantity !== quantityRef.current) { // But only if it's not already open (quantity also changes when editing items in cart). if (!isOpen) { @@ -111,7 +111,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) { >
{
-

+

{item.quantity}

diff --git a/components/grid/three-items.tsx b/components/grid/three-items.tsx index 59a3e799f..f19e30db0 100644 --- a/components/grid/three-items.tsx +++ b/components/grid/three-items.tsx @@ -4,17 +4,27 @@ import { isSeoUrls } from 'lib/shopware/helpers'; import type { Product } from 'lib/shopware/types'; 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 (
- + - - +
+ +
); diff --git a/components/grid/tile.tsx b/components/grid/tile.tsx index 3bd9da080..959ec19d5 100644 --- a/components/grid/tile.tsx +++ b/components/grid/tile.tsx @@ -33,8 +33,7 @@ export function GridTileImage({ diff --git a/components/layout/footer-menu.tsx b/components/layout/footer-menu.tsx index 22be798be..0c606a134 100644 --- a/components/layout/footer-menu.tsx +++ b/components/layout/footer-menu.tsx @@ -15,11 +15,11 @@ const FooterMenuItem = ({ item }: { item: Menu }) => { }, [pathname, item.path]); return ( -
  • +
  • - + {SITE_NAME} diff --git a/components/layout/navbar/index.tsx b/components/layout/navbar/index.tsx index 178712e8c..d618cc5de 100644 --- a/components/layout/navbar/index.tsx +++ b/components/layout/navbar/index.tsx @@ -26,12 +26,12 @@ export default async function Navbar() { {menu.length ? ( -
      +
        {menu.map((item: Menu) => (
      • {item.title} diff --git a/components/layout/navbar/mobile-menu.tsx b/components/layout/navbar/mobile-menu.tsx index 1f9862614..8b571190c 100644 --- a/components/layout/navbar/mobile-menu.tsx +++ b/components/layout/navbar/mobile-menu.tsx @@ -32,8 +32,12 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) { return ( <> - @@ -59,7 +63,11 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) { >
        - @@ -67,14 +75,13 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
        {menu.length ? ( -
          +
            {menu.map((item: Menu) => ( -
          • - +
          • + {item.title}
          • diff --git a/components/layout/navbar/search.tsx b/components/layout/navbar/search.tsx index e6d9ce307..c7a410b9d 100644 --- a/components/layout/navbar/search.tsx +++ b/components/layout/navbar/search.tsx @@ -32,7 +32,7 @@ export default function Search() { } return ( -
            + {products.map((product) => ( - + diff --git a/components/product/gallery.tsx b/components/product/gallery.tsx index 5d65bca7e..4d442e61c 100644 --- a/components/product/gallery.tsx +++ b/components/product/gallery.tsx @@ -2,33 +2,40 @@ import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; import { GridTileImage } from 'components/grid/tile'; +import { createUrl } from 'lib/utils'; 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 }[] }) { - 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') { - if (direction === 'next') { - setCurrentImageIndex(currentImageIndex + 1 < images.length ? currentImageIndex + 1 : 0); - } else { - setCurrentImageIndex(currentImageIndex === 0 ? images.length - 1 : currentImageIndex - 1); - } - } + const nextSearchParams = new URLSearchParams(searchParams.toString()); + const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0; + nextSearchParams.set('image', nextImageIndex.toString()); + const nextUrl = createUrl(pathname, nextSearchParams); + + const previousSearchParams = new URLSearchParams(searchParams.toString()); + const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1; + previousSearchParams.set('image', previousImageIndex.toString()); + const previousUrl = createUrl(pathname, previousSearchParams); 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 (
            -
            - {images[currentImageIndex] && ( +
            + {images[imageIndex] && ( {images[currentImageIndex]?.altText )} @@ -36,48 +43,56 @@ export function Gallery({ images }: { images: { src: string; altText: string }[] {images.length > 1 ? (
            - +
            - +
            ) : null}
            {images.length > 1 ? ( -
            +
              {images.map((image, index) => { - const isActive = index === currentImageIndex; + const isActive = index === imageIndex; + const imageSearchParams = new URLSearchParams(searchParams.toString()); + + imageSearchParams.set('image', index.toString()); + return ( - +
            • + + + +
            • ); })} -
            +
          ) : null}
    ); diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index 64b5d24e8..a8804d045 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -1,5 +1,8 @@ import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants'; import { isShopifyError } from 'lib/type-guards'; +import { revalidateTag } from 'next/cache'; +import { headers } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; import { addToCartMutation, createCartMutation, @@ -408,3 +411,35 @@ export async function getProducts({ 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 { + // 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() }); +} diff --git a/package.json b/package.json index db9cd20ca..bff1c09d5 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "packageManager": "pnpm@8.2.0", "engines": { "node": ">=18", - "pnpm": ">=8" + "pnpm": ">=7" }, "scripts": { "build": "next build",