diff --git a/.env.template b/.env.template index b24458b80..a5885494e 100644 --- a/.env.template +++ b/.env.template @@ -23,3 +23,7 @@ NEXT_PUBLIC_SALEOR_CHANNEL= NEXT_PUBLIC_VENDURE_SHOP_API_URL= NEXT_PUBLIC_VENDURE_LOCAL_URL= + +ORDERCLOUD_CLIENT_ID= +ORDERCLOUD_CLIENT_SECRET= +STRIPE_SECRET= diff --git a/framework/commerce/config.js b/framework/commerce/config.js index 019c59a51..7fd0536f8 100644 --- a/framework/commerce/config.js +++ b/framework/commerce/config.js @@ -14,6 +14,7 @@ const PROVIDERS = [ 'shopify', 'swell', 'vendure', + 'ordercloud', ] function getProviderName() { diff --git a/framework/ordercloud/.env.template b/framework/ordercloud/.env.template new file mode 100644 index 000000000..9b33282ba --- /dev/null +++ b/framework/ordercloud/.env.template @@ -0,0 +1,5 @@ +COMMERCE_PROVIDER=ordercloud + +ORDERCLOUD_CLIENT_ID= +ORDERCLOUD_CLIENT_SECRET= +STRIPE_SECRET= diff --git a/framework/ordercloud/README.md b/framework/ordercloud/README.md new file mode 100644 index 000000000..9bf6f63af --- /dev/null +++ b/framework/ordercloud/README.md @@ -0,0 +1 @@ +# Next.js Ordercloud Provider 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..197f58a0e --- /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: { restFetch, 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 restFetch('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 restFetch( + 'GET', + `/me/products/${item.productId}/variants/${item.variantId}` + ).then((res: RawVariant) => res.Specs) + } + + // Add the item to the order + await restFetch('POST', `/orders/Outgoing/${cartId}/lineitems`, { + ProductID: item.productId, + Quantity: item.quantity, + Specs: specs, + }) + + // Get cart + const [cart, lineItems] = await Promise.all([ + restFetch('GET', `/orders/Outgoing/${cartId}`), + restFetch('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..0340600e0 --- /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: { restFetch, cartCookie }, +}) => { + if (!cartId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + try { + // Get cart + const cart = await restFetch('GET', `/orders/Outgoing/${cartId}`) + + // Get line items + const lineItems = await restFetch( + '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/index.ts b/framework/ordercloud/api/endpoints/cart/index.ts new file mode 100644 index 000000000..756bce9fe --- /dev/null +++ b/framework/ordercloud/api/endpoints/cart/index.ts @@ -0,0 +1,28 @@ +import type { CartSchema } from '../../../types/cart' +import type { OrdercloudAPI } from '../..' + +import { GetAPISchema, createEndpoint } from '@commerce/api' +import cartEndpoint from '@commerce/api/endpoints/cart' + +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'] = { + getCart, + addItem, + updateItem, + removeItem, +} + +const cartApi = createEndpoint({ + handler: cartEndpoint, + handlers, +}) + +export default cartApi 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..40a39aa3a --- /dev/null +++ b/framework/ordercloud/api/endpoints/cart/remove-item.ts @@ -0,0 +1,39 @@ +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: { restFetch }, +}) => { + if (!cartId || !itemId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + // Remove the item to the order + await restFetch( + 'DELETE', + `/orders/Outgoing/${cartId}/lineitems/${itemId}` + ) + + // Get cart + const [cart, lineItems] = await Promise.all([ + restFetch('GET', `/orders/Outgoing/${cartId}`), + restFetch('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..e10ccc0ad --- /dev/null +++ b/framework/ordercloud/api/endpoints/cart/update-item.ts @@ -0,0 +1,56 @@ +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: { restFetch }, +}) => { + 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 restFetch( + 'GET', + `/me/products/${item.productId}/variants/${item.variantId}` + ).then((res: RawVariant) => res.Specs) + } + + // Add the item to the order + await restFetch( + 'PATCH', + `/orders/Outgoing/${cartId}/lineitems/${itemId}`, + { + ProductID: item.productId, + Quantity: item.quantity, + Specs: specs, + } + ) + + // Get cart + const [cart, lineItems] = await Promise.all([ + restFetch('GET', `/orders/Outgoing/${cartId}`), + restFetch('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/catalog/index.ts b/framework/ordercloud/api/endpoints/catalog/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/ordercloud/api/endpoints/catalog/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/ordercloud/api/endpoints/catalog/products.ts b/framework/ordercloud/api/endpoints/catalog/products.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/ordercloud/api/endpoints/catalog/products.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/ordercloud/api/endpoints/checkout/get-checkout.ts b/framework/ordercloud/api/endpoints/checkout/get-checkout.ts new file mode 100644 index 000000000..90e33813d --- /dev/null +++ b/framework/ordercloud/api/endpoints/checkout/get-checkout.ts @@ -0,0 +1,35 @@ +import type { CheckoutEndpoint } from '.' + +const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({ + res, + body: {cartId}, + config: { restFetch }, +}) => { + // Return an error if no item is present + if (!cartId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing cookie' }], + }) + } + + // Register credit card + const payments = await restFetch('GET', `/orders/Outgoing/${cartId}/payments`).then( + (response: {Items: unknown[]}) => response.Items + ) + + const address = await restFetch('GET', `/orders/Outgoing/${cartId}`).then( + (response: {ShippingAddressID: string}) => response.ShippingAddressID + ) + + // Return cart and errors + res.status(200).json({ + data: { + hasPayment: payments.length > 0, + hasShipping: Boolean(address) + }, + errors: [] + }) +} + +export default getCheckout diff --git a/framework/ordercloud/api/endpoints/checkout/index.ts b/framework/ordercloud/api/endpoints/checkout/index.ts new file mode 100644 index 000000000..e1b8a9f1c --- /dev/null +++ b/framework/ordercloud/api/endpoints/checkout/index.ts @@ -0,0 +1,23 @@ +import type { CheckoutSchema } from '../../../types/checkout' +import type { OrdercloudAPI } from '../..' + +import { GetAPISchema, createEndpoint } from '@commerce/api' +import checkoutEndpoint from '@commerce/api/endpoints/checkout' + +import getCheckout from './get-checkout' +import submitCheckout from './submit-checkout' + +export type CheckoutAPI = GetAPISchema +export type CheckoutEndpoint = CheckoutAPI['endpoint'] + +export const handlers: CheckoutEndpoint['handlers'] = { + getCheckout, + submitCheckout, +} + +const checkoutApi = createEndpoint({ + handler: checkoutEndpoint, + handlers, +}) + +export default checkoutApi diff --git a/framework/ordercloud/api/endpoints/checkout/submit-checkout.ts b/framework/ordercloud/api/endpoints/checkout/submit-checkout.ts new file mode 100644 index 000000000..ada347f67 --- /dev/null +++ b/framework/ordercloud/api/endpoints/checkout/submit-checkout.ts @@ -0,0 +1,23 @@ +import type { CheckoutEndpoint } from '.' + +const submitCheckout: CheckoutEndpoint['handlers']['submitCheckout'] = async ({ + res, + body: { cartId }, + config: { restFetch }, +}) => { + // Return an error if no item is present + if (!cartId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing item' }], + }) + } + + // Submit order + await restFetch('POST', `/orders/Outgoing/${cartId}/submit`, {}) + + // Return cart and errors + res.status(200).json({ data: null, errors: [] }) +} + +export default submitCheckout diff --git a/framework/ordercloud/api/endpoints/customer/address/add-item.ts b/framework/ordercloud/api/endpoints/customer/address/add-item.ts new file mode 100644 index 000000000..7e913e8a0 --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/address/add-item.ts @@ -0,0 +1,49 @@ +import type { CustomerAddressEndpoint } from '.' + +const addItem: CustomerAddressEndpoint['handlers']['addItem'] = async ({ + res, + body: { item, cartId }, + config: { restFetch }, +}) => { + // Return an error if no item is present + if (!item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing item' }], + }) + } + + // Return an error if no item is present + if (!cartId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Cookie not found' }], + }) + } + + // Register address + const address = await restFetch('POST', `/me/addresses`, { + "AddressName": "main address", + "CompanyName": item.company, + "FirstName": item.firstName, + "LastName": item.lastName, + "Street1": item.streetNumber, + "Street2": item.streetNumber, + "City": item.city, + "State": item.city, + "Zip": item.zipCode, + "Country": item.country.slice(0, 2).toLowerCase(), + "Shipping": true + }).then( + (response: {ID: string}) => response.ID + ) + + // Assign address to order + await restFetch('PATCH', `/orders/Outgoing/${cartId}`, { + ShippingAddressID: address + }) + + return res.status(200).json({ data: null, errors: [] }) +} + +export default addItem diff --git a/framework/ordercloud/api/endpoints/customer/address/get-addresses.ts b/framework/ordercloud/api/endpoints/customer/address/get-addresses.ts new file mode 100644 index 000000000..2e27591c0 --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/address/get-addresses.ts @@ -0,0 +1,9 @@ +import type { CustomerAddressEndpoint } from '.' + +const getCards: CustomerAddressEndpoint['handlers']['getAddresses'] = async ({ + res, +}) => { + return res.status(200).json({ data: null, errors: [] }) +} + +export default getCards diff --git a/framework/ordercloud/api/endpoints/customer/address/index.ts b/framework/ordercloud/api/endpoints/customer/address/index.ts new file mode 100644 index 000000000..385bc57f1 --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/address/index.ts @@ -0,0 +1,27 @@ +import type { CustomerAddressSchema } from '../../../../types/customer/address' +import type { OrdercloudAPI } from '../../..' + +import { GetAPISchema, createEndpoint } from '@commerce/api' +import customerAddressEndpoint from '@commerce/api/endpoints/customer/address' + +import getAddresses from './get-addresses' +import addItem from './add-item' +import updateItem from './update-item' +import removeItem from './remove-item' + +export type CustomerAddressAPI = GetAPISchema +export type CustomerAddressEndpoint = CustomerAddressAPI['endpoint'] + +export const handlers: CustomerAddressEndpoint['handlers'] = { + getAddresses, + addItem, + updateItem, + removeItem, +} + +const customerAddressApi = createEndpoint({ + handler: customerAddressEndpoint, + handlers, +}) + +export default customerAddressApi diff --git a/framework/ordercloud/api/endpoints/customer/address/remove-item.ts b/framework/ordercloud/api/endpoints/customer/address/remove-item.ts new file mode 100644 index 000000000..fba4e1154 --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/address/remove-item.ts @@ -0,0 +1,9 @@ +import type { CustomerAddressEndpoint } from '.' + +const removeItem: CustomerAddressEndpoint['handlers']['removeItem'] = async ({ + res, +}) => { + return res.status(200).json({ data: null, errors: [] }) +} + +export default removeItem diff --git a/framework/ordercloud/api/endpoints/customer/address/update-item.ts b/framework/ordercloud/api/endpoints/customer/address/update-item.ts new file mode 100644 index 000000000..4c4b4b9ae --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/address/update-item.ts @@ -0,0 +1,9 @@ +import type { CustomerAddressEndpoint } from '.' + +const updateItem: CustomerAddressEndpoint['handlers']['updateItem'] = async ({ + res, +}) => { + return res.status(200).json({ data: null, errors: [] }) +} + +export default updateItem diff --git a/framework/ordercloud/api/endpoints/customer/card/add-item.ts b/framework/ordercloud/api/endpoints/customer/card/add-item.ts new file mode 100644 index 000000000..8c5ad6a33 --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/card/add-item.ts @@ -0,0 +1,62 @@ +import type { CustomerCardEndpoint } from '.' +import type { OredercloudCreditCard } from '../../../../types/customer/card' + +import Stripe from 'stripe' + +const stripe = new Stripe(process.env.STRIPE_SECRET as string, { + apiVersion: '2020-08-27', +}) + +const addItem: CustomerCardEndpoint['handlers']['addItem'] = async ({ + res, + body: { item, cartId }, + config: { restFetch }, +}) => { + // Return an error if no item is present + if (!item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing item' }], + }) + } + + // Return an error if no item is present + if (!cartId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Cookie not found' }], + }) + } + + // Get token + const token = await stripe.tokens + .create({ + card: { + number: item.cardNumber, + exp_month: item.cardExpireDate.split('/')[0], + exp_year: item.cardExpireDate.split('/')[1], + cvc: item.cardCvc, + }, + }) + .then((res: { id: string }) => res.id) + + // Register credit card + const creditCard = await restFetch('POST', `/me/creditcards`, { + Token: token, + CardType: 'credit', + PartialAccountNumber: item.cardNumber.slice(-4), + CardholderName: item.cardHolder, + ExpirationDate: item.cardExpireDate, + }).then((response: OredercloudCreditCard) => response.ID) + + // Assign payment to order + await restFetch('POST', `/orders/Outgoing/${cartId}/payments`, { + Accepted: true, + Type: 'CreditCard', + CreditCardID: creditCard, + }) + + return res.status(200).json({ data: null, errors: [] }) +} + +export default addItem diff --git a/framework/ordercloud/api/endpoints/customer/card/get-cards.ts b/framework/ordercloud/api/endpoints/customer/card/get-cards.ts new file mode 100644 index 000000000..e77520803 --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/card/get-cards.ts @@ -0,0 +1,9 @@ +import type { CustomerCardEndpoint } from '.' + +const getCards: CustomerCardEndpoint['handlers']['getCards'] = async ({ + res, +}) => { + return res.status(200).json({ data: null, errors: [] }) +} + +export default getCards diff --git a/framework/ordercloud/api/endpoints/customer/card/index.ts b/framework/ordercloud/api/endpoints/customer/card/index.ts new file mode 100644 index 000000000..672939a8b --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/card/index.ts @@ -0,0 +1,27 @@ +import type { CustomerCardSchema } from '../../../../types/customer/card' +import type { OrdercloudAPI } from '../../..' + +import { GetAPISchema, createEndpoint } from '@commerce/api' +import customerCardEndpoint from '@commerce/api/endpoints/customer/card' + +import getCards from './get-cards' +import addItem from './add-item' +import updateItem from './update-item' +import removeItem from './remove-item' + +export type CustomerCardAPI = GetAPISchema +export type CustomerCardEndpoint = CustomerCardAPI['endpoint'] + +export const handlers: CustomerCardEndpoint['handlers'] = { + getCards, + addItem, + updateItem, + removeItem, +} + +const customerCardApi = createEndpoint({ + handler: customerCardEndpoint, + handlers, +}) + +export default customerCardApi diff --git a/framework/ordercloud/api/endpoints/customer/card/remove-item.ts b/framework/ordercloud/api/endpoints/customer/card/remove-item.ts new file mode 100644 index 000000000..1a81d1cf4 --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/card/remove-item.ts @@ -0,0 +1,9 @@ +import type { CustomerCardEndpoint } from '.' + +const removeItem: CustomerCardEndpoint['handlers']['removeItem'] = async ({ + res, +}) => { + return res.status(200).json({ data: null, errors: [] }) +} + +export default removeItem diff --git a/framework/ordercloud/api/endpoints/customer/card/update-item.ts b/framework/ordercloud/api/endpoints/customer/card/update-item.ts new file mode 100644 index 000000000..9770644aa --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/card/update-item.ts @@ -0,0 +1,9 @@ +import type { CustomerCardEndpoint } from '.' + +const updateItem: CustomerCardEndpoint['handlers']['updateItem'] = async ({ + res, +}) => { + return res.status(200).json({ data: null, errors: [] }) +} + +export default updateItem diff --git a/framework/ordercloud/api/endpoints/customer/index.ts b/framework/ordercloud/api/endpoints/customer/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/ordercloud/api/endpoints/login/index.ts b/framework/ordercloud/api/endpoints/login/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/ordercloud/api/endpoints/login/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/ordercloud/api/endpoints/logout/index.ts b/framework/ordercloud/api/endpoints/logout/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/ordercloud/api/endpoints/logout/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/ordercloud/api/endpoints/signup/index.ts b/framework/ordercloud/api/endpoints/signup/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/ordercloud/api/endpoints/signup/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/ordercloud/api/endpoints/wishlist/index.tsx b/framework/ordercloud/api/endpoints/wishlist/index.tsx new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/ordercloud/api/endpoints/wishlist/index.tsx @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/ordercloud/api/index.ts b/framework/ordercloud/api/index.ts new file mode 100644 index 000000000..6f0d08cf5 --- /dev/null +++ b/framework/ordercloud/api/index.ts @@ -0,0 +1,54 @@ +import type { CommerceAPI, CommerceAPIConfig } from '@commerce/api' +import { getCommerceApi as commerceApi } from '@commerce/api' +import createRestFetcher from './utils/fetch-rest' +import createGraphqlFetcher from './utils/fetch-graphql' + +import getAllPages from './operations/get-all-pages' +import getPage from './operations/get-page' +import getSiteInfo from './operations/get-site-info' +import getAllProductPaths from './operations/get-all-product-paths' +import getAllProducts from './operations/get-all-products' +import getProduct from './operations/get-product' + +import { API_URL, API_VERSION, CART_COOKIE, CUSTOMER_COOKIE } from '../constants' + +export interface OrdercloudConfig extends CommerceAPIConfig { + restFetch: ( + method: string, + resource: string, + body?: Record, + fetchOptions?: Record + ) => Promise, + apiVersion: string; +} + +const config: OrdercloudConfig = { + commerceUrl: API_URL, + apiToken: '', + apiVersion: API_VERSION, + cartCookie: CART_COOKIE, + customerCookie: CUSTOMER_COOKIE, + cartCookieMaxAge: 2592000, + restFetch: createRestFetcher(() => getCommerceApi().getConfig()), + fetch: createGraphqlFetcher(() => getCommerceApi().getConfig()), +} + +const operations = { + getAllPages, + getPage, + getSiteInfo, + getAllProductPaths, + getAllProducts, + getProduct, +} + +export const provider = { config, operations } + +export type Provider = typeof provider +export type OrdercloudAPI

= CommerceAPI

+ +export function getCommerceApi

( + customProvider: P = provider as any +): OrdercloudAPI

{ + return commerceApi(customProvider as any) +} diff --git a/framework/ordercloud/api/operations/get-all-pages.ts b/framework/ordercloud/api/operations/get-all-pages.ts new file mode 100644 index 000000000..1727532e2 --- /dev/null +++ b/framework/ordercloud/api/operations/get-all-pages.ts @@ -0,0 +1,22 @@ +import type { OrdercloudConfig } from '../' + +import { GetAllPagesOperation } from '@commerce/types/page' + +export type Page = { url: string } +export type GetAllPagesResult = { pages: Page[] } + +export default function getAllPagesOperation() { + async function getAllPages({ + config, + preview, + }: { + url?: string + config?: Partial + preview?: boolean + } = {}): Promise { + return Promise.resolve({ + pages: [], + }) + } + return getAllPages +} diff --git a/framework/ordercloud/api/operations/get-all-product-paths.ts b/framework/ordercloud/api/operations/get-all-product-paths.ts new file mode 100644 index 000000000..e8966539c --- /dev/null +++ b/framework/ordercloud/api/operations/get-all-product-paths.ts @@ -0,0 +1,34 @@ +import type { OperationContext } from '@commerce/api/operations' +import type { GetAllProductPathsOperation } from '@commerce/types/product' + +import type { RawProduct } from '../../types/product' +import type { OrdercloudConfig, Provider } from '../' + +export type GetAllProductPathsResult = { + products: Array<{ path: string }> +} + +export default function getAllProductPathsOperation({ + commerce, +}: OperationContext) { + async function getAllProductPaths({ + config, + }: { + config?: Partial + } = {}): Promise { + // Get fetch from the config + const { restFetch } = commerce.getConfig(config) + + // Get all products + const rawProducts: RawProduct[] = await restFetch<{ + Items: RawProduct[] + }>('GET', '/me/products').then((response) => response.Items) + + return { + // Match a path for every product retrieved + products: rawProducts.map((product) => ({ path: `/${product.ID}` })), + } + } + + return getAllProductPaths +} diff --git a/framework/ordercloud/api/operations/get-all-products.ts b/framework/ordercloud/api/operations/get-all-products.ts new file mode 100644 index 000000000..6e1020e5d --- /dev/null +++ b/framework/ordercloud/api/operations/get-all-products.ts @@ -0,0 +1,35 @@ +import type { GetAllProductsOperation } from '@commerce/types/product' +import type { OperationContext } from '@commerce/api/operations' + +import type { RawProduct } from '../../types/product' +import type { OrdercloudConfig, Provider } from '../index' + +import { normalize as normalizeProduct } from '../../utils/product' + +export default function getAllProductsOperation({ + commerce, +}: OperationContext) { + async function getAllProducts({ + config, + }: { + query?: string + variables?: T['variables'] + config?: Partial + preview?: boolean + } = {}): Promise { + // Get fetch from the config + const { restFetch } = commerce.getConfig(config) + + // Get all products + const rawProducts: RawProduct[] = await restFetch<{ + Items: RawProduct[] + }>('GET', '/me/products').then((response) => response.Items) + + return { + // Normalize products to commerce schema + products: rawProducts.map(normalizeProduct), + } + } + + return getAllProducts +} diff --git a/framework/ordercloud/api/operations/get-page.ts b/framework/ordercloud/api/operations/get-page.ts new file mode 100644 index 000000000..6b0a86a4d --- /dev/null +++ b/framework/ordercloud/api/operations/get-page.ts @@ -0,0 +1,15 @@ +import { GetPageOperation } from "@commerce/types/page" + +export type Page = any +export type GetPageResult = { page?: Page } + +export type PageVariables = { + id: number +} + +export default function getPageOperation() { + async function getPage(): Promise { + return Promise.resolve({}) + } + return getPage +} diff --git a/framework/ordercloud/api/operations/get-product.ts b/framework/ordercloud/api/operations/get-product.ts new file mode 100644 index 000000000..9fba59b3a --- /dev/null +++ b/framework/ordercloud/api/operations/get-product.ts @@ -0,0 +1,60 @@ +import type { OperationContext } from '@commerce/api/operations' +import type { GetProductOperation } from '@commerce/types/product' + +import type { RawProduct, RawSpec, RawVariant } from '../../types/product' +import type { OrdercloudConfig, Provider } from '../index' + +import { normalize as normalizeProduct } from '../../utils/product' + +export default function getProductOperation({ + commerce, +}: OperationContext) { + async function getProduct({ + config, + variables, + }: { + query?: string + variables?: T['variables'] + config?: Partial + preview?: boolean + } = {}): Promise { + // Get fetch from the config + const { restFetch } = commerce.getConfig(config) + + // Get a single product + const productPromise = restFetch( + 'GET', + `/me/products/${variables?.slug}` + ) + + // Get product specs + const specsPromise = restFetch<{ Items: RawSpec[] }>( + 'GET', + `/me/products/${variables?.slug}/specs` + ).then((res) => res.Items) + + // Get product variants + const variantsPromise = restFetch<{ Items: RawVariant[] }>( + 'GET', + `/me/products/${variables?.slug}/variants` + ).then((res) => res.Items) + + // Execute all promises in parallel + const [product, specs, variants] = await Promise.all([ + productPromise, + specsPromise, + variantsPromise, + ]) + + // Hydrate product + product.xp.Specs = specs + product.xp.Variants = variants + + return { + // Normalize product to commerce schema + product: normalizeProduct(product), + } + } + + return getProduct +} diff --git a/framework/ordercloud/api/operations/get-site-info.ts b/framework/ordercloud/api/operations/get-site-info.ts new file mode 100644 index 000000000..247b2aa07 --- /dev/null +++ b/framework/ordercloud/api/operations/get-site-info.ts @@ -0,0 +1,46 @@ +import type { OperationContext } from '@commerce/api/operations' +import type { Category, GetSiteInfoOperation } from '@commerce/types/site' + +import type { RawCategory } from '../../types/category' +import type { OrdercloudConfig, Provider } from '../index' + +export type GetSiteInfoResult< + T extends { categories: any[]; brands: any[] } = { + categories: Category[] + brands: any[] + } +> = T + +export default function getSiteInfoOperation({ + commerce, +}: OperationContext) { + async function getSiteInfo({ + config, + }: { + query?: string + variables?: any + config?: Partial + preview?: boolean + } = {}): Promise { + // Get fetch from the config + const { restFetch } = commerce.getConfig(config) + + // Get list of categories + const rawCategories: RawCategory[] = await restFetch<{ + Items: RawCategory[] + }>('GET', `/me/categories`).then((response) => response.Items) + + return { + // Normalize categories + categories: rawCategories.map((category) => ({ + id: category.ID, + name: category.Name, + slug: category.ID, + path: `/${category.ID}`, + })), + brands: [], + } + } + + return getSiteInfo +} diff --git a/framework/ordercloud/api/operations/index.ts b/framework/ordercloud/api/operations/index.ts new file mode 100644 index 000000000..84b04a978 --- /dev/null +++ b/framework/ordercloud/api/operations/index.ts @@ -0,0 +1,6 @@ +export { default as getAllPages } from './get-all-pages' +export { default as getPage } from './get-page' +export { default as getSiteInfo } from './get-site-info' +export { default as getProduct } from './get-product' +export { default as getAllProducts } from './get-all-products' +export { default as getAllProductPaths } from './get-all-product-paths' 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-graphql.ts b/framework/ordercloud/api/utils/fetch-graphql.ts new file mode 100644 index 000000000..af72a337c --- /dev/null +++ b/framework/ordercloud/api/utils/fetch-graphql.ts @@ -0,0 +1,14 @@ +import type { GraphQLFetcher } from '@commerce/api' +import type { OrdercloudConfig } from '../' + +import { FetcherError } from '@commerce/utils/errors' + +const fetchGraphqlApi: (getConfig: () => OrdercloudConfig) => GraphQLFetcher = + () => async () => { + throw new FetcherError({ + errors: [{ message: 'GraphQL fetch is not implemented' }], + status: 500, + }) + } + +export default fetchGraphqlApi diff --git a/framework/ordercloud/api/utils/fetch-rest.ts b/framework/ordercloud/api/utils/fetch-rest.ts new file mode 100644 index 000000000..f355174e9 --- /dev/null +++ b/framework/ordercloud/api/utils/fetch-rest.ts @@ -0,0 +1,145 @@ +import vercelFetch from '@vercel/fetch' +import { FetcherError } from '@commerce/utils/errors' +import jwt from 'jsonwebtoken' + +import { OrdercloudConfig } from '../index' + +// Get an instance to vercel fetch +const fetch = vercelFetch() + +// Get token util +async function getToken(baseUrl: string) { + // If not, get a new one and store it + const authResponse = await fetch(`${baseUrl}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: `client_id=${process.env.ORDERCLOUD_CLIENT_ID}&client_secret=${process.env.ORDERCLOUD_CLIENT_SECRET}&grant_type=client_credentials`, + }) + + // 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, + }) + } + + // Return the token + return authResponse.json().then((response) => response.access_token) +} + +export async function fetchData( + opts: { + path: string + method: string + baseUrl: string + apiVersion: string + fetchOptions?: Record + body?: Record + }, + retries = 0 +): Promise { + // Destructure opts + const { path, body, fetchOptions, baseUrl, apiVersion, method = 'GET' } = opts + + // Decode token + const decoded = jwt.decode(global.token as string) as jwt.JwtPayload | null + + // If token is not present or its expired, get a new one and store it + if ( + !global.token || + (typeof decoded?.exp === 'number' && decoded?.exp * 1000 < +new Date()) + ) { + // Get a new one + const token = await getToken(baseUrl) + + // Store it + global.token = token + } + + // Do the request with the correct headers + const dataResponse = await fetch(`${baseUrl}/${apiVersion}${path}`, { + ...fetchOptions, + method, + headers: { + ...fetchOptions?.headers, + 'Content-Type': 'application/json', + 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) { + // Get a new one + const token = await getToken(baseUrl) + + // Store it + global.token = token + } + + // And if retries left + if (retries < 2) { + // Refetch + return fetchData(opts, 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, + }) + } + + try { + // Return data response as json + return (await dataResponse.json()) as Promise + } catch (error) { + // If response is empty return it as text + return null as unknown as Promise + } +} + +const serverFetcher: ( + getConfig: () => OrdercloudConfig +) => ( + method: string, + path: string, + body?: Record, + fetchOptions?: Record +) => Promise = + (getConfig) => + async ( + method: string, + path: string, + body?: Record, + fetchOptions?: Record + ) => { + // Get provider config + const { commerceUrl, apiVersion } = getConfig() + + // Return the data and specify the expected type + return fetchData({ + fetchOptions, + method, + baseUrl: commerceUrl, + apiVersion, + path, + body, + }) + } + +export default serverFetcher diff --git a/framework/ordercloud/auth/index.ts b/framework/ordercloud/auth/index.ts new file mode 100644 index 000000000..36e757a89 --- /dev/null +++ b/framework/ordercloud/auth/index.ts @@ -0,0 +1,3 @@ +export { default as useLogin } from './use-login' +export { default as useLogout } from './use-logout' +export { default as useSignup } from './use-signup' diff --git a/framework/ordercloud/auth/use-login.tsx b/framework/ordercloud/auth/use-login.tsx new file mode 100644 index 000000000..28351dc7f --- /dev/null +++ b/framework/ordercloud/auth/use-login.tsx @@ -0,0 +1,16 @@ +import { MutationHook } from '@commerce/utils/types' +import useLogin, { UseLogin } from '@commerce/auth/use-login' + +export default useLogin as UseLogin + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher() { + return null + }, + useHook: () => () => { + return async function () {} + }, +} diff --git a/framework/ordercloud/auth/use-logout.tsx b/framework/ordercloud/auth/use-logout.tsx new file mode 100644 index 000000000..9b3fc3e44 --- /dev/null +++ b/framework/ordercloud/auth/use-logout.tsx @@ -0,0 +1,17 @@ +import { MutationHook } from '@commerce/utils/types' +import useLogout, { UseLogout } from '@commerce/auth/use-logout' + +export default useLogout as UseLogout + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher() { + return null + }, + useHook: + ({ fetch }) => + () => + async () => {}, +} diff --git a/framework/ordercloud/auth/use-signup.tsx b/framework/ordercloud/auth/use-signup.tsx new file mode 100644 index 000000000..e9ad13458 --- /dev/null +++ b/framework/ordercloud/auth/use-signup.tsx @@ -0,0 +1,19 @@ +import { useCallback } from 'react' +import useCustomer from '../customer/use-customer' +import { MutationHook } from '@commerce/utils/types' +import useSignup, { UseSignup } from '@commerce/auth/use-signup' + +export default useSignup as UseSignup + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher() { + return null + }, + useHook: + ({ fetch }) => + () => + () => {}, +} diff --git a/framework/ordercloud/cart/index.ts b/framework/ordercloud/cart/index.ts new file mode 100644 index 000000000..3b8ba990e --- /dev/null +++ b/framework/ordercloud/cart/index.ts @@ -0,0 +1,4 @@ +export { default as useCart } from './use-cart' +export { default as useAddItem } from './use-add-item' +export { default as useRemoveItem } from './use-remove-item' +export { default as useUpdateItem } from './use-update-item' diff --git a/framework/ordercloud/cart/use-add-item.tsx b/framework/ordercloud/cart/use-add-item.tsx new file mode 100644 index 000000000..4699202c3 --- /dev/null +++ b/framework/ordercloud/cart/use-add-item.tsx @@ -0,0 +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 useCart from './use-cart' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/cart', + method: 'POST', + }, + 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 new file mode 100644 index 000000000..d194f4097 --- /dev/null +++ b/framework/ordercloud/cart/use-cart.tsx @@ -0,0 +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 = { + fetchOptions: { + url: '/api/cart', + method: 'GET', + }, + useHook: ({ useData }) => + function useHook(input) { + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input?.swrOptions }, + }) + + return useMemo( + () => + 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 new file mode 100644 index 000000000..748ba963d --- /dev/null +++ b/framework/ordercloud/cart/use-remove-item.tsx @@ -0,0 +1,60 @@ +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 = { + fetchOptions: { + url: '/api/cart', + method: 'DELETE', + }, + 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 new file mode 100644 index 000000000..cc9d93b03 --- /dev/null +++ b/framework/ordercloud/cart/use-update-item.tsx @@ -0,0 +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: { + url: '/api/cart', + method: 'PUT', + }, + 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/checkout/index.ts b/framework/ordercloud/checkout/index.ts new file mode 100644 index 000000000..306621059 --- /dev/null +++ b/framework/ordercloud/checkout/index.ts @@ -0,0 +1,2 @@ +export { default as useSubmitCheckout } from './use-submit-checkout' +export { default as useCheckout } from './use-checkout' diff --git a/framework/ordercloud/checkout/use-checkout.tsx b/framework/ordercloud/checkout/use-checkout.tsx new file mode 100644 index 000000000..6ce13dbb6 --- /dev/null +++ b/framework/ordercloud/checkout/use-checkout.tsx @@ -0,0 +1,41 @@ +import type { GetCheckoutHook } from '@commerce/types/checkout' + +import { useMemo } from 'react' +import { SWRHook } from '@commerce/utils/types' +import useCheckout, { UseCheckout } from '@commerce/checkout/use-checkout' +import useSubmitCheckout from './use-submit-checkout' + +export default useCheckout as UseCheckout + +export const handler: SWRHook = { + fetchOptions: { + url: '/api/checkout', + method: 'GET', + }, + useHook: ({ useData }) => + function useHook(input) { + const submit = useSubmitCheckout(); + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input?.swrOptions }, + }) + + return useMemo( + () => + Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems?.length ?? 0) <= 0 + }, + enumerable: true, + }, + submit: { + get() { + return submit + }, + enumerable: true, + }, + }), + [response, submit] + ) + }, +} diff --git a/framework/ordercloud/checkout/use-submit-checkout.tsx b/framework/ordercloud/checkout/use-submit-checkout.tsx new file mode 100644 index 000000000..47644de8e --- /dev/null +++ b/framework/ordercloud/checkout/use-submit-checkout.tsx @@ -0,0 +1,36 @@ +import type { SubmitCheckoutHook } from '@commerce/types/checkout' +import type { MutationHook } from '@commerce/utils/types' + +import { useCallback } from 'react' +import useSubmitCheckout, { UseSubmitCheckout } from '@commerce/checkout/use-submit-checkout' + +export default useSubmitCheckout as UseSubmitCheckout + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/checkout', + method: 'POST', + }, + async fetcher({ input: item, options, fetch }) { + // @TODO: Make form validations in here, import generic error like import { CommerceError } from '@commerce/utils/errors' + // Get payment and delivery information in here + + const data = await fetch({ + ...options, + body: { item }, + }) + + return data + }, + useHook: ({ fetch }) => + function useHook() { + return useCallback( + async function onSubmitCheckout(input) { + const data = await fetch({ input }) + + return data + }, + [fetch] + ) + }, +} diff --git a/framework/ordercloud/commerce.config.json b/framework/ordercloud/commerce.config.json new file mode 100644 index 000000000..d93afa783 --- /dev/null +++ b/framework/ordercloud/commerce.config.json @@ -0,0 +1,10 @@ +{ + "provider": "ordercloud", + "features": { + "wishlist": false, + "cart": true, + "search": false, + "customerAuth": false, + "customCheckout": true + } +} diff --git a/framework/ordercloud/constants.ts b/framework/ordercloud/constants.ts new file mode 100644 index 000000000..0c7ad21b3 --- /dev/null +++ b/framework/ordercloud/constants.ts @@ -0,0 +1,5 @@ +export const CART_COOKIE = 'ordercloud.cart' +export const CUSTOMER_COOKIE = 'ordercloud.customer' +export const API_URL = 'https://sandboxapi.ordercloud.io' +export const API_VERSION = 'v1' +export const LOCALE = 'en-us' diff --git a/framework/ordercloud/customer/address/index.ts b/framework/ordercloud/customer/address/index.ts new file mode 100644 index 000000000..02c73e53b --- /dev/null +++ b/framework/ordercloud/customer/address/index.ts @@ -0,0 +1,4 @@ +export { default as useAddresses } from './use-addresses' +export { default as useAddItem } from './use-add-item' +export { default as useRemoveItem } from './use-remove-item' +export { default as useUpdateItem } from './use-update-item' diff --git a/framework/ordercloud/customer/address/use-add-item.tsx b/framework/ordercloud/customer/address/use-add-item.tsx new file mode 100644 index 000000000..d8234a0ac --- /dev/null +++ b/framework/ordercloud/customer/address/use-add-item.tsx @@ -0,0 +1,48 @@ +import type { AddItemHook } from '@commerce/types/customer/address' +import type { MutationHook } from '@commerce/utils/types' + +import { useCallback } from 'react' +import { CommerceError } from '@commerce/utils/errors' +import useAddItem, { UseAddItem } from '@commerce/customer/address/use-add-item' +import useAddresses from './use-addresses' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/customer/address', + method: 'POST', + }, + 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 } = useAddresses() + + return useCallback( + async function addItem(input) { + const data = await fetch({ input }) + + await mutate(data, false) + + return data + }, + [fetch, mutate] + ) + }, +} diff --git a/framework/ordercloud/customer/address/use-addresses.tsx b/framework/ordercloud/customer/address/use-addresses.tsx new file mode 100644 index 000000000..dc17c9f00 --- /dev/null +++ b/framework/ordercloud/customer/address/use-addresses.tsx @@ -0,0 +1,33 @@ +import type { GetAddressesHook } from '@commerce/types/customer/address' + +import { useMemo } from 'react' +import { SWRHook } from '@commerce/utils/types' +import useAddresses, { UseAddresses } from '@commerce/customer/address/use-addresses' + +export default useAddresses as UseAddresses + +export const handler: SWRHook = { + fetchOptions: { + url: '/api/customer/address', + method: 'GET', + }, + useHook: ({ useData }) => + function useHook(input) { + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input?.swrOptions }, + }) + + return useMemo( + () => + Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems?.length ?? 0) <= 0 + }, + enumerable: true, + }, + }), + [response] + ) + }, +} diff --git a/framework/ordercloud/customer/address/use-remove-item.tsx b/framework/ordercloud/customer/address/use-remove-item.tsx new file mode 100644 index 000000000..b818497a8 --- /dev/null +++ b/framework/ordercloud/customer/address/use-remove-item.tsx @@ -0,0 +1,60 @@ +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/customer/address/use-remove-item' + +import useAddresses from './use-addresses' + +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 = { + fetchOptions: { + url: '/api/customer/address', + method: 'DELETE', + }, + 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 } = useAddresses() + 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/customer/address/use-update-item.tsx b/framework/ordercloud/customer/address/use-update-item.tsx new file mode 100644 index 000000000..8bd0c98e4 --- /dev/null +++ b/framework/ordercloud/customer/address/use-update-item.tsx @@ -0,0 +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/customer/address/use-update-item' + +import { handler as removeItemHandler } from './use-remove-item' +import useAddresses from './use-addresses' + +export type UpdateItemActionInput = T extends LineItem + ? Partial + : UpdateItemHook['actionInput'] + +export default useUpdateItem as UseUpdateItem + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/customer/address', + method: 'PUT', + }, + 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 } = useAddresses() 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/customer/card/index.ts b/framework/ordercloud/customer/card/index.ts new file mode 100644 index 000000000..357d30500 --- /dev/null +++ b/framework/ordercloud/customer/card/index.ts @@ -0,0 +1,4 @@ +export { default as useCards } from './use-cards' +export { default as useAddItem } from './use-add-item' +export { default as useRemoveItem } from './use-remove-item' +export { default as useUpdateItem } from './use-update-item' diff --git a/framework/ordercloud/customer/card/use-add-item.tsx b/framework/ordercloud/customer/card/use-add-item.tsx new file mode 100644 index 000000000..466ce8b5b --- /dev/null +++ b/framework/ordercloud/customer/card/use-add-item.tsx @@ -0,0 +1,48 @@ +import type { AddItemHook } from '@commerce/types/customer/card' +import type { MutationHook } from '@commerce/utils/types' + +import { useCallback } from 'react' +import { CommerceError } from '@commerce/utils/errors' +import useAddItem, { UseAddItem } from '@commerce/customer/card/use-add-item' +import useCards from './use-cards' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/customer/card', + method: 'POST', + }, + 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 } = useCards() + + return useCallback( + async function addItem(input) { + const data = await fetch({ input }) + + await mutate(data, false) + + return data + }, + [fetch, mutate] + ) + }, +} diff --git a/framework/ordercloud/customer/card/use-cards.tsx b/framework/ordercloud/customer/card/use-cards.tsx new file mode 100644 index 000000000..76f030462 --- /dev/null +++ b/framework/ordercloud/customer/card/use-cards.tsx @@ -0,0 +1,33 @@ +import type { GetCardsHook } from '@commerce/types/customer/card' + +import { useMemo } from 'react' +import { SWRHook } from '@commerce/utils/types' +import useCard, { UseCards } from '@commerce/customer/card/use-cards' + +export default useCard as UseCards + +export const handler: SWRHook = { + fetchOptions: { + url: '/api/customer/card', + method: 'GET', + }, + useHook: ({ useData }) => + function useHook(input) { + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input?.swrOptions }, + }) + + return useMemo( + () => + Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems?.length ?? 0) <= 0 + }, + enumerable: true, + }, + }), + [response] + ) + }, +} diff --git a/framework/ordercloud/customer/card/use-remove-item.tsx b/framework/ordercloud/customer/card/use-remove-item.tsx new file mode 100644 index 000000000..cf46404db --- /dev/null +++ b/framework/ordercloud/customer/card/use-remove-item.tsx @@ -0,0 +1,60 @@ +import type { + MutationHookContext, + HookFetcherContext, +} from '@commerce/utils/types' +import type { Cart, LineItem, RemoveItemHook } from '@commerce/types/customer/card' + +import { useCallback } from 'react' + +import { ValidationError } from '@commerce/utils/errors' +import useRemoveItem, { UseRemoveItem } from '@commerce/customer/card/use-remove-item' + +import useCards from './use-cards' + +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 = { + fetchOptions: { + url: '/api/customer/card', + method: 'DELETE', + }, + 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 } = useCards() + 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/customer/card/use-update-item.tsx b/framework/ordercloud/customer/card/use-update-item.tsx new file mode 100644 index 000000000..88d59aa78 --- /dev/null +++ b/framework/ordercloud/customer/card/use-update-item.tsx @@ -0,0 +1,93 @@ +import type { + HookFetcherContext, + MutationHookContext, +} from '@commerce/utils/types' +import type { UpdateItemHook, LineItem } from '@commerce/types/customer/card' + +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/customer/card/use-update-item' + +import { handler as removeItemHandler } from './use-remove-item' +import useCards from './use-cards' + +export type UpdateItemActionInput = T extends LineItem + ? Partial + : UpdateItemHook['actionInput'] + +export default useUpdateItem as UseUpdateItem + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/customer/card', + method: 'PUT', + }, + 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 } = useCards() 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/customer/index.ts b/framework/ordercloud/customer/index.ts new file mode 100644 index 000000000..6c903ecc5 --- /dev/null +++ b/framework/ordercloud/customer/index.ts @@ -0,0 +1 @@ +export { default as useCustomer } from './use-customer' diff --git a/framework/ordercloud/customer/use-customer.tsx b/framework/ordercloud/customer/use-customer.tsx new file mode 100644 index 000000000..41757cd0d --- /dev/null +++ b/framework/ordercloud/customer/use-customer.tsx @@ -0,0 +1,15 @@ +import { SWRHook } from '@commerce/utils/types' +import useCustomer, { UseCustomer } from '@commerce/customer/use-customer' + +export default useCustomer as UseCustomer +export const handler: SWRHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: () => () => { + return async function addItem() { + return {} + } + }, +} diff --git a/framework/ordercloud/fetcher.ts b/framework/ordercloud/fetcher.ts new file mode 100644 index 000000000..6f314a71e --- /dev/null +++ b/framework/ordercloud/fetcher.ts @@ -0,0 +1,17 @@ +import { Fetcher } from '@commerce/utils/types' + +const clientFetcher: Fetcher = async ({ method, url, body }) => { + const response = await fetch(url!, { + method, + body: body ? JSON.stringify(body) : undefined, + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((response) => response.json()) + .then((response) => response.data) + + return response +} + +export default clientFetcher diff --git a/framework/ordercloud/index.tsx b/framework/ordercloud/index.tsx new file mode 100644 index 000000000..6a01c2ee4 --- /dev/null +++ b/framework/ordercloud/index.tsx @@ -0,0 +1,9 @@ +import { ordercloudProvider, OrdercloudProvider } from './provider' +import { getCommerceProvider, useCommerce as useCoreCommerce } from '@commerce' + +export { ordercloudProvider } +export type { OrdercloudProvider } + +export const CommerceProvider = getCommerceProvider(ordercloudProvider) + +export const useCommerce = () => useCoreCommerce() diff --git a/framework/ordercloud/next.config.js b/framework/ordercloud/next.config.js new file mode 100644 index 000000000..793a4589f --- /dev/null +++ b/framework/ordercloud/next.config.js @@ -0,0 +1,8 @@ +const commerce = require('./commerce.config.json') + +module.exports = { + commerce, + images: { + domains: ['localhost', 'ocdevops.blob.core.windows.net'], + }, +} diff --git a/framework/ordercloud/product/index.ts b/framework/ordercloud/product/index.ts new file mode 100644 index 000000000..426a3edcd --- /dev/null +++ b/framework/ordercloud/product/index.ts @@ -0,0 +1,2 @@ +export { default as usePrice } from './use-price' +export { default as useSearch } from './use-search' diff --git a/framework/ordercloud/product/use-price.tsx b/framework/ordercloud/product/use-price.tsx new file mode 100644 index 000000000..0174faf5e --- /dev/null +++ b/framework/ordercloud/product/use-price.tsx @@ -0,0 +1,2 @@ +export * from '@commerce/product/use-price' +export { default } from '@commerce/product/use-price' diff --git a/framework/ordercloud/product/use-search.tsx b/framework/ordercloud/product/use-search.tsx new file mode 100644 index 000000000..30e699537 --- /dev/null +++ b/framework/ordercloud/product/use-search.tsx @@ -0,0 +1,17 @@ +import { SWRHook } from '@commerce/utils/types' +import useSearch, { UseSearch } from '@commerce/product/use-search' +export default useSearch as UseSearch + +export const handler: SWRHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: () => () => { + return { + data: { + products: [], + }, + } + }, +} diff --git a/framework/ordercloud/provider.ts b/framework/ordercloud/provider.ts new file mode 100644 index 000000000..337eed657 --- /dev/null +++ b/framework/ordercloud/provider.ts @@ -0,0 +1,62 @@ +import { handler as useCart } from './cart/use-cart' +import { handler as useAddCartItem } from './cart/use-add-item' +import { handler as useUpdateCartItem } from './cart/use-update-item' +import { handler as useRemoveCartItem } from './cart/use-remove-item' + +import { handler as useCustomer } from './customer/use-customer' +import { handler as useSearch } from './product/use-search' + +import { handler as useLogin } from './auth/use-login' +import { handler as useLogout } from './auth/use-logout' +import { handler as useSignup } from './auth/use-signup' + +import { handler as useCheckout } from './checkout/use-checkout' +import { handler as useSubmitCheckout } from './checkout/use-submit-checkout' + +import { handler as useCards } from './customer/card/use-cards' +import { handler as useAddCardItem } from './customer/card/use-add-item' +import { handler as useUpdateCardItem } from './customer/card/use-update-item' +import { handler as useRemoveCardItem } from './customer/card/use-remove-item' + +import { handler as useAddresses } from './customer/address/use-addresses' +import { handler as useAddAddressItem } from './customer/address/use-add-item' +import { handler as useUpdateAddressItem } from './customer/address/use-update-item' +import { handler as useRemoveAddressItem } from './customer/address/use-remove-item' + +import { CART_COOKIE, LOCALE } from './constants' +import { default as fetcher } from './fetcher' + +export const ordercloudProvider = { + locale: LOCALE, + cartCookie: CART_COOKIE, + fetcher, + cart: { + useCart, + useAddItem: useAddCartItem, + useUpdateItem: useUpdateCartItem, + useRemoveItem: useRemoveCartItem + }, + checkout: { + useCheckout, + useSubmitCheckout, + }, + customer: { + useCustomer, + card: { + useCards, + useAddItem: useAddCardItem, + useUpdateItem: useUpdateCardItem, + useRemoveItem: useRemoveCardItem + }, + address: { + useAddresses, + useAddItem: useAddAddressItem, + useUpdateItem: useUpdateAddressItem, + useRemoveItem: useRemoveAddressItem + } + }, + products: { useSearch }, + auth: { useLogin, useLogout, useSignup }, +} + +export type OrdercloudProvider = typeof ordercloudProvider 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/category.ts b/framework/ordercloud/types/category.ts new file mode 100644 index 000000000..247844a56 --- /dev/null +++ b/framework/ordercloud/types/category.ts @@ -0,0 +1,10 @@ +export interface RawCategory { + ID: string + Name: string + Description: null | string + ListOrder: number + Active: boolean + ParentID: null + ChildCount: number + xp: null +} diff --git a/framework/ordercloud/types/checkout.ts b/framework/ordercloud/types/checkout.ts new file mode 100644 index 000000000..17cbf43de --- /dev/null +++ b/framework/ordercloud/types/checkout.ts @@ -0,0 +1,4 @@ +import * as Core from '@commerce/types/checkout' + +export type CheckoutTypes = Core.CheckoutTypes +export type CheckoutSchema = Core.CheckoutSchema diff --git a/framework/ordercloud/types/customer/address.ts b/framework/ordercloud/types/customer/address.ts new file mode 100644 index 000000000..3aaddc9a2 --- /dev/null +++ b/framework/ordercloud/types/customer/address.ts @@ -0,0 +1,31 @@ +import * as Core from '@commerce/types/customer/address' + +export type CustomerAddressTypes = Core.CustomerAddressTypes +export type CustomerAddressSchema = Core.CustomerAddressSchema + +export interface OrdercloudAddress { + ID: string; + "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": null +} diff --git a/framework/ordercloud/types/customer/card.ts b/framework/ordercloud/types/customer/card.ts new file mode 100644 index 000000000..eb1abffbb --- /dev/null +++ b/framework/ordercloud/types/customer/card.ts @@ -0,0 +1,16 @@ +import * as Core from '@commerce/types/customer/card' + +export type CustomerCardTypes = Core.CustomerCardTypes +export type CustomerCardSchema = Core.CustomerCardSchema + +export interface OredercloudCreditCard { + "ID": string; + "Editable": boolean; + "Token": string; + "DateCreated": string; + "CardType": string; + "PartialAccountNumber": string; + "CardholderName": string; + "ExpirationDate": string; + "xp": null +} diff --git a/framework/ordercloud/types/node.d.ts b/framework/ordercloud/types/node.d.ts new file mode 100644 index 000000000..f4e4a21f4 --- /dev/null +++ b/framework/ordercloud/types/node.d.ts @@ -0,0 +1,5 @@ +declare module NodeJS { + interface Global { + token: string | null | undefined + } +} diff --git a/framework/ordercloud/types/product.ts b/framework/ordercloud/types/product.ts new file mode 100644 index 000000000..8ccb778d2 --- /dev/null +++ b/framework/ordercloud/types/product.ts @@ -0,0 +1,55 @@ +interface RawVariantSpec { + SpecID: string + Name: string + OptionID: string + Value: string + PriceMarkupType: string + PriceMarkup: string | null +} + +export interface RawSpec { + ID: string + Name: string + Options: { + ID: string + Value: string + xp: { + hexColor?: string + } + }[] +} + +export interface RawVariant { + ID: string + Specs: RawVariantSpec[] +} + +export interface RawProduct { + OwnerID: string + DefaultPriceScheduleID: string | null + AutoForward: boolean + ID: string + Name: string + Description: string + QuantityMultiplier: number + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: boolean + SpecCount: number + VariantCount: number + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: boolean + xp: { + Price: number + PriceCurrency: string + Images: { + url: string + }[] + Variants?: RawVariant[] + Specs?: RawSpec[] + } +} diff --git a/framework/ordercloud/utils/product.ts b/framework/ordercloud/utils/product.ts new file mode 100644 index 000000000..ee334f175 --- /dev/null +++ b/framework/ordercloud/utils/product.ts @@ -0,0 +1,47 @@ +import type { Product } from '@commerce/types/product' + +import type { RawProduct } from '../types/product' + +export function normalize(product: RawProduct): Product { + return { + id: product.ID, + name: product.Name, + description: product.Description, + slug: product.ID, + images: product.xp.Images, + price: { + value: product.xp.Price, + currencyCode: product.xp.PriceCurrency, + }, + variants: product.xp.Variants?.length + ? product.xp.Variants.map((variant) => ({ + id: variant.ID, + options: variant.Specs.map((spec) => ({ + id: spec.SpecID, + __typename: 'MultipleChoiceOption', + displayName: spec.Name, + values: [ + { + label: spec.Value, + }, + ], + })), + })) + : [ + { + id: '', + options: [], + }, + ], + options: product.xp.Specs?.length + ? product.xp.Specs.map((spec) => ({ + id: spec.ID, + displayName: spec.Name, + values: spec.Options.map((option) => ({ + label: option.Value, + ...(option.xp?.hexColor && { hexColors: [option.xp.hexColor] }), + })), + })) + : [], + } +} diff --git a/framework/ordercloud/wishlist/use-add-item.tsx b/framework/ordercloud/wishlist/use-add-item.tsx new file mode 100644 index 000000000..75f067c3a --- /dev/null +++ b/framework/ordercloud/wishlist/use-add-item.tsx @@ -0,0 +1,13 @@ +import { useCallback } from 'react' + +export function emptyHook() { + const useEmptyHook = async (options = {}) => { + return useCallback(async function () { + return Promise.resolve() + }, []) + } + + return useEmptyHook +} + +export default emptyHook diff --git a/framework/ordercloud/wishlist/use-remove-item.tsx b/framework/ordercloud/wishlist/use-remove-item.tsx new file mode 100644 index 000000000..a2d3a8a05 --- /dev/null +++ b/framework/ordercloud/wishlist/use-remove-item.tsx @@ -0,0 +1,17 @@ +import { useCallback } from 'react' + +type Options = { + includeProducts?: boolean +} + +export function emptyHook(options?: Options) { + const useEmptyHook = async ({ id }: { id: string | number }) => { + return useCallback(async function () { + return Promise.resolve() + }, []) + } + + return useEmptyHook +} + +export default emptyHook diff --git a/framework/ordercloud/wishlist/use-wishlist.tsx b/framework/ordercloud/wishlist/use-wishlist.tsx new file mode 100644 index 000000000..9fe0e758f --- /dev/null +++ b/framework/ordercloud/wishlist/use-wishlist.tsx @@ -0,0 +1,43 @@ +import { HookFetcher } from '@commerce/utils/types' +import type { Product } from '@commerce/types/product' + +const defaultOpts = {} + +export type Wishlist = { + items: [ + { + product_id: number + variant_id: number + id: number + product: Product + } + ] +} + +export interface UseWishlistOptions { + includeProducts?: boolean +} + +export interface UseWishlistInput extends UseWishlistOptions { + customerId?: number +} + +export const fetcher: HookFetcher = () => { + return null +} + +export function extendHook( + customFetcher: typeof fetcher, + // swrOptions?: SwrOptions + swrOptions?: any +) { + const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => { + return { data: null } + } + + useWishlist.extend = extendHook + + return useWishlist +} + +export default extendHook(fetcher) diff --git a/package.json b/package.json index 68bf0059d..f42b2619b 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "react-fast-marquee": "^1.1.4", "react-merge-refs": "^1.1.0", "react-use-measure": "^2.0.4", + "stripe": "^8.176.0", "swell-js": "^4.0.0-next.0", "swr": "^0.5.6", "tabbable": "^5.2.0", diff --git a/yarn.lock b/yarn.lock index 35e9ca835..28e045710 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1170,6 +1170,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.18.tgz#1d3ca764718915584fcd9f6344621b7672665c67" integrity sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ== +"@types/node@>=8.1.0": + version "16.9.6" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.6.tgz#040a64d7faf9e5d9e940357125f0963012e66f04" + integrity sha512-YHUZhBOMTM3mjFkXVcK+WwAcYmyhe1wL4lfqNtzI0b3qAy7yuSetnM7QJazgE5PFmgVTNGiLOgRFfJMqW7XpSQ== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -5686,6 +5691,13 @@ qs@6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== +qs@^6.6.0: + version "6.10.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a" + integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg== + dependencies: + side-channel "^1.0.4" + querystring-es3@0.2.1, querystring-es3@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -6487,6 +6499,14 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +stripe@^8.176.0: + version "8.176.0" + resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.176.0.tgz#2f4980ab49acbfe6d67ecaddd54c05e20de9532c" + integrity sha512-0KCDo8TWFgeNWU7cPaqdjO2u2OSth0cmWYZmA7xsuxRCk7/lgWbJ/UbeSphx74cCIjFCmGuzDoNuNxqon9lEbg== + dependencies: + "@types/node" ">=8.1.0" + qs "^6.6.0" + styled-jsx@3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-3.3.2.tgz#2474601a26670a6049fb4d3f94bd91695b3ce018"