Skip to content

Commit

Permalink
Improves form submissions and updates dependencies (#1209)
Browse files Browse the repository at this point in the history
  • Loading branch information
leerob authored Oct 11, 2023
1 parent ece49c4 commit 1f47796
Show file tree
Hide file tree
Showing 13 changed files with 851 additions and 915 deletions.
46 changes: 30 additions & 16 deletions components/cart/actions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
'use server';

import { TAGS } from 'lib/constants';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';

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

Expand All @@ -17,53 +19,65 @@ export const addItem = async (variantId: string | undefined): Promise<String | u
cookies().set('cartId', cartId);
}

if (!variantId) {
if (!selectedVariantId) {
return 'Missing product variant ID';
}

try {
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
revalidateTag(TAGS.cart);
} catch (e) {
return 'Error adding item to cart';
}
};
}

export const removeItem = async (lineId: string): Promise<String | undefined> => {
export async function removeItem(prevState: any, lineId: string) {
const cartId = cookies().get('cartId')?.value;

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

try {
await removeFromCart(cartId, [lineId]);
revalidateTag(TAGS.cart);
} catch (e) {
return 'Error removing item from cart';
}
};
}

export const updateItemQuantity = async ({
lineId,
variantId,
quantity
}: {
lineId: string;
variantId: string;
quantity: number;
}): Promise<String | undefined> => {
export async function updateItemQuantity(
prevState: any,
payload: {
lineId: string;
variantId: string;
quantity: number;
}
) {
const cartId = cookies().get('cartId')?.value;

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

const { lineId, variantId, quantity } = payload;

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

await updateCart(cartId, [
{
id: lineId,
merchandiseId: variantId,
quantity
}
]);
revalidateTag(TAGS.cart);
} catch (e) {
return 'Error updating item quantity';
}
};
}
110 changes: 69 additions & 41 deletions components/cart/add-to-cart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,67 @@ import clsx from 'clsx';
import { addItem } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { useSearchParams } from 'next/navigation';
import {
// @ts-ignore
experimental_useFormState as useFormState,
experimental_useFormStatus as useFormStatus
} from 'react-dom';

function SubmitButton({
availableForSale,
selectedVariantId
}: {
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';

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

if (!selectedVariantId) {
return (
<button
aria-label="Please select an option"
aria-disabled
className={clsx(buttonClasses, disabledClasses)}
>
<div className="absolute left-0 ml-4">
<PlusIcon className="h-5" />
</div>
Add To Cart
</button>
);
}

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
})}
>
<div className="absolute left-0 ml-4">
{pending ? <LoadingDots className="mb-3 bg-white" /> : <PlusIcon className="h-5" />}
</div>
Add To Cart
</button>
);
}

export function AddToCart({
variants,
Expand All @@ -15,54 +74,23 @@ export function AddToCart({
variants: ProductVariant[];
availableForSale: boolean;
}) {
const router = useRouter();
const [message, formAction] = useFormState(addItem, null);
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
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())
)
);
const selectedVariantId = variant?.id || defaultVariantId;
const title = !availableForSale
? 'Out of stock'
: !selectedVariantId
? 'Please select options'
: undefined;
const actionWithVariant = formAction.bind(null, selectedVariantId);

return (
<button
aria-label="Add item to cart"
disabled={isPending || !availableForSale || !selectedVariantId}
title={title}
onClick={() => {
// Safeguard in case someone messes with `disabled` in devtools.
if (!availableForSale || !selectedVariantId) return;

startTransition(async () => {
const error = await addItem(selectedVariantId);

if (error) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}

router.refresh();
});
}}
className={clsx(
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90',
{
'cursor-not-allowed opacity-60 hover:opacity-60': !availableForSale || !selectedVariantId,
'cursor-not-allowed': isPending
}
)}
>
<div className="absolute left-0 ml-4">
{!isPending ? <PlusIcon className="h-5" /> : <LoadingDots className="mb-3 bg-white" />}
</div>
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
</button>
<form action={actionWithVariant}>
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}
54 changes: 32 additions & 22 deletions components/cart/delete-item-button.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,54 @@
import { XMarkIcon } from '@heroicons/react/24/outline';
import LoadingDots from 'components/loading-dots';
import { useRouter } from 'next/navigation';
'use client';

import { XMarkIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { removeItem } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/shopify/types';
import { useTransition } from 'react';
import {
// @ts-ignore
experimental_useFormState as useFormState,
experimental_useFormStatus as useFormStatus
} from 'react-dom';

export default function DeleteItemButton({ item }: { item: CartItem }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
function SubmitButton() {
const { pending } = useFormStatus();

return (
<button
aria-label="Remove cart item"
onClick={() => {
startTransition(async () => {
const error = await removeItem(item.id);

if (error) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}

router.refresh();
});
type="submit"
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
disabled={isPending}
aria-label="Remove cart item"
aria-disabled={pending}
className={clsx(
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200',
{
'cursor-not-allowed px-0': isPending
'cursor-not-allowed px-0': pending
}
)}
>
{isPending ? (
{pending ? (
<LoadingDots className="bg-white" />
) : (
<XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
)}
</button>
);
}

export function DeleteItemButton({ item }: { item: CartItem }) {
const [message, formAction] = useFormState(removeItem, null);
const itemId = item.id;
const actionWithVariant = formAction.bind(null, itemId);

return (
<form action={actionWithVariant}>
<SubmitButton />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}
69 changes: 35 additions & 34 deletions components/cart/edit-item-quantity-button.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,36 @@
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
'use client';

import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { removeItem, updateItemQuantity } from 'components/cart/actions';
import { updateItemQuantity } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/shopify/types';
import {
// @ts-ignore
experimental_useFormState as useFormState,
experimental_useFormStatus as useFormStatus
} from 'react-dom';

export default function EditItemQuantityButton({
item,
type
}: {
item: CartItem;
type: 'plus' | 'minus';
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
const { pending } = useFormStatus();

return (
<button
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
onClick={() => {
startTransition(async () => {
const error =
type === 'minus' && item.quantity - 1 === 0
? await removeItem(item.id)
: await updateItemQuantity({
lineId: item.id,
variantId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
});

if (error) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}

router.refresh();
});
type="submit"
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
disabled={isPending}
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
aria-disabled={pending}
className={clsx(
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
{
'cursor-not-allowed': isPending,
'cursor-not-allowed': pending,
'ml-auto': type === 'minus'
}
)}
>
{isPending ? (
{pending ? (
<LoadingDots className="bg-black dark:bg-white" />
) : type === 'plus' ? (
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
Expand All @@ -58,3 +40,22 @@ export default function EditItemQuantityButton({
</button>
);
}

export function EditItemQuantityButton({ item, type }: { item: CartItem; type: 'plus' | 'minus' }) {
const [message, formAction] = useFormState(updateItemQuantity, null);
const payload = {
lineId: item.id,
variantId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
};
const actionWithVariant = formAction.bind(null, payload);

return (
<form action={actionWithVariant}>
<SubmitButton type={type} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}
Loading

1 comment on commit 1f47796

@vercel
Copy link

@vercel vercel bot commented on 1f47796 Oct 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.