Skip to content

Optimize product page route shell #1455

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 10 additions & 14 deletions app/product/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { notFound } from 'next/navigation';
import { GridTileImage } from 'components/grid/tile';
import Footer from 'components/layout/footer';
import { Gallery } from 'components/product/gallery';
import { ProductProvider } from 'components/product/product-context';
import { GallerySkeleton } from 'components/product/gallery-skeleton';
import { ProductDescription } from 'components/product/product-description';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify';
Expand Down Expand Up @@ -76,8 +76,13 @@ export default async function ProductPage(props: {
}
};

const images = product.images.slice(0, 5).map((image: Image) => ({
src: image.url,
altText: image.altText
}));

return (
<ProductProvider>
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
Expand All @@ -87,17 +92,8 @@ export default async function ProductPage(props: {
<div className="mx-auto max-w-(--breakpoint-2xl) px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black">
<div className="h-full w-full basis-full lg:basis-4/6">
<Suspense
fallback={
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
}
>
<Gallery
images={product.images.slice(0, 5).map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
/>
<Suspense fallback={<GallerySkeleton images={images} />}>
<Gallery images={images} />
</Suspense>
</div>

Expand All @@ -112,7 +108,7 @@ export default async function ProductPage(props: {
</Suspense>
</div>
<Footer />
</ProductProvider>
</>
);
}

Expand Down
30 changes: 30 additions & 0 deletions components/cart/add-to-cart-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { Product } from 'lib/shopify/types';

export function AddToCartSkeleton({ product }: { product: Product }) {
const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';

if (!product.availableForSale) {
return (
<button disabled className={clsx(buttonClasses, disabledClasses)}>
Out Of Stock
</button>
);
}

return (
<button
aria-label="Please select an option"
disabled
className={clsx(buttonClasses, disabledClasses)}
>
<div className="absolute left-0 ml-4">
<PlusIcon className="h-5" />
</div>
Add To Cart
</button>
);
}
11 changes: 10 additions & 1 deletion components/cart/add-to-cart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { addItem } from 'components/cart/actions';
import { useProduct } from 'components/product/product-context';
import { useProduct } from 'components/product/use-product';
import { Product, ProductVariant } from 'lib/shopify/types';
import { useActionState } from 'react';
import { useCart } from './cart-context';
Expand Down Expand Up @@ -57,6 +57,15 @@ function SubmitButton({
);
}

export function AddToCartSkeleton({ product }: { product: Product }) {
return (
<SubmitButton
availableForSale={product.availableForSale}
selectedVariantId={undefined}
/>
);
}

export function AddToCart({ product }: { product: Product }) {
const { variants, availableForSale } = product;
const { addCartItem } = useCart();
Expand Down
6 changes: 4 additions & 2 deletions components/grid/tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export function GridTileImage({
return (
<div
className={clsx(
'group flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black',
'group flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white dark:bg-black',
{ 'hover:border-blue-600': isInteractive },
{
relative: label,
'border-2 border-blue-600': active,
Expand All @@ -31,7 +32,8 @@ export function GridTileImage({
{props.src ? (
<Image
className={clsx('relative h-full w-full object-contain', {
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive
'transition duration-300 ease-in-out group-hover:scale-105':
isInteractive
})}
{...props}
/>
Expand Down
66 changes: 66 additions & 0 deletions components/product/gallery-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import { GridTileImage } from 'components/grid/tile';

export function GallerySkeleton({
images
}: {
images: { src: string; altText: string }[];
}) {
const buttonClassName =
'h-full px-6 transition-all ease-in-out flex items-center justify-center cursor-not-allowed';

return (
<form>
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
{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-sm dark:border-black dark:bg-neutral-900/80">
<button
aria-label="Previous product image"
aria-disabled
disabled
className={buttonClassName}
>
<ArrowLeftIcon className="h-5" />
</button>
<div className="mx-1 h-6 w-px bg-neutral-500"></div>
<button
aria-label="Next product image"
aria-disabled
disabled
className={buttonClassName}
>
<ArrowRightIcon className="h-5" />
</button>
</div>
</div>
) : null}
</div>

{images.length > 1 ? (
<ul className="my-12 flex items-center flex-wrap justify-center gap-2 overflow-auto py-1 lg:mb-0">
{images.map((image) => {
return (
<li key={image.src} className="h-20 w-20">
<button
aria-label="Select product image"
aria-disabled
disabled
className="h-full w-full cursor-not-allowed"
>
<GridTileImage
alt={image.altText}
src={image.src}
width={80}
height={80}
isInteractive={false}
/>
</button>
</li>
);
})}
</ul>
) : null}
</form>
);
}
11 changes: 8 additions & 3 deletions components/product/gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@

import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import { GridTileImage } from 'components/grid/tile';
import { useProduct, useUpdateURL } from 'components/product/product-context';
import { useProduct, useUpdateURL } from 'components/product/use-product';
import Image from 'next/image';

export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
export function Gallery({
images
}: {
images: { src: string; altText: string }[];
}) {
const { state, updateImage } = useProduct();
const updateURL = useUpdateURL();
const imageIndex = state.image ? parseInt(state.image) : 0;

const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
const previousImageIndex =
imageIndex === 0 ? images.length - 1 : imageIndex - 1;

const buttonClassName =
'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center';
Expand Down
17 changes: 14 additions & 3 deletions components/product/product-description.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { AddToCart } from 'components/cart/add-to-cart';
import { AddToCart, AddToCartSkeleton } from 'components/cart/add-to-cart';
import Price from 'components/price';
import Prose from 'components/prose';
import { Product } from 'lib/shopify/types';
import { Suspense } from 'react';
import { VariantSelector } from './variant-selector';
import { VariantSelectorSkeleton } from './variant-selector-skeleton';

export function ProductDescription({ product }: { product: Product }) {
return (
Expand All @@ -16,14 +18,23 @@ export function ProductDescription({ product }: { product: Product }) {
/>
</div>
</div>
<VariantSelector options={product.options} variants={product.variants} />
<Suspense
fallback={<VariantSelectorSkeleton options={product.options} />}
>
<VariantSelector
options={product.options}
variants={product.variants}
/>
</Suspense>
{product.descriptionHtml ? (
<Prose
className="mb-6 text-sm leading-tight dark:text-white/[60%]"
html={product.descriptionHtml}
/>
) : null}
<AddToCart product={product} />
<Suspense fallback={<AddToCartSkeleton product={product} />}>
<AddToCart product={product} />
</Suspense>
</>
);
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
'use client';

import { useRouter, useSearchParams } from 'next/navigation';
import React, { createContext, useContext, useMemo, useOptimistic } from 'react';
import { useMemo, useOptimistic } from 'react';

type ProductState = {
[key: string]: string;
} & {
image?: string;
};

type ProductContextType = {
type UseProductResult = {
state: ProductState;
updateOption: (name: string, value: string) => ProductState;
updateImage: (index: string) => ProductState;
};

const ProductContext = createContext<ProductContextType | undefined>(undefined);

export function ProductProvider({ children }: { children: React.ReactNode }) {
export function useProduct(): UseProductResult {
const searchParams = useSearchParams();

const getInitialState = () => {
Expand Down Expand Up @@ -48,24 +46,14 @@ export function ProductProvider({ children }: { children: React.ReactNode }) {
return { ...state, ...newState };
};

const value = useMemo(
return useMemo(
() => ({
state,
updateOption,
updateImage
}),
[state]
);

return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>;
}

export function useProduct() {
const context = useContext(ProductContext);
if (context === undefined) {
throw new Error('useProduct must be used within a ProductProvider');
}
return context;
}

export function useUpdateURL() {
Expand Down
40 changes: 40 additions & 0 deletions components/product/variant-selector-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { ProductOption } from 'lib/shopify/types';

export function VariantSelectorSkeleton({
options
}: {
options: ProductOption[];
}) {
const hasNoOptionsOrJustOneOption =
!options.length ||
(options.length === 1 && options[0]?.values.length === 1);

if (hasNoOptionsOrJustOneOption) {
return null;
}

return options.map((option) => (
<form key={option.id}>
<dl className="mb-8">
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
<dd className="flex flex-wrap gap-3">
{option.values.map((value) => {
return (
<button
key={value}
aria-disabled
disabled
title={`${option.name} ${value}`}
className={
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 relative z-10 cursor-not-allowed overflow-hidden text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 dark:before:bg-neutral-700'
}
>
{value}
</button>
);
})}
</dd>
</dl>
</form>
));
}
Loading