diff --git a/framework/ordercloud/api/endpoints/cart/add-item.ts b/framework/ordercloud/api/endpoints/cart/add-item.ts new file mode 100644 index 000000000..a9b36afb2 --- /dev/null +++ b/framework/ordercloud/api/endpoints/cart/add-item.ts @@ -0,0 +1,77 @@ +import type { CartEndpoint } from '.' +import type { RawVariant } from '../../../types/product' +import type { OrdercloudLineItem } from '../../../types/cart' + +import { serialize } from 'cookie' + +import { formatCart } from '../../utils/cart' + +const addItem: CartEndpoint['handlers']['addItem'] = async ({ + res, + body: { cartId, item }, + config: { fetch, cartCookie }, +}) => { + // Return an error if no item is present + if (!item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing item' }], + }) + } + + // Set the quantity if not present + if (!item.quantity) item.quantity = 1 + + // Create an order if it doesn't exist + if (!cartId) { + cartId = await fetch('POST', `/orders/Outgoing`, {}).then( + (response: { ID: string }) => response.ID + ) + } + + // Set the cart cookie + res.setHeader( + 'Set-Cookie', + serialize(cartCookie, cartId, { + maxAge: 60 * 60 * 24 * 30, + expires: new Date(Date.now() + 60 * 60 * 24 * 30 * 1000), + secure: process.env.NODE_ENV === 'production', + path: '/', + sameSite: 'lax', + }) + ) + + // Store specs + let specs: RawVariant['Specs'] = [] + + // If a variant is present, fetch its specs + if (item.variantId) { + specs = await fetch( + 'GET', + `/me/products/${item.productId}/variants/${item.variantId}` + ).then((res: RawVariant) => res.Specs) + } + + // Add the item to the order + await fetch('POST', `/orders/Outgoing/${cartId}/lineitems`, { + ProductID: item.productId, + Quantity: item.quantity, + Specs: specs, + }) + + // Get cart + const [cart, lineItems] = await Promise.all([ + fetch('GET', `/orders/Outgoing/${cartId}`), + fetch('GET', `/orders/Outgoing/${cartId}/lineitems`).then( + (response: { Items: OrdercloudLineItem[] }) => response.Items + ), + ]) + + // Format cart + const formattedCart = formatCart(cart, lineItems) + + // Return cart and errors + res.status(200).json({ data: formattedCart, errors: [] }) +} + +export default addItem diff --git a/framework/ordercloud/api/endpoints/cart/get-cart.ts b/framework/ordercloud/api/endpoints/cart/get-cart.ts new file mode 100644 index 000000000..3ed8da349 --- /dev/null +++ b/framework/ordercloud/api/endpoints/cart/get-cart.ts @@ -0,0 +1,51 @@ +import type { OrdercloudLineItem } from '../../../types/cart' +import type { CartEndpoint } from '.' + +import { serialize } from 'cookie' + +import { formatCart } from '../../utils/cart' + +// Return current cart info +const getCart: CartEndpoint['handlers']['getCart'] = async ({ + res, + body: { cartId }, + config: { fetch, cartCookie }, +}) => { + if (!cartId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + try { + // Get cart + const cart = await fetch('GET', `/orders/Outgoing/${cartId}`) + + // Get line items + const lineItems = await fetch( + 'GET', + `/orders/Outgoing/${cartId}/lineitems` + ).then((response: { Items: OrdercloudLineItem[] }) => response.Items) + + // Format cart + const formattedCart = formatCart(cart, lineItems) + + // Return cart and errors + res.status(200).json({ data: formattedCart, errors: [] }) + } catch (error) { + // Reset cart cookie + res.setHeader( + 'Set-Cookie', + serialize(cartCookie, cartId, { + maxAge: -1, + path: '/', + }) + ) + + // Return empty cart + res.status(200).json({ data: null, errors: [] }) + } +} + +export default getCart diff --git a/framework/ordercloud/api/endpoints/cart/remove-item.ts b/framework/ordercloud/api/endpoints/cart/remove-item.ts new file mode 100644 index 000000000..664e398dd --- /dev/null +++ b/framework/ordercloud/api/endpoints/cart/remove-item.ts @@ -0,0 +1,36 @@ +import type { CartEndpoint } from '.' + +import { formatCart } from '../../utils/cart' +import { OrdercloudLineItem } from '../../../types/cart' + +const removeItem: CartEndpoint['handlers']['removeItem'] = async ({ + res, + body: { cartId, itemId }, + config: { fetch }, +}) => { + if (!cartId || !itemId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + // Remove the item to the order + await fetch('DELETE', `/orders/Outgoing/${cartId}/lineitems/${itemId}`) + + // Get cart + const [cart, lineItems] = await Promise.all([ + fetch('GET', `/orders/Outgoing/${cartId}`), + fetch('GET', `/orders/Outgoing/${cartId}/lineitems`).then( + (response: { Items: OrdercloudLineItem[] }) => response.Items + ), + ]) + + // Format cart + const formattedCart = formatCart(cart, lineItems) + + // Return cart and errors + res.status(200).json({ data: formattedCart, errors: [] }) +} + +export default removeItem diff --git a/framework/ordercloud/api/endpoints/cart/update-item.ts b/framework/ordercloud/api/endpoints/cart/update-item.ts new file mode 100644 index 000000000..08c38b13b --- /dev/null +++ b/framework/ordercloud/api/endpoints/cart/update-item.ts @@ -0,0 +1,52 @@ +import type { OrdercloudLineItem } from '../../../types/cart' +import type { RawVariant } from '../../../types/product' +import type { CartEndpoint } from '.' + +import { formatCart } from '../../utils/cart' + +const updateItem: CartEndpoint['handlers']['updateItem'] = async ({ + res, + body: { cartId, itemId, item }, + config: { fetch }, +}) => { + if (!cartId || !itemId || !item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + // Store specs + let specs: RawVariant['Specs'] = [] + + // If a variant is present, fetch its specs + if (item.variantId) { + specs = await fetch( + 'GET', + `/me/products/${item.productId}/variants/${item.variantId}` + ).then((res: RawVariant) => res.Specs) + } + + // Add the item to the order + await fetch('PATCH', `/orders/Outgoing/${cartId}/lineitems/${itemId}`, { + ProductID: item.productId, + Quantity: item.quantity, + Specs: specs, + }) + + // Get cart + const [cart, lineItems] = await Promise.all([ + fetch('GET', `/orders/Outgoing/${cartId}`), + fetch('GET', `/orders/Outgoing/${cartId}/lineitems`).then( + (response: { Items: OrdercloudLineItem[] }) => response.Items + ), + ]) + + // Format cart + const formattedCart = formatCart(cart, lineItems) + + // Return cart and errors + res.status(200).json({ data: formattedCart, errors: [] }) +} + +export default updateItem diff --git a/framework/ordercloud/api/endpoints/login/index.ts b/framework/ordercloud/api/endpoints/login/index.ts deleted file mode 100644 index 491bf0ac9..000000000 --- a/framework/ordercloud/api/endpoints/login/index.ts +++ /dev/null @@ -1 +0,0 @@ -export default function noopApi(...args: any[]): void {} diff --git a/framework/ordercloud/api/utils/cart.ts b/framework/ordercloud/api/utils/cart.ts new file mode 100644 index 000000000..716f3521e --- /dev/null +++ b/framework/ordercloud/api/utils/cart.ts @@ -0,0 +1,41 @@ +import type { Cart, OrdercloudCart, OrdercloudLineItem } from '../../types/cart' + +export function formatCart( + cart: OrdercloudCart, + lineItems: OrdercloudLineItem[] +): Cart { + return { + id: cart.ID, + customerId: cart.FromUserID, + email: cart.FromUser.Email, + createdAt: cart.DateCreated, + currency: { + code: cart.FromUser?.xp?.currency ?? 'USD', + }, + taxesIncluded: cart.TaxCost === 0, + lineItems: lineItems.map((lineItem) => ({ + id: lineItem.ID, + variantId: lineItem.Variant ? String(lineItem.Variant.ID) : '', + productId: lineItem.ProductID, + name: lineItem.Product.Name, + quantity: lineItem.Quantity, + discounts: [], + path: lineItem.ProductID, + variant: { + id: lineItem.Variant ? String(lineItem.Variant.ID) : '', + sku: lineItem.ID, + name: lineItem.Product.Name, + image: { + url: lineItem.Product.xp?.Images?.[0]?.url, + }, + requiresShipping: Boolean(lineItem.ShippingAddress), + price: lineItem.UnitPrice, + listPrice: lineItem.UnitPrice, + }, + })), + lineItemsSubtotalPrice: cart.Subtotal, + subtotalPrice: cart.Subtotal, + totalPrice: cart.Total, + discounts: [], + } +} diff --git a/framework/ordercloud/api/utils/fetch-rest.ts b/framework/ordercloud/api/utils/fetch-rest.ts deleted file mode 100644 index eefed5e72..000000000 --- a/framework/ordercloud/api/utils/fetch-rest.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { OrdercloudConfig } from '../index' - -import { FetcherError } from '@commerce/utils/errors' -import fetch from './fetch' - -const fetchRestApi: ( - getConfig: () => OrdercloudConfig -) => ( - method: string, - resource: string, - body?: Record, - fetchOptions?: Record -) => Promise = - (getConfig) => - async ( - method: string, - resource: string, - body?: Record, - fetchOptions?: Record - ) => { - const { commerceUrl } = getConfig() - - async function getToken() { - // If not, get a new one and store it - const authResponse = await fetch(`${commerceUrl}/oauth/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }, - body: `client_id=${process.env.NEXT_PUBLIC_ORDERCLOUD_CLIENT_ID}&grant_type=client_credentials&client_secret=${process.env.NEXT_PUBLIC_ORDERCLOUD_CLIENT_SECRET}`, - }) - - // If something failed getting the auth response - if (!authResponse.ok) { - // Get the body of it - const error = await authResponse.json() - - // And return an error - throw new FetcherError({ - errors: [{ message: error.error_description.Code }], - status: error.error_description.HttpStatus, - }) - } - - // If everything is fine, store the access token in global.token - global.token = await authResponse - .json() - .then((response) => response.access_token) - } - - async function fetchData(retries = 0): Promise { - // Do the request with the correct headers - const dataResponse = await fetch(`${commerceUrl}/v1${resource}`, { - ...fetchOptions, - method, - headers: { - ...fetchOptions?.headers, - accept: 'application/json, text/plain, */*', - authorization: `Bearer ${global.token}`, - }, - body: body ? JSON.stringify(body) : undefined, - }) - - // If something failed getting the data response - if (!dataResponse.ok) { - // If token is expired - if (dataResponse.status === 401) { - // Reset it - global.token = null - - // Get a new one - await getToken() - - // And if retries left - if (retries < 2) { - // Refetch - return fetchData(retries + 1) - } - } - - // Get the body of it - const error = await dataResponse.json() - - // And return an error - throw new FetcherError({ - errors: [{ message: error.error_description.Code }], - status: error.error_description.HttpStatus, - }) - } - - // Return data response - return dataResponse.json() as Promise - } - - // Check if we have a token stored - if (!global.token) { - // If not, get a new one and store it - await getToken() - } - - // Return the data and specify the expected type - return fetchData() - } - -export default fetchRestApi diff --git a/framework/ordercloud/cart/use-add-item.tsx b/framework/ordercloud/cart/use-add-item.tsx index 7f3d1061f..4699202c3 100644 --- a/framework/ordercloud/cart/use-add-item.tsx +++ b/framework/ordercloud/cart/use-add-item.tsx @@ -1,17 +1,48 @@ +import type { AddItemHook } from '@commerce/types/cart' +import type { MutationHook } from '@commerce/utils/types' + +import { useCallback } from 'react' +import { CommerceError } from '@commerce/utils/errors' import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item' -import { MutationHook } from '@commerce/utils/types' +import useCart from './use-cart' export default useAddItem as UseAddItem -export const handler: MutationHook = { + +export const handler: MutationHook = { fetchOptions: { - query: '', + url: '/api/cart', + method: 'POST', }, - async fetcher({ input, options, fetch }) {}, - useHook: - ({ fetch }) => - () => { - return async function addItem() { - return {} - } + async fetcher({ input: item, options, fetch }) { + if ( + item.quantity && + (!Number.isInteger(item.quantity) || item.quantity! < 1) + ) { + throw new CommerceError({ + message: 'The item quantity has to be a valid integer greater than 0', + }) + } + + const data = await fetch({ + ...options, + body: { item }, + }) + + return data + }, + useHook: ({ fetch }) => + function useHook() { + const { mutate } = useCart() + + return useCallback( + async function addItem(input) { + const data = await fetch({ input }) + + await mutate(data, false) + + return data + }, + [fetch, mutate] + ) }, } diff --git a/framework/ordercloud/cart/use-cart.tsx b/framework/ordercloud/cart/use-cart.tsx index b3e509a21..d194f4097 100644 --- a/framework/ordercloud/cart/use-cart.tsx +++ b/framework/ordercloud/cart/use-cart.tsx @@ -1,42 +1,33 @@ +import type { GetCartHook } from '@commerce/types/cart' + import { useMemo } from 'react' import { SWRHook } from '@commerce/utils/types' import useCart, { UseCart } from '@commerce/cart/use-cart' export default useCart as UseCart -export const handler: SWRHook = { +export const handler: SWRHook = { fetchOptions: { - query: '', + url: '/api/cart', + method: 'GET', }, - async fetcher() { - return { - id: '', - createdAt: '', - currency: { code: '' }, - taxesIncluded: '', - lineItems: [], - lineItemsSubtotalPrice: '', - subtotalPrice: 0, - totalPrice: 0, - } - }, - useHook: - ({ useData }) => - (input) => { + useHook: ({ useData }) => + function useHook(input) { + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input?.swrOptions }, + }) + return useMemo( () => - Object.create( - {}, - { - isEmpty: { - get() { - return true - }, - enumerable: true, + Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems?.length ?? 0) <= 0 }, - } - ), - [] + enumerable: true, + }, + }), + [response] ) }, } diff --git a/framework/ordercloud/cart/use-remove-item.tsx b/framework/ordercloud/cart/use-remove-item.tsx index b4ed583b8..748ba963d 100644 --- a/framework/ordercloud/cart/use-remove-item.tsx +++ b/framework/ordercloud/cart/use-remove-item.tsx @@ -1,18 +1,60 @@ -import { MutationHook } from '@commerce/utils/types' +import type { + MutationHookContext, + HookFetcherContext, +} from '@commerce/utils/types' +import type { Cart, LineItem, RemoveItemHook } from '@commerce/types/cart' + +import { useCallback } from 'react' + +import { ValidationError } from '@commerce/utils/errors' import useRemoveItem, { UseRemoveItem } from '@commerce/cart/use-remove-item' +import useCart from './use-cart' + +export type RemoveItemFn = T extends LineItem + ? (input?: RemoveItemActionInput) => Promise + : (input: RemoveItemActionInput) => Promise + +export type RemoveItemActionInput = T extends LineItem + ? Partial + : RemoveItemHook['actionInput'] + export default useRemoveItem as UseRemoveItem -export const handler: MutationHook = { +export const handler = { fetchOptions: { - query: '', + url: '/api/cart', + method: 'DELETE', }, - async fetcher({ input, options, fetch }) {}, - useHook: - ({ fetch }) => - () => { - return async function removeItem(input) { - return {} + async fetcher({ + input: { itemId }, + options, + fetch, + }: HookFetcherContext) { + return await fetch({ ...options, body: { itemId } }) + }, + useHook: ({ fetch }: MutationHookContext) => + function useHook( + ctx: { item?: T } = {} + ) { + const { item } = ctx + const { mutate } = useCart() + const removeItem: RemoveItemFn = async (input) => { + const itemId = input?.id ?? item?.id + + if (!itemId) { + throw new ValidationError({ + message: 'Invalid input used for this operation', + }) + } + + const data = await fetch({ input: { itemId } }) + + await mutate(data, false) + + return data } + + return useCallback(removeItem as RemoveItemFn, [fetch, mutate]) }, } diff --git a/framework/ordercloud/cart/use-update-item.tsx b/framework/ordercloud/cart/use-update-item.tsx index 06d703f70..cc9d93b03 100644 --- a/framework/ordercloud/cart/use-update-item.tsx +++ b/framework/ordercloud/cart/use-update-item.tsx @@ -1,18 +1,93 @@ +import type { + HookFetcherContext, + MutationHookContext, +} from '@commerce/utils/types' +import type { UpdateItemHook, LineItem } from '@commerce/types/cart' + +import { useCallback } from 'react' +import debounce from 'lodash.debounce' + import { MutationHook } from '@commerce/utils/types' +import { ValidationError } from '@commerce/utils/errors' import useUpdateItem, { UseUpdateItem } from '@commerce/cart/use-update-item' +import { handler as removeItemHandler } from './use-remove-item' +import useCart from './use-cart' + +export type UpdateItemActionInput = T extends LineItem + ? Partial + : UpdateItemHook['actionInput'] + export default useUpdateItem as UseUpdateItem export const handler: MutationHook = { fetchOptions: { - query: '', + url: '/api/cart', + method: 'PUT', }, - async fetcher({ input, options, fetch }) {}, - useHook: - ({ fetch }) => - () => { - return async function addItem() { - return {} + async fetcher({ + input: { itemId, item }, + options, + fetch, + }: HookFetcherContext) { + if (Number.isInteger(item.quantity)) { + // Also allow the update hook to remove an item if the quantity is lower than 1 + if (item.quantity! < 1) { + return removeItemHandler.fetcher({ + options: removeItemHandler.fetchOptions, + input: { itemId }, + fetch, + }) } + } else if (item.quantity) { + throw new ValidationError({ + message: 'The item quantity has to be a valid integer', + }) + } + + return await fetch({ + ...options, + body: { itemId, item }, + }) + }, + useHook: ({ fetch }: MutationHookContext) => + function useHook( + ctx: { + item?: T + wait?: number + } = {} + ) { + const { item } = ctx + const { mutate } = useCart() as any + + return useCallback( + debounce(async (input: UpdateItemActionInput) => { + const itemId = input.id ?? item?.id + const productId = input.productId ?? item?.productId + const variantId = input.productId ?? item?.variantId + + if (!itemId || !productId) { + throw new ValidationError({ + message: 'Invalid input used for this operation', + }) + } + + const data = await fetch({ + input: { + itemId, + item: { + productId, + variantId: variantId || '', + quantity: input.quantity, + }, + }, + }) + + await mutate(data, false) + + return data + }, ctx.wait ?? 500), + [fetch, mutate] + ) }, } diff --git a/framework/ordercloud/types/cart.ts b/framework/ordercloud/types/cart.ts new file mode 100644 index 000000000..4716c355d --- /dev/null +++ b/framework/ordercloud/types/cart.ts @@ -0,0 +1,126 @@ +import * as Core from '@commerce/types/cart' + +export * from '@commerce/types/cart' + +export interface OrdercloudCart { + ID: string + FromUser: { + ID: string + Username: string + Password: null + FirstName: string + LastName: string + Email: string + Phone: null + TermsAccepted: null + Active: true + xp: { + something: string + currency: string + } + AvailableRoles: null + DateCreated: string + PasswordLastSetDate: null + } + FromCompanyID: string + ToCompanyID: string + FromUserID: string + BillingAddressID: null + BillingAddress: null + ShippingAddressID: null + Comments: null + LineItemCount: number + Status: string + DateCreated: string + DateSubmitted: null + DateApproved: null + DateDeclined: null + DateCanceled: null + DateCompleted: null + LastUpdated: string + Subtotal: number + ShippingCost: number + TaxCost: number + PromotionDiscount: number + Total: number + IsSubmitted: false + xp: { + productId: string + variantId: string + quantity: 1 + } +} + +export interface OrdercloudLineItem { + ID: string + ProductID: string + Quantity: 1 + DateAdded: string + QuantityShipped: number + UnitPrice: number + PromotionDiscount: number + LineTotal: number + LineSubtotal: number + CostCenter: null + DateNeeded: null + ShippingAccount: null + ShippingAddressID: null + ShipFromAddressID: null + Product: { + ID: string + Name: string + Description: string + QuantityMultiplier: number + ShipWeight: number + ShipHeight: null + ShipWidth: null + ShipLength: null + xp: { + Images: { + url: string + }[] + } + } + Variant: null | { + ID: string + Name: null + Description: null + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + xp: null + } + ShippingAddress: null + ShipFromAddress: null + SupplierID: null + Specs: [] + xp: null +} + +/** + * Extend core cart types + */ + +export type Cart = Core.Cart & { + lineItems: Core.LineItem[] + url?: string +} + +export type CartTypes = Core.CartTypes + +export type CartHooks = Core.CartHooks + +export type GetCartHook = CartHooks['getCart'] +export type AddItemHook = CartHooks['addItem'] +export type UpdateItemHook = CartHooks['updateItem'] +export type RemoveItemHook = CartHooks['removeItem'] + +export type CartSchema = Core.CartSchema + +export type CartHandlers = Core.CartHandlers + +export type GetCartHandler = CartHandlers['getCart'] +export type AddItemHandler = CartHandlers['addItem'] +export type UpdateItemHandler = CartHandlers['updateItem'] +export type RemoveItemHandler = CartHandlers['removeItem'] diff --git a/framework/ordercloud/types/product.ts b/framework/ordercloud/types/product.ts index 3de95a9e1..8ccb778d2 100644 --- a/framework/ordercloud/types/product.ts +++ b/framework/ordercloud/types/product.ts @@ -3,6 +3,8 @@ interface RawVariantSpec { Name: string OptionID: string Value: string + PriceMarkupType: string + PriceMarkup: string | null } export interface RawSpec { diff --git a/framework/ordercloud/utils/product.ts b/framework/ordercloud/utils/product.ts index 1c3f2336c..ee334f175 100644 --- a/framework/ordercloud/utils/product.ts +++ b/framework/ordercloud/utils/product.ts @@ -29,7 +29,7 @@ export function normalize(product: RawProduct): Product { })) : [ { - id: product.ID, + id: '', options: [], }, ],