mirror of
https://github.com/vercel/commerce.git
synced 2025-06-08 09:16:58 +00:00
Merge branch 'main' of https://github.com/vercel/commerce into elasticpath-master
This commit is contained in:
commit
59912899a4
@ -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=
|
||||
|
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode"]
|
||||
"recommendations": ["esbenp.prettier-vscode", "csstools.postcss", "bradlc.vscode-tailwindcss"]
|
||||
}
|
||||
|
17
README.md
17
README.md
@ -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
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>) {
|
||||
try {
|
||||
setLoadingSubmit(true)
|
||||
event.preventDefault()
|
||||
|
||||
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>
|
||||
|
@ -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
|
||||
|
111
components/checkout/context.tsx
Normal file
111
components/checkout/context.tsx
Normal 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
|
||||
}
|
@ -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}
|
||||
|
@ -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>
|
||||
{process.env.COMMERCE_SEARCH_ENABLED && (
|
||||
<div className="flex pb-4 lg:px-6 lg:hidden">
|
||||
<Searchbar id="mobile-search" />
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</NavbarRoot>
|
||||
)
|
||||
|
@ -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>
|
||||
|
@ -0,0 +1,7 @@
|
||||
.root {
|
||||
@apply px-4 sm:px-6 sm:w-full flex-1 z-20;
|
||||
}
|
||||
|
||||
.item {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
@ -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
|
6
components/common/UserNav/MenuSidebarView/index.ts
Normal file
6
components/common/UserNav/MenuSidebarView/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { default } from './MenuSidebarView'
|
||||
|
||||
export interface Link {
|
||||
href: string
|
||||
label: string
|
||||
}
|
@ -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 {
|
||||
.mobileMenu {
|
||||
@apply flex lg:hidden ml-6
|
||||
}
|
||||
|
||||
.avatarButton:focus,
|
||||
.mobileMenu:focus {
|
||||
@apply outline-none;
|
||||
}
|
@ -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>
|
||||
)
|
||||
|
@ -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
21
components/icons/Menu.tsx
Normal 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
|
@ -39,13 +39,14 @@ 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 && (
|
||||
<div>
|
||||
<Image
|
||||
quality="85"
|
||||
src={product.images[0]?.url || placeholderImg}
|
||||
@ -55,6 +56,7 @@ const ProductCard: FC<Props> = ({
|
||||
layout="fixed"
|
||||
{...imgProps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@ -80,6 +82,7 @@ const ProductCard: FC<Props> = ({
|
||||
)}
|
||||
<div className={s.imageContainer}>
|
||||
{product?.images && (
|
||||
<div>
|
||||
<Image
|
||||
alt={product.name || 'Product Image'}
|
||||
className={s.productImage}
|
||||
@ -90,6 +93,7 @@ const ProductCard: FC<Props> = ({
|
||||
layout="responsive"
|
||||
{...imgProps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@ -110,6 +114,7 @@ const ProductCard: FC<Props> = ({
|
||||
/>
|
||||
<div className={s.imageContainer}>
|
||||
{product?.images && (
|
||||
<div>
|
||||
<Image
|
||||
alt={product.name || 'Product Image'}
|
||||
className={s.productImage}
|
||||
@ -120,6 +125,7 @@ const ProductCard: FC<Props> = ({
|
||||
layout="responsive"
|
||||
{...imgProps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
@ -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 (
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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">
|
||||
|
@ -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 } : {}}
|
||||
|
@ -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(),
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
@ -34,7 +30,6 @@ const Sidebar: FC<SidebarProps> = ({ children, onClose }) => {
|
||||
}
|
||||
|
||||
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}
|
||||
|
@ -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. */
|
||||
|
||||
|
@ -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,6 +64,7 @@ const WishlistCard: FC<Props> = ({ product }) => {
|
||||
return (
|
||||
<div className={cn(s.root, { 'opacity-75 pointer-events-none': removing })}>
|
||||
<div className={`col-span-3 ${s.productBg}`}>
|
||||
<div>
|
||||
<Image
|
||||
src={product.images[0]?.url || placeholderImg}
|
||||
width={400}
|
||||
@ -69,6 +72,7 @@ const WishlistCard: FC<Props> = ({ product }) => {
|
||||
alt={product.images[0]?.alt || 'Product Image'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-7">
|
||||
<h3 className="text-2xl mb-2">
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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',
|
||||
|
@ -24,11 +24,8 @@ export const getLoggedInCustomerQuery = /* GraphQL */ `
|
||||
|
||||
export type Customer = NonNullable<GetLoggedInCustomerQuery['customer']>
|
||||
|
||||
const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] = async ({
|
||||
req,
|
||||
res,
|
||||
config,
|
||||
}) => {
|
||||
const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] =
|
||||
async ({ req, res, config }) => {
|
||||
const token = req.cookies[config.customerCookie]
|
||||
|
||||
if (token) {
|
||||
|
@ -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}`
|
||||
)
|
||||
|
@ -1,3 +1,3 @@
|
||||
import zeitFetch from '@vercel/fetch'
|
||||
import vercelFetch from '@vercel/fetch'
|
||||
|
||||
export default zeitFetch()
|
||||
export default vercelFetch()
|
||||
|
@ -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,7 +24,9 @@ export const handler: MutationHook<LoginHook> = {
|
||||
body: { email, password },
|
||||
})
|
||||
},
|
||||
useHook: ({ fetch }) => () => {
|
||||
useHook:
|
||||
({ fetch }) =>
|
||||
() => {
|
||||
const { revalidate } = useCustomer()
|
||||
|
||||
return useCallback(
|
||||
|
@ -11,7 +11,9 @@ export const handler: MutationHook<LogoutHook> = {
|
||||
url: '/api/logout',
|
||||
method: 'GET',
|
||||
},
|
||||
useHook: ({ fetch }) => () => {
|
||||
useHook:
|
||||
({ fetch }) =>
|
||||
() => {
|
||||
const { mutate } = useCustomer()
|
||||
|
||||
return useCallback(
|
||||
|
@ -29,7 +29,9 @@ export const handler: MutationHook<SignupHook> = {
|
||||
body: { firstName, lastName, email, password },
|
||||
})
|
||||
},
|
||||
useHook: ({ fetch }) => () => {
|
||||
useHook:
|
||||
({ fetch }) =>
|
||||
() => {
|
||||
const { revalidate } = useCustomer()
|
||||
|
||||
return useCallback(
|
||||
|
@ -29,7 +29,9 @@ export const handler: MutationHook<AddItemHook> = {
|
||||
|
||||
return data
|
||||
},
|
||||
useHook: ({ fetch }) => () => {
|
||||
useHook:
|
||||
({ fetch }) =>
|
||||
() => {
|
||||
const { mutate } = useCart()
|
||||
|
||||
return useCallback(
|
||||
|
@ -10,7 +10,9 @@ export const handler: SWRHook<GetCartHook> = {
|
||||
url: '/api/cart',
|
||||
method: 'GET',
|
||||
},
|
||||
useHook: ({ useData }) => (input) => {
|
||||
useHook:
|
||||
({ useData }) =>
|
||||
(input) => {
|
||||
const response = useData({
|
||||
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
|
||||
})
|
||||
|
@ -30,11 +30,9 @@ export const handler = {
|
||||
}: HookFetcherContext<RemoveItemHook>) {
|
||||
return await fetch({ ...options, body: { itemId } })
|
||||
},
|
||||
useHook: ({ fetch }: MutationHookContext<RemoveItemHook>) => <
|
||||
T extends LineItem | undefined = undefined
|
||||
>(
|
||||
ctx: { item?: T } = {}
|
||||
) => {
|
||||
useHook:
|
||||
({ fetch }: MutationHookContext<RemoveItemHook>) =>
|
||||
<T extends LineItem | undefined = undefined>(ctx: { item?: T } = {}) => {
|
||||
const { item } = ctx
|
||||
const { mutate } = useCart()
|
||||
const removeItem: RemoveItemFn<LineItem> = async (input) => {
|
||||
|
@ -46,9 +46,9 @@ export const handler = {
|
||||
body: { itemId, item },
|
||||
})
|
||||
},
|
||||
useHook: ({ fetch }: MutationHookContext<UpdateItemHook>) => <
|
||||
T extends LineItem | undefined = undefined
|
||||
>(
|
||||
useHook:
|
||||
({ fetch }: MutationHookContext<UpdateItemHook>) =>
|
||||
<T extends LineItem | undefined = undefined>(
|
||||
ctx: {
|
||||
item?: T
|
||||
wait?: number
|
||||
|
@ -13,7 +13,9 @@ export const handler: SWRHook<CustomerHook> = {
|
||||
const data = await fetch(options)
|
||||
return data?.customer ?? null
|
||||
},
|
||||
useHook: ({ useData }) => (input) => {
|
||||
useHook:
|
||||
({ useData }) =>
|
||||
(input) => {
|
||||
return useData({
|
||||
swrOptions: {
|
||||
revalidateOnFocus: false,
|
||||
|
@ -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,
|
||||
|
@ -33,7 +33,9 @@ export const handler: SWRHook<SearchProductsHook> = {
|
||||
method: options.method,
|
||||
})
|
||||
},
|
||||
useHook: ({ useData }) => (input = {}) => {
|
||||
useHook:
|
||||
({ useData }) =>
|
||||
(input = {}) => {
|
||||
return useData({
|
||||
input: [
|
||||
['search', input.search],
|
||||
|
@ -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>
|
||||
|
@ -13,7 +13,9 @@ export const handler: MutationHook<AddItemHook> = {
|
||||
url: '/api/wishlist',
|
||||
method: 'POST',
|
||||
},
|
||||
useHook: ({ fetch }) => () => {
|
||||
useHook:
|
||||
({ fetch }) =>
|
||||
() => {
|
||||
const { data: customer } = useCustomer()
|
||||
const { revalidate } = useWishlist()
|
||||
|
||||
|
@ -15,7 +15,9 @@ export const handler: MutationHook<RemoveItemHook> = {
|
||||
url: '/api/wishlist',
|
||||
method: 'DELETE',
|
||||
},
|
||||
useHook: ({ fetch }) => ({ wishlist } = {}) => {
|
||||
useHook:
|
||||
({ fetch }) =>
|
||||
({ wishlist } = {}) => {
|
||||
const { data: customer } = useCustomer()
|
||||
const { revalidate } = useWishlist(wishlist)
|
||||
|
||||
|
@ -24,7 +24,9 @@ export const handler: SWRHook<GetWishlistHook> = {
|
||||
method: options.method,
|
||||
})
|
||||
},
|
||||
useHook: ({ useData }) => (input) => {
|
||||
useHook:
|
||||
({ useData }) =>
|
||||
(input) => {
|
||||
const { data: customer } = useCustomer()
|
||||
const response = useData({
|
||||
input: [
|
||||
|
@ -3,10 +3,8 @@ 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 cartEndpoint: GetAPISchema<any, CartSchema<any>>['endpoint']['handler'] =
|
||||
async (ctx) => {
|
||||
const { req, res, handlers, config } = ctx
|
||||
|
||||
if (
|
||||
|
@ -12,6 +12,7 @@ const loginEndpoint: GetAPISchema<
|
||||
if (
|
||||
!isAllowedOperation(req, res, {
|
||||
POST: handlers['login'],
|
||||
GET: handlers['login'],
|
||||
})
|
||||
) {
|
||||
return
|
||||
|
@ -3,10 +3,8 @@ 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 logoutEndpoint: GetAPISchema<any, LogoutSchema>['endpoint']['handler'] =
|
||||
async (ctx) => {
|
||||
const { req, res, handlers } = ctx
|
||||
|
||||
if (
|
||||
|
@ -3,10 +3,8 @@ 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 signupEndpoint: GetAPISchema<any, SignupSchema>['endpoint']['handler'] =
|
||||
async (ctx) => {
|
||||
const { req, res, handlers, config } = ctx
|
||||
|
||||
if (
|
||||
|
@ -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> {
|
||||
|
@ -16,7 +16,10 @@ const PROVIDERS = [
|
||||
'vendure',
|
||||
'local',
|
||||
'elasticpath',
|
||||
'ordercloud'
|
||||
'ordercloud',
|
||||
'kibocommerce',
|
||||
'spree',
|
||||
'commercejs',
|
||||
]
|
||||
|
||||
function getProviderName() {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -77,9 +77,8 @@ export type ProductsSchema<T extends ProductTypes = ProductTypes> = {
|
||||
}
|
||||
}
|
||||
|
||||
export type GetAllProductPathsOperation<
|
||||
T extends ProductTypes = ProductTypes
|
||||
> = {
|
||||
export type GetAllProductPathsOperation<T extends ProductTypes = ProductTypes> =
|
||||
{
|
||||
data: { products: Pick<T['product'], 'path'>[] }
|
||||
variables: { first?: number }
|
||||
}
|
||||
|
@ -11,8 +11,10 @@ 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 }
|
||||
type DefineProperty<
|
||||
Prop extends PropertyKey,
|
||||
Desc extends PropertyDescriptor
|
||||
> = Desc extends { writable: any; set(val: any): any }
|
||||
? never
|
||||
: Desc extends { writable: any; get(): any }
|
||||
? never
|
||||
|
7
framework/commercejs/.env.template
Normal file
7
framework/commercejs/.env.template
Normal 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=
|
13
framework/commercejs/README.md
Normal file
13
framework/commercejs/README.md
Normal 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.
|
@ -0,0 +1 @@
|
||||
export default function noopApi(...args: any[]): void {}
|
@ -0,0 +1 @@
|
||||
export default function noopApi(...args: any[]): void {}
|
23
framework/commercejs/api/endpoints/checkout/index.ts
Normal file
23
framework/commercejs/api/endpoints/checkout/index.ts
Normal 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
|
@ -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
|
@ -0,0 +1 @@
|
||||
export default function noopApi(...args: any[]): void {}
|
@ -0,0 +1 @@
|
||||
export default function noopApi(...args: any[]): void {}
|
1
framework/commercejs/api/endpoints/customer/index.ts
Normal file
1
framework/commercejs/api/endpoints/customer/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export default function noopApi(...args: any[]): void {}
|
18
framework/commercejs/api/endpoints/login/index.ts
Normal file
18
framework/commercejs/api/endpoints/login/index.ts
Normal 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
|
33
framework/commercejs/api/endpoints/login/login.ts
Normal file
33
framework/commercejs/api/endpoints/login/login.ts
Normal 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
|
1
framework/commercejs/api/endpoints/logout/index.ts
Normal file
1
framework/commercejs/api/endpoints/logout/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export default function noopApi(...args: any[]): void {}
|
1
framework/commercejs/api/endpoints/signup/index.ts
Normal file
1
framework/commercejs/api/endpoints/signup/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export default function noopApi(...args: any[]): void {}
|
1
framework/commercejs/api/endpoints/wishlist/index.tsx
Normal file
1
framework/commercejs/api/endpoints/wishlist/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export default function noopApi(...args: any[]): void {}
|
46
framework/commercejs/api/index.ts
Normal file
46
framework/commercejs/api/index.ts
Normal 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)
|
||||
}
|
21
framework/commercejs/api/operations/get-all-pages.ts
Normal file
21
framework/commercejs/api/operations/get-all-pages.ts
Normal 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
|
||||
}
|
35
framework/commercejs/api/operations/get-all-product-paths.ts
Normal file
35
framework/commercejs/api/operations/get-all-product-paths.ts
Normal 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
|
||||
}
|
29
framework/commercejs/api/operations/get-all-products.ts
Normal file
29
framework/commercejs/api/operations/get-all-products.ts
Normal 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
|
||||
}
|
15
framework/commercejs/api/operations/get-page.ts
Normal file
15
framework/commercejs/api/operations/get-page.ts
Normal 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
|
||||
}
|
44
framework/commercejs/api/operations/get-product.ts
Normal file
44
framework/commercejs/api/operations/get-product.ts
Normal 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
|
||||
}
|
36
framework/commercejs/api/operations/get-site-info.ts
Normal file
36
framework/commercejs/api/operations/get-site-info.ts
Normal 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
|
||||
}
|
6
framework/commercejs/api/operations/index.ts
Normal file
6
framework/commercejs/api/operations/index.ts
Normal 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'
|
14
framework/commercejs/api/utils/graphql-fetch.ts
Normal file
14
framework/commercejs/api/utils/graphql-fetch.ts
Normal 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
|
19
framework/commercejs/api/utils/sdk-fetch.ts
Normal file
19
framework/commercejs/api/utils/sdk-fetch.ts
Normal 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
|
||||
}
|
3
framework/commercejs/auth/index.ts
Normal file
3
framework/commercejs/auth/index.ts
Normal 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'
|
34
framework/commercejs/auth/use-login.tsx
Normal file
34
framework/commercejs/auth/use-login.tsx
Normal 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 })
|
||||
}, [])
|
||||
},
|
||||
}
|
27
framework/commercejs/auth/use-logout.tsx
Normal file
27
framework/commercejs/auth/use-logout.tsx
Normal 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]
|
||||
)
|
||||
},
|
||||
}
|
17
framework/commercejs/auth/use-signup.tsx
Normal file
17
framework/commercejs/auth/use-signup.tsx
Normal 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 }) =>
|
||||
() =>
|
||||
() => {},
|
||||
}
|
4
framework/commercejs/cart/index.ts
Normal file
4
framework/commercejs/cart/index.ts
Normal 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'
|
45
framework/commercejs/cart/use-add-item.tsx
Normal file
45
framework/commercejs/cart/use-add-item.tsx
Normal 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]
|
||||
)
|
||||
},
|
||||
}
|
41
framework/commercejs/cart/use-cart.tsx
Normal file
41
framework/commercejs/cart/use-cart.tsx
Normal 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]
|
||||
)
|
||||
},
|
||||
}
|
36
framework/commercejs/cart/use-remove-item.tsx
Normal file
36
framework/commercejs/cart/use-remove-item.tsx
Normal 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]
|
||||
)
|
||||
},
|
||||
}
|
76
framework/commercejs/cart/use-update-item.tsx
Normal file
76
framework/commercejs/cart/use-update-item.tsx
Normal 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]
|
||||
)
|
||||
},
|
||||
}
|
2
framework/commercejs/checkout/index.ts
Normal file
2
framework/commercejs/checkout/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as useSubmitCheckout } from './use-submit-checkout'
|
||||
export { default as useCheckout } from './use-checkout'
|
52
framework/commercejs/checkout/use-checkout.tsx
Normal file
52
framework/commercejs/checkout/use-checkout.tsx
Normal 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]
|
||||
)
|
||||
},
|
||||
}
|
38
framework/commercejs/checkout/use-submit-checkout.tsx
Normal file
38
framework/commercejs/checkout/use-submit-checkout.tsx
Normal 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]
|
||||
)
|
||||
},
|
||||
}
|
10
framework/commercejs/commerce.config.json
Normal file
10
framework/commercejs/commerce.config.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"provider": "commercejs",
|
||||
"features": {
|
||||
"cart": true,
|
||||
"search": true,
|
||||
"customCheckout": true,
|
||||
"customerAuth": true,
|
||||
"wishlist": false
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user