Merge branch 'vercel:main' into main

This commit is contained in:
Sol Irvine 2023-08-08 09:13:27 +09:00 committed by GitHub
commit 7bda174e1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 294 additions and 251 deletions

2
.nvmrc
View File

@ -1 +1 @@
16
18

View File

@ -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);
}

View File

@ -2,9 +2,18 @@
export default function Error({ reset }: { reset: () => void }) {
return (
<div>
<h2>Something went wrong.</h2>
<button onClick={() => reset()}>Try again</button>
<div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12">
<h2 className="text-xl font-bold">Oh no!</h2>
<p className="my-2">
There was an issue with our storefront. This could be a temporary issue, please try your
action again.
</p>
<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"
onClick={() => reset()}
>
Try Again
</button>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -4,10 +4,14 @@ import { ReactNode, Suspense } from 'react';
import './globals.css';
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
export const metadata = {
metadataBase: new URL(baseUrl),
title: {
default: SITE_NAME,
default: SITE_NAME!,
template: `%s | ${SITE_NAME}`
},
robots: {

View File

@ -23,17 +23,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
@ -82,8 +82,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,
@ -92,7 +92,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>
@ -115,14 +115,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.handle}`}
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
{relatedProducts.map((product) => (
<li
key={product.handle}
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.handle}`}>
<GridTileImage
alt={product.title}
label={{
@ -131,13 +130,13 @@ async function RelatedProducts({ id }: { id: string }) {
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage?.url}
width={600}
height={600}
fill
sizes="(min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, (min-width: 475px) 50vw, 100vw"
/>
</Link>
);
})}
</div>
</li>
))}
</ul>
</div>
);
}

View File

@ -24,7 +24,7 @@ export default async function SearchPage({
return (
<>
{searchValue ? (
<p>
<p className="mb-4">
{products.length === 0
? 'There are no products that match '
: `Showing ${products.length} ${resultsText} for `}

View File

@ -1,11 +1,16 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
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()
@ -32,9 +37,13 @@ export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.S
}))
);
const fetchedRoutes = (
await Promise.all([collectionsPromise, productsPromise, pagesPromise])
).flat();
let fetchedRoutes: Route[] = [];
try {
fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
} catch (error) {
throw JSON.stringify(error, null, 2);
}
return [...routesMap, ...fetchedRoutes];
}

View File

@ -8,15 +8,18 @@ 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
<ul className="flex animate-carousel gap-4">
{carouselProducts.map((product, i) => (
<li
key={`${product.handle}${i}`}
href={`/product/${product.handle}`}
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"
>
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
<GridTileImage
alt={product.title}
label={{
@ -25,12 +28,13 @@ export async function Carousel() {
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage?.url}
width={600}
height={600}
fill
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
/>
</Link>
</li>
))}
</div>
</ul>
</div>
);
}

View File

@ -3,7 +3,7 @@
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
import { cookies } from 'next/headers';
export const addItem = async (variantId: string | undefined): Promise<Error | undefined> => {
export const addItem = async (variantId: string | undefined): Promise<String | undefined> => {
let cartId = cookies().get('cartId')?.value;
let cart;
@ -18,25 +18,26 @@ export const addItem = async (variantId: string | undefined): Promise<Error | un
}
if (!variantId) {
return new Error('Missing variantId');
return 'Missing product variant ID';
}
try {
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
} catch (e) {
return new Error('Error adding item', { cause: e });
return 'Error adding item to cart';
}
};
export const removeItem = async (lineId: string): Promise<Error | undefined> => {
export const removeItem = async (lineId: string): Promise<String | undefined> => {
const cartId = cookies().get('cartId')?.value;
if (!cartId) {
return new Error('Missing cartId');
return 'Missing cart ID';
}
try {
await removeFromCart(cartId, [lineId]);
} catch (e) {
return new Error('Error removing item', { cause: e });
return 'Error removing item from cart';
}
};
@ -48,11 +49,11 @@ export const updateItemQuantity = async ({
lineId: string;
variantId: string;
quantity: number;
}): Promise<Error | undefined> => {
}): Promise<String | undefined> => {
const cartId = cookies().get('cartId')?.value;
if (!cartId) {
return new Error('Missing cartId');
return 'Missing cart ID';
}
try {
await updateCart(cartId, [
@ -63,6 +64,6 @@ export const updateItemQuantity = async ({
}
]);
} catch (e) {
return new Error('Error updating item quantity', { cause: e });
return 'Error updating item quantity';
}
};

View File

@ -6,7 +6,7 @@ import { addItem } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import { useTransition } from 'react';
export function AddToCart({
variants,
@ -15,21 +15,16 @@ export function AddToCart({
variants: ProductVariant[];
availableForSale: boolean;
}) {
const [selectedVariantId, setSelectedVariantId] = useState<string | undefined>(undefined);
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
useEffect(() => {
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every(
(option) => option.value === searchParams.get(option.name.toLowerCase())
)
);
setSelectedVariantId(variant?.id);
}, [searchParams, variants, setSelectedVariantId]);
const selectedVariantId = variant?.id || defaultVariantId;
const title = !availableForSale
? 'Out of stock'
: !selectedVariantId
@ -49,8 +44,8 @@ export function AddToCart({
const error = await addItem(selectedVariantId);
if (error) {
alert(error);
return;
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
router.refresh();

View File

@ -19,8 +19,8 @@ export default function DeleteItemButton({ item }: { item: CartItem }) {
const error = await removeItem(item.id);
if (error) {
alert(error);
return;
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
router.refresh();

View File

@ -32,8 +32,8 @@ export default function EditItemQuantityButton({
});
if (error) {
alert(error);
return;
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
router.refresh();

View File

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

View File

@ -3,17 +3,27 @@ import { getCollectionProducts } from 'lib/shopify';
import type { Product } from 'lib/shopify/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.handle}`}>
<Link className="relative block aspect-square h-full w-full" href={`/product/${item.handle}`}>
<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',
@ -38,9 +48,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>
);

View File

@ -1,13 +0,0 @@
export default function GitHubIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M12 0C5.37 0 0 5.50583 0 12.3035C0 17.7478 3.435 22.3463 8.205 23.9765C8.805 24.0842 9.03 23.715 9.03 23.3921C9.03 23.0999 9.015 22.131 9.015 21.1005C6 21.6696 5.22 20.347 4.98 19.6549C4.845 19.3012 4.26 18.2092 3.75 17.917C3.33 17.6863 2.73 17.1173 3.735 17.1019C4.68 17.0865 5.355 17.9939 5.58 18.363C6.66 20.2239 8.385 19.701 9.075 19.3781C9.18 18.5783 9.495 18.04 9.84 17.7325C7.17 17.4249 4.38 16.3637 4.38 11.6576C4.38 10.3196 4.845 9.21227 5.61 8.35102C5.49 8.04343 5.07 6.78232 5.73 5.09058C5.73 5.09058 6.735 4.76762 9.03 6.3517C9.99 6.07487 11.01 5.93645 12.03 5.93645C13.05 5.93645 14.07 6.07487 15.03 6.3517C17.325 4.75224 18.33 5.09058 18.33 5.09058C18.99 6.78232 18.57 8.04343 18.45 8.35102C19.215 9.21227 19.68 10.3042 19.68 11.6576C19.68 16.3791 16.875 17.4249 14.205 17.7325C14.64 18.1169 15.015 18.8552 15.015 20.0086C15.015 21.6542 15 22.9768 15 23.3921C15 23.715 15.225 24.0995 15.825 23.9765C18.2072 23.1519 20.2773 21.5822 21.7438 19.4882C23.2103 17.3942 23.9994 14.8814 24 12.3035C24 5.50583 18.63 0 12 0Z" />
</svg>
);
}

View File

@ -18,8 +18,8 @@ const Label = ({
'lg:px-20 lg:pb-[35%]': position === 'center'
})}
>
<div className="flex items-center rounded-full border bg-white/70 p-1 text-[10px] font-semibold text-black backdrop-blur-md @[275px]/label:text-xs dark:border-neutral-800 dark:bg-black/70 dark:text-white">
<h3 className="mr-4 inline pl-2 leading-none tracking-tight">{title}</h3>
<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 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
<Price
className="flex-none rounded-full bg-blue-600 p-2 text-white"
amount={amount}

View File

@ -15,11 +15,11 @@ const FooterMenuItem = ({ item }: { item: Menu }) => {
}, [pathname, item.path]);
return (
<li 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
}

View File

@ -1,6 +1,5 @@
import Link from 'next/link';
import GitHubIcon from 'components/icons/github';
import FooterMenu from 'components/layout/footer-menu';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify';
@ -19,7 +18,7 @@ export default async function Footer() {
<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 dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 xl:px-0">
<div>
<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>
@ -40,12 +39,13 @@ export default async function Footer() {
</Suspense>
<div className="md:ml-auto">
<a
className="flex items-center gap-2 hover:text-black dark:hover:text-neutral-300"
aria-label="Github Repository"
href="https://github.com/vercel/commerce"
className="flex h-8 flex-none items-center justify-center rounded-md border border-neutral-200 bg-white text-xs text-black dark:border-neutral-700 dark:bg-black dark:text-white"
aria-label="Deploy on Vercel"
href="https://vercel.com/templates/next.js/nextjs-commerce"
>
<GitHubIcon className="h-6" />
<p>Source</p>
<span className="px-3"></span>
<hr className="h-full border-r border-neutral-200 dark:border-neutral-700" />
<span className="px-3">Deploy</span>
</a>
</div>
</div>

View File

@ -30,12 +30,12 @@ export default async function Navbar() {
</div>
</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>

View File

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

View File

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

View File

@ -8,7 +8,7 @@ export default function ProductGridItems({ products }: { products: Product[] })
<>
{products.map((product) => (
<Grid.Item key={product.handle} className="animate-fadeIn">
<Link className="inline-block h-full w-full" href={`/product/${product.handle}`}>
<Link className="relative inline-block h-full w-full" href={`/product/${product.handle}`}>
<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>

View File

@ -52,7 +52,7 @@ function SortFilterItem({ item }: { item: SortFilterItem }) {
<DynamicTag
prefetch={!active ? false : undefined}
href={href}
className={clsx('w-full', {
className={clsx('w-full hover:underline hover:underline-offset-4', {
'underline underline-offset-4': active
})}
>

View File

@ -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] 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,49 +43,57 @@ 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
<li key={image.src} className="h-auto w-20">
<Link
aria-label="Enlarge product image"
key={image.src}
className="h-auto w-20"
onClick={() => setCurrentImageIndex(index)}
href={createUrl(pathname, imageSearchParams)}
scroll={false}
className="h-full w-full"
>
<GridTileImage
alt={image.altText}
src={image.src}
width={600}
height={600}
width={80}
height={80}
active={isActive}
/>
</button>
</Link>
</li>
);
})}
</div>
</ul>
) : null}
</div>
</>
);
}

View File

@ -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() });
}

View File

@ -2,7 +2,7 @@
"private": true,
"packageManager": "pnpm@8.2.0",
"engines": {
"node": ">=16",
"node": ">=18",
"pnpm": ">=7"
},
"scripts": {
@ -25,7 +25,7 @@
"@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18",
"clsx": "^2.0.0",
"next": "13.4.12",
"next": "13.4.13-canary.15",
"react": "18.2.0",
"react-dom": "18.2.0"
},
@ -43,7 +43,7 @@
"eslint-plugin-unicorn": "^48.0.0",
"lint-staged": "^13.2.3",
"postcss": "^8.4.27",
"prettier": "^3.0.0",
"prettier": "3.0.1",
"prettier-plugin-tailwindcss": "^0.4.1",
"tailwindcss": "^3.3.3",
"typescript": "5.1.6"

85
pnpm-lock.yaml generated
View File

@ -11,8 +11,8 @@ dependencies:
specifier: ^2.0.0
version: 2.0.0
next:
specifier: 13.4.12
version: 13.4.12(react-dom@18.2.0)(react@18.2.0)
specifier: 13.4.13-canary.15
version: 13.4.13-canary.15(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@ -61,11 +61,11 @@ devDependencies:
specifier: ^8.4.27
version: 8.4.27
prettier:
specifier: ^3.0.0
version: 3.0.0
specifier: 3.0.1
version: 3.0.1
prettier-plugin-tailwindcss:
specifier: ^0.4.1
version: 0.4.1(prettier@3.0.0)
version: 0.4.1(prettier@3.0.1)
tailwindcss:
specifier: ^3.3.3
version: 3.3.3
@ -224,8 +224,8 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.14
dev: true
/@next/env@13.4.12:
resolution: {integrity: sha512-RmHanbV21saP/6OEPBJ7yJMuys68cIf8OBBWd7+uj40LdpmswVAwe1uzeuFyUsd6SfeITWT3XnQfn6wULeKwDQ==}
/@next/env@13.4.13-canary.15:
resolution: {integrity: sha512-AljMmO5a2uB0ZTDcBVhcfkE7WtdQDfnPg2zz/e6jKjVMRFPSvxaoRoSGUwONIhk9CAPbX9px7bZYom2wbhrTkw==}
dev: false
/@next/eslint-plugin-next@13.4.12:
@ -234,8 +234,8 @@ packages:
glob: 7.1.7
dev: true
/@next/swc-darwin-arm64@13.4.12:
resolution: {integrity: sha512-deUrbCXTMZ6ZhbOoloqecnUeNpUOupi8SE2tx4jPfNS9uyUR9zK4iXBvH65opVcA/9F5I/p8vDXSYbUlbmBjZg==}
/@next/swc-darwin-arm64@13.4.13-canary.15:
resolution: {integrity: sha512-ymE/tPjf5DXIqWxEefkqGX094ZDpKw/0sKb7xmzF0m8Kolac1eqA6ZnCsb1TKXYVQyrGUx/Z0xmxCK4cm2dEdw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@ -243,8 +243,8 @@ packages:
dev: false
optional: true
/@next/swc-darwin-x64@13.4.12:
resolution: {integrity: sha512-WRvH7RxgRHlC1yb5oG0ZLx8F7uci9AivM5/HGGv9ZyG2Als8Ij64GC3d+mQ5sJhWjusyU6T6V1WKTUoTmOB0zQ==}
/@next/swc-darwin-x64@13.4.13-canary.15:
resolution: {integrity: sha512-B9fCPRjE1t5r1bmivq5fqHvU8mLNX7hkS2zj9arVrZEC7HdOugbSOpmQb5+yr5ZmNKMItQbPDJIATY+ZAiUtww==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@ -252,8 +252,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-gnu@13.4.12:
resolution: {integrity: sha512-YEKracAWuxp54tKiAvvq73PUs9lok57cc8meYRibTWe/VdPB2vLgkTVWFcw31YDuRXdEhdX0fWS6Q+ESBhnEig==}
/@next/swc-linux-arm64-gnu@13.4.13-canary.15:
resolution: {integrity: sha512-K30IPFxZPtZLs1gqir95oNdCNgmu0awbC7MMLqOu9+wmW+LYjA6M3ltRe2Duy9nZ7JQob1oRl/s7MMbtCuzVAA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -261,8 +261,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-musl@13.4.12:
resolution: {integrity: sha512-LhJR7/RAjdHJ2Isl2pgc/JaoxNk0KtBgkVpiDJPVExVWA1c6gzY57+3zWuxuyWzTG+fhLZo2Y80pLXgIJv7g3g==}
/@next/swc-linux-arm64-musl@13.4.13-canary.15:
resolution: {integrity: sha512-ClJvWIhvCLXM3iSMet9bqKxyxifN7DGo8+wiV8gwIU+OMWHGNgGtmZ3xXae3R91w8DOLrsREyBN4uGLlgpwRXg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -270,8 +270,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-gnu@13.4.12:
resolution: {integrity: sha512-1DWLL/B9nBNiQRng+1aqs3OaZcxC16Nf+mOnpcrZZSdyKHek3WQh6j/fkbukObgNGwmCoVevLUa/p3UFTTqgqg==}
/@next/swc-linux-x64-gnu@13.4.13-canary.15:
resolution: {integrity: sha512-/B0xaPcdx2HWDC9Bxks3dLIUyu9Falmd7ENRanYizfdihgM+kV2zIQe/5h5zaESKMEltLt2ELPOPCaFU5gOnYA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -279,8 +279,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-musl@13.4.12:
resolution: {integrity: sha512-kEAJmgYFhp0VL+eRWmUkVxLVunn7oL9Mdue/FS8yzRBVj7Z0AnIrHpTIeIUl1bbdQq1VaoOztnKicAjfkLTRCQ==}
/@next/swc-linux-x64-musl@13.4.13-canary.15:
resolution: {integrity: sha512-YZZlKne+5iwsPe9yN8QP5sfyDN7ybpWTuYukfv6sKL68STuAVqqp4QX2g7a3Fw+LMJiDwyCFJaUDgO9KSLEqDw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -288,8 +288,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-arm64-msvc@13.4.12:
resolution: {integrity: sha512-GMLuL/loR6yIIRTnPRY6UGbLL9MBdw2anxkOnANxvLvsml4F0HNIgvnU3Ej4BjbqMTNjD4hcPFdlEow4XHPdZA==}
/@next/swc-win32-arm64-msvc@13.4.13-canary.15:
resolution: {integrity: sha512-nOi9w+E+ajqJuQhcB260AMJERJPYS1K5pL+5Rymyt9VWCZEJZiHTRuaf8y/H7sObZcQKwRVa7C/EWyZjj658XA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@ -297,8 +297,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-ia32-msvc@13.4.12:
resolution: {integrity: sha512-PhgNqN2Vnkm7XaMdRmmX0ZSwZXQAtamBVSa9A/V1dfKQCV1rjIZeiy/dbBnVYGdj63ANfsOR/30XpxP71W0eww==}
/@next/swc-win32-ia32-msvc@13.4.13-canary.15:
resolution: {integrity: sha512-EqV5Bt7TmdFWa00KkoEeb5K4uRFrV1BAiwqylsk+d+2U1N2UIad/dTOyTzobjDcZ9uii1EaCVMiTuMfcsGIahw==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
@ -306,8 +306,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-x64-msvc@13.4.12:
resolution: {integrity: sha512-Z+56e/Ljt0bUs+T+jPjhFyxYBcdY2RIq9ELFU+qAMQMteHo7ymbV7CKmlcX59RI9C4YzN8PgMgLyAoi916b5HA==}
/@next/swc-win32-x64-msvc@13.4.13-canary.15:
resolution: {integrity: sha512-hZcZ0vS1eevRTr1JUyDfTYXF36DBEowNZWyzNW8ZrlGMZyQloE9yf/jHoLtutxr2jV6GoHWGGqVSw4exOyjjKw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -2241,25 +2241,22 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: true
/next@13.4.12(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-eHfnru9x6NRmTMcjQp6Nz0J4XH9OubmzOa7CkWL+AUrUxpibub3vWwttjduu9No16dug1kq04hiUUpo7J3m3Xw==}
/next@13.4.13-canary.15(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-dSOzenhqdjH6fNbSKYZ4PkqmKLOviFSVUd75Csz+zZPoTWmAKR+9waUAttOyRnUgYd/qutt8KXGH+DiU0nmhVA==}
engines: {node: '>=16.8.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
fibers: '>= 3.1.0'
react: ^18.2.0
react-dom: ^18.2.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
fibers:
optional: true
sass:
optional: true
dependencies:
'@next/env': 13.4.12
'@next/env': 13.4.13-canary.15
'@swc/helpers': 0.5.1
busboy: 1.6.0
caniuse-lite: 1.0.30001517
@ -2270,15 +2267,15 @@ packages:
watchpack: 2.4.0
zod: 3.21.4
optionalDependencies:
'@next/swc-darwin-arm64': 13.4.12
'@next/swc-darwin-x64': 13.4.12
'@next/swc-linux-arm64-gnu': 13.4.12
'@next/swc-linux-arm64-musl': 13.4.12
'@next/swc-linux-x64-gnu': 13.4.12
'@next/swc-linux-x64-musl': 13.4.12
'@next/swc-win32-arm64-msvc': 13.4.12
'@next/swc-win32-ia32-msvc': 13.4.12
'@next/swc-win32-x64-msvc': 13.4.12
'@next/swc-darwin-arm64': 13.4.13-canary.15
'@next/swc-darwin-x64': 13.4.13-canary.15
'@next/swc-linux-arm64-gnu': 13.4.13-canary.15
'@next/swc-linux-arm64-musl': 13.4.13-canary.15
'@next/swc-linux-x64-gnu': 13.4.13-canary.15
'@next/swc-linux-x64-musl': 13.4.13-canary.15
'@next/swc-win32-arm64-msvc': 13.4.13-canary.15
'@next/swc-win32-ia32-msvc': 13.4.13-canary.15
'@next/swc-win32-x64-msvc': 13.4.13-canary.15
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@ -2633,7 +2630,7 @@ packages:
engines: {node: '>= 0.8.0'}
dev: true
/prettier-plugin-tailwindcss@0.4.1(prettier@3.0.0):
/prettier-plugin-tailwindcss@0.4.1(prettier@3.0.1):
resolution: {integrity: sha512-hwn2EiJmv8M+AW4YDkbjJ6HlZCTzLyz1QlySn9sMuKV/Px0fjwldlB7tol8GzdgqtkdPtzT3iJ4UzdnYXP25Ag==}
engines: {node: '>=12.17.0'}
peerDependencies:
@ -2685,11 +2682,11 @@ packages:
prettier-plugin-twig-melody:
optional: true
dependencies:
prettier: 3.0.0
prettier: 3.0.1
dev: true
/prettier@3.0.0:
resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==}
/prettier@3.0.1:
resolution: {integrity: sha512-fcOWSnnpCrovBsmFZIGIy9UqK2FaI7Hqax+DIO0A9UxeVoY4iweyaFjS5TavZN97Hfehph0nhsZnjlVKzEQSrQ==}
engines: {node: '>=14'}
hasBin: true
dev: true