From d8b42acfa2e67de2e2fe14242354f6acfe1f41bd Mon Sep 17 00:00:00 2001 From: Chris Vibert Date: Thu, 16 Dec 2021 19:06:52 +0000 Subject: [PATCH] Commerce.js Provider (#548) * commercejs: Initial commit with basic product list * ui: Handle no variants on product * commercejs: Support individual product pages * commercejs: Use separate sdkFetch function * commercejs: Show option hex colors based on option name * commercejs: Support product search and filter * commercejs: Enable carts feature * commercejs: Remove unused API endpoints * commercejs: Fix adding variants to cart * commercejs: Fix types for update cart hook * commercejs: Update README * commercejs: Add sorting to product search * commercejs: Add generic types to cart actions * commercejs: Better cart normalization * commercejs: Provide typing for sdkFetch function * commercejs: Refactor product search logic * commercejs: Update commercejs types package and export types from local directory * commercejs: Remove unused checkout hooks * commercejs: Enhance fetcher to allow custom API routes * commercejs: Fix product types * commercejs: Add checkout functionality * commercejs: Add commercejs to README list of providers * commercejs: Add login/logout auth hooks * commercejs: Adds comment to sdkFetch function * commercejs: Bring back empty useSignup hook to fix build * commercejs: Refactor useCheckout hook logic * commercejs: Add errors to fetcher function if using invalid resource/method * commercejs: Remove use of hex colors for color variants * ui: Fix undefined error when no variants * commercejs: Handle add to cart when no variants * commercejs: Enable customer auth feature * commercejs: Rename public key env variable as commercejs * commercejs: Remove duplicate customer fields * commercejs: Use variants API to generate product variants * commercejs: Fetch all products using sort order * commercejs: Fix use of normalizeProduct function * commercejs: Disable customer auth * commercejs: Show selected variant details in cart view * commercejs: Update to latest commercejs types * commercejs: Fix login email * commercejs: Remove unnecessary ts-ignore * api: Allow parameter to be passed to login API * api: Allow login handler to accept GET requests * commercejs: Add login API for login callback email link * commercejs: Remove unused argument to API * commercejs: Add hook to fetch logged in customer * commercejs: Rename token to match SDK name * commercejs: Enable logout * commercejs: Fix VERCEL_URL env variable * commercejs: Fix using vercel deployment url * commercejs: Add deployment url env vars to templates * Replace yarn with npm * commercejs: Allow checkout submit even without card/address details * ui: Add loading and cart refresh to checkout * commercejs: Leave link to issue on TODO comment * Update docs/README/env.template for commercejs provider * ui: Prevent toggle loading after component unmount * commercejs: Handle product without images * ui: Explicity set loading to false after checkout * Revert "api: Allow parameter to be passed to login API" This reverts commit c3713ec6e23f1b423a071a31221069995d419486. * commercejs: Handle login using API redirect * commercejs: Adds shipping and billing details to checkout data * commercejs: Fix types for fetcher and submit checkout * commercejs: Update README with demo url * commercejs: Update checkout hooks to use checkout context * commercejs: Update checkout logic to use customer fields * ui: Clear checkout fields context after checkout * commercejs: Remove unused clear checkout function * commercejs: Import constants directly --- .env.template | 5 +- README.md | 5 +- .../CheckoutSidebarView.tsx | 24 ++++-- .../product/ProductSidebar/ProductSidebar.tsx | 2 +- components/product/helpers.ts | 2 +- framework/commerce/api/endpoints/login.ts | 1 + framework/commerce/config.js | 3 +- framework/commerce/new-provider.md | 1 + framework/commercejs/.env.template | 7 ++ framework/commercejs/README.md | 13 ++++ .../commercejs/api/endpoints/cart/index.ts | 1 + .../commercejs/api/endpoints/catalog/index.ts | 1 + .../api/endpoints/catalog/products/index.ts | 1 + .../api/endpoints/checkout/get-checkout.ts | 1 + .../api/endpoints/checkout/index.ts | 23 ++++++ .../api/endpoints/checkout/submit-checkout.ts | 44 +++++++++++ .../api/endpoints/customer/address/index.ts | 1 + .../api/endpoints/customer/card/index.ts | 1 + .../api/endpoints/customer/index.ts | 1 + .../commercejs/api/endpoints/login/index.ts | 18 +++++ .../commercejs/api/endpoints/login/login.ts | 33 ++++++++ .../commercejs/api/endpoints/logout/index.ts | 1 + .../commercejs/api/endpoints/signup/index.ts | 1 + .../api/endpoints/wishlist/index.tsx | 1 + framework/commercejs/api/index.ts | 46 +++++++++++ .../api/operations/get-all-pages.ts | 21 +++++ .../api/operations/get-all-product-paths.ts | 35 +++++++++ .../api/operations/get-all-products.ts | 29 +++++++ .../commercejs/api/operations/get-page.ts | 15 ++++ .../commercejs/api/operations/get-product.ts | 44 +++++++++++ .../api/operations/get-site-info.ts | 36 +++++++++ framework/commercejs/api/operations/index.ts | 6 ++ .../commercejs/api/utils/graphql-fetch.ts | 14 ++++ framework/commercejs/api/utils/sdk-fetch.ts | 19 +++++ framework/commercejs/auth/index.ts | 3 + framework/commercejs/auth/use-login.tsx | 34 ++++++++ framework/commercejs/auth/use-logout.tsx | 27 +++++++ framework/commercejs/auth/use-signup.tsx | 17 ++++ framework/commercejs/cart/index.ts | 4 + framework/commercejs/cart/use-add-item.tsx | 45 +++++++++++ framework/commercejs/cart/use-cart.tsx | 41 ++++++++++ framework/commercejs/cart/use-remove-item.tsx | 36 +++++++++ framework/commercejs/cart/use-update-item.tsx | 76 ++++++++++++++++++ framework/commercejs/checkout/index.ts | 2 + .../commercejs/checkout/use-checkout.tsx | 52 +++++++++++++ .../checkout/use-submit-checkout.tsx | 38 +++++++++ framework/commercejs/commerce.config.json | 10 +++ framework/commercejs/constants.ts | 4 + .../commercejs/customer/address/index.ts | 2 + .../customer/address/use-add-item.tsx | 25 ++++++ .../customer/address/use-addresses.tsx | 34 ++++++++ framework/commercejs/customer/card/index.ts | 2 + .../commercejs/customer/card/use-add-item.tsx | 25 ++++++ .../commercejs/customer/card/use-cards.tsx | 31 ++++++++ framework/commercejs/customer/index.ts | 1 + .../commercejs/customer/use-customer.tsx | 44 +++++++++++ framework/commercejs/fetcher.ts | 61 +++++++++++++++ framework/commercejs/index.tsx | 9 +++ framework/commercejs/lib/commercejs.ts | 11 +++ framework/commercejs/next.config.js | 16 ++++ framework/commercejs/product/index.ts | 2 + framework/commercejs/product/use-price.tsx | 2 + framework/commercejs/product/use-search.tsx | 53 +++++++++++++ framework/commercejs/provider.ts | 55 +++++++++++++ framework/commercejs/types/cart.ts | 4 + framework/commercejs/types/checkout.ts | 3 + framework/commercejs/types/common.ts | 1 + framework/commercejs/types/customer.ts | 1 + framework/commercejs/types/index.ts | 25 ++++++ framework/commercejs/types/login.ts | 9 +++ framework/commercejs/types/logout.ts | 1 + framework/commercejs/types/page.ts | 1 + framework/commercejs/types/product.ts | 4 + framework/commercejs/types/signup.ts | 1 + framework/commercejs/types/site.ts | 3 + framework/commercejs/types/wishlist.ts | 1 + .../commercejs/utils/get-deployment-url.ts | 12 +++ framework/commercejs/utils/normalize-cart.ts | 74 ++++++++++++++++++ .../commercejs/utils/normalize-category.ts | 14 ++++ .../commercejs/utils/normalize-checkout.ts | 63 +++++++++++++++ .../commercejs/utils/normalize-product.ts | 77 ++++++++++++++++++ framework/commercejs/utils/product-search.ts | 54 +++++++++++++ .../commercejs/wishlist/use-add-item.tsx | 13 ++++ .../commercejs/wishlist/use-remove-item.tsx | 17 ++++ .../commercejs/wishlist/use-wishlist.tsx | 40 ++++++++++ package-lock.json | 78 +++++++++++++++++++ package.json | 2 + tsconfig.json | 3 +- 88 files changed, 1706 insertions(+), 13 deletions(-) create mode 100644 framework/commercejs/.env.template create mode 100644 framework/commercejs/README.md create mode 100644 framework/commercejs/api/endpoints/cart/index.ts create mode 100644 framework/commercejs/api/endpoints/catalog/index.ts create mode 100644 framework/commercejs/api/endpoints/catalog/products/index.ts create mode 100644 framework/commercejs/api/endpoints/checkout/get-checkout.ts create mode 100644 framework/commercejs/api/endpoints/checkout/index.ts create mode 100644 framework/commercejs/api/endpoints/checkout/submit-checkout.ts create mode 100644 framework/commercejs/api/endpoints/customer/address/index.ts create mode 100644 framework/commercejs/api/endpoints/customer/card/index.ts create mode 100644 framework/commercejs/api/endpoints/customer/index.ts create mode 100644 framework/commercejs/api/endpoints/login/index.ts create mode 100644 framework/commercejs/api/endpoints/login/login.ts create mode 100644 framework/commercejs/api/endpoints/logout/index.ts create mode 100644 framework/commercejs/api/endpoints/signup/index.ts create mode 100644 framework/commercejs/api/endpoints/wishlist/index.tsx create mode 100644 framework/commercejs/api/index.ts create mode 100644 framework/commercejs/api/operations/get-all-pages.ts create mode 100644 framework/commercejs/api/operations/get-all-product-paths.ts create mode 100644 framework/commercejs/api/operations/get-all-products.ts create mode 100644 framework/commercejs/api/operations/get-page.ts create mode 100644 framework/commercejs/api/operations/get-product.ts create mode 100644 framework/commercejs/api/operations/get-site-info.ts create mode 100644 framework/commercejs/api/operations/index.ts create mode 100644 framework/commercejs/api/utils/graphql-fetch.ts create mode 100644 framework/commercejs/api/utils/sdk-fetch.ts create mode 100644 framework/commercejs/auth/index.ts create mode 100644 framework/commercejs/auth/use-login.tsx create mode 100644 framework/commercejs/auth/use-logout.tsx create mode 100644 framework/commercejs/auth/use-signup.tsx create mode 100644 framework/commercejs/cart/index.ts create mode 100644 framework/commercejs/cart/use-add-item.tsx create mode 100644 framework/commercejs/cart/use-cart.tsx create mode 100644 framework/commercejs/cart/use-remove-item.tsx create mode 100644 framework/commercejs/cart/use-update-item.tsx create mode 100644 framework/commercejs/checkout/index.ts create mode 100644 framework/commercejs/checkout/use-checkout.tsx create mode 100644 framework/commercejs/checkout/use-submit-checkout.tsx create mode 100644 framework/commercejs/commerce.config.json create mode 100644 framework/commercejs/constants.ts create mode 100644 framework/commercejs/customer/address/index.ts create mode 100644 framework/commercejs/customer/address/use-add-item.tsx create mode 100644 framework/commercejs/customer/address/use-addresses.tsx create mode 100644 framework/commercejs/customer/card/index.ts create mode 100644 framework/commercejs/customer/card/use-add-item.tsx create mode 100644 framework/commercejs/customer/card/use-cards.tsx create mode 100644 framework/commercejs/customer/index.ts create mode 100644 framework/commercejs/customer/use-customer.tsx create mode 100644 framework/commercejs/fetcher.ts create mode 100644 framework/commercejs/index.tsx create mode 100644 framework/commercejs/lib/commercejs.ts create mode 100644 framework/commercejs/next.config.js create mode 100644 framework/commercejs/product/index.ts create mode 100644 framework/commercejs/product/use-price.tsx create mode 100644 framework/commercejs/product/use-search.tsx create mode 100644 framework/commercejs/provider.ts create mode 100644 framework/commercejs/types/cart.ts create mode 100644 framework/commercejs/types/checkout.ts create mode 100644 framework/commercejs/types/common.ts create mode 100644 framework/commercejs/types/customer.ts create mode 100644 framework/commercejs/types/index.ts create mode 100644 framework/commercejs/types/login.ts create mode 100644 framework/commercejs/types/logout.ts create mode 100644 framework/commercejs/types/page.ts create mode 100644 framework/commercejs/types/product.ts create mode 100644 framework/commercejs/types/signup.ts create mode 100644 framework/commercejs/types/site.ts create mode 100644 framework/commercejs/types/wishlist.ts create mode 100644 framework/commercejs/utils/get-deployment-url.ts create mode 100644 framework/commercejs/utils/normalize-cart.ts create mode 100644 framework/commercejs/utils/normalize-category.ts create mode 100644 framework/commercejs/utils/normalize-checkout.ts create mode 100644 framework/commercejs/utils/normalize-product.ts create mode 100644 framework/commercejs/utils/product-search.ts create mode 100644 framework/commercejs/wishlist/use-add-item.tsx create mode 100644 framework/commercejs/wishlist/use-remove-item.tsx create mode 100644 framework/commercejs/wishlist/use-wishlist.tsx diff --git a/.env.template b/.env.template index d5f59f0e8..42de11f29 100644 --- a/.env.template +++ b/.env.template @@ -1,4 +1,4 @@ -# Available providers: local, bigcommerce, shopify, swell, saleor, spree +# Available providers: local, bigcommerce, shopify, swell, saleor, spree, commercejs COMMERCE_PROVIDER= BIGCOMMERCE_STOREFRONT_API_URL= @@ -34,3 +34,6 @@ KIBO_SHARED_SECRET= KIBO_CART_COOKIE= KIBO_CUSTOMER_COOKIE= KIBO_API_HOST= + +NEXT_PUBLIC_COMMERCEJS_PUBLIC_KEY= +NEXT_PUBLIC_COMMERCEJS_DEPLOYMENT_URL= diff --git a/README.md b/README.md index bf53d57e2..b851b7e9d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/) - Saleor Demo: https://saleor.vercel.store/ - Ordercloud Demo: https://ordercloud.vercel.store/ - Spree Demo: https://spree.vercel.store/ +- Commerce.js Demo: https://commercejs.vercel.store/ ## Features @@ -29,7 +30,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/) ## Integrations -Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor, Vendure and Spree. We plan to support all major ecommerce backends. +Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor, Vendure, Spree and Commerce.js. We plan to support all major ecommerce backends. ## Considerations @@ -155,4 +156,4 @@ After Email confirmation, Checkout should be manually enabled through BigCommerc

BigCommerce team has been notified and they plan to add more details about this subject. - \ No newline at end of file + diff --git a/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx b/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx index 91c6efc53..7bd88fedc 100644 --- a/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx +++ b/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx @@ -1,5 +1,5 @@ import Link from 'next/link' -import { FC } from 'react' +import { FC, useState } from 'react' import CartItem from '@components/cart/CartItem' import { Button, Text } from '@components/ui' import { useUI } from '@components/ui/context' @@ -10,18 +10,29 @@ import useCheckout from '@framework/checkout/use-checkout' import ShippingWidget from '../ShippingWidget' import PaymentWidget from '../PaymentWidget' import s from './CheckoutSidebarView.module.css' +import { useCheckoutContext } from '../context' const CheckoutSidebarView: FC = () => { + const [loadingSubmit, setLoadingSubmit] = useState(false) const { setSidebarView, closeSidebar } = useUI() - const { data: cartData } = useCart() + const { data: cartData, revalidate: refreshCart } = useCart() const { data: checkoutData, submit: onCheckout } = useCheckout() + const { clearCheckoutFields } = useCheckoutContext() async function handleSubmit(event: React.ChangeEvent) { - event.preventDefault() + try { + setLoadingSubmit(true) + event.preventDefault() - await onCheckout() - - closeSidebar() + await onCheckout() + clearCheckoutFields() + setLoadingSubmit(false) + refreshCart() + closeSidebar() + } catch { + // TODO - handle error UI here. + setLoadingSubmit(false) + } } const { price: subTotal } = usePrice( @@ -98,6 +109,7 @@ const CheckoutSidebarView: FC = () => { type="submit" width="100%" disabled={!checkoutData?.hasPayment || !checkoutData?.hasShipping} + loading={loadingSubmit} > Confirm Purchase diff --git a/components/product/ProductSidebar/ProductSidebar.tsx b/components/product/ProductSidebar/ProductSidebar.tsx index fd1ef1e0a..250aa41bc 100644 --- a/components/product/ProductSidebar/ProductSidebar.tsx +++ b/components/product/ProductSidebar/ProductSidebar.tsx @@ -31,7 +31,7 @@ const ProductSidebar: FC = ({ product, className }) => { try { await addItem({ productId: String(product.id), - variantId: String(variant ? variant.id : product.variants[0].id), + variantId: String(variant ? variant.id : product.variants[0]?.id), }) openSidebar() setLoading(false) diff --git a/components/product/helpers.ts b/components/product/helpers.ts index d3fbd5ef5..77e385bb8 100644 --- a/components/product/helpers.ts +++ b/components/product/helpers.ts @@ -23,7 +23,7 @@ export function selectDefaultOptionFromProduct( updater: Dispatch> ) { // Selects the default option - product.variants[0].options?.forEach((v) => { + product.variants[0]?.options?.forEach((v) => { updater((choices) => ({ ...choices, [v.displayName.toLowerCase()]: v.values[0].label.toLowerCase(), diff --git a/framework/commerce/api/endpoints/login.ts b/framework/commerce/api/endpoints/login.ts index bc071b751..6f69629b1 100644 --- a/framework/commerce/api/endpoints/login.ts +++ b/framework/commerce/api/endpoints/login.ts @@ -12,6 +12,7 @@ const loginEndpoint: GetAPISchema< if ( !isAllowedOperation(req, res, { POST: handlers['login'], + GET: handlers['login'], }) ) { return diff --git a/framework/commerce/config.js b/framework/commerce/config.js index 67311b912..1a48dc456 100644 --- a/framework/commerce/config.js +++ b/framework/commerce/config.js @@ -16,7 +16,8 @@ const PROVIDERS = [ 'vendure', 'ordercloud', 'kibocommerce', - 'spree' + 'spree', + 'commercejs', ] function getProviderName() { diff --git a/framework/commerce/new-provider.md b/framework/commerce/new-provider.md index ce4464657..63c945db6 100644 --- a/framework/commerce/new-provider.md +++ b/framework/commerce/new-provider.md @@ -7,6 +7,7 @@ A commerce provider is a headless e-commerce platform that integrates with the [ - BigCommerce ([framework/bigcommerce](../bigcommerce)) - Saleor ([framework/saleor](../saleor)) - Shopify ([framework/shopify](../shopify)) +- Commerce.js ([framework/commercejs](../commercejs)) Adding a commerce provider means adding a new folder in `framework` with a folder structure like the next one: diff --git a/framework/commercejs/.env.template b/framework/commercejs/.env.template new file mode 100644 index 000000000..daeb86c06 --- /dev/null +++ b/framework/commercejs/.env.template @@ -0,0 +1,7 @@ +COMMERCE_PROVIDER=commercejs + +# Public key for your Commerce.js account +NEXT_PUBLIC_COMMERCEJS_PUBLIC_KEY= + +# The URL for the current deployment, optional but should be used for production deployments +NEXT_PUBLIC_COMMERCEJS_DEPLOYMENT_URL= diff --git a/framework/commercejs/README.md b/framework/commercejs/README.md new file mode 100644 index 000000000..b0333c96a --- /dev/null +++ b/framework/commercejs/README.md @@ -0,0 +1,13 @@ +# [Commerce.js](https://commercejs.com/) Provider + +**Demo:** https://commercejs.vercel.store/ + +To use this provider you must have a [Commerce.js account](https://commercejs.com/) and you should add some products in the Commerce.js dashboard. + +Next, copy the `.env.template` file in this directory to `.env.local` in the main directory (which will be ignored by Git): + +```bash +cp framework/commercejs/.env.template .env.local +``` + +Then, set the environment variables in `.env.local` to match the ones from your store. You'll need your Commerce.js public API key, which can be found in your Commerce.js dashboard in the `Developer -> API keys` section. diff --git a/framework/commercejs/api/endpoints/cart/index.ts b/framework/commercejs/api/endpoints/cart/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/commercejs/api/endpoints/cart/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/commercejs/api/endpoints/catalog/index.ts b/framework/commercejs/api/endpoints/catalog/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/commercejs/api/endpoints/catalog/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/commercejs/api/endpoints/catalog/products/index.ts b/framework/commercejs/api/endpoints/catalog/products/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/commercejs/api/endpoints/catalog/products/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/commercejs/api/endpoints/checkout/get-checkout.ts b/framework/commercejs/api/endpoints/checkout/get-checkout.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/commercejs/api/endpoints/checkout/get-checkout.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/commercejs/api/endpoints/checkout/index.ts b/framework/commercejs/api/endpoints/checkout/index.ts new file mode 100644 index 000000000..3fc7332e2 --- /dev/null +++ b/framework/commercejs/api/endpoints/checkout/index.ts @@ -0,0 +1,23 @@ +import { GetAPISchema, createEndpoint } from '@commerce/api' +import checkoutEndpoint from '@commerce/api/endpoints/checkout' +import type { CheckoutSchema } from '../../../types/checkout' +import type { CommercejsAPI } from '../..' + +import submitCheckout from './submit-checkout' +import getCheckout from './get-checkout' + +export type CheckoutAPI = GetAPISchema + +export type CheckoutEndpoint = CheckoutAPI['endpoint'] + +export const handlers: CheckoutEndpoint['handlers'] = { + submitCheckout, + getCheckout, +} + +const checkoutApi = createEndpoint({ + handler: checkoutEndpoint, + handlers, +}) + +export default checkoutApi diff --git a/framework/commercejs/api/endpoints/checkout/submit-checkout.ts b/framework/commercejs/api/endpoints/checkout/submit-checkout.ts new file mode 100644 index 000000000..a06f349d7 --- /dev/null +++ b/framework/commercejs/api/endpoints/checkout/submit-checkout.ts @@ -0,0 +1,44 @@ +import type { CardFields } from '@commerce/types/customer/card' +import type { AddressFields } from '@commerce/types/customer/address' +import type { CheckoutEndpoint } from '.' +import sdkFetcherFunction from '../../utils/sdk-fetch' +import { normalizeTestCheckout } from '../../../utils/normalize-checkout' + +const submitCheckout: CheckoutEndpoint['handlers']['submitCheckout'] = async ({ + res, + body: { item, cartId }, + config: { sdkFetch }, +}) => { + const sdkFetcher: typeof sdkFetcherFunction = sdkFetch + + // Generate a checkout token + const { id: checkoutToken } = await sdkFetcher( + 'checkout', + 'generateTokenFrom', + 'cart', + cartId + ) + + const shippingMethods = await sdkFetcher( + 'checkout', + 'getShippingOptions', + checkoutToken, + { + country: 'US', + } + ) + + const shippingMethodToUse = shippingMethods?.[0]?.id || '' + const checkoutData = normalizeTestCheckout({ + paymentInfo: item?.card as CardFields, + shippingInfo: item?.address as AddressFields, + shippingOption: shippingMethodToUse, + }) + + // Capture the order + await sdkFetcher('checkout', 'capture', checkoutToken, checkoutData) + + res.status(200).json({ data: null, errors: [] }) +} + +export default submitCheckout diff --git a/framework/commercejs/api/endpoints/customer/address/index.ts b/framework/commercejs/api/endpoints/customer/address/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/commercejs/api/endpoints/customer/address/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/commercejs/api/endpoints/customer/card/index.ts b/framework/commercejs/api/endpoints/customer/card/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/commercejs/api/endpoints/customer/card/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/commercejs/api/endpoints/customer/index.ts b/framework/commercejs/api/endpoints/customer/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/commercejs/api/endpoints/customer/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/commercejs/api/endpoints/login/index.ts b/framework/commercejs/api/endpoints/login/index.ts new file mode 100644 index 000000000..d9eb5abce --- /dev/null +++ b/framework/commercejs/api/endpoints/login/index.ts @@ -0,0 +1,18 @@ +import { GetAPISchema, createEndpoint } from '@commerce/api' +import loginEndpoint from '@commerce/api/endpoints/login' +import type { LoginSchema } from '../../../types/login' +import type { CommercejsAPI } from '../..' +import login from './login' + +export type LoginAPI = GetAPISchema + +export type LoginEndpoint = LoginAPI['endpoint'] + +export const handlers: LoginEndpoint['handlers'] = { login } + +const loginApi = createEndpoint({ + handler: loginEndpoint, + handlers, +}) + +export default loginApi diff --git a/framework/commercejs/api/endpoints/login/login.ts b/framework/commercejs/api/endpoints/login/login.ts new file mode 100644 index 000000000..b9088ad22 --- /dev/null +++ b/framework/commercejs/api/endpoints/login/login.ts @@ -0,0 +1,33 @@ +import { serialize } from 'cookie' +import sdkFetcherFunction from '../../utils/sdk-fetch' +import { getDeploymentUrl } from '../../../utils/get-deployment-url' +import type { LoginEndpoint } from '.' + +const login: LoginEndpoint['handlers']['login'] = async ({ + req, + res, + config: { sdkFetch, customerCookie }, +}) => { + const sdkFetcher: typeof sdkFetcherFunction = sdkFetch + const redirectUrl = getDeploymentUrl() + try { + const loginToken = req.query?.token as string + if (!loginToken) { + res.redirect(redirectUrl) + } + const { jwt } = await sdkFetcher('customer', 'getToken', loginToken, false) + res.setHeader( + 'Set-Cookie', + serialize(customerCookie, jwt, { + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24, + path: '/', + }) + ) + res.redirect(redirectUrl) + } catch { + res.redirect(redirectUrl) + } +} + +export default login diff --git a/framework/commercejs/api/endpoints/logout/index.ts b/framework/commercejs/api/endpoints/logout/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/commercejs/api/endpoints/logout/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/commercejs/api/endpoints/signup/index.ts b/framework/commercejs/api/endpoints/signup/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/commercejs/api/endpoints/signup/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/commercejs/api/endpoints/wishlist/index.tsx b/framework/commercejs/api/endpoints/wishlist/index.tsx new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/commercejs/api/endpoints/wishlist/index.tsx @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/commercejs/api/index.ts b/framework/commercejs/api/index.ts new file mode 100644 index 000000000..faccd5a01 --- /dev/null +++ b/framework/commercejs/api/index.ts @@ -0,0 +1,46 @@ +import type { CommerceAPI, CommerceAPIConfig } from '@commerce/api' +import { getCommerceApi as commerceApi } from '@commerce/api' + +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 sdkFetch from './utils/sdk-fetch' +import createGraphqlFetcher from './utils/graphql-fetch' +import { API_URL, CART_COOKIE, CUSTOMER_COOKIE } from '../constants' + +export interface CommercejsConfig extends CommerceAPIConfig { + sdkFetch: typeof sdkFetch +} + +const config: CommercejsConfig = { + commerceUrl: API_URL, + cartCookie: CART_COOKIE, + cartCookieMaxAge: 2592000, + customerCookie: CUSTOMER_COOKIE, + apiToken: '', + fetch: createGraphqlFetcher(() => getCommerceApi().getConfig()), + sdkFetch, +} + +const operations = { + getAllPages, + getPage, + getSiteInfo, + getAllProductPaths, + getAllProducts, + getProduct, +} + +export const provider = { config, operations } + +export type Provider = typeof provider +export type CommercejsAPI

= CommerceAPI

+ +export function getCommerceApi

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

{ + return commerceApi(customProvider as any) +} diff --git a/framework/commercejs/api/operations/get-all-pages.ts b/framework/commercejs/api/operations/get-all-pages.ts new file mode 100644 index 000000000..c8c9e41b2 --- /dev/null +++ b/framework/commercejs/api/operations/get-all-pages.ts @@ -0,0 +1,21 @@ +import type { CommercejsConfig } from '..' +import { GetAllPagesOperation } from '../../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/commercejs/api/operations/get-all-product-paths.ts b/framework/commercejs/api/operations/get-all-product-paths.ts new file mode 100644 index 000000000..570d43b13 --- /dev/null +++ b/framework/commercejs/api/operations/get-all-product-paths.ts @@ -0,0 +1,35 @@ +import type { OperationContext } from '@commerce/api/operations' +import type { + GetAllProductPathsOperation, + CommercejsProduct, +} from '../../types/product' + +import type { CommercejsConfig, Provider } from '..' + +export type GetAllProductPathsResult = { + products: Array<{ path: string }> +} + +export default function getAllProductPathsOperation({ + commerce, +}: OperationContext) { + async function getAllProductPaths({ + config, + }: { + config?: Partial + } = {}): Promise { + const { sdkFetch } = commerce.getConfig(config) + const { data } = await sdkFetch('products', 'list') + + // Match a path for every product retrieved + const productPaths = data.map(({ permalink }: CommercejsProduct) => ({ + path: `/${permalink}`, + })) + + return { + products: productPaths, + } + } + + return getAllProductPaths +} diff --git a/framework/commercejs/api/operations/get-all-products.ts b/framework/commercejs/api/operations/get-all-products.ts new file mode 100644 index 000000000..14e49d2d3 --- /dev/null +++ b/framework/commercejs/api/operations/get-all-products.ts @@ -0,0 +1,29 @@ +import type { OperationContext } from '@commerce/api/operations' +import type { GetAllProductsOperation } from '../../types/product' +import type { CommercejsConfig, Provider } from '../index' + +import { normalizeProduct } from '../../utils/normalize-product' + +export default function getAllProductsOperation({ + commerce, +}: OperationContext) { + async function getAllProducts({ + config, + }: { + config?: Partial + } = {}): Promise { + const { sdkFetch } = commerce.getConfig(config) + const { data } = await sdkFetch('products', 'list', { + sortBy: 'sort_order', + }) + + const productsFormatted = + data?.map((product) => normalizeProduct(product)) || [] + + return { + products: productsFormatted, + } + } + + return getAllProducts +} diff --git a/framework/commercejs/api/operations/get-page.ts b/framework/commercejs/api/operations/get-page.ts new file mode 100644 index 000000000..f4b69c90d --- /dev/null +++ b/framework/commercejs/api/operations/get-page.ts @@ -0,0 +1,15 @@ +import { GetPageOperation } from '../../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/commercejs/api/operations/get-product.ts b/framework/commercejs/api/operations/get-product.ts new file mode 100644 index 000000000..f71aab278 --- /dev/null +++ b/framework/commercejs/api/operations/get-product.ts @@ -0,0 +1,44 @@ +import type { OperationContext } from '@commerce/api/operations' +import type { GetProductOperation } from '../../types/product' +import type { CommercejsConfig, Provider } from '../index' +import { normalizeProduct } from '../../utils/normalize-product' + +export default function getProductOperation({ + commerce, +}: OperationContext) { + async function getProduct({ + config, + variables, + }: { + query?: string + variables?: T['variables'] + config?: Partial + preview?: boolean + } = {}): Promise { + const { sdkFetch } = commerce.getConfig(config) + + // Fetch a product by its permalink. + const product = await sdkFetch( + 'products', + 'retrieve', + variables?.slug || '', + { + type: 'permalink', + } + ) + + const { data: variants } = await sdkFetch( + 'products', + 'getVariants', + product.id + ) + + const productFormatted = normalizeProduct(product, variants) + + return { + product: productFormatted, + } + } + + return getProduct +} diff --git a/framework/commercejs/api/operations/get-site-info.ts b/framework/commercejs/api/operations/get-site-info.ts new file mode 100644 index 000000000..922fd7e76 --- /dev/null +++ b/framework/commercejs/api/operations/get-site-info.ts @@ -0,0 +1,36 @@ +import type { OperationContext } from '@commerce/api/operations' +import type { Category, GetSiteInfoOperation } from '../../types/site' +import { normalizeCategory } from '../../utils/normalize-category' +import type { CommercejsConfig, 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 { + const { sdkFetch } = commerce.getConfig(config) + const { data: categories } = await sdkFetch('categories', 'list') + + const formattedCategories = categories.map(normalizeCategory) + + return { + categories: formattedCategories, + brands: [], + } + } + + return getSiteInfo +} diff --git a/framework/commercejs/api/operations/index.ts b/framework/commercejs/api/operations/index.ts new file mode 100644 index 000000000..84b04a978 --- /dev/null +++ b/framework/commercejs/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/commercejs/api/utils/graphql-fetch.ts b/framework/commercejs/api/utils/graphql-fetch.ts new file mode 100644 index 000000000..805177405 --- /dev/null +++ b/framework/commercejs/api/utils/graphql-fetch.ts @@ -0,0 +1,14 @@ +import type { GraphQLFetcher } from '@commerce/api' +import type { CommercejsConfig } from '../' + +import { FetcherError } from '@commerce/utils/errors' + +const fetchGraphqlApi: (getConfig: () => CommercejsConfig) => GraphQLFetcher = + () => async () => { + throw new FetcherError({ + errors: [{ message: 'GraphQL fetch is not implemented' }], + status: 500, + }) + } + +export default fetchGraphqlApi diff --git a/framework/commercejs/api/utils/sdk-fetch.ts b/framework/commercejs/api/utils/sdk-fetch.ts new file mode 100644 index 000000000..c0123ac34 --- /dev/null +++ b/framework/commercejs/api/utils/sdk-fetch.ts @@ -0,0 +1,19 @@ +import { commerce } from '../../lib/commercejs' +import Commerce from '@chec/commerce.js' + +type MethodKeys = { + [K in keyof T]: T[K] extends (...args: any) => infer R ? K : never +}[keyof T] + +// Calls the relevant Commerce.js SDK method based on resource and method arguments. +export default async function sdkFetch< + Resource extends keyof Commerce, + Method extends MethodKeys +>( + resource: Resource, + method: Method, + ...variables: Parameters +): Promise> { + const data = await commerce[resource][method](...variables) + return data +} diff --git a/framework/commercejs/auth/index.ts b/framework/commercejs/auth/index.ts new file mode 100644 index 000000000..36e757a89 --- /dev/null +++ b/framework/commercejs/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/commercejs/auth/use-login.tsx b/framework/commercejs/auth/use-login.tsx new file mode 100644 index 000000000..7bc9fd534 --- /dev/null +++ b/framework/commercejs/auth/use-login.tsx @@ -0,0 +1,34 @@ +import { useCallback } from 'react' +import { MutationHook } from '@commerce/utils/types' +import useLogin, { UseLogin } from '@commerce/auth/use-login' +import type { LoginHook } from '@commerce/types/login' +import { getDeploymentUrl } from '../utils/get-deployment-url' + +export default useLogin as UseLogin + +const getLoginCallbackUrl = () => { + const baseUrl = getDeploymentUrl() + const API_ROUTE_PATH = 'api/login' + return `${baseUrl}/${API_ROUTE_PATH}` +} + +export const handler: MutationHook = { + fetchOptions: { + query: 'customer', + method: 'login', + }, + async fetcher({ input, options: { query, method }, fetch }) { + await fetch({ + query, + method, + variables: [input.email, getLoginCallbackUrl()], + }) + return null + }, + useHook: ({ fetch }) => + function useHook() { + return useCallback(async function login(input) { + return fetch({ input }) + }, []) + }, +} diff --git a/framework/commercejs/auth/use-logout.tsx b/framework/commercejs/auth/use-logout.tsx new file mode 100644 index 000000000..6b841637f --- /dev/null +++ b/framework/commercejs/auth/use-logout.tsx @@ -0,0 +1,27 @@ +import { useCallback } from 'react' +import Cookies from 'js-cookie' +import { MutationHook } from '@commerce/utils/types' +import useLogout, { UseLogout } from '@commerce/auth/use-logout' +import type { LogoutHook } from '@commerce/types/logout' +import useCustomer from '../customer/use-customer' +import { CUSTOMER_COOKIE } from '../constants' + +export default useLogout as UseLogout + +export const handler: MutationHook = { + fetchOptions: { + query: '_', + method: '_', + }, + useHook: () => () => { + const { mutate } = useCustomer() + return useCallback( + async function logout() { + Cookies.remove(CUSTOMER_COOKIE) + await mutate(null, false) + return null + }, + [mutate] + ) + }, +} diff --git a/framework/commercejs/auth/use-signup.tsx b/framework/commercejs/auth/use-signup.tsx new file mode 100644 index 000000000..07fabce0f --- /dev/null +++ b/framework/commercejs/auth/use-signup.tsx @@ -0,0 +1,17 @@ +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/commercejs/cart/index.ts b/framework/commercejs/cart/index.ts new file mode 100644 index 000000000..3b8ba990e --- /dev/null +++ b/framework/commercejs/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/commercejs/cart/use-add-item.tsx b/framework/commercejs/cart/use-add-item.tsx new file mode 100644 index 000000000..24dd057b6 --- /dev/null +++ b/framework/commercejs/cart/use-add-item.tsx @@ -0,0 +1,45 @@ +import type { AddItemHook } from '@commerce/types/cart' +import type { MutationHook } from '@commerce/utils/types' +import { useCallback } from 'react' +import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item' +import type { CommercejsCart } from '../types/cart' +import { normalizeCart } from '../utils/normalize-cart' +import useCart from './use-cart' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + query: 'cart', + method: 'add', + }, + async fetcher({ input: item, options, fetch }) { + // Frontend stringifies variantId even if undefined. + const hasVariant = !item.variantId || item.variantId !== 'undefined' + + const variables = [item.productId, item?.quantity || 1] + if (hasVariant) { + variables.push(item.variantId) + } + + const { cart } = await fetch<{ cart: CommercejsCart }>({ + query: options.query, + method: options.method, + variables, + }) + return normalizeCart(cart) + }, + useHook: ({ fetch }) => + function useHook() { + const { mutate } = useCart() + + return useCallback( + async function addItem(input) { + const cart = await fetch({ input }) + await mutate(cart, false) + return cart + }, + [mutate] + ) + }, +} diff --git a/framework/commercejs/cart/use-cart.tsx b/framework/commercejs/cart/use-cart.tsx new file mode 100644 index 000000000..beb807362 --- /dev/null +++ b/framework/commercejs/cart/use-cart.tsx @@ -0,0 +1,41 @@ +import { useMemo } from 'react' +import type { GetCartHook } from '@commerce/types/cart' +import { SWRHook } from '@commerce/utils/types' +import useCart, { UseCart } from '@commerce/cart/use-cart' +import type { CommercejsCart } from '../types/cart' +import { normalizeCart } from '../utils/normalize-cart' + +export default useCart as UseCart + +export const handler: SWRHook = { + fetchOptions: { + query: 'cart', + method: 'retrieve', + }, + async fetcher({ options, fetch }) { + const cart = await fetch({ + query: options.query, + method: options.method, + }) + return normalizeCart(cart) + }, + 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/commercejs/cart/use-remove-item.tsx b/framework/commercejs/cart/use-remove-item.tsx new file mode 100644 index 000000000..9b492e9eb --- /dev/null +++ b/framework/commercejs/cart/use-remove-item.tsx @@ -0,0 +1,36 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@commerce/utils/types' +import type { RemoveItemHook } from '@commerce/types/cart' +import useRemoveItem, { UseRemoveItem } from '@commerce/cart/use-remove-item' +import type { CommercejsCart } from '../types/cart' +import { normalizeCart } from '../utils/normalize-cart' +import useCart from './use-cart' + +export default useRemoveItem as UseRemoveItem + +export const handler: MutationHook = { + fetchOptions: { + query: 'cart', + method: 'remove', + }, + async fetcher({ input, options, fetch }) { + const { cart } = await fetch<{ cart: CommercejsCart }>({ + query: options.query, + method: options.method, + variables: input.itemId, + }) + return normalizeCart(cart) + }, + useHook: ({ fetch }) => + function useHook() { + const { mutate } = useCart() + return useCallback( + async function removeItem(input) { + const cart = await fetch({ input: { itemId: input.id } }) + await mutate(cart, false) + return cart + }, + [mutate] + ) + }, +} diff --git a/framework/commercejs/cart/use-update-item.tsx b/framework/commercejs/cart/use-update-item.tsx new file mode 100644 index 000000000..8e890de49 --- /dev/null +++ b/framework/commercejs/cart/use-update-item.tsx @@ -0,0 +1,76 @@ +import type { UpdateItemHook, LineItem } from '@commerce/types/cart' +import type { + HookFetcherContext, + MutationHookContext, +} from '@commerce/utils/types' +import { ValidationError } from '@commerce/utils/errors' +import debounce from 'lodash.debounce' +import { useCallback } from 'react' +import useUpdateItem, { UseUpdateItem } from '@commerce/cart/use-update-item' +import type { CommercejsCart } from '../types/cart' +import { normalizeCart } from '../utils/normalize-cart' +import useCart from './use-cart' + +export default useUpdateItem as UseUpdateItem + +export type UpdateItemActionInput = T extends LineItem + ? Partial + : UpdateItemHook['actionInput'] + +export const handler = { + fetchOptions: { + query: 'cart', + method: 'update', + }, + async fetcher({ input, options, fetch }: HookFetcherContext) { + const variables = [input.itemId, { quantity: input.item.quantity }] + const { cart } = await fetch<{ cart: CommercejsCart }>({ + query: options.query, + method: options.method, + variables, + }) + return normalizeCart(cart) + }, + useHook: + ({ fetch }: MutationHookContext) => + ( + ctx: { + item?: T + wait?: number + } = {} + ) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { mutate } = useCart() as any + const { item } = ctx + + // eslint-disable-next-line react-hooks/rules-of-hooks + return useCallback( + debounce(async (input: UpdateItemActionInput) => { + const itemId = input.id ?? item?.id + const productId = input.productId ?? item?.productId + const variantId = input.productId ?? item?.variantId + const quantity = input?.quantity ?? item?.quantity + + if (!itemId || !productId || !variantId) { + throw new ValidationError({ + message: 'Invalid input for updating cart item', + }) + } + + const cart = await fetch({ + input: { + itemId, + item: { + quantity, + productId, + variantId, + }, + }, + }) + await mutate(cart, false) + return cart + }, ctx.wait ?? 500), + [mutate, item] + ) + }, +} diff --git a/framework/commercejs/checkout/index.ts b/framework/commercejs/checkout/index.ts new file mode 100644 index 000000000..306621059 --- /dev/null +++ b/framework/commercejs/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/commercejs/checkout/use-checkout.tsx b/framework/commercejs/checkout/use-checkout.tsx new file mode 100644 index 000000000..f41b01a59 --- /dev/null +++ b/framework/commercejs/checkout/use-checkout.tsx @@ -0,0 +1,52 @@ +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' +import { useCheckoutContext } from '@components/checkout/context' + +export default useCheckout as UseCheckout + +export const handler: SWRHook = { + fetchOptions: { + query: '_', + method: '_', + }, + useHook: () => + function useHook() { + const { cardFields, addressFields } = useCheckoutContext() + const submit = useSubmitCheckout() + + // Basic validation - check that at least one field has a value. + const hasEnteredCard = Object.values(cardFields).some( + (fieldValue) => !!fieldValue + ) + const hasEnteredAddress = Object.values(addressFields).some( + (fieldValue) => !!fieldValue + ) + + const response = useMemo( + () => ({ + data: { + hasPayment: hasEnteredCard, + hasShipping: hasEnteredAddress, + }, + }), + [hasEnteredCard, hasEnteredAddress] + ) + + return useMemo( + () => + Object.create(response, { + submit: { + get() { + return submit + }, + enumerable: true, + }, + }), + [submit, response] + ) + }, +} diff --git a/framework/commercejs/checkout/use-submit-checkout.tsx b/framework/commercejs/checkout/use-submit-checkout.tsx new file mode 100644 index 000000000..77039ef51 --- /dev/null +++ b/framework/commercejs/checkout/use-submit-checkout.tsx @@ -0,0 +1,38 @@ +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' +import { useCheckoutContext } from '@components/checkout/context' + +export default useSubmitCheckout as UseSubmitCheckout + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/checkout', + method: 'POST', + }, + async fetcher({ input: item, options, fetch }) { + const data = await fetch({ + ...options, + body: { item }, + }) + return data + }, + useHook: ({ fetch }) => + function useHook() { + const { cardFields, addressFields } = useCheckoutContext() + + return useCallback( + async function onSubmitCheckout(input) { + const data = await fetch({ + input: { card: cardFields, address: addressFields }, + }) + return data + }, + [cardFields, addressFields] + ) + }, +} diff --git a/framework/commercejs/commerce.config.json b/framework/commercejs/commerce.config.json new file mode 100644 index 000000000..ba52b04c4 --- /dev/null +++ b/framework/commercejs/commerce.config.json @@ -0,0 +1,10 @@ +{ + "provider": "commercejs", + "features": { + "cart": true, + "search": true, + "customCheckout": true, + "customerAuth": true, + "wishlist": false + } +} diff --git a/framework/commercejs/constants.ts b/framework/commercejs/constants.ts new file mode 100644 index 000000000..33a767793 --- /dev/null +++ b/framework/commercejs/constants.ts @@ -0,0 +1,4 @@ +export const CART_COOKIE = 'commercejs_cart_id' +export const CUSTOMER_COOKIE = 'commercejs_customer_token' +export const API_URL = 'https://api.chec.io/v1' +export const LOCALE = 'en-us' diff --git a/framework/commercejs/customer/address/index.ts b/framework/commercejs/customer/address/index.ts new file mode 100644 index 000000000..1fb07c055 --- /dev/null +++ b/framework/commercejs/customer/address/index.ts @@ -0,0 +1,2 @@ +export { default as useAddresses } from './use-addresses' +export { default as useAddItem } from './use-add-item' diff --git a/framework/commercejs/customer/address/use-add-item.tsx b/framework/commercejs/customer/address/use-add-item.tsx new file mode 100644 index 000000000..3e0022761 --- /dev/null +++ b/framework/commercejs/customer/address/use-add-item.tsx @@ -0,0 +1,25 @@ +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 { useCheckoutContext } from '@components/checkout/context' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + query: '_', + method: '_', + }, + useHook: () => + function useHook() { + const { setAddressFields } = useCheckoutContext() + return useCallback( + async function addItem(input) { + setAddressFields(input) + return undefined + }, + [setAddressFields] + ) + }, +} diff --git a/framework/commercejs/customer/address/use-addresses.tsx b/framework/commercejs/customer/address/use-addresses.tsx new file mode 100644 index 000000000..5d0ad0ab6 --- /dev/null +++ b/framework/commercejs/customer/address/use-addresses.tsx @@ -0,0 +1,34 @@ +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: '_', + method: '_', + }, + useHook: () => + function useHook() { + return useMemo( + () => + Object.create( + {}, + { + isEmpty: { + get() { + return true + }, + enumerable: true, + }, + } + ), + [] + ) + }, +} diff --git a/framework/commercejs/customer/card/index.ts b/framework/commercejs/customer/card/index.ts new file mode 100644 index 000000000..4048ca29a --- /dev/null +++ b/framework/commercejs/customer/card/index.ts @@ -0,0 +1,2 @@ +export { default as useCards } from './use-cards' +export { default as useAddItem } from './use-add-item' diff --git a/framework/commercejs/customer/card/use-add-item.tsx b/framework/commercejs/customer/card/use-add-item.tsx new file mode 100644 index 000000000..d83c44cd5 --- /dev/null +++ b/framework/commercejs/customer/card/use-add-item.tsx @@ -0,0 +1,25 @@ +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 { useCheckoutContext } from '@components/checkout/context' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + url: '_', + method: '_', + }, + useHook: () => + function useHook() { + const { setCardFields } = useCheckoutContext() + return useCallback( + async function addItem(input) { + setCardFields(input) + return undefined + }, + [setCardFields] + ) + }, +} diff --git a/framework/commercejs/customer/card/use-cards.tsx b/framework/commercejs/customer/card/use-cards.tsx new file mode 100644 index 000000000..2372eaa53 --- /dev/null +++ b/framework/commercejs/customer/card/use-cards.tsx @@ -0,0 +1,31 @@ +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: { + query: '_', + method: '_', + }, + useHook: () => + function useHook() { + return useMemo( + () => + Object.create( + {}, + { + isEmpty: { + get() { + return true + }, + enumerable: true, + }, + } + ), + [] + ) + }, +} diff --git a/framework/commercejs/customer/index.ts b/framework/commercejs/customer/index.ts new file mode 100644 index 000000000..6c903ecc5 --- /dev/null +++ b/framework/commercejs/customer/index.ts @@ -0,0 +1 @@ +export { default as useCustomer } from './use-customer' diff --git a/framework/commercejs/customer/use-customer.tsx b/framework/commercejs/customer/use-customer.tsx new file mode 100644 index 000000000..3f91b5abe --- /dev/null +++ b/framework/commercejs/customer/use-customer.tsx @@ -0,0 +1,44 @@ +import Cookies from 'js-cookie' +import { decode } from 'jsonwebtoken' +import { SWRHook } from '@commerce/utils/types' +import useCustomer, { UseCustomer } from '@commerce/customer/use-customer' +import { CUSTOMER_COOKIE, API_URL } from '../constants' +import type { CustomerHook } from '../types/customer' + +export default useCustomer as UseCustomer +export const handler: SWRHook = { + fetchOptions: { + query: 'customer', + method: '_request', + }, + async fetcher({ options, fetch }) { + const token = Cookies.get(CUSTOMER_COOKIE) + if (!token) { + return null + } + + const decodedToken = decode(token) as { cid: string } + const customer = await fetch({ + query: options.query, + method: options.method, + variables: [ + `${API_URL}/customers/${decodedToken.cid}`, + 'get', + null, + {}, + token, + ], + }) + return customer + }, + useHook: + ({ useData }) => + (input) => { + return useData({ + swrOptions: { + revalidateOnFocus: false, + ...input?.swrOptions, + }, + }) + }, +} diff --git a/framework/commercejs/fetcher.ts b/framework/commercejs/fetcher.ts new file mode 100644 index 000000000..c556582bf --- /dev/null +++ b/framework/commercejs/fetcher.ts @@ -0,0 +1,61 @@ +import { commerce } from './lib/commercejs' +import type { Fetcher } from '@commerce/utils/types' +import { FetcherError } from '@commerce/utils/errors' + +function isValidSDKQuery(query?: string): query is keyof typeof commerce { + if (!query) return false + return query in commerce +} + +// Fetches from an API route within /api/endpoints directory +const customFetcher: 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 +} + +const fetcher: Fetcher = async ({ url, query, method, variables, body }) => { + // If a URL is passed, it means that the fetch needs to be passed on to a custom API route. + const isCustomFetch = !!url + if (isCustomFetch) { + const data = await customFetcher({ url, method, body }) + return data + } + + // Fetch using the Commerce.js SDK, but make sure that it's a valid method. + if (!isValidSDKQuery(query)) { + throw new FetcherError({ + errors: [ + { message: `Query ${query} does not exist on Commerce.js SDK.` }, + ], + status: 400, + }) + } + + const resource: any = commerce[query] + + if (!method || !resource[method]) { + throw new FetcherError({ + errors: [ + { + message: `Method ${method} does not exist on Commerce.js SDK ${query} resource.`, + }, + ], + status: 400, + }) + } + + const variablesArgument = Array.isArray(variables) ? variables : [variables] + const data = await resource[method](...variablesArgument) + return data +} + +export default fetcher diff --git a/framework/commercejs/index.tsx b/framework/commercejs/index.tsx new file mode 100644 index 000000000..c1ca7e4be --- /dev/null +++ b/framework/commercejs/index.tsx @@ -0,0 +1,9 @@ +import { commercejsProvider, CommercejsProvider } from './provider' +import { getCommerceProvider, useCommerce as useCoreCommerce } from '@commerce' + +export { commercejsProvider } +export type { CommercejsProvider } + +export const CommerceProvider = getCommerceProvider(commercejsProvider) + +export const useCommerce = () => useCoreCommerce() diff --git a/framework/commercejs/lib/commercejs.ts b/framework/commercejs/lib/commercejs.ts new file mode 100644 index 000000000..8acea540b --- /dev/null +++ b/framework/commercejs/lib/commercejs.ts @@ -0,0 +1,11 @@ +import Commerce from '@chec/commerce.js' + +const commercejsPublicKey = process.env + .NEXT_PUBLIC_COMMERCEJS_PUBLIC_KEY as string +const devEnvironment = process.env.NODE_ENV === 'development' + +if (devEnvironment && !commercejsPublicKey) { + throw Error('A Commerce.js public API key must be provided') +} + +export const commerce = new Commerce(commercejsPublicKey, devEnvironment) diff --git a/framework/commercejs/next.config.js b/framework/commercejs/next.config.js new file mode 100644 index 000000000..0c9e96b4c --- /dev/null +++ b/framework/commercejs/next.config.js @@ -0,0 +1,16 @@ +const commerce = require('./commerce.config.json') + +module.exports = { + commerce, + images: { + domains: ['cdn.chec.io'], + }, + rewrites() { + return [ + { + source: '/api/login/:token', + destination: '/api/login?token=:token', + }, + ] + }, +} diff --git a/framework/commercejs/product/index.ts b/framework/commercejs/product/index.ts new file mode 100644 index 000000000..426a3edcd --- /dev/null +++ b/framework/commercejs/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/commercejs/product/use-price.tsx b/framework/commercejs/product/use-price.tsx new file mode 100644 index 000000000..0174faf5e --- /dev/null +++ b/framework/commercejs/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/commercejs/product/use-search.tsx b/framework/commercejs/product/use-search.tsx new file mode 100644 index 000000000..e0561dc1f --- /dev/null +++ b/framework/commercejs/product/use-search.tsx @@ -0,0 +1,53 @@ +import { SWRHook } from '@commerce/utils/types' +import useSearch, { UseSearch } from '@commerce/product/use-search' +import { SearchProductsHook } from '@commerce/types/product' +import type { CommercejsProduct } from '../types/product' +import { getProductSearchVariables } from '../utils/product-search' +import { normalizeProduct } from '../utils/normalize-product' + +export default useSearch as UseSearch + +export const handler: SWRHook = { + fetchOptions: { + query: 'products', + method: 'list', + }, + async fetcher({ input, options, fetch }) { + const { data, meta } = await fetch<{ + data: CommercejsProduct[] + meta: { + pagination: { + total: number + } + } + }>({ + query: options.query, + method: options.method, + variables: getProductSearchVariables(input), + }) + + const formattedProducts = + data?.map((product) => normalizeProduct(product)) || [] + + return { + products: formattedProducts, + found: meta.pagination.total > 0, + } + }, + useHook: + ({ useData }) => + (input = {}) => { + return useData({ + input: [ + ['search', input.search], + ['categoryId', input.categoryId], + ['brandId', input.brandId], + ['sort', input.sort], + ], + swrOptions: { + revalidateOnFocus: false, + ...input.swrOptions, + }, + }) + }, +} diff --git a/framework/commercejs/provider.ts b/framework/commercejs/provider.ts new file mode 100644 index 000000000..d596fa9b2 --- /dev/null +++ b/framework/commercejs/provider.ts @@ -0,0 +1,55 @@ +import { handler as useCart } from './cart/use-cart' +import { handler as useAddItem } from './cart/use-add-item' +import { handler as useUpdateItem } from './cart/use-update-item' +import { handler as useRemoveItem } 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 useAddresses } from './customer/address/use-addresses' +import { handler as useAddAddressItem } from './customer/address/use-add-item' + +import { CART_COOKIE, CUSTOMER_COOKIE, LOCALE } from './constants' +import { default as sdkFetcher } from './fetcher' + +export const commercejsProvider = { + locale: LOCALE, + cartCookie: CART_COOKIE, + customerCookie: CUSTOMER_COOKIE, + fetcher: sdkFetcher, + cart: { + useCart, + useAddItem, + useUpdateItem, + useRemoveItem, + }, + checkout: { + useCheckout, + useSubmitCheckout, + }, + customer: { + useCustomer, + card: { + useCards, + useAddItem: useAddCardItem, + }, + address: { + useAddresses, + useAddItem: useAddAddressItem, + }, + }, + products: { useSearch }, + auth: { useLogin, useLogout, useSignup }, +} + +export type CommercejsProvider = typeof commercejsProvider diff --git a/framework/commercejs/types/cart.ts b/framework/commercejs/types/cart.ts new file mode 100644 index 000000000..fe7b7bdc2 --- /dev/null +++ b/framework/commercejs/types/cart.ts @@ -0,0 +1,4 @@ +export * from '@commerce/types/cart' + +export type { Cart as CommercejsCart } from '@chec/commerce.js/types/cart' +export type { LineItem as CommercejsLineItem } from '@chec/commerce.js/types/line-item' diff --git a/framework/commercejs/types/checkout.ts b/framework/commercejs/types/checkout.ts new file mode 100644 index 000000000..47b6a63b9 --- /dev/null +++ b/framework/commercejs/types/checkout.ts @@ -0,0 +1,3 @@ +export * from '@commerce/types/checkout' + +export type { CheckoutCapture as CommercejsCheckoutCapture } from '@chec/commerce.js/types/checkout-capture' diff --git a/framework/commercejs/types/common.ts b/framework/commercejs/types/common.ts new file mode 100644 index 000000000..b52c33a4d --- /dev/null +++ b/framework/commercejs/types/common.ts @@ -0,0 +1 @@ +export * from '@commerce/types/common' diff --git a/framework/commercejs/types/customer.ts b/framework/commercejs/types/customer.ts new file mode 100644 index 000000000..87c9afcc4 --- /dev/null +++ b/framework/commercejs/types/customer.ts @@ -0,0 +1 @@ +export * from '@commerce/types/customer' diff --git a/framework/commercejs/types/index.ts b/framework/commercejs/types/index.ts new file mode 100644 index 000000000..7ab0b7f64 --- /dev/null +++ b/framework/commercejs/types/index.ts @@ -0,0 +1,25 @@ +import * as Cart from './cart' +import * as Checkout from './checkout' +import * as Common from './common' +import * as Customer from './customer' +import * as Login from './login' +import * as Logout from './logout' +import * as Page from './page' +import * as Product from './product' +import * as Signup from './signup' +import * as Site from './site' +import * as Wishlist from './wishlist' + +export type { + Cart, + Checkout, + Common, + Customer, + Login, + Logout, + Page, + Product, + Signup, + Site, + Wishlist, +} diff --git a/framework/commercejs/types/login.ts b/framework/commercejs/types/login.ts new file mode 100644 index 000000000..97f879078 --- /dev/null +++ b/framework/commercejs/types/login.ts @@ -0,0 +1,9 @@ +import { LoginBody, LoginTypes } from '@commerce/types/login' +export * from '@commerce/types/login' + +export type LoginHook = { + data: null + actionInput: LoginBody + fetcherInput: LoginBody + body: T['body'] +} diff --git a/framework/commercejs/types/logout.ts b/framework/commercejs/types/logout.ts new file mode 100644 index 000000000..9f0a466af --- /dev/null +++ b/framework/commercejs/types/logout.ts @@ -0,0 +1 @@ +export * from '@commerce/types/logout' diff --git a/framework/commercejs/types/page.ts b/framework/commercejs/types/page.ts new file mode 100644 index 000000000..20ec8ea38 --- /dev/null +++ b/framework/commercejs/types/page.ts @@ -0,0 +1 @@ +export * from '@commerce/types/page' diff --git a/framework/commercejs/types/product.ts b/framework/commercejs/types/product.ts new file mode 100644 index 000000000..4db475d95 --- /dev/null +++ b/framework/commercejs/types/product.ts @@ -0,0 +1,4 @@ +export * from '@commerce/types/product' + +export type { Product as CommercejsProduct } from '@chec/commerce.js/types/product' +export type { Variant as CommercejsVariant } from '@chec/commerce.js/types/variant' diff --git a/framework/commercejs/types/signup.ts b/framework/commercejs/types/signup.ts new file mode 100644 index 000000000..58543c6f6 --- /dev/null +++ b/framework/commercejs/types/signup.ts @@ -0,0 +1 @@ +export * from '@commerce/types/signup' diff --git a/framework/commercejs/types/site.ts b/framework/commercejs/types/site.ts new file mode 100644 index 000000000..8fd61a07d --- /dev/null +++ b/framework/commercejs/types/site.ts @@ -0,0 +1,3 @@ +export * from '@commerce/types/site' + +export type { Category as CommercejsCategory } from '@chec/commerce.js/types/category' diff --git a/framework/commercejs/types/wishlist.ts b/framework/commercejs/types/wishlist.ts new file mode 100644 index 000000000..8907fbf82 --- /dev/null +++ b/framework/commercejs/types/wishlist.ts @@ -0,0 +1 @@ +export * from '@commerce/types/wishlist' diff --git a/framework/commercejs/utils/get-deployment-url.ts b/framework/commercejs/utils/get-deployment-url.ts new file mode 100644 index 000000000..b0926abc7 --- /dev/null +++ b/framework/commercejs/utils/get-deployment-url.ts @@ -0,0 +1,12 @@ +export const getDeploymentUrl = () => { + // Custom environment variable. + if (process.env.NEXT_PUBLIC_COMMERCEJS_DEPLOYMENT_URL) { + return process.env.NEXT_PUBLIC_COMMERCEJS_DEPLOYMENT_URL + } + // Automatic Vercel deployment URL. + if (process.env.NEXT_PUBLIC_VERCEL_URL) { + return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + } + // Assume local development. + return 'http://localhost:3000' +} diff --git a/framework/commercejs/utils/normalize-cart.ts b/framework/commercejs/utils/normalize-cart.ts new file mode 100644 index 000000000..c01ea6dea --- /dev/null +++ b/framework/commercejs/utils/normalize-cart.ts @@ -0,0 +1,74 @@ +import type { + Cart, + LineItem, + CommercejsCart, + CommercejsLineItem, +} from '../types/cart' + +type CommercejsLineItemType = CommercejsLineItem & { image: { url: string } } + +const normalizeLineItem = ( + commercejsLineItem: CommercejsLineItemType +): LineItem => { + const { + id, + sku, + quantity, + price, + product_id, + product_name, + permalink, + variant, + image, + selected_options, + } = commercejsLineItem + return { + id, + variantId: variant?.id ?? '', + productId: product_id, + name: product_name, + quantity, + discounts: [], + path: permalink, + options: selected_options?.map(({ group_name, option_name }) => ({ + name: group_name, + value: option_name, + })), + variant: { + id: variant?.id ?? id, + sku: variant?.sku ?? sku, + name: product_name, + requiresShipping: false, + price: variant?.price?.raw ?? price.raw, + listPrice: variant?.price?.raw ?? price.raw, + image: { + url: image?.url, + }, + }, + } +} + +export const normalizeCart = (commercejsCart: CommercejsCart): Cart => { + const { + id, + created, + subtotal: { raw: rawPrice }, + currency, + line_items, + } = commercejsCart + + return { + id, + createdAt: new Date(created * 1000).toISOString(), + currency: { + code: currency.code, + }, + taxesIncluded: false, + lineItems: line_items.map((item) => { + return normalizeLineItem(item as CommercejsLineItemType) + }), + lineItemsSubtotalPrice: rawPrice, + subtotalPrice: rawPrice, + totalPrice: rawPrice, + } +} diff --git a/framework/commercejs/utils/normalize-category.ts b/framework/commercejs/utils/normalize-category.ts new file mode 100644 index 000000000..e47969e81 --- /dev/null +++ b/framework/commercejs/utils/normalize-category.ts @@ -0,0 +1,14 @@ +import type { Category } from '@commerce/types/site' +import type { Category as CommercejsCategory } from '@chec/commerce.js/types/category' + +export function normalizeCategory( + commercejsCatgeory: CommercejsCategory +): Category { + const { id, name, slug } = commercejsCatgeory + return { + id, + name, + slug, + path: slug, + } +} diff --git a/framework/commercejs/utils/normalize-checkout.ts b/framework/commercejs/utils/normalize-checkout.ts new file mode 100644 index 000000000..2cc996f88 --- /dev/null +++ b/framework/commercejs/utils/normalize-checkout.ts @@ -0,0 +1,63 @@ +import type { CardFields } from '@commerce/types/customer/card' +import type { AddressFields } from '@commerce/types/customer/address' +import type { CommercejsCheckoutCapture } from '../types/checkout' + +/** + * Creates a checkout payload suitable for test checkouts. + * 1. Hard-codes the payment values for the Commerce.js test gateway. + * 2. Hard-codes the email until an email field exists on the checkout form. + * 3. Gets as much as much checkout info as possible from the checkout form, and uses fallback values. + */ +export function normalizeTestCheckout({ + paymentInfo, + shippingInfo, + shippingOption, +}: { + paymentInfo?: CardFields + shippingInfo?: AddressFields + shippingOption: string +}): CommercejsCheckoutCapture { + const firstName = + shippingInfo?.firstName || paymentInfo?.firstName || 'Nextjs' + const lastName = shippingInfo?.lastName || paymentInfo?.lastName || 'Commerce' + const fullName = `${firstName} ${lastName}` + const postalCode = shippingInfo?.zipCode || paymentInfo?.zipCode || '94103' + const street = + shippingInfo?.streetNumber || paymentInfo?.streetNumber || 'Test Street' + const townCity = shippingInfo?.city || paymentInfo?.city || 'Test Town' + + return { + payment: { + gateway: 'test_gateway', + card: { + number: '4242 4242 4242 4242', + expiry_month: '01', + expiry_year: '2024', + cvc: '123', + postal_zip_code: postalCode, + }, + }, + customer: { + email: 'nextcommerce@test.com', + firstname: firstName, + lastname: lastName, + }, + shipping: { + name: fullName, + street, + town_city: townCity, + country: 'US', + }, + billing: { + name: fullName, + street, + town_city: townCity, + postal_zip_code: postalCode, + county_state: 'California', + country: 'US', + }, + fulfillment: { + shipping_method: shippingOption, + }, + } +} diff --git a/framework/commercejs/utils/normalize-product.ts b/framework/commercejs/utils/normalize-product.ts new file mode 100644 index 000000000..86c42d922 --- /dev/null +++ b/framework/commercejs/utils/normalize-product.ts @@ -0,0 +1,77 @@ +import type { + Product, + CommercejsProduct, + CommercejsVariant, +} from '../types/product' + +function getOptionsFromVariantGroups( + variantGroups: CommercejsProduct['variant_groups'] +): Product['options'] { + const optionsFromVariantGroups = variantGroups.map( + ({ id, name: variantName, options }) => ({ + id, + displayName: variantName, + values: options.map(({ name: optionName }) => ({ + label: optionName, + })), + }) + ) + return optionsFromVariantGroups +} + +function normalizeVariants( + variants: Array = [], + variantGroups: CommercejsProduct['variant_groups'] +) { + if (!Array.isArray(variants)) return [] + return variants?.map((variant) => ({ + id: variant.id, + options: Object.entries(variant.options).map( + ([variantGroupId, variantOptionId]) => { + const variantGroupFromId = variantGroups.find( + (group) => group.id === variantGroupId + ) + const valueLabel = variantGroupFromId?.options.find( + (option) => option.id === variantOptionId + )?.name + + return { + id: variantOptionId, + displayName: variantGroupFromId?.name || '', + __typename: 'MultipleChoiceOption' as 'MultipleChoiceOption', + values: [ + { + label: valueLabel || '', + }, + ], + } + } + ), + })) +} + +export function normalizeProduct( + commercejsProduct: CommercejsProduct, + commercejsProductVariants: Array = [] +): Product { + const { id, name, description, permalink, assets, price, variant_groups } = + commercejsProduct + return { + id, + name, + description, + descriptionHtml: description, + slug: permalink, + path: permalink, + images: assets.map(({ url, description, filename }) => ({ + url, + alt: description || filename, + })), + price: { + value: price.raw, + currencyCode: 'USD', + }, + variants: normalizeVariants(commercejsProductVariants, variant_groups), + options: getOptionsFromVariantGroups(variant_groups), + } +} diff --git a/framework/commercejs/utils/product-search.ts b/framework/commercejs/utils/product-search.ts new file mode 100644 index 000000000..b1ee96681 --- /dev/null +++ b/framework/commercejs/utils/product-search.ts @@ -0,0 +1,54 @@ +import { SearchProductsBody } from '@commerce/types/product' + +const getFilterVariables = ({ + search, + categoryId, +}: { + search?: string + categoryId?: string | number +}) => { + let filterVariables: { [key: string]: any } = {} + if (search) { + filterVariables.query = search + } + if (categoryId) { + filterVariables['category_id'] = categoryId + } + return filterVariables +} + +const getSortVariables = ({ sort }: { sort?: string }) => { + let sortVariables: { [key: string]: any } = {} + switch (sort) { + case 'trending-desc': + case 'latest-desc': + sortVariables = { + sortBy: 'updated', + sortDirection: 'desc', + } + break + case 'price-asc': + sortVariables = { + sortBy: 'price', + sortDirection: 'asc', + } + break + case 'price-desc': + sortVariables = { + sortBy: 'price', + sortDirection: 'desc', + } + break + } + return sortVariables +} + +export const getProductSearchVariables = (input: SearchProductsBody) => { + const { search, categoryId, sort } = input + const filterVariables = getFilterVariables({ search, categoryId }) + const sortVariables = getSortVariables({ sort }) + return { + ...filterVariables, + ...sortVariables, + } +} diff --git a/framework/commercejs/wishlist/use-add-item.tsx b/framework/commercejs/wishlist/use-add-item.tsx new file mode 100644 index 000000000..75f067c3a --- /dev/null +++ b/framework/commercejs/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/commercejs/wishlist/use-remove-item.tsx b/framework/commercejs/wishlist/use-remove-item.tsx new file mode 100644 index 000000000..a2d3a8a05 --- /dev/null +++ b/framework/commercejs/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/commercejs/wishlist/use-wishlist.tsx b/framework/commercejs/wishlist/use-wishlist.tsx new file mode 100644 index 000000000..125ee2686 --- /dev/null +++ b/framework/commercejs/wishlist/use-wishlist.tsx @@ -0,0 +1,40 @@ +import { HookFetcher } from '@commerce/utils/types' + +export type Wishlist = { + items: [ + { + product_id: number + variant_id: number + id: number + product: any + } + ] +} + +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-lock.json b/package-lock.json index 196a45582..882bd6216 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@chec/commerce.js": "^2.8.0", "@react-spring/web": "^9.2.1", "@spree/storefront-api-v2-sdk": "^5.0.1", "@vercel/fetch": "^6.1.1", @@ -47,6 +48,7 @@ "@graphql-codegen/typescript-operations": "^1.18.1", "@next/bundle-analyzer": "^10.2.3", "@types/body-scroll-lock": "^2.6.1", + "@types/chec__commerce.js": "^2.8.4", "@types/cookie": "^0.4.0", "@types/js-cookie": "^2.2.6", "@types/lodash.debounce": "^4.0.6", @@ -1282,6 +1284,15 @@ "to-fast-properties": "^2.0.0" } }, + "node_modules/@chec/commerce.js": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@chec/commerce.js/-/commerce.js-2.8.0.tgz", + "integrity": "sha512-OPBphT/hU33iDp52zzYOqz/oSXLhEuhGVUg2UNvYtmBW4eCNmtsM0dqW0+wu+6K0d6fZojurCBdVQMKb2R7l3g==", + "dependencies": { + "@babel/runtime": "^7.7.4", + "axios": "^0.21.1" + } + }, "node_modules/@csstools/convert-colors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@csstools/convert-colors/-/convert-colors-1.4.0.tgz", @@ -2509,6 +2520,15 @@ "integrity": "sha512-PPFm/2A6LfKmSpvMg58gHtSqwwMChbcKKGhSCRIhY4MyFzhY8moAN6HrTCpOeZQUqkFdTFfMqr7njeqGLKt72Q==", "dev": true }, + "node_modules/@types/chec__commerce.js": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@types/chec__commerce.js/-/chec__commerce.js-2.8.4.tgz", + "integrity": "sha512-hyR2OXEB3gIRp/ESWOQaFStefBG+C5OdnkxGC1Gmp0ePVzl/wk5FyvaK5NsT1ddNC/y1YsmDAVPe+DArr6/9Jg==", + "dev": true, + "dependencies": { + "@types/chec__commerce.js": "*" + } + }, "node_modules/@types/cookie": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.0.tgz", @@ -3254,6 +3274,14 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -5809,6 +5837,25 @@ "deprecated": "flatten is deprecated in favor of utility frameworks such as lodash.", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreach": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", @@ -16303,6 +16350,15 @@ "to-fast-properties": "^2.0.0" } }, + "@chec/commerce.js": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@chec/commerce.js/-/commerce.js-2.8.0.tgz", + "integrity": "sha512-OPBphT/hU33iDp52zzYOqz/oSXLhEuhGVUg2UNvYtmBW4eCNmtsM0dqW0+wu+6K0d6fZojurCBdVQMKb2R7l3g==", + "requires": { + "@babel/runtime": "^7.7.4", + "axios": "^0.21.1" + } + }, "@csstools/convert-colors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@csstools/convert-colors/-/convert-colors-1.4.0.tgz", @@ -17254,6 +17310,15 @@ "integrity": "sha512-PPFm/2A6LfKmSpvMg58gHtSqwwMChbcKKGhSCRIhY4MyFzhY8moAN6HrTCpOeZQUqkFdTFfMqr7njeqGLKt72Q==", "dev": true }, + "@types/chec__commerce.js": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@types/chec__commerce.js/-/chec__commerce.js-2.8.4.tgz", + "integrity": "sha512-hyR2OXEB3gIRp/ESWOQaFStefBG+C5OdnkxGC1Gmp0ePVzl/wk5FyvaK5NsT1ddNC/y1YsmDAVPe+DArr6/9Jg==", + "dev": true, + "requires": { + "@types/chec__commerce.js": "*" + } + }, "@types/cookie": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.0.tgz", @@ -17843,6 +17908,14 @@ "integrity": "sha512-5LMaDRWm8ZFPAEdzTYmgjjEdj1YnQcpfrVajO/sn/LhbpGp0Y0H64c2hLZI1gRMxfA+w1S71Uc/nHaOXgcCvGg==", "dev": true }, + "axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "requires": { + "follow-redirects": "^1.14.0" + } + }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -19897,6 +19970,11 @@ "integrity": "sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==", "dev": true }, + "follow-redirects": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==" + }, "foreach": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", diff --git a/package.json b/package.json index f0fa51e02..0252f5392 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "node": ">=14.x" }, "dependencies": { + "@chec/commerce.js": "^2.8.0", "@react-spring/web": "^9.2.1", "@spree/storefront-api-v2-sdk": "^5.0.1", "@vercel/fetch": "^6.1.1", @@ -59,6 +60,7 @@ "@graphql-codegen/typescript-operations": "^1.18.1", "@next/bundle-analyzer": "^10.2.3", "@types/body-scroll-lock": "^2.6.1", + "@types/chec__commerce.js": "^2.8.4", "@types/cookie": "^0.4.0", "@types/js-cookie": "^2.2.6", "@types/lodash.debounce": "^4.0.6", diff --git a/tsconfig.json b/tsconfig.json index 340929669..3373fb42f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,6 +34,7 @@ "./framework/shopify", "./framework/swell", "./framework/vendure", - "./framework/saleor" + "./framework/saleor", + "./framework/commercejs" ] }