From 1b2904ac1fc132242d0f9d5560706b8452f6421c Mon Sep 17 00:00:00 2001 From: Chloe Date: Wed, 27 Apr 2022 15:57:16 +0700 Subject: [PATCH] Add cart endpoints/handlers Signed-off-by: Chloe --- .../src/api/endpoints/cart/add-item.ts | 66 +++++ .../src/api/endpoints/cart/get-cart.ts | 31 +++ .../src/api/endpoints/cart/index.ts | 27 +- .../src/api/endpoints/cart/remove-item.ts | 37 +++ .../src/api/endpoints/cart/update-item.ts | 37 +++ packages/opencommerce/src/api/index.ts | 2 + .../src/api/mutations/add-cart-item.ts | 24 ++ .../src/api/mutations/create-cart.ts | 24 ++ .../src/api/mutations/remove-cart-item.ts | 13 + .../mutations/update-cart-item-quantity.ts | 13 + .../src/api/queries/get-anonymous-cart.ts | 11 + .../queries/get-cart-by-accountId-query.ts | 11 + .../src/api/queries/get-cart-query.ts | 241 ++++++++++++++++++ .../src/api/utils/get-cart-cookie.ts | 20 ++ packages/opencommerce/src/types/cart.ts | 23 ++ packages/opencommerce/src/utils/normalize.ts | 75 ++++++ 16 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 packages/opencommerce/src/api/endpoints/cart/add-item.ts create mode 100644 packages/opencommerce/src/api/endpoints/cart/get-cart.ts create mode 100644 packages/opencommerce/src/api/endpoints/cart/remove-item.ts create mode 100644 packages/opencommerce/src/api/endpoints/cart/update-item.ts create mode 100644 packages/opencommerce/src/api/mutations/add-cart-item.ts create mode 100644 packages/opencommerce/src/api/mutations/create-cart.ts create mode 100644 packages/opencommerce/src/api/mutations/remove-cart-item.ts create mode 100644 packages/opencommerce/src/api/mutations/update-cart-item-quantity.ts create mode 100644 packages/opencommerce/src/api/queries/get-anonymous-cart.ts create mode 100644 packages/opencommerce/src/api/queries/get-cart-by-accountId-query.ts create mode 100644 packages/opencommerce/src/api/queries/get-cart-query.ts create mode 100644 packages/opencommerce/src/api/utils/get-cart-cookie.ts create mode 100644 packages/opencommerce/src/types/cart.ts diff --git a/packages/opencommerce/src/api/endpoints/cart/add-item.ts b/packages/opencommerce/src/api/endpoints/cart/add-item.ts new file mode 100644 index 000000000..cc2af828f --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/cart/add-item.ts @@ -0,0 +1,66 @@ +import { normalizeCart } from '../../../utils/normalize' +import getCartCookie from '../../utils/get-cart-cookie' +import addCartItemsMutation from '../../mutations/add-cart-item' +import createCartMutation from '../../mutations/create-cart' + +import type { CartEndpoint } from '.' +import { CreateCartPayload } from '../../../../schema' + +const addItem: CartEndpoint['handlers']['addItem'] = async ({ + res, + body: { cartId, item }, + config, + req: { cookies }, +}) => { + if (!item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing item' }], + }) + } + if (!item.quantity) item.quantity = 1 + + const variables = { + input: { + shopId: config.shopId, + items: [ + { + productConfiguration: { + productId: item.productId, + productVariantId: item.variantId, + }, + quantity: item.quantity, + price: item.price, + }, + ], + }, + } + + if (!cartId) { + const { data } = await config.fetch(createCartMutation, { variables }) + res.setHeader('Set-Cookie', [ + getCartCookie(config.cartCookie, data.cart._id, config.cartCookieMaxAge), + getCartCookie( + config.anonymousCartTokenCookie, + data.token, + config.cartCookieMaxAge + ), + ]) + + return res.status(200).json({ data: normalizeCart(data.cart) }) + } + + const { data } = await config.fetch(addCartItemsMutation, { + variables: { + input: { + items: variables.input.items, + cartId, + cartToken: cookies[config.anonymousCartTokenCookie], + }, + }, + }) + + return res.status(200).json({ data: normalizeCart(data.cart) }) +} + +export default addItem diff --git a/packages/opencommerce/src/api/endpoints/cart/get-cart.ts b/packages/opencommerce/src/api/endpoints/cart/get-cart.ts new file mode 100644 index 000000000..a96599e40 --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/cart/get-cart.ts @@ -0,0 +1,31 @@ +import { normalizeCart } from '../../../utils/normalize' +import getCartCookie from '../../utils/get-cart-cookie' +import getAnonymousCart from '../../queries/get-anonymous-cart' +import type { CartEndpoint } from '.' + +// Return current cart info +const getCart: CartEndpoint['handlers']['getCart'] = async ({ + res, + req: { cookies }, + body: { cartId }, + config, +}) => { + if (cartId && cookies[config.anonymousCartTokenCookie]) { + const { data } = await config.fetch(getAnonymousCart, { + variables: { + cartId, + cartToken: cookies[config.anonymousCartTokenCookie], + }, + }) + + return res.status(200).json({ + data: normalizeCart(data), + }) + } + + res.status(200).json({ + data: null, + }) +} + +export default getCart diff --git a/packages/opencommerce/src/api/endpoints/cart/index.ts b/packages/opencommerce/src/api/endpoints/cart/index.ts index 491bf0ac9..cd69adfe4 100644 --- a/packages/opencommerce/src/api/endpoints/cart/index.ts +++ b/packages/opencommerce/src/api/endpoints/cart/index.ts @@ -1 +1,26 @@ -export default function noopApi(...args: any[]): void {} +import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' +import cartEndpoint from '@vercel/commerce/api/endpoints/cart' +import type { CartSchema } from '../../../types/cart' +import type { OpenCommerceAPI } from '../../index' +import getCart from './get-cart' +import addItem from './add-item' +import updateItem from './update-item' +import removeItem from './remove-item' + +export type CartAPI = GetAPISchema + +export type CartEndpoint = CartAPI['endpoint'] + +export const handlers: CartEndpoint['handlers'] = { + addItem, + getCart, + updateItem, + removeItem, +} + +const cartApi = createEndpoint({ + handler: cartEndpoint, + handlers, +}) + +export default cartApi diff --git a/packages/opencommerce/src/api/endpoints/cart/remove-item.ts b/packages/opencommerce/src/api/endpoints/cart/remove-item.ts new file mode 100644 index 000000000..dd20ed931 --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/cart/remove-item.ts @@ -0,0 +1,37 @@ +import { normalizeCart } from '../../../utils/normalize' +import getCartCookie from '../../utils/get-cart-cookie' +import removeCartItemsMutation from '../../mutations/remove-cart-item' +import type { CartEndpoint } from '.' + +const removeItem: CartEndpoint['handlers']['removeItem'] = async ({ + res, + body: { cartId, itemId }, + config, + req: { cookies }, +}) => { + if (!cartId || !itemId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + const { data } = await config.fetch(removeCartItemsMutation, { + variables: { + input: { + cartId, + cartItemIds: [itemId], + cartToken: cookies[config.anonymousCartTokenCookie], + }, + }, + }) + + res.setHeader( + 'Set-Cookie', + getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge) + ) + + res.status(200).json({ data: normalizeCart(data.cart) }) +} + +export default removeItem diff --git a/packages/opencommerce/src/api/endpoints/cart/update-item.ts b/packages/opencommerce/src/api/endpoints/cart/update-item.ts new file mode 100644 index 000000000..db14da4cc --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/cart/update-item.ts @@ -0,0 +1,37 @@ +import { normalizeCart } from '../../../utils/normalize' +import getCartCookie from '../../utils/get-cart-cookie' +import updateCartItemsQuantityMutation from '../../mutations/update-cart-item-quantity' +import type { CartEndpoint } from '.' + +const updateItem: CartEndpoint['handlers']['updateItem'] = async ({ + res, + body: { cartId, itemId, item }, + config, + req: { cookies }, +}) => { + if (!cartId || !itemId || !item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + const { data } = await config.fetch(updateCartItemsQuantityMutation, { + variables: { + input: { + cartId, + cartToken: cookies[config.anonymousCartTokenCookie], + items: [{ cartItemId: itemId, quantity: item.quantity }], + }, + }, + }) + + // Update the cart cookie + res.setHeader( + 'Set-Cookie', + getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge) + ) + res.status(200).json({ data: normalizeCart(data.cart) }) +} + +export default updateItem diff --git a/packages/opencommerce/src/api/index.ts b/packages/opencommerce/src/api/index.ts index b418aac0e..9065b5421 100644 --- a/packages/opencommerce/src/api/index.ts +++ b/packages/opencommerce/src/api/index.ts @@ -18,6 +18,7 @@ if (!API_URL) { export interface OpenCommerceConfig extends CommerceAPIConfig { shopId: string + anonymousCartTokenCookie: string } const ONE_DAY = 60 * 60 * 24 @@ -29,6 +30,7 @@ const config: OpenCommerceConfig = { customerCookie: 'opencommerce_customerToken', cartCookie: 'opencommerce_cartId', cartCookieMaxAge: ONE_DAY * 30, + anonymousCartTokenCookie: 'opencommerce_anonymousCartToken', fetch: createFetchGraphqlApi(() => getCommerceApi().getConfig()), } diff --git a/packages/opencommerce/src/api/mutations/add-cart-item.ts b/packages/opencommerce/src/api/mutations/add-cart-item.ts new file mode 100644 index 000000000..e694c1b16 --- /dev/null +++ b/packages/opencommerce/src/api/mutations/add-cart-item.ts @@ -0,0 +1,24 @@ +import { + cartPayloadFragment, + incorrectPriceFailureDetailsFragment, + minOrderQuantityFailureDetailsFragment, +} from '../queries/get-cart-query' + +const addCartItemsMutation = /* GraphQL */ ` + mutation addCartItemsMutation($input: AddCartItemsInput!) { + addCartItems(input: $input) { + cart { + ${cartPayloadFragment} + } + incorrectPriceFailures { + ${incorrectPriceFailureDetailsFragment} + } + minOrderQuantityFailures { + ${minOrderQuantityFailureDetailsFragment} + } + clientMutationId + } + } +` + +export default addCartItemsMutation diff --git a/packages/opencommerce/src/api/mutations/create-cart.ts b/packages/opencommerce/src/api/mutations/create-cart.ts new file mode 100644 index 000000000..9f7a289c4 --- /dev/null +++ b/packages/opencommerce/src/api/mutations/create-cart.ts @@ -0,0 +1,24 @@ +import { + cartPayloadFragment, + incorrectPriceFailureDetailsFragment, + minOrderQuantityFailureDetailsFragment, +} from '../queries/get-cart-query' + +const createCartMutation = /* GraphQL */ ` + mutation createCartMutation($input: CreateCartInput!) { + createCart(input: $input) { + cart { + ${cartPayloadFragment} + } + incorrectPriceFailures { + ${incorrectPriceFailureDetailsFragment} + } + minOrderQuantityFailures { + ${minOrderQuantityFailureDetailsFragment} + } + clientMutationId + token + } + } +` +export default createCartMutation diff --git a/packages/opencommerce/src/api/mutations/remove-cart-item.ts b/packages/opencommerce/src/api/mutations/remove-cart-item.ts new file mode 100644 index 000000000..25d43993b --- /dev/null +++ b/packages/opencommerce/src/api/mutations/remove-cart-item.ts @@ -0,0 +1,13 @@ +import { cartPayloadFragment } from '../queries/get-cart-query' + +const removeCartItemsMutation = ` + mutation removeCartItemsMutation($input: RemoveCartItemsInput!) { + removeCartItems(input: $input) { + cart { + ${cartPayloadFragment} + } + } + } +` + +export default removeCartItemsMutation diff --git a/packages/opencommerce/src/api/mutations/update-cart-item-quantity.ts b/packages/opencommerce/src/api/mutations/update-cart-item-quantity.ts new file mode 100644 index 000000000..2d74dd1c2 --- /dev/null +++ b/packages/opencommerce/src/api/mutations/update-cart-item-quantity.ts @@ -0,0 +1,13 @@ +import { cartPayloadFragment } from '../queries/get-cart-query' + +const updateCartItemsQuantityMutation = ` + mutation updateCartItemsQuantity($updateCartItemsQuantityInput: UpdateCartItemsQuantityInput!) { + updateCartItemsQuantity(input: $updateCartItemsQuantityInput) { + cart { + ${cartPayloadFragment} + } + } + } +` + +export default updateCartItemsQuantityMutation diff --git a/packages/opencommerce/src/api/queries/get-anonymous-cart.ts b/packages/opencommerce/src/api/queries/get-anonymous-cart.ts new file mode 100644 index 000000000..fdefb93a7 --- /dev/null +++ b/packages/opencommerce/src/api/queries/get-anonymous-cart.ts @@ -0,0 +1,11 @@ +import { cartQueryFragment } from './get-cart-query' + +export const getAnonymousCart = /* GraphQL */ ` + query anonymousCartByCartIdQuery($cartId: ID!, $cartToken: String!) { + cart: anonymousCartByCartId(cartId: $cartId, cartToken: $cartToken) { + ${cartQueryFragment} + } + } +` + +export default getAnonymousCart diff --git a/packages/opencommerce/src/api/queries/get-cart-by-accountId-query.ts b/packages/opencommerce/src/api/queries/get-cart-by-accountId-query.ts new file mode 100644 index 000000000..259f72c60 --- /dev/null +++ b/packages/opencommerce/src/api/queries/get-cart-by-accountId-query.ts @@ -0,0 +1,11 @@ +import { cartQueryFragment } from './get-cart-query' + +const accountCartByAccountIdQuery = ` + query accountCartByAccountIdQuery($accountId: ID!, $shopId: ID!, $itemsAfterCursor: ConnectionCursor) { + cart: accountCartByAccountId(accountId: $accountId, shopId: $shopId) { + ${cartQueryFragment} + } + } +` + +export default accountCartByAccountIdQuery diff --git a/packages/opencommerce/src/api/queries/get-cart-query.ts b/packages/opencommerce/src/api/queries/get-cart-query.ts new file mode 100644 index 000000000..bae98d0b0 --- /dev/null +++ b/packages/opencommerce/src/api/queries/get-cart-query.ts @@ -0,0 +1,241 @@ +export const cartCommon = ` + _id + createdAt + account { + _id + emailRecords { + address + } + } + shop { + _id + currency { + code + } + } + email + updatedAt + expiresAt + checkout { + fulfillmentGroups { + _id + type + data { + shippingAddress { + address1 + address2 + city + company + country + fullName + isBillingDefault + isCommercial + isShippingDefault + phone + postal + region + } + } + availableFulfillmentOptions { + price { + amount + displayAmount + } + fulfillmentMethod { + _id + name + displayName + } + } + selectedFulfillmentOption { + fulfillmentMethod { + _id + name + displayName + } + price { + amount + displayAmount + } + handlingPrice { + amount + displayAmount + } + } + shop { + _id + } + shippingAddress { + address1 + address2 + city + company + country + fullName + isBillingDefault + isCommercial + isShippingDefault + phone + postal + region + } + } + summary { + fulfillmentTotal { + displayAmount + } + itemTotal { + amount + displayAmount + } + surchargeTotal { + amount + displayAmount + } + taxTotal { + amount + displayAmount + } + total { + amount + currency { + code + } + displayAmount + } + } + } + totalItemQuantity +` + +const cartItemConnectionFragment = ` + pageInfo { + hasNextPage + endCursor + } + edges { + node { + _id + productConfiguration { + productId + productVariantId + } + addedAt + attributes { + label + value + } + createdAt + isBackorder + isLowQuantity + isSoldOut + imageURLs { + large + small + original + medium + thumbnail + } + metafields { + value + key + } + parcel { + length + width + weight + height + } + price { + amount + displayAmount + currency { + code + } + } + priceWhenAdded { + amount + displayAmount + currency { + code + } + } + productSlug + productType + quantity + shop { + _id + } + subtotal { + displayAmount + } + title + productTags { + nodes { + name + } + } + productVendor + variantTitle + optionTitle + updatedAt + inventoryAvailableToSell + } + } +` + +export const cartPayloadFragment = ` + ${cartCommon} + items { + ${cartItemConnectionFragment} + } +` + +export const incorrectPriceFailureDetailsFragment = ` + currentPrice { + amount + currency { + code + } + displayAmount + } + productConfiguration { + productId + productVariantId + } + providedPrice { + amount + currency { + code + } + displayAmount + } +` + +export const minOrderQuantityFailureDetailsFragment = ` + minOrderQuantity + productConfiguration { + productId + productVariantId + } + quantity +` + +const getCartQuery = /* GraphQL */ ` + query($checkoutId: ID!) { + node(id: $checkoutId) { + ... on Checkout { + ${cartCommon} + } + } + } +` + +export const cartQueryFragment = ` + ${cartCommon} + items(first: 20, after: $itemsAfterCursor) { + ${cartItemConnectionFragment} + } +` + +export default getCartQuery diff --git a/packages/opencommerce/src/api/utils/get-cart-cookie.ts b/packages/opencommerce/src/api/utils/get-cart-cookie.ts new file mode 100644 index 000000000..7ca6cd5e4 --- /dev/null +++ b/packages/opencommerce/src/api/utils/get-cart-cookie.ts @@ -0,0 +1,20 @@ +import { serialize, CookieSerializeOptions } from 'cookie' + +export default function getCartCookie( + name: string, + cartId?: string, + maxAge?: number +) { + const options: CookieSerializeOptions = + cartId && maxAge + ? { + maxAge, + expires: new Date(Date.now() + maxAge * 1000), + secure: process.env.NODE_ENV === 'production', + path: '/', + sameSite: 'lax', + } + : { maxAge: -1, path: '/' } // Removes the cookie + + return serialize(name, cartId || '', options) +} diff --git a/packages/opencommerce/src/types/cart.ts b/packages/opencommerce/src/types/cart.ts new file mode 100644 index 000000000..c88aee77f --- /dev/null +++ b/packages/opencommerce/src/types/cart.ts @@ -0,0 +1,23 @@ +import * as Core from '@vercel/commerce/types/cart' + +export * from '@vercel/commerce/types/cart' + +export type Cart = Core.Cart & { + lineItems: Core.LineItem[] + id: string +} + +export type CartItemBody = Core.CartItemBody & { + price: { + amount: number + currency: string + } +} + +export type CartTypes = { + cart: Cart + item: Core.LineItem + itemBody: CartItemBody +} + +export type CartSchema = Core.CartSchema diff --git a/packages/opencommerce/src/utils/normalize.ts b/packages/opencommerce/src/utils/normalize.ts index e2745a9ce..133881c90 100644 --- a/packages/opencommerce/src/utils/normalize.ts +++ b/packages/opencommerce/src/utils/normalize.ts @@ -10,7 +10,11 @@ import { CatalogProduct, CatalogProductVariant, ImageInfo, + Cart as OCCart, + CartItemEdge, + CartItem, } from '../../schema' +import { Cart, LineItem } from '../types/cart' const normalizeProductImages = (images: ImageInfo[], name: string) => images.map((image) => ({ @@ -227,3 +231,74 @@ export function normalizeVendors({ name }: OCVendor): Vendor { }, } } + +export function normalizeCart(cart: OCCart): Cart { + return { + id: cart._id, + customerId: cart.account?._id ?? '', + email: + (cart.account?.emailRecords && cart.account?.emailRecords[0]?.address) ?? + '', + + createdAt: cart.createdAt, + currency: { + code: cart.checkout?.summary?.total?.currency.code ?? '', + }, + lineItems: + cart.items?.edges?.map((cartItem) => + normalizeLineItem(cartItem) + ) ?? [], + lineItemsSubtotalPrice: +(cart.checkout?.summary?.itemTotal?.amount ?? 0), + subtotalPrice: +(cart.checkout?.summary?.itemTotal?.amount ?? 0), + totalPrice: cart.checkout?.summary?.total?.amount ?? 0, + discounts: [], + taxesIncluded: !!cart.checkout?.summary?.taxTotal?.amount, + } +} + +function normalizeLineItem(cartItemEdge: CartItemEdge): LineItem { + const cartItem = cartItemEdge.node + + if (!cartItem) { + return {} + } + + const { + _id, + compareAtPrice, + imageURLs, + title, + productConfiguration, + priceWhenAdded, + optionTitle, + variantTitle, + quantity, + } = cartItem + + return { + id: _id, + variantId: String(productConfiguration?.productVariantId), + productId: String(productConfiguration?.productId), + name: `${title}`, + quantity, + variant: { + id: String(productConfiguration?.productVariantId), + sku: String(productConfiguration?.productVariantId), + name: String(optionTitle || variantTitle), + image: { + url: imageURLs?.thumbnail ?? '/product-img-placeholder.svg', + }, + requiresShipping: true, + price: priceWhenAdded?.amount, + listPrice: compareAtPrice?.amount ?? 0, + }, + path: '', + discounts: [], + options: [ + { + value: String(optionTitle || variantTitle), + name: String(optionTitle || variantTitle), + }, + ], + } +}