Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Chloe 2023-03-08 17:23:03 +07:00
commit 1f2634a38c
610 changed files with 25283 additions and 18280 deletions

3
.gitignore vendored
View File

@ -1,9 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
node_modules node_modules
.pnp .pnp
.pnp.js .pnp.js
.pnpm-debug.log
# testing # testing
coverage coverage

View File

@ -20,11 +20,11 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/)
## Run minimal version locally ## Run minimal version locally
> To run a minimal version of Next.js Commerce you can start with the default local provider `@vercel/commerce-local` that has disabled all features (cart, auth) and use static files for the backend > To run a minimal version of Next.js Commerce you can start with the default local provider `@vercel/commerce-local` that has all features disabled (cart, auth) and uses static files for the backend
```bash ```bash
yarn # run this command in root folder of the mono repo pnpm install & pnpm build # run these commands in the root folder of the mono repo
yarn dev pnpm dev # run this command in the site folder
``` ```
> If you encounter any problems while installing and running for the first time, please see the Troubleshoot section > If you encounter any problems while installing and running for the first time, please see the Troubleshoot section
@ -47,10 +47,10 @@ Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Sal
## Considerations ## Considerations
- `packages/commerce` contains all types, helpers and functions to be used as base to build a new **provider**. - `packages/commerce` contains all types, helpers and functions to be used as a base to build a new **provider**.
- **Providers** live under `packages`'s root folder and they will extend Next.js Commerce types and functionality (`packages/commerce`). - **Providers** live under `packages`'s root folder and they will extend Next.js Commerce types and functionality (`packages/commerce`).
- We have a **Features API** to ensure feature parity between the UI and the Provider. The UI should update accordingly and no extra code should be bundled. All extra configuration for features will live under `features` in `commerce.config.json` and if needed it can also be accessed programatically. - We have a **Features API** to ensure feature parity between the UI and the Provider. The UI should update accordingly and no extra code should be bundled. All extra configuration for features will live under `features` in `commerce.config.json` and if needed it can also be accessed programmatically.
- Each **provider** should add its corresponding `next.config.js` and `commerce.config.json` adding specific data related to the provider. For example in case of BigCommerce, the images CDN and additional API routes. - Each **provider** should add its corresponding `next.config.js` and `commerce.config.json` adding specific data related to the provider. For example in the case of BigCommerce, the images CDN and additional API routes.
## Configuration ## Configuration
@ -73,7 +73,7 @@ Every provider defines the features that it supports under `packages/{provider}/
#### Features Available #### Features Available
The following features can be enabled or disabled. This means that the UI will remove all code related to the feature. The following features can be enabled or disabled. This means that the UI will remove all code related to the feature.
For example: Turning `cart` off will disable Cart capabilities. For example: turning `cart` off will disable Cart capabilities.
- cart - cart
- search - search
@ -83,7 +83,7 @@ For example: Turning `cart` off will disable Cart capabilities.
#### How to turn Features on and off #### How to turn Features on and off
> NOTE: The selected provider should support the feature that you are toggling. (This means that you can't turn wishlist on if the provider doesn't support this functionality out the box) > NOTE: The selected provider should support the feature that you are toggling. (This means that you can't turn wishlist on if the provider doesn't support this functionality out of the box)
- Open `site/commerce.config.json` - Open `site/commerce.config.json`
- You'll see a config file like this: - You'll see a config file like this:
@ -110,11 +110,12 @@ Our commitment to Open Source can be found [here](https://vercel.com/oss).
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device. 1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
2. Create a new branch `git checkout -b MY_BRANCH_NAME` 2. Create a new branch `git checkout -b MY_BRANCH_NAME`
3. Install the dependencies: `yarn` 3. Install the dependencies: `pnpm install`
4. Duplicate `site/.env.template` and rename it to `site/.env.local` 4. Build the packages: `pnpm build`
5. Add proper store values to `site/.env.local` 5. Duplicate `site/.env.template` and rename it to `site/.env.local`
6. Run `cd site` and `yarn dev` to build and watch for code changes 6. Add proper store values to `site/.env.local`
7. Run `yarn turbo run build` to check the build after your changes 7. Run `cd site` & `pnpm dev` to watch for code changes
8. Run `pnpm turbo run build` to check the build after your changes
## Work in progress ## Work in progress
@ -189,10 +190,10 @@ error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command. info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
``` ```
The error usually occurs when running yarn dev inside of the `/site/` folder after installing a fresh repository. The error usually occurs when running `pnpm dev` inside of the `/site/` folder after installing a fresh repository.
In order to fix this, run `yarn dev` in the monorepo root folder first. In order to fix this, run `pnpm build` in the monorepo root folder first.
> Using `yarn dev` from the root is recommended for developing, which will run watch mode on all packages. > Using `pnpm dev` from the root is recommended for developing, which will run watch mode on all packages.
</details> </details>

View File

@ -2,26 +2,22 @@
"name": "commerce", "name": "commerce",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"workspaces": [
"site",
"packages/*"
],
"scripts": { "scripts": {
"build": "turbo run build --scope=next-commerce --include-dependencies --no-deps", "build": "turbo run build --filter=next-commerce...",
"dev": "turbo run dev", "dev": "turbo run dev",
"start": "turbo run start", "start": "turbo run start",
"types": "turbo run types", "types": "turbo run types",
"prettier-fix": "prettier --write ." "prettier-fix": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"husky": "^7.0.4", "husky": "^8.0.1",
"prettier": "^2.5.1", "prettier": "^2.7.1",
"turbo": "^1.1.2" "turbo": "^1.4.6"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
"pre-commit": "turbo run lint" "pre-commit": "turbo run lint"
} }
}, },
"packageManager": "yarn@1.22.17" "packageManager": "pnpm@7.5.0"
} }

View File

@ -47,17 +47,20 @@
} }
}, },
"dependencies": { "dependencies": {
"@vercel/fetch": "^6.1.1", "@cfworker/uuid": "^1.12.4",
"@tsndr/cloudflare-worker-jwt": "^2.1.0",
"@vercel/commerce": "workspace:*",
"cookie": "^0.4.1", "cookie": "^0.4.1",
"immutability-helper": "^3.1.1", "immutability-helper": "^3.1.1",
"js-cookie": "^3.0.1",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"uuidv4": "^6.2.12" "uuidv4": "^6.2.13"
}, },
"peerDependencies": { "peerDependencies": {
"next": "^12", "next": "^13",
"react": "^17", "react": "^18",
"react-dom": "^17" "react-dom": "^18"
}, },
"devDependencies": { "devDependencies": {
"@taskr/clear": "^1.1.0", "@taskr/clear": "^1.1.0",
@ -67,15 +70,16 @@
"@types/jsonwebtoken": "^8.5.7", "@types/jsonwebtoken": "^8.5.7",
"@types/lodash.debounce": "^4.0.6", "@types/lodash.debounce": "^4.0.6",
"@types/node": "^17.0.8", "@types/node": "^17.0.8",
"@types/react": "^17.0.38", "@types/node-fetch": "^2.6.2",
"@types/react": "^18.0.14",
"lint-staged": "^12.1.7", "lint-staged": "^12.1.7",
"next": "^12.0.8", "next": "^13.0.6",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"react": "^17.0.2", "react": "^18.2.0",
"react-dom": "^17.0.2", "react-dom": "^18.2.0",
"taskr": "^1.1.0", "taskr": "^1.1.0",
"taskr-swc": "^0.0.1", "taskr-swc": "^0.0.1",
"typescript": "^4.5.4" "typescript": "^4.7.4"
}, },
"lint-staged": { "lint-staged": {
"**/*.{js,jsx,ts,tsx,json}": [ "**/*.{js,jsx,ts,tsx,json}": [

View File

@ -1130,7 +1130,7 @@ export interface definitions {
*/ */
search_keywords?: string search_keywords?: string
/** /**
* Image URL used for this category on the storefront. Images can be uploaded via form file post to `/brands/{brandId}/image`, or by providing a publicly accessible URL in this field. * Image URL used for this category on the storefront. Images can be uploaded via form file post to `/{brandId}/image`, or by providing a publicly accessible URL in this field.
*/ */
image_url?: string image_url?: string
custom_url?: definitions['customUrl_Full'] custom_url?: definitions['customUrl_Full']

View File

@ -1,21 +1,14 @@
import type { CartEndpoint } from '.'
import type { BigcommerceCart } from '../../../types'
import { normalizeCart } from '../../../lib/normalize' import { normalizeCart } from '../../../lib/normalize'
import { parseCartItem } from '../../utils/parse-item' import { parseCartItem } from '../../utils/parse-item'
import getCartCookie from '../../utils/get-cart-cookie' import getCartCookie from '../../utils/get-cart-cookie'
import type { CartEndpoint } from '.'
const addItem: CartEndpoint['handlers']['addItem'] = async ({ const addItem: CartEndpoint['handlers']['addItem'] = async ({
res,
body: { cartId, item }, body: { cartId, item },
config, config,
}) => { }) => {
if (!item) {
return res.status(400).json({
data: null,
errors: [{ message: 'Missing item' }],
})
}
if (!item.quantity) item.quantity = 1
const options = { const options = {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@ -25,22 +18,27 @@ const addItem: CartEndpoint['handlers']['addItem'] = async ({
: {}), : {}),
}), }),
} }
const { data } = cartId const { data } = cartId
? await config.storeApiFetch( ? await config.storeApiFetch<{ data: BigcommerceCart }>(
`/v3/carts/${cartId}/items?include=line_items.physical_items.options,line_items.digital_items.options`, `/v3/carts/${cartId}/items?include=line_items.physical_items.options,line_items.digital_items.options`,
options options
) )
: await config.storeApiFetch( : await config.storeApiFetch<{ data: BigcommerceCart }>(
'/v3/carts?include=line_items.physical_items.options,line_items.digital_items.options', '/v3/carts?include=line_items.physical_items.options,line_items.digital_items.options',
options options
) )
// Create or update the cart cookie return {
res.setHeader( data: normalizeCart(data),
'Set-Cookie', headers: {
getCartCookie(config.cartCookie, data.id, config.cartCookieMaxAge) 'Set-Cookie': getCartCookie(
) config.cartCookie,
res.status(200).json({ data: normalizeCart(data) }) data.id,
config.cartCookieMaxAge
),
},
}
} }
export default addItem export default addItem

View File

@ -1,35 +1,41 @@
import type { CartEndpoint } from '.'
import type { BigcommerceCart } from '../../../types'
import getCartCookie from '../../utils/get-cart-cookie'
import { normalizeCart } from '../../../lib/normalize' import { normalizeCart } from '../../../lib/normalize'
import { BigcommerceApiError } from '../../utils/errors' import { BigcommerceApiError } from '../../utils/errors'
import getCartCookie from '../../utils/get-cart-cookie'
import type { BigcommerceCart } from '../../../types/cart'
import type { CartEndpoint } from '.'
// Return current cart info // Return current cart info
const getCart: CartEndpoint['handlers']['getCart'] = async ({ const getCart: CartEndpoint['handlers']['getCart'] = async ({
res,
body: { cartId }, body: { cartId },
config, config,
}) => { }) => {
let result: { data?: BigcommerceCart } = {}
if (cartId) { if (cartId) {
try { try {
result = await config.storeApiFetch( const result = await config.storeApiFetch<{
data?: BigcommerceCart
} | null>(
`/v3/carts/${cartId}?include=line_items.physical_items.options,line_items.digital_items.options` `/v3/carts/${cartId}?include=line_items.physical_items.options,line_items.digital_items.options`
) )
return {
data: result?.data ? normalizeCart(result.data) : null,
}
} catch (error) { } catch (error) {
if (error instanceof BigcommerceApiError && error.status === 404) { if (error instanceof BigcommerceApiError && error.status === 404) {
// Remove the cookie if it exists but the cart wasn't found return {
res.setHeader('Set-Cookie', getCartCookie(config.cartCookie)) headers: { 'Set-Cookie': getCartCookie(config.cartCookie) },
}
} else { } else {
throw error throw error
} }
} }
} }
res.status(200).json({ return {
data: result.data ? normalizeCart(result.data) : null, data: null,
}) }
} }
export default getCart export default getCart

View File

@ -1,6 +1,6 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' import { type GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import cartEndpoint from '@vercel/commerce/api/endpoints/cart' import cartEndpoint from '@vercel/commerce/api/endpoints/cart'
import type { CartSchema } from '../../../types/cart' import type { CartSchema } from '@vercel/commerce/types/cart'
import type { BigcommerceAPI } from '../..' import type { BigcommerceAPI } from '../..'
import getCart from './get-cart' import getCart from './get-cart'
import addItem from './add-item' import addItem from './add-item'

View File

@ -1,34 +1,26 @@
import { normalizeCart } from '../../../lib/normalize'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartEndpoint } from '.' import type { CartEndpoint } from '.'
import { normalizeCart } from '../../../lib/normalize'
import getCartCookie from '../../utils/get-cart-cookie'
const removeItem: CartEndpoint['handlers']['removeItem'] = async ({ const removeItem: CartEndpoint['handlers']['removeItem'] = async ({
res,
body: { cartId, itemId }, body: { cartId, itemId },
config, config,
}) => { }) => {
if (!cartId || !itemId) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
const result = await config.storeApiFetch<{ data: any } | null>( const result = await config.storeApiFetch<{ data: any } | null>(
`/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`, `/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`,
{ method: 'DELETE' } { method: 'DELETE' }
) )
const data = result?.data ?? null return {
data: result?.data ? normalizeCart(result.data) : null,
res.setHeader( headers: {
'Set-Cookie', 'Set-Cookie': result?.data
data ? // Update the cart cookie
? // Update the cart cookie getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge)
getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge) : // Remove the cart cookie if the cart was removed (empty items)
: // Remove the cart cookie if the cart was removed (empty items) getCartCookie(config.cartCookie),
getCartCookie(config.cartCookie) },
) }
res.status(200).json({ data: data && normalizeCart(data) })
} }
export default removeItem export default removeItem

View File

@ -1,21 +1,15 @@
import type { CartEndpoint } from '.'
import type { BigcommerceCart } from '../../../types'
import { normalizeCart } from '../../../lib/normalize' import { normalizeCart } from '../../../lib/normalize'
import { parseCartItem } from '../../utils/parse-item' import { parseCartItem } from '../../utils/parse-item'
import getCartCookie from '../../utils/get-cart-cookie' import getCartCookie from '../../utils/get-cart-cookie'
import type { CartEndpoint } from '.'
const updateItem: CartEndpoint['handlers']['updateItem'] = async ({ const updateItem: CartEndpoint['handlers']['updateItem'] = async ({
res,
body: { cartId, itemId, item }, body: { cartId, itemId, item },
config, config,
}) => { }) => {
if (!cartId || !itemId || !item) { const { data } = await config.storeApiFetch<{ data: BigcommerceCart }>(
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
const { data } = await config.storeApiFetch(
`/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`, `/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`,
{ {
method: 'PUT', method: 'PUT',
@ -25,12 +19,16 @@ const updateItem: CartEndpoint['handlers']['updateItem'] = async ({
} }
) )
// Update the cart cookie return {
res.setHeader( data: normalizeCart(data),
'Set-Cookie', headers: {
getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge) 'Set-Cookie': getCartCookie(
) config.cartCookie,
res.status(200).json({ data: normalizeCart(data) }) cartId,
config.cartCookieMaxAge
),
},
}
} }
export default updateItem export default updateItem

View File

@ -11,7 +11,6 @@ const LIMIT = 12
// Return current cart info // Return current cart info
const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({ const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({
res,
body: { search, categoryId, brandId, sort }, body: { search, categoryId, brandId, sort },
config, config,
commerce, commerce,
@ -73,7 +72,7 @@ const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({
if (product) products.push(product) if (product) products.push(product)
}) })
res.status(200).json({ data: { products, found } }) return { data: { products, found } }
} }
export default getProducts export default getProducts

View File

@ -1,6 +1,6 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import productsEndpoint from '@vercel/commerce/api/endpoints/catalog/products' import productsEndpoint from '@vercel/commerce/api/endpoints/catalog/products'
import type { ProductsSchema } from '../../../../types/product' import type { ProductsSchema } from '@vercel/commerce/types/product'
import type { BigcommerceAPI } from '../../..' import type { BigcommerceAPI } from '../../..'
import getProducts from './get-products' import getProducts from './get-products'

View File

@ -1,38 +1,47 @@
import type { CheckoutEndpoint } from '.' import type { CheckoutEndpoint } from '.'
import getCustomerId from '../../utils/get-customer-id' import getCustomerId from '../../utils/get-customer-id'
import jwt from 'jsonwebtoken'
import { uuid } from 'uuidv4'
const fullCheckout = true const fullCheckout = true
const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({ const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({
req, req,
res,
config, config,
}) => { }) => {
const { cookies } = req const { cookies } = req
const cartId = cookies[config.cartCookie] const cartId = cookies.get(config.cartCookie)?.value
const customerToken = cookies[config.customerCookie] const customerToken = cookies.get(config.customerCookie)?.value
if (!cartId) { if (!cartId) {
res.redirect('/cart') return { redirectTo: '/cart' }
return
} }
const { data } = await config.storeApiFetch(
const { data } = await config.storeApiFetch<any>(
`/v3/carts/${cartId}/redirect_urls`, `/v3/carts/${cartId}/redirect_urls`,
{ {
method: 'POST', method: 'POST',
} }
) )
const customerId = const customerId =
customerToken && (await getCustomerId({ customerToken, config })) customerToken && (await getCustomerId({ customerToken, config }))
//if there is a customer create a jwt token //if there is a customer create a jwt token
if (!customerId) { if (!customerId) {
if (fullCheckout) { if (fullCheckout) {
res.redirect(data.checkout_url) return { redirectTo: data.checkout_url }
return
} }
} else { } else {
// Dynamically import uuid & jsonwebtoken based on the runtime
const { uuid } =
process.env.NEXT_RUNTIME === 'edge'
? await import('@cfworker/uuid')
: await import('uuidv4')
const jwt =
process.env.NEXT_RUNTIME === 'edge'
? await import('@tsndr/cloudflare-worker-jwt')
: await import('jsonwebtoken')
const dateCreated = Math.round(new Date().getTime() / 1000) const dateCreated = Math.round(new Date().getTime() / 1000)
const payload = { const payload = {
iss: config.storeApiClientId, iss: config.storeApiClientId,
@ -42,49 +51,51 @@ const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({
store_hash: config.storeHash, store_hash: config.storeHash,
customer_id: customerId, customer_id: customerId,
channel_id: config.storeChannelId, channel_id: config.storeChannelId,
redirect_to: data.checkout_url.replace(config.storeUrl, ""), redirect_to: data.checkout_url.replace(config.storeUrl, ''),
} }
let token = jwt.sign(payload, config.storeApiClientSecret!, { let token = jwt.sign(payload, config.storeApiClientSecret!, {
algorithm: 'HS256', algorithm: 'HS256',
}) })
let checkouturl = `${config.storeUrl}/login/token/${token}` let checkouturl = `${config.storeUrl}/login/token/${token}`
console.log('checkouturl', checkouturl)
if (fullCheckout) { if (fullCheckout) {
res.redirect(checkouturl) return { redirectTo: checkouturl }
return
} }
} }
// TODO: make the embedded checkout work too! // TODO: make the embedded checkout work too!
const html = ` // const html = `
<!DOCTYPE html> // <!DOCTYPE html>
<html lang="en"> // <html lang="en">
<head> // <head>
<meta charset="UTF-8"> // <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> // <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Checkout</title> // <title>Checkout</title>
<script src="https://checkout-sdk.bigcommerce.com/v1/loader.js"></script> // <script src="https://checkout-sdk.bigcommerce.com/v1/loader.js"></script>
<script> // <script>
window.onload = function() { // window.onload = function() {
checkoutKitLoader.load('checkout-sdk').then(function (service) { // checkoutKitLoader.load('checkout-sdk').then(function (service) {
service.embedCheckout({ // service.embedCheckout({
containerId: 'checkout', // containerId: 'checkout',
url: '${data.embedded_checkout_url}' // url: '${data.embedded_checkout_url}'
}); // });
}); // });
} // }
</script> // </script>
</head> // </head>
<body> // <body>
<div id="checkout"></div> // <div id="checkout"></div>
</body> // </body>
</html> // </html>
` // `
res.status(200) // return new Response(html, {
res.setHeader('Content-Type', 'text/html') // headers: {
res.write(html) // 'Content-Type': 'text/html',
res.end() // },
// })
return { data: null }
} }
export default getCheckout export default getCheckout

View File

@ -1,6 +1,6 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import checkoutEndpoint from '@vercel/commerce/api/endpoints/checkout' import checkoutEndpoint from '@vercel/commerce/api/endpoints/checkout'
import type { CheckoutSchema } from '../../../types/checkout' import type { CheckoutSchema } from '@vercel/commerce/types/checkout'
import type { BigcommerceAPI } from '../..' import type { BigcommerceAPI } from '../..'
import getCheckout from './get-checkout' import getCheckout from './get-checkout'

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import type { GetLoggedInCustomerQuery } from '../../../../schema' import type { GetLoggedInCustomerQuery } from '../../../../schema'
import type { CustomerEndpoint } from '.' import type { CustomerEndpoint } from '.'
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
export const getLoggedInCustomerQuery = /* GraphQL */ ` export const getLoggedInCustomerQuery = /* GraphQL */ `
query getLoggedInCustomer { query getLoggedInCustomer {
@ -25,8 +26,8 @@ export const getLoggedInCustomerQuery = /* GraphQL */ `
export type Customer = NonNullable<GetLoggedInCustomerQuery['customer']> export type Customer = NonNullable<GetLoggedInCustomerQuery['customer']>
const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] = const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] =
async ({ req, res, config }) => { async ({ req, config }) => {
const token = req.cookies[config.customerCookie] const token = req.cookies.get(config.customerCookie)?.value
if (token) { if (token) {
const { data } = await config.fetch<GetLoggedInCustomerQuery>( const { data } = await config.fetch<GetLoggedInCustomerQuery>(
@ -41,16 +42,29 @@ const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] =
const { customer } = data const { customer } = data
if (!customer) { if (!customer) {
return res.status(400).json({ throw new CommerceAPIError('Customer not found', {
data: null, status: 404,
errors: [{ message: 'Customer not found', code: 'not_found' }],
}) })
} }
return res.status(200).json({ data: { customer } }) return {
data: {
customer: {
id: String(customer.entityId),
firstName: customer.firstName,
lastName: customer.lastName,
email: customer.email,
company: customer.company,
phone: customer.phone,
notes: customer.notes,
},
},
}
} }
res.status(200).json({ data: null }) return {
data: null,
}
} }
export default getLoggedInCustomer export default getLoggedInCustomer

View File

@ -1,6 +1,6 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import customerEndpoint from '@vercel/commerce/api/endpoints/customer' import customerEndpoint from '@vercel/commerce/api/endpoints/customer'
import type { CustomerSchema } from '../../../types/customer' import type { CustomerSchema } from '@vercel/commerce/types/customer'
import type { BigcommerceAPI } from '../..' import type { BigcommerceAPI } from '../..'
import getLoggedInCustomer from './get-logged-in-customer' import getLoggedInCustomer from './get-logged-in-customer'

View File

@ -0,0 +1,27 @@
import type { BigcommerceAPI, Provider } from '..'
import createEndpoints from '@vercel/commerce/api/endpoints'
import cart from './cart'
import login from './login'
import logout from './logout'
import signup from './signup'
import checkout from './checkout'
import customer from './customer'
import wishlist from './wishlist'
import products from './catalog/products'
const endpoints = {
cart,
login,
logout,
signup,
checkout,
wishlist,
customer,
'catalog/products': products,
}
export default function bigcommerceAPI(commerce: BigcommerceAPI) {
return createEndpoints<Provider>(commerce, endpoints)
}

View File

@ -1,6 +1,6 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import loginEndpoint from '@vercel/commerce/api/endpoints/login' import loginEndpoint from '@vercel/commerce/api/endpoints/login'
import type { LoginSchema } from '../../../types/login' import type { LoginSchema } from '@vercel/commerce/types/login'
import type { BigcommerceAPI } from '../..' import type { BigcommerceAPI } from '../..'
import login from './login' import login from './login'

View File

@ -1,49 +1,35 @@
import { FetcherError } from '@vercel/commerce/utils/errors'
import type { LoginEndpoint } from '.' import type { LoginEndpoint } from '.'
import { FetcherError } from '@vercel/commerce/utils/errors'
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
const invalidCredentials = /invalid credentials/i const invalidCredentials = /invalid credentials/i
const login: LoginEndpoint['handlers']['login'] = async ({ const login: LoginEndpoint['handlers']['login'] = async ({
res,
body: { email, password }, body: { email, password },
config, config,
commerce, commerce,
}) => { }) => {
// TODO: Add proper validations with something like Ajv
if (!(email && password)) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
// TODO: validate the password and email
// Passwords must be at least 7 characters and contain both alphabetic
// and numeric characters.
try { try {
await commerce.login({ variables: { email, password }, config, res }) const response = await commerce.login({
variables: { email, password },
config,
})
return response
} catch (error) { } catch (error) {
// Check if the email and password didn't match an existing account // Check if the email and password didn't match an existing account
if ( if (error instanceof FetcherError) {
error instanceof FetcherError && throw new CommerceAPIError(
invalidCredentials.test(error.message) invalidCredentials.test(error.message)
) { ? 'Cannot find an account that matches the provided credentials'
return res.status(401).json({ : error.message,
data: null, { status: 401 }
errors: [ )
{ } else {
message: throw error
'Cannot find an account that matches the provided credentials',
code: 'invalid_credentials',
},
],
})
} }
throw error
} }
res.status(200).json({ data: null })
} }
export default login export default login

View File

@ -1,6 +1,6 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import logoutEndpoint from '@vercel/commerce/api/endpoints/logout' import logoutEndpoint from '@vercel/commerce/api/endpoints/logout'
import type { LogoutSchema } from '../../../types/logout' import type { LogoutSchema } from '@vercel/commerce/types/logout'
import type { BigcommerceAPI } from '../..' import type { BigcommerceAPI } from '../..'
import logout from './logout' import logout from './logout'

View File

@ -2,22 +2,24 @@ import { serialize } from 'cookie'
import type { LogoutEndpoint } from '.' import type { LogoutEndpoint } from '.'
const logout: LogoutEndpoint['handlers']['logout'] = async ({ const logout: LogoutEndpoint['handlers']['logout'] = async ({
res,
body: { redirectTo }, body: { redirectTo },
config, config,
}) => { }) => {
// Remove the cookie const headers = {
res.setHeader( 'Set-Cookie': serialize(config.customerCookie, '', {
'Set-Cookie', maxAge: -1,
serialize(config.customerCookie, '', { maxAge: -1, path: '/' }) path: '/',
) }),
// Only allow redirects to a relative URL
if (redirectTo?.startsWith('/')) {
res.redirect(redirectTo)
} else {
res.status(200).json({ data: null })
} }
return redirectTo
? {
redirectTo,
headers,
}
: {
headers,
}
} }
export default logout export default logout

View File

@ -1,6 +1,6 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import signupEndpoint from '@vercel/commerce/api/endpoints/signup' import signupEndpoint from '@vercel/commerce/api/endpoints/signup'
import type { SignupSchema } from '../../../types/signup' import type { SignupSchema } from '@vercel/commerce/types/signup'
import type { BigcommerceAPI } from '../..' import type { BigcommerceAPI } from '../..'
import signup from './signup' import signup from './signup'

View File

@ -1,23 +1,12 @@
import { BigcommerceApiError } from '../../utils/errors'
import type { SignupEndpoint } from '.' import type { SignupEndpoint } from '.'
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
import { BigcommerceApiError } from '../../utils/errors'
const signup: SignupEndpoint['handlers']['signup'] = async ({ const signup: SignupEndpoint['handlers']['signup'] = async ({
res,
body: { firstName, lastName, email, password }, body: { firstName, lastName, email, password },
config, config,
commerce, commerce,
}) => { }) => {
// TODO: Add proper validations with something like Ajv
if (!(firstName && lastName && email && password)) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
// TODO: validate the password and email
// Passwords must be at least 7 characters and contain both alphabetic
// and numeric characters.
try { try {
await config.storeApiFetch('/v3/customers', { await config.storeApiFetch('/v3/customers', {
method: 'POST', method: 'POST',
@ -32,31 +21,28 @@ const signup: SignupEndpoint['handlers']['signup'] = async ({
}, },
]), ]),
}) })
} catch (error) {
if (error instanceof BigcommerceApiError && error.status === 422) {
const hasEmailError = '0.email' in error.data?.errors
// If there's an error with the email, it most likely means it's duplicated // Login the customer right after creating it
if (hasEmailError) { const response = await commerce.login({
return res.status(400).json({ variables: { email, password },
data: null, config,
errors: [ })
{
message: 'The email is already in use', return response
code: 'duplicated_email', } catch (error) {
}, // Display all validation errors from BigCommerce in a single error message
], if (error instanceof BigcommerceApiError && error.status >= 400) {
const message = Object.values(error.data.errors).join('<br />')
if (message) {
throw new CommerceAPIError(message, {
status: 400,
code: 'invalid_request',
}) })
} }
} }
throw error throw error
} }
// Login the customer right after creating it
await commerce.login({ variables: { email, password }, res, config })
res.status(200).json({ data: null })
} }
export default signup export default signup

View File

@ -1,66 +1,55 @@
import getCustomerWishlist from '../../operations/get-customer-wishlist'
import { parseWishlistItem } from '../../utils/parse-item' import { parseWishlistItem } from '../../utils/parse-item'
import getCustomerId from '../../utils/get-customer-id' import getCustomerId from '../../utils/get-customer-id'
import type { WishlistEndpoint } from '.' import type { WishlistEndpoint } from '.'
import { normalizeWishlist } from '../../../lib/normalize'
const addItem: WishlistEndpoint['handlers']['addItem'] = async ({ const addItem: WishlistEndpoint['handlers']['addItem'] = async ({
res,
body: { customerToken, item }, body: { customerToken, item },
config, config,
commerce, commerce,
}) => { }) => {
if (!item) { const customerId =
return res.status(400).json({ customerToken && (await getCustomerId({ customerToken, config }))
data: null,
errors: [{ message: 'Missing item' }], if (!customerId) {
}) throw new Error('Invalid request. No CustomerId')
} }
try { let { wishlist } = await commerce.getCustomerWishlist({
const customerId = variables: { customerId },
customerToken && (await getCustomerId({ customerToken, config })) config,
})
if (!customerId) { if (!wishlist) {
throw new Error('Invalid request. No CustomerId') // If user has no wishlist, then let's create one with new item
} const { data } = await config.storeApiFetch<any>('/v3/wishlists', {
method: 'POST',
let { wishlist } = await commerce.getCustomerWishlist({ body: JSON.stringify({
variables: { customerId }, name: 'Next.js Commerce Wishlist',
config, is_public: false,
customer_id: Number(customerId),
items: [parseWishlistItem(item)],
}),
}) })
return {
if (!wishlist) { data: normalizeWishlist(data),
// If user has no wishlist, then let's create one with new item
const { data } = await config.storeApiFetch('/v3/wishlists', {
method: 'POST',
body: JSON.stringify({
name: 'Next.js Commerce Wishlist',
is_public: false,
customer_id: Number(customerId),
items: [parseWishlistItem(item)],
}),
})
return res.status(200).json(data)
} }
}
// Existing Wishlist, let's add Item to Wishlist // Existing Wishlist, let's add Item to Wishlist
const { data } = await config.storeApiFetch( const { data } = await config.storeApiFetch<any>(
`/v3/wishlists/${wishlist.id}/items`, `/v3/wishlists/${wishlist.id}/items`,
{ {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
items: [parseWishlistItem(item)], items: [parseWishlistItem(item)],
}), }),
} }
) )
// Returns Wishlist // Returns Wishlist
return res.status(200).json(data) return {
} catch (err: any) { data: normalizeWishlist(data),
res.status(500).json({
data: null,
errors: [{ message: err.message }],
})
} }
} }

View File

@ -1,27 +1,19 @@
import type { Wishlist } from '../../../types/wishlist' import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
import type { WishlistEndpoint } from '.' import type { WishlistEndpoint } from '.'
import getCustomerId from '../../utils/get-customer-id' import getCustomerId from '../../utils/get-customer-id'
import getCustomerWishlist from '../../operations/get-customer-wishlist'
// Return wishlist info // Return wishlist info
const getWishlist: WishlistEndpoint['handlers']['getWishlist'] = async ({ const getWishlist: WishlistEndpoint['handlers']['getWishlist'] = async ({
res,
body: { customerToken, includeProducts }, body: { customerToken, includeProducts },
config, config,
commerce, commerce,
}) => { }) => {
let result: { data?: Wishlist } = {}
if (customerToken) { if (customerToken) {
const customerId = const customerId =
customerToken && (await getCustomerId({ customerToken, config })) customerToken && (await getCustomerId({ customerToken, config }))
if (!customerId) { if (!customerId) {
// If the customerToken is invalid, then this request is too throw new CommerceAPIError('Wishlist not found', { status: 404 })
return res.status(404).json({
data: null,
errors: [{ message: 'Wishlist not found' }],
})
} }
const { wishlist } = await commerce.getCustomerWishlist({ const { wishlist } = await commerce.getCustomerWishlist({
@ -30,10 +22,10 @@ const getWishlist: WishlistEndpoint['handlers']['getWishlist'] = async ({
config, config,
}) })
result = { data: wishlist } return { data: wishlist }
} }
res.status(200).json({ data: result.data ?? null }) return { data: null }
} }
export default getWishlist export default getWishlist

View File

@ -1,6 +1,6 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import wishlistEndpoint from '@vercel/commerce/api/endpoints/wishlist' import wishlistEndpoint from '@vercel/commerce/api/endpoints/wishlist'
import type { WishlistSchema } from '../../../types/wishlist' import type { WishlistSchema } from '@vercel/commerce/types/wishlist'
import type { BigcommerceAPI } from '../..' import type { BigcommerceAPI } from '../..'
import getWishlist from './get-wishlist' import getWishlist from './get-wishlist'
import addItem from './add-item' import addItem from './add-item'

View File

@ -1,17 +1,19 @@
import type { Wishlist } from '../../../types/wishlist'
import getCustomerWishlist from '../../operations/get-customer-wishlist'
import getCustomerId from '../../utils/get-customer-id'
import type { WishlistEndpoint } from '.' import type { WishlistEndpoint } from '.'
import type { BCWishlist } from '../../utils/types'
import getCustomerId from '../../utils/get-customer-id'
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
import { normalizeWishlist } from '../../../lib/normalize'
// Return wishlist info // Return wishlist info
const removeItem: WishlistEndpoint['handlers']['removeItem'] = async ({ const removeItem: WishlistEndpoint['handlers']['removeItem'] = async ({
res,
body: { customerToken, itemId }, body: { customerToken, itemId },
config, config,
commerce, commerce,
}) => { }) => {
const customerId = const customerId =
customerToken && (await getCustomerId({ customerToken, config })) customerToken && (await getCustomerId({ customerToken, config }))
const { wishlist } = const { wishlist } =
(customerId && (customerId &&
(await commerce.getCustomerWishlist({ (await commerce.getCustomerWishlist({
@ -21,19 +23,15 @@ const removeItem: WishlistEndpoint['handlers']['removeItem'] = async ({
{} {}
if (!wishlist || !itemId) { if (!wishlist || !itemId) {
return res.status(400).json({ throw new CommerceAPIError('Wishlist not found', { status: 400 })
data: null,
errors: [{ message: 'Invalid request' }],
})
} }
const result = await config.storeApiFetch<{ data: Wishlist } | null>( const result = await config.storeApiFetch<{ data: BCWishlist } | null>(
`/v3/wishlists/${wishlist.id}/items/${itemId}`, `/v3/wishlists/${wishlist.id}/items/${itemId}`,
{ method: 'DELETE' } { method: 'DELETE' }
) )
const data = result?.data ?? null
res.status(200).json({ data }) return { data: result?.data ? normalizeWishlist(result.data) : null }
} }
export default removeItem export default removeItem

View File

@ -1,4 +1,3 @@
import type { RequestInit } from '@vercel/fetch'
import { import {
CommerceAPI, CommerceAPI,
CommerceAPIConfig, CommerceAPIConfig,
@ -35,7 +34,14 @@ export interface BigcommerceConfig extends CommerceAPIConfig {
storeUrl?: string storeUrl?: string
storeApiClientSecret?: string storeApiClientSecret?: string
storeHash?: string storeHash?: string
storeApiFetch<T>(endpoint: string, options?: RequestInit): Promise<T> storeApiFetch<T>(
endpoint: string,
options?: {
method?: string
body?: any
headers?: HeadersInit
}
): Promise<T>
} }
const API_URL = process.env.BIGCOMMERCE_STOREFRONT_API_URL // GraphAPI const API_URL = process.env.BIGCOMMERCE_STOREFRONT_API_URL // GraphAPI

View File

@ -2,10 +2,13 @@ import type {
OperationContext, OperationContext,
OperationOptions, OperationOptions,
} from '@vercel/commerce/api/operations' } from '@vercel/commerce/api/operations'
import type { Page, GetAllPagesOperation } from '../../types/page' import type { GetAllPagesOperation } from '@vercel/commerce/types/page'
import type { RecursivePartial, RecursiveRequired } from '../utils/types' import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import { BigcommerceConfig, Provider } from '..' import { BigcommerceConfig, Provider } from '..'
import { definitions } from '../definitions/store-content'
import { normalizePage } from '../../lib/normalize'
export default function getAllPagesOperation({ export default function getAllPagesOperation({
commerce, commerce,
}: OperationContext<Provider>) { }: OperationContext<Provider>) {
@ -33,12 +36,14 @@ export default function getAllPagesOperation({
// RecursivePartial forces the method to check for every prop in the data, which is // RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `url` // required in case there's a custom `url`
const { data } = await cfg.storeApiFetch< const { data } = await cfg.storeApiFetch<
RecursivePartial<{ data: Page[] }> RecursivePartial<{ data: definitions['page_Full'][] }>
>('/v3/content/pages') >('/v3/content/pages')
const pages = (data as RecursiveRequired<typeof data>) ?? [] const pages = (data as RecursiveRequired<typeof data>) ?? []
return { return {
pages: preview ? pages : pages.filter((p) => p.is_visible), pages: preview
? pages.map(normalizePage)
: pages.filter((p) => p.is_visible).map(normalizePage),
} }
} }

View File

@ -3,7 +3,7 @@ import type {
OperationOptions, OperationOptions,
} from '@vercel/commerce/api/operations' } from '@vercel/commerce/api/operations'
import type { GetAllProductPathsQuery } from '../../../schema' import type { GetAllProductPathsQuery } from '../../../schema'
import type { GetAllProductPathsOperation } from '../../types/product' import type { GetAllProductPathsOperation } from '@vercel/commerce/types/product'
import type { RecursivePartial, RecursiveRequired } from '../utils/types' import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import filterEdges from '../utils/filter-edges' import filterEdges from '../utils/filter-edges'
import { BigcommerceConfig, Provider } from '..' import { BigcommerceConfig, Provider } from '..'

View File

@ -6,7 +6,7 @@ import type {
GetAllProductsQuery, GetAllProductsQuery,
GetAllProductsQueryVariables, GetAllProductsQueryVariables,
} from '../../../schema' } from '../../../schema'
import type { GetAllProductsOperation } from '../../types/product' import type { GetAllProductsOperation } from '@vercel/commerce/types/product'
import type { RecursivePartial, RecursiveRequired } from '../utils/types' import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import filterEdges from '../utils/filter-edges' import filterEdges from '../utils/filter-edges'
import setProductLocaleMeta from '../utils/set-product-locale-meta' import setProductLocaleMeta from '../utils/set-product-locale-meta'

View File

@ -2,13 +2,11 @@ import type {
OperationContext, OperationContext,
OperationOptions, OperationOptions,
} from '@vercel/commerce/api/operations' } from '@vercel/commerce/api/operations'
import type { import type { GetCustomerWishlistOperation } from '@vercel/commerce/types/wishlist'
GetCustomerWishlistOperation, import type { RecursivePartial, BCWishlist } from '../utils/types'
Wishlist,
} from '../../types/wishlist'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import { BigcommerceConfig, Provider } from '..' import { BigcommerceConfig, Provider } from '..'
import getAllProducts, { ProductEdge } from './get-all-products' import { ProductEdge } from './get-all-products'
import { normalizeWishlist } from '../../lib/normalize'
export default function getCustomerWishlistOperation({ export default function getCustomerWishlistOperation({
commerce, commerce,
@ -41,18 +39,22 @@ export default function getCustomerWishlistOperation({
}): Promise<T['data']> { }): Promise<T['data']> {
config = commerce.getConfig(config) config = commerce.getConfig(config)
const { data = [] } = await config.storeApiFetch< const { data = [] } = await config.storeApiFetch<{ data: BCWishlist[] }>(
RecursivePartial<{ data: Wishlist[] }> `/v3/wishlists?customer_id=${variables.customerId}`
>(`/v3/wishlists?customer_id=${variables.customerId}`) )
const wishlist = data[0] const wishlist = data[0]
if (includeProducts && wishlist?.items?.length) { if (includeProducts && wishlist?.items?.length) {
const ids = wishlist.items const ids = []
?.map((item) => (item?.product_id ? String(item?.product_id) : null))
.filter((id): id is string => !!id)
if (ids?.length) { for (const wishlistItem of wishlist.items) {
if (wishlistItem.product_id) {
ids.push(String(wishlistItem.product_id))
}
}
if (ids.length) {
const graphqlData = await commerce.getAllProducts({ const graphqlData = await commerce.getAllProducts({
variables: { first: 50, ids }, variables: { first: 50, ids },
config, config,
@ -66,7 +68,7 @@ export default function getCustomerWishlistOperation({
}, {}) }, {})
// Populate the wishlist items with the graphql products // Populate the wishlist items with the graphql products
wishlist.items.forEach((item) => { wishlist.items.forEach((item) => {
const product = item && productsById[item.product_id!] const product = item && productsById[Number(item.product_id)]
if (item && product) { if (item && product) {
// @ts-ignore Fix this type when the wishlist type is properly defined // @ts-ignore Fix this type when the wishlist type is properly defined
item.product = product item.product = product
@ -75,7 +77,7 @@ export default function getCustomerWishlistOperation({
} }
} }
return { wishlist: wishlist as RecursiveRequired<typeof wishlist> } return { wishlist: wishlist && normalizeWishlist(wishlist) }
} }
return getCustomerWishlist return getCustomerWishlist

View File

@ -2,7 +2,7 @@ import type {
OperationContext, OperationContext,
OperationOptions, OperationOptions,
} from '@vercel/commerce/api/operations' } from '@vercel/commerce/api/operations'
import type { GetPageOperation, Page } from '../../types/page' import type { GetPageOperation, Page } from '@vercel/commerce/types/page'
import type { RecursivePartial, RecursiveRequired } from '../utils/types' import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import type { BigcommerceConfig, Provider } from '..' import type { BigcommerceConfig, Provider } from '..'
import { normalizePage } from '../../lib/normalize' import { normalizePage } from '../../lib/normalize'

View File

@ -2,7 +2,7 @@ import type {
OperationContext, OperationContext,
OperationOptions, OperationOptions,
} from '@vercel/commerce/api/operations' } from '@vercel/commerce/api/operations'
import type { GetProductOperation } from '../../types/product' import type { GetProductOperation } from '@vercel/commerce/types/product'
import type { GetProductQuery, GetProductQueryVariables } from '../../../schema' import type { GetProductQuery, GetProductQueryVariables } from '../../../schema'
import setProductLocaleMeta from '../utils/set-product-locale-meta' import setProductLocaleMeta from '../utils/set-product-locale-meta'
import { productInfoFragment } from '../fragments/product' import { productInfoFragment } from '../fragments/product'
@ -100,7 +100,7 @@ export default function getAllProductPathsOperation({
const variables: GetProductQueryVariables = { const variables: GetProductQueryVariables = {
locale, locale,
hasLocale: !!locale, hasLocale: !!locale,
path: slug ? `/${slug}/` : vars.path!, path: slug ? `/${slug}` : vars.path!,
} }
const { data } = await config.fetch<GetProductQuery>(query, { variables }) const { data } = await config.fetch<GetProductQuery>(query, { variables })
const product = data.site?.route?.node const product = data.site?.route?.node

View File

@ -2,12 +2,12 @@ import type {
OperationContext, OperationContext,
OperationOptions, OperationOptions,
} from '@vercel/commerce/api/operations' } from '@vercel/commerce/api/operations'
import type { GetSiteInfoOperation } from '../../types/site' import type { GetSiteInfoOperation } from '@vercel/commerce/types/site'
import type { GetSiteInfoQuery } from '../../../schema' import type { GetSiteInfoQuery } from '../../../schema'
import filterEdges from '../utils/filter-edges' import filterEdges from '../utils/filter-edges'
import type { BigcommerceConfig, Provider } from '..' import type { BigcommerceConfig, Provider } from '..'
import { categoryTreeItemFragment } from '../fragments/category-tree' import { categoryTreeItemFragment } from '../fragments/category-tree'
import { normalizeCategory } from '../../lib/normalize' import { normalizeBrand, normalizeCategory } from '../../lib/normalize'
// Get 3 levels of categories // Get 3 levels of categories
export const getSiteInfoQuery = /* GraphQL */ ` export const getSiteInfoQuery = /* GraphQL */ `
@ -79,7 +79,7 @@ export default function getSiteInfoOperation({
return { return {
categories: categories ?? [], categories: categories ?? [],
brands: filterEdges(brands), brands: filterEdges(brands).map(normalizeBrand),
} }
} }

View File

@ -1,12 +1,10 @@
import type { ServerResponse } from 'http'
import type { import type {
OperationContext, OperationContext,
OperationOptions, OperationOptions,
} from '@vercel/commerce/api/operations' } from '@vercel/commerce/api/operations'
import type { LoginOperation } from '../../types/login' import type { LoginOperation } from '@vercel/commerce/types/login'
import type { LoginMutation } from '../../../schema' import type { LoginMutation } from '../../../schema'
import type { RecursivePartial } from '../utils/types' import type { RecursivePartial } from '../utils/types'
import concatHeader from '../utils/concat-cookie'
import type { BigcommerceConfig, Provider } from '..' import type { BigcommerceConfig, Provider } from '..'
export const loginMutation = /* GraphQL */ ` export const loginMutation = /* GraphQL */ `
@ -23,26 +21,23 @@ export default function loginOperation({
async function login<T extends LoginOperation>(opts: { async function login<T extends LoginOperation>(opts: {
variables: T['variables'] variables: T['variables']
config?: BigcommerceConfig config?: BigcommerceConfig
res: ServerResponse
}): Promise<T['data']> }): Promise<T['data']>
async function login<T extends LoginOperation>( async function login<T extends LoginOperation>(
opts: { opts: {
variables: T['variables'] variables: T['variables']
config?: BigcommerceConfig config?: BigcommerceConfig
res: ServerResponse
} & OperationOptions } & OperationOptions
): Promise<T['data']> ): Promise<T['data']>
async function login<T extends LoginOperation>({ async function login<T extends LoginOperation>({
query = loginMutation, query = loginMutation,
variables, variables,
res: response,
config, config,
}: { }: {
query?: string query?: string
variables: T['variables'] variables: T['variables']
res: ServerResponse
config?: BigcommerceConfig config?: BigcommerceConfig
}): Promise<T['data']> { }): Promise<T['data']> {
config = commerce.getConfig(config) config = commerce.getConfig(config)
@ -51,6 +46,9 @@ export default function loginOperation({
query, query,
{ variables } { variables }
) )
const headers = new Headers()
// Bigcommerce returns a Set-Cookie header with the auth cookie // Bigcommerce returns a Set-Cookie header with the auth cookie
let cookie = res.headers.get('Set-Cookie') let cookie = res.headers.get('Set-Cookie')
@ -64,14 +62,13 @@ export default function loginOperation({
cookie = cookie.replace(/; SameSite=none/gi, '; SameSite=lax') cookie = cookie.replace(/; SameSite=none/gi, '; SameSite=lax')
} }
response.setHeader( headers.set('Set-Cookie', cookie)
'Set-Cookie',
concatHeader(response.getHeader('Set-Cookie'), cookie)!
)
} }
return { return {
result: data.login?.result, result: data.login?.result,
headers,
status: res.status,
} }
} }

View File

@ -1,4 +1,4 @@
type Header = string | number | string[] | undefined type Header = string | number | string[] | undefined | null
export default function concatHeader(prev: Header, val: Header) { export default function concatHeader(prev: Header, val: Header) {
if (!val) return prev if (!val) return prev

View File

@ -1,5 +1,3 @@
import type { Response } from '@vercel/fetch'
// Used for GraphQL errors // Used for GraphQL errors
export class BigcommerceGraphQLError extends Error {} export class BigcommerceGraphQLError extends Error {}

View File

@ -1,22 +1,26 @@
import { FetcherError } from '@vercel/commerce/utils/errors' import { FetcherError } from '@vercel/commerce/utils/errors'
import type { GraphQLFetcher } from '@vercel/commerce/api' import type { FetchOptions, GraphQLFetcher } from '@vercel/commerce/api'
import type { BigcommerceConfig } from '../index' import type { BigcommerceConfig } from '../index'
import fetch from './fetch'
const fetchGraphqlApi: (getConfig: () => BigcommerceConfig) => GraphQLFetcher = const fetchGraphqlApi: (getConfig: () => BigcommerceConfig) => GraphQLFetcher =
(getConfig) => (getConfig) =>
async (query: string, { variables, preview } = {}, fetchOptions) => { async (
query: string,
{ variables, preview } = {},
options?: FetchOptions
): Promise<any> => {
// log.warn(query) // log.warn(query)
const config = getConfig() const config = getConfig()
const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), { const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), {
...fetchOptions, method: options?.method || 'POST',
method: 'POST',
headers: { headers: {
Authorization: `Bearer ${config.apiToken}`, Authorization: `Bearer ${config.apiToken}`,
...fetchOptions?.headers, ...options?.headers,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
...options?.body,
query, query,
variables, variables,
}), }),

View File

@ -1,11 +1,16 @@
import type { FetchOptions, Response } from '@vercel/fetch'
import type { BigcommerceConfig } from '../index' import type { BigcommerceConfig } from '../index'
import { BigcommerceApiError, BigcommerceNetworkError } from './errors' import { BigcommerceApiError, BigcommerceNetworkError } from './errors'
import fetch from './fetch'
const fetchStoreApi = const fetchStoreApi =
<T>(getConfig: () => BigcommerceConfig) => <T>(getConfig: () => BigcommerceConfig) =>
async (endpoint: string, options?: FetchOptions): Promise<T> => { async (
endpoint: string,
options?: {
method?: string
body?: any
headers?: HeadersInit
}
): Promise<T> => {
const config = getConfig() const config = getConfig()
let res: Response let res: Response

View File

@ -1,5 +1,5 @@
import type { WishlistItemBody } from '../../types/wishlist' import type { WishlistItemBody } from '@vercel/commerce/types/wishlist'
import type { CartItemBody, OptionSelections } from '../../types/cart' import type { CartItemBody, SelectedOption } from '@vercel/commerce/types/cart'
type BCWishlistItemBody = { type BCWishlistItemBody = {
product_id: number product_id: number
@ -10,7 +10,7 @@ type BCCartItemBody = {
product_id: number product_id: number
variant_id: number variant_id: number
quantity?: number quantity?: number
option_selections?: OptionSelections[] option_selections?: SelectedOption[]
} }
export const parseWishlistItem = ( export const parseWishlistItem = (
@ -24,5 +24,5 @@ export const parseCartItem = (item: CartItemBody): BCCartItemBody => ({
quantity: item.quantity, quantity: item.quantity,
product_id: Number(item.productId), product_id: Number(item.productId),
variant_id: Number(item.variantId), variant_id: Number(item.variantId),
option_selections: item.optionSelections, option_selections: item.optionsSelected,
}) })

View File

@ -5,3 +5,15 @@ export type RecursivePartial<T> = {
export type RecursiveRequired<T> = { export type RecursiveRequired<T> = {
[P in keyof T]-?: RecursiveRequired<T[P]> [P in keyof T]-?: RecursiveRequired<T[P]>
} }
export interface BCWishlist {
id: number
items: {
id: number
customer_id: number
is_public: boolean
product_id: number
variant_id: number
}[]
token: string
}

View File

@ -2,14 +2,14 @@ import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types' import type { MutationHook } from '@vercel/commerce/utils/types'
import { CommerceError } from '@vercel/commerce/utils/errors' import { CommerceError } from '@vercel/commerce/utils/errors'
import useLogin, { UseLogin } from '@vercel/commerce/auth/use-login' import useLogin, { UseLogin } from '@vercel/commerce/auth/use-login'
import type { LoginHook } from '../types/login' import type { LoginHook } from '@vercel/commerce/types/login'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
export default useLogin as UseLogin<typeof handler> export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<LoginHook> = { export const handler: MutationHook<LoginHook> = {
fetchOptions: { fetchOptions: {
url: '/api/login', url: '/api/commerce/login',
method: 'POST', method: 'POST',
}, },
async fetcher({ input: { email, password }, options, fetch }) { async fetcher({ input: { email, password }, options, fetch }) {

View File

@ -1,14 +1,14 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types' import type { MutationHook } from '@vercel/commerce/utils/types'
import useLogout, { UseLogout } from '@vercel/commerce/auth/use-logout' import useLogout, { UseLogout } from '@vercel/commerce/auth/use-logout'
import type { LogoutHook } from '../types/logout' import type { LogoutHook } from '@vercel/commerce/types/logout'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
export default useLogout as UseLogout<typeof handler> export default useLogout as UseLogout<typeof handler>
export const handler: MutationHook<LogoutHook> = { export const handler: MutationHook<LogoutHook> = {
fetchOptions: { fetchOptions: {
url: '/api/logout', url: '/api/commerce/logout',
method: 'GET', method: 'GET',
}, },
useHook: useHook:

View File

@ -1,15 +1,15 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types' import type { MutationHook } from '@vercel/commerce/utils/types'
import { CommerceError } from '@vercel/commerce/utils/errors' import { CommerceError } from '@vercel/commerce/utils/errors'
import useSignup, { UseSignup } from '@vercel/commerce/auth/use-signup' import useSignup, { type UseSignup } from '@vercel/commerce/auth/use-signup'
import type { SignupHook } from '../types/signup' import type { SignupHook } from '@vercel/commerce/types/signup'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
export default useSignup as UseSignup<typeof handler> export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<SignupHook> = { export const handler: MutationHook<SignupHook> = {
fetchOptions: { fetchOptions: {
url: '/api/signup', url: '/api/commerce/signup',
method: 'POST', method: 'POST',
}, },
async fetcher({ async fetcher({

View File

@ -9,7 +9,7 @@ export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = { export const handler: MutationHook<AddItemHook> = {
fetchOptions: { fetchOptions: {
url: '/api/cart', url: '/api/commerce/cart',
method: 'POST', method: 'POST',
}, },
async fetcher({ input: item, options, fetch }) { async fetcher({ input: item, options, fetch }) {
@ -33,7 +33,6 @@ export const handler: MutationHook<AddItemHook> = {
({ fetch }) => ({ fetch }) =>
() => { () => {
const { mutate } = useCart() const { mutate } = useCart()
return useCallback( return useCallback(
async function addItem(input) { async function addItem(input) {
const data = await fetch({ input }) const data = await fetch({ input })

View File

@ -7,7 +7,7 @@ export default useCart as UseCart<typeof handler>
export const handler: SWRHook<GetCartHook> = { export const handler: SWRHook<GetCartHook> = {
fetchOptions: { fetchOptions: {
url: '/api/cart', url: '/api/commerce/cart',
method: 'GET', method: 'GET',
}, },
useHook: useHook:

View File

@ -4,8 +4,14 @@ import type {
HookFetcherContext, HookFetcherContext,
} from '@vercel/commerce/utils/types' } from '@vercel/commerce/utils/types'
import { ValidationError } from '@vercel/commerce/utils/errors' import { ValidationError } from '@vercel/commerce/utils/errors'
import useRemoveItem, { UseRemoveItem } from '@vercel/commerce/cart/use-remove-item' import useRemoveItem, {
import type { Cart, LineItem, RemoveItemHook } from '@vercel/commerce/types/cart' UseRemoveItem,
} from '@vercel/commerce/cart/use-remove-item'
import type {
Cart,
LineItem,
RemoveItemHook,
} from '@vercel/commerce/types/cart'
import useCart from './use-cart' import useCart from './use-cart'
export type RemoveItemFn<T = any> = T extends LineItem export type RemoveItemFn<T = any> = T extends LineItem
@ -20,7 +26,7 @@ export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler = { export const handler = {
fetchOptions: { fetchOptions: {
url: '/api/cart', url: '/api/commerce/cart',
method: 'DELETE', method: 'DELETE',
}, },
async fetcher({ async fetcher({

View File

@ -5,7 +5,9 @@ import type {
HookFetcherContext, HookFetcherContext,
} from '@vercel/commerce/utils/types' } from '@vercel/commerce/utils/types'
import { ValidationError } from '@vercel/commerce/utils/errors' import { ValidationError } from '@vercel/commerce/utils/errors'
import useUpdateItem, { UseUpdateItem } from '@vercel/commerce/cart/use-update-item' import useUpdateItem, {
UseUpdateItem,
} from '@vercel/commerce/cart/use-update-item'
import type { LineItem, UpdateItemHook } from '@vercel/commerce/types/cart' import type { LineItem, UpdateItemHook } from '@vercel/commerce/types/cart'
import { handler as removeItemHandler } from './use-remove-item' import { handler as removeItemHandler } from './use-remove-item'
import useCart from './use-cart' import useCart from './use-cart'
@ -18,7 +20,7 @@ export default useUpdateItem as UseUpdateItem<typeof handler>
export const handler = { export const handler = {
fetchOptions: { fetchOptions: {
url: '/api/cart', url: '/api/commerce/cart',
method: 'PUT', method: 'PUT',
}, },
async fetcher({ async fetcher({

View File

@ -1,12 +1,14 @@
import { SWRHook } from '@vercel/commerce/utils/types' import type { SWRHook } from '@vercel/commerce/utils/types'
import useCustomer, { UseCustomer } from '@vercel/commerce/customer/use-customer' import useCustomer, {
import type { CustomerHook } from '../types/customer' type UseCustomer,
} from '@vercel/commerce/customer/use-customer'
import type { CustomerHook } from '@vercel/commerce/types/customer'
export default useCustomer as UseCustomer<typeof handler> export default useCustomer as UseCustomer<typeof handler>
export const handler: SWRHook<CustomerHook> = { export const handler: SWRHook<CustomerHook> = {
fetchOptions: { fetchOptions: {
url: '/api/customer', url: '/api/commerce/customer',
method: 'GET', method: 'GET',
}, },
async fetcher({ options, fetch }) { async fetcher({ options, fetch }) {

View File

@ -1,4 +1,7 @@
import { getCommerceProvider, useCommerce as useCoreCommerce } from '@vercel/commerce' import {
getCommerceProvider,
useCommerce as useCoreCommerce,
} from '@vercel/commerce'
import { bigcommerceProvider, BigcommerceProvider } from './provider' import { bigcommerceProvider, BigcommerceProvider } from './provider'
export { bigcommerceProvider } export { bigcommerceProvider }

View File

@ -1,10 +1,14 @@
import type { Product } from '../types/product' import type { Page } from '@vercel/commerce/types/page'
import type { Cart, BigcommerceCart, LineItem } from '../types/cart' import type { Product } from '@vercel/commerce/types/product'
import type { Page } from '../types/page' import type { Cart, LineItem } from '@vercel/commerce/types/cart'
import type { BCCategory, Category } from '../types/site' import type { Category, Brand } from '@vercel/commerce/types/site'
import { definitions } from '../api/definitions/store-content' import type { BigcommerceCart, BCCategory, BCBrand } from '../types'
import update from './immutability' import type { ProductNode } from '../api/operations/get-all-products'
import type { definitions } from '../api/definitions/store-content'
import type { BCWishlist } from '../api/utils/types'
import getSlug from './get-slug' import getSlug from './get-slug'
import { Wishlist } from '@vercel/commerce/types/wishlist'
function normalizeProductOption(productOption: any) { function normalizeProductOption(productOption: any) {
const { const {
@ -12,61 +16,50 @@ function normalizeProductOption(productOption: any) {
} = productOption } = productOption
return { return {
id: entityId, id: String(entityId),
values: edges?.map(({ node }: any) => node), values: edges?.map(({ node }: any) => node),
...rest, ...rest,
} }
} }
export function normalizeProduct(productNode: any): Product { export function normalizeProduct(productNode: ProductNode): Product {
const { const {
entityId: id, entityId: id,
productOptions, productOptions,
prices, prices,
path, path,
id: _, images,
options: _0, variants,
} = productNode } = productNode
return update(productNode, { return {
id: { $set: String(id) }, id: String(id),
images: { name: productNode.name,
$apply: ({ edges }: any) => description: productNode.description,
edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({ images:
url: urlOriginal, images.edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({
alt: altText, url: urlOriginal,
...rest, alt: altText,
})), ...rest,
}, })) || [],
variants: { path: `/${getSlug(path)}`,
$apply: ({ edges }: any) => variants:
edges?.map(({ node: { entityId, productOptions, ...rest } }: any) => ({ variants.edges?.map(
id: entityId, ({ node: { entityId, productOptions, ...rest } }: any) => ({
id: String(entityId),
options: productOptions?.edges options: productOptions?.edges
? productOptions.edges.map(normalizeProductOption) ? productOptions.edges.map(normalizeProductOption)
: [], : [],
...rest, ...rest,
})), })
}, ) || [],
options: { options: productOptions?.edges?.map(normalizeProductOption) || [],
$set: productOptions.edges slug: path?.replace(/^\/+|\/+$/g, ''),
? productOptions?.edges.map(normalizeProductOption)
: [],
},
brand: {
$apply: (brand: any) => (brand?.entityId ? brand?.entityId : null),
},
slug: {
$set: path?.replace(/^\/+|\/+$/g, ''),
},
price: { price: {
$set: { value: prices?.price.value,
value: prices?.price.value, currencyCode: prices?.price.currencyCode,
currencyCode: prices?.price.currencyCode,
},
}, },
$unset: ['entityId'], }
})
} }
export function normalizePage(page: definitions['page_Full']): Page { export function normalizePage(page: definitions['page_Full']): Page {
@ -75,7 +68,8 @@ export function normalizePage(page: definitions['page_Full']): Page {
name: page.name, name: page.name,
is_visible: page.is_visible, is_visible: page.is_visible,
sort_order: page.sort_order, sort_order: page.sort_order,
body: page.body, body: page.body ?? '',
url: page.url,
} }
} }
@ -119,7 +113,7 @@ function normalizeLineItem(item: any): LineItem {
listPrice: item.list_price, listPrice: item.list_price,
}, },
options: item.options, options: item.options,
path: item.url.split('/')[3], path: `/${item.url.split('/')[3]}`,
discounts: item.discounts.map((discount: any) => ({ discounts: item.discounts.map((discount: any) => ({
value: discount.discounted_amount, value: discount.discounted_amount,
})), })),
@ -134,3 +128,27 @@ export function normalizeCategory(category: BCCategory): Category {
path: category.path, path: category.path,
} }
} }
export function normalizeBrand(brand: BCBrand): Brand {
const path = brand.node.path.replace('/brands/', '')
const slug = getSlug(path)
return {
id: `${brand.node.entityId}`,
name: brand.node.name,
slug,
path: `/${slug}`,
}
}
export function normalizeWishlist(wishlist: BCWishlist): Wishlist {
return {
id: String(wishlist.id),
token: wishlist.token,
items: wishlist.items.map((item: any) => ({
id: String(item.id),
productId: String(item.product_id),
variantId: String(item.variant_id),
product: item.product,
})),
}
}

View File

@ -1,20 +1,20 @@
import { SWRHook } from '@vercel/commerce/utils/types' import { SWRHook } from '@vercel/commerce/utils/types'
import useSearch, { UseSearch } from '@vercel/commerce/product/use-search' import useSearch, { UseSearch } from '@vercel/commerce/product/use-search'
import type { SearchProductsHook } from '../types/product' import type { SearchProductsHook } from '@vercel/commerce/types/product'
export default useSearch as UseSearch<typeof handler> export default useSearch as UseSearch<typeof handler>
export type SearchProductsInput = { export type SearchProductsInput = {
search?: string search?: string
categoryId?: number | string categoryId?: string
brandId?: number brandId?: string
sort?: string sort?: string
locale?: string locale?: string
} }
export const handler: SWRHook<SearchProductsHook> = { export const handler: SWRHook<SearchProductsHook> = {
fetchOptions: { fetchOptions: {
url: '/api/catalog/products', url: '/api/commerce/catalog/products',
method: 'GET', method: 'GET',
}, },
fetcher({ input: { search, categoryId, brandId, sort }, options, fetch }) { fetcher({ input: { search, categoryId, brandId, sort }, options, fetch }) {
@ -24,7 +24,7 @@ export const handler: SWRHook<SearchProductsHook> = {
if (search) url.searchParams.set('search', search) if (search) url.searchParams.set('search', search)
if (Number.isInteger(Number(categoryId))) if (Number.isInteger(Number(categoryId)))
url.searchParams.set('categoryId', String(categoryId)) url.searchParams.set('categoryId', String(categoryId))
if (Number.isInteger(brandId)) if (Number.isInteger(Number(brandId)))
url.searchParams.set('brandId', String(brandId)) url.searchParams.set('brandId', String(brandId))
if (sort) url.searchParams.set('sort', sort) if (sort) url.searchParams.set('sort', sort)

View File

@ -0,0 +1,32 @@
import type { GetSiteInfoQuery } from '../schema'
export type BCCategory = NonNullable<
GetSiteInfoQuery['site']['categoryTree']
>[0]
export type BCBrand = NonNullable<
NonNullable<GetSiteInfoQuery['site']['brands']['edges']>[0]
>
// TODO: this type should match:
// https://developer.bigcommerce.com/api-reference/cart-checkout/server-server-cart-api/cart/getacart#responses
export type BigcommerceCart = {
id: string
parent_id?: string
customer_id: number
email: string
currency: { code: string }
tax_included: boolean
base_amount: number
discount_amount: number
cart_amount: number
line_items: {
custom_items: any[]
digital_items: any[]
gift_certificates: any[]
physical_items: any[]
}
created_time: string
discounts?: { id: number; discounted_amount: number }[]
// TODO: add missing fields
}

View File

@ -1,66 +0,0 @@
import * as Core from '@vercel/commerce/types/cart'
export * from '@vercel/commerce/types/cart'
// TODO: this type should match:
// https://developer.bigcommerce.com/api-reference/cart-checkout/server-server-cart-api/cart/getacart#responses
export type BigcommerceCart = {
id: string
parent_id?: string
customer_id: number
email: string
currency: { code: string }
tax_included: boolean
base_amount: number
discount_amount: number
cart_amount: number
line_items: {
custom_items: any[]
digital_items: any[]
gift_certificates: any[]
physical_items: any[]
}
created_time: string
discounts?: { id: number; discounted_amount: number }[]
// TODO: add missing fields
}
/**
* Extend core cart types
*/
export type Cart = Core.Cart & {
lineItems: Core.LineItem[]
}
export type OptionSelections = {
option_id: number
option_value: number | string
}
export type CartItemBody = Core.CartItemBody & {
productId: string // The product id is always required for BC
optionSelections?: OptionSelections[]
}
export type CartTypes = {
cart: Cart
item: Core.LineItem
itemBody: CartItemBody
}
export type CartHooks = Core.CartHooks<CartTypes>
export type GetCartHook = CartHooks['getCart']
export type AddItemHook = CartHooks['addItem']
export type UpdateItemHook = CartHooks['updateItem']
export type RemoveItemHook = CartHooks['removeItem']
export type CartSchema = Core.CartSchema<CartTypes>
export type CartHandlers = Core.CartHandlers<CartTypes>
export type GetCartHandler = CartHandlers['getCart']
export type AddItemHandler = CartHandlers['addItem']
export type UpdateItemHandler = CartHandlers['updateItem']
export type RemoveItemHandler = CartHandlers['removeItem']

View File

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

View File

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

View File

@ -1,5 +0,0 @@
import * as Core from '@vercel/commerce/types/customer'
export * from '@vercel/commerce/types/customer'
export type CustomerSchema = Core.CustomerSchema

View File

@ -1,25 +0,0 @@
import * as Cart from './cart'
import * as Checkout from './checkout'
import * as Common from './common'
import * as Customer from './customer'
import * as Login from './login'
import * as Logout from './logout'
import * as Page from './page'
import * as Product from './product'
import * as Signup from './signup'
import * as Site from './site'
import * as Wishlist from './wishlist'
export type {
Cart,
Checkout,
Common,
Customer,
Login,
Logout,
Page,
Product,
Signup,
Site,
Wishlist,
}

View File

@ -1,8 +0,0 @@
import * as Core from '@vercel/commerce/types/login'
import type { LoginMutationVariables } from '../../schema'
export * from '@vercel/commerce/types/login'
export type LoginOperation = Core.LoginOperation & {
variables: LoginMutationVariables
}

View File

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

View File

@ -1,11 +0,0 @@
import * as Core from '@vercel/commerce/types/page'
export * from '@vercel/commerce/types/page'
export type Page = Core.Page
export type PageTypes = {
page: Page
}
export type GetAllPagesOperation = Core.GetAllPagesOperation<PageTypes>
export type GetPageOperation = Core.GetPageOperation<PageTypes>

View File

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

View File

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

View File

@ -1,19 +0,0 @@
import * as Core from '@vercel/commerce/types/site'
import type { GetSiteInfoQuery, GetSiteInfoQueryVariables } from '../../schema'
export * from '@vercel/commerce/types/site'
export type BCCategory = NonNullable<
GetSiteInfoQuery['site']['categoryTree']
>[0]
export type Brand = NonNullable<
NonNullable<GetSiteInfoQuery['site']['brands']['edges']>[0]
>
export type SiteTypes = {
category: Core.Category
brand: Brand
}
export type GetSiteInfoOperation = Core.GetSiteInfoOperation<SiteTypes>

View File

@ -1,24 +0,0 @@
import * as Core from '@vercel/commerce/types/wishlist'
import { definitions } from '../api/definitions/wishlist'
import type { ProductEdge } from '../api/operations/get-all-products'
export * from '@vercel/commerce/types/wishlist'
export type WishlistItem = NonNullable<
definitions['wishlist_Full']['items']
>[0] & {
product?: ProductEdge['node']
}
export type Wishlist = Omit<definitions['wishlist_Full'], 'items'> & {
items?: WishlistItem[]
}
export type WishlistTypes = {
wishlist: Wishlist
itemBody: Core.WishlistItemBody
}
export type WishlistSchema = Core.WishlistSchema<WishlistTypes>
export type GetCustomerWishlistOperation =
Core.GetCustomerWishlistOperation<WishlistTypes>

View File

@ -1,8 +1,10 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types' import type { MutationHook } from '@vercel/commerce/utils/types'
import { CommerceError } from '@vercel/commerce/utils/errors' import { CommerceError } from '@vercel/commerce/utils/errors'
import useAddItem, { UseAddItem } from '@vercel/commerce/wishlist/use-add-item' import useAddItem, {
import type { AddItemHook } from '../types/wishlist' type UseAddItem,
} from '@vercel/commerce/wishlist/use-add-item'
import type { AddItemHook } from '@vercel/commerce/types/wishlist'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
import useWishlist from './use-wishlist' import useWishlist from './use-wishlist'
@ -10,7 +12,7 @@ export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = { export const handler: MutationHook<AddItemHook> = {
fetchOptions: { fetchOptions: {
url: '/api/wishlist', url: '/api/commerce/wishlist',
method: 'POST', method: 'POST',
}, },
useHook: useHook:

View File

@ -2,9 +2,9 @@ import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types' import type { MutationHook } from '@vercel/commerce/utils/types'
import { CommerceError } from '@vercel/commerce/utils/errors' import { CommerceError } from '@vercel/commerce/utils/errors'
import useRemoveItem, { import useRemoveItem, {
UseRemoveItem, type UseRemoveItem,
} from '@vercel/commerce/wishlist/use-remove-item' } from '@vercel/commerce/wishlist/use-remove-item'
import type { RemoveItemHook } from '../types/wishlist' import type { RemoveItemHook } from '@vercel/commerce/types/wishlist'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
import useWishlist from './use-wishlist' import useWishlist from './use-wishlist'
@ -12,7 +12,7 @@ export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler: MutationHook<RemoveItemHook> = { export const handler: MutationHook<RemoveItemHook> = {
fetchOptions: { fetchOptions: {
url: '/api/wishlist', url: '/api/commerce/wishlist',
method: 'DELETE', method: 'DELETE',
}, },
useHook: useHook:

View File

@ -1,16 +1,16 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { SWRHook } from '@vercel/commerce/utils/types' import { SWRHook } from '@vercel/commerce/utils/types'
import useWishlist, { import useWishlist, {
UseWishlist, type UseWishlist,
} from '@vercel/commerce/wishlist/use-wishlist' } from '@vercel/commerce/wishlist/use-wishlist'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
import type { GetWishlistHook } from '../types/wishlist' import type { GetWishlistHook } from '@vercel/commerce/types/wishlist'
export default useWishlist as UseWishlist<typeof handler> export default useWishlist as UseWishlist<typeof handler>
export const handler: SWRHook<GetWishlistHook> = { export const handler: SWRHook<GetWishlistHook> = {
fetchOptions: { fetchOptions: {
url: '/api/wishlist', url: '/api/commerce/wishlist',
method: 'GET', method: 'GET',
}, },
async fetcher({ input: { customerId, includeProducts }, options, fetch }) { async fetcher({ input: { customerId, includeProducts }, options, fetch }) {
@ -32,7 +32,7 @@ export const handler: SWRHook<GetWishlistHook> = {
const { data: customer } = useCustomer() const { data: customer } = useCustomer()
const response = useData({ const response = useData({
input: [ input: [
['customerId', customer?.entityId], ['customerId', customer?.id],
['includeProducts', input?.includeProducts], ['includeProducts', input?.includeProducts],
], ],
swrOptions: { swrOptions: {

View File

@ -70,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: 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 ```tsx
import { getCommerceProvider, useCommerce as useCoreCommerce } from '@vercel/commerce' import {
getCommerceProvider,
useCommerce as useCoreCommerce,
} from '@vercel/commerce'
import { bigcommerceProvider, BigcommerceProvider } from './provider' import { bigcommerceProvider, BigcommerceProvider } from './provider'
export { bigcommerceProvider } export { bigcommerceProvider }
@ -136,7 +139,7 @@ export default useCart as UseCart<typeof handler>
export const handler: SWRHook<GetCartHook> = { export const handler: SWRHook<GetCartHook> = {
fetchOptions: { fetchOptions: {
url: '/api/cart', url: '/api/commerce/cart',
method: 'GET', method: 'GET',
}, },
useHook: useHook:
@ -176,7 +179,7 @@ export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = { export const handler: MutationHook<AddItemHook> = {
fetchOptions: { fetchOptions: {
url: '/api/cart', url: '/api/commerce/cart',
method: 'POST', method: 'POST',
}, },
async fetcher({ input: item, options, fetch }) { async fetcher({ input: item, options, fetch }) {
@ -214,25 +217,26 @@ export const handler: MutationHook<AddItemHook> = {
``` ```
## Showing progress and features ## 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. 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** **Status**
* [ ] CommerceProvider - [ ] CommerceProvider
* [ ] Schema & TS types - [ ] Schema & TS types
* [ ] API Operations - Get all collections - [ ] API Operations - Get all collections
* [ ] API Operations - Get all pages - [ ] API Operations - Get all pages
* [ ] API Operations - Get all products - [ ] API Operations - Get all products
* [ ] API Operations - Get page - [ ] API Operations - Get page
* [ ] API Operations - Get product - [ ] API Operations - Get product
* [ ] API Operations - Get Shop Info (categories and vendors working — `vendors` query still a WIP PR on Reaction) - [ ] API Operations - Get Shop Info (categories and vendors working — `vendors` query still a WIP PR on Reaction)
* [ ] Hook - Add Item - [ ] Hook - Add Item
* [ ] Hook - Remove Item - [ ] Hook - Remove Item
* [ ] Hook - Update Item - [ ] Hook - Update Item
* [ ] Hook - Get Cart (account-tied carts working, anonymous carts working, cart reconciliation working) - [ ] 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) - [ ] Auth (based on a WIP PR on Reaction - still need to implement refresh tokens)
* [ ] Customer information - [ ] Customer information
* [ ] Product attributes - Size, Colors - [ ] Product attributes - Size, Colors
* [ ] Custom checkout - [ ] Custom checkout
* [ ] Typing (in progress) - [ ] Typing (in progress)
* [ ] Tests - [ ] Tests

View File

@ -47,16 +47,17 @@
} }
}, },
"dependencies": { "dependencies": {
"@vercel/fetch": "^6.1.1", "@vercel/edge": "^0.0.4",
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
"import-cwd": "^3.0.0", "import-cwd": "^3.0.0",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"swr": "^1.2.0" "swr": "^1.3.0",
"zod": "^3.19.1"
}, },
"peerDependencies": { "peerDependencies": {
"next": "^12", "next": "^13",
"react": "^17", "react": "^18",
"react-dom": "^17" "react-dom": "^18"
}, },
"devDependencies": { "devDependencies": {
"@taskr/clear": "^1.1.0", "@taskr/clear": "^1.1.0",
@ -64,15 +65,16 @@
"@taskr/watch": "^1.1.0", "@taskr/watch": "^1.1.0",
"@types/js-cookie": "^3.0.1", "@types/js-cookie": "^3.0.1",
"@types/node": "^17.0.8", "@types/node": "^17.0.8",
"@types/react": "^17.0.38", "@types/node-fetch": "2.6.2",
"@types/react": "^18.0.14",
"lint-staged": "^12.1.7", "lint-staged": "^12.1.7",
"next": "^12.0.8", "next": "^13.0.6",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"react": "^17.0.2", "react": "^18.2.0",
"react-dom": "^17.0.2", "react-dom": "^18.2.0",
"taskr": "^1.1.0", "taskr": "^1.1.0",
"taskr-swc": "^0.0.1", "taskr-swc": "^0.0.1",
"typescript": "^4.5.4" "typescript": "^4.7.4"
}, },
"lint-staged": { "lint-staged": {
"**/*.{js,jsx,ts,tsx,json}": [ "**/*.{js,jsx,ts,tsx,json}": [

View File

@ -1,60 +1,64 @@
import type { CartSchema } from '../../types/cart'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..' import type { GetAPISchema } from '..'
import type { CartSchema } from '../../types/cart'
const cartEndpoint: GetAPISchema<any, CartSchema<any>>['endpoint']['handler'] = import { parse, getInput } from '../utils'
async (ctx) => { import validateHandlers from '../utils/validate-handlers'
const { req, res, handlers, config } = ctx
if ( import {
!isAllowedOperation(req, res, { getCartBodySchema,
GET: handlers['getCart'], addItemBodySchema,
POST: handlers['addItem'], updateItemBodySchema,
PUT: handlers['updateItem'], removeItemBodySchema,
DELETE: handlers['removeItem'], cartSchema,
}) } from '../../schemas/cart'
) {
return
}
const { cookies } = req const cartEndpoint: GetAPISchema<
const cartId = cookies[config.cartCookie] any,
CartSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, handlers, config } = ctx
try { validateHandlers(req, {
// Return current cart info GET: handlers['getCart'],
if (req.method === 'GET') { POST: handlers['addItem'],
const body = { cartId } PUT: handlers['updateItem'],
return await handlers['getCart']({ ...ctx, body }) DELETE: handlers['removeItem'],
} })
// Create or add an item to the cart const input = await getInput(req)
if (req.method === 'POST') {
const body = { ...req.body, cartId }
return await handlers['addItem']({ ...ctx, body })
}
// Update item in cart let output
if (req.method === 'PUT') { const { cookies } = req
const body = { ...req.body, cartId } const cartId = cookies.get(config.cartCookie)?.value
return await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from the cart // Return current cart info
if (req.method === 'DELETE') { if (req.method === 'GET') {
const body = { ...req.body, cartId } const body = getCartBodySchema.parse({ cartId })
return await handlers['removeItem']({ ...ctx, body }) output = await handlers['getCart']({ ...ctx, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
} }
// Create or add an item to the cart
if (req.method === 'POST') {
const body = addItemBodySchema.parse({ ...input, cartId })
if (!body.item.quantity) {
body.item.quantity = 1
}
output = await handlers['addItem']({ ...ctx, body })
}
// Update item in cart
if (req.method === 'PUT') {
const body = updateItemBodySchema.parse({ ...input, cartId })
output = await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from the cart
if (req.method === 'DELETE') {
const body = removeItemBodySchema.parse({ ...input, cartId })
return await handlers['removeItem']({ ...ctx, body })
}
return output ? parse(output, cartSchema.nullish()) : { status: 405 }
}
export default cartEndpoint export default cartEndpoint

View File

@ -1,31 +1,37 @@
import type { ProductsSchema } from '../../../types/product'
import { CommerceAPIError } from '../../utils/errors'
import isAllowedOperation from '../../utils/is-allowed-operation'
import type { GetAPISchema } from '../..' import type { GetAPISchema } from '../..'
import type { ProductsSchema } from '../../../types/product'
import validateHandlers from '../../utils/validate-handlers'
import {
searchProductBodySchema,
searchProductsSchema,
} from '../../../schemas/product'
import { parse } from '../../utils'
const productsEndpoint: GetAPISchema< const productsEndpoint: GetAPISchema<
any, any,
ProductsSchema ProductsSchema
>['endpoint']['handler'] = async (ctx) => { >['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers } = ctx const { req, handlers } = ctx
if (!isAllowedOperation(req, res, { GET: handlers['getProducts'] })) { validateHandlers(req, { GET: handlers['getProducts'] })
return const { searchParams } = new URL(req.url)
const body = searchProductBodySchema.parse({
search: searchParams.get('search') ?? undefined,
categoryId: searchParams.get('categoryId') ?? undefined,
brandId: searchParams.get('brandId') ?? undefined,
sort: searchParams.get('sort') ?? undefined,
})
const res = await handlers['getProducts']({ ...ctx, body })
res.headers = {
'Cache-Control': 'max-age=0, s-maxage=3600, stale-while-revalidate, public',
...res.headers,
} }
try { return parse(res, searchProductsSchema)
const body = req.query
return await handlers['getProducts']({ ...ctx, body })
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
} }
export default productsEndpoint export default productsEndpoint

View File

@ -1,49 +1,45 @@
import type { CheckoutSchema } from '../../types/checkout'
import type { GetAPISchema } from '..' import type { GetAPISchema } from '..'
import type { CheckoutSchema } from '../../types/checkout'
import { CommerceAPIError } from '../utils/errors' import {
import isAllowedOperation from '../utils/is-allowed-operation' checkoutSchema,
getCheckoutBodySchema,
submitCheckoutBodySchema,
} from '../../schemas/checkout'
import { parse, getInput } from '../utils'
import validateHandlers from '../utils/validate-handlers'
const checkoutEndpoint: GetAPISchema< const checkoutEndpoint: GetAPISchema<
any, any,
CheckoutSchema CheckoutSchema
>['endpoint']['handler'] = async (ctx) => { >['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers, config } = ctx const { req, handlers, config } = ctx
if ( validateHandlers(req, {
!isAllowedOperation(req, res, { GET: handlers['getCheckout'],
GET: handlers['getCheckout'], POST: handlers['submitCheckout'],
POST: handlers['submitCheckout'], })
})
) {
return
}
const { cookies } = req const { cookies } = req
const cartId = cookies[config.cartCookie] const cartId = cookies.get(config.cartCookie)?.value
const input = await getInput(req)
try { // Get checkout
// Create checkout if (req.method === 'GET') {
if (req.method === 'GET') { const body = getCheckoutBodySchema.parse({ ...input, cartId })
const body = { ...req.body, cartId } const res = await handlers['getCheckout']({ ...ctx, body })
return await handlers['getCheckout']({ ...ctx, body }) return parse(res, checkoutSchema.optional())
}
// Create checkout
if (req.method === 'POST' && handlers['submitCheckout']) {
const body = { ...req.body, cartId }
return await handlers['submitCheckout']({ ...ctx, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
} }
// Create checkout
if (req.method === 'POST' && handlers['submitCheckout']) {
const body = submitCheckoutBodySchema.parse({ ...input, cartId })
const res = await handlers['submitCheckout']({ ...ctx, body })
return parse(res, checkoutSchema.optional())
}
return { status: 405 }
} }
export default checkoutEndpoint export default checkoutEndpoint

View File

@ -1,65 +1,68 @@
import type { CustomerAddressSchema } from '../../../types/customer/address' import type { CustomerAddressSchema } from '../../../types/customer/address'
import type { GetAPISchema } from '../..' import type { GetAPISchema } from '../..'
import { CommerceAPIError } from '../../utils/errors' import validateHandlers from '../../utils/validate-handlers'
import isAllowedOperation from '../../utils/is-allowed-operation'
import {
addAddressBodySchema,
addressSchema,
deleteAddressBodySchema,
updateAddressBodySchema,
} from '../../../schemas/customer'
import { parse, getInput } from '../../utils'
import { getCartBodySchema } from '../../../schemas/cart'
// create a function that returns a function
const customerShippingEndpoint: GetAPISchema< const customerShippingEndpoint: GetAPISchema<
any, any,
CustomerAddressSchema CustomerAddressSchema
>['endpoint']['handler'] = async (ctx) => { >['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers, config } = ctx const { req, handlers, config } = ctx
if ( validateHandlers(req, {
!isAllowedOperation(req, res, { GET: handlers['getAddresses'],
GET: handlers['getAddresses'], POST: handlers['addItem'],
POST: handlers['addItem'], PUT: handlers['updateItem'],
PUT: handlers['updateItem'], DELETE: handlers['removeItem'],
DELETE: handlers['removeItem'], })
})
) {
return
}
let output
const input = await getInput(req)
const { cookies } = req const { cookies } = req
// Cart id might be usefull for anonymous shopping // Cart id might be usefull for anonymous shopping
const cartId = cookies[config.cartCookie] const cartId = cookies.get(config.cartCookie)?.value
try { // Return customer addresses
// Return customer addresses if (req.method === 'GET') {
if (req.method === 'GET') { const body = getCartBodySchema.parse({ cartId })
const body = { cartId } return parse(
return await handlers['getAddresses']({ ...ctx, body }) await handlers['getAddresses']({ ...ctx, body }),
} addressSchema
)
// Create or add an item to customer addresses list
if (req.method === 'POST') {
const body = { ...req.body, cartId }
return await handlers['addItem']({ ...ctx, body })
}
// Update item in customer addresses list
if (req.method === 'PUT') {
const body = { ...req.body, cartId }
return await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from customer addresses list
if (req.method === 'DELETE') {
const body = { ...req.body, cartId }
return await handlers['removeItem']({ ...ctx, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
} }
// Create or add an item to customer addresses list
if (req.method === 'POST') {
const body = addAddressBodySchema.parse({ ...input, cartId })
output = await handlers['addItem']({ ...ctx, body })
}
// Update item in customer addresses list
if (req.method === 'PUT') {
const body = updateAddressBodySchema.parse({ ...input, cartId })
output = await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from customer addresses list
if (req.method === 'DELETE') {
const body = deleteAddressBodySchema.parse({ ...input, cartId })
return await handlers['removeItem']({ ...ctx, body })
}
return output ? parse(output, addressSchema) : { status: 405 }
} }
export default customerShippingEndpoint export default customerShippingEndpoint

View File

@ -1,65 +1,67 @@
import type { CustomerCardSchema } from '../../../types/customer/card' import type { CustomerCardSchema } from '../../../types/customer/card'
import type { GetAPISchema } from '../..' import type { GetAPISchema } from '../..'
import { CommerceAPIError } from '../../utils/errors' import { z } from 'zod'
import isAllowedOperation from '../../utils/is-allowed-operation'
import {
cardSchema,
addCardBodySchema,
deleteCardBodySchema,
updateCardBodySchema,
} from '../../../schemas/customer'
import { parse, getInput } from '../../utils'
import validateHandlers from '../../utils/validate-handlers'
const customerCardEndpoint: GetAPISchema< const customerCardEndpoint: GetAPISchema<
any, any,
CustomerCardSchema CustomerCardSchema
>['endpoint']['handler'] = async (ctx) => { >['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers, config } = ctx const { req, handlers, config } = ctx
if ( validateHandlers(req, {
!isAllowedOperation(req, res, { GET: handlers['getCards'],
GET: handlers['getCards'], POST: handlers['addItem'],
POST: handlers['addItem'], PUT: handlers['updateItem'],
PUT: handlers['updateItem'], DELETE: handlers['removeItem'],
DELETE: handlers['removeItem'], })
})
) {
return
}
let output
const input = await getInput(req)
const { cookies } = req const { cookies } = req
// Cart id might be usefull for anonymous shopping // Cart id might be usefull for anonymous shopping
const cartId = cookies[config.cartCookie] const cartId = cookies.get(config.cartCookie)?.value
try { // Create or add a card
// Create or add a card if (req.method === 'GET') {
if (req.method === 'GET') { const body = { ...input }
const body = { ...req.body } return parse(
return await handlers['getCards']({ ...ctx, body }) await handlers['getCards']({ ...ctx, body }),
} z.array(cardSchema).optional()
)
// Create or add an item to customer cards
if (req.method === 'POST') {
const body = { ...req.body, cartId }
return await handlers['addItem']({ ...ctx, body })
}
// Update item in customer cards
if (req.method === 'PUT') {
const body = { ...req.body, cartId }
return await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from customer cards
if (req.method === 'DELETE') {
const body = { ...req.body, cartId }
return await handlers['removeItem']({ ...ctx, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
} }
// Create or add an item to customer cards
if (req.method === 'POST') {
const body = addCardBodySchema.parse({ ...input, cartId })
output = await handlers['addItem']({ ...ctx, body })
}
// Update item in customer cards
if (req.method === 'PUT') {
const body = updateCardBodySchema.parse({ ...input, cartId })
output = await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from customer cards
if (req.method === 'DELETE') {
const body = deleteCardBodySchema.parse({ ...input, cartId })
return await handlers['removeItem']({ ...ctx, body })
}
return output ? parse(output, cardSchema.nullish()) : { status: 405 }
} }
export default customerCardEndpoint export default customerCardEndpoint

View File

@ -1,36 +1,32 @@
import type { CustomerSchema } from '../../../types/customer' import type { CustomerSchema } from '../../../types/customer'
import type { GetAPISchema } from '../..' import type { GetAPISchema } from '../..'
import { z } from 'zod'
import { parse } from '../../utils'
import validateHandlers from '../../utils/validate-handlers'
import { CommerceAPIError } from '../../utils/errors' import { customerSchema } from '../../../schemas/customer'
import isAllowedOperation from '../../utils/is-allowed-operation'
const customerEndpoint: GetAPISchema< const customerEndpoint: GetAPISchema<
any, any,
CustomerSchema<any> CustomerSchema
>['endpoint']['handler'] = async (ctx) => { >['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers } = ctx const { req, handlers } = ctx
if ( validateHandlers(req, {
!isAllowedOperation(req, res, { GET: handlers['getLoggedInCustomer'],
GET: handlers['getLoggedInCustomer'], })
})
) {
return
}
try { const body = null
const body = null const output = await handlers['getLoggedInCustomer']({ ...ctx, body })
return await handlers['getLoggedInCustomer']({ ...ctx, body })
} catch (error) {
console.error(error)
const message = return output
error instanceof CommerceAPIError ? parse(
? 'An unexpected error ocurred with the Commerce API' output,
: 'An unexpected error ocurred' z.object({
customer: customerSchema,
res.status(500).json({ data: null, errors: [{ message }] }) })
} )
: { status: 204 }
} }
export default customerEndpoint export default customerEndpoint

View File

@ -0,0 +1,10 @@
import edgeHandler from '../utils/edge-handler'
import nodeHandler from '../utils/node-handler'
/**
* Next.js Commerce API endpoints handler. Based on the path, it will call the corresponding endpoint handler,
* exported from the `endpoints` folder of the provider.
* @param {CommerceAPI} commerce The Commerce API instance.
* @param endpoints An object containing the handlers for each endpoint.
*/
export default process.env.NEXT_RUNTIME === 'edge' ? edgeHandler : nodeHandler

View File

@ -1,36 +1,25 @@
import type { LoginSchema } from '../../types/login'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..' import type { GetAPISchema } from '..'
import type { LoginSchema } from '../../types/login'
import { getInput } from '../utils'
import validateHandlers from '../utils/validate-handlers'
import { loginBodySchema } from '../../schemas/auth'
const loginEndpoint: GetAPISchema< const loginEndpoint: GetAPISchema<
any, any,
LoginSchema<any> LoginSchema
>['endpoint']['handler'] = async (ctx) => { >['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers } = ctx const { req, handlers } = ctx
if ( validateHandlers(req, {
!isAllowedOperation(req, res, { POST: handlers['login'],
POST: handlers['login'], GET: handlers['login'],
GET: handlers['login'], })
})
) {
return
}
try { const input = await getInput(req)
const body = req.body ?? {} const body = loginBodySchema.parse(input)
return await handlers['login']({ ...ctx, body }) return handlers['login']({ ...ctx, body })
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
} }
export default loginEndpoint export default loginEndpoint

View File

@ -1,35 +1,26 @@
import type { LogoutSchema } from '../../types/logout'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..' import type { GetAPISchema } from '..'
import type { LogoutSchema } from '../../types/logout'
const logoutEndpoint: GetAPISchema<any, LogoutSchema>['endpoint']['handler'] = import { logoutBodySchema } from '../../schemas/auth'
async (ctx) => { import validateHandlers from '../utils/validate-handlers'
const { req, res, handlers } = ctx
if ( const logoutEndpoint: GetAPISchema<
!isAllowedOperation(req, res, { any,
GET: handlers['logout'], LogoutSchema
}) >['endpoint']['handler'] = async (ctx) => {
) { const { req, handlers } = ctx
return
}
try { validateHandlers(req, {
const redirectTo = req.query.redirect_to GET: handlers['logout'],
const body = typeof redirectTo === 'string' ? { redirectTo } : {} })
return await handlers['logout']({ ...ctx, body }) const redirectTo = new URL(req.url).searchParams.get('redirectTo')
} catch (error) {
console.error(error)
const message = const body = logoutBodySchema.parse(
error instanceof CommerceAPIError typeof redirectTo === 'string' ? { redirectTo } : {}
? 'An unexpected error ocurred with the Commerce API' )
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] }) return handlers['logout']({ ...ctx, body })
} }
}
export default logoutEndpoint export default logoutEndpoint

View File

@ -1,36 +1,27 @@
import type { SignupSchema } from '../../types/signup'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..' import type { GetAPISchema } from '..'
import type { SignupSchema } from '../../types/signup'
const signupEndpoint: GetAPISchema<any, SignupSchema>['endpoint']['handler'] = import { getInput } from '../utils'
async (ctx) => { import validateHandlers from '../utils/validate-handlers'
const { req, res, handlers, config } = ctx
if ( import { signupBodySchema } from '../../schemas/auth'
!isAllowedOperation(req, res, {
POST: handlers['signup'],
})
) {
return
}
const { cookies } = req const signupEndpoint: GetAPISchema<
const cartId = cookies[config.cartCookie] any,
SignupSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, handlers, config } = ctx
try { validateHandlers(req, {
const body = { ...req.body, cartId } POST: handlers['signup'],
return await handlers['signup']({ ...ctx, body }) })
} catch (error) {
console.error(error)
const message = const input = await getInput(req)
error instanceof CommerceAPIError const { cookies } = req
? 'An unexpected error ocurred with the Commerce API' const cartId = cookies.get(config.cartCookie)?.value
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] }) const body = signupBodySchema.parse({ ...input, cartId })
} return handlers['signup']({ ...ctx, body })
} }
export default signupEndpoint export default signupEndpoint

View File

@ -1,58 +1,58 @@
import type { WishlistSchema } from '../../types/wishlist'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..' import type { GetAPISchema } from '..'
import type { WishlistSchema } from '../../types/wishlist'
import { parse, getInput } from '../utils'
import {
wishlistSchema,
addItemBodySchema,
removeItemBodySchema,
getWishlistBodySchema,
} from '../../schemas/whishlist'
import validateHandlers from '../utils/validate-handlers'
const wishlistEndpoint: GetAPISchema< const wishlistEndpoint: GetAPISchema<
any, any,
WishlistSchema<any> WishlistSchema
>['endpoint']['handler'] = async (ctx) => { >['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers, config } = ctx const { req, handlers, config } = ctx
if ( validateHandlers(req, {
!isAllowedOperation(req, res, { GET: handlers['getWishlist'],
GET: handlers['getWishlist'], POST: handlers['addItem'],
POST: handlers['addItem'], DELETE: handlers['removeItem'],
DELETE: handlers['removeItem'], })
})
) {
return
}
let output
const { cookies } = req const { cookies } = req
const customerToken = cookies[config.customerCookie] const input = await getInput(req)
try { const customerToken = cookies.get(config.customerCookie)?.value
// Return current wishlist info const products = new URL(req.url).searchParams.get('products')
if (req.method === 'GET') {
const body = {
customerToken,
includeProducts: req.query.products === '1',
}
return await handlers['getWishlist']({ ...ctx, body })
}
// Add an item to the wishlist // Return current wishlist info
if (req.method === 'POST') { if (req.method === 'GET') {
const body = { ...req.body, customerToken } const body = getWishlistBodySchema.parse({
return await handlers['addItem']({ ...ctx, body }) customerToken,
} includeProducts: !!products,
})
// Remove an item from the wishlist output = await handlers['getWishlist']({ ...ctx, body })
if (req.method === 'DELETE') {
const body = { ...req.body, customerToken }
return await handlers['removeItem']({ ...ctx, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
} }
// Add an item to the wishlist
if (req.method === 'POST') {
const body = addItemBodySchema.parse({ ...input, customerToken })
output = await handlers['addItem']({ ...ctx, body })
}
// Remove an item from the wishlist
if (req.method === 'DELETE') {
const body = removeItemBodySchema.parse({ ...input, customerToken })
output = await handlers['removeItem']({ ...ctx, body })
}
return output ? parse(output, wishlistSchema.optional()) : { status: 405 }
} }
export default wishlistEndpoint export default wishlistEndpoint

View File

@ -1,6 +1,5 @@
import type { NextApiHandler } from 'next' import type { NextRequest } from 'next/server'
import type { FetchOptions, Response } from '@vercel/fetch' import type { APIEndpoint, APIHandler, APIResponse } from './utils/types'
import type { APIEndpoint, APIHandler } from './utils/types'
import type { CartSchema } from '../types/cart' import type { CartSchema } from '../types/cart'
import type { CustomerSchema } from '../types/customer' import type { CustomerSchema } from '../types/customer'
import type { LoginSchema } from '../types/login' import type { LoginSchema } from '../types/login'
@ -11,11 +10,14 @@ import type { WishlistSchema } from '../types/wishlist'
import type { CheckoutSchema } from '../types/checkout' import type { CheckoutSchema } from '../types/checkout'
import type { CustomerCardSchema } from '../types/customer/card' import type { CustomerCardSchema } from '../types/customer/card'
import type { CustomerAddressSchema } from '../types/customer/address' import type { CustomerAddressSchema } from '../types/customer/address'
import { withOperationCallback } from './utils/with-operation-callback'
import { import {
defaultOperations,
OPERATIONS, OPERATIONS,
AllOperations, AllOperations,
APIOperations, APIOperations,
defaultOperations,
} from './operations' } from './operations'
export type APISchemas = export type APISchemas =
@ -71,6 +73,12 @@ export type EndpointHandlers<
> >
} }
export type FetchOptions<Body = any> = {
method?: string
body?: Body
headers?: HeadersInit
}
export type APIProvider = { export type APIProvider = {
config: CommerceAPIConfig config: CommerceAPIConfig
operations: APIOperations<any> operations: APIOperations<any>
@ -106,13 +114,18 @@ export function getCommerceApi<P extends APIProvider>(
OPERATIONS.forEach((k) => { OPERATIONS.forEach((k) => {
const op = ops[k] const op = ops[k]
if (op) { if (op) {
commerce[k] = op({ commerce }) as AllOperations<P>[typeof k] commerce[k] = withOperationCallback(
k,
op({ commerce })
) as AllOperations<P>[typeof k]
} }
}) })
return commerce return commerce
} }
export type EndpointHandler = (req: NextRequest) => Promise<APIResponse>
export function getEndpoint< export function getEndpoint<
P extends APIProvider, P extends APIProvider,
T extends GetAPISchema<any, any> T extends GetAPISchema<any, any>
@ -122,13 +135,11 @@ export function getEndpoint<
config?: P['config'] config?: P['config']
options?: T['schema']['endpoint']['options'] options?: T['schema']['endpoint']['options']
} }
): NextApiHandler { ): EndpointHandler {
const cfg = commerce.getConfig(context.config) const cfg = commerce.getConfig(context.config)
return function apiHandler(req) {
return function apiHandler(req, res) {
return context.handler({ return context.handler({
req, req,
res,
commerce, commerce,
config: cfg, config: cfg,
handlers: context.handlers, handlers: context.handlers,
@ -145,7 +156,7 @@ export const createEndpoint =
config?: P['config'] config?: P['config']
options?: API['schema']['endpoint']['options'] options?: API['schema']['endpoint']['options']
} }
): NextApiHandler => { ): EndpointHandler => {
return getEndpoint(commerce, { ...endpoint, ...context }) return getEndpoint(commerce, { ...endpoint, ...context })
} }
@ -160,7 +171,7 @@ export interface CommerceAPIConfig {
fetch<Data = any, Variables = any>( fetch<Data = any, Variables = any>(
query: string, query: string,
queryData?: CommerceAPIFetchOptions<Variables>, queryData?: CommerceAPIFetchOptions<Variables>,
fetchOptions?: FetchOptions options?: FetchOptions
): Promise<GraphQLFetcherResult<Data>> ): Promise<GraphQLFetcherResult<Data>>
} }
@ -169,8 +180,7 @@ export type GraphQLFetcher<
Variables = any Variables = any
> = ( > = (
query: string, query: string,
queryData?: CommerceAPIFetchOptions<Variables>, queryData?: CommerceAPIFetchOptions<Variables>
fetchOptions?: FetchOptions
) => Promise<Data> ) => Promise<Data>
export interface GraphQLFetcherResult<Data = any> { export interface GraphQLFetcherResult<Data = any> {

View File

@ -1,4 +1,3 @@
import type { ServerResponse } from 'http'
import type { LoginOperation } from '../types/login' import type { LoginOperation } from '../types/login'
import type { GetAllPagesOperation, GetPageOperation } from '../types/page' import type { GetAllPagesOperation, GetPageOperation } from '../types/page'
import type { GetSiteInfoOperation } from '../types/site' import type { GetSiteInfoOperation } from '../types/site'
@ -25,6 +24,13 @@ export const OPERATIONS = [
'getProduct', 'getProduct',
] as const ] as const
export type Operation = {
[O in AllowedOperations]: {
name: O
data: Awaited<ReturnType<Operations<APIProvider>[O]>>
}
}[AllowedOperations]
export const defaultOperations = OPERATIONS.reduce((ops, k) => { export const defaultOperations = OPERATIONS.reduce((ops, k) => {
ops[k] = noop ops[k] = noop
return ops return ops
@ -37,14 +43,14 @@ export type Operations<P extends APIProvider> = {
<T extends LoginOperation>(opts: { <T extends LoginOperation>(opts: {
variables: T['variables'] variables: T['variables']
config?: P['config'] config?: P['config']
res: ServerResponse res: Response
}): Promise<T['data']> }): Promise<T['data']>
<T extends LoginOperation>( <T extends LoginOperation>(
opts: { opts: {
variables: T['variables'] variables: T['variables']
config?: P['config'] config?: P['config']
res: ServerResponse res: Response
} & OperationOptions } & OperationOptions
): Promise<T['data']> ): Promise<T['data']>
} }

View File

@ -0,0 +1,81 @@
import type { APIProvider, CommerceAPI, EndpointHandler } from '..'
import type { NextRequest } from 'next/server'
import { normalizeApiError } from './errors'
import { transformHeaders } from '.'
export default function edgeHandler<P extends APIProvider>(
commerce: CommerceAPI<P>,
endpoints: Record<string, (commerce: CommerceAPI<P>) => EndpointHandler>
) {
const endpointsKeys = Object.keys(endpoints)
const handlers = endpointsKeys.reduce<Record<string, EndpointHandler>>(
(acc, endpoint) =>
Object.assign(acc, {
[endpoint]: endpoints[endpoint](commerce),
}),
{}
)
return async (req: NextRequest) => {
try {
const { pathname } = new URL(req.url)
/**
* Get the current endpoint by removing the leading and trailing slash & base path.
* Csovers: /api/commerce/cart & /checkout
*/
const endpoint = pathname
.replace('/api/commerce/', '')
.replace(/^\/|\/$/g, '')
// Check if the handler for this path exists and return a 404 if it doesn't
if (!endpointsKeys.includes(endpoint)) {
throw new Error(
`Endpoint "${endpoint}" not implemented. Please use one of the available api endpoints: ${endpointsKeys.join(
', '
)}`
)
}
/**
* Executes the handler for this endpoint, provided by the provider,
* parses the input body and returns the parsed output
*/
const output = await handlers[endpoint](req)
// If the output is a Response, return it directly (E.g. checkout page & validateMethod util)
if (output instanceof Response) {
return output
}
const headers = transformHeaders(output.headers)
// If the output contains a redirectTo property, return a Response with the redirect
if (output.redirectTo) {
headers.append('Location', output.redirectTo)
return new Response(null, {
status: 302,
headers,
})
}
// Otherwise, return a JSON response with the output data or errors returned by the handler
const { data = null, errors, status } = output
return new Response(JSON.stringify({ data, errors }), {
status,
headers,
})
} catch (error) {
const output = normalizeApiError(error)
if (output instanceof Response) {
return output
}
const { status = 500, ...rest } = output
return output instanceof Response
? output
: new Response(JSON.stringify(rest), { status })
}
}
}

View File

@ -1,6 +1,9 @@
import type { Response } from '@vercel/fetch' import type { NextRequest } from 'next/server'
export class CommerceAPIError extends Error { import { CommerceError } from '../../utils/errors'
import { ZodError } from 'zod'
export class CommerceAPIResponseError extends Error {
status: number status: number
res: Response res: Response
data: any data: any
@ -14,9 +17,85 @@ export class CommerceAPIError extends Error {
} }
} }
export class CommerceAPIError extends Error {
status: number
code: string
constructor(
msg: string,
options?: {
status?: number
code?: string
}
) {
super(msg)
this.name = 'CommerceApiError'
this.status = options?.status || 500
this.code = options?.code || 'api_error'
}
}
export class CommerceNetworkError extends Error { export class CommerceNetworkError extends Error {
constructor(msg: string) { constructor(msg: string) {
super(msg) super(msg)
this.name = 'CommerceNetworkError' this.name = 'CommerceNetworkError'
} }
} }
export const normalizeZodIssues = (issues: ZodError['issues']) =>
issues.map(({ path, message }) =>
path.length ? `${message} at "${path.join('.')}" field` : message
)
export const getOperationError = (operation: string, error: unknown) => {
if (error instanceof ZodError) {
return new CommerceError({
code: 'SCHEMA_VALIDATION_ERROR',
message:
`Validation ${
error.issues.length === 1 ? 'error' : 'errors'
} at "${operation}" operation: \n` +
normalizeZodIssues(error.issues).join('\n'),
})
}
return error
}
export const normalizeApiError = (error: unknown, req?: NextRequest) => {
if (error instanceof CommerceAPIResponseError && error.res) {
return error.res
}
req?.url && console.log(req.url)
if (error instanceof ZodError) {
const message = 'Validation error, please check the input data!'
const errors = normalizeZodIssues(error.issues).map((message) => ({
message,
}))
console.error(`${message}\n${errors.map((e) => e.message).join('\n')}`)
return {
status: 400,
data: null,
errors,
}
}
console.error(error)
if (error instanceof CommerceAPIError) {
return {
errors: [
{
message: error.message,
code: error.code,
},
],
status: error.status,
}
}
return {
data: null,
errors: [{ message: 'An unexpected error ocurred' }],
}
}

View File

@ -0,0 +1,91 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import type { ZodSchema } from 'zod'
import type { APIResponse } from './types'
import { NextRequest } from 'next/server'
/**
* Parses the output data of the API handler and returns a valid APIResponse
* or throws an error if the data is invalid.
* @param res APIResponse
* @param parser ZodSchema
*/
export const parse = <T>(res: APIResponse<T>, parser: ZodSchema) => {
if (res.data) {
res.data = parser.parse(res.data)
}
return res
}
/**
* Returns the body of the request as a JSON object.
* @param req NextRequest
*/
export const getInput = (req: NextRequest) => req.json().catch(() => ({}))
/**
* Convert NextApiRequest to NextRequest
* @param req NextApiRequest
* @param path string
*/
export const transformRequest = (req: NextApiRequest) => {
const headers = new Headers()
let body
for (let i = 0; i < req.rawHeaders.length; i += 2) {
headers.append(req.rawHeaders[i], req.rawHeaders[i + 1])
}
if (
req.method === 'POST' ||
req.method === 'PUT' ||
req.method === 'DELETE'
) {
body = JSON.stringify(req.body)
}
// Get the url path & query string
const url = new URL(req.url || '/', `https://${req.headers.host}`)
return new NextRequest(url, {
headers,
method: req.method,
body,
})
}
/**
* Sets the custom headers received in the APIResponse in the
* @param headers Record<string, string|string[]> | Headers | undefined
* @returns Headers
*/
export const transformHeaders = (
headers: Record<string, string | number | string[]> | Headers = {}
) => {
if (headers instanceof Headers) {
return headers
}
const newHeaders = new Headers()
Object.entries(headers).forEach(([key, value]) => {
newHeaders.append(key, value as string)
})
return newHeaders
}
export const setHeaders = (
res: NextApiResponse,
headers: Record<string, string | number | string[]> | Headers = {}
) => {
if (headers instanceof Headers) {
headers.forEach((value, key) => {
res.setHeader(key, value)
})
} else {
Object.entries(headers).forEach(([key, value]) => {
res.setHeader(key, value)
})
}
}

View File

@ -1,30 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
export type HTTP_METHODS = 'OPTIONS' | 'GET' | 'POST' | 'PUT' | 'DELETE'
export default function isAllowedMethod(
req: NextApiRequest,
res: NextApiResponse,
allowedMethods: HTTP_METHODS[]
) {
const methods = allowedMethods.includes('OPTIONS')
? allowedMethods
: [...allowedMethods, 'OPTIONS']
if (!req.method || !methods.includes(req.method)) {
res.status(405)
res.setHeader('Allow', methods.join(', '))
res.end()
return false
}
if (req.method === 'OPTIONS') {
res.status(200)
res.setHeader('Allow', methods.join(', '))
res.setHeader('Content-Length', '0')
res.end()
return false
}
return true
}

View File

@ -1,19 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import isAllowedMethod, { HTTP_METHODS } from './is-allowed-method'
import { APIHandler } from './types'
export default function isAllowedOperation(
req: NextApiRequest,
res: NextApiResponse,
allowedOperations: { [k in HTTP_METHODS]?: APIHandler<any, any> }
) {
const methods = Object.keys(allowedOperations) as HTTP_METHODS[]
const allowedMethods = methods.reduce<HTTP_METHODS[]>((arr, method) => {
if (allowedOperations[method]) {
arr.push(method)
}
return arr
}, [])
return isAllowedMethod(req, res, allowedMethods)
}

View File

@ -0,0 +1,75 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import type { APIProvider, CommerceAPI, EndpointHandler } from '..'
import { normalizeApiError } from './errors'
import { transformRequest, setHeaders } from '.'
export default function nodeHandler<P extends APIProvider>(
commerce: CommerceAPI<P>,
endpoints: {
[key: string]: (commerce: CommerceAPI<P>) => EndpointHandler
}
) {
const paths = Object.keys(endpoints)
const handlers = paths.reduce<Record<string, EndpointHandler>>(
(acc, path) =>
Object.assign(acc, {
[path]: endpoints[path](commerce),
}),
{}
)
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
if (!req.query.commerce) {
throw new Error(
'Invalid configuration. Please make sure that the /pages/api/commerce/[[...commerce]].ts route is configured correctly, and it passes the commerce instance.'
)
}
/**
* Get the url path
*/
const path = Array.isArray(req.query.commerce)
? req.query.commerce.join('/')
: req.query.commerce
// Check if the handler for this path exists and return a 404 if it doesn't
if (!paths.includes(path)) {
throw new Error(
`Endpoint handler not implemented. Please use one of the available api endpoints: ${paths.join(
', '
)}`
)
}
const output = await handlers[path](transformRequest(req))
const { status, errors, data, redirectTo, headers } = output
setHeaders(res, headers)
if (output instanceof Response) {
return res.end(output.body)
}
if (redirectTo) {
return res.redirect(redirectTo)
}
res.status(status || 200).json({
data,
errors,
})
} catch (error) {
const output = normalizeApiError(error)
if (output instanceof Response) {
return res.end(output.body)
}
const { status = 500, ...rest } = output
res.status(status).json(rest)
}
}
}

View File

@ -1,14 +1,19 @@
import type { NextApiRequest, NextApiResponse } from 'next' import type { NextRequest } from 'next/server'
import type { CommerceAPI } from '..' import type { CommerceAPI } from '..'
export type ErrorData = { message: string; code?: string } export type ErrorData = { message: string; code?: string }
export type APIResponse<Data = any> = export type APIResponse<Data = any> = {
| { data: Data; errors?: ErrorData[] } data?: Data
// If `data` doesn't include `null`, then `null` is only allowed on errors errors?: ErrorData[]
| (Data extends null status?: number
? { data: null; errors?: ErrorData[] } headers?: Record<string, number | string | string[]> | Headers
: { data: null; errors: ErrorData[] }) /**
* @type {string}
* @example redirectTo: '/cart'
*/
redirectTo?: string
}
export type APIHandlerContext< export type APIHandlerContext<
C extends CommerceAPI, C extends CommerceAPI,
@ -16,14 +21,10 @@ export type APIHandlerContext<
Data = any, Data = any,
Options extends {} = {} Options extends {} = {}
> = { > = {
req: NextApiRequest req: NextRequest
res: NextApiResponse<APIResponse<Data>>
commerce: C commerce: C
config: C['provider']['config'] config: C['provider']['config']
handlers: H handlers: H
/**
* Custom configs that may be used by a particular handler
*/
options: Options options: Options
} }
@ -35,7 +36,7 @@ export type APIHandler<
Options extends {} = {} Options extends {} = {}
> = ( > = (
context: APIHandlerContext<C, H, Data, Options> & { body: Body } context: APIHandlerContext<C, H, Data, Options> & { body: Body }
) => void | Promise<void> ) => Promise<APIResponse<Data>>
export type APIHandlers<C extends CommerceAPI> = { export type APIHandlers<C extends CommerceAPI> = {
[k: string]: APIHandler<C, any, any, any, any> [k: string]: APIHandler<C, any, any, any, any>
@ -46,4 +47,6 @@ export type APIEndpoint<
H extends APIHandlers<C> = {}, H extends APIHandlers<C> = {},
Data = any, Data = any,
Options extends {} = {} Options extends {} = {}
> = (context: APIHandlerContext<C, H, Data, Options>) => void | Promise<void> > = (
context: APIHandlerContext<C, H, Data, Options>
) => Promise<APIResponse<Data>>

View File

@ -0,0 +1,24 @@
import type { NextRequest } from 'next/server'
import type { APIHandler } from './types'
import validateMethod, { HTTP_METHODS } from './validate-method'
/**
* Validates the request method and throws an error if it's not allowed, or if the handler is not implemented.
* and stops the execution of the handler.
* @param req The request object.
* @param allowedOperations An object containing the handlers for each method.
* @throws Error when the method is not allowed or the handler is not implemented.
*/
export default function validateHandlers(
req: NextRequest,
allowedOperations: { [k in HTTP_METHODS]?: APIHandler<any, any> }
) {
const methods = Object.keys(allowedOperations) as HTTP_METHODS[]
const allowedMethods = methods.reduce<HTTP_METHODS[]>((arr, method) => {
if (allowedOperations[method]) {
arr.push(method)
}
return arr
}, [])
return validateMethod(req, allowedMethods)
}

View File

@ -0,0 +1,48 @@
import type { NextRequest } from 'next/server'
import { CommerceAPIResponseError } from './errors'
export type HTTP_METHODS = 'OPTIONS' | 'GET' | 'POST' | 'PUT' | 'DELETE'
export default function validateMethod(
req: NextRequest,
allowedMethods: HTTP_METHODS[]
) {
const methods = allowedMethods.includes('OPTIONS')
? allowedMethods
: [...allowedMethods, 'OPTIONS']
if (!req.method || !methods.includes(req.method)) {
throw new CommerceAPIResponseError(
`The HTTP ${req.method} method is not supported at this route.`,
new Response(
JSON.stringify({
errors: [
{
code: 'invalid_method',
message: `The HTTP ${req.method} method is not supported at this route.`,
},
],
}),
{
status: 405,
headers: {
Allow: methods.join(', '),
},
}
)
)
}
if (req.method === 'OPTIONS') {
throw new CommerceAPIResponseError(
'This is a CORS preflight request.',
new Response(null, {
status: 204,
headers: {
Allow: methods.join(', '),
'Content-Length': '0',
},
})
)
}
}

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