Update Vendure provider to latest changes

This commit is contained in:
Michael Bromley 2021-03-10 17:39:49 +01:00
parent 41e59e9a85
commit 23c3412c17
33 changed files with 713 additions and 807 deletions

View File

@ -4,8 +4,6 @@ UI hooks and data fetching methods built from the ground up for e-commerce appli
## Usage ## Usage
As this is still under development, no npm package is yet provided. Here's how you can try it out:
1. First you'll need a Vendure server. You can your own local server up-and-running with just a single command: 1. First you'll need a Vendure server. You can your own local server up-and-running with just a single command:
```shell ```shell
npx @vendure/create my-app npx @vendure/create my-app
@ -25,30 +23,41 @@ As this is still under development, no npm package is yet provided. Here's how y
3. Clone this repo and install its dependencies with `yarn install` or `npm install` 3. Clone this repo and install its dependencies with `yarn install` or `npm install`
4. Change the paths in [tsconfig.json](../../tsconfig.json) to point to the Vendure hooks: 4. Change the paths in [tsconfig.json](../../tsconfig.json) to point to the Vendure hooks:
```diff ```diff
"paths": {
"@lib/*": ["lib/*"],
"@assets/*": ["assets/*"],
"@config/*": ["config/*"],
"@components/*": ["components/*"],
"@utils/*": ["utils/*"],
"@commerce/*": ["framework/commerce/*"],
"@commerce": ["framework/commerce"],
- "@framework/*": ["framework/bigcommerce/*"], - "@framework/*": ["framework/bigcommerce/*"],
- "@framework": ["framework/bigcommerce"] - "@framework": ["framework/bigcommerce"]
+ "@framework/*": ["framework/vendure/*"], + "@framework/*": ["framework/vendure/*"],
+ "@framework": ["framework/vendure"] + "@framework": ["framework/vendure"]
}
``` ```
5. Set the Vendure Shop API URL in your `.env.local` file: 5. Set the Vendure Shop API URL in your `.env.local` file:
```sh ```sh
NEXT_PUBLIC_VENDURE_SHOP_API_URL=http://localhost:3001/shop-api NEXT_PUBLIC_VENDURE_SHOP_API_URL=http://localhost:3001/shop-api
``` ```
6. With the Vendure server running, start this project using `yarn dev` or `npm run dev`. 6. Add the `localhost` domain to the `images` property next.config.js file as per the [image optimization docs](https://nextjs.org/docs/basic-features/image-optimization#domains)
```js
module.exports = {
// ...
images: {
domains: ['example.com'],
},
}
```
7. With the Vendure server running, start this project using `yarn dev` or `npm run dev`.
## Known Limitations ## Known Limitations
1. Vendure does not ship with built-in wishlist functionality. 1. Vendure does not ship with built-in wishlist functionality.
2. Nor does it come with any kind of blog/page-building feature. Both of these can be created as Vendure plugins, however. 2. Nor does it come with any kind of blog/page-building feature. Both of these can be created as Vendure plugins, however.
3. The entire Vendure customer flow is carried out via its GraphQL API. This means that there is no external, pre-existing checkout flow. The checkout flow must be created as part of the Next.js app. 3. The entire Vendure customer flow is carried out via its GraphQL API. This means that there is no external, pre-existing checkout flow. The checkout flow must be created as part of the Next.js app. See https://github.com/vercel/commerce/issues/64 for further discusion.
4. By default, the sign-up flow in Vendure uses email verification. This means that using the existing "sign up" flow from this project will not grant a new user the ability to authenticate, since the new account must first be verified. Again, the necessary parts to support this flow can be created as part of the Next.js app. 4. By default, the sign-up flow in Vendure uses email verification. This means that using the existing "sign up" flow from this project will not grant a new user the ability to authenticate, since the new account must first be verified. Again, the necessary parts to support this flow can be created as part of the Next.js app.
5. The mapping of products & variants may not totally match up with what the storefront expects. As the storefront matures and improves this mapping can be refined.
## Code generation
This provider makes use of GraphQL code generation. The [schema.graphql](./schema.graphql) and [schema.d.ts](./schema.d.ts) files contain the generated types & schema introspection results.
When developing the provider, changes to any GraphQL operations should be followed by re-generation of the types and schema files:
From the project root dir, run
```sh
graphql-codegen --config ./framework/vendure/codegen.json
```

View File

@ -1,9 +1,8 @@
import { BigcommerceConfig, getConfig } from '../../bigcommerce/api' import { BigcommerceConfig, getConfig } from '../../bigcommerce/api'
import { NextApiHandler } from 'next' import { NextApiHandler } from 'next'
const checkoutApi= async (req: any, res: any, config: any) => { const checkoutApi = async (req: any, res: any, config: any) => {
try { try {
// TODO: make the embedded checkout work too!
const html = ` const html = `
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -15,6 +14,9 @@ const checkoutApi= async (req: any, res: any, config: any) => {
<body> <body>
<div style='margin: 10rem auto; text-align: center; font-family: SansSerif, "Segoe UI", Helvetica'> <div style='margin: 10rem auto; text-align: center; font-family: SansSerif, "Segoe UI", Helvetica'>
<h1>Checkout not implemented :(</h1> <h1>Checkout not implemented :(</h1>
<p>
See <a href='https://github.com/vercel/commerce/issues/64' target='_blank'>#64</a>
</p>
</div> </div>
</body> </body>
</html> </html>
@ -33,11 +35,7 @@ const checkoutApi= async (req: any, res: any, config: any) => {
} }
} }
export function createApiHandler< export function createApiHandler<T = any, H = {}, Options extends {} = {}>(
T = any,
H = {},
Options extends {} = {}
>(
handler: any, handler: any,
handlers: H, handlers: H,
defaultOptions: Options defaultOptions: Options

View File

@ -2,6 +2,7 @@ export const cartFragment = /* GraphQL */ `
fragment Cart on Order { fragment Cart on Order {
id id
code code
createdAt
totalQuantity totalQuantity
subTotal subTotal
subTotalWithTax subTotalWithTax
@ -14,13 +15,23 @@ export const cartFragment = /* GraphQL */ `
lines { lines {
id id
quantity quantity
linePriceWithTax
discountedLinePriceWithTax
featuredAsset { featuredAsset {
id id
preview preview
} }
discounts {
description
amount
}
productVariant { productVariant {
id id
name name
sku
price
priceWithTax
stockLevel
product { product {
slug slug
} }

View File

@ -1,14 +0,0 @@
type Header = string | number | string[] | undefined
export default function concatHeader(prev: Header, val: Header) {
if (!val) return prev
if (!prev) return val
if (Array.isArray(prev)) return prev.concat(String(val))
prev = String(prev)
if (Array.isArray(val)) return [prev].concat(val)
return [prev, String(val)]
}

View File

@ -1,3 +0,0 @@
export { default as useLogin } from './use-login'
export { default as useLogout } from './use-logout'
export { default as useSignup } from './use-signup'

View File

@ -1,82 +0,0 @@
import type { ServerResponse } from 'http'
import type { LoginMutation, LoginMutationVariables } from '../schema'
import concatHeader from '../api/utils/concat-cookie'
import { getConfig, VendureConfig } from '../api'
import { CommerceError } from '@commerce/utils/errors'
import { ErrorResult } from '../schema'
export const loginMutation = /* GraphQL */ `
mutation loginServer($email: String!, $password: String!) {
login(username: $email, password: $password) {
__typename
... on CurrentUser {
id
}
... on ErrorResult {
errorCode
message
}
}
}
`
export type LoginResult<T extends { result?: any } = { result?: string }> = T
export type LoginVariables = LoginMutationVariables
async function login(opts: {
variables: LoginVariables
config?: VendureConfig
res: ServerResponse
}): Promise<LoginResult>
async function login<T extends { result?: any }, V = any>(opts: {
query: string
variables: V
res: ServerResponse
config?: VendureConfig
}): Promise<LoginResult<T>>
async function login({
query = loginMutation,
variables,
res: response,
config,
}: {
query?: string
variables: LoginVariables
res: ServerResponse
config?: VendureConfig
}): Promise<LoginResult> {
config = getConfig(config)
const { data, res } = await config.fetch<LoginMutation>(query, { variables })
if (data.login.__typename !== 'CurrentUser') {
throw new CommerceError({ message: (data.login as ErrorResult).message })
}
// Bigcommerce returns a Set-Cookie header with the auth cookie
let cookie = res.headers.get('Set-Cookie')
if (cookie && typeof cookie === 'string') {
// In development, don't set a secure cookie or the browser will ignore it
if (process.env.NODE_ENV !== 'production') {
cookie = cookie.replace('; Secure', '')
// SameSite=none can't be set unless the cookie is Secure
// bc seems to sometimes send back SameSite=None rather than none so make
// this case insensitive
cookie = cookie.replace(/; SameSite=none/gi, '; SameSite=lax')
}
response.setHeader(
'Set-Cookie',
concatHeader(response.getHeader('Set-Cookie'), cookie)!
)
}
return {
result: data.login.id.toString(),
}
}
export default login

View File

@ -1,13 +1,9 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import type { HookFetcher } from '@commerce/utils/types' import { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors' import useLogin, { UseLogin } from '@commerce/auth/use-login'
import useCommerceLogin from '@commerce/use-login' import { CommerceError, ValidationError } from '@commerce/utils/errors'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
import { import { LoginMutation, LoginMutationVariables } from '../schema'
ErrorResult,
LoginMutation,
LoginMutationVariables,
} from '@framework/schema'
export const loginMutation = /* GraphQL */ ` export const loginMutation = /* GraphQL */ `
mutation login($username: String!, $password: String!) { mutation login($username: String!, $password: String!) {
@ -24,53 +20,45 @@ export const loginMutation = /* GraphQL */ `
} }
` `
export const fetcher: HookFetcher<LoginMutation, LoginMutationVariables> = ( export default useLogin as UseLogin<typeof handler>
options,
{ username, password }, export const handler: MutationHook<null, {}, any> = {
fetch fetchOptions: {
) => { query: loginMutation,
if (!(username && password)) { },
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({ throw new CommerceError({
message: 'An email address and password are required to login', message: 'A email and password are required to login',
}) })
} }
return fetch({ const variables: LoginMutationVariables = {
...options, username: email,
query: loginMutation, password,
variables: { username, password }, }
})
}
export function extendHook(customFetcher: typeof fetcher) { const { login } = await fetch<LoginMutation>({
const useLogin = () => { ...options,
variables,
})
if (login.__typename !== 'CurrentUser') {
throw new ValidationError(login)
}
return null
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer() const { revalidate } = useCustomer()
const fn = useCommerceLogin<LoginMutation, LoginMutationVariables>(
{},
customFetcher
)
return useCallback( return useCallback(
async function login(input: { email: string; password: string }) { async function login(input) {
const data = await fn({ const data = await fetch({ input })
username: input.email,
password: input.password,
})
if (data.login.__typename !== 'CurrentUser') {
throw new CommerceError({
message: (data.login as ErrorResult).message,
})
}
await revalidate() await revalidate()
return data return data
}, },
[fn] [fetch, revalidate]
) )
} },
useLogin.extend = extendHook
return useLogin
} }
export default extendHook(fetcher)

View File

@ -1,8 +1,8 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import type { HookFetcher } from '@commerce/utils/types' import { MutationHook } from '@commerce/utils/types'
import useCommerceLogout from '@commerce/use-logout' import useLogout, { UseLogout } from '@commerce/auth/use-logout'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
import { LogoutMutation } from '@framework/schema' import { LogoutMutation } from '../schema'
export const logoutMutation = /* GraphQL */ ` export const logoutMutation = /* GraphQL */ `
mutation logout { mutation logout {
@ -12,31 +12,28 @@ export const logoutMutation = /* GraphQL */ `
} }
` `
export const fetcher: HookFetcher<LogoutMutation> = (options, _, fetch) => { export default useLogout as UseLogout<typeof handler>
return fetch({
...options,
query: logoutMutation,
})
}
export function extendHook(customFetcher: typeof fetcher) { export const handler: MutationHook<null> = {
const useLogout = () => { fetchOptions: {
query: logoutMutation,
},
async fetcher({ options, fetch }) {
await fetch<LogoutMutation>({
...options,
})
return null
},
useHook: ({ fetch }) => () => {
const { mutate } = useCustomer() const { mutate } = useCustomer()
const fn = useCommerceLogout<LogoutMutation>({}, customFetcher)
return useCallback( return useCallback(
async function logout() { async function logout() {
const data = await fn(null) const data = await fetch()
await mutate(null as any, false) await mutate(null, false)
return data return data
}, },
[fn] [fetch, mutate]
) )
} },
useLogout.extend = extendHook
return useLogout
} }
export default extendHook(fetcher)

View File

@ -1,13 +1,13 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import type { HookFetcher } from '@commerce/utils/types' import { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors' import { CommerceError, ValidationError } from '@commerce/utils/errors'
import useCommerceSignup from '@commerce/use-signup' import useSignup, { UseSignup } from '@commerce/auth/use-signup'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
import { import {
ErrorResult, RegisterCustomerInput,
SignupMutation, SignupMutation,
SignupMutationVariables, SignupMutationVariables,
} from '@framework/schema' } from '../schema'
export const signupMutation = /* GraphQL */ ` export const signupMutation = /* GraphQL */ `
mutation signup($input: RegisterCustomerInput!) { mutation signup($input: RegisterCustomerInput!) {
@ -24,66 +24,57 @@ export const signupMutation = /* GraphQL */ `
} }
` `
export type SignupInput = { export default useSignup as UseSignup<typeof handler>
email: string
firstName: string
lastName: string
password: string
}
export const fetcher: HookFetcher<SignupMutation, SignupMutationVariables> = ( export const handler: MutationHook<
null,
{},
RegisterCustomerInput,
RegisterCustomerInput
> = {
fetchOptions: {
query: signupMutation,
},
async fetcher({
input: { firstName, lastName, emailAddress, password },
options, options,
{ input }, fetch,
fetch }) {
) => {
const { firstName, lastName, emailAddress, password } = input
if (!(firstName && lastName && emailAddress && password)) { if (!(firstName && lastName && emailAddress && password)) {
throw new CommerceError({ throw new CommerceError({
message: message:
'A first name, last name, email and password are required to signup', 'A first name, last name, email and password are required to signup',
}) })
} }
const variables: SignupMutationVariables = {
return fetch({ input: {
firstName,
lastName,
emailAddress,
password,
},
}
const { registerCustomerAccount } = await fetch<SignupMutation>({
...options, ...options,
query: signupMutation, variables,
variables: { input },
}) })
}
export function extendHook(customFetcher: typeof fetcher) { if (registerCustomerAccount.__typename !== 'Success') {
const useSignup = () => { throw new ValidationError(registerCustomerAccount)
}
return null
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer() const { revalidate } = useCustomer()
const fn = useCommerceSignup<SignupMutation, SignupMutationVariables>(
{},
customFetcher
)
return useCallback( return useCallback(
async function signup(input: SignupInput) { async function signup(input) {
const { registerCustomerAccount } = await fn({ const data = await fetch({ input })
input: {
firstName: input.firstName,
lastName: input.lastName,
emailAddress: input.email,
password: input.password,
},
})
if (registerCustomerAccount.__typename !== 'Success') {
throw new CommerceError({
message: (registerCustomerAccount as ErrorResult).message,
})
}
await revalidate() await revalidate()
return { registerCustomerAccount } return data
}, },
[fn] [fetch, revalidate]
) )
} },
useSignup.extend = extendHook
return useSignup
} }
export default extendHook(fetcher)

View File

@ -1,15 +1,12 @@
import { Cart, CartItemBody } from '@commerce/types'
import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item'
import { CommerceError } from '@commerce/utils/errors' import { CommerceError } from '@commerce/utils/errors'
import { HookFetcher } from '@commerce/utils/types' import { MutationHook } from '@commerce/utils/types'
import fetchGraphqlApi from '@framework/api/utils/fetch-graphql-api'
import useCartAddItem from '@commerce/cart/use-add-item'
import useCart from './use-cart'
import { useCallback } from 'react' import { useCallback } from 'react'
import useCart from './use-cart'
import { cartFragment } from '../api/fragments/cart' import { cartFragment } from '../api/fragments/cart'
import { import { AddItemToOrderMutation } from '../schema'
AddItemToOrderMutation, import { normalizeCart } from '../lib/normalize'
AddItemToOrderMutationVariables,
ErrorResult,
} from '@framework/schema'
export const addItemToOrderMutation = /* GraphQL */ ` export const addItemToOrderMutation = /* GraphQL */ `
mutation addItemToOrder($variantId: ID!, $quantity: Int!) { mutation addItemToOrder($variantId: ID!, $quantity: Int!) {
@ -25,56 +22,45 @@ export const addItemToOrderMutation = /* GraphQL */ `
${cartFragment} ${cartFragment}
` `
export type AddItemInput = { export default useAddItem as UseAddItem<typeof handler>
productId?: number
variantId: number
quantity?: number
}
export const fetcher: HookFetcher< export const handler: MutationHook<Cart, {}, CartItemBody> = {
AddItemToOrderMutation, fetchOptions: {
AddItemToOrderMutationVariables query: addItemToOrderMutation,
> = (options, { variantId, quantity }, fetch) => { },
if (quantity && (!Number.isInteger(quantity) || quantity! < 1)) { async fetcher({ input, options, fetch }) {
if (
input.quantity &&
(!Number.isInteger(input.quantity) || input.quantity! < 1)
) {
throw new CommerceError({ throw new CommerceError({
message: 'The item quantity has to be a valid integer greater than 0', message: 'The item quantity has to be a valid integer greater than 0',
}) })
} }
return fetch({ const { addItemToOrder } = await fetch<AddItemToOrderMutation>({
...options, ...options,
query: addItemToOrderMutation, variables: {
variables: { variantId, quantity: quantity || 1 },
})
}
export function extendHook(customFetcher: typeof fetcher) {
const useAddItem = () => {
const { mutate } = useCart()
const fn = useCartAddItem({}, customFetcher)
return useCallback(
async function addItem(input: AddItemInput) {
const { addItemToOrder } = await fn({
quantity: input.quantity || 1, quantity: input.quantity || 1,
variantId: input.variantId, variantId: input.variantId,
})
if (addItemToOrder.__typename === 'Order') {
await mutate({ addItemToOrder }, false)
} else {
throw new CommerceError({
message: (addItemToOrder as ErrorResult).message,
})
}
return { addItemToOrder }
}, },
[fn, mutate] })
)
if (addItemToOrder.__typename === 'Order') {
return normalizeCart(addItemToOrder)
} }
throw new CommerceError(addItemToOrder)
},
useHook: ({ fetch }) => () => {
const { mutate } = useCart()
useAddItem.extend = extendHook return useCallback(
async function addItem(input) {
return useAddItem const data = await fetch({ input })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
} }
export default extendHook(fetcher)

View File

@ -1,9 +1,10 @@
import { HookFetcher } from '@commerce/utils/types' import { Cart } from '@commerce/types'
import useData, { SwrOptions } from '@commerce/utils/use-data' import { SWRHook } from '@commerce/utils/types'
import useResponse from '@commerce/utils/use-response' import useCart, { FetchCartInput, UseCart } from '@commerce/cart/use-cart'
import { cartFragment } from '../api/fragments/cart' import { cartFragment } from '../api/fragments/cart'
import { CartFragment } from '../schema' import { ActiveOrderQuery, CartFragment } from '../schema'
import { normalizeCart } from '@framework/lib/normalize' import { normalizeCart } from '../lib/normalize'
import { useMemo } from 'react'
export const getCartQuery = /* GraphQL */ ` export const getCartQuery = /* GraphQL */ `
query activeOrder { query activeOrder {
@ -14,10 +15,6 @@ export const getCartQuery = /* GraphQL */ `
${cartFragment} ${cartFragment}
` `
export const fetcher: HookFetcher<any, null> = (options, input, fetch) => {
return fetch({ ...options, query: getCartQuery })
}
export type CartResult = { export type CartResult = {
activeOrder?: CartFragment activeOrder?: CartFragment
addItemToOrder?: CartFragment addItemToOrder?: CartFragment
@ -25,42 +22,37 @@ export type CartResult = {
removeOrderLine?: CartFragment removeOrderLine?: CartFragment
} }
export function extendHook( export default useCart as UseCart<typeof handler>
customFetcher: typeof fetcher,
swrOptions?: SwrOptions<any | null> export const handler: SWRHook<
) { Cart | null,
const useCart = () => { {},
const response = useData<CartResult>( FetchCartInput,
{ query: getCartQuery }, { isEmpty?: boolean }
[], > = {
customFetcher, fetchOptions: {
swrOptions query: getCartQuery,
)
const res = useResponse(response, {
normalizer: (data) => {
const order =
data?.activeOrder ||
data?.addItemToOrder ||
data?.adjustOrderLine ||
data?.removeOrderLine
return order ? normalizeCart(order) : null
}, },
descriptors: { async fetcher({ input: { cartId }, options, fetch }) {
const { activeOrder } = await fetch<ActiveOrderQuery>(options)
return activeOrder ? normalizeCart(activeOrder) : null
},
useHook: ({ useData }) => (input) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
return useMemo(
() =>
Object.create(response, {
isEmpty: { isEmpty: {
get() { get() {
return response.data?.activeOrder?.totalQuantity === 0 return (response.data?.lineItems.length ?? 0) <= 0
}, },
enumerable: true, enumerable: true,
}, },
}),
[response]
)
}, },
})
return res
}
useCart.extend = extendHook
return useCart
} }
export default extendHook(fetcher)

View File

@ -1,14 +1,15 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { HookFetcher } from '@commerce/utils/types' import { HookFetcherContext, MutationHookContext } from '@commerce/utils/types'
import useCartRemoveItem from '@commerce/cart/use-remove-item' import useRemoveItem, { UseRemoveItem } from '@commerce/cart/use-remove-item'
import { CommerceError } from '@commerce/utils/errors'
import useCart from './use-cart' import useCart from './use-cart'
import { cartFragment } from '@framework/api/fragments/cart' import { cartFragment } from '../api/fragments/cart'
import { import {
ErrorResult,
RemoveOrderLineMutation, RemoveOrderLineMutation,
RemoveOrderLineMutationVariables, RemoveOrderLineMutationVariables,
} from '@framework/schema' } from '../schema'
import { CommerceError } from '@commerce/utils/errors' import { Cart, LineItem, RemoveCartItemBody } from '@commerce/types'
import { normalizeCart } from '../lib/normalize'
export const removeOrderLineMutation = /* GraphQL */ ` export const removeOrderLineMutation = /* GraphQL */ `
mutation removeOrderLine($orderLineId: ID!) { mutation removeOrderLine($orderLineId: ID!) {
@ -24,44 +25,38 @@ export const removeOrderLineMutation = /* GraphQL */ `
${cartFragment} ${cartFragment}
` `
export const fetcher: HookFetcher< export default useRemoveItem as UseRemoveItem<typeof handler>
RemoveOrderLineMutation,
RemoveOrderLineMutationVariables
> = (options, { orderLineId }, fetch) => {
return fetch({
...options,
query: removeOrderLineMutation,
variables: { orderLineId },
})
}
export function extendHook(customFetcher: typeof fetcher) { export const handler = {
const useRemoveItem = (item?: any) => { fetchOptions: {
query: removeOrderLineMutation,
},
async fetcher({ input, options, fetch }: HookFetcherContext<LineItem>) {
const variables: RemoveOrderLineMutationVariables = {
orderLineId: input.id,
}
const { removeOrderLine } = await fetch<RemoveOrderLineMutation>({
...options,
variables,
})
if (removeOrderLine.__typename === 'Order') {
return normalizeCart(removeOrderLine)
}
throw new CommerceError(removeOrderLine)
},
useHook: ({
fetch,
}: MutationHookContext<Cart | null, RemoveCartItemBody>) => (ctx = {}) => {
const { mutate } = useCart() const { mutate } = useCart()
const fn = useCartRemoveItem<
RemoveOrderLineMutation,
RemoveOrderLineMutationVariables
>({}, customFetcher)
return useCallback( return useCallback(
async function removeItem(input: any) { async function removeItem(input) {
const { removeOrderLine } = await fn({ orderLineId: input.id }) const data = await fetch({ input })
if (removeOrderLine.__typename === 'Order') { await mutate(data, false)
await mutate({ removeOrderLine }, false) return data
} else {
throw new CommerceError({
message: (removeOrderLine as ErrorResult).message,
})
}
return { removeOrderLine }
}, },
[fn, mutate] [fetch, mutate]
) )
} },
useRemoveItem.extend = extendHook
return useRemoveItem
} }
export default extendHook(fetcher)

View File

@ -1,15 +1,20 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import debounce from 'lodash.debounce' import { HookFetcherContext, MutationHookContext } from '@commerce/utils/types'
import type { HookFetcher } from '@commerce/utils/types' import { CommerceError, ValidationError } from '@commerce/utils/errors'
import useCartUpdateItem from '@commerce/cart/use-update-item' import useUpdateItem, { UseUpdateItem } from '@commerce/cart/use-update-item'
import {
Cart,
CartItemBody,
LineItem,
UpdateCartItemBody,
} from '@commerce/types'
import useCart from './use-cart' import useCart from './use-cart'
import { cartFragment } from '@framework/api/fragments/cart'
import { import {
AdjustOrderLineMutation, AdjustOrderLineMutation,
AdjustOrderLineMutationVariables, AdjustOrderLineMutationVariables,
ErrorResult, } from '../schema'
} from '@framework/schema' import { cartFragment } from '../api/fragments/cart'
import { CommerceError } from '@commerce/utils/errors' import { normalizeCart } from '../lib/normalize'
export const adjustOrderLineMutation = /* GraphQL */ ` export const adjustOrderLineMutation = /* GraphQL */ `
mutation adjustOrderLine($orderLineId: ID!, $quantity: Int!) { mutation adjustOrderLine($orderLineId: ID!, $quantity: Int!) {
@ -24,47 +29,64 @@ export const adjustOrderLineMutation = /* GraphQL */ `
} }
${cartFragment} ${cartFragment}
` `
export const fetcher: HookFetcher<
AdjustOrderLineMutation,
AdjustOrderLineMutationVariables
> = (options, { orderLineId, quantity }, fetch) => {
return fetch({
...options,
query: adjustOrderLineMutation,
variables: { orderLineId, quantity },
})
}
function extendHook(customFetcher: typeof fetcher, cfg?: { wait?: number }) { export default useUpdateItem as UseUpdateItem<typeof handler>
const useUpdateItem = (item?: any) => {
export const handler = {
fetchOptions: {
query: adjustOrderLineMutation,
},
async fetcher(context: HookFetcherContext<UpdateCartItemBody<CartItemBody>>) {
const { input, options, fetch } = context
const variables: AdjustOrderLineMutationVariables = {
quantity: input.item.quantity || 1,
orderLineId: input.itemId,
}
const { adjustOrderLine } = await fetch<AdjustOrderLineMutation>({
...options,
variables,
})
if (adjustOrderLine.__typename === 'Order') {
return normalizeCart(adjustOrderLine)
}
throw new CommerceError(adjustOrderLine)
},
useHook: ({
fetch,
}: MutationHookContext<Cart | null, UpdateCartItemBody<CartItemBody>>) => (
ctx: {
item?: LineItem
wait?: number
} = {}
) => {
const { item } = ctx
const { mutate } = useCart() const { mutate } = useCart()
const fn = useCartUpdateItem<
AdjustOrderLineMutation,
AdjustOrderLineMutationVariables
>({}, customFetcher)
return useCallback( return useCallback(
debounce(async (input: any) => { async function addItem(input: CartItemBody) {
const { adjustOrderLine } = await fn({ const itemId = item?.id
orderLineId: 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, quantity: input.quantity,
},
itemId,
},
}) })
if (adjustOrderLine.__typename === 'Order') { await mutate(data, false)
await mutate({ adjustOrderLine }, false) return data
} else { },
throw new CommerceError({ [fetch, mutate]
message: (adjustOrderLine as ErrorResult).message,
})
}
return { adjustOrderLine }
}, cfg?.wait ?? 500),
[fn, mutate]
) )
} },
useUpdateItem.extend = extendHook
return useUpdateItem
} }
export default extendHook(fetcher)

View File

@ -14,7 +14,7 @@
"plugins": ["typescript", "typescript-operations"], "plugins": ["typescript", "typescript-operations"],
"config": { "config": {
"scalars": { "scalars": {
"ID": "number" "ID": "string"
} }
} }
}, },

View File

@ -0,0 +1,6 @@
{
"provider": "vendure",
"features": {
"wishlist": false
}
}

View File

@ -1,6 +1,6 @@
import { VendureConfig, getConfig } from '../api' import { VendureConfig, getConfig } from '../api'
import { GetCollectionsQuery } from '@framework/schema' import { GetCollectionsQuery } from '../schema'
import { arrayToTree } from '@framework/lib/array-to-tree' import { arrayToTree } from '../lib/array-to-tree'
export const getCollectionsQuery = /* GraphQL */ ` export const getCollectionsQuery = /* GraphQL */ `
query getCollections { query getCollections {

View File

@ -1,8 +1,7 @@
import type { HookFetcher } from '@commerce/utils/types' import { SWRHook } from '@commerce/utils/types'
import type { SwrOptions } from '@commerce/utils/use-data' import useCustomer, { UseCustomer } from '@commerce/customer/use-customer'
import useCommerceCustomer from '@commerce/use-customer' import { Customer } from '@commerce/types'
import { ActiveCustomerQuery } from '@framework/schema' import { ActiveCustomerQuery } from '../schema'
import useResponse from '@commerce/utils/use-response'
export const activeCustomerQuery = /* GraphQL */ ` export const activeCustomerQuery = /* GraphQL */ `
query activeCustomer { query activeCustomer {
@ -15,40 +14,30 @@ export const activeCustomerQuery = /* GraphQL */ `
} }
` `
export const fetcher: HookFetcher<ActiveCustomerQuery> = async ( export default useCustomer as UseCustomer<typeof handler>
options,
_,
fetch
) => {
return await fetch<ActiveCustomerQuery>({ query: activeCustomerQuery })
}
export function extendHook( export const handler: SWRHook<Customer | null> = {
customFetcher: typeof fetcher, fetchOptions: {
swrOptions?: SwrOptions<ActiveCustomerQuery> query: activeCustomerQuery,
) { },
const useCustomer = () => { async fetcher({ options, fetch }) {
const response = useCommerceCustomer({}, [], customFetcher, { const { activeCustomer } = await fetch<ActiveCustomerQuery>({
revalidateOnFocus: false, ...options,
...swrOptions,
}) })
return activeCustomer
return useResponse(response, { ? ({
normalizer: (data) => { firstName: activeCustomer.firstName ?? '',
return data?.activeCustomer lastName: activeCustomer.lastName ?? '',
? { email: activeCustomer.emailAddress ?? '',
firstName: data?.activeCustomer?.firstName ?? '', } as any)
lastName: data?.activeCustomer?.lastName ?? '',
email: data?.activeCustomer?.emailAddress ?? '',
}
: null : null
}, },
useHook: ({ useData }) => (input) => {
return useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
}) })
} },
useCustomer.extend = extendHook
return useCustomer
} }
export default extendHook(fetcher)

View File

@ -0,0 +1,49 @@
import { Fetcher } from '@commerce/utils/types'
import { FetcherError } from '@commerce/utils/errors'
async function getText(res: Response) {
try {
return (await res.text()) || res.statusText
} catch (error) {
return res.statusText
}
}
async function getError(res: Response) {
if (res.headers.get('Content-Type')?.includes('application/json')) {
const data = await res.json()
return new FetcherError({ errors: data.errors, status: res.status })
}
return new FetcherError({ message: await getText(res), status: res.status })
}
export const fetcher: Fetcher = async ({
url,
method = 'POST',
variables,
query,
body: bodyObj,
}) => {
const shopApiUrl = process.env.NEXT_PUBLIC_VENDURE_SHOP_API_URL
if (!shopApiUrl) {
throw new Error(
'The Vendure Shop API url has not been provided. Please define NEXT_PUBLIC_VENDURE_SHOP_API_URL in .env.local'
)
}
const hasBody = Boolean(variables || query)
const body = hasBody ? JSON.stringify({ query, variables }) : undefined
const headers = hasBody ? { 'Content-Type': 'application/json' } : undefined
const res = await fetch(shopApiUrl, {
method,
body,
headers,
credentials: 'include',
})
if (res.ok) {
const { data } = await res.json()
return data
}
throw await getError(res)
}

View File

@ -1,51 +1,15 @@
import * as React from 'react' import * as React from 'react'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { CommerceConfig, CommerceProvider as CoreCommerceProvider, useCommerce as useCoreCommerce } from '@commerce' import {
import { FetcherError } from '@commerce/utils/errors' CommerceConfig,
CommerceProvider as CoreCommerceProvider,
async function getText(res: Response) { useCommerce as useCoreCommerce,
try { } from '@commerce'
return (await res.text()) || res.statusText import { vendureProvider } from './provider'
} catch (error) {
return res.statusText
}
}
async function getError(res: Response) {
if (res.headers.get('Content-Type')?.includes('application/json')) {
const data = await res.json()
return new FetcherError({ errors: data.errors, status: res.status })
}
return new FetcherError({ message: await getText(res), status: res.status })
}
export const vendureConfig: CommerceConfig = { export const vendureConfig: CommerceConfig = {
locale: 'en-us', locale: 'en-us',
cartCookie: 'bc_cartId', cartCookie: 'session',
async fetcher({ url, method = 'POST', variables, query, body: bodyObj }) {
const shopApiUrl = process.env.NEXT_PUBLIC_VENDURE_SHOP_API_URL
if (!shopApiUrl) {
throw new Error('The Vendure Shop API url has not been provided. Please define NEXT_PUBLIC_VENDURE_SHOP_API_URL in .env.local')
}
const hasBody = Boolean(variables || query)
const body = hasBody
? JSON.stringify({ query, variables })
: undefined
const headers = hasBody ? { 'Content-Type': 'application/json' } : undefined
const res = await fetch(shopApiUrl, {
method,
body,
headers,
credentials: 'include'
})
if (res.ok) {
const { data } = await res.json()
return data
}
throw await getError(res)
},
} }
export type VendureConfig = Partial<CommerceConfig> export type VendureConfig = Partial<CommerceConfig>
@ -57,7 +21,10 @@ export type VendureProps = {
export function CommerceProvider({ children, ...config }: VendureProps) { export function CommerceProvider({ children, ...config }: VendureProps) {
return ( return (
<CoreCommerceProvider config={{ ...vendureConfig, ...config }}> <CoreCommerceProvider
provider={vendureProvider}
config={{ ...vendureConfig, ...config }}
>
{children} {children}
</CoreCommerceProvider> </CoreCommerceProvider>
) )

View File

@ -1,10 +1,10 @@
export type HasParent = { id: number; parent?: { id: number } | null } export type HasParent = { id: string; parent?: { id: string } | null }
export type TreeNode<T extends HasParent> = T & { export type TreeNode<T extends HasParent> = T & {
children: Array<TreeNode<T>> children: Array<TreeNode<T>>
expanded: boolean expanded: boolean
} }
export type RootNode<T extends HasParent> = { export type RootNode<T extends HasParent> = {
id?: number id?: string
children: Array<TreeNode<T>> children: Array<TreeNode<T>>
} }
@ -54,8 +54,8 @@ export function arrayToTree<T extends HasParent>(
*/ */
function treeToMap<T extends HasParent>( function treeToMap<T extends HasParent>(
tree?: RootNode<T> tree?: RootNode<T>
): Map<number, TreeNode<T>> { ): Map<string, TreeNode<T>> {
const nodeMap = new Map<number, TreeNode<T>>() const nodeMap = new Map<string, TreeNode<T>>()
function visit(node: TreeNode<T>) { function visit(node: TreeNode<T>) {
nodeMap.set(node.id, node) nodeMap.set(node.id, node)
node.children.forEach(visit) node.children.forEach(visit)

View File

@ -1,5 +1,6 @@
import update from '@framework/lib/immutability' import { Cart, Product } from '@commerce/types'
import { CartFragment, SearchResultFragment } from '@framework/schema' import update from '../lib/immutability'
import { CartFragment, SearchResultFragment } from '../schema'
function normalizeProductOption(productOption: any) { function normalizeProductOption(productOption: any) {
const { const {
@ -89,11 +90,14 @@ export function normalizeSearchResult(item: SearchResultFragment): Product {
export function normalizeCart(order: CartFragment): Cart { export function normalizeCart(order: CartFragment): Cart {
return { return {
id: order.id.toString(), id: order.id.toString(),
createdAt: order.createdAt,
taxesIncluded: true,
lineItemsSubtotalPrice: order.subTotalWithTax / 100,
currency: { code: order.currencyCode }, currency: { code: order.currencyCode },
subTotal: order.subTotalWithTax / 100, subtotalPrice: order.subTotalWithTax / 100,
total: order.totalWithTax / 100, totalPrice: order.totalWithTax / 100,
customerId: order.customer?.id as number, customerId: order.customer?.id,
items: order.lines?.map((l) => ({ lineItems: order.lines?.map((l) => ({
id: l.id, id: l.id,
name: l.productVariant.name, name: l.productVariant.name,
quantity: l.quantity, quantity: l.quantity,
@ -101,7 +105,19 @@ export function normalizeCart(order: CartFragment): Cart {
variantId: l.productVariant.id, variantId: l.productVariant.id,
productId: l.productVariant.productId, productId: l.productVariant.productId,
images: [{ url: l.featuredAsset?.preview + '?preset=thumb' || '' }], images: [{ url: l.featuredAsset?.preview + '?preset=thumb' || '' }],
prices: [], discounts: l.discounts.map((d) => ({ value: d.amount / 100 })),
path: '',
variant: {
id: l.productVariant.id,
name: l.productVariant.name,
sku: l.productVariant.sku,
price: l.discountedLinePriceWithTax / 100,
listPrice: l.linePriceWithTax / 100,
image: {
url: l.featuredAsset?.preview + '?preset=thumb' || '',
},
requiresShipping: true,
},
})), })),
} }
} }

View File

@ -1,7 +1,8 @@
import { Product } from '@commerce/types'
import { getConfig, VendureConfig } from '../api' import { getConfig, VendureConfig } from '../api'
import { searchResultFragment } from '@framework/api/fragments/search-result' import { searchResultFragment } from '../api/fragments/search-result'
import { GetAllProductsQuery } from '@framework/schema' import { GetAllProductsQuery } from '../schema'
import { normalizeSearchResult } from '@framework/lib/normalize' import { normalizeSearchResult } from '../lib/normalize'
export const getAllProductsQuery = /* GraphQL */ ` export const getAllProductsQuery = /* GraphQL */ `
query getAllProducts($input: SearchInput!) { query getAllProducts($input: SearchInput!) {

View File

@ -1,4 +1,6 @@
import { Product } from '@commerce/types'
import { getConfig, VendureConfig } from '../api' import { getConfig, VendureConfig } from '../api'
import { GetProductQuery } from '@framework/schema'
export const getProductQuery = /* GraphQL */ ` export const getProductQuery = /* GraphQL */ `
query getProduct($slug: String!) { query getProduct($slug: String!) {
@ -21,12 +23,20 @@ export const getProductQuery = /* GraphQL */ `
name name
code code
groupId groupId
group {
id
options {
name
}
}
} }
} }
optionGroups { optionGroups {
id
code code
name name
options { options {
id
name name
} }
} }
@ -47,7 +57,7 @@ async function getProduct({
config = getConfig(config) config = getConfig(config)
const locale = config.locale const locale = config.locale
const { data } = await config.fetch(query, { variables }) const { data } = await config.fetch<GetProductQuery>(query, { variables })
const product = data.product const product = data.product
if (product) { if (product) {
@ -57,26 +67,28 @@ async function getProduct({
name: product.name, name: product.name,
description: product.description, description: product.description,
slug: product.slug, slug: product.slug,
images: product.assets.map((a: any) => ({ images: product.assets.map((a) => ({
url: a.preview, url: a.preview,
alt: a.name, alt: a.name,
})), })),
variants: product.variants.map((v: any) => ({ variants: product.variants.map((v) => ({
id: v.id, id: v.id,
options: v.options.map((o: any) => ({ options: v.options.map((o) => ({
id: o.id,
displayName: o.name, displayName: o.name,
values: [], values: o.group.options.map((_o) => ({ label: _o.name })),
})), })),
})), })),
price: { price: {
value: product.variants[0].priceWithTax / 100, value: product.variants[0].priceWithTax / 100,
currencyCode: product.variants[0].currencyCode, currencyCode: product.variants[0].currencyCode,
}, },
options: product.optionGroups.map((og: any) => ({ options: product.optionGroups.map((og) => ({
id: og.id,
displayName: og.name, displayName: og.name,
values: og.options.map((o: any) => ({ label: o.name })), values: og.options.map((o) => ({ label: o.name })),
})), })),
}, } as Product,
} }
} }

View File

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

View File

@ -1,10 +1,9 @@
import type { HookFetcher } from '@commerce/utils/types' import { SWRHook } from '@commerce/utils/types'
import type { SwrOptions } from '@commerce/utils/use-data' import useSearch, { UseSearch } from '@commerce/product/use-search'
import useCommerceSearch from '@commerce/products/use-search' import { Product } from '@commerce/types'
import useResponse from '@commerce/utils/use-response' import { SearchQuery, SearchQueryVariables } from '../schema'
import { searchResultFragment } from '@framework/api/fragments/search-result' import { searchResultFragment } from '../api/fragments/search-result'
import { SearchQuery } from '@framework/schema' import { normalizeSearchResult } from '../lib/normalize'
import { normalizeSearchResult } from '@framework/lib/normalize'
export const searchQuery = /* GraphQL */ ` export const searchQuery = /* GraphQL */ `
query search($input: SearchInput!) { query search($input: SearchInput!) {
@ -18,61 +17,61 @@ export const searchQuery = /* GraphQL */ `
${searchResultFragment} ${searchResultFragment}
` `
export default useSearch as UseSearch<typeof handler>
export type SearchProductsInput = { export type SearchProductsInput = {
search?: string search?: string
categoryId?: number categoryId?: string
brandId?: number brandId?: string
sort?: string sort?: string
} }
export const fetcher: HookFetcher<SearchQuery, SearchProductsInput> = ( export type SearchProductsData = {
options, products: Product[]
{ search, categoryId, brandId, sort }, found: boolean
fetch
) => {
return fetch({
query: searchQuery,
variables: {
input: {
term: search,
collectionId: categoryId,
groupByProduct: true,
},
},
})
} }
export function extendHook( export const handler: SWRHook<
customFetcher: typeof fetcher, SearchProductsData,
swrOptions?: SwrOptions<any, SearchProductsInput> SearchProductsInput,
) { SearchProductsInput
const useSearch = (input: SearchProductsInput = {}) => { > = {
const response = useCommerceSearch<SearchQuery, SearchProductsInput>( fetchOptions: {
{}, query: searchQuery,
[ },
async fetcher({ input, options, fetch }) {
const { categoryId, brandId } = input
const variables: SearchQueryVariables = {
input: {
term: input.search,
collectionId: input.categoryId,
groupByProduct: true,
// TODO: what is the "sort" value?
},
}
const { search } = await fetch<SearchQuery>({
query: searchQuery,
variables,
})
return {
found: search.totalItems > 0,
products: search.items.map((item) => normalizeSearchResult(item)) ?? [],
}
},
useHook: ({ useData }) => (input = {}) => {
return useData({
input: [
['search', input.search], ['search', input.search],
['categoryId', input.categoryId], ['categoryId', input.categoryId],
['brandId', input.brandId], ['brandId', input.brandId],
['sort', input.sort], ['sort', input.sort],
], ],
customFetcher, swrOptions: {
{ revalidateOnFocus: false, ...swrOptions } revalidateOnFocus: false,
) ...input.swrOptions,
return useResponse(response, {
normalizer: (data) => {
return {
found: data?.search.totalItems && data?.search.totalItems > 0,
products:
data?.search.items.map((item) => normalizeSearchResult(item)) ?? [],
}
}, },
}) })
} },
useSearch.extend = extendHook
return useSearch
} }
export default extendHook(fetcher)

View File

@ -0,0 +1,21 @@
import { Provider } from '@commerce'
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 vendureProvider: Provider = {
locale: 'en-us',
cartCookie: '',
fetcher,
cart: { useCart, useAddItem, useUpdateItem, useRemoveItem },
customer: { useCustomer },
products: { useSearch },
auth: { useLogin, useLogout, useSignup },
}

View File

@ -8,7 +8,7 @@ export type MakeMaybe<T, K extends keyof T> = Omit<T, K> &
{ [SubKey in K]: Maybe<T[SubKey]> } { [SubKey in K]: Maybe<T[SubKey]> }
/** All built-in and custom scalars, mapped to their actual values */ /** All built-in and custom scalars, mapped to their actual values */
export type Scalars = { export type Scalars = {
ID: number ID: string
String: string String: string
Boolean: boolean Boolean: boolean
Int: number Int: number
@ -41,6 +41,8 @@ export type Query = {
collection?: Maybe<Collection> collection?: Maybe<Collection>
/** Returns a list of eligible shipping methods based on the current active Order */ /** Returns a list of eligible shipping methods based on the current active Order */
eligibleShippingMethods: Array<ShippingMethodQuote> eligibleShippingMethods: Array<ShippingMethodQuote>
/** Returns a list of payment methods and their eligibility based on the current active Order */
eligiblePaymentMethods: Array<PaymentMethodQuote>
/** Returns information about the current authenticated User */ /** Returns information about the current authenticated User */
me?: Maybe<CurrentUser> me?: Maybe<CurrentUser>
/** Returns the possible next states that the activeOrder can transition to */ /** Returns the possible next states that the activeOrder can transition to */
@ -329,6 +331,7 @@ export type Asset = Node & {
source: Scalars['String'] source: Scalars['String']
preview: Scalars['String'] preview: Scalars['String']
focalPoint?: Maybe<Coordinate> focalPoint?: Maybe<Coordinate>
customFields?: Maybe<Scalars['JSON']>
} }
export type Coordinate = { export type Coordinate = {
@ -376,6 +379,7 @@ export type Channel = Node & {
defaultLanguageCode: LanguageCode defaultLanguageCode: LanguageCode
currencyCode: CurrencyCode currencyCode: CurrencyCode
pricesIncludeTax: Scalars['Boolean'] pricesIncludeTax: Scalars['Boolean']
customFields?: Maybe<Scalars['JSON']>
} }
export type Collection = Node & { export type Collection = Node & {
@ -534,6 +538,7 @@ export enum ErrorCode {
OrderModificationError = 'ORDER_MODIFICATION_ERROR', OrderModificationError = 'ORDER_MODIFICATION_ERROR',
IneligibleShippingMethodError = 'INELIGIBLE_SHIPPING_METHOD_ERROR', IneligibleShippingMethodError = 'INELIGIBLE_SHIPPING_METHOD_ERROR',
OrderPaymentStateError = 'ORDER_PAYMENT_STATE_ERROR', OrderPaymentStateError = 'ORDER_PAYMENT_STATE_ERROR',
IneligiblePaymentMethodError = 'INELIGIBLE_PAYMENT_METHOD_ERROR',
PaymentFailedError = 'PAYMENT_FAILED_ERROR', PaymentFailedError = 'PAYMENT_FAILED_ERROR',
PaymentDeclinedError = 'PAYMENT_DECLINED_ERROR', PaymentDeclinedError = 'PAYMENT_DECLINED_ERROR',
CouponCodeInvalidError = 'COUPON_CODE_INVALID_ERROR', CouponCodeInvalidError = 'COUPON_CODE_INVALID_ERROR',
@ -652,6 +657,8 @@ export type ConfigArgDefinition = {
name: Scalars['String'] name: Scalars['String']
type: Scalars['String'] type: Scalars['String']
list: Scalars['Boolean'] list: Scalars['Boolean']
required: Scalars['Boolean']
defaultValue?: Maybe<Scalars['JSON']>
label?: Maybe<Scalars['String']> label?: Maybe<Scalars['String']>
description?: Maybe<Scalars['String']> description?: Maybe<Scalars['String']>
ui?: Maybe<Scalars['JSON']> ui?: Maybe<Scalars['JSON']>
@ -678,6 +685,7 @@ export type DeletionResponse = {
export type ConfigArgInput = { export type ConfigArgInput = {
name: Scalars['String'] name: Scalars['String']
/** A JSON stringified representation of the actual value */
value: Scalars['String'] value: Scalars['String']
} }
@ -789,6 +797,25 @@ export type Success = {
success: Scalars['Boolean'] success: Scalars['Boolean']
} }
export type ShippingMethodQuote = {
__typename?: 'ShippingMethodQuote'
id: Scalars['ID']
price: Scalars['Int']
priceWithTax: Scalars['Int']
name: Scalars['String']
description: Scalars['String']
/** Any optional metadata returned by the ShippingCalculator in the ShippingCalculationResult */
metadata?: Maybe<Scalars['JSON']>
}
export type PaymentMethodQuote = {
__typename?: 'PaymentMethodQuote'
id: Scalars['ID']
code: Scalars['String']
isEligible: Scalars['Boolean']
eligibilityMessage?: Maybe<Scalars['String']>
}
export type Country = Node & { export type Country = Node & {
__typename?: 'Country' __typename?: 'Country'
id: Scalars['ID'] id: Scalars['ID']
@ -1827,16 +1854,6 @@ export type OrderList = PaginatedList & {
totalItems: Scalars['Int'] totalItems: Scalars['Int']
} }
export type ShippingMethodQuote = {
__typename?: 'ShippingMethodQuote'
id: Scalars['ID']
price: Scalars['Int']
priceWithTax: Scalars['Int']
name: Scalars['String']
description: Scalars['String']
metadata?: Maybe<Scalars['JSON']>
}
export type ShippingLine = { export type ShippingLine = {
__typename?: 'ShippingLine' __typename?: 'ShippingLine'
shippingMethod: ShippingMethod shippingMethod: ShippingMethod
@ -1897,6 +1914,10 @@ export type OrderLine = Node & {
unitPrice: Scalars['Int'] unitPrice: Scalars['Int']
/** The price of a single unit, including tax but excluding discounts */ /** The price of a single unit, including tax but excluding discounts */
unitPriceWithTax: Scalars['Int'] unitPriceWithTax: Scalars['Int']
/** Non-zero if the unitPrice has changed since it was initially added to Order */
unitPriceChangeSinceAdded: Scalars['Int']
/** Non-zero if the unitPriceWithTax has changed since it was initially added to Order */
unitPriceWithTaxChangeSinceAdded: Scalars['Int']
/** /**
* The price of a single unit including discounts, excluding tax. * The price of a single unit including discounts, excluding tax.
* *
@ -2173,6 +2194,7 @@ export type ProductVariant = Node & {
/** @deprecated price now always excludes tax */ /** @deprecated price now always excludes tax */
priceIncludesTax: Scalars['Boolean'] priceIncludesTax: Scalars['Boolean']
priceWithTax: Scalars['Int'] priceWithTax: Scalars['Int']
stockLevel: Scalars['String']
taxRateApplied: TaxRate taxRateApplied: TaxRate
taxCategory: TaxCategory taxCategory: TaxCategory
options: Array<ProductOption> options: Array<ProductOption>
@ -2279,6 +2301,7 @@ export type TaxCategory = Node & {
createdAt: Scalars['DateTime'] createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime'] updatedAt: Scalars['DateTime']
name: Scalars['String'] name: Scalars['String']
isDefault: Scalars['Boolean']
} }
export type TaxRate = Node & { export type TaxRate = Node & {
@ -2337,7 +2360,7 @@ export type OrderModificationError = ErrorResult & {
message: Scalars['String'] message: Scalars['String']
} }
/** Returned when attempting to set a ShippingMethod for which the order is not eligible */ /** Returned when attempting to set a ShippingMethod for which the Order is not eligible */
export type IneligibleShippingMethodError = ErrorResult & { export type IneligibleShippingMethodError = ErrorResult & {
__typename?: 'IneligibleShippingMethodError' __typename?: 'IneligibleShippingMethodError'
errorCode: ErrorCode errorCode: ErrorCode
@ -2351,6 +2374,14 @@ export type OrderPaymentStateError = ErrorResult & {
message: Scalars['String'] message: Scalars['String']
} }
/** Returned when attempting to add a Payment using a PaymentMethod for which the Order is not eligible. */
export type IneligiblePaymentMethodError = ErrorResult & {
__typename?: 'IneligiblePaymentMethodError'
errorCode: ErrorCode
message: Scalars['String']
eligibilityCheckerMessage?: Maybe<Scalars['String']>
}
/** Returned when a Payment fails due to an error. */ /** Returned when a Payment fails due to an error. */
export type PaymentFailedError = ErrorResult & { export type PaymentFailedError = ErrorResult & {
__typename?: 'PaymentFailedError' __typename?: 'PaymentFailedError'
@ -2546,6 +2577,7 @@ export type ApplyCouponCodeResult =
export type AddPaymentToOrderResult = export type AddPaymentToOrderResult =
| Order | Order
| OrderPaymentStateError | OrderPaymentStateError
| IneligiblePaymentMethodError
| PaymentFailedError | PaymentFailedError
| PaymentDeclinedError | PaymentDeclinedError
| OrderStateTransitionError | OrderStateTransitionError
@ -2704,6 +2736,7 @@ export type ProductVariantFilterParameter = {
currencyCode?: Maybe<StringOperators> currencyCode?: Maybe<StringOperators>
priceIncludesTax?: Maybe<BooleanOperators> priceIncludesTax?: Maybe<BooleanOperators>
priceWithTax?: Maybe<NumberOperators> priceWithTax?: Maybe<NumberOperators>
stockLevel?: Maybe<StringOperators>
} }
export type ProductVariantSortParameter = { export type ProductVariantSortParameter = {
@ -2715,6 +2748,7 @@ export type ProductVariantSortParameter = {
name?: Maybe<SortOrder> name?: Maybe<SortOrder>
price?: Maybe<SortOrder> price?: Maybe<SortOrder>
priceWithTax?: Maybe<SortOrder> priceWithTax?: Maybe<SortOrder>
stockLevel?: Maybe<SortOrder>
} }
export type CustomerFilterParameter = { export type CustomerFilterParameter = {
@ -2800,6 +2834,7 @@ export type CartFragment = { __typename?: 'Order' } & Pick<
Order, Order,
| 'id' | 'id'
| 'code' | 'code'
| 'createdAt'
| 'totalQuantity' | 'totalQuantity'
| 'subTotal' | 'subTotal'
| 'subTotalWithTax' | 'subTotalWithTax'
@ -2809,13 +2844,28 @@ export type CartFragment = { __typename?: 'Order' } & Pick<
> & { > & {
customer?: Maybe<{ __typename?: 'Customer' } & Pick<Customer, 'id'>> customer?: Maybe<{ __typename?: 'Customer' } & Pick<Customer, 'id'>>
lines: Array< lines: Array<
{ __typename?: 'OrderLine' } & Pick<OrderLine, 'id' | 'quantity'> & { { __typename?: 'OrderLine' } & Pick<
OrderLine,
'id' | 'quantity' | 'linePriceWithTax' | 'discountedLinePriceWithTax'
> & {
featuredAsset?: Maybe< featuredAsset?: Maybe<
{ __typename?: 'Asset' } & Pick<Asset, 'id' | 'preview'> { __typename?: 'Asset' } & Pick<Asset, 'id' | 'preview'>
> >
discounts: Array<
{ __typename?: 'Adjustment' } & Pick<
Adjustment,
'description' | 'amount'
>
>
productVariant: { __typename?: 'ProductVariant' } & Pick< productVariant: { __typename?: 'ProductVariant' } & Pick<
ProductVariant, ProductVariant,
'id' | 'name' | 'productId' | 'id'
| 'name'
| 'sku'
| 'price'
| 'priceWithTax'
| 'stockLevel'
| 'productId'
> & { product: { __typename?: 'Product' } & Pick<Product, 'slug'> } > & { product: { __typename?: 'Product' } & Pick<Product, 'slug'> }
} }
> >
@ -2836,28 +2886,6 @@ export type SearchResultFragment = { __typename?: 'SearchResult' } & Pick<
| ({ __typename?: 'SinglePrice' } & Pick<SinglePrice, 'value'>) | ({ __typename?: 'SinglePrice' } & Pick<SinglePrice, 'value'>)
} }
export type LoginServerMutationVariables = Exact<{
email: Scalars['String']
password: Scalars['String']
}>
export type LoginServerMutation = { __typename?: 'Mutation' } & {
login:
| ({ __typename: 'CurrentUser' } & Pick<CurrentUser, 'id'>)
| ({ __typename: 'InvalidCredentialsError' } & Pick<
InvalidCredentialsError,
'errorCode' | 'message'
>)
| ({ __typename: 'NotVerifiedError' } & Pick<
NotVerifiedError,
'errorCode' | 'message'
>)
| ({ __typename: 'NativeAuthStrategyError' } & Pick<
NativeAuthStrategyError,
'errorCode' | 'message'
>)
}
export type LoginMutationVariables = Exact<{ export type LoginMutationVariables = Exact<{
username: Scalars['String'] username: Scalars['String']
password: Scalars['String'] password: Scalars['String']
@ -3049,17 +3077,32 @@ export type GetProductQuery = { __typename?: 'Query' } & {
{ __typename?: 'ProductOption' } & Pick< { __typename?: 'ProductOption' } & Pick<
ProductOption, ProductOption,
'id' | 'name' | 'code' | 'groupId' 'id' | 'name' | 'code' | 'groupId'
> & {
group: { __typename?: 'ProductOptionGroup' } & Pick<
ProductOptionGroup,
'id'
> & {
options: Array<
{ __typename?: 'ProductOption' } & Pick<
ProductOption,
'name'
> >
> >
} }
}
>
}
> >
optionGroups: Array< optionGroups: Array<
{ __typename?: 'ProductOptionGroup' } & Pick< { __typename?: 'ProductOptionGroup' } & Pick<
ProductOptionGroup, ProductOptionGroup,
'code' | 'name' 'id' | 'code' | 'name'
> & { > & {
options: Array< options: Array<
{ __typename?: 'ProductOption' } & Pick<ProductOption, 'name'> { __typename?: 'ProductOption' } & Pick<
ProductOption,
'id' | 'name'
>
> >
} }
> >

View File

@ -36,6 +36,11 @@ type Query {
""" """
eligibleShippingMethods: [ShippingMethodQuote!]! eligibleShippingMethods: [ShippingMethodQuote!]!
"""
Returns a list of payment methods and their eligibility based on the current active Order
"""
eligiblePaymentMethods: [PaymentMethodQuote!]!
""" """
Returns information about the current authenticated User Returns information about the current authenticated User
""" """
@ -289,6 +294,7 @@ type Asset implements Node {
source: String! source: String!
preview: String! preview: String!
focalPoint: Coordinate focalPoint: Coordinate
customFields: JSON
} }
type Coordinate { type Coordinate {
@ -331,6 +337,7 @@ type Channel implements Node {
defaultLanguageCode: LanguageCode! defaultLanguageCode: LanguageCode!
currencyCode: CurrencyCode! currencyCode: CurrencyCode!
pricesIncludeTax: Boolean! pricesIncludeTax: Boolean!
customFields: JSON
} }
type Collection implements Node { type Collection implements Node {
@ -568,6 +575,7 @@ enum ErrorCode {
ORDER_MODIFICATION_ERROR ORDER_MODIFICATION_ERROR
INELIGIBLE_SHIPPING_METHOD_ERROR INELIGIBLE_SHIPPING_METHOD_ERROR
ORDER_PAYMENT_STATE_ERROR ORDER_PAYMENT_STATE_ERROR
INELIGIBLE_PAYMENT_METHOD_ERROR
PAYMENT_FAILED_ERROR PAYMENT_FAILED_ERROR
PAYMENT_DECLINED_ERROR PAYMENT_DECLINED_ERROR
COUPON_CODE_INVALID_ERROR COUPON_CODE_INVALID_ERROR
@ -704,6 +712,8 @@ type ConfigArgDefinition {
name: String! name: String!
type: String! type: String!
list: Boolean! list: Boolean!
required: Boolean!
defaultValue: JSON
label: String label: String
description: String description: String
ui: JSON ui: JSON
@ -727,6 +737,10 @@ type DeletionResponse {
input ConfigArgInput { input ConfigArgInput {
name: String! name: String!
"""
A JSON stringified representation of the actual value
"""
value: String! value: String!
} }
@ -839,6 +853,26 @@ type Success {
success: Boolean! success: Boolean!
} }
type ShippingMethodQuote {
id: ID!
price: Int!
priceWithTax: Int!
name: String!
description: String!
"""
Any optional metadata returned by the ShippingCalculator in the ShippingCalculationResult
"""
metadata: JSON
}
type PaymentMethodQuote {
id: ID!
code: String!
isEligible: Boolean!
eligibilityMessage: String
}
type Country implements Node { type Country implements Node {
id: ID! id: ID!
createdAt: DateTime! createdAt: DateTime!
@ -2817,15 +2851,6 @@ type OrderList implements PaginatedList {
totalItems: Int! totalItems: Int!
} }
type ShippingMethodQuote {
id: ID!
price: Int!
priceWithTax: Int!
name: String!
description: String!
metadata: JSON
}
type ShippingLine { type ShippingLine {
shippingMethod: ShippingMethod! shippingMethod: ShippingMethod!
price: Int! price: Int!
@ -2904,6 +2929,16 @@ type OrderLine implements Node {
""" """
unitPriceWithTax: Int! unitPriceWithTax: Int!
"""
Non-zero if the unitPrice has changed since it was initially added to Order
"""
unitPriceChangeSinceAdded: Int!
"""
Non-zero if the unitPriceWithTax has changed since it was initially added to Order
"""
unitPriceWithTaxChangeSinceAdded: Int!
""" """
The price of a single unit including discounts, excluding tax. The price of a single unit including discounts, excluding tax.
@ -3197,6 +3232,7 @@ type ProductVariant implements Node {
priceIncludesTax: Boolean! priceIncludesTax: Boolean!
@deprecated(reason: "price now always excludes tax") @deprecated(reason: "price now always excludes tax")
priceWithTax: Int! priceWithTax: Int!
stockLevel: String!
taxRateApplied: TaxRate! taxRateApplied: TaxRate!
taxCategory: TaxCategory! taxCategory: TaxCategory!
options: [ProductOption!]! options: [ProductOption!]!
@ -3292,6 +3328,7 @@ type TaxCategory implements Node {
createdAt: DateTime! createdAt: DateTime!
updatedAt: DateTime! updatedAt: DateTime!
name: String! name: String!
isDefault: Boolean!
} }
type TaxRate implements Node { type TaxRate implements Node {
@ -3347,7 +3384,7 @@ type OrderModificationError implements ErrorResult {
} }
""" """
Returned when attempting to set a ShippingMethod for which the order is not eligible Returned when attempting to set a ShippingMethod for which the Order is not eligible
""" """
type IneligibleShippingMethodError implements ErrorResult { type IneligibleShippingMethodError implements ErrorResult {
errorCode: ErrorCode! errorCode: ErrorCode!
@ -3362,6 +3399,15 @@ type OrderPaymentStateError implements ErrorResult {
message: String! message: String!
} }
"""
Returned when attempting to add a Payment using a PaymentMethod for which the Order is not eligible.
"""
type IneligiblePaymentMethodError implements ErrorResult {
errorCode: ErrorCode!
message: String!
eligibilityCheckerMessage: String
}
""" """
Returned when a Payment fails due to an error. Returned when a Payment fails due to an error.
""" """
@ -3562,6 +3608,7 @@ union ApplyCouponCodeResult =
union AddPaymentToOrderResult = union AddPaymentToOrderResult =
Order Order
| OrderPaymentStateError | OrderPaymentStateError
| IneligiblePaymentMethodError
| PaymentFailedError | PaymentFailedError
| PaymentDeclinedError | PaymentDeclinedError
| OrderStateTransitionError | OrderStateTransitionError
@ -3718,6 +3765,7 @@ input ProductVariantFilterParameter {
currencyCode: StringOperators currencyCode: StringOperators
priceIncludesTax: BooleanOperators priceIncludesTax: BooleanOperators
priceWithTax: NumberOperators priceWithTax: NumberOperators
stockLevel: StringOperators
} }
input ProductVariantSortParameter { input ProductVariantSortParameter {
@ -3729,6 +3777,7 @@ input ProductVariantSortParameter {
name: SortOrder name: SortOrder
price: SortOrder price: SortOrder
priceWithTax: SortOrder priceWithTax: SortOrder
stockLevel: SortOrder
} }
input CustomerFilterParameter { input CustomerFilterParameter {

View File

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

View File

@ -1,57 +1,13 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { HookFetcher } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useWishlistAddItem from '@commerce/wishlist/use-add-item'
import type { ItemBody, AddItemBody } from '../api/wishlist'
import useCustomer from '../customer/use-customer'
import useWishlist, { UseWishlistOptions, Wishlist } from './use-wishlist'
const defaultOpts = { export function emptyHook() {
url: '/api/bigcommerce/wishlist', const useEmptyHook = async (options = {}) => {
method: 'POST', return useCallback(async function () {
} return Promise.resolve()
}, [])
export type AddItemInput = ItemBody
export const fetcher: HookFetcher<Wishlist, AddItemBody> = (
options,
{ item },
fetch
) => {
// TODO: add validations before doing the fetch
return fetch({
...defaultOpts,
...options,
body: { item },
})
}
export function extendHook(customFetcher: typeof fetcher) {
const useAddItem = (opts?: UseWishlistOptions) => {
const { data: customer } = useCustomer()
const { revalidate } = useWishlist(opts)
const fn = useWishlistAddItem(defaultOpts, customFetcher)
return useCallback(
async function addItem(input: AddItemInput) {
if (!customer) {
// A signed customer is required in order to have a wishlist
throw new CommerceError({
message: 'Signed customer not found',
})
} }
const data = await fn({ item: input }) return useEmptyHook
await revalidate()
return data
},
[fn, revalidate, customer]
)
}
useAddItem.extend = extendHook
return useAddItem
} }
export default extendHook(fetcher) export default emptyHook

View File

@ -1,61 +1,17 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { HookFetcher } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useWishlistRemoveItem from '@commerce/wishlist/use-remove-item'
import type { RemoveItemBody } from '../api/wishlist'
import useCustomer from '../customer/use-customer'
import useWishlist, { UseWishlistOptions, Wishlist } from './use-wishlist'
const defaultOpts = { type Options = {
url: '/api/bigcommerce/wishlist', includeProducts?: boolean
method: 'DELETE',
} }
export type RemoveItemInput = { export function emptyHook(options?: Options) {
id: string | number const useEmptyHook = async ({ id }: { id: string | number }) => {
} return useCallback(async function () {
return Promise.resolve()
export const fetcher: HookFetcher<Wishlist | null, RemoveItemBody> = ( }, [])
options,
{ itemId },
fetch
) => {
return fetch({
...defaultOpts,
...options,
body: { itemId },
})
}
export function extendHook(customFetcher: typeof fetcher) {
const useRemoveItem = (opts?: UseWishlistOptions) => {
const { data: customer } = useCustomer()
const { revalidate } = useWishlist(opts)
const fn = useWishlistRemoveItem<Wishlist | null, RemoveItemBody>(
defaultOpts,
customFetcher
)
return useCallback(
async function removeItem(input: RemoveItemInput) {
if (!customer) {
// A signed customer is required in order to have a wishlist
throw new CommerceError({
message: 'Signed customer not found',
})
} }
const data = await fn({ itemId: String(input.id) }) return useEmptyHook
await revalidate()
return data
},
[fn, revalidate, customer]
)
}
useRemoveItem.extend = extendHook
return useRemoveItem
} }
export default extendHook(fetcher) export default emptyHook

View File

@ -1,11 +0,0 @@
import useAddItem from './use-add-item'
import useRemoveItem from './use-remove-item'
// This hook is probably not going to be used, but it's here
// to show how a commerce should be structuring it
export default function useWishlistActions() {
const addItem = useAddItem()
const removeItem = useRemoveItem()
return { addItem, removeItem }
}

View File

@ -1,17 +1,22 @@
// TODO: replace this hook and other wishlist hooks with a handler, or remove them if
// Shopify doesn't have a wishlist
import { HookFetcher } from '@commerce/utils/types' import { HookFetcher } from '@commerce/utils/types'
import { SwrOptions } from '@commerce/utils/use-data' import { Product } from '../schema'
import defineProperty from '@commerce/utils/define-property'
import useCommerceWishlist from '@commerce/wishlist/use-wishlist'
import type { Wishlist } from '../api/wishlist'
import useCustomer from '../customer/use-customer'
const defaultOpts = { const defaultOpts = {}
url: '/api/bigcommerce/wishlist',
method: 'GET', export type Wishlist = {
items: [
{
product_id: number
variant_id: number
id: number
product: Product
}
]
} }
export type { Wishlist }
export interface UseWishlistOptions { export interface UseWishlistOptions {
includeProducts?: boolean includeProducts?: boolean
} }
@ -20,55 +25,17 @@ export interface UseWishlistInput extends UseWishlistOptions {
customerId?: number customerId?: number
} }
export const fetcher: HookFetcher<Wishlist | null, UseWishlistInput> = ( export const fetcher: HookFetcher<Wishlist | null, UseWishlistInput> = () => {
options, return null
{ customerId, includeProducts },
fetch
) => {
if (!customerId) return null
// Use a dummy base as we only care about the relative path
const url = new URL(options?.url ?? defaultOpts.url, 'http://a')
if (includeProducts) url.searchParams.set('products', '1')
return fetch({
url: url.pathname + url.search,
method: options?.method ?? defaultOpts.method,
})
} }
export function extendHook( export function extendHook(
customFetcher: typeof fetcher, customFetcher: typeof fetcher,
swrOptions?: SwrOptions<Wishlist | null, UseWishlistInput> // swrOptions?: SwrOptions<Wishlist | null, UseWishlistInput>
swrOptions?: any
) { ) {
const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => { const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => {
const { data: customer } = useCustomer() return { data: null }
const response = useCommerceWishlist(
defaultOpts,
[
['customerId', customer?.id],
['includeProducts', includeProducts],
],
customFetcher,
{
revalidateOnFocus: false,
...swrOptions,
}
)
// Uses a getter to only calculate the prop when required
// response.data is also a getter and it's better to not trigger it early
if (!('isEmpty' in response)) {
defineProperty(response, 'isEmpty', {
get() {
return (response.data?.items?.length || 0) <= 0
},
set: (x) => x,
})
}
return response
} }
useWishlist.extend = extendHook useWishlist.extend = extendHook