({
+ handler: checkoutEndpoint,
+ handlers,
+})
+
+export default checkoutApi
diff --git a/framework/spree/api/endpoints/customer/address.ts b/framework/spree/api/endpoints/customer/address.ts
new file mode 100644
index 0000000000..491bf0ac93
--- /dev/null
+++ b/framework/spree/api/endpoints/customer/address.ts
@@ -0,0 +1 @@
+export default function noopApi(...args: any[]): void {}
diff --git a/framework/spree/api/endpoints/customer/card.ts b/framework/spree/api/endpoints/customer/card.ts
new file mode 100644
index 0000000000..491bf0ac93
--- /dev/null
+++ b/framework/spree/api/endpoints/customer/card.ts
@@ -0,0 +1 @@
+export default function noopApi(...args: any[]): void {}
diff --git a/framework/spree/api/endpoints/customer/index.ts b/framework/spree/api/endpoints/customer/index.ts
new file mode 100644
index 0000000000..491bf0ac93
--- /dev/null
+++ b/framework/spree/api/endpoints/customer/index.ts
@@ -0,0 +1 @@
+export default function noopApi(...args: any[]): void {}
diff --git a/framework/spree/api/endpoints/login/index.ts b/framework/spree/api/endpoints/login/index.ts
new file mode 100644
index 0000000000..491bf0ac93
--- /dev/null
+++ b/framework/spree/api/endpoints/login/index.ts
@@ -0,0 +1 @@
+export default function noopApi(...args: any[]): void {}
diff --git a/framework/spree/api/endpoints/logout/index.ts b/framework/spree/api/endpoints/logout/index.ts
new file mode 100644
index 0000000000..491bf0ac93
--- /dev/null
+++ b/framework/spree/api/endpoints/logout/index.ts
@@ -0,0 +1 @@
+export default function noopApi(...args: any[]): void {}
diff --git a/framework/spree/api/endpoints/signup/index.ts b/framework/spree/api/endpoints/signup/index.ts
new file mode 100644
index 0000000000..491bf0ac93
--- /dev/null
+++ b/framework/spree/api/endpoints/signup/index.ts
@@ -0,0 +1 @@
+export default function noopApi(...args: any[]): void {}
diff --git a/framework/spree/api/endpoints/wishlist/index.tsx b/framework/spree/api/endpoints/wishlist/index.tsx
new file mode 100644
index 0000000000..491bf0ac93
--- /dev/null
+++ b/framework/spree/api/endpoints/wishlist/index.tsx
@@ -0,0 +1 @@
+export default function noopApi(...args: any[]): void {}
diff --git a/framework/spree/api/index.ts b/framework/spree/api/index.ts
new file mode 100644
index 0000000000..d9ef79e1af
--- /dev/null
+++ b/framework/spree/api/index.ts
@@ -0,0 +1,45 @@
+import type { CommerceAPI, CommerceAPIConfig } from '@commerce/api'
+import { getCommerceApi as commerceApi } from '@commerce/api'
+import createApiFetch from './utils/create-api-fetch'
+
+import getAllPages from './operations/get-all-pages'
+import getPage from './operations/get-page'
+import getSiteInfo from './operations/get-site-info'
+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'
+
+export interface SpreeApiConfig extends CommerceAPIConfig {}
+
+const config: SpreeApiConfig = {
+ commerceUrl: '',
+ apiToken: '',
+ cartCookie: '',
+ customerCookie: '',
+ cartCookieMaxAge: 2592000,
+ fetch: createApiFetch(() => getCommerceApi().getConfig()),
+}
+
+const operations = {
+ getAllPages,
+ getPage,
+ getSiteInfo,
+ getCustomerWishlist,
+ getAllProductPaths,
+ getAllProducts,
+ getProduct,
+}
+
+export const provider = { config, operations }
+
+export type SpreeApiProvider = typeof provider
+
+export type SpreeApi =
+ CommerceAPI
+
+export function getCommerceApi
(
+ customProvider: P = provider as any
+): SpreeApi
{
+ return commerceApi(customProvider)
+}
diff --git a/framework/spree/api/operations/get-all-pages.ts b/framework/spree/api/operations/get-all-pages.ts
new file mode 100644
index 0000000000..580a74999c
--- /dev/null
+++ b/framework/spree/api/operations/get-all-pages.ts
@@ -0,0 +1,82 @@
+import type {
+ OperationContext,
+ OperationOptions,
+} from '@commerce/api/operations'
+import type { GetAllPagesOperation, Page } from '@commerce/types/page'
+import { requireConfigValue } from '../../isomorphic-config'
+import normalizePage from '../../utils/normalizations/normalize-page'
+import type { IPages } from '@spree/storefront-api-v2-sdk/types/interfaces/Page'
+import type { SpreeSdkVariables } from '../../types'
+import type { SpreeApiConfig, SpreeApiProvider } from '../index'
+
+export default function getAllPagesOperation({
+ commerce,
+}: OperationContext) {
+ async function getAllPages(options?: {
+ config?: Partial
+ preview?: boolean
+ }): Promise
+
+ async function getAllPages(
+ opts: {
+ config?: Partial
+ preview?: boolean
+ } & OperationOptions
+ ): Promise
+
+ async function getAllPages({
+ config: userConfig,
+ preview,
+ query,
+ url,
+ }: {
+ url?: string
+ config?: Partial
+ preview?: boolean
+ query?: string
+ } = {}): Promise {
+ console.info(
+ 'getAllPages called. Configuration: ',
+ 'query: ',
+ query,
+ 'userConfig: ',
+ userConfig,
+ 'preview: ',
+ preview,
+ 'url: ',
+ url
+ )
+
+ const config = commerce.getConfig(userConfig)
+ const { fetch: apiFetch } = config
+
+ const variables: SpreeSdkVariables = {
+ methodPath: 'pages.list',
+ arguments: [
+ {
+ per_page: 500,
+ filter: {
+ locale_eq:
+ config.locale || (requireConfigValue('defaultLocale') as string),
+ },
+ },
+ ],
+ }
+
+ const { data: spreeSuccessResponse } = await apiFetch<
+ IPages,
+ SpreeSdkVariables
+ >('__UNUSED__', {
+ variables,
+ })
+
+ const normalizedPages: Page[] = spreeSuccessResponse.data.map(
+ (spreePage) =>
+ normalizePage(spreeSuccessResponse, spreePage, config.locales || [])
+ )
+
+ return { pages: normalizedPages }
+ }
+
+ return getAllPages
+}
diff --git a/framework/spree/api/operations/get-all-product-paths.ts b/framework/spree/api/operations/get-all-product-paths.ts
new file mode 100644
index 0000000000..4795d1fdb7
--- /dev/null
+++ b/framework/spree/api/operations/get-all-product-paths.ts
@@ -0,0 +1,97 @@
+import type {
+ OperationContext,
+ OperationOptions,
+} from '@commerce/api/operations'
+import type { Product } from '@commerce/types/product'
+import type { GetAllProductPathsOperation } from '@commerce/types/product'
+import { requireConfigValue } from '../../isomorphic-config'
+import type { IProductsSlugs, SpreeSdkVariables } from '../../types'
+import getProductPath from '../../utils/get-product-path'
+import type { SpreeApiConfig, SpreeApiProvider } from '..'
+
+const imagesSize = requireConfigValue('imagesSize') as string
+const imagesQuality = requireConfigValue('imagesQuality') as number
+
+export default function getAllProductPathsOperation({
+ commerce,
+}: OperationContext) {
+ async function getAllProductPaths<
+ T extends GetAllProductPathsOperation
+ >(opts?: {
+ variables?: T['variables']
+ config?: Partial
+ }): Promise
+
+ async function getAllProductPaths(
+ opts: {
+ variables?: T['variables']
+ config?: Partial
+ } & OperationOptions
+ ): Promise
+
+ async function getAllProductPaths({
+ query,
+ variables: getAllProductPathsVariables = {},
+ config: userConfig,
+ }: {
+ query?: string
+ variables?: T['variables']
+ config?: Partial
+ } = {}): Promise {
+ console.info(
+ 'getAllProductPaths called. Configuration: ',
+ 'query: ',
+ query,
+ 'getAllProductPathsVariables: ',
+ getAllProductPathsVariables,
+ 'config: ',
+ userConfig
+ )
+
+ const productsCount = requireConfigValue(
+ 'lastUpdatedProductsPrerenderCount'
+ )
+
+ if (productsCount === 0) {
+ return {
+ products: [],
+ }
+ }
+
+ const variables: SpreeSdkVariables = {
+ methodPath: 'products.list',
+ arguments: [
+ {},
+ {
+ fields: {
+ product: 'slug',
+ },
+ per_page: productsCount,
+ image_transformation: {
+ quality: imagesQuality,
+ size: imagesSize,
+ },
+ },
+ ],
+ }
+
+ const config = commerce.getConfig(userConfig)
+ const { fetch: apiFetch } = config // TODO: Send config.locale to Spree.
+
+ const { data: spreeSuccessResponse } = await apiFetch<
+ IProductsSlugs,
+ SpreeSdkVariables
+ >('__UNUSED__', {
+ variables,
+ })
+
+ const normalizedProductsPaths: Pick[] =
+ spreeSuccessResponse.data.map((spreeProduct) => ({
+ path: getProductPath(spreeProduct),
+ }))
+
+ return { products: normalizedProductsPaths }
+ }
+
+ return getAllProductPaths
+}
diff --git a/framework/spree/api/operations/get-all-products.ts b/framework/spree/api/operations/get-all-products.ts
new file mode 100644
index 0000000000..a292e6097e
--- /dev/null
+++ b/framework/spree/api/operations/get-all-products.ts
@@ -0,0 +1,92 @@
+import type { Product } from '@commerce/types/product'
+import type { GetAllProductsOperation } from '@commerce/types/product'
+import type {
+ OperationContext,
+ OperationOptions,
+} from '@commerce/api/operations'
+import type { IProducts } from '@spree/storefront-api-v2-sdk/types/interfaces/Product'
+import type { SpreeApiConfig, SpreeApiProvider } from '../index'
+import type { SpreeSdkVariables } from '../../types'
+import normalizeProduct from '../../utils/normalizations/normalize-product'
+import { requireConfigValue } from '../../isomorphic-config'
+
+const imagesSize = requireConfigValue('imagesSize') as string
+const imagesQuality = requireConfigValue('imagesQuality') as number
+
+export default function getAllProductsOperation({
+ commerce,
+}: OperationContext) {
+ async function getAllProducts(opts?: {
+ variables?: T['variables']
+ config?: Partial
+ preview?: boolean
+ }): Promise
+
+ async function getAllProducts(
+ opts: {
+ variables?: T['variables']
+ config?: Partial
+ preview?: boolean
+ } & OperationOptions
+ ): Promise
+
+ async function getAllProducts({
+ variables: getAllProductsVariables = {},
+ config: userConfig,
+ }: {
+ variables?: T['variables']
+ config?: Partial
+ } = {}): Promise<{ products: Product[] }> {
+ console.info(
+ 'getAllProducts called. Configuration: ',
+ 'getAllProductsVariables: ',
+ getAllProductsVariables,
+ 'config: ',
+ userConfig
+ )
+
+ const defaultProductsTaxonomyId = requireConfigValue(
+ 'allProductsTaxonomyId'
+ ) as string | false
+
+ const first = getAllProductsVariables.first
+ const filter = !defaultProductsTaxonomyId
+ ? {}
+ : { filter: { taxons: defaultProductsTaxonomyId }, sort: '-updated_at' }
+
+ const variables: SpreeSdkVariables = {
+ methodPath: 'products.list',
+ arguments: [
+ {},
+ {
+ include:
+ 'primary_variant,variants,images,option_types,variants.option_values',
+ per_page: first,
+ ...filter,
+ image_transformation: {
+ quality: imagesQuality,
+ size: imagesSize,
+ },
+ },
+ ],
+ }
+
+ const config = commerce.getConfig(userConfig)
+ const { fetch: apiFetch } = config // TODO: Send config.locale to Spree.
+
+ const { data: spreeSuccessResponse } = await apiFetch<
+ IProducts,
+ SpreeSdkVariables
+ >('__UNUSED__', {
+ variables,
+ })
+
+ const normalizedProducts: Product[] = spreeSuccessResponse.data.map(
+ (spreeProduct) => normalizeProduct(spreeSuccessResponse, spreeProduct)
+ )
+
+ return { products: normalizedProducts }
+ }
+
+ return getAllProducts
+}
diff --git a/framework/spree/api/operations/get-customer-wishlist.ts b/framework/spree/api/operations/get-customer-wishlist.ts
new file mode 100644
index 0000000000..8c34b9e875
--- /dev/null
+++ b/framework/spree/api/operations/get-customer-wishlist.ts
@@ -0,0 +1,6 @@
+export default function getCustomerWishlistOperation() {
+ function getCustomerWishlist(): any {
+ return { wishlist: {} }
+ }
+ return getCustomerWishlist
+}
diff --git a/framework/spree/api/operations/get-page.ts b/framework/spree/api/operations/get-page.ts
new file mode 100644
index 0000000000..ecb02755d0
--- /dev/null
+++ b/framework/spree/api/operations/get-page.ts
@@ -0,0 +1,81 @@
+import type {
+ OperationContext,
+ OperationOptions,
+} from '@commerce/api/operations'
+import type { GetPageOperation } from '@commerce/types/page'
+import type { SpreeSdkVariables } from '../../types'
+import type { SpreeApiConfig, SpreeApiProvider } from '..'
+import type { IPage } from '@spree/storefront-api-v2-sdk/types/interfaces/Page'
+import normalizePage from '../../utils/normalizations/normalize-page'
+
+export type Page = any
+export type GetPageResult = { page?: Page }
+
+export type PageVariables = {
+ id: number
+}
+
+export default function getPageOperation({
+ commerce,
+}: OperationContext) {
+ async function getPage(opts: {
+ variables: T['variables']
+ config?: Partial
+ preview?: boolean
+ }): Promise
+
+ async function getPage(
+ opts: {
+ variables: T['variables']
+ config?: Partial
+ preview?: boolean
+ } & OperationOptions
+ ): Promise
+
+ async function getPage({
+ url,
+ config: userConfig,
+ preview,
+ variables: getPageVariables,
+ }: {
+ url?: string
+ variables: T['variables']
+ config?: Partial
+ preview?: boolean
+ }): Promise {
+ console.info(
+ 'getPage called. Configuration: ',
+ 'userConfig: ',
+ userConfig,
+ 'preview: ',
+ preview,
+ 'url: ',
+ url
+ )
+
+ const config = commerce.getConfig(userConfig)
+ const { fetch: apiFetch } = config
+
+ const variables: SpreeSdkVariables = {
+ methodPath: 'pages.show',
+ arguments: [getPageVariables.id],
+ }
+
+ const { data: spreeSuccessResponse } = await apiFetch<
+ IPage,
+ SpreeSdkVariables
+ >('__UNUSED__', {
+ variables,
+ })
+
+ const normalizedPage: Page = normalizePage(
+ spreeSuccessResponse,
+ spreeSuccessResponse.data,
+ config.locales || []
+ )
+
+ return { page: normalizedPage }
+ }
+
+ return getPage
+}
diff --git a/framework/spree/api/operations/get-product.ts b/framework/spree/api/operations/get-product.ts
new file mode 100644
index 0000000000..18e9643cd1
--- /dev/null
+++ b/framework/spree/api/operations/get-product.ts
@@ -0,0 +1,90 @@
+import type { SpreeApiConfig, SpreeApiProvider } from '../index'
+import type { GetProductOperation } from '@commerce/types/product'
+import type {
+ OperationContext,
+ OperationOptions,
+} from '@commerce/api/operations'
+import type { IProduct } from '@spree/storefront-api-v2-sdk/types/interfaces/Product'
+import type { SpreeSdkVariables } from '../../types'
+import MissingSlugVariableError from '../../errors/MissingSlugVariableError'
+import normalizeProduct from '../../utils/normalizations/normalize-product'
+import { requireConfigValue } from '../../isomorphic-config'
+
+const imagesSize = requireConfigValue('imagesSize') as string
+const imagesQuality = requireConfigValue('imagesQuality') as number
+
+export default function getProductOperation({
+ commerce,
+}: OperationContext) {
+ async function getProduct(opts: {
+ variables: T['variables']
+ config?: Partial
+ preview?: boolean
+ }): Promise
+
+ async function getProduct(
+ opts: {
+ variables: T['variables']
+ config?: Partial
+ preview?: boolean
+ } & OperationOptions
+ ): Promise
+
+ async function getProduct({
+ query = '',
+ variables: getProductVariables,
+ config: userConfig,
+ }: {
+ query?: string
+ variables?: T['variables']
+ config?: Partial
+ preview?: boolean
+ }): Promise {
+ console.log(
+ 'getProduct called. Configuration: ',
+ 'getProductVariables: ',
+ getProductVariables,
+ 'config: ',
+ userConfig
+ )
+
+ if (!getProductVariables?.slug) {
+ throw new MissingSlugVariableError()
+ }
+
+ const variables: SpreeSdkVariables = {
+ methodPath: 'products.show',
+ arguments: [
+ getProductVariables.slug,
+ {},
+ {
+ include:
+ 'primary_variant,variants,images,option_types,variants.option_values',
+ image_transformation: {
+ quality: imagesQuality,
+ size: imagesSize,
+ },
+ },
+ ],
+ }
+
+ const config = commerce.getConfig(userConfig)
+ const { fetch: apiFetch } = config // TODO: Send config.locale to Spree.
+
+ const { data: spreeSuccessResponse } = await apiFetch<
+ IProduct,
+ SpreeSdkVariables
+ >('__UNUSED__', {
+ variables,
+ })
+
+ return {
+ product: normalizeProduct(
+ spreeSuccessResponse,
+ spreeSuccessResponse.data
+ ),
+ }
+ }
+
+ return getProduct
+}
diff --git a/framework/spree/api/operations/get-site-info.ts b/framework/spree/api/operations/get-site-info.ts
new file mode 100644
index 0000000000..4d9aaf0ad8
--- /dev/null
+++ b/framework/spree/api/operations/get-site-info.ts
@@ -0,0 +1,135 @@
+import type {
+ OperationContext,
+ OperationOptions,
+} from '@commerce/api/operations'
+import type { Category, GetSiteInfoOperation } from '@commerce/types/site'
+import type {
+ ITaxons,
+ TaxonAttr,
+} from '@spree/storefront-api-v2-sdk/types/interfaces/Taxon'
+import { requireConfigValue } from '../../isomorphic-config'
+import type { SpreeSdkVariables } from '../../types'
+import type { SpreeApiConfig, SpreeApiProvider } from '..'
+
+const taxonsSort = (spreeTaxon1: TaxonAttr, spreeTaxon2: TaxonAttr): number => {
+ const { left: left1, right: right1 } = spreeTaxon1.attributes
+ const { left: left2, right: right2 } = spreeTaxon2.attributes
+
+ if (right1 < left2) {
+ return -1
+ }
+
+ if (right2 < left1) {
+ return 1
+ }
+
+ return 0
+}
+
+export type GetSiteInfoResult<
+ T extends { categories: any[]; brands: any[] } = {
+ categories: Category[]
+ brands: any[]
+ }
+> = T
+
+export default function getSiteInfoOperation({
+ commerce,
+}: OperationContext) {
+ async function getSiteInfo(opts?: {
+ config?: Partial
+ preview?: boolean
+ }): Promise
+
+ async function getSiteInfo(
+ opts: {
+ config?: Partial
+ preview?: boolean
+ } & OperationOptions
+ ): Promise
+
+ async function getSiteInfo({
+ query,
+ variables: getSiteInfoVariables = {},
+ config: userConfig,
+ }: {
+ query?: string
+ variables?: any
+ config?: Partial
+ preview?: boolean
+ } = {}): Promise {
+ console.info(
+ 'getSiteInfo called. Configuration: ',
+ 'query: ',
+ query,
+ 'getSiteInfoVariables ',
+ getSiteInfoVariables,
+ 'config: ',
+ userConfig
+ )
+
+ const createVariables = (parentPermalink: string): SpreeSdkVariables => ({
+ methodPath: 'taxons.list',
+ arguments: [
+ {
+ filter: {
+ parent_permalink: parentPermalink,
+ },
+ },
+ ],
+ })
+
+ const config = commerce.getConfig(userConfig)
+ const { fetch: apiFetch } = config // TODO: Send config.locale to Spree.
+
+ const { data: spreeCategoriesSuccessResponse } = await apiFetch<
+ ITaxons,
+ SpreeSdkVariables
+ >('__UNUSED__', {
+ variables: createVariables(
+ requireConfigValue('categoriesTaxonomyPermalink') as string
+ ),
+ })
+
+ const { data: spreeBrandsSuccessResponse } = await apiFetch<
+ ITaxons,
+ SpreeSdkVariables
+ >('__UNUSED__', {
+ variables: createVariables(
+ requireConfigValue('brandsTaxonomyPermalink') as string
+ ),
+ })
+
+ const normalizedCategories: GetSiteInfoOperation['data']['categories'] =
+ spreeCategoriesSuccessResponse.data
+ .sort(taxonsSort)
+ .map((spreeTaxon: TaxonAttr) => {
+ return {
+ id: spreeTaxon.id,
+ name: spreeTaxon.attributes.name,
+ slug: spreeTaxon.id,
+ path: spreeTaxon.id,
+ }
+ })
+
+ const normalizedBrands: GetSiteInfoOperation['data']['brands'] =
+ spreeBrandsSuccessResponse.data
+ .sort(taxonsSort)
+ .map((spreeTaxon: TaxonAttr) => {
+ return {
+ node: {
+ entityId: spreeTaxon.id,
+ path: `brands/${spreeTaxon.id}`,
+ name: spreeTaxon.attributes.name,
+ },
+ }
+ })
+
+ return {
+ categories: normalizedCategories,
+ brands: normalizedBrands,
+ }
+ }
+
+ return getSiteInfo
+}
diff --git a/framework/spree/api/operations/index.ts b/framework/spree/api/operations/index.ts
new file mode 100644
index 0000000000..086fdf83ae
--- /dev/null
+++ b/framework/spree/api/operations/index.ts
@@ -0,0 +1,6 @@
+export { default as getPage } from './get-page'
+export { default as getSiteInfo } from './get-site-info'
+export { default as getAllPages } from './get-all-pages'
+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/spree/api/utils/create-api-fetch.ts b/framework/spree/api/utils/create-api-fetch.ts
new file mode 100644
index 0000000000..0c7d51b0b2
--- /dev/null
+++ b/framework/spree/api/utils/create-api-fetch.ts
@@ -0,0 +1,79 @@
+import { SpreeApiConfig } from '..'
+import { errors, makeClient } from '@spree/storefront-api-v2-sdk'
+import { requireConfigValue } from '../../isomorphic-config'
+import convertSpreeErrorToGraphQlError from '../../utils/convert-spree-error-to-graph-ql-error'
+import type { ResultResponse } from '@spree/storefront-api-v2-sdk/types/interfaces/ResultResponse'
+import getSpreeSdkMethodFromEndpointPath from '../../utils/get-spree-sdk-method-from-endpoint-path'
+import SpreeSdkMethodFromEndpointPathError from '../../errors/SpreeSdkMethodFromEndpointPathError'
+import { GraphQLFetcher, GraphQLFetcherResult } from '@commerce/api'
+import createCustomizedFetchFetcher, {
+ fetchResponseKey,
+} from '../../utils/create-customized-fetch-fetcher'
+import fetch, { Request } from 'node-fetch'
+import type { SpreeSdkResponseWithRawResponse } from '../../types'
+
+export type CreateApiFetch = (
+ getConfig: () => SpreeApiConfig
+) => GraphQLFetcher, any>
+
+// TODO: GraphQLFetcher, any> should be GraphQLFetcher, SpreeSdkVariables>.
+// But CommerceAPIConfig['fetch'] cannot be extended from Variables = any to SpreeSdkVariables.
+
+const createApiFetch: CreateApiFetch = (_getConfig) => {
+ const client = makeClient({
+ host: requireConfigValue('apiHost') as string,
+ createFetcher: (fetcherOptions) => {
+ return createCustomizedFetchFetcher({
+ fetch,
+ requestConstructor: Request,
+ ...fetcherOptions,
+ })
+ },
+ })
+
+ return async (url, queryData = {}, fetchOptions = {}) => {
+ console.log(
+ 'apiFetch called. query = ',
+ 'url = ',
+ url,
+ 'queryData = ',
+ queryData,
+ 'fetchOptions = ',
+ fetchOptions
+ )
+
+ const { variables } = queryData
+
+ if (!variables) {
+ throw new SpreeSdkMethodFromEndpointPathError(
+ `Required SpreeSdkVariables not provided.`
+ )
+ }
+
+ const storeResponse: ResultResponse =
+ await getSpreeSdkMethodFromEndpointPath(
+ client,
+ variables.methodPath
+ )(...variables.arguments)
+
+ if (storeResponse.isSuccess()) {
+ const data = storeResponse.success()
+ const rawFetchResponse = data[fetchResponseKey]
+
+ return {
+ data,
+ res: rawFetchResponse,
+ }
+ }
+
+ const storeResponseError = storeResponse.fail()
+
+ if (storeResponseError instanceof errors.SpreeError) {
+ throw convertSpreeErrorToGraphQlError(storeResponseError)
+ }
+
+ throw storeResponseError
+ }
+}
+
+export default createApiFetch
diff --git a/framework/spree/api/utils/fetch.ts b/framework/spree/api/utils/fetch.ts
new file mode 100644
index 0000000000..26f9ab6748
--- /dev/null
+++ b/framework/spree/api/utils/fetch.ts
@@ -0,0 +1,3 @@
+import vercelFetch from '@vercel/fetch'
+
+export default vercelFetch()
diff --git a/framework/spree/auth/index.ts b/framework/spree/auth/index.ts
new file mode 100644
index 0000000000..36e757a89c
--- /dev/null
+++ b/framework/spree/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/spree/auth/use-login.tsx b/framework/spree/auth/use-login.tsx
new file mode 100644
index 0000000000..308ac6597c
--- /dev/null
+++ b/framework/spree/auth/use-login.tsx
@@ -0,0 +1,85 @@
+import { useCallback } from 'react'
+import type { MutationHook } from '@commerce/utils/types'
+import useLogin, { UseLogin } from '@commerce/auth/use-login'
+import type { LoginHook } from '@commerce/types/login'
+import type { AuthTokenAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Authentication'
+import { FetcherError, ValidationError } from '@commerce/utils/errors'
+import useCustomer from '../customer/use-customer'
+import useCart from '../cart/use-cart'
+import useWishlist from '../wishlist/use-wishlist'
+import login from '../utils/login'
+
+export default useLogin as UseLogin
+
+export const handler: MutationHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ url: 'authentication',
+ query: 'getToken',
+ },
+ async fetcher({ input, options, fetch }) {
+ console.info(
+ 'useLogin fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ const { email, password } = input
+
+ if (!email || !password) {
+ throw new ValidationError({
+ message: 'Email and password need to be provided.',
+ })
+ }
+
+ const getTokenParameters: AuthTokenAttr = {
+ username: email,
+ password,
+ }
+
+ try {
+ await login(fetch, getTokenParameters, false)
+
+ return null
+ } catch (getTokenError) {
+ if (
+ getTokenError instanceof FetcherError &&
+ getTokenError.status === 400
+ ) {
+ // Change the error message to be more user friendly.
+ throw new FetcherError({
+ status: getTokenError.status,
+ message: 'The email or password is invalid.',
+ code: getTokenError.code,
+ })
+ }
+
+ throw getTokenError
+ }
+ },
+ useHook: ({ fetch }) => {
+ const useWrappedHook: ReturnType['useHook']> =
+ () => {
+ const customer = useCustomer()
+ const cart = useCart()
+ const wishlist = useWishlist()
+
+ return useCallback(
+ async function login(input) {
+ const data = await fetch({ input })
+
+ await customer.revalidate()
+ await cart.revalidate()
+ await wishlist.revalidate()
+
+ return data
+ },
+ [customer, cart, wishlist]
+ )
+ }
+
+ return useWrappedHook
+ },
+}
diff --git a/framework/spree/auth/use-logout.tsx b/framework/spree/auth/use-logout.tsx
new file mode 100644
index 0000000000..0d8eb4bc9d
--- /dev/null
+++ b/framework/spree/auth/use-logout.tsx
@@ -0,0 +1,80 @@
+import { MutationHook } from '@commerce/utils/types'
+import useLogout, { UseLogout } from '@commerce/auth/use-logout'
+import type { LogoutHook } from '@commerce/types/logout'
+import { useCallback } from 'react'
+import useCustomer from '../customer/use-customer'
+import useCart from '../cart/use-cart'
+import useWishlist from '../wishlist/use-wishlist'
+import {
+ ensureUserTokenResponse,
+ removeUserTokenResponse,
+} from '../utils/tokens/user-token-response'
+import revokeUserTokens from '../utils/tokens/revoke-user-tokens'
+import TokensNotRejectedError from '../errors/TokensNotRejectedError'
+
+export default useLogout as UseLogout
+
+export const handler: MutationHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ url: 'authentication',
+ query: 'revokeToken',
+ },
+ async fetcher({ input, options, fetch }) {
+ console.info(
+ 'useLogout fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ const userToken = ensureUserTokenResponse()
+
+ if (userToken) {
+ try {
+ // Revoke any tokens associated with the logged in user.
+ await revokeUserTokens(fetch, {
+ accessToken: userToken.access_token,
+ refreshToken: userToken.refresh_token,
+ })
+ } catch (revokeUserTokenError) {
+ // Squash token revocation errors and rethrow anything else.
+ if (!(revokeUserTokenError instanceof TokensNotRejectedError)) {
+ throw revokeUserTokenError
+ }
+ }
+
+ // Whether token revocation succeeded or not, remove them from local storage.
+ removeUserTokenResponse()
+ }
+
+ return null
+ },
+ useHook: ({ fetch }) => {
+ const useWrappedHook: ReturnType['useHook']> =
+ () => {
+ const customer = useCustomer({
+ swrOptions: { isPaused: () => true },
+ })
+ const cart = useCart({
+ swrOptions: { isPaused: () => true },
+ })
+ const wishlist = useWishlist({
+ swrOptions: { isPaused: () => true },
+ })
+
+ return useCallback(async () => {
+ const data = await fetch()
+
+ await customer.mutate(null, false)
+ await cart.mutate(null, false)
+ await wishlist.mutate(null, false)
+
+ return data
+ }, [customer, cart, wishlist])
+ }
+
+ return useWrappedHook
+ },
+}
diff --git a/framework/spree/auth/use-signup.tsx b/framework/spree/auth/use-signup.tsx
new file mode 100644
index 0000000000..708668b9cb
--- /dev/null
+++ b/framework/spree/auth/use-signup.tsx
@@ -0,0 +1,95 @@
+import { useCallback } from 'react'
+import type { GraphQLFetcherResult } from '@commerce/api'
+import type { MutationHook } from '@commerce/utils/types'
+import useSignup, { UseSignup } from '@commerce/auth/use-signup'
+import type { SignupHook } from '@commerce/types/signup'
+import { ValidationError } from '@commerce/utils/errors'
+import type { IAccount } from '@spree/storefront-api-v2-sdk/types/interfaces/Account'
+import type { AuthTokenAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Authentication'
+import useCustomer from '../customer/use-customer'
+import useCart from '../cart/use-cart'
+import useWishlist from '../wishlist/use-wishlist'
+import login from '../utils/login'
+import { requireConfigValue } from '../isomorphic-config'
+
+export default useSignup as UseSignup
+
+export const handler: MutationHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ url: 'account',
+ query: 'create',
+ },
+ async fetcher({ input, options, fetch }) {
+ console.info(
+ 'useSignup fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ const { email, password } = input
+
+ if (!email || !password) {
+ throw new ValidationError({
+ message: 'Email and password need to be provided.',
+ })
+ }
+
+ // TODO: Replace any with specific type from Spree SDK
+ // once it's added to the SDK.
+ const createAccountParameters: any = {
+ user: {
+ email,
+ password,
+ // The stock NJC interface doesn't have a
+ // password confirmation field, so just copy password.
+ passwordConfirmation: password,
+ },
+ }
+
+ // Create the user account.
+ await fetch>({
+ variables: {
+ methodPath: 'account.create',
+ arguments: [createAccountParameters],
+ },
+ })
+
+ const getTokenParameters: AuthTokenAttr = {
+ username: email,
+ password,
+ }
+
+ // Login immediately after the account is created.
+ if (requireConfigValue('loginAfterSignup')) {
+ await login(fetch, getTokenParameters, true)
+ }
+
+ return null
+ },
+ useHook: ({ fetch }) => {
+ const useWrappedHook: ReturnType['useHook']> =
+ () => {
+ const customer = useCustomer()
+ const cart = useCart()
+ const wishlist = useWishlist()
+
+ return useCallback(
+ async (input) => {
+ const data = await fetch({ input })
+
+ await customer.revalidate()
+ await cart.revalidate()
+ await wishlist.revalidate()
+
+ return data
+ },
+ [customer, cart, wishlist]
+ )
+ }
+
+ return useWrappedHook
+ },
+}
diff --git a/framework/spree/cart/index.ts b/framework/spree/cart/index.ts
new file mode 100644
index 0000000000..3b8ba990ef
--- /dev/null
+++ b/framework/spree/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/spree/cart/use-add-item.tsx b/framework/spree/cart/use-add-item.tsx
new file mode 100644
index 0000000000..74bdd633fe
--- /dev/null
+++ b/framework/spree/cart/use-add-item.tsx
@@ -0,0 +1,117 @@
+import useAddItem from '@commerce/cart/use-add-item'
+import type { UseAddItem } from '@commerce/cart/use-add-item'
+import type { MutationHook } from '@commerce/utils/types'
+import { useCallback } from 'react'
+import useCart from './use-cart'
+import type { AddItemHook } from '@commerce/types/cart'
+import normalizeCart from '../utils/normalizations/normalize-cart'
+import type { GraphQLFetcherResult } from '@commerce/api'
+import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order'
+import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import type { AddItem } from '@spree/storefront-api-v2-sdk/types/interfaces/endpoints/CartClass'
+import { setCartToken } from '../utils/tokens/cart-token'
+import ensureIToken from '../utils/tokens/ensure-itoken'
+import createEmptyCart from '../utils/create-empty-cart'
+import { FetcherError } from '@commerce/utils/errors'
+import isLoggedIn from '../utils/tokens/is-logged-in'
+
+export default useAddItem as UseAddItem
+
+export const handler: MutationHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ url: 'cart',
+ query: 'addItem',
+ },
+ async fetcher({ input, options, fetch }) {
+ console.info(
+ 'useAddItem fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ const { quantity, productId, variantId } = input
+
+ const safeQuantity = quantity ?? 1
+
+ let token: IToken | undefined = ensureIToken()
+
+ const addItemParameters: AddItem = {
+ variant_id: variantId,
+ quantity: safeQuantity,
+ include: [
+ 'line_items',
+ 'line_items.variant',
+ 'line_items.variant.product',
+ 'line_items.variant.product.images',
+ 'line_items.variant.images',
+ 'line_items.variant.option_values',
+ 'line_items.variant.product.option_types',
+ ].join(','),
+ }
+
+ if (!token) {
+ const { data: spreeCartCreateSuccessResponse } = await createEmptyCart(
+ fetch
+ )
+
+ setCartToken(spreeCartCreateSuccessResponse.data.attributes.token)
+ token = ensureIToken()
+ }
+
+ try {
+ const { data: spreeSuccessResponse } = await fetch<
+ GraphQLFetcherResult
+ >({
+ variables: {
+ methodPath: 'cart.addItem',
+ arguments: [token, addItemParameters],
+ },
+ })
+
+ return normalizeCart(spreeSuccessResponse, spreeSuccessResponse.data)
+ } catch (addItemError) {
+ if (addItemError instanceof FetcherError && addItemError.status === 404) {
+ const { data: spreeRetroactiveCartCreateSuccessResponse } =
+ await createEmptyCart(fetch)
+
+ if (!isLoggedIn()) {
+ setCartToken(
+ spreeRetroactiveCartCreateSuccessResponse.data.attributes.token
+ )
+ }
+
+ // Return an empty cart. The user has to add the item again.
+ // This is going to be a rare situation.
+
+ return normalizeCart(
+ spreeRetroactiveCartCreateSuccessResponse,
+ spreeRetroactiveCartCreateSuccessResponse.data
+ )
+ }
+
+ throw addItemError
+ }
+ },
+ useHook: ({ fetch }) => {
+ const useWrappedHook: ReturnType['useHook']> =
+ () => {
+ const { mutate } = useCart()
+
+ return useCallback(
+ async (input) => {
+ const data = await fetch({ input })
+
+ await mutate(data, false)
+
+ return data
+ },
+ [mutate]
+ )
+ }
+
+ return useWrappedHook
+ },
+}
diff --git a/framework/spree/cart/use-cart.tsx b/framework/spree/cart/use-cart.tsx
new file mode 100644
index 0000000000..e700c27fad
--- /dev/null
+++ b/framework/spree/cart/use-cart.tsx
@@ -0,0 +1,123 @@
+import { useMemo } from 'react'
+import type { SWRHook } from '@commerce/utils/types'
+import useCart from '@commerce/cart/use-cart'
+import type { UseCart } from '@commerce/cart/use-cart'
+import type { GetCartHook } from '@commerce/types/cart'
+import normalizeCart from '../utils/normalizations/normalize-cart'
+import type { GraphQLFetcherResult } from '@commerce/api'
+import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order'
+import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import { FetcherError } from '@commerce/utils/errors'
+import { setCartToken } from '../utils/tokens/cart-token'
+import ensureIToken from '../utils/tokens/ensure-itoken'
+import isLoggedIn from '../utils/tokens/is-logged-in'
+import createEmptyCart from '../utils/create-empty-cart'
+import { requireConfigValue } from '../isomorphic-config'
+
+const imagesSize = requireConfigValue('imagesSize') as string
+const imagesQuality = requireConfigValue('imagesQuality') as number
+
+export default useCart as UseCart
+
+// This handler avoids calling /api/cart.
+// There doesn't seem to be a good reason to call it.
+// So far, only @framework/bigcommerce uses it.
+export const handler: SWRHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ url: 'cart',
+ query: 'show',
+ },
+ async fetcher({ input, options, fetch }) {
+ console.info(
+ 'useCart fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ let spreeCartResponse: IOrder | null
+
+ const token: IToken | undefined = ensureIToken()
+
+ if (!token) {
+ spreeCartResponse = null
+ } else {
+ try {
+ const { data: spreeCartShowSuccessResponse } = await fetch<
+ GraphQLFetcherResult
+ >({
+ variables: {
+ methodPath: 'cart.show',
+ arguments: [
+ token,
+ {
+ include: [
+ 'line_items',
+ 'line_items.variant',
+ 'line_items.variant.product',
+ 'line_items.variant.product.images',
+ 'line_items.variant.images',
+ 'line_items.variant.option_values',
+ 'line_items.variant.product.option_types',
+ ].join(','),
+ image_transformation: {
+ quality: imagesQuality,
+ size: imagesSize,
+ },
+ },
+ ],
+ },
+ })
+
+ spreeCartResponse = spreeCartShowSuccessResponse
+ } catch (fetchCartError) {
+ if (
+ !(fetchCartError instanceof FetcherError) ||
+ fetchCartError.status !== 404
+ ) {
+ throw fetchCartError
+ }
+
+ spreeCartResponse = null
+ }
+ }
+
+ if (!spreeCartResponse || spreeCartResponse?.data.attributes.completed_at) {
+ const { data: spreeCartCreateSuccessResponse } = await createEmptyCart(
+ fetch
+ )
+
+ spreeCartResponse = spreeCartCreateSuccessResponse
+
+ if (!isLoggedIn()) {
+ setCartToken(spreeCartResponse.data.attributes.token)
+ }
+ }
+
+ return normalizeCart(spreeCartResponse, spreeCartResponse.data)
+ },
+ useHook: ({ useData }) => {
+ const useWrappedHook: ReturnType['useHook']> = (
+ input
+ ) => {
+ const response = useData({
+ swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
+ })
+
+ return useMemo(() => {
+ return Object.create(response, {
+ isEmpty: {
+ get() {
+ return (response.data?.lineItems.length ?? 0) === 0
+ },
+ enumerable: true,
+ },
+ })
+ }, [response])
+ }
+
+ return useWrappedHook
+ },
+}
diff --git a/framework/spree/cart/use-remove-item.tsx b/framework/spree/cart/use-remove-item.tsx
new file mode 100644
index 0000000000..42e7536a91
--- /dev/null
+++ b/framework/spree/cart/use-remove-item.tsx
@@ -0,0 +1,118 @@
+import type { MutationHook } from '@commerce/utils/types'
+import useRemoveItem from '@commerce/cart/use-remove-item'
+import type { UseRemoveItem } from '@commerce/cart/use-remove-item'
+import type { RemoveItemHook } from '@commerce/types/cart'
+import useCart from './use-cart'
+import { useCallback } from 'react'
+import normalizeCart from '../utils/normalizations/normalize-cart'
+import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order'
+import type { GraphQLFetcherResult } from '@commerce/api'
+import type { IQuery } from '@spree/storefront-api-v2-sdk/types/interfaces/Query'
+import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import ensureIToken from '../utils/tokens/ensure-itoken'
+import createEmptyCart from '../utils/create-empty-cart'
+import { setCartToken } from '../utils/tokens/cart-token'
+import { FetcherError } from '@commerce/utils/errors'
+import isLoggedIn from '../utils/tokens/is-logged-in'
+
+export default useRemoveItem as UseRemoveItem
+
+export const handler: MutationHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ url: 'cart',
+ query: 'removeItem',
+ },
+ async fetcher({ input, options, fetch }) {
+ console.info(
+ 'useRemoveItem fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ const { itemId: lineItemId } = input
+
+ let token: IToken | undefined = ensureIToken()
+
+ if (!token) {
+ const { data: spreeCartCreateSuccessResponse } = await createEmptyCart(
+ fetch
+ )
+
+ setCartToken(spreeCartCreateSuccessResponse.data.attributes.token)
+ token = ensureIToken()
+ }
+
+ const removeItemParameters: IQuery = {
+ include: [
+ 'line_items',
+ 'line_items.variant',
+ 'line_items.variant.product',
+ 'line_items.variant.product.images',
+ 'line_items.variant.images',
+ 'line_items.variant.option_values',
+ 'line_items.variant.product.option_types',
+ ].join(','),
+ }
+
+ try {
+ const { data: spreeSuccessResponse } = await fetch<
+ GraphQLFetcherResult
+ >({
+ variables: {
+ methodPath: 'cart.removeItem',
+ arguments: [token, lineItemId, removeItemParameters],
+ },
+ })
+
+ return normalizeCart(spreeSuccessResponse, spreeSuccessResponse.data)
+ } catch (removeItemError) {
+ if (
+ removeItemError instanceof FetcherError &&
+ removeItemError.status === 404
+ ) {
+ const { data: spreeRetroactiveCartCreateSuccessResponse } =
+ await createEmptyCart(fetch)
+
+ if (!isLoggedIn()) {
+ setCartToken(
+ spreeRetroactiveCartCreateSuccessResponse.data.attributes.token
+ )
+ }
+
+ // Return an empty cart. This is going to be a rare situation.
+
+ return normalizeCart(
+ spreeRetroactiveCartCreateSuccessResponse,
+ spreeRetroactiveCartCreateSuccessResponse.data
+ )
+ }
+
+ throw removeItemError
+ }
+ },
+ useHook: ({ fetch }) => {
+ const useWrappedHook: ReturnType['useHook']> =
+ () => {
+ const { mutate } = useCart()
+
+ return useCallback(
+ async (input) => {
+ const data = await fetch({ input: { itemId: input.id } })
+
+ // Upon calling cart.removeItem, Spree returns the old version of the cart,
+ // with the already removed line item. Invalidate the useCart mutation
+ // to fetch the cart again.
+ await mutate(data, true)
+
+ return data
+ },
+ [mutate]
+ )
+ }
+
+ return useWrappedHook
+ },
+}
diff --git a/framework/spree/cart/use-update-item.tsx b/framework/spree/cart/use-update-item.tsx
new file mode 100644
index 0000000000..86b8599fa5
--- /dev/null
+++ b/framework/spree/cart/use-update-item.tsx
@@ -0,0 +1,145 @@
+import type { MutationHook } from '@commerce/utils/types'
+import useUpdateItem, { UseUpdateItem } from '@commerce/cart/use-update-item'
+import type { UpdateItemHook } from '@commerce/types/cart'
+import useCart from './use-cart'
+import { useMemo } from 'react'
+import { FetcherError, ValidationError } from '@commerce/utils/errors'
+import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import type { SetQuantity } from '@spree/storefront-api-v2-sdk/types/interfaces/endpoints/CartClass'
+import type { GraphQLFetcherResult } from '@commerce/api'
+import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order'
+import normalizeCart from '../utils/normalizations/normalize-cart'
+import debounce from 'lodash.debounce'
+import ensureIToken from '../utils/tokens/ensure-itoken'
+import createEmptyCart from '../utils/create-empty-cart'
+import { setCartToken } from '../utils/tokens/cart-token'
+import isLoggedIn from '../utils/tokens/is-logged-in'
+
+export default useUpdateItem as UseUpdateItem
+
+export const handler: MutationHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ url: 'cart',
+ query: 'setQuantity',
+ },
+ async fetcher({ input, options, fetch }) {
+ console.info(
+ 'useRemoveItem fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ const { itemId, item } = input
+
+ if (!item.quantity) {
+ throw new ValidationError({
+ message: 'Line item quantity needs to be provided.',
+ })
+ }
+
+ let token: IToken | undefined = ensureIToken()
+
+ if (!token) {
+ const { data: spreeCartCreateSuccessResponse } = await createEmptyCart(
+ fetch
+ )
+
+ setCartToken(spreeCartCreateSuccessResponse.data.attributes.token)
+ token = ensureIToken()
+ }
+
+ try {
+ const setQuantityParameters: SetQuantity = {
+ line_item_id: itemId,
+ quantity: item.quantity,
+ include: [
+ 'line_items',
+ 'line_items.variant',
+ 'line_items.variant.product',
+ 'line_items.variant.product.images',
+ 'line_items.variant.images',
+ 'line_items.variant.option_values',
+ 'line_items.variant.product.option_types',
+ ].join(','),
+ }
+
+ const { data: spreeSuccessResponse } = await fetch<
+ GraphQLFetcherResult
+ >({
+ variables: {
+ methodPath: 'cart.setQuantity',
+ arguments: [token, setQuantityParameters],
+ },
+ })
+
+ return normalizeCart(spreeSuccessResponse, spreeSuccessResponse.data)
+ } catch (updateItemError) {
+ if (
+ updateItemError instanceof FetcherError &&
+ updateItemError.status === 404
+ ) {
+ const { data: spreeRetroactiveCartCreateSuccessResponse } =
+ await createEmptyCart(fetch)
+
+ if (!isLoggedIn()) {
+ setCartToken(
+ spreeRetroactiveCartCreateSuccessResponse.data.attributes.token
+ )
+ }
+
+ // Return an empty cart. The user has to update the item again.
+ // This is going to be a rare situation.
+
+ return normalizeCart(
+ spreeRetroactiveCartCreateSuccessResponse,
+ spreeRetroactiveCartCreateSuccessResponse.data
+ )
+ }
+
+ throw updateItemError
+ }
+ },
+ useHook: ({ fetch }) => {
+ const useWrappedHook: ReturnType['useHook']> =
+ (context) => {
+ const { mutate } = useCart()
+
+ return useMemo(
+ () =>
+ debounce(async (input: UpdateItemHook['actionInput']) => {
+ const itemId = context?.item?.id
+ const productId = input.productId ?? context?.item?.productId
+ const variantId = input.variantId ?? context?.item?.variantId
+ const quantity = input.quantity
+
+ if (!itemId || !productId || !variantId) {
+ throw new ValidationError({
+ message: 'Invalid input used for this operation',
+ })
+ }
+
+ const data = await fetch({
+ input: {
+ item: {
+ productId,
+ variantId,
+ quantity,
+ },
+ itemId,
+ },
+ })
+
+ await mutate(data, false)
+
+ return data
+ }, context?.wait ?? 500),
+ [mutate, context]
+ )
+ }
+
+ return useWrappedHook
+ },
+}
diff --git a/framework/spree/checkout/use-checkout.tsx b/framework/spree/checkout/use-checkout.tsx
new file mode 100644
index 0000000000..dfd7fe02f5
--- /dev/null
+++ b/framework/spree/checkout/use-checkout.tsx
@@ -0,0 +1,17 @@
+import { SWRHook } from '@commerce/utils/types'
+import useCheckout, { UseCheckout } from '@commerce/checkout/use-checkout'
+
+export default useCheckout as UseCheckout
+
+export const handler: SWRHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ // TODO: Revise url and query
+ url: 'checkout',
+ query: 'show',
+ },
+ async fetcher({ input, options, fetch }) {},
+ useHook:
+ ({ useData }) =>
+ async (input) => ({}),
+}
diff --git a/framework/spree/commerce.config.json b/framework/spree/commerce.config.json
new file mode 100644
index 0000000000..6f8399fb50
--- /dev/null
+++ b/framework/spree/commerce.config.json
@@ -0,0 +1,10 @@
+{
+ "provider": "spree",
+ "features": {
+ "wishlist": true,
+ "cart": true,
+ "search": true,
+ "customerAuth": true,
+ "customCheckout": false
+ }
+}
diff --git a/framework/spree/customer/address/use-add-item.tsx b/framework/spree/customer/address/use-add-item.tsx
new file mode 100644
index 0000000000..c2f645a164
--- /dev/null
+++ b/framework/spree/customer/address/use-add-item.tsx
@@ -0,0 +1,18 @@
+import useAddItem from '@commerce/customer/address/use-add-item'
+import type { UseAddItem } from '@commerce/customer/address/use-add-item'
+import type { MutationHook } from '@commerce/utils/types'
+
+export default useAddItem as UseAddItem
+
+export const handler: MutationHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ url: 'account',
+ query: 'createAddress',
+ },
+ async fetcher({ input, options, fetch }) {},
+ useHook:
+ ({ fetch }) =>
+ () =>
+ async () => ({}),
+}
diff --git a/framework/spree/customer/card/use-add-item.tsx b/framework/spree/customer/card/use-add-item.tsx
new file mode 100644
index 0000000000..a8bb3cd887
--- /dev/null
+++ b/framework/spree/customer/card/use-add-item.tsx
@@ -0,0 +1,19 @@
+import useAddItem from '@commerce/customer/address/use-add-item'
+import type { UseAddItem } from '@commerce/customer/address/use-add-item'
+import type { MutationHook } from '@commerce/utils/types'
+
+export default useAddItem as UseAddItem
+
+export const handler: MutationHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ // TODO: Revise url and query
+ url: 'checkout',
+ query: 'addPayment',
+ },
+ async fetcher({ input, options, fetch }) {},
+ useHook:
+ ({ fetch }) =>
+ () =>
+ async () => ({}),
+}
diff --git a/framework/spree/customer/index.ts b/framework/spree/customer/index.ts
new file mode 100644
index 0000000000..6c903ecc55
--- /dev/null
+++ b/framework/spree/customer/index.ts
@@ -0,0 +1 @@
+export { default as useCustomer } from './use-customer'
diff --git a/framework/spree/customer/use-customer.tsx b/framework/spree/customer/use-customer.tsx
new file mode 100644
index 0000000000..647645ac2d
--- /dev/null
+++ b/framework/spree/customer/use-customer.tsx
@@ -0,0 +1,83 @@
+import type { SWRHook } from '@commerce/utils/types'
+import useCustomer from '@commerce/customer/use-customer'
+import type { UseCustomer } from '@commerce/customer/use-customer'
+import type { CustomerHook } from '@commerce/types/customer'
+import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import type { GraphQLFetcherResult } from '@commerce/api'
+import type { IAccount } from '@spree/storefront-api-v2-sdk/types/interfaces/Account'
+import { FetcherError } from '@commerce/utils/errors'
+import normalizeUser from '../utils/normalizations/normalize-user'
+import isLoggedIn from '../utils/tokens/is-logged-in'
+import ensureIToken from '../utils/tokens/ensure-itoken'
+
+export default useCustomer as UseCustomer
+
+export const handler: SWRHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ url: 'account',
+ query: 'get',
+ },
+ async fetcher({ input, options, fetch }) {
+ console.info(
+ 'useCustomer fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ if (!isLoggedIn()) {
+ return null
+ }
+
+ const token: IToken | undefined = ensureIToken()
+
+ if (!token) {
+ return null
+ }
+
+ try {
+ const { data: spreeAccountInfoSuccessResponse } = await fetch<
+ GraphQLFetcherResult
+ >({
+ variables: {
+ methodPath: 'account.accountInfo',
+ arguments: [token],
+ },
+ })
+
+ const spreeUser = spreeAccountInfoSuccessResponse.data
+
+ const normalizedUser = normalizeUser(
+ spreeAccountInfoSuccessResponse,
+ spreeUser
+ )
+
+ return normalizedUser
+ } catch (fetchUserError) {
+ if (
+ !(fetchUserError instanceof FetcherError) ||
+ fetchUserError.status !== 404
+ ) {
+ throw fetchUserError
+ }
+
+ return null
+ }
+ },
+ useHook: ({ useData }) => {
+ const useWrappedHook: ReturnType['useHook']> = (
+ input
+ ) => {
+ return useData({
+ swrOptions: {
+ revalidateOnFocus: false,
+ ...input?.swrOptions,
+ },
+ })
+ }
+
+ return useWrappedHook
+ },
+}
diff --git a/framework/spree/errors/AccessTokenError.ts b/framework/spree/errors/AccessTokenError.ts
new file mode 100644
index 0000000000..4c79c0be80
--- /dev/null
+++ b/framework/spree/errors/AccessTokenError.ts
@@ -0,0 +1 @@
+export default class AccessTokenError extends Error {}
diff --git a/framework/spree/errors/MisconfigurationError.ts b/framework/spree/errors/MisconfigurationError.ts
new file mode 100644
index 0000000000..0717ae4045
--- /dev/null
+++ b/framework/spree/errors/MisconfigurationError.ts
@@ -0,0 +1 @@
+export default class MisconfigurationError extends Error {}
diff --git a/framework/spree/errors/MissingConfigurationValueError.ts b/framework/spree/errors/MissingConfigurationValueError.ts
new file mode 100644
index 0000000000..02b497bf14
--- /dev/null
+++ b/framework/spree/errors/MissingConfigurationValueError.ts
@@ -0,0 +1 @@
+export default class MissingConfigurationValueError extends Error {}
diff --git a/framework/spree/errors/MissingLineItemVariantError.ts b/framework/spree/errors/MissingLineItemVariantError.ts
new file mode 100644
index 0000000000..d9bee08039
--- /dev/null
+++ b/framework/spree/errors/MissingLineItemVariantError.ts
@@ -0,0 +1 @@
+export default class MissingLineItemVariantError extends Error {}
diff --git a/framework/spree/errors/MissingOptionValueError.ts b/framework/spree/errors/MissingOptionValueError.ts
new file mode 100644
index 0000000000..04457ac5e3
--- /dev/null
+++ b/framework/spree/errors/MissingOptionValueError.ts
@@ -0,0 +1 @@
+export default class MissingOptionValueError extends Error {}
diff --git a/framework/spree/errors/MissingPrimaryVariantError.ts b/framework/spree/errors/MissingPrimaryVariantError.ts
new file mode 100644
index 0000000000..f9af41b035
--- /dev/null
+++ b/framework/spree/errors/MissingPrimaryVariantError.ts
@@ -0,0 +1 @@
+export default class MissingPrimaryVariantError extends Error {}
diff --git a/framework/spree/errors/MissingProductError.ts b/framework/spree/errors/MissingProductError.ts
new file mode 100644
index 0000000000..3098be6890
--- /dev/null
+++ b/framework/spree/errors/MissingProductError.ts
@@ -0,0 +1 @@
+export default class MissingProductError extends Error {}
diff --git a/framework/spree/errors/MissingSlugVariableError.ts b/framework/spree/errors/MissingSlugVariableError.ts
new file mode 100644
index 0000000000..09b9d2e200
--- /dev/null
+++ b/framework/spree/errors/MissingSlugVariableError.ts
@@ -0,0 +1 @@
+export default class MissingSlugVariableError extends Error {}
diff --git a/framework/spree/errors/MissingVariantError.ts b/framework/spree/errors/MissingVariantError.ts
new file mode 100644
index 0000000000..5ed9e0ed24
--- /dev/null
+++ b/framework/spree/errors/MissingVariantError.ts
@@ -0,0 +1 @@
+export default class MissingVariantError extends Error {}
diff --git a/framework/spree/errors/RefreshTokenError.ts b/framework/spree/errors/RefreshTokenError.ts
new file mode 100644
index 0000000000..a79365bbbe
--- /dev/null
+++ b/framework/spree/errors/RefreshTokenError.ts
@@ -0,0 +1 @@
+export default class RefreshTokenError extends Error {}
diff --git a/framework/spree/errors/SpreeResponseContentError.ts b/framework/spree/errors/SpreeResponseContentError.ts
new file mode 100644
index 0000000000..19c10cf2e9
--- /dev/null
+++ b/framework/spree/errors/SpreeResponseContentError.ts
@@ -0,0 +1 @@
+export default class SpreeResponseContentError extends Error {}
diff --git a/framework/spree/errors/SpreeSdkMethodFromEndpointPathError.ts b/framework/spree/errors/SpreeSdkMethodFromEndpointPathError.ts
new file mode 100644
index 0000000000..bf15aada0d
--- /dev/null
+++ b/framework/spree/errors/SpreeSdkMethodFromEndpointPathError.ts
@@ -0,0 +1 @@
+export default class SpreeSdkMethodFromEndpointPathError extends Error {}
diff --git a/framework/spree/errors/TokensNotRejectedError.ts b/framework/spree/errors/TokensNotRejectedError.ts
new file mode 100644
index 0000000000..245f66414e
--- /dev/null
+++ b/framework/spree/errors/TokensNotRejectedError.ts
@@ -0,0 +1 @@
+export default class TokensNotRejectedError extends Error {}
diff --git a/framework/spree/errors/UserTokenResponseParseError.ts b/framework/spree/errors/UserTokenResponseParseError.ts
new file mode 100644
index 0000000000..9631971c14
--- /dev/null
+++ b/framework/spree/errors/UserTokenResponseParseError.ts
@@ -0,0 +1 @@
+export default class UserTokenResponseParseError extends Error {}
diff --git a/framework/spree/fetcher.ts b/framework/spree/fetcher.ts
new file mode 100644
index 0000000000..c9505e4c93
--- /dev/null
+++ b/framework/spree/fetcher.ts
@@ -0,0 +1,116 @@
+import type { Fetcher } from '@commerce/utils/types'
+import convertSpreeErrorToGraphQlError from './utils/convert-spree-error-to-graph-ql-error'
+import { makeClient, errors } from '@spree/storefront-api-v2-sdk'
+import type { ResultResponse } from '@spree/storefront-api-v2-sdk/types/interfaces/ResultResponse'
+import type { GraphQLFetcherResult } from '@commerce/api'
+import { requireConfigValue } from './isomorphic-config'
+import getSpreeSdkMethodFromEndpointPath from './utils/get-spree-sdk-method-from-endpoint-path'
+import SpreeSdkMethodFromEndpointPathError from './errors/SpreeSdkMethodFromEndpointPathError'
+import type {
+ FetcherVariables,
+ SpreeSdkResponse,
+ SpreeSdkResponseWithRawResponse,
+} from './types'
+import createCustomizedFetchFetcher, {
+ fetchResponseKey,
+} from './utils/create-customized-fetch-fetcher'
+import ensureFreshUserAccessToken from './utils/tokens/ensure-fresh-user-access-token'
+import RefreshTokenError from './errors/RefreshTokenError'
+
+const client = makeClient({
+ host: requireConfigValue('apiHost') as string,
+ createFetcher: (fetcherOptions) => {
+ return createCustomizedFetchFetcher({
+ fetch: globalThis.fetch,
+ requestConstructor: globalThis.Request,
+ ...fetcherOptions,
+ })
+ },
+})
+
+const normalizeSpreeSuccessResponse = (
+ storeResponse: ResultResponse
+): GraphQLFetcherResult => {
+ const data = storeResponse.success()
+ const rawFetchResponse = data[fetchResponseKey]
+
+ return {
+ data,
+ res: rawFetchResponse,
+ }
+}
+
+const fetcher: Fetcher> = async (
+ requestOptions
+) => {
+ const { url, method, variables, query } = requestOptions
+
+ console.log(
+ 'Fetcher called. Configuration: ',
+ 'url = ',
+ url,
+ 'requestOptions = ',
+ requestOptions
+ )
+
+ if (!variables) {
+ throw new SpreeSdkMethodFromEndpointPathError(
+ `Required FetcherVariables not provided.`
+ )
+ }
+
+ const {
+ methodPath,
+ arguments: args,
+ refreshExpiredAccessToken = true,
+ replayUnauthorizedRequest = true,
+ } = variables as FetcherVariables
+
+ if (refreshExpiredAccessToken) {
+ await ensureFreshUserAccessToken(client)
+ }
+
+ const spreeSdkMethod = getSpreeSdkMethodFromEndpointPath(client, methodPath)
+
+ const storeResponse: ResultResponse =
+ await spreeSdkMethod(...args)
+
+ if (storeResponse.isSuccess()) {
+ return normalizeSpreeSuccessResponse(storeResponse)
+ }
+
+ const storeResponseError = storeResponse.fail()
+
+ if (
+ storeResponseError instanceof errors.SpreeError &&
+ storeResponseError.serverResponse.status === 401 &&
+ replayUnauthorizedRequest
+ ) {
+ console.info(
+ 'Request ended with 401. Replaying request after refreshing the user token.'
+ )
+
+ await ensureFreshUserAccessToken(client)
+
+ const replayedStoreResponse: ResultResponse =
+ await spreeSdkMethod(...args)
+
+ if (replayedStoreResponse.isSuccess()) {
+ return normalizeSpreeSuccessResponse(replayedStoreResponse)
+ }
+
+ console.warn('Replaying the request failed', replayedStoreResponse.fail())
+
+ throw new RefreshTokenError(
+ 'Could not authorize request with current access token.'
+ )
+ }
+
+ if (storeResponseError instanceof errors.SpreeError) {
+ throw convertSpreeErrorToGraphQlError(storeResponseError)
+ }
+
+ throw storeResponseError
+}
+
+export default fetcher
diff --git a/framework/spree/index.tsx b/framework/spree/index.tsx
new file mode 100644
index 0000000000..f7eff69e9c
--- /dev/null
+++ b/framework/spree/index.tsx
@@ -0,0 +1,49 @@
+import type { ComponentType, FunctionComponent } from 'react'
+import {
+ Provider,
+ CommerceProviderProps,
+ CoreCommerceProvider,
+ useCommerce as useCoreCommerce,
+} from '@commerce'
+import { spreeProvider } from './provider'
+import type { SpreeProvider } from './provider'
+import { SWRConfig } from 'swr'
+import handleTokenErrors from './utils/handle-token-errors'
+import useLogout from '@commerce/auth/use-logout'
+
+export { spreeProvider }
+export type { SpreeProvider }
+
+export const WithTokenErrorsHandling: FunctionComponent = ({ children }) => {
+ const logout = useLogout()
+
+ return (
+ {
+ handleTokenErrors(error, () => void logout())
+ },
+ }}
+ >
+ {children}
+
+ )
+}
+
+export const getCommerceProvider = (provider: P) => {
+ return function CommerceProvider({
+ children,
+ ...props
+ }: CommerceProviderProps) {
+ return (
+
+ {children}
+
+ )
+ }
+}
+
+export const CommerceProvider =
+ getCommerceProvider(spreeProvider)
+
+export const useCommerce = () => useCoreCommerce()
diff --git a/framework/spree/isomorphic-config.ts b/framework/spree/isomorphic-config.ts
new file mode 100644
index 0000000000..b824fd80a3
--- /dev/null
+++ b/framework/spree/isomorphic-config.ts
@@ -0,0 +1,81 @@
+import forceIsomorphicConfigValues from './utils/force-isomorphic-config-values'
+import requireConfig from './utils/require-config'
+import validateAllProductsTaxonomyId from './utils/validations/validate-all-products-taxonomy-id'
+import validateCookieExpire from './utils/validations/validate-cookie-expire'
+import validateImagesOptionFilter from './utils/validations/validate-images-option-filter'
+import validatePlaceholderImageUrl from './utils/validations/validate-placeholder-image-url'
+import validateProductsPrerenderCount from './utils/validations/validate-products-prerender-count'
+import validateImagesSize from './utils/validations/validate-images-size'
+import validateImagesQuality from './utils/validations/validate-images-quality'
+
+const isomorphicConfig = {
+ apiHost: process.env.NEXT_PUBLIC_SPREE_API_HOST,
+ defaultLocale: process.env.NEXT_PUBLIC_SPREE_DEFAULT_LOCALE,
+ cartCookieName: process.env.NEXT_PUBLIC_SPREE_CART_COOKIE_NAME,
+ cartCookieExpire: validateCookieExpire(
+ process.env.NEXT_PUBLIC_SPREE_CART_COOKIE_EXPIRE
+ ),
+ userCookieName: process.env.NEXT_PUBLIC_SPREE_USER_COOKIE_NAME,
+ userCookieExpire: validateCookieExpire(
+ process.env.NEXT_PUBLIC_SPREE_CART_COOKIE_EXPIRE
+ ),
+ imageHost: process.env.NEXT_PUBLIC_SPREE_IMAGE_HOST,
+ categoriesTaxonomyPermalink:
+ process.env.NEXT_PUBLIC_SPREE_CATEGORIES_TAXONOMY_PERMALINK,
+ brandsTaxonomyPermalink:
+ process.env.NEXT_PUBLIC_SPREE_BRANDS_TAXONOMY_PERMALINK,
+ allProductsTaxonomyId: validateAllProductsTaxonomyId(
+ process.env.NEXT_PUBLIC_SPREE_ALL_PRODUCTS_TAXONOMY_ID
+ ),
+ showSingleVariantOptions:
+ process.env.NEXT_PUBLIC_SPREE_SHOW_SINGLE_VARIANT_OPTIONS === 'true',
+ lastUpdatedProductsPrerenderCount: validateProductsPrerenderCount(
+ process.env.NEXT_PUBLIC_SPREE_LAST_UPDATED_PRODUCTS_PRERENDER_COUNT
+ ),
+ productPlaceholderImageUrl: validatePlaceholderImageUrl(
+ process.env.NEXT_PUBLIC_SPREE_PRODUCT_PLACEHOLDER_IMAGE_URL
+ ),
+ lineItemPlaceholderImageUrl: validatePlaceholderImageUrl(
+ process.env.NEXT_PUBLIC_SPREE_LINE_ITEM_PLACEHOLDER_IMAGE_URL
+ ),
+ imagesOptionFilter: validateImagesOptionFilter(
+ process.env.NEXT_PUBLIC_SPREE_IMAGES_OPTION_FILTER
+ ),
+ imagesSize: validateImagesSize(process.env.NEXT_PUBLIC_SPREE_IMAGES_SIZE),
+ imagesQuality: validateImagesQuality(
+ process.env.NEXT_PUBLIC_SPREE_IMAGES_QUALITY
+ ),
+ loginAfterSignup: process.env.NEXT_PUBLIC_SPREE_LOGIN_AFTER_SIGNUP === 'true',
+}
+
+export default forceIsomorphicConfigValues(
+ isomorphicConfig,
+ [],
+ [
+ 'apiHost',
+ 'defaultLocale',
+ 'cartCookieName',
+ 'cartCookieExpire',
+ 'userCookieName',
+ 'userCookieExpire',
+ 'imageHost',
+ 'categoriesTaxonomyPermalink',
+ 'brandsTaxonomyPermalink',
+ 'allProductsTaxonomyId',
+ 'showSingleVariantOptions',
+ 'lastUpdatedProductsPrerenderCount',
+ 'productPlaceholderImageUrl',
+ 'lineItemPlaceholderImageUrl',
+ 'imagesOptionFilter',
+ 'imagesSize',
+ 'imagesQuality',
+ 'loginAfterSignup',
+ ]
+)
+
+type IsomorphicConfig = typeof isomorphicConfig
+
+const requireConfigValue = (key: keyof IsomorphicConfig) =>
+ requireConfig(isomorphicConfig, key)
+
+export { requireConfigValue }
diff --git a/framework/spree/next.config.js b/framework/spree/next.config.js
new file mode 100644
index 0000000000..0aaa87e0a7
--- /dev/null
+++ b/framework/spree/next.config.js
@@ -0,0 +1,16 @@
+const commerce = require('./commerce.config.json')
+
+module.exports = {
+ commerce,
+ images: {
+ domains: [process.env.NEXT_PUBLIC_SPREE_ALLOWED_IMAGE_DOMAIN],
+ },
+ rewrites() {
+ return [
+ {
+ source: '/checkout',
+ destination: '/api/checkout',
+ },
+ ]
+ },
+}
diff --git a/framework/spree/product/index.ts b/framework/spree/product/index.ts
new file mode 100644
index 0000000000..426a3edcd5
--- /dev/null
+++ b/framework/spree/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/spree/product/use-price.tsx b/framework/spree/product/use-price.tsx
new file mode 100644
index 0000000000..0174faf5e8
--- /dev/null
+++ b/framework/spree/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/spree/product/use-search.tsx b/framework/spree/product/use-search.tsx
new file mode 100644
index 0000000000..5912a72cab
--- /dev/null
+++ b/framework/spree/product/use-search.tsx
@@ -0,0 +1,101 @@
+import type { SWRHook } from '@commerce/utils/types'
+import useSearch from '@commerce/product/use-search'
+import type { Product, SearchProductsHook } from '@commerce/types/product'
+import type { UseSearch } from '@commerce/product/use-search'
+import normalizeProduct from '../utils/normalizations/normalize-product'
+import type { GraphQLFetcherResult } from '@commerce/api'
+import { IProducts } from '@spree/storefront-api-v2-sdk/types/interfaces/Product'
+import { requireConfigValue } from '../isomorphic-config'
+
+const imagesSize = requireConfigValue('imagesSize') as string
+const imagesQuality = requireConfigValue('imagesQuality') as number
+
+const nextToSpreeSortMap: { [key: string]: string } = {
+ 'trending-desc': 'available_on',
+ 'latest-desc': 'updated_at',
+ 'price-asc': 'price',
+ 'price-desc': '-price',
+}
+
+export const handler: SWRHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ url: 'products',
+ query: 'list',
+ },
+ async fetcher({ input, options, fetch }) {
+ // This method is only needed if the options need to be modified before calling the generic fetcher (created in createFetcher).
+
+ console.info(
+ 'useSearch fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ const taxons = [input.categoryId, input.brandId].filter(Boolean)
+
+ const filter = {
+ filter: {
+ ...(taxons.length > 0 ? { taxons: taxons.join(',') } : {}),
+ ...(input.search ? { name: input.search } : {}),
+ },
+ }
+
+ const sort = input.sort ? { sort: nextToSpreeSortMap[input.sort] } : {}
+
+ const { data: spreeSuccessResponse } = await fetch<
+ GraphQLFetcherResult
+ >({
+ variables: {
+ methodPath: 'products.list',
+ arguments: [
+ {},
+ {
+ include:
+ 'primary_variant,variants,images,option_types,variants.option_values',
+ per_page: 50,
+ ...filter,
+ ...sort,
+ image_transformation: {
+ quality: imagesQuality,
+ size: imagesSize,
+ },
+ },
+ ],
+ },
+ })
+
+ const normalizedProducts: Product[] = spreeSuccessResponse.data.map(
+ (spreeProduct) => normalizeProduct(spreeSuccessResponse, spreeProduct)
+ )
+
+ const found = spreeSuccessResponse.data.length > 0
+
+ return { products: normalizedProducts, found }
+ },
+ useHook: ({ useData }) => {
+ const useWrappedHook: ReturnType['useHook']> = (
+ input = {}
+ ) => {
+ return useData({
+ input: [
+ ['search', input.search],
+ ['categoryId', input.categoryId],
+ ['brandId', input.brandId],
+ ['sort', input.sort],
+ ],
+ swrOptions: {
+ revalidateOnFocus: false,
+ // revalidateOnFocus: false means do not fetch products again when website is refocused in the web browser.
+ ...input.swrOptions,
+ },
+ })
+ }
+
+ return useWrappedHook
+ },
+}
+
+export default useSearch as UseSearch
diff --git a/framework/spree/provider.ts b/framework/spree/provider.ts
new file mode 100644
index 0000000000..de6ddb2077
--- /dev/null
+++ b/framework/spree/provider.ts
@@ -0,0 +1,35 @@
+import fetcher from './fetcher'
+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 useWishlist } from './wishlist/use-wishlist'
+import { handler as useWishlistAddItem } from './wishlist/use-add-item'
+import { handler as useWishlistRemoveItem } from './wishlist/use-remove-item'
+import { requireConfigValue } from './isomorphic-config'
+
+const spreeProvider = {
+ locale: requireConfigValue('defaultLocale') as string,
+ cartCookie: requireConfigValue('cartCookieName') as string,
+ fetcher,
+ cart: { useCart, useAddItem, useUpdateItem, useRemoveItem },
+ customer: { useCustomer },
+ products: { useSearch },
+ auth: { useLogin, useLogout, useSignup },
+ checkout: { useCheckout },
+ wishlist: {
+ useWishlist,
+ useAddItem: useWishlistAddItem,
+ useRemoveItem: useWishlistRemoveItem,
+ },
+}
+
+export { spreeProvider }
+
+export type SpreeProvider = typeof spreeProvider
diff --git a/framework/spree/types/index.ts b/framework/spree/types/index.ts
new file mode 100644
index 0000000000..79b75c2494
--- /dev/null
+++ b/framework/spree/types/index.ts
@@ -0,0 +1,164 @@
+import type { fetchResponseKey } from '../utils/create-customized-fetch-fetcher'
+import type {
+ JsonApiDocument,
+ JsonApiListResponse,
+ JsonApiSingleResponse,
+} from '@spree/storefront-api-v2-sdk/types/interfaces/JsonApi'
+import type { ResultResponse } from '@spree/storefront-api-v2-sdk/types/interfaces/ResultResponse'
+import type { Response } from '@vercel/fetch'
+import type { ProductOption, Product } from '@commerce/types/product'
+import type {
+ AddItemHook,
+ RemoveItemHook,
+ WishlistItemBody,
+ WishlistTypes,
+} from '@commerce/types/wishlist'
+
+export type UnknownObjectValues = Record
+
+export type NonUndefined = T extends undefined ? never : T
+
+export type ValueOf = T[keyof T]
+
+export type SpreeSdkResponse = JsonApiSingleResponse | JsonApiListResponse
+
+export type SpreeSdkResponseWithRawResponse = SpreeSdkResponse & {
+ [fetchResponseKey]: Response
+}
+
+export type SpreeSdkResultResponseSuccessType = SpreeSdkResponseWithRawResponse
+
+export type SpreeSdkMethodReturnType<
+ ResultResponseSuccessType extends SpreeSdkResultResponseSuccessType = SpreeSdkResultResponseSuccessType
+> = Promise>
+
+export type SpreeSdkMethod<
+ ResultResponseSuccessType extends SpreeSdkResultResponseSuccessType = SpreeSdkResultResponseSuccessType
+> = (...args: any[]) => SpreeSdkMethodReturnType
+
+export type SpreeSdkVariables = {
+ methodPath: string
+ arguments: any[]
+}
+
+export type FetcherVariables = SpreeSdkVariables & {
+ refreshExpiredAccessToken: boolean
+ replayUnauthorizedRequest: boolean
+}
+
+export interface ImageStyle {
+ url: string
+ width: string
+ height: string
+ size: string
+}
+
+export interface SpreeProductImage extends JsonApiDocument {
+ attributes: {
+ position: number
+ alt: string
+ original_url: string
+ transformed_url: string | null
+ styles: ImageStyle[]
+ }
+}
+
+export interface OptionTypeAttr extends JsonApiDocument {
+ attributes: {
+ name: string
+ presentation: string
+ position: number
+ created_at: string
+ updated_at: string
+ filterable: boolean
+ }
+}
+
+export interface LineItemAttr extends JsonApiDocument {
+ attributes: {
+ name: string
+ quantity: number
+ slug: string
+ options_text: string
+ price: string
+ currency: string
+ display_price: string
+ total: string
+ display_total: string
+ adjustment_total: string
+ display_adjustment_total: string
+ additional_tax_total: string
+ display_additional_tax_total: string
+ discounted_amount: string
+ display_discounted_amount: string
+ pre_tax_amount: string
+ display_pre_tax_amount: string
+ promo_total: string
+ display_promo_total: string
+ included_tax_total: string
+ display_inluded_tax_total: string
+ }
+}
+
+export interface VariantAttr extends JsonApiDocument {
+ attributes: {
+ sku: string
+ price: string
+ currency: string
+ display_price: string
+ weight: string
+ height: string
+ width: string
+ depth: string
+ is_master: boolean
+ options_text: string
+ purchasable: boolean
+ in_stock: boolean
+ backorderable: boolean
+ }
+}
+
+export interface ProductSlugAttr extends JsonApiDocument {
+ attributes: {
+ slug: string
+ }
+}
+export interface IProductsSlugs extends JsonApiListResponse {
+ data: ProductSlugAttr[]
+}
+
+export type ExpandedProductOption = ProductOption & { position: number }
+
+export type UserOAuthTokens = {
+ refreshToken: string
+ accessToken: string
+}
+
+// TODO: ExplicitCommerceWishlist is a temporary type
+// derived from tsx views. It will be removed once
+// Wishlist in @commerce/types/wishlist is updated
+// to a more specific type than `any`.
+export type ExplicitCommerceWishlist = {
+ id: string
+ token: string
+ items: {
+ id: string
+ product_id: number
+ variant_id: number
+ product: Product
+ }[]
+}
+
+export type ExplicitWishlistAddItemHook = AddItemHook<
+ WishlistTypes & {
+ wishlist: ExplicitCommerceWishlist
+ itemBody: WishlistItemBody & {
+ wishlistToken?: string
+ }
+ }
+>
+
+export type ExplicitWishlistRemoveItemHook = RemoveItemHook & {
+ fetcherInput: { wishlistToken?: string }
+ body: { wishlistToken?: string }
+}
diff --git a/framework/spree/utils/convert-spree-error-to-graph-ql-error.ts b/framework/spree/utils/convert-spree-error-to-graph-ql-error.ts
new file mode 100644
index 0000000000..def4920ba0
--- /dev/null
+++ b/framework/spree/utils/convert-spree-error-to-graph-ql-error.ts
@@ -0,0 +1,52 @@
+import { FetcherError } from '@commerce/utils/errors'
+import { errors } from '@spree/storefront-api-v2-sdk'
+
+const convertSpreeErrorToGraphQlError = (
+ error: errors.SpreeError
+): FetcherError => {
+ if (error instanceof errors.ExpandedSpreeError) {
+ // Assuming error.errors[key] is a list of strings.
+
+ if ('base' in error.errors) {
+ const baseErrorMessage = error.errors.base as unknown as string
+
+ return new FetcherError({
+ status: error.serverResponse.status,
+ message: baseErrorMessage,
+ })
+ }
+
+ const fetcherErrors = Object.keys(error.errors).map((sdkErrorKey) => {
+ const errors = error.errors[sdkErrorKey] as string[]
+
+ // Naively assume sdkErrorKey is a label. Capitalize it for a better
+ // out-of-the-box experience.
+ const capitalizedSdkErrorKey = sdkErrorKey.replace(/^\w/, (firstChar) =>
+ firstChar.toUpperCase()
+ )
+
+ return {
+ message: `${capitalizedSdkErrorKey} ${errors.join(', ')}`,
+ }
+ })
+
+ return new FetcherError({
+ status: error.serverResponse.status,
+ errors: fetcherErrors,
+ })
+ }
+
+ if (error instanceof errors.BasicSpreeError) {
+ return new FetcherError({
+ status: error.serverResponse.status,
+ message: error.summary,
+ })
+ }
+
+ return new FetcherError({
+ status: error.serverResponse.status,
+ message: error.message,
+ })
+}
+
+export default convertSpreeErrorToGraphQlError
diff --git a/framework/spree/utils/create-customized-fetch-fetcher.ts b/framework/spree/utils/create-customized-fetch-fetcher.ts
new file mode 100644
index 0000000000..1c10b19e94
--- /dev/null
+++ b/framework/spree/utils/create-customized-fetch-fetcher.ts
@@ -0,0 +1,105 @@
+import {
+ errors,
+ request as spreeSdkRequestHelpers,
+} from '@spree/storefront-api-v2-sdk'
+import type { CreateCustomizedFetchFetcher } from '@spree/storefront-api-v2-sdk/types/interfaces/CreateCustomizedFetchFetcher'
+import isJsonContentType from './is-json-content-type'
+
+export const fetchResponseKey = Symbol('fetch-response-key')
+
+const createCustomizedFetchFetcher: CreateCustomizedFetchFetcher = (
+ fetcherOptions
+) => {
+ const { FetchError } = errors
+ const sharedHeaders = {
+ 'Content-Type': 'application/json',
+ }
+
+ const { host, fetch, requestConstructor } = fetcherOptions
+
+ return {
+ fetch: async (fetchOptions) => {
+ // This fetcher always returns request equal null,
+ // because @vercel/fetch doesn't accept a Request object as argument
+ // and it's not used by NJC anyway.
+ try {
+ const { url, params, method, headers, responseParsing } = fetchOptions
+ const absoluteUrl = new URL(url, host)
+ let payload
+
+ switch (method.toUpperCase()) {
+ case 'PUT':
+ case 'POST':
+ case 'DELETE':
+ case 'PATCH':
+ payload = { body: JSON.stringify(params) }
+ break
+ default:
+ payload = null
+ absoluteUrl.search =
+ spreeSdkRequestHelpers.objectToQuerystring(params)
+ }
+
+ const request: Request = new requestConstructor(
+ absoluteUrl.toString(),
+ {
+ method: method.toUpperCase(),
+ headers: { ...sharedHeaders, ...headers },
+ ...payload,
+ }
+ )
+
+ try {
+ const response: Response = await fetch(request)
+ const responseContentType = response.headers.get('content-type')
+ let data
+
+ if (responseParsing === 'automatic') {
+ if (responseContentType && isJsonContentType(responseContentType)) {
+ data = await response.json()
+ } else {
+ data = await response.text()
+ }
+ } else if (responseParsing === 'text') {
+ data = await response.text()
+ } else if (responseParsing === 'json') {
+ data = await response.json()
+ } else if (responseParsing === 'stream') {
+ data = await response.body
+ }
+
+ if (!response.ok) {
+ // Use the "traditional" approach and reject non 2xx responses.
+ throw new FetchError(response, request, data)
+ }
+
+ data[fetchResponseKey] = response
+
+ return { data }
+ } catch (error) {
+ if (error instanceof FetchError) {
+ throw error
+ }
+
+ if (!(error instanceof Error)) {
+ throw error
+ }
+
+ throw new FetchError(null, request, null, error.message)
+ }
+ } catch (error) {
+ if (error instanceof FetchError) {
+ throw error
+ }
+
+ if (!(error instanceof Error)) {
+ throw error
+ }
+
+ throw new FetchError(null, null, null, error.message)
+ }
+ },
+ }
+}
+
+export default createCustomizedFetchFetcher
diff --git a/framework/spree/utils/create-empty-cart.ts b/framework/spree/utils/create-empty-cart.ts
new file mode 100644
index 0000000000..0bf0aa5228
--- /dev/null
+++ b/framework/spree/utils/create-empty-cart.ts
@@ -0,0 +1,22 @@
+import type { GraphQLFetcherResult } from '@commerce/api'
+import type { HookFetcherContext } from '@commerce/utils/types'
+import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order'
+import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import ensureIToken from './tokens/ensure-itoken'
+
+const createEmptyCart = (
+ fetch: HookFetcherContext<{
+ data: any
+ }>['fetch']
+): Promise> => {
+ const token: IToken | undefined = ensureIToken()
+
+ return fetch>({
+ variables: {
+ methodPath: 'cart.create',
+ arguments: [token],
+ },
+ })
+}
+
+export default createEmptyCart
diff --git a/framework/spree/utils/create-get-absolute-image-url.ts b/framework/spree/utils/create-get-absolute-image-url.ts
new file mode 100644
index 0000000000..6e9e3260a3
--- /dev/null
+++ b/framework/spree/utils/create-get-absolute-image-url.ts
@@ -0,0 +1,26 @@
+import { SpreeProductImage } from '../types'
+import getImageUrl from './get-image-url'
+
+const createGetAbsoluteImageUrl =
+ (host: string, useOriginalImageSize: boolean = true) =>
+ (
+ image: SpreeProductImage,
+ minWidth: number,
+ minHeight: number
+ ): string | null => {
+ let url
+
+ if (useOriginalImageSize) {
+ url = image.attributes.transformed_url || null
+ } else {
+ url = getImageUrl(image, minWidth, minHeight)
+ }
+
+ if (url === null) {
+ return null
+ }
+
+ return `${host}${url}`
+ }
+
+export default createGetAbsoluteImageUrl
diff --git a/framework/spree/utils/expand-options.ts b/framework/spree/utils/expand-options.ts
new file mode 100644
index 0000000000..29b9d6760e
--- /dev/null
+++ b/framework/spree/utils/expand-options.ts
@@ -0,0 +1,103 @@
+import type { ProductOptionValues } from '@commerce/types/product'
+import type {
+ JsonApiDocument,
+ JsonApiResponse,
+} from '@spree/storefront-api-v2-sdk/types/interfaces/JsonApi'
+import { jsonApi } from '@spree/storefront-api-v2-sdk'
+import type { RelationType } from '@spree/storefront-api-v2-sdk/types/interfaces/Relationships'
+import SpreeResponseContentError from '../errors/SpreeResponseContentError'
+import type { OptionTypeAttr, ExpandedProductOption } from '../types'
+import sortOptionsByPosition from '../utils/sort-option-types'
+
+const isColorProductOption = (productOption: ExpandedProductOption) => {
+ return productOption.displayName === 'Color'
+}
+
+const expandOptions = (
+ spreeSuccessResponse: JsonApiResponse,
+ spreeOptionValue: JsonApiDocument,
+ accumulatedOptions: ExpandedProductOption[]
+): ExpandedProductOption[] => {
+ const spreeOptionTypeIdentifier = spreeOptionValue.relationships.option_type
+ .data as RelationType
+
+ const existingOptionIndex = accumulatedOptions.findIndex(
+ (option) => option.id == spreeOptionTypeIdentifier.id
+ )
+
+ let option: ExpandedProductOption
+
+ if (existingOptionIndex === -1) {
+ const spreeOptionType = jsonApi.findDocument(
+ spreeSuccessResponse,
+ spreeOptionTypeIdentifier
+ )
+
+ if (!spreeOptionType) {
+ throw new SpreeResponseContentError(
+ `Option type with id ${spreeOptionTypeIdentifier.id} not found.`
+ )
+ }
+
+ option = {
+ __typename: 'MultipleChoiceOption',
+ id: spreeOptionType.id,
+ displayName: spreeOptionType.attributes.presentation,
+ position: spreeOptionType.attributes.position,
+ values: [],
+ }
+ } else {
+ const existingOption = accumulatedOptions[existingOptionIndex]
+
+ option = existingOption
+ }
+
+ let optionValue: ProductOptionValues
+
+ const label = isColorProductOption(option)
+ ? spreeOptionValue.attributes.name
+ : spreeOptionValue.attributes.presentation
+
+ const productOptionValueExists = option.values.some(
+ (optionValue: ProductOptionValues) => optionValue.label === label
+ )
+
+ if (!productOptionValueExists) {
+ if (isColorProductOption(option)) {
+ optionValue = {
+ label,
+ hexColors: [spreeOptionValue.attributes.presentation],
+ }
+ } else {
+ optionValue = {
+ label,
+ }
+ }
+
+ if (existingOptionIndex === -1) {
+ return [
+ ...accumulatedOptions,
+ {
+ ...option,
+ values: [optionValue],
+ },
+ ]
+ }
+
+ const expandedOptionValues = [...option.values, optionValue]
+ const expandedOptions = [...accumulatedOptions]
+
+ expandedOptions[existingOptionIndex] = {
+ ...option,
+ values: expandedOptionValues,
+ }
+
+ const sortedOptions = sortOptionsByPosition(expandedOptions)
+
+ return sortedOptions
+ }
+
+ return accumulatedOptions
+}
+
+export default expandOptions
diff --git a/framework/spree/utils/force-isomorphic-config-values.ts b/framework/spree/utils/force-isomorphic-config-values.ts
new file mode 100644
index 0000000000..630b6859e4
--- /dev/null
+++ b/framework/spree/utils/force-isomorphic-config-values.ts
@@ -0,0 +1,43 @@
+import type { NonUndefined, UnknownObjectValues } from '../types'
+import MisconfigurationError from '../errors/MisconfigurationError'
+import isServer from './is-server'
+
+const generateMisconfigurationErrorMessage = (
+ keys: Array
+) => `${keys.join(', ')} must have a value before running the Framework.`
+
+const forceIsomorphicConfigValues = <
+ X extends keyof T,
+ T extends UnknownObjectValues,
+ H extends Record>
+>(
+ config: T,
+ requiredServerKeys: string[],
+ requiredPublicKeys: X[]
+) => {
+ if (isServer) {
+ const missingServerConfigValues = requiredServerKeys.filter(
+ (requiredServerKey) => typeof config[requiredServerKey] === 'undefined'
+ )
+
+ if (missingServerConfigValues.length > 0) {
+ throw new MisconfigurationError(
+ generateMisconfigurationErrorMessage(missingServerConfigValues)
+ )
+ }
+ }
+
+ const missingPublicConfigValues = requiredPublicKeys.filter(
+ (requiredPublicKey) => typeof config[requiredPublicKey] === 'undefined'
+ )
+
+ if (missingPublicConfigValues.length > 0) {
+ throw new MisconfigurationError(
+ generateMisconfigurationErrorMessage(missingPublicConfigValues)
+ )
+ }
+
+ return config as T & H
+}
+
+export default forceIsomorphicConfigValues
diff --git a/framework/spree/utils/get-image-url.ts b/framework/spree/utils/get-image-url.ts
new file mode 100644
index 0000000000..8594f5c344
--- /dev/null
+++ b/framework/spree/utils/get-image-url.ts
@@ -0,0 +1,44 @@
+// Based on https://github.com/spark-solutions/spree2vuestorefront/blob/d88d85ae1bcd2ec99b13b81cd2e3c25600a0216e/src/utils/index.ts
+
+import type { ImageStyle, SpreeProductImage } from '../types'
+
+const getImageUrl = (
+ image: SpreeProductImage,
+ minWidth: number,
+ _: number
+): string | null => {
+ // every image is still resized in vue-storefront-api, no matter what getImageUrl returns
+ if (image) {
+ const {
+ attributes: { styles },
+ } = image
+ const bestStyleIndex = styles.reduce(
+ (bSIndex: number | null, style: ImageStyle, styleIndex: number) => {
+ // assuming all images are the same dimensions, just scaled
+ if (bSIndex === null) {
+ return 0
+ }
+ const bestStyle = styles[bSIndex]
+ const widthDiff = +bestStyle.width - minWidth
+ const minWidthDiff = +style.width - minWidth
+ if (widthDiff < 0 && minWidthDiff > 0) {
+ return styleIndex
+ }
+ if (widthDiff > 0 && minWidthDiff < 0) {
+ return bSIndex
+ }
+ return Math.abs(widthDiff) < Math.abs(minWidthDiff)
+ ? bSIndex
+ : styleIndex
+ },
+ null
+ )
+
+ if (bestStyleIndex !== null) {
+ return styles[bestStyleIndex].url
+ }
+ }
+ return null
+}
+
+export default getImageUrl
diff --git a/framework/spree/utils/get-media-gallery.ts b/framework/spree/utils/get-media-gallery.ts
new file mode 100644
index 0000000000..da939c82be
--- /dev/null
+++ b/framework/spree/utils/get-media-gallery.ts
@@ -0,0 +1,25 @@
+// Based on https://github.com/spark-solutions/spree2vuestorefront/blob/d88d85ae1bcd2ec99b13b81cd2e3c25600a0216e/src/utils/index.ts
+
+import type { ProductImage } from '@commerce/types/product'
+import type { SpreeProductImage } from '../types'
+
+const getMediaGallery = (
+ images: SpreeProductImage[],
+ getImageUrl: (
+ image: SpreeProductImage,
+ minWidth: number,
+ minHeight: number
+ ) => string | null
+) => {
+ return images.reduce((productImages, _, imageIndex) => {
+ const url = getImageUrl(images[imageIndex], 800, 800)
+
+ if (url) {
+ return [...productImages, { url }]
+ }
+
+ return productImages
+ }, [])
+}
+
+export default getMediaGallery
diff --git a/framework/spree/utils/get-product-path.ts b/framework/spree/utils/get-product-path.ts
new file mode 100644
index 0000000000..6749a4a3eb
--- /dev/null
+++ b/framework/spree/utils/get-product-path.ts
@@ -0,0 +1,7 @@
+import type { ProductSlugAttr } from '../types'
+
+const getProductPath = (partialSpreeProduct: ProductSlugAttr) => {
+ return `/${partialSpreeProduct.attributes.slug}`
+}
+
+export default getProductPath
diff --git a/framework/spree/utils/get-spree-sdk-method-from-endpoint-path.ts b/framework/spree/utils/get-spree-sdk-method-from-endpoint-path.ts
new file mode 100644
index 0000000000..9b87daadc8
--- /dev/null
+++ b/framework/spree/utils/get-spree-sdk-method-from-endpoint-path.ts
@@ -0,0 +1,61 @@
+import type { Client } from '@spree/storefront-api-v2-sdk'
+import SpreeSdkMethodFromEndpointPathError from '../errors/SpreeSdkMethodFromEndpointPathError'
+import type {
+ SpreeSdkMethod,
+ SpreeSdkResultResponseSuccessType,
+} from '../types'
+
+const getSpreeSdkMethodFromEndpointPath = <
+ ExactSpreeSdkClientType extends Client,
+ ResultResponseSuccessType extends SpreeSdkResultResponseSuccessType = SpreeSdkResultResponseSuccessType
+>(
+ client: ExactSpreeSdkClientType,
+ path: string
+): SpreeSdkMethod => {
+ const pathParts = path.split('.')
+ const reachedPath: string[] = []
+ let node = >client
+
+ console.log(`Looking for ${path} in Spree Sdk.`)
+
+ while (reachedPath.length < pathParts.length - 1) {
+ const checkedPathPart = pathParts[reachedPath.length]
+ const checkedNode = node[checkedPathPart]
+
+ console.log(`Checking part ${checkedPathPart}.`)
+
+ if (typeof checkedNode !== 'object') {
+ throw new SpreeSdkMethodFromEndpointPathError(
+ `Couldn't reach ${path}. Farthest path reached was: ${reachedPath.join(
+ '.'
+ )}.`
+ )
+ }
+
+ if (checkedNode === null) {
+ throw new SpreeSdkMethodFromEndpointPathError(
+ `Path ${path} doesn't exist.`
+ )
+ }
+
+ node = >checkedNode
+ reachedPath.push(checkedPathPart)
+ }
+
+ const foundEndpointMethod = node[pathParts[reachedPath.length]]
+
+ if (
+ reachedPath.length !== pathParts.length - 1 ||
+ typeof foundEndpointMethod !== 'function'
+ ) {
+ throw new SpreeSdkMethodFromEndpointPathError(
+ `Couldn't reach ${path}. Farthest path reached was: ${reachedPath.join(
+ '.'
+ )}.`
+ )
+ }
+
+ return foundEndpointMethod.bind(node)
+}
+
+export default getSpreeSdkMethodFromEndpointPath
diff --git a/framework/spree/utils/handle-token-errors.ts b/framework/spree/utils/handle-token-errors.ts
new file mode 100644
index 0000000000..a5d49fde6b
--- /dev/null
+++ b/framework/spree/utils/handle-token-errors.ts
@@ -0,0 +1,14 @@
+import AccessTokenError from '../errors/AccessTokenError'
+import RefreshTokenError from '../errors/RefreshTokenError'
+
+const handleTokenErrors = (error: unknown, action: () => void): boolean => {
+ if (error instanceof AccessTokenError || error instanceof RefreshTokenError) {
+ action()
+
+ return true
+ }
+
+ return false
+}
+
+export default handleTokenErrors
diff --git a/framework/spree/utils/is-json-content-type.ts b/framework/spree/utils/is-json-content-type.ts
new file mode 100644
index 0000000000..fd82d65fd8
--- /dev/null
+++ b/framework/spree/utils/is-json-content-type.ts
@@ -0,0 +1,5 @@
+const isJsonContentType = (contentType: string): boolean =>
+ contentType.includes('application/json') ||
+ contentType.includes('application/vnd.api+json')
+
+export default isJsonContentType
diff --git a/framework/spree/utils/is-server.ts b/framework/spree/utils/is-server.ts
new file mode 100644
index 0000000000..4544a48840
--- /dev/null
+++ b/framework/spree/utils/is-server.ts
@@ -0,0 +1 @@
+export default typeof window === 'undefined'
diff --git a/framework/spree/utils/login.ts b/framework/spree/utils/login.ts
new file mode 100644
index 0000000000..3894b79526
--- /dev/null
+++ b/framework/spree/utils/login.ts
@@ -0,0 +1,58 @@
+import type { GraphQLFetcherResult } from '@commerce/api'
+import type { HookFetcherContext } from '@commerce/utils/types'
+import type { AuthTokenAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Authentication'
+import type { AssociateCart } from '@spree/storefront-api-v2-sdk/types/interfaces/endpoints/CartClass'
+import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order'
+import type {
+ IOAuthToken,
+ IToken,
+} from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import { getCartToken, removeCartToken } from './tokens/cart-token'
+import { setUserTokenResponse } from './tokens/user-token-response'
+
+const login = async (
+ fetch: HookFetcherContext<{
+ data: any
+ }>['fetch'],
+ getTokenParameters: AuthTokenAttr,
+ associateGuestCart: boolean
+): Promise => {
+ const { data: spreeGetTokenSuccessResponse } = await fetch<
+ GraphQLFetcherResult
+ >({
+ variables: {
+ methodPath: 'authentication.getToken',
+ arguments: [getTokenParameters],
+ },
+ })
+
+ setUserTokenResponse(spreeGetTokenSuccessResponse)
+
+ if (associateGuestCart) {
+ const cartToken = getCartToken()
+
+ if (cartToken) {
+ // If the user had a cart as guest still use its contents
+ // after logging in.
+ const accessToken = spreeGetTokenSuccessResponse.access_token
+ const token: IToken = { bearerToken: accessToken }
+
+ const associateGuestCartParameters: AssociateCart = {
+ guest_order_token: cartToken,
+ }
+
+ await fetch>({
+ variables: {
+ methodPath: 'cart.associateGuestCart',
+ arguments: [token, associateGuestCartParameters],
+ },
+ })
+
+ // We no longer need the guest cart token, so let's remove it.
+ }
+ }
+
+ removeCartToken()
+}
+
+export default login
diff --git a/framework/spree/utils/normalizations/normalize-cart.ts b/framework/spree/utils/normalizations/normalize-cart.ts
new file mode 100644
index 0000000000..a1751eaecc
--- /dev/null
+++ b/framework/spree/utils/normalizations/normalize-cart.ts
@@ -0,0 +1,211 @@
+import type {
+ Cart,
+ LineItem,
+ ProductVariant,
+ SelectedOption,
+} from '@commerce/types/cart'
+import MissingLineItemVariantError from '../../errors/MissingLineItemVariantError'
+import { requireConfigValue } from '../../isomorphic-config'
+import type { OrderAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Order'
+import type { ProductAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Product'
+import type { Image } from '@commerce/types/common'
+import { jsonApi } from '@spree/storefront-api-v2-sdk'
+import createGetAbsoluteImageUrl from '../create-get-absolute-image-url'
+import getMediaGallery from '../get-media-gallery'
+import type {
+ LineItemAttr,
+ OptionTypeAttr,
+ SpreeProductImage,
+ SpreeSdkResponse,
+ VariantAttr,
+} from '../../types'
+
+const placeholderImage = requireConfigValue('lineItemPlaceholderImageUrl') as
+ | string
+ | false
+
+const isColorProductOption = (productOptionType: OptionTypeAttr) => {
+ return productOptionType.attributes.presentation === 'Color'
+}
+
+const normalizeVariant = (
+ spreeSuccessResponse: SpreeSdkResponse,
+ spreeVariant: VariantAttr
+): ProductVariant => {
+ const spreeProduct = jsonApi.findSingleRelationshipDocument(
+ spreeSuccessResponse,
+ spreeVariant,
+ 'product'
+ )
+
+ if (spreeProduct === null) {
+ throw new MissingLineItemVariantError(
+ `Couldn't find product for variant with id ${spreeVariant.id}.`
+ )
+ }
+
+ const spreeVariantImageRecords =
+ jsonApi.findRelationshipDocuments(
+ spreeSuccessResponse,
+ spreeVariant,
+ 'images'
+ )
+
+ let lineItemImage
+
+ const variantImage = getMediaGallery(
+ spreeVariantImageRecords,
+ createGetAbsoluteImageUrl(requireConfigValue('imageHost') as string)
+ )[0]
+
+ if (variantImage) {
+ lineItemImage = variantImage
+ } else {
+ const spreeProductImageRecords =
+ jsonApi.findRelationshipDocuments(
+ spreeSuccessResponse,
+ spreeProduct,
+ 'images'
+ )
+
+ const productImage = getMediaGallery(
+ spreeProductImageRecords,
+ createGetAbsoluteImageUrl(requireConfigValue('imageHost') as string)
+ )[0]
+
+ lineItemImage = productImage
+ }
+
+ const image: Image =
+ lineItemImage ??
+ (placeholderImage === false ? undefined : { url: placeholderImage })
+
+ return {
+ id: spreeVariant.id,
+ sku: spreeVariant.attributes.sku,
+ name: spreeProduct.attributes.name,
+ requiresShipping: true,
+ price: parseFloat(spreeVariant.attributes.price),
+ listPrice: parseFloat(spreeVariant.attributes.price),
+ image,
+ isInStock: spreeVariant.attributes.in_stock,
+ availableForSale: spreeVariant.attributes.purchasable,
+ ...(spreeVariant.attributes.weight === '0.0'
+ ? {}
+ : {
+ weight: {
+ value: parseFloat(spreeVariant.attributes.weight),
+ unit: 'KILOGRAMS',
+ },
+ }),
+ // TODO: Add height, width and depth when Measurement type allows distance measurements.
+ }
+}
+
+const normalizeLineItem = (
+ spreeSuccessResponse: SpreeSdkResponse,
+ spreeLineItem: LineItemAttr
+): LineItem => {
+ const variant = jsonApi.findSingleRelationshipDocument(
+ spreeSuccessResponse,
+ spreeLineItem,
+ 'variant'
+ )
+
+ if (variant === null) {
+ throw new MissingLineItemVariantError(
+ `Couldn't find variant for line item with id ${spreeLineItem.id}.`
+ )
+ }
+
+ const product = jsonApi.findSingleRelationshipDocument(
+ spreeSuccessResponse,
+ variant,
+ 'product'
+ )
+
+ if (product === null) {
+ throw new MissingLineItemVariantError(
+ `Couldn't find product for variant with id ${variant.id}.`
+ )
+ }
+
+ // CartItem.tsx expects path without a '/' prefix unlike pages/product/[slug].tsx and others.
+ const path = `${product.attributes.slug}`
+
+ const spreeOptionValues = jsonApi.findRelationshipDocuments(
+ spreeSuccessResponse,
+ variant,
+ 'option_values'
+ )
+
+ const options: SelectedOption[] = spreeOptionValues.map(
+ (spreeOptionValue) => {
+ const spreeOptionType =
+ jsonApi.findSingleRelationshipDocument(
+ spreeSuccessResponse,
+ spreeOptionValue,
+ 'option_type'
+ )
+
+ if (spreeOptionType === null) {
+ throw new MissingLineItemVariantError(
+ `Couldn't find option type of option value with id ${spreeOptionValue.id}.`
+ )
+ }
+
+ const label = isColorProductOption(spreeOptionType)
+ ? spreeOptionValue.attributes.name
+ : spreeOptionValue.attributes.presentation
+
+ return {
+ id: spreeOptionValue.id,
+ name: spreeOptionType.attributes.presentation,
+ value: label,
+ }
+ }
+ )
+
+ return {
+ id: spreeLineItem.id,
+ variantId: variant.id,
+ productId: product.id,
+ name: spreeLineItem.attributes.name,
+ quantity: spreeLineItem.attributes.quantity,
+ discounts: [], // TODO: Implement when the template starts displaying them.
+ path,
+ variant: normalizeVariant(spreeSuccessResponse, variant),
+ options,
+ }
+}
+
+const normalizeCart = (
+ spreeSuccessResponse: SpreeSdkResponse,
+ spreeCart: OrderAttr
+): Cart => {
+ const lineItems = jsonApi
+ .findRelationshipDocuments(
+ spreeSuccessResponse,
+ spreeCart,
+ 'line_items'
+ )
+ .map((lineItem) => normalizeLineItem(spreeSuccessResponse, lineItem))
+
+ return {
+ id: spreeCart.id,
+ createdAt: spreeCart.attributes.created_at.toString(),
+ currency: { code: spreeCart.attributes.currency },
+ taxesIncluded: true,
+ lineItems,
+ lineItemsSubtotalPrice: parseFloat(spreeCart.attributes.item_total),
+ subtotalPrice: parseFloat(spreeCart.attributes.item_total),
+ totalPrice: parseFloat(spreeCart.attributes.total),
+ customerId: spreeCart.attributes.token,
+ email: spreeCart.attributes.email,
+ discounts: [], // TODO: Implement when the template starts displaying them.
+ }
+}
+
+export { normalizeLineItem }
+
+export default normalizeCart
diff --git a/framework/spree/utils/normalizations/normalize-page.ts b/framework/spree/utils/normalizations/normalize-page.ts
new file mode 100644
index 0000000000..c49d862d1a
--- /dev/null
+++ b/framework/spree/utils/normalizations/normalize-page.ts
@@ -0,0 +1,42 @@
+import { Page } from '@commerce/types/page'
+import type { PageAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Page'
+import { SpreeSdkResponse } from '../../types'
+
+const normalizePage = (
+ _spreeSuccessResponse: SpreeSdkResponse,
+ spreePage: PageAttr,
+ commerceLocales: string[]
+): Page => {
+ // If the locale returned by Spree is not available, search
+ // for a similar one.
+
+ const spreeLocale = spreePage.attributes.locale
+ let usedCommerceLocale: string
+
+ if (commerceLocales.includes(spreeLocale)) {
+ usedCommerceLocale = spreeLocale
+ } else {
+ const genericSpreeLocale = spreeLocale.split('-')[0]
+
+ const foundExactGenericLocale = commerceLocales.includes(genericSpreeLocale)
+
+ if (foundExactGenericLocale) {
+ usedCommerceLocale = genericSpreeLocale
+ } else {
+ const foundSimilarLocale = commerceLocales.find((locale) => {
+ return locale.split('-')[0] === genericSpreeLocale
+ })
+
+ usedCommerceLocale = foundSimilarLocale || spreeLocale
+ }
+ }
+
+ return {
+ id: spreePage.id,
+ name: spreePage.attributes.title,
+ url: `/${usedCommerceLocale}/${spreePage.attributes.slug}`,
+ body: spreePage.attributes.content,
+ }
+}
+
+export default normalizePage
diff --git a/framework/spree/utils/normalizations/normalize-product.ts b/framework/spree/utils/normalizations/normalize-product.ts
new file mode 100644
index 0000000000..e70bd34b46
--- /dev/null
+++ b/framework/spree/utils/normalizations/normalize-product.ts
@@ -0,0 +1,240 @@
+import type {
+ Product,
+ ProductImage,
+ ProductPrice,
+ ProductVariant,
+} from '@commerce/types/product'
+import type { ProductAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Product'
+import type { RelationType } from '@spree/storefront-api-v2-sdk/types/interfaces/Relationships'
+import { jsonApi } from '@spree/storefront-api-v2-sdk'
+import { JsonApiDocument } from '@spree/storefront-api-v2-sdk/types/interfaces/JsonApi'
+import { requireConfigValue } from '../../isomorphic-config'
+import createGetAbsoluteImageUrl from '../create-get-absolute-image-url'
+import expandOptions from '../expand-options'
+import getMediaGallery from '../get-media-gallery'
+import getProductPath from '../get-product-path'
+import MissingPrimaryVariantError from '../../errors/MissingPrimaryVariantError'
+import MissingOptionValueError from '../../errors/MissingOptionValueError'
+import type {
+ ExpandedProductOption,
+ SpreeSdkResponse,
+ VariantAttr,
+} from '../../types'
+
+const placeholderImage = requireConfigValue('productPlaceholderImageUrl') as
+ | string
+ | false
+
+const imagesOptionFilter = requireConfigValue('imagesOptionFilter') as
+ | string
+ | false
+
+const normalizeProduct = (
+ spreeSuccessResponse: SpreeSdkResponse,
+ spreeProduct: ProductAttr
+): Product => {
+ const spreePrimaryVariant =
+ jsonApi.findSingleRelationshipDocument(
+ spreeSuccessResponse,
+ spreeProduct,
+ 'primary_variant'
+ )
+
+ if (spreePrimaryVariant === null) {
+ throw new MissingPrimaryVariantError(
+ `Couldn't find primary variant for product with id ${spreeProduct.id}.`
+ )
+ }
+
+ const sku = spreePrimaryVariant.attributes.sku
+
+ const price: ProductPrice = {
+ value: parseFloat(spreeProduct.attributes.price),
+ currencyCode: spreeProduct.attributes.currency,
+ }
+
+ const hasNonMasterVariants =
+ (spreeProduct.relationships.variants.data as RelationType[]).length > 1
+
+ const showOptions =
+ (requireConfigValue('showSingleVariantOptions') as boolean) ||
+ hasNonMasterVariants
+
+ let options: ExpandedProductOption[] = []
+
+ const spreeVariantRecords = jsonApi.findRelationshipDocuments(
+ spreeSuccessResponse,
+ spreeProduct,
+ 'variants'
+ )
+
+ // Use variants with option values if available. Fall back to
+ // Spree primary_variant if no explicit variants are present.
+ const spreeOptionsVariantsOrPrimary =
+ spreeVariantRecords.length === 0
+ ? [spreePrimaryVariant]
+ : spreeVariantRecords
+
+ const variants: ProductVariant[] = spreeOptionsVariantsOrPrimary.map(
+ (spreeVariantRecord) => {
+ let variantOptions: ExpandedProductOption[] = []
+
+ if (showOptions) {
+ const spreeOptionValues = jsonApi.findRelationshipDocuments(
+ spreeSuccessResponse,
+ spreeVariantRecord,
+ 'option_values'
+ )
+
+ // Only include options which are used by variants.
+
+ spreeOptionValues.forEach((spreeOptionValue) => {
+ variantOptions = expandOptions(
+ spreeSuccessResponse,
+ spreeOptionValue,
+ variantOptions
+ )
+
+ options = expandOptions(
+ spreeSuccessResponse,
+ spreeOptionValue,
+ options
+ )
+ })
+ }
+
+ return {
+ id: spreeVariantRecord.id,
+ options: variantOptions,
+ }
+ }
+ )
+
+ const spreePrimaryVariantImageRecords = jsonApi.findRelationshipDocuments(
+ spreeSuccessResponse,
+ spreePrimaryVariant,
+ 'images'
+ )
+
+ let spreeVariantImageRecords: JsonApiDocument[]
+
+ if (imagesOptionFilter === false) {
+ spreeVariantImageRecords = spreeVariantRecords.reduce(
+ (accumulatedImageRecords, spreeVariantRecord) => {
+ return [
+ ...accumulatedImageRecords,
+ ...jsonApi.findRelationshipDocuments(
+ spreeSuccessResponse,
+ spreeVariantRecord,
+ 'images'
+ ),
+ ]
+ },
+ []
+ )
+ } else {
+ const spreeOptionTypes = jsonApi.findRelationshipDocuments(
+ spreeSuccessResponse,
+ spreeProduct,
+ 'option_types'
+ )
+
+ const imagesFilterOptionType = spreeOptionTypes.find(
+ (spreeOptionType) =>
+ spreeOptionType.attributes.name === imagesOptionFilter
+ )
+
+ if (!imagesFilterOptionType) {
+ console.warn(
+ `Couldn't find option type having name ${imagesOptionFilter} for product with id ${spreeProduct.id}.` +
+ ' Showing no images for this product.'
+ )
+
+ spreeVariantImageRecords = []
+ } else {
+ const imagesOptionTypeFilterId = imagesFilterOptionType.id
+ const includedOptionValuesImagesIds: string[] = []
+
+ spreeVariantImageRecords = spreeVariantRecords.reduce(
+ (accumulatedImageRecords, spreeVariantRecord) => {
+ const spreeVariantOptionValuesIdentifiers: RelationType[] =
+ spreeVariantRecord.relationships.option_values.data
+
+ const spreeOptionValueOfFilterTypeIdentifier =
+ spreeVariantOptionValuesIdentifiers.find(
+ (spreeVariantOptionValuesIdentifier: RelationType) =>
+ imagesFilterOptionType.relationships.option_values.data.some(
+ (filterOptionTypeValueIdentifier: RelationType) =>
+ filterOptionTypeValueIdentifier.id ===
+ spreeVariantOptionValuesIdentifier.id
+ )
+ )
+
+ if (!spreeOptionValueOfFilterTypeIdentifier) {
+ throw new MissingOptionValueError(
+ `Couldn't find option value related to option type with id ${imagesOptionTypeFilterId}.`
+ )
+ }
+
+ const optionValueImagesAlreadyIncluded =
+ includedOptionValuesImagesIds.includes(
+ spreeOptionValueOfFilterTypeIdentifier.id
+ )
+
+ if (optionValueImagesAlreadyIncluded) {
+ return accumulatedImageRecords
+ }
+
+ includedOptionValuesImagesIds.push(
+ spreeOptionValueOfFilterTypeIdentifier.id
+ )
+
+ return [
+ ...accumulatedImageRecords,
+ ...jsonApi.findRelationshipDocuments(
+ spreeSuccessResponse,
+ spreeVariantRecord,
+ 'images'
+ ),
+ ]
+ },
+ []
+ )
+ }
+ }
+
+ const spreeImageRecords = [
+ ...spreePrimaryVariantImageRecords,
+ ...spreeVariantImageRecords,
+ ]
+
+ const productImages = getMediaGallery(
+ spreeImageRecords,
+ createGetAbsoluteImageUrl(requireConfigValue('imageHost') as string)
+ )
+
+ const images: ProductImage[] =
+ productImages.length === 0
+ ? placeholderImage === false
+ ? []
+ : [{ url: placeholderImage }]
+ : productImages
+
+ const slug = spreeProduct.attributes.slug
+ const path = getProductPath(spreeProduct)
+
+ return {
+ id: spreeProduct.id,
+ name: spreeProduct.attributes.name,
+ description: spreeProduct.attributes.description,
+ images,
+ variants,
+ options,
+ price,
+ slug,
+ path,
+ sku,
+ }
+}
+
+export default normalizeProduct
diff --git a/framework/spree/utils/normalizations/normalize-user.ts b/framework/spree/utils/normalizations/normalize-user.ts
new file mode 100644
index 0000000000..897b1c59b7
--- /dev/null
+++ b/framework/spree/utils/normalizations/normalize-user.ts
@@ -0,0 +1,16 @@
+import type { Customer } from '@commerce/types/customer'
+import type { AccountAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Account'
+import type { SpreeSdkResponse } from '../../types'
+
+const normalizeUser = (
+ _spreeSuccessResponse: SpreeSdkResponse,
+ spreeUser: AccountAttr
+): Customer => {
+ const email = spreeUser.attributes.email
+
+ return {
+ email,
+ }
+}
+
+export default normalizeUser
diff --git a/framework/spree/utils/normalizations/normalize-wishlist.ts b/framework/spree/utils/normalizations/normalize-wishlist.ts
new file mode 100644
index 0000000000..c9cfee2dbf
--- /dev/null
+++ b/framework/spree/utils/normalizations/normalize-wishlist.ts
@@ -0,0 +1,68 @@
+import MissingProductError from '../../errors/MissingProductError'
+import MissingVariantError from '../../errors/MissingVariantError'
+import { jsonApi } from '@spree/storefront-api-v2-sdk'
+import type { ProductAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Product'
+import type { WishedItemAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/WishedItem'
+import type { WishlistAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Wishlist'
+import type {
+ ExplicitCommerceWishlist,
+ SpreeSdkResponse,
+ VariantAttr,
+} from '../../types'
+import normalizeProduct from './normalize-product'
+
+const normalizeWishlist = (
+ spreeSuccessResponse: SpreeSdkResponse,
+ spreeWishlist: WishlistAttr
+): ExplicitCommerceWishlist => {
+ const spreeWishedItems = jsonApi.findRelationshipDocuments(
+ spreeSuccessResponse,
+ spreeWishlist,
+ 'wished_items'
+ )
+
+ const items: ExplicitCommerceWishlist['items'] = spreeWishedItems.map(
+ (spreeWishedItem) => {
+ const spreeWishedVariant =
+ jsonApi.findSingleRelationshipDocument(
+ spreeSuccessResponse,
+ spreeWishedItem,
+ 'variant'
+ )
+
+ if (spreeWishedVariant === null) {
+ throw new MissingVariantError(
+ `Couldn't find variant for wished item with id ${spreeWishedItem.id}.`
+ )
+ }
+
+ const spreeWishedProduct =
+ jsonApi.findSingleRelationshipDocument(
+ spreeSuccessResponse,
+ spreeWishedVariant,
+ 'product'
+ )
+
+ if (spreeWishedProduct === null) {
+ throw new MissingProductError(
+ `Couldn't find product for variant with id ${spreeWishedVariant.id}.`
+ )
+ }
+
+ return {
+ id: spreeWishedItem.id,
+ product_id: parseInt(spreeWishedProduct.id, 10),
+ variant_id: parseInt(spreeWishedVariant.id, 10),
+ product: normalizeProduct(spreeSuccessResponse, spreeWishedProduct),
+ }
+ }
+ )
+
+ return {
+ id: spreeWishlist.id,
+ token: spreeWishlist.attributes.token,
+ items,
+ }
+}
+
+export default normalizeWishlist
diff --git a/framework/spree/utils/require-config.ts b/framework/spree/utils/require-config.ts
new file mode 100644
index 0000000000..92b7916ca4
--- /dev/null
+++ b/framework/spree/utils/require-config.ts
@@ -0,0 +1,16 @@
+import MissingConfigurationValueError from '../errors/MissingConfigurationValueError'
+import type { NonUndefined, ValueOf } from '../types'
+
+const requireConfig = (isomorphicConfig: T, key: keyof T) => {
+ const valueUnderKey = isomorphicConfig[key]
+
+ if (typeof valueUnderKey === 'undefined') {
+ throw new MissingConfigurationValueError(
+ `Value for configuration key ${key} was undefined.`
+ )
+ }
+
+ return valueUnderKey as NonUndefined>
+}
+
+export default requireConfig
diff --git a/framework/spree/utils/sort-option-types.ts b/framework/spree/utils/sort-option-types.ts
new file mode 100644
index 0000000000..bac632e09e
--- /dev/null
+++ b/framework/spree/utils/sort-option-types.ts
@@ -0,0 +1,11 @@
+import type { ExpandedProductOption } from '../types'
+
+const sortOptionsByPosition = (
+ options: ExpandedProductOption[]
+): ExpandedProductOption[] => {
+ return options.sort((firstOption, secondOption) => {
+ return firstOption.position - secondOption.position
+ })
+}
+
+export default sortOptionsByPosition
diff --git a/framework/spree/utils/tokens/cart-token.ts b/framework/spree/utils/tokens/cart-token.ts
new file mode 100644
index 0000000000..8352f9adaa
--- /dev/null
+++ b/framework/spree/utils/tokens/cart-token.ts
@@ -0,0 +1,21 @@
+import { requireConfigValue } from '../../isomorphic-config'
+import Cookies from 'js-cookie'
+
+export const getCartToken = () =>
+ Cookies.get(requireConfigValue('cartCookieName') as string)
+
+export const setCartToken = (cartToken: string) => {
+ const cookieOptions = {
+ expires: requireConfigValue('cartCookieExpire') as number,
+ }
+
+ Cookies.set(
+ requireConfigValue('cartCookieName') as string,
+ cartToken,
+ cookieOptions
+ )
+}
+
+export const removeCartToken = () => {
+ Cookies.remove(requireConfigValue('cartCookieName') as string)
+}
diff --git a/framework/spree/utils/tokens/ensure-fresh-user-access-token.ts b/framework/spree/utils/tokens/ensure-fresh-user-access-token.ts
new file mode 100644
index 0000000000..de22634fb7
--- /dev/null
+++ b/framework/spree/utils/tokens/ensure-fresh-user-access-token.ts
@@ -0,0 +1,51 @@
+import { SpreeSdkResponseWithRawResponse } from '../../types'
+import type { Client } from '@spree/storefront-api-v2-sdk'
+import type { IOAuthToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import getSpreeSdkMethodFromEndpointPath from '../get-spree-sdk-method-from-endpoint-path'
+import {
+ ensureUserTokenResponse,
+ removeUserTokenResponse,
+ setUserTokenResponse,
+} from './user-token-response'
+import AccessTokenError from '../../errors/AccessTokenError'
+
+/**
+ * If the user has a saved access token, make sure it's not expired
+ * If it is expired, attempt to refresh it.
+ */
+const ensureFreshUserAccessToken = async (client: Client): Promise => {
+ const userTokenResponse = ensureUserTokenResponse()
+
+ if (!userTokenResponse) {
+ // There's no user token or it has an invalid format.
+ return
+ }
+
+ const isAccessTokenExpired =
+ (userTokenResponse.created_at + userTokenResponse.expires_in) * 1000 <
+ Date.now()
+
+ if (!isAccessTokenExpired) {
+ return
+ }
+
+ const spreeRefreshAccessTokenSdkMethod = getSpreeSdkMethodFromEndpointPath<
+ Client,
+ SpreeSdkResponseWithRawResponse & IOAuthToken
+ >(client, 'authentication.refreshToken')
+
+ const spreeRefreshAccessTokenResponse =
+ await spreeRefreshAccessTokenSdkMethod({
+ refresh_token: userTokenResponse.refresh_token,
+ })
+
+ if (spreeRefreshAccessTokenResponse.isFail()) {
+ removeUserTokenResponse()
+
+ throw new AccessTokenError('Could not refresh access token.')
+ }
+
+ setUserTokenResponse(spreeRefreshAccessTokenResponse.success())
+}
+
+export default ensureFreshUserAccessToken
diff --git a/framework/spree/utils/tokens/ensure-itoken.ts b/framework/spree/utils/tokens/ensure-itoken.ts
new file mode 100644
index 0000000000..0d4e6f8997
--- /dev/null
+++ b/framework/spree/utils/tokens/ensure-itoken.ts
@@ -0,0 +1,25 @@
+import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import { getCartToken } from './cart-token'
+import { ensureUserTokenResponse } from './user-token-response'
+
+const ensureIToken = (): IToken | undefined => {
+ const userTokenResponse = ensureUserTokenResponse()
+
+ if (userTokenResponse) {
+ return {
+ bearerToken: userTokenResponse.access_token,
+ }
+ }
+
+ const cartToken = getCartToken()
+
+ if (cartToken) {
+ return {
+ orderToken: cartToken,
+ }
+ }
+
+ return undefined
+}
+
+export default ensureIToken
diff --git a/framework/spree/utils/tokens/is-logged-in.ts b/framework/spree/utils/tokens/is-logged-in.ts
new file mode 100644
index 0000000000..218c25bdd5
--- /dev/null
+++ b/framework/spree/utils/tokens/is-logged-in.ts
@@ -0,0 +1,9 @@
+import { ensureUserTokenResponse } from './user-token-response'
+
+const isLoggedIn = (): boolean => {
+ const userTokenResponse = ensureUserTokenResponse()
+
+ return !!userTokenResponse
+}
+
+export default isLoggedIn
diff --git a/framework/spree/utils/tokens/revoke-user-tokens.ts b/framework/spree/utils/tokens/revoke-user-tokens.ts
new file mode 100644
index 0000000000..9c603a8843
--- /dev/null
+++ b/framework/spree/utils/tokens/revoke-user-tokens.ts
@@ -0,0 +1,49 @@
+import type { GraphQLFetcherResult } from '@commerce/api'
+import type { HookFetcherContext } from '@commerce/utils/types'
+import TokensNotRejectedError from '../../errors/TokensNotRejectedError'
+import type { UserOAuthTokens } from '../../types'
+import type { EmptyObjectResponse } from '@spree/storefront-api-v2-sdk/types/interfaces/EmptyObject'
+
+const revokeUserTokens = async (
+ fetch: HookFetcherContext<{
+ data: any
+ }>['fetch'],
+ userTokens: UserOAuthTokens
+): Promise => {
+ const spreeRevokeTokensResponses = await Promise.allSettled([
+ fetch>({
+ variables: {
+ methodPath: 'authentication.revokeToken',
+ arguments: [
+ {
+ token: userTokens.refreshToken,
+ },
+ ],
+ },
+ }),
+ fetch>({
+ variables: {
+ methodPath: 'authentication.revokeToken',
+ arguments: [
+ {
+ token: userTokens.accessToken,
+ },
+ ],
+ },
+ }),
+ ])
+
+ const anyRejected = spreeRevokeTokensResponses.some(
+ (response) => response.status === 'rejected'
+ )
+
+ if (anyRejected) {
+ throw new TokensNotRejectedError(
+ 'Some tokens could not be rejected in Spree.'
+ )
+ }
+
+ return undefined
+}
+
+export default revokeUserTokens
diff --git a/framework/spree/utils/tokens/user-token-response.ts b/framework/spree/utils/tokens/user-token-response.ts
new file mode 100644
index 0000000000..0c524eccfe
--- /dev/null
+++ b/framework/spree/utils/tokens/user-token-response.ts
@@ -0,0 +1,58 @@
+import { requireConfigValue } from '../../isomorphic-config'
+import Cookies from 'js-cookie'
+import type { IOAuthToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import UserTokenResponseParseError from '../../errors/UserTokenResponseParseError'
+
+export const getUserTokenResponse = (): IOAuthToken | undefined => {
+ const stringifiedToken = Cookies.get(
+ requireConfigValue('userCookieName') as string
+ )
+
+ if (!stringifiedToken) {
+ return undefined
+ }
+
+ try {
+ const token: IOAuthToken = JSON.parse(stringifiedToken)
+
+ return token
+ } catch (parseError) {
+ throw new UserTokenResponseParseError(
+ 'Could not parse stored user token response.'
+ )
+ }
+}
+
+/**
+ * Retrieves the saved user token response. If the response fails json parsing,
+ * removes the saved token and returns @type {undefined} instead.
+ */
+export const ensureUserTokenResponse = (): IOAuthToken | undefined => {
+ try {
+ return getUserTokenResponse()
+ } catch (error) {
+ if (error instanceof UserTokenResponseParseError) {
+ removeUserTokenResponse()
+
+ return undefined
+ }
+
+ throw error
+ }
+}
+
+export const setUserTokenResponse = (token: IOAuthToken) => {
+ const cookieOptions = {
+ expires: requireConfigValue('userCookieExpire') as number,
+ }
+
+ Cookies.set(
+ requireConfigValue('userCookieName') as string,
+ JSON.stringify(token),
+ cookieOptions
+ )
+}
+
+export const removeUserTokenResponse = () => {
+ Cookies.remove(requireConfigValue('userCookieName') as string)
+}
diff --git a/framework/spree/utils/validations/validate-all-products-taxonomy-id.ts b/framework/spree/utils/validations/validate-all-products-taxonomy-id.ts
new file mode 100644
index 0000000000..5eaaa0b4bc
--- /dev/null
+++ b/framework/spree/utils/validations/validate-all-products-taxonomy-id.ts
@@ -0,0 +1,13 @@
+const validateAllProductsTaxonomyId = (taxonomyId: unknown): string | false => {
+ if (!taxonomyId || taxonomyId === 'false') {
+ return false
+ }
+
+ if (typeof taxonomyId === 'string') {
+ return taxonomyId
+ }
+
+ throw new TypeError('taxonomyId must be a string or falsy.')
+}
+
+export default validateAllProductsTaxonomyId
diff --git a/framework/spree/utils/validations/validate-cookie-expire.ts b/framework/spree/utils/validations/validate-cookie-expire.ts
new file mode 100644
index 0000000000..1bd9872738
--- /dev/null
+++ b/framework/spree/utils/validations/validate-cookie-expire.ts
@@ -0,0 +1,21 @@
+const validateCookieExpire = (expire: unknown): number => {
+ let expireInteger: number
+
+ if (typeof expire === 'string') {
+ expireInteger = parseFloat(expire)
+ } else if (typeof expire === 'number') {
+ expireInteger = expire
+ } else {
+ throw new TypeError(
+ 'expire must be a string containing a number or an integer.'
+ )
+ }
+
+ if (expireInteger < 0) {
+ throw new RangeError('expire must be non-negative.')
+ }
+
+ return expireInteger
+}
+
+export default validateCookieExpire
diff --git a/framework/spree/utils/validations/validate-images-option-filter.ts b/framework/spree/utils/validations/validate-images-option-filter.ts
new file mode 100644
index 0000000000..8b6ef9892c
--- /dev/null
+++ b/framework/spree/utils/validations/validate-images-option-filter.ts
@@ -0,0 +1,15 @@
+const validateImagesOptionFilter = (
+ optionTypeNameOrFalse: unknown
+): string | false => {
+ if (!optionTypeNameOrFalse || optionTypeNameOrFalse === 'false') {
+ return false
+ }
+
+ if (typeof optionTypeNameOrFalse === 'string') {
+ return optionTypeNameOrFalse
+ }
+
+ throw new TypeError('optionTypeNameOrFalse must be a string or falsy.')
+}
+
+export default validateImagesOptionFilter
diff --git a/framework/spree/utils/validations/validate-images-quality.ts b/framework/spree/utils/validations/validate-images-quality.ts
new file mode 100644
index 0000000000..909caad57e
--- /dev/null
+++ b/framework/spree/utils/validations/validate-images-quality.ts
@@ -0,0 +1,23 @@
+const validateImagesQuality = (quality: unknown): number => {
+ let quality_level: number
+
+ if (typeof quality === 'string') {
+ quality_level = parseInt(quality)
+ } else if (typeof quality === 'number') {
+ quality_level = quality
+ } else {
+ throw new TypeError(
+ 'prerenderCount count must be a string containing a number or an integer.'
+ )
+ }
+
+ if (quality_level === NaN) {
+ throw new TypeError(
+ 'prerenderCount count must be a string containing a number or an integer.'
+ )
+ }
+
+ return quality_level
+}
+
+export default validateImagesQuality
diff --git a/framework/spree/utils/validations/validate-images-size.ts b/framework/spree/utils/validations/validate-images-size.ts
new file mode 100644
index 0000000000..e02036dad3
--- /dev/null
+++ b/framework/spree/utils/validations/validate-images-size.ts
@@ -0,0 +1,13 @@
+const validateImagesSize = (size: unknown): string => {
+ if (typeof size !== 'string') {
+ throw new TypeError('size must be a string.')
+ }
+
+ if (!size.includes('x') || size.split('x').length != 2) {
+ throw new Error("size must have two numbers separated with an 'x'")
+ }
+
+ return size
+}
+
+export default validateImagesSize
diff --git a/framework/spree/utils/validations/validate-placeholder-image-url.ts b/framework/spree/utils/validations/validate-placeholder-image-url.ts
new file mode 100644
index 0000000000..cce2e27da0
--- /dev/null
+++ b/framework/spree/utils/validations/validate-placeholder-image-url.ts
@@ -0,0 +1,15 @@
+const validatePlaceholderImageUrl = (
+ placeholderUrlOrFalse: unknown
+): string | false => {
+ if (!placeholderUrlOrFalse || placeholderUrlOrFalse === 'false') {
+ return false
+ }
+
+ if (typeof placeholderUrlOrFalse === 'string') {
+ return placeholderUrlOrFalse
+ }
+
+ throw new TypeError('placeholderUrlOrFalse must be a string or falsy.')
+}
+
+export default validatePlaceholderImageUrl
diff --git a/framework/spree/utils/validations/validate-products-prerender-count.ts b/framework/spree/utils/validations/validate-products-prerender-count.ts
new file mode 100644
index 0000000000..024db1ea64
--- /dev/null
+++ b/framework/spree/utils/validations/validate-products-prerender-count.ts
@@ -0,0 +1,21 @@
+const validateProductsPrerenderCount = (prerenderCount: unknown): number => {
+ let prerenderCountInteger: number
+
+ if (typeof prerenderCount === 'string') {
+ prerenderCountInteger = parseInt(prerenderCount)
+ } else if (typeof prerenderCount === 'number') {
+ prerenderCountInteger = prerenderCount
+ } else {
+ throw new TypeError(
+ 'prerenderCount count must be a string containing a number or an integer.'
+ )
+ }
+
+ if (prerenderCountInteger < 0) {
+ throw new RangeError('prerenderCount must be non-negative.')
+ }
+
+ return prerenderCountInteger
+}
+
+export default validateProductsPrerenderCount
diff --git a/framework/spree/wishlist/index.ts b/framework/spree/wishlist/index.ts
new file mode 100644
index 0000000000..241af3c7e4
--- /dev/null
+++ b/framework/spree/wishlist/index.ts
@@ -0,0 +1,3 @@
+export { default as useAddItem } from './use-add-item'
+export { default as useWishlist } from './use-wishlist'
+export { default as useRemoveItem } from './use-remove-item'
diff --git a/framework/spree/wishlist/use-add-item.tsx b/framework/spree/wishlist/use-add-item.tsx
new file mode 100644
index 0000000000..dac003ddc1
--- /dev/null
+++ b/framework/spree/wishlist/use-add-item.tsx
@@ -0,0 +1,87 @@
+import { useCallback } from 'react'
+import type { MutationHook } from '@commerce/utils/types'
+import useAddItem from '@commerce/wishlist/use-add-item'
+import type { UseAddItem } from '@commerce/wishlist/use-add-item'
+import useWishlist from './use-wishlist'
+import type { ExplicitWishlistAddItemHook } from '../types'
+import type {
+ WishedItem,
+ WishlistsAddWishedItem,
+} from '@spree/storefront-api-v2-sdk/types/interfaces/WishedItem'
+import type { GraphQLFetcherResult } from '@commerce/api'
+import ensureIToken from '../utils/tokens/ensure-itoken'
+import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import type { AddItemHook } from '@commerce/types/wishlist'
+import isLoggedIn from '../utils/tokens/is-logged-in'
+
+export default useAddItem as UseAddItem
+
+export const handler: MutationHook = {
+ fetchOptions: {
+ url: 'wishlists',
+ query: 'addWishedItem',
+ },
+ async fetcher({ input, options, fetch }) {
+ console.info(
+ 'useAddItem (wishlist) fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ const {
+ item: { productId, variantId, wishlistToken },
+ } = input
+
+ if (!isLoggedIn() || !wishlistToken) {
+ return null
+ }
+
+ let token: IToken | undefined = ensureIToken()
+
+ const addItemParameters: WishlistsAddWishedItem = {
+ variant_id: `${variantId}`,
+ quantity: 1,
+ }
+
+ await fetch>({
+ variables: {
+ methodPath: 'wishlists.addWishedItem',
+ arguments: [token, wishlistToken, addItemParameters],
+ },
+ })
+
+ return null
+ },
+ useHook: ({ fetch }) => {
+ const useWrappedHook: ReturnType['useHook']> =
+ () => {
+ const wishlist = useWishlist()
+
+ return useCallback(
+ async (item) => {
+ if (!wishlist.data) {
+ return null
+ }
+
+ const data = await fetch({
+ input: {
+ item: {
+ ...item,
+ wishlistToken: wishlist.data.token,
+ },
+ },
+ })
+
+ await wishlist.revalidate()
+
+ return data
+ },
+ [wishlist]
+ )
+ }
+
+ return useWrappedHook
+ },
+}
diff --git a/framework/spree/wishlist/use-remove-item.tsx b/framework/spree/wishlist/use-remove-item.tsx
new file mode 100644
index 0000000000..3b92b029f4
--- /dev/null
+++ b/framework/spree/wishlist/use-remove-item.tsx
@@ -0,0 +1,75 @@
+import { useCallback } from 'react'
+import type { MutationHook } from '@commerce/utils/types'
+import useRemoveItem from '@commerce/wishlist/use-remove-item'
+import type { UseRemoveItem } from '@commerce/wishlist/use-remove-item'
+import useWishlist from './use-wishlist'
+import type { ExplicitWishlistRemoveItemHook } from '../types'
+import isLoggedIn from '../utils/tokens/is-logged-in'
+import ensureIToken from '../utils/tokens/ensure-itoken'
+import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import type { GraphQLFetcherResult } from '@commerce/api'
+import type { WishedItem } from '@spree/storefront-api-v2-sdk/types/interfaces/WishedItem'
+
+export default useRemoveItem as UseRemoveItem
+
+export const handler: MutationHook = {
+ fetchOptions: {
+ url: 'wishlists',
+ query: 'removeWishedItem',
+ },
+ async fetcher({ input, options, fetch }) {
+ console.info(
+ 'useRemoveItem (wishlist) fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ const { itemId, wishlistToken } = input
+
+ if (!isLoggedIn() || !wishlistToken) {
+ return null
+ }
+
+ let token: IToken | undefined = ensureIToken()
+
+ await fetch>({
+ variables: {
+ methodPath: 'wishlists.removeWishedItem',
+ arguments: [token, wishlistToken, itemId],
+ },
+ })
+
+ return null
+ },
+ useHook: ({ fetch }) => {
+ const useWrappedHook: ReturnType<
+ MutationHook['useHook']
+ > = () => {
+ const wishlist = useWishlist()
+
+ return useCallback(
+ async (input) => {
+ if (!wishlist.data) {
+ return null
+ }
+
+ const data = await fetch({
+ input: {
+ itemId: `${input.id}`,
+ wishlistToken: wishlist.data.token,
+ },
+ })
+
+ await wishlist.revalidate()
+
+ return data
+ },
+ [wishlist]
+ )
+ }
+
+ return useWrappedHook
+ },
+}
diff --git a/framework/spree/wishlist/use-wishlist.tsx b/framework/spree/wishlist/use-wishlist.tsx
new file mode 100644
index 0000000000..0292d40961
--- /dev/null
+++ b/framework/spree/wishlist/use-wishlist.tsx
@@ -0,0 +1,93 @@
+import { useMemo } from 'react'
+import type { SWRHook } from '@commerce/utils/types'
+import useWishlist from '@commerce/wishlist/use-wishlist'
+import type { UseWishlist } from '@commerce/wishlist/use-wishlist'
+import type { GetWishlistHook } from '@commerce/types/wishlist'
+import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token'
+import type { GraphQLFetcherResult } from '@commerce/api'
+import type { Wishlist } from '@spree/storefront-api-v2-sdk/types/interfaces/Wishlist'
+import ensureIToken from '../utils/tokens/ensure-itoken'
+import normalizeWishlist from '../utils/normalizations/normalize-wishlist'
+import isLoggedIn from '../utils/tokens/is-logged-in'
+
+export default useWishlist as UseWishlist
+
+export const handler: SWRHook = {
+ // Provide fetchOptions for SWR cache key
+ fetchOptions: {
+ url: 'wishlists',
+ query: 'default',
+ },
+ async fetcher({ input, options, fetch }) {
+ console.info(
+ 'useWishlist fetcher called. Configuration: ',
+ 'input: ',
+ input,
+ 'options: ',
+ options
+ )
+
+ if (!isLoggedIn()) {
+ return null
+ }
+
+ // TODO: Optimize with includeProducts.
+
+ const token: IToken | undefined = ensureIToken()
+
+ const { data: spreeWishlistsDefaultSuccessResponse } = await fetch<
+ GraphQLFetcherResult
+ >({
+ variables: {
+ methodPath: 'wishlists.default',
+ arguments: [
+ token,
+ {
+ include: [
+ 'wished_items',
+ 'wished_items.variant',
+ 'wished_items.variant.product',
+ 'wished_items.variant.product.primary_variant',
+ 'wished_items.variant.product.images',
+ 'wished_items.variant.product.option_types',
+ 'wished_items.variant.product.variants',
+ 'wished_items.variant.product.variants.option_values',
+ ].join(','),
+ },
+ ],
+ },
+ })
+
+ return normalizeWishlist(
+ spreeWishlistsDefaultSuccessResponse,
+ spreeWishlistsDefaultSuccessResponse.data
+ )
+ },
+ useHook: ({ useData }) => {
+ const useWrappedHook: ReturnType['useHook']> = (
+ input
+ ) => {
+ const response = useData({
+ swrOptions: {
+ revalidateOnFocus: false,
+ ...input?.swrOptions,
+ },
+ })
+
+ return useMemo(
+ () =>
+ Object.create(response, {
+ isEmpty: {
+ get() {
+ return (response.data?.items?.length || 0) <= 0
+ },
+ enumerable: true,
+ },
+ }),
+ [response]
+ )
+ }
+
+ return useWrappedHook
+ },
+}
diff --git a/package-lock.json b/package-lock.json
index 9628d7fa8c..196a45582b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@react-spring/web": "^9.2.1",
+ "@spree/storefront-api-v2-sdk": "^5.0.1",
"@vercel/fetch": "^6.1.1",
"autoprefixer": "^10.2.6",
"body-scroll-lock": "^3.1.5",
@@ -2456,6 +2457,26 @@
"node": ">=6"
}
},
+ "node_modules/@spree/storefront-api-v2-sdk": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@spree/storefront-api-v2-sdk/-/storefront-api-v2-sdk-5.0.1.tgz",
+ "integrity": "sha512-4soQAydchJ9G1d3Xa96XRZ5Uq6IqE0amc8jEjL3H0QLv1NJEv1IK4OfbLK5VRMxv+7QcL/ewHEo2zHm6tqBizA==",
+ "engines": {
+ "node": ">=14.17.0"
+ },
+ "peerDependencies": {
+ "axios": "^0.24.0",
+ "node-fetch": "^2.6.6"
+ },
+ "peerDependenciesMeta": {
+ "axios": {
+ "optional": true
+ },
+ "node-fetch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@szmarczak/http-timer": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
@@ -17201,6 +17222,12 @@
"integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==",
"dev": true
},
+ "@spree/storefront-api-v2-sdk": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@spree/storefront-api-v2-sdk/-/storefront-api-v2-sdk-5.0.1.tgz",
+ "integrity": "sha512-4soQAydchJ9G1d3Xa96XRZ5Uq6IqE0amc8jEjL3H0QLv1NJEv1IK4OfbLK5VRMxv+7QcL/ewHEo2zHm6tqBizA==",
+ "requires": {}
+ },
"@szmarczak/http-timer": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
diff --git a/package.json b/package.json
index f1161db9dd..b53d79ece7 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
},
"dependencies": {
"@react-spring/web": "^9.2.1",
+ "@spree/storefront-api-v2-sdk": "^5.0.1",
"@vercel/fetch": "^6.1.1",
"autoprefixer": "^10.2.6",
"body-scroll-lock": "^3.1.5",