Skip to content

Commit

Permalink
Make image, variant, and cart updates faster with useOptimistic (ve…
Browse files Browse the repository at this point in the history
  • Loading branch information
leerob authored Jul 29, 2024
1 parent dd7449f commit 9a4c995
Show file tree
Hide file tree
Showing 24 changed files with 644 additions and 384 deletions.
21 changes: 18 additions & 3 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import Navbar from 'components/layout/navbar';
import { CartProvider } from 'components/cart/cart-context';
import { Navbar } from 'components/layout/navbar';
import { WelcomeToast } from 'components/welcome-toast';
import { GeistSans } from 'geist/font/sans';
import { getCart } from 'lib/shopify';
import { ensureStartsWith } from 'lib/utils';
import { cookies } from 'next/headers';
import { ReactNode } from 'react';
import { Toaster } from 'sonner';
import './globals.css';

const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
Expand Down Expand Up @@ -32,11 +37,21 @@ export const metadata = {
};

export default async function RootLayout({ children }: { children: ReactNode }) {
const cartId = cookies().get('cartId')?.value;
// Don't await the fetch, pass the Promise to the context provider
const cart = getCart(cartId);

return (
<html lang="en" className={GeistSans.variable}>
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<Navbar />
<main>{children}</main>
<CartProvider cartPromise={cart}>
<Navbar />
<main>
{children}
<Toaster closeButton />
<WelcomeToast />
</main>
</CartProvider>
</body>
</html>
);
Expand Down
2 changes: 1 addition & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const metadata = {
}
};

export default async function HomePage() {
export default function HomePage() {
return (
<>
<ThreeItemGrid />
Expand Down
11 changes: 7 additions & 4 deletions app/product/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -72,7 +73,7 @@ export default async function ProductPage({ params }: { params: { handle: string
};

return (
<>
<ProductProvider>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
Expand All @@ -88,7 +89,7 @@ export default async function ProductPage({ params }: { params: { handle: string
}
>
<Gallery
images={product.images.map((image: Image) => ({
images={product.images.slice(0, 5).map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
Expand All @@ -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>
);
}

Expand Down
2 changes: 1 addition & 1 deletion app/search/loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
97 changes: 63 additions & 34 deletions components/cart/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,9 @@ import { redirect } from 'next/navigation';

export async function addItem(prevState: any, selectedVariantId: string | undefined) {
let cartId = cookies().get('cartId')?.value;
let cart;

if (cartId) {
cart = await getCart(cartId);
}

if (!cartId || !cart) {
cart = await createCart();
cartId = cart.id;
cookies().set('cartId', cartId);
}

if (!selectedVariantId) {
return 'Missing product variant ID';
if (!cartId || !selectedVariantId) {
return 'Error adding item to cart';
}

try {
Expand All @@ -32,16 +21,28 @@ export async function addItem(prevState: any, selectedVariantId: string | undefi
}
}

export async function removeItem(prevState: any, lineId: string) {
const cartId = cookies().get('cartId')?.value;
export async function removeItem(prevState: any, merchandiseId: string) {
let cartId = cookies().get('cartId')?.value;

if (!cartId) {
return 'Missing cart ID';
}

try {
await removeFromCart(cartId, [lineId]);
revalidateTag(TAGS.cart);
const cart = await getCart(cartId);

if (!cart) {
return 'Error fetching cart';
}

const lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);

if (lineItem && lineItem.id) {
await removeFromCart(cartId, [lineItem.id]);
revalidateTag(TAGS.cart);
} else {
return 'Item not found in cart';
}
} catch (e) {
return 'Error removing item from cart';
}
Expand All @@ -50,40 +51,68 @@ export async function removeItem(prevState: any, lineId: string) {
export async function updateItemQuantity(
prevState: any,
payload: {
lineId: string;
variantId: string;
merchandiseId: string;
quantity: number;
}
) {
const cartId = cookies().get('cartId')?.value;
let cartId = cookies().get('cartId')?.value;

if (!cartId) {
return 'Missing cart ID';
}

const { lineId, variantId, quantity } = payload;
const { merchandiseId, quantity } = payload;

try {
if (quantity === 0) {
await removeFromCart(cartId, [lineId]);
revalidateTag(TAGS.cart);
return;
const cart = await getCart(cartId);

if (!cart) {
return 'Error fetching cart';
}

await updateCart(cartId, [
{
id: lineId,
merchandiseId: variantId,
quantity
const lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);

if (lineItem && lineItem.id) {
if (quantity === 0) {
await removeFromCart(cartId, [lineItem.id]);
} else {
await updateCart(cartId, [
{
id: lineItem.id,
merchandiseId,
quantity
}
]);
}
]);
} else if (quantity > 0) {
// If the item doesn't exist in the cart and quantity > 0, add it
await addToCart(cartId, [{ merchandiseId, quantity }]);
}

revalidateTag(TAGS.cart);
} catch (e) {
console.error(e);
return 'Error updating item quantity';
}
}

export async function redirectToCheckout(formData: FormData) {
const url = formData.get('url') as string;
redirect(url);
export async function redirectToCheckout() {
let cartId = cookies().get('cartId')?.value;

if (!cartId) {
return 'Missing cart ID';
}

let cart = await getCart(cartId);

if (!cart) {
return 'Error fetching cart';
}

redirect(cart.checkoutUrl);
}

export async function createCartAndSetCookie() {
let cart = await createCart();
cookies().set('cartId', cart.id!);
}
45 changes: 20 additions & 25 deletions components/cart/add-to-cart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { addItem } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types';
import { useSearchParams } from 'next/navigation';
import { useFormState, useFormStatus } from 'react-dom';
import { useProduct } from 'components/product/product-context';
import { Product, ProductVariant } from 'lib/shopify/types';
import { useFormState } from 'react-dom';
import { useCart } from './cart-context';

function SubmitButton({
availableForSale,
Expand All @@ -15,7 +15,6 @@ function SubmitButton({
availableForSale: boolean;
selectedVariantId: string | undefined;
}) {
const { pending } = useFormStatus();
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';
Expand Down Expand Up @@ -45,44 +44,40 @@ function SubmitButton({

return (
<button
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Add to cart"
aria-disabled={pending}
className={clsx(buttonClasses, {
'hover:opacity-90': true,
[disabledClasses]: pending
'hover:opacity-90': true
})}
>
<div className="absolute left-0 ml-4">
{pending ? <LoadingDots className="mb-3 bg-white" /> : <PlusIcon className="h-5" />}
<PlusIcon className="h-5" />
</div>
Add To Cart
</button>
);
}

export function AddToCart({
variants,
availableForSale
}: {
variants: ProductVariant[];
availableForSale: boolean;
}) {
export function AddToCart({ product }: { product: Product }) {
const { variants, availableForSale } = product;
const { addCartItem } = useCart();
const { state } = useProduct();
const [message, formAction] = useFormState(addItem, null);
const searchParams = useSearchParams();
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;

const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every(
(option) => option.value === searchParams.get(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;
const actionWithVariant = formAction.bind(null, selectedVariantId);
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;

return (
<form action={actionWithVariant}>
<form
action={async () => {
addCartItem(finalVariant, product);
await actionWithVariant();
}}
>
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
<p aria-live="polite" className="sr-only" role="status">
{message}
Expand Down
Loading

0 comments on commit 9a4c995

Please sign in to comment.