mirror of
https://github.com/vercel/commerce.git
synced 2025-05-19 07:56:59 +00:00
Merge branch 'main' into poc/react-nextjs-new-design
This commit is contained in:
commit
9a594a84bc
@ -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<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() });
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
return revalidate(req);
|
||||
}
|
||||
|
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
Before Width: | Height: | Size: 535 B After Width: | Height: | Size: 15 KiB |
@ -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
|
||||
}}
|
||||
/>
|
||||
<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="lg:col-span-4">
|
||||
<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="h-full w-full basis-full lg:basis-4/6">
|
||||
<Gallery
|
||||
images={product.images.map((image: Image) => ({
|
||||
src: image.url,
|
||||
@ -93,7 +93,7 @@ export default async function ProductPage({ params }: { params: { handle: string
|
||||
/>
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
@ -116,14 +116,13 @@ async function RelatedProducts({ id }: { id: string }) {
|
||||
return (
|
||||
<div className="py-8">
|
||||
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
|
||||
<div className="flex w-full gap-4 overflow-x-auto pt-1">
|
||||
{relatedProducts.map((product, i) => {
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
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}`}
|
||||
>
|
||||
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
|
||||
{relatedProducts.map((product) => (
|
||||
<li
|
||||
key={product.path}
|
||||
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
|
||||
>
|
||||
<Link className="relative h-full w-full" href={`/product/${product.path}`}>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
@ -132,13 +131,13 @@ async function RelatedProducts({ id }: { id: string }) {
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
width={500}
|
||||
height={500}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, (min-width: 475px) 50vw, 100vw"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ export default async function SearchPage({
|
||||
<>
|
||||
{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">
|
||||
<p>
|
||||
<p className="mb-4">
|
||||
{'There are no products that match '}
|
||||
<span className="font-bold">"{searchValue}"</span>
|
||||
</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="order-first w-full flex-none md:max-w-[125px]">
|
||||
{searchValue ? (
|
||||
<p className="text-sm text-neutral-500">
|
||||
<p className="mb-4 text-sm text-neutral-500">
|
||||
{`Showing ${products.length} ${resultsText} for `}
|
||||
<span className="font-bold">"{searchValue}"</span>
|
||||
</p>
|
||||
|
@ -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<Promise<Promise<MetadataRoute.Sitemap>>> {
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const routesMap = [''].map((route) => ({
|
||||
url: `${baseUrl}${route}`,
|
||||
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];
|
||||
}
|
||||
|
@ -13,29 +13,33 @@ export async function Carousel() {
|
||||
|
||||
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 (
|
||||
<div className="w-full overflow-x-auto pb-6 pt-1">
|
||||
<div className="flex animate-carousel gap-4">
|
||||
{[...products, ...products].map((product, i) => (
|
||||
<Link
|
||||
<div className=" w-full overflow-x-auto pb-6 pt-1">
|
||||
<ul className="flex animate-carousel gap-4">
|
||||
{carouselProducts.map((product, i) => (
|
||||
<li
|
||||
key={`${product.path}${i}`}
|
||||
href={`/product/${product.path}`}
|
||||
className="h-[30vh] w-2/3 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"
|
||||
>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
width={600}
|
||||
height={600}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={`/product/${product.path}`} className="relative h-full w-full">
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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 }) {
|
||||
>
|
||||
<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
|
||||
className="h-full w-full object-cover "
|
||||
className="h-full w-full object-cover"
|
||||
width={64}
|
||||
height={64}
|
||||
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">
|
||||
<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>
|
||||
</p>
|
||||
<EditItemQuantityButton item={item} type="plus" />
|
||||
|
@ -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 (
|
||||
<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
|
||||
src={item.featuredImage.url}
|
||||
width={size === 'full' ? 1080 : 540}
|
||||
height={size === 'full' ? 1080 : 540}
|
||||
priority={true}
|
||||
fill
|
||||
sizes={
|
||||
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
|
||||
}
|
||||
priority={priority}
|
||||
alt={item.title}
|
||||
label={{
|
||||
position: size === 'full' ? 'center' : 'bottom',
|
||||
@ -42,9 +52,9 @@ export async function ThreeItemGrid() {
|
||||
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
||||
|
||||
return (
|
||||
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 lg:grid-cols-6 lg:grid-rows-2">
|
||||
<ThreeItemGridItem size="full" item={firstProduct} />
|
||||
<ThreeItemGridItem size="half" item={secondProduct} />
|
||||
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2">
|
||||
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={thirdProduct} />
|
||||
</section>
|
||||
);
|
||||
|
@ -33,8 +33,7 @@ export function GridTileImage({
|
||||
<Image
|
||||
className={clsx('relative h-full w-full object-contain', {
|
||||
'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-[20rem] min-h-[20rem]': props.width === 500 && props.height === 500 // this styling is for the gallery in recommendations 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
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -15,11 +15,11 @@ const FooterMenuItem = ({ item }: { item: Menu }) => {
|
||||
}, [pathname, item.path]);
|
||||
|
||||
return (
|
||||
<li key={item.title} className="mt-2 first:mt-1">
|
||||
<li>
|
||||
<Link
|
||||
href={item.path}
|
||||
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
|
||||
}
|
||||
|
@ -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="grid grid-cols-1 gap-8 transition-colors duration-150 lg:grid-cols-12">
|
||||
<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" />
|
||||
<span className="uppercase">{SITE_NAME}</span>
|
||||
</Link>
|
||||
|
@ -26,12 +26,12 @@ export default async function Navbar() {
|
||||
<LogoSquare />
|
||||
</Link>
|
||||
{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) => (
|
||||
<li key={item.title}>
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
|
@ -32,8 +32,12 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={openMobileMenu} aria-label="Open mobile menu" className="md:hidden">
|
||||
<Bars3Icon className="h-6" />
|
||||
<button
|
||||
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>
|
||||
<Transition show={isOpen}>
|
||||
<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">
|
||||
<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" />
|
||||
</button>
|
||||
|
||||
@ -67,14 +75,13 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
<Search />
|
||||
</div>
|
||||
{menu.length ? (
|
||||
<ul className="flex flex-col">
|
||||
<ul className="flex w-full flex-col">
|
||||
{menu.map((item: Menu) => (
|
||||
<li key={item.title}>
|
||||
<Link
|
||||
href={item.path}
|
||||
className="rounded-lg py-1 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<li
|
||||
className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
|
||||
key={item.title}
|
||||
>
|
||||
<Link href={item.path} onClick={closeMobileMenu}>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
|
@ -32,7 +32,7 @@ export default function Search() {
|
||||
}
|
||||
|
||||
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
|
||||
type="text"
|
||||
name="search"
|
||||
|
@ -8,7 +8,7 @@ export default function ProductGridItems({ products }: { products: Product[] })
|
||||
<>
|
||||
{products.map((product) => (
|
||||
<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
|
||||
alt={product.title}
|
||||
label={{
|
||||
@ -17,8 +17,8 @@ export default function ProductGridItems({ products }: { products: Product[] })
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
width={600}
|
||||
height={600}
|
||||
fill
|
||||
sizes="(min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
</Link>
|
||||
</Grid.Item>
|
||||
|
@ -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 (
|
||||
<div className="mr-8 h-full">
|
||||
<div className="relative mb-12 h-full max-h-[550px] min-h-[550px] overflow-hidden">
|
||||
{images[currentImageIndex] && (
|
||||
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
|
||||
{images[imageIndex] && (
|
||||
<Image
|
||||
className="relative h-full w-full object-contain"
|
||||
height={600}
|
||||
width={600}
|
||||
alt={images[currentImageIndex]?.altText as string}
|
||||
src={images[currentImageIndex]?.src as string}
|
||||
className="h-full w-full object-contain"
|
||||
fill
|
||||
sizes="(min-width: 1024px) 66vw, 100vw"
|
||||
alt={images[imageIndex]?.altText as string}
|
||||
src={images[imageIndex]?.src as string}
|
||||
priority={true}
|
||||
/>
|
||||
)}
|
||||
@ -36,48 +43,56 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
|
||||
{images.length > 1 ? (
|
||||
<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">
|
||||
<button
|
||||
<Link
|
||||
aria-label="Previous product image"
|
||||
onClick={() => handleNavigate('previous')}
|
||||
href={previousUrl}
|
||||
className={buttonClassName}
|
||||
scroll={false}
|
||||
>
|
||||
<ArrowLeftIcon className="h-5" />
|
||||
</button>
|
||||
</Link>
|
||||
<div className="mx-1 h-6 w-px bg-neutral-500"></div>
|
||||
<button
|
||||
<Link
|
||||
aria-label="Next product image"
|
||||
onClick={() => handleNavigate('next')}
|
||||
href={nextUrl}
|
||||
className={buttonClassName}
|
||||
scroll={false}
|
||||
>
|
||||
<ArrowRightIcon className="h-5" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{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) => {
|
||||
const isActive = index === currentImageIndex;
|
||||
const isActive = index === imageIndex;
|
||||
const imageSearchParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
imageSearchParams.set('image', index.toString());
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Enlarge product image"
|
||||
key={image.src}
|
||||
className="h-auto w-20"
|
||||
onClick={() => setCurrentImageIndex(index)}
|
||||
>
|
||||
<GridTileImage
|
||||
alt={image.altText}
|
||||
src={image.src}
|
||||
width={200}
|
||||
height={200}
|
||||
active={isActive}
|
||||
/>
|
||||
</button>
|
||||
<li key={image.src} className="h-auto w-20">
|
||||
<Link
|
||||
aria-label="Enlarge product image"
|
||||
href={createUrl(pathname, imageSearchParams)}
|
||||
scroll={false}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<GridTileImage
|
||||
alt={image.altText}
|
||||
src={image.src}
|
||||
width={200}
|
||||
height={200}
|
||||
active={isActive}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
@ -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<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() });
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
"packageManager": "pnpm@8.2.0",
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"pnpm": ">=8"
|
||||
"pnpm": ">=7"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
|
Loading…
x
Reference in New Issue
Block a user