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

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

2
.nvmrc
View File

@ -1 +1 @@
16
18

View File

@ -1,37 +1,8 @@
import { TAGS } from 'lib/constants';
import { 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);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -24,17 +24,17 @@ export async function generateMetadata({
if (!product) return notFound();
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>
);
}

View File

@ -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">&quot;{searchValue}&quot;</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">&quot;{searchValue}&quot;</span>
</p>

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@ export default async function Footer() {
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 xl:px-0">
<div className="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>

View File

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

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

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] 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>
);

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

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