diff --git a/app/page.tsx b/app/page.tsx index 0fad0ac28..7d407ede8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,7 +9,7 @@ export const metadata = { } }; -export default async function HomePage() { +export default function HomePage() { return ( <> <ThreeItemGrid /> diff --git a/app/product/[handle]/page.tsx b/app/product/[handle]/page.tsx index dd964ccf5..e2280675d 100644 --- a/app/product/[handle]/page.tsx +++ b/app/product/[handle]/page.tsx @@ -4,6 +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 { ProductDescription } from 'components/product/product-description'; import { HIDDEN_PRODUCT_TAG } from 'lib/constants'; import { getProduct, getProductRecommendations } from 'lib/shopify'; @@ -72,7 +73,7 @@ export default async function ProductPage({ params }: { params: { handle: string }; return ( - <> + <ProductProvider> <script type="application/ld+json" dangerouslySetInnerHTML={{ @@ -97,13 +98,15 @@ export default async function ProductPage({ params }: { params: { handle: string </div> <div className="basis-full lg:basis-2/6"> - <ProductDescription product={product} /> + <Suspense fallback={null}> + <ProductDescription product={product} /> + </Suspense> </div> </div> <RelatedProducts id={product.id} /> </div> <Footer /> - </> + </ProductProvider> ); } diff --git a/app/search/loading.tsx b/app/search/loading.tsx index 855c371bc..7b75dd922 100644 --- a/app/search/loading.tsx +++ b/app/search/loading.tsx @@ -7,7 +7,7 @@ export default function Loading() { .fill(0) .map((_, index) => { return ( - <Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-900" /> + <Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" /> ); })} </Grid> diff --git a/components/cart/add-to-cart.tsx b/components/cart/add-to-cart.tsx index 4f7cb31b3..5a260af2b 100644 --- a/components/cart/add-to-cart.tsx +++ b/components/cart/add-to-cart.tsx @@ -3,7 +3,7 @@ import { PlusIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import { addItem } from 'components/cart/actions'; -import { useProductOptions } from 'components/product/product-context'; +import { useProduct } from 'components/product/product-context'; import { Product, ProductVariant } from 'lib/shopify/types'; import { useFormState } from 'react-dom'; import { useCart } from './cart-context'; @@ -60,13 +60,11 @@ function SubmitButton({ export function AddToCart({ product }: { product: Product }) { const { variants, availableForSale } = product; const { addCartItem } = useCart(); - const { options: selectedOptions } = useProductOptions(); + const { state } = useProduct(); const [message, formAction] = useFormState(addItem, null); const variant = variants.find((variant: ProductVariant) => - variant.selectedOptions.every( - (option) => option.value === selectedOptions[option.name.toLowerCase()] - ) + variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()]) ); const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined; const selectedVariantId = variant?.id || defaultVariantId; diff --git a/components/layout/navbar/search.tsx b/components/layout/navbar/search.tsx index 551d781c2..10286c0a5 100644 --- a/components/layout/navbar/search.tsx +++ b/components/layout/navbar/search.tsx @@ -33,7 +33,7 @@ export default function Search() { placeholder="Search for products..." autoComplete="off" defaultValue={searchParams?.get('q') || ''} - className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400" + className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400" /> <div className="absolute right-0 top-0 mr-3 flex h-full items-center"> <MagnifyingGlassIcon className="h-4" /> diff --git a/components/product/gallery.tsx b/components/product/gallery.tsx index 0b03557a5..f54d6015c 100644 --- a/components/product/gallery.tsx +++ b/components/product/gallery.tsx @@ -2,26 +2,15 @@ import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; import { GridTileImage } from 'components/grid/tile'; -import { createUrl } from 'lib/utils'; +import { useProduct } from 'components/product/product-context'; import Image from 'next/image'; -import Link from 'next/link'; -import { usePathname, useSearchParams } from 'next/navigation'; export function Gallery({ images }: { images: { src: string; altText: string }[] }) { - const pathname = usePathname(); - const searchParams = useSearchParams(); - const imageSearchParam = searchParams.get('image'); - const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0; + const { state, updateImage } = useProduct(); + const imageIndex = state.image ? parseInt(state.image) : 0; - 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 flex items-center justify-center'; @@ -43,23 +32,21 @@ 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"> - <Link + <button aria-label="Previous product image" - href={previousUrl} + onClick={() => updateImage(previousImageIndex.toString())} className={buttonClassName} - scroll={false} > <ArrowLeftIcon className="h-5" /> - </Link> + </button> <div className="mx-1 h-6 w-px bg-neutral-500"></div> - <Link + <button aria-label="Next product image" - href={nextUrl} + onClick={() => updateImage(nextImageIndex.toString())} className={buttonClassName} - scroll={false} > <ArrowRightIcon className="h-5" /> - </Link> + </button> </div> </div> ) : null} @@ -69,17 +56,13 @@ export function Gallery({ images }: { images: { src: string; altText: string }[] <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 === imageIndex; - const imageSearchParams = new URLSearchParams(searchParams.toString()); - - imageSearchParams.set('image', index.toString()); return ( <li key={image.src} className="h-20 w-20"> - <Link - aria-label="Enlarge product image" - href={createUrl(pathname, imageSearchParams)} - scroll={false} + <button + aria-label="Select product image" className="h-full w-full" + onClick={() => updateImage(index.toString())} > <GridTileImage alt={image.altText} @@ -88,7 +71,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[] height={80} active={isActive} /> - </Link> + </button> </li> ); })} diff --git a/components/product/product-context.tsx b/components/product/product-context.tsx index 6809822af..7be633b26 100644 --- a/components/product/product-context.tsx +++ b/components/product/product-context.tsx @@ -3,71 +3,70 @@ import { useRouter, useSearchParams } from 'next/navigation'; import React, { createContext, useContext, useMemo, useOptimistic } from 'react'; -type ProductOptionsState = { +type ProductState = { [key: string]: string; +} & { + image?: string; }; -type ProductOptionsAction = { type: 'UPDATE_OPTION'; payload: { name: string; value: string } }; - -type ProductOptionsContextType = { - options: ProductOptionsState; +type ProductContextType = { + state: ProductState; updateOption: (name: string, value: string) => void; + updateImage: (index: string) => void; }; -const ProductOptionsContext = createContext<ProductOptionsContextType | undefined>(undefined); +const ProductContext = createContext<ProductContextType | undefined>(undefined); -function productOptionsReducer( - state: ProductOptionsState, - action: ProductOptionsAction -): ProductOptionsState { - switch (action.type) { - case 'UPDATE_OPTION': { - return { - ...state, - [action.payload.name]: action.payload.value - }; - } - default: - return state; - } -} - -export function ProductOptionsProvider({ children }: { children: React.ReactNode }) { +export function ProductProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); const searchParams = useSearchParams(); - const getInitialOptions = () => { - const params: ProductOptionsState = {}; + const getInitialState = () => { + const params: ProductState = {}; for (const [key, value] of searchParams.entries()) { params[key] = value; } return params; }; - const [options, updateOptions] = useOptimistic(getInitialOptions(), productOptionsReducer); + const [state, setOptimisticState] = useOptimistic( + getInitialState(), + (prevState: ProductState, update: ProductState) => ({ + ...prevState, + ...update + }) + ); const updateOption = (name: string, value: string) => { - updateOptions({ type: 'UPDATE_OPTION', payload: { name, value } }); + setOptimisticState({ [name]: value }); const newParams = new URLSearchParams(window.location.search); newParams.set(name, value); router.push(`?${newParams.toString()}`, { scroll: false }); }; + const updateImage = (index: string) => { + setOptimisticState({ image: index }); + const newParams = new URLSearchParams(window.location.search); + newParams.set('image', index); + router.push(`?${newParams.toString()}`, { scroll: false }); + }; + const value = useMemo( () => ({ - options, - updateOption + state, + updateOption, + updateImage }), - [options] + [state] ); - return <ProductOptionsContext.Provider value={value}>{children}</ProductOptionsContext.Provider>; + return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>; } -export function useProductOptions() { - const context = useContext(ProductOptionsContext); +export function useProduct() { + const context = useContext(ProductContext); if (context === undefined) { - throw new Error('useProductOptions must be used within a ProductOptionsProvider'); + throw new Error('useProduct must be used within a ProductProvider'); } return context; } diff --git a/components/product/product-description.tsx b/components/product/product-description.tsx index b2e3dbfea..427916a84 100644 --- a/components/product/product-description.tsx +++ b/components/product/product-description.tsx @@ -1,33 +1,29 @@ import { AddToCart } from 'components/cart/add-to-cart'; import Price from 'components/price'; -import { ProductOptionsProvider } from 'components/product/product-context'; import Prose from 'components/prose'; import { Product } from 'lib/shopify/types'; -import { Suspense } from 'react'; import { VariantSelector } from './variant-selector'; export function ProductDescription({ product }: { product: Product }) { return ( - <Suspense fallback={null}> - <ProductOptionsProvider> - <div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700"> - <h1 className="mb-2 text-5xl font-medium">{product.title}</h1> - <div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white"> - <Price - amount={product.priceRange.maxVariantPrice.amount} - currencyCode={product.priceRange.maxVariantPrice.currencyCode} - /> - </div> - </div> - <VariantSelector options={product.options} variants={product.variants} /> - {product.descriptionHtml ? ( - <Prose - className="mb-6 text-sm leading-tight dark:text-white/[60%]" - html={product.descriptionHtml} + <> + <div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700"> + <h1 className="mb-2 text-5xl font-medium">{product.title}</h1> + <div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white"> + <Price + amount={product.priceRange.maxVariantPrice.amount} + currencyCode={product.priceRange.maxVariantPrice.currencyCode} /> - ) : null} - <AddToCart product={product} /> - </ProductOptionsProvider> - </Suspense> + </div> + </div> + <VariantSelector options={product.options} variants={product.variants} /> + {product.descriptionHtml ? ( + <Prose + className="mb-6 text-sm leading-tight dark:text-white/[60%]" + html={product.descriptionHtml} + /> + ) : null} + <AddToCart product={product} /> + </> ); } diff --git a/components/product/variant-selector.tsx b/components/product/variant-selector.tsx index a36ca7cba..c89f99a81 100644 --- a/components/product/variant-selector.tsx +++ b/components/product/variant-selector.tsx @@ -1,7 +1,7 @@ 'use client'; import clsx from 'clsx'; -import { useProductOptions } from 'components/product/product-context'; +import { useProduct } from 'components/product/product-context'; import { ProductOption, ProductVariant } from 'lib/shopify/types'; type Combination = { @@ -17,7 +17,7 @@ export function VariantSelector({ options: ProductOption[]; variants: ProductVariant[]; }) { - const { options: selectedOptions, updateOption } = useProductOptions(); + const { state, updateOption } = useProduct(); const hasNoOptionsOrJustOneOption = !options.length || (options.length === 1 && options[0]?.values.length === 1); @@ -42,7 +42,7 @@ export function VariantSelector({ const optionNameLowerCase = option.name.toLowerCase(); // Base option params on current selectedOptions so we can preserve any other param state. - const optionParams = { ...selectedOptions, [optionNameLowerCase]: value }; + const optionParams = { ...state, [optionNameLowerCase]: value }; // Filter out invalid options and check if the option combination is available for sale. const filtered = Object.entries(optionParams).filter(([key, value]) => @@ -57,7 +57,7 @@ export function VariantSelector({ ); // The option is active if it's in the selected options. - const isActive = selectedOptions[optionNameLowerCase] === value; + const isActive = state[optionNameLowerCase] === value; return ( <button