feat: add olist-provider

This commit is contained in:
Eduardo Marques 2022-08-03 14:23:36 -03:00
parent 87134e2990
commit 72db684864
104 changed files with 2532 additions and 40 deletions

3
.gitignore vendored
View File

@ -38,3 +38,6 @@ yarn-error.log*
# Turborepo
.turbo
#typescript
tsconfig.tsbuildinfo

View File

@ -17,6 +17,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/)
- Kibo Commerce Demo: https://kibocommerce.vercel.store/
- Commerce.js Demo: https://commercejs.vercel.store/
- SalesForce Cloud Commerce Demo: https://salesforce-cloud-commerce.vercel.store/
- Olist Demo: https://olist.vercel.store/
## Run minimal version locally

View File

@ -69,7 +69,7 @@
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -15,6 +15,7 @@ A commerce provider is a headless e-commerce platform that integrates with the [
- Kibo Commerce ([packages/kibocommerce](../kibocommerce))
- Commerce.js ([packages/commercejs](../commercejs))
- SFCC - SalesForce Cloud Commerce ([packages/sfcc](../sfcc))
- Olist ([packages/olist](../olist))
Adding a commerce provider means adding a new folder in `packages` with a folder structure like the next one:
@ -69,7 +70,10 @@ Then, open [/site/.env.template](/site/.env.template) and add the provider name
Using BigCommerce as an example. The first thing to do is export a `CommerceProvider` component that includes a `provider` object with all the handlers that can be used for hooks:
```tsx
import { getCommerceProvider, useCommerce as useCoreCommerce } from '@vercel/commerce'
import {
getCommerceProvider,
useCommerce as useCoreCommerce,
} from '@vercel/commerce'
import { bigcommerceProvider, BigcommerceProvider } from './provider'
export { bigcommerceProvider }
@ -213,25 +217,26 @@ export const handler: MutationHook<AddItemHook> = {
```
## Showing progress and features
When creating a PR for a new provider, include this list in the PR description and mark the progress as you push so we can organize the code review. Not all points are required (but advised) so make sure to keep the list up to date.
**Status**
* [ ] CommerceProvider
* [ ] Schema & TS types
* [ ] API Operations - Get all collections
* [ ] API Operations - Get all pages
* [ ] API Operations - Get all products
* [ ] API Operations - Get page
* [ ] API Operations - Get product
* [ ] API Operations - Get Shop Info (categories and vendors working — `vendors` query still a WIP PR on Reaction)
* [ ] Hook - Add Item
* [ ] Hook - Remove Item
* [ ] Hook - Update Item
* [ ] Hook - Get Cart (account-tied carts working, anonymous carts working, cart reconciliation working)
* [ ] Auth (based on a WIP PR on Reaction - still need to implement refresh tokens)
* [ ] Customer information
* [ ] Product attributes - Size, Colors
* [ ] Custom checkout
* [ ] Typing (in progress)
* [ ] Tests
- [ ] CommerceProvider
- [ ] Schema & TS types
- [ ] API Operations - Get all collections
- [ ] API Operations - Get all pages
- [ ] API Operations - Get all products
- [ ] API Operations - Get page
- [ ] API Operations - Get product
- [ ] API Operations - Get Shop Info (categories and vendors working — `vendors` query still a WIP PR on Reaction)
- [ ] Hook - Add Item
- [ ] Hook - Remove Item
- [ ] Hook - Update Item
- [ ] Hook - Get Cart (account-tied carts working, anonymous carts working, cart reconciliation working)
- [ ] Auth (based on a WIP PR on Reaction - still need to implement refresh tokens)
- [ ] Customer information
- [ ] Product attributes - Size, Colors
- [ ] Custom checkout
- [ ] Typing (in progress)
- [ ] Tests

View File

@ -66,7 +66,7 @@
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -1,5 +1,6 @@
import type { NextApiHandler } from 'next'
import type { FetchOptions, Response } from '@vercel/fetch'
import type { APIEndpoint, APIHandler } from './utils/types'
import type { CartSchema } from '../types/cart'
import type { CustomerSchema } from '../types/customer'

View File

@ -65,7 +65,7 @@
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -70,7 +70,7 @@
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -62,7 +62,7 @@
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -0,0 +1,4 @@
COMMERCE_PROVIDER=@vercel/commerce-olist
NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN=
NEXT_PUBLIC_OLIST_STOREFRONT_ACCESS_TOKEN=

View File

@ -0,0 +1,2 @@
node_modules
dist

View File

@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}

1
packages/olist/README.md Normal file
View File

@ -0,0 +1 @@
# Next.js Olist Provider

View File

@ -0,0 +1,80 @@
{
"name": "@vercel/commerce-olist",
"version": "0.0.1",
"license": "MIT",
"scripts": {
"release": "taskr release",
"build": "taskr build",
"dev": "taskr",
"types": "tsc --emitDeclarationOnly",
"prettier-fix": "prettier --write ."
},
"sideEffects": false,
"type": "module",
"exports": {
".": "./dist/index.js",
"./*": [
"./dist/*.js",
"./dist/*/index.js"
],
"./next.config": "./dist/next.config.cjs"
},
"typesVersions": {
"*": {
"*": [
"src/*",
"src/*/index"
],
"next.config": [
"dist/next.config.d.cts"
]
}
},
"files": [
"dist"
],
"publishConfig": {
"typesVersions": {
"*": {
"*": [
"dist/*.d.ts",
"dist/*/index.d.ts"
],
"next.config": [
"dist/next.config.d.cts"
]
}
}
},
"dependencies": {
"@vercel/commerce": "^0.0.1",
"@vercel/fetch": "^6.1.1",
"@vnda/headless-framework": "^0.0.33"
},
"peerDependencies": {
"next": "^12",
"react": "^17",
"react-dom": "^17"
},
"devDependencies": {
"@taskr/clear": "^1.1.0",
"@taskr/esnext": "^1.1.0",
"@taskr/watch": "^1.1.0",
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"taskr": "^1.1.0",
"taskr-swc": "^0.0.1",
"typescript": "^4.5.4"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json}": [
"prettier --write",
"git add"
]
}
}

View File

@ -0,0 +1,84 @@
import { serialize } from 'cookie'
import type { Cart } from '@vnda/headless-framework'
import type { CartEndpoint, Handler } from '.'
import {
mapCommerceToRawRequest,
mapRawToCommerceResponse,
} from '../../../utils/cart'
const addItem: CartEndpoint['handlers']['addItem'] = async ({
res: response,
body: { cartId, item },
config: { service, cartCookie, cartTokenCookie },
}: Handler) => {
try {
if (!item) {
return response.status(400).json({
data: null,
errors: [{ message: 'Missing item' }],
})
}
if (!item.quantity) item.quantity = 1
let cart: Cart
if (!cartId) {
cart = await service.cart.create()
response.setHeader('Set-Cookie', [
serialize(cartCookie, cart.id.toString(), {
maxAge: 60 * 60 * 24 * 30,
expires: new Date(Date.now() + 60 * 60 * 24 * 30 * 1000),
secure: process.env.NODE_ENV === 'production',
path: '/',
sameSite: 'lax',
}),
serialize(cartTokenCookie, cart.token, {
maxAge: 60 * 60 * 24 * 30,
expires: new Date(Date.now() + 60 * 60 * 24 * 30 * 1000),
secure: process.env.NODE_ENV === 'production',
path: '/',
sameSite: 'lax',
}),
])
} else {
cart = await service.cart.getById(Number(cartId))
}
const itemExistQuantity =
(
cart.items.find(({ variantSku }) => variantSku === item.variantId)! ||
[]
).quantity || 0
const cartItem = await service.cart.addItem(
cartId ? Number(cartId) : cart!.id,
mapCommerceToRawRequest({
...item,
quantity: Number(itemExistQuantity) + 1 || 1,
})
)
cart.items = [
...(cart?.items.map((value) =>
value.variantSku === item.variantId ? cartItem : value
) || []),
...(itemExistQuantity ? [] : [cartItem]),
]
response.status(200).json({
data: mapRawToCommerceResponse(cart),
errors: [],
})
} catch (error) {
response.status(500).json({
data: {},
errors: error,
})
}
}
export default addItem

View File

@ -0,0 +1,41 @@
import { serialize } from 'cookie'
import type { CartEndpoint, Handler } from '.'
import { mapRawToCommerceResponse } from '../../../utils/cart'
const getCart: CartEndpoint['handlers']['getCart'] = async ({
res: response,
body: { cartId },
config: { service, cartCookie, cartTokenCookie },
}: Handler) => {
if (!cartId) {
return response.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
try {
const cart = await service.cart.getById(Number(cartId))
response
.status(200)
.json({ data: mapRawToCommerceResponse(cart), errors: [] })
} catch (error) {
response.setHeader('Set-Cookie', [
serialize(cartCookie, cartId, {
maxAge: -1,
path: '/',
}),
serialize(cartTokenCookie, cartId, {
maxAge: -1,
path: '/',
}),
])
response.status(200).json({ data: null, errors: [] })
}
}
export default getCart

View File

@ -0,0 +1,47 @@
import cartEndpoint from '@vercel/commerce/api/endpoints/cart'
import { createEndpoint } from '@vercel/commerce/api'
import type { GetAPISchema } from '@vercel/commerce/api'
import type { CartItemBody, CartSchema } from '@vercel/commerce/types/cart'
import getCart from './get-cart'
import addItem from './add-item'
import updateItem from './update-item'
import removeItem from './remove-item'
import type { OlistAPI } from '../../../api'
import type { Handler as HandlerAPI } from '../../../types/api'
export type CartAPI = GetAPISchema<OlistAPI, CartSchema>
export type CartEndpoint = CartAPI['endpoint']
export const handlers: CartEndpoint['handlers'] = {
getCart,
addItem,
updateItem,
removeItem,
}
type GetCartBody = {
cartId?: string
}
type AddItemBody = {
item?: CartItemBody
}
type RemoveItemBody = {
itemId?: string
}
export type Handler = {
body: GetCartBody & AddItemBody & RemoveItemBody
} & HandlerAPI
const cartApi = createEndpoint<CartAPI>({
handler: cartEndpoint,
handlers,
})
export default cartApi

View File

@ -0,0 +1,26 @@
import type { CartEndpoint, Handler } from '.'
import { mapRawToCommerceResponse } from '../../../utils/cart'
const removeItem: CartEndpoint['handlers']['removeItem'] = async ({
res: response,
body: { cartId, itemId },
config: { service },
}: Handler) => {
if (!cartId || !itemId) {
return response.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
await service.cart.removeItem(Number(cartId), Number(itemId))
const cart = await service.cart.getById(Number(cartId))
response
.status(200)
.json({ data: mapRawToCommerceResponse(cart), errors: [] })
}
export default removeItem

View File

@ -0,0 +1,28 @@
import type { CartEndpoint, Handler } from '.'
import { mapRawToCommerceResponse } from '../../../utils/cart'
const updateItem: CartEndpoint['handlers']['updateItem'] = async ({
res: response,
body: { cartId, itemId, item },
config: { service },
}: Handler) => {
if (!cartId || !itemId || !item) {
return response.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
await service.cart.updateItem(Number(cartId), Number(itemId), {
quantity: item.quantity,
})
const cart = await service.cart.getById(Number(cartId))
response
.status(200)
.json({ data: mapRawToCommerceResponse(cart), errors: [] })
}
export default updateItem

View File

@ -0,0 +1,34 @@
import type { Product } from '@vnda/headless-framework'
import { ProductsEndpoint, Handler } from '.'
import { mapItemRawToCommerceResponse } from '../../../../utils/product'
// Get products for the product list page. Search and category filter implemented. Sort and brand filter not implemented.
const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({
res,
body: { search, sort, categoryId },
config: { service },
}: Handler) => {
try {
let result: Product[] = []
result = await (search
? service.product.search({ term: search })
: service.product.list({
sort,
limit: 20,
...(categoryId && { tag: [categoryId.toString()] }),
}))
const found = result?.length > 0
res.status(200).json({
data: { products: result?.map(mapItemRawToCommerceResponse), found },
})
} catch (error) {
throw error
}
}
export default getProducts

View File

@ -0,0 +1,30 @@
import { createEndpoint } from '@vercel/commerce/api'
import productsEndpoint from '@vercel/commerce/api/endpoints/catalog/products'
import type { GetAPISchema } from '@vercel/commerce/api'
import type {
ProductsSchema,
SearchProductsBody,
} from '@vercel/commerce/types/product'
import getProducts from './get-products'
import type { OlistAPI } from '../../../../api'
import type { Handler as HandlerAPI } from '../../../../types/api'
export type ProductsAPI = GetAPISchema<OlistAPI, ProductsSchema>
export type ProductsEndpoint = ProductsAPI['endpoint']
export const handlers: ProductsEndpoint['handlers'] = { getProducts }
export type Handler = {
body: SearchProductsBody
} & HandlerAPI
const productsApi = createEndpoint<ProductsAPI>({
handler: productsEndpoint,
handlers,
})
export default productsApi

View File

@ -0,0 +1,20 @@
import type { CheckoutEndpoint } from '.'
import { Handler } from '../cart'
const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({
req: request,
res: response,
body: { cartId },
config: { storeDomain, cartTokenCookie },
}: Handler) => {
const cartToken = request.cookies[cartTokenCookie]
if (!cartId || !cartToken) {
response.redirect('/')
return
}
response.redirect(`https://${storeDomain}/checkout/${cartToken}`)
}
export default getCheckout

View File

@ -0,0 +1,25 @@
import { createEndpoint } from '@vercel/commerce/api'
import checkoutEndpoint from '@vercel/commerce/api/endpoints/checkout'
import type { GetAPISchema } from '@vercel/commerce/api'
import type { CheckoutSchema } from '@vercel/commerce/types/checkout'
import getCheckout from './get-checkout'
import submitCheckout from './submit-checkout'
import type { OlistAPI } from '../..'
export type CheckoutAPI = GetAPISchema<OlistAPI, CheckoutSchema>
export type CheckoutEndpoint = CheckoutAPI['endpoint']
export const handlers: CheckoutEndpoint['handlers'] = {
getCheckout,
submitCheckout,
}
const checkoutApi = createEndpoint<CheckoutAPI>({
handler: checkoutEndpoint,
handlers,
})
export default checkoutApi

View File

@ -0,0 +1,10 @@
import type { CheckoutEndpoint } from '.'
const submitCheckout: CheckoutEndpoint['handlers']['submitCheckout'] = async ({
res,
}) => {
// Return cart and errors
res.status(200).json({ data: null, errors: [] })
}
export default submitCheckout

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1,51 @@
import { verify, TokenExpiredError } from 'jsonwebtoken'
import type { CustomerEndpoint, Handler } from '.'
const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] =
async ({
req: request,
res: response,
config: { apiToken, customerTokenCookie, service },
}: Handler) => {
const token = request.cookies[customerTokenCookie]
if (token) {
const decoded = verify(token, apiToken)
try {
const customer = await service.client.getUserById((decoded as any).id)
if (!customer) {
return response.status(400).json({
data: null,
errors: [{ message: 'Customer not found', code: 'not_found' }],
})
}
return response.status(200).json({
data: {
customer,
},
})
} catch (error) {
if (error instanceof TokenExpiredError) {
response.status(401).json({
data: null,
errors: [
{
message: 'Jwt expired',
code: 'token_expired_error',
},
],
})
}
throw error
}
}
response.status(200).json({ data: null })
}
export default getLoggedInCustomer

View File

@ -0,0 +1,24 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import customerEndpoint from '@vercel/commerce/api/endpoints/customer'
import type { CustomerSchema } from '@vercel/commerce/types/customer'
import { OlistAPI } from '../../../api'
import getLoggedInCustomer from './get-logged-in-customer'
import type { Handler as HandlerAPI } from '../../../types/api'
export type CustomerAPI = GetAPISchema<OlistAPI, CustomerSchema>
export type CustomerEndpoint = CustomerAPI['endpoint']
export const handlers: CustomerEndpoint['handlers'] = { getLoggedInCustomer }
export type Handler = { body: any } & HandlerAPI
const customerApi = createEndpoint<CustomerAPI>({
handler: customerEndpoint,
handlers,
})
export default customerApi

View File

@ -0,0 +1,25 @@
import { createEndpoint } from '@vercel/commerce/api'
import loginEndpoint from '@vercel/commerce/api/endpoints/login'
import type { GetAPISchema } from '@vercel/commerce/api'
import type { LoginBody, LoginSchema } from '@vercel/commerce/types/login'
import login from './login'
import type { OlistAPI } from '../../../api'
import type { Handler as HandlerAPI } from '../../../types/api'
export type LoginAPI = GetAPISchema<OlistAPI, LoginSchema>
export type LoginEndpoint = LoginAPI['endpoint']
export type Handler = { body: LoginBody } & HandlerAPI
export const handlers: LoginEndpoint['handlers'] = { login }
const loginApi = createEndpoint<LoginAPI>({
handler: loginEndpoint,
handlers,
})
export default loginApi

View File

@ -0,0 +1,63 @@
import { sign } from 'jsonwebtoken'
import { serialize } from 'cookie'
import { FetcherError } from '@vercel/commerce/utils/errors'
import type { Handler, LoginEndpoint } from '.'
export const invalidCredentials = /email and\/or password invalid/i
const login: LoginEndpoint['handlers']['login'] = async ({
res: response,
body: { email, password },
config: { apiToken, customerTokenCookie, service },
}: Handler) => {
if (!email || !password) {
return response.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
try {
const { id } = await service.client.login({ email, password })
if (id) {
response.setHeader('Set-Cookie', [
serialize(
customerTokenCookie,
sign({ id: id.toString() }, apiToken, { expiresIn: 60 * 60 }),
{
maxAge: 60 * 60,
expires: new Date(Date.now() + 60 * 60 * 1000),
secure: process.env.NODE_ENV === 'production',
path: '/',
sameSite: 'lax',
}
),
])
}
} catch (error) {
if (
error instanceof FetcherError &&
invalidCredentials.test(error.message)
) {
return response.status(401).json({
data: null,
errors: [
{
message:
'Cannot find an account that matches the provided credentials',
code: 'invalid_credentials',
},
],
})
}
throw error
}
response.status(200).json({ data: null })
}
export default login

View File

@ -0,0 +1,25 @@
import { createEndpoint } from '@vercel/commerce/api'
import logoutEndpoint from '@vercel/commerce/api/endpoints/logout'
import { LogoutSchema, LogoutTypes } from '@vercel/commerce/types/logout'
import type { GetAPISchema } from '@vercel/commerce/api'
import logout from './logout'
import type { OlistAPI } from '../../../api'
import type { Handler as HandlerAPI } from '../../../types/api'
export type LogoutAPI = GetAPISchema<OlistAPI, LogoutSchema>
export type LogoutEndpoint = LogoutAPI['endpoint']
export const handlers: LogoutEndpoint['handlers'] = { logout }
export type Handler = { body: LogoutTypes['body'] } & HandlerAPI
const logoutApi = createEndpoint<LogoutAPI>({
handler: logoutEndpoint,
handlers,
})
export default logoutApi

View File

@ -0,0 +1,21 @@
import { serialize } from 'cookie'
import type { Handler, LogoutEndpoint } from '.'
const logout: LogoutEndpoint['handlers']['logout'] = async ({
res: response,
body: { redirectTo },
config: { customerTokenCookie },
}: Handler) => {
response.setHeader(
'Set-Cookie',
serialize(customerTokenCookie, '', { maxAge: -1, path: '/' })
)
if (redirectTo?.startsWith('/')) {
response.redirect(redirectTo)
} else {
response.status(200).json({ data: null })
}
}
export default logout

View File

@ -0,0 +1,25 @@
import { createEndpoint } from '@vercel/commerce/api'
import signupEndpoint from '@vercel/commerce/api/endpoints/signup'
import type { GetAPISchema } from '@vercel/commerce/api'
import type { SignupBody, SignupSchema } from '@vercel/commerce/types/signup'
import signup from './signup'
import type { OlistAPI } from '../../../api'
import type { Handler as HandlerAPI } from '../../../types/api'
export type SignupAPI = GetAPISchema<OlistAPI, SignupSchema>
export type SignupEndpoint = SignupAPI['endpoint']
export const handlers: SignupEndpoint['handlers'] = { signup }
export type Handler = { body: SignupBody } & HandlerAPI
const singupApi = createEndpoint<SignupAPI>({
handler: signupEndpoint,
handlers,
})
export default singupApi

View File

@ -0,0 +1,48 @@
import { sign } from 'jsonwebtoken'
import { serialize } from 'cookie'
import type { Handler, SignupEndpoint } from '.'
const signup: SignupEndpoint['handlers']['signup'] = async ({
res: response,
body: { firstName, lastName, email, password },
config: { apiToken, customerTokenCookie, service },
}: Handler) => {
if (!(firstName && lastName && email && password)) {
return response.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
try {
const { id } = await service.client.signup({
firstName,
lastName,
email,
password,
passwordConfirmation: password,
})
if (id) {
response.setHeader('Set-Cookie', [
serialize(
customerTokenCookie,
sign({ id: id.toString() }, apiToken, { expiresIn: 60 * 60 }),
{
maxAge: 60 * 60,
expires: new Date(Date.now() + 60 * 60 * 1000),
secure: process.env.NODE_ENV === 'production',
path: '/',
sameSite: 'lax',
}
),
])
}
} catch (error) {
throw error
}
response.status(200).json({ data: null })
}
export default signup

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1,86 @@
import { getCommerceApi as commerceApi } from '@vercel/commerce/api'
import type { CommerceAPI, CommerceAPIConfig } from '@vercel/commerce/api'
import type { VndaServiceSingleton } from '@vnda/headless-framework'
import createGraphqlFetcher from './utils/fetch-graphql'
import { client } from '../api/utils/fetch'
import getPage from '../api/operations/get-page'
import getProduct from '../api/operations/get-product'
import getSiteInfo from '../api/operations/get-site-info'
import getAllPages from '../api/operations/get-all-pages'
import getAllProducts from '../api/operations/get-all-products'
import getAllProductPaths from '../api/operations/get-all-product-paths'
import {
API_URL,
API_VERSION,
CART_COOKIE,
CUSTOMER_COOKIE,
TOKEN_COOKIE,
API_TOKEN,
STORE_DOMAIN,
CART_TOKEN_COOKIE,
CUSTOMER_TOKEN_COOKIE,
} from '../constants'
if (!API_TOKEN) {
throw new Error(
`The environment variable NEXT_PUBLIC_OLIST_STOREFRONT_ACCESS_TOKEN is missing and it's required to access your store`
)
}
if (!STORE_DOMAIN) {
throw new Error(
`The environment variable NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN is missing and it's required to access your store`
)
}
export interface OlistConfig extends CommerceAPIConfig {
commerceUrl: string
service: VndaServiceSingleton
apiVersion: string
tokenCookie: string
storeDomain: string
cartTokenCookie: string
customerTokenCookie: string
}
const ONE_DAY = 60 * 60 * 24
const config: OlistConfig = {
commerceUrl: API_URL,
apiToken: API_TOKEN,
storeDomain: STORE_DOMAIN,
apiVersion: API_VERSION,
cartCookie: CART_COOKIE,
customerCookie: CUSTOMER_COOKIE,
tokenCookie: TOKEN_COOKIE,
cartTokenCookie: CART_TOKEN_COOKIE,
customerTokenCookie: CUSTOMER_TOKEN_COOKIE,
cartCookieMaxAge: ONE_DAY * 30,
fetch: createGraphqlFetcher(() => getCommerceApi().getConfig()),
service: client(API_TOKEN, STORE_DOMAIN),
}
const operations = {
getAllPages,
getPage,
getSiteInfo,
getAllProductPaths,
getAllProducts,
getProduct,
}
export const provider = { config, operations }
export type Provider = typeof provider
export type OlistAPI<P extends Provider = Provider> = CommerceAPI<P | any>
export function getCommerceApi<P extends Provider>(
customProvider: P = provider as any
): OlistAPI<P> {
return commerceApi(customProvider as any)
}

View File

@ -0,0 +1,22 @@
import { GetAllPagesOperation } from '@vercel/commerce/types/page'
import type { OlistConfig } from '../'
export type Page = { url: string }
export type GetAllPagesResult = { pages: Page[] }
export default function getAllPagesOperation() {
async function getAllPages<T extends GetAllPagesOperation>({
config,
preview,
}: {
url?: string
config?: Partial<OlistConfig>
preview?: boolean
} = {}): Promise<T['data']> {
return Promise.resolve({
pages: [],
})
}
return getAllPages
}

View File

@ -0,0 +1,30 @@
import type { OperationContext } from '@vercel/commerce/api/operations'
import type { GetAllProductPathsOperation } from '@vercel/commerce/types/product'
import type { OlistConfig, Provider } from '../'
export type GetAllProductPathsResult = {
products: Array<{ path: string }>
}
export default function getAllProductPathsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProductPaths<T extends GetAllProductPathsOperation>({
config,
}: {
config?: Partial<OlistConfig>
} = {}): Promise<T['data']> {
const { service } = commerce.getConfig(config)
const products = await service.product.list({ limit: 20 })
return {
products: products.map((product) => ({
path: `/${product.slug}-${product.id}`,
})),
}
}
return getAllProductPaths
}

View File

@ -0,0 +1,28 @@
import type { OperationContext } from '@vercel/commerce/api/operations'
import type { GetAllProductsOperation } from '@vercel/commerce/types/product'
import type { OlistConfig, Provider } from '..'
import { mapItemRawToCommerceResponse } from '../../utils/product'
export default function getAllProductsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProducts<T extends GetAllProductsOperation>({
config,
}: {
query?: string
variables?: T['variables']
config?: Partial<OlistConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const { service } = commerce.getConfig(config)
const products = await service.product.list({ limit: 20 })
return {
products: products.map(mapItemRawToCommerceResponse),
}
}
return getAllProducts
}

View File

@ -0,0 +1,15 @@
import { GetPageOperation } from '@vercel/commerce/types/page'
export type Page = any
export type GetPageResult = { page?: Page }
export type PageVariables = {
id: number
}
export default function getPageOperation() {
async function getPage<T extends GetPageOperation>(): Promise<T['data']> {
return Promise.resolve({})
}
return getPage
}

View File

@ -0,0 +1,51 @@
import type { OperationContext } from '@vercel/commerce/api/operations'
import type { GetProductOperation } from '@vercel/commerce/types/product'
import type { OlistConfig, Provider } from '..'
import {
mapItemRawToCommerceResponse,
extractProductId,
mapImagesRawToCommerceResponse,
mapVariantsRawToCommerceResponse,
mapVariantsOptionsToCommerceCommerce,
} from '../../utils/product'
export default function getProductOperation({
commerce,
}: OperationContext<Provider>) {
async function getProduct<T extends GetProductOperation>({
config,
variables,
}: {
query?: string
variables?: T['variables']
config?: Partial<OlistConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const { service } = commerce.getConfig(config)
const productId = extractProductId(variables?.slug || variables?.path)
const productPromise = service.product.getById(Number(productId))
const variantsPromise = service.variant.list(Number(productId))
const imagesPromise = service.product.image.list(Number(productId))
const [product, images, variants] = await Promise.all([
productPromise,
imagesPromise,
variantsPromise,
])
return {
product: {
...mapItemRawToCommerceResponse(product),
images: mapImagesRawToCommerceResponse(images),
variants: mapVariantsRawToCommerceResponse(variants),
options: mapVariantsOptionsToCommerceCommerce(variants),
},
}
}
return getProduct
}

View File

@ -0,0 +1,44 @@
import type { OperationContext } from '@vercel/commerce/api/operations'
import type {
Category,
GetSiteInfoOperation,
} from '@vercel/commerce/types/site'
import type { OlistConfig, Provider } from '..'
export type GetSiteInfoResult<
T extends { categories: any[]; brands: any[] } = {
categories: Category[]
brands: any[]
}
> = T
export default function getSiteInfoOperation({
commerce,
}: OperationContext<Provider>) {
async function getSiteInfo<T extends GetSiteInfoOperation>({
config,
}: {
query?: string
variables?: any
config?: Partial<OlistConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const { service } = commerce.getConfig(config)
const tags = await service.tag.list({ paginate: { perPage: 10 } })
return {
categories:
tags?.map((category, idx) => ({
id: category.name,
name: category.name || `name-${idx}`,
slug: category.name || `slug-${idx}`,
path: `/${category.name}`,
})) || [],
brands: [],
}
}
return getSiteInfo
}

View File

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

View File

@ -0,0 +1,14 @@
import type { GraphQLFetcher } from '@vercel/commerce/api'
import type { OlistConfig } from '../'
import { FetcherError } from '@vercel/commerce/utils/errors'
const fetchGraphqlApi: (getConfig: () => OlistConfig) => GraphQLFetcher =
() => async () => {
throw new FetcherError({
errors: [{ message: 'GraphQL fetch is not implemented' }],
status: 500,
})
}
export default fetchGraphqlApi

View File

@ -0,0 +1,127 @@
import vercelFetch from '@vercel/fetch'
import { FetcherError } from '@vercel/commerce/utils/errors'
import { OlistConfig } from '..'
export type FetchRest = <T>(
method: string,
resource: string,
body?: Record<string, unknown>,
fetchOptions?: Record<string, any>
) => Promise<T>
// Get an instance to vercel fetch
const fetch = vercelFetch()
export async function fetchData<T>(opts: {
token: string
path: string
method: string
config: OlistConfig
fetchOptions?: Record<string, any>
body?: Record<string, unknown>
}): Promise<T> {
// Destructure opts
const { path, body, fetchOptions, config, token, method = 'GET' } = opts
// Do the request with the correct headers
const dataResponse = await fetch(
`${config.commerceUrl}/${config.apiVersion}${path}`,
{
...fetchOptions,
method,
headers: {
...fetchOptions?.headers,
'Content-Type': 'application/json',
accept: 'application/json, text/plain, */*',
authorization: `Bearer ${token}`,
},
body: body ? JSON.stringify(body) : undefined,
}
)
// If something failed getting the data response
if (!dataResponse.ok) {
// Get the body of it
const error = await dataResponse.textConverted()
// And return an error
throw new FetcherError({
errors: [{ message: error || dataResponse.statusText }],
status: dataResponse.status,
})
}
try {
const result = { data: await dataResponse.json() }
// Return data response as json
return result as unknown as Promise<T>
} catch (error) {
// If response is empty return it as text
return null as unknown as Promise<T>
}
}
export const createMiddlewareFetcher: (
getConfig: () => OlistConfig
) => FetchRest =
(getConfig) =>
async <T>(
method: string,
path: string,
body?: Record<string, unknown>,
fetchOptions?: Record<string, any>
) => {
// Get provider config
const config = getConfig()
// Get a token
const token = config.apiToken
// Return the data and specify the expected type
return fetchData<T>({
token,
fetchOptions,
method,
config,
path,
body,
})
}
export const createBuyerFetcher: (getConfig: () => OlistConfig) => FetchRest =
(getConfig) =>
async <T>(
method: string,
path: string,
body?: Record<string, unknown>,
fetchOptions?: Record<string, any>
) => {
// Get provider config
const config = getConfig()
// If a token was passed, set it on global
if (fetchOptions?.token) {
;(global as any).token = fetchOptions.token
}
// Get a token
if (!(global as any).token) {
;(global as any).token = config.apiToken
}
// Return the data and specify the expected type
const data = await fetchData<T>({
token: (global as any).token as string,
fetchOptions,
config,
method,
path,
body,
})
return {
...data,
meta: { token: (global as any).token as string },
}
}

View File

@ -0,0 +1,9 @@
import {
VndaServiceInstance,
VndaServiceSingleton,
} from '@vnda/headless-framework'
export const client = (
apiToken: string,
shopHost: string
): VndaServiceSingleton => VndaServiceInstance(apiToken, { shopHost })

View File

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

View File

@ -0,0 +1,43 @@
import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types'
import { CommerceError } from '@vercel/commerce/utils/errors'
import useLogin, { UseLogin } from '@vercel/commerce/auth/use-login'
import type { LoginHook } from '@vercel/commerce/types/login'
import useCustomer from '../customer/use-customer'
export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<LoginHook> = {
fetchOptions: {
url: '/api/login',
method: 'POST',
},
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message: 'An email and password are required to login',
})
}
return fetch({
...options,
body: { email, password },
})
},
useHook: ({ fetch }) =>
function useHook() {
const { mutate } = useCustomer()
return useCallback(
async function login(input) {
const data = await fetch({ input })
await mutate()
return data
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,30 @@
import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types'
import useLogout, { UseLogout } from '@vercel/commerce/auth/use-logout'
import type { LogoutHook } from '@vercel/commerce/types/logout'
import useCustomer from '../customer/use-customer'
export default useLogout as UseLogout<typeof handler>
export const handler: MutationHook<LogoutHook> = {
fetchOptions: {
url: '/api/logout',
method: 'GET',
},
useHook: ({ fetch }) =>
function useHook() {
const { mutate } = useCustomer()
return useCallback(
async function logout() {
const data = await fetch()
await mutate(null, false)
return data
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,48 @@
import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types'
import { CommerceError } from '@vercel/commerce/utils/errors'
import useSignup, { UseSignup } from '@vercel/commerce/auth/use-signup'
import type { SignupHook } from '@vercel/commerce/types/signup'
import useCustomer from '../customer/use-customer'
export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<SignupHook> = {
fetchOptions: {
url: '/api/signup',
method: 'POST',
},
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',
})
}
return fetch({
...options,
body: { firstName, lastName, email, password },
})
},
useHook: ({ fetch }) =>
function useHook() {
const { mutate } = useCustomer()
return useCallback(
async function signup(input) {
const data = await fetch({ input })
await mutate()
return data
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,4 @@
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 useUpdateItem } from './use-update-item'

View File

@ -0,0 +1,49 @@
import { useCallback } from 'react'
import { CommerceError } from '@vercel/commerce/utils/errors'
import useAddItem, { UseAddItem } from '@vercel/commerce/cart/use-add-item'
import type { AddItemHook } from '@vercel/commerce/types/cart'
import type { MutationHook } from '@vercel/commerce/utils/types'
import useCart from './use-cart'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = {
fetchOptions: {
url: '/api/cart',
method: 'POST',
},
async fetcher({ input: item, options, fetch }) {
if (
item.quantity &&
(!Number.isInteger(item.quantity) || item.quantity! < 1)
) {
throw new CommerceError({
message: 'The item quantity has to be a valid integer greater than 0',
})
}
const data = await fetch({
...options,
body: { item },
})
return data
},
useHook: ({ fetch }) =>
function useHook() {
const { mutate } = useCart()
return useCallback(
async function addItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[mutate]
)
},
}

View File

@ -0,0 +1,33 @@
import { useMemo } from 'react'
import { SWRHook } from '@vercel/commerce/utils/types'
import useCart, { UseCart } from '@vercel/commerce/cart/use-cart'
import type { GetCartHook } from '@vercel/commerce/types/cart'
export default useCart as UseCart<typeof handler>
export const handler: SWRHook<GetCartHook> = {
fetchOptions: {
url: '/api/cart',
method: 'GET',
},
useHook: ({ useData }) =>
function useHook(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,66 @@
import { useCallback } from 'react'
import { ValidationError } from '@vercel/commerce/utils/errors'
import useRemoveItem, {
UseRemoveItem,
} from '@vercel/commerce/cart/use-remove-item'
import type {
Cart,
LineItem,
RemoveItemHook,
} from '@vercel/commerce/types/cart'
import type {
MutationHookContext,
HookFetcherContext,
} from '@vercel/commerce/utils/types'
import useCart from './use-cart'
export type RemoveItemFn<T = any> = T extends LineItem
? (input?: RemoveItemActionInput<T>) => Promise<Cart | null | undefined>
: (input: RemoveItemActionInput<T>) => Promise<Cart | null>
export type RemoveItemActionInput<T = any> = T extends LineItem
? Partial<RemoveItemHook['actionInput']>
: RemoveItemHook['actionInput']
export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler = {
fetchOptions: {
url: '/api/cart',
method: 'DELETE',
},
async fetcher({
input: { itemId },
options,
fetch,
}: HookFetcherContext<RemoveItemHook>) {
return await fetch({ ...options, body: { itemId } })
},
useHook: ({ fetch }: MutationHookContext<RemoveItemHook>) =>
function useHook<T extends LineItem | undefined = undefined>(
ctx: { item?: T } = {}
) {
const { item } = ctx
const { mutate } = useCart()
const removeItem: RemoveItemFn<LineItem> = async (input) => {
const itemId = input?.id ?? item?.id
if (!itemId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
})
}
const data = await fetch({ input: { itemId } })
await mutate(data, false)
return data
}
// eslint-disable-next-line react-hooks/exhaustive-deps
return useCallback(removeItem as RemoveItemFn<T>, [fetch, mutate])
},
}

View File

@ -0,0 +1,94 @@
import { useCallback } from 'react'
import debounce from 'lodash.debounce'
import { MutationHook } from '@vercel/commerce/utils/types'
import { ValidationError } from '@vercel/commerce/utils/errors'
import useUpdateItem, {
UseUpdateItem,
} from '@vercel/commerce/cart/use-update-item'
import type { UpdateItemHook, LineItem } from '@vercel/commerce/types/cart'
import type {
HookFetcherContext,
MutationHookContext,
} from '@vercel/commerce/utils/types'
import useCart from './use-cart'
import { handler as removeItemHandler } from './use-remove-item'
export type UpdateItemActionInput<T = any> = T extends LineItem
? Partial<UpdateItemHook['actionInput']>
: UpdateItemHook['actionInput']
export default useUpdateItem as UseUpdateItem<any>
export const handler: MutationHook<any> = {
fetchOptions: {
url: '/api/cart',
method: 'PUT',
},
async fetcher({
input: { itemId, item },
options,
fetch,
}: HookFetcherContext<UpdateItemHook>) {
if (Number.isInteger(item.quantity)) {
if (item.quantity! < 1) {
return removeItemHandler.fetcher({
options: removeItemHandler.fetchOptions,
input: { itemId },
fetch,
})
}
} else if (item.quantity) {
throw new ValidationError({
message: 'The item quantity has to be a valid integer',
})
}
return await fetch({
...options,
body: { itemId, item },
})
},
useHook: ({ fetch }: MutationHookContext<UpdateItemHook>) =>
function useHook<T extends LineItem | undefined = undefined>(
ctx: {
item?: T
wait?: number
} = {}
) {
const { item } = ctx
const { mutate } = useCart() as any
// eslint-disable-next-line react-hooks/exhaustive-deps
return useCallback(
debounce(async (input: UpdateItemActionInput<T>) => {
const itemId = input.id ?? item?.id
const productId = input.productId ?? item?.productId
const variantId = input.productId ?? item?.variantId
if (!itemId || !productId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
})
}
const data = await fetch({
input: {
itemId,
item: {
productId,
variantId: variantId || '',
quantity: input.quantity,
},
},
})
await mutate(data, false)
return data
}, ctx.wait ?? 500),
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,2 @@
export { default as useSubmitCheckout } from './use-submit-checkout'
export { default as useCheckout } from './use-checkout'

View File

@ -0,0 +1,43 @@
import type { GetCheckoutHook } from '@vercel/commerce/types/checkout'
import { useMemo } from 'react'
import { SWRHook } from '@vercel/commerce/utils/types'
import useCheckout, {
UseCheckout,
} from '@vercel/commerce/checkout/use-checkout'
import useSubmitCheckout from './use-submit-checkout'
export default useCheckout as UseCheckout<typeof handler>
export const handler: SWRHook<GetCheckoutHook> = {
fetchOptions: {
url: '/api/checkout',
method: 'GET',
},
useHook: ({ useData }) =>
function useHook(input) {
const submit = useSubmitCheckout()
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems?.length ?? 0) <= 0
},
enumerable: true,
},
submit: {
get() {
return submit
},
enumerable: true,
},
}),
[response, submit]
)
},
}

View File

@ -0,0 +1,38 @@
import type { SubmitCheckoutHook } from '@vercel/commerce/types/checkout'
import type { MutationHook } from '@vercel/commerce/utils/types'
import { useCallback } from 'react'
import useSubmitCheckout, {
UseSubmitCheckout,
} from '@vercel/commerce/checkout/use-submit-checkout'
export default useSubmitCheckout as UseSubmitCheckout<typeof handler>
export const handler: MutationHook<SubmitCheckoutHook> = {
fetchOptions: {
url: '/api/checkout',
method: 'POST',
},
async fetcher({ input: item, options, fetch }) {
// @TODO: Make form validations in here, import generic error like import { CommerceError } from '@vercel/commerce/utils/errors'
// Get payment and delivery information in here
const data = await fetch({
...options,
body: { item },
})
return data
},
useHook: ({ fetch }) =>
function useHook() {
return useCallback(
async function onSubmitCheckout(input) {
const data = await fetch({ input })
return data
},
[fetch]
)
},
}

View File

@ -0,0 +1,10 @@
{
"provider": "olist",
"features": {
"wishlist": false,
"cart": true,
"search": true,
"customerAuth": false,
"customCheckout": false
}
}

View File

@ -0,0 +1,10 @@
export const CART_COOKIE = 'olist.vnda.cart'
export const CART_TOKEN_COOKIE = 'olist.vnda.cart.token'
export const TOKEN_COOKIE = 'olist.vnda.token'
export const CUSTOMER_COOKIE = 'olist.vnda.customer'
export const API_URL = `https://${process.env.NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN}/api`
export const API_VERSION = 'v2'
export const LOCALE = 'en-us'
export const STORE_DOMAIN = process.env.NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN
export const API_TOKEN = process.env.NEXT_PUBLIC_OLIST_STOREFRONT_ACCESS_TOKEN
export const CUSTOMER_TOKEN_COOKIE = 'olist.vnda.customer.token'

View File

@ -0,0 +1,18 @@
import useAddItem, {
UseAddItem,
} from '@vercel/commerce/customer/address/use-add-item'
import type { MutationHook } from '@vercel/commerce/utils/types'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: '',
},
async fetcher({ input, options, fetch }) {},
useHook:
({ fetch }) =>
() =>
async () => ({}),
}

View File

@ -0,0 +1,18 @@
import useAddItem, {
UseAddItem,
} from '@vercel/commerce/customer/card/use-add-item'
import type { MutationHook } from '@vercel/commerce/utils/types'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: '',
},
async fetcher({ input, options, fetch }) {},
useHook:
({ fetch }) =>
() =>
async () => ({}),
}

View File

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

View File

@ -0,0 +1,29 @@
import { SWRHook } from '@vercel/commerce/utils/types'
import useCustomer, {
UseCustomer,
} from '@vercel/commerce/customer/use-customer'
import type { CustomerHook } from '@vercel/commerce/types/customer'
export default useCustomer as UseCustomer<typeof handler>
export const handler: SWRHook<CustomerHook> = {
fetchOptions: {
url: '/api/customer',
method: 'GET',
},
async fetcher({ options, fetch }) {
const data = await fetch(options)
return data?.customer ?? null
},
useHook:
({ useData }) =>
(input) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
},
}

View File

@ -0,0 +1,17 @@
import { Fetcher } from '@vercel/commerce/utils/types'
const clientFetcher: Fetcher = async ({ method, url, body }) => {
const response = await fetch(url!, {
method,
body: body ? JSON.stringify(body) : undefined,
headers: {
'Content-Type': 'application/json',
},
})
.then((response) => response.json())
.then((response) => response.data)
return response
}
export default clientFetcher

View File

@ -0,0 +1,12 @@
import { olistProvider, OlistProvider } from './provider'
import {
getCommerceProvider,
useCommerce as useCoreCommerce,
} from '@vercel/commerce'
export { olistProvider }
export type { OlistProvider }
export const CommerceProvider = getCommerceProvider(olistProvider)
export const useCommerce = () => useCoreCommerce()

View File

@ -0,0 +1,16 @@
const commerce = require('./commerce.config.json')
module.exports = {
commerce,
images: {
domains: [
'localhost',
'b0.vnda.com.br',
'b1.vnda.com.br',
'b2.vnda.com.br',
'b3.vnda.com.br',
'b4.vnda.com.br',
'cdn.vnda.dev',
],
},
}

View File

@ -0,0 +1,2 @@
export { default as usePrice } from './use-price'
export { default as useSearch } from './use-search'

View File

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

View File

@ -0,0 +1,39 @@
import { SWRHook } from '@vercel/commerce/utils/types'
import useSearch, { UseSearch } from '@vercel/commerce/product/use-search'
import { SearchProductsHook } from '@vercel/commerce/types/product'
export default useSearch as UseSearch<typeof handler>
export const handler: SWRHook<SearchProductsHook> = {
fetchOptions: {
url: '/api/catalog/products',
method: 'GET',
},
fetcher({ input: { search, categoryId, brandId, sort }, options, fetch }) {
const url = new URLSearchParams()
if (search) url.set('search', String(search))
if (categoryId) url.set('categoryId', String(categoryId))
if (brandId) url.set('brandId', String(brandId))
if (sort) url.set('sort', String(sort))
return fetch({
url: `${options.url!}?${url}`,
method: options.method,
})
},
useHook: ({ useData }) =>
function useHook(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,51 @@
import { handler as useCart } from './cart/use-cart'
import { handler as useAddCartItem } from './cart/use-add-item'
import { handler as useUpdateCartItem } from './cart/use-update-item'
import { handler as useRemoveCartItem } 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 { handler as useCheckout } from './checkout/use-checkout'
import { handler as useSubmitCheckout } from './checkout/use-submit-checkout'
import { handler as useAddCardItem } from './customer/card/use-add-item'
import { handler as useAddAddressItem } from './customer/address/use-add-item'
import { CART_COOKIE, CART_TOKEN_COOKIE, LOCALE } from './constants'
import { default as fetcher } from './fetcher'
export const olistProvider = {
locale: LOCALE,
cartCookie: CART_COOKIE,
cartTokenCookie: CART_TOKEN_COOKIE,
fetcher,
cart: {
useCart,
useAddItem: useAddCartItem,
useUpdateItem: useUpdateCartItem,
useRemoveItem: useRemoveCartItem,
},
checkout: {
useCheckout,
useSubmitCheckout,
},
customer: {
useCustomer,
card: {
useAddItem: useAddCardItem,
},
address: {
useAddItem: useAddAddressItem,
},
},
products: { useSearch },
auth: { useLogin, useLogout, useSignup },
}
export type OlistProvider = typeof olistProvider

View File

@ -0,0 +1,12 @@
import { NextApiRequest, NextApiResponse } from 'next'
import type { OlistConfig } from '../api'
export type Handler = {
req: NextApiRequest
res: NextApiResponse
config: OlistConfig
}
export type FetcherResponse<T> = {
data: T
}

View File

@ -0,0 +1,95 @@
export * from '@vercel/commerce/types/cart'
export type RawCartItemRequest = {
sku: string
quantity: number
extra?: {}
place_id?: number
}
export type RawCartResponse = {
agent: string
billing_address_id: number
channel: string
client_id: number
code: string
coupon_code: string
discount: {
id: number
name: string
description: string
facebook: false
valid_to: string
seal_uid: string
seal_url: string
start_at: string
end_at: string
email: string
cpf: string
tags: string
}
discount_price: number
extra: Record<string, any>
id: number
items: RawCartItemResponse[]
items_count: number
shipping_address_id: number
shipping_method: string
shipping_methods: [
{
package: string
name: string
label: string
price: string
delivery_days: string
delivery_type: string
description: string
short_description: string
}
]
shipping_price: number
subtotal: number
token: string
total: number
total_for_deposit: number
total_for_slip: number
total_for_pix: number
updated_at: string
rebate_token: string
rebate_discount: number
handling_days: number
subtotal_discount: number
total_discount: number
}
export type RawCartItemResponse = {
id: string
available_quantity: number
delivery_days: number
extra: Record<string, any>
place_id: number
price: number
product_id: number
product_name: string
product_reference: string
product_url: string
product_type: string
quantity: number
seller: string
seller_name: string
subtotal: number
total: number
updated_at: string
has_customizations: boolean
image_url: string
variant_attributes: Record<string, any>
variant_min_quantity: number
variant_name: string
variant_price: number
variant_intl_price: number
variant_properties: Record<
string,
{ name: string; value: string; defining: boolean }
>
variant_sku: string
}

View File

@ -0,0 +1,8 @@
export interface RawCategory {
id: string
name: string
title: string
subtitle: null | string
description: null | string
image_url: null | string
}

View File

@ -0,0 +1,4 @@
import * as Core from '@vercel/commerce/types/checkout'
export type CheckoutTypes = Core.CheckoutTypes
export type CheckoutSchema = Core.CheckoutSchema<CheckoutTypes>

View File

@ -0,0 +1,51 @@
export * from '@vercel/commerce/types/customer'
export type RawCustomerResponse = {
id: number
first_name: string
last_name: string
email: string
gender: string
phone_area: string
phone: string
cpf: string
cnpj: string
ie: string
tags: string
lists: string[]
facebook_uid: string
liked_facebook_page: boolean
updated_at: string
birthdate: string
recent_address: [
{
id: string
first_name: string
last_name: string
company_name: string
street_name: string
street_number: string
neighborhood: string
complement: string
reference: string
city: string
state: string
zip: string
first_phone_area: string
first_phone: string
second_phone_area: string
second_phone: string
email: string
documents: {
cpf: string
cnpj: string
}
}
]
auth_token: string
last_confirmed_order_at: string
received_orders_count: number
confirmed_orders_count: number
canceled_orders_count: number
renew_password: boolean
}

7
packages/olist/src/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare global {
namespace NodeJS {
interface Global {
token: string | undefined | null
}
}
}

3
packages/olist/src/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare module globalThis {
var token: string | null | undefined
}

View File

@ -0,0 +1,6 @@
export * from '@vercel/commerce/types/login'
export type RawLoginResponse = {
id: number
auth_token: string
}

View File

@ -0,0 +1 @@
export * from '@vercel/commerce/types/logout'

View File

@ -0,0 +1,63 @@
export interface RawVariantProperty {
name: string
value: string
defining?: boolean
}
export interface RawVariantProperties {
[key: string]: RawVariantProperty
}
export interface RawVariant {
id: number
main: boolean
available: boolean
sku: string
name: string
slug: string
min_quantity: number
quantity: number
quantity_sold: number
stock: number
custom_attributes: object
properties?: RawVariantProperties
updated_at: string
price: number
installments: number[]
available_quantity: number
weight: number
width: number
height: number
length: number
handling_days: number
inventories: any[]
sale_price: number
intl_price: number
image_url: string
product_id: number
barcode: string
norder: number
}
export interface RawProduct {
id: number
name: string
active: boolean
available: boolean
description: string | null
html_description?: string | null
image_url?: string | null
price: number
sale_price: number
reference: string
slug: string
url: string
variants?: RawVariant[]
}
export interface RawProductImages {
id: number
url: string
updated_at: string
variant_ids?: string[] | number[]
}

View File

@ -0,0 +1,58 @@
export * from '@vercel/commerce/types/signup'
export type RawSignUpRequest = {
email: string
first_name: string
last_name: string
birthdate: string
gender: string
tags: string
lists: string[]
password: string
password_confirmation: string
terms: boolean
}
export type RawSignUpResponse = {
id: number
first_name: string
last_name: string
email: string
gender: string
phone_area: string
phone: string
cpf: string
cnpj: string
ie: string
tags: string
lists: string[]
facebook_uid: string
liked_facebook_page: boolean
updated_at: string
birthdate: string
recent_address: [
{
id: string
first_name: string
last_name: string
company_name: string
street_name: string
street_number: string
neighborhood: string
complement: string
reference: string
city: string
state: string
zip: string
first_phone_area: string
first_phone: string
second_phone_area: string
second_phone: string
email: string
documents: {
cpf: string
cnpj: string
}
}
]
}

View File

@ -0,0 +1,77 @@
import type { Cart, CartItemBody, LineItem } from '@vercel/commerce/types/cart'
import type {
Cart as CartRequest,
CartItem,
CartAddItemRequest,
} from '@vnda/headless-framework'
import { getLastItem } from './product'
export const mapCommerceToRawRequest = ({
quantity,
variantId,
}: CartItemBody): CartAddItemRequest => ({
quantity: quantity || 1,
sku: variantId,
})
export const mapItemRawToCommerceResponse = ({
id,
quantity,
productId,
productName,
productUrl,
variantName,
variantSku,
variantPrice,
imageUrl,
availableQuantity,
variantProperties,
}: CartItem): LineItem => ({
id: id.toString(),
variantId: variantSku,
productId: productId.toString(),
name: productName,
quantity: quantity,
discounts: [],
path: getLastItem(productUrl),
variant: {
id: variantSku,
sku: variantSku,
name: variantName,
requiresShipping: false,
price: variantPrice,
listPrice: variantPrice,
isInStock: availableQuantity > 0,
availableForSale: availableQuantity > 0,
image: {
url: imageUrl || 'http://localhost:3000/',
},
},
options: Object.values(variantProperties).map(({ name, value }) => ({
name: name === 'Cor' ? 'Color' : name,
value,
})),
})
export const mapRawToCommerceResponse = ({
id,
clientId,
discountPrice,
subtotal,
total,
items,
}: CartRequest): Cart => ({
id: id?.toString(),
customerId: clientId?.toString(),
createdAt: '',
currency: {
code: 'BRL',
},
taxesIncluded: false,
lineItems: items?.map(mapItemRawToCommerceResponse),
lineItemsSubtotalPrice: 0,
subtotalPrice: subtotal,
totalPrice: total,
discounts: [{ value: discountPrice }],
})

View File

@ -0,0 +1,122 @@
import type {
Product,
ProductImage,
ProductOption,
ProductVariant,
} from '@vercel/commerce/types/product'
import type {
Variant,
Product as ProductRequest,
ProductImage as ProductImageRequest,
} from '@vnda/headless-framework'
export const getLastItem = (path: string) =>
path.substring(path.lastIndexOf('/') + 1)
const getRelativePaths = (url: string) =>
!['http:', 'https:'].includes(url) ? `https:${url}` : url
export const getVariantOptions = (variant: Variant): ProductOption[] =>
Object.entries(variant.properties || [])
.filter(([_, { value }]) => !!value && !!value)
.map(([_, property]) => ({
__typename: 'MultipleChoiceOption',
id: `option-${property.name.toLocaleLowerCase()}`,
displayName: property.name,
values: [{ label: property.value }],
}))
export const mapImagesRawToCommerceResponse = (
images: ProductImageRequest[]
): ProductImage[] =>
(images || []).map((item) => ({
url: getRelativePaths(item.url),
alt: `image-for-item-id-${item.id}`,
}))
export const mapVariantsOptionsToCommerceCommerce = (
variants: Variant[]
): ProductOption[] => {
if (!variants || !variants.length) {
return []
}
let options: ProductOption[] = []
variants.forEach((variant) => {
const opts = getVariantOptions(variant)
opts.forEach((opt) => {
if (options.filter((option) => option.id !== opt.id)) {
options.push({
id: opt.id,
displayName: opt.displayName,
values: opt.values,
})
}
})
})
var ids = Array.from(new Set(options.map((d) => d.id)))
options = ids.map((id) => {
let values: any[] = []
options
.filter((option) => option.id === id)
.map(({ values }) =>
values?.forEach(({ label, hexColors }) => {
if (label && !values.find((value) => value?.label === label)) {
values.push({ label, hexColors })
}
})
)
return {
id,
displayName: options.find((option) => option.id === id)?.displayName!,
values: Array.from(new Set(values)),
}
})
return options
}
export const mapVariantsRawToCommerceResponse = (
variants: Variant[]
): ProductVariant[] =>
(variants || []).map((variant) => ({
id: variant.sku,
options: getVariantOptions(variant),
availableForSale: variant.stock ? variant.stock > 0 : false,
}))
export const mapItemRawToCommerceResponse = (
product: ProductRequest
): Product => ({
id: product.id.toString(),
description: product.description || '',
images: product.imageUrl ? [{ url: getRelativePaths(product.imageUrl) }] : [],
name: product.name,
price: {
value: product.price,
currencyCode: 'BRL',
salePrice: product.salePrice,
},
...(product.htmlDescription && {
descriptionHtml: product.htmlDescription,
}),
sku: product.reference,
slug: `${product.slug}-${product.id.toString()}`,
path: product.url ? getLastItem(product.url) : undefined,
options: [],
variants: [],
})
export const extractProductId = (value?: string) => {
if (!value || typeof value !== 'string') {
return value
}
return value.substring(value.lastIndexOf('-') + 1)
}

View File

@ -0,0 +1,15 @@
import type { RawSignUpRequest, SignupBody } from '../types/signup'
export const mapCommerceToRawRequest = ({
email,
firstName,
lastName,
password,
}: SignupBody): Partial<RawSignUpRequest> => ({
first_name: firstName,
last_name: lastName,
email,
password,
password_confirmation: password,
terms: true,
})

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,43 @@
import { HookFetcher } from '@vercel/commerce/utils/types'
import type { Product } from '@vercel/commerce/types/product'
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

@ -0,0 +1,20 @@
export async function build(task, opts) {
await task
.source('src/**/*.+(ts|tsx|js)')
.swc({ dev: opts.dev, outDir: 'dist', baseUrl: 'src' })
.target('dist')
.source('src/**/*.+(cjs|json)')
.target('dist')
task.$.log('Compiled src files')
}
export async function release(task) {
await task.clear('dist').start('build')
}
export default async function dev(task) {
const opts = { dev: true }
await task.clear('dist')
await task.start('build', opts)
await task.watch('src/**/*.+(ts|tsx|js|cjs|json)', 'build', opts)
}

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"outDir": "dist",
"baseUrl": "src",
"lib": ["dom", "dom.iterable", "esnext"],
"declaration": true,
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"incremental": true,
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@ -65,7 +65,7 @@
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -70,7 +70,7 @@
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -63,7 +63,7 @@
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -71,7 +71,7 @@
"@types/react": "^17.0.38",
"dotenv": "^12.0.3",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -66,7 +66,7 @@
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -6,7 +6,7 @@ const requireConfig = <T>(isomorphicConfig: T, key: keyof T) => {
if (typeof valueUnderKey === 'undefined') {
throw new MissingConfigurationValueError(
`Value for configuration key ${key} was undefined.`
`Value for configuration key ${key as string} was undefined.`
)
}

View File

@ -66,7 +66,7 @@
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -68,7 +68,7 @@
"@types/node": "^17.0.8",
"@types/react": "^17.0.38",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"next": "^12",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -10,6 +10,7 @@
# @vercel/commerce-kibocommerce
# @vercel/commerce-commercejs
# @vercel/commerce-sfcc
# @vercel/commerce-olist
COMMERCE_PROVIDER=
BIGCOMMERCE_STOREFRONT_API_URL=
@ -35,6 +36,9 @@ NEXT_PUBLIC_SALEOR_CHANNEL=
NEXT_PUBLIC_VENDURE_SHOP_API_URL=
NEXT_PUBLIC_VENDURE_LOCAL_URL=
NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN=
NEXT_PUBLIC_OLIST_STOREFRONT_ACCESS_TOKEN=
ORDERCLOUD_CLIENT_ID=
ORDERCLOUD_CLIENT_SECRET=
STRIPE_SECRET=
@ -53,4 +57,4 @@ SFCC_CLIENT_ID=
SFCC_CLIENT_SECRET=
SFCC_ORG_ID=
SFCC_SHORT_CODE=
SFCC_SITE_ID=RefArch
SFCC_SITE_ID=RefArch

View File

@ -20,6 +20,7 @@ const PROVIDERS = [
'@vercel/commerce-spree',
'@vercel/commerce-commercejs',
'@vercel/commerce-sfcc',
'@vercel/commerce-olist',
]
function getProviderName() {
@ -31,6 +32,8 @@ function getProviderName() {
? '@vercel/commerce-shopify'
: process.env.NEXT_PUBLIC_SWELL_STORE_ID
? '@vercel/commerce-swell'
: process.env.NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN
? '@vercel/commerce-olist'
: '@vercel/commerce-local')
)
}
@ -40,6 +43,7 @@ function withCommerceConfig(nextConfig = {}) {
{ commerce: { provider: getProviderName() } },
nextConfig
)
const { commerce } = config
const { provider } = commerce

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