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/api/index.ts b/framework/commerce/api/index.ts index 716c11ed5..d7ba37059 100644 --- a/framework/commerce/api/index.ts +++ b/framework/commerce/api/index.ts @@ -65,8 +65,8 @@ export type EndpointHandlers< [H in keyof E['handlers']]: APIHandler< C, EndpointHandlers, - E['handlers'][H]['data'], - E['handlers'][H]['body'], + NonNullable['data'], + NonNullable['body'], E['options'] > } 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/commerce/types/checkout.ts b/framework/commerce/types/checkout.ts index 58b895368..d75b63902 100644 --- a/framework/commerce/types/checkout.ts +++ b/framework/commerce/types/checkout.ts @@ -30,7 +30,7 @@ export type GetCheckoutHook = { } export type CheckoutHooks = { - submitCheckout: SubmitCheckoutHook + submitCheckout?: SubmitCheckoutHook getCheckout: GetCheckoutHook } diff --git a/framework/commerce/types/customer/address.ts b/framework/commerce/types/customer/address.ts index 5b6ca4b49..8dc6ffc0d 100644 --- a/framework/commerce/types/customer/address.ts +++ b/framework/commerce/types/customer/address.ts @@ -1,41 +1,46 @@ export interface Address { - id: string; - mask: string; + id: string + mask: string } export interface AddressFields { - type: string; - firstName: string; - lastName: string; - company: string; - streetNumber: string; - apartments: string; - zipCode: string; - city: string; - country: string; + type: string + firstName: string + lastName: string + company: string + streetNumber: string + apartments: string + zipCode: string + city: string + country: string } export type CustomerAddressTypes = { - address?: Address; - fields: AddressFields; + address?: Address + fields: AddressFields } -export type GetAddressesHook = { - data: T['address'] | null +export type GetAddressesHook< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { + data: T['address'][] | null input: {} fetcherInput: { cartId?: string } swrState: { isEmpty: boolean } } -export type AddItemHook = { - data: T['address'] - input?: T['fields'] - fetcherInput: T['fields'] - body: { item: T['fields'] } - actionInput: T['fields'] -} +export type AddItemHook = + { + data: T['address'] + input?: T['fields'] + fetcherInput: T['fields'] + body: { item: T['fields'] } + actionInput: T['fields'] + } -export type UpdateItemHook = { +export type UpdateItemHook< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { data: T['address'] | null input: { item?: T['fields']; wait?: number } fetcherInput: { itemId: string; item: T['fields'] } @@ -43,49 +48,62 @@ export type UpdateItemHook = { +export type RemoveItemHook< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { data: T['address'] | null - input: { item?: T['fields'] } + input: { item?: T['address'] } fetcherInput: { itemId: string } body: { itemId: string } actionInput: { id: string } } -export type CustomerAddressHooks = { +export type CustomerAddressHooks< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { getAddresses: GetAddressesHook addItem: AddItemHook updateItem: UpdateItemHook removeItem: RemoveItemHook } -export type AddresssHandler = GetAddressesHook & { +export type AddressHandler< + T extends CustomerAddressTypes = CustomerAddressTypes +> = GetAddressesHook & { body: { cartId?: string } } -export type AddItemHandler = AddItemHook & { +export type AddItemHandler< + T extends CustomerAddressTypes = CustomerAddressTypes +> = AddItemHook & { body: { cartId: string } } -export type UpdateItemHandler = - UpdateItemHook & { - data: T['address'] - body: { cartId: string } - } +export type UpdateItemHandler< + T extends CustomerAddressTypes = CustomerAddressTypes +> = UpdateItemHook & { + data: T['address'] + body: { cartId: string } +} -export type RemoveItemHandler = - RemoveItemHook & { - body: { cartId: string } - } +export type RemoveItemHandler< + T extends CustomerAddressTypes = CustomerAddressTypes +> = RemoveItemHook & { + body: { cartId: string } +} - -export type CustomerAddressHandlers = { +export type CustomerAddressHandlers< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { getAddresses: GetAddressesHook addItem: AddItemHandler updateItem: UpdateItemHandler removeItem: RemoveItemHandler } -export type CustomerAddressSchema = { +export type CustomerAddressSchema< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { endpoint: { options: {} handlers: CustomerAddressHandlers diff --git a/framework/commerce/types/customer/card.ts b/framework/commerce/types/customer/card.ts index a8731411f..e9b220dcc 100644 --- a/framework/commerce/types/customer/card.ts +++ b/framework/commerce/types/customer/card.ts @@ -1,30 +1,30 @@ export interface Card { - id: string; - mask: string; - provider: string; + id: string + mask: string + provider: string } export interface CardFields { - cardHolder: string; - cardNumber: string; - cardExpireDate: string; - cardCvc: string; - firstName: string; - lastName: string; - company: string; - streetNumber: string; - zipCode: string; - city: string; - country: string; + cardHolder: string + cardNumber: string + cardExpireDate: string + cardCvc: string + firstName: string + lastName: string + company: string + streetNumber: string + zipCode: string + city: string + country: string } export type CustomerCardTypes = { - card?: Card; - fields: CardFields; + card?: Card + fields: CardFields } export type GetCardsHook = { - data: T['card'] | null + data: T['card'][] | null input: {} fetcherInput: { cartId?: string } swrState: { isEmpty: boolean } @@ -48,26 +48,29 @@ export type UpdateItemHook = { export type RemoveItemHook = { data: T['card'] | null - input: { item?: T['fields'] } + input: { item?: T['card'] } fetcherInput: { itemId: string } body: { itemId: string } actionInput: { id: string } } -export type CustomerCardHooks = { - getCards: GetCardsHook - addItem: AddItemHook - updateItem: UpdateItemHook - removeItem: RemoveItemHook -} +export type CustomerCardHooks = + { + getCards: GetCardsHook + addItem: AddItemHook + updateItem: UpdateItemHook + removeItem: RemoveItemHook + } -export type CardsHandler = GetCardsHook & { - body: { cartId?: string } -} +export type CardsHandler = + GetCardsHook & { + body: { cartId?: string } + } -export type AddItemHandler = AddItemHook & { - body: { cartId: string } -} +export type AddItemHandler = + AddItemHook & { + body: { cartId: string } + } export type UpdateItemHandler = UpdateItemHook & { @@ -80,15 +83,18 @@ export type RemoveItemHandler = body: { cartId: string } } - -export type CustomerCardHandlers = { +export type CustomerCardHandlers< + T extends CustomerCardTypes = CustomerCardTypes +> = { getCards: GetCardsHook addItem: AddItemHandler updateItem: UpdateItemHandler removeItem: RemoveItemHandler } -export type CustomerCardSchema = { +export type CustomerCardSchema< + T extends CustomerCardTypes = CustomerCardTypes +> = { endpoint: { options: {} handlers: CustomerCardHandlers 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..ca1438eae --- /dev/null +++ b/framework/ordercloud/README.md @@ -0,0 +1,3 @@ +# Next.js Ordercloud Provider + +Create your own store from [here](https://nextjs.org/commerce) 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..28d372bd0 --- /dev/null +++ b/framework/ordercloud/api/endpoints/cart/add-item.ts @@ -0,0 +1,99 @@ +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: { restBuyerFetch, cartCookie, tokenCookie }, +}) => { + // Return an error if no item is present + if (!item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing item' }], + }) + } + + // Store token + let token + + // Set the quantity if not present + if (!item.quantity) item.quantity = 1 + + // Create an order if it doesn't exist + if (!cartId) { + const { ID, meta } = await restBuyerFetch( + 'POST', + `/orders/Outgoing`, + {} + ).then((response: { ID: string; meta: { token: string } }) => response) + + // Set the cart id and token + cartId = ID + token = meta.token + + // Set the cart and token cookie + res.setHeader('Set-Cookie', [ + serialize(tokenCookie, meta.token, { + maxAge: 60 * 60 * 24 * 30, + expires: new Date(Date.now() + 60 * 60 * 24 * 30 * 1000), + secure: process.env.NODE_ENV === 'production', + path: '/', + sameSite: 'lax', + }), + 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 restBuyerFetch( + 'GET', + `/me/products/${item.productId}/variants/${item.variantId}`, + null, + { token } + ).then((res: RawVariant) => res.Specs) + } + + // Add the item to the order + await restBuyerFetch( + 'POST', + `/orders/Outgoing/${cartId}/lineitems`, + { + ProductID: item.productId, + Quantity: item.quantity, + Specs: specs, + }, + { token } + ) + + // Get cart + const [cart, lineItems] = await Promise.all([ + restBuyerFetch('GET', `/orders/Outgoing/${cartId}`, null, { token }), + restBuyerFetch('GET', `/orders/Outgoing/${cartId}/lineitems`, null, { + token, + }).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..7ea077b54 --- /dev/null +++ b/framework/ordercloud/api/endpoints/cart/get-cart.ts @@ -0,0 +1,65 @@ +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 ({ + req, + res, + body: { cartId }, + config: { restBuyerFetch, cartCookie, tokenCookie }, +}) => { + if (!cartId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + try { + // Get token from cookies + const token = req.cookies[tokenCookie] + + // Get cart + const cart = await restBuyerFetch( + 'GET', + `/orders/Outgoing/${cartId}`, + null, + { token } + ) + + // Get line items + const lineItems = await restBuyerFetch( + 'GET', + `/orders/Outgoing/${cartId}/lineitems`, + null, + { token } + ).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 and token cookie + res.setHeader('Set-Cookie', [ + serialize(cartCookie, cartId, { + maxAge: -1, + path: '/', + }), + serialize(tokenCookie, 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..ea9c46e4c --- /dev/null +++ b/framework/ordercloud/api/endpoints/cart/remove-item.ts @@ -0,0 +1,45 @@ +import type { CartEndpoint } from '.' + +import { formatCart } from '../../utils/cart' +import { OrdercloudLineItem } from '../../../types/cart' + +const removeItem: CartEndpoint['handlers']['removeItem'] = async ({ + req, + res, + body: { cartId, itemId }, + config: { restBuyerFetch, tokenCookie }, +}) => { + if (!cartId || !itemId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + // Get token from cookies + const token = req.cookies[tokenCookie] + + // Remove the item to the order + await restBuyerFetch( + 'DELETE', + `/orders/Outgoing/${cartId}/lineitems/${itemId}`, + null, + { token } + ) + + // Get cart + const [cart, lineItems] = await Promise.all([ + restBuyerFetch('GET', `/orders/Outgoing/${cartId}`, null, { token }), + restBuyerFetch('GET', `/orders/Outgoing/${cartId}/lineitems`, null, { + token, + }).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..20113baee --- /dev/null +++ b/framework/ordercloud/api/endpoints/cart/update-item.ts @@ -0,0 +1,63 @@ +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 ({ + req, + res, + body: { cartId, itemId, item }, + config: { restBuyerFetch, tokenCookie }, +}) => { + if (!cartId || !itemId || !item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + // Get token from cookies + const token = req.cookies[tokenCookie] + + // Store specs + let specs: RawVariant['Specs'] = [] + + // If a variant is present, fetch its specs + if (item.variantId) { + specs = await restBuyerFetch( + 'GET', + `/me/products/${item.productId}/variants/${item.variantId}`, + null, + { token } + ).then((res: RawVariant) => res.Specs) + } + + // Add the item to the order + await restBuyerFetch( + 'PATCH', + `/orders/Outgoing/${cartId}/lineitems/${itemId}`, + { + ProductID: item.productId, + Quantity: item.quantity, + Specs: specs, + }, + { token } + ) + + // Get cart + const [cart, lineItems] = await Promise.all([ + restBuyerFetch('GET', `/orders/Outgoing/${cartId}`, null, { token }), + restBuyerFetch('GET', `/orders/Outgoing/${cartId}/lineitems`, null, { + token, + }).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..c0ab1a40d --- /dev/null +++ b/framework/ordercloud/api/endpoints/checkout/get-checkout.ts @@ -0,0 +1,47 @@ +import type { CheckoutEndpoint } from '.' + +const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({ + req, + res, + body: { cartId }, + config: { restBuyerFetch, tokenCookie }, +}) => { + // Return an error if no item is present + if (!cartId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing cookie' }], + }) + } + + // Get token from cookies + const token = req.cookies[tokenCookie] + + // Register credit card + const payments = await restBuyerFetch( + 'GET', + `/orders/Outgoing/${cartId}/payments`, + null, + { token } + ).then((response: { Items: unknown[] }) => response.Items) + + const address = await restBuyerFetch( + 'GET', + `/orders/Outgoing/${cartId}`, + null, + { token } + ).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..8cd9be5e4 --- /dev/null +++ b/framework/ordercloud/api/endpoints/checkout/submit-checkout.ts @@ -0,0 +1,32 @@ +import type { CheckoutEndpoint } from '.' + +const submitCheckout: CheckoutEndpoint['handlers']['submitCheckout'] = async ({ + req, + res, + body: { cartId }, + config: { restBuyerFetch, tokenCookie }, +}) => { + // Return an error if no item is present + if (!cartId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing item' }], + }) + } + + // Get token from cookies + const token = req.cookies[tokenCookie] + + // Submit order + await restBuyerFetch( + 'POST', + `/orders/Outgoing/${cartId}/submit`, + {}, + { token } + ) + + // 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..434c2400d --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/address/add-item.ts @@ -0,0 +1,47 @@ +import type { CustomerAddressEndpoint } from '.' + +const addItem: CustomerAddressEndpoint['handlers']['addItem'] = async ({ + res, + body: { item, cartId }, + config: { restBuyerFetch }, +}) => { + // 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 restBuyerFetch('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 restBuyerFetch('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..ad7dead7c --- /dev/null +++ b/framework/ordercloud/api/endpoints/customer/card/add-item.ts @@ -0,0 +1,74 @@ +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: { restBuyerFetch, restMiddlewareFetch }, +}) => { + // 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 restBuyerFetch('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 + const payment = await restBuyerFetch( + 'POST', + `/orders/All/${cartId}/payments`, + { + Type: 'CreditCard', + CreditCardID: creditCard, + } + ).then((response: { ID: string }) => response.ID) + + // Accept payment to order + await restMiddlewareFetch( + 'PATCH', + `/orders/All/${cartId}/payments/${payment}`, + { + Accepted: true, + } + ) + + 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..df62843ab --- /dev/null +++ b/framework/ordercloud/api/index.ts @@ -0,0 +1,71 @@ +import type { CommerceAPI, CommerceAPIConfig } from '@commerce/api' +import { getCommerceApi as commerceApi } from '@commerce/api' +import { createBuyerFetcher, createMiddlewareFetcher } 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, + TOKEN_COOKIE, +} from '../constants' + +export interface OrdercloudConfig extends CommerceAPIConfig { + restBuyerFetch: ( + method: string, + resource: string, + body?: Record, + fetchOptions?: Record + ) => Promise + restMiddlewareFetch: ( + method: string, + resource: string, + body?: Record, + fetchOptions?: Record + ) => Promise + apiVersion: string + tokenCookie: string +} + +const config: OrdercloudConfig = { + commerceUrl: API_URL, + apiToken: '', + apiVersion: API_VERSION, + cartCookie: CART_COOKIE, + customerCookie: CUSTOMER_COOKIE, + tokenCookie: TOKEN_COOKIE, + cartCookieMaxAge: 2592000, + restBuyerFetch: createBuyerFetcher(() => getCommerceApi().getConfig()), + restMiddlewareFetch: createMiddlewareFetcher(() => + 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..1ac23c033 --- /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 { restBuyerFetch } = commerce.getConfig(config) + + // Get all products + const rawProducts: RawProduct[] = await restBuyerFetch<{ + 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..6af24d945 --- /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 { restBuyerFetch } = commerce.getConfig(config) + + // Get all products + const rawProducts: RawProduct[] = await restBuyerFetch<{ + 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..864f931d4 --- /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 { restBuyerFetch } = commerce.getConfig(config) + + // Get a single product + const productPromise = restBuyerFetch( + 'GET', + `/me/products/${variables?.slug}` + ) + + // Get product specs + const specsPromise = restBuyerFetch<{ Items: RawSpec[] }>( + 'GET', + `/me/products/${variables?.slug}/specs` + ).then((res) => res.Items) + + // Get product variants + const variantsPromise = restBuyerFetch<{ 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..95188c58e --- /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 { restBuyerFetch } = commerce.getConfig(config) + + // Get list of categories + const rawCategories: RawCategory[] = await restBuyerFetch<{ + 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..fd686b958 --- /dev/null +++ b/framework/ordercloud/api/utils/fetch-rest.ts @@ -0,0 +1,176 @@ +import vercelFetch from '@vercel/fetch' +import { FetcherError } from '@commerce/utils/errors' + +import { OrdercloudConfig } from '../index' + +// Get an instance to vercel fetch +const fetch = vercelFetch() + +// Get token util +async function getToken({ + baseUrl, + clientId, + clientSecret, +}: { + baseUrl: string + clientId: string + clientSecret?: string +}): Promise { + // 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=${clientId}&client_secret=${clientSecret}&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: { access_token: string }) => response.access_token) +} + +export async function fetchData(opts: { + token: string + path: string + method: string + config: OrdercloudConfig + fetchOptions?: Record + body?: Record +}): Promise { + // Destructure opts + const { path, body, fetchOptions, config, token, method = 'GET' } = opts + + // Do the request with the correct headers + const dataResponse = await fetch( + `${config.commerceUrl}/${config.apiVersion}${path}`, + { + ...fetchOptions, + method, + headers: { + ...fetchOptions?.headers, + 'Content-Type': 'application/json', + accept: 'application/json, text/plain, */*', + authorization: `Bearer ${token}`, + }, + body: body ? JSON.stringify(body) : undefined, + } + ) + + // If something failed getting the data response + if (!dataResponse.ok) { + // Get the body of it + const error = await dataResponse.textConverted() + + // And return an error + throw new FetcherError({ + errors: [{ message: error || dataResponse.statusText }], + status: dataResponse.status, + }) + } + + 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 + } +} + +export const createMiddlewareFetcher: ( + 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 config = getConfig() + + // Get a token + const token = await getToken({ + baseUrl: config.commerceUrl, + clientId: process.env.ORDERCLOUD_MIDDLEWARE_CLIENT_ID as string, + clientSecret: process.env.ORDERCLOUD_MIDDLEWARE_CLIENT_SECRET, + }) + + // Return the data and specify the expected type + return fetchData({ + token, + fetchOptions, + method, + config, + path, + body, + }) + } + +export const createBuyerFetcher: ( + 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 config = getConfig() + + // If a token was passed, set it on global + if (fetchOptions?.token) { + global.token = fetchOptions.token + } + + // Get a token + if (!global.token) { + global.token = await getToken({ + baseUrl: config.commerceUrl, + clientId: process.env.ORDERCLOUD_BUYER_CLIENT_ID as string, + }) + } + + // Return the data and specify the expected type + const data = await fetchData({ + token: global.token as string, + fetchOptions, + config, + method, + path, + body, + }) + + return { + ...data, + meta: { token: global.token as string }, + } + } 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..d89b13f64 --- /dev/null +++ b/framework/ordercloud/constants.ts @@ -0,0 +1,6 @@ +export const CART_COOKIE = 'ordercloud.cart' +export const TOKEN_COOKIE = 'ordercloud.token' +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..cf3f22c46 --- /dev/null +++ b/framework/ordercloud/customer/address/use-add-item.tsx @@ -0,0 +1,38 @@ +import type { AddItemHook } from '@commerce/types/customer/address' +import type { MutationHook } from '@commerce/utils/types' + +import { useCallback } from 'react' +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 }) { + 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..e9ddc7001 --- /dev/null +++ b/framework/ordercloud/customer/address/use-addresses.tsx @@ -0,0 +1,35 @@ +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?.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..4e6282c99 --- /dev/null +++ b/framework/ordercloud/customer/address/use-remove-item.tsx @@ -0,0 +1,62 @@ +import type { + MutationHookContext, + HookFetcherContext, +} from '@commerce/utils/types' +import type { Address, RemoveItemHook } from '@commerce/types/customer/address' + +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 Address + ? (input?: RemoveItemActionInput) => Promise

+ : (input: RemoveItemActionInput) => Promise
+ +export type RemoveItemActionInput = T extends Address + ? 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([], 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..720a339d8 --- /dev/null +++ b/framework/ordercloud/customer/address/use-update-item.tsx @@ -0,0 +1,52 @@ +import type { + HookFetcherContext, + MutationHookContext, +} from '@commerce/utils/types' +import type { UpdateItemHook, Address } from '@commerce/types/customer/address' + +import { useCallback } from 'react' + +import { MutationHook } from '@commerce/utils/types' +import useUpdateItem, { + UseUpdateItem, +} from '@commerce/customer/address/use-update-item' + +import useAddresses from './use-addresses' + +export type UpdateItemActionInput = T extends Address + ? 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) { + return await fetch({ + ...options, + body: { itemId, item }, + }) + }, + useHook: ({ fetch }: MutationHookContext) => + function useHook() { + const { mutate } = useAddresses() + + return useCallback( + async function updateItem(input) { + const data = await fetch({ input }) + + await mutate([], false) + + return data + }, + [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..6c6a6d7fd --- /dev/null +++ b/framework/ordercloud/customer/card/use-add-item.tsx @@ -0,0 +1,38 @@ +import type { AddItemHook } from '@commerce/types/customer/card' +import type { MutationHook } from '@commerce/utils/types' + +import { useCallback } from 'react' +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 }) { + 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..92236deb2 --- /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?.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..dc0781b95 --- /dev/null +++ b/framework/ordercloud/customer/card/use-remove-item.tsx @@ -0,0 +1,62 @@ +import type { + MutationHookContext, + HookFetcherContext, +} from '@commerce/utils/types' +import type { Card, 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 Card + ? (input?: RemoveItemActionInput) => Promise + : (input: RemoveItemActionInput) => Promise + +export type RemoveItemActionInput = T extends Card + ? 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([], 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..0dfb218d5 --- /dev/null +++ b/framework/ordercloud/customer/card/use-update-item.tsx @@ -0,0 +1,52 @@ +import type { + HookFetcherContext, + MutationHookContext, +} from '@commerce/utils/types' +import type { UpdateItemHook, Card } from '@commerce/types/customer/card' + +import { useCallback } from 'react' + +import { MutationHook } from '@commerce/utils/types' +import useUpdateItem, { + UseUpdateItem, +} from '@commerce/customer/card/use-update-item' + +import useCards from './use-cards' + +export type UpdateItemActionInput = T extends Card + ? 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) { + return await fetch({ + ...options, + body: { itemId, item }, + }) + }, + useHook: ({ fetch }: MutationHookContext) => + function useHook() { + const { mutate } = useCards() + + return useCallback( + async function updateItem(input) { + const data = await fetch({ input }) + + await mutate([], false) + + return data + }, + [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"