diff --git a/.env.template b/.env.template index 84659d9d9..94230d130 100644 --- a/.env.template +++ b/.env.template @@ -28,3 +28,4 @@ KIBO_API_TOKEN= KIBO_API_URL= KIBO_CART_COOKIE= KIBO_CUSTOMER_COOKIE= +KIBO_API_HOST= diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..810263023 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "attach", + "name": "Attach to Next JS App", + "skipFiles": ["/**"], + "port": 9229 + } + ] +} diff --git a/framework/kibocommerce/README.md b/framework/kibocommerce/README.md index a3bc1db32..845a3cbeb 100644 --- a/framework/kibocommerce/README.md +++ b/framework/kibocommerce/README.md @@ -1 +1 @@ -# Next.js Local Provider +# Next.js Kibo Provider diff --git a/framework/kibocommerce/api/endpoints/cart/get-cart.ts b/framework/kibocommerce/api/endpoints/cart/get-cart.ts new file mode 100644 index 000000000..306784ef9 --- /dev/null +++ b/framework/kibocommerce/api/endpoints/cart/get-cart.ts @@ -0,0 +1,23 @@ +import { normalizeCart } from '@framework/lib/normalize' +import { Cart } from '@framework/schema' +import type { CartEndpoint } from '.' +import { getCartQuery } from '../../queries/getCartQuery' + +const getCart: CartEndpoint['handlers']['getCart'] = async ({ + res, + body: { cartId }, + config, +}) => { + let currentCart: Cart = {} + try { + let result = await config.fetch(getCartQuery) + currentCart = result?.data?.currentCart + } catch (error) { + throw error + } + res.status(200).json({ + data: currentCart ? normalizeCart(currentCart) : null, + }) +} + +export default getCart diff --git a/framework/kibocommerce/api/endpoints/cart/index.ts b/framework/kibocommerce/api/endpoints/cart/index.ts index 491bf0ac9..45cf41c44 100644 --- a/framework/kibocommerce/api/endpoints/cart/index.ts +++ b/framework/kibocommerce/api/endpoints/cart/index.ts @@ -1 +1,26 @@ -export default function noopApi(...args: any[]): void {} +import { GetAPISchema, createEndpoint } from '@commerce/api' +import cartEndpoint from '@commerce/api/endpoints/cart' +// import type { CartSchema } from '../../../types/cart' +import type { KiboCommerceAPI } from '../..' +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/kibocommerce/api/index.ts b/framework/kibocommerce/api/index.ts index 3999d6b48..6ed9a8209 100644 --- a/framework/kibocommerce/api/index.ts +++ b/framework/kibocommerce/api/index.ts @@ -1,6 +1,6 @@ import type { CommerceAPI, CommerceAPIConfig } from '@commerce/api' import { getCommerceApi as commerceApi } from '@commerce/api' -import createFetcher from './utils/fetch-local' +import createFetchGraphqlApi from './utils/fetch-graphql-api' import getAllPages from './operations/get-all-pages' import getPage from './operations/get-page' @@ -9,15 +9,28 @@ import getCustomerWishlist from './operations/get-customer-wishlist' import getAllProductPaths from './operations/get-all-product-paths' import getAllProducts from './operations/get-all-products' import getProduct from './operations/get-product' +import createFetchStoreApi from './utils/fetch-store-api' +import type { RequestInit } from '@vercel/fetch' + +export interface KiboCommerceConfig extends CommerceAPIConfig { + apiHost?: string + clientId?: string + sharedSecret?: string + storeApiFetch(endpoint: string, options?: RequestInit): Promise +} -export interface KiboCommerceConfig extends CommerceAPIConfig {} const config: KiboCommerceConfig = { commerceUrl: process.env.KIBO_API_URL || '', apiToken: process.env.KIBO_API_TOKEN || '', cartCookie: process.env.KIBO_CART_COOKIE || '', customerCookie: process.env.KIBO_CUSTOMER_COOKIE || '', cartCookieMaxAge: 2592000, - fetch: createFetcher(() => getCommerceApi().getConfig()), + fetch: createFetchGraphqlApi(() => getCommerceApi().getConfig()), + // REST API + apiHost: process.env.KIBO_API_HOST || '', + clientId: process.env.KIBO_CLIENT_ID || '', + sharedSecret: process.env.KIBO_SHARED_SECRET || '', + storeApiFetch: createFetchStoreApi(() => getCommerceApi().getConfig()), } const operations = { diff --git a/framework/kibocommerce/api/queries/getAnonymousShopperTokenQuery.ts b/framework/kibocommerce/api/queries/getAnonymousShopperTokenQuery.ts new file mode 100644 index 000000000..1c205f67d --- /dev/null +++ b/framework/kibocommerce/api/queries/getAnonymousShopperTokenQuery.ts @@ -0,0 +1,7 @@ +export const getAnonymousShopperTokenQuery = /* GraphQL */ ` + query { + getAnonymousShopperToken { + accessToken + } + } +` \ No newline at end of file diff --git a/framework/kibocommerce/api/queries/getCartQuery.ts b/framework/kibocommerce/api/queries/getCartQuery.ts new file mode 100644 index 000000000..a33746d12 --- /dev/null +++ b/framework/kibocommerce/api/queries/getCartQuery.ts @@ -0,0 +1,54 @@ +export const getCartQuery = /* GraphQL */` +query cart { + currentCart { + id + userId + orderDiscounts { + impact + discount { + id + name + } + couponCode + } + subtotal + shippingTotal + total + items { + id + subtotal + unitPrice{ + extendedAmount + } + product { + productCode + variationProductCode + name + description + imageUrl + options { + attributeFQN + name + value + } + properties { + attributeFQN + name + values { + value + } + } + sku + price { + price + salePrice + } + categories { + id + } + } + quantity + } + } + } +` diff --git a/framework/kibocommerce/api/utils/fetch-graphql-api.ts b/framework/kibocommerce/api/utils/fetch-graphql-api.ts new file mode 100644 index 000000000..7da8c46ed --- /dev/null +++ b/framework/kibocommerce/api/utils/fetch-graphql-api.ts @@ -0,0 +1,43 @@ +import { FetcherError } from '@commerce/utils/errors' +import type { GraphQLFetcher } from '@commerce/api' +import type { KiboCommerceConfig } from '../index' +import fetch from './fetch' +import getAnonymousShopperToken from './get-anonymous-shopper-token' + +const fetchGraphqlApi: ( + getConfig: () => KiboCommerceConfig +) => GraphQLFetcher = (getConfig) => async ( + query: string, + { variables, preview } = {}, + fetchOptions +) => { + const config = getConfig() + const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), { + ...fetchOptions, + method: 'POST', + headers: { + Authorization: `Bearer ${config.apiToken}`, + ...fetchOptions?.headers, + 'Content-Type': 'application/json', + // Need to fetch access token from cookie + 'x-vol-user-claims': + 'z40ROeWoZYd65SxBHSqnq/j/SRP0tIBHAf/3Sxw2MJLS8lmj1sF9Y+8eWaTObnCbAtFkiNx/BPfojtUFYQj2P9aVPgHsR+IaTpeAdfG1AM0fMLFvIrDbHK6E/BKhupU5NJQAFwYsoImRzIh8jOpXrigBWH9OW/dBjOtuAJaDaDRHdZ3xyDKZQnFa24IZN6b/UZYHf4r6arUU3MjPoVibQdtBObtJPYwe3XtOI/xaInqpehTJPq9nTZlTWR8Tv59UelC4bVWIuGtSAdawmuSS7H8pb5PemmB9MwMeLkGaWZsaRdxMfdOJE8REGqOYr3j89iEj/0a6G1zraVbLzGXyW0hVkz6InxARzA4p96n2n+ZCwWI/olcQKTxJCLsoZ3dVVkWretgUJFMxzAbzDEDtUIda+VuhzhhmlY4SFgOjxtSIudlyAcYs4xwksjDhBtt8RrTyobCUUau1sfht9Zf1pw==', + }, + body: JSON.stringify({ + query, + variables, + }), + }) + + const json = await res.json() + if (json.errors) { + throw new FetcherError({ + errors: json.errors ?? [{ message: 'Failed to fetch KiboCommerce API' }], + status: res.status, + }) + } + + return { data: json.data, res } +} + +export default fetchGraphqlApi diff --git a/framework/kibocommerce/api/utils/fetch-store-api.ts b/framework/kibocommerce/api/utils/fetch-store-api.ts new file mode 100644 index 000000000..666871dd3 --- /dev/null +++ b/framework/kibocommerce/api/utils/fetch-store-api.ts @@ -0,0 +1,68 @@ +import type { RequestInit, Response } from '@vercel/fetch' +import type { KiboCommerceConfig } from '../index' +import fetch from './fetch' + +const fetchStoreApi = (getConfig: () => KiboCommerceConfig) => async ( + endpoint: string, + options?: RequestInit +): Promise => { + const config = getConfig() + let res: Response + try { + res = await fetch(config.apiHost + endpoint, { + ...options, + headers: { + ...options?.headers, + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.apiToken}`, + }, + }) + } catch (error) { + throw new Error(`Fetch to Kibocommerce failed: ${error.message}`) + } + + const contentType = res.headers.get('Content-Type') + const isJSON = contentType?.includes('application/json') + + if (!res.ok) { + const data = isJSON ? await res.json() : await getTextOrNull(res) + console.log('-----------anon-----------', data) + const headers = getRawHeaders(res) + const msg = `Kibo Commerce API error (${ + res.status + }) \nHeaders: ${JSON.stringify(headers, null, 2)}\n${ + typeof data === 'string' ? data : JSON.stringify(data, null, 2) + }` + + // throw new BigcommerceApiError(msg, res, data) + } + + // if (res.status !== 204 && !isJSON) { + // throw new BigcommerceApiError( + // `Fetch to Bigcommerce API failed, expected JSON content but found: ${contentType}`, + // res + // ) + // } + + // If something was removed, the response will be empty + return res.status === 200 && (await res.json()) +} +export default fetchStoreApi + +function getRawHeaders(res: Response) { + const headers: { [key: string]: string } = {} + + res.headers.forEach((value, key) => { + headers[key] = value + }) + + return headers +} + +function getTextOrNull(res: Response) { + try { + return res.text() + } catch (err) { + return null + } +} diff --git a/framework/kibocommerce/api/utils/get-anonymous-shopper-token.ts b/framework/kibocommerce/api/utils/get-anonymous-shopper-token.ts new file mode 100644 index 000000000..a016c8ed1 --- /dev/null +++ b/framework/kibocommerce/api/utils/get-anonymous-shopper-token.ts @@ -0,0 +1,13 @@ +import type { KiboCommerceConfig } from '../' +import { getAnonymousShopperTokenQuery } from '../queries/getAnonymousShopperTokenQuery' + +async function getAnonymousShopperToken({ + config, +}: { + config: KiboCommerceConfig +}): Promise { + const { data } = await config.fetch(getAnonymousShopperTokenQuery) + return String(data?.getAnonymousShopperToken?.accessToken) +} + +export default getAnonymousShopperToken diff --git a/framework/kibocommerce/cart/use-cart.tsx b/framework/kibocommerce/cart/use-cart.tsx index b3e509a21..0c565e094 100644 --- a/framework/kibocommerce/cart/use-cart.tsx +++ b/framework/kibocommerce/cart/use-cart.tsx @@ -6,37 +6,28 @@ export default useCart as UseCart export const handler: SWRHook = { fetchOptions: { - query: '', + method: 'GET', + url: '/api/cart', }, - async fetcher() { - return { - id: '', - createdAt: '', - currency: { code: '' }, - taxesIncluded: '', - lineItems: [], - lineItemsSubtotalPrice: '', - subtotalPrice: 0, - totalPrice: 0, - } + async fetcher({ options, fetch }) { + return await fetch({ ...options }) + }, + useHook: ({ useData }) => (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] + ) }, - useHook: - ({ useData }) => - (input) => { - return useMemo( - () => - Object.create( - {}, - { - isEmpty: { - get() { - return true - }, - enumerable: true, - }, - } - ), - [] - ) - }, } diff --git a/framework/kibocommerce/commerce.config.json b/framework/kibocommerce/commerce.config.json index 0871b8849..7b96ac1c2 100644 --- a/framework/kibocommerce/commerce.config.json +++ b/framework/kibocommerce/commerce.config.json @@ -1,9 +1,9 @@ { "provider": "kibocommerce", "features": { - "wishlist": false, - "cart": false, - "search": false, - "customerAuth": false + "wishlist": true, + "cart": true, + "search": true, + "customerAuth": true } } diff --git a/framework/kibocommerce/lib/get-slug.ts b/framework/kibocommerce/lib/get-slug.ts new file mode 100644 index 000000000..329c5a27e --- /dev/null +++ b/framework/kibocommerce/lib/get-slug.ts @@ -0,0 +1,5 @@ +// Remove trailing and leading slash, usually included in nodes +// returned by the BigCommerce API +const getSlug = (path: string) => path.replace(/^\/|\/$/g, '') + +export default getSlug diff --git a/framework/kibocommerce/lib/immutability.ts b/framework/kibocommerce/lib/immutability.ts new file mode 100644 index 000000000..488d3570f --- /dev/null +++ b/framework/kibocommerce/lib/immutability.ts @@ -0,0 +1,13 @@ +import update, { Context } from 'immutability-helper' + +const c = new Context() + +c.extend('$auto', function (value, object) { + return object ? c.update(object, value) : c.update({}, value) +}) + +c.extend('$autoArray', function (value, object) { + return object ? c.update(object, value) : c.update([], value) +}) + +export default c.update diff --git a/framework/kibocommerce/lib/normalize.ts b/framework/kibocommerce/lib/normalize.ts new file mode 100644 index 000000000..f372e8cec --- /dev/null +++ b/framework/kibocommerce/lib/normalize.ts @@ -0,0 +1,137 @@ +// import type { Product } from '../types/product' +// import type { Cart, BigcommerceCart, LineItem } from '../types/cart' +// import type { Page } from '../types/page' +// import type { BCCategory, Category } from '../types/site' +// import { definitions } from '../api/definitions/store-content' +import update from './immutability' +import getSlug from './get-slug' + +function normalizeProductOption(productOption: any) { + const { + node: { entityId, values: { edges = [] } = {}, ...rest }, + } = productOption + + return { + id: entityId, + values: edges?.map(({ node }: any) => node), + ...rest, + } +} + +export function normalizeProduct(productNode: any): any { + const { + entityId: id, + productOptions, + prices, + path, + id: _, + options: _0, + } = productNode + + return update(productNode, { + id: { $set: String(id) }, + images: { + $apply: ({ edges }: any) => + edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({ + url: urlOriginal, + alt: altText, + ...rest, + })), + }, + variants: { + $apply: ({ edges }: any) => + edges?.map(({ node: { entityId, productOptions, ...rest } }: any) => ({ + id: entityId, + options: productOptions?.edges + ? productOptions.edges.map(normalizeProductOption) + : [], + ...rest, + })), + }, + options: { + $set: productOptions.edges + ? productOptions?.edges.map(normalizeProductOption) + : [], + }, + brand: { + $apply: (brand: any) => (brand?.entityId ? brand?.entityId : null), + }, + slug: { + $set: path?.replace(/^\/+|\/+$/g, ''), + }, + price: { + $set: { + value: prices?.price.value, + currencyCode: prices?.price.currencyCode, + }, + }, + $unset: ['entityId'], + }) +} + +export function normalizePage(page: any): any { + return { + id: String(page.id), + name: page.name, + is_visible: page.is_visible, + sort_order: page.sort_order, + body: page.body, + } +} + +export function normalizeCart(data: any): any { + return { + id: data.id, + customerId: data.userId, + email: data?.email, + createdAt: data?.created_time, + currency: { + code: 'USD', + }, + taxesIncluded: true, + lineItems: data.items.map(normalizeLineItem), + lineItemsSubtotalPrice: data?.items.reduce( + (acc: number, obj: { subtotal: number }) => acc + obj.subtotal, + 0 + ), + subtotalPrice: data?.subtotal, + totalPrice: data?.total, + discounts: data.orderDiscounts?.map((discount: any) => ({ + value: discount.impact, + })), + } +} + +function normalizeLineItem(item: any): any { + return { + id: item.id, + variantId: item.product.variationProductCode, + productId: String(item.product.productCode), + name: item.product.name, + quantity: item.quantity, + variant: { + id: item.product.variationProductCode, + sku: item.product?.sku, + name: item.product.name, + image: { + url: item?.product.imageUrl, + }, + requiresShipping: item?.is_require_shipping, + price: item?.unitPrice.extendedAmount, + listPrice: 0, + }, + path: `${item.product.productCode}/na`, + discounts: item?.discounts?.map((discount: any) => ({ + value: discount.discounted_amount, + })), + } +} + +export function normalizeCategory(category: any): any { + return { + id: `${category.entityId}`, + name: category.name, + slug: getSlug(category.path), + path: category.path, + } +} diff --git a/framework/kibocommerce/provider.ts b/framework/kibocommerce/provider.ts index 2b68bf69e..e99f445ac 100644 --- a/framework/kibocommerce/provider.ts +++ b/framework/kibocommerce/provider.ts @@ -11,7 +11,7 @@ import { handler as useSignup } from './auth/use-signup' export const kiboCommerceProvider = { locale: 'en-us', - cartCookie: 'bc_cartId', + cartCookie: 'kibo_cart', fetcher, cart: { useCart, useAddItem, useUpdateItem, useRemoveItem }, customer: { useCustomer }, diff --git a/pages/cart.tsx b/pages/cart.tsx index 3279301da..730586f05 100644 --- a/pages/cart.tsx +++ b/pages/cart.tsx @@ -30,13 +30,13 @@ export default function Cart() { const { price: subTotal } = usePrice( data && { amount: Number(data.subtotalPrice), - currencyCode: data.currency.code, + currencyCode: data?.currency?.code, } ) const { price: total } = usePrice( data && { amount: Number(data.totalPrice), - currencyCode: data.currency.code, + currencyCode: data?.currency?.code, } ) @@ -83,7 +83,7 @@ export default function Cart() { ))}