Skip to content

Commit

Permalink
Vendure provider (vercel#223)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
michaelbromley authored May 27, 2021
1 parent 8fb6c7b commit da43710
Show file tree
Hide file tree
Showing 71 changed files with 8,593 additions and 51 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ out/
# misc
.DS_Store
*.pem
.idea

# debug
npm-debug.log*
Expand Down
2 changes: 1 addition & 1 deletion framework/commerce/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions framework/vendure/.env.template
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
NEXT_PUBLIC_VENDURE_SHOP_API_URL=http://localhost:3001/shop-api
33 changes: 33 additions & 0 deletions framework/vendure/README.md
Original file line number Diff line number Diff line change
@@ -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
```
1 change: 1 addition & 0 deletions framework/vendure/api/cart/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function () {}
1 change: 1 addition & 0 deletions framework/vendure/api/catalog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function () {}
1 change: 1 addition & 0 deletions framework/vendure/api/catalog/products.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function () {}
60 changes: 60 additions & 0 deletions framework/vendure/api/checkout/index.ts
Original file line number Diff line number Diff line change
@@ -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, {}, {})
1 change: 1 addition & 0 deletions framework/vendure/api/customers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function () {}
1 change: 1 addition & 0 deletions framework/vendure/api/customers/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function () {}
1 change: 1 addition & 0 deletions framework/vendure/api/customers/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function () {}
1 change: 1 addition & 0 deletions framework/vendure/api/customers/signup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function () {}
51 changes: 51 additions & 0 deletions framework/vendure/api/index.ts
Original file line number Diff line number Diff line change
@@ -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)
}
37 changes: 37 additions & 0 deletions framework/vendure/api/utils/fetch-graphql-api.ts
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions framework/vendure/api/utils/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import zeitFetch from '@vercel/fetch'

export default zeitFetch()
2 changes: 2 additions & 0 deletions framework/vendure/api/wishlist/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type WishlistItem = { product: any; id: number }
export default function () {}
50 changes: 50 additions & 0 deletions framework/vendure/auth/use-login.tsx
Original file line number Diff line number Diff line change
@@ -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]
)
},
}
32 changes: 32 additions & 0 deletions framework/vendure/auth/use-logout.tsx
Original file line number Diff line number Diff line change
@@ -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]
)
},
}
68 changes: 68 additions & 0 deletions framework/vendure/auth/use-signup.tsx
Original file line number Diff line number Diff line change
@@ -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]
)
},
}
5 changes: 5 additions & 0 deletions framework/vendure/cart/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading

0 comments on commit da43710

Please sign in to comment.