Merge branch 'main' of https://github.com/vercel/commerce into elasticpath-master

This commit is contained in:
GunaTrika 2022-01-04 11:44:51 +05:30
commit 59912899a4
406 changed files with 31447 additions and 10449 deletions

View File

@ -1,4 +1,4 @@
# Available providers: local, bigcommerce, shopify, swell, saleor
# Available providers: local, bigcommerce, shopify, swell, saleor, spree, ordercloud, vendure, kibocommerce, commercejs
COMMERCE_PROVIDER=
BIGCOMMERCE_STOREFRONT_API_URL=
@ -27,3 +27,13 @@ NEXT_PUBLIC_VENDURE_LOCAL_URL=
ORDERCLOUD_CLIENT_ID=
ORDERCLOUD_CLIENT_SECRET=
STRIPE_SECRET=
KIBO_API_URL=
KIBO_CLIENT_ID=
KIBO_SHARED_SECRET=
KIBO_CART_COOKIE=
KIBO_CUSTOMER_COOKIE=
KIBO_API_HOST=
NEXT_PUBLIC_COMMERCEJS_PUBLIC_KEY=
NEXT_PUBLIC_COMMERCEJS_DEPLOYMENT_URL=

View File

@ -1,3 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode"]
"recommendations": ["esbenp.prettier-vscode", "csstools.postcss", "bradlc.vscode-tailwindcss"]
}

View File

@ -12,6 +12,10 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/)
- BigCommerce Demo: https://bigcommerce.vercel.store/
- Vendure Demo: https://vendure.vercel.store
- Saleor Demo: https://saleor.vercel.store/
- Ordercloud Demo: https://ordercloud.vercel.store/
- Spree Demo: https://spree.vercel.store/
- Kibo Commerce Demo: https://kibocommerce.vercel.store/
- Commerce.js Demo: https://commercejs.vercel.store/
## Features
@ -27,7 +31,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/)
## Integrations
Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor and Vendure. We plan to support all major ecommerce backends.
Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor, Vendure, Spree and Commerce.js. We plan to support all major ecommerce backends.
## Considerations
@ -94,6 +98,8 @@ For example: Turning `cart` off will disable Cart capabilities.
### How to create a new provider
🔔 New providers are on hold [until we have a new API for commerce](https://github.com/vercel/commerce/pull/252) 🔔
Follow our docs for [Adding a new Commerce Provider](framework/commerce/new-provider.md).
If you succeeded building a provider, submit a PR with a valid demo and we'll review it asap.
@ -104,11 +110,10 @@ Our commitment to Open Source can be found [here](https://vercel.com/oss).
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
2. Create a new branch `git checkout -b MY_BRANCH_NAME`
3. Install yarn: `npm install -g yarn`
4. Install the dependencies: `yarn`
5. Duplicate `.env.template` and rename it to `.env.local`
6. Add proper store values to `.env.local`
7. Run `yarn dev` to build and watch for code changes
3. Install the dependencies: `npm i`
4. Duplicate `.env.template` and rename it to `.env.local`
5. Add proper store values to `.env.local`
6. Run `npm run dev` to build and watch for code changes
## Work in progress

View File

@ -3,7 +3,6 @@ import cn from 'classnames'
import Image from 'next/image'
import Link from 'next/link'
import s from './CartItem.module.css'
import { Trash, Plus, Minus, Cross } from '@components/icons'
import { useUI } from '@components/ui/context'
import type { LineItem } from '@commerce/types/cart'
import usePrice from '@framework/product/use-price'
@ -18,6 +17,8 @@ type ItemOption = {
valueId: number
}
const placeholderImg = '/product-img-placeholder.svg'
const CartItem = ({
item,
variant = 'default',
@ -91,8 +92,8 @@ const CartItem = ({
className={s.productImage}
width={150}
height={150}
src={item.variant.image!.url}
alt={item.variant.image!.altText}
src={item.variant.image?.url || placeholderImg}
alt={item.variant.image?.altText || "Product Image"}
unoptimized
/>
</a>

View File

@ -1,5 +1,5 @@
import Link from 'next/link'
import { FC } from 'react'
import { FC, useState } from 'react'
import CartItem from '@components/cart/CartItem'
import { Button, Text } from '@components/ui'
import { useUI } from '@components/ui/context'
@ -10,18 +10,29 @@ import useCheckout from '@framework/checkout/use-checkout'
import ShippingWidget from '../ShippingWidget'
import PaymentWidget from '../PaymentWidget'
import s from './CheckoutSidebarView.module.css'
import { useCheckoutContext } from '../context'
const CheckoutSidebarView: FC = () => {
const [loadingSubmit, setLoadingSubmit] = useState(false)
const { setSidebarView, closeSidebar } = useUI()
const { data: cartData } = useCart()
const { data: cartData, revalidate: refreshCart } = useCart()
const { data: checkoutData, submit: onCheckout } = useCheckout()
const { clearCheckoutFields } = useCheckoutContext()
async function handleSubmit(event: React.ChangeEvent<HTMLFormElement>) {
event.preventDefault()
try {
setLoadingSubmit(true)
event.preventDefault()
await onCheckout()
closeSidebar()
await onCheckout()
clearCheckoutFields()
setLoadingSubmit(false)
refreshCart()
closeSidebar()
} catch {
// TODO - handle error UI here.
setLoadingSubmit(false)
}
}
const { price: subTotal } = usePrice(
@ -93,11 +104,12 @@ const CheckoutSidebarView: FC = () => {
<span>{total}</span>
</div>
<div>
{/* Once data is correcly filled */}
{/* Once data is correctly filled */}
<Button
type="submit"
width="100%"
disabled={!checkoutData?.hasPayment || !checkoutData?.hasShipping}
loading={loadingSubmit}
>
Confirm Purchase
</Button>

View File

@ -22,7 +22,7 @@ interface Form extends HTMLFormElement {
country: HTMLSelectElement
}
const PaymentMethodView: FC = () => {
const ShippingView: FC = () => {
const { setSidebarView } = useUI()
const addAddress = useAddAddress()
@ -115,4 +115,4 @@ const PaymentMethodView: FC = () => {
)
}
export default PaymentMethodView
export default ShippingView

View File

@ -0,0 +1,111 @@
import React, {
FC,
useCallback,
useMemo,
useReducer,
useContext,
createContext,
} from 'react'
import type { CardFields } from '@commerce/types/customer/card'
import type { AddressFields } from '@commerce/types/customer/address'
export type State = {
cardFields: CardFields
addressFields: AddressFields
}
type CheckoutContextType = State & {
setCardFields: (cardFields: CardFields) => void
setAddressFields: (addressFields: AddressFields) => void
clearCheckoutFields: () => void
}
type Action =
| {
type: 'SET_CARD_FIELDS'
card: CardFields
}
| {
type: 'SET_ADDRESS_FIELDS'
address: AddressFields
}
| {
type: 'CLEAR_CHECKOUT_FIELDS'
}
const initialState: State = {
cardFields: {} as CardFields,
addressFields: {} as AddressFields,
}
export const CheckoutContext = createContext<State | any>(initialState)
CheckoutContext.displayName = 'CheckoutContext'
const checkoutReducer = (state: State, action: Action): State => {
switch (action.type) {
case 'SET_CARD_FIELDS':
return {
...state,
cardFields: action.card,
}
case 'SET_ADDRESS_FIELDS':
return {
...state,
addressFields: action.address,
}
case 'CLEAR_CHECKOUT_FIELDS':
return {
...state,
cardFields: initialState.cardFields,
addressFields: initialState.addressFields,
}
default:
return state
}
}
export const CheckoutProvider: FC = (props) => {
const [state, dispatch] = useReducer(checkoutReducer, initialState)
const setCardFields = useCallback(
(card: CardFields) => dispatch({ type: 'SET_CARD_FIELDS', card }),
[dispatch]
)
const setAddressFields = useCallback(
(address: AddressFields) =>
dispatch({ type: 'SET_ADDRESS_FIELDS', address }),
[dispatch]
)
const clearCheckoutFields = useCallback(
() => dispatch({ type: 'CLEAR_CHECKOUT_FIELDS' }),
[dispatch]
)
const cardFields = useMemo(() => state.cardFields, [state.cardFields])
const addressFields = useMemo(() => state.addressFields, [state.addressFields])
const value = useMemo(
() => ({
cardFields,
addressFields,
setCardFields,
setAddressFields,
clearCheckoutFields,
}),
[cardFields, addressFields, setCardFields, setAddressFields, clearCheckoutFields]
)
return <CheckoutContext.Provider value={value} {...props} />
}
export const useCheckoutContext = () => {
const context = useContext<CheckoutContextType>(CheckoutContext)
if (context === undefined) {
throw new Error(`useCheckoutContext must be used within a CheckoutProvider`)
}
return context
}

View File

@ -10,9 +10,11 @@ import type { Category } from '@commerce/types/site'
import ShippingView from '@components/checkout/ShippingView'
import CartSidebarView from '@components/cart/CartSidebarView'
import { useAcceptCookies } from '@lib/hooks/useAcceptCookies'
import { Sidebar, Button, Modal, LoadingDots } from '@components/ui'
import { Sidebar, Button, LoadingDots } from '@components/ui'
import PaymentMethodView from '@components/checkout/PaymentMethodView'
import CheckoutSidebarView from '@components/checkout/CheckoutSidebarView'
import { CheckoutProvider } from '@components/checkout/context'
import MenuSidebarView, { Link } from '../UserNav/MenuSidebarView'
import LoginView from '@components/auth/LoginView'
import s from './Layout.module.css'
@ -29,17 +31,31 @@ const dynamicProps = {
const SignUpView = dynamic(
() => import('@components/auth/SignUpView'),
dynamicProps
{
...dynamicProps
}
)
const ForgotPassword = dynamic(
() => import('@components/auth/ForgotPassword'),
dynamicProps
{
...dynamicProps
}
)
const FeatureBar = dynamic(
() => import('@components/common/FeatureBar'),
dynamicProps
{
...dynamicProps
}
)
const Modal = dynamic(
() => import('@components/ui/Modal'),
{
...dynamicProps,
ssr: false
}
)
interface Props {
@ -69,12 +85,14 @@ const ModalUI: FC = () => {
) : null
}
const SidebarView: FC<{ sidebarView: string; closeSidebar(): any }> = ({
sidebarView,
closeSidebar,
}) => {
const SidebarView: FC<{
sidebarView: string
closeSidebar(): any
links: Link[]
}> = ({ sidebarView, closeSidebar, links }) => {
return (
<Sidebar onClose={closeSidebar}>
{sidebarView === 'MOBILEMENU_VIEW' && <MenuSidebarView links={links} />}
{sidebarView === 'CART_VIEW' && <CartSidebarView />}
{sidebarView === 'CHECKOUT_VIEW' && <CheckoutSidebarView />}
{sidebarView === 'PAYMENT_VIEW' && <PaymentMethodView />}
@ -86,10 +104,14 @@ const SidebarView: FC<{ sidebarView: string; closeSidebar(): any }> = ({
)
}
const SidebarUI: FC = () => {
const SidebarUI: FC<{ links: any }> = ({ links }) => {
const { displaySidebar, closeSidebar, sidebarView } = useUI()
return displaySidebar ? (
<SidebarView sidebarView={sidebarView} closeSidebar={closeSidebar} />
<SidebarView
sidebarView={sidebarView}
closeSidebar={closeSidebar}
links={links}
/>
) : null
}
@ -111,7 +133,9 @@ const Layout: FC<Props> = ({
<main className="fit">{children}</main>
<Footer pages={pageProps.pages} />
<ModalUI />
<SidebarUI />
<CheckoutProvider>
<SidebarUI links={navBarlinks} />
</CheckoutProvider>
<FeatureBar
title="This site uses cookies to improve your experience. By clicking, you agree to our Privacy Policy."
hide={acceptedCookies}

View File

@ -9,6 +9,7 @@ interface Link {
href: string
label: string
}
interface NavbarProps {
links?: Link[]
}
@ -43,9 +44,11 @@ const Navbar: FC<NavbarProps> = ({ links }) => (
<UserNav />
</div>
</div>
<div className="flex pb-4 lg:px-6 lg:hidden">
<Searchbar id="mobile-search" />
</div>
{process.env.COMMERCE_SEARCH_ENABLED && (
<div className="flex pb-4 lg:px-6 lg:hidden">
<Searchbar id="mobile-search" />
</div>
)}
</Container>
</NavbarRoot>
)

View File

@ -22,7 +22,7 @@ const SidebarLayout: FC<ComponentProps> = ({
<button
onClick={handleClose}
aria-label="Close"
className="hover:text-accent-5 transition ease-in-out duration-150 flex items-center focus:outline-none"
className="hover:text-accent-5 transition ease-in-out duration-150 flex items-center focus:outline-none mr-6"
>
<Cross className="h-6 w-6 hover:text-accent-3" />
<span className="ml-2 text-accent-7 text-sm ">Close</span>

View File

@ -0,0 +1,7 @@
.root {
@apply px-4 sm:px-6 sm:w-full flex-1 z-20;
}
.item {
@apply text-2xl font-bold;
}

View File

@ -0,0 +1,41 @@
import Link from 'next/link'
import s from './MenuSidebarView.module.css'
import { FC } from 'react'
import { useUI } from '@components/ui/context'
import SidebarLayout from '@components/common/SidebarLayout'
import { Link as LinkProps} from '.'
interface MenuProps {
links?: LinkProps[]
}
const MenuSidebarView: FC<MenuProps> = (props) => {
const { closeSidebar } = useUI()
const handleClose = () => closeSidebar()
return (
<SidebarLayout handleClose={handleClose}>
<div className={s.root}>
<nav>
<ul>
<li className={s.item}>
<Link href="/search">
<a>All</a>
</Link>
</li>
{props.links?.map((l: any) => (
<li key={l.href} className={s.item}>
<Link href={l.href}>
<a>{l.label}</a>
</Link>
</li>
))}
</ul>
</nav>
</div>
</SidebarLayout>
)
}
export default MenuSidebarView

View File

@ -0,0 +1,6 @@
export { default } from './MenuSidebarView'
export interface Link {
href: string
label: string
}

View File

@ -1,5 +1,5 @@
.root {
@apply relative;
@apply relative flex items-center;
}
.list {
@ -7,14 +7,14 @@
}
.item {
@apply mr-6 cursor-pointer relative transition ease-in-out duration-100 flex items-center outline-none text-primary;
@apply ml-6 cursor-pointer relative transition ease-in-out duration-100 flex items-center outline-none text-primary;
&:hover {
@apply text-accent-6 transition scale-110 duration-100;
}
&:last-child {
@apply mr-0;
&:first-child {
@apply ml-0;
}
&:focus,
@ -35,6 +35,11 @@
@apply inline-flex justify-center rounded-full;
}
.avatarButton:focus {
@apply outline-none;
.mobileMenu {
@apply flex lg:hidden ml-6
}
.avatarButton:focus,
.mobileMenu:focus {
@apply outline-none;
}

View File

@ -10,6 +10,7 @@ import { useUI } from '@components/ui/context'
import Button from '@components/ui/Button'
import DropdownMenu from './DropdownMenu'
import s from './UserNav.module.css'
import Menu from '@components/icons/Menu'
interface Props {
className?: string
@ -20,7 +21,8 @@ const countItem = (count: number, item: LineItem) => count + item.quantity
const UserNav: FC<Props> = ({ className }) => {
const { data } = useCart()
const { data: customer } = useCustomer()
const { toggleSidebar, closeSidebarIfPresent, openModal } = useUI()
const { toggleSidebar, closeSidebarIfPresent, openModal, setSidebarView } =
useUI()
const itemsCount = data?.lineItems.reduce(countItem, 0) ?? 0
return (
@ -28,9 +30,19 @@ const UserNav: FC<Props> = ({ className }) => {
<ul className={s.list}>
{process.env.COMMERCE_CART_ENABLED && (
<li className={s.item}>
<Button className={s.item} variant="naked" onClick={toggleSidebar} aria-label="Cart">
<Button
className={s.item}
variant="naked"
onClick={() => {
setSidebarView('CART_VIEW')
toggleSidebar()
}}
aria-label={`Cart items: ${itemsCount}`}
>
<Bag />
{itemsCount > 0 && <span className={s.bagCount}>{itemsCount}</span>}
{itemsCount > 0 && (
<span className={s.bagCount}>{itemsCount}</span>
)}
</Button>
</li>
)}
@ -58,6 +70,19 @@ const UserNav: FC<Props> = ({ className }) => {
)}
</li>
)}
<li className={s.mobileMenu}>
<Button
className={s.item}
variant="naked"
onClick={() => {
setSidebarView('MOBILEMENU_VIEW')
toggleSidebar()
}}
aria-label="Menu"
>
<Menu />
</Button>
</li>
</ul>
</nav>
)

View File

@ -1,4 +1,4 @@
const ChevronUp = ({ ...props }) => {
const ChevronRight = ({ ...props }) => {
return (
<svg
viewBox="0 0 24 24"
@ -17,4 +17,4 @@ const ChevronUp = ({ ...props }) => {
)
}
export default ChevronUp
export default ChevronRight

21
components/icons/Menu.tsx Normal file
View File

@ -0,0 +1,21 @@
const Menu = ({ ...props }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16m-7 6h7"
/>
</svg>
)
}
export default Menu

View File

@ -39,22 +39,24 @@ const ProductCard: FC<Props> = ({
return (
<Link href={`/product/${product.slug}`}>
<a className={rootClassName}>
<a className={rootClassName} aria-label={product.name}>
{variant === 'slim' && (
<>
<div className={s.header}>
<span>{product.name}</span>
</div>
{product?.images && (
<Image
quality="85"
src={product.images[0]?.url || placeholderImg}
alt={product.name || 'Product Image'}
height={320}
width={320}
layout="fixed"
{...imgProps}
/>
<div>
<Image
quality="85"
src={product.images[0]?.url || placeholderImg}
alt={product.name || 'Product Image'}
height={320}
width={320}
layout="fixed"
{...imgProps}
/>
</div>
)}
</>
)}
@ -80,16 +82,18 @@ const ProductCard: FC<Props> = ({
)}
<div className={s.imageContainer}>
{product?.images && (
<Image
alt={product.name || 'Product Image'}
className={s.productImage}
src={product.images[0]?.url || placeholderImg}
height={540}
width={540}
quality="85"
layout="responsive"
{...imgProps}
/>
<div>
<Image
alt={product.name || 'Product Image'}
className={s.productImage}
src={product.images[0]?.url || placeholderImg}
height={540}
width={540}
quality="85"
layout="responsive"
{...imgProps}
/>
</div>
)}
</div>
</>
@ -110,16 +114,18 @@ const ProductCard: FC<Props> = ({
/>
<div className={s.imageContainer}>
{product?.images && (
<Image
alt={product.name || 'Product Image'}
className={s.productImage}
src={product.images[0]?.url || placeholderImg}
height={540}
width={540}
quality="85"
layout="responsive"
{...imgProps}
/>
<div>
<Image
alt={product.name || 'Product Image'}
className={s.productImage}
src={product.images[0]?.url || placeholderImg}
height={540}
width={540}
quality="85"
layout="responsive"
{...imgProps}
/>
</div>
)}
</div>
</>

View File

@ -21,7 +21,7 @@ const ProductOptions: React.FC<ProductOptionsProps> = ({
<h2 className="uppercase font-medium text-sm tracking-wide">
{opt.displayName}
</h2>
<div className="flex flex-row py-4">
<div role="listbox" className="flex flex-row py-4">
{opt.values.map((v, i: number) => {
const active = selectedOptions[opt.displayName.toLowerCase()]
return (

View File

@ -31,7 +31,7 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
try {
await addItem({
productId: String(product.id),
variantId: String(variant ? variant.id : product.variants[0].id),
variantId: String(variant ? variant.id : product.variants[0]?.id),
})
openSidebar()
setLoading(false)

View File

@ -13,9 +13,7 @@
}
.thumb {
@apply transition-transform transition-colors
ease-linear duration-75 overflow-hidden inline-block
cursor-pointer h-full;
@apply overflow-hidden inline-block cursor-pointer h-full;
width: 125px;
width: calc(100% / 3);
}
@ -48,11 +46,6 @@
@screen md {
.thumb:hover {
transform: scale(1.02);
background-color: rgba(255, 255, 255, 0.08);
}
.thumb.selected {
@apply bg-white;
}
.album {

View File

@ -1,7 +1,6 @@
import { useKeenSlider } from 'keen-slider/react'
import React, {
Children,
FC,
isValidElement,
useState,
useRef,
@ -28,16 +27,14 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
const [ref, slider] = useKeenSlider<HTMLDivElement>({
loop: true,
slidesPerView: 1,
mounted: () => setIsMounted(true),
slides: { perView: 1 },
created: () => setIsMounted(true),
slideChanged(s) {
const slideNumber = s.details().relativeSlide
const slideNumber = s.track.details.rel
setCurrentSlide(slideNumber)
if (thumbsContainerRef.current) {
const $el = document.getElementById(
`thumb-${s.details().relativeSlide}`
)
const $el = document.getElementById(`thumb-${slideNumber}`)
if (slideNumber >= 3) {
thumbsContainerRef.current.scrollLeft = $el!.offsetLeft
} else {
@ -77,8 +74,8 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
}
}, [])
const onPrev = React.useCallback(() => slider.prev(), [slider])
const onNext = React.useCallback(() => slider.next(), [slider])
const onPrev = React.useCallback(() => slider.current?.prev(), [slider])
const onNext = React.useCallback(() => slider.current?.next(), [slider])
return (
<div className={cn(s.root, className)} ref={sliderContainerRef}>
@ -117,7 +114,7 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
}),
id: `thumb-${idx}`,
onClick: () => {
slider.moveToSlideRelative(idx)
slider.current?.moveToIdx(idx)
},
},
}

View File

@ -17,16 +17,15 @@
}
.imageContainer {
@apply text-center;
@apply text-center h-full relative;
}
.imageContainer > div,
.imageContainer > div > div {
@apply h-full;
.imageContainer > span {
height: 100% !important;
}
.sliderContainer .img {
@apply w-full h-auto max-h-full object-cover;
@apply w-full h-full max-h-full object-cover;
}
.button {

View File

@ -58,7 +58,11 @@ const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
)}
</div>
<ProductSidebar key={product.id} product={product} className={s.sidebar} />
<ProductSidebar
key={product.id}
product={product}
className={s.sidebar}
/>
</div>
<hr className="mt-7 border-accent-2" />
<section className="py-12 px-6 mb-10">

View File

@ -42,7 +42,9 @@ const Swatch: React.FC<Omit<ButtonProps, 'variant'> & SwatchProps> = React.memo(
return (
<Button
aria-label="Variant Swatch"
role="option"
aria-selected={active}
aria-label={variant && label ? `${variant} ${label}` : 'Variant Swatch'}
className={swatchClassName}
{...(label && color && { title: label })}
style={color ? { backgroundColor: color } : {}}

View File

@ -23,7 +23,7 @@ export function selectDefaultOptionFromProduct(
updater: Dispatch<SetStateAction<SelectedOptions>>
) {
// Selects the default option
product.variants[0].options?.forEach((v) => {
product.variants[0]?.options?.forEach((v) => {
updater((choices) => ({
...choices,
[v.displayName.toLowerCase()]: v.values[0].label.toLowerCase(),

View File

@ -2,11 +2,8 @@ import { FC, useRef, useEffect, useCallback } from 'react'
import s from './Modal.module.css'
import FocusTrap from '@lib/focus-trap'
import { Cross } from '@components/icons'
import {
disableBodyScroll,
clearAllBodyScrollLocks,
enableBodyScroll,
} from 'body-scroll-lock'
import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock'
interface ModalProps {
className?: string
children?: any
@ -34,9 +31,6 @@ const Modal: FC<ModalProps> = ({ children, onClose }) => {
window.addEventListener('keydown', handleKey)
}
return () => {
if (modal) {
enableBodyScroll(modal)
}
clearAllBodyScrollLocks()
window.removeEventListener('keydown', handleKey)
}

View File

@ -1,11 +1,7 @@
import { FC, useEffect, useRef } from 'react'
import s from './Sidebar.module.css'
import cn from 'classnames'
import {
disableBodyScroll,
enableBodyScroll,
clearAllBodyScrollLocks,
} from 'body-scroll-lock'
import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock'
interface SidebarProps {
children: any
@ -28,13 +24,12 @@ const Sidebar: FC<SidebarProps> = ({ children, onClose }) => {
}
const contentElement = contentRef.current
if (contentElement) {
disableBodyScroll(contentElement, { reserveScrollBarGap: true })
}
return () => {
if (contentElement) enableBodyScroll(contentElement)
clearAllBodyScrollLocks()
}
}, [])
@ -48,7 +43,7 @@ const Sidebar: FC<SidebarProps> = ({ children, onClose }) => {
>
<div className="absolute inset-0 overflow-hidden">
<div className={s.backdrop} onClick={onClose} />
<section className="absolute inset-y-0 right-0 max-w-full flex outline-none pl-10">
<section className="absolute inset-y-0 right-0 w-full md:w-auto max-w-full flex outline-none md:pl-10">
<div className="h-full w-full md:w-screen md:max-w-md">
<div className={s.sidebar} ref={contentRef}>
{children}

View File

@ -14,7 +14,6 @@
@apply pt-1 pb-2 text-2xl font-bold tracking-wide cursor-pointer mb-2;
}
/* Apply base font sizes and styles for typography markup (h2, h2, ul, p, etc.).
A helpful addition for whenn page content is consumed from a source managed through a wysiwyg editor. */

View File

@ -11,14 +11,16 @@ 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'
import { Wishlist } from '@commerce/types/wishlist'
interface Props {
product: Product
item: Wishlist
}
const placeholderImg = '/product-img-placeholder.svg'
const WishlistCard: FC<Props> = ({ product }) => {
const WishlistCard: FC<Props> = ({ item }) => {
const product: Product = item.product
const { price } = usePrice({
amount: product.price?.value,
baseAmount: product.price?.retailPrice,
@ -40,7 +42,7 @@ const WishlistCard: FC<Props> = ({ product }) => {
try {
// If this action succeeds then there's no need to do `setRemoving(true)`
// because the component will be removed from the view
await removeItem({ id: product.id! })
await removeItem({ id: item.id! })
} catch (error) {
setRemoving(false)
}
@ -62,12 +64,14 @@ const WishlistCard: FC<Props> = ({ product }) => {
return (
<div className={cn(s.root, { 'opacity-75 pointer-events-none': removing })}>
<div className={`col-span-3 ${s.productBg}`}>
<Image
src={product.images[0]?.url || placeholderImg}
width={400}
height={400}
alt={product.images[0]?.alt || 'Product Image'}
/>
<div>
<Image
src={product.images[0]?.url || placeholderImg}
width={400}
height={400}
alt={product.images[0]?.alt || 'Product Image'}
/>
</div>
</div>
<div className="col-span-7">

View File

@ -27,11 +27,11 @@ const addItem: CartEndpoint['handlers']['addItem'] = async ({
}
const { data } = cartId
? await config.storeApiFetch(
`/v3/carts/${cartId}/items?include=line_items.physical_items.options`,
`/v3/carts/${cartId}/items?include=line_items.physical_items.options,line_items.digital_items.options`,
options
)
: await config.storeApiFetch(
'/v3/carts?include=line_items.physical_items.options',
'/v3/carts?include=line_items.physical_items.options,line_items.digital_items.options',
options
)

View File

@ -15,7 +15,7 @@ const getCart: CartEndpoint['handlers']['getCart'] = async ({
if (cartId) {
try {
result = await config.storeApiFetch(
`/v3/carts/${cartId}?include=line_items.physical_items.options`
`/v3/carts/${cartId}?include=line_items.physical_items.options,line_items.digital_items.options`
)
} catch (error) {
if (error instanceof BigcommerceApiError && error.status === 404) {

View File

@ -42,7 +42,7 @@ const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({
store_hash: config.storeHash,
customer_id: customerId,
channel_id: config.storeChannelId,
redirect_to: data.checkout_url,
redirect_to: data.checkout_url.replace(config.storeUrl, ""),
}
let token = jwt.sign(payload, config.storeApiClientSecret!, {
algorithm: 'HS256',

View File

@ -24,36 +24,33 @@ export const getLoggedInCustomerQuery = /* GraphQL */ `
export type Customer = NonNullable<GetLoggedInCustomerQuery['customer']>
const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] = async ({
req,
res,
config,
}) => {
const token = req.cookies[config.customerCookie]
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}`,
},
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' }],
})
}
)
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 } })
}
return res.status(200).json({ data: { customer } })
res.status(200).json({ data: null })
}
res.status(200).json({ data: null })
}
export default getLoggedInCustomer

View File

@ -34,7 +34,7 @@ export interface BigcommerceConfig extends CommerceAPIConfig {
storeChannelId?: string
storeUrl?: string
storeApiClientSecret?: string
storeHash?:string
storeHash?: string
storeApiFetch<T>(endpoint: string, options?: RequestInit): Promise<T>
}
@ -81,8 +81,8 @@ const config: BigcommerceConfig = {
storeApiToken: STORE_API_TOKEN,
storeApiClientId: STORE_API_CLIENT_ID,
storeChannelId: STORE_CHANNEL_ID,
storeUrl:STORE_URL,
storeApiClientSecret:CLIENT_SECRET,
storeUrl: STORE_URL,
storeApiClientSecret: CLIENT_SECRET,
storeHash: STOREFRONT_HASH,
storeApiFetch: createFetchStoreApi(() => getCommerceApi().getConfig()),
}

View File

@ -1,11 +1,11 @@
import type { RequestInit, Response } from '@vercel/fetch'
import type { FetchOptions, Response } from '@vercel/fetch'
import type { BigcommerceConfig } from '../index'
import { BigcommerceApiError, BigcommerceNetworkError } from './errors'
import fetch from './fetch'
const fetchStoreApi =
<T>(getConfig: () => BigcommerceConfig) =>
async (endpoint: string, options?: RequestInit): Promise<T> => {
async (endpoint: string, options?: FetchOptions): Promise<T> => {
const config = getConfig()
let res: Response
@ -19,7 +19,7 @@ const fetchStoreApi =
'X-Auth-Client': config.storeApiClientId,
},
})
} catch (error) {
} catch (error: any) {
throw new BigcommerceNetworkError(
`Fetch to Bigcommerce failed: ${error.message}`
)

View File

@ -1,3 +1,3 @@
import zeitFetch from '@vercel/fetch'
import vercelFetch from '@vercel/fetch'
export default zeitFetch()
export default vercelFetch()

View File

@ -15,8 +15,7 @@ export const handler: MutationHook<LoginHook> = {
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message:
'An email and password are required to login',
message: 'An email and password are required to login',
})
}
@ -25,16 +24,18 @@ export const handler: MutationHook<LoginHook> = {
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

@ -11,16 +11,18 @@ export const handler: MutationHook<LogoutHook> = {
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

@ -29,16 +29,18 @@ export const handler: MutationHook<SignupHook> = {
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

@ -29,16 +29,18 @@ export const handler: MutationHook<AddItemHook> = {
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

@ -10,22 +10,24 @@ export const handler: SWRHook<GetCartHook> = {
url: '/api/cart',
method: 'GET',
},
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

@ -30,27 +30,25 @@ export const handler = {
}: HookFetcherContext<RemoveItemHook>) {
return await fetch({ ...options, body: { itemId } })
},
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
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

@ -46,39 +46,39 @@ export const handler = {
body: { itemId, item },
})
},
useHook: ({ fetch }: MutationHookContext<UpdateItemHook>) => <
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: UpdateItemActionInput<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

@ -13,12 +13,14 @@ export const handler: SWRHook<CustomerHook> = {
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

@ -8,11 +8,7 @@ import getSlug from './get-slug'
function normalizeProductOption(productOption: any) {
const {
node: {
entityId,
values: { edges = [] } = {},
...rest
},
node: { entityId, values: { edges = [] } = {}, ...rest },
} = productOption
return {
@ -122,6 +118,7 @@ function normalizeLineItem(item: any): LineItem {
price: item.sale_price,
listPrice: item.list_price,
},
options: item.options,
path: item.url.split('/')[3],
discounts: item.discounts.map((discount: any) => ({
value: discount.discounted_amount,

View File

@ -33,18 +33,20 @@ export const handler: SWRHook<SearchProductsHook> = {
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

@ -20,4 +20,5 @@ export type WishlistTypes = {
}
export type WishlistSchema = Core.WishlistSchema<WishlistTypes>
export type GetCustomerWishlistOperation = Core.GetCustomerWishlistOperation<WishlistTypes>
export type GetCustomerWishlistOperation =
Core.GetCustomerWishlistOperation<WishlistTypes>

View File

@ -13,25 +13,27 @@ export const handler: MutationHook<AddItemHook> = {
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

@ -15,24 +15,26 @@ export const handler: MutationHook<RemoveItemHook> = {
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

@ -24,30 +24,32 @@ export const handler: SWRHook<GetWishlistHook> = {
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

@ -3,60 +3,58 @@ 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
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
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 }] })
}
}
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

@ -12,6 +12,7 @@ const loginEndpoint: GetAPISchema<
if (
!isAllowedOperation(req, res, {
POST: handlers['login'],
GET: handlers['login'],
})
) {
return

View File

@ -3,35 +3,33 @@ 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
const logoutEndpoint: GetAPISchema<any, LogoutSchema>['endpoint']['handler'] =
async (ctx) => {
const { req, res, handlers } = ctx
if (
!isAllowedOperation(req, res, {
GET: handlers['logout'],
})
) {
return
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 }] })
}
}
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

View File

@ -3,36 +3,34 @@ import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..'
const signupEndpoint: GetAPISchema<
any,
SignupSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers, config } = ctx
const signupEndpoint: GetAPISchema<any, SignupSchema>['endpoint']['handler'] =
async (ctx) => {
const { req, res, handlers, config } = ctx
if (
!isAllowedOperation(req, res, {
POST: handlers['signup'],
})
) {
return
if (
!isAllowedOperation(req, res, {
POST: handlers['signup'],
})
) {
return
}
const { cookies } = req
const cartId = cookies[config.cartCookie]
try {
const body = { ...req.body, cartId }
return await handlers['signup']({ ...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 }] })
}
}
const { cookies } = req
const cartId = cookies[config.cartCookie]
try {
const body = { ...req.body, cartId }
return await handlers['signup']({ ...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 signupEndpoint

View File

@ -1,5 +1,5 @@
import type { NextApiHandler } from 'next'
import type { RequestInit, Response } from '@vercel/fetch'
import type { FetchOptions, Response } from '@vercel/fetch'
import type { APIEndpoint, APIHandler } from './utils/types'
import type { CartSchema } from '../types/cart'
import type { CustomerSchema } from '../types/customer'
@ -160,7 +160,7 @@ export interface CommerceAPIConfig {
fetch<Data = any, Variables = any>(
query: string,
queryData?: CommerceAPIFetchOptions<Variables>,
fetchOptions?: RequestInit
fetchOptions?: FetchOptions
): Promise<GraphQLFetcherResult<Data>>
}
@ -170,7 +170,7 @@ export type GraphQLFetcher<
> = (
query: string,
queryData?: CommerceAPIFetchOptions<Variables>,
fetchOptions?: RequestInit
fetchOptions?: FetchOptions
) => Promise<Data>
export interface GraphQLFetcherResult<Data = any> {

View File

@ -16,7 +16,10 @@ const PROVIDERS = [
'vendure',
'local',
'elasticpath',
'ordercloud'
'ordercloud',
'kibocommerce',
'spree',
'commercejs',
]
function getProviderName() {

View File

@ -1,10 +1,19 @@
# Adding a new Commerce Provider
🔔 New providers are on hold [until we have a new API for commerce](https://github.com/vercel/commerce/pull/252) 🔔
A commerce provider is a headless e-commerce platform that integrates with the [Commerce Framework](./README.md). Right now we have the following providers:
- BigCommerce ([framework/bigcommerce](../bigcommerce))
- Saleor ([framework/saleor](../saleor))
- Local ([framework/local](../local))
- Shopify ([framework/shopify](../shopify))
- Swell ([framework/swell](../swell))
- BigCommerce ([framework/bigcommerce](../bigcommerce))
- Vendure ([framework/vendure](../vendure))
- Saleor ([framework/saleor](../saleor))
- OrderCloud ([framework/ordercloud](../ordercloud))
- Spree ([framework/spree](../spree))
- Kibo Commerce ([framework/kibocommerce](../kibocommerce))
- Commerce.js ([framework/commercejs](../commercejs))
Adding a commerce provider means adding a new folder in `framework` with a folder structure like the next one:
@ -244,6 +253,30 @@ export const handler: MutationHook<Cart, {}, CartItemBody> = {
}
```
## Showing progress and features
When creating a PR for a new provider, include this list in the PR description and mark the progress as you push so we can organize the code review. Not all points are required (but advised) so make sure to keep the list up to date.
**Status**
* [ ] CommerceProvider
* [ ] Schema & TS types
* [ ] API Operations - Get all collections
* [ ] API Operations - Get all pages
* [ ] API Operations - Get all products
* [ ] API Operations - Get page
* [ ] API Operations - Get product
* [ ] API Operations - Get Shop Info (categories and vendors working — `vendors` query still a WIP PR on Reaction)
* [ ] Hook - Add Item
* [ ] Hook - Remove Item
* [ ] Hook - Update Item
* [ ] Hook - Get Cart (account-tied carts working, anonymous carts working, cart reconciliation working)
* [ ] Auth (based on a WIP PR on Reaction - still need to implement refresh tokens)
* [ ] Customer information
* [ ] Product attributes - Size, Colors
* [ ] Custom checkout
* [ ] Typing (in progress)
* [ ] Tests
## Adding the Node.js provider API
TODO

View File

@ -1,13 +1,13 @@
import type { UseSubmitCheckout } from '../checkout/use-submit-checkout'
import type { Address } from './customer/address'
import type { Card } from './customer/card'
import type { Address, AddressFields } from './customer/address'
import type { Card, CardFields } from './customer/card'
// Index
export type Checkout = any
export type CheckoutTypes = {
card?: Card
address?: Address
card?: Card | CardFields
address?: Address | AddressFields
checkout?: Checkout
hasPayment?: boolean
hasShipping?: boolean

View File

@ -1,5 +1,5 @@
export * as Card from "./card"
export * as Address from "./address"
export * as Card from './card'
export * as Address from './address'
// TODO: define this type
export type Customer = any

View File

@ -77,12 +77,11 @@ export type ProductsSchema<T extends ProductTypes = ProductTypes> = {
}
}
export type GetAllProductPathsOperation<
T extends ProductTypes = ProductTypes
> = {
data: { products: Pick<T['product'], 'path'>[] }
variables: { first?: number }
}
export type GetAllProductPathsOperation<T extends ProductTypes = ProductTypes> =
{
data: { products: Pick<T['product'], 'path'>[] }
variables: { first?: number }
}
export type GetAllProductsOperation<T extends ProductTypes = ProductTypes> = {
data: { products: T['product'][] }

View File

@ -11,16 +11,18 @@ type InferValue<Prop extends PropertyKey, Desc> = Desc extends {
? Record<Prop, T>
: never
type DefineProperty<Prop extends PropertyKey, Desc extends PropertyDescriptor> =
Desc extends { writable: any; set(val: any): any }
? never
: Desc extends { writable: any; get(): any }
? never
: Desc extends { writable: false }
? Readonly<InferValue<Prop, Desc>>
: Desc extends { writable: true }
? InferValue<Prop, Desc>
: Readonly<InferValue<Prop, Desc>>
type DefineProperty<
Prop extends PropertyKey,
Desc extends PropertyDescriptor
> = Desc extends { writable: any; set(val: any): any }
? never
: Desc extends { writable: any; get(): any }
? never
: Desc extends { writable: false }
? Readonly<InferValue<Prop, Desc>>
: Desc extends { writable: true }
? InferValue<Prop, Desc>
: Readonly<InferValue<Prop, Desc>>
export default function defineProperty<
Obj extends object,

View File

@ -0,0 +1,7 @@
COMMERCE_PROVIDER=commercejs
# Public key for your Commerce.js account
NEXT_PUBLIC_COMMERCEJS_PUBLIC_KEY=
# The URL for the current deployment, optional but should be used for production deployments
NEXT_PUBLIC_COMMERCEJS_DEPLOYMENT_URL=

View File

@ -0,0 +1,13 @@
# [Commerce.js](https://commercejs.com/) Provider
**Demo:** https://commercejs.vercel.store/
To use this provider you must have a [Commerce.js account](https://commercejs.com/) and you should add some products in the Commerce.js dashboard.
Next, copy the `.env.template` file in this directory to `.env.local` in the main directory (which will be ignored by Git):
```bash
cp framework/commercejs/.env.template .env.local
```
Then, set the environment variables in `.env.local` to match the ones from your store. You'll need your Commerce.js public API key, which can be found in your Commerce.js dashboard in the `Developer -> API keys` section.

View File

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

View File

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

View File

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

View File

@ -0,0 +1,44 @@
import type { CardFields } from '@commerce/types/customer/card'
import type { AddressFields } from '@commerce/types/customer/address'
import type { CheckoutEndpoint } from '.'
import sdkFetcherFunction from '../../utils/sdk-fetch'
import { normalizeTestCheckout } from '../../../utils/normalize-checkout'
const submitCheckout: CheckoutEndpoint['handlers']['submitCheckout'] = async ({
res,
body: { item, cartId },
config: { sdkFetch },
}) => {
const sdkFetcher: typeof sdkFetcherFunction = sdkFetch
// Generate a checkout token
const { id: checkoutToken } = await sdkFetcher(
'checkout',
'generateTokenFrom',
'cart',
cartId
)
const shippingMethods = await sdkFetcher(
'checkout',
'getShippingOptions',
checkoutToken,
{
country: 'US',
}
)
const shippingMethodToUse = shippingMethods?.[0]?.id || ''
const checkoutData = normalizeTestCheckout({
paymentInfo: item?.card as CardFields,
shippingInfo: item?.address as AddressFields,
shippingOption: shippingMethodToUse,
})
// Capture the order
await sdkFetcher('checkout', 'capture', checkoutToken, checkoutData)
res.status(200).json({ data: null, errors: [] })
}
export default submitCheckout

View File

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

View File

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

View File

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

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 { CommercejsAPI } from '../..'
import login from './login'
export type LoginAPI = GetAPISchema<CommercejsAPI, LoginSchema>
export type LoginEndpoint = LoginAPI['endpoint']
export const handlers: LoginEndpoint['handlers'] = { login }
const loginApi = createEndpoint<LoginAPI>({
handler: loginEndpoint,
handlers,
})
export default loginApi

View File

@ -0,0 +1,33 @@
import { serialize } from 'cookie'
import sdkFetcherFunction from '../../utils/sdk-fetch'
import { getDeploymentUrl } from '../../../utils/get-deployment-url'
import type { LoginEndpoint } from '.'
const login: LoginEndpoint['handlers']['login'] = async ({
req,
res,
config: { sdkFetch, customerCookie },
}) => {
const sdkFetcher: typeof sdkFetcherFunction = sdkFetch
const redirectUrl = getDeploymentUrl()
try {
const loginToken = req.query?.token as string
if (!loginToken) {
res.redirect(redirectUrl)
}
const { jwt } = await sdkFetcher('customer', 'getToken', loginToken, false)
res.setHeader(
'Set-Cookie',
serialize(customerCookie, jwt, {
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24,
path: '/',
})
)
res.redirect(redirectUrl)
} catch {
res.redirect(redirectUrl)
}
}
export default login

View File

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

View File

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

View File

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

View File

@ -0,0 +1,46 @@
import type { CommerceAPI, CommerceAPIConfig } from '@commerce/api'
import { getCommerceApi as commerceApi } from '@commerce/api'
import getAllPages from './operations/get-all-pages'
import getPage from './operations/get-page'
import getSiteInfo from './operations/get-site-info'
import getAllProductPaths from './operations/get-all-product-paths'
import getAllProducts from './operations/get-all-products'
import getProduct from './operations/get-product'
import sdkFetch from './utils/sdk-fetch'
import createGraphqlFetcher from './utils/graphql-fetch'
import { API_URL, CART_COOKIE, CUSTOMER_COOKIE } from '../constants'
export interface CommercejsConfig extends CommerceAPIConfig {
sdkFetch: typeof sdkFetch
}
const config: CommercejsConfig = {
commerceUrl: API_URL,
cartCookie: CART_COOKIE,
cartCookieMaxAge: 2592000,
customerCookie: CUSTOMER_COOKIE,
apiToken: '',
fetch: createGraphqlFetcher(() => getCommerceApi().getConfig()),
sdkFetch,
}
const operations = {
getAllPages,
getPage,
getSiteInfo,
getAllProductPaths,
getAllProducts,
getProduct,
}
export const provider = { config, operations }
export type Provider = typeof provider
export type CommercejsAPI<P extends Provider = Provider> = CommerceAPI<P | any>
export function getCommerceApi<P extends Provider>(
customProvider: P = provider as any
): CommercejsAPI<P> {
return commerceApi(customProvider as any)
}

View File

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

View File

@ -0,0 +1,35 @@
import type { OperationContext } from '@commerce/api/operations'
import type {
GetAllProductPathsOperation,
CommercejsProduct,
} from '../../types/product'
import type { CommercejsConfig, Provider } from '..'
export type GetAllProductPathsResult = {
products: Array<{ path: string }>
}
export default function getAllProductPathsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProductPaths<T extends GetAllProductPathsOperation>({
config,
}: {
config?: Partial<CommercejsConfig>
} = {}): Promise<T['data']> {
const { sdkFetch } = commerce.getConfig(config)
const { data } = await sdkFetch('products', 'list')
// Match a path for every product retrieved
const productPaths = data.map(({ permalink }: CommercejsProduct) => ({
path: `/${permalink}`,
}))
return {
products: productPaths,
}
}
return getAllProductPaths
}

View File

@ -0,0 +1,29 @@
import type { OperationContext } from '@commerce/api/operations'
import type { GetAllProductsOperation } from '../../types/product'
import type { CommercejsConfig, Provider } from '../index'
import { normalizeProduct } from '../../utils/normalize-product'
export default function getAllProductsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProducts<T extends GetAllProductsOperation>({
config,
}: {
config?: Partial<CommercejsConfig>
} = {}): Promise<T['data']> {
const { sdkFetch } = commerce.getConfig(config)
const { data } = await sdkFetch('products', 'list', {
sortBy: 'sort_order',
})
const productsFormatted =
data?.map((product) => normalizeProduct(product)) || []
return {
products: productsFormatted,
}
}
return getAllProducts
}

View File

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

View File

@ -0,0 +1,44 @@
import type { OperationContext } from '@commerce/api/operations'
import type { GetProductOperation } from '../../types/product'
import type { CommercejsConfig, Provider } from '../index'
import { normalizeProduct } from '../../utils/normalize-product'
export default function getProductOperation({
commerce,
}: OperationContext<Provider>) {
async function getProduct<T extends GetProductOperation>({
config,
variables,
}: {
query?: string
variables?: T['variables']
config?: Partial<CommercejsConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const { sdkFetch } = commerce.getConfig(config)
// Fetch a product by its permalink.
const product = await sdkFetch(
'products',
'retrieve',
variables?.slug || '',
{
type: 'permalink',
}
)
const { data: variants } = await sdkFetch(
'products',
'getVariants',
product.id
)
const productFormatted = normalizeProduct(product, variants)
return {
product: productFormatted,
}
}
return getProduct
}

View File

@ -0,0 +1,36 @@
import type { OperationContext } from '@commerce/api/operations'
import type { Category, GetSiteInfoOperation } from '../../types/site'
import { normalizeCategory } from '../../utils/normalize-category'
import type { CommercejsConfig, Provider } from '../index'
export type GetSiteInfoResult<
T extends { categories: any[]; brands: any[] } = {
categories: Category[]
brands: any[]
}
> = T
export default function getSiteInfoOperation({
commerce,
}: OperationContext<Provider>) {
async function getSiteInfo<T extends GetSiteInfoOperation>({
config,
}: {
query?: string
variables?: any
config?: Partial<CommercejsConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const { sdkFetch } = commerce.getConfig(config)
const { data: categories } = await sdkFetch('categories', 'list')
const formattedCategories = categories.map(normalizeCategory)
return {
categories: formattedCategories,
brands: [],
}
}
return getSiteInfo
}

View File

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

View File

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

View File

@ -0,0 +1,19 @@
import { commerce } from '../../lib/commercejs'
import Commerce from '@chec/commerce.js'
type MethodKeys<T> = {
[K in keyof T]: T[K] extends (...args: any) => infer R ? K : never
}[keyof T]
// Calls the relevant Commerce.js SDK method based on resource and method arguments.
export default async function sdkFetch<
Resource extends keyof Commerce,
Method extends MethodKeys<Commerce[Resource]>
>(
resource: Resource,
method: Method,
...variables: Parameters<Commerce[Resource][Method]>
): Promise<ReturnType<Commerce[Resource][Method]>> {
const data = await commerce[resource][method](...variables)
return data
}

View File

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

View File

@ -0,0 +1,34 @@
import { useCallback } from 'react'
import { MutationHook } from '@commerce/utils/types'
import useLogin, { UseLogin } from '@commerce/auth/use-login'
import type { LoginHook } from '@commerce/types/login'
import { getDeploymentUrl } from '../utils/get-deployment-url'
export default useLogin as UseLogin<typeof handler>
const getLoginCallbackUrl = () => {
const baseUrl = getDeploymentUrl()
const API_ROUTE_PATH = 'api/login'
return `${baseUrl}/${API_ROUTE_PATH}`
}
export const handler: MutationHook<LoginHook> = {
fetchOptions: {
query: 'customer',
method: 'login',
},
async fetcher({ input, options: { query, method }, fetch }) {
await fetch({
query,
method,
variables: [input.email, getLoginCallbackUrl()],
})
return null
},
useHook: ({ fetch }) =>
function useHook() {
return useCallback(async function login(input) {
return fetch({ input })
}, [])
},
}

View File

@ -0,0 +1,27 @@
import { useCallback } from 'react'
import Cookies from 'js-cookie'
import { MutationHook } from '@commerce/utils/types'
import useLogout, { UseLogout } from '@commerce/auth/use-logout'
import type { LogoutHook } from '@commerce/types/logout'
import useCustomer from '../customer/use-customer'
import { CUSTOMER_COOKIE } from '../constants'
export default useLogout as UseLogout<typeof handler>
export const handler: MutationHook<LogoutHook> = {
fetchOptions: {
query: '_',
method: '_',
},
useHook: () => () => {
const { mutate } = useCustomer()
return useCallback(
async function logout() {
Cookies.remove(CUSTOMER_COOKIE)
await mutate(null, false)
return null
},
[mutate]
)
},
}

View File

@ -0,0 +1,17 @@
import { MutationHook } from '@commerce/utils/types'
import useSignup, { UseSignup } from '@commerce/auth/use-signup'
export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: '',
},
async fetcher() {
return null
},
useHook:
({ fetch }) =>
() =>
() => {},
}

View File

@ -0,0 +1,4 @@
export { default as useCart } from './use-cart'
export { default as useAddItem } from './use-add-item'
export { default as useRemoveItem } from './use-remove-item'
export { default as useUpdateItem } from './use-update-item'

View File

@ -0,0 +1,45 @@
import type { AddItemHook } from '@commerce/types/cart'
import type { MutationHook } from '@commerce/utils/types'
import { useCallback } from 'react'
import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item'
import type { CommercejsCart } from '../types/cart'
import { normalizeCart } from '../utils/normalize-cart'
import useCart from './use-cart'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = {
fetchOptions: {
query: 'cart',
method: 'add',
},
async fetcher({ input: item, options, fetch }) {
// Frontend stringifies variantId even if undefined.
const hasVariant = !item.variantId || item.variantId !== 'undefined'
const variables = [item.productId, item?.quantity || 1]
if (hasVariant) {
variables.push(item.variantId)
}
const { cart } = await fetch<{ cart: CommercejsCart }>({
query: options.query,
method: options.method,
variables,
})
return normalizeCart(cart)
},
useHook: ({ fetch }) =>
function useHook() {
const { mutate } = useCart()
return useCallback(
async function addItem(input) {
const cart = await fetch({ input })
await mutate(cart, false)
return cart
},
[mutate]
)
},
}

View File

@ -0,0 +1,41 @@
import { useMemo } from 'react'
import type { GetCartHook } from '@commerce/types/cart'
import { SWRHook } from '@commerce/utils/types'
import useCart, { UseCart } from '@commerce/cart/use-cart'
import type { CommercejsCart } from '../types/cart'
import { normalizeCart } from '../utils/normalize-cart'
export default useCart as UseCart<typeof handler>
export const handler: SWRHook<GetCartHook> = {
fetchOptions: {
query: 'cart',
method: 'retrieve',
},
async fetcher({ options, fetch }) {
const cart = await fetch<CommercejsCart>({
query: options.query,
method: options.method,
})
return normalizeCart(cart)
},
useHook: ({ useData }) =>
function useHook(input) {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems?.length ?? 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}

View File

@ -0,0 +1,36 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import type { RemoveItemHook } from '@commerce/types/cart'
import useRemoveItem, { UseRemoveItem } from '@commerce/cart/use-remove-item'
import type { CommercejsCart } from '../types/cart'
import { normalizeCart } from '../utils/normalize-cart'
import useCart from './use-cart'
export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler: MutationHook<RemoveItemHook> = {
fetchOptions: {
query: 'cart',
method: 'remove',
},
async fetcher({ input, options, fetch }) {
const { cart } = await fetch<{ cart: CommercejsCart }>({
query: options.query,
method: options.method,
variables: input.itemId,
})
return normalizeCart(cart)
},
useHook: ({ fetch }) =>
function useHook() {
const { mutate } = useCart()
return useCallback(
async function removeItem(input) {
const cart = await fetch({ input: { itemId: input.id } })
await mutate(cart, false)
return cart
},
[mutate]
)
},
}

View File

@ -0,0 +1,76 @@
import type { UpdateItemHook, LineItem } from '@commerce/types/cart'
import type {
HookFetcherContext,
MutationHookContext,
} from '@commerce/utils/types'
import { ValidationError } from '@commerce/utils/errors'
import debounce from 'lodash.debounce'
import { useCallback } from 'react'
import useUpdateItem, { UseUpdateItem } from '@commerce/cart/use-update-item'
import type { CommercejsCart } from '../types/cart'
import { normalizeCart } from '../utils/normalize-cart'
import useCart from './use-cart'
export default useUpdateItem as UseUpdateItem<typeof handler>
export type UpdateItemActionInput<T = any> = T extends LineItem
? Partial<UpdateItemHook['actionInput']>
: UpdateItemHook['actionInput']
export const handler = {
fetchOptions: {
query: 'cart',
method: 'update',
},
async fetcher({ input, options, fetch }: HookFetcherContext<UpdateItemHook>) {
const variables = [input.itemId, { quantity: input.item.quantity }]
const { cart } = await fetch<{ cart: CommercejsCart }>({
query: options.query,
method: options.method,
variables,
})
return normalizeCart(cart)
},
useHook:
({ fetch }: MutationHookContext<UpdateItemHook>) =>
<T extends LineItem | undefined = undefined>(
ctx: {
item?: T
wait?: number
} = {}
) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { mutate } = useCart() as any
const { item } = ctx
// eslint-disable-next-line react-hooks/rules-of-hooks
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
const quantity = input?.quantity ?? item?.quantity
if (!itemId || !productId || !variantId) {
throw new ValidationError({
message: 'Invalid input for updating cart item',
})
}
const cart = await fetch({
input: {
itemId,
item: {
quantity,
productId,
variantId,
},
},
})
await mutate(cart, false)
return cart
}, ctx.wait ?? 500),
[mutate, item]
)
},
}

View File

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

View File

@ -0,0 +1,52 @@
import type { GetCheckoutHook } from '@commerce/types/checkout'
import { useMemo } from 'react'
import { SWRHook } from '@commerce/utils/types'
import useCheckout, { UseCheckout } from '@commerce/checkout/use-checkout'
import useSubmitCheckout from './use-submit-checkout'
import { useCheckoutContext } from '@components/checkout/context'
export default useCheckout as UseCheckout<typeof handler>
export const handler: SWRHook<GetCheckoutHook> = {
fetchOptions: {
query: '_',
method: '_',
},
useHook: () =>
function useHook() {
const { cardFields, addressFields } = useCheckoutContext()
const submit = useSubmitCheckout()
// Basic validation - check that at least one field has a value.
const hasEnteredCard = Object.values(cardFields).some(
(fieldValue) => !!fieldValue
)
const hasEnteredAddress = Object.values(addressFields).some(
(fieldValue) => !!fieldValue
)
const response = useMemo(
() => ({
data: {
hasPayment: hasEnteredCard,
hasShipping: hasEnteredAddress,
},
}),
[hasEnteredCard, hasEnteredAddress]
)
return useMemo(
() =>
Object.create(response, {
submit: {
get() {
return submit
},
enumerable: true,
},
}),
[submit, response]
)
},
}

View File

@ -0,0 +1,38 @@
import type { SubmitCheckoutHook } from '@commerce/types/checkout'
import type { MutationHook } from '@commerce/utils/types'
import { useCallback } from 'react'
import useSubmitCheckout, {
UseSubmitCheckout,
} from '@commerce/checkout/use-submit-checkout'
import { useCheckoutContext } from '@components/checkout/context'
export default useSubmitCheckout as UseSubmitCheckout<typeof handler>
export const handler: MutationHook<SubmitCheckoutHook> = {
fetchOptions: {
url: '/api/checkout',
method: 'POST',
},
async fetcher({ input: item, options, fetch }) {
const data = await fetch({
...options,
body: { item },
})
return data
},
useHook: ({ fetch }) =>
function useHook() {
const { cardFields, addressFields } = useCheckoutContext()
return useCallback(
async function onSubmitCheckout(input) {
const data = await fetch({
input: { card: cardFields, address: addressFields },
})
return data
},
[cardFields, addressFields]
)
},
}

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