Vendure provider (#223)

* Minimal list/detail views working with Vendure

* Implement useCart/useAddItem

* Implement useUpdateItem & useRemoveItem

* Implement useSearch

* Add operations codegen, tidy up

* Dummy checkout page

* Implement auth/customer hooks

* Use env var for Shop API url

* Add some documentation

* Improve error handling

* Optimize preview image size

* Fix accidental change

* Update Vendure provider to latest changes

* Vendure provider: split out gql operations, remove unused files

* Update Vendure provider readme

* Add local next.config to Vendure provider, update docs

* Update to use demo server

* Fix build errors

* Use proxy for vendure api

* Simplify instructions in Vendure readme

* Refactor Vendure checkout api handler

* Improve image quality
This commit is contained in:
Michael Bromley 2021-05-27 23:06:56 +02:00 committed by GitHub
parent 8fb6c7b206
commit da4371090d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 8593 additions and 51 deletions

1
.gitignore vendored
View File

@ -18,6 +18,7 @@ out/
# misc
.DS_Store
*.pem
.idea
# debug
npm-debug.log*

View File

@ -7,7 +7,7 @@ const fs = require('fs')
const merge = require('deepmerge')
const prettier = require('prettier')
const PROVIDERS = ['bigcommerce', 'shopify', 'swell']
const PROVIDERS = ['bigcommerce', 'shopify', 'swell', 'vendure']
function getProviderName() {
return (

View File

@ -0,0 +1 @@
NEXT_PUBLIC_VENDURE_SHOP_API_URL=http://localhost:3001/shop-api

View File

@ -0,0 +1,33 @@
# Vendure Storefront Data Hooks
UI hooks and data fetching methods built from the ground up for e-commerce applications written in React, that use [Vendure](http://vendure.io/) as a headless e-commerce platform.
## Usage
1. Clone this repo and install its dependencies with `yarn install` or `npm install`
2. Set the Vendure provider and API URL in your `.env.local` file:
```
COMMERCE_PROVIDER=vendure
NEXT_PUBLIC_VENDURE_SHOP_API_URL=https://demo.vendure.io/shop-api
NEXT_PUBLIC_VENDURE_LOCAL_URL=/vendure-shop-api
```
3. 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. 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.
## 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

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

View File

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

View File

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

View File

@ -0,0 +1,60 @@
import { NextApiHandler } from 'next'
const checkoutApi = async (req: any, res: any, config: any) => {
try {
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Checkout</title>
</head>
<body>
<div style='margin: 10rem auto; text-align: center; font-family: SansSerif, "Segoe UI", Helvetica'>
<h1>Checkout not implemented :(</h1>
<p>
See <a href='https://github.com/vercel/commerce/issues/64' target='_blank'>#64</a>
</p>
</div>
</body>
</html>
`
res.status(200)
res.setHeader('Content-Type', 'text/html')
res.write(html)
res.end()
} catch (error) {
console.error(error)
const message = 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
export function createApiHandler<T = any, H = {}, Options extends {} = {}>(
handler: any,
handlers: H,
defaultOptions: Options
) {
return function getApiHandler({
config,
operations,
options,
}: {
config?: any
operations?: Partial<H>
options?: Options extends {} ? Partial<Options> : never
} = {}): NextApiHandler {
const ops = { ...operations, ...handlers }
const opts = { ...defaultOptions, ...options }
return function apiHandler(req, res) {
return handler(req, res, config, ops, opts)
}
}
}
export default createApiHandler(checkoutApi, {}, {})

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,51 @@
import type { CommerceAPIConfig } from '@commerce/api'
import fetchGraphqlApi from './utils/fetch-graphql-api'
export interface VendureConfig extends CommerceAPIConfig {}
const API_URL = process.env.NEXT_PUBLIC_VENDURE_SHOP_API_URL
if (!API_URL) {
throw new Error(
`The environment variable NEXT_PUBLIC_VENDURE_SHOP_API_URL is missing and it's required to access your store`
)
}
export class Config {
private config: VendureConfig
constructor(config: VendureConfig) {
this.config = {
...config,
}
}
getConfig(userConfig: Partial<VendureConfig> = {}) {
return Object.entries(userConfig).reduce<VendureConfig>(
(cfg, [key, value]) => Object.assign(cfg, { [key]: value }),
{ ...this.config }
)
}
setConfig(newConfig: Partial<VendureConfig>) {
Object.assign(this.config, newConfig)
}
}
const ONE_DAY = 60 * 60 * 24
const config = new Config({
commerceUrl: API_URL,
apiToken: '',
cartCookie: '',
customerCookie: '',
cartCookieMaxAge: ONE_DAY * 30,
fetch: fetchGraphqlApi,
})
export function getConfig(userConfig?: Partial<VendureConfig>) {
return config.getConfig(userConfig)
}
export function setConfig(newConfig: Partial<VendureConfig>) {
return config.setConfig(newConfig)
}

View File

@ -0,0 +1,37 @@
import { FetcherError } from '@commerce/utils/errors'
import type { GraphQLFetcher } from '@commerce/api'
import { getConfig } from '..'
import fetch from './fetch'
const fetchGraphqlApi: GraphQLFetcher = async (
query: string,
{ variables, preview } = {},
fetchOptions
) => {
const config = getConfig()
const res = await fetch(config.commerceUrl, {
...fetchOptions,
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiToken}`,
...fetchOptions?.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
})
const json = await res.json()
if (json.errors) {
throw new FetcherError({
errors: json.errors ?? [{ message: 'Failed to fetch Vendure API' }],
status: res.status,
})
}
return { data: json.data, res }
}
export default fetchGraphqlApi

View File

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

View File

@ -0,0 +1,2 @@
export type WishlistItem = { product: any; id: number }
export default function () {}

View File

@ -0,0 +1,50 @@
import { useCallback } from 'react'
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 { LoginMutation, LoginMutationVariables } from '../schema'
import { loginMutation } from '../lib/mutations/log-in-mutation'
export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<null, {}, any> = {
fetchOptions: {
query: loginMutation,
},
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message: 'A email and password are required to login',
})
}
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()
return useCallback(
async function login(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

View File

@ -0,0 +1,32 @@
import { useCallback } from 'react'
import { MutationHook } from '@commerce/utils/types'
import useLogout, { UseLogout } from '@commerce/auth/use-logout'
import useCustomer from '../customer/use-customer'
import { LogoutMutation } from '../schema'
import { logoutMutation } from '../lib/mutations/log-out-mutation'
export default useLogout as UseLogout<typeof handler>
export const handler: MutationHook<null> = {
fetchOptions: {
query: logoutMutation,
},
async fetcher({ options, fetch }) {
await fetch<LogoutMutation>({
...options,
})
return null
},
useHook: ({ fetch }) => () => {
const { mutate } = useCustomer()
return useCallback(
async function logout() {
const data = await fetch()
await mutate(null, false)
return data
},
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,68 @@
import { useCallback } from 'react'
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 {
RegisterCustomerInput,
SignupMutation,
SignupMutationVariables,
} from '../schema'
import { signupMutation } from '../lib/mutations/sign-up-mutation'
export default useSignup as UseSignup<typeof handler>
export type SignupInput = {
email: string
firstName: string
lastName: string
password: string
}
export const handler: MutationHook<null, {}, SignupInput, SignupInput> = {
fetchOptions: {
query: signupMutation,
},
async fetcher({
input: { firstName, lastName, email, password },
options,
fetch,
}) {
if (!(firstName && lastName && email && password)) {
throw new CommerceError({
message:
'A first name, last name, email and password are required to signup',
})
}
const variables: SignupMutationVariables = {
input: {
firstName,
lastName,
emailAddress: email,
password,
},
}
const { registerCustomerAccount } = await fetch<SignupMutation>({
...options,
variables,
})
if (registerCustomerAccount.__typename !== 'Success') {
throw new ValidationError(registerCustomerAccount)
}
return null
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
return useCallback(
async function signup(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

View File

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

View File

@ -0,0 +1,52 @@
import { Cart, CartItemBody } from '@commerce/types'
import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item'
import { CommerceError } from '@commerce/utils/errors'
import { MutationHook } from '@commerce/utils/types'
import { useCallback } from 'react'
import useCart from './use-cart'
import { AddItemToOrderMutation } from '../schema'
import { normalizeCart } from '../lib/normalize'
import { addItemToOrderMutation } from '../lib/mutations/add-item-to-order-mutation'
export default useAddItem as UseAddItem<typeof handler>
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',
})
}
const { addItemToOrder } = await fetch<AddItemToOrderMutation>({
...options,
variables: {
quantity: input.quantity || 1,
variantId: input.variantId,
},
})
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]
)
},
}

View File

@ -0,0 +1,13 @@
import useAddItem from './use-add-item'
import useRemoveItem from './use-remove-item'
import useUpdateItem from './use-update-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 useCartActions() {
const addItem = useAddItem()
const updateItem = useUpdateItem()
const removeItem = useRemoveItem()
return { addItem, updateItem, removeItem }
}

View File

@ -0,0 +1,49 @@
import { Cart } from '@commerce/types'
import { SWRHook } from '@commerce/utils/types'
import useCart, { FetchCartInput, UseCart } from '@commerce/cart/use-cart'
import { ActiveOrderQuery, CartFragment } from '../schema'
import { normalizeCart } from '../lib/normalize'
import { useMemo } from 'react'
import { getCartQuery } from '../lib/queries/get-cart-query'
export type CartResult = {
activeOrder?: CartFragment
addItemToOrder?: CartFragment
adjustOrderLine?: CartFragment
removeOrderLine?: CartFragment
}
export default useCart as UseCart<typeof handler>
export const handler: SWRHook<
Cart | null,
{},
FetchCartInput,
{ isEmpty?: boolean }
> = {
fetchOptions: {
query: getCartQuery,
},
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?.lineItems.length ?? 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}

View File

@ -0,0 +1,48 @@
import { useCallback } from 'react'
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 {
RemoveOrderLineMutation,
RemoveOrderLineMutationVariables,
} from '../schema'
import { Cart, LineItem, RemoveCartItemBody } from '@commerce/types'
import { normalizeCart } from '../lib/normalize'
import { removeOrderLineMutation } from '../lib/mutations/remove-order-line-mutation'
export default useRemoveItem as UseRemoveItem<typeof handler>
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()
return useCallback(
async function removeItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,78 @@
import { useCallback } from 'react'
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 {
AdjustOrderLineMutation,
AdjustOrderLineMutationVariables,
} from '../schema'
import { normalizeCart } from '../lib/normalize'
import { adjustOrderLineMutation } from '../lib/mutations/adjust-order-line-mutation'
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()
return useCallback(
async function addItem(input: Partial<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,
},
})
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,28 @@
{
"schema": {
"http://localhost:3001/shop-api": {}
},
"documents": [
{
"./framework/vendure/**/*.{ts,tsx}": {
"noRequire": true
}
}
],
"generates": {
"./framework/vendure/schema.d.ts": {
"plugins": ["typescript", "typescript-operations"],
"config": {
"scalars": {
"ID": "string"
}
}
},
"./framework/vendure/schema.graphql": {
"plugins": ["schema-ast"]
}
},
"hooks": {
"afterAllFileWrite": ["prettier --write"]
}
}

View File

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

View File

@ -0,0 +1,35 @@
import { getConfig, VendureConfig } from '../api'
export type Page = any
export type GetAllPagesResult<
T extends { pages: any[] } = { pages: Page[] }
> = T
async function getAllPages(opts?: {
config?: VendureConfig
preview?: boolean
}): Promise<GetAllPagesResult>
async function getAllPages<T extends { pages: any[] }>(opts: {
url: string
config?: VendureConfig
preview?: boolean
}): Promise<GetAllPagesResult<T>>
async function getAllPages({
config,
preview,
}: {
url?: string
config?: VendureConfig
preview?: boolean
} = {}): Promise<GetAllPagesResult> {
config = getConfig(config)
return {
pages: [],
}
}
export default getAllPages

View File

@ -0,0 +1,40 @@
import { VendureConfig, getConfig } from '../api'
export type Page = any
export type GetPageResult<T extends { page?: any } = { page?: Page }> = T
export type PageVariables = {
id: number
}
async function getPage(opts: {
url?: string
variables: PageVariables
config?: VendureConfig
preview?: boolean
}): Promise<GetPageResult>
async function getPage<T extends { page?: any }, V = any>(opts: {
url: string
variables: V
config?: VendureConfig
preview?: boolean
}): Promise<GetPageResult<T>>
async function getPage({
url,
variables,
config,
preview,
}: {
url?: string
variables: PageVariables
config?: VendureConfig
preview?: boolean
}): Promise<GetPageResult> {
config = getConfig(config)
return {}
}
export default getPage

View File

@ -0,0 +1,49 @@
import { getConfig, VendureConfig } from '../api'
import { GetCollectionsQuery } from '../schema'
import { arrayToTree } from '../lib/array-to-tree'
import { getCollectionsQuery } from '../lib/queries/get-collections-query'
export type Category = {
entityId: string
name: string
path: string
productCount: number
}
export type GetSiteInfoResult<
T extends { categories: any[]; brands: any[] } = {
categories: Category[]
brands: any[]
}
> = T
async function getSiteInfo({
query = getCollectionsQuery,
variables,
config,
}: {
query?: string
variables?: any
config?: VendureConfig
preview?: boolean
} = {}): Promise<GetSiteInfoResult> {
config = getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const { data } = await config.fetch<GetCollectionsQuery>(query, { variables })
const collections = data.collections?.items.map((i) => ({
...i,
entityId: i.id,
path: i.slug,
productCount: i.productVariants.totalItems,
}))
const categories = arrayToTree(collections).children
const brands = [] as any[]
return {
categories: categories ?? [],
brands,
}
}
export default getSiteInfo

View File

@ -0,0 +1,18 @@
import { getConfig, VendureConfig } from '../api'
async function getCustomerWishlist({
config,
variables,
includeProducts,
}: {
url?: string
variables: any
config?: VendureConfig
includeProducts?: boolean
}): Promise<any> {
// Not implemented as Vendure does not ship with wishlist functionality at present
config = getConfig(config)
return { wishlist: {} }
}
export default getCustomerWishlist

View File

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

View File

@ -0,0 +1,33 @@
import { SWRHook } from '@commerce/utils/types'
import useCustomer, { UseCustomer } from '@commerce/customer/use-customer'
import { Customer } from '@commerce/types'
import { ActiveCustomerQuery } from '../schema'
import { activeCustomerQuery } from '../lib/queries/active-customer-query'
export default useCustomer as UseCustomer<typeof handler>
export const handler: SWRHook<Customer | null> = {
fetchOptions: {
query: activeCustomerQuery,
},
async fetcher({ options, fetch }) {
const { activeCustomer } = await fetch<ActiveCustomerQuery>({
...options,
})
return activeCustomer
? ({
firstName: activeCustomer.firstName ?? '',
lastName: activeCustomer.lastName ?? '',
email: activeCustomer.emailAddress ?? '',
} as any)
: null
},
useHook: ({ useData }) => (input) => {
return useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
},
}

View File

@ -0,0 +1,51 @@
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_LOCAL_URL ||
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

@ -0,0 +1,33 @@
import * as React from 'react'
import { ReactNode } from 'react'
import {
CommerceConfig,
CommerceProvider as CoreCommerceProvider,
useCommerce as useCoreCommerce,
} from '@commerce'
import { vendureProvider } from './provider'
export const vendureConfig: CommerceConfig = {
locale: 'en-us',
cartCookie: 'session',
}
export type VendureConfig = Partial<CommerceConfig>
export type VendureProps = {
children?: ReactNode
locale: string
} & VendureConfig
export function CommerceProvider({ children, ...config }: VendureProps) {
return (
<CoreCommerceProvider
provider={vendureProvider}
config={{ ...vendureConfig, ...config }}
>
{children}
</CoreCommerceProvider>
)
}
export const useCommerce = () => useCoreCommerce()

View File

@ -0,0 +1,67 @@
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?: string
children: Array<TreeNode<T>>
}
export function arrayToTree<T extends HasParent>(
nodes: T[],
currentState?: RootNode<T>
): RootNode<T> {
const topLevelNodes: Array<TreeNode<T>> = []
const mappedArr: { [id: string]: TreeNode<T> } = {}
const currentStateMap = treeToMap(currentState)
// First map the nodes of the array to an object -> create a hash table.
for (const node of nodes) {
mappedArr[node.id] = { ...(node as any), children: [] }
}
for (const id of nodes.map((n) => n.id)) {
if (mappedArr.hasOwnProperty(id)) {
const mappedElem = mappedArr[id]
mappedElem.expanded = currentStateMap.get(id)?.expanded ?? false
const parent = mappedElem.parent
if (!parent) {
continue
}
// If the element is not at the root level, add it to its parent array of children.
const parentIsRoot = !mappedArr[parent.id]
if (!parentIsRoot) {
if (mappedArr[parent.id]) {
mappedArr[parent.id].children.push(mappedElem)
} else {
mappedArr[parent.id] = { children: [mappedElem] } as any
}
} else {
topLevelNodes.push(mappedElem)
}
}
}
// tslint:disable-next-line:no-non-null-assertion
const rootId = topLevelNodes.length ? topLevelNodes[0].parent!.id : undefined
return { id: rootId, children: topLevelNodes }
}
/**
* Converts an existing tree (as generated by the arrayToTree function) into a flat
* Map. This is used to persist certain states (e.g. `expanded`) when re-building the
* tree.
*/
function treeToMap<T extends HasParent>(
tree?: RootNode<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)
}
if (tree) {
visit(tree as TreeNode<T>)
}
return nodeMap
}

View File

@ -0,0 +1,42 @@
export const cartFragment = /* GraphQL */ `
fragment Cart on Order {
id
code
createdAt
totalQuantity
subTotal
subTotalWithTax
total
totalWithTax
currencyCode
customer {
id
}
lines {
id
quantity
linePriceWithTax
discountedLinePriceWithTax
featuredAsset {
id
preview
}
discounts {
description
amount
}
productVariant {
id
name
sku
price
priceWithTax
stockLevel
product {
slug
}
productId
}
}
}
`

View File

@ -0,0 +1,24 @@
export const searchResultFragment = /* GraphQL */ `
fragment SearchResult on SearchResult {
productId
productName
description
description
slug
sku
currencyCode
productAsset {
id
preview
}
priceWithTax {
... on SinglePrice {
value
}
... on PriceRange {
min
max
}
}
}
`

View File

@ -0,0 +1,15 @@
import { cartFragment } from '../fragments/cart-fragment'
export const addItemToOrderMutation = /* GraphQL */ `
mutation addItemToOrder($variantId: ID!, $quantity: Int!) {
addItemToOrder(productVariantId: $variantId, quantity: $quantity) {
__typename
...Cart
... on ErrorResult {
errorCode
message
}
}
}
${cartFragment}
`

View File

@ -0,0 +1,15 @@
import { cartFragment } from '../fragments/cart-fragment'
export const adjustOrderLineMutation = /* GraphQL */ `
mutation adjustOrderLine($orderLineId: ID!, $quantity: Int!) {
adjustOrderLine(orderLineId: $orderLineId, quantity: $quantity) {
__typename
...Cart
... on ErrorResult {
errorCode
message
}
}
}
${cartFragment}
`

View File

@ -0,0 +1,14 @@
export const loginMutation = /* GraphQL */ `
mutation login($username: String!, $password: String!) {
login(username: $username, password: $password) {
__typename
... on CurrentUser {
id
}
... on ErrorResult {
errorCode
message
}
}
}
`

View File

@ -0,0 +1,7 @@
export const logoutMutation = /* GraphQL */ `
mutation logout {
logout {
success
}
}
`

View File

@ -0,0 +1,15 @@
import { cartFragment } from '../fragments/cart-fragment'
export const removeOrderLineMutation = /* GraphQL */ `
mutation removeOrderLine($orderLineId: ID!) {
removeOrderLine(orderLineId: $orderLineId) {
__typename
...Cart
... on ErrorResult {
errorCode
message
}
}
}
${cartFragment}
`

View File

@ -0,0 +1,14 @@
export const signupMutation = /* GraphQL */ `
mutation signup($input: RegisterCustomerInput!) {
registerCustomerAccount(input: $input) {
__typename
... on Success {
success
}
... on ErrorResult {
errorCode
message
}
}
}
`

View File

@ -0,0 +1,55 @@
import { Cart, Product } from '@commerce/types'
import { CartFragment, SearchResultFragment } from '../schema'
export function normalizeSearchResult(item: SearchResultFragment): Product {
return {
id: item.productId,
name: item.productName,
description: item.description,
slug: item.slug,
path: item.slug,
images: [{ url: item.productAsset?.preview + '?w=800&mode=crop' || '' }],
variants: [],
price: {
value: (item.priceWithTax as any).min / 100,
currencyCode: item.currencyCode,
},
options: [],
sku: item.sku,
}
}
export function normalizeCart(order: CartFragment): Cart {
return {
id: order.id.toString(),
createdAt: order.createdAt,
taxesIncluded: true,
lineItemsSubtotalPrice: order.subTotalWithTax / 100,
currency: { code: order.currencyCode },
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,
url: l.productVariant.product.slug,
variantId: l.productVariant.id,
productId: l.productVariant.productId,
images: [{ url: l.featuredAsset?.preview + '?preset=thumb' || '' }],
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

@ -0,0 +1,10 @@
export const activeCustomerQuery = /* GraphQL */ `
query activeCustomer {
activeCustomer {
id
firstName
lastName
emailAddress
}
}
`

View File

@ -0,0 +1,9 @@
export const getAllProductPathsQuery = /* GraphQL */ `
query getAllProductPaths($first: Int = 100) {
products(options: { take: $first }) {
items {
slug
}
}
}
`

View File

@ -0,0 +1,12 @@
import { searchResultFragment } from '../fragments/search-result-fragment'
export const getAllProductsQuery = /* GraphQL */ `
query getAllProducts($input: SearchInput!) {
search(input: $input) {
items {
...SearchResult
}
}
}
${searchResultFragment}
`

View File

@ -0,0 +1,10 @@
import { cartFragment } from '../fragments/cart-fragment'
export const getCartQuery = /* GraphQL */ `
query activeOrder {
activeOrder {
...Cart
}
}
${cartFragment}
`

View File

@ -0,0 +1,21 @@
export const getCollectionsQuery = /* GraphQL */ `
query getCollections {
collections {
items {
id
name
description
slug
productVariants {
totalItems
}
parent {
id
}
children {
id
}
}
}
}
`

View File

@ -0,0 +1,41 @@
export const getProductQuery = /* GraphQL */ `
query getProduct($slug: String!) {
product(slug: $slug) {
id
name
slug
description
assets {
id
preview
name
}
variants {
id
priceWithTax
currencyCode
options {
id
name
code
groupId
group {
id
options {
name
}
}
}
}
optionGroups {
id
code
name
options {
id
name
}
}
}
}
`

View File

@ -0,0 +1,13 @@
import { searchResultFragment } from '../fragments/search-result-fragment'
export const searchQuery = /* GraphQL */ `
query search($input: SearchInput!) {
search(input: $input) {
items {
...SearchResult
}
totalItems
}
}
${searchResultFragment}
`

View File

@ -0,0 +1,8 @@
const commerce = require('./commerce.config.json')
module.exports = {
commerce,
images: {
domains: ['localhost', 'demo.vendure.io'],
},
}

View File

@ -0,0 +1,48 @@
import type {
GetAllProductPathsQuery,
GetAllProductPathsQueryVariables,
} from '../schema'
import { getConfig, VendureConfig } from '../api'
import { getAllProductPathsQuery } from '../lib/queries/get-all-product-paths-query'
export type GetAllProductPathsResult = {
products: Array<{ node: { path: string } }>
}
async function getAllProductPaths(opts?: {
variables?: GetAllProductPathsQueryVariables
config?: VendureConfig
}): Promise<GetAllProductPathsResult>
async function getAllProductPaths<
T extends { products: any[] },
V = any
>(opts: {
query: string
variables?: V
config?: VendureConfig
}): Promise<GetAllProductPathsResult>
async function getAllProductPaths({
query = getAllProductPathsQuery,
variables,
config,
}: {
query?: string
variables?: GetAllProductPathsQueryVariables
config?: VendureConfig
} = {}): Promise<GetAllProductPathsResult> {
config = getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const { data } = await config.fetch<GetAllProductPathsQuery>(query, {
variables,
})
const products = data.products.items
return {
products: products.map((p) => ({ node: { path: `/${p.slug}` } })),
}
}
export default getAllProductPaths

View File

@ -0,0 +1,39 @@
import { Product } from '@commerce/types'
import { getConfig, VendureConfig } from '../api'
import { GetAllProductsQuery } from '../schema'
import { normalizeSearchResult } from '../lib/normalize'
import { getAllProductsQuery } from '../lib/queries/get-all-products-query'
export type ProductVariables = { first?: number }
async function getAllProducts(opts?: {
variables?: ProductVariables
config?: VendureConfig
preview?: boolean
}): Promise<{ products: Product[] }>
async function getAllProducts({
query = getAllProductsQuery,
variables: { ...vars } = {},
config,
}: {
query?: string
variables?: ProductVariables
config?: VendureConfig
preview?: boolean
} = {}): Promise<{ products: Product[] | any[] }> {
config = getConfig(config)
const variables = {
input: {
take: vars.first,
groupByProduct: true,
},
}
const { data } = await config.fetch<GetAllProductsQuery>(query, { variables })
return {
products: data.search.items.map((item) => normalizeSearchResult(item)),
}
}
export default getAllProducts

View File

@ -0,0 +1,57 @@
import { Product } from '@commerce/types'
import { getConfig, VendureConfig } from '../api'
import { GetProductQuery } from '../schema'
import { getProductQuery } from '../lib/queries/get-product-query'
async function getProduct({
query = getProductQuery,
variables,
config,
}: {
query?: string
variables: { slug: string }
config?: VendureConfig
preview?: boolean
}): Promise<Product | {} | any> {
config = getConfig(config)
const locale = config.locale
const { data } = await config.fetch<GetProductQuery>(query, { variables })
const product = data.product
if (product) {
return {
product: {
id: product.id,
name: product.name,
description: product.description,
slug: product.slug,
images: product.assets.map((a) => ({
url: a.preview,
alt: a.name,
})),
variants: product.variants.map((v) => ({
id: v.id,
options: v.options.map((o) => ({
id: o.id,
displayName: o.name,
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) => ({
id: og.id,
displayName: og.name,
values: og.options.map((o) => ({ label: o.name })),
})),
} as Product,
}
}
return {}
}
export default getProduct

View File

@ -0,0 +1,4 @@
export { default as usePrice } from './use-price'
export { default as useSearch } from './use-search'
export { default as getProduct } from './get-product'
export { default as getAllProducts } from './get-all-products'

View File

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

View File

@ -0,0 +1,65 @@
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 { normalizeSearchResult } from '../lib/normalize'
import { searchQuery } from '../lib/queries/search-query'
export default useSearch as UseSearch<typeof handler>
export type SearchProductsInput = {
search?: string
categoryId?: string
brandId?: string
sort?: string
}
export type SearchProductsData = {
products: Product[]
found: boolean
}
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],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
}

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: 'session',
fetcher,
cart: { useCart, useAddItem, useUpdateItem, useRemoveItem },
customer: { useCustomer },
products: { useSearch },
auth: { useLogin, useLogout, useSignup },
}

3122
framework/vendure/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
import * as Core from '@commerce/types'
export interface LineItem extends Core.LineItem {
options?: any[]
}

View File

@ -0,0 +1,13 @@
import { useCallback } from 'react'
export function emptyHook() {
const useEmptyHook = async (options = {}) => {
return useCallback(async function () {
return Promise.resolve()
}, [])
}
return useEmptyHook
}
export default emptyHook

View File

@ -0,0 +1,17 @@
import { useCallback } from 'react'
type Options = {
includeProducts?: boolean
}
export function emptyHook(options?: Options) {
const useEmptyHook = async ({ id }: { id: string | number }) => {
return useCallback(async function () {
return Promise.resolve()
}, [])
}
return useEmptyHook
}
export default emptyHook

View File

@ -0,0 +1,46 @@
// 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 { Product } from '../schema'
const defaultOpts = {}
export type Wishlist = {
items: [
{
product_id: number
variant_id: number
id: number
product: Product
}
]
}
export interface UseWishlistOptions {
includeProducts?: boolean
}
export interface UseWishlistInput extends UseWishlistOptions {
customerId?: number
}
export const fetcher: HookFetcher<Wishlist | null, UseWishlistInput> = () => {
return null
}
export function extendHook(
customFetcher: typeof fetcher,
// swrOptions?: SwrOptions<Wishlist | null, UseWishlistInput>
swrOptions?: any
) {
const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => {
return { data: null }
}
useWishlist.extend = extendHook
return useWishlist
}
export default extendHook(fetcher)

View File

@ -8,6 +8,7 @@ const provider = commerce.provider || getProviderName()
const isBC = provider === 'bigcommerce'
const isShopify = provider === 'shopify'
const isSwell = provider === 'swell'
const isVendure = provider === 'vendure'
module.exports = withCommerceConfig({
commerce,
@ -17,7 +18,7 @@ module.exports = withCommerceConfig({
},
rewrites() {
return [
(isBC || isShopify || isSwell) && {
(isBC || isShopify || isSwell || isVendure) && {
source: '/checkout',
destination: '/api/bigcommerce/checkout',
},
@ -27,6 +28,13 @@ module.exports = withCommerceConfig({
source: '/logout',
destination: '/api/bigcommerce/customers/logout?redirect_to=/',
},
// For Vendure, rewrite the local api url to the remote (external) api url. This is required
// to make the session cookies work.
isVendure &&
process.env.NEXT_PUBLIC_VENDURE_LOCAL_URL && {
source: `${process.env.NEXT_PUBLIC_VENDURE_LOCAL_URL}/:path*`,
destination: `${process.env.NEXT_PUBLIC_VENDURE_SHOP_API_URL}/:path*`,
},
// Rewrites for /search
{
source: '/search/designers/:name',

View File

@ -9,6 +9,7 @@
"prettier-fix": "prettier --write .",
"find:unused": "next-unused",
"generate": "graphql-codegen",
"generate:vendure": "graphql-codegen --config framework/vendure/codegen.json",
"generate:definitions": "node framework/bigcommerce/scripts/generate-definitions.js"
},
"sideEffects": false,

View File

@ -21,15 +21,15 @@ export async function getStaticProps({
preview,
})
const { categories, brands } = await getSiteInfo({ config, preview })
const { pages } = await getAllPages({ config, preview })
// const { categories, brands } = await getSiteInfo({ config, preview })
// const { pages } = await getAllPages({ config, preview })
return {
props: {
products,
categories,
brands,
pages,
categories: [],
brands: [],
pages: [],
},
revalidate: 14400,
}

View File

@ -22,8 +22,8 @@
"@components/*": ["components/*"],
"@commerce": ["framework/commerce"],
"@commerce/*": ["framework/commerce/*"],
"@framework": ["framework/bigcommerce"],
"@framework/*": ["framework/bigcommerce/*"]
"@framework": ["framework/vendure"],
"@framework/*": ["framework/vendure/*"]
}
},
"include": ["next-env.d.ts", "**/*.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"],

View File

@ -3061,21 +3061,6 @@ get-stream@^5.0.0, get-stream@^5.1.0:
dependencies:
pump "^3.0.0"
glob-base@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=
dependencies:
glob-parent "^2.0.0"
is-glob "^2.0.0"
glob-parent@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=
dependencies:
is-glob "^2.0.0"
glob-parent@^5.1.0, glob-parent@~5.1.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229"
@ -3511,16 +3496,6 @@ is-date-object@^1.0.1:
resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e"
integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==
is-dotfile@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=
is-extglob@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
@ -3550,13 +3525,6 @@ is-glob@4.0.1, is-glob@^4.0.1, is-glob@~4.0.1:
dependencies:
is-extglob "^2.1.1"
is-glob@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=
dependencies:
is-extglob "^1.0.0"
is-interactive@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e"
@ -4868,16 +4836,6 @@ parse-filepath@^1.0.2:
map-cache "^0.2.0"
path-root "^0.1.1"
parse-glob@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw=
dependencies:
glob-base "^0.3.0"
is-dotfile "^1.0.0"
is-extglob "^1.0.0"
is-glob "^2.0.0"
parse-json@^5.0.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"