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
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:
```shell
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`
4. Change the paths in [tsconfig.json](../../tsconfig.json) to point to the Vendure hooks:
```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/vendure/*"],
+ "@framework": ["framework/vendure"]
}
```
5. Set the Vendure Shop API URL in your `.env.local` file:
```sh
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
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.
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.
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

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

View File

@ -2,6 +2,7 @@ export const cartFragment = /* GraphQL */ `
fragment Cart on Order {
id
code
createdAt
totalQuantity
subTotal
subTotalWithTax
@ -14,13 +15,23 @@ export const cartFragment = /* GraphQL */ `
lines {
id
quantity
linePriceWithTax
discountedLinePriceWithTax
featuredAsset {
id
preview
}
discounts {
description
amount
}
productVariant {
id
name
sku
price
priceWithTax
stockLevel
product {
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 type { HookFetcher } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useCommerceLogin from '@commerce/use-login'
import { MutationHook } from '@commerce/utils/types'
import useLogin, { UseLogin } from '@commerce/auth/use-login'
import { CommerceError, ValidationError } from '@commerce/utils/errors'
import useCustomer from '../customer/use-customer'
import {
ErrorResult,
LoginMutation,
LoginMutationVariables,
} from '@framework/schema'
import { LoginMutation, LoginMutationVariables } from '../schema'
export const loginMutation = /* GraphQL */ `
mutation login($username: String!, $password: String!) {
@ -24,53 +20,45 @@ export const loginMutation = /* GraphQL */ `
}
`
export const fetcher: HookFetcher<LoginMutation, LoginMutationVariables> = (
options,
{ username, password },
fetch
) => {
if (!(username && password)) {
throw new CommerceError({
message: 'An email address and password are required to login',
})
}
export default useLogin as UseLogin<typeof handler>
return fetch({
...options,
export const handler: MutationHook<null, {}, any> = {
fetchOptions: {
query: loginMutation,
variables: { username, password },
},
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message: 'A email and password are required to login',
})
}
export function extendHook(customFetcher: typeof fetcher) {
const useLogin = () => {
const variables: LoginMutationVariables = {
username: email,
password,
}
const { login } = await fetch<LoginMutation>({
...options,
variables,
})
if (login.__typename !== 'CurrentUser') {
throw new ValidationError(login)
}
return null
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
const fn = useCommerceLogin<LoginMutation, LoginMutationVariables>(
{},
customFetcher
)
return useCallback(
async function login(input: { email: string; password: string }) {
const data = await fn({
username: input.email,
password: input.password,
})
if (data.login.__typename !== 'CurrentUser') {
throw new CommerceError({
message: (data.login as ErrorResult).message,
})
}
async function login(input) {
const data = await fetch({ input })
await revalidate()
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 type { HookFetcher } from '@commerce/utils/types'
import useCommerceLogout from '@commerce/use-logout'
import { MutationHook } from '@commerce/utils/types'
import useLogout, { UseLogout } from '@commerce/auth/use-logout'
import useCustomer from '../customer/use-customer'
import { LogoutMutation } from '@framework/schema'
import { LogoutMutation } from '../schema'
export const logoutMutation = /* GraphQL */ `
mutation logout {
@ -12,31 +12,28 @@ export const logoutMutation = /* GraphQL */ `
}
`
export const fetcher: HookFetcher<LogoutMutation> = (options, _, fetch) => {
return fetch({
...options,
query: logoutMutation,
})
}
export default useLogout as UseLogout<typeof handler>
export function extendHook(customFetcher: typeof fetcher) {
const useLogout = () => {
export const handler: MutationHook<null> = {
fetchOptions: {
query: logoutMutation,
},
async fetcher({ options, fetch }) {
await fetch<LogoutMutation>({
...options,
})
return null
},
useHook: ({ fetch }) => () => {
const { mutate } = useCustomer()
const fn = useCommerceLogout<LogoutMutation>({}, customFetcher)
return useCallback(
async function logout() {
const data = await fn(null)
await mutate(null as any, false)
const data = await fetch()
await mutate(null, false)
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 type { HookFetcher } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useCommerceSignup from '@commerce/use-signup'
import { MutationHook } from '@commerce/utils/types'
import { CommerceError, ValidationError } from '@commerce/utils/errors'
import useSignup, { UseSignup } from '@commerce/auth/use-signup'
import useCustomer from '../customer/use-customer'
import {
ErrorResult,
RegisterCustomerInput,
SignupMutation,
SignupMutationVariables,
} from '@framework/schema'
} from '../schema'
export const signupMutation = /* GraphQL */ `
mutation signup($input: RegisterCustomerInput!) {
@ -24,66 +24,57 @@ export const signupMutation = /* GraphQL */ `
}
`
export type SignupInput = {
email: string
firstName: string
lastName: string
password: string
}
export default useSignup as UseSignup<typeof handler>
export const fetcher: HookFetcher<SignupMutation, SignupMutationVariables> = (
export const handler: MutationHook<
null,
{},
RegisterCustomerInput,
RegisterCustomerInput
> = {
fetchOptions: {
query: signupMutation,
},
async fetcher({
input: { firstName, lastName, emailAddress, password },
options,
{ input },
fetch
) => {
const { firstName, lastName, emailAddress, password } = input
fetch,
}) {
if (!(firstName && lastName && emailAddress && password)) {
throw new CommerceError({
message:
'A first name, last name, email and password are required to signup',
})
}
return fetch({
const variables: SignupMutationVariables = {
input: {
firstName,
lastName,
emailAddress,
password,
},
}
const { registerCustomerAccount } = await fetch<SignupMutation>({
...options,
query: signupMutation,
variables: { input },
variables,
})
if (registerCustomerAccount.__typename !== 'Success') {
throw new ValidationError(registerCustomerAccount)
}
export function extendHook(customFetcher: typeof fetcher) {
const useSignup = () => {
return null
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
const fn = useCommerceSignup<SignupMutation, SignupMutationVariables>(
{},
customFetcher
)
return useCallback(
async function signup(input: SignupInput) {
const { registerCustomerAccount } = await fn({
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,
})
}
async function signup(input) {
const data = await fetch({ input })
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 { HookFetcher } 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 { MutationHook } from '@commerce/utils/types'
import { useCallback } from 'react'
import useCart from './use-cart'
import { cartFragment } from '../api/fragments/cart'
import {
AddItemToOrderMutation,
AddItemToOrderMutationVariables,
ErrorResult,
} from '@framework/schema'
import { AddItemToOrderMutation } from '../schema'
import { normalizeCart } from '../lib/normalize'
export const addItemToOrderMutation = /* GraphQL */ `
mutation addItemToOrder($variantId: ID!, $quantity: Int!) {
@ -25,56 +22,45 @@ export const addItemToOrderMutation = /* GraphQL */ `
${cartFragment}
`
export type AddItemInput = {
productId?: number
variantId: number
quantity?: number
}
export default useAddItem as UseAddItem<typeof handler>
export const fetcher: HookFetcher<
AddItemToOrderMutation,
AddItemToOrderMutationVariables
> = (options, { variantId, quantity }, fetch) => {
if (quantity && (!Number.isInteger(quantity) || quantity! < 1)) {
export const handler: MutationHook<Cart, {}, CartItemBody> = {
fetchOptions: {
query: addItemToOrderMutation,
},
async fetcher({ input, options, fetch }) {
if (
input.quantity &&
(!Number.isInteger(input.quantity) || input.quantity! < 1)
) {
throw new CommerceError({
message: 'The item quantity has to be a valid integer greater than 0',
})
}
return fetch({
const { addItemToOrder } = await fetch<AddItemToOrderMutation>({
...options,
query: addItemToOrderMutation,
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({
variables: {
quantity: input.quantity || 1,
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()
return useCallback(
async function addItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
}
useAddItem.extend = extendHook
return useAddItem
}
export default extendHook(fetcher)

View File

@ -1,9 +1,10 @@
import { HookFetcher } from '@commerce/utils/types'
import useData, { SwrOptions } from '@commerce/utils/use-data'
import useResponse from '@commerce/utils/use-response'
import { Cart } from '@commerce/types'
import { SWRHook } from '@commerce/utils/types'
import useCart, { FetchCartInput, UseCart } from '@commerce/cart/use-cart'
import { cartFragment } from '../api/fragments/cart'
import { CartFragment } from '../schema'
import { normalizeCart } from '@framework/lib/normalize'
import { ActiveOrderQuery, CartFragment } from '../schema'
import { normalizeCart } from '../lib/normalize'
import { useMemo } from 'react'
export const getCartQuery = /* GraphQL */ `
query activeOrder {
@ -14,10 +15,6 @@ export const getCartQuery = /* GraphQL */ `
${cartFragment}
`
export const fetcher: HookFetcher<any, null> = (options, input, fetch) => {
return fetch({ ...options, query: getCartQuery })
}
export type CartResult = {
activeOrder?: CartFragment
addItemToOrder?: CartFragment
@ -25,42 +22,37 @@ export type CartResult = {
removeOrderLine?: CartFragment
}
export function extendHook(
customFetcher: typeof fetcher,
swrOptions?: SwrOptions<any | null>
) {
const useCart = () => {
const response = useData<CartResult>(
{ query: getCartQuery },
[],
customFetcher,
swrOptions
)
const res = useResponse(response, {
normalizer: (data) => {
const order =
data?.activeOrder ||
data?.addItemToOrder ||
data?.adjustOrderLine ||
data?.removeOrderLine
return order ? normalizeCart(order) : null
export default useCart as UseCart<typeof handler>
export const handler: SWRHook<
Cart | null,
{},
FetchCartInput,
{ isEmpty?: boolean }
> = {
fetchOptions: {
query: getCartQuery,
},
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: {
get() {
return response.data?.activeOrder?.totalQuantity === 0
return (response.data?.lineItems.length ?? 0) <= 0
},
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 { HookFetcher } from '@commerce/utils/types'
import useCartRemoveItem from '@commerce/cart/use-remove-item'
import { HookFetcherContext, MutationHookContext } from '@commerce/utils/types'
import useRemoveItem, { UseRemoveItem } from '@commerce/cart/use-remove-item'
import { CommerceError } from '@commerce/utils/errors'
import useCart from './use-cart'
import { cartFragment } from '@framework/api/fragments/cart'
import { cartFragment } from '../api/fragments/cart'
import {
ErrorResult,
RemoveOrderLineMutation,
RemoveOrderLineMutationVariables,
} from '@framework/schema'
import { CommerceError } from '@commerce/utils/errors'
} from '../schema'
import { Cart, LineItem, RemoveCartItemBody } from '@commerce/types'
import { normalizeCart } from '../lib/normalize'
export const removeOrderLineMutation = /* GraphQL */ `
mutation removeOrderLine($orderLineId: ID!) {
@ -24,44 +25,38 @@ export const removeOrderLineMutation = /* GraphQL */ `
${cartFragment}
`
export const fetcher: HookFetcher<
RemoveOrderLineMutation,
RemoveOrderLineMutationVariables
> = (options, { orderLineId }, fetch) => {
return fetch({
...options,
query: removeOrderLineMutation,
variables: { orderLineId },
})
}
export default useRemoveItem as UseRemoveItem<typeof handler>
export function extendHook(customFetcher: typeof fetcher) {
const useRemoveItem = (item?: any) => {
export const handler = {
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 fn = useCartRemoveItem<
RemoveOrderLineMutation,
RemoveOrderLineMutationVariables
>({}, customFetcher)
return useCallback(
async function removeItem(input: any) {
const { removeOrderLine } = await fn({ orderLineId: input.id })
if (removeOrderLine.__typename === 'Order') {
await mutate({ removeOrderLine }, false)
} else {
throw new CommerceError({
message: (removeOrderLine as ErrorResult).message,
})
}
return { removeOrderLine }
async function removeItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fn, mutate]
[fetch, mutate]
)
},
}
useRemoveItem.extend = extendHook
return useRemoveItem
}
export default extendHook(fetcher)

View File

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

View File

@ -14,7 +14,7 @@
"plugins": ["typescript", "typescript-operations"],
"config": {
"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 { GetCollectionsQuery } from '@framework/schema'
import { arrayToTree } from '@framework/lib/array-to-tree'
import { GetCollectionsQuery } from '../schema'
import { arrayToTree } from '../lib/array-to-tree'
export const getCollectionsQuery = /* GraphQL */ `
query getCollections {

View File

@ -1,8 +1,7 @@
import type { HookFetcher } from '@commerce/utils/types'
import type { SwrOptions } from '@commerce/utils/use-data'
import useCommerceCustomer from '@commerce/use-customer'
import { ActiveCustomerQuery } from '@framework/schema'
import useResponse from '@commerce/utils/use-response'
import { SWRHook } from '@commerce/utils/types'
import useCustomer, { UseCustomer } from '@commerce/customer/use-customer'
import { Customer } from '@commerce/types'
import { ActiveCustomerQuery } from '../schema'
export const activeCustomerQuery = /* GraphQL */ `
query activeCustomer {
@ -15,40 +14,30 @@ export const activeCustomerQuery = /* GraphQL */ `
}
`
export const fetcher: HookFetcher<ActiveCustomerQuery> = async (
options,
_,
fetch
) => {
return await fetch<ActiveCustomerQuery>({ query: activeCustomerQuery })
}
export default useCustomer as UseCustomer<typeof handler>
export function extendHook(
customFetcher: typeof fetcher,
swrOptions?: SwrOptions<ActiveCustomerQuery>
) {
const useCustomer = () => {
const response = useCommerceCustomer({}, [], customFetcher, {
revalidateOnFocus: false,
...swrOptions,
export const handler: SWRHook<Customer | null> = {
fetchOptions: {
query: activeCustomerQuery,
},
async fetcher({ options, fetch }) {
const { activeCustomer } = await fetch<ActiveCustomerQuery>({
...options,
})
return useResponse(response, {
normalizer: (data) => {
return data?.activeCustomer
? {
firstName: data?.activeCustomer?.firstName ?? '',
lastName: data?.activeCustomer?.lastName ?? '',
email: data?.activeCustomer?.emailAddress ?? '',
}
return activeCustomer
? ({
firstName: activeCustomer.firstName ?? '',
lastName: activeCustomer.lastName ?? '',
email: activeCustomer.emailAddress ?? '',
} as any)
: 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 { ReactNode } from 'react'
import { CommerceConfig, CommerceProvider as CoreCommerceProvider, useCommerce as useCoreCommerce } from '@commerce'
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 })
}
import {
CommerceConfig,
CommerceProvider as CoreCommerceProvider,
useCommerce as useCoreCommerce,
} from '@commerce'
import { vendureProvider } from './provider'
export const vendureConfig: CommerceConfig = {
locale: 'en-us',
cartCookie: 'bc_cartId',
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)
},
cartCookie: 'session',
}
export type VendureConfig = Partial<CommerceConfig>
@ -57,7 +21,10 @@ export type VendureProps = {
export function CommerceProvider({ children, ...config }: VendureProps) {
return (
<CoreCommerceProvider config={{ ...vendureConfig, ...config }}>
<CoreCommerceProvider
provider={vendureProvider}
config={{ ...vendureConfig, ...config }}
>
{children}
</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 & {
children: Array<TreeNode<T>>
expanded: boolean
}
export type RootNode<T extends HasParent> = {
id?: number
id?: string
children: Array<TreeNode<T>>
}
@ -54,8 +54,8 @@ export function arrayToTree<T extends HasParent>(
*/
function treeToMap<T extends HasParent>(
tree?: RootNode<T>
): Map<number, TreeNode<T>> {
const nodeMap = new Map<number, TreeNode<T>>()
): Map<string, TreeNode<T>> {
const nodeMap = new Map<string, TreeNode<T>>()
function visit(node: TreeNode<T>) {
nodeMap.set(node.id, node)
node.children.forEach(visit)

View File

@ -1,5 +1,6 @@
import update from '@framework/lib/immutability'
import { CartFragment, SearchResultFragment } from '@framework/schema'
import { Cart, Product } from '@commerce/types'
import update from '../lib/immutability'
import { CartFragment, SearchResultFragment } from '../schema'
function normalizeProductOption(productOption: any) {
const {
@ -89,11 +90,14 @@ export function normalizeSearchResult(item: SearchResultFragment): Product {
export function normalizeCart(order: CartFragment): Cart {
return {
id: order.id.toString(),
createdAt: order.createdAt,
taxesIncluded: true,
lineItemsSubtotalPrice: order.subTotalWithTax / 100,
currency: { code: order.currencyCode },
subTotal: order.subTotalWithTax / 100,
total: order.totalWithTax / 100,
customerId: order.customer?.id as number,
items: order.lines?.map((l) => ({
subtotalPrice: order.subTotalWithTax / 100,
totalPrice: order.totalWithTax / 100,
customerId: order.customer?.id,
lineItems: order.lines?.map((l) => ({
id: l.id,
name: l.productVariant.name,
quantity: l.quantity,
@ -101,7 +105,19 @@ export function normalizeCart(order: CartFragment): Cart {
variantId: l.productVariant.id,
productId: l.productVariant.productId,
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 { searchResultFragment } from '@framework/api/fragments/search-result'
import { GetAllProductsQuery } from '@framework/schema'
import { normalizeSearchResult } from '@framework/lib/normalize'
import { searchResultFragment } from '../api/fragments/search-result'
import { GetAllProductsQuery } from '../schema'
import { normalizeSearchResult } from '../lib/normalize'
export const getAllProductsQuery = /* GraphQL */ `
query getAllProducts($input: SearchInput!) {

View File

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

View File

@ -1,10 +1,9 @@
import type { HookFetcher } from '@commerce/utils/types'
import type { SwrOptions } from '@commerce/utils/use-data'
import useCommerceSearch from '@commerce/products/use-search'
import useResponse from '@commerce/utils/use-response'
import { searchResultFragment } from '@framework/api/fragments/search-result'
import { SearchQuery } from '@framework/schema'
import { normalizeSearchResult } from '@framework/lib/normalize'
import { SWRHook } from '@commerce/utils/types'
import useSearch, { UseSearch } from '@commerce/product/use-search'
import { Product } from '@commerce/types'
import { SearchQuery, SearchQueryVariables } from '../schema'
import { searchResultFragment } from '../api/fragments/search-result'
import { normalizeSearchResult } from '../lib/normalize'
export const searchQuery = /* GraphQL */ `
query search($input: SearchInput!) {
@ -18,61 +17,61 @@ export const searchQuery = /* GraphQL */ `
${searchResultFragment}
`
export default useSearch as UseSearch<typeof handler>
export type SearchProductsInput = {
search?: string
categoryId?: number
brandId?: number
categoryId?: string
brandId?: string
sort?: string
}
export const fetcher: HookFetcher<SearchQuery, SearchProductsInput> = (
options,
{ search, categoryId, brandId, sort },
fetch
) => {
return fetch({
query: searchQuery,
variables: {
input: {
term: search,
collectionId: categoryId,
groupByProduct: true,
},
},
})
export type SearchProductsData = {
products: Product[]
found: boolean
}
export function extendHook(
customFetcher: typeof fetcher,
swrOptions?: SwrOptions<any, SearchProductsInput>
) {
const useSearch = (input: SearchProductsInput = {}) => {
const response = useCommerceSearch<SearchQuery, SearchProductsInput>(
{},
[
export const handler: SWRHook<
SearchProductsData,
SearchProductsInput,
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],
['categoryId', input.categoryId],
['brandId', input.brandId],
['sort', input.sort],
],
customFetcher,
{ revalidateOnFocus: false, ...swrOptions }
)
return useResponse(response, {
normalizer: (data) => {
return {
found: data?.search.totalItems && data?.search.totalItems > 0,
products:
data?.search.items.map((item) => normalizeSearchResult(item)) ?? [],
}
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
}
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]> }
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: number
ID: string
String: string
Boolean: boolean
Int: number
@ -41,6 +41,8 @@ export type Query = {
collection?: Maybe<Collection>
/** Returns a list of eligible shipping methods based on the current active Order */
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 */
me?: Maybe<CurrentUser>
/** Returns the possible next states that the activeOrder can transition to */
@ -329,6 +331,7 @@ export type Asset = Node & {
source: Scalars['String']
preview: Scalars['String']
focalPoint?: Maybe<Coordinate>
customFields?: Maybe<Scalars['JSON']>
}
export type Coordinate = {
@ -376,6 +379,7 @@ export type Channel = Node & {
defaultLanguageCode: LanguageCode
currencyCode: CurrencyCode
pricesIncludeTax: Scalars['Boolean']
customFields?: Maybe<Scalars['JSON']>
}
export type Collection = Node & {
@ -534,6 +538,7 @@ export enum ErrorCode {
OrderModificationError = 'ORDER_MODIFICATION_ERROR',
IneligibleShippingMethodError = 'INELIGIBLE_SHIPPING_METHOD_ERROR',
OrderPaymentStateError = 'ORDER_PAYMENT_STATE_ERROR',
IneligiblePaymentMethodError = 'INELIGIBLE_PAYMENT_METHOD_ERROR',
PaymentFailedError = 'PAYMENT_FAILED_ERROR',
PaymentDeclinedError = 'PAYMENT_DECLINED_ERROR',
CouponCodeInvalidError = 'COUPON_CODE_INVALID_ERROR',
@ -652,6 +657,8 @@ export type ConfigArgDefinition = {
name: Scalars['String']
type: Scalars['String']
list: Scalars['Boolean']
required: Scalars['Boolean']
defaultValue?: Maybe<Scalars['JSON']>
label?: Maybe<Scalars['String']>
description?: Maybe<Scalars['String']>
ui?: Maybe<Scalars['JSON']>
@ -678,6 +685,7 @@ export type DeletionResponse = {
export type ConfigArgInput = {
name: Scalars['String']
/** A JSON stringified representation of the actual value */
value: Scalars['String']
}
@ -789,6 +797,25 @@ export type Success = {
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 & {
__typename?: 'Country'
id: Scalars['ID']
@ -1827,16 +1854,6 @@ export type OrderList = PaginatedList & {
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 = {
__typename?: 'ShippingLine'
shippingMethod: ShippingMethod
@ -1897,6 +1914,10 @@ export type OrderLine = Node & {
unitPrice: Scalars['Int']
/** The price of a single unit, including tax but excluding discounts */
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.
*
@ -2173,6 +2194,7 @@ export type ProductVariant = Node & {
/** @deprecated price now always excludes tax */
priceIncludesTax: Scalars['Boolean']
priceWithTax: Scalars['Int']
stockLevel: Scalars['String']
taxRateApplied: TaxRate
taxCategory: TaxCategory
options: Array<ProductOption>
@ -2279,6 +2301,7 @@ export type TaxCategory = Node & {
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
name: Scalars['String']
isDefault: Scalars['Boolean']
}
export type TaxRate = Node & {
@ -2337,7 +2360,7 @@ export type OrderModificationError = ErrorResult & {
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 & {
__typename?: 'IneligibleShippingMethodError'
errorCode: ErrorCode
@ -2351,6 +2374,14 @@ export type OrderPaymentStateError = ErrorResult & {
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. */
export type PaymentFailedError = ErrorResult & {
__typename?: 'PaymentFailedError'
@ -2546,6 +2577,7 @@ export type ApplyCouponCodeResult =
export type AddPaymentToOrderResult =
| Order
| OrderPaymentStateError
| IneligiblePaymentMethodError
| PaymentFailedError
| PaymentDeclinedError
| OrderStateTransitionError
@ -2704,6 +2736,7 @@ export type ProductVariantFilterParameter = {
currencyCode?: Maybe<StringOperators>
priceIncludesTax?: Maybe<BooleanOperators>
priceWithTax?: Maybe<NumberOperators>
stockLevel?: Maybe<StringOperators>
}
export type ProductVariantSortParameter = {
@ -2715,6 +2748,7 @@ export type ProductVariantSortParameter = {
name?: Maybe<SortOrder>
price?: Maybe<SortOrder>
priceWithTax?: Maybe<SortOrder>
stockLevel?: Maybe<SortOrder>
}
export type CustomerFilterParameter = {
@ -2800,6 +2834,7 @@ export type CartFragment = { __typename?: 'Order' } & Pick<
Order,
| 'id'
| 'code'
| 'createdAt'
| 'totalQuantity'
| 'subTotal'
| 'subTotalWithTax'
@ -2809,13 +2844,28 @@ export type CartFragment = { __typename?: 'Order' } & Pick<
> & {
customer?: Maybe<{ __typename?: 'Customer' } & Pick<Customer, 'id'>>
lines: Array<
{ __typename?: 'OrderLine' } & Pick<OrderLine, 'id' | 'quantity'> & {
{ __typename?: 'OrderLine' } & Pick<
OrderLine,
'id' | 'quantity' | 'linePriceWithTax' | 'discountedLinePriceWithTax'
> & {
featuredAsset?: Maybe<
{ __typename?: 'Asset' } & Pick<Asset, 'id' | 'preview'>
>
discounts: Array<
{ __typename?: 'Adjustment' } & Pick<
Adjustment,
'description' | 'amount'
>
>
productVariant: { __typename?: 'ProductVariant' } & Pick<
ProductVariant,
'id' | 'name' | 'productId'
| 'id'
| 'name'
| 'sku'
| 'price'
| 'priceWithTax'
| 'stockLevel'
| 'productId'
> & { product: { __typename?: 'Product' } & Pick<Product, 'slug'> }
}
>
@ -2836,28 +2886,6 @@ export type SearchResultFragment = { __typename?: 'SearchResult' } & Pick<
| ({ __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<{
username: Scalars['String']
password: Scalars['String']
@ -3049,17 +3077,32 @@ export type GetProductQuery = { __typename?: 'Query' } & {
{ __typename?: 'ProductOption' } & Pick<
ProductOption,
'id' | 'name' | 'code' | 'groupId'
> & {
group: { __typename?: 'ProductOptionGroup' } & Pick<
ProductOptionGroup,
'id'
> & {
options: Array<
{ __typename?: 'ProductOption' } & Pick<
ProductOption,
'name'
>
>
}
}
>
}
>
optionGroups: Array<
{ __typename?: 'ProductOptionGroup' } & Pick<
ProductOptionGroup,
'code' | 'name'
'id' | 'code' | 'name'
> & {
options: Array<
{ __typename?: 'ProductOption' } & Pick<ProductOption, 'name'>
{ __typename?: 'ProductOption' } & Pick<
ProductOption,
'id' | 'name'
>
>
}
>

View File

@ -36,6 +36,11 @@ type Query {
"""
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
"""
@ -289,6 +294,7 @@ type Asset implements Node {
source: String!
preview: String!
focalPoint: Coordinate
customFields: JSON
}
type Coordinate {
@ -331,6 +337,7 @@ type Channel implements Node {
defaultLanguageCode: LanguageCode!
currencyCode: CurrencyCode!
pricesIncludeTax: Boolean!
customFields: JSON
}
type Collection implements Node {
@ -568,6 +575,7 @@ enum ErrorCode {
ORDER_MODIFICATION_ERROR
INELIGIBLE_SHIPPING_METHOD_ERROR
ORDER_PAYMENT_STATE_ERROR
INELIGIBLE_PAYMENT_METHOD_ERROR
PAYMENT_FAILED_ERROR
PAYMENT_DECLINED_ERROR
COUPON_CODE_INVALID_ERROR
@ -704,6 +712,8 @@ type ConfigArgDefinition {
name: String!
type: String!
list: Boolean!
required: Boolean!
defaultValue: JSON
label: String
description: String
ui: JSON
@ -727,6 +737,10 @@ type DeletionResponse {
input ConfigArgInput {
name: String!
"""
A JSON stringified representation of the actual value
"""
value: String!
}
@ -839,6 +853,26 @@ type Success {
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 {
id: ID!
createdAt: DateTime!
@ -2817,15 +2851,6 @@ type OrderList implements PaginatedList {
totalItems: Int!
}
type ShippingMethodQuote {
id: ID!
price: Int!
priceWithTax: Int!
name: String!
description: String!
metadata: JSON
}
type ShippingLine {
shippingMethod: ShippingMethod!
price: Int!
@ -2904,6 +2929,16 @@ type OrderLine implements Node {
"""
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.
@ -3197,6 +3232,7 @@ type ProductVariant implements Node {
priceIncludesTax: Boolean!
@deprecated(reason: "price now always excludes tax")
priceWithTax: Int!
stockLevel: String!
taxRateApplied: TaxRate!
taxCategory: TaxCategory!
options: [ProductOption!]!
@ -3292,6 +3328,7 @@ type TaxCategory implements Node {
createdAt: DateTime!
updatedAt: DateTime!
name: String!
isDefault: Boolean!
}
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 {
errorCode: ErrorCode!
@ -3362,6 +3399,15 @@ type OrderPaymentStateError implements ErrorResult {
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.
"""
@ -3562,6 +3608,7 @@ union ApplyCouponCodeResult =
union AddPaymentToOrderResult =
Order
| OrderPaymentStateError
| IneligiblePaymentMethodError
| PaymentFailedError
| PaymentDeclinedError
| OrderStateTransitionError
@ -3718,6 +3765,7 @@ input ProductVariantFilterParameter {
currencyCode: StringOperators
priceIncludesTax: BooleanOperators
priceWithTax: NumberOperators
stockLevel: StringOperators
}
input ProductVariantSortParameter {
@ -3729,6 +3777,7 @@ input ProductVariantSortParameter {
name: SortOrder
price: SortOrder
priceWithTax: SortOrder
stockLevel: SortOrder
}
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 { 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 = {
url: '/api/bigcommerce/wishlist',
method: 'POST',
export function emptyHook() {
const useEmptyHook = async (options = {}) => {
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 },
})
return useEmptyHook
}
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 })
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 { 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 = {
url: '/api/bigcommerce/wishlist',
method: 'DELETE',
type Options = {
includeProducts?: boolean
}
export type RemoveItemInput = {
id: string | number
export function emptyHook(options?: Options) {
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 },
})
return useEmptyHook
}
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) })
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,16 +1,21 @@
// 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 { SwrOptions } from '@commerce/utils/use-data'
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'
import { Product } from '../schema'
const defaultOpts = {
url: '/api/bigcommerce/wishlist',
method: 'GET',
const defaultOpts = {}
export type Wishlist = {
items: [
{
product_id: number
variant_id: number
id: number
product: Product
}
]
}
export type { Wishlist }
export interface UseWishlistOptions {
includeProducts?: boolean
@ -20,55 +25,17 @@ export interface UseWishlistInput extends UseWishlistOptions {
customerId?: number
}
export const fetcher: HookFetcher<Wishlist | null, UseWishlistInput> = (
options,
{ 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 const fetcher: HookFetcher<Wishlist | null, UseWishlistInput> = () => {
return null
}
export function extendHook(
customFetcher: typeof fetcher,
swrOptions?: SwrOptions<Wishlist | null, UseWishlistInput>
// swrOptions?: SwrOptions<Wishlist | null, UseWishlistInput>
swrOptions?: any
) {
const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => {
const { data: customer } = useCustomer()
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
return { data: null }
}
useWishlist.extend = extendHook