Add cart endpoints/handlers

Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
Chloe 2022-04-27 15:57:16 +07:00
parent 50f99e907a
commit 1b2904ac1f
16 changed files with 654 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -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<OpenCommerceAPI, CartSchema>
export type CartEndpoint = CartAPI['endpoint']
export const handlers: CartEndpoint['handlers'] = {
addItem,
getCart,
updateItem,
removeItem,
}
const cartApi = createEndpoint<CartAPI>({
handler: cartEndpoint,
handlers,
})
export default cartApi

View File

@ -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

View File

@ -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

View File

@ -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()),
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
}

View File

@ -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<CartTypes>

View File

@ -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(<CartItemEdge>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 <LineItem>{}
}
const {
_id,
compareAtPrice,
imageURLs,
title,
productConfiguration,
priceWhenAdded,
optionTitle,
variantTitle,
quantity,
} = <CartItem>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),
},
],
}
}