diff --git a/components/cart/CartItem/CartItem.tsx b/components/cart/CartItem/CartItem.tsx index 71ab58966..1e9062e91 100644 --- a/components/cart/CartItem/CartItem.tsx +++ b/components/cart/CartItem/CartItem.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import s from './CartItem.module.css' import { Trash, Plus, Minus } from '@components/icons' import { useUI } from '@components/ui/context' +import type { LineItem } from '@framework/types' import usePrice from '@framework/product/use-price' import useUpdateItem from '@framework/cart/use-update-item' import useRemoveItem from '@framework/cart/use-remove-item' diff --git a/framework/bigcommerce/api/cart/handlers/update-item.ts b/framework/bigcommerce/api/cart/handlers/update-item.ts index df9ccaee8..b0ccc710b 100644 --- a/framework/bigcommerce/api/cart/handlers/update-item.ts +++ b/framework/bigcommerce/api/cart/handlers/update-item.ts @@ -14,6 +14,9 @@ const updateItem: CartHandlers['updateItem'] = async ({ }) } + console.log('ITEM', item) + console.log('AFTER', parseCartItem(item)) + const { data } = await config.storeApiFetch( `/v3/carts/${cartId}/items/${itemId}`, { diff --git a/framework/bigcommerce/api/cart/index.ts b/framework/bigcommerce/api/cart/index.ts index 8988f3606..0f0365cc3 100644 --- a/framework/bigcommerce/api/cart/index.ts +++ b/framework/bigcommerce/api/cart/index.ts @@ -8,6 +8,7 @@ import getCart from './handlers/get-cart' import addItem from './handlers/add-item' import updateItem from './handlers/update-item' import removeItem from './handlers/remove-item' +import type { Cart, UpdateCartItemHandlerBody } from '../../types' type OptionSelections = { option_id: Number @@ -23,40 +24,12 @@ export type ItemBody = { export type AddItemBody = { item: ItemBody } -export type UpdateItemBody = { itemId: string; item: ItemBody } - export type RemoveItemBody = { itemId: string } -// TODO: this type should match: -// https://developer.bigcommerce.com/api-reference/cart-checkout/server-server-cart-api/cart/getacart#responses -export type Cart = { - id: string - parent_id?: string - customer_id: number - email: string - currency: { code: string } - tax_included: boolean - base_amount: number - discount_amount: number - cart_amount: number - line_items: { - custom_items: any[] - digital_items: any[] - gift_certificates: any[] - physical_items: any[] - } - created_time: string - discounts?: { id: number; discounted_amount: number }[] - // TODO: add missing fields -} - export type CartHandlers = { getCart: BigcommerceHandler addItem: BigcommerceHandler> - updateItem: BigcommerceHandler< - Cart, - { cartId?: string } & Partial - > + updateItem: BigcommerceHandler removeItem: BigcommerceHandler< Cart, { cartId?: string } & Partial diff --git a/framework/bigcommerce/api/utils/parse-item.ts b/framework/bigcommerce/api/utils/parse-item.ts index 8a50881b1..dcc716c23 100644 --- a/framework/bigcommerce/api/utils/parse-item.ts +++ b/framework/bigcommerce/api/utils/parse-item.ts @@ -1,14 +1,21 @@ import type { ItemBody as WishlistItemBody } from '../wishlist' -import type { ItemBody } from '../cart' +import type { CartItemBody, OptionSelections } from '../../types' + +type BCCartItemBody = { + product_id: number + variant_id: number + quantity?: number + option_selections?: OptionSelections +} export const parseWishlistItem = (item: WishlistItemBody) => ({ product_id: item.productId, variant_id: item.variantId, }) -export const parseCartItem = (item: ItemBody) => ({ +export const parseCartItem = (item: CartItemBody): BCCartItemBody => ({ quantity: item.quantity, - product_id: item.productId, - variant_id: item.variantId, + product_id: Number(item.productId), + variant_id: Number(item.variantId), option_selections: item.optionSelections, }) diff --git a/framework/bigcommerce/cart/use-update-item.tsx b/framework/bigcommerce/cart/use-update-item.tsx index 88a5b8c9d..6592b090f 100644 --- a/framework/bigcommerce/cart/use-update-item.tsx +++ b/framework/bigcommerce/cart/use-update-item.tsx @@ -1,14 +1,16 @@ import { useCallback } from 'react' import debounce from 'lodash.debounce' import type { HookFetcher } from '@commerce/utils/types' -import { CommerceError } from '@commerce/utils/errors' +import { ValidationError } from '@commerce/utils/errors' import useCartUpdateItem from '@commerce/cart/use-update-item' import { normalizeCart } from '../lib/normalize' import type { - ItemBody, - UpdateItemBody, - Cart as BigcommerceCart, -} from '../api/cart' + UpdateCartItemBody, + UpdateCartItemInput, + Cart, + BigcommerceCart, + LineItem, +} from '../types' import { fetcher as removeFetcher } from './use-remove-item' import useCart from './use-cart' @@ -17,9 +19,7 @@ const defaultOpts = { method: 'PUT', } -export type UpdateItemInput = Partial<{ id: string } & ItemBody> - -export const fetcher: HookFetcher = async ( +export const fetcher: HookFetcher = async ( options, { itemId, item }, fetch @@ -30,12 +30,12 @@ export const fetcher: HookFetcher = async ( return removeFetcher(null, { itemId }, fetch) } } else if (item.quantity) { - throw new CommerceError({ + throw new ValidationError({ message: 'The item quantity has to be a valid integer', }) } - const data = await fetch({ + const data = await fetch({ ...defaultOpts, ...options, body: { itemId, item }, @@ -45,26 +45,41 @@ export const fetcher: HookFetcher = async ( } function extendHook(customFetcher: typeof fetcher, cfg?: { wait?: number }) { - const useUpdateItem = (item?: any) => { + const useUpdateItem = ( + item?: T + ) => { const { mutate } = useCart() - const fn = useCartUpdateItem( + const fn = useCartUpdateItem( defaultOpts, customFetcher ) return useCallback( - debounce(async (input: UpdateItemInput) => { - const data = await fn({ - itemId: input.id ?? item?.id, - item: { - productId: input.productId ?? item?.product_id, - variantId: input.productId ?? item?.variant_id, - quantity: input.quantity, - }, - }) - await mutate(data, false) - return data - }, cfg?.wait ?? 500), + debounce( + async ( + input: T extends LineItem + ? Partial + : UpdateCartItemInput + ) => { + const itemId = input.id ?? item?.id + const productId = input.productId ?? item?.productId + const variantId = input.productId ?? item?.variantId + + if (!itemId || !productId || !variantId) { + throw new ValidationError({ + message: 'Invalid input used for this operation', + }) + } + + const data = await fn({ + itemId, + item: { productId, variantId, quantity: input.quantity }, + }) + await mutate(data, false) + return data + }, + cfg?.wait ?? 500 + ), [fn, mutate] ) } diff --git a/framework/bigcommerce/lib/normalize.ts b/framework/bigcommerce/lib/normalize.ts index ec0f73a86..89aed2c38 100644 --- a/framework/bigcommerce/lib/normalize.ts +++ b/framework/bigcommerce/lib/normalize.ts @@ -1,4 +1,4 @@ -import type { Cart as BigcommerceCart } from '../api/cart' +import type { Cart, BigcommerceCart, LineItem } from '../types' import update from './immutability' function normalizeProductOption(productOption: any) { @@ -90,6 +90,7 @@ function normalizeLineItem(item: any): LineItem { return { id: item.id, variantId: String(item.variant_id), + productId: String(item.product_id), name: item.name, quantity: item.quantity, variant: { diff --git a/framework/bigcommerce/types.d.ts b/framework/bigcommerce/types.d.ts deleted file mode 100644 index d987381e7..000000000 --- a/framework/bigcommerce/types.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -interface Cart extends BaseCart { - lineItems: LineItem[] -} - -interface LineItem extends BaseLineItem {} diff --git a/framework/bigcommerce/types.ts b/framework/bigcommerce/types.ts new file mode 100644 index 000000000..766f2dbe9 --- /dev/null +++ b/framework/bigcommerce/types.ts @@ -0,0 +1,54 @@ +import * as Core from '@commerce/types' + +// TODO: this type should match: +// https://developer.bigcommerce.com/api-reference/cart-checkout/server-server-cart-api/cart/getacart#responses +export type BigcommerceCart = { + id: string + parent_id?: string + customer_id: number + email: string + currency: { code: string } + tax_included: boolean + base_amount: number + discount_amount: number + cart_amount: number + line_items: { + custom_items: any[] + digital_items: any[] + gift_certificates: any[] + physical_items: any[] + } + created_time: string + discounts?: { id: number; discounted_amount: number }[] + // TODO: add missing fields +} + +export interface Cart extends Core.Cart { + lineItems: LineItem[] +} + +export interface LineItem extends Core.LineItem {} + +/** + * Cart mutations + */ + +export type OptionSelections = { + option_id: number + option_value: number | string +} + +export interface CartItemBody extends Core.CartItemBody { + productId: string // The product id is always required for BC + optionSelections?: OptionSelections +} + +export interface UpdateCartItemBody extends Core.UpdateCartItemBody { + item: CartItemBody +} + +export interface UpdateCartItemInput + extends Core.UpdateCartItemInput {} + +export interface UpdateCartItemHandlerBody + extends Core.UpdateCartItemHandlerBody {} diff --git a/framework/commerce/cart/use-cart.tsx b/framework/commerce/cart/use-cart.tsx index ecc537539..94ccb73fc 100644 --- a/framework/commerce/cart/use-cart.tsx +++ b/framework/commerce/cart/use-cart.tsx @@ -1,15 +1,16 @@ import Cookies from 'js-cookie' import type { HookInput, HookFetcher, HookFetcherOptions } from '../utils/types' import useData, { ResponseState, SwrOptions } from '../utils/use-data' +import type { Cart } from '../types' import { useCommerce } from '..' export type CartResponse = ResponseState & { isEmpty?: boolean } export type CartInput = { - cartId?: BaseCart['id'] + cartId?: Cart['id'] } -export default function useCart( +export default function useCart( options: HookFetcherOptions, input: HookInput, fetcherFn: HookFetcher, diff --git a/framework/commerce/types.d.ts b/framework/commerce/types.d.ts index 9bb996470..9e69ec25d 100644 --- a/framework/commerce/types.d.ts +++ b/framework/commerce/types.d.ts @@ -45,105 +45,6 @@ interface ProductPrice { extendedListPrice?: number } -interface DiscountBase { - // The value of the discount, can be an amount or percentage - value: number -} - -interface BaseLineItem { - id: string - variantId: string - name: string - quantity: number - discounts: DiscountBase[] - // A human-friendly unique string automatically generated from the product’s name - path: string - variant: BaseProductVariant -} - -interface Measurement { - value: number - unit: 'KILOGRAMS' | 'GRAMS' | 'POUNDS' | 'OUNCES' -} - -interface Image { - url: string - altText?: string - width?: number - height?: number -} - -interface BaseProductVariant { - id: string - // The SKU (stock keeping unit) associated with the product variant. - sku: string - // The product variant’s title, or the product's name. - name: string - // Whether a customer needs to provide a shipping address when placing - // an order for the product variant. - requiresShipping: boolean - // The product variant’s price after all discounts are applied. - price: number - // Product variant’s price, as quoted by the manufacturer/distributor. - listPrice: number - // Image associated with the product variant. Falls back to the product image - // if no image is available. - image?: Image - // Indicates whether this product variant is in stock. - isInStock?: boolean - // Indicates if the product variant is available for sale. - availableForSale?: boolean - // The variant's weight. If a weight was not explicitly specified on the - // variant this will be the product's weight. - weight?: Measurement - // The variant's height. If a height was not explicitly specified on the - // variant, this will be the product's height. - height?: Measurement - // The variant's width. If a width was not explicitly specified on the - // variant, this will be the product's width. - width?: Measurement - // The variant's depth. If a depth was not explicitly specified on the - // variant, this will be the product's depth. - depth?: Measurement -} - -// Shopping cart, a.k.a Checkout -interface BaseCart { - id: string - // ID of the customer to which the cart belongs. - customerId?: string - // The email assigned to this cart - email?: string - // The date and time when the cart was created. - createdAt: string - // The currency used for this cart - currency: { code: string } - // Specifies if taxes are included in the line items. - taxesIncluded: boolean - lineItems: BaseLineItem[] - // The sum of all the prices of all the items in the cart. - // Duties, taxes, shipping and discounts excluded. - lineItemsSubtotalPrice: number - // Price of the cart before duties, shipping and taxes. - subtotalPrice: number - // The sum of all the prices of all the items in the cart. - // Duties, taxes and discounts included. - totalPrice: number - // Discounts that have been applied on the cart. - discounts?: DiscountBase[] -} - -// TODO: Remove this type in favor of BaseCart -interface Cart2 extends Entity { - id: string | undefined - currency: { code: string } - taxIncluded?: boolean - items: Pick & CartItem[] - subTotal: number | string - total: number | string - customerId: Customer['id'] -} - interface CartItem extends Entity { quantity: number productId: Product['id'] diff --git a/framework/commerce/types.ts b/framework/commerce/types.ts new file mode 100644 index 000000000..c662077a1 --- /dev/null +++ b/framework/commerce/types.ts @@ -0,0 +1,111 @@ +export interface Discount { + // The value of the discount, can be an amount or percentage + value: number +} + +export interface LineItem { + id: string + variantId: string + productId: string + name: string + quantity: number + discounts: Discount[] + // A human-friendly unique string automatically generated from the product’s name + path: string + variant: ProductVariant +} + +export interface Measurement { + value: number + unit: 'KILOGRAMS' | 'GRAMS' | 'POUNDS' | 'OUNCES' +} + +export interface Image { + url: string + altText?: string + width?: number + height?: number +} + +export interface ProductVariant { + id: string + // The SKU (stock keeping unit) associated with the product variant. + sku: string + // The product variant’s title, or the product's name. + name: string + // Whether a customer needs to provide a shipping address when placing + // an order for the product variant. + requiresShipping: boolean + // The product variant’s price after all discounts are applied. + price: number + // Product variant’s price, as quoted by the manufacturer/distributor. + listPrice: number + // Image associated with the product variant. Falls back to the product image + // if no image is available. + image?: Image + // Indicates whether this product variant is in stock. + isInStock?: boolean + // Indicates if the product variant is available for sale. + availableForSale?: boolean + // The variant's weight. If a weight was not explicitly specified on the + // variant this will be the product's weight. + weight?: Measurement + // The variant's height. If a height was not explicitly specified on the + // variant, this will be the product's height. + height?: Measurement + // The variant's width. If a width was not explicitly specified on the + // variant, this will be the product's width. + width?: Measurement + // The variant's depth. If a depth was not explicitly specified on the + // variant, this will be the product's depth. + depth?: Measurement +} + +// Shopping cart, a.k.a Checkout +export interface Cart { + id: string + // ID of the customer to which the cart belongs. + customerId?: string + // The email assigned to this cart + email?: string + // The date and time when the cart was created. + createdAt: string + // The currency used for this cart + currency: { code: string } + // Specifies if taxes are included in the line items. + taxesIncluded: boolean + lineItems: LineItem[] + // The sum of all the prices of all the items in the cart. + // Duties, taxes, shipping and discounts excluded. + lineItemsSubtotalPrice: number + // Price of the cart before duties, shipping and taxes. + subtotalPrice: number + // The sum of all the prices of all the items in the cart. + // Duties, taxes and discounts included. + totalPrice: number + // Discounts that have been applied on the cart. + discounts?: Discount[] +} + +// Base cart item body used for cart mutations +export interface CartItemBody { + variantId: string + productId?: string + quantity?: number +} + +// Body by the update operation +export interface UpdateCartItemBody { + itemId: string + item: CartItemBody +} + +// Input expected by the `useUpdateItem` hook +export type UpdateCartItemInput = T & { + id: string +} + +// Body expected by the update operation handler +export interface UpdateCartItemHandlerBody extends Partial { + cartId?: string +} diff --git a/framework/commerce/utils/errors.ts b/framework/commerce/utils/errors.ts index 76f899ab7..f4ab9fb9a 100644 --- a/framework/commerce/utils/errors.ts +++ b/framework/commerce/utils/errors.ts @@ -26,6 +26,14 @@ export class CommerceError extends Error { } } +// Used for errors that come from a bad implementation of the hooks +export class ValidationError extends CommerceError { + constructor(options: ErrorProps) { + super(options) + this.code = 'validation_error' + } +} + export class FetcherError extends CommerceError { status: number diff --git a/framework/commerce/utils/types.ts b/framework/commerce/utils/types.ts index 483b0c73c..010205f62 100644 --- a/framework/commerce/utils/types.ts +++ b/framework/commerce/utils/types.ts @@ -1,18 +1,18 @@ // Core fetcher added by CommerceProvider export type Fetcher = (options: FetcherOptions) => T | Promise -export type FetcherOptions = { +export type FetcherOptions = { url?: string query?: string method?: string variables?: any - body?: any + body?: Body } export type HookFetcher = ( options: HookFetcherOptions | null, input: Input, - fetch: (options: FetcherOptions) => Promise + fetch: (options: FetcherOptions) => Promise ) => Data | Promise export type HookFetcherOptions = {