More optimism

This commit is contained in:
Lee Robinson 2024-07-28 16:32:59 -05:00
parent 0a3da9c037
commit f321a69bbf
9 changed files with 80 additions and 101 deletions

View File

@ -9,7 +9,7 @@ export const metadata = {
} }
}; };
export default async function HomePage() { export default function HomePage() {
return ( return (
<> <>
<ThreeItemGrid /> <ThreeItemGrid />

View File

@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from 'components/grid/tile';
import Footer from 'components/layout/footer'; import Footer from 'components/layout/footer';
import { Gallery } from 'components/product/gallery'; import { Gallery } from 'components/product/gallery';
import { ProductProvider } from 'components/product/product-context';
import { ProductDescription } from 'components/product/product-description'; import { ProductDescription } from 'components/product/product-description';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants'; import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify'; import { getProduct, getProductRecommendations } from 'lib/shopify';
@ -72,7 +73,7 @@ export default async function ProductPage({ params }: { params: { handle: string
}; };
return ( return (
<> <ProductProvider>
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@ -97,13 +98,15 @@ export default async function ProductPage({ params }: { params: { handle: string
</div> </div>
<div className="basis-full lg:basis-2/6"> <div className="basis-full lg:basis-2/6">
<ProductDescription product={product} /> <Suspense fallback={null}>
<ProductDescription product={product} />
</Suspense>
</div> </div>
</div> </div>
<RelatedProducts id={product.id} /> <RelatedProducts id={product.id} />
</div> </div>
<Footer /> <Footer />
</> </ProductProvider>
); );
} }

View File

@ -7,7 +7,7 @@ export default function Loading() {
.fill(0) .fill(0)
.map((_, index) => { .map((_, index) => {
return ( 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> </Grid>

View File

@ -3,7 +3,7 @@
import { PlusIcon } from '@heroicons/react/24/outline'; import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { addItem } from 'components/cart/actions'; 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 { Product, ProductVariant } from 'lib/shopify/types';
import { useFormState } from 'react-dom'; import { useFormState } from 'react-dom';
import { useCart } from './cart-context'; import { useCart } from './cart-context';
@ -60,13 +60,11 @@ function SubmitButton({
export function AddToCart({ product }: { product: Product }) { export function AddToCart({ product }: { product: Product }) {
const { variants, availableForSale } = product; const { variants, availableForSale } = product;
const { addCartItem } = useCart(); const { addCartItem } = useCart();
const { options: selectedOptions } = useProductOptions(); const { state } = useProduct();
const [message, formAction] = useFormState(addItem, null); const [message, formAction] = useFormState(addItem, null);
const variant = variants.find((variant: ProductVariant) => const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every( variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
(option) => option.value === selectedOptions[option.name.toLowerCase()]
)
); );
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined; const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const selectedVariantId = variant?.id || defaultVariantId; const selectedVariantId = variant?.id || defaultVariantId;

View File

@ -33,7 +33,7 @@ export default function Search() {
placeholder="Search for products..." placeholder="Search for products..."
autoComplete="off" autoComplete="off"
defaultValue={searchParams?.get('q') || ''} 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"> <div className="absolute right-0 top-0 mr-3 flex h-full items-center">
<MagnifyingGlassIcon className="h-4" /> <MagnifyingGlassIcon className="h-4" />

View File

@ -2,26 +2,15 @@
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 { useProduct } from 'components/product/product-context';
import Image from 'next/image'; 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 }[] }) { export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
const pathname = usePathname(); const { state, updateImage } = useProduct();
const searchParams = useSearchParams(); const imageIndex = state.image ? parseInt(state.image) : 0;
const imageSearchParam = searchParams.get('image');
const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
const nextSearchParams = new URLSearchParams(searchParams.toString());
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0; 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; 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 flex items-center justify-center'; '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 ? ( {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">
<Link <button
aria-label="Previous product image" aria-label="Previous product image"
href={previousUrl} onClick={() => updateImage(previousImageIndex.toString())}
className={buttonClassName} className={buttonClassName}
scroll={false}
> >
<ArrowLeftIcon className="h-5" /> <ArrowLeftIcon className="h-5" />
</Link> </button>
<div className="mx-1 h-6 w-px bg-neutral-500"></div> <div className="mx-1 h-6 w-px bg-neutral-500"></div>
<Link <button
aria-label="Next product image" aria-label="Next product image"
href={nextUrl} onClick={() => updateImage(nextImageIndex.toString())}
className={buttonClassName} className={buttonClassName}
scroll={false}
> >
<ArrowRightIcon className="h-5" /> <ArrowRightIcon className="h-5" />
</Link> </button>
</div> </div>
</div> </div>
) : null} ) : 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"> <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 === imageIndex; const isActive = index === imageIndex;
const imageSearchParams = new URLSearchParams(searchParams.toString());
imageSearchParams.set('image', index.toString());
return ( return (
<li key={image.src} className="h-20 w-20"> <li key={image.src} className="h-20 w-20">
<Link <button
aria-label="Enlarge product image" aria-label="Select product image"
href={createUrl(pathname, imageSearchParams)}
scroll={false}
className="h-full w-full" className="h-full w-full"
onClick={() => updateImage(index.toString())}
> >
<GridTileImage <GridTileImage
alt={image.altText} alt={image.altText}
@ -88,7 +71,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
height={80} height={80}
active={isActive} active={isActive}
/> />
</Link> </button>
</li> </li>
); );
})} })}

View File

@ -3,71 +3,70 @@
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import React, { createContext, useContext, useMemo, useOptimistic } from 'react'; import React, { createContext, useContext, useMemo, useOptimistic } from 'react';
type ProductOptionsState = { type ProductState = {
[key: string]: string; [key: string]: string;
} & {
image?: string;
}; };
type ProductOptionsAction = { type: 'UPDATE_OPTION'; payload: { name: string; value: string } }; type ProductContextType = {
state: ProductState;
type ProductOptionsContextType = {
options: ProductOptionsState;
updateOption: (name: string, value: string) => void; updateOption: (name: string, value: string) => void;
updateImage: (index: string) => void;
}; };
const ProductOptionsContext = createContext<ProductOptionsContextType | undefined>(undefined); const ProductContext = createContext<ProductContextType | undefined>(undefined);
function productOptionsReducer( export function ProductProvider({ children }: { children: React.ReactNode }) {
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 }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const getInitialOptions = () => { const getInitialState = () => {
const params: ProductOptionsState = {}; const params: ProductState = {};
for (const [key, value] of searchParams.entries()) { for (const [key, value] of searchParams.entries()) {
params[key] = value; params[key] = value;
} }
return params; 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) => { const updateOption = (name: string, value: string) => {
updateOptions({ type: 'UPDATE_OPTION', payload: { name, value } }); setOptimisticState({ [name]: value });
const newParams = new URLSearchParams(window.location.search); const newParams = new URLSearchParams(window.location.search);
newParams.set(name, value); newParams.set(name, value);
router.push(`?${newParams.toString()}`, { scroll: false }); 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( const value = useMemo(
() => ({ () => ({
options, state,
updateOption updateOption,
updateImage
}), }),
[options] [state]
); );
return <ProductOptionsContext.Provider value={value}>{children}</ProductOptionsContext.Provider>; return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>;
} }
export function useProductOptions() { export function useProduct() {
const context = useContext(ProductOptionsContext); const context = useContext(ProductContext);
if (context === undefined) { 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; return context;
} }

View File

@ -1,33 +1,29 @@
import { AddToCart } from 'components/cart/add-to-cart'; import { AddToCart } from 'components/cart/add-to-cart';
import Price from 'components/price'; import Price from 'components/price';
import { ProductOptionsProvider } from 'components/product/product-context';
import Prose from 'components/prose'; import Prose from 'components/prose';
import { Product } from 'lib/shopify/types'; import { Product } from 'lib/shopify/types';
import { Suspense } from 'react';
import { VariantSelector } from './variant-selector'; import { VariantSelector } from './variant-selector';
export function ProductDescription({ product }: { product: Product }) { export function ProductDescription({ product }: { product: Product }) {
return ( return (
<Suspense fallback={null}> <>
<ProductOptionsProvider> <div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
<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>
<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">
<div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white"> <Price
<Price amount={product.priceRange.maxVariantPrice.amount}
amount={product.priceRange.maxVariantPrice.amount} currencyCode={product.priceRange.maxVariantPrice.currencyCode}
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}
/> />
) : null} </div>
<AddToCart product={product} /> </div>
</ProductOptionsProvider> <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} />
</>
); );
} }

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import clsx from 'clsx'; 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'; import { ProductOption, ProductVariant } from 'lib/shopify/types';
type Combination = { type Combination = {
@ -17,7 +17,7 @@ export function VariantSelector({
options: ProductOption[]; options: ProductOption[];
variants: ProductVariant[]; variants: ProductVariant[];
}) { }) {
const { options: selectedOptions, updateOption } = useProductOptions(); const { state, updateOption } = useProduct();
const hasNoOptionsOrJustOneOption = const hasNoOptionsOrJustOneOption =
!options.length || (options.length === 1 && options[0]?.values.length === 1); !options.length || (options.length === 1 && options[0]?.values.length === 1);
@ -42,7 +42,7 @@ export function VariantSelector({
const optionNameLowerCase = option.name.toLowerCase(); const optionNameLowerCase = option.name.toLowerCase();
// Base option params on current selectedOptions so we can preserve any other param state. // 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. // Filter out invalid options and check if the option combination is available for sale.
const filtered = Object.entries(optionParams).filter(([key, value]) => 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. // The option is active if it's in the selected options.
const isActive = selectedOptions[optionNameLowerCase] === value; const isActive = state[optionNameLowerCase] === value;
return ( return (
<button <button