diff --git a/framework/bigcommerce/api/endpoints/cart/handlers/add-item.ts b/framework/bigcommerce/api/endpoints/cart/handlers/add-item.ts new file mode 100644 index 000000000..c47e72cdb --- /dev/null +++ b/framework/bigcommerce/api/endpoints/cart/handlers/add-item.ts @@ -0,0 +1,45 @@ +import { parseCartItem } from '../../utils/parse-item' +import getCartCookie from '../../utils/get-cart-cookie' +import type { CartHandlers } from '..' + +const addItem: CartHandlers['addItem'] = async ({ + res, + body: { cartId, item }, + config, +}) => { + if (!item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing item' }], + }) + } + if (!item.quantity) item.quantity = 1 + + const options = { + method: 'POST', + body: JSON.stringify({ + line_items: [parseCartItem(item)], + ...(!cartId && config.storeChannelId + ? { channel_id: config.storeChannelId } + : {}), + }), + } + const { data } = cartId + ? await config.storeApiFetch( + `/v3/carts/${cartId}/items?include=line_items.physical_items.options`, + options + ) + : await config.storeApiFetch( + '/v3/carts?include=line_items.physical_items.options', + options + ) + + // Create or update the cart cookie + res.setHeader( + 'Set-Cookie', + getCartCookie(config.cartCookie, data.id, config.cartCookieMaxAge) + ) + res.status(200).json({ data }) +} + +export default addItem diff --git a/framework/bigcommerce/api/endpoints/cart/handlers/get-cart.ts b/framework/bigcommerce/api/endpoints/cart/handlers/get-cart.ts new file mode 100644 index 000000000..890ac9997 --- /dev/null +++ b/framework/bigcommerce/api/endpoints/cart/handlers/get-cart.ts @@ -0,0 +1,32 @@ +import type { BigcommerceCart } from '../../../types' +import { BigcommerceApiError } from '../../utils/errors' +import getCartCookie from '../../utils/get-cart-cookie' +import type { CartHandlers } from '../' + +// Return current cart info +const getCart: CartHandlers['getCart'] = async ({ + res, + body: { cartId }, + config, +}) => { + let result: { data?: BigcommerceCart } = {} + + if (cartId) { + try { + result = await config.storeApiFetch( + `/v3/carts/${cartId}?include=line_items.physical_items.options` + ) + } catch (error) { + if (error instanceof BigcommerceApiError && error.status === 404) { + // Remove the cookie if it exists but the cart wasn't found + res.setHeader('Set-Cookie', getCartCookie(config.cartCookie)) + } else { + throw error + } + } + } + + res.status(200).json({ data: result.data ?? null }) +} + +export default getCart diff --git a/framework/bigcommerce/api/endpoints/cart/handlers/remove-item.ts b/framework/bigcommerce/api/endpoints/cart/handlers/remove-item.ts new file mode 100644 index 000000000..c09848948 --- /dev/null +++ b/framework/bigcommerce/api/endpoints/cart/handlers/remove-item.ts @@ -0,0 +1,33 @@ +import getCartCookie from '../../utils/get-cart-cookie' +import type { CartHandlers } from '..' + +const removeItem: CartHandlers['removeItem'] = async ({ + res, + body: { cartId, itemId }, + config, +}) => { + if (!cartId || !itemId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + const result = await config.storeApiFetch<{ data: any } | null>( + `/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`, + { method: 'DELETE' } + ) + const data = result?.data ?? null + + res.setHeader( + 'Set-Cookie', + data + ? // Update the cart cookie + getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge) + : // Remove the cart cookie if the cart was removed (empty items) + getCartCookie(config.cartCookie) + ) + res.status(200).json({ data }) +} + +export default removeItem diff --git a/framework/bigcommerce/api/endpoints/cart/handlers/update-item.ts b/framework/bigcommerce/api/endpoints/cart/handlers/update-item.ts new file mode 100644 index 000000000..27b74ca20 --- /dev/null +++ b/framework/bigcommerce/api/endpoints/cart/handlers/update-item.ts @@ -0,0 +1,35 @@ +import { parseCartItem } from '../../utils/parse-item' +import getCartCookie from '../../utils/get-cart-cookie' +import type { CartHandlers } from '..' + +const updateItem: CartHandlers['updateItem'] = async ({ + res, + body: { cartId, itemId, item }, + config, +}) => { + if (!cartId || !itemId || !item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + const { data } = await config.storeApiFetch( + `/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`, + { + method: 'PUT', + body: JSON.stringify({ + line_item: parseCartItem(item), + }), + } + ) + + // Update the cart cookie + res.setHeader( + 'Set-Cookie', + getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge) + ) + res.status(200).json({ data }) +} + +export default updateItem diff --git a/framework/bigcommerce/api/endpoints/cart/index.ts b/framework/bigcommerce/api/endpoints/cart/index.ts new file mode 100644 index 000000000..4ee668895 --- /dev/null +++ b/framework/bigcommerce/api/endpoints/cart/index.ts @@ -0,0 +1,78 @@ +import isAllowedMethod from '../utils/is-allowed-method' +import createApiHandler, { + BigcommerceApiHandler, + BigcommerceHandler, +} from '../utils/create-api-handler' +import { BigcommerceApiError } from '../utils/errors' +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 { + BigcommerceCart, + GetCartHandlerBody, + AddCartItemHandlerBody, + UpdateCartItemHandlerBody, + RemoveCartItemHandlerBody, +} from '../../types' + +export type CartHandlers = { + getCart: BigcommerceHandler + addItem: BigcommerceHandler + updateItem: BigcommerceHandler + removeItem: BigcommerceHandler +} + +const METHODS = ['GET', 'POST', 'PUT', 'DELETE'] + +// TODO: a complete implementation should have schema validation for `req.body` +const cartApi: BigcommerceApiHandler = async ( + req, + res, + config, + handlers +) => { + if (!isAllowedMethod(req, res, METHODS)) return + + const { cookies } = req + const cartId = cookies[config.cartCookie] + + try { + // Return current cart info + if (req.method === 'GET') { + const body = { cartId } + return await handlers['getCart']({ req, res, config, body }) + } + + // Create or add an item to the cart + if (req.method === 'POST') { + const body = { ...req.body, cartId } + return await handlers['addItem']({ req, res, config, body }) + } + + // Update item in cart + if (req.method === 'PUT') { + const body = { ...req.body, cartId } + return await handlers['updateItem']({ req, res, config, body }) + } + + // Remove an item from the cart + if (req.method === 'DELETE') { + const body = { ...req.body, cartId } + return await handlers['removeItem']({ req, res, config, body }) + } + } catch (error) { + console.error(error) + + const message = + error instanceof BigcommerceApiError + ? 'An unexpected error ocurred with the Bigcommerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } +} + +export const handlers = { getCart, addItem, updateItem, removeItem } + +export default createApiHandler(cartApi, handlers, {}) diff --git a/framework/bigcommerce/api/index.ts b/framework/bigcommerce/api/index.ts index 0216fe61b..96ac92477 100644 --- a/framework/bigcommerce/api/index.ts +++ b/framework/bigcommerce/api/index.ts @@ -1,5 +1,5 @@ import type { RequestInit } from '@vercel/fetch' -import type { CommerceAPIConfig } from '@commerce/api' +import { CommerceAPIConfig, createAPIProvider } from '@commerce/api' import fetchGraphqlApi from './utils/fetch-graphql-api' import fetchStoreApi from './utils/fetch-store-api' @@ -79,6 +79,24 @@ const config = new Config({ storeApiFetch: fetchStoreApi, }) +const config2: BigcommerceConfig = { + commerceUrl: API_URL, + apiToken: API_TOKEN, + customerCookie: 'SHOP_TOKEN', + cartCookie: process.env.BIGCOMMERCE_CART_COOKIE ?? 'bc_cartId', + cartCookieMaxAge: ONE_DAY * 30, + fetch: fetchGraphqlApi, + applyLocale: true, + // REST API only + storeApiUrl: STORE_API_URL, + storeApiToken: STORE_API_TOKEN, + storeApiClientId: STORE_API_CLIENT_ID, + storeChannelId: STORE_CHANNEL_ID, + storeApiFetch: fetchStoreApi, +} + +export const commerce = createAPIProvider({ config: config2 }) + export function getConfig(userConfig?: Partial) { return config.getConfig(userConfig) } diff --git a/framework/bigcommerce/api/provider.ts b/framework/bigcommerce/api/provider.ts new file mode 100644 index 000000000..e69de29bb diff --git a/framework/bigcommerce/api/utils/create-api-handler.ts b/framework/bigcommerce/api/utils/create-api-handler.ts index 315ec464b..c1d651d9b 100644 --- a/framework/bigcommerce/api/utils/create-api-handler.ts +++ b/framework/bigcommerce/api/utils/create-api-handler.ts @@ -48,7 +48,7 @@ export default function createApiHandler< operations?: Partial options?: Options extends {} ? Partial : never } = {}): NextApiHandler { - const ops = { ...operations, ...handlers } + const ops = { ...handlers, ...operations } const opts = { ...defaultOptions, ...options } return function apiHandler(req, res) { diff --git a/framework/commerce/api/endpoints/cart.ts b/framework/commerce/api/endpoints/cart.ts new file mode 100644 index 000000000..e7570c34c --- /dev/null +++ b/framework/commerce/api/endpoints/cart.ts @@ -0,0 +1,58 @@ +import type { APIEndpoint, APIHandler } from '../utils/types' +import isAllowedMethod from '../utils/is-allowed-method' + +import cn from 'classnames' +import isAllowedOperation from '../utils/is-allowed-operation' +import type { APIProvider, CartHandlers } from '..' + +cn({ yo: true }) + +const METHODS = ['GET', 'POST', 'PUT', 'DELETE'] + +const cartApi: APIEndpoint = async (ctx) => { + if ( + !isAllowedOperation(ctx.req, ctx.res, { + GET: ctx.handlers['getCart'], + }) + ) { + return + } + + const { cookies } = req + const cartId = cookies[config.cartCookie] + + try { + // Return current cart info + if (req.method === 'GET') { + const body = { cartId } + return await handlers['getCart']({ req, res, config, body }) + } + + // Create or add an item to the cart + if (req.method === 'POST') { + const body = { ...req.body, cartId } + return await handlers['addItem']({ req, res, config, body }) + } + + // Update item in cart + if (req.method === 'PUT') { + const body = { ...req.body, cartId } + return await handlers['updateItem']({ req, res, config, body }) + } + + // Remove an item from the cart + if (req.method === 'DELETE') { + const body = { ...req.body, cartId } + return await handlers['removeItem']({ req, res, config, body }) + } + } catch (error) { + console.error(error) + + const message = + error instanceof BigcommerceApiError + ? 'An unexpected error ocurred with the Bigcommerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } +} diff --git a/framework/commerce/api/index.ts b/framework/commerce/api/index.ts index 77b2eeb7e..61c9c89d2 100644 --- a/framework/commerce/api/index.ts +++ b/framework/commerce/api/index.ts @@ -1,4 +1,44 @@ import type { RequestInit, Response } from '@vercel/fetch' +import type { APIEndpoint, APIHandler } from './utils/types' + +export type CartHandlers = { + getCart: APIHandler + addItem: APIHandler + updateItem: APIHandler + removeItem: APIHandler +} + +export type CoreAPIProvider = { + config: CommerceAPIConfig + endpoints?: { + cart?: { + handler: APIEndpoint + handlers: CartHandlers + } + } +} + +export type APIProvider

= P & { + getConfig(userConfig?: Partial): P['config'] + setConfig(newConfig: Partial): void +} + +export function createAPIProvider

( + provider: P +): APIProvider

{ + return { + ...provider, + getConfig(userConfig = {}) { + return Object.entries(userConfig).reduce( + (cfg, [key, value]) => Object.assign(cfg, { [key]: value }), + { ...this.config } + ) + }, + setConfig(newConfig) { + Object.assign(this.config, newConfig) + }, + } +} export interface CommerceAPIConfig { locale?: string diff --git a/framework/commerce/api/utils/is-allowed-method.ts b/framework/commerce/api/utils/is-allowed-method.ts new file mode 100644 index 000000000..51c37e221 --- /dev/null +++ b/framework/commerce/api/utils/is-allowed-method.ts @@ -0,0 +1,30 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +export type HTTP_METHODS = 'OPTIONS' | 'GET' | 'POST' | 'PUT' | 'DELETE' + +export default function isAllowedMethod( + req: NextApiRequest, + res: NextApiResponse, + allowedMethods: HTTP_METHODS[] +) { + const methods = allowedMethods.includes('OPTIONS') + ? allowedMethods + : [...allowedMethods, 'OPTIONS'] + + if (!req.method || !methods.includes(req.method)) { + res.status(405) + res.setHeader('Allow', methods.join(', ')) + res.end() + return false + } + + if (req.method === 'OPTIONS') { + res.status(200) + res.setHeader('Allow', methods.join(', ')) + res.setHeader('Content-Length', '0') + res.end() + return false + } + + return true +} diff --git a/framework/commerce/api/utils/is-allowed-operation.ts b/framework/commerce/api/utils/is-allowed-operation.ts new file mode 100644 index 000000000..dd650c8f1 --- /dev/null +++ b/framework/commerce/api/utils/is-allowed-operation.ts @@ -0,0 +1,19 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import isAllowedMethod, { HTTP_METHODS } from './is-allowed-method' +import { APIHandler } from './types' + +export default function isAllowedOperation( + req: NextApiRequest, + res: NextApiResponse, + allowedHandlers: { [k in HTTP_METHODS]?: APIHandler } +) { + const methods = Object.keys(allowedHandlers) as HTTP_METHODS[] + const allowedMethods = methods.reduce((arr, method) => { + if (allowedHandlers[method]) { + arr.push(method) + } + return arr + }, []) + + return isAllowedMethod(req, res, allowedMethods) +} diff --git a/framework/commerce/api/utils/types.ts b/framework/commerce/api/utils/types.ts new file mode 100644 index 000000000..fcf2057dd --- /dev/null +++ b/framework/commerce/api/utils/types.ts @@ -0,0 +1,45 @@ +import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next' +import type { APIProvider } from '..' + +export type APIResponse = { + data: Data + errors?: { message: string; code?: string }[] +} + +export type APIHandlerContext< + P extends APIProvider, + H extends APIHandlers = {}, + Data = any, + Options extends {} = {} +> = { + req: NextApiRequest + res: NextApiResponse> + provider: P + config: P['config'] + handlers: H + /** + * Custom configs that may be used by a particular handler + */ + options: Options +} + +export type APIHandler< + P extends APIProvider, + H extends APIHandlers = {}, + Data = any, + Body = any, + Options extends {} = {} +> = ( + context: APIHandlerContext & { body: Body } +) => void | Promise + +export type APIHandlers

= { + [k: string]: APIHandler +} + +export type APIEndpoint< + P extends APIProvider = APIProvider, + H extends APIHandlers = {}, + Data = any, + Options extends {} = {} +> = (context: APIHandlerContext) => void | Promise diff --git a/framework/commerce/types.ts b/framework/commerce/types.ts index a398070ac..c48c66f2b 100644 --- a/framework/commerce/types.ts +++ b/framework/commerce/types.ts @@ -160,6 +160,19 @@ interface Entity { [prop: string]: any } +export interface Product2 { + id: string + name: string + description: string + sku?: string + slug?: string + path?: string + images: ProductImage[] + variants: ProductVariant2[] + price: ProductPrice + options: ProductOption[] +} + export interface Product extends Entity { name: string description: string