Merge branch 'main' into sol/basic-site-working

This commit is contained in:
Sol Irvine 2023-08-08 09:15:13 +09:00
commit 60ca3d8b22
28 changed files with 294 additions and 258 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() });
} }

View File

@ -2,9 +2,18 @@
export default function Error({ reset }: { reset: () => void }) { export default function Error({ reset }: { reset: () => void }) {
return ( return (
<div> <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>Something went wrong.</h2> <h2 className="text-xl font-bold">Oh no!</h2>
<button onClick={() => reset()}>Try again</button> <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> </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'; import './globals.css';
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env; 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 = { export const metadata = {
metadataBase: new URL(baseUrl),
title: { title: {
default: SITE_NAME, default: SITE_NAME!,
template: `%s | ${SITE_NAME}` template: `%s | ${SITE_NAME}`
}, },
robots: { robots: {

View File

@ -23,17 +23,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
@ -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="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,
@ -92,7 +92,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>
@ -115,14 +115,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.handle}
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.handle}`} <Link className="relative h-full w-full" href={`/product/${product.handle}`}>
>
<GridTileImage <GridTileImage
alt={product.title} alt={product.title}
label={{ label={{
@ -131,13 +130,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={600} fill
height={600} 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

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

View File

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

View File

@ -8,29 +8,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.handle}${i}`} key={`${product.handle}${i}`}
href={`/product/${product.handle}`} 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.handle}`} 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

@ -3,7 +3,7 @@
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify'; import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
import { cookies } from 'next/headers'; 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 cartId = cookies().get('cartId')?.value;
let cart; let cart;
@ -18,25 +18,26 @@ export const addItem = async (variantId: string | undefined): Promise<Error | un
} }
if (!variantId) { if (!variantId) {
return new Error('Missing variantId'); return 'Missing product variant ID';
} }
try { try {
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]); await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
} catch (e) { } 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; const cartId = cookies().get('cartId')?.value;
if (!cartId) { if (!cartId) {
return new Error('Missing cartId'); return 'Missing cart ID';
} }
try { try {
await removeFromCart(cartId, [lineId]); await removeFromCart(cartId, [lineId]);
} catch (e) { } 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; lineId: string;
variantId: string; variantId: string;
quantity: number; quantity: number;
}): Promise<Error | undefined> => { }): Promise<String | undefined> => {
const cartId = cookies().get('cartId')?.value; const cartId = cookies().get('cartId')?.value;
if (!cartId) { if (!cartId) {
return new Error('Missing cartId'); return 'Missing cart ID';
} }
try { try {
await updateCart(cartId, [ await updateCart(cartId, [
@ -63,6 +64,6 @@ export const updateItemQuantity = async ({
} }
]); ]);
} catch (e) { } 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 LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types'; import { ProductVariant } from 'lib/shopify/types';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react'; import { useTransition } from 'react';
export function AddToCart({ export function AddToCart({
variants, variants,
@ -15,28 +15,16 @@ export function AddToCart({
variants: ProductVariant[]; variants: ProductVariant[];
availableForSale: boolean; availableForSale: boolean;
}) { }) {
const [selectedVariantId, setSelectedVariantId] = useState<string | undefined>(undefined);
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
useEffect(() => { const variant = variants.find((variant: ProductVariant) =>
const variant = variants.find((variant: ProductVariant) => variant.selectedOptions.every(
variant.selectedOptions.every( (option) => option.value === searchParams.get(option.name.toLowerCase())
(option) => option.value === searchParams.get(option.name.toLowerCase()) )
);
) const selectedVariantId = variant?.id || defaultVariantId;
);
if (variants?.length > 0 && !!variants?.[0] && !variant) {
setSelectedVariantId(variants?.[0].id);
return;
}
setSelectedVariantId(variant?.id);
}, [searchParams, variants, setSelectedVariantId]);
const title = !availableForSale const title = !availableForSale
? 'Out of stock' ? 'Out of stock'
: !selectedVariantId : !selectedVariantId
@ -56,8 +44,8 @@ export function AddToCart({
const error = await addItem(selectedVariantId); const error = await addItem(selectedVariantId);
if (error) { if (error) {
alert(error); // Trigger the error boundary in the root error.js
return; throw new Error(error.toString());
} }
router.refresh(); router.refresh();

View File

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

View File

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

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

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

@ -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' '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"> <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 inline pl-2 leading-none tracking-tight">{title}</h3> <h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
<Price <Price
className="flex-none rounded-full bg-blue-600 p-2 text-white" className="flex-none rounded-full bg-blue-600 p-2 text-white"
amount={amount} amount={amount}

View File

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

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

View File

@ -30,12 +30,12 @@ export default async function Navbar() {
</div> </div>
</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.handle} className="animate-fadeIn"> <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 <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

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

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="relative mb-12 h-full max-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,49 +43,57 @@ 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={600} src={image.src}
height={600} width={80}
active={isActive} height={80}
/> active={isActive}
</button> />
</Link>
</li>
); );
})} })}
</div> </ul>
) : null} ) : null}
</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 @@
"private": true, "private": true,
"packageManager": "yarn@3.6.1", "packageManager": "yarn@3.6.1",
"engines": { "engines": {
"node": ">=16", "node": ">=18",
"pnpm": ">=7" "pnpm": ">=7"
}, },
"scripts": { "scripts": {
@ -26,7 +26,7 @@
"@headlessui/react": "^1.7.15", "@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18", "@heroicons/react": "^2.0.18",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"next": "13.4.12", "next": "13.4.13-canary.15",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0" "react-dom": "18.2.0"
}, },
@ -43,7 +43,7 @@
"eslint-plugin-unicorn": "^48.0.0", "eslint-plugin-unicorn": "^48.0.0",
"lint-staged": "^13.2.3", "lint-staged": "^13.2.3",
"postcss": "^8.4.27", "postcss": "^8.4.27",
"prettier": "^3.0.0", "prettier": "3.0.1",
"prettier-plugin-tailwindcss": "^0.4.1", "prettier-plugin-tailwindcss": "^0.4.1",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "5.1.6" "typescript": "5.1.6"

85
pnpm-lock.yaml generated
View File

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