Updated Saleor Provider (#356)

* Initial work, copied from the Shopify provider

* Added basis setup and type generation for the products queries

* refactor: adjust the types

* task: relax the Node.js constraint

* fix: page/product properties

* disable unknown fields

* mention Saleor in the README

* setup debugging for Next.js

* Check nextjs-commerce bug if no images are added for a product

* fix: client/server pecularities for env visibility

Must prefix with `NEXT_PUBLIC_` so that the API URL is
visible on the client

* re: make search work with Saleor API (WIP)

* task: update deps

* task: move to Webpack 5.x

* saleor: initial cart integration

* update deps

* saleor: shall the cart appear!

* task: remove deprecated packages

* saleor: adding/removing from the cart

* saleor: preliminary signup process

* saleor: fix the prices in the cart

* update deps

* update deps

* Added the options for a variant to the product page

* Mapped options to variants

* Mapped options to variants

* saleor: refine the auth process

* saleor: remove unused code

* saleor: handle customer find via refresh

temporary solution

* saleor: update deps

* saleor: fix the session handling

* saleor: fix the variants

* saleor: simplify the naming for GraphQL statements

* saleor: fix the type for collection

* saleor: arrange the error codes

* saleor: integrate collections

* saleor: fix product sorting

* saleor: set cookie location

* saleor: update the schema

* saleor: attach checkout to customer

* saleor: fix the checkout flow

* saleor: unify GraphQL naming approach

* task: update deps

* Add the env variables for saleor to the template

* task: prettier

* saleor: stub API for build/typescript compilation

thanks @cond0r

* task: temporarily disable for the `build`

* saleor: refactor GraphQL queries

* saleor: adjust the config

* task: update dependencies

* revert: Next.js to `10.0.9`

* saleor: fix the checkout fetch query

* task: update dependencies

* saleor: adapt for displaying featured products

* saleor: update the provider structure

* saleor: make the home page representable

* feature/cart: display the variant name (cond)

Co-authored-by: Patryk Zawadzki <patrys@room-303.com>
Co-authored-by: royderks <10717410+royderks@users.noreply.github.com>
This commit is contained in:
Jakub Neander 2021-06-10 08:46:28 +02:00 committed by GitHub
parent 685fb932db
commit 3b2bf654fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
115 changed files with 34182 additions and 1671 deletions

View File

@ -13,3 +13,6 @@ NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
NEXT_PUBLIC_SWELL_STORE_ID=
NEXT_PUBLIC_SWELL_PUBLIC_KEY=
NEXT_PUBLIC_SALEOR_API_URL=
NEXT_PUBLIC_SALEOR_CHANNEL=

View File

@ -2,5 +2,13 @@
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
"useTabs": false,
"overrides": [
{
"files": ["framework/saleor/**/*"],
"options": {
"printWidth": 120
}
}
]
}

View File

@ -11,6 +11,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/)
- Swell Demo: https://swell.vercel.store/
- BigCommerce Demo: https://bigcommerce.vercel.store/
- Vendure Demo: https://vendure.vercel.store
- Saleor Demo: https://saleor.vercel.store/
## Features
@ -26,7 +27,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/)
## Integrations
Next.js Commerce integrates out-of-the-box with BigCommerce and Shopify. We plan to support all major ecommerce backends.
Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify and Saleor. We plan to support all major ecommerce backends.
## Considerations

View File

@ -127,4 +127,3 @@ a {
opacity: 1;
}
}

View File

@ -1,3 +1,3 @@
.fit {
min-height: calc(100vh - 88px);
}
}

27
codegen.bigcommerce.json Normal file
View File

@ -0,0 +1,27 @@
{
"schema": {
"https://buybutton.store/graphql": {
"headers": {
"Authorization": "Bearer xzy"
}
}
},
"documents": [
{
"./framework/bigcommerce/api/**/*.ts": {
"noRequire": true
}
}
],
"generates": {
"./framework/bigcommerce/schema.d.ts": {
"plugins": ["typescript", "typescript-operations"]
},
"./framework/bigcommerce/schema.graphql": {
"plugins": ["schema-ast"]
}
},
"hooks": {
"afterAllFileWrite": ["prettier --write"]
}
}

View File

@ -1,23 +1,29 @@
{
"schema": {
"https://buybutton.store/graphql": {
"headers": {
"Authorization": "Bearer xzy"
}
}
"https://master.staging.saleor.cloud/graphql/": {}
},
"documents": [
{
"./framework/bigcommerce/api/**/*.ts": {
"./framework/saleor/utils/queries/get-all-products-query.ts": {
"noRequire": true
}
},
{
"./framework/saleor/utils/queries/get-all-products-paths-query.ts": {
"noRequire": true
}
},
{
"./framework/saleor/utils/queries/get-products.ts": {
"noRequire": true
}
}
],
"generates": {
"./framework/bigcommerce/schema.d.ts": {
"./framework/saleor/schema.d.ts": {
"plugins": ["typescript", "typescript-operations"]
},
"./framework/bigcommerce/schema.graphql": {
"./framework/saleor/schema.graphql": {
"plugins": ["schema-ast"]
}
},

View File

@ -1,5 +1,6 @@
{
"features": {
"wishlist": true
"wishlist": false,
"customCheckout": false
}
}

View File

@ -108,10 +108,14 @@ const CartItem = ({
<div className="flex-1 flex flex-col text-base">
<Link href={`/product/${item.path}`}>
<span
className="font-bold text-lg cursor-pointer leading-6"
onClick={() => closeSidebarIfPresent()}
>
{item.name}
<div
className="font-bold text-lg cursor-pointer leading-6"
>
{item.name}
</div>
{item.variant ? <span> {item.variant.name}</span> : ""}
</span>
</Link>
{options && options.length > 0 ? (

View File

@ -49,13 +49,8 @@ const Layout: FC<Props> = ({
children,
pageProps: { categories = [], ...pageProps },
}) => {
const {
displaySidebar,
displayModal,
closeSidebar,
closeModal,
modalView,
} = useUI()
const { displaySidebar, displayModal, closeSidebar, closeModal, modalView } =
useUI()
const { acceptedCookies, onAcceptCookies } = useAcceptCookies()
const { locale = 'en-US' } = useRouter()

View File

@ -10,7 +10,7 @@ interface Props {
className?: string
product: Product
variant?: 'slim' | 'simple'
imgProps?: Omit<ImageProps, 'src'>
imgProps?: Omit<any, 'src'>
}
const placeholderImg = '/product-img-placeholder.svg'
@ -38,7 +38,7 @@ const ProductCard: FC<Props> = ({
alt={product.name || 'Product Image'}
height={320}
width={320}
layout="fixed"
layout="fixed"
{...imgProps}
/>
)}

View File

@ -32,10 +32,11 @@ const ProductView: FC<Props> = ({ product }) => {
useEffect(() => {
// Selects the default option
product.variants[0].options?.forEach((v) => {
const options = product.variants[0].options || []
options.forEach((v) => {
setChoices((choices) => ({
...choices,
[v.displayName.toLowerCase()]: v.values[0].label.toLowerCase(),
[v.displayName.toLowerCase()]: v.values[0]?.label.toLowerCase(),
}))
})
}, [])
@ -126,7 +127,8 @@ const ProductView: FC<Props> = ({ product }) => {
setChoices((choices) => {
return {
...choices,
[opt.displayName.toLowerCase()]: v.label.toLowerCase(),
[opt.displayName.toLowerCase()]:
v.label.toLowerCase(),
}
})
}}

View File

@ -13,9 +13,8 @@ const Container: FC<Props> = ({ children, className, el = 'div', clean }) => {
'mx-auto max-w-8xl px-6': !clean,
})
let Component: React.ComponentType<
React.HTMLAttributes<HTMLDivElement>
> = el as any
let Component: React.ComponentType<React.HTMLAttributes<HTMLDivElement>> =
el as any
return <Component className={rootClassName}>{children}</Component>
}

View File

@ -7,7 +7,7 @@ const fs = require('fs')
const merge = require('deepmerge')
const prettier = require('prettier')
const PROVIDERS = ['bigcommerce', 'shopify', 'swell', 'vendure']
const PROVIDERS = ['bigcommerce', 'shopify', 'swell', 'vendure', 'saleor']
function getProviderName() {
return (
@ -18,6 +18,8 @@ function getProviderName() {
? 'shopify'
: process.env.NEXT_PUBLIC_SWELL_STORE_ID
? 'swell'
: process.env.NEXT_PUBLIC_SALEOR_API_URL
? 'saleor'
: null)
)
}

View File

@ -3,6 +3,7 @@
A commerce provider is a headless e-commerce platform that integrates with the [Commerce Framework](./README.md). Right now we have the following providers:
- BigCommerce ([framework/bigcommerce](../bigcommerce))
- Saleor ([framework/saleor](../saleor))
- Shopify ([framework/shopify](../shopify))
Adding a commerce provider means adding a new folder in `framework` with a folder structure like the next one:
@ -156,24 +157,26 @@ export const handler: SWRHook<
const data = cartId ? await fetch(options) : null
return data && normalizeCart(data)
},
useHook: ({ useData }) => (input) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
useHook:
({ useData }) =>
(input) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) <= 0
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) <= 0
},
enumerable: true,
},
enumerable: true,
},
}),
[response]
)
},
}),
[response]
)
},
}
```
@ -217,18 +220,20 @@ export const handler: MutationHook<Cart, {}, CartItemBody> = {
return normalizeCart(data)
},
useHook: ({ fetch }) => () => {
const { mutate } = useCart()
useHook:
({ fetch }) =>
() => {
const { mutate } = useCart()
return useCallback(
async function addItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
return useCallback(
async function addItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
}
```

View File

@ -11,18 +11,16 @@ type InferValue<Prop extends PropertyKey, Desc> = Desc extends {
? Record<Prop, T>
: never
type DefineProperty<
Prop extends PropertyKey,
Desc extends PropertyDescriptor
> = Desc extends { writable: any; set(val: any): any }
? never
: Desc extends { writable: any; get(): any }
? never
: Desc extends { writable: false }
? Readonly<InferValue<Prop, Desc>>
: Desc extends { writable: true }
? InferValue<Prop, Desc>
: Readonly<InferValue<Prop, Desc>>
type DefineProperty<Prop extends PropertyKey, Desc extends PropertyDescriptor> =
Desc extends { writable: any; set(val: any): any }
? never
: Desc extends { writable: any; get(): any }
? never
: Desc extends { writable: false }
? Readonly<InferValue<Prop, Desc>>
: Desc extends { writable: true }
? InferValue<Prop, Desc>
: Readonly<InferValue<Prop, Desc>>
export default function defineProperty<
Obj extends object,

View File

@ -0,0 +1,4 @@
COMMERCE_PROVIDER=saleor
NEXT_SALEOR_API_URL=
NEXT_SALEOR_CHANNEL=

View File

@ -0,0 +1,19 @@
## Saleor Provider
**Demo:** TBD
Before getting starter, a [Saleor](https://saleor.io/) account and store is required before using the provider.
Next, copy the `.env.template` file in this directory to `.env.local` in the main directory (which will be ignored by Git):
```bash
cp framework/saleor/.env.template .env.local
```
Then, set the environment variables in `.env.local` to match the ones from your store.
## Contribute
Our commitment to Open Source can be found [here](https://vercel.com/oss).
If you find an issue with the provider or want a new feature, feel free to open a PR or [create a new issue](https://github.com/vercel/commerce/issues).

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function (_commerce: any) {}

View File

@ -0,0 +1 @@
export default function (_commerce: any) {}

View File

@ -0,0 +1,57 @@
import { CommerceAPI, GetAPISchema, createEndpoint } from '@commerce/api'
import checkoutEndpoint from '@commerce/api/endpoints/checkout'
import { CheckoutSchema } from '@commerce/types/checkout'
export type CheckoutAPI = GetAPISchema<CommerceAPI, CheckoutSchema>
export type CheckoutEndpoint = CheckoutAPI['endpoint']
const checkout: CheckoutEndpoint['handlers']['checkout'] = async ({
req,
res,
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>
`
res.status(200)
res.setHeader('Content-Type', 'text/html')
res.write(html)
res.end()
} catch (error) {
console.error(error)
const message = 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
export const handlers: CheckoutEndpoint['handlers'] = { checkout }
const checkoutApi = createEndpoint<CheckoutAPI>({
handler: checkoutEndpoint,
handlers,
})
export default checkoutApi

View File

@ -0,0 +1 @@
export default function (_commerce: any) {}

View File

@ -0,0 +1 @@
export default function (_commerce: any) {}

View File

@ -0,0 +1 @@
export default function (_commerce: any) {}

View File

@ -0,0 +1 @@
export default function (_commerce: any) {}

View File

@ -0,0 +1 @@
export default function (_commerce: any) {}

View File

@ -0,0 +1,49 @@
import type { CommerceAPIConfig } from '@commerce/api'
import * as Const from '../const'
if (!Const.API_URL) {
throw new Error(`The environment variable NEXT_SALEOR_API_URL is missing and it's required to access your store`)
}
if (!Const.API_CHANNEL) {
throw new Error(`The environment variable NEXT_SALEOR_CHANNEL is missing and it's required to access your store`)
}
import fetchGraphqlApi from './utils/fetch-graphql-api'
export interface SaleorConfig extends CommerceAPIConfig {
storeChannel: string
}
const config: SaleorConfig = {
locale: 'en-US',
commerceUrl: Const.API_URL,
apiToken: Const.SALEOR_TOKEN,
cartCookie: Const.CHECKOUT_ID_COOKIE,
cartCookieMaxAge: 60 * 60 * 24 * 30,
fetch: fetchGraphqlApi,
customerCookie: '',
storeChannel: Const.API_CHANNEL,
}
import {
CommerceAPI,
getCommerceApi as commerceApi,
} from '@commerce/api'
import * as operations from './operations'
export interface ShopifyConfig extends CommerceAPIConfig {}
export const provider = { config, operations }
export type Provider = typeof provider
export type SaleorAPI<P extends Provider = Provider> = CommerceAPI<P>
export function getCommerceApi<P extends Provider>(
customProvider: P = provider as any
): SaleorAPI<P> {
return commerceApi(customProvider)
}

View File

@ -0,0 +1,50 @@
import type { OperationContext } from '@commerce/api/operations'
import { QueryPagesArgs, PageCountableEdge } from '../../schema'
import type { SaleorConfig, Provider } from '..'
import * as Query from '../../utils/queries'
export type Page = any
export type GetAllPagesResult<
T extends { pages: any[] } = { pages: Page[] }
> = T
export default function getAllPagesOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllPages({
query = Query.PageMany,
config,
variables,
}: {
url?: string
config?: Partial<SaleorConfig>
variables?: QueryPagesArgs
preview?: boolean
query?: string
} = {}): Promise<GetAllPagesResult> {
const { fetch, locale, locales = ['en-US'] } = commerce.getConfig(config)
const { data } = await fetch(query, { variables },
{
...(locale && {
headers: {
'Accept-Language': locale,
},
}),
}
)
const pages = data.pages?.edges?.map(({ node: { title: name, slug, ...node } }: PageCountableEdge) => ({
...node,
url: `/${locale}/${slug}`,
name,
}))
return { pages }
}
return getAllPages
}

View File

@ -0,0 +1,46 @@
import type { OperationContext } from '@commerce/api/operations'
import {
GetAllProductPathsQuery,
GetAllProductPathsQueryVariables,
ProductCountableEdge,
} from '../../schema'
import type { ShopifyConfig, Provider, SaleorConfig } from '..'
import { getAllProductsPathsQuery } from '../../utils/queries'
import fetchAllProducts from '../utils/fetch-all-products'
export type GetAllProductPathsResult = {
products: Array<{ path: string }>
}
export default function getAllProductPathsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProductPaths({
query,
config,
variables,
}: {
query?: string
config?: SaleorConfig
variables?: any
} = {}): Promise<GetAllProductPathsResult> {
config = commerce.getConfig(config)
const products = await fetchAllProducts({
config,
query: getAllProductsPathsQuery,
variables,
})
return {
products: products?.map(({ node: { slug } }: ProductCountableEdge) => ({
path: `/${slug}`,
})),
}
}
return getAllProductPaths
}

View File

@ -0,0 +1,67 @@
import type { OperationContext } from '@commerce/api/operations'
import { Product } from '@commerce/types/product'
import { ProductCountableEdge } from '../../schema'
import type { Provider, SaleorConfig } from '..'
import { normalizeProduct } from '../../utils'
import * as Query from '../../utils/queries'
import { GraphQLFetcherResult } from '@commerce/api'
type ReturnType = {
products: Product[]
}
export default function getAllProductsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProducts({
query = Query.ProductMany,
variables,
config,
featured,
}: {
query?: string
variables?: any
config?: Partial<SaleorConfig>
preview?: boolean
featured?: boolean
} = {}): Promise<ReturnType> {
const { fetch, locale } = commerce.getConfig(config)
if (featured) {
variables = { ...variables, categoryId: 'Q29sbGVjdGlvbjo0' };
query = Query.CollectionOne
}
const { data }: GraphQLFetcherResult = await fetch(
query,
{ variables },
{
...(locale && {
headers: {
'Accept-Language': locale,
},
}),
}
)
if (featured) {
const products = data.collection.products?.edges?.map(({ node: p }: ProductCountableEdge) => normalizeProduct(p)) ?? []
return {
products,
}
} else {
const products = data.products?.edges?.map(({ node: p }: ProductCountableEdge) => normalizeProduct(p)) ?? []
return {
products,
}
}
}
return getAllProducts
}

View File

@ -0,0 +1,51 @@
import type { OperationContext } from '@commerce/api/operations'
import type { Provider, SaleorConfig } from '..'
import { QueryPageArgs } from '../../schema'
import * as Query from '../../utils/queries'
export type Page = any
export type GetPageResult<T extends { page?: any } = { page?: Page }> = T
export default function getPageOperation({
commerce,
}: OperationContext<Provider>) {
async function getPage({
query = Query.PageOne,
variables,
config,
}: {
query?: string
variables: QueryPageArgs,
config?: Partial<SaleorConfig>
preview?: boolean
}): Promise<GetPageResult> {
const { fetch, locale = 'en-US' } = commerce.getConfig(config)
const {
data: { page },
} = await fetch(query, { variables },
{
...(locale && {
headers: {
'Accept-Language': locale,
},
}),
}
)
return {
page: page
? {
...page,
name: page.title,
url: `/${locale}/${page.slug}`,
}
: null,
}
}
return getPage
}

View File

@ -0,0 +1,46 @@
import type { OperationContext } from '@commerce/api/operations'
import { normalizeProduct, } from '../../utils'
import type { Provider, SaleorConfig } from '..'
import * as Query from '../../utils/queries'
type Variables = {
slug: string
}
type ReturnType = {
product: any
}
export default function getProductOperation({
commerce,
}: OperationContext<Provider>) {
async function getProduct({
query = Query.ProductOneBySlug,
variables,
config: cfg,
}: {
query?: string
variables: Variables
config?: Partial<SaleorConfig>
preview?: boolean
}): Promise<ReturnType> {
const { fetch, locale } = commerce.getConfig(cfg)
const { data } = await fetch(query, { variables },
{
...(locale && {
headers: {
'Accept-Language': locale,
},
}),
}
)
return {
product: data?.product ? normalizeProduct(data.product) : null,
}
}
return getProduct
}

View File

@ -0,0 +1,35 @@
import type { OperationContext } from '@commerce/api/operations'
import { Category } from '@commerce/types/site'
import type { SaleorConfig, Provider } from '..'
import { getCategories, getVendors } from '../../utils'
interface GetSiteInfoResult {
categories: Category[]
brands: any[]
}
export default function getSiteInfoOperation({ commerce }: OperationContext<Provider>) {
async function getSiteInfo({
query,
config,
variables,
}: {
query?: string
config?: Partial<SaleorConfig>
preview?: boolean
variables?: any
} = {}): Promise<GetSiteInfoResult> {
const cfg = commerce.getConfig(config)
const categories = await getCategories(cfg)
const brands = await getVendors(cfg)
return {
categories,
brands,
}
}
return getSiteInfo
}

View File

@ -0,0 +1,7 @@
export { default as getAllPages } from './get-all-pages'
export { default as getPage } from './get-page'
export { default as getAllProducts } from './get-all-products'
export { default as getAllProductPaths } from './get-all-product-paths'
export { default as getProduct } from './get-product'
export { default as getSiteInfo } from './get-site-info'
export { default as login } from './login'

View File

@ -0,0 +1,42 @@
import type { ServerResponse } from 'http'
import type { OperationContext } from '@commerce/api/operations'
import type { Provider, SaleorConfig } from '..'
import {
throwUserErrors,
} from '../../utils'
import * as Mutation from '../../utils/mutations'
export default function loginOperation({
commerce,
}: OperationContext<Provider>) {
async function login({
query = Mutation.SessionCreate,
variables,
config,
}: {
query?: string
variables: any
res: ServerResponse
config?: SaleorConfig
}): Promise<any> {
config = commerce.getConfig(config)
const { data: { customerAccessTokenCreate } } = await config.fetch(query, { variables })
throwUserErrors(customerAccessTokenCreate?.customerUserErrors)
const customerAccessToken = customerAccessTokenCreate?.customerAccessToken
const accessToken = customerAccessToken?.accessToken
// if (accessToken) {
// setCustomerToken(accessToken)
// }
return {
result: customerAccessToken?.accessToken,
}
}
return login
}

View File

@ -0,0 +1,41 @@
import { ProductCountableEdge } from '../../schema'
import { SaleorConfig } from '..'
const fetchAllProducts = async ({
config,
query,
variables,
acc = [],
cursor,
}: {
config: SaleorConfig
query: string
acc?: ProductCountableEdge[]
variables?: any
cursor?: string
}): Promise<ProductCountableEdge[]> => {
const { data } = await config.fetch(query, {
variables: { ...variables, cursor },
})
const edges: ProductCountableEdge[] = data.products?.edges ?? []
const hasNextPage = data.products?.pageInfo?.hasNextPage
acc = acc.concat(edges)
if (hasNextPage) {
const cursor = edges.pop()?.cursor
if (cursor) {
return fetchAllProducts({
config,
query,
variables,
acc,
cursor,
})
}
}
return acc
}
export default fetchAllProducts

View File

@ -0,0 +1,37 @@
import type { GraphQLFetcher } from '@commerce/api'
import fetch from './fetch'
import { API_URL } from '../../const'
import { getError } from '../../utils/handle-fetch-response'
import { getCommerceApi } from '..'
import { getToken } from '@framework/utils'
const fetchGraphqlApi: GraphQLFetcher = async (query: string, { variables } = {}, fetchOptions) => {
const config = getCommerceApi().getConfig()
const token = getToken()
const res = await fetch(API_URL!, {
...fetchOptions,
method: 'POST',
headers: {
...(token && {
Authorization: `Bearer ${token}`,
}),
...fetchOptions?.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
})
const { data, errors, status } = await res.json()
if (errors) {
throw getError(errors, status)
}
return { data, res }
}
export default fetchGraphqlApi

View File

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

View File

@ -0,0 +1,22 @@
import type { NextApiRequest, NextApiResponse } from 'next'
export default function isAllowedMethod(req: NextApiRequest, res: NextApiResponse, allowedMethods: string[]) {
const methods = allowedMethods.includes('OPTIONS') ? allowedMethods : [...allowedMethods, 'OPTIONS']
if (!req.method || !methods.includes(req.method)) {
res.status(405)
res.setHeader('Allow', methods.join(', '))
res.end()
return false
}
if (req.method === 'OPTIONS') {
res.status(200)
res.setHeader('Allow', methods.join(', '))
res.setHeader('Content-Length', '0')
res.end()
return false
}
return true
}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1,63 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useCustomer from '../customer/use-customer'
import * as mutation from '../utils/mutations'
import { Mutation, MutationTokenCreateArgs } from '../schema'
import useLogin, { UseLogin } from '@commerce/auth/use-login'
import { setCSRFToken, setToken, throwUserErrors, checkoutAttach, getCheckoutId } from '../utils'
import { LoginHook } from '@commerce/types/login'
export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<LoginHook> = {
fetchOptions: {
query: mutation.SessionCreate,
},
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message: 'A first name, last name, email and password are required to login',
})
}
const { tokenCreate } = await fetch<Mutation, MutationTokenCreateArgs>({
...options,
variables: { email, password },
})
throwUserErrors(tokenCreate?.errors)
const { token, csrfToken } = tokenCreate!
if (token && csrfToken) {
setToken(token)
setCSRFToken(csrfToken)
const { checkoutId } = getCheckoutId()
checkoutAttach(fetch, {
variables: { checkoutId },
headers: {
Authorization: `JWT ${token}`,
},
})
}
return null
},
useHook:
({ fetch }) =>
() => {
const { revalidate } = useCustomer()
return useCallback(
async function login(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

View File

@ -0,0 +1,41 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import useLogout, { UseLogout } from '@commerce/auth/use-logout'
import useCustomer from '../customer/use-customer'
import * as mutation from '../utils/mutations'
import { setCSRFToken, setToken, setCheckoutToken } from '../utils/customer-token'
import { LogoutHook } from '@commerce/types/logout'
export default useLogout as UseLogout<typeof handler>
export const handler: MutationHook<LogoutHook> = {
fetchOptions: {
query: mutation.SessionDestroy,
},
async fetcher({ options, fetch }) {
await fetch({
...options,
variables: {},
})
setToken()
setCSRFToken()
setCheckoutToken()
return null
},
useHook:
({ fetch }) =>
() => {
const { mutate } = useCustomer()
return useCallback(
async function logout() {
const data = await fetch()
await mutate(null, false)
return data
},
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,56 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useSignup, { UseSignup } from '@commerce/auth/use-signup'
import useCustomer from '../customer/use-customer'
import { AccountRegisterInput, Mutation, MutationAccountRegisterArgs } from '../schema'
import * as mutation from '../utils/mutations'
import { handleAutomaticLogin, throwUserErrors } from '../utils'
import { SignupHook } from '@commerce/types/signup'
export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<SignupHook> = {
fetchOptions: {
query: mutation.AccountCreate,
},
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message: 'A first name, last name, email and password are required to signup',
})
}
const { customerCreate } = await fetch<Mutation, MutationAccountRegisterArgs>({
...options,
variables: {
input: {
email,
password,
redirectUrl: 'https://localhost.com',
channel: 'default-channel'
},
},
})
throwUserErrors(customerCreate?.errors)
await handleAutomaticLogin(fetch, { email, password })
return null
},
useHook:
({ fetch }) =>
() => {
const { revalidate } = useCustomer()
return useCallback(
async function signup(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

View File

@ -0,0 +1,4 @@
export { default as useCart } from './use-cart'
export { default as useAddItem } from './use-add-item'
export { default as useUpdateItem } from './use-update-item'
export { default as useRemoveItem } from './use-remove-item'

View File

@ -0,0 +1,54 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item'
import useCart from './use-cart'
import * as mutation from '../utils/mutations'
import { getCheckoutId, checkoutToCart } from '../utils'
import { Mutation, MutationCheckoutLinesAddArgs } from '../schema'
import { AddItemHook } from '@commerce/types/cart'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = {
fetchOptions: { query: mutation.CheckoutLineAdd },
async fetcher({ input: item, options, fetch }) {
if (item.quantity && (!Number.isInteger(item.quantity) || item.quantity! < 1)) {
throw new CommerceError({
message: 'The item quantity has to be a valid integer greater than 0',
})
}
const { checkoutLinesAdd } = await fetch<Mutation, MutationCheckoutLinesAddArgs>({
...options,
variables: {
checkoutId: getCheckoutId().checkoutId,
lineItems: [
{
variantId: item.variantId,
quantity: item.quantity ?? 1,
},
],
},
})
return checkoutToCart(checkoutLinesAdd)
},
useHook:
({ fetch }) =>
() => {
const { mutate } = useCart()
return useCallback(
async function addItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,53 @@
import { useMemo } from 'react'
import useCommerceCart, { UseCart } from '@commerce/cart/use-cart'
import { SWRHook } from '@commerce/utils/types'
import { checkoutCreate, checkoutToCart, getCheckoutId } from '../utils'
import * as query from '../utils/queries'
import { GetCartHook } from '@commerce/types/cart'
export default useCommerceCart as UseCart<typeof handler>
export const handler: SWRHook<GetCartHook> = {
fetchOptions: {
query: query.CheckoutOne,
},
async fetcher({ input: { cartId: checkoutId }, options, fetch }) {
let checkout
if (checkoutId) {
const checkoutId = getCheckoutId().checkoutToken
const data = await fetch({
...options,
variables: { checkoutId },
})
checkout = data
}
if (checkout?.completedAt || !checkoutId) {
checkout = await checkoutCreate(fetch)
}
return checkoutToCart(checkout)
},
useHook:
({ useData }) =>
(input) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}

View File

@ -0,0 +1,39 @@
import { useCallback } from 'react'
import type { MutationHookContext, HookFetcherContext, MutationHook } from '@commerce/utils/types'
import useRemoveItem, { UseRemoveItem } from '@commerce/cart/use-remove-item'
import useCart from './use-cart'
import * as mutation from '../utils/mutations'
import { getCheckoutId, checkoutToCart } from '../utils'
import { Mutation, MutationCheckoutLineDeleteArgs } from '../schema'
import { LineItem, RemoveItemHook } from '../types/cart'
export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler = {
fetchOptions: { query: mutation.CheckoutLineDelete },
async fetcher({ input: { itemId }, options, fetch }: HookFetcherContext<RemoveItemHook>) {
const data = await fetch<Mutation, MutationCheckoutLineDeleteArgs>({
...options,
variables: {
checkoutId: getCheckoutId().checkoutId,
lineId: itemId,
},
})
return checkoutToCart(data.checkoutLineDelete)
},
useHook: ({ fetch }: MutationHookContext<RemoveItemHook>) => <
T extends LineItem | undefined = undefined
> () => {
const { mutate } = useCart()
return useCallback(
async function removeItem(input) {
const data = await fetch({ input: { itemId: input.id } })
await mutate(data, false)
return data
},
[fetch, mutate]
);
},
}

View File

@ -0,0 +1,99 @@
import { useCallback } from 'react'
import debounce from 'lodash.debounce'
import type { HookFetcherContext, MutationHookContext } from '@commerce/utils/types'
import { ValidationError } from '@commerce/utils/errors'
import useUpdateItem, { UseUpdateItem } from '@commerce/cart/use-update-item'
import useCart from './use-cart'
import { handler as removeItemHandler } from './use-remove-item'
import type { LineItem } from '../types'
import { checkoutToCart } from '../utils'
import { getCheckoutId } from '../utils'
import { Mutation, MutationCheckoutLinesUpdateArgs } from '../schema'
import * as mutation from '../utils/mutations'
import type { UpdateItemHook } from '../types/cart'
export type UpdateItemActionInput<T = any> = T extends LineItem
? Partial<UpdateItemHook['actionInput']>
: UpdateItemHook['actionInput']
export default useUpdateItem as UseUpdateItem<typeof handler>
export const handler = {
fetchOptions: { query: mutation.CheckoutLineUpdate },
async fetcher({
input: { itemId, item },
options,
fetch
}: HookFetcherContext<UpdateItemHook>) {
if (Number.isInteger(item.quantity)) {
// Also allow the update hook to remove an item if the quantity is lower than 1
if (item.quantity! < 1) {
return removeItemHandler.fetcher({
options: removeItemHandler.fetchOptions,
input: { itemId },
fetch,
})
}
} else if (item.quantity) {
throw new ValidationError({
message: 'The item quantity has to be a valid integer',
})
}
const checkoutId = getCheckoutId().checkoutId
const { checkoutLinesUpdate } = await fetch<Mutation, MutationCheckoutLinesUpdateArgs>({
...options,
variables: {
checkoutId,
lineItems: [
{
variantId: item.variantId,
quantity: item.quantity,
},
],
},
})
return checkoutToCart(checkoutLinesUpdate)
},
useHook: ({ fetch }: MutationHookContext<UpdateItemHook>) =>
<T extends LineItem | undefined = undefined>(
ctx: {
item?: T
wait?: number
} = {}
) => {
const { item } = ctx
const { mutate } = useCart() as any
return useCallback(
debounce(async (input: UpdateItemActionInput<T>) => {
const itemId = input.id ?? item?.id
const productId = input.productId ?? item?.productId
const variantId = input.productId ?? item?.variantId
if (!itemId || !productId || !variantId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
})
}
const data = await fetch({
input: {
item: {
productId,
variantId,
quantity: input.quantity,
},
itemId,
},
})
await mutate(data, false)
return data
}, ctx.wait ?? 500),
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,7 @@
{
"provider": "saleor",
"features": {
"wishlist": false,
"customCheckout": true
}
}

View File

@ -0,0 +1,5 @@
export const API_URL = process.env.NEXT_PUBLIC_SALEOR_API_URL
export const API_CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL
export const CHECKOUT_ID_COOKIE = 'saleor.CheckoutID'
export const SALEOR_TOKEN = 'saleor.Token'
export const SALEOR_CRSF_TOKEN = 'saleor.CSRFToken'

View File

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

View File

@ -0,0 +1,30 @@
import useCustomer, { UseCustomer } from '@commerce/customer/use-customer'
import { CustomerHook } from '@commerce/types/customer'
import { SWRHook } from '@commerce/utils/types'
import * as query from '../utils/queries'
export default useCustomer as UseCustomer<typeof handler>
export const handler: SWRHook<CustomerHook> = {
fetchOptions: {
query: query.CustomerCurrent,
},
async fetcher({ options, fetch }) {
const data = await fetch<any | null>({
...options,
variables: {},
})
return data.me ?? null
},
useHook:
({ useData }) =>
(input) => {
return useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
},
}

View File

@ -0,0 +1,20 @@
import { Fetcher } from '@commerce/utils/types'
import { API_URL } from './const'
import { getToken, handleFetchResponse } from './utils'
const fetcher: Fetcher = async ({ url = API_URL, method = 'POST', variables, query }) => {
const token = getToken()
return handleFetchResponse(
await fetch(url!, {
method,
body: JSON.stringify({ query, variables }),
headers: {
Authorization: `JWT ${token}`,
'Content-Type': 'application/json',
},
})
)
}
export default fetcher

View File

@ -0,0 +1,32 @@
import * as React from 'react'
import { ReactNode } from 'react'
import { CommerceConfig, CommerceProvider as CoreCommerceProvider, useCommerce as useCoreCommerce } from '@commerce'
import { saleorProvider, SaleorProvider } from './provider'
import * as Const from './const'
export { saleorProvider }
export type { SaleorProvider }
export const saleorConfig: CommerceConfig = {
locale: 'en-us',
cartCookie: Const.CHECKOUT_ID_COOKIE,
}
export type SaleorConfig = Partial<CommerceConfig>
export type SaleorProps = {
children?: ReactNode
locale: string
} & SaleorConfig
export function CommerceProvider({ children, ...config }: SaleorProps) {
return (
<CoreCommerceProvider provider={saleorProvider} config={{ ...saleorConfig, ...config }}>
{children}
</CoreCommerceProvider>
)
}
export const useCommerce = () => useCoreCommerce()

View File

@ -0,0 +1,8 @@
const commerce = require('./commerce.config.json')
module.exports = {
commerce,
images: {
domains: [process.env.COMMERCE_IMAGE_HOST],
},
}

View File

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

View File

@ -0,0 +1,74 @@
import { SWRHook } from '@commerce/utils/types'
import { Product } from '@commerce/types/product'
import useSearch, { UseSearch } from '@commerce/product/use-search'
import { ProductCountableEdge } from '../schema'
import { getSearchVariables, normalizeProduct } from '../utils'
import * as query from '../utils/queries'
import { SearchProductsHook } from '@commerce/types/product'
export default useSearch as UseSearch<typeof handler>
export type SearchProductsInput = {
search?: string
categoryId?: string | number
brandId?: string | number
sort?: string
}
export type SearchProductsData = {
products: Product[]
found: boolean
}
export const handler: SWRHook<SearchProductsHook> = {
fetchOptions: {
query: query.ProductMany,
},
async fetcher({ input, options, fetch }) {
const { categoryId, brandId } = input
const data = await fetch({
query: categoryId ? query.CollectionOne : options.query,
method: options?.method,
variables: getSearchVariables(input),
})
let edges
if (categoryId) {
edges = data.collection?.products?.edges ?? []
// FIXME @zaiste, no `vendor` in Saleor
// if (brandId) {
// edges = edges.filter(
// ({ node: { vendor } }: ProductCountableEdge) =>
// vendor.replace(/\s+/g, '-').toLowerCase() === brandId
// )
// }
} else {
edges = data.products?.edges ?? []
}
return {
products: edges.map(({ node }: ProductCountableEdge) => normalizeProduct(node)),
found: !!edges.length,
}
},
useHook:
({ useData }) =>
(input = {}) => {
return useData({
input: [
['search', input.search],
['categoryId', input.categoryId],
['brandId', input.brandId],
['sort', input.sort],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
}

View File

@ -0,0 +1,26 @@
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 fetcher from './fetcher'
export const saleorProvider = {
locale: 'en-us',
cartCookie: '',
cartCookieToken: '',
fetcher,
cart: { useCart, useAddItem, useUpdateItem, useRemoveItem },
customer: { useCustomer },
products: { useSearch },
auth: { useLogin, useLogout, useSignup },
}
export type SaleorProvider = typeof saleorProvider

11488
framework/saleor/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

43
framework/saleor/types.ts Normal file
View File

@ -0,0 +1,43 @@
import type { Cart as CoreCart } from '@commerce/types'
import { CheckoutLine } from './schema'
export type SaleorCheckout = {
id: string
webUrl: string
lineItems: CheckoutLine[]
}
export type Cart = CoreCart.Cart & {
lineItems: LineItem[]
}
export interface LineItem extends CoreCart.LineItem {
options?: any[]
}
/**
* Cart mutations
*/
export type OptionSelections = {
option_id: number
option_value: number | string
}
export type CartItemBody = CoreCart.CartItemBody & {
productId: string // The product id is always required for BC
optionSelections?: OptionSelections
}
// export type GetCartHandlerBody = CoreCart.GetCartHandlerBody
// export type AddCartItemBody = Core.AddCartItemBody<CartItemBody>
// export type AddCartItemHandlerBody = Core.AddCartItemHandlerBody<CartItemBody>
// export type UpdateCartItemBody = Core.UpdateCartItemBody<CartItemBody>
// export type UpdateCartItemHandlerBody = Core.UpdateCartItemHandlerBody<CartItemBody>
// export type RemoveCartItemBody = Core.RemoveCartItemBody
// export type RemoveCartItemHandlerBody = Core.RemoveCartItemHandlerBody

View File

@ -0,0 +1,32 @@
import * as Core from '@commerce/types/cart'
export * from '@commerce/types/cart'
export type SaleorCart = {}
/**
* Extend core cart types
*/
export type Cart = Core.Cart & {
lineItems: Core.LineItem[]
url?: string
}
export type CartTypes = Core.CartTypes
export type CartHooks = Core.CartHooks<CartTypes>
export type GetCartHook = CartHooks['getCart']
export type AddItemHook = CartHooks['addItem']
export type UpdateItemHook = CartHooks['updateItem']
export type RemoveItemHook = CartHooks['removeItem']
export type CartSchema = Core.CartSchema<CartTypes>
export type CartHandlers = Core.CartHandlers<CartTypes>
export type GetCartHandler = CartHandlers['getCart']
export type AddItemHandler = CartHandlers['addItem']
export type UpdateItemHandler = CartHandlers['updateItem']
export type RemoveItemHandler = CartHandlers['removeItem']

View File

@ -0,0 +1,12 @@
import * as mutation from './mutations'
import { CheckoutCustomerAttach } from '../schema'
export const checkoutAttach = async (fetch: any, { variables, headers }: any): Promise<CheckoutCustomerAttach> => {
const data = await fetch({
query: mutation.CheckoutAttach,
variables,
headers,
})
return data
}

View File

@ -0,0 +1,25 @@
import Cookies from 'js-cookie'
import * as mutation from './mutations'
import { CheckoutCreate } from '../schema'
import { CHECKOUT_ID_COOKIE } from '@framework/const'
export const checkoutCreate = async (fetch: any): Promise<CheckoutCreate> => {
const data = await fetch({ query: mutation.CheckoutCreate })
const checkout = data.checkoutCreate?.checkout
const checkoutId = checkout?.id
const checkoutToken = checkout?.token
const value = `${checkoutId}:${checkoutToken}`
if (checkoutId) {
const options = {
expires: 60 * 60 * 24 * 30,
}
Cookies.set(CHECKOUT_ID_COOKIE, value, options)
}
return checkout
}
export default checkoutCreate

View File

@ -0,0 +1,35 @@
import { Cart } from '../types'
import { CommerceError } from '@commerce/utils/errors'
import { CheckoutLinesAdd, CheckoutLinesUpdate, CheckoutCreate, CheckoutError, Checkout, Maybe, CheckoutLineDelete } from '../schema'
import { normalizeCart } from './normalize'
import throwUserErrors from './throw-user-errors'
export type CheckoutQuery = {
checkout: Checkout
errors?: Array<CheckoutError>
}
export type CheckoutPayload = CheckoutLinesAdd | CheckoutLinesUpdate | CheckoutCreate | CheckoutQuery | CheckoutLineDelete
const checkoutToCart = (checkoutPayload?: Maybe<CheckoutPayload>): Cart => {
if (!checkoutPayload) {
throw new CommerceError({
message: 'Missing checkout payload from response',
})
}
const checkout = checkoutPayload?.checkout
throwUserErrors(checkoutPayload?.errors)
if (!checkout) {
throw new CommerceError({
message: 'Missing checkout object from response',
})
}
return normalizeCart(checkout)
}
export default checkoutToCart

View File

@ -0,0 +1,25 @@
import Cookies, { CookieAttributes } from 'js-cookie'
import * as Const from '../const'
export const getToken = () => Cookies.get(Const.SALEOR_TOKEN)
export const setToken = (token?: string, options?: CookieAttributes) => {
setCookie(Const.SALEOR_TOKEN, token, options)
}
export const getCSRFToken = () => Cookies.get(Const.SALEOR_CRSF_TOKEN)
export const setCSRFToken = (token?: string, options?: CookieAttributes) => {
setCookie(Const.SALEOR_CRSF_TOKEN, token, options)
}
export const getCheckoutToken = () => Cookies.get(Const.CHECKOUT_ID_COOKIE)
export const setCheckoutToken = (token?: string, options?: CookieAttributes) => {
setCookie(Const.CHECKOUT_ID_COOKIE, token, options)
}
const setCookie = (name: string, token?: string, options?: CookieAttributes) => {
if (!token) {
Cookies.remove(name)
} else {
Cookies.set(name, token, options ?? { expires: 60 * 60 * 24 * 30 })
}
}

View File

@ -0,0 +1,49 @@
export const CheckoutDetails = /* GraphQL */ `
fragment CheckoutDetails on Checkout {
id
token
created
totalPrice {
currency
gross {
amount
}
}
subtotalPrice {
currency
gross {
amount
}
}
lines {
id
variant {
id
name
sku
product {
name
slug
}
media {
url
}
pricing {
price {
gross {
amount
}
}
}
}
quantity
totalPrice {
currency
gross {
amount
}
}
}
}
`

View File

@ -0,0 +1,2 @@
export { ProductConnection } from './product'
export { CheckoutDetails } from './checkout-details'

View File

@ -0,0 +1,29 @@
export const ProductConnection = /* GraphQL */ `
fragment ProductConnection on ProductCountableConnection {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
id
name
description
slug
pricing {
priceRange {
start {
net {
amount
}
}
}
}
media {
url
alt
}
}
}
}
`

View File

@ -0,0 +1,23 @@
import { Category } from '@commerce/types/site'
import { SaleorConfig } from '../api'
import { CollectionCountableEdge } from '../schema'
import * as query from './queries'
const getCategories = async (config: SaleorConfig): Promise<Category[]> => {
const { data } = await config.fetch(query.CollectionMany, {
variables: {
first: 100,
},
})
return (
data.collections?.edges?.map(({ node: { id, name, slug } }: CollectionCountableEdge) => ({
id,
name,
slug,
path: `/${slug}`,
})) ?? []
)
}
export default getCategories

View File

@ -0,0 +1,9 @@
import Cookies from 'js-cookie'
import { CHECKOUT_ID_COOKIE } from '../const'
const getCheckoutId = (id?: string) => {
const r = Cookies.get(CHECKOUT_ID_COOKIE)?.split(':') || []
return { checkoutId: r[0], checkoutToken: r[1] }
}
export default getCheckoutId

View File

@ -0,0 +1,18 @@
import { getSortVariables } from './get-sort-variables'
import type { SearchProductsInput } from '../product/use-search'
export const getSearchVariables = ({ brandId, search, categoryId, sort }: SearchProductsInput) => {
const sortBy = {
field: 'NAME',
direction: 'ASC',
...getSortVariables(sort, !!categoryId),
channel: 'default-channel',
}
return {
categoryId,
filter: { search },
sortBy,
}
}
export default getSearchVariables

View File

@ -0,0 +1,30 @@
export const getSortVariables = (sort?: string, isCategory: boolean = false) => {
let output = {}
switch (sort) {
case 'price-asc':
output = {
field: 'PRICE',
direction: 'ASC',
}
break
case 'price-desc':
output = {
field: 'PRICE',
direction: 'DESC',
}
break
case 'trending-desc':
output = {
field: 'RANK',
direction: 'DESC',
}
break
case 'latest-desc':
output = {
field: 'DATE',
direction: 'DESC',
}
break
}
return output
}

View File

@ -0,0 +1,41 @@
import { SaleorConfig } from '../api'
export type Brand = {
entityId: string
name: string
path: string
}
export type BrandEdge = {
node: Brand
}
export type Brands = BrandEdge[]
// TODO: Find a way to get vendors from meta
const getVendors = async (config: SaleorConfig): Promise<BrandEdge[]> => {
// const vendors = await fetchAllProducts({
// config,
// query: getAllProductVendors,
// variables: {
// first: 100,
// },
// })
// let vendorsStrings = vendors.map(({ node: { vendor } }) => vendor)
// return [...new Set(vendorsStrings)].map((v) => {
// const id = v.replace(/\s+/g, '-').toLowerCase()
// return {
// node: {
// entityId: id,
// name: v,
// path: `brands/${id}`,
// },
// }
// })
return []
}
export default getVendors

View File

@ -0,0 +1,27 @@
import { FetcherError } from '@commerce/utils/errors'
export function getError(errors: any[], status: number) {
errors = errors ?? [{ message: 'Failed to fetch Saleor API' }]
return new FetcherError({ errors, status })
}
export async function getAsyncError(res: Response) {
const data = await res.json()
return getError(data.errors, res.status)
}
const handleFetchResponse = async (res: Response) => {
if (res.ok) {
const { data, errors } = await res.json()
if (errors && errors.length) {
throw getError(errors, res.status)
}
return data
}
throw await getAsyncError(res)
}
export default handleFetchResponse

View File

@ -0,0 +1,35 @@
import { FetcherOptions } from '@commerce/utils/types'
import { CreateToken, Mutation, MutationTokenCreateArgs } from '../schema'
import { setToken, setCSRFToken } from './customer-token'
import * as mutation from './mutations'
import throwUserErrors from './throw-user-errors'
const handleLogin = (data: CreateToken) => {
throwUserErrors(data?.errors)
const token = data?.token
if (token) {
setToken(token)
setCSRFToken(token)
}
return token
}
export const handleAutomaticLogin = async (
fetch: <T = any, B = Body>(options: FetcherOptions<B>) => Promise<T>,
input: MutationTokenCreateArgs
) => {
try {
const { tokenCreate } = await fetch<Mutation, MutationTokenCreateArgs>({
query: mutation.SessionCreate,
variables: { ...input },
})
handleLogin(tokenCreate!)
} catch (error) {
//
}
}
export default handleLogin

View File

@ -0,0 +1,19 @@
export { getSortVariables } from './get-sort-variables'
export { default as handleFetchResponse } from './handle-fetch-response'
export { default as getSearchVariables } from './get-search-variables'
export { default as getVendors } from './get-vendors'
export { default as getCategories } from './get-categories'
export { default as getCheckoutId } from './get-checkout-id'
export { default as checkoutCreate } from './checkout-create'
export { checkoutAttach } from './checkout-attach'
export { default as checkoutToCart } from './checkout-to-cart'
export { default as handleLogin, handleAutomaticLogin } from './handle-login'
export { default as throwUserErrors } from './throw-user-errors'
export * from './queries'
export * from './mutations'
export * from './normalize'
export * from './customer-token'

View File

@ -0,0 +1,15 @@
export const AccountCreate = /* GraphQL */ `
mutation AccountCreate($input: AccountRegisterInput!) {
accountRegister(input: $input) {
errors {
code
field
message
}
user {
email
isActive
}
}
}
`

View File

@ -0,0 +1,12 @@
export const CheckoutAttach = /* GraphQl */ `
mutation CheckoutAttach($checkoutId: ID!) {
checkoutCustomerAttach(checkoutId: $checkoutId) {
errors {
message
}
checkout {
id
}
}
}
`

View File

@ -0,0 +1,17 @@
import * as fragment from '../fragments'
export const CheckoutCreate = /* GraphQL */ `
mutation CheckoutCreate {
checkoutCreate(input: { email: "customer@example.com", lines: [], channel: "default-channel" }) {
errors {
code
field
message
}
checkout {
...CheckoutDetails
}
}
}
${fragment.CheckoutDetails}
`

View File

@ -0,0 +1,17 @@
import * as fragment from '../fragments'
export const CheckoutLineAdd = /* GraphQL */ `
mutation CheckoutLineAdd($checkoutId: ID!, $lineItems: [CheckoutLineInput!]!) {
checkoutLinesAdd(checkoutId: $checkoutId, lines: $lineItems) {
errors {
code
field
message
}
checkout {
...CheckoutDetails
}
}
}
${fragment.CheckoutDetails}
`

View File

@ -0,0 +1,17 @@
import * as fragment from '../fragments'
export const CheckoutLineDelete = /* GraphQL */ `
mutation CheckoutLineDelete($checkoutId: ID!, $lineId: ID!) {
checkoutLineDelete(checkoutId: $checkoutId, lineId: $lineId) {
errors {
code
field
message
}
checkout {
...CheckoutDetails
}
}
}
${fragment.CheckoutDetails}
`

View File

@ -0,0 +1,17 @@
import * as fragment from '../fragments'
export const CheckoutLineUpdate = /* GraphQL */ `
mutation CheckoutLineUpdate($checkoutId: ID!, $lineItems: [CheckoutLineInput!]!) {
checkoutLinesUpdate(checkoutId: $checkoutId, lines: $lineItems) {
errors {
code
field
message
}
checkout {
...CheckoutDetails
}
}
}
${fragment.CheckoutDetails}
`

View File

@ -0,0 +1,8 @@
export { AccountCreate } from './account-create'
export { CheckoutCreate } from './checkout-create'
export { CheckoutLineAdd } from './checkout-line-add'
export { CheckoutLineUpdate } from './checkout-line-update'
export { CheckoutLineDelete } from './checkout-line-remove'
export { SessionCreate } from './session-create'
export { SessionDestroy } from './session-destroy'
export { CheckoutAttach } from './checkout-attach'

View File

@ -0,0 +1,14 @@
export const SessionCreate = /* GraphQL */ `
mutation SessionCreate($email: String!, $password: String!) {
tokenCreate(email: $email, password: $password) {
token
refreshToken
csrfToken
errors {
code
field
message
}
}
}
`

View File

@ -0,0 +1,10 @@
export const SessionDestroy = /* GraphQL */ `
mutation SessionDestroy {
tokensDeactivateAll {
errors {
field
message
}
}
}
`

View File

@ -0,0 +1,133 @@
import { Product } from '@commerce/types/product'
import { Product as SaleorProduct, Checkout, CheckoutLine, Money, ProductVariant } from '../schema'
import type { Cart, LineItem } from '../types'
// TODO: Check nextjs-commerce bug if no images are added for a product
const placeholderImg = '/product-img-placeholder.svg'
const money = ({ amount, currency }: Money) => {
return {
value: +amount,
currencyCode: currency || 'USD',
}
}
const normalizeProductOptions = (options: ProductVariant[]) => {
return options
?.map((option) => option?.attributes)
.flat(1)
.reduce<any>((acc, x) => {
if (acc.find(({ displayName }: any) => displayName === x.attribute.name)) {
return acc.map((opt: any) => {
return opt.displayName === x.attribute.name
? {
...opt,
values: [
...opt.values,
...x.values.map((value: any) => ({
label: value?.name,
})),
],
}
: opt
})
}
return acc.concat({
__typename: 'MultipleChoiceOption',
displayName: x.attribute.name,
variant: 'size',
values: x.values.map((value: any) => ({
label: value?.name,
})),
})
}, [])
}
const normalizeProductVariants = (variants: ProductVariant[]) => {
return variants?.map((variant) => {
const { id, sku, name, pricing } = variant
const price = pricing?.price?.net && money(pricing.price.net)?.value
return {
id,
name,
sku: sku ?? id,
price,
listPrice: price,
requiresShipping: true,
options: normalizeProductOptions([variant]),
}
})
}
export function normalizeProduct(productNode: SaleorProduct): Product {
const { id, name, media = [], variants, description, slug, pricing, ...rest } = productNode
const product = {
id,
name,
vendor: '',
description: description ? JSON.parse(description)?.blocks[0]?.data.text : '',
path: `/${slug}`,
slug: slug?.replace(/^\/+|\/+$/g, ''),
price: (pricing?.priceRange?.start?.net && money(pricing.priceRange.start.net)) || {
value: 0,
currencyCode: 'USD',
},
// TODO: Check nextjs-commerce bug if no images are added for a product
images: media?.length ? media : [{ url: placeholderImg }],
variants: variants && variants.length > 0 ? normalizeProductVariants(variants as ProductVariant[]) : [],
options: variants && variants.length > 0 ? normalizeProductOptions(variants as ProductVariant[]) : [],
...rest,
}
return product as Product
}
export function normalizeCart(checkout: Checkout): Cart {
const lines = checkout.lines as CheckoutLine[]
const lineItems: LineItem[] = lines.length > 0 ? lines?.map<LineItem>(normalizeLineItem) : []
return {
id: checkout.id,
customerId: '',
email: '',
createdAt: checkout.created,
currency: {
code: checkout.totalPrice?.currency!,
},
taxesIncluded: false,
lineItems,
lineItemsSubtotalPrice: checkout.subtotalPrice?.gross?.amount!,
subtotalPrice: checkout.subtotalPrice?.gross?.amount!,
totalPrice: checkout.totalPrice?.gross.amount!,
discounts: [],
}
}
function normalizeLineItem({ id, variant, quantity }: CheckoutLine): LineItem {
return {
id,
variantId: String(variant?.id),
productId: String(variant?.id),
name: `${variant.product.name}`,
quantity,
variant: {
id: String(variant?.id),
sku: variant?.sku ?? '',
name: variant?.name!,
image: {
url: variant?.media![0] ? variant?.media![0].url : placeholderImg,
},
requiresShipping: false,
price: variant?.pricing?.price?.gross.amount!,
listPrice: 0,
},
path: String(variant?.product?.slug),
discounts: [],
options: [],
}
}

View File

@ -0,0 +1,12 @@
import * as fragment from '../fragments'
export const CheckoutOne = /* GraphQL */ `
query CheckoutOne($checkoutId: UUID!) {
checkout(token: $checkoutId) {
... on Checkout {
...CheckoutDetails
}
}
}
${fragment.CheckoutDetails}
`

View File

@ -0,0 +1,13 @@
export const CollectionMany = /* GraphQL */ `
query CollectionMany($first: Int!, $channel: String = "default-channel") {
collections(first: $first, channel: $channel) {
edges {
node {
id
name
slug
}
}
}
}
`

View File

@ -0,0 +1,13 @@
import * as fragment from '../fragments'
export const CollectionOne = /* GraphQL */ `
query getProductsFromCollection($categoryId: ID!, $first: Int = 100, $channel: String = "default-channel") {
collection(id: $categoryId, channel: $channel) {
id
products(first: $first) {
...ProductConnection
}
}
}
${fragment.ProductConnection}
`

View File

@ -0,0 +1,11 @@
export const CustomerCurrent = /* GraphQL */ `
query CustomerCurrent {
me {
id
email
firstName
lastName
dateJoined
}
}
`

View File

@ -0,0 +1,7 @@
export const CustomerOne = /* GraphQL */ `
query CustomerOne($customerAccessToken: String!) {
customer(customerAccessToken: $customerAccessToken) {
id
}
}
`

View File

@ -0,0 +1,16 @@
export const getAllProductVendors = /* GraphQL */ `
query getAllProductVendors($first: Int = 250, $cursor: String) {
products(first: $first, after: $cursor) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
vendor
}
cursor
}
}
}
`

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