mirror of
https://github.com/vercel/commerce.git
synced 2025-07-23 04:36:49 +00:00
Dynamic API routes (#836)
* Add dynamic API endpoints * Add missing dependency * Update api handlers * Updates * Fix build errors * Update package.json * Add checkout endpoint parser & update errors * Update tsconfig.json * Update cart.ts * Update parser * Update errors.ts * Update errors.ts * Move to Edge runtime * Revert to local * Fix switchable runtimes * Make nodejs default runtime * Update pnpm-lock.yaml * Update handlers * Fix build errors * Change headers
This commit is contained in:
@@ -47,14 +47,15 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@cfworker/uuid": "^1.12.4",
|
||||
"@tsndr/cloudflare-worker-jwt": "^2.1.0",
|
||||
"@vercel/commerce": "workspace:*",
|
||||
"@vercel/fetch": "^6.2.0",
|
||||
"cookie": "^0.4.1",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"js-cookie": "^3.0.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"uuidv4": "^6.2.12",
|
||||
"node-fetch": "^2.6.7"
|
||||
"uuidv4": "^6.2.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^12",
|
||||
@@ -69,8 +70,8 @@
|
||||
"@types/jsonwebtoken": "^8.5.7",
|
||||
"@types/lodash.debounce": "^4.0.6",
|
||||
"@types/node": "^17.0.8",
|
||||
"@types/react": "^18.0.14",
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/react": "^18.0.14",
|
||||
"lint-staged": "^12.1.7",
|
||||
"next": "^12.0.8",
|
||||
"prettier": "^2.5.1",
|
||||
|
@@ -1,22 +1,14 @@
|
||||
// @ts-nocheck
|
||||
import type { CartEndpoint } from '.'
|
||||
import type { BigcommerceCart } from '../../../types'
|
||||
|
||||
import { normalizeCart } from '../../../lib/normalize'
|
||||
import { parseCartItem } from '../../utils/parse-item'
|
||||
import getCartCookie from '../../utils/get-cart-cookie'
|
||||
import type { CartEndpoint } from '.'
|
||||
|
||||
const addItem: CartEndpoint['handlers']['addItem'] = async ({
|
||||
res,
|
||||
body: { cartId, item },
|
||||
config,
|
||||
}) => {
|
||||
if (!item) {
|
||||
return res.status(400).json({
|
||||
data: null,
|
||||
errors: [{ message: 'Missing item' }],
|
||||
})
|
||||
}
|
||||
if (!item.quantity) item.quantity = 1
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -26,22 +18,27 @@ const addItem: CartEndpoint['handlers']['addItem'] = async ({
|
||||
: {}),
|
||||
}),
|
||||
}
|
||||
|
||||
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`,
|
||||
options
|
||||
)
|
||||
: await config.storeApiFetch(
|
||||
: await config.storeApiFetch<{ data: BigcommerceCart }>(
|
||||
'/v3/carts?include=line_items.physical_items.options,line_items.digital_items.options',
|
||||
options
|
||||
)
|
||||
|
||||
// Create or update the cart cookie
|
||||
res.setHeader(
|
||||
'Set-Cookie',
|
||||
getCartCookie(config.cartCookie, data.id, config.cartCookieMaxAge)
|
||||
)
|
||||
res.status(200).json({ data: normalizeCart(data) })
|
||||
return {
|
||||
data: normalizeCart(data),
|
||||
headers: {
|
||||
'Set-Cookie': getCartCookie(
|
||||
config.cartCookie,
|
||||
data.id,
|
||||
config.cartCookieMaxAge
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default addItem
|
||||
|
@@ -1,36 +1,41 @@
|
||||
// @ts-nocheck
|
||||
import type { CartEndpoint } from '.'
|
||||
import type { BigcommerceCart } from '../../../types'
|
||||
|
||||
import getCartCookie from '../../utils/get-cart-cookie'
|
||||
|
||||
import { normalizeCart } from '../../../lib/normalize'
|
||||
import { BigcommerceApiError } from '../../utils/errors'
|
||||
import getCartCookie from '../../utils/get-cart-cookie'
|
||||
import type { BigcommerceCart } from '../../../types'
|
||||
import type { CartEndpoint } from '.'
|
||||
|
||||
// Return current cart info
|
||||
const getCart: CartEndpoint['handlers']['getCart'] = async ({
|
||||
res,
|
||||
body: { cartId },
|
||||
config,
|
||||
}) => {
|
||||
let result: { data?: BigcommerceCart } = {}
|
||||
|
||||
if (cartId) {
|
||||
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`
|
||||
)
|
||||
|
||||
return {
|
||||
data: result?.data ? normalizeCart(result.data) : null,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BigcommerceApiError && error.status === 404) {
|
||||
// Remove the cookie if it exists but the cart wasn't found
|
||||
res.setHeader('Set-Cookie', getCartCookie(config.cartCookie))
|
||||
return {
|
||||
headers: { 'Set-Cookie': getCartCookie(config.cartCookie) },
|
||||
}
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
data: result.data ? normalizeCart(result.data) : null,
|
||||
})
|
||||
return {
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
export default getCart
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
|
||||
import { type GetAPISchema, createEndpoint } from '@vercel/commerce/api'
|
||||
import cartEndpoint from '@vercel/commerce/api/endpoints/cart'
|
||||
import type { CartSchema } from '@vercel/commerce/types/cart'
|
||||
import type { BigcommerceAPI } from '../..'
|
||||
|
@@ -1,34 +1,26 @@
|
||||
import { normalizeCart } from '../../../lib/normalize'
|
||||
import getCartCookie from '../../utils/get-cart-cookie'
|
||||
import type { CartEndpoint } from '.'
|
||||
|
||||
import { normalizeCart } from '../../../lib/normalize'
|
||||
import getCartCookie from '../../utils/get-cart-cookie'
|
||||
|
||||
const removeItem: CartEndpoint['handlers']['removeItem'] = async ({
|
||||
res,
|
||||
body: { cartId, itemId },
|
||||
config,
|
||||
}) => {
|
||||
if (!cartId || !itemId) {
|
||||
return res.status(400).json({
|
||||
data: null,
|
||||
errors: [{ message: 'Invalid request' }],
|
||||
})
|
||||
}
|
||||
|
||||
const result = await config.storeApiFetch<{ data: any } | null>(
|
||||
`/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
const data = result?.data ?? null
|
||||
|
||||
res.setHeader(
|
||||
'Set-Cookie',
|
||||
data
|
||||
? // Update the cart cookie
|
||||
getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge)
|
||||
: // Remove the cart cookie if the cart was removed (empty items)
|
||||
getCartCookie(config.cartCookie)
|
||||
)
|
||||
res.status(200).json({ data: data && normalizeCart(data) })
|
||||
return {
|
||||
data: result?.data ? normalizeCart(result.data) : null,
|
||||
headers: {
|
||||
'Set-Cookie': result?.data
|
||||
? // Update the cart cookie
|
||||
getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge)
|
||||
: // Remove the cart cookie if the cart was removed (empty items)
|
||||
getCartCookie(config.cartCookie),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default removeItem
|
||||
|
@@ -1,21 +1,15 @@
|
||||
import type { CartEndpoint } from '.'
|
||||
import type { BigcommerceCart } from '../../../types'
|
||||
|
||||
import { normalizeCart } from '../../../lib/normalize'
|
||||
import { parseCartItem } from '../../utils/parse-item'
|
||||
import getCartCookie from '../../utils/get-cart-cookie'
|
||||
import type { CartEndpoint } from '.'
|
||||
|
||||
const updateItem: CartEndpoint['handlers']['updateItem'] = async ({
|
||||
res,
|
||||
body: { cartId, itemId, item },
|
||||
config,
|
||||
}) => {
|
||||
if (!cartId || !itemId || !item) {
|
||||
return res.status(400).json({
|
||||
data: null,
|
||||
errors: [{ message: 'Invalid request' }],
|
||||
})
|
||||
}
|
||||
|
||||
const { data } = await config.storeApiFetch(
|
||||
const { data } = await config.storeApiFetch<{ data: BigcommerceCart }>(
|
||||
`/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`,
|
||||
{
|
||||
method: 'PUT',
|
||||
@@ -25,12 +19,16 @@ const updateItem: CartEndpoint['handlers']['updateItem'] = async ({
|
||||
}
|
||||
)
|
||||
|
||||
// Update the cart cookie
|
||||
res.setHeader(
|
||||
'Set-Cookie',
|
||||
getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge)
|
||||
)
|
||||
res.status(200).json({ data: normalizeCart(data) })
|
||||
return {
|
||||
data: normalizeCart(data),
|
||||
headers: {
|
||||
'Set-Cookie': getCartCookie(
|
||||
config.cartCookie,
|
||||
cartId,
|
||||
config.cartCookieMaxAge
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default updateItem
|
||||
|
@@ -11,7 +11,6 @@ const LIMIT = 12
|
||||
|
||||
// Return current cart info
|
||||
const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({
|
||||
res,
|
||||
body: { search, categoryId, brandId, sort },
|
||||
config,
|
||||
commerce,
|
||||
@@ -73,7 +72,7 @@ const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({
|
||||
if (product) products.push(product)
|
||||
})
|
||||
|
||||
res.status(200).json({ data: { products, found } })
|
||||
return { data: { products, found } }
|
||||
}
|
||||
|
||||
export default getProducts
|
||||
|
@@ -1,38 +1,47 @@
|
||||
import type { CheckoutEndpoint } from '.'
|
||||
import getCustomerId from '../../utils/get-customer-id'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { uuid } from 'uuidv4'
|
||||
|
||||
const fullCheckout = true
|
||||
|
||||
const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({
|
||||
req,
|
||||
res,
|
||||
config,
|
||||
}) => {
|
||||
const { cookies } = req
|
||||
const cartId = cookies[config.cartCookie]
|
||||
const customerToken = cookies[config.customerCookie]
|
||||
const cartId = cookies.get(config.cartCookie)
|
||||
const customerToken = cookies.get(config.customerCookie)
|
||||
|
||||
if (!cartId) {
|
||||
res.redirect('/cart')
|
||||
return
|
||||
return { redirectTo: '/cart' }
|
||||
}
|
||||
const { data } = await config.storeApiFetch(
|
||||
|
||||
const { data } = await config.storeApiFetch<any>(
|
||||
`/v3/carts/${cartId}/redirect_urls`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
)
|
||||
|
||||
const customerId =
|
||||
customerToken && (await getCustomerId({ customerToken, config }))
|
||||
|
||||
//if there is a customer create a jwt token
|
||||
if (!customerId) {
|
||||
if (fullCheckout) {
|
||||
res.redirect(data.checkout_url)
|
||||
return
|
||||
return { redirectTo: data.checkout_url }
|
||||
}
|
||||
} 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 payload = {
|
||||
iss: config.storeApiClientId,
|
||||
@@ -42,49 +51,51 @@ const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({
|
||||
store_hash: config.storeHash,
|
||||
customer_id: customerId,
|
||||
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!, {
|
||||
algorithm: 'HS256',
|
||||
})
|
||||
let checkouturl = `${config.storeUrl}/login/token/${token}`
|
||||
console.log('checkouturl', checkouturl)
|
||||
|
||||
if (fullCheckout) {
|
||||
res.redirect(checkouturl)
|
||||
return
|
||||
return { redirectTo: checkouturl }
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: make the embedded checkout work too!
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Checkout</title>
|
||||
<script src="https://checkout-sdk.bigcommerce.com/v1/loader.js"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
checkoutKitLoader.load('checkout-sdk').then(function (service) {
|
||||
service.embedCheckout({
|
||||
containerId: 'checkout',
|
||||
url: '${data.embedded_checkout_url}'
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="checkout"></div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
// const html = `
|
||||
// <!DOCTYPE html>
|
||||
// <html lang="en">
|
||||
// <head>
|
||||
// <meta charset="UTF-8">
|
||||
// <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
// <title>Checkout</title>
|
||||
// <script src="https://checkout-sdk.bigcommerce.com/v1/loader.js"></script>
|
||||
// <script>
|
||||
// window.onload = function() {
|
||||
// checkoutKitLoader.load('checkout-sdk').then(function (service) {
|
||||
// service.embedCheckout({
|
||||
// containerId: 'checkout',
|
||||
// url: '${data.embedded_checkout_url}'
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
// </script>
|
||||
// </head>
|
||||
// <body>
|
||||
// <div id="checkout"></div>
|
||||
// </body>
|
||||
// </html>
|
||||
// `
|
||||
|
||||
res.status(200)
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.write(html)
|
||||
res.end()
|
||||
// return new Response(html, {
|
||||
// headers: {
|
||||
// 'Content-Type': 'text/html',
|
||||
// },
|
||||
// })
|
||||
|
||||
return { data: null }
|
||||
}
|
||||
|
||||
export default getCheckout
|
||||
|
@@ -1 +0,0 @@
|
||||
export default function noopApi(...args: any[]): void {}
|
@@ -1 +0,0 @@
|
||||
export default function noopApi(...args: any[]): void {}
|
@@ -1,5 +1,6 @@
|
||||
import type { GetLoggedInCustomerQuery } from '../../../../schema'
|
||||
import type { CustomerEndpoint } from '.'
|
||||
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
|
||||
|
||||
export const getLoggedInCustomerQuery = /* GraphQL */ `
|
||||
query getLoggedInCustomer {
|
||||
@@ -25,29 +26,26 @@ export const getLoggedInCustomerQuery = /* GraphQL */ `
|
||||
export type Customer = NonNullable<GetLoggedInCustomerQuery['customer']>
|
||||
|
||||
const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] =
|
||||
async ({ req, res, config }) => {
|
||||
const token = req.cookies[config.customerCookie]
|
||||
async ({ req, config }) => {
|
||||
const token = req.cookies.get(config.customerCookie)
|
||||
|
||||
if (token) {
|
||||
const { data } = await config.fetch<GetLoggedInCustomerQuery>(
|
||||
getLoggedInCustomerQuery,
|
||||
undefined,
|
||||
{
|
||||
headers: {
|
||||
cookie: `${config.customerCookie}=${token}`,
|
||||
},
|
||||
'Set-Cookie': `${config.customerCookie}=${token}`,
|
||||
}
|
||||
)
|
||||
const { customer } = data
|
||||
|
||||
if (!customer) {
|
||||
return res.status(400).json({
|
||||
data: null,
|
||||
errors: [{ message: 'Customer not found', code: 'not_found' }],
|
||||
throw new CommerceAPIError('Customer not found', {
|
||||
status: 404,
|
||||
})
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
return {
|
||||
data: {
|
||||
customer: {
|
||||
id: String(customer.entityId),
|
||||
@@ -59,10 +57,12 @@ const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] =
|
||||
notes: customer.notes,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ data: null })
|
||||
return {
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
export default getLoggedInCustomer
|
||||
|
27
packages/bigcommerce/src/api/endpoints/index.ts
Normal file
27
packages/bigcommerce/src/api/endpoints/index.ts
Normal 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)
|
||||
}
|
@@ -1,49 +1,35 @@
|
||||
import { FetcherError } from '@vercel/commerce/utils/errors'
|
||||
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 login: LoginEndpoint['handlers']['login'] = async ({
|
||||
res,
|
||||
body: { email, password },
|
||||
config,
|
||||
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 {
|
||||
const res = new Response()
|
||||
await commerce.login({ variables: { email, password }, config, res })
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if the email and password didn't match an existing account
|
||||
if (
|
||||
error instanceof FetcherError &&
|
||||
invalidCredentials.test(error.message)
|
||||
) {
|
||||
return res.status(401).json({
|
||||
data: null,
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'Cannot find an account that matches the provided credentials',
|
||||
code: 'invalid_credentials',
|
||||
},
|
||||
],
|
||||
})
|
||||
if (error instanceof FetcherError) {
|
||||
throw new CommerceAPIError(
|
||||
invalidCredentials.test(error.message)
|
||||
? 'Cannot find an account that matches the provided credentials'
|
||||
: error.message,
|
||||
{ status: error.status || 401 }
|
||||
)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
res.status(200).json({ data: null })
|
||||
}
|
||||
|
||||
export default login
|
||||
|
@@ -2,22 +2,24 @@ import { serialize } from 'cookie'
|
||||
import type { LogoutEndpoint } from '.'
|
||||
|
||||
const logout: LogoutEndpoint['handlers']['logout'] = async ({
|
||||
res,
|
||||
body: { redirectTo },
|
||||
config,
|
||||
}) => {
|
||||
// Remove the cookie
|
||||
res.setHeader(
|
||||
'Set-Cookie',
|
||||
serialize(config.customerCookie, '', { maxAge: -1, path: '/' })
|
||||
)
|
||||
|
||||
// Only allow redirects to a relative URL
|
||||
if (redirectTo?.startsWith('/')) {
|
||||
res.redirect(redirectTo)
|
||||
} else {
|
||||
res.status(200).json({ data: null })
|
||||
const headers = {
|
||||
'Set-Cookie': serialize(config.customerCookie, '', {
|
||||
maxAge: -1,
|
||||
path: '/',
|
||||
}),
|
||||
}
|
||||
|
||||
return redirectTo
|
||||
? {
|
||||
redirectTo,
|
||||
headers,
|
||||
}
|
||||
: {
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
export default logout
|
||||
|
@@ -1,23 +1,13 @@
|
||||
import { BigcommerceApiError } from '../../utils/errors'
|
||||
import type { SignupEndpoint } from '.'
|
||||
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
|
||||
|
||||
import { BigcommerceApiError } from '../../utils/errors'
|
||||
|
||||
const signup: SignupEndpoint['handlers']['signup'] = async ({
|
||||
res,
|
||||
body: { firstName, lastName, email, password },
|
||||
config,
|
||||
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 {
|
||||
await config.storeApiFetch('/v3/customers', {
|
||||
method: 'POST',
|
||||
@@ -35,28 +25,26 @@ 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
|
||||
if (hasEmailError) {
|
||||
return res.status(400).json({
|
||||
data: null,
|
||||
errors: [
|
||||
{
|
||||
message: 'The email is already in use',
|
||||
code: 'duplicated_email',
|
||||
},
|
||||
],
|
||||
throw new CommerceAPIError('Email already in use', {
|
||||
status: 400,
|
||||
code: 'duplicated_email',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const res = new Response()
|
||||
|
||||
// Login the customer right after creating it
|
||||
await commerce.login({ variables: { email, password }, res, config })
|
||||
|
||||
res.status(200).json({ data: null })
|
||||
return {
|
||||
headers: res.headers,
|
||||
}
|
||||
}
|
||||
|
||||
export default signup
|
||||
|
@@ -1,67 +1,53 @@
|
||||
import getCustomerWishlist from '../../operations/get-customer-wishlist'
|
||||
import { parseWishlistItem } from '../../utils/parse-item'
|
||||
import getCustomerId from '../../utils/get-customer-id'
|
||||
import type { WishlistEndpoint } from '.'
|
||||
|
||||
const addItem: WishlistEndpoint['handlers']['addItem'] = async ({
|
||||
res,
|
||||
body: { customerToken, item },
|
||||
config,
|
||||
commerce,
|
||||
}) => {
|
||||
if (!item) {
|
||||
return res.status(400).json({
|
||||
data: null,
|
||||
errors: [{ message: 'Missing item' }],
|
||||
})
|
||||
const customerId =
|
||||
customerToken && (await getCustomerId({ customerToken, config }))
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error('Invalid request. No CustomerId')
|
||||
}
|
||||
|
||||
try {
|
||||
const customerId =
|
||||
customerToken && (await getCustomerId({ customerToken, config }))
|
||||
let { wishlist } = await commerce.getCustomerWishlist({
|
||||
variables: { customerId },
|
||||
config,
|
||||
})
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error('Invalid request. No CustomerId')
|
||||
}
|
||||
|
||||
let { wishlist } = await commerce.getCustomerWishlist({
|
||||
variables: { customerId },
|
||||
config,
|
||||
if (!wishlist) {
|
||||
// If user has no wishlist, then let's create one with new item
|
||||
const { data } = await config.storeApiFetch<any>('/v3/wishlists', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: 'Next.js Commerce Wishlist',
|
||||
is_public: false,
|
||||
customer_id: Number(customerId),
|
||||
items: [parseWishlistItem(item)],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!wishlist) {
|
||||
// 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)
|
||||
return {
|
||||
data,
|
||||
}
|
||||
|
||||
// Existing Wishlist, let's add Item to Wishlist
|
||||
const { data } = await config.storeApiFetch(
|
||||
`/v3/wishlists/${wishlist.id}/items`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
items: [parseWishlistItem(item)],
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// Returns Wishlist
|
||||
return res.status(200).json(data)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({
|
||||
data: null,
|
||||
errors: [{ message: err.message }],
|
||||
})
|
||||
}
|
||||
|
||||
// Existing Wishlist, let's add Item to Wishlist
|
||||
const { data } = await config.storeApiFetch<any>(
|
||||
`/v3/wishlists/${wishlist.id}/items`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
items: [parseWishlistItem(item)],
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// Returns Wishlist
|
||||
return { data }
|
||||
}
|
||||
|
||||
export default addItem
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
|
||||
import type { Wishlist } from '@vercel/commerce/types/wishlist'
|
||||
import type { WishlistEndpoint } from '.'
|
||||
import getCustomerId from '../../utils/get-customer-id'
|
||||
|
||||
// Return wishlist info
|
||||
const getWishlist: WishlistEndpoint['handlers']['getWishlist'] = async ({
|
||||
res,
|
||||
body: { customerToken, includeProducts },
|
||||
config,
|
||||
commerce,
|
||||
@@ -16,11 +16,7 @@ const getWishlist: WishlistEndpoint['handlers']['getWishlist'] = async ({
|
||||
customerToken && (await getCustomerId({ customerToken, config }))
|
||||
|
||||
if (!customerId) {
|
||||
// If the customerToken is invalid, then this request is too
|
||||
return res.status(404).json({
|
||||
data: null,
|
||||
errors: [{ message: 'Wishlist not found' }],
|
||||
})
|
||||
throw new CommerceAPIError('Wishlist not found', { status: 404 })
|
||||
}
|
||||
|
||||
const { wishlist } = await commerce.getCustomerWishlist({
|
||||
@@ -32,7 +28,7 @@ const getWishlist: WishlistEndpoint['handlers']['getWishlist'] = async ({
|
||||
result = { data: wishlist }
|
||||
}
|
||||
|
||||
res.status(200).json({ data: result.data ?? null })
|
||||
return { data: result.data ?? null }
|
||||
}
|
||||
|
||||
export default getWishlist
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import type { Wishlist } from '@vercel/commerce/types/wishlist'
|
||||
import getCustomerId from '../../utils/get-customer-id'
|
||||
import type { WishlistEndpoint } from '.'
|
||||
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
|
||||
|
||||
// Return wishlist info
|
||||
const removeItem: WishlistEndpoint['handlers']['removeItem'] = async ({
|
||||
res,
|
||||
body: { customerToken, itemId },
|
||||
config,
|
||||
commerce,
|
||||
@@ -20,10 +20,7 @@ const removeItem: WishlistEndpoint['handlers']['removeItem'] = async ({
|
||||
{}
|
||||
|
||||
if (!wishlist || !itemId) {
|
||||
return res.status(400).json({
|
||||
data: null,
|
||||
errors: [{ message: 'Invalid request' }],
|
||||
})
|
||||
throw new CommerceAPIError('Wishlist not found', { status: 400 })
|
||||
}
|
||||
|
||||
const result = await config.storeApiFetch<{ data: Wishlist } | null>(
|
||||
@@ -32,7 +29,7 @@ const removeItem: WishlistEndpoint['handlers']['removeItem'] = async ({
|
||||
)
|
||||
const data = result?.data ?? null
|
||||
|
||||
res.status(200).json({ data })
|
||||
return { data }
|
||||
}
|
||||
|
||||
export default removeItem
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import type { RequestInit } from '@vercel/fetch'
|
||||
import {
|
||||
CommerceAPI,
|
||||
CommerceAPIConfig,
|
||||
@@ -35,7 +34,14 @@ export interface BigcommerceConfig extends CommerceAPIConfig {
|
||||
storeUrl?: string
|
||||
storeApiClientSecret?: 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
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import type { ServerResponse } from 'http'
|
||||
import type {
|
||||
OperationContext,
|
||||
OperationOptions,
|
||||
@@ -23,14 +22,14 @@ export default function loginOperation({
|
||||
async function login<T extends LoginOperation>(opts: {
|
||||
variables: T['variables']
|
||||
config?: BigcommerceConfig
|
||||
res: ServerResponse
|
||||
res: Response
|
||||
}): Promise<T['data']>
|
||||
|
||||
async function login<T extends LoginOperation>(
|
||||
opts: {
|
||||
variables: T['variables']
|
||||
config?: BigcommerceConfig
|
||||
res: ServerResponse
|
||||
res: Response
|
||||
} & OperationOptions
|
||||
): Promise<T['data']>
|
||||
|
||||
@@ -42,7 +41,7 @@ export default function loginOperation({
|
||||
}: {
|
||||
query?: string
|
||||
variables: T['variables']
|
||||
res: ServerResponse
|
||||
res: Response
|
||||
config?: BigcommerceConfig
|
||||
}): Promise<T['data']> {
|
||||
config = commerce.getConfig(config)
|
||||
@@ -64,10 +63,15 @@ export default function loginOperation({
|
||||
cookie = cookie.replace(/; SameSite=none/gi, '; SameSite=lax')
|
||||
}
|
||||
|
||||
response.setHeader(
|
||||
'Set-Cookie',
|
||||
concatHeader(response.getHeader('Set-Cookie'), cookie)!
|
||||
)
|
||||
const prevCookie = response.headers.get('Set-Cookie')
|
||||
const newCookie = concatHeader(prevCookie, cookie)
|
||||
|
||||
if (newCookie) {
|
||||
res.headers.set(
|
||||
'Set-Cookie',
|
||||
String(Array.isArray(newCookie) ? newCookie.join(',') : newCookie)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
@@ -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) {
|
||||
if (!val) return prev
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import type { Response } from '@vercel/fetch'
|
||||
|
||||
// Used for GraphQL errors
|
||||
export class BigcommerceGraphQLError extends Error {}
|
||||
|
||||
|
@@ -1,19 +1,22 @@
|
||||
import { FetcherError } from '@vercel/commerce/utils/errors'
|
||||
import type { GraphQLFetcher } from '@vercel/commerce/api'
|
||||
import type { BigcommerceConfig } from '../index'
|
||||
import fetch from './fetch'
|
||||
|
||||
const fetchGraphqlApi: (getConfig: () => BigcommerceConfig) => GraphQLFetcher =
|
||||
(getConfig) =>
|
||||
async (query: string, { variables, preview } = {}, fetchOptions) => {
|
||||
async (
|
||||
query: string,
|
||||
{ variables, preview } = {},
|
||||
options: { headers?: HeadersInit } = {}
|
||||
): Promise<any> => {
|
||||
// log.warn(query)
|
||||
const config = getConfig()
|
||||
|
||||
const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), {
|
||||
...fetchOptions,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiToken}`,
|
||||
...fetchOptions?.headers,
|
||||
...options.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
@@ -1,11 +1,16 @@
|
||||
import type { FetchOptions, Response } from '@vercel/fetch'
|
||||
import type { BigcommerceConfig } from '../index'
|
||||
import { BigcommerceApiError, BigcommerceNetworkError } from './errors'
|
||||
import fetch from './fetch'
|
||||
|
||||
const fetchStoreApi =
|
||||
<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()
|
||||
let res: Response
|
||||
|
||||
|
@@ -20,9 +20,7 @@ async function getCustomerId({
|
||||
getCustomerIdQuery,
|
||||
undefined,
|
||||
{
|
||||
headers: {
|
||||
cookie: `${config.customerCookie}=${customerToken}`,
|
||||
},
|
||||
'Set-Cookie': `${config.customerCookie}=${customerToken}`,
|
||||
}
|
||||
)
|
||||
|
||||
|
@@ -9,7 +9,7 @@ export default useLogin as UseLogin<typeof handler>
|
||||
|
||||
export const handler: MutationHook<LoginHook> = {
|
||||
fetchOptions: {
|
||||
url: '/api/login',
|
||||
url: '/api/commerce/login',
|
||||
method: 'POST',
|
||||
},
|
||||
async fetcher({ input: { email, password }, options, fetch }) {
|
||||
|
@@ -8,7 +8,7 @@ export default useLogout as UseLogout<typeof handler>
|
||||
|
||||
export const handler: MutationHook<LogoutHook> = {
|
||||
fetchOptions: {
|
||||
url: '/api/logout',
|
||||
url: '/api/commerce/logout',
|
||||
method: 'GET',
|
||||
},
|
||||
useHook:
|
||||
|
@@ -9,7 +9,7 @@ export default useSignup as UseSignup<typeof handler>
|
||||
|
||||
export const handler: MutationHook<SignupHook> = {
|
||||
fetchOptions: {
|
||||
url: '/api/signup',
|
||||
url: '/api/commerce/signup',
|
||||
method: 'POST',
|
||||
},
|
||||
async fetcher({
|
||||
|
@@ -9,7 +9,7 @@ export default useAddItem as UseAddItem<typeof handler>
|
||||
|
||||
export const handler: MutationHook<AddItemHook> = {
|
||||
fetchOptions: {
|
||||
url: '/api/cart',
|
||||
url: '/api/commerce/cart',
|
||||
method: 'POST',
|
||||
},
|
||||
async fetcher({ input: item, options, fetch }) {
|
||||
@@ -33,7 +33,6 @@ export const handler: MutationHook<AddItemHook> = {
|
||||
({ fetch }) =>
|
||||
() => {
|
||||
const { mutate } = useCart()
|
||||
|
||||
return useCallback(
|
||||
async function addItem(input) {
|
||||
const data = await fetch({ input })
|
||||
|
@@ -7,7 +7,7 @@ export default useCart as UseCart<typeof handler>
|
||||
|
||||
export const handler: SWRHook<GetCartHook> = {
|
||||
fetchOptions: {
|
||||
url: '/api/cart',
|
||||
url: '/api/commerce/cart',
|
||||
method: 'GET',
|
||||
},
|
||||
useHook:
|
||||
|
@@ -26,7 +26,7 @@ export default useRemoveItem as UseRemoveItem<typeof handler>
|
||||
|
||||
export const handler = {
|
||||
fetchOptions: {
|
||||
url: '/api/cart',
|
||||
url: '/api/commerce/cart',
|
||||
method: 'DELETE',
|
||||
},
|
||||
async fetcher({
|
||||
|
@@ -5,7 +5,9 @@ import type {
|
||||
HookFetcherContext,
|
||||
} from '@vercel/commerce/utils/types'
|
||||
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 { handler as removeItemHandler } from './use-remove-item'
|
||||
import useCart from './use-cart'
|
||||
@@ -18,7 +20,7 @@ export default useUpdateItem as UseUpdateItem<typeof handler>
|
||||
|
||||
export const handler = {
|
||||
fetchOptions: {
|
||||
url: '/api/cart',
|
||||
url: '/api/commerce/cart',
|
||||
method: 'PUT',
|
||||
},
|
||||
async fetcher({
|
||||
|
@@ -8,7 +8,7 @@ export default useCustomer as UseCustomer<typeof handler>
|
||||
|
||||
export const handler: SWRHook<CustomerHook> = {
|
||||
fetchOptions: {
|
||||
url: '/api/customer',
|
||||
url: '/api/commerce/customer',
|
||||
method: 'GET',
|
||||
},
|
||||
async fetcher({ options, fetch }) {
|
||||
|
@@ -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'
|
||||
|
||||
export { bigcommerceProvider }
|
||||
|
@@ -3,9 +3,9 @@ import type { Product } from '@vercel/commerce/types/product'
|
||||
import type { Cart, LineItem } from '@vercel/commerce/types/cart'
|
||||
import type { Category, Brand } from '@vercel/commerce/types/site'
|
||||
import type { BigcommerceCart, BCCategory, BCBrand } from '../types'
|
||||
import type { ProductNode } from '../api/operations/get-all-products'
|
||||
import type { definitions } from '../api/definitions/store-content'
|
||||
|
||||
import { definitions } from '../api/definitions/store-content'
|
||||
import update from './immutability'
|
||||
import getSlug from './get-slug'
|
||||
|
||||
function normalizeProductOption(productOption: any) {
|
||||
@@ -20,55 +20,44 @@ function normalizeProductOption(productOption: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProduct(productNode: any): Product {
|
||||
export function normalizeProduct(productNode: ProductNode): Product {
|
||||
const {
|
||||
entityId: id,
|
||||
productOptions,
|
||||
prices,
|
||||
path,
|
||||
id: _,
|
||||
options: _0,
|
||||
images,
|
||||
variants,
|
||||
} = productNode
|
||||
|
||||
return update(productNode, {
|
||||
id: { $set: String(id) },
|
||||
images: {
|
||||
$apply: ({ edges }: any) =>
|
||||
edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({
|
||||
url: urlOriginal,
|
||||
alt: altText,
|
||||
...rest,
|
||||
})),
|
||||
},
|
||||
variants: {
|
||||
$apply: ({ edges }: any) =>
|
||||
edges?.map(({ node: { entityId, productOptions, ...rest } }: any) => ({
|
||||
return {
|
||||
id: String(id),
|
||||
name: productNode.name,
|
||||
description: productNode.description,
|
||||
images:
|
||||
images.edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({
|
||||
url: urlOriginal,
|
||||
alt: altText,
|
||||
...rest,
|
||||
})) || [],
|
||||
path: `/${getSlug(path)}`,
|
||||
variants:
|
||||
variants.edges?.map(
|
||||
({ node: { entityId, productOptions, ...rest } }: any) => ({
|
||||
id: String(entityId),
|
||||
options: productOptions?.edges
|
||||
? productOptions.edges.map(normalizeProductOption)
|
||||
: [],
|
||||
...rest,
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
$set: productOptions.edges
|
||||
? productOptions?.edges.map(normalizeProductOption)
|
||||
: [],
|
||||
},
|
||||
brand: {
|
||||
$apply: (brand: any) => (brand?.id ? brand.id : null),
|
||||
},
|
||||
slug: {
|
||||
$set: path?.replace(/^\/+|\/+$/g, ''),
|
||||
},
|
||||
})
|
||||
) || [],
|
||||
options: productOptions?.edges?.map(normalizeProductOption) || [],
|
||||
slug: path?.replace(/^\/+|\/+$/g, ''),
|
||||
price: {
|
||||
$set: {
|
||||
value: prices?.price.value,
|
||||
currencyCode: prices?.price.currencyCode,
|
||||
},
|
||||
value: prices?.price.value,
|
||||
currencyCode: prices?.price.currencyCode,
|
||||
},
|
||||
$unset: ['entityId'],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePage(page: definitions['page_Full']): Page {
|
||||
@@ -122,7 +111,7 @@ function normalizeLineItem(item: any): LineItem {
|
||||
listPrice: item.list_price,
|
||||
},
|
||||
options: item.options,
|
||||
path: item.url.split('/')[3],
|
||||
path: `/${item.url.split('/')[3]}`,
|
||||
discounts: item.discounts.map((discount: any) => ({
|
||||
value: discount.discounted_amount,
|
||||
})),
|
||||
|
@@ -14,7 +14,7 @@ export type SearchProductsInput = {
|
||||
|
||||
export const handler: SWRHook<SearchProductsHook> = {
|
||||
fetchOptions: {
|
||||
url: '/api/catalog/products',
|
||||
url: '/api/commerce/catalog/products',
|
||||
method: 'GET',
|
||||
},
|
||||
fetcher({ input: { search, categoryId, brandId, sort }, options, fetch }) {
|
||||
|
@@ -12,7 +12,7 @@ export default useAddItem as UseAddItem<typeof handler>
|
||||
|
||||
export const handler: MutationHook<AddItemHook> = {
|
||||
fetchOptions: {
|
||||
url: '/api/wishlist',
|
||||
url: '/api/commerce/wishlist',
|
||||
method: 'POST',
|
||||
},
|
||||
useHook:
|
||||
|
@@ -12,7 +12,7 @@ export default useRemoveItem as UseRemoveItem<typeof handler>
|
||||
|
||||
export const handler: MutationHook<RemoveItemHook> = {
|
||||
fetchOptions: {
|
||||
url: '/api/wishlist',
|
||||
url: '/api/commerce/wishlist',
|
||||
method: 'DELETE',
|
||||
},
|
||||
useHook:
|
||||
|
@@ -10,7 +10,7 @@ import type { GetWishlistHook } from '@vercel/commerce/types/wishlist'
|
||||
export default useWishlist as UseWishlist<typeof handler>
|
||||
export const handler: SWRHook<GetWishlistHook> = {
|
||||
fetchOptions: {
|
||||
url: '/api/wishlist',
|
||||
url: '/api/commerce/wishlist',
|
||||
method: 'GET',
|
||||
},
|
||||
async fetcher({ input: { customerId, includeProducts }, options, fetch }) {
|
||||
|
Reference in New Issue
Block a user