From 1a0d183681953da9bd72241b8a67ed1a8d67efcb Mon Sep 17 00:00:00 2001 From: Chloe Date: Fri, 31 May 2024 15:32:00 +0700 Subject: [PATCH] feat: core charge shouldn't be treated as a separate product Signed-off-by: Chloe --- components/cart/actions.ts | 26 +++-- components/cart/delete-item-button.tsx | 7 +- components/cart/edit-item-quantity-button.tsx | 22 +++- components/cart/line-item.tsx | 108 ++++++++++++++++++ components/cart/modal.tsx | 96 ++-------------- lib/shopify/index.ts | 65 ++++++++++- lib/shopify/queries/product.ts | 18 +++ lib/shopify/types.ts | 30 +++++ 8 files changed, 262 insertions(+), 110 deletions(-) create mode 100644 components/cart/line-item.tsx diff --git a/components/cart/actions.ts b/components/cart/actions.ts index 595315540..084b5dc85 100644 --- a/components/cart/actions.ts +++ b/components/cart/actions.ts @@ -34,7 +34,7 @@ export async function addItem(prevState: any, selectedVariantIds: Array) } } -export async function removeItem(prevState: any, lineId: string) { +export async function removeItem(prevState: any, lineIds: string[]) { const cartId = cookies().get('cartId')?.value; if (!cartId) { @@ -42,7 +42,7 @@ export async function removeItem(prevState: any, lineId: string) { } try { - await removeFromCart(cartId, [lineId]); + await removeFromCart(cartId, lineIds); revalidateTag(TAGS.cart); } catch (e) { return 'Error removing item from cart'; @@ -55,7 +55,7 @@ export async function updateItemQuantity( lineId: string; variantId: string; quantity: number; - } + }[] ) { const cartId = cookies().get('cartId')?.value; @@ -63,24 +63,28 @@ export async function updateItemQuantity( return 'Missing cart ID'; } - const { lineId, variantId, quantity } = payload; + const itemsToRemove = payload.filter((item) => item.quantity === 0); try { - if (quantity === 0) { - await removeFromCart(cartId, [lineId]); + if (itemsToRemove.length > 0) { + await removeFromCart( + cartId, + itemsToRemove.map((item) => item.lineId) + ); revalidateTag(TAGS.cart); return; } - await updateCart(cartId, [ - { + await updateCart( + cartId, + payload.map(({ lineId, variantId, quantity }) => ({ id: lineId, merchandiseId: variantId, quantity - } - ]); + })) + ); revalidateTag(TAGS.cart); } catch (e) { - return 'Error updating item quantity'; + return 'Error updating items quantity'; } } diff --git a/components/cart/delete-item-button.tsx b/components/cart/delete-item-button.tsx index 814e1f389..c0854727b 100644 --- a/components/cart/delete-item-button.tsx +++ b/components/cart/delete-item-button.tsx @@ -36,8 +36,11 @@ function SubmitButton() { export function DeleteItemButton({ item }: { item: CartItem }) { const [message, formAction] = useFormState(removeItem, null); - const itemId = item.id; - const actionWithVariant = formAction.bind(null, itemId); + const { id: itemId, coreCharge } = item; + const actionWithVariant = formAction.bind(null, [ + itemId, + ...(coreCharge?.lineId ? [coreCharge.lineId] : []) + ]); return (
diff --git a/components/cart/edit-item-quantity-button.tsx b/components/cart/edit-item-quantity-button.tsx index b743ab704..007e33f0c 100644 --- a/components/cart/edit-item-quantity-button.tsx +++ b/components/cart/edit-item-quantity-button.tsx @@ -39,11 +39,23 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) { 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 quantity = type === 'plus' ? item.quantity + 1 : item.quantity - 1; + const payload = [ + { + lineId: item.id, + variantId: item.merchandise.id, + quantity + } + ]; + + if (item.coreCharge?.lineId) { + payload.push({ + lineId: item.coreCharge.lineId, + variantId: item.coreCharge.id, + quantity + }); + } + const actionWithVariant = formAction.bind(null, payload); return ( diff --git a/components/cart/line-item.tsx b/components/cart/line-item.tsx new file mode 100644 index 000000000..a6a8bafcc --- /dev/null +++ b/components/cart/line-item.tsx @@ -0,0 +1,108 @@ +import { PlusIcon } from '@heroicons/react/16/solid'; +import Price from 'components/price'; +import { DEFAULT_OPTION } from 'lib/constants'; +import { CartItem } from 'lib/shopify/types'; +import { createUrl } from 'lib/utils'; +import Image from 'next/image'; +import Link from 'next/link'; +import { DeleteItemButton } from './delete-item-button'; +import { EditItemQuantityButton } from './edit-item-quantity-button'; + +type LineItemProps = { + item: CartItem; + closeCart: () => void; +}; + +type MerchandiseSearchParams = { + [key: string]: string; +}; + +const CoreCharge = ({ + coreCharge, + quantity +}: { + coreCharge: CartItem['coreCharge']; + quantity: number; +}) => { + if (!coreCharge) return null; + + return ( +
+ +
+ {coreCharge.selectedOptions[0] ? ( + + ) : ( + Included + )} + {`x ${quantity}`} +
(Core Charge)
+
+
+ ); +}; +const LineItem = ({ item, closeCart }: LineItemProps) => { + const merchandiseSearchParams = {} as MerchandiseSearchParams; + + item.merchandise.selectedOptions.forEach(({ name, value }) => { + if (value !== DEFAULT_OPTION) { + merchandiseSearchParams[name.toLowerCase()] = value; + } + }); + + const merchandiseUrl = createUrl( + `/product/${item.merchandise.product.handle}`, + new URLSearchParams(merchandiseSearchParams) + ); + + return ( +
  • +
    +
    + +
    + +
    + {item.merchandise.product.featuredImage.altText +
    + +
    + {item.merchandise.product.title} + {item.merchandise.title !== DEFAULT_OPTION ? ( +

    + {item.merchandise.title} +

    + ) : null} +
    + +
    +
    + +
    + +

    + {item.quantity} +

    + +
    +
    + +
  • + ); +}; + +export default LineItem; diff --git a/components/cart/modal.tsx b/components/cart/modal.tsx index 728ce24d3..82bc3f5fa 100644 --- a/components/cart/modal.tsx +++ b/components/cart/modal.tsx @@ -1,23 +1,14 @@ 'use client'; -import { Dialog, Transition } from '@headlessui/react'; +import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; import { ShoppingCartIcon } from '@heroicons/react/24/outline'; import Price from 'components/price'; -import { DEFAULT_OPTION } from 'lib/constants'; import type { Cart } from 'lib/shopify/types'; -import { createUrl } from 'lib/utils'; -import Image from 'next/image'; -import Link from 'next/link'; import { Fragment, useEffect, useRef, useState } from 'react'; import CloseCart from './close-cart'; -import { DeleteItemButton } from './delete-item-button'; -import { EditItemQuantityButton } from './edit-item-quantity-button'; +import LineItem from './line-item'; import OpenCart from './open-cart'; -type MerchandiseSearchParams = { - [key: string]: string; -}; - export default function CartModal({ cart }: { cart: Cart | undefined }) { const [isOpen, setIsOpen] = useState(false); const quantityRef = useRef(cart?.totalQuantity); @@ -44,7 +35,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) { - diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index 3f12c8d65..b4af42976 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -34,10 +34,13 @@ import { getPageQuery, getPagesQuery } from './queries/page'; import { getProductQuery, getProductRecommendationsQuery, + getProductVariantQuery, getProductsQuery } from './queries/product'; import { Cart, + CartItem, + CartProductVariant, Collection, Connection, Filter, @@ -49,6 +52,7 @@ import { PageInfo, Product, ProductVariant, + ProductVariantOperation, ShopifyAddToCartOperation, ShopifyCart, ShopifyCartOperation, @@ -377,7 +381,55 @@ export async function getCart(cartId: string): Promise { return undefined; } - return reshapeCart(res.body.data.cart); + const cart = reshapeCart(res.body.data.cart); + + let extendedCartLines = cart.lines; + + const lineIdMap = {} as { [key: string]: string }; + // get product variants details including core charge variant data + const productVariantPromises = + cart?.lines.map((line) => { + lineIdMap[line.merchandise.id] = line.id; + return getProductVariant(line?.merchandise.id); + }) || []; + + if (productVariantPromises.length) { + const coreVariantIds = [] as string[]; + const productVariantsById = (await Promise.allSettled(productVariantPromises)) + .filter((result) => result.status === 'fulfilled') + .reduce( + (acc, result) => { + const _result = result as PromiseFulfilledResult; + return { + ...acc, + [_result.value.id]: { ..._result.value, lineId: lineIdMap[_result.value.id] } + }; + }, + {} as { [key: string]: CartProductVariant & { lineId?: string } } + ); + + // add core charge field to cart line item if any + extendedCartLines = cart?.lines + .reduce((lines, item) => { + const productVariant = productVariantsById[item.merchandise.id]; + if (productVariant && productVariant.coreVariantId) { + const coreCharge = productVariantsById[productVariant.coreVariantId]; + coreVariantIds.push(productVariant.coreVariantId); + return lines.concat([ + { + ...item, + coreCharge + } + ]); + } + return lines; + }, [] as CartItem[]) + .filter((item) => !coreVariantIds.includes(item.merchandise.id)); // remove core charge items from cart lines as it's not a separate line item + } + + const totalQuantity = extendedCartLines.reduce((sum, line) => sum + line.quantity, 0); + + return { ...cart, totalQuantity, lines: extendedCartLines }; } export async function getCollection({ @@ -567,16 +619,17 @@ export async function getProduct(handle: string): Promise { return reshapeProduct(res.body.data.product, false); } -export async function getProductVariant(handle: string): Promise { - const res = await shopifyFetch({ - query: getProductQuery, +export async function getProductVariant(id: string) { + const res = await shopifyFetch({ + query: getProductVariantQuery, tags: [TAGS.products], variables: { - handle + id } }); - return reshapeProduct(res.body.data.product, false); + const variant = res.body.data.node; + return { ...variant, coreVariantId: variant.coreVariantId?.value || null }; } export async function getProductRecommendations(productId: string): Promise { diff --git a/lib/shopify/queries/product.ts b/lib/shopify/queries/product.ts index e1f7e74c2..92aabb21c 100644 --- a/lib/shopify/queries/product.ts +++ b/lib/shopify/queries/product.ts @@ -35,3 +35,21 @@ export const getProductRecommendationsQuery = /* GraphQL */ ` } ${productFragment} `; + +export const getProductVariantQuery = /* GraphQL */ ` + query getProductVariant($id: ID!) { + node(id: $id) { + ... on ProductVariant { + id + title + selectedOptions { + name + value + } + coreVariantId: metafield(namespace: "custom", key: "coreVariant") { + value + } + } + } + } +`; diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts index 1a76814b0..b24df84ee 100644 --- a/lib/shopify/types.ts +++ b/lib/shopify/types.ts @@ -27,6 +27,15 @@ export type CartItem = { }[]; product: Product; }; + coreCharge?: { + id: string; + title: string; + lineId?: string; + selectedOptions: { + name: string; + value: string; + }[]; + }; }; export type Collection = ShopifyCollection & { @@ -128,6 +137,20 @@ export type ProductVariant = { condition: string | null; }; +export type ShopifyCartProductVariant = { + title: string; + id: string; + selectedOptions: { + name: string; + value: string; + }[]; + coreVariantId: { value: string } | null; +}; + +export type CartProductVariant = Omit & { + coreVariantId: string | null; +}; + export type ShopifyProductVariant = Omit< ProductVariant, 'coreCharge' | 'waiverAvailable' | 'coreVariantId' | 'mileage' | 'estimatedDelivery' | 'condition' @@ -334,6 +357,13 @@ export type ShopifyProductOperation = { }; }; +export type ProductVariantOperation = { + data: { node: ShopifyCartProductVariant }; + variables: { + id: string; + }; +}; + export type ShopifyProductRecommendationsOperation = { data: { productRecommendations: ShopifyProduct[];