4
0
forked from crowetic/commerce

Spree Commerce Provider (#484)

* Include @spree/storefront-api-v2-sdk

* Add basic Spree framework structure

* Add Spree as allowed Framework

* Fetch product images, standardize API fetch using Spree SDK

* Include slug and path in products

* Fetch single product during build time

* PLP with searching by category

* Fetch Spree Categories and Brands

* Sort PLP

* Search products by name

* Fix option values collection

* Fix hasNonMasterVariants

* Sort Categories and Brands

* Add configuration to show product options when there's one variant available

* Enable text search for the Spree Framework

* Allow removing line items

* Allow updating line item quantity

* Add __typename to variant options to allow adding the selected variant to the cart

* Use fetch and Request from node-fetch in Spree SDK

* Update Spree SDK fetcher

* Show placeholder message for /chechout and adjust api fetcher type

* Use kebab case instead of camel case

* Remove outdated comments

* Remove outdated comment

* Resolve isColorProductOption duplication

* Type Spree variants and line items and temporarily remove height, width and depth

* Remove outdated comment

* Update comments about cart discounts

* Remove 'spree' prefix from isomorphicConfig and add lastUpdatedProductsPrerenderCount

* Implement getAllProductPaths to prerender some products during build time

* Adjust fetchers to the latest Spree SDK interface

* Add types to Spree taxons mapping

* Revert port change in package.json scripts

* Add basic README describing Spree installation

* Expand README's installation section

* Upgrade Spree SDK to 4.7.0 and add node-fetch to dependencies

* Order providers alphanumerically

Co-authored-by: Damian Legawiec <damian@sparksolutions.co>

* Sort products by available_on when using the Trending sorting in useSearch

* Change the default Spree port to 4000 and update README in sync with Spree Starter changes

* Save primary variant's SKU when normalizing a product from Spree

* Create a new cart if Spree can't find the current using a token

* Add separator to README

* Add missing Error subclass

* Allow placeholder images for products and line items without images

* Add image

* Reset tsconfig.json paths to originla values

* Search taxonomies by permalinks instead of IDs

* Upgrade Spree SDK to version 4.7.1

* Remove references to @framework and use relative paths instead

* Generalize TypeScript and add typings to getPage

* Update fetcher to avoid parsing non-JSON responses

* Use original product image by default instead of resized

* Link to an online demo of the Spree integration in the README

* Flatten fetcher responses

* Include Spree in the list of supported ecommerce backends in README

* Update README.md

* Format Spree's README

* Add link to the Spree demo site in the main README

* Update README.md

* Update README.md

* Allow setting a taxon id for getAllProducts

* Use Spree SDK's JSON:API helpers

* Sort getAllProducts by -updated_at when using a taxonomy

* Remove slash '/' from line item's paths

* Allow filtering variant images by option type

* Upgrade checkout behavior in line with core NextJS Commerce changes

* Remove dummy submitCheckout function

* [NX-24] Display PDP option types sorted by position from Spree

* Supply Spree primary variant if a product has no option variants

* Do not throw an error if a product doesn't have NEXT_PUBLIC_SPREE_IMAGES_OPTION_FILTER

* [NX-43] Uses image transformations when fetching products images

* Use bind to properly call Spree SDK methods and update SDK fetcher in line with SDK 4.12.0

* Fix ESLint issues in useHook

* Support account sign up, login and logout

Also
- Converts the guest cart to a persisted cart tied to the logged in user after log in.
- Fixes issues with use-remove-item. The cart will now properly refresh after an item is removed.
- Uses the logged in user's token to adjust the cart and make other authenticated requests.
- Transparently refreshed the access token of the logged in user with a refresh token. Replays requests to Spree which fail with a 401 error after refreshing the access token.

* Fetch logged in user's cart after login or signup but associate guest cart only after signup

* Support Spree default wishlist show, add and remove wished items operations

* Fetch Spree CMS Pages

* Fix login, handle critical token errors and fix WishlistCard

Fix to WishlistCard changes its props to be consistent with WishlistButton when calling useRemoveItem

* Fix variable name (#574)

Variable name should be `ChevronRight`

* Update get-cart.ts (#474)

include digital items

Co-authored-by: Gonzalo Pozzo <gonzalo.pozzo4@gmail.com>

* Update normalize.ts (#475)

add missing options property to `normalizeLineItem`

Co-authored-by: Gonzalo Pozzo <gonzalo.pozzo4@gmail.com>

* Update add-item.ts (#473)

* Update add-item.ts

include digital items

* Update add-item.ts

include digital items

Co-authored-by: Gonzalo Pozzo <gonzalo.pozzo4@gmail.com>

* fix typo (#572)

Co-authored-by: Gonzalo Pozzo <gonzalo.pozzo4@gmail.com>

* Fix authentication.refreshToken arguments

* Remove redundant comments and logs

* Fix createEmptyCart request to Spree and add option to disable auto login

* Fix formatting issues

* Apply image transformation when fetching images for products in cart

* Replace call to qs with Spree SDK built-in helper

* Upgrade Spree SDK to 5.0.1

* Rename zeitFetch import to vercelFetch

* Abstract fetcher JSON Content-Type checking into separate function

* Rename imageUrl to url

getMediaGallery already provides context for the constant

* Remove return type for getProductPath

The return type can be trivially determined from the returned value.

* Change URL to Spree demo store in root README

Co-authored-by: Gonzalo Pozzo <gonzalo.pozzo4@gmail.com>

* Change label for link to Spree demo store in Spree's README

Co-authored-by: Gonzalo Pozzo <gonzalo.pozzo4@gmail.com>

* Change URL to Spree demo store in Spree's README

Co-authored-by: Gonzalo Pozzo <gonzalo.pozzo4@gmail.com>

* Use only relative paths to /framework/spree from itself

Co-authored-by: tniezg <tomek.niezgoda@mail.com>
Co-authored-by: Damian Legawiec <damian@sparksolutions.co>
Co-authored-by: Robert Nowakowski <aplegatt@gmail.com>
Co-authored-by: Grey <57859708+greyhere@users.noreply.github.com>
Co-authored-by: pfcodes <phil@auroradigit.al>
Co-authored-by: Gonzalo Pozzo <gonzalo.pozzo4@gmail.com>
Co-authored-by: Konrad Kruk <github@konradk.pl>
This commit is contained in:
Tomek Niezgoda 2021-12-13 21:42:30 +01:00 committed by GitHub
parent 541009fd15
commit d77d000431
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
107 changed files with 4142 additions and 2 deletions

View File

@ -1,4 +1,4 @@
# Available providers: local, bigcommerce, shopify, swell, saleor # Available providers: local, bigcommerce, shopify, swell, saleor, spree
COMMERCE_PROVIDER= COMMERCE_PROVIDER=
BIGCOMMERCE_STOREFRONT_API_URL= BIGCOMMERCE_STOREFRONT_API_URL=

View File

@ -13,6 +13,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/)
- Vendure Demo: https://vendure.vercel.store - Vendure Demo: https://vendure.vercel.store
- Saleor Demo: https://saleor.vercel.store/ - Saleor Demo: https://saleor.vercel.store/
- Ordercloud Demo: https://ordercloud.vercel.store/ - Ordercloud Demo: https://ordercloud.vercel.store/
- Spree Demo: https://spree.vercel.store/
## Features ## Features
@ -28,7 +29,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/)
## Integrations ## 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 ## Considerations

View File

@ -15,6 +15,7 @@ const PROVIDERS = [
'swell', 'swell',
'vendure', 'vendure',
'ordercloud', 'ordercloud',
'spree',
] ]
function getProviderName() { function getProviderName() {

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

33
framework/spree/README.md Normal file
View File

@ -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/

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1,44 @@
import type { CheckoutEndpoint } from '.'
const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({
req: _request,
res: response,
config: _config,
}) => {
try {
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Checkout</title>
</head>
<body>
<div style='margin: 10rem auto; text-align: center; font-family: SansSerif, "Segoe UI", Helvetica; color: #888;'>
<svg xmlns="http://www.w3.org/2000/svg" style='height: 60px; width: 60px;' fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h1>Checkout not yet implemented :(</h1>
<p>
See <a href='https://github.com/vercel/commerce/issues/64' target='_blank'>#64</a>
</p>
</div>
</body>
</html>
`
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

View File

@ -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<SpreeApiProvider>,
CheckoutSchema
>
export type CheckoutEndpoint = CheckoutAPI['endpoint']
export const handlers: CheckoutEndpoint['handlers'] = { getCheckout }
const checkoutApi = createEndpoint<CheckoutAPI>({
handler: checkoutEndpoint,
handlers,
})
export default checkoutApi

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -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<P extends SpreeApiProvider = SpreeApiProvider> =
CommerceAPI<P>
export function getCommerceApi<P extends SpreeApiProvider>(
customProvider: P = provider as any
): SpreeApi<P> {
return commerceApi(customProvider)
}

View File

@ -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<SpreeApiProvider>) {
async function getAllPages<T extends GetAllPagesOperation>(options?: {
config?: Partial<SpreeApiConfig>
preview?: boolean
}): Promise<T['data']>
async function getAllPages<T extends GetAllPagesOperation>(
opts: {
config?: Partial<SpreeApiConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getAllPages<T extends GetAllPagesOperation>({
config: userConfig,
preview,
query,
url,
}: {
url?: string
config?: Partial<SpreeApiConfig>
preview?: boolean
query?: string
} = {}): Promise<T['data']> {
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<Page>(
(spreePage) =>
normalizePage(spreeSuccessResponse, spreePage, config.locales || [])
)
return { pages: normalizedPages }
}
return getAllPages
}

View File

@ -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<SpreeApiProvider>) {
async function getAllProductPaths<
T extends GetAllProductPathsOperation
>(opts?: {
variables?: T['variables']
config?: Partial<SpreeApiConfig>
}): Promise<T['data']>
async function getAllProductPaths<T extends GetAllProductPathsOperation>(
opts: {
variables?: T['variables']
config?: Partial<SpreeApiConfig>
} & OperationOptions
): Promise<T['data']>
async function getAllProductPaths<T extends GetAllProductPathsOperation>({
query,
variables: getAllProductPathsVariables = {},
config: userConfig,
}: {
query?: string
variables?: T['variables']
config?: Partial<SpreeApiConfig>
} = {}): Promise<T['data']> {
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<Product, 'path'>[] =
spreeSuccessResponse.data.map((spreeProduct) => ({
path: getProductPath(spreeProduct),
}))
return { products: normalizedProductsPaths }
}
return getAllProductPaths
}

View File

@ -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<SpreeApiProvider>) {
async function getAllProducts<T extends GetAllProductsOperation>(opts?: {
variables?: T['variables']
config?: Partial<SpreeApiConfig>
preview?: boolean
}): Promise<T['data']>
async function getAllProducts<T extends GetAllProductsOperation>(
opts: {
variables?: T['variables']
config?: Partial<SpreeApiConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getAllProducts<T extends GetAllProductsOperation>({
variables: getAllProductsVariables = {},
config: userConfig,
}: {
variables?: T['variables']
config?: Partial<SpreeApiConfig>
} = {}): 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
}

View File

@ -0,0 +1,6 @@
export default function getCustomerWishlistOperation() {
function getCustomerWishlist(): any {
return { wishlist: {} }
}
return getCustomerWishlist
}

View File

@ -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<SpreeApiProvider>) {
async function getPage<T extends GetPageOperation>(opts: {
variables: T['variables']
config?: Partial<SpreeApiConfig>
preview?: boolean
}): Promise<T['data']>
async function getPage<T extends GetPageOperation>(
opts: {
variables: T['variables']
config?: Partial<SpreeApiConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getPage<T extends GetPageOperation>({
url,
config: userConfig,
preview,
variables: getPageVariables,
}: {
url?: string
variables: T['variables']
config?: Partial<SpreeApiConfig>
preview?: boolean
}): Promise<T['data']> {
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
}

View File

@ -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<SpreeApiProvider>) {
async function getProduct<T extends GetProductOperation>(opts: {
variables: T['variables']
config?: Partial<SpreeApiConfig>
preview?: boolean
}): Promise<T['data']>
async function getProduct<T extends GetProductOperation>(
opts: {
variables: T['variables']
config?: Partial<SpreeApiConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getProduct<T extends GetProductOperation>({
query = '',
variables: getProductVariables,
config: userConfig,
}: {
query?: string
variables?: T['variables']
config?: Partial<SpreeApiConfig>
preview?: boolean
}): Promise<T['data']> {
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
}

View File

@ -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<SpreeApiProvider>) {
async function getSiteInfo<T extends GetSiteInfoOperation>(opts?: {
config?: Partial<SpreeApiConfig>
preview?: boolean
}): Promise<T['data']>
async function getSiteInfo<T extends GetSiteInfoOperation>(
opts: {
config?: Partial<SpreeApiConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getSiteInfo<T extends GetSiteInfoOperation>({
query,
variables: getSiteInfoVariables = {},
config: userConfig,
}: {
query?: string
variables?: any
config?: Partial<SpreeApiConfig>
preview?: boolean
} = {}): Promise<GetSiteInfoResult> {
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
}

View File

@ -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'

View File

@ -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<GraphQLFetcherResult<any>, any>
// TODO: GraphQLFetcher<GraphQLFetcherResult<any>, any> should be GraphQLFetcher<GraphQLFetcherResult<any>, 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<SpreeSdkResponseWithRawResponse> =
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

View File

@ -0,0 +1,3 @@
import vercelFetch from '@vercel/fetch'
export default vercelFetch()

View File

@ -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'

View File

@ -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<typeof handler>
export const handler: MutationHook<LoginHook> = {
// 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<MutationHook<LoginHook>['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
},
}

View File

@ -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<typeof handler>
export const handler: MutationHook<LogoutHook> = {
// 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<MutationHook<LogoutHook>['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
},
}

View File

@ -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<typeof handler>
export const handler: MutationHook<SignupHook> = {
// 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<GraphQLFetcherResult<IAccount>>({
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<MutationHook<SignupHook>['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
},
}

View File

@ -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'

View File

@ -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<typeof handler>
export const handler: MutationHook<AddItemHook> = {
// 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<IOrder>
>({
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<MutationHook<AddItemHook>['useHook']> =
() => {
const { mutate } = useCart()
return useCallback(
async (input) => {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[mutate]
)
}
return useWrappedHook
},
}

View File

@ -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<typeof handler>
// 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<GetCartHook> = {
// 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<IOrder>
>({
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<SWRHook<GetCartHook>['useHook']> = (
input
) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
return useMemo<typeof response & { isEmpty: boolean }>(() => {
return Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) === 0
},
enumerable: true,
},
})
}, [response])
}
return useWrappedHook
},
}

View File

@ -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<typeof handler>
export const handler: MutationHook<RemoveItemHook> = {
// 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<IOrder>
>({
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<MutationHook<RemoveItemHook>['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
},
}

View File

@ -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<any>
export const handler: MutationHook<UpdateItemHook> = {
// 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<IOrder>
>({
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<MutationHook<UpdateItemHook>['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
},
}

View File

@ -0,0 +1,17 @@
import { SWRHook } from '@commerce/utils/types'
import useCheckout, { UseCheckout } from '@commerce/checkout/use-checkout'
export default useCheckout as UseCheckout<typeof handler>
export const handler: SWRHook<any> = {
// 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) => ({}),
}

View File

@ -0,0 +1,10 @@
{
"provider": "spree",
"features": {
"wishlist": true,
"cart": true,
"search": true,
"customerAuth": true,
"customCheckout": false
}
}

View File

@ -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<typeof handler>
export const handler: MutationHook<any> = {
// Provide fetchOptions for SWR cache key
fetchOptions: {
url: 'account',
query: 'createAddress',
},
async fetcher({ input, options, fetch }) {},
useHook:
({ fetch }) =>
() =>
async () => ({}),
}

View File

@ -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<typeof handler>
export const handler: MutationHook<any> = {
// Provide fetchOptions for SWR cache key
fetchOptions: {
// TODO: Revise url and query
url: 'checkout',
query: 'addPayment',
},
async fetcher({ input, options, fetch }) {},
useHook:
({ fetch }) =>
() =>
async () => ({}),
}

View File

@ -0,0 +1 @@
export { default as useCustomer } from './use-customer'

View File

@ -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<typeof handler>
export const handler: SWRHook<CustomerHook> = {
// 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<IAccount>
>({
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<SWRHook<CustomerHook>['useHook']> = (
input
) => {
return useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
}
return useWrappedHook
},
}

View File

@ -0,0 +1 @@
export default class AccessTokenError extends Error {}

View File

@ -0,0 +1 @@
export default class MisconfigurationError extends Error {}

View File

@ -0,0 +1 @@
export default class MissingConfigurationValueError extends Error {}

View File

@ -0,0 +1 @@
export default class MissingLineItemVariantError extends Error {}

View File

@ -0,0 +1 @@
export default class MissingOptionValueError extends Error {}

View File

@ -0,0 +1 @@
export default class MissingPrimaryVariantError extends Error {}

View File

@ -0,0 +1 @@
export default class MissingProductError extends Error {}

View File

@ -0,0 +1 @@
export default class MissingSlugVariableError extends Error {}

View File

@ -0,0 +1 @@
export default class MissingVariantError extends Error {}

View File

@ -0,0 +1 @@
export default class RefreshTokenError extends Error {}

View File

@ -0,0 +1 @@
export default class SpreeResponseContentError extends Error {}

View File

@ -0,0 +1 @@
export default class SpreeSdkMethodFromEndpointPathError extends Error {}

View File

@ -0,0 +1 @@
export default class TokensNotRejectedError extends Error {}

View File

@ -0,0 +1 @@
export default class UserTokenResponseParseError extends Error {}

116
framework/spree/fetcher.ts Normal file
View File

@ -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<SpreeSdkResponseWithRawResponse>
): GraphQLFetcherResult<SpreeSdkResponse> => {
const data = storeResponse.success()
const rawFetchResponse = data[fetchResponseKey]
return {
data,
res: rawFetchResponse,
}
}
const fetcher: Fetcher<GraphQLFetcherResult<SpreeSdkResponse>> = 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<SpreeSdkResponseWithRawResponse> =
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<SpreeSdkResponseWithRawResponse> =
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

49
framework/spree/index.tsx Normal file
View File

@ -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 (
<SWRConfig
value={{
onError: (error, _key) => {
handleTokenErrors(error, () => void logout())
},
}}
>
{children}
</SWRConfig>
)
}
export const getCommerceProvider = <P extends Provider>(provider: P) => {
return function CommerceProvider({
children,
...props
}: CommerceProviderProps) {
return (
<CoreCommerceProvider provider={{ ...provider, ...props }}>
<WithTokenErrorsHandling>{children}</WithTokenErrorsHandling>
</CoreCommerceProvider>
)
}
}
export const CommerceProvider =
getCommerceProvider<SpreeProvider>(spreeProvider)
export const useCommerce = () => useCoreCommerce<SpreeProvider>()

View File

@ -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>(isomorphicConfig, key)
export { requireConfigValue }

View File

@ -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',
},
]
},
}

View File

@ -0,0 +1,2 @@
export { default as usePrice } from './use-price'
export { default as useSearch } from './use-search'

View File

@ -0,0 +1,2 @@
export * from '@commerce/product/use-price'
export { default } from '@commerce/product/use-price'

View File

@ -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<SearchProductsHook> = {
// 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<IProducts>
>({
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<SWRHook<SearchProductsHook>['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<typeof handler>

View File

@ -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

View File

@ -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<string, unknown>
export type NonUndefined<T> = T extends undefined ? never : T
export type ValueOf<T> = 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<ResultResponse<ResultResponseSuccessType>>
export type SpreeSdkMethod<
ResultResponseSuccessType extends SpreeSdkResultResponseSuccessType = SpreeSdkResultResponseSuccessType
> = (...args: any[]) => SpreeSdkMethodReturnType<ResultResponseSuccessType>
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 }
}

View File

@ -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

View File

@ -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

View File

@ -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<GraphQLFetcherResult<IOrder>> => {
const token: IToken | undefined = ensureIToken()
return fetch<GraphQLFetcherResult<IOrder>>({
variables: {
methodPath: 'cart.create',
arguments: [token],
},
})
}
export default createEmptyCart

View File

@ -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

View File

@ -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<OptionTypeAttr>(
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

View File

@ -0,0 +1,43 @@
import type { NonUndefined, UnknownObjectValues } from '../types'
import MisconfigurationError from '../errors/MisconfigurationError'
import isServer from './is-server'
const generateMisconfigurationErrorMessage = (
keys: Array<string | number | symbol>
) => `${keys.join(', ')} must have a value before running the Framework.`
const forceIsomorphicConfigValues = <
X extends keyof T,
T extends UnknownObjectValues,
H extends Record<X, NonUndefined<T[X]>>
>(
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

View File

@ -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

View File

@ -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<ProductImage[]>((productImages, _, imageIndex) => {
const url = getImageUrl(images[imageIndex], 800, 800)
if (url) {
return [...productImages, { url }]
}
return productImages
}, [])
}
export default getMediaGallery

View File

@ -0,0 +1,7 @@
import type { ProductSlugAttr } from '../types'
const getProductPath = (partialSpreeProduct: ProductSlugAttr) => {
return `/${partialSpreeProduct.attributes.slug}`
}
export default getProductPath

View File

@ -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<ResultResponseSuccessType> => {
const pathParts = path.split('.')
const reachedPath: string[] = []
let node = <Record<string, unknown>>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 = <Record<string, unknown>>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

View File

@ -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

View File

@ -0,0 +1,5 @@
const isJsonContentType = (contentType: string): boolean =>
contentType.includes('application/json') ||
contentType.includes('application/vnd.api+json')
export default isJsonContentType

View File

@ -0,0 +1 @@
export default typeof window === 'undefined'

View File

@ -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<void> => {
const { data: spreeGetTokenSuccessResponse } = await fetch<
GraphQLFetcherResult<IOAuthToken>
>({
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<GraphQLFetcherResult<IOrder>>({
variables: {
methodPath: 'cart.associateGuestCart',
arguments: [token, associateGuestCartParameters],
},
})
// We no longer need the guest cart token, so let's remove it.
}
}
removeCartToken()
}
export default login

View File

@ -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<ProductAttr>(
spreeSuccessResponse,
spreeVariant,
'product'
)
if (spreeProduct === null) {
throw new MissingLineItemVariantError(
`Couldn't find product for variant with id ${spreeVariant.id}.`
)
}
const spreeVariantImageRecords =
jsonApi.findRelationshipDocuments<SpreeProductImage>(
spreeSuccessResponse,
spreeVariant,
'images'
)
let lineItemImage
const variantImage = getMediaGallery(
spreeVariantImageRecords,
createGetAbsoluteImageUrl(requireConfigValue('imageHost') as string)
)[0]
if (variantImage) {
lineItemImage = variantImage
} else {
const spreeProductImageRecords =
jsonApi.findRelationshipDocuments<SpreeProductImage>(
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<VariantAttr>(
spreeSuccessResponse,
spreeLineItem,
'variant'
)
if (variant === null) {
throw new MissingLineItemVariantError(
`Couldn't find variant for line item with id ${spreeLineItem.id}.`
)
}
const product = jsonApi.findSingleRelationshipDocument<ProductAttr>(
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<OptionTypeAttr>(
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<LineItemAttr>(
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

View File

@ -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

View File

@ -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<VariantAttr>(
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<JsonApiDocument[]>(
(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<JsonApiDocument[]>(
(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

View File

@ -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

View File

@ -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<WishedItemAttr>(
spreeSuccessResponse,
spreeWishlist,
'wished_items'
)
const items: ExplicitCommerceWishlist['items'] = spreeWishedItems.map(
(spreeWishedItem) => {
const spreeWishedVariant =
jsonApi.findSingleRelationshipDocument<VariantAttr>(
spreeSuccessResponse,
spreeWishedItem,
'variant'
)
if (spreeWishedVariant === null) {
throw new MissingVariantError(
`Couldn't find variant for wished item with id ${spreeWishedItem.id}.`
)
}
const spreeWishedProduct =
jsonApi.findSingleRelationshipDocument<ProductAttr>(
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

View File

@ -0,0 +1,16 @@
import MissingConfigurationValueError from '../errors/MissingConfigurationValueError'
import type { NonUndefined, ValueOf } from '../types'
const requireConfig = <T>(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<ValueOf<T>>
}
export default requireConfig

View File

@ -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

View File

@ -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)
}

View File

@ -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<void> => {
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

View File

@ -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

View File

@ -0,0 +1,9 @@
import { ensureUserTokenResponse } from './user-token-response'
const isLoggedIn = (): boolean => {
const userTokenResponse = ensureUserTokenResponse()
return !!userTokenResponse
}
export default isLoggedIn

View File

@ -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<void> => {
const spreeRevokeTokensResponses = await Promise.allSettled([
fetch<GraphQLFetcherResult<EmptyObjectResponse>>({
variables: {
methodPath: 'authentication.revokeToken',
arguments: [
{
token: userTokens.refreshToken,
},
],
},
}),
fetch<GraphQLFetcherResult<EmptyObjectResponse>>({
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

View File

@ -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)
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More