add spree module

This commit is contained in:
Timur Suleymanov 2024-05-21 20:00:11 +05:00
parent 42d5d8efcf
commit c625e1cc89
105 changed files with 7150 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

33
lib/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 `npm run 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,19 @@
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 {}

48
lib/spree/api/index.ts Normal file
View File

@ -0,0 +1,48 @@
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';
import getAllTaxons from './operations/get-all-taxons';
import getProducts from './operations/get-products';
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,
getAllTaxons,
getProducts
};
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,72 @@
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,91 @@
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,84 @@
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,63 @@
import _ from 'lodash';
import type { ITaxons, TaxonAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Taxon';
import type { SpreeSdkVariables } from '../../types';
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;
};
const buildTaxonsTree = (taxons: TaxonAttr[], parentId: string | number): TaxonAttr[] => {
const children = _.chain(taxons)
.filter((item) => {
const relationships = item.relationships || {};
return parentId === _.get(relationships, 'parent.data.id');
})
.sort(taxonsSort)
.value();
return children.map((child) => ({
id: child.id,
name: child.attributes.name,
type: child.type,
position: child.attributes.position,
children: buildTaxonsTree(taxons, child.id)
}));
};
export default function getAllTaxonsOperation({ commerce, locale }) {
async function getAllTaxons(options = {}) {
const { config: userConfig } = options;
const config = commerce.getConfig(userConfig);
const { fetch: apiFetch } = config;
const variables: SpreeSdkVariables = {
methodPath: 'taxons.list',
arguments: [
{
locale: config.locale
}
]
};
const { data: spreeSuccessResponse } = await apiFetch('__UNUSED__', { variables });
const normalizedTaxons = buildTaxonsTree(spreeSuccessResponse.data, '1');
return { taxons: normalizedTaxons };
}
return getAllTaxons;
}

View File

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

View File

@ -0,0 +1,73 @@
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,81 @@
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,37 @@
import normalizeProduct from '../../utils/normalizations/normalize-product';
import { requireConfigValue } from '../../isomorphic-config';
const imagesSize = requireConfigValue('imagesSize');
const imagesQuality = requireConfigValue('imagesQuality');
export default function getProductsOperation({ commerce }) {
async function getProducts({ taxons = [], config: userConfig } = {}) {
const filter = { filter: { taxons: taxons.join(',') }, sort: '-updated_at' };
const variables = {
methodPath: 'products.list',
arguments: [
{
include: 'primary_variant,variants,images,option_types,variants.option_values',
per_page: 100,
...filter,
image_transformation: {
quality: imagesQuality,
size: imagesSize
}
}
]
};
const config = commerce.getConfig(userConfig);
const { fetch: apiFetch } = config;
const { data: spreeSuccessResponse } = await apiFetch('__UNUSED__', { variables });
const normalizedProducts = spreeSuccessResponse.data.map((spreeProduct) =>
normalizeProduct(spreeSuccessResponse, spreeProduct)
);
return { products: normalizedProducts };
}
return getProducts;
}

View File

@ -0,0 +1,120 @@
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,8 @@
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';
export { default as getAllTaxons } from './get-all-taxons';
export { default as getProducts } from './get-products';

View File

@ -0,0 +1,74 @@
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();

3
lib/spree/auth/index.ts Normal file
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,81 @@
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,79 @@
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,94 @@
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;
}
};

4
lib/spree/cart/index.ts Normal file
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,109 @@
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;
}
};

108
lib/spree/cart/use-cart.tsx Normal file
View File

@ -0,0 +1,108 @@
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,107 @@
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,134 @@
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,75 @@
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 {}

9
lib/spree/index.tsx Normal file
View File

@ -0,0 +1,9 @@
const createAxiosFetcher = require('@spree/axios-fetcher/dist/server/index').default;
import { makeClient } from '@spree/storefront-api-v2-sdk/dist/client';
const spreeClient = makeClient({
host: 'http://localhost:3000',
createFetcher: createAxiosFetcher
});
export default spreeClient;

16
lib/spree/next.config.js Normal file
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,96 @@
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>;

35
lib/spree/provider.ts Normal file
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;

166
lib/spree/types/index.ts Normal file
View File

@ -0,0 +1,166 @@
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,50 @@
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,96 @@
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,22 @@
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,102 @@
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,42 @@
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,38 @@
// 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,21 @@
// 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,50 @@
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,4 @@
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';

53
lib/spree/utils/login.ts Normal file
View File

@ -0,0 +1,53 @@
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,191 @@
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,196 @@
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,60 @@
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,14 @@
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,9 @@
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,16 @@
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,49 @@
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,45 @@
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,50 @@
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,19 @@
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,13 @@
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,19 @@
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,13 @@
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;

View File

@ -0,0 +1,19 @@
const validateProductsPrerenderCount = (prerenderCount: unknown): number => {
let prerenderCountInteger: number;
if (typeof prerenderCount === 'string') {
prerenderCountInteger = parseInt(prerenderCount);
} else if (typeof prerenderCount === 'number') {
prerenderCountInteger = prerenderCount;
} else {
throw new TypeError('prerenderCount count must be a string containing a number or an integer.');
}
if (prerenderCountInteger < 0) {
throw new RangeError('prerenderCount must be non-negative.');
}
return prerenderCountInteger;
};
export default validateProductsPrerenderCount;

View File

@ -0,0 +1,3 @@
export { default as useAddItem } from './use-add-item';
export { default as useWishlist } from './use-wishlist';
export { default as useRemoveItem } from './use-remove-item';

View File

@ -0,0 +1,86 @@
import { useCallback } from 'react';
import type { MutationHook } from '@commerce/utils/types';
import useAddItem from '@commerce/wishlist/use-add-item';
import type { UseAddItem } from '@commerce/wishlist/use-add-item';
import useWishlist from './use-wishlist';
import type { ExplicitWishlistAddItemHook } from '../types';
import type {
WishedItem,
WishlistsAddWishedItem
} from '@spree/storefront-api-v2-sdk/types/interfaces/WishedItem';
import type { GraphQLFetcherResult } from '@commerce/api';
import ensureIToken from '../utils/tokens/ensure-itoken';
import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token';
import type { AddItemHook } from '@commerce/types/wishlist';
import isLoggedIn from '../utils/tokens/is-logged-in';
export default useAddItem as UseAddItem<typeof handler>;
export const handler: MutationHook<ExplicitWishlistAddItemHook> = {
fetchOptions: {
url: 'wishlists',
query: 'addWishedItem'
},
async fetcher({ input, options, fetch }) {
console.info(
'useAddItem (wishlist) fetcher called. Configuration: ',
'input: ',
input,
'options: ',
options
);
const {
item: { productId, variantId, wishlistToken }
} = input;
if (!isLoggedIn() || !wishlistToken) {
return null;
}
let token: IToken | undefined = ensureIToken();
const addItemParameters: WishlistsAddWishedItem = {
variant_id: `${variantId}`,
quantity: 1
};
await fetch<GraphQLFetcherResult<WishedItem>>({
variables: {
methodPath: 'wishlists.addWishedItem',
arguments: [token, wishlistToken, addItemParameters]
}
});
return null;
},
useHook: ({ fetch }) => {
const useWrappedHook: ReturnType<MutationHook<AddItemHook>['useHook']> = () => {
const wishlist = useWishlist();
return useCallback(
async (item) => {
if (!wishlist.data) {
return null;
}
const data = await fetch({
input: {
item: {
...item,
wishlistToken: wishlist.data.token
}
}
});
await wishlist.revalidate();
return data;
},
[wishlist]
);
};
return useWrappedHook;
}
};

View File

@ -0,0 +1,75 @@
import { useCallback } from 'react';
import type { MutationHook } from '@commerce/utils/types';
import useRemoveItem from '@commerce/wishlist/use-remove-item';
import type { UseRemoveItem } from '@commerce/wishlist/use-remove-item';
import useWishlist from './use-wishlist';
import type { ExplicitWishlistRemoveItemHook } from '../types';
import isLoggedIn from '../utils/tokens/is-logged-in';
import ensureIToken from '../utils/tokens/ensure-itoken';
import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token';
import type { GraphQLFetcherResult } from '@commerce/api';
import type { WishedItem } from '@spree/storefront-api-v2-sdk/types/interfaces/WishedItem';
export default useRemoveItem as UseRemoveItem<typeof handler>;
export const handler: MutationHook<ExplicitWishlistRemoveItemHook> = {
fetchOptions: {
url: 'wishlists',
query: 'removeWishedItem'
},
async fetcher({ input, options, fetch }) {
console.info(
'useRemoveItem (wishlist) fetcher called. Configuration: ',
'input: ',
input,
'options: ',
options
);
const { itemId, wishlistToken } = input;
if (!isLoggedIn() || !wishlistToken) {
return null;
}
let token: IToken | undefined = ensureIToken();
await fetch<GraphQLFetcherResult<WishedItem>>({
variables: {
methodPath: 'wishlists.removeWishedItem',
arguments: [token, wishlistToken, itemId]
}
});
return null;
},
useHook: ({ fetch }) => {
const useWrappedHook: ReturnType<
MutationHook<ExplicitWishlistRemoveItemHook>['useHook']
> = () => {
const wishlist = useWishlist();
return useCallback(
async (input) => {
if (!wishlist.data) {
return null;
}
const data = await fetch({
input: {
itemId: `${input.id}`,
wishlistToken: wishlist.data.token
}
});
await wishlist.revalidate();
return data;
},
[wishlist]
);
};
return useWrappedHook;
}
};

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