Merge branch 'master' of github.com:vercel/commerce into outgrow-reaction-commerce-provider

Signed-off-by: Loan Laux <loan@outgrow.io>
This commit is contained in:
Loan Laux 2021-06-01 16:31:30 +04:00
commit 92179b3bd9
No known key found for this signature in database
GPG Key ID: AF9E9BD6548AD52E
258 changed files with 5083 additions and 3371 deletions

View File

@ -50,7 +50,7 @@ NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=xxxxxxx.myshopify.com
```
And change the `tsconfig.json` to resolve to the chosen provider:
And check that the `tsconfig.json` resolves to the chosen provider:
```
"@framework": ["framework/shopify"],

View File

@ -5,7 +5,7 @@ import Link from 'next/link'
import s from './CartItem.module.css'
import { Trash, Plus, Minus } from '@components/icons'
import { useUI } from '@components/ui/context'
import type { LineItem } from '@framework/types'
import type { LineItem } from '@commerce/types/cart'
import usePrice from '@framework/product/use-price'
import useUpdateItem from '@framework/cart/use-update-item'
import useRemoveItem from '@framework/cart/use-remove-item'
@ -35,7 +35,7 @@ const CartItem = ({
const updateItem = useUpdateItem({ item })
const removeItem = useRemoveItem()
const [quantity, setQuantity] = useState(item.quantity)
const [quantity, setQuantity] = useState<number | ''>(item.quantity)
const [removing, setRemoving] = useState(false)
const updateQuantity = async (val: number) => {
@ -44,10 +44,10 @@ const CartItem = ({
}
const handleQuantity = (e: ChangeEvent<HTMLInputElement>) => {
const val = Number(e.target.value)
const val = !e.target.value ? '' : Number(e.target.value)
if (Number.isInteger(val) && val >= 0) {
setQuantity(Number(e.target.value))
if (!val || (Number.isInteger(val) && val >= 0)) {
setQuantity(val)
}
}
const handleBlur = () => {

View File

@ -2,7 +2,7 @@ import { FC } from 'react'
import cn from 'classnames'
import Link from 'next/link'
import { useRouter } from 'next/router'
import type { Page } from '@framework/common/get-all-pages'
import type { Page } from '@commerce/types/page'
import getSlug from '@lib/get-slug'
import { Github, Vercel } from '@components/icons'
import { Logo, Container } from '@components/ui'

View File

@ -1,6 +1,6 @@
import { FC } from 'react'
import Link from 'next/link'
import type { Product } from '@commerce/types'
import type { Product } from '@commerce/types/product'
import { Grid } from '@components/ui'
import { ProductCard } from '@components/product'
import s from './HomeAllProductsGrid.module.css'

View File

@ -1,17 +1,17 @@
import cn from 'classnames'
import dynamic from 'next/dynamic'
import s from './Layout.module.css'
import { useRouter } from 'next/router'
import React, { FC } from 'react'
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import cn from 'classnames'
import type { Page } from '@commerce/types/page'
import type { Category } from '@commerce/types/site'
import { CommerceProvider } from '@framework'
import { useAcceptCookies } from '@lib/hooks/useAcceptCookies'
import { useUI } from '@components/ui/context'
import { Navbar, Footer } from '@components/common'
import { useAcceptCookies } from '@lib/hooks/useAcceptCookies'
import { Sidebar, Button, Modal, LoadingDots } from '@components/ui'
import CartSidebarView from '@components/cart/CartSidebarView'
import LoginView from '@components/auth/LoginView'
import { CommerceProvider } from '@framework'
import type { Page } from '@framework/common/get-all-pages'
import s from './Layout.module.css'
const Loading = () => (
<div className="w-80 h-80 flex items-center text-center justify-center p-3">
@ -41,27 +41,28 @@ const FeatureBar = dynamic(
interface Props {
pageProps: {
pages?: Page[]
commerceFeatures: Record<string, boolean>
categories: Category[]
}
}
const Layout: FC<Props> = ({
children,
pageProps: { commerceFeatures, ...pageProps },
pageProps: { categories = [], ...pageProps },
}) => {
const {
displaySidebar,
displayModal,
closeSidebar,
closeModal,
modalView,
} = useUI()
const { displaySidebar, displayModal, closeSidebar, closeModal, modalView } =
useUI()
const { acceptedCookies, onAcceptCookies } = useAcceptCookies()
const { locale = 'en-US' } = useRouter()
const navBarlinks = categories.slice(0, 2).map((c) => ({
label: c.name,
href: `/search/${c.slug}`,
}))
return (
<CommerceProvider locale={locale}>
<div className={cn(s.root)}>
<Navbar />
<Navbar links={navBarlinks} />
<main className="fit">{children}</main>
<Footer pages={pageProps.pages} />

View File

@ -5,7 +5,15 @@ import { Searchbar, UserNav } from '@components/common'
import NavbarRoot from './NavbarRoot'
import s from './Navbar.module.css'
const Navbar: FC = () => (
interface Link {
href: string
label: string
}
interface NavbarProps {
links?: Link[]
}
const Navbar: FC<NavbarProps> = ({ links }) => (
<NavbarRoot>
<Container>
<div className="relative flex flex-row justify-between py-4 align-center md:py-6">
@ -19,15 +27,11 @@ const Navbar: FC = () => (
<Link href="/search">
<a className={s.link}>All</a>
</Link>
<Link href="/search?q=clothes">
<a className={s.link}>Clothes</a>
</Link>
<Link href="/search?q=accessories">
<a className={s.link}>Accessories</a>
</Link>
<Link href="/search?q=shoes">
<a className={s.link}>Shoes</a>
</Link>
{links?.map((l) => (
<Link href={l.href} key={l.href}>
<a className={s.link}>{l.label}</a>
</Link>
))}
</nav>
</div>

View File

@ -1,7 +1,7 @@
import { FC } from 'react'
import Link from 'next/link'
import cn from 'classnames'
import type { LineItem } from '@framework/types'
import type { LineItem } from '@commerce/types/cart'
import useCart from '@framework/cart/use-cart'
import useCustomer from '@framework/customer/use-customer'
import { Avatar } from '@components/common'

View File

@ -1,7 +1,7 @@
import { FC } from 'react'
import cn from 'classnames'
import Link from 'next/link'
import type { Product } from '@commerce/types'
import type { Product } from '@commerce/types/product'
import s from './ProductCard.module.css'
import Image, { ImageProps } from 'next/image'
import WishlistButton from '@components/wishlist/WishlistButton'

View File

@ -5,7 +5,7 @@ import { FC, useEffect, useState } from 'react'
import s from './ProductView.module.css'
import { Swatch, ProductSlider } from '@components/product'
import { Button, Container, Text, useUI } from '@components/ui'
import type { Product } from '@commerce/types'
import type { Product } from '@commerce/types/product'
import usePrice from '@framework/product/use-price'
import { useAddItem } from '@framework/cart'
import { getVariant, SelectedOptions } from '../helpers'
@ -18,6 +18,8 @@ interface Props {
}
const ProductView: FC<Props> = ({ product }) => {
// TODO: fix this missing argument issue
/* @ts-ignore */
const addItem = useAddItem()
const { price } = usePrice({
amount: product.price.value,
@ -132,7 +134,8 @@ const ProductView: FC<Props> = ({ product }) => {
setChoices((choices) => {
return {
...choices,
[opt.displayName.toLowerCase()]: v.label.toLowerCase(),
[opt.displayName.toLowerCase()]:
v.label.toLowerCase(),
}
})
}}
@ -154,8 +157,11 @@ const ProductView: FC<Props> = ({ product }) => {
className={s.button}
onClick={addToCart}
loading={loading}
disabled={variant?.availableForSale === false}
>
Add to Cart
{variant?.availableForSale === false
? 'Not Available'
: 'Add To Cart'}
</Button>
</div>
</div>

View File

@ -1,33 +1,52 @@
.root {
.swatch {
box-sizing: border-box;
composes: root from 'components/ui/Button/Button.module.css';
@apply h-12 w-12 bg-primary text-primary rounded-full mr-3 inline-flex
items-center justify-center cursor-pointer transition duration-150 ease-in-out
p-0 shadow-none border-gray-200 border box-border;
margin-right: calc(0.75rem - 1px);
overflow: hidden;
}
& > span {
@apply absolute;
}
.swatch::before,
.swatch::after {
box-sizing: border-box;
}
&:hover {
@apply transform scale-110 bg-hover;
}
.swatch:hover {
@apply transform scale-110 bg-hover;
}
.swatch > span {
@apply absolute;
}
.color {
@apply text-black transition duration-150 ease-in-out;
}
&:hover {
@apply text-black;
}
.color :hover {
@apply text-black;
}
&.dark,
&.dark:hover {
color: white !important;
}
.color.dark,
.color.dark:hover {
color: white !important;
}
.active {
&.size {
@apply border-accents-9 border-2;
}
@apply border-accents-9 border-2;
padding-right: 1px;
padding-left: 1px;
}
.textLabel {
@apply w-auto px-4;
min-width: 3rem;
}
.active.textLabel {
@apply border-accents-9 border-2;
padding-right: calc(1rem - 1px);
padding-left: calc(1rem - 1px);
}

View File

@ -4,50 +4,55 @@ import s from './Swatch.module.css'
import { Check } from '@components/icons'
import Button, { ButtonProps } from '@components/ui/Button'
import { isDark } from '@lib/colors'
interface Props {
interface SwatchProps {
active?: boolean
children?: any
className?: string
label?: string
variant?: 'size' | 'color' | string
color?: string
label?: string | null
}
const Swatch: FC<Omit<ButtonProps, 'variant'> & Props> = ({
const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
className,
color = '',
label,
label = null,
variant = 'size',
active,
...props
}) => {
variant = variant?.toLowerCase()
label = label?.toLowerCase()
const rootClassName = cn(
s.root,
if (label) {
label = label?.toLowerCase()
}
const swatchClassName = cn(
s.swatch,
{
[s.active]: active,
[s.size]: variant === 'size',
[s.color]: color,
[s.dark]: color ? isDark(color) : false,
[s.textLabel]: !color && label && label.length > 3,
},
className
)
return (
<Button
className={rootClassName}
className={swatchClassName}
style={color ? { backgroundColor: color } : {}}
aria-label="Variant Swatch"
{...(label && color && { title: label })}
{...props}
>
{variant === 'color' && active && (
{color && active && (
<span>
<Check />
</span>
)}
{variant === 'size' ? label : null}
{!color ? label : null}
</Button>
)
}

View File

@ -1,4 +1,4 @@
import type { Product } from '@commerce/types'
import type { Product } from '@commerce/types/product'
export type SelectedOptions = Record<string, string | null>
export function getVariant(product: Product, opts: SelectedOptions) {

View File

@ -6,7 +6,7 @@ import useAddItem from '@framework/wishlist/use-add-item'
import useCustomer from '@framework/customer/use-customer'
import useWishlist from '@framework/wishlist/use-wishlist'
import useRemoveItem from '@framework/wishlist/use-remove-item'
import type { Product, ProductVariant } from '@commerce/types'
import type { Product, ProductVariant } from '@commerce/types/product'
type Props = {
productId: Product['id']

View File

@ -7,7 +7,7 @@ import { Trash } from '@components/icons'
import { Button, Text } from '@components/ui'
import { useUI } from '@components/ui/context'
import type { Product } from '@commerce/types'
import type { Product } from '@commerce/types/product'
import usePrice from '@framework/product/use-price'
import useAddItem from '@framework/cart/use-add-item'
import useRemoveItem from '@framework/wishlist/use-remove-item'
@ -16,16 +16,21 @@ interface Props {
product: Product
}
const placeholderImg = '/product-img-placeholder.svg'
const WishlistCard: FC<Props> = ({ product }) => {
const { price } = usePrice({
amount: product.prices?.price?.value,
baseAmount: product.prices?.retailPrice?.value,
currencyCode: product.prices?.price?.currencyCode!,
amount: product.price?.value,
baseAmount: product.price?.retailPrice,
currencyCode: product.price?.currencyCode!,
})
// @ts-ignore Wishlist is not always enabled
const removeItem = useRemoveItem({ wishlist: { includeProducts: true } })
const [loading, setLoading] = useState(false)
const [removing, setRemoving] = useState(false)
// TODO: fix this missing argument issue
/* @ts-ignore */
const addItem = useAddItem()
const { openSidebar } = useUI()
@ -58,10 +63,10 @@ const WishlistCard: FC<Props> = ({ product }) => {
<div className={cn(s.root, { 'opacity-75 pointer-events-none': removing })}>
<div className={`col-span-3 ${s.productBg}`}>
<Image
src={product.images[0].url}
src={product.images[0]?.url || placeholderImg}
width={400}
height={400}
alt={product.images[0].alt || 'Product Image'}
alt={product.images[0]?.alt || 'Product Image'}
/>
</div>

View File

@ -1,78 +0,0 @@
import isAllowedMethod from '../utils/is-allowed-method'
import createApiHandler, {
BigcommerceApiHandler,
BigcommerceHandler,
} from '../utils/create-api-handler'
import { BigcommerceApiError } from '../utils/errors'
import getCart from './handlers/get-cart'
import addItem from './handlers/add-item'
import updateItem from './handlers/update-item'
import removeItem from './handlers/remove-item'
import type {
BigcommerceCart,
GetCartHandlerBody,
AddCartItemHandlerBody,
UpdateCartItemHandlerBody,
RemoveCartItemHandlerBody,
} from '../../types'
export type CartHandlers = {
getCart: BigcommerceHandler<BigcommerceCart, GetCartHandlerBody>
addItem: BigcommerceHandler<BigcommerceCart, AddCartItemHandlerBody>
updateItem: BigcommerceHandler<BigcommerceCart, UpdateCartItemHandlerBody>
removeItem: BigcommerceHandler<BigcommerceCart, RemoveCartItemHandlerBody>
}
const METHODS = ['GET', 'POST', 'PUT', 'DELETE']
// TODO: a complete implementation should have schema validation for `req.body`
const cartApi: BigcommerceApiHandler<BigcommerceCart, CartHandlers> = async (
req,
res,
config,
handlers
) => {
if (!isAllowedMethod(req, res, METHODS)) return
const { cookies } = req
const cartId = cookies[config.cartCookie]
try {
// Return current cart info
if (req.method === 'GET') {
const body = { cartId }
return await handlers['getCart']({ req, res, config, body })
}
// Create or add an item to the cart
if (req.method === 'POST') {
const body = { ...req.body, cartId }
return await handlers['addItem']({ req, res, config, body })
}
// Update item in cart
if (req.method === 'PUT') {
const body = { ...req.body, cartId }
return await handlers['updateItem']({ req, res, config, body })
}
// Remove an item from the cart
if (req.method === 'DELETE') {
const body = { ...req.body, cartId }
return await handlers['removeItem']({ req, res, config, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof BigcommerceApiError
? 'An unexpected error ocurred with the Bigcommerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
export const handlers = { getCart, addItem, updateItem, removeItem }
export default createApiHandler(cartApi, handlers, {})

View File

@ -1,48 +0,0 @@
import type { Product } from '@commerce/types'
import isAllowedMethod from '../utils/is-allowed-method'
import createApiHandler, {
BigcommerceApiHandler,
BigcommerceHandler,
} from '../utils/create-api-handler'
import { BigcommerceApiError } from '../utils/errors'
import getProducts from './handlers/get-products'
export type SearchProductsData = {
products: Product[]
found: boolean
}
export type ProductsHandlers = {
getProducts: BigcommerceHandler<
SearchProductsData,
{ search?: string; category?: string; brand?: string; sort?: string }
>
}
const METHODS = ['GET']
// TODO(lf): a complete implementation should have schema validation for `req.body`
const productsApi: BigcommerceApiHandler<
SearchProductsData,
ProductsHandlers
> = async (req, res, config, handlers) => {
if (!isAllowedMethod(req, res, METHODS)) return
try {
const body = req.query
return await handlers['getProducts']({ req, res, config, body })
} catch (error) {
console.error(error)
const message =
error instanceof BigcommerceApiError
? 'An unexpected error ocurred with the Bigcommerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
export const handlers = { getProducts }
export default createApiHandler(productsApi, handlers, {})

View File

@ -1,77 +0,0 @@
import isAllowedMethod from './utils/is-allowed-method'
import createApiHandler, {
BigcommerceApiHandler,
} from './utils/create-api-handler'
import { BigcommerceApiError } from './utils/errors'
const METHODS = ['GET']
const fullCheckout = true
// TODO: a complete implementation should have schema validation for `req.body`
const checkoutApi: BigcommerceApiHandler<any> = async (req, res, config) => {
if (!isAllowedMethod(req, res, METHODS)) return
const { cookies } = req
const cartId = cookies[config.cartCookie]
try {
if (!cartId) {
res.redirect('/cart')
return
}
const { data } = await config.storeApiFetch(
`/v3/carts/${cartId}/redirect_urls`,
{
method: 'POST',
}
)
if (fullCheckout) {
res.redirect(data.checkout_url)
return
}
// 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>
`
res.status(200)
res.setHeader('Content-Type', 'text/html')
res.write(html)
res.end()
} catch (error) {
console.error(error)
const message =
error instanceof BigcommerceApiError
? 'An unexpected error ocurred with the Bigcommerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
export default createApiHandler(checkoutApi, {}, {})

View File

@ -1,59 +0,0 @@
import type { GetLoggedInCustomerQuery } from '../../../schema'
import type { CustomersHandlers } from '..'
export const getLoggedInCustomerQuery = /* GraphQL */ `
query getLoggedInCustomer {
customer {
entityId
firstName
lastName
email
company
customerGroupId
notes
phone
addressCount
attributeCount
storeCredit {
value
currencyCode
}
}
}
`
export type Customer = NonNullable<GetLoggedInCustomerQuery['customer']>
const getLoggedInCustomer: CustomersHandlers['getLoggedInCustomer'] = async ({
req,
res,
config,
}) => {
const token = req.cookies[config.customerCookie]
if (token) {
const { data } = await config.fetch<GetLoggedInCustomerQuery>(
getLoggedInCustomerQuery,
undefined,
{
headers: {
cookie: `${config.customerCookie}=${token}`,
},
}
)
const { customer } = data
if (!customer) {
return res.status(400).json({
data: null,
errors: [{ message: 'Customer not found', code: 'not_found' }],
})
}
return res.status(200).json({ data: { customer } })
}
res.status(200).json({ data: null })
}
export default getLoggedInCustomer

View File

@ -1,46 +0,0 @@
import createApiHandler, {
BigcommerceApiHandler,
BigcommerceHandler,
} from '../utils/create-api-handler'
import isAllowedMethod from '../utils/is-allowed-method'
import { BigcommerceApiError } from '../utils/errors'
import getLoggedInCustomer, {
Customer,
} from './handlers/get-logged-in-customer'
export type { Customer }
export type CustomerData = {
customer: Customer
}
export type CustomersHandlers = {
getLoggedInCustomer: BigcommerceHandler<CustomerData>
}
const METHODS = ['GET']
const customersApi: BigcommerceApiHandler<
CustomerData,
CustomersHandlers
> = async (req, res, config, handlers) => {
if (!isAllowedMethod(req, res, METHODS)) return
try {
const body = null
return await handlers['getLoggedInCustomer']({ req, res, config, body })
} catch (error) {
console.error(error)
const message =
error instanceof BigcommerceApiError
? 'An unexpected error ocurred with the Bigcommerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
const handlers = { getLoggedInCustomer }
export default createApiHandler(customersApi, handlers, {})

View File

@ -1,45 +0,0 @@
import createApiHandler, {
BigcommerceApiHandler,
BigcommerceHandler,
} from '../utils/create-api-handler'
import isAllowedMethod from '../utils/is-allowed-method'
import { BigcommerceApiError } from '../utils/errors'
import login from './handlers/login'
export type LoginBody = {
email: string
password: string
}
export type LoginHandlers = {
login: BigcommerceHandler<null, Partial<LoginBody>>
}
const METHODS = ['POST']
const loginApi: BigcommerceApiHandler<null, LoginHandlers> = async (
req,
res,
config,
handlers
) => {
if (!isAllowedMethod(req, res, METHODS)) return
try {
const body = req.body ?? {}
return await handlers['login']({ req, res, config, body })
} catch (error) {
console.error(error)
const message =
error instanceof BigcommerceApiError
? 'An unexpected error ocurred with the Bigcommerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
const handlers = { login }
export default createApiHandler(loginApi, handlers, {})

View File

@ -1,42 +0,0 @@
import createApiHandler, {
BigcommerceApiHandler,
BigcommerceHandler,
} from '../utils/create-api-handler'
import isAllowedMethod from '../utils/is-allowed-method'
import { BigcommerceApiError } from '../utils/errors'
import logout from './handlers/logout'
export type LogoutHandlers = {
logout: BigcommerceHandler<null, { redirectTo?: string }>
}
const METHODS = ['GET']
const logoutApi: BigcommerceApiHandler<null, LogoutHandlers> = async (
req,
res,
config,
handlers
) => {
if (!isAllowedMethod(req, res, METHODS)) return
try {
const redirectTo = req.query.redirect_to
const body = typeof redirectTo === 'string' ? { redirectTo } : {}
return await handlers['logout']({ req, res, config, body })
} catch (error) {
console.error(error)
const message =
error instanceof BigcommerceApiError
? 'An unexpected error ocurred with the Bigcommerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
const handlers = { logout }
export default createApiHandler(logoutApi, handlers, {})

View File

@ -1,50 +0,0 @@
import createApiHandler, {
BigcommerceApiHandler,
BigcommerceHandler,
} from '../utils/create-api-handler'
import isAllowedMethod from '../utils/is-allowed-method'
import { BigcommerceApiError } from '../utils/errors'
import signup from './handlers/signup'
export type SignupBody = {
firstName: string
lastName: string
email: string
password: string
}
export type SignupHandlers = {
signup: BigcommerceHandler<null, { cartId?: string } & Partial<SignupBody>>
}
const METHODS = ['POST']
const signupApi: BigcommerceApiHandler<null, SignupHandlers> = async (
req,
res,
config,
handlers
) => {
if (!isAllowedMethod(req, res, METHODS)) return
const { cookies } = req
const cartId = cookies[config.cartCookie]
try {
const body = { ...req.body, cartId }
return await handlers['signup']({ req, res, config, body })
} catch (error) {
console.error(error)
const message =
error instanceof BigcommerceApiError
? 'An unexpected error ocurred with the Bigcommerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
const handlers = { signup }
export default createApiHandler(signupApi, handlers, {})

View File

@ -1,8 +1,9 @@
import { normalizeCart } from '../../../lib/normalize'
import { parseCartItem } from '../../utils/parse-item'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartHandlers } from '..'
import type { CartEndpoint } from '.'
const addItem: CartHandlers['addItem'] = async ({
const addItem: CartEndpoint['handlers']['addItem'] = async ({
res,
body: { cartId, item },
config,
@ -39,7 +40,7 @@ const addItem: CartHandlers['addItem'] = async ({
'Set-Cookie',
getCartCookie(config.cartCookie, data.id, config.cartCookieMaxAge)
)
res.status(200).json({ data })
res.status(200).json({ data: normalizeCart(data) })
}
export default addItem

View File

@ -1,10 +1,11 @@
import type { BigcommerceCart } from '../../../types'
import { normalizeCart } from '../../../lib/normalize'
import { BigcommerceApiError } from '../../utils/errors'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartHandlers } from '../'
import type { BigcommerceCart } from '../../../types/cart'
import type { CartEndpoint } from '.'
// Return current cart info
const getCart: CartHandlers['getCart'] = async ({
const getCart: CartEndpoint['handlers']['getCart'] = async ({
res,
body: { cartId },
config,
@ -26,7 +27,9 @@ const getCart: CartHandlers['getCart'] = async ({
}
}
res.status(200).json({ data: result.data ?? null })
res.status(200).json({
data: result.data ? normalizeCart(result.data) : null,
})
}
export default getCart

View File

@ -0,0 +1,26 @@
import { GetAPISchema, createEndpoint } from '@commerce/api'
import cartEndpoint from '@commerce/api/endpoints/cart'
import type { CartSchema } from '../../../types/cart'
import type { BigcommerceAPI } from '../..'
import getCart from './get-cart'
import addItem from './add-item'
import updateItem from './update-item'
import removeItem from './remove-item'
export type CartAPI = GetAPISchema<BigcommerceAPI, CartSchema>
export type CartEndpoint = CartAPI['endpoint']
export const handlers: CartEndpoint['handlers'] = {
getCart,
addItem,
updateItem,
removeItem,
}
const cartApi = createEndpoint<CartAPI>({
handler: cartEndpoint,
handlers,
})
export default cartApi

View File

@ -1,7 +1,8 @@
import { normalizeCart } from '../../../lib/normalize'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartHandlers } from '..'
import type { CartEndpoint } from '.'
const removeItem: CartHandlers['removeItem'] = async ({
const removeItem: CartEndpoint['handlers']['removeItem'] = async ({
res,
body: { cartId, itemId },
config,
@ -27,7 +28,7 @@ const removeItem: CartHandlers['removeItem'] = async ({
: // Remove the cart cookie if the cart was removed (empty items)
getCartCookie(config.cartCookie)
)
res.status(200).json({ data })
res.status(200).json({ data: data && normalizeCart(data) })
}
export default removeItem

View File

@ -1,8 +1,9 @@
import { normalizeCart } from '../../../lib/normalize'
import { parseCartItem } from '../../utils/parse-item'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartHandlers } from '..'
import type { CartEndpoint } from '.'
const updateItem: CartHandlers['updateItem'] = async ({
const updateItem: CartEndpoint['handlers']['updateItem'] = async ({
res,
body: { cartId, itemId, item },
config,
@ -29,7 +30,7 @@ const updateItem: CartHandlers['updateItem'] = async ({
'Set-Cookie',
getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge)
)
res.status(200).json({ data })
res.status(200).json({ data: normalizeCart(data) })
}
export default updateItem

View File

@ -1,6 +1,5 @@
import { Product } from '@commerce/types'
import getAllProducts, { ProductEdge } from '../../../product/get-all-products'
import type { ProductsHandlers } from '../products'
import { Product } from '@commerce/types/product'
import { ProductsEndpoint } from '.'
const SORT: { [key: string]: string | undefined } = {
latest: 'id',
@ -11,10 +10,11 @@ const SORT: { [key: string]: string | undefined } = {
const LIMIT = 12
// Return current cart info
const getProducts: ProductsHandlers['getProducts'] = async ({
const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({
res,
body: { search, category, brand, sort },
body: { search, categoryId, brandId, sort },
config,
commerce,
}) => {
// Use a dummy base as we only care about the relative path
const url = new URL('/v3/catalog/products', 'http://a')
@ -24,11 +24,11 @@ const getProducts: ProductsHandlers['getProducts'] = async ({
if (search) url.searchParams.set('keyword', search)
if (category && Number.isInteger(Number(category)))
url.searchParams.set('categories:in', category)
if (categoryId && Number.isInteger(Number(categoryId)))
url.searchParams.set('categories:in', String(categoryId))
if (brand && Number.isInteger(Number(brand)))
url.searchParams.set('brand_id', brand)
if (brandId && Number.isInteger(Number(brandId)))
url.searchParams.set('brand_id', String(brandId))
if (sort) {
const [_sort, direction] = sort.split('-')
@ -47,18 +47,18 @@ const getProducts: ProductsHandlers['getProducts'] = async ({
url.pathname + url.search
)
const entityIds = data.map((p) => p.id)
const found = entityIds.length > 0
const ids = data.map((p) => String(p.id))
const found = ids.length > 0
// We want the GraphQL version of each product
const graphqlData = await getAllProducts({
variables: { first: LIMIT, entityIds },
const graphqlData = await commerce.getAllProducts({
variables: { first: LIMIT, ids },
config,
})
// Put the products in an object that we can use to get them by id
const productsById = graphqlData.products.reduce<{
[k: number]: Product
[k: string]: Product
}>((prods, p) => {
prods[Number(p.id)] = p
return prods
@ -68,7 +68,7 @@ const getProducts: ProductsHandlers['getProducts'] = async ({
// Populate the products array with the graphql products, in the order
// assigned by the list of entity ids
entityIds.forEach((id) => {
ids.forEach((id) => {
const product = productsById[id]
if (product) products.push(product)
})

View File

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

View File

@ -0,0 +1,62 @@
import type { CheckoutEndpoint } from '.'
const fullCheckout = true
const checkout: CheckoutEndpoint['handlers']['checkout'] = async ({
req,
res,
config,
}) => {
const { cookies } = req
const cartId = cookies[config.cartCookie]
if (!cartId) {
res.redirect('/cart')
return
}
const { data } = await config.storeApiFetch(
`/v3/carts/${cartId}/redirect_urls`,
{
method: 'POST',
}
)
if (fullCheckout) {
res.redirect(data.checkout_url)
return
}
// 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>
`
res.status(200)
res.setHeader('Content-Type', 'text/html')
res.write(html)
res.end()
}
export default checkout

View File

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

View File

@ -0,0 +1,56 @@
import type { GetLoggedInCustomerQuery } from '../../../schema'
import type { CustomerEndpoint } from '.'
export const getLoggedInCustomerQuery = /* GraphQL */ `
query getLoggedInCustomer {
customer {
entityId
firstName
lastName
email
company
customerGroupId
notes
phone
addressCount
attributeCount
storeCredit {
value
currencyCode
}
}
}
`
export type Customer = NonNullable<GetLoggedInCustomerQuery['customer']>
const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] =
async ({ req, res, config }) => {
const token = req.cookies[config.customerCookie]
if (token) {
const { data } = await config.fetch<GetLoggedInCustomerQuery>(
getLoggedInCustomerQuery,
undefined,
{
headers: {
cookie: `${config.customerCookie}=${token}`,
},
}
)
const { customer } = data
if (!customer) {
return res.status(400).json({
data: null,
errors: [{ message: 'Customer not found', code: 'not_found' }],
})
}
return res.status(200).json({ data: { customer } })
}
res.status(200).json({ data: null })
}
export default getLoggedInCustomer

View File

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

View File

@ -0,0 +1,18 @@
import { GetAPISchema, createEndpoint } from '@commerce/api'
import loginEndpoint from '@commerce/api/endpoints/login'
import type { LoginSchema } from '../../../types/login'
import type { BigcommerceAPI } from '../..'
import login from './login'
export type LoginAPI = GetAPISchema<BigcommerceAPI, LoginSchema>
export type LoginEndpoint = LoginAPI['endpoint']
export const handlers: LoginEndpoint['handlers'] = { login }
const loginApi = createEndpoint<LoginAPI>({
handler: loginEndpoint,
handlers,
})
export default loginApi

View File

@ -1,13 +1,13 @@
import { FetcherError } from '@commerce/utils/errors'
import login from '../../../auth/login'
import type { LoginHandlers } from '../login'
import type { LoginEndpoint } from '.'
const invalidCredentials = /invalid credentials/i
const loginHandler: LoginHandlers['login'] = async ({
const login: LoginEndpoint['handlers']['login'] = async ({
res,
body: { email, password },
config,
commerce,
}) => {
// TODO: Add proper validations with something like Ajv
if (!(email && password)) {
@ -21,7 +21,7 @@ const loginHandler: LoginHandlers['login'] = async ({
// and numeric characters.
try {
await login({ variables: { email, password }, config, res })
await commerce.login({ variables: { email, password }, config, res })
} catch (error) {
// Check if the email and password didn't match an existing account
if (
@ -46,4 +46,4 @@ const loginHandler: LoginHandlers['login'] = async ({
res.status(200).json({ data: null })
}
export default loginHandler
export default login

View File

@ -0,0 +1,18 @@
import { GetAPISchema, createEndpoint } from '@commerce/api'
import logoutEndpoint from '@commerce/api/endpoints/logout'
import type { LogoutSchema } from '../../../types/logout'
import type { BigcommerceAPI } from '../..'
import logout from './logout'
export type LogoutAPI = GetAPISchema<BigcommerceAPI, LogoutSchema>
export type LogoutEndpoint = LogoutAPI['endpoint']
export const handlers: LogoutEndpoint['handlers'] = { logout }
const logoutApi = createEndpoint<LogoutAPI>({
handler: logoutEndpoint,
handlers,
})
export default logoutApi

View File

@ -1,7 +1,7 @@
import { serialize } from 'cookie'
import { LogoutHandlers } from '../logout'
import type { LogoutEndpoint } from '.'
const logoutHandler: LogoutHandlers['logout'] = async ({
const logout: LogoutEndpoint['handlers']['logout'] = async ({
res,
body: { redirectTo },
config,
@ -20,4 +20,4 @@ const logoutHandler: LogoutHandlers['logout'] = async ({
}
}
export default logoutHandler
export default logout

View File

@ -0,0 +1,18 @@
import { GetAPISchema, createEndpoint } from '@commerce/api'
import signupEndpoint from '@commerce/api/endpoints/signup'
import type { SignupSchema } from '../../../types/signup'
import type { BigcommerceAPI } from '../..'
import signup from './signup'
export type SignupAPI = GetAPISchema<BigcommerceAPI, SignupSchema>
export type SignupEndpoint = SignupAPI['endpoint']
export const handlers: SignupEndpoint['handlers'] = { signup }
const singupApi = createEndpoint<SignupAPI>({
handler: signupEndpoint,
handlers,
})
export default singupApi

View File

@ -1,11 +1,11 @@
import { BigcommerceApiError } from '../../utils/errors'
import login from '../../../auth/login'
import { SignupHandlers } from '../signup'
import type { SignupEndpoint } from '.'
const signup: SignupHandlers['signup'] = async ({
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)) {
@ -54,7 +54,7 @@ const signup: SignupHandlers['signup'] = async ({
}
// Login the customer right after creating it
await login({ variables: { email, password }, res, config })
await commerce.login({ variables: { email, password }, res, config })
res.status(200).json({ data: null })
}

View File

@ -1,13 +1,14 @@
import type { WishlistHandlers } from '..'
import getCustomerId from '../../../customer/get-customer-id'
import getCustomerWishlist from '../../../customer/get-customer-wishlist'
import getCustomerWishlist from '../../operations/get-customer-wishlist'
import { parseWishlistItem } from '../../utils/parse-item'
import getCustomerId from './utils/get-customer-id'
import type { WishlistEndpoint } from '.'
// Returns the wishlist of the signed customer
const addItem: WishlistHandlers['addItem'] = async ({
// Return wishlist info
const addItem: WishlistEndpoint['handlers']['addItem'] = async ({
res,
body: { customerToken, item },
config,
commerce,
}) => {
if (!item) {
return res.status(400).json({
@ -26,7 +27,7 @@ const addItem: WishlistHandlers['addItem'] = async ({
})
}
const { wishlist } = await getCustomerWishlist({
const { wishlist } = await commerce.getCustomerWishlist({
variables: { customerId },
config,
})

View File

@ -1,12 +1,14 @@
import getCustomerId from '../../../customer/get-customer-id'
import getCustomerWishlist from '../../../customer/get-customer-wishlist'
import type { Wishlist, WishlistHandlers } from '..'
import type { Wishlist } from '../../../types/wishlist'
import type { WishlistEndpoint } from '.'
import getCustomerId from './utils/get-customer-id'
import getCustomerWishlist from '../../operations/get-customer-wishlist'
// Return wishlist info
const getWishlist: WishlistHandlers['getWishlist'] = async ({
const getWishlist: WishlistEndpoint['handlers']['getWishlist'] = async ({
res,
body: { customerToken, includeProducts },
config,
commerce,
}) => {
let result: { data?: Wishlist } = {}
@ -22,7 +24,7 @@ const getWishlist: WishlistHandlers['getWishlist'] = async ({
})
}
const { wishlist } = await getCustomerWishlist({
const { wishlist } = await commerce.getCustomerWishlist({
variables: { customerId },
includeProducts,
config,

View File

@ -0,0 +1,24 @@
import { GetAPISchema, createEndpoint } from '@commerce/api'
import wishlistEndpoint from '@commerce/api/endpoints/wishlist'
import type { WishlistSchema } from '../../../types/wishlist'
import type { BigcommerceAPI } from '../..'
import getWishlist from './get-wishlist'
import addItem from './add-item'
import removeItem from './remove-item'
export type WishlistAPI = GetAPISchema<BigcommerceAPI, WishlistSchema>
export type WishlistEndpoint = WishlistAPI['endpoint']
export const handlers: WishlistEndpoint['handlers'] = {
getWishlist,
addItem,
removeItem,
}
const wishlistApi = createEndpoint<WishlistAPI>({
handler: wishlistEndpoint,
handlers,
})
export default wishlistApi

View File

@ -1,20 +1,20 @@
import getCustomerId from '../../../customer/get-customer-id'
import getCustomerWishlist, {
Wishlist,
} from '../../../customer/get-customer-wishlist'
import type { WishlistHandlers } from '..'
import type { Wishlist } from '../../../types/wishlist'
import getCustomerWishlist from '../../operations/get-customer-wishlist'
import getCustomerId from './utils/get-customer-id'
import type { WishlistEndpoint } from '.'
// Return current wishlist info
const removeItem: WishlistHandlers['removeItem'] = async ({
// Return wishlist info
const removeItem: WishlistEndpoint['handlers']['removeItem'] = async ({
res,
body: { customerToken, itemId },
config,
commerce,
}) => {
const customerId =
customerToken && (await getCustomerId({ customerToken, config }))
const { wishlist } =
(customerId &&
(await getCustomerWishlist({
(await commerce.getCustomerWishlist({
variables: { customerId },
config,
}))) ||

View File

@ -1,5 +1,5 @@
import { GetCustomerIdQuery } from '../schema'
import { BigcommerceConfig, getConfig } from '../api'
import type { GetCustomerIdQuery } from '../../../../schema'
import type { BigcommerceConfig } from '../../..'
export const getCustomerIdQuery = /* GraphQL */ `
query getCustomerId {
@ -14,10 +14,8 @@ async function getCustomerId({
config,
}: {
customerToken: string
config?: BigcommerceConfig
}): Promise<number | undefined> {
config = getConfig(config)
config: BigcommerceConfig
}): Promise<string | undefined> {
const { data } = await config.fetch<GetCustomerIdQuery>(
getCustomerIdQuery,
undefined,
@ -28,7 +26,7 @@ async function getCustomerId({
}
)
return data?.customer?.entityId
return String(data?.customer?.entityId)
}
export default getCustomerId

View File

@ -1,8 +1,29 @@
import type { RequestInit } from '@vercel/fetch'
import type { CommerceAPIConfig } from '@commerce/api'
import {
CommerceAPI,
CommerceAPIConfig,
getCommerceApi as commerceApi,
} from '@commerce/api'
import fetchGraphqlApi from './utils/fetch-graphql-api'
import fetchStoreApi from './utils/fetch-store-api'
import type { CartAPI } from './endpoints/cart'
import type { CustomerAPI } from './endpoints/customer'
import type { LoginAPI } from './endpoints/login'
import type { LogoutAPI } from './endpoints/logout'
import type { SignupAPI } from './endpoints/signup'
import type { ProductsAPI } from './endpoints/catalog/products'
import type { WishlistAPI } from './endpoints/wishlist'
import login from './operations/login'
import getAllPages from './operations/get-all-pages'
import getPage from './operations/get-page'
import getSiteInfo from './operations/get-site-info'
import getCustomerWishlist from './operations/get-customer-wishlist'
import getAllProductPaths from './operations/get-all-product-paths'
import getAllProducts from './operations/get-all-products'
import getProduct from './operations/get-product'
export interface BigcommerceConfig extends CommerceAPIConfig {
// Indicates if the returned metadata with translations should be applied to the
// data or returned as it is
@ -39,34 +60,12 @@ if (!(STORE_API_URL && STORE_API_TOKEN && STORE_API_CLIENT_ID)) {
)
}
export class Config {
private config: BigcommerceConfig
constructor(config: Omit<BigcommerceConfig, 'customerCookie'>) {
this.config = {
...config,
// The customerCookie is not customizable for now, BC sets the cookie and it's
// not important to rename it
customerCookie: 'SHOP_TOKEN',
}
}
getConfig(userConfig: Partial<BigcommerceConfig> = {}) {
return Object.entries(userConfig).reduce<BigcommerceConfig>(
(cfg, [key, value]) => Object.assign(cfg, { [key]: value }),
{ ...this.config }
)
}
setConfig(newConfig: Partial<BigcommerceConfig>) {
Object.assign(this.config, newConfig)
}
}
const ONE_DAY = 60 * 60 * 24
const config = new Config({
const config: BigcommerceConfig = {
commerceUrl: API_URL,
apiToken: API_TOKEN,
customerCookie: 'SHOP_TOKEN',
cartCookie: process.env.BIGCOMMERCE_CART_COOKIE ?? 'bc_cartId',
cartCookieMaxAge: ONE_DAY * 30,
fetch: fetchGraphqlApi,
@ -77,12 +76,36 @@ const config = new Config({
storeApiClientId: STORE_API_CLIENT_ID,
storeChannelId: STORE_CHANNEL_ID,
storeApiFetch: fetchStoreApi,
})
export function getConfig(userConfig?: Partial<BigcommerceConfig>) {
return config.getConfig(userConfig)
}
export function setConfig(newConfig: Partial<BigcommerceConfig>) {
return config.setConfig(newConfig)
const operations = {
login,
getAllPages,
getPage,
getSiteInfo,
getCustomerWishlist,
getAllProductPaths,
getAllProducts,
getProduct,
}
export const provider = { config, operations }
export type Provider = typeof provider
export type APIs =
| CartAPI
| CustomerAPI
| LoginAPI
| LogoutAPI
| SignupAPI
| ProductsAPI
| WishlistAPI
export type BigcommerceAPI<P extends Provider = Provider> = CommerceAPI<P>
export function getCommerceApi<P extends Provider>(
customProvider: P = provider as any
): BigcommerceAPI<P> {
return commerceApi(customProvider)
}

View File

@ -0,0 +1,46 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import type { Page, GetAllPagesOperation } from '../../types/page'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import { BigcommerceConfig, Provider } from '..'
export default function getAllPagesOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllPages<T extends GetAllPagesOperation>(opts?: {
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']>
async function getAllPages<T extends GetAllPagesOperation>(
opts: {
config?: Partial<BigcommerceConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getAllPages<T extends GetAllPagesOperation>({
config,
preview,
}: {
url?: string
config?: Partial<BigcommerceConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const cfg = commerce.getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `url`
const { data } = await cfg.storeApiFetch<
RecursivePartial<{ data: Page[] }>
>('/v3/content/pages')
const pages = (data as RecursiveRequired<typeof data>) ?? []
return {
pages: preview ? pages : pages.filter((p) => p.is_visible),
}
}
return getAllPages
}

View File

@ -0,0 +1,66 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import type { GetAllProductPathsQuery } from '../../schema'
import type { GetAllProductPathsOperation } from '../../types/product'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import filterEdges from '../utils/filter-edges'
import { BigcommerceConfig, Provider } from '..'
export const getAllProductPathsQuery = /* GraphQL */ `
query getAllProductPaths($first: Int = 100) {
site {
products(first: $first) {
edges {
node {
path
}
}
}
}
}
`
export default function getAllProductPathsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProductPaths<
T extends GetAllProductPathsOperation
>(opts?: {
variables?: T['variables']
config?: BigcommerceConfig
}): Promise<T['data']>
async function getAllProductPaths<T extends GetAllProductPathsOperation>(
opts: {
variables?: T['variables']
config?: BigcommerceConfig
} & OperationOptions
): Promise<T['data']>
async function getAllProductPaths<T extends GetAllProductPathsOperation>({
query = getAllProductPathsQuery,
variables,
config,
}: {
query?: string
variables?: T['variables']
config?: BigcommerceConfig
} = {}): Promise<T['data']> {
config = commerce.getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const { data } = await config.fetch<
RecursivePartial<GetAllProductPathsQuery>
>(query, { variables })
const products = data.site?.products?.edges
return {
products: filterEdges(products as RecursiveRequired<typeof products>).map(
({ node }) => node
),
}
}
return getAllProductPaths
}

View File

@ -0,0 +1,135 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import type {
GetAllProductsQuery,
GetAllProductsQueryVariables,
} from '../../schema'
import type { GetAllProductsOperation } from '../../types/product'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import filterEdges from '../utils/filter-edges'
import setProductLocaleMeta from '../utils/set-product-locale-meta'
import { productConnectionFragment } from '../fragments/product'
import { BigcommerceConfig, Provider } from '..'
import { normalizeProduct } from '../../lib/normalize'
export const getAllProductsQuery = /* GraphQL */ `
query getAllProducts(
$hasLocale: Boolean = false
$locale: String = "null"
$entityIds: [Int!]
$first: Int = 10
$products: Boolean = false
$featuredProducts: Boolean = false
$bestSellingProducts: Boolean = false
$newestProducts: Boolean = false
) {
site {
products(first: $first, entityIds: $entityIds) @include(if: $products) {
...productConnnection
}
featuredProducts(first: $first) @include(if: $featuredProducts) {
...productConnnection
}
bestSellingProducts(first: $first) @include(if: $bestSellingProducts) {
...productConnnection
}
newestProducts(first: $first) @include(if: $newestProducts) {
...productConnnection
}
}
}
${productConnectionFragment}
`
export type ProductEdge = NonNullable<
NonNullable<GetAllProductsQuery['site']['products']['edges']>[0]
>
export type ProductNode = ProductEdge['node']
export type GetAllProductsResult<
T extends Record<keyof GetAllProductsResult, any[]> = {
products: ProductEdge[]
}
> = T
function getProductsType(
relevance?: GetAllProductsOperation['variables']['relevance']
) {
switch (relevance) {
case 'featured':
return 'featuredProducts'
case 'best_selling':
return 'bestSellingProducts'
case 'newest':
return 'newestProducts'
default:
return 'products'
}
}
export default function getAllProductsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProducts<T extends GetAllProductsOperation>(opts?: {
variables?: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']>
async function getAllProducts<T extends GetAllProductsOperation>(
opts: {
variables?: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getAllProducts<T extends GetAllProductsOperation>({
query = getAllProductsQuery,
variables: vars = {},
config: cfg,
}: {
query?: string
variables?: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const config = commerce.getConfig(cfg)
const { locale } = config
const field = getProductsType(vars.relevance)
const variables: GetAllProductsQueryVariables = {
locale,
hasLocale: !!locale,
}
variables[field] = true
if (vars.first) variables.first = vars.first
if (vars.ids) variables.entityIds = vars.ids.map((id) => Number(id))
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const { data } = await config.fetch<RecursivePartial<GetAllProductsQuery>>(
query,
{ variables }
)
const edges = data.site?.[field]?.edges
const products = filterEdges(edges as RecursiveRequired<typeof edges>)
if (locale && config.applyLocale) {
products.forEach((product: RecursivePartial<ProductEdge>) => {
if (product.node) setProductLocaleMeta(product.node)
})
}
return {
products: products.map(({ node }) => normalizeProduct(node as any)),
}
}
return getAllProducts
}

View File

@ -0,0 +1,81 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import type {
GetCustomerWishlistOperation,
Wishlist,
} from '../../types/wishlist'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import { BigcommerceConfig, Provider } from '..'
import getAllProducts, { ProductEdge } from './get-all-products'
export default function getCustomerWishlistOperation({
commerce,
}: OperationContext<Provider>) {
async function getCustomerWishlist<
T extends GetCustomerWishlistOperation
>(opts: {
variables: T['variables']
config?: BigcommerceConfig
includeProducts?: boolean
}): Promise<T['data']>
async function getCustomerWishlist<T extends GetCustomerWishlistOperation>(
opts: {
variables: T['variables']
config?: BigcommerceConfig
includeProducts?: boolean
} & OperationOptions
): Promise<T['data']>
async function getCustomerWishlist<T extends GetCustomerWishlistOperation>({
config,
variables,
includeProducts,
}: {
url?: string
variables: T['variables']
config?: BigcommerceConfig
includeProducts?: boolean
}): Promise<T['data']> {
config = commerce.getConfig(config)
const { data = [] } = await config.storeApiFetch<
RecursivePartial<{ data: Wishlist[] }>
>(`/v3/wishlists?customer_id=${variables.customerId}`)
const wishlist = data[0]
if (includeProducts && wishlist?.items?.length) {
const ids = wishlist.items
?.map((item) => (item?.product_id ? String(item?.product_id) : null))
.filter((id): id is string => !!id)
if (ids?.length) {
const graphqlData = await commerce.getAllProducts({
variables: { first: 100, ids },
config,
})
// Put the products in an object that we can use to get them by id
const productsById = graphqlData.products.reduce<{
[k: number]: ProductEdge
}>((prods, p) => {
prods[Number(p.id)] = p as any
return prods
}, {})
// Populate the wishlist items with the graphql products
wishlist.items.forEach((item) => {
const product = item && productsById[item.product_id!]
if (item && product) {
// @ts-ignore Fix this type when the wishlist type is properly defined
item.product = product
}
})
}
}
return { wishlist: wishlist as RecursiveRequired<typeof wishlist> }
}
return getCustomerWishlist
}

View File

@ -0,0 +1,54 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import type { GetPageOperation, Page } from '../../types/page'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import type { BigcommerceConfig, Provider } from '..'
import { normalizePage } from '../../lib/normalize'
export default function getPageOperation({
commerce,
}: OperationContext<Provider>) {
async function getPage<T extends GetPageOperation>(opts: {
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']>
async function getPage<T extends GetPageOperation>(
opts: {
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getPage<T extends GetPageOperation>({
url,
variables,
config,
preview,
}: {
url?: string
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']> {
const cfg = commerce.getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `url`
const { data } = await cfg.storeApiFetch<
RecursivePartial<{ data: Page[] }>
>(url || `/v3/content/pages?id=${variables.id}&include=body`)
const firstPage = data?.[0]
const page = firstPage as RecursiveRequired<typeof firstPage>
if (preview || page?.is_visible) {
return { page: normalizePage(page as any) }
}
return {}
}
return getPage
}

View File

@ -0,0 +1,119 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import type { GetProductOperation } from '../../types/product'
import type { GetProductQuery, GetProductQueryVariables } from '../../schema'
import setProductLocaleMeta from '../utils/set-product-locale-meta'
import { productInfoFragment } from '../fragments/product'
import { BigcommerceConfig, Provider } from '..'
import { normalizeProduct } from '../../lib/normalize'
export const getProductQuery = /* GraphQL */ `
query getProduct(
$hasLocale: Boolean = false
$locale: String = "null"
$path: String!
) {
site {
route(path: $path) {
node {
__typename
... on Product {
...productInfo
variants {
edges {
node {
entityId
defaultImage {
urlOriginal
altText
isDefault
}
prices {
...productPrices
}
inventory {
aggregated {
availableToSell
warningLevel
}
isInStock
}
productOptions {
edges {
node {
__typename
entityId
displayName
...multipleChoiceOption
}
}
}
}
}
}
}
}
}
}
}
${productInfoFragment}
`
// TODO: See if this type is useful for defining the Product type
// export type ProductNode = Extract<
// GetProductQuery['site']['route']['node'],
// { __typename: 'Product' }
// >
export default function getAllProductPathsOperation({
commerce,
}: OperationContext<Provider>) {
async function getProduct<T extends GetProductOperation>(opts: {
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']>
async function getProduct<T extends GetProductOperation>(
opts: {
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getProduct<T extends GetProductOperation>({
query = getProductQuery,
variables: { slug, ...vars },
config: cfg,
}: {
query?: string
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']> {
const config = commerce.getConfig(cfg)
const { locale } = config
const variables: GetProductQueryVariables = {
locale,
hasLocale: !!locale,
path: slug ? `/${slug}/` : vars.path!,
}
const { data } = await config.fetch<GetProductQuery>(query, { variables })
const product = data.site?.route?.node
if (product?.__typename === 'Product') {
if (locale && config.applyLocale) {
setProductLocaleMeta(product)
}
return { product: normalizeProduct(product as any) }
}
return {}
}
return getProduct
}

View File

@ -0,0 +1,87 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import type { GetSiteInfoOperation } from '../../types/site'
import type { GetSiteInfoQuery } from '../../schema'
import filterEdges from '../utils/filter-edges'
import type { BigcommerceConfig, Provider } from '..'
import { categoryTreeItemFragment } from '../fragments/category-tree'
import { normalizeCategory } from '../../lib/normalize'
// Get 3 levels of categories
export const getSiteInfoQuery = /* GraphQL */ `
query getSiteInfo {
site {
categoryTree {
...categoryTreeItem
children {
...categoryTreeItem
children {
...categoryTreeItem
}
}
}
brands {
pageInfo {
startCursor
endCursor
}
edges {
cursor
node {
entityId
name
defaultImage {
urlOriginal
altText
}
pageTitle
metaDesc
metaKeywords
searchKeywords
path
}
}
}
}
}
${categoryTreeItemFragment}
`
export default function getSiteInfoOperation({
commerce,
}: OperationContext<Provider>) {
async function getSiteInfo<T extends GetSiteInfoOperation>(opts?: {
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']>
async function getSiteInfo<T extends GetSiteInfoOperation>(
opts: {
config?: Partial<BigcommerceConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getSiteInfo<T extends GetSiteInfoOperation>({
query = getSiteInfoQuery,
config,
}: {
query?: string
config?: Partial<BigcommerceConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const cfg = commerce.getConfig(config)
const { data } = await cfg.fetch<GetSiteInfoQuery>(query)
const categories = data.site.categoryTree.map(normalizeCategory)
const brands = data.site?.brands?.edges
return {
categories: categories ?? [],
brands: filterEdges(brands),
}
}
return getSiteInfo
}

View File

@ -0,0 +1,79 @@
import type { ServerResponse } from 'http'
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import type { LoginOperation } from '../../types/login'
import type { LoginMutation } from '../../schema'
import type { RecursivePartial } from '../utils/types'
import concatHeader from '../utils/concat-cookie'
import type { BigcommerceConfig, Provider } from '..'
export const loginMutation = /* GraphQL */ `
mutation login($email: String!, $password: String!) {
login(email: $email, password: $password) {
result
}
}
`
export default function loginOperation({
commerce,
}: OperationContext<Provider>) {
async function login<T extends LoginOperation>(opts: {
variables: T['variables']
config?: BigcommerceConfig
res: ServerResponse
}): Promise<T['data']>
async function login<T extends LoginOperation>(
opts: {
variables: T['variables']
config?: BigcommerceConfig
res: ServerResponse
} & OperationOptions
): Promise<T['data']>
async function login<T extends LoginOperation>({
query = loginMutation,
variables,
res: response,
config,
}: {
query?: string
variables: T['variables']
res: ServerResponse
config?: BigcommerceConfig
}): Promise<T['data']> {
config = commerce.getConfig(config)
const { data, res } = await config.fetch<RecursivePartial<LoginMutation>>(
query,
{ variables }
)
// Bigcommerce returns a Set-Cookie header with the auth cookie
let cookie = res.headers.get('Set-Cookie')
if (cookie && typeof cookie === 'string') {
// In development, don't set a secure cookie or the browser will ignore it
if (process.env.NODE_ENV !== 'production') {
cookie = cookie.replace('; Secure', '')
// SameSite=none can't be set unless the cookie is Secure
// bc seems to sometimes send back SameSite=None rather than none so make
// this case insensitive
cookie = cookie.replace(/; SameSite=none/gi, '; SameSite=lax')
}
response.setHeader(
'Set-Cookie',
concatHeader(response.getHeader('Set-Cookie'), cookie)!
)
}
return {
result: data.login?.result,
}
}
return login
}

View File

@ -1,58 +0,0 @@
import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
import { BigcommerceConfig, getConfig } from '..'
export type BigcommerceApiHandler<
T = any,
H extends BigcommerceHandlers = {},
Options extends {} = {}
> = (
req: NextApiRequest,
res: NextApiResponse<BigcommerceApiResponse<T>>,
config: BigcommerceConfig,
handlers: H,
// Custom configs that may be used by a particular handler
options: Options
) => void | Promise<void>
export type BigcommerceHandler<T = any, Body = null> = (options: {
req: NextApiRequest
res: NextApiResponse<BigcommerceApiResponse<T>>
config: BigcommerceConfig
body: Body
}) => void | Promise<void>
export type BigcommerceHandlers<T = any> = {
[k: string]: BigcommerceHandler<T, any>
}
export type BigcommerceApiResponse<T> = {
data: T | null
errors?: { message: string; code?: string }[]
}
export default function createApiHandler<
T = any,
H extends BigcommerceHandlers = {},
Options extends {} = {}
>(
handler: BigcommerceApiHandler<T, H, Options>,
handlers: H,
defaultOptions: Options
) {
return function getApiHandler({
config,
operations,
options,
}: {
config?: BigcommerceConfig
operations?: Partial<H>
options?: Options extends {} ? Partial<Options> : never
} = {}): NextApiHandler {
const ops = { ...operations, ...handlers }
const opts = { ...defaultOptions, ...options }
return function apiHandler(req, res) {
return handler(req, res, getConfig(config), ops, opts)
}
}
}

View File

@ -1,6 +1,6 @@
import { FetcherError } from '@commerce/utils/errors'
import type { GraphQLFetcher } from '@commerce/api'
import { getConfig } from '..'
import { provider } from '..'
import fetch from './fetch'
const fetchGraphqlApi: GraphQLFetcher = async (
@ -9,7 +9,7 @@ const fetchGraphqlApi: GraphQLFetcher = async (
fetchOptions
) => {
// log.warn(query)
const config = getConfig()
const { config } = provider
const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), {
...fetchOptions,
method: 'POST',

View File

@ -1,5 +1,5 @@
import type { RequestInit, Response } from '@vercel/fetch'
import { getConfig } from '..'
import { provider } from '..'
import { BigcommerceApiError, BigcommerceNetworkError } from './errors'
import fetch from './fetch'
@ -7,7 +7,7 @@ export default async function fetchStoreApi<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const config = getConfig()
const { config } = provider
let res: Response
try {

View File

@ -1,5 +1,5 @@
import type { ItemBody as WishlistItemBody } from '../wishlist'
import type { CartItemBody, OptionSelections } from '../../types'
import type { WishlistItemBody } from '../../types/wishlist'
import type { CartItemBody, OptionSelections } from '../../types/cart'
type BCWishlistItemBody = {
product_id: number

View File

@ -1,4 +1,4 @@
import type { ProductNode } from '../../product/get-all-products'
import type { ProductNode } from '../operations/get-all-products'
import type { RecursivePartial } from './types'
export default function setProductLocaleMeta(

View File

@ -1,104 +0,0 @@
import isAllowedMethod from '../utils/is-allowed-method'
import createApiHandler, {
BigcommerceApiHandler,
BigcommerceHandler,
} from '../utils/create-api-handler'
import { BigcommerceApiError } from '../utils/errors'
import type {
Wishlist,
WishlistItem,
} from '../../customer/get-customer-wishlist'
import getWishlist from './handlers/get-wishlist'
import addItem from './handlers/add-item'
import removeItem from './handlers/remove-item'
import type { Product, ProductVariant, Customer } from '@commerce/types'
export type { Wishlist, WishlistItem }
export type ItemBody = {
productId: Product['id']
variantId: ProductVariant['id']
}
export type AddItemBody = { item: ItemBody }
export type RemoveItemBody = { itemId: Product['id'] }
export type WishlistBody = {
customer_id: Customer['entityId']
is_public: number
name: string
items: any[]
}
export type AddWishlistBody = { wishlist: WishlistBody }
export type WishlistHandlers = {
getWishlist: BigcommerceHandler<
Wishlist,
{ customerToken?: string; includeProducts?: boolean }
>
addItem: BigcommerceHandler<
Wishlist,
{ customerToken?: string } & Partial<AddItemBody>
>
removeItem: BigcommerceHandler<
Wishlist,
{ customerToken?: string } & Partial<RemoveItemBody>
>
}
const METHODS = ['GET', 'POST', 'DELETE']
// TODO: a complete implementation should have schema validation for `req.body`
const wishlistApi: BigcommerceApiHandler<Wishlist, WishlistHandlers> = async (
req,
res,
config,
handlers
) => {
if (!isAllowedMethod(req, res, METHODS)) return
const { cookies } = req
const customerToken = cookies[config.customerCookie]
try {
// Return current wishlist info
if (req.method === 'GET') {
const body = {
customerToken,
includeProducts: req.query.products === '1',
}
return await handlers['getWishlist']({ req, res, config, body })
}
// Add an item to the wishlist
if (req.method === 'POST') {
const body = { ...req.body, customerToken }
return await handlers['addItem']({ req, res, config, body })
}
// Remove an item from the wishlist
if (req.method === 'DELETE') {
const body = { ...req.body, customerToken }
return await handlers['removeItem']({ req, res, config, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof BigcommerceApiError
? 'An unexpected error ocurred with the Bigcommerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
export const handlers = {
getWishlist,
addItem,
removeItem,
}
export default createApiHandler(wishlistApi, handlers, {})

View File

@ -1,73 +0,0 @@
import type { ServerResponse } from 'http'
import type { LoginMutation, LoginMutationVariables } from '../schema'
import type { RecursivePartial } from '../api/utils/types'
import concatHeader from '../api/utils/concat-cookie'
import { BigcommerceConfig, getConfig } from '../api'
export const loginMutation = /* GraphQL */ `
mutation login($email: String!, $password: String!) {
login(email: $email, password: $password) {
result
}
}
`
export type LoginResult<T extends { result?: any } = { result?: string }> = T
export type LoginVariables = LoginMutationVariables
async function login(opts: {
variables: LoginVariables
config?: BigcommerceConfig
res: ServerResponse
}): Promise<LoginResult>
async function login<T extends { result?: any }, V = any>(opts: {
query: string
variables: V
res: ServerResponse
config?: BigcommerceConfig
}): Promise<LoginResult<T>>
async function login({
query = loginMutation,
variables,
res: response,
config,
}: {
query?: string
variables: LoginVariables
res: ServerResponse
config?: BigcommerceConfig
}): Promise<LoginResult> {
config = getConfig(config)
const { data, res } = await config.fetch<RecursivePartial<LoginMutation>>(
query,
{ variables }
)
// Bigcommerce returns a Set-Cookie header with the auth cookie
let cookie = res.headers.get('Set-Cookie')
if (cookie && typeof cookie === 'string') {
// In development, don't set a secure cookie or the browser will ignore it
if (process.env.NODE_ENV !== 'production') {
cookie = cookie.replace('; Secure', '')
// SameSite=none can't be set unless the cookie is Secure
// bc seems to sometimes send back SameSite=None rather than none so make
// this case insensitive
cookie = cookie.replace(/; SameSite=none/gi, '; SameSite=lax')
}
response.setHeader(
'Set-Cookie',
concatHeader(response.getHeader('Set-Cookie'), cookie)!
)
}
return {
result: data.login?.result,
}
}
export default login

View File

@ -2,14 +2,14 @@ import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useLogin, { UseLogin } from '@commerce/auth/use-login'
import type { LoginBody } from '../api/customers/login'
import type { LoginHook } from '../types/login'
import useCustomer from '../customer/use-customer'
export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<null, {}, LoginBody> = {
export const handler: MutationHook<LoginHook> = {
fetchOptions: {
url: '/api/bigcommerce/customers/login',
url: '/api/login',
method: 'POST',
},
async fetcher({ input: { email, password }, options, fetch }) {
@ -25,16 +25,18 @@ export const handler: MutationHook<null, {}, LoginBody> = {
body: { email, password },
})
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
useHook:
({ fetch }) =>
() => {
const { revalidate } = useCustomer()
return useCallback(
async function login(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
return useCallback(
async function login(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

View File

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

View File

@ -2,14 +2,14 @@ import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useSignup, { UseSignup } from '@commerce/auth/use-signup'
import type { SignupBody } from '../api/customers/signup'
import type { SignupHook } from '../types/signup'
import useCustomer from '../customer/use-customer'
export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<null, {}, SignupBody, SignupBody> = {
export const handler: MutationHook<SignupHook> = {
fetchOptions: {
url: '/api/bigcommerce/customers/signup',
url: '/api/signup',
method: 'POST',
},
async fetcher({
@ -29,16 +29,18 @@ export const handler: MutationHook<null, {}, SignupBody, SignupBody> = {
body: { firstName, lastName, email, password },
})
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
useHook:
({ fetch }) =>
() => {
const { revalidate } = useCustomer()
return useCallback(
async function signup(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
return useCallback(
async function signup(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

View File

@ -2,20 +2,14 @@ import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item'
import { normalizeCart } from '../lib/normalize'
import type {
Cart,
BigcommerceCart,
CartItemBody,
AddCartItemBody,
} from '../types'
import type { AddItemHook } from '@commerce/types/cart'
import useCart from './use-cart'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<Cart, {}, CartItemBody> = {
export const handler: MutationHook<AddItemHook> = {
fetchOptions: {
url: '/api/bigcommerce/cart',
url: '/api/cart',
method: 'POST',
},
async fetcher({ input: item, options, fetch }) {
@ -28,23 +22,25 @@ export const handler: MutationHook<Cart, {}, CartItemBody> = {
})
}
const data = await fetch<BigcommerceCart, AddCartItemBody>({
const data = await fetch({
...options,
body: { item },
})
return normalizeCart(data)
return data
},
useHook: ({ fetch }) => () => {
const { mutate } = useCart()
useHook:
({ fetch }) =>
() => {
const { mutate } = useCart()
return useCallback(
async function addItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
return useCallback(
async function addItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
}

View File

@ -1,41 +1,33 @@
import { useMemo } from 'react'
import { SWRHook } from '@commerce/utils/types'
import useCart, { UseCart, FetchCartInput } from '@commerce/cart/use-cart'
import { normalizeCart } from '../lib/normalize'
import type { Cart } from '../types'
import useCart, { UseCart } from '@commerce/cart/use-cart'
import type { GetCartHook } from '@commerce/types/cart'
export default useCart as UseCart<typeof handler>
export const handler: SWRHook<
Cart | null,
{},
FetchCartInput,
{ isEmpty?: boolean }
> = {
export const handler: SWRHook<GetCartHook> = {
fetchOptions: {
url: '/api/bigcommerce/cart',
url: '/api/cart',
method: 'GET',
},
async fetcher({ input: { cartId }, options, fetch }) {
const data = cartId ? await fetch(options) : null
return data && normalizeCart(data)
},
useHook: ({ useData }) => (input) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
useHook:
({ useData }) =>
(input) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) <= 0
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) <= 0
},
enumerable: true,
},
enumerable: true,
},
}),
[response]
)
},
}),
[response]
)
},
}

View File

@ -4,68 +4,51 @@ import type {
HookFetcherContext,
} from '@commerce/utils/types'
import { ValidationError } from '@commerce/utils/errors'
import useRemoveItem, {
RemoveItemInput as RemoveItemInputBase,
UseRemoveItem,
} from '@commerce/cart/use-remove-item'
import { normalizeCart } from '../lib/normalize'
import type {
RemoveCartItemBody,
Cart,
BigcommerceCart,
LineItem,
} from '../types'
import useRemoveItem, { UseRemoveItem } from '@commerce/cart/use-remove-item'
import type { Cart, LineItem, RemoveItemHook } from '@commerce/types/cart'
import useCart from './use-cart'
export type RemoveItemFn<T = any> = T extends LineItem
? (input?: RemoveItemInput<T>) => Promise<Cart | null>
: (input: RemoveItemInput<T>) => Promise<Cart | null>
? (input?: RemoveItemActionInput<T>) => Promise<Cart | null | undefined>
: (input: RemoveItemActionInput<T>) => Promise<Cart | null>
export type RemoveItemInput<T = any> = T extends LineItem
? Partial<RemoveItemInputBase>
: RemoveItemInputBase
export type RemoveItemActionInput<T = any> = T extends LineItem
? Partial<RemoveItemHook['actionInput']>
: RemoveItemHook['actionInput']
export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler = {
fetchOptions: {
url: '/api/bigcommerce/cart',
url: '/api/cart',
method: 'DELETE',
},
async fetcher({
input: { itemId },
options,
fetch,
}: HookFetcherContext<RemoveCartItemBody>) {
const data = await fetch<BigcommerceCart>({
...options,
body: { itemId },
})
return normalizeCart(data)
}: HookFetcherContext<RemoveItemHook>) {
return await fetch({ ...options, body: { itemId } })
},
useHook: ({
fetch,
}: MutationHookContext<Cart | null, RemoveCartItemBody>) => <
T extends LineItem | undefined = undefined
>(
ctx: { item?: T } = {}
) => {
const { item } = ctx
const { mutate } = useCart()
const removeItem: RemoveItemFn<LineItem> = async (input) => {
const itemId = input?.id ?? item?.id
useHook:
({ fetch }: MutationHookContext<RemoveItemHook>) =>
<T extends LineItem | undefined = undefined>(ctx: { item?: T } = {}) => {
const { item } = ctx
const { mutate } = useCart()
const removeItem: RemoveItemFn<LineItem> = async (input) => {
const itemId = input?.id ?? item?.id
if (!itemId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
})
if (!itemId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
})
}
const data = await fetch({ input: { itemId } })
await mutate(data, false)
return data
}
const data = await fetch({ input: { itemId } })
await mutate(data, false)
return data
}
return useCallback(removeItem as RemoveItemFn<T>, [fetch, mutate])
},
return useCallback(removeItem as RemoveItemFn<T>, [fetch, mutate])
},
}

View File

@ -5,36 +5,27 @@ import type {
HookFetcherContext,
} from '@commerce/utils/types'
import { ValidationError } from '@commerce/utils/errors'
import useUpdateItem, {
UpdateItemInput as UpdateItemInputBase,
UseUpdateItem,
} from '@commerce/cart/use-update-item'
import { normalizeCart } from '../lib/normalize'
import type {
UpdateCartItemBody,
Cart,
BigcommerceCart,
LineItem,
} from '../types'
import useUpdateItem, { UseUpdateItem } from '@commerce/cart/use-update-item'
import type { LineItem, UpdateItemHook } from '@commerce/types/cart'
import { handler as removeItemHandler } from './use-remove-item'
import useCart from './use-cart'
export type UpdateItemInput<T = any> = T extends LineItem
? Partial<UpdateItemInputBase<LineItem>>
: UpdateItemInputBase<LineItem>
export type UpdateItemActionInput<T = any> = T extends LineItem
? Partial<UpdateItemHook['actionInput']>
: UpdateItemHook['actionInput']
export default useUpdateItem as UseUpdateItem<typeof handler>
export const handler = {
fetchOptions: {
url: '/api/bigcommerce/cart',
url: '/api/cart',
method: 'PUT',
},
async fetcher({
input: { itemId, item },
options,
fetch,
}: HookFetcherContext<UpdateCartItemBody>) {
}: HookFetcherContext<UpdateItemHook>) {
if (Number.isInteger(item.quantity)) {
// Also allow the update hook to remove an item if the quantity is lower than 1
if (item.quantity! < 1) {
@ -50,48 +41,44 @@ export const handler = {
})
}
const data = await fetch<BigcommerceCart, UpdateCartItemBody>({
return await fetch({
...options,
body: { itemId, item },
})
return normalizeCart(data)
},
useHook: ({
fetch,
}: MutationHookContext<Cart | null, UpdateCartItemBody>) => <
T extends LineItem | undefined = undefined
>(
ctx: {
item?: T
wait?: number
} = {}
) => {
const { item } = ctx
const { mutate } = useCart() as any
useHook:
({ fetch }: MutationHookContext<UpdateItemHook>) =>
<T extends LineItem | undefined = undefined>(
ctx: {
item?: T
wait?: number
} = {}
) => {
const { item } = ctx
const { mutate } = useCart() as any
return useCallback(
debounce(async (input: UpdateItemInput<T>) => {
const itemId = input.id ?? item?.id
const productId = input.productId ?? item?.productId
const variantId = input.productId ?? item?.variantId
return useCallback(
debounce(async (input: UpdateItemActionInput<T>) => {
const itemId = input.id ?? item?.id
const productId = input.productId ?? item?.productId
const variantId = input.productId ?? item?.variantId
if (!itemId || !productId || !variantId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
if (!itemId || !productId || !variantId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
})
}
const data = await fetch({
input: {
itemId,
item: { productId, variantId, quantity: input.quantity },
},
})
}
const data = await fetch({
input: {
itemId,
item: { productId, variantId, quantity: input.quantity },
},
})
await mutate(data, false)
return data
}, ctx.wait ?? 500),
[fetch, mutate]
)
},
await mutate(data, false)
return data
}, ctx.wait ?? 500),
[fetch, mutate]
)
},
}

View File

@ -1,43 +0,0 @@
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import { BigcommerceConfig, getConfig } from '../api'
import { definitions } from '../api/definitions/store-content'
export type Page = definitions['page_Full']
export type GetAllPagesResult<
T extends { pages: any[] } = { pages: Page[] }
> = T
async function getAllPages(opts?: {
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetAllPagesResult>
async function getAllPages<T extends { pages: any[] }>(opts: {
url: string
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetAllPagesResult<T>>
async function getAllPages({
config,
preview,
}: {
url?: string
config?: BigcommerceConfig
preview?: boolean
} = {}): Promise<GetAllPagesResult> {
config = getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `url`
const { data } = await config.storeApiFetch<
RecursivePartial<{ data: Page[] }>
>('/v3/content/pages')
const pages = (data as RecursiveRequired<typeof data>) ?? []
return {
pages: preview ? pages : pages.filter((p) => p.is_visible),
}
}
export default getAllPages

View File

@ -1,53 +0,0 @@
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import { BigcommerceConfig, getConfig } from '../api'
import { definitions } from '../api/definitions/store-content'
export type Page = definitions['page_Full']
export type GetPageResult<T extends { page?: any } = { page?: Page }> = T
export type PageVariables = {
id: number
}
async function getPage(opts: {
url?: string
variables: PageVariables
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetPageResult>
async function getPage<T extends { page?: any }, V = any>(opts: {
url: string
variables: V
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetPageResult<T>>
async function getPage({
url,
variables,
config,
preview,
}: {
url?: string
variables: PageVariables
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetPageResult> {
config = getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `url`
const { data } = await config.storeApiFetch<
RecursivePartial<{ data: Page[] }>
>(url || `/v3/content/pages?id=${variables.id}&include=body`)
const firstPage = data?.[0]
const page = firstPage as RecursiveRequired<typeof firstPage>
if (preview || page?.is_visible) {
return { page }
}
return {}
}
export default getPage

View File

@ -1,106 +0,0 @@
import type { GetSiteInfoQuery, GetSiteInfoQueryVariables } from '../schema'
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import filterEdges from '../api/utils/filter-edges'
import { BigcommerceConfig, getConfig } from '../api'
import { categoryTreeItemFragment } from '../api/fragments/category-tree'
// Get 3 levels of categories
export const getSiteInfoQuery = /* GraphQL */ `
query getSiteInfo {
site {
categoryTree {
...categoryTreeItem
children {
...categoryTreeItem
children {
...categoryTreeItem
}
}
}
brands {
pageInfo {
startCursor
endCursor
}
edges {
cursor
node {
entityId
name
defaultImage {
urlOriginal
altText
}
pageTitle
metaDesc
metaKeywords
searchKeywords
path
}
}
}
}
}
${categoryTreeItemFragment}
`
export type CategoriesTree = NonNullable<
GetSiteInfoQuery['site']['categoryTree']
>
export type BrandEdge = NonNullable<
NonNullable<GetSiteInfoQuery['site']['brands']['edges']>[0]
>
export type Brands = BrandEdge[]
export type GetSiteInfoResult<
T extends { categories: any[]; brands: any[] } = {
categories: CategoriesTree
brands: Brands
}
> = T
async function getSiteInfo(opts?: {
variables?: GetSiteInfoQueryVariables
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetSiteInfoResult>
async function getSiteInfo<
T extends { categories: any[]; brands: any[] },
V = any
>(opts: {
query: string
variables?: V
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetSiteInfoResult<T>>
async function getSiteInfo({
query = getSiteInfoQuery,
variables,
config,
}: {
query?: string
variables?: GetSiteInfoQueryVariables
config?: BigcommerceConfig
preview?: boolean
} = {}): Promise<GetSiteInfoResult> {
config = getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const { data } = await config.fetch<RecursivePartial<GetSiteInfoQuery>>(
query,
{ variables }
)
const categories = data.site?.categoryTree
const brands = data.site?.brands?.edges
return {
categories: (categories as RecursiveRequired<typeof categories>) ?? [],
brands: filterEdges(brands as RecursiveRequired<typeof brands>),
}
}
export default getSiteInfo

View File

@ -1,88 +0,0 @@
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import { definitions } from '../api/definitions/wishlist'
import { BigcommerceConfig, getConfig } from '../api'
import getAllProducts, { ProductEdge } from '../product/get-all-products'
export type Wishlist = Omit<definitions['wishlist_Full'], 'items'> & {
items?: WishlistItem[]
}
export type WishlistItem = NonNullable<
definitions['wishlist_Full']['items']
>[0] & {
product?: ProductEdge['node']
}
export type GetCustomerWishlistResult<
T extends { wishlist?: any } = { wishlist?: Wishlist }
> = T
export type GetCustomerWishlistVariables = {
customerId: number
}
async function getCustomerWishlist(opts: {
variables: GetCustomerWishlistVariables
config?: BigcommerceConfig
includeProducts?: boolean
}): Promise<GetCustomerWishlistResult>
async function getCustomerWishlist<
T extends { wishlist?: any },
V = any
>(opts: {
url: string
variables: V
config?: BigcommerceConfig
includeProducts?: boolean
}): Promise<GetCustomerWishlistResult<T>>
async function getCustomerWishlist({
config,
variables,
includeProducts,
}: {
url?: string
variables: GetCustomerWishlistVariables
config?: BigcommerceConfig
includeProducts?: boolean
}): Promise<GetCustomerWishlistResult> {
config = getConfig(config)
const { data = [] } = await config.storeApiFetch<
RecursivePartial<{ data: Wishlist[] }>
>(`/v3/wishlists?customer_id=${variables.customerId}`)
const wishlist = data[0]
if (includeProducts && wishlist?.items?.length) {
const entityIds = wishlist.items
?.map((item) => item?.product_id)
.filter((id): id is number => !!id)
if (entityIds?.length) {
const graphqlData = await getAllProducts({
variables: { first: 100, entityIds },
config,
})
// Put the products in an object that we can use to get them by id
const productsById = graphqlData.products.reduce<{
[k: number]: ProductEdge
}>((prods, p) => {
prods[Number(p.id)] = p as any
return prods
}, {})
// Populate the wishlist items with the graphql products
wishlist.items.forEach((item) => {
const product = item && productsById[item.product_id!]
if (item && product) {
// @ts-ignore Fix this type when the wishlist type is properly defined
item.product = product
}
})
}
}
return { wishlist: wishlist as RecursiveRequired<typeof wishlist> }
}
export default getCustomerWishlist

View File

@ -1,24 +1,26 @@
import { SWRHook } from '@commerce/utils/types'
import useCustomer, { UseCustomer } from '@commerce/customer/use-customer'
import type { Customer, CustomerData } from '../api/customers'
import type { CustomerHook } from '../types/customer'
export default useCustomer as UseCustomer<typeof handler>
export const handler: SWRHook<Customer | null> = {
export const handler: SWRHook<CustomerHook> = {
fetchOptions: {
url: '/api/bigcommerce/customers',
url: '/api/customer',
method: 'GET',
},
async fetcher({ options, fetch }) {
const data = await fetch<CustomerData | null>(options)
const data = await fetch(options)
return data?.customer ?? null
},
useHook: ({ useData }) => (input) => {
return useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
},
useHook:
({ useData }) =>
(input) => {
return useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
},
}

View File

@ -0,0 +1,5 @@
// Remove trailing and leading slash, usually included in nodes
// returned by the BigCommerce API
const getSlug = (path: string) => path.replace(/^\/|\/$/g, '')
export default getSlug

View File

@ -1,6 +1,10 @@
import type { Product } from '@commerce/types'
import type { Cart, BigcommerceCart, LineItem } from '../types'
import type { Product } from '../types/product'
import type { Cart, BigcommerceCart, LineItem } from '../types/cart'
import type { Page } from '../types/page'
import type { BCCategory, Category } from '../types/site'
import { definitions } from '../api/definitions/store-content'
import update from './immutability'
import getSlug from './get-slug'
function normalizeProductOption(productOption: any) {
const {
@ -69,6 +73,16 @@ export function normalizeProduct(productNode: any): Product {
})
}
export function normalizePage(page: definitions['page_Full']): Page {
return {
id: String(page.id),
name: page.name,
is_visible: page.is_visible,
sort_order: page.sort_order,
body: page.body,
}
}
export function normalizeCart(data: BigcommerceCart): Cart {
return {
id: data.id,
@ -111,3 +125,12 @@ function normalizeLineItem(item: any): LineItem {
})),
}
}
export function normalizeCategory(category: BCCategory): Category {
return {
id: `${category.entityId}`,
name: category.name,
slug: getSlug(category.path),
path: category.path,
}
}

View File

@ -1,71 +0,0 @@
import type {
GetAllProductPathsQuery,
GetAllProductPathsQueryVariables,
} from '../schema'
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import filterEdges from '../api/utils/filter-edges'
import { BigcommerceConfig, getConfig } from '../api'
export const getAllProductPathsQuery = /* GraphQL */ `
query getAllProductPaths($first: Int = 100) {
site {
products(first: $first) {
edges {
node {
path
}
}
}
}
}
`
export type ProductPath = NonNullable<
NonNullable<GetAllProductPathsQuery['site']['products']['edges']>[0]
>
export type ProductPaths = ProductPath[]
export type { GetAllProductPathsQueryVariables }
export type GetAllProductPathsResult<
T extends { products: any[] } = { products: ProductPaths }
> = T
async function getAllProductPaths(opts?: {
variables?: GetAllProductPathsQueryVariables
config?: BigcommerceConfig
}): Promise<GetAllProductPathsResult>
async function getAllProductPaths<
T extends { products: any[] },
V = any
>(opts: {
query: string
variables?: V
config?: BigcommerceConfig
}): Promise<GetAllProductPathsResult<T>>
async function getAllProductPaths({
query = getAllProductPathsQuery,
variables,
config,
}: {
query?: string
variables?: GetAllProductPathsQueryVariables
config?: BigcommerceConfig
} = {}): Promise<GetAllProductPathsResult> {
config = getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const { data } = await config.fetch<
RecursivePartial<GetAllProductPathsQuery>
>(query, { variables })
const products = data.site?.products?.edges
return {
products: filterEdges(products as RecursiveRequired<typeof products>),
}
}
export default getAllProductPaths

View File

@ -1,135 +0,0 @@
import type {
GetAllProductsQuery,
GetAllProductsQueryVariables,
} from '../schema'
import type { Product } from '@commerce/types'
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import filterEdges from '../api/utils/filter-edges'
import setProductLocaleMeta from '../api/utils/set-product-locale-meta'
import { productConnectionFragment } from '../api/fragments/product'
import { BigcommerceConfig, getConfig } from '../api'
import { normalizeProduct } from '../lib/normalize'
export const getAllProductsQuery = /* GraphQL */ `
query getAllProducts(
$hasLocale: Boolean = false
$locale: String = "null"
$entityIds: [Int!]
$first: Int = 10
$products: Boolean = false
$featuredProducts: Boolean = false
$bestSellingProducts: Boolean = false
$newestProducts: Boolean = false
) {
site {
products(first: $first, entityIds: $entityIds) @include(if: $products) {
...productConnnection
}
featuredProducts(first: $first) @include(if: $featuredProducts) {
...productConnnection
}
bestSellingProducts(first: $first) @include(if: $bestSellingProducts) {
...productConnnection
}
newestProducts(first: $first) @include(if: $newestProducts) {
...productConnnection
}
}
}
${productConnectionFragment}
`
export type ProductEdge = NonNullable<
NonNullable<GetAllProductsQuery['site']['products']['edges']>[0]
>
export type ProductNode = ProductEdge['node']
export type GetAllProductsResult<
T extends Record<keyof GetAllProductsResult, any[]> = {
products: ProductEdge[]
}
> = T
const FIELDS = [
'products',
'featuredProducts',
'bestSellingProducts',
'newestProducts',
]
export type ProductTypes =
| 'products'
| 'featuredProducts'
| 'bestSellingProducts'
| 'newestProducts'
export type ProductVariables = { field?: ProductTypes } & Omit<
GetAllProductsQueryVariables,
ProductTypes | 'hasLocale'
>
async function getAllProducts(opts?: {
variables?: ProductVariables
config?: BigcommerceConfig
preview?: boolean
}): Promise<{ products: Product[] }>
async function getAllProducts<
T extends Record<keyof GetAllProductsResult, any[]>,
V = any
>(opts: {
query: string
variables?: V
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetAllProductsResult<T>>
async function getAllProducts({
query = getAllProductsQuery,
variables: { field = 'products', ...vars } = {},
config,
}: {
query?: string
variables?: ProductVariables
config?: BigcommerceConfig
preview?: boolean
// TODO: fix the product type here
} = {}): Promise<{ products: Product[] | any[] }> {
config = getConfig(config)
const locale = vars.locale || config.locale
const variables: GetAllProductsQueryVariables = {
...vars,
locale,
hasLocale: !!locale,
}
if (!FIELDS.includes(field)) {
throw new Error(
`The field variable has to match one of ${FIELDS.join(', ')}`
)
}
variables[field] = true
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const { data } = await config.fetch<RecursivePartial<GetAllProductsQuery>>(
query,
{ variables }
)
const edges = data.site?.[field]?.edges
const products = filterEdges(edges as RecursiveRequired<typeof edges>)
if (locale && config.applyLocale) {
products.forEach((product: RecursivePartial<ProductEdge>) => {
if (product.node) setProductLocaleMeta(product.node)
})
}
return { products: products.map(({ node }) => normalizeProduct(node as any)) }
}
export default getAllProducts

View File

@ -1,121 +0,0 @@
import type { GetProductQuery, GetProductQueryVariables } from '../schema'
import setProductLocaleMeta from '../api/utils/set-product-locale-meta'
import { productInfoFragment } from '../api/fragments/product'
import { BigcommerceConfig, getConfig } from '../api'
import { normalizeProduct } from '../lib/normalize'
import type { Product } from '@commerce/types'
export const getProductQuery = /* GraphQL */ `
query getProduct(
$hasLocale: Boolean = false
$locale: String = "null"
$path: String!
) {
site {
route(path: $path) {
node {
__typename
... on Product {
...productInfo
variants {
edges {
node {
entityId
defaultImage {
urlOriginal
altText
isDefault
}
prices {
...productPrices
}
inventory {
aggregated {
availableToSell
warningLevel
}
isInStock
}
productOptions {
edges {
node {
__typename
entityId
displayName
...multipleChoiceOption
}
}
}
}
}
}
}
}
}
}
}
${productInfoFragment}
`
export type ProductNode = Extract<
GetProductQuery['site']['route']['node'],
{ __typename: 'Product' }
>
export type GetProductResult<
T extends { product?: any } = { product?: ProductNode }
> = T
export type ProductVariables = { locale?: string } & (
| { path: string; slug?: never }
| { path?: never; slug: string }
)
async function getProduct(opts: {
variables: ProductVariables
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetProductResult>
async function getProduct<T extends { product?: any }, V = any>(opts: {
query: string
variables: V
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetProductResult<T>>
async function getProduct({
query = getProductQuery,
variables: { slug, ...vars },
config,
}: {
query?: string
variables: ProductVariables
config?: BigcommerceConfig
preview?: boolean
}): Promise<Product | {} | any> {
config = getConfig(config)
const locale = vars.locale || config.locale
const variables: GetProductQueryVariables = {
...vars,
locale,
hasLocale: !!locale,
path: slug ? `/${slug}/` : vars.path!,
}
const { data } = await config.fetch<GetProductQuery>(query, { variables })
const product = data.site?.route?.node
if (product?.__typename === 'Product') {
if (locale && config.applyLocale) {
setProductLocaleMeta(product)
}
return { product: normalizeProduct(product as any) }
}
return {}
}
export default getProduct

View File

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

View File

@ -1,23 +1,20 @@
import { SWRHook } from '@commerce/utils/types'
import useSearch, { UseSearch } from '@commerce/product/use-search'
import type { SearchProductsData } from '../api/catalog/products'
import type { SearchProductsHook } from '../types/product'
export default useSearch as UseSearch<typeof handler>
export type SearchProductsInput = {
search?: string
categoryId?: number
categoryId?: number | string
brandId?: number
sort?: string
locale?: string
}
export const handler: SWRHook<
SearchProductsData,
SearchProductsInput,
SearchProductsInput
> = {
export const handler: SWRHook<SearchProductsHook> = {
fetchOptions: {
url: '/api/bigcommerce/catalog/products',
url: '/api/catalog/products',
method: 'GET',
},
fetcher({ input: { search, categoryId, brandId, sort }, options, fetch }) {
@ -26,9 +23,9 @@ export const handler: SWRHook<
if (search) url.searchParams.set('search', search)
if (Number.isInteger(categoryId))
url.searchParams.set('category', String(categoryId))
url.searchParams.set('categoryId', String(categoryId))
if (Number.isInteger(brandId))
url.searchParams.set('brand', String(brandId))
url.searchParams.set('brandId', String(brandId))
if (sort) url.searchParams.set('sort', sort)
return fetch({
@ -36,18 +33,20 @@ export const handler: SWRHook<
method: options.method,
})
},
useHook: ({ useData }) => (input = {}) => {
return useData({
input: [
['search', input.search],
['categoryId', input.categoryId],
['brandId', input.brandId],
['sort', input.sort],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
useHook:
({ useData }) =>
(input = {}) => {
return useData({
input: [
['search', input.search],
['categoryId', input.categoryId],
['brandId', input.brandId],
['sort', input.sort],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
}

View File

@ -1,4 +1,6 @@
import * as Core from '@commerce/types'
import * as Core from '@commerce/types/cart'
export * from '@commerce/types/cart'
// TODO: this type should match:
// https://developer.bigcommerce.com/api-reference/cart-checkout/server-server-cart-api/cart/getacart#responses
@ -23,16 +25,14 @@ export type BigcommerceCart = {
// TODO: add missing fields
}
export type Cart = Core.Cart & {
lineItems: LineItem[]
}
export type LineItem = Core.LineItem
/**
* Cart mutations
* Extend core cart types
*/
export type Cart = Core.Cart & {
lineItems: Core.LineItem[]
}
export type OptionSelections = {
option_id: number
option_value: number | string
@ -43,16 +43,24 @@ export type CartItemBody = Core.CartItemBody & {
optionSelections?: OptionSelections
}
export type GetCartHandlerBody = Core.GetCartHandlerBody
export type CartTypes = {
cart: Cart
item: Core.LineItem
itemBody: CartItemBody
}
export type AddCartItemBody = Core.AddCartItemBody<CartItemBody>
export type CartHooks = Core.CartHooks<CartTypes>
export type AddCartItemHandlerBody = Core.AddCartItemHandlerBody<CartItemBody>
export type GetCartHook = CartHooks['getCart']
export type AddItemHook = CartHooks['addItem']
export type UpdateItemHook = CartHooks['updateItem']
export type RemoveItemHook = CartHooks['removeItem']
export type UpdateCartItemBody = Core.UpdateCartItemBody<CartItemBody>
export type CartSchema = Core.CartSchema<CartTypes>
export type UpdateCartItemHandlerBody = Core.UpdateCartItemHandlerBody<CartItemBody>
export type CartHandlers = Core.CartHandlers<CartTypes>
export type RemoveCartItemBody = Core.RemoveCartItemBody
export type RemoveCartItemHandlerBody = Core.RemoveCartItemHandlerBody
export type GetCartHandler = CartHandlers['getCart']
export type AddItemHandler = CartHandlers['addItem']
export type UpdateItemHandler = CartHandlers['updateItem']
export type RemoveItemHandler = CartHandlers['removeItem']

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,11 @@
import * as Core from '@commerce/types/page'
export * from '@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

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

View File

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

View File

@ -0,0 +1,19 @@
import * as Core from '@commerce/types/site'
import type { GetSiteInfoQuery, GetSiteInfoQueryVariables } from '../schema'
export * from '@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

@ -0,0 +1,24 @@
import * as Core from '@commerce/types/wishlist'
import { definitions } from '../api/definitions/wishlist'
import type { ProductEdge } from '../api/operations/get-all-products'
export * from '@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

@ -2,36 +2,38 @@ import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useAddItem, { UseAddItem } from '@commerce/wishlist/use-add-item'
import type { ItemBody, AddItemBody } from '../api/wishlist'
import type { AddItemHook } from '../types/wishlist'
import useCustomer from '../customer/use-customer'
import useWishlist from './use-wishlist'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<any, {}, ItemBody, AddItemBody> = {
export const handler: MutationHook<AddItemHook> = {
fetchOptions: {
url: '/api/bigcommerce/wishlist',
url: '/api/wishlist',
method: 'POST',
},
useHook: ({ fetch }) => () => {
const { data: customer } = useCustomer()
const { revalidate } = useWishlist()
useHook:
({ fetch }) =>
() => {
const { data: customer } = useCustomer()
const { revalidate } = useWishlist()
return useCallback(
async function addItem(item) {
if (!customer) {
// A signed customer is required in order to have a wishlist
throw new CommerceError({
message: 'Signed customer not found',
})
}
return useCallback(
async function addItem(item) {
if (!customer) {
// A signed customer is required in order to have a wishlist
throw new CommerceError({
message: 'Signed customer not found',
})
}
// TODO: add validations before doing the fetch
const data = await fetch({ input: { item } })
await revalidate()
return data
},
[fetch, revalidate, customer]
)
},
// TODO: add validations before doing the fetch
const data = await fetch({ input: { item } })
await revalidate()
return data
},
[fetch, revalidate, customer]
)
},
}

View File

@ -2,43 +2,39 @@ import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useRemoveItem, {
RemoveItemInput,
UseRemoveItem,
} from '@commerce/wishlist/use-remove-item'
import type { RemoveItemBody, Wishlist } from '../api/wishlist'
import type { RemoveItemHook } from '../types/wishlist'
import useCustomer from '../customer/use-customer'
import useWishlist, { UseWishlistInput } from './use-wishlist'
import useWishlist from './use-wishlist'
export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler: MutationHook<
Wishlist | null,
{ wishlist?: UseWishlistInput },
RemoveItemInput,
RemoveItemBody
> = {
export const handler: MutationHook<RemoveItemHook> = {
fetchOptions: {
url: '/api/bigcommerce/wishlist',
url: '/api/wishlist',
method: 'DELETE',
},
useHook: ({ fetch }) => ({ wishlist } = {}) => {
const { data: customer } = useCustomer()
const { revalidate } = useWishlist(wishlist)
useHook:
({ fetch }) =>
({ wishlist } = {}) => {
const { data: customer } = useCustomer()
const { revalidate } = useWishlist(wishlist)
return useCallback(
async function removeItem(input) {
if (!customer) {
// A signed customer is required in order to have a wishlist
throw new CommerceError({
message: 'Signed customer not found',
})
}
return useCallback(
async function removeItem(input) {
if (!customer) {
// A signed customer is required in order to have a wishlist
throw new CommerceError({
message: 'Signed customer not found',
})
}
const data = await fetch({ input: { itemId: String(input.id) } })
await revalidate()
return data
},
[fetch, revalidate, customer]
)
},
const data = await fetch({ input: { itemId: String(input.id) } })
await revalidate()
return data
},
[fetch, revalidate, customer]
)
},
}

View File

@ -1,21 +1,14 @@
import { useMemo } from 'react'
import { SWRHook } from '@commerce/utils/types'
import useWishlist, { UseWishlist } from '@commerce/wishlist/use-wishlist'
import type { Wishlist } from '../api/wishlist'
import type { GetWishlistHook } from '../types/wishlist'
import useCustomer from '../customer/use-customer'
export type UseWishlistInput = { includeProducts?: boolean }
export default useWishlist as UseWishlist<typeof handler>
export const handler: SWRHook<
Wishlist | null,
UseWishlistInput,
{ customerId?: number } & UseWishlistInput,
{ isEmpty?: boolean }
> = {
export const handler: SWRHook<GetWishlistHook> = {
fetchOptions: {
url: '/api/bigcommerce/wishlist',
url: '/api/wishlist',
method: 'GET',
},
async fetcher({ input: { customerId, includeProducts }, options, fetch }) {
@ -31,30 +24,32 @@ export const handler: SWRHook<
method: options.method,
})
},
useHook: ({ useData }) => (input) => {
const { data: customer } = useCustomer()
const response = useData({
input: [
['customerId', customer?.entityId],
['includeProducts', input?.includeProducts],
],
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
useHook:
({ useData }) =>
(input) => {
const { data: customer } = useCustomer()
const response = useData({
input: [
['customerId', customer?.entityId],
['includeProducts', input?.includeProducts],
],
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.items?.length || 0) <= 0
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.items?.length || 0) <= 0
},
enumerable: true,
},
enumerable: true,
},
}),
[response]
)
},
}),
[response]
)
},
}

View File

@ -0,0 +1,60 @@
import type { CartSchema } from '../../types/cart'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..'
const cartEndpoint: GetAPISchema<any, CartSchema<any>>['endpoint']['handler'] =
async (ctx) => {
const { req, res, handlers, config } = ctx
if (
!isAllowedOperation(req, res, {
GET: handlers['getCart'],
POST: handlers['addItem'],
PUT: handlers['updateItem'],
DELETE: handlers['removeItem'],
})
) {
return
}
const { cookies } = req
const cartId = cookies[config.cartCookie]
try {
// Return current cart info
if (req.method === 'GET') {
const body = { cartId }
return await handlers['getCart']({ ...ctx, body })
}
// Create or add an item to the cart
if (req.method === 'POST') {
const body = { ...req.body, cartId }
return await handlers['addItem']({ ...ctx, body })
}
// Update item in cart
if (req.method === 'PUT') {
const body = { ...req.body, cartId }
return await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from the cart
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 }] })
}
}
export default cartEndpoint

View File

@ -0,0 +1,31 @@
import type { ProductsSchema } from '../../../types/product'
import { CommerceAPIError } from '../../utils/errors'
import isAllowedOperation from '../../utils/is-allowed-operation'
import type { GetAPISchema } from '../..'
const productsEndpoint: GetAPISchema<
any,
ProductsSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers } = ctx
if (!isAllowedOperation(req, res, { GET: handlers['getProducts'] })) {
return
}
try {
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

View File

@ -0,0 +1,35 @@
import type { CheckoutSchema } from '../../types/checkout'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..'
const checkoutEndpoint: GetAPISchema<
any,
CheckoutSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers } = ctx
if (
!isAllowedOperation(req, res, {
GET: handlers['checkout'],
})
) {
return
}
try {
const body = null
return await handlers['checkout']({ ...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 checkoutEndpoint

View File

@ -0,0 +1,35 @@
import type { CustomerSchema } from '../../types/customer'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..'
const customerEndpoint: GetAPISchema<
any,
CustomerSchema<any>
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers } = ctx
if (
!isAllowedOperation(req, res, {
GET: handlers['getLoggedInCustomer'],
})
) {
return
}
try {
const body = null
return await handlers['getLoggedInCustomer']({ ...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 customerEndpoint

View File

@ -0,0 +1,35 @@
import type { LoginSchema } from '../../types/login'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..'
const loginEndpoint: GetAPISchema<
any,
LoginSchema<any>
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers } = ctx
if (
!isAllowedOperation(req, res, {
POST: handlers['login'],
})
) {
return
}
try {
const body = req.body ?? {}
return await 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

View File

@ -0,0 +1,35 @@
import type { LogoutSchema } from '../../types/logout'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..'
const logoutEndpoint: GetAPISchema<any, LogoutSchema>['endpoint']['handler'] =
async (ctx) => {
const { req, res, handlers } = ctx
if (
!isAllowedOperation(req, res, {
GET: handlers['logout'],
})
) {
return
}
try {
const redirectTo = req.query.redirect_to
const body = typeof redirectTo === 'string' ? { redirectTo } : {}
return await handlers['logout']({ ...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 logoutEndpoint

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