From c2ec7d05b22bbe5754ff030b3222a6238f8f0c79 Mon Sep 17 00:00:00 2001 From: "alex.saiannyi" Date: Mon, 22 May 2023 15:22:52 +0200 Subject: [PATCH] feat: add cart operations --- app/api/cart/route.ts | 44 ++++++++++++++--- components/cart/delete-item-button.tsx | 5 +- components/cart/edit-item-quantity-button.tsx | 6 ++- components/cart/modal.tsx | 14 +++--- components/product/add-to-cart.tsx | 10 ++-- lib/bigcommerce/index.ts | 48 ++++++++++++------- lib/bigcommerce/mappers.ts | 13 +++-- lib/bigcommerce/mutations/cart.ts | 1 - 8 files changed, 97 insertions(+), 44 deletions(-) diff --git a/app/api/cart/route.ts b/app/api/cart/route.ts index ba5ef9049..35c64c29a 100644 --- a/app/api/cart/route.ts +++ b/app/api/cart/route.ts @@ -10,15 +10,43 @@ function formatErrorMessage(err: Error): string { export async function POST(req: NextRequest): Promise { const cartId = cookies().get('cartId')?.value; - const { merchandiseId, isBigCommerceAPI } = await req.json(); + const { merchandiseId, isBigCommerceAPI, lineId, productId, quantity, variantId } = await req.json(); - if ((!isBigCommerceAPI && !cartId?.length) || !merchandiseId?.length) { + if (!isBigCommerceAPI && (!cartId?.length || !merchandiseId?.length)) { return NextResponse.json({ error: 'Missing cartId or variantId' }, { status: 400 }); - } else if (isBigCommerceAPI && !merchandiseId?.length) { + } else if (!isBigCommerceAPI && !merchandiseId?.length) { return NextResponse.json({ error: 'Missing variantId' }, { status: 400 }); } + + if (cartId && isBigCommerceAPI && lineId && !variantId || quantity === 0) { + try { + await removeFromCart(cartId!, [lineId]); + + return NextResponse.json({ status: 204 }); + } catch (e) { + if (isVercelCommerceError(e)) { + return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status }); + } + + return NextResponse.json({ status: 500 }); + } + } + try { - await addToCart(cartId || '', [{ merchandiseId, quantity: 1 }]); + const { id: cartEntityId } = await addToCart(cartId || '', [{ merchandiseId, quantity: 1, productId }]); + + if ( isBigCommerceAPI && cartEntityId) { + const response = NextResponse.json({ status: 204 }); + + response.cookies.set('cartId', cartEntityId, { + path: '/', + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production' + }); + + return response; + } + return NextResponse.json({ status: 204 }); } catch (e) { if (isVercelCommerceError(e)) { @@ -31,7 +59,7 @@ export async function POST(req: NextRequest): Promise { export async function PUT(req: NextRequest): Promise { const cartId = cookies().get('cartId')?.value; - const { variantId, quantity, lineId } = await req.json(); + const { variantId, quantity, lineId, productId } = await req.json(); if (!cartId || !variantId || !quantity || !lineId) { return NextResponse.json( @@ -44,9 +72,11 @@ export async function PUT(req: NextRequest): Promise { { id: lineId, merchandiseId: variantId, - quantity + quantity, + productId } ]); + return NextResponse.json({ status: 204 }); } catch (e) { if (isVercelCommerceError(e)) { @@ -57,6 +87,8 @@ export async function PUT(req: NextRequest): Promise { } } +// NOTE: delete route handler fails +// https://github.com/vercel/next.js/issues/48096 export async function DELETE(req: NextRequest): Promise { const cartId = cookies().get('cartId')?.value; const { lineId } = await req.json(); diff --git a/components/cart/delete-item-button.tsx b/components/cart/delete-item-button.tsx index 4fc5197f8..8a8d8de5e 100644 --- a/components/cart/delete-item-button.tsx +++ b/components/cart/delete-item-button.tsx @@ -14,9 +14,10 @@ export default function DeleteItemButton({ item }: { item: CartItem }) { setRemoving(true); const response = await fetch(`/api/cart`, { - method: 'DELETE', + method: 'POST', body: JSON.stringify({ - lineId: item.id + lineId: item.id, + isBigCommerceAPI: true, }) }); const data = await response.json(); diff --git a/components/cart/edit-item-quantity-button.tsx b/components/cart/edit-item-quantity-button.tsx index fd1828536..0a19028fa 100644 --- a/components/cart/edit-item-quantity-button.tsx +++ b/components/cart/edit-item-quantity-button.tsx @@ -21,11 +21,13 @@ export default function EditItemQuantityButton({ setEditing(true); const response = await fetch(`/api/cart`, { - method: type === 'minus' && item.quantity - 1 === 0 ? 'DELETE' : 'PUT', + method: type === 'minus' && item.quantity - 1 === 0 ? 'POST' : 'PUT', body: JSON.stringify({ lineId: item.id, + productId: item.merchandise.product.handle, variantId: item.merchandise.id, - quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1 + quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1, + isBigCommerceAPI: true }) }); diff --git a/components/cart/modal.tsx b/components/cart/modal.tsx index 6c4191525..4a93dc2d0 100644 --- a/components/cart/modal.tsx +++ b/components/cart/modal.tsx @@ -6,8 +6,8 @@ import Link from 'next/link'; import CloseIcon from 'components/icons/close'; import ShoppingBagIcon from 'components/icons/shopping-bag'; import Price from 'components/price'; -import { DEFAULT_OPTION } from 'lib/constants'; import type { VercelCart as Cart } from 'lib/bigcommerce/types'; +import { DEFAULT_OPTION } from 'lib/constants'; import { createUrl } from 'lib/utils'; import DeleteItemButton from './delete-item-button'; import EditItemQuantityButton from './edit-item-quantity-button'; @@ -81,8 +81,10 @@ export default function CartModal({
    {cart.lines.map((item, i) => { const merchandiseSearchParams = {} as MerchandiseSearchParams; + let subTitleWithSelectedOptions = ''; item.merchandise.selectedOptions.forEach(({ name, value }) => { + subTitleWithSelectedOptions += `${name}: ${value} `; if (value !== DEFAULT_OPTION) { merchandiseSearchParams[name.toLowerCase()] = value; } @@ -118,14 +120,14 @@ export default function CartModal({ {item.merchandise.title !== DEFAULT_OPTION ? (

    - {item.merchandise.title} + {item.merchandise.title === item.merchandise.product.title && subTitleWithSelectedOptions ? subTitleWithSelectedOptions : item.merchandise.title}

    ) : null}
    @@ -146,7 +148,7 @@ export default function CartModal({
    @@ -154,7 +156,7 @@ export default function CartModal({
    @@ -166,7 +168,7 @@ export default function CartModal({
    diff --git a/components/product/add-to-cart.tsx b/components/product/add-to-cart.tsx index 9e6b808ee..18409e579 100644 --- a/components/product/add-to-cart.tsx +++ b/components/product/add-to-cart.tsx @@ -14,8 +14,10 @@ export function AddToCart({ variants: ProductVariant[]; availableForSale: boolean; }) { - const productEntityId = variants[0]?.parentId || variants[0]?.id; - const [selectedVariantId, setSelectedVariantId] = useState(productEntityId); + const productEntityId = variants[0]?.parentId; + const varianEntitytId = variants[0]?.id; + const [selectedVariantId, setSelectedVariantId] = useState(varianEntitytId); + const [selectedProductId, setSelectedProductId] = useState(productEntityId); const router = useRouter(); const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); @@ -29,7 +31,8 @@ export function AddToCart({ ); if (variant) { - setSelectedVariantId(variant.parentId || variant.id); + setSelectedVariantId(variant.id); + setSelectedProductId(variant.parentId); } }, [searchParams, variants, setSelectedVariantId]); @@ -44,6 +47,7 @@ export function AddToCart({ method: 'POST', body: JSON.stringify({ merchandiseId: selectedVariantId, + productId: selectedProductId, isBigCommerceAPI: true }) }); diff --git a/lib/bigcommerce/index.ts b/lib/bigcommerce/index.ts index a3493d154..6da2daaaa 100644 --- a/lib/bigcommerce/index.ts +++ b/lib/bigcommerce/index.ts @@ -167,7 +167,7 @@ const getBigCommerceProductsWithCheckout = async ( ) => { const bigCommerceProducts = await Promise.all( lines.map(async ({ merchandiseId }) => { - const productId = Number(merchandiseId); + const productId = parseInt(merchandiseId, 10); const resp = await bigcommerceFetch({ query: getProductQuery, @@ -191,10 +191,24 @@ const getBigCommerceProductsWithCheckout = async ( }, cache: 'no-store' }); + const checkout = resCheckout.body.data.site.checkout ?? { + subtotal: { + value: 0, + currencyCode: '', + }, + grandTotal: { + value: 0, + currencyCode: '', + }, + taxTotal: { + value: 0, + currencyCode: '', + }, + }; return { productsByIdList: bigCommerceProducts, - checkout: resCheckout.body.data.site.checkout + checkout, }; }; @@ -225,7 +239,7 @@ export async function createCart(): Promise { export async function addToCart( cartId: string, - lines: { merchandiseId: string; quantity: number }[] + lines: { merchandiseId: string; quantity: number, productId?: string }[] ): Promise { let bigCommerceCart: BigCommerceCart; @@ -236,8 +250,9 @@ export async function addToCart( addCartLineItemsInput: { cartEntityId: cartId, data: { - lineItems: lines.map(({ merchandiseId, quantity }) => ({ - productEntityId: parseInt(merchandiseId, 10), + lineItems: lines.map(({ merchandiseId, quantity, productId }) => ({ + productEntityId: parseInt(productId!, 10), + variantEntityId: parseInt(merchandiseId, 10), quantity })) } @@ -252,8 +267,9 @@ export async function addToCart( query: createCartMutation, variables: { createCartInput: { - lineItems: lines.map(({ merchandiseId, quantity }) => ({ - productEntityId: parseInt(merchandiseId, 10), + lineItems: lines.map(({ merchandiseId, quantity, productId }) => ({ + productEntityId: parseInt(productId!, 10), + variantEntityId: parseInt(merchandiseId, 10), quantity })) } @@ -303,12 +319,12 @@ export async function removeFromCart(cartId: string, lineIds: string[]): Promise // Update on selected options requires variantEntityId, optionEntityId export async function updateCart( cartId: string, - lines: { id: string; merchandiseId: string; quantity: number }[] + lines: { id: string; merchandiseId: string; quantity: number, productId?: string}[] ): Promise { let cartState: { status: number; body: BigCommerceUpdateCartItemOperation } | undefined; for (let updates = lines.length; updates > 0; updates--) { - const { id, merchandiseId, quantity } = lines[updates - 1]!; + const { id, merchandiseId, quantity, productId } = lines[updates - 1]!; const res = await bigcommerceFetch({ query: updateCartLineItemMutation, variables: { @@ -317,8 +333,9 @@ export async function updateCart( lineItemEntityId: id, data: { lineItem: { - quantity, - productEntityId: Number(merchandiseId) + productEntityId: parseInt(productId!, 10), + variantEntityId: parseInt(merchandiseId, 10), + quantity } } } @@ -335,7 +352,6 @@ export async function updateCart( return bigcommerceToVercelCart(updatedCart, productsByIdList, checkout); } -// NOTE: DONE & review if it works export async function getCart(cartId: string): Promise { const res = await bigcommerceFetch({ query: getCartQuery, @@ -351,11 +367,11 @@ export async function getCart(cartId: string): Promise { const lines = vercelFromBigCommerceLineItems(cart.lineItems); const { productsByIdList, checkout } = await getBigCommerceProductsWithCheckout(cartId, lines); - return bigcommerceToVercelCart(cart, productsByIdList, checkout); + return bigcommerceToVercelCart(cart, productsByIdList, checkout);; } export async function getCollection(handle: string): Promise { - const entityId = await getCategoryEntityIdbyHandle(handle); // NOTE: check if this approach suits us + const entityId = await getCategoryEntityIdbyHandle(handle); const res = await bigcommerceFetch({ query: getCategoryQuery, variables: { @@ -481,7 +497,7 @@ export async function getMenu(handle: string): Promise { title: name, path: createVercelCollectionPath(verceLTitle!) }; - // NOTE: for NavBar we probably should keep it only high level categories + // NOTE: keep only high level categories for NavBar // if (hasChildren && children) { // return configureVercelMenu(children, hasChildren); // } @@ -504,7 +520,6 @@ export async function getMenu(handle: string): Promise { return []; } -// TODO: replace with BC API next Page(s) Methods export async function getPage(handle: string): Promise { const entityId = await getEntityIdByHandle(handle); const res = await bigcommerceFetch({ @@ -528,7 +543,6 @@ export async function getPages(): Promise { } export async function getProduct(handle: string): Promise { - // const productId = await getEntityIdByHandle(handle); // NOTE: check of this approach work const res = await bigcommerceFetch({ query: getProductQuery, variables: { diff --git a/lib/bigcommerce/mappers.ts b/lib/bigcommerce/mappers.ts index e5370353d..af12b7255 100644 --- a/lib/bigcommerce/mappers.ts +++ b/lib/bigcommerce/mappers.ts @@ -22,8 +22,8 @@ type ProductsList = { productId: number; productData: BigCommerceProduct }[]; const vercelFromBigCommerceLineItems = (lineItems: BigCommerceCart['lineItems']) => { const { physicalItems, digitalItems, customItems } = lineItems; - const cartItemMapper = ({ entityId, quantity }: DigitalOrPhysicalItem | CartCustomItem) => ({ - merchandiseId: entityId.toString(), + const cartItemMapper = ({ entityId, quantity, productEntityId }: DigitalOrPhysicalItem | CartCustomItem) => ({ + merchandiseId: productEntityId ? productEntityId.toString() : entityId.toString(), quantity }); @@ -201,17 +201,17 @@ const bigcommerceToVercelCartItems = ( } return { - id: item.entityId.toString(), + id: item.entityId.toString(), // NOTE: used as lineId || lineItemId quantity: item.quantity, cost: { totalAmount: { amount: - item.extendedListPrice.value.toString() || item.listPrice.value.toString() || '0', + item.extendedListPrice.value.toString() || item.listPrice.value.toString() || '0', currencyCode: item.extendedListPrice.currencyCode || item.listPrice.currencyCode || '' } }, merchandise: { - id: item.entityId.toString(), + id: isCustomItem ? item.entityId.toString() : (item as DigitalOrPhysicalItem).variantEntityId!.toString(), title: `${item.name}`, selectedOptions, product @@ -238,9 +238,8 @@ const bigcommerceToVercelCart = ( ): VercelCart => { return { id: cart.entityId, - checkoutUrl: '', // NOTE: where to get checkoutUrl?? + checkoutUrl: '', // TODO: add later cost: { - // NOTE: these props lay down in checkout not cart subtotalAmount: { amount: checkout.subtotal.value.toString(), currencyCode: checkout.subtotal.currencyCode diff --git a/lib/bigcommerce/mutations/cart.ts b/lib/bigcommerce/mutations/cart.ts index 5d145ce73..8bbc93b6c 100644 --- a/lib/bigcommerce/mutations/cart.ts +++ b/lib/bigcommerce/mutations/cart.ts @@ -111,7 +111,6 @@ const updateCartLineItemMutation = /* GraphQL */ ` updatedAt { utc } - totalQuantity lineItems { totalQuantity physicalItems {