diff --git a/.env.template b/.env.template index a5885494e9..32649d29e4 100644 --- a/.env.template +++ b/.env.template @@ -1,4 +1,4 @@ -# Available providers: local, bigcommerce, shopify, swell, saleor +# Available providers: local, bigcommerce, shopify, swell, saleor, spree COMMERCE_PROVIDER= BIGCOMMERCE_STOREFRONT_API_URL= diff --git a/README.md b/README.md index b6266246ea..1c862e172a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/) - Vendure Demo: https://vendure.vercel.store - Saleor Demo: https://saleor.vercel.store/ - Ordercloud Demo: https://ordercloud.vercel.store/ +- Spree Demo: https://spree.vercel.store/ ## Features @@ -28,7 +29,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/) ## Integrations -Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor and Vendure. We plan to support all major ecommerce backends. +Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor, Vendure and Spree. We plan to support all major ecommerce backends. ## Considerations diff --git a/framework/commerce/config.js b/framework/commerce/config.js index 7fd0536f81..7e61921ae6 100644 --- a/framework/commerce/config.js +++ b/framework/commerce/config.js @@ -15,6 +15,7 @@ const PROVIDERS = [ 'swell', 'vendure', 'ordercloud', + 'spree', ] function getProviderName() { diff --git a/framework/spree/.env.template b/framework/spree/.env.template new file mode 100644 index 0000000000..8f4dbf5dd0 --- /dev/null +++ b/framework/spree/.env.template @@ -0,0 +1,25 @@ +# Template to be used for creating .env* files (.env, .env.local etc.) in the project's root directory. + +COMMERCE_PROVIDER=spree + +{# - NEXT_PUBLIC_* are exposed to the web browser and the server #} +NEXT_PUBLIC_SPREE_API_HOST=http://localhost:4000 +NEXT_PUBLIC_SPREE_DEFAULT_LOCALE=en-us +NEXT_PUBLIC_SPREE_CART_COOKIE_NAME=spree_cart_token +{# -- cookie expire in days #} +NEXT_PUBLIC_SPREE_CART_COOKIE_EXPIRE=7 +NEXT_PUBLIC_SPREE_USER_COOKIE_NAME=spree_user_token +NEXT_PUBLIC_SPREE_USER_COOKIE_EXPIRE=7 +NEXT_PUBLIC_SPREE_IMAGE_HOST=http://localhost:4000 +NEXT_PUBLIC_SPREE_ALLOWED_IMAGE_DOMAIN=localhost +NEXT_PUBLIC_SPREE_CATEGORIES_TAXONOMY_PERMALINK=categories +NEXT_PUBLIC_SPREE_BRANDS_TAXONOMY_PERMALINK=brands +NEXT_PUBLIC_SPREE_ALL_PRODUCTS_TAXONOMY_ID=false +NEXT_PUBLIC_SPREE_SHOW_SINGLE_VARIANT_OPTIONS=false +NEXT_PUBLIC_SPREE_LAST_UPDATED_PRODUCTS_PRERENDER_COUNT=10 +NEXT_PUBLIC_SPREE_PRODUCT_PLACEHOLDER_IMAGE_URL=/product-img-placeholder.svg +NEXT_PUBLIC_SPREE_LINE_ITEM_PLACEHOLDER_IMAGE_URL=/product-img-placeholder.svg +NEXT_PUBLIC_SPREE_IMAGES_OPTION_FILTER=false +NEXT_PUBLIC_SPREE_IMAGES_SIZE=1000x1000 +NEXT_PUBLIC_SPREE_IMAGES_QUALITY=100 +NEXT_PUBLIC_SPREE_LOGIN_AFTER_SIGNUP=true diff --git a/framework/spree/README-assets/screenshots.png b/framework/spree/README-assets/screenshots.png new file mode 100644 index 0000000000..93c133e06d Binary files /dev/null and b/framework/spree/README-assets/screenshots.png differ diff --git a/framework/spree/README.md b/framework/spree/README.md new file mode 100644 index 0000000000..0b2068cb58 --- /dev/null +++ b/framework/spree/README.md @@ -0,0 +1,33 @@ +# [Spree Commerce][1] Provider + +![Screenshots of Spree Commerce and NextJS Commerce][5] + +An integration of [Spree Commerce](https://spreecommerce.org/) within NextJS Commerce. It supports browsing and searching Spree products and adding products to the cart. + +**Demo**: [https://spree.vercel.store/][6] + +## Installation + +1. Setup Spree - [follow the Getting Started guide](https://dev-docs.spreecommerce.org/getting-started/installation). + +1. Setup Nextjs Commerce - [instructions for setting up NextJS Commerce][2]. + +1. Copy the `.env.template` file in this directory (`/framework/spree`) to `.env.local` in the main directory + + ```bash + cp framework/spree/.env.template .env.local + ``` + +1. Set `NEXT_PUBLIC_SPREE_CATEGORIES_TAXONOMY_PERMALINK` and `NEXT_PUBLIC_SPREE_BRANDS_TAXONOMY_PERMALINK` environment variables: + + - They rely on [taxonomies'](https://dev-docs.spreecommerce.org/internals/products#taxons-and-taxonomies) permalinks in Spree. + - Go to the Spree admin panel and create `Categories` and `Brands` taxonomies if they don't exist and copy their permalinks into `.env.local` in NextJS Commerce. + +1. Finally, run `yarn dev` :tada: + +[1]: https://spreecommerce.org/ +[2]: https://github.com/vercel/commerce +[3]: https://github.com/spree/spree_starter +[4]: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS +[5]: ./README-assets/screenshots.png +[6]: https://spree.vercel.store/ diff --git a/framework/spree/api/endpoints/cart/index.ts b/framework/spree/api/endpoints/cart/index.ts new file mode 100644 index 0000000000..491bf0ac93 --- /dev/null +++ b/framework/spree/api/endpoints/cart/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/spree/api/endpoints/catalog/index.ts b/framework/spree/api/endpoints/catalog/index.ts new file mode 100644 index 0000000000..491bf0ac93 --- /dev/null +++ b/framework/spree/api/endpoints/catalog/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/spree/api/endpoints/catalog/products.ts b/framework/spree/api/endpoints/catalog/products.ts new file mode 100644 index 0000000000..491bf0ac93 --- /dev/null +++ b/framework/spree/api/endpoints/catalog/products.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/spree/api/endpoints/checkout/get-checkout.ts b/framework/spree/api/endpoints/checkout/get-checkout.ts new file mode 100644 index 0000000000..985239678c --- /dev/null +++ b/framework/spree/api/endpoints/checkout/get-checkout.ts @@ -0,0 +1,44 @@ +import type { CheckoutEndpoint } from '.' + +const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({ + req: _request, + res: response, + config: _config, +}) => { + try { + const html = ` + + + + + + Checkout + + +
+ + + +

Checkout not yet implemented :(

+

+ See #64 +

+
+ + + ` + + response.status(200) + response.setHeader('Content-Type', 'text/html') + response.write(html) + response.end() + } catch (error) { + console.error(error) + + const message = 'An unexpected error ocurred' + + response.status(500).json({ data: null, errors: [{ message }] }) + } +} + +export default getCheckout diff --git a/framework/spree/api/endpoints/checkout/index.ts b/framework/spree/api/endpoints/checkout/index.ts new file mode 100644 index 0000000000..0a5ee9e722 --- /dev/null +++ b/framework/spree/api/endpoints/checkout/index.ts @@ -0,0 +1,22 @@ +import { createEndpoint } from '@commerce/api' +import type { GetAPISchema, CommerceAPI } from '@commerce/api' +import checkoutEndpoint from '@commerce/api/endpoints/checkout' +import type { CheckoutSchema } from '@commerce/types/checkout' +import getCheckout from './get-checkout' +import type { SpreeApiProvider } from '../..' + +export type CheckoutAPI = GetAPISchema< + CommerceAPI, + CheckoutSchema +> + +export type CheckoutEndpoint = CheckoutAPI['endpoint'] + +export const handlers: CheckoutEndpoint['handlers'] = { getCheckout } + +const checkoutApi = createEndpoint({ + 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",