From 512c338e4abcb3cdb7f457e4012e0c90c6a8391a Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Fri, 22 Nov 2024 15:27:37 -0500 Subject: [PATCH] feat(core): update add to cart visual errors (#1674) --- .changeset/quick-owls-relate.md | 5 +++++ .../(default)/compare/_actions/add-to-cart.ts | 19 ++++++++++++---- .../compare/_components/add-to-cart/index.tsx | 8 ++++--- .../product-form/_actions/add-to-cart.ts | 22 ++++++++++++++----- .../[slug]/_components/product-form/index.tsx | 7 ++++-- core/app/notifications.tsx | 2 +- core/client/mutations/add-cart-line-item.ts | 22 ++++++++++++++++--- core/client/mutations/create-cart.ts | 22 ++++++++++++++++--- .../add-to-cart/form/_actions/add-to-cart.ts | 19 ++++++++++++---- .../product-card/add-to-cart/form/index.tsx | 8 ++++--- core/messages/en.json | 7 ++---- 11 files changed, 107 insertions(+), 34 deletions(-) create mode 100644 .changeset/quick-owls-relate.md diff --git a/.changeset/quick-owls-relate.md b/.changeset/quick-owls-relate.md new file mode 100644 index 000000000..3787c3389 --- /dev/null +++ b/.changeset/quick-owls-relate.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Uses the API responses to show better errors when adding a product to the cart. diff --git a/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts b/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts index 38187c1bd..223363596 100644 --- a/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts +++ b/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts @@ -3,8 +3,11 @@ import { revalidateTag } from 'next/cache'; import { cookies } from 'next/headers'; -import { addCartLineItem } from '~/client/mutations/add-cart-line-item'; -import { createCart } from '~/client/mutations/create-cart'; +import { + addCartLineItem, + assertAddCartLineItemErrors, +} from '~/client/mutations/add-cart-line-item'; +import { assertCreateCartErrors, createCart } from '~/client/mutations/create-cart'; import { getCart } from '~/client/queries/get-cart'; import { TAGS } from '~/client/tags'; @@ -20,7 +23,7 @@ export const addToCart = async (data: FormData) => { cart = await getCart(cartId); if (cart) { - cart = await addCartLineItem(cart.entityId, { + const addCartLineItemResponse = await addCartLineItem(cart.entityId, { lineItems: [ { productEntityId, @@ -29,6 +32,10 @@ export const addToCart = async (data: FormData) => { ], }); + assertAddCartLineItemErrors(addCartLineItemResponse); + + cart = addCartLineItemResponse.data.cart.addCartLineItems?.cart; + if (!cart?.entityId) { return { status: 'error', error: 'Failed to add product to cart.' }; } @@ -38,7 +45,11 @@ export const addToCart = async (data: FormData) => { return { status: 'success', data: cart }; } - cart = await createCart([{ productEntityId, quantity: 1 }]); + const createCartResponse = await createCart([{ productEntityId, quantity: 1 }]); + + assertCreateCartErrors(createCartResponse); + + cart = createCartResponse.data.cart.createCart?.cart; if (!cart?.entityId) { return { status: 'error', error: 'Failed to add product to cart.' }; diff --git a/core/app/[locale]/(default)/compare/_components/add-to-cart/index.tsx b/core/app/[locale]/(default)/compare/_components/add-to-cart/index.tsx index 27deb4a88..6cbbf4102 100644 --- a/core/app/[locale]/(default)/compare/_components/add-to-cart/index.tsx +++ b/core/app/[locale]/(default)/compare/_components/add-to-cart/index.tsx @@ -3,7 +3,7 @@ import { FragmentOf } from 'gql.tada'; import { AlertCircle, Check } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useTransition } from 'react'; +import { useId, useTransition } from 'react'; import { toast } from 'react-hot-toast'; import { AddToCartButton } from '~/components/add-to-cart-button'; @@ -17,6 +17,7 @@ import { AddToCartFragment } from './fragment'; export const AddToCart = ({ data: product }: { data: FragmentOf }) => { const t = useTranslations('Compare.AddToCart'); const cart = useCart(); + const toastId = useId(); const [isPending, startTransition] = useTransition(); const handleSubmit = (event: React.FormEvent) => { @@ -47,7 +48,7 @@ export const AddToCart = ({ data: product }: { data: FragmentOf ), - { icon: }, + { icon: , id: toastId }, ); startTransition(async () => { @@ -56,8 +57,9 @@ export const AddToCart = ({ data: product }: { data: FragmentOf, + id: toastId, }); } }); diff --git a/core/app/[locale]/(default)/product/[slug]/_components/product-form/_actions/add-to-cart.ts b/core/app/[locale]/(default)/product/[slug]/_components/product-form/_actions/add-to-cart.ts index ca2ce82ba..73adec219 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/product-form/_actions/add-to-cart.ts +++ b/core/app/[locale]/(default)/product/[slug]/_components/product-form/_actions/add-to-cart.ts @@ -5,8 +5,11 @@ import { revalidateTag } from 'next/cache'; import { cookies } from 'next/headers'; import { FragmentOf, graphql } from '~/client/graphql'; -import { addCartLineItem } from '~/client/mutations/add-cart-line-item'; -import { createCart } from '~/client/mutations/create-cart'; +import { + addCartLineItem, + assertAddCartLineItemErrors, +} from '~/client/mutations/add-cart-line-item'; +import { assertCreateCartErrors, createCart } from '~/client/mutations/create-cart'; import { getCart } from '~/client/queries/get-cart'; import { TAGS } from '~/client/tags'; @@ -138,7 +141,7 @@ export async function handleAddToCart( cart = await getCart(cartId); if (cart) { - cart = await addCartLineItem(cart.entityId, { + const addCartLineItemResponse = await addCartLineItem(cart.entityId, { lineItems: [ { productEntityId, @@ -148,8 +151,12 @@ export async function handleAddToCart( ], }); + assertAddCartLineItemErrors(addCartLineItemResponse); + + cart = addCartLineItemResponse.data.cart.addCartLineItems?.cart; + if (!cart?.entityId) { - return { status: 'error', error: 'Failed to add product to cart.' }; + throw new Error('Failed to add product to cart.'); } revalidateTag(TAGS.cart); @@ -157,8 +164,7 @@ export async function handleAddToCart( return { status: 'success', data: cart }; } - // Create cart - cart = await createCart([ + const createCartResponse = await createCart([ { productEntityId, selectedOptions, @@ -166,6 +172,10 @@ export async function handleAddToCart( }, ]); + assertCreateCartErrors(createCartResponse); + + cart = createCartResponse.data.cart.createCart?.cart; + if (!cart?.entityId) { return { status: 'error', error: 'Failed to add product to cart.' }; } diff --git a/core/app/[locale]/(default)/product/[slug]/_components/product-form/index.tsx b/core/app/[locale]/(default)/product/[slug]/_components/product-form/index.tsx index ed280a51c..277617070 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/product-form/index.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/product-form/index.tsx @@ -4,6 +4,7 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { FragmentOf } from 'gql.tada'; import { AlertCircle, Check, Heart, ShoppingCart } from 'lucide-react'; import { useTranslations } from 'next-intl'; +import { useId } from 'react'; import { FormProvider, useFormContext } from 'react-hook-form'; import { toast } from 'react-hot-toast'; @@ -63,6 +64,7 @@ export const ProductForm = ({ data: product }: Props) => { const productOptions = removeEdgesAndNodes(product.productOptions); const cart = useCart(); + const toastId = useId(); const { handleSubmit, register, ...methods } = useProductForm(); const productFormSubmit = async (data: ProductFormData) => { @@ -91,14 +93,15 @@ export const ProductForm = ({ data: product }: Props) => { ), - { icon: }, + { icon: , id: toastId }, ); const result = await handleAddToCart(data, product); if (result.error) { - toast.error(t('error'), { + toast.error(result.error, { icon: , + id: toastId, }); cart.decrement(quantity); diff --git a/core/app/notifications.tsx b/core/app/notifications.tsx index 095ae743a..a529a4892 100644 --- a/core/app/notifications.tsx +++ b/core/app/notifications.tsx @@ -7,7 +7,7 @@ export const Notifications = () => { position="top-right" toastOptions={{ className: - '!text-black !rounded !border !border-gray-200 !bg-white !shadow-lg !py-4 !px-6 !text-base', + '!text-black !rounded !border !border-gray-200 !bg-white !shadow-lg !py-4 !px-6 !text-base [&>svg]:!shrink-0', }} /> ); diff --git a/core/client/mutations/add-cart-line-item.ts b/core/client/mutations/add-cart-line-item.ts index 00f3aa530..8280d6469 100644 --- a/core/client/mutations/add-cart-line-item.ts +++ b/core/client/mutations/add-cart-line-item.ts @@ -24,12 +24,28 @@ export const addCartLineItem = async ( ) => { const customerAccessToken = await getSessionCustomerAccessToken(); - const response = await client.fetch({ + return await client.fetch({ document: AddCartLineItemMutation, variables: { input: { cartEntityId, data } }, customerAccessToken, fetchOptions: { cache: 'no-store' }, }); - - return response.data.cart.addCartLineItems?.cart; }; + +export function assertAddCartLineItemErrors( + response: Awaited>, +): asserts response is Awaited> { + if (typeof response === 'object' && 'errors' in response && Array.isArray(response.errors)) { + response.errors.forEach((error) => { + if (error.message.includes('Not enough stock:')) { + // This removes the item id from the message. It's very brittle, but it's the only + // solution to do it until our API returns a better error message. + throw new Error( + error.message.replace('Not enough stock: ', '').replace(/\(\w.+\)\s{1}/, ''), + ); + } + + throw new Error(error.message); + }); + } +} diff --git a/core/client/mutations/create-cart.ts b/core/client/mutations/create-cart.ts index c244d7522..8ca9d2e79 100644 --- a/core/client/mutations/create-cart.ts +++ b/core/client/mutations/create-cart.ts @@ -22,7 +22,7 @@ type LineItems = CreateCartInput['lineItems']; export const createCart = async (cartItems: LineItems) => { const customerAccessToken = await getSessionCustomerAccessToken(); - const response = await client.fetch({ + return await client.fetch({ document: CreateCartMutation, variables: { createCartInput: { @@ -32,6 +32,22 @@ export const createCart = async (cartItems: LineItems) => { customerAccessToken, fetchOptions: { cache: 'no-store' }, }); - - return response.data.cart.createCart?.cart; }; + +export function assertCreateCartErrors( + response: Awaited>, +): asserts response is Awaited> { + if (typeof response === 'object' && 'errors' in response && Array.isArray(response.errors)) { + response.errors.forEach((error) => { + if (error.message.includes('Not enough stock:')) { + // This removes the item id from the message. It's very brittle, but it's the only + // solution to do it until our API returns a better error message. + throw new Error( + error.message.replace('Not enough stock: ', '').replace(/\(\w.+\)\s{1}/, ''), + ); + } + + throw new Error(error.message); + }); + } +} diff --git a/core/components/product-card/add-to-cart/form/_actions/add-to-cart.ts b/core/components/product-card/add-to-cart/form/_actions/add-to-cart.ts index 55a14dc8b..222f50446 100644 --- a/core/components/product-card/add-to-cart/form/_actions/add-to-cart.ts +++ b/core/components/product-card/add-to-cart/form/_actions/add-to-cart.ts @@ -3,8 +3,11 @@ import { revalidateTag } from 'next/cache'; import { cookies } from 'next/headers'; -import { addCartLineItem } from '~/client/mutations/add-cart-line-item'; -import { createCart } from '~/client/mutations/create-cart'; +import { + addCartLineItem, + assertAddCartLineItemErrors, +} from '~/client/mutations/add-cart-line-item'; +import { assertCreateCartErrors, createCart } from '~/client/mutations/create-cart'; import { getCart } from '~/client/queries/get-cart'; import { TAGS } from '~/client/tags'; @@ -19,7 +22,7 @@ export const addToCart = async (data: FormData) => { cart = await getCart(cartId); if (cart) { - cart = await addCartLineItem(cart.entityId, { + const addCartLineItemResponse = await addCartLineItem(cart.entityId, { lineItems: [ { productEntityId, @@ -28,6 +31,10 @@ export const addToCart = async (data: FormData) => { ], }); + assertAddCartLineItemErrors(addCartLineItemResponse); + + cart = addCartLineItemResponse.data.cart.addCartLineItems?.cart; + if (!cart?.entityId) { return { status: 'error', error: 'Failed to add product to cart.' }; } @@ -37,7 +44,11 @@ export const addToCart = async (data: FormData) => { return { status: 'success', data: cart }; } - cart = await createCart([{ productEntityId, quantity: 1 }]); + const createCartResponse = await createCart([{ productEntityId, quantity: 1 }]); + + assertCreateCartErrors(createCartResponse); + + cart = createCartResponse.data.cart.createCart?.cart; if (!cart?.entityId) { return { status: 'error', error: 'Failed to add product to cart.' }; diff --git a/core/components/product-card/add-to-cart/form/index.tsx b/core/components/product-card/add-to-cart/form/index.tsx index 7433031a3..c5e78a96b 100644 --- a/core/components/product-card/add-to-cart/form/index.tsx +++ b/core/components/product-card/add-to-cart/form/index.tsx @@ -3,7 +3,7 @@ import { FragmentOf } from 'gql.tada'; import { AlertCircle, Check } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useTransition } from 'react'; +import { useId, useTransition } from 'react'; import { toast } from 'react-hot-toast'; import { AddToCartButton } from '~/components/add-to-cart-button'; @@ -21,6 +21,7 @@ interface Props { export const Form = ({ data: product }: Props) => { const t = useTranslations('Components.ProductCard.AddToCart'); const cart = useCart(); + const toastId = useId(); const [isPending, startTransition] = useTransition(); const handleSubmit = (event: React.FormEvent) => { @@ -51,7 +52,7 @@ export const Form = ({ data: product }: Props) => { ), - { icon: }, + { icon: , id: toastId }, ); startTransition(async () => { @@ -60,8 +61,9 @@ export const Form = ({ data: product }: Props) => { if (result.error) { cart.decrement(quantity); - toast.error(t('error'), { + toast.error(result.error, { icon: , + id: toastId, }); } }); diff --git a/core/messages/en.json b/core/messages/en.json index 66f67cf1e..bd6810638 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -335,8 +335,7 @@ } }, "AddToCart": { - "success": "{cartItems, plural, =1 {1 Item} other {# Items}} added to your cart", - "error": "Error adding product to cart. Please try again." + "success": "{cartItems, plural, =1 {1 Item} other {# Items}} added to your cart" } }, "Product": { @@ -346,7 +345,6 @@ }, "Form": { "success": "{cartItems, plural, =1 {1 Item} other {# Items}} added to your cart", - "error": "Error adding product to cart. Please try again.", "saveToWishlist": "Save to wishlist", "quantityLabel": "Quantity" }, @@ -461,8 +459,7 @@ "ProductCard": { "AddToCart": { "viewOptions": "View options", - "success": "{cartItems, plural, =1 {1 Item} other {# Items}} added to your cart", - "error": "Error adding product to cart. Please try again." + "success": "{cartItems, plural, =1 {1 Item} other {# Items}} added to your cart" } }, "FormFields": {