Shopify i18n (#9)

This commit is contained in:
l198881 2021-06-01 07:55:04 -03:00 committed by GitHub
parent 05501c6f99
commit 628fbf50bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
319 changed files with 44504 additions and 2202 deletions

View File

@ -1,5 +1,15 @@
# Available providers: bigcommerce, shopify, swell
COMMERCE_PROVIDER=
BIGCOMMERCE_STOREFRONT_API_URL= BIGCOMMERCE_STOREFRONT_API_URL=
BIGCOMMERCE_STOREFRONT_API_TOKEN= BIGCOMMERCE_STOREFRONT_API_TOKEN=
BIGCOMMERCE_STORE_API_URL= BIGCOMMERCE_STORE_API_URL=
BIGCOMMERCE_STORE_API_TOKEN= BIGCOMMERCE_STORE_API_TOKEN=
BIGCOMMERCE_STORE_API_CLIENT_ID= BIGCOMMERCE_STORE_API_CLIENT_ID=
BIGCOMMERCE_CHANNEL_ID=
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
NEXT_PUBLIC_SWELL_STORE_ID=
NEXT_PUBLIC_SWELL_PUBLIC_KEY=

1
.gitignore vendored
View File

@ -18,6 +18,7 @@ out/
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
.idea
# debug # debug
npm-debug.log* npm-debug.log*

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode"]
}

View File

@ -1,4 +0,0 @@
## Changelog
- Select Variants Working
- Click on cart item title, closes the sidebar

116
README.md
View File

@ -7,7 +7,10 @@ Start right now at [nextjs.org/commerce](https://nextjs.org/commerce)
Demo live at: [demo.vercel.store](https://demo.vercel.store/) Demo live at: [demo.vercel.store](https://demo.vercel.store/)
This project is currently <b>under development</b>. - Shopify Demo: https://shopify.vercel.store/
- Swell Demo: https://swell.vercel.store/
- BigCommerce Demo: https://bigcommerce.vercel.store/
- Vendure Demo: https://vendure.vercel.store
## Features ## Features
@ -21,82 +24,66 @@ This project is currently <b>under development</b>.
- Integrations - Integrate seamlessly with the most common ecommerce platforms. - Integrations - Integrate seamlessly with the most common ecommerce platforms.
- Dark Mode Support - Dark Mode Support
## Work in progress
We're using Github Projects to keep track of issues in progress and todo's. Here is our [Board](https://github.com/vercel/commerce/projects/1)
## Integrations ## Integrations
Next.js Commerce integrates out-of-the-box with BigCommerce. We plan to support all major ecommerce backends. Next.js Commerce integrates out-of-the-box with BigCommerce and Shopify. We plan to support all major ecommerce backends.
## Goals ## Considerations
- **Next.js Commerce** should have a completely data **agnostic** UI - `framework/commerce` contains all types, helpers and functions to be used as base to build a new **provider**.
- **Aware of schema**: should ship with the right data schemas and types. - **Providers** live under `framework`'s root folder and they will extend Next.js Commerce types and functionality (`framework/commerce`).
- All providers should return the right data types and schemas to blend correctly with Next.js Commerce. - We have a **Features API** to ensure feature parity between the UI and the Provider. The UI should update accordingly and no extra code should be bundled. All extra configuration for features will live under `features` in `commerce.config.json` and if needed it can also be accessed programatically.
- `@framework` will be the alias utilized in commerce and it will map to the ecommerce provider of preference- e.g BigCommerce, Shopify, Swell. All providers should expose the same standardized functions. _Note that the same applies for recipes using a CMS + an ecommerce provider._ - Each **provider** should add its corresponding `next.config.js` and `commerce.config.json` adding specific data related to the provider. For example in case of BigCommerce, the images CDN and additional API routes.
- **Providers don't depend on anything that's specific to the application they're used in**. They only depend on `framework/commerce`, on their own framework folder and on some dependencies included in `package.json`
There is a `framework` folder in the root folder that will contain multiple ecommerce providers. ## Configuration
Additionally, we need to ensure feature parity (not all providers have e.g. wishlist) we will also have to build a feature API to disable/enable features in the UI. ### How to change providers
People actively working on this project: @okbel & @lfades. Open `.env.local` and change the value of `COMMERCE_PROVIDER` to the provider you would like to use, then set the environment variables for that provider (use `.env.template` as the base).
## Framework The setup for Shopify would look like this for example:
Framework is where the data comes from. It contains mostly hooks and functions. ```
COMMERCE_PROVIDER=shopify
## Structure NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=xxxxxxx.myshopify.com
Main folder and its exposed functions
- `product`
- usePrice
- useSearch
- getProduct
- getAllProducts
- `wishlist`
- useWishlist
- addWishlistItem
- removeWishlistItem
- `auth`
- useLogin
- useLogout
- useSignup
- `cart`
- useCart
- useAddItem
- useRemoveItem
- useCartActions
- useUpdateItem
- `config.json`
- README.md
#### Example of correct usage of Commece Framework
```js
import { useUI } from '@components/ui'
import { useCustomer } from '@framework/customer'
import { useAddItem, useWishlist, useRemoveItem } from '@framework/wishlist'
``` ```
## Config And check that the `tsconfig.json` resolves to the chosen provider:
```
"@framework": ["framework/shopify"],
"@framework/*": ["framework/shopify/*"]
```
That's it!
### Features ### Features
In order to make the UI entirely functional, we need to specify which features certain providers do not **provide**. Every provider defines the features that it supports under `framework/{provider}/commerce.config.json`
**Disabling wishlist:** #### How to turn Features on and off
``` > NOTE: The selected provider should support the feature that you are toggling. (This means that you can't turn wishlist on if the provider doesn't support this functionality out the box)
{
- Open `commerce.config.json`
- You'll see a config file like this:
```json
{
"features": { "features": {
"wishlist": false "wishlist": false
} }
} }
``` ```
- Turn wishlist on by setting wishlist to true.
- Run the app and the wishlist functionality should be back on.
### How to create a new provider
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.
## Contribute ## Contribute
@ -106,11 +93,15 @@ Our commitment to Open Source can be found [here](https://vercel.com/oss).
2. Create a new branch `git checkout -b MY_BRANCH_NAME` 2. Create a new branch `git checkout -b MY_BRANCH_NAME`
3. Install yarn: `npm install -g yarn` 3. Install yarn: `npm install -g yarn`
4. Install the dependencies: `yarn` 4. Install the dependencies: `yarn`
5. Duplicate `.env.template` and rename it to `.env.local`. 5. Duplicate `.env.template` and rename it to `.env.local`
6. Add proper store values to `.env.local`. 6. Add proper store values to `.env.local`
7. Run `yarn dev` to build and watch for code changes 7. Run `yarn dev` to build and watch for code changes
8. The development branch is `canary` (this is the branch pull requests should be made against).
On a release, `canary` branch is rebased into `master`. ## Work in progress
We're using Github Projects to keep track of issues in progress and todo's. Here is our [Board](https://github.com/vercel/commerce/projects/1)
People actively working on this project: @okbel & @lfades.
## Troubleshoot ## Troubleshoot
@ -128,6 +119,7 @@ BIGCOMMERCE_STOREFRONT_API_TOKEN=<>
BIGCOMMERCE_STORE_API_URL=<> BIGCOMMERCE_STORE_API_URL=<>
BIGCOMMERCE_STORE_API_TOKEN=<> BIGCOMMERCE_STORE_API_TOKEN=<>
BIGCOMMERCE_STORE_API_CLIENT_ID=<> BIGCOMMERCE_STORE_API_CLIENT_ID=<>
BIGCOMMERCE_CHANNEL_ID=<>
``` ```
If your project was started with a "Deploy with Vercel" button, you can use Vercel's CLI to retrieve these credentials. If your project was started with a "Deploy with Vercel" button, you can use Vercel's CLI to retrieve these credentials.

View File

@ -33,7 +33,7 @@ const CartItem = ({
currencyCode, currencyCode,
}) })
const updateItem = useUpdateItem(item) const updateItem = useUpdateItem({ item })
const removeItem = useRemoveItem() const removeItem = useRemoveItem()
const [quantity, setQuantity] = useState(item.quantity) const [quantity, setQuantity] = useState(item.quantity)
const [removing, setRemoving] = useState(false) const [removing, setRemoving] = useState(false)
@ -92,8 +92,10 @@ const CartItem = ({
})} })}
{...rest} {...rest}
> >
<div className="w-16 h-16 bg-violet relative overflow-hidden"> <div className="w-16 h-16 bg-violet relative overflow-hidden cursor-pointer">
<Link href={`/product/${item.path}`}>
<Image <Image
onClick={() => closeSidebarIfPresent()}
className={s.productImage} className={s.productImage}
width={150} width={150}
height={150} height={150}
@ -101,6 +103,7 @@ const CartItem = ({
alt={item.variant.image!.altText} alt={item.variant.image!.altText}
unoptimized unoptimized
/> />
</Link>
</div> </div>
<div className="flex-1 flex flex-col text-base"> <div className="flex-1 flex flex-col text-base">
<Link href={`/product/${item.path}`}> <Link href={`/product/${item.path}`}>

View File

@ -1,15 +1,16 @@
import { FC } from 'react' import { FC } from 'react'
import cn from 'classnames' import cn from 'classnames'
import { UserNav } from '@components/common' import Link from 'next/link'
import { Button } from '@components/ui'
import { Bag, Cross, Check } from '@components/icons'
import { useUI } from '@components/ui/context'
import useCart from '@framework/cart/use-cart'
import usePrice from '@framework/product/use-price'
import CartItem from '../CartItem' import CartItem from '../CartItem'
import s from './CartSidebarView.module.css' import s from './CartSidebarView.module.css'
import { Button } from '@components/ui'
import { UserNav } from '@components/common'
import { useUI } from '@components/ui/context'
import { Bag, Cross, Check } from '@components/icons'
import useCart from '@framework/cart/use-cart'
import usePrice from '@framework/product/use-price'
const CartSidebarView: FC<{ wishlist?: boolean }> = ({ wishlist }) => { const CartSidebarView: FC = () => {
const { closeSidebar } = useUI() const { closeSidebar } = useUI()
const { data, isLoading, isEmpty } = useCart() const { data, isLoading, isEmpty } = useCart()
@ -48,7 +49,7 @@ const CartSidebarView: FC<{ wishlist?: boolean }> = ({ wishlist }) => {
</button> </button>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<UserNav wishlist={wishlist} /> <UserNav />
</div> </div>
</div> </div>
</header> </header>
@ -87,11 +88,16 @@ const CartSidebarView: FC<{ wishlist?: boolean }> = ({ wishlist }) => {
) : ( ) : (
<> <>
<div className="px-4 sm:px-6 flex-1"> <div className="px-4 sm:px-6 flex-1">
<h2 className="pt-1 pb-4 text-2xl leading-7 font-bold text-base tracking-wide"> <Link href="/cart">
<h2
className="pt-1 pb-4 text-2xl leading-7 font-bold text-base tracking-wide cursor-pointer inline-block"
onClick={handleClose}
>
My Cart My Cart
</h2> </h2>
</Link>
<ul className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accents-3 border-t border-accents-3"> <ul className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accents-3 border-t border-accents-3">
{data!.lineItems.map((item) => ( {data!.lineItems.map((item: any) => (
<CartItem <CartItem
key={item.id} key={item.id}
item={item} item={item}

View File

@ -1,7 +1,9 @@
.root { .root {
@apply text-center p-6 bg-primary text-sm flex-row justify-center items-center font-medium fixed bottom-0 w-full z-30 transition-all duration-300 ease-out; @apply text-center p-6 bg-primary text-sm flex-row justify-center items-center font-medium fixed bottom-0 w-full z-30 transition-all duration-300 ease-out;
}
@screen md { @screen md {
.root {
@apply flex text-left; @apply flex text-left;
} }
} }

View File

@ -44,20 +44,6 @@ const Footer: FC<Props> = ({ className, pages }) => {
</a> </a>
</Link> </Link>
</li> </li>
<li className="py-3 md:py-0 md:pb-4">
<Link href="/">
<a className="text-primary hover:text-accents-6 transition ease-in-out duration-150">
Careers
</a>
</Link>
</li>
<li className="py-3 md:py-0 md:pb-4">
<Link href="/blog">
<a className="text-primary hover:text-accents-6 transition ease-in-out duration-150">
Blog
</a>
</Link>
</li>
{sitePages.map((page) => ( {sitePages.map((page) => (
<li key={page.url} className="py-3 md:py-0 md:pb-4"> <li key={page.url} className="py-3 md:py-0 md:pb-4">
<Link href={page.url!}> <Link href={page.url!}>

View File

@ -5,20 +5,17 @@ import { Grid } from '@components/ui'
import { ProductCard } from '@components/product' import { ProductCard } from '@components/product'
import s from './HomeAllProductsGrid.module.css' import s from './HomeAllProductsGrid.module.css'
import { getCategoryPath, getDesignerPath } from '@lib/search' import { getCategoryPath, getDesignerPath } from '@lib/search'
import wishlist from '@framework/api/wishlist'
interface Props { interface Props {
categories?: any categories?: any
brands?: any brands?: any
products?: Product[] products?: Product[]
wishlist?: boolean
} }
const HomeAllProductsGrid: FC<Props> = ({ const HomeAllProductsGrid: FC<Props> = ({
categories, categories,
brands, brands,
products = [], products = [],
wishlist = false,
}) => { }) => {
return ( return (
<div className={s.root}> <div className={s.root}>
@ -65,7 +62,6 @@ const HomeAllProductsGrid: FC<Props> = ({
width: 480, width: 480,
height: 480, height: 480,
}} }}
wishlist={wishlist}
/> />
))} ))}
</Grid> </Grid>

View File

@ -16,14 +16,16 @@
.dropdownMenu { .dropdownMenu {
@apply fixed right-0 top-12 mt-2 origin-top-right outline-none bg-primary z-40 w-full h-full; @apply fixed right-0 top-12 mt-2 origin-top-right outline-none bg-primary z-40 w-full h-full;
}
@screen lg { @screen lg {
.dropdownMenu {
@apply absolute border border-accents-1 shadow-lg w-56 h-auto; @apply absolute border border-accents-1 shadow-lg w-56 h-auto;
} }
} }
.closeButton { @screen md {
@screen md { .closeButton {
@apply hidden; @apply hidden;
} }
} }

View File

@ -58,11 +58,10 @@ const Layout: FC<Props> = ({
} = useUI() } = useUI()
const { acceptedCookies, onAcceptCookies } = useAcceptCookies() const { acceptedCookies, onAcceptCookies } = useAcceptCookies()
const { locale = 'en-US' } = useRouter() const { locale = 'en-US' } = useRouter()
const isWishlistEnabled = commerceFeatures.wishlist
return ( return (
<CommerceProvider locale={locale}> <CommerceProvider locale={locale}>
<div className={cn(s.root)}> <div className={cn(s.root)}>
<Navbar wishlist={isWishlistEnabled} /> <Navbar />
<main className="fit">{children}</main> <main className="fit">{children}</main>
<Footer pages={pageProps.pages} /> <Footer pages={pageProps.pages} />
@ -73,7 +72,7 @@ const Layout: FC<Props> = ({
</Modal> </Modal>
<Sidebar open={displaySidebar} onClose={closeSidebar}> <Sidebar open={displaySidebar} onClose={closeSidebar}>
<CartSidebarView wishlist={isWishlistEnabled} /> <CartSidebarView />
</Sidebar> </Sidebar>
<FeatureBar <FeatureBar

View File

@ -5,7 +5,7 @@ import { Searchbar, UserNav } from '@components/common'
import NavbarRoot from './NavbarRoot' import NavbarRoot from './NavbarRoot'
import s from './Navbar.module.css' import s from './Navbar.module.css'
const Navbar: FC<{ wishlist?: boolean }> = ({ wishlist }) => ( const Navbar: FC = () => (
<NavbarRoot> <NavbarRoot>
<Container> <Container>
<div className="relative flex flex-row justify-between py-4 align-center md:py-6"> <div className="relative flex flex-row justify-between py-4 align-center md:py-6">
@ -25,6 +25,9 @@ const Navbar: FC<{ wishlist?: boolean }> = ({ wishlist }) => (
<Link href="/search?q=accessories"> <Link href="/search?q=accessories">
<a className={s.link}>Accessories</a> <a className={s.link}>Accessories</a>
</Link> </Link>
<Link href="/search?q=shoes">
<a className={s.link}>Shoes</a>
</Link>
</nav> </nav>
</div> </div>
@ -33,7 +36,7 @@ const Navbar: FC<{ wishlist?: boolean }> = ({ wishlist }) => (
</div> </div>
<div className="flex justify-end flex-1 space-x-8"> <div className="flex justify-end flex-1 space-x-8">
<UserNav wishlist={wishlist} /> <UserNav />
</div> </div>
</div> </div>

View File

@ -1,7 +1,9 @@
.dropdownMenu { .dropdownMenu {
@apply fixed right-0 mt-2 origin-top-right outline-none bg-primary z-40 w-full h-full; @apply fixed right-0 mt-2 origin-top-right outline-none bg-primary z-40 w-full h-full;
}
@screen lg { @screen lg {
.dropdownMenu {
@apply absolute top-10 border border-accents-1 shadow-lg w-56 h-auto; @apply absolute top-10 border border-accents-1 shadow-lg w-56 h-auto;
} }
} }

View File

@ -4,20 +4,19 @@ import cn from 'classnames'
import type { LineItem } from '@framework/types' import type { LineItem } from '@framework/types'
import useCart from '@framework/cart/use-cart' import useCart from '@framework/cart/use-cart'
import useCustomer from '@framework/customer/use-customer' import useCustomer from '@framework/customer/use-customer'
import { Avatar } from '@components/common'
import { Heart, Bag } from '@components/icons' import { Heart, Bag } from '@components/icons'
import { useUI } from '@components/ui/context' import { useUI } from '@components/ui/context'
import DropdownMenu from './DropdownMenu' import DropdownMenu from './DropdownMenu'
import s from './UserNav.module.css' import s from './UserNav.module.css'
import { Avatar } from '@components/common'
interface Props { interface Props {
className?: string className?: string
wishlist?: boolean
} }
const countItem = (count: number, item: LineItem) => count + item.quantity const countItem = (count: number, item: LineItem) => count + item.quantity
const UserNav: FC<Props> = ({ className, wishlist = false }) => { const UserNav: FC<Props> = ({ className }) => {
const { data } = useCart() const { data } = useCart()
const { data: customer } = useCustomer() const { data: customer } = useCustomer()
const { toggleSidebar, closeSidebarIfPresent, openModal } = useUI() const { toggleSidebar, closeSidebarIfPresent, openModal } = useUI()
@ -31,7 +30,7 @@ const UserNav: FC<Props> = ({ className, wishlist = false }) => {
<Bag /> <Bag />
{itemsCount > 0 && <span className={s.bagCount}>{itemsCount}</span>} {itemsCount > 0 && <span className={s.bagCount}>{itemsCount}</span>}
</li> </li>
{wishlist && ( {process.env.COMMERCE_WISHLIST_ENABLED && (
<li className={s.item}> <li className={s.item}>
<Link href="/wishlist"> <Link href="/wishlist">
<a onClick={closeSidebarIfPresent} aria-label="Wishlist"> <a onClick={closeSidebarIfPresent} aria-label="Wishlist">

View File

@ -0,0 +1,20 @@
const CreditCard = ({ ...props }) => {
return (
<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<path d="M1 10h22" />
</svg>
)
}
export default CreditCard

View File

@ -0,0 +1,20 @@
const MapPin = ({ ...props }) => {
return (
<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
)
}
export default MapPin

View File

@ -14,3 +14,5 @@ export { default as RightArrow } from './RightArrow'
export { default as Info } from './Info' export { default as Info } from './Info'
export { default as ChevronUp } from './ChevronUp' export { default as ChevronUp } from './ChevronUp'
export { default as Vercel } from './Vercel' export { default as Vercel } from './Vercel'
export { default as MapPin } from './MapPin'
export { default as CreditCard } from './CreditCard'

View File

@ -11,7 +11,6 @@ interface Props {
product: Product product: Product
variant?: 'slim' | 'simple' variant?: 'slim' | 'simple'
imgProps?: Omit<ImageProps, 'src'> imgProps?: Omit<ImageProps, 'src'>
wishlist?: boolean
} }
const placeholderImg = '/product-img-placeholder.svg' const placeholderImg = '/product-img-placeholder.svg'
@ -21,7 +20,6 @@ const ProductCard: FC<Props> = ({
product, product,
variant, variant,
imgProps, imgProps,
wishlist = false,
...props ...props
}) => ( }) => (
<Link href={`/product/${product.slug}`} {...props}> <Link href={`/product/${product.slug}`} {...props}>
@ -59,11 +57,11 @@ const ProductCard: FC<Props> = ({
{product.price.currencyCode} {product.price.currencyCode}
</span> </span>
</div> </div>
{wishlist && ( {process.env.COMMERCE_WISHLIST_ENABLED && (
<WishlistButton <WishlistButton
className={s.wishlistButton} className={s.wishlistButton}
productId={product.id} productId={product.id}
variant={product.variants[0]} variant={product.variants[0] as any}
/> />
)} )}
</div> </div>

View File

@ -7,7 +7,7 @@
} }
.productDisplay { .productDisplay {
@apply relative flex px-0 pb-0 relative box-border col-span-1 bg-violet; @apply relative flex px-0 pb-0 box-border col-span-1 bg-violet;
min-height: 600px; min-height: 600px;
@screen md { @screen md {

View File

@ -1,28 +1,23 @@
import cn from 'classnames' import cn from 'classnames'
import Image from 'next/image' import Image from 'next/image'
import { NextSeo } from 'next-seo' import { NextSeo } from 'next-seo'
import { FC, useState } from 'react' import { FC, useEffect, useState } from 'react'
import s from './ProductView.module.css' import s from './ProductView.module.css'
import { useUI } from '@components/ui'
import { Swatch, ProductSlider } from '@components/product' import { Swatch, ProductSlider } from '@components/product'
import { Button, Container, Text } from '@components/ui' import { Button, Container, Text, useUI } from '@components/ui'
import type { Product } from '@commerce/types' import type { Product } from '@commerce/types'
import usePrice from '@framework/product/use-price' import usePrice from '@framework/product/use-price'
import { useAddItem } from '@framework/cart' import { useAddItem } from '@framework/cart'
import { getVariant, SelectedOptions } from '../helpers' import { getVariant, SelectedOptions } from '../helpers'
import WishlistButton from '@components/wishlist/WishlistButton' import WishlistButton from '@components/wishlist/WishlistButton'
interface Props { interface Props {
className?: string
children?: any children?: any
product: Product product: Product
wishlist?: boolean className?: string
} }
const ProductView: FC<Props> = ({ product, wishlist = false }) => { const ProductView: FC<Props> = ({ product }) => {
const addItem = useAddItem() const addItem = useAddItem()
const { price } = usePrice({ const { price } = usePrice({
amount: product.price.value, amount: product.price.value,
@ -31,12 +26,18 @@ const ProductView: FC<Props> = ({ product, wishlist = false }) => {
}) })
const { openSidebar } = useUI() const { openSidebar } = useUI()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [choices, setChoices] = useState<SelectedOptions>({ const [choices, setChoices] = useState<SelectedOptions>({})
size: null,
color: null, useEffect(() => {
}) // Selects the default option
product.variants[0].options?.forEach((v) => {
setChoices((choices) => ({
...choices,
[v.displayName.toLowerCase()]: v.values[0].label.toLowerCase(),
}))
})
}, [])
// Select the correct variant based on choices
const variant = getVariant(product, choices) const variant = getVariant(product, choices)
const addToCart = async () => { const addToCart = async () => {
@ -101,7 +102,6 @@ const ProductView: FC<Props> = ({ product, wishlist = false }) => {
</ProductSlider> </ProductSlider>
</div> </div>
</div> </div>
<div className={s.sidebar}> <div className={s.sidebar}>
<section> <section>
{product.options?.map((opt) => ( {product.options?.map((opt) => (
@ -136,7 +136,7 @@ const ProductView: FC<Props> = ({ product, wishlist = false }) => {
))} ))}
<div className="pb-14 break-words w-full max-w-xl"> <div className="pb-14 break-words w-full max-w-xl">
<Text html={product.description} /> <Text html={product.descriptionHtml || product.description} />
</div> </div>
</section> </section>
<div> <div>
@ -146,17 +146,16 @@ const ProductView: FC<Props> = ({ product, wishlist = false }) => {
className={s.button} className={s.button}
onClick={addToCart} onClick={addToCart}
loading={loading} loading={loading}
disabled={!variant && product.options.length > 0}
> >
Add to Cart Add to Cart
</Button> </Button>
</div> </div>
</div> </div>
{wishlist && ( {process.env.COMMERCE_WISHLIST_ENABLED && (
<WishlistButton <WishlistButton
className={s.wishlistButton} className={s.wishlistButton}
productId={product.id} productId={product.id}
variant={product.variants[0]!} variant={product.variants[0]! as any}
/> />
)} )}
</div> </div>

View File

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

View File

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

View File

@ -1,9 +1,5 @@
import type { Product } from '@commerce/types' import type { Product } from '@commerce/types'
export type SelectedOptions = Record<string, string | null>
export type SelectedOptions = {
size: string | null
color: string | null
}
export function getVariant(product: Product, opts: SelectedOptions) { export function getVariant(product: Product, opts: SelectedOptions) {
const variant = product.variants.find((variant) => { const variant = product.variants.find((variant) => {

View File

@ -3,10 +3,6 @@
@apply grid grid-cols-1 gap-0; @apply grid grid-cols-1 gap-0;
min-height: var(--row-height); min-height: var(--row-height);
@screen lg {
@apply grid-cols-3 grid-rows-2;
}
& > * { & > * {
@apply row-span-1 bg-transparent box-border overflow-hidden; @apply row-span-1 bg-transparent box-border overflow-hidden;
height: 500px; height: 500px;
@ -19,6 +15,17 @@
} }
} }
@screen lg {
.root {
@apply grid-cols-3 grid-rows-2;
}
.root & > * {
@apply col-span-1;
height: inherit;
}
}
.default { .default {
& > * { & > * {
@apply bg-transparent; @apply bg-transparent;

View File

@ -1,6 +1,9 @@
.root { .root {
@apply mx-auto grid grid-cols-1 py-32 gap-4; @apply mx-auto grid grid-cols-1 py-32 gap-4;
@screen md { }
@screen md {
.root {
@apply grid-cols-2; @apply grid-cols-2;
} }
} }

View File

@ -21,7 +21,7 @@ const Hero: FC<Props> = ({ headline, description }) => {
<p className="mt-5 text-xl leading-7 text-accent-2 text-white"> <p className="mt-5 text-xl leading-7 text-accent-2 text-white">
{description} {description}
</p> </p>
<Link href="/blog"> <Link href="/">
<a className="text-white pt-3 font-bold hover:underline flex flex-row cursor-pointer w-max-content"> <a className="text-white pt-3 font-bold hover:underline flex flex-row cursor-pointer w-max-content">
Read it here Read it here
<RightArrow width="20" heigh="20" className="ml-1" /> <RightArrow width="20" heigh="20" className="ml-1" />

View File

@ -8,6 +8,7 @@ export interface State {
displayToast: boolean displayToast: boolean
modalView: string modalView: string
toastText: string toastText: string
userAvatar: string
} }
const initialState = { const initialState = {
@ -17,6 +18,7 @@ const initialState = {
modalView: 'LOGIN_VIEW', modalView: 'LOGIN_VIEW',
displayToast: false, displayToast: false,
toastText: '', toastText: '',
userAvatar: '',
} }
type Action = type Action =
@ -57,7 +59,12 @@ type Action =
value: string value: string
} }
type MODAL_VIEWS = 'SIGNUP_VIEW' | 'LOGIN_VIEW' | 'FORGOT_VIEW' type MODAL_VIEWS =
| 'SIGNUP_VIEW'
| 'LOGIN_VIEW'
| 'FORGOT_VIEW'
| 'NEW_SHIPPING_ADDRESS'
| 'NEW_PAYMENT_METHOD'
type ToastText = string type ToastText = string
export const UIContext = React.createContext<State | any>(initialState) export const UIContext = React.createContext<State | any>(initialState)

View File

@ -1,13 +1,12 @@
import React, { FC, useState } from 'react' import React, { FC, useState } from 'react'
import cn from 'classnames' import cn from 'classnames'
import { Heart } from '@components/icons'
import { useUI } from '@components/ui' import { useUI } from '@components/ui'
import type { Product, ProductVariant } from '@commerce/types' import { Heart } from '@components/icons'
import useCustomer from '@framework/customer/use-customer'
import useAddItem from '@framework/wishlist/use-add-item' import useAddItem from '@framework/wishlist/use-add-item'
import useCustomer from '@framework/customer/use-customer'
import useWishlist from '@framework/wishlist/use-wishlist'
import useRemoveItem from '@framework/wishlist/use-remove-item' import useRemoveItem from '@framework/wishlist/use-remove-item'
import useWishlist from '@framework/wishlist/use-add-item' import type { Product, ProductVariant } from '@commerce/types'
type Props = { type Props = {
productId: Product['id'] productId: Product['id']
@ -27,8 +26,12 @@ const WishlistButton: FC<Props> = ({
const { openModal, setModalView } = useUI() const { openModal, setModalView } = useUI()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// @ts-ignore Wishlist is not always enabled
const itemInWishlist = data?.items?.find( const itemInWishlist = data?.items?.find(
(item) => item.product_id === productId && item.variant_id === variant.id // @ts-ignore Wishlist is not always enabled
(item) =>
item.product_id === Number(productId) &&
(item.variant_id as any) === Number(variant.id)
) )
const handleWishlistChange = async (e: any) => { const handleWishlistChange = async (e: any) => {

View File

@ -22,7 +22,8 @@ const WishlistCard: FC<Props> = ({ product }) => {
baseAmount: product.prices?.retailPrice?.value, baseAmount: product.prices?.retailPrice?.value,
currencyCode: product.prices?.price?.currencyCode!, currencyCode: product.prices?.price?.currencyCode!,
}) })
const removeItem = useRemoveItem({ includeProducts: true }) // @ts-ignore Wishlist is not always enabled
const removeItem = useRemoveItem({ wishlist: { includeProducts: true } })
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [removing, setRemoving] = useState(false) const [removing, setRemoving] = useState(false)
const addItem = useAddItem() const addItem = useAddItem()

View File

@ -1,12 +1,22 @@
{ {
"title": "ACME Storefront | Powered by Next.js Commerce", "title": "ACME Storefront | Powered by Next.js Commerce",
"titleTemplate": "%s - ACME Storefront", "titleTemplate": "%s - ACME Storefront",
"description": "Next.js Commerce -> https://www.nextjs.org/commerce", "description": "Next.js Commerce - https://www.nextjs.org/commerce",
"openGraph": { "openGraph": {
"title": "ACME Storefront | Powered by Next.js Commerce",
"description": "Next.js Commerce - https://www.nextjs.org/commerce",
"type": "website", "type": "website",
"locale": "en_IE", "locale": "en_IE",
"url": "https://nextjs.org/commerce", "url": "https://nextjs.org/commerce",
"site_name": "Next.js Commerce" "site_name": "Next.js Commerce",
"images": [
{
"url": "/card.png",
"width": 800,
"height": 600,
"alt": "Next.js Commerce"
}
]
}, },
"twitter": { "twitter": {
"handle": "@nextjs", "handle": "@nextjs",

View File

@ -1 +0,0 @@
# Roadmap

View File

@ -0,0 +1,8 @@
COMMERCE_PROVIDER=bigcommerce
BIGCOMMERCE_STOREFRONT_API_URL=
BIGCOMMERCE_STOREFRONT_API_TOKEN=
BIGCOMMERCE_STORE_API_URL=
BIGCOMMERCE_STORE_API_TOKEN=
BIGCOMMERCE_STORE_API_CLIENT_ID=
BIGCOMMERCE_CHANNEL_ID=

View File

@ -1,45 +1,34 @@
# Table of Contents # Bigcommerce Provider
- [BigCommerce Storefront Data Hooks](#bigcommerce-storefront-data-hooks) **Demo:** https://bigcommerce.demo.vercel.store/
- [Installation](#installation)
- [General Usage](#general-usage)
- [CommerceProvider](#commerceprovider)
- [useLogin hook](#uselogin-hook)
- [useLogout](#uselogout)
- [useCustomer](#usecustomer)
- [useSignup](#usesignup)
- [usePrice](#useprice)
- [Cart Hooks](#cart-hooks)
- [useCart](#usecart)
- [useAddItem](#useadditem)
- [useUpdateItem](#useupdateitem)
- [useRemoveItem](#useremoveitem)
- [Wishlist Hooks](#wishlist-hooks)
- [Product Hooks and API](#product-hooks-and-api)
- [useSearch](#usesearch)
- [getAllProducts](#getallproducts)
- [getProduct](#getproduct)
- [More](#more)
# BigCommerce Storefront Data Hooks With the deploy button below you'll be able to have a [BigCommerce](https://www.bigcommerce.com/) account and a store that works with this starter:
> This project is under active development, new features and updates will be continuously added over time [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-description=An%20all-in-one%20starter%20kit%20for%20high-performance%20e-commerce%20sites.&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&integration-ids=oac_MuWZiE4jtmQ2ejZQaQ7ncuDT)
UI hooks and data fetching methods built from the ground up for e-commerce applications written in React, that use BigCommerce as a headless e-commerce platform. The package provides: If you already have a BigCommerce account and want to use your current store, then copy the `.env.template` file in this directory to `.env.local` in the main directory (which will be ignored by Git):
- Code splitted hooks for data fetching using [SWR](https://swr.vercel.app/), and to handle common user actions ```bash
- Code splitted data fetching methods for initial data population and static generation of content cp framework/bigcommerce/.env.template .env.local
- Helpers to create the API endpoints that connect to the hooks, very well suited for Next.js applications
## Installation
To install:
```
yarn add storefront-data-hooks
``` ```
After install, the first thing you do is: <b>set your environment variables</b> in `.env.local` Then, set the environment variables in `.env.local` to match the ones from your store.
## Contribute
Our commitment to Open Source can be found [here](https://vercel.com/oss).
If you find an issue with the provider or want a new feature, feel free to open a PR or [create a new issue](https://github.com/vercel/commerce/issues).
## Troubleshoot
<details>
<summary>I already own a BigCommerce store. What should I do?</summary>
<br>
First thing you do is: <b>set your environment variables</b>
<br>
<br>
.env.local
```sh ```sh
BIGCOMMERCE_STOREFRONT_API_URL=<> BIGCOMMERCE_STOREFRONT_API_URL=<>
@ -47,333 +36,24 @@ BIGCOMMERCE_STOREFRONT_API_TOKEN=<>
BIGCOMMERCE_STORE_API_URL=<> BIGCOMMERCE_STORE_API_URL=<>
BIGCOMMERCE_STORE_API_TOKEN=<> BIGCOMMERCE_STORE_API_TOKEN=<>
BIGCOMMERCE_STORE_API_CLIENT_ID=<> BIGCOMMERCE_STORE_API_CLIENT_ID=<>
BIGCOMMERCE_CHANNEL_ID=<>
``` ```
## General Usage If your project was started with a "Deploy with Vercel" button, you can use Vercel's CLI to retrieve these credentials.
### CommerceProvider 1. Install Vercel CLI: `npm i -g vercel`
2. Link local instance with Vercel and Github accounts (creates .vercel file): `vercel link`
3. Download your environment variables: `vercel env pull .env.local`
This component is a provider pattern component that creates commerce context for it's children. It takes config values for the locale and an optional `fetcherRef` object for data fetching. Next, you're free to customize the starter. More updates coming soon. Stay tuned.
```jsx </details>
...
import { CommerceProvider } from '@bigcommerce/storefront-data-hooks'
const App = ({ locale = 'en-US', children }) => { <details>
return ( <summary>BigCommerce shows a Coming Soon page and requests a Preview Code</summary>
<CommerceProvider locale={locale}> <br>
{children} After Email confirmation, Checkout should be manually enabled through BigCommerce platform. Look for "Review & test your store" section through BigCommerce's dashboard.
</CommerceProvider> <br>
) <br>
} BigCommerce team has been notified and they plan to add more detailed about this subject.
... </details>
```
### useLogin hook
Hook for bigcommerce user login functionality, returns `login` function to handle user login.
```jsx
...
import useLogin from '@bigcommerce/storefront-data-hooks/use-login'
const LoginView = () => {
const login = useLogin()
const handleLogin = async () => {
await login({
email,
password,
})
}
return (
<form onSubmit={handleLogin}>
{children}
</form>
)
}
...
```
### useLogout
Hook to logout user.
```jsx
...
import useLogout from '@bigcommerce/storefront-data-hooks/use-logout'
const LogoutLink = () => {
const logout = useLogout()
return (
<a onClick={() => logout()}>
Logout
</a>
)
}
```
### useCustomer
Hook for getting logged in customer data, and fetching customer info.
```jsx
...
import useCustomer from '@bigcommerce/storefront-data-hooks/use-customer'
...
const Profile = () => {
const { data } = useCustomer()
if (!data) {
return null
}
return (
<div>Hello, {data.firstName}</div>
)
}
```
### useSignup
Hook for bigcommerce user signup, returns `signup` function to handle user signups.
```jsx
...
import useSignup from '@bigcommerce/storefront-data-hooks/use-login'
const SignupView = () => {
const signup = useSignup()
const handleSignup = async () => {
await signup({
email,
firstName,
lastName,
password,
})
}
return (
<form onSubmit={handleSignup}>
{children}
</form>
)
}
...
```
### usePrice
Helper hook to format price according to commerce locale, and return discount if available.
```jsx
import usePrice from '@bigcommerce/storefront-data-hooks/use-price'
...
const { price, discount, basePrice } = usePrice(
data && {
amount: data.cart_amount,
currencyCode: data.currency.code,
}
)
...
```
## Cart Hooks
### useCart
Returns the current cart data for use
```jsx
...
import useCart from '@bigcommerce/storefront-data-hooks/cart/use-cart'
const countItem = (count: number, item: LineItem) => count + item.quantity
const CartNumber = () => {
const { data } = useCart()
const itemsCount = data?.lineItems.reduce(countItem, 0) ?? 0
return itemsCount > 0 ? <span>{itemsCount}</span> : null
}
```
### useAddItem
```jsx
...
import useAddItem from '@bigcommerce/storefront-data-hooks/cart/use-add-item'
const AddToCartButton = ({ productId, variantId }) => {
const addItem = useAddItem()
const addToCart = async () => {
await addItem({
productId,
variantId,
})
}
return <button onClick={addToCart}>Add To Cart</button>
}
...
```
### useUpdateItem
```jsx
...
import useUpdateItem from '@bigcommerce/storefront-data-hooks/cart/use-update-item'
const CartItem = ({ item }) => {
const [quantity, setQuantity] = useState(item.quantity)
const updateItem = useUpdateItem(item)
const updateQuantity = async (e) => {
const val = e.target.value
await updateItem({ quantity: val })
}
return (
<input
type="number"
max={99}
min={0}
value={quantity}
onChange={updateQuantity}
/>
)
}
...
```
### useRemoveItem
Provided with a cartItemId, will remove an item from the cart:
```jsx
...
import useRemoveItem from '@bigcommerce/storefront-data-hooks/cart/use-remove-item'
const RemoveButton = ({ item }) => {
const removeItem = useRemoveItem()
const handleRemove = async () => {
await removeItem({ id: item.id })
}
return <button onClick={handleRemove}>Remove</button>
}
...
```
## Wishlist Hooks
Wishlist hooks are similar to cart hooks. See the below example for how to use `useWishlist`, `useAddItem`, and `useRemoveItem`.
```jsx
import useAddItem from '@bigcommerce/storefront-data-hooks/wishlist/use-add-item'
import useRemoveItem from '@bigcommerce/storefront-data-hooks/wishlist/use-remove-item'
import useWishlist from '@bigcommerce/storefront-data-hooks/wishlist/use-wishlist'
const WishlistButton = ({ productId, variant }) => {
const addItem = useAddItem()
const removeItem = useRemoveItem()
const { data } = useWishlist()
const { data: customer } = useCustomer()
const itemInWishlist = data?.items?.find(
(item) =>
item.product_id === productId &&
item.variant_id === variant?.node.entityId
)
const handleWishlistChange = async (e) => {
e.preventDefault()
if (!customer) {
return
}
if (itemInWishlist) {
await removeItem({ id: itemInWishlist.id! })
} else {
await addItem({
productId,
variantId: variant?.node.entityId!,
})
}
}
return (
<button onClick={handleWishlistChange}>
<Heart fill={itemInWishlist ? 'var(--pink)' : 'none'} />
</button>
)
}
```
## Product Hooks and API
### useSearch
`useSearch` handles searching the bigcommerce storefront product catalog by catalog, brand, and query string.
```jsx
...
import useSearch from '@bigcommerce/storefront-data-hooks/products/use-search'
const SearchPage = ({ searchString, category, brand, sortStr }) => {
const { data } = useSearch({
search: searchString || '',
categoryId: category?.entityId,
brandId: brand?.entityId,
sort: sortStr || '',
})
return (
<Grid layout="normal">
{data.products.map(({ node }) => (
<ProductCard key={node.path} product={node} />
))}
</Grid>
)
}
```
### getAllProducts
API function to retrieve a product list.
```js
import { getConfig } from '@bigcommerce/storefront-data-hooks/api'
import getAllProducts from '@bigcommerce/storefront-data-hooks/api/operations/get-all-products'
const { products } = await getAllProducts({
variables: { field: 'featuredProducts', first: 6 },
config,
preview,
})
```
### getProduct
API product to retrieve a single product when provided with the product
slug string.
```js
import { getConfig } from '@bigcommerce/storefront-data-hooks/api'
import getProduct from '@bigcommerce/storefront-data-hooks/api/operations/get-product'
const { product } = await getProduct({
variables: { slug },
config,
preview,
})
```
## More
Feel free to read through the source for more usage, and check the commerce vercel demo and commerce repo for usage examples: ([demo.vercel.store](https://demo.vercel.store/)) ([repo](https://github.com/vercel/commerce))

View File

@ -1,6 +1,11 @@
import type { ItemBody as WishlistItemBody } from '../wishlist' import type { ItemBody as WishlistItemBody } from '../wishlist'
import type { CartItemBody, OptionSelections } from '../../types' import type { CartItemBody, OptionSelections } from '../../types'
type BCWishlistItemBody = {
product_id: number
variant_id: number
}
type BCCartItemBody = { type BCCartItemBody = {
product_id: number product_id: number
variant_id: number variant_id: number
@ -8,9 +13,11 @@ type BCCartItemBody = {
option_selections?: OptionSelections option_selections?: OptionSelections
} }
export const parseWishlistItem = (item: WishlistItemBody) => ({ export const parseWishlistItem = (
product_id: item.productId, item: WishlistItemBody
variant_id: item.variantId, ): BCWishlistItemBody => ({
product_id: Number(item.productId),
variant_id: Number(item.variantId),
}) })
export const parseCartItem = (item: CartItemBody): BCCartItemBody => ({ export const parseCartItem = (item: CartItemBody): BCCartItemBody => ({

View File

@ -1,22 +1,18 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import type { HookFetcher } from '@commerce/utils/types' import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors' import { CommerceError } from '@commerce/utils/errors'
import useCommerceLogin from '@commerce/use-login' import useLogin, { UseLogin } from '@commerce/auth/use-login'
import type { LoginBody } from '../api/customers/login' import type { LoginBody } from '../api/customers/login'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
const defaultOpts = { export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<null, {}, LoginBody> = {
fetchOptions: {
url: '/api/bigcommerce/customers/login', url: '/api/bigcommerce/customers/login',
method: 'POST', method: 'POST',
} },
async fetcher({ input: { email, password }, options, fetch }) {
export type LoginInput = LoginBody
export const fetcher: HookFetcher<null, LoginBody> = (
options,
{ email, password },
fetch
) => {
if (!(email && password)) { if (!(email && password)) {
throw new CommerceError({ throw new CommerceError({
message: message:
@ -25,30 +21,20 @@ export const fetcher: HookFetcher<null, LoginBody> = (
} }
return fetch({ return fetch({
...defaultOpts,
...options, ...options,
body: { email, password }, body: { email, password },
}) })
} },
useHook: ({ fetch }) => () => {
export function extendHook(customFetcher: typeof fetcher) {
const useLogin = () => {
const { revalidate } = useCustomer() const { revalidate } = useCustomer()
const fn = useCommerceLogin<null, LoginInput>(defaultOpts, customFetcher)
return useCallback( return useCallback(
async function login(input: LoginInput) { async function login(input) {
const data = await fn(input) const data = await fetch({ input })
await revalidate() await revalidate()
return data return data
}, },
[fn] [fetch, revalidate]
) )
} },
useLogin.extend = extendHook
return useLogin
} }
export default extendHook(fetcher)

View File

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

View File

@ -1,22 +1,22 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import type { HookFetcher } from '@commerce/utils/types' import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors' import { CommerceError } from '@commerce/utils/errors'
import useCommerceSignup from '@commerce/use-signup' import useSignup, { UseSignup } from '@commerce/auth/use-signup'
import type { SignupBody } from '../api/customers/signup' import type { SignupBody } from '../api/customers/signup'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
const defaultOpts = { export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<null, {}, SignupBody, SignupBody> = {
fetchOptions: {
url: '/api/bigcommerce/customers/signup', url: '/api/bigcommerce/customers/signup',
method: 'POST', method: 'POST',
} },
async fetcher({
export type SignupInput = SignupBody input: { firstName, lastName, email, password },
export const fetcher: HookFetcher<null, SignupBody> = (
options, options,
{ firstName, lastName, email, password }, fetch,
fetch }) {
) => {
if (!(firstName && lastName && email && password)) { if (!(firstName && lastName && email && password)) {
throw new CommerceError({ throw new CommerceError({
message: message:
@ -25,30 +25,20 @@ export const fetcher: HookFetcher<null, SignupBody> = (
} }
return fetch({ return fetch({
...defaultOpts,
...options, ...options,
body: { firstName, lastName, email, password }, body: { firstName, lastName, email, password },
}) })
} },
useHook: ({ fetch }) => () => {
export function extendHook(customFetcher: typeof fetcher) {
const useSignup = () => {
const { revalidate } = useCustomer() const { revalidate } = useCustomer()
const fn = useCommerceSignup<null, SignupInput>(defaultOpts, customFetcher)
return useCallback( return useCallback(
async function signup(input: SignupInput) { async function signup(input) {
const data = await fn(input) const data = await fetch({ input })
await revalidate() await revalidate()
return data return data
}, },
[fn] [fetch, revalidate]
) )
} },
useSignup.extend = extendHook
return useSignup
} }
export default extendHook(fetcher)

View File

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

View File

@ -1,29 +1,24 @@
import type { MutationHandler } from '@commerce/utils/types' import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors' import { CommerceError } from '@commerce/utils/errors'
import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item' import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item'
import { normalizeCart } from '../lib/normalize' import { normalizeCart } from '../lib/normalize'
import type { import type {
AddCartItemBody,
Cart, Cart,
BigcommerceCart, BigcommerceCart,
CartItemBody, CartItemBody,
AddCartItemBody,
} from '../types' } from '../types'
import useCart from './use-cart' import useCart from './use-cart'
import { BigcommerceProvider } from '..'
const defaultOpts = { export default useAddItem as UseAddItem<typeof handler>
url: '/api/bigcommerce/cart',
method: 'POST',
}
export default useAddItem as UseAddItem<BigcommerceProvider, CartItemBody> export const handler: MutationHook<Cart, {}, CartItemBody> = {
export const handler: MutationHandler<Cart, {}, AddCartItemBody> = {
fetchOptions: { fetchOptions: {
url: '/api/bigcommerce/cart', url: '/api/bigcommerce/cart',
method: 'GET', method: 'POST',
}, },
async fetcher({ input: { item }, options, fetch }) { async fetcher({ input: item, options, fetch }) {
if ( if (
item.quantity && item.quantity &&
(!Number.isInteger(item.quantity) || item.quantity! < 1) (!Number.isInteger(item.quantity) || item.quantity! < 1)
@ -34,20 +29,22 @@ export const handler: MutationHandler<Cart, {}, AddCartItemBody> = {
} }
const data = await fetch<BigcommerceCart, AddCartItemBody>({ const data = await fetch<BigcommerceCart, AddCartItemBody>({
...defaultOpts,
...options, ...options,
body: { item }, body: { item },
}) })
return normalizeCart(data) return normalizeCart(data)
}, },
useHook() { useHook: ({ fetch }) => () => {
const { mutate } = useCart() const { mutate } = useCart()
return async function addItem({ input, fetch }) { return useCallback(
async function addItem(input) {
const data = await fetch({ input }) const data = await fetch({ input })
await mutate(data, false) await mutate(data, false)
return data return data
} },
[fetch, mutate]
)
}, },
} }

View File

@ -1,13 +1,12 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { HookHandler } from '@commerce/utils/types' import { SWRHook } from '@commerce/utils/types'
import useCart, { UseCart, FetchCartInput } from '@commerce/cart/use-cart' import useCart, { UseCart, FetchCartInput } from '@commerce/cart/use-cart'
import { normalizeCart } from '../lib/normalize' import { normalizeCart } from '../lib/normalize'
import type { Cart } from '../types' import type { Cart } from '../types'
import type { BigcommerceProvider } from '..'
export default useCart as UseCart<BigcommerceProvider> export default useCart as UseCart<typeof handler>
export const handler: HookHandler< export const handler: SWRHook<
Cart | null, Cart | null,
{}, {},
FetchCartInput, FetchCartInput,
@ -21,9 +20,9 @@ export const handler: HookHandler<
const data = cartId ? await fetch(options) : null const data = cartId ? await fetch(options) : null
return data && normalizeCart(data) return data && normalizeCart(data)
}, },
useHook({ input, useData }) { useHook: ({ useData }) => (input) => {
const response = useData({ const response = useData({
swrOptions: { revalidateOnFocus: false, ...input.swrOptions }, swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
}) })
return useMemo( return useMemo(

View File

@ -1,8 +1,12 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { HookFetcher } from '@commerce/utils/types' import type {
MutationHookContext,
HookFetcherContext,
} from '@commerce/utils/types'
import { ValidationError } from '@commerce/utils/errors' import { ValidationError } from '@commerce/utils/errors'
import useCartRemoveItem, { import useRemoveItem, {
RemoveItemInput as UseRemoveItemInput, RemoveItemInput as RemoveItemInputBase,
UseRemoveItem,
} from '@commerce/cart/use-remove-item' } from '@commerce/cart/use-remove-item'
import { normalizeCart } from '../lib/normalize' import { normalizeCart } from '../lib/normalize'
import type { import type {
@ -13,41 +17,41 @@ import type {
} from '../types' } from '../types'
import useCart from './use-cart' import useCart from './use-cart'
const defaultOpts = {
url: '/api/bigcommerce/cart',
method: 'DELETE',
}
export type RemoveItemFn<T = any> = T extends LineItem export type RemoveItemFn<T = any> = T extends LineItem
? (input?: RemoveItemInput<T>) => Promise<Cart | null> ? (input?: RemoveItemInput<T>) => Promise<Cart | null>
: (input: RemoveItemInput<T>) => Promise<Cart | null> : (input: RemoveItemInput<T>) => Promise<Cart | null>
export type RemoveItemInput<T = any> = T extends LineItem export type RemoveItemInput<T = any> = T extends LineItem
? Partial<UseRemoveItemInput> ? Partial<RemoveItemInputBase>
: UseRemoveItemInput : RemoveItemInputBase
export const fetcher: HookFetcher<Cart | null, RemoveCartItemBody> = async ( export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler = {
fetchOptions: {
url: '/api/bigcommerce/cart',
method: 'DELETE',
},
async fetcher({
input: { itemId },
options, options,
{ itemId }, fetch,
fetch }: HookFetcherContext<RemoveCartItemBody>) {
) => {
const data = await fetch<BigcommerceCart>({ const data = await fetch<BigcommerceCart>({
...defaultOpts,
...options, ...options,
body: { itemId }, body: { itemId },
}) })
return normalizeCart(data) return normalizeCart(data)
} },
useHook: ({
export function extendHook(customFetcher: typeof fetcher) { fetch,
const useRemoveItem = <T extends LineItem | undefined = undefined>( }: MutationHookContext<Cart | null, RemoveCartItemBody>) => <
item?: T T extends LineItem | undefined = undefined
>(
ctx: { item?: T } = {}
) => { ) => {
const { item } = ctx
const { mutate } = useCart() const { mutate } = useCart()
const fn = useCartRemoveItem<Cart | null, RemoveCartItemBody>(
defaultOpts,
customFetcher
)
const removeItem: RemoveItemFn<LineItem> = async (input) => { const removeItem: RemoveItemFn<LineItem> = async (input) => {
const itemId = input?.id ?? item?.id const itemId = input?.id ?? item?.id
@ -57,17 +61,11 @@ export function extendHook(customFetcher: typeof fetcher) {
}) })
} }
const data = await fn({ itemId }) const data = await fetch({ input: { itemId } })
await mutate(data, false) await mutate(data, false)
return data return data
} }
return useCallback(removeItem as RemoveItemFn<T>, [fn, mutate]) return useCallback(removeItem as RemoveItemFn<T>, [fetch, mutate])
} },
useRemoveItem.extend = extendHook
return useRemoveItem
} }
export default extendHook(fetcher)

View File

@ -1,9 +1,13 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import type { HookFetcher } from '@commerce/utils/types' import type {
MutationHookContext,
HookFetcherContext,
} from '@commerce/utils/types'
import { ValidationError } from '@commerce/utils/errors' import { ValidationError } from '@commerce/utils/errors'
import useCartUpdateItem, { import useUpdateItem, {
UpdateItemInput as UseUpdateItemInput, UpdateItemInput as UpdateItemInputBase,
UseUpdateItem,
} from '@commerce/cart/use-update-item' } from '@commerce/cart/use-update-item'
import { normalizeCart } from '../lib/normalize' import { normalizeCart } from '../lib/normalize'
import type { import type {
@ -12,27 +16,33 @@ import type {
BigcommerceCart, BigcommerceCart,
LineItem, LineItem,
} from '../types' } from '../types'
import { fetcher as removeFetcher } from './use-remove-item' import { handler as removeItemHandler } from './use-remove-item'
import useCart from './use-cart' import useCart from './use-cart'
const defaultOpts = { export type UpdateItemInput<T = any> = T extends LineItem
? Partial<UpdateItemInputBase<LineItem>>
: UpdateItemInputBase<LineItem>
export default useUpdateItem as UseUpdateItem<typeof handler>
export const handler = {
fetchOptions: {
url: '/api/bigcommerce/cart', url: '/api/bigcommerce/cart',
method: 'PUT', method: 'PUT',
} },
async fetcher({
export type UpdateItemInput<T = any> = T extends LineItem input: { itemId, item },
? Partial<UseUpdateItemInput<LineItem>>
: UseUpdateItemInput<LineItem>
export const fetcher: HookFetcher<Cart | null, UpdateCartItemBody> = async (
options, options,
{ itemId, item }, fetch,
fetch }: HookFetcherContext<UpdateCartItemBody>) {
) => {
if (Number.isInteger(item.quantity)) { if (Number.isInteger(item.quantity)) {
// Also allow the update hook to remove an item if the quantity is lower than 1 // Also allow the update hook to remove an item if the quantity is lower than 1
if (item.quantity! < 1) { if (item.quantity! < 1) {
return removeFetcher(null, { itemId }, fetch) return removeItemHandler.fetcher({
options: removeItemHandler.fetchOptions,
input: { itemId },
fetch,
})
} }
} else if (item.quantity) { } else if (item.quantity) {
throw new ValidationError({ throw new ValidationError({
@ -41,23 +51,24 @@ export const fetcher: HookFetcher<Cart | null, UpdateCartItemBody> = async (
} }
const data = await fetch<BigcommerceCart, UpdateCartItemBody>({ const data = await fetch<BigcommerceCart, UpdateCartItemBody>({
...defaultOpts,
...options, ...options,
body: { itemId, item }, body: { itemId, item },
}) })
return normalizeCart(data) return normalizeCart(data)
} },
useHook: ({
function extendHook(customFetcher: typeof fetcher, cfg?: { wait?: number }) { fetch,
const useUpdateItem = <T extends LineItem | undefined = undefined>( }: MutationHookContext<Cart | null, UpdateCartItemBody>) => <
T extends LineItem | undefined = undefined
>(
ctx: {
item?: T item?: T
wait?: number
} = {}
) => { ) => {
const { mutate } = useCart() const { item } = ctx
const fn = useCartUpdateItem<Cart | null, UpdateCartItemBody>( const { mutate } = useCart() as any
defaultOpts,
customFetcher
)
return useCallback( return useCallback(
debounce(async (input: UpdateItemInput<T>) => { debounce(async (input: UpdateItemInput<T>) => {
@ -71,20 +82,16 @@ function extendHook(customFetcher: typeof fetcher, cfg?: { wait?: number }) {
}) })
} }
const data = await fn({ const data = await fetch({
input: {
itemId, itemId,
item: { productId, variantId, quantity: input.quantity }, item: { productId, variantId, quantity: input.quantity },
},
}) })
await mutate(data, false) await mutate(data, false)
return data return data
}, cfg?.wait ?? 500), }, ctx.wait ?? 500),
[fn, mutate] [fetch, mutate]
) )
} },
useUpdateItem.extend = extendHook
return useUpdateItem
} }
export default extendHook(fetcher)

View File

@ -0,0 +1,6 @@
{
"provider": "bigcommerce",
"features": {
"wishlist": true
}
}

View File

@ -68,14 +68,15 @@ async function getCustomerWishlist({
const productsById = graphqlData.products.reduce<{ const productsById = graphqlData.products.reduce<{
[k: number]: ProductEdge [k: number]: ProductEdge
}>((prods, p) => { }>((prods, p) => {
prods[Number(p.node.entityId)] = p as any prods[Number(p.id)] = p as any
return prods return prods
}, {}) }, {})
// Populate the wishlist items with the graphql products // Populate the wishlist items with the graphql products
wishlist.items.forEach((item) => { wishlist.items.forEach((item) => {
const product = item && productsById[item.product_id!] const product = item && productsById[item.product_id!]
if (item && product) { if (item && product) {
item.product = product.node // @ts-ignore Fix this type when the wishlist type is properly defined
item.product = product
} }
}) })
} }

View File

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

View File

@ -0,0 +1,8 @@
const commerce = require('./commerce.config.json')
module.exports = {
commerce,
images: {
domains: ['cdn11.bigcommerce.com'],
},
}

View File

@ -2,7 +2,7 @@ import type { GetProductQuery, GetProductQueryVariables } from '../schema'
import setProductLocaleMeta from '../api/utils/set-product-locale-meta' import setProductLocaleMeta from '../api/utils/set-product-locale-meta'
import { productInfoFragment } from '../api/fragments/product' import { productInfoFragment } from '../api/fragments/product'
import { BigcommerceConfig, getConfig } from '../api' import { BigcommerceConfig, getConfig } from '../api'
import { normalizeProduct } from '@framework/lib/normalize' import { normalizeProduct } from '../lib/normalize'
import type { Product } from '@commerce/types' import type { Product } from '@commerce/types'
export const getProductQuery = /* GraphQL */ ` export const getProductQuery = /* GraphQL */ `

View File

@ -1,2 +1,2 @@
export * from '@commerce/use-price' export * from '@commerce/product/use-price'
export { default } from '@commerce/use-price' export { default } from '@commerce/product/use-price'

View File

@ -1,9 +1,8 @@
import { HookHandler } from '@commerce/utils/types' import { SWRHook } from '@commerce/utils/types'
import useSearch, { UseSearch } from '@commerce/product/use-search' import useSearch, { UseSearch } from '@commerce/product/use-search'
import type { SearchProductsData } from '../api/catalog/products' import type { SearchProductsData } from '../api/catalog/products'
import type { BigcommerceProvider } from '..'
export default useSearch as UseSearch<BigcommerceProvider> export default useSearch as UseSearch<typeof handler>
export type SearchProductsInput = { export type SearchProductsInput = {
search?: string search?: string
@ -12,7 +11,7 @@ export type SearchProductsInput = {
sort?: string sort?: string
} }
export const handler: HookHandler< export const handler: SWRHook<
SearchProductsData, SearchProductsData,
SearchProductsInput, SearchProductsInput,
SearchProductsInput SearchProductsInput
@ -37,7 +36,7 @@ export const handler: HookHandler<
method: options.method, method: options.method,
}) })
}, },
useHook({ input, useData }) { useHook: ({ useData }) => (input = {}) => {
return useData({ return useData({
input: [ input: [
['search', input.search], ['search', input.search],

View File

@ -1,18 +1,34 @@
import { handler as useCart } from './cart/use-cart' import { handler as useCart } from './cart/use-cart'
import { handler as useAddItem } from './cart/use-add-item' import { handler as useAddItem } from './cart/use-add-item'
import { handler as useUpdateItem } from './cart/use-update-item'
import { handler as useRemoveItem } from './cart/use-remove-item'
import { handler as useWishlist } from './wishlist/use-wishlist' import { handler as useWishlist } from './wishlist/use-wishlist'
import { handler as useWishlistAddItem } from './wishlist/use-add-item'
import { handler as useWishlistRemoveItem } from './wishlist/use-remove-item'
import { handler as useCustomer } from './customer/use-customer' import { handler as useCustomer } from './customer/use-customer'
import { handler as useSearch } from './product/use-search' import { handler as useSearch } from './product/use-search'
import { handler as useLogin } from './auth/use-login'
import { handler as useLogout } from './auth/use-logout'
import { handler as useSignup } from './auth/use-signup'
import fetcher from './fetcher' import fetcher from './fetcher'
export const bigcommerceProvider = { export const bigcommerceProvider = {
locale: 'en-us', locale: 'en-us',
cartCookie: 'bc_cartId', cartCookie: 'bc_cartId',
fetcher, fetcher,
cart: { useCart, useAddItem }, cart: { useCart, useAddItem, useUpdateItem, useRemoveItem },
wishlist: { useWishlist }, wishlist: {
useWishlist,
useAddItem: useWishlistAddItem,
useRemoveItem: useWishlistRemoveItem,
},
customer: { useCustomer }, customer: { useCustomer },
products: { useSearch }, products: { useSearch },
auth: { useLogin, useLogout, useSignup },
} }
export type BigcommerceProvider = typeof bigcommerceProvider export type BigcommerceProvider = typeof bigcommerceProvider

View File

@ -43,9 +43,6 @@ export type CartItemBody = Core.CartItemBody & {
optionSelections?: OptionSelections optionSelections?: OptionSelections
} }
type X = Core.CartItemBody extends CartItemBody ? any : never
type Y = CartItemBody extends Core.CartItemBody ? any : never
export type GetCartHandlerBody = Core.GetCartHandlerBody export type GetCartHandlerBody = Core.GetCartHandlerBody
export type AddCartItemBody = Core.AddCartItemBody<CartItemBody> export type AddCartItemBody = Core.AddCartItemBody<CartItemBody>

View File

@ -1,4 +1,3 @@
export { default as useAddItem } from './use-add-item' export { default as useAddItem } from './use-add-item'
export { default as useWishlist } from './use-wishlist' export { default as useWishlist } from './use-wishlist'
export { default as useRemoveItem } from './use-remove-item' export { default as useRemoveItem } from './use-remove-item'
export { default as useWishlistActions } from './use-wishlist-actions'

View File

@ -1,43 +1,24 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { HookFetcher } from '@commerce/utils/types' import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors' import { CommerceError } from '@commerce/utils/errors'
import useWishlistAddItem, { import useAddItem, { UseAddItem } from '@commerce/wishlist/use-add-item'
AddItemInput,
} from '@commerce/wishlist/use-add-item'
import { UseWishlistInput } from '@commerce/wishlist/use-wishlist'
import type { ItemBody, AddItemBody } from '../api/wishlist' import type { ItemBody, AddItemBody } from '../api/wishlist'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
import useWishlist from './use-wishlist' import useWishlist from './use-wishlist'
import type { BigcommerceProvider } from '..'
const defaultOpts = { export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<any, {}, ItemBody, AddItemBody> = {
fetchOptions: {
url: '/api/bigcommerce/wishlist', url: '/api/bigcommerce/wishlist',
method: 'POST', method: 'POST',
} },
useHook: ({ fetch }) => () => {
// export type AddItemInput = ItemBody
export const fetcher: HookFetcher<any, AddItemBody> = (
options,
{ item },
fetch
) => {
// TODO: add validations before doing the fetch
return fetch({
...defaultOpts,
...options,
body: { item },
})
}
export function extendHook(customFetcher: typeof fetcher) {
const useAddItem = (opts?: UseWishlistInput<BigcommerceProvider>) => {
const { data: customer } = useCustomer() const { data: customer } = useCustomer()
const { revalidate } = useWishlist(opts) const { revalidate } = useWishlist()
const fn = useWishlistAddItem(defaultOpts, customFetcher)
return useCallback( return useCallback(
async function addItem(input: AddItemInput<any>) { async function addItem(item) {
if (!customer) { if (!customer) {
// A signed customer is required in order to have a wishlist // A signed customer is required in order to have a wishlist
throw new CommerceError({ throw new CommerceError({
@ -45,17 +26,12 @@ export function extendHook(customFetcher: typeof fetcher) {
}) })
} }
const data = await fn({ item: input }) // TODO: add validations before doing the fetch
const data = await fetch({ input: { item } })
await revalidate() await revalidate()
return data return data
}, },
[fn, revalidate, customer] [fetch, revalidate, customer]
) )
} },
useAddItem.extend = extendHook
return useAddItem
} }
export default extendHook(fetcher)

View File

@ -1,43 +1,32 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { HookFetcher } from '@commerce/utils/types' import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors' import { CommerceError } from '@commerce/utils/errors'
import useWishlistRemoveItem from '@commerce/wishlist/use-remove-item' import useRemoveItem, {
import type { RemoveItemBody } from '../api/wishlist' RemoveItemInput,
UseRemoveItem,
} from '@commerce/wishlist/use-remove-item'
import type { RemoveItemBody, Wishlist } from '../api/wishlist'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
import useWishlist from './use-wishlist' import useWishlist, { UseWishlistInput } from './use-wishlist'
const defaultOpts = { export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler: MutationHook<
Wishlist | null,
{ wishlist?: UseWishlistInput },
RemoveItemInput,
RemoveItemBody
> = {
fetchOptions: {
url: '/api/bigcommerce/wishlist', url: '/api/bigcommerce/wishlist',
method: 'DELETE', method: 'DELETE',
} },
useHook: ({ fetch }) => ({ wishlist } = {}) => {
export type RemoveItemInput = {
id: string | number
}
export const fetcher: HookFetcher<any | null, RemoveItemBody> = (
options,
{ itemId },
fetch
) => {
return fetch({
...defaultOpts,
...options,
body: { itemId },
})
}
export function extendHook(customFetcher: typeof fetcher) {
const useRemoveItem = (opts?: any) => {
const { data: customer } = useCustomer() const { data: customer } = useCustomer()
const { revalidate } = useWishlist(opts) const { revalidate } = useWishlist(wishlist)
const fn = useWishlistRemoveItem<any | null, RemoveItemBody>(
defaultOpts,
customFetcher
)
return useCallback( return useCallback(
async function removeItem(input: RemoveItemInput) { async function removeItem(input) {
if (!customer) { if (!customer) {
// A signed customer is required in order to have a wishlist // A signed customer is required in order to have a wishlist
throw new CommerceError({ throw new CommerceError({
@ -45,17 +34,11 @@ export function extendHook(customFetcher: typeof fetcher) {
}) })
} }
const data = await fn({ itemId: String(input.id) }) const data = await fetch({ input: { itemId: String(input.id) } })
await revalidate() await revalidate()
return data return data
}, },
[fn, revalidate, customer] [fetch, revalidate, customer]
) )
} },
useRemoveItem.extend = extendHook
return useRemoveItem
} }
export default extendHook(fetcher)

View File

@ -1,11 +0,0 @@
import useAddItem from './use-add-item'
import useRemoveItem from './use-remove-item'
// This hook is probably not going to be used, but it's here
// to show how a commerce should be structuring it
export default function useWishlistActions() {
const addItem = useAddItem()
const removeItem = useRemoveItem()
return { addItem, removeItem }
}

View File

@ -1,23 +1,24 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { HookHandler } from '@commerce/utils/types' import { SWRHook } from '@commerce/utils/types'
import useWishlist, { UseWishlist } from '@commerce/wishlist/use-wishlist' import useWishlist, { UseWishlist } from '@commerce/wishlist/use-wishlist'
import type { Wishlist } from '../api/wishlist' import type { Wishlist } from '../api/wishlist'
import useCustomer from '../customer/use-customer' import useCustomer from '../customer/use-customer'
import type { BigcommerceProvider } from '..'
export default useWishlist as UseWishlist<BigcommerceProvider> export type UseWishlistInput = { includeProducts?: boolean }
export const handler: HookHandler< export default useWishlist as UseWishlist<typeof handler>
export const handler: SWRHook<
Wishlist | null, Wishlist | null,
{ includeProducts?: boolean }, UseWishlistInput,
{ customerId?: number; includeProducts: boolean }, { customerId?: number } & UseWishlistInput,
{ isEmpty?: boolean } { isEmpty?: boolean }
> = { > = {
fetchOptions: { fetchOptions: {
url: '/api/bigcommerce/wishlist', url: '/api/bigcommerce/wishlist',
method: 'GET', method: 'GET',
}, },
fetcher({ input: { customerId, includeProducts }, options, fetch }) { async fetcher({ input: { customerId, includeProducts }, options, fetch }) {
if (!customerId) return null if (!customerId) return null
// Use a dummy base as we only care about the relative path // Use a dummy base as we only care about the relative path
@ -30,16 +31,16 @@ export const handler: HookHandler<
method: options.method, method: options.method,
}) })
}, },
useHook({ input, useData }) { useHook: ({ useData }) => (input) => {
const { data: customer } = useCustomer() const { data: customer } = useCustomer()
const response = useData({ const response = useData({
input: [ input: [
['customerId', (customer as any)?.id], ['customerId', customer?.entityId],
['includeProducts', input.includeProducts], ['includeProducts', input?.includeProducts],
], ],
swrOptions: { swrOptions: {
revalidateOnFocus: false, revalidateOnFocus: false,
...input.swrOptions, ...input?.swrOptions,
}, },
}) })

View File

@ -0,0 +1,334 @@
# Commerce Framework
- [Commerce Framework](#commerce-framework)
- [Commerce Hooks](#commerce-hooks)
- [CommerceProvider](#commerceprovider)
- [Authentication Hooks](#authentication-hooks)
- [useSignup](#usesignup)
- [useLogin](#uselogin)
- [useLogout](#uselogout)
- [Customer Hooks](#customer-hooks)
- [useCustomer](#usecustomer)
- [Product Hooks](#product-hooks)
- [usePrice](#useprice)
- [useSearch](#usesearch)
- [Cart Hooks](#cart-hooks)
- [useCart](#usecart)
- [useAddItem](#useadditem)
- [useUpdateItem](#useupdateitem)
- [useRemoveItem](#useremoveitem)
- [Wishlist Hooks](#wishlist-hooks)
- [Commerce API](#commerce-api)
- [More](#more)
The commerce framework ships multiple hooks and a Node.js API, both using an underlying headless e-commerce platform, which we call commerce providers.
The core features are:
- Code splitted hooks for data fetching using [SWR](https://swr.vercel.app/), and to handle common user actions
- A Node.js API for initial data population, static generation of content and for creating the API endpoints that connect to the hooks, if required.
> 👩‍🔬 If you would like to contribute a new provider, check the docs for [Adding a new Commerce Provider](./new-provider.md).
> 🚧 The core commerce framework is under active development, new features and updates will be continuously added over time. Breaking changes are expected while we finish the API.
## Commerce Hooks
A commerce hook is a [React hook](https://reactjs.org/docs/hooks-intro.html) that's connected to a commerce provider. They focus on user actions and data fetching of data that wasn't statically generated.
Data fetching hooks use [SWR](https://swr.vercel.app/) underneath and you're welcome to use any of its [return values](https://swr.vercel.app/docs/options#return-values) and [options](https://swr.vercel.app/docs/options#options). For example, using the `useCustomer` hook:
```jsx
const { data, isLoading, error } = useCustomer({
swrOptions: {
revalidateOnFocus: true,
},
})
```
### CommerceProvider
This component adds the provider config and handlers to the context of your React tree for it's children. You can optionally pass the `locale` to it:
```jsx
import { CommerceProvider } from '@framework'
const App = ({ locale = 'en-US', children }) => {
return <CommerceProvider locale={locale}>{children}</CommerceProvider>
}
```
## Authentication Hooks
### useSignup
Returns a _signup_ function that can be used to sign up the current visitor:
```jsx
import useSignup from '@framework/auth/use-signup'
const SignupView = () => {
const signup = useSignup()
const handleSignup = async () => {
await signup({
email,
firstName,
lastName,
password,
})
}
return <form onSubmit={handleSignup}>{children}</form>
}
```
### useLogin
Returns a _login_ function that can be used to sign in the current visitor into an existing customer:
```jsx
import useLogin from '@framework/auth/use-login'
const LoginView = () => {
const login = useLogin()
const handleLogin = async () => {
await login({
email,
password,
})
}
return <form onSubmit={handleLogin}>{children}</form>
}
```
### useLogout
Returns a _logout_ function that signs out the current customer when called.
```jsx
import useLogout from '@framework/auth/use-logout'
const LogoutButton = () => {
const logout = useLogout()
return (
<button type="button" onClick={() => logout()}>
Logout
</button>
)
}
```
## Customer Hooks
### useCustomer
Fetches and returns the data of the signed in customer:
```jsx
import useCustomer from '@framework/customer/use-customer'
const Profile = () => {
const { data, isLoading, error } = useCustomer()
if (isLoading) return <p>Loading...</p>
if (error) return <p>{error.message}</p>
if (!data) return null
return <div>Hello, {data.firstName}</div>
}
```
## Product Hooks
### usePrice
Helper hook to format price according to the commerce locale and currency code. It also handles discounts:
```jsx
import useCart from '@framework/cart/use-cart'
import usePrice from '@framework/product/use-price'
// ...
const { data } = useCart()
const { price, discount, basePrice } = usePrice(
data && {
amount: data.subtotalPrice,
currencyCode: data.currency.code,
// If `baseAmount` is used, a discount will be calculated
// baseAmount: number,
}
)
// ...
```
### useSearch
Fetches and returns the products that match a set of filters:
```jsx
import useSearch from '@framework/product/use-search'
const SearchPage = ({ searchString, category, brand, sortStr }) => {
const { data } = useSearch({
search: searchString || '',
categoryId: category?.entityId,
brandId: brand?.entityId,
sort: sortStr,
})
return (
<Grid layout="normal">
{data.products.map((product) => (
<ProductCard key={product.path} product={product} />
))}
</Grid>
)
}
```
## Cart Hooks
### useCart
Fetches and returns the data of the current cart:
```jsx
import useCart from '@framework/cart/use-cart'
const CartTotal = () => {
const { data, isLoading, isEmpty, error } = useCart()
if (isLoading) return <p>Loading...</p>
if (error) return <p>{error.message}</p>
if (isEmpty) return <p>The cart is empty</p>
return <p>The cart total is {data.totalPrice}</p>
}
```
### useAddItem
Returns a function that adds a new item to the cart when called, if this is the first item it will create the cart:
```jsx
import { useAddItem } from '@framework/cart'
const AddToCartButton = ({ productId, variantId }) => {
const addItem = useAddItem()
const addToCart = async () => {
await addItem({
productId,
variantId,
})
}
return <button onClick={addToCart}>Add To Cart</button>
}
```
### useUpdateItem
Returns a function that updates a current item in the cart when called, usually the quantity.
```jsx
import { useUpdateItem } from '@framework/cart'
const CartItemQuantity = ({ item }) => {
const [quantity, setQuantity] = useState(item.quantity)
const updateItem = useUpdateItem({ item })
const updateQuantity = async (e) => {
const val = e.target.value
setQuantity(val)
await updateItem({ quantity: val })
}
return (
<input
type="number"
max={99}
min={0}
value={quantity}
onChange={updateQuantity}
/>
)
}
```
If the `quantity` is lower than 1 the item will be removed from the cart.
### useRemoveItem
Returns a function that removes an item in the cart when called:
```jsx
import { useRemoveItem } from '@framework/cart'
const RemoveButton = ({ item }) => {
const removeItem = useRemoveItem()
const handleRemove = async () => {
await removeItem(item)
}
return <button onClick={handleRemove}>Remove</button>
}
```
## Wishlist Hooks
Wishlist hooks work just like [cart hooks](#cart-hooks). Feel free to check how those work first.
The example below shows how to use the `useWishlist`, `useAddItem` and `useRemoveItem` hooks:
```jsx
import { useWishlist, useAddItem, useRemoveItem } from '@framework/wishlist'
const WishlistButton = ({ productId, variant }) => {
const addItem = useAddItem()
const removeItem = useRemoveItem()
const { data, isLoading, isEmpty, error } = useWishlist()
if (isLoading) return <p>Loading...</p>
if (error) return <p>{error.message}</p>
if (isEmpty) return <p>The wihslist is empty</p>
const { data: customer } = useCustomer()
const itemInWishlist = data?.items?.find(
(item) => item.product_id === productId && item.variant_id === variant.id
)
const handleWishlistChange = async (e) => {
e.preventDefault()
if (!customer) return
if (itemInWishlist) {
await removeItem({ id: itemInWishlist.id })
} else {
await addItem({
productId,
variantId: variant.id,
})
}
}
return (
<button onClick={handleWishlistChange}>
<Heart fill={itemInWishlist ? 'var(--pink)' : 'none'} />
</button>
)
}
```
## Commerce API
While commerce hooks focus on client side data fetching and interactions, the commerce API focuses on static content generation for pages and API endpoints in a Node.js context.
> The commerce API is currently going through a refactor in https://github.com/vercel/commerce/pull/252 - We'll update the docs once the API is released.
## More
Feel free to read through the source for more usage, and check the commerce vercel demo and commerce repo for usage examples: ([demo.vercel.store](https://demo.vercel.store/)) ([repo](https://github.com/vercel/commerce))

View File

@ -2,6 +2,7 @@ import type { RequestInit, Response } from '@vercel/fetch'
export interface CommerceAPIConfig { export interface CommerceAPIConfig {
locale?: string locale?: string
locales?: string[]
commerceUrl: string commerceUrl: string
apiToken: string apiToken: string
cartCookie: string cartCookie: string

View File

@ -0,0 +1,19 @@
import { useHook, useMutationHook } from '../utils/use-hook'
import { mutationFetcher } from '../utils/default-fetcher'
import type { MutationHook, HookFetcherFn } from '../utils/types'
import type { Provider } from '..'
export type UseLogin<
H extends MutationHook<any, any, any> = MutationHook<null, {}, {}>
> = ReturnType<H['useHook']>
export const fetcher: HookFetcherFn<null, {}> = mutationFetcher
const fn = (provider: Provider) => provider.auth?.useLogin!
const useLogin: UseLogin = (...args) => {
const hook = useHook(fn)
return useMutationHook({ fetcher, ...hook })(...args)
}
export default useLogin

View File

@ -0,0 +1,19 @@
import { useHook, useMutationHook } from '../utils/use-hook'
import { mutationFetcher } from '../utils/default-fetcher'
import type { HookFetcherFn, MutationHook } from '../utils/types'
import type { Provider } from '..'
export type UseLogout<
H extends MutationHook<any, any, any> = MutationHook<null>
> = ReturnType<H['useHook']>
export const fetcher: HookFetcherFn<null> = mutationFetcher
const fn = (provider: Provider) => provider.auth?.useLogout!
const useLogout: UseLogout = (...args) => {
const hook = useHook(fn)
return useMutationHook({ fetcher, ...hook })(...args)
}
export default useLogout

View File

@ -0,0 +1,19 @@
import { useHook, useMutationHook } from '../utils/use-hook'
import { mutationFetcher } from '../utils/default-fetcher'
import type { HookFetcherFn, MutationHook } from '../utils/types'
import type { Provider } from '..'
export type UseSignup<
H extends MutationHook<any, any, any> = MutationHook<null>
> = ReturnType<H['useHook']>
export const fetcher: HookFetcherFn<null> = mutationFetcher
const fn = (provider: Provider) => provider.auth?.useSignup!
const useSignup: UseSignup = (...args) => {
const hook = useHook(fn)
return useMutationHook({ fetcher, ...hook })(...args)
}
export default useSignup

View File

@ -1,69 +1,23 @@
import { useCallback } from 'react' import { useHook, useMutationHook } from '../utils/use-hook'
import type { import { mutationFetcher } from '../utils/default-fetcher'
Prop, import type { HookFetcherFn, MutationHook } from '../utils/types'
HookFetcherFn,
UseHookInput,
UseHookResponse,
} from '../utils/types'
import type { Cart, CartItemBody, AddCartItemBody } from '../types' import type { Cart, CartItemBody, AddCartItemBody } from '../types'
import { Provider, useCommerce } from '..' import type { Provider } from '..'
import { BigcommerceProvider } from '@framework'
export type UseAddItemHandler<P extends Provider> = Prop< export type UseAddItem<
Prop<P, 'cart'>, H extends MutationHook<any, any, any> = MutationHook<Cart, {}, CartItemBody>
'useAddItem' > = ReturnType<H['useHook']>
>
// Input expected by the action returned by the `useAddItem` hook
export type UseAddItemInput<P extends Provider> = UseHookInput<
UseAddItemHandler<P>
>
export type UseAddItemResult<P extends Provider> = ReturnType<
UseHookResponse<UseAddItemHandler<P>>
>
export type UseAddItem<P extends Provider, Input> = Partial<
UseAddItemInput<P>
> extends UseAddItemInput<P>
? (input?: UseAddItemInput<P>) => (input: Input) => UseAddItemResult<P>
: (input: UseAddItemInput<P>) => (input: Input) => UseAddItemResult<P>
export const fetcher: HookFetcherFn< export const fetcher: HookFetcherFn<
Cart, Cart,
AddCartItemBody<CartItemBody> AddCartItemBody<CartItemBody>
> = async ({ options, input, fetch }) => { > = mutationFetcher
return fetch({ ...options, body: input })
const fn = (provider: Provider) => provider.cart?.useAddItem!
const useAddItem: UseAddItem = (...args) => {
const hook = useHook(fn)
return useMutationHook({ fetcher, ...hook })(...args)
} }
type X = UseAddItemResult<BigcommerceProvider> export default useAddItem
export default function useAddItem<P extends Provider, Input>(
input: UseAddItemInput<P>
) {
const { providerRef, fetcherRef } = useCommerce<P>()
const provider = providerRef.current
const opts = provider.cart?.useAddItem
const fetcherFn = opts?.fetcher ?? fetcher
const useHook = opts?.useHook ?? (() => () => {})
const fetchFn = provider.fetcher ?? fetcherRef.current
const action = useHook({ input })
return useCallback(
function addItem(input: Input) {
return action({
input,
fetch({ input }) {
return fetcherFn({
input,
options: opts!.fetchOptions,
fetch: fetchFn,
})
},
})
},
[input, fetchFn, opts?.fetchOptions]
)
}

View File

@ -1,17 +0,0 @@
import type { HookFetcher, HookFetcherOptions } from '../utils/types'
import useAddItem from './use-add-item'
import useRemoveItem from './use-remove-item'
import useUpdateItem from './use-update-item'
// This hook is probably not going to be used, but it's here
// to show how a commerce should be structuring it
export default function useCartActions<T, Input>(
options: HookFetcherOptions,
fetcher: HookFetcher<T, Input>
) {
const addItem = useAddItem<T, Input>(options, fetcher)
const updateItem = useUpdateItem<T, Input>(options, fetcher)
const removeItem = useRemoveItem<T, Input>(options, fetcher)
return { addItem, updateItem, removeItem }
}

View File

@ -1,34 +1,21 @@
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import { useHook, useSWRHook } from '../utils/use-hook'
import type { HookFetcherFn, SWRHook } from '../utils/types'
import type { Cart } from '../types' import type { Cart } from '../types'
import type {
Prop,
HookFetcherFn,
UseHookInput,
UseHookResponse,
} from '../utils/types'
import useData from '../utils/use-data'
import { Provider, useCommerce } from '..' import { Provider, useCommerce } from '..'
export type FetchCartInput = { export type FetchCartInput = {
cartId?: Cart['id'] cartId?: Cart['id']
} }
export type UseCartHandler<P extends Provider> = Prop< export type UseCart<
Prop<P, 'cart'>, H extends SWRHook<any, any, any> = SWRHook<
'useCart' Cart | null,
> {},
FetchCartInput,
export type UseCartInput<P extends Provider> = UseHookInput<UseCartHandler<P>> { isEmpty?: boolean }
>
export type CartResponse<P extends Provider> = UseHookResponse< > = ReturnType<H['useHook']>
UseCartHandler<P>
>
export type UseCart<P extends Provider> = Partial<
UseCartInput<P>
> extends UseCartInput<P>
? (input?: UseCartInput<P>) => CartResponse<P>
: (input: UseCartInput<P>) => CartResponse<P>
export const fetcher: HookFetcherFn<Cart | null, FetchCartInput> = async ({ export const fetcher: HookFetcherFn<Cart | null, FetchCartInput> = async ({
options, options,
@ -38,32 +25,17 @@ export const fetcher: HookFetcherFn<Cart | null, FetchCartInput> = async ({
return cartId ? await fetch({ ...options }) : null return cartId ? await fetch({ ...options }) : null
} }
export default function useCart<P extends Provider>( const fn = (provider: Provider) => provider.cart?.useCart!
input: UseCartInput<P> = {}
) {
const { providerRef, fetcherRef, cartCookie } = useCommerce<P>()
const provider = providerRef.current
const opts = provider.cart?.useCart
const fetcherFn = opts?.fetcher ?? fetcher
const useHook = opts?.useHook ?? ((ctx) => ctx.useData())
const useCart: UseCart = (input) => {
const hook = useHook(fn)
const { cartCookie } = useCommerce()
const fetcherFn = hook.fetcher ?? fetcher
const wrapper: typeof fetcher = (context) => { const wrapper: typeof fetcher = (context) => {
context.input.cartId = Cookies.get(cartCookie) context.input.cartId = Cookies.get(cartCookie)
return fetcherFn(context) return fetcherFn(context)
} }
return useSWRHook({ ...hook, fetcher: wrapper })(input)
return useHook({
input,
useData(ctx) {
const response = useData(
{ ...opts!, fetcher: wrapper },
ctx?.input ?? [],
provider.fetcher ?? fetcherRef.current,
ctx?.swrOptions ?? input.swrOptions
)
return response
},
})
} }
export default useCart

View File

@ -1,10 +1,35 @@
import useAction from '../utils/use-action' import { useHook, useMutationHook } from '../utils/use-hook'
import { mutationFetcher } from '../utils/default-fetcher'
import type { HookFetcherFn, MutationHook } from '../utils/types'
import type { Cart, LineItem, RemoveCartItemBody } from '../types'
import type { Provider } from '..'
// Input expected by the action returned by the `useRemoveItem` hook /**
export interface RemoveItemInput { * Input expected by the action returned by the `useRemoveItem` hook
*/
export type RemoveItemInput = {
id: string id: string
} }
const useRemoveItem = useAction export type UseRemoveItem<
H extends MutationHook<any, any, any> = MutationHook<
Cart | null,
{ item?: LineItem },
RemoveItemInput,
RemoveCartItemBody
>
> = ReturnType<H['useHook']>
export const fetcher: HookFetcherFn<
Cart | null,
RemoveCartItemBody
> = mutationFetcher
const fn = (provider: Provider) => provider.cart?.useRemoveItem!
const useRemoveItem: UseRemoveItem = (input) => {
const hook = useHook(fn)
return useMutationHook({ fetcher, ...hook })(input)
}
export default useRemoveItem export default useRemoveItem

View File

@ -1,11 +1,38 @@
import useAction from '../utils/use-action' import { useHook, useMutationHook } from '../utils/use-hook'
import type { CartItemBody } from '../types' import { mutationFetcher } from '../utils/default-fetcher'
import type { HookFetcherFn, MutationHook } from '../utils/types'
import type { Cart, CartItemBody, LineItem, UpdateCartItemBody } from '../types'
import type { Provider } from '..'
// Input expected by the action returned by the `useUpdateItem` hook /**
* Input expected by the action returned by the `useUpdateItem` hook
*/
export type UpdateItemInput<T extends CartItemBody> = T & { export type UpdateItemInput<T extends CartItemBody> = T & {
id: string id: string
} }
const useUpdateItem = useAction export type UseUpdateItem<
H extends MutationHook<any, any, any> = MutationHook<
Cart | null,
{
item?: LineItem
wait?: number
},
UpdateItemInput<CartItemBody>,
UpdateCartItemBody<CartItemBody>
>
> = ReturnType<H['useHook']>
export const fetcher: HookFetcherFn<
Cart | null,
UpdateCartItemBody<CartItemBody>
> = mutationFetcher
const fn = (provider: Provider) => provider.cart?.useUpdateItem!
const useUpdateItem: UseUpdateItem = (input) => {
const hook = useHook(fn)
return useMutationHook({ fetcher, ...hook })(input)
}
export default useUpdateItem export default useUpdateItem

View File

@ -0,0 +1,68 @@
/**
* This file is expected to be used in next.config.js only
*/
const path = require('path')
const fs = require('fs')
const merge = require('deepmerge')
const prettier = require('prettier')
const PROVIDERS = ['bigcommerce', 'shopify', 'swell', 'vendure']
function getProviderName() {
return (
process.env.COMMERCE_PROVIDER ||
(process.env.BIGCOMMERCE_STOREFRONT_API_URL
? 'bigcommerce'
: process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN
? 'shopify'
: process.env.NEXT_PUBLIC_SWELL_STORE_ID
? 'swell'
: null)
)
}
function withCommerceConfig(nextConfig = {}) {
const commerce = nextConfig.commerce || {}
const name = commerce.provider || getProviderName()
if (!name) {
throw new Error(
`The commerce provider is missing, please add a valid provider name or its environment variables`
)
}
if (!PROVIDERS.includes(name)) {
throw new Error(
`The commerce provider "${name}" can't be found, please use one of "${PROVIDERS.join(
', '
)}"`
)
}
const commerceNextConfig = require(path.join('../', name, 'next.config'))
const config = merge(commerceNextConfig, nextConfig)
config.env = config.env || {}
Object.entries(config.commerce.features).forEach(([k, v]) => {
if (v) config.env[`COMMERCE_${k.toUpperCase()}_ENABLED`] = true
})
// Update paths in `tsconfig.json` to point to the selected provider
if (config.commerce.updateTSConfig !== false) {
const tsconfigPath = path.join(process.cwd(), 'tsconfig.json')
const tsconfig = require(tsconfigPath)
tsconfig.compilerOptions.paths['@framework'] = [`framework/${name}`]
tsconfig.compilerOptions.paths['@framework/*'] = [`framework/${name}/*`]
fs.writeFileSync(
tsconfigPath,
prettier.format(JSON.stringify(tsconfig), { parser: 'json' })
)
}
return config
}
module.exports = { withCommerceConfig, getProviderName }

View File

@ -1,56 +1,20 @@
import { useHook, useSWRHook } from '../utils/use-hook'
import { SWRFetcher } from '../utils/default-fetcher'
import type { HookFetcherFn, SWRHook } from '../utils/types'
import type { Customer } from '../types' import type { Customer } from '../types'
import type { import { Provider } from '..'
Prop,
HookFetcherFn,
UseHookInput,
UseHookResponse,
} from '../utils/types'
import defaultFetcher from '../utils/default-fetcher'
import useData from '../utils/use-data'
import { Provider, useCommerce } from '..'
export type UseCustomerHandler<P extends Provider> = Prop< export type UseCustomer<
Prop<P, 'customer'>, H extends SWRHook<any, any, any> = SWRHook<Customer | null>
'useCustomer' > = ReturnType<H['useHook']>
>
export type UseCustomerInput<P extends Provider> = UseHookInput< export const fetcher: HookFetcherFn<Customer | null, any> = SWRFetcher
UseCustomerHandler<P>
>
export type CustomerResponse<P extends Provider> = UseHookResponse< const fn = (provider: Provider) => provider.customer?.useCustomer!
UseCustomerHandler<P>
>
export type UseCustomer<P extends Provider> = Partial< const useCustomer: UseCustomer = (input) => {
UseCustomerInput<P> const hook = useHook(fn)
> extends UseCustomerInput<P> return useSWRHook({ fetcher, ...hook })(input)
? (input?: UseCustomerInput<P>) => CustomerResponse<P>
: (input: UseCustomerInput<P>) => CustomerResponse<P>
export const fetcher = defaultFetcher as HookFetcherFn<Customer | null>
export default function useCustomer<P extends Provider>(
input: UseCustomerInput<P> = {}
) {
const { providerRef, fetcherRef } = useCommerce<P>()
const provider = providerRef.current
const opts = provider.customer?.useCustomer
const fetcherFn = opts?.fetcher ?? fetcher
const useHook = opts?.useHook ?? ((ctx) => ctx.useData())
return useHook({
input,
useData(ctx) {
const response = useData(
{ ...opts!, fetcher: fetcherFn },
ctx?.input ?? [],
provider.fetcher ?? fetcherRef.current,
ctx?.swrOptions ?? input.swrOptions
)
return response
},
})
} }
export default useCustomer

View File

@ -6,7 +6,7 @@ import {
useMemo, useMemo,
useRef, useRef,
} from 'react' } from 'react'
import { Fetcher, HookHandler, MutationHandler } from './utils/types' import { Fetcher, SWRHook, MutationHook } from './utils/types'
import type { FetchCartInput } from './cart/use-cart' import type { FetchCartInput } from './cart/use-cart'
import type { Cart, Wishlist, Customer, SearchProductsData } from './types' import type { Cart, Wishlist, Customer, SearchProductsData } from './types'
@ -15,17 +15,26 @@ const Commerce = createContext<CommerceContextValue<any> | {}>({})
export type Provider = CommerceConfig & { export type Provider = CommerceConfig & {
fetcher: Fetcher fetcher: Fetcher
cart?: { cart?: {
useCart?: HookHandler<Cart | null, any, FetchCartInput> useCart?: SWRHook<Cart | null, any, FetchCartInput>
useAddItem?: MutationHandler<Cart, any, any> useAddItem?: MutationHook<any, any, any>
useUpdateItem?: MutationHook<any, any, any>
useRemoveItem?: MutationHook<any, any, any>
} }
wishlist?: { wishlist?: {
useWishlist?: HookHandler<Wishlist | null, any, any> useWishlist?: SWRHook<Wishlist | null, any, any>
useAddItem?: MutationHook<any, any, any>
useRemoveItem?: MutationHook<any, any, any>
} }
customer: { customer?: {
useCustomer?: HookHandler<Customer | null, any, any> useCustomer?: SWRHook<Customer | null, any, any>
} }
products: { products?: {
useSearch?: HookHandler<SearchProductsData, any, any> useSearch?: SWRHook<SearchProductsData, any, any>
}
auth?: {
useSignup?: MutationHook<any, any, any>
useLogin?: MutationHook<any, any, any>
useLogout?: MutationHook<any, any, any>
} }
} }

View File

@ -0,0 +1,239 @@
# Adding a new Commerce Provider
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))
- Shopify ([framework/shopify](../shopify))
Adding a commerce provider means adding a new folder in `framework` with a folder structure like the next one:
- `api`
- index.ts
- `product`
- usePrice
- useSearch
- getProduct
- getAllProducts
- `wishlist`
- useWishlist
- useAddItem
- useRemoveItem
- `auth`
- useLogin
- useLogout
- useSignup
- `customer`
- useCustomer
- getCustomerId
- getCustomerWistlist
- `cart`
- useCart
- useAddItem
- useRemoveItem
- useUpdateItem
- `env.template`
- `index.ts`
- `provider.ts`
- `commerce.config.json`
- `next.config.js`
- `README.md`
`provider.ts` exports a provider object with handlers for the [Commerce Hooks](./README.md#commerce-hooks) and `api/index.ts` exports a Node.js provider for the [Commerce API](./README.md#commerce-api)
> **Important:** We use TypeScript for every provider and expect its usage for every new one.
The app imports from the provider directly instead of the core commerce folder (`framework/commerce`), but all providers are interchangeable and to achieve it every provider always has to implement the core types and helpers.
The provider folder should only depend on `framework/commerce` and dependencies in the main `package.json`. In the future we'll move the `framework` folder to a package that can be shared easily for multiple apps.
## Adding the provider hooks
Using BigCommerce as an example. The first thing to do is export a `CommerceProvider` component that includes a `provider` object with all the handlers that can be used for hooks:
```tsx
import type { ReactNode } from 'react'
import {
CommerceConfig,
CommerceProvider as CoreCommerceProvider,
useCommerce as useCoreCommerce,
} from '@commerce'
import { bigcommerceProvider, BigcommerceProvider } from './provider'
export { bigcommerceProvider }
export type { BigcommerceProvider }
export const bigcommerceConfig: CommerceConfig = {
locale: 'en-us',
cartCookie: 'bc_cartId',
}
export type BigcommerceConfig = Partial<CommerceConfig>
export type BigcommerceProps = {
children?: ReactNode
locale: string
} & BigcommerceConfig
export function CommerceProvider({ children, ...config }: BigcommerceProps) {
return (
<CoreCommerceProvider
provider={bigcommerceProvider}
config={{ ...bigcommerceConfig, ...config }}
>
{children}
</CoreCommerceProvider>
)
}
export const useCommerce = () => useCoreCommerce<BigcommerceProvider>()
```
The exported types and components extend from the core ones exported by `@commerce`, which refers to `framework/commerce`.
The `bigcommerceProvider` object looks like this:
```tsx
import { handler as useCart } from './cart/use-cart'
import { handler as useAddItem } from './cart/use-add-item'
import { handler as useUpdateItem } from './cart/use-update-item'
import { handler as useRemoveItem } from './cart/use-remove-item'
import { handler as useWishlist } from './wishlist/use-wishlist'
import { handler as useWishlistAddItem } from './wishlist/use-add-item'
import { handler as useWishlistRemoveItem } from './wishlist/use-remove-item'
import { handler as useCustomer } from './customer/use-customer'
import { handler as useSearch } from './product/use-search'
import { handler as useLogin } from './auth/use-login'
import { handler as useLogout } from './auth/use-logout'
import { handler as useSignup } from './auth/use-signup'
import fetcher from './fetcher'
export const bigcommerceProvider = {
locale: 'en-us',
cartCookie: 'bc_cartId',
fetcher,
cart: { useCart, useAddItem, useUpdateItem, useRemoveItem },
wishlist: {
useWishlist,
useAddItem: useWishlistAddItem,
useRemoveItem: useWishlistRemoveItem,
},
customer: { useCustomer },
products: { useSearch },
auth: { useLogin, useLogout, useSignup },
}
export type BigcommerceProvider = typeof bigcommerceProvider
```
The provider object, in this case `bigcommerceProvider`, has to match the `Provider` type defined in [framework/commerce](./index.ts).
A hook handler, like `useCart`, looks like this:
```tsx
import { useMemo } from 'react'
import { SWRHook } from '@commerce/utils/types'
import useCart, { UseCart, FetchCartInput } from '@commerce/cart/use-cart'
import { normalizeCart } from '../lib/normalize'
import type { Cart } from '../types'
export default useCart as UseCart<typeof handler>
export const handler: SWRHook<
Cart | null,
{},
FetchCartInput,
{ isEmpty?: boolean }
> = {
fetchOptions: {
url: '/api/bigcommerce/cart',
method: 'GET',
},
async fetcher({ input: { cartId }, options, fetch }) {
const data = cartId ? await fetch(options) : null
return data && normalizeCart(data)
},
useHook: ({ useData }) => (input) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}
```
In the case of data fetching hooks like `useCart` each handler has to implement the `SWRHook` type that's defined in the core types. For mutations it's the `MutationHook`, e.g for `useAddItem`:
```tsx
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item'
import { normalizeCart } from '../lib/normalize'
import type {
Cart,
BigcommerceCart,
CartItemBody,
AddCartItemBody,
} from '../types'
import useCart from './use-cart'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<Cart, {}, CartItemBody> = {
fetchOptions: {
url: '/api/bigcommerce/cart',
method: 'POST',
},
async fetcher({ input: item, options, fetch }) {
if (
item.quantity &&
(!Number.isInteger(item.quantity) || item.quantity! < 1)
) {
throw new CommerceError({
message: 'The item quantity has to be a valid integer greater than 0',
})
}
const data = await fetch<BigcommerceCart, AddCartItemBody>({
...options,
body: { item },
})
return normalizeCart(data)
},
useHook: ({ fetch }) => () => {
const { mutate } = useCart()
return useCallback(
async function addItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
}
```
## Adding the Node.js provider API
TODO
> The commerce API is currently going through a refactor in https://github.com/vercel/commerce/pull/252 - We'll update the docs once the API is released.

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useCommerce } from '.' import { useCommerce } from '..'
export function formatPrice({ export function formatPrice({
amount, amount,

View File

@ -1,57 +1,20 @@
import { useHook, useSWRHook } from '../utils/use-hook'
import { SWRFetcher } from '../utils/default-fetcher'
import type { HookFetcherFn, SWRHook } from '../utils/types'
import type { SearchProductsData } from '../types' import type { SearchProductsData } from '../types'
import type { import { Provider } from '..'
Prop,
HookFetcherFn,
UseHookInput,
UseHookResponse,
} from '../utils/types'
import defaultFetcher from '../utils/default-fetcher'
import useData from '../utils/use-data'
import { Provider, useCommerce } from '..'
import { BigcommerceProvider } from '@framework'
export type UseSearchHandler<P extends Provider> = Prop< export type UseSearch<
Prop<P, 'products'>, H extends SWRHook<any, any, any> = SWRHook<SearchProductsData>
'useSearch' > = ReturnType<H['useHook']>
>
export type UseSeachInput<P extends Provider> = UseHookInput< export const fetcher: HookFetcherFn<SearchProductsData, any> = SWRFetcher
UseSearchHandler<P>
>
export type SearchResponse<P extends Provider> = UseHookResponse< const fn = (provider: Provider) => provider.products?.useSearch!
UseSearchHandler<P>
>
export type UseSearch<P extends Provider> = Partial< const useSearch: UseSearch = (input) => {
UseSeachInput<P> const hook = useHook(fn)
> extends UseSeachInput<P> return useSWRHook({ fetcher, ...hook })(input)
? (input?: UseSeachInput<P>) => SearchResponse<P>
: (input: UseSeachInput<P>) => SearchResponse<P>
export const fetcher = defaultFetcher as HookFetcherFn<SearchProductsData>
export default function useSearch<P extends Provider>(
input: UseSeachInput<P> = {}
) {
const { providerRef, fetcherRef } = useCommerce<P>()
const provider = providerRef.current
const opts = provider.products?.useSearch
const fetcherFn = opts?.fetcher ?? fetcher
const useHook = opts?.useHook ?? ((ctx) => ctx.useData())
return useHook({
input,
useData(ctx) {
const response = useData(
{ ...opts!, fetcher: fetcherFn },
ctx?.input ?? [],
provider.fetcher ?? fetcherRef.current,
ctx?.swrOptions ?? input.swrOptions
)
return response
},
})
} }
export default useSearch

View File

@ -1,10 +1,6 @@
import type { Wishlist as BCWishlist } from '@framework/api/wishlist' import type { Wishlist as BCWishlist } from '../bigcommerce/api/wishlist'
import type { Customer as BCCustomer } from '@framework/api/customers' import type { Customer as BCCustomer } from '../bigcommerce/api/customers'
import type { SearchProductsData as BCSearchProductsData } from '@framework/api/catalog/products' import type { SearchProductsData as BCSearchProductsData } from '../bigcommerce/api/catalog/products'
export type CommerceProviderConfig = {
features: Record<string, boolean>
}
export type Discount = { export type Discount = {
// The value of the discount, can be an amount or percentage // The value of the discount, can be an amount or percentage
@ -167,6 +163,7 @@ interface Entity {
export interface Product extends Entity { export interface Product extends Entity {
name: string name: string
description: string description: string
descriptionHtml?: string
slug?: string slug?: string
path?: string path?: string
images: ProductImage[] images: ProductImage[]

View File

@ -1,5 +0,0 @@
import useAction from './utils/use-action'
const useLogin = useAction
export default useLogin

View File

@ -1,5 +0,0 @@
import useAction from './utils/use-action'
const useLogout = useAction
export default useLogout

View File

@ -1,5 +0,0 @@
import useAction from './utils/use-action'
const useSignup = useAction
export default useSignup

View File

@ -1,6 +1,12 @@
import type { HookFetcherFn } from './types' import type { HookFetcherFn } from './types'
const defaultFetcher: HookFetcherFn<any> = ({ options, fetch }) => export const SWRFetcher: HookFetcherFn<any, any> = ({ options, fetch }) =>
fetch(options) fetch(options)
export default defaultFetcher export const mutationFetcher: HookFetcherFn<any, any> = ({
input,
options,
fetch,
}) => fetch({ ...options, body: input })
export default SWRFetcher

View File

@ -1,37 +0,0 @@
import commerceProviderConfig from '@framework/config.json'
import type { CommerceProviderConfig } from '../types'
import memo from 'lodash.memoize'
type FeaturesAPI = {
isEnabled: (desideredFeature: string) => boolean
}
function isFeatureEnabled(config: CommerceProviderConfig) {
const features = config.features
return (desideredFeature: string) =>
Object.keys(features)
.filter((k) => features[k])
.includes(desideredFeature)
}
function boostrap(): FeaturesAPI {
const basis = {
isEnabled: () => false,
}
if (!commerceProviderConfig) {
console.log('No config.json found - Please add a config.json')
return basis
}
if (commerceProviderConfig.features) {
return {
...basis,
isEnabled: memo(isFeatureEnabled(commerceProviderConfig)),
}
}
return basis
}
export default boostrap()

View File

@ -2,13 +2,18 @@ import type { ConfigInterface } from 'swr'
import type { CommerceError } from './errors' import type { CommerceError } from './errors'
import type { ResponseState } from './use-data' import type { ResponseState } from './use-data'
/**
* Returns the properties in T with the properties in type K, overriding properties defined in T
*/
export type Override<T, K> = Omit<T, keyof K> & K export type Override<T, K> = Omit<T, keyof K> & K
/** /**
* Returns the properties in T with the properties in type K changed from optional to required * Returns the properties in T with the properties in type K changed from optional to required
*/ */
export type PickRequired<T, K extends keyof T> = Omit<T, K> & export type PickRequired<T, K extends keyof T> = Omit<T, K> &
Required<Pick<T, K>> {
[P in K]-?: NonNullable<T[P]>
}
/** /**
* Core fetcher added by CommerceProvider * Core fetcher added by CommerceProvider
@ -31,16 +36,15 @@ export type HookFetcher<Data, Input = null, Result = any> = (
fetch: <T = Result, Body = any>(options: FetcherOptions<Body>) => Promise<T> fetch: <T = Result, Body = any>(options: FetcherOptions<Body>) => Promise<T>
) => Data | Promise<Data> ) => Data | Promise<Data>
export type HookFetcherFn< export type HookFetcherFn<Data, Input = undefined, Result = any, Body = any> = (
Data, context: HookFetcherContext<Input, Result, Body>
Input = never, ) => Data | Promise<Data>
Result = any,
Body = any export type HookFetcherContext<Input = undefined, Result = any, Body = any> = {
> = (context: {
options: HookFetcherOptions options: HookFetcherOptions
input: Input input: Input
fetch: <T = Result, B = Body>(options: FetcherOptions<B>) => Promise<T> fetch: <T = Result, B = Body>(options: FetcherOptions<B>) => Promise<T>
}) => Data | Promise<Data> }
export type HookFetcherOptions = { method?: string } & ( export type HookFetcherOptions = { method?: string } & (
| { query: string; url?: string } | { query: string; url?: string }
@ -49,13 +53,20 @@ export type HookFetcherOptions = { method?: string } & (
export type HookInputValue = string | number | boolean | undefined export type HookInputValue = string | number | boolean | undefined
export type HookSwrInput = [string, HookInputValue][] export type HookSWRInput = [string, HookInputValue][]
export type HookFetchInput = { [k: string]: HookInputValue } export type HookFetchInput = { [k: string]: HookInputValue }
export type HookInput = {} export type HookFunction<
Input extends { [k: string]: unknown } | null,
T
> = keyof Input extends never
? () => T
: Partial<Input> extends Input
? (input?: Input) => T
: (input: Input) => T
export type HookHandler< export type SWRHook<
// Data obj returned by the hook and fetch operation // Data obj returned by the hook and fetch operation
Data, Data,
// Input expected by the hook // Input expected by the hook
@ -65,58 +76,56 @@ export type HookHandler<
// Custom state added to the response object of SWR // Custom state added to the response object of SWR
State = {} State = {}
> = { > = {
useHook?(context: { useHook(
input: Input & { swrOptions?: SwrOptions<Data, FetchInput> } context: SWRHookContext<Data, FetchInput>
useData(context?: { ): HookFunction<
input?: HookFetchInput | HookSwrInput Input & { swrOptions?: SwrOptions<Data, FetchInput> },
swrOptions?: SwrOptions<Data, FetchInput> ResponseState<Data> & State
}): ResponseState<Data> >
}): ResponseState<Data> & State
fetchOptions: HookFetcherOptions fetchOptions: HookFetcherOptions
fetcher?: HookFetcherFn<Data, FetchInput> fetcher?: HookFetcherFn<Data, FetchInput>
} }
export type MutationHandler< export type SWRHookContext<
Data,
FetchInput extends { [k: string]: unknown } = {}
> = {
useData(context?: {
input?: HookFetchInput | HookSWRInput
swrOptions?: SwrOptions<Data, FetchInput>
}): ResponseState<Data>
}
export type MutationHook<
// Data obj returned by the hook and fetch operation // Data obj returned by the hook and fetch operation
Data, Data,
// Input expected by the hook // Input expected by the hook
Input extends { [k: string]: unknown } = {}, Input extends { [k: string]: unknown } = {},
// Input expected by the action returned by the hook
ActionInput extends { [k: string]: unknown } = {},
// Input expected before doing a fetch operation // Input expected before doing a fetch operation
FetchInput extends { [k: string]: unknown } = {} FetchInput extends { [k: string]: unknown } = ActionInput
> = { > = {
useHook?(context: { useHook(
input: Input context: MutationHookContext<Data, FetchInput>
}): (context: { ): HookFunction<Input, HookFunction<ActionInput, Data | Promise<Data>>>
input: FetchInput
fetch: (context: { input: FetchInput }) => Data | Promise<Data>
}) => Data | Promise<Data>
fetchOptions: HookFetcherOptions fetchOptions: HookFetcherOptions
fetcher?: HookFetcherFn<Data, FetchInput> fetcher?: HookFetcherFn<Data, FetchInput>
} }
export type MutationHookContext<
Data,
FetchInput extends { [k: string]: unknown } | null = {}
> = {
fetch: keyof FetchInput extends never
? () => Data | Promise<Data>
: Partial<FetchInput> extends FetchInput
? (context?: { input?: FetchInput }) => Data | Promise<Data>
: (context: { input: FetchInput }) => Data | Promise<Data>
}
export type SwrOptions<Data, Input = null, Result = any> = ConfigInterface< export type SwrOptions<Data, Input = null, Result = any> = ConfigInterface<
Data, Data,
CommerceError, CommerceError,
HookFetcher<Data, Input, Result> HookFetcher<Data, Input, Result>
> >
/**
* Returns the property K from type T excluding nullables
*/
export type Prop<T, K extends keyof T> = NonNullable<T[K]>
export type HookHandlerType =
| HookHandler<any, any, any>
| MutationHandler<any, any, any>
export type UseHookParameters<H extends HookHandlerType> = Parameters<
Prop<H, 'useHook'>
>
export type UseHookResponse<H extends HookHandlerType> = ReturnType<
Prop<H, 'useHook'>
>
export type UseHookInput<
H extends HookHandlerType
> = UseHookParameters<H>[0]['input']

View File

@ -1,15 +0,0 @@
import { useCallback } from 'react'
import type { HookFetcher, HookFetcherOptions } from './types'
import { useCommerce } from '..'
export default function useAction<T, Input = null>(
options: HookFetcherOptions,
fetcher: HookFetcher<T, Input>
) {
const { fetcherRef } = useCommerce()
return useCallback(
(input: Input) => fetcher(options, input, fetcherRef.current),
[fetcher]
)
}

View File

@ -1,11 +1,11 @@
import useSWR, { responseInterface } from 'swr' import useSWR, { responseInterface } from 'swr'
import type { import type {
HookHandler, HookSWRInput,
HookSwrInput,
HookFetchInput, HookFetchInput,
PickRequired,
Fetcher, Fetcher,
SwrOptions, SwrOptions,
HookFetcherOptions,
HookFetcherFn,
} from './types' } from './types'
import defineProperty from './define-property' import defineProperty from './define-property'
import { CommerceError } from './errors' import { CommerceError } from './errors'
@ -14,13 +14,12 @@ export type ResponseState<Result> = responseInterface<Result, CommerceError> & {
isLoading: boolean isLoading: boolean
} }
export type UseData = < export type UseData = <Data = any, FetchInput extends HookFetchInput = {}>(
Data = any, options: {
Input extends { [k: string]: unknown } = {}, fetchOptions: HookFetcherOptions
FetchInput extends HookFetchInput = {} fetcher: HookFetcherFn<Data, FetchInput>
>( },
options: PickRequired<HookHandler<Data, Input, FetchInput>, 'fetcher'>, input: HookFetchInput | HookSWRInput,
input: HookFetchInput | HookSwrInput,
fetcherFn: Fetcher, fetcherFn: Fetcher,
swrOptions?: SwrOptions<Data, FetchInput> swrOptions?: SwrOptions<Data, FetchInput>
) => ResponseState<Data> ) => ResponseState<Data>

View File

@ -0,0 +1,50 @@
import { useCallback } from 'react'
import { Provider, useCommerce } from '..'
import type { MutationHook, PickRequired, SWRHook } from './types'
import useData from './use-data'
export function useFetcher() {
const { providerRef, fetcherRef } = useCommerce()
return providerRef.current.fetcher ?? fetcherRef.current
}
export function useHook<
P extends Provider,
H extends MutationHook<any, any, any> | SWRHook<any, any, any>
>(fn: (provider: P) => H) {
const { providerRef } = useCommerce<P>()
const provider = providerRef.current
return fn(provider)
}
export function useSWRHook<H extends SWRHook<any, any, any>>(
hook: PickRequired<H, 'fetcher'>
) {
const fetcher = useFetcher()
return hook.useHook({
useData(ctx) {
const response = useData(hook, ctx?.input ?? [], fetcher, ctx?.swrOptions)
return response
},
})
}
export function useMutationHook<H extends MutationHook<any, any, any>>(
hook: PickRequired<H, 'fetcher'>
) {
const fetcher = useFetcher()
return hook.useHook({
fetch: useCallback(
({ input } = {}) => {
return hook.fetcher({
input,
options: hook.fetchOptions,
fetch: fetcher,
})
},
[fetcher, hook.fetchOptions]
),
})
}

View File

@ -1,40 +0,0 @@
import { useMemo } from 'react'
import { responseInterface } from 'swr'
import { CommerceError } from './errors'
import { Override } from './types'
export type UseResponseOptions<
D,
R extends responseInterface<any, CommerceError>
> = {
descriptors?: PropertyDescriptorMap
normalizer?: (data: R['data']) => D
}
export type UseResponse = <D, R extends responseInterface<any, CommerceError>>(
response: R,
options: UseResponseOptions<D, R>
) => D extends object ? Override<R, { data?: D }> : R
const useResponse: UseResponse = (response, { descriptors, normalizer }) => {
const memoizedResponse = useMemo(
() =>
Object.create(response, {
...descriptors,
...(normalizer
? {
data: {
get() {
return response.data && normalizer(response.data)
},
enumerable: true,
},
}
: {}),
}),
[response]
)
return memoizedResponse
}
export default useResponse

View File

@ -1,12 +1,19 @@
import useAction from '../utils/use-action' import { useHook, useMutationHook } from '../utils/use-hook'
import type { CartItemBody } from '../types' import { mutationFetcher } from '../utils/default-fetcher'
import type { MutationHook } from '../utils/types'
import type { Provider } from '..'
// Input expected by the action returned by the `useAddItem` hook export type UseAddItem<
// export interface AddItemInput { H extends MutationHook<any, any, any> = MutationHook<any, {}, {}>
// includeProducts?: boolean > = ReturnType<H['useHook']>
// }
export type AddItemInput<T extends CartItemBody> = T
const useAddItem = useAction export const fetcher = mutationFetcher
const fn = (provider: Provider) => provider.wishlist?.useAddItem!
const useAddItem: UseAddItem = (...args) => {
const hook = useHook(fn)
return useMutationHook({ fetcher, ...hook })(...args)
}
export default useAddItem export default useAddItem

View File

@ -1,5 +1,28 @@
import useAction from '../utils/use-action' import { useHook, useMutationHook } from '../utils/use-hook'
import { mutationFetcher } from '../utils/default-fetcher'
import type { HookFetcherFn, MutationHook } from '../utils/types'
import type { Provider } from '..'
const useRemoveItem = useAction export type RemoveItemInput = {
id: string | number
}
export type UseRemoveItem<
H extends MutationHook<any, any, any> = MutationHook<
any | null,
{ wishlist?: any },
RemoveItemInput,
{}
>
> = ReturnType<H['useHook']>
export const fetcher: HookFetcherFn<any | null, {}> = mutationFetcher
const fn = (provider: Provider) => provider.wishlist?.useRemoveItem!
const useRemoveItem: UseRemoveItem = (input) => {
const hook = useHook(fn)
return useMutationHook({ fetcher, ...hook })(input)
}
export default useRemoveItem export default useRemoveItem

View File

@ -1,56 +1,25 @@
import { useHook, useSWRHook } from '../utils/use-hook'
import { SWRFetcher } from '../utils/default-fetcher'
import type { HookFetcherFn, SWRHook } from '../utils/types'
import type { Wishlist } from '../types' import type { Wishlist } from '../types'
import type { import type { Provider } from '..'
Prop,
HookFetcherFn,
UseHookInput,
UseHookResponse,
} from '../utils/types'
import defaultFetcher from '../utils/default-fetcher'
import useData from '../utils/use-data'
import { Provider, useCommerce } from '..'
export type UseWishlistHandler<P extends Provider> = Prop< export type UseWishlist<
Prop<P, 'wishlist'>, H extends SWRHook<any, any, any> = SWRHook<
'useWishlist' Wishlist | null,
> { includeProducts?: boolean },
{ customerId?: number; includeProducts: boolean },
{ isEmpty?: boolean }
>
> = ReturnType<H['useHook']>
export type UseWishlistInput<P extends Provider> = UseHookInput< export const fetcher: HookFetcherFn<Wishlist | null, any> = SWRFetcher
UseWishlistHandler<P>
>
export type WishlistResponse<P extends Provider> = UseHookResponse< const fn = (provider: Provider) => provider.wishlist?.useWishlist!
UseWishlistHandler<P>
>
export type UseWishlist<P extends Provider> = Partial< const useWishlist: UseWishlist = (input) => {
UseWishlistInput<P> const hook = useHook(fn)
> extends UseWishlistInput<P> return useSWRHook({ fetcher, ...hook })(input)
? (input?: UseWishlistInput<P>) => WishlistResponse<P>
: (input: UseWishlistInput<P>) => WishlistResponse<P>
export const fetcher = defaultFetcher as HookFetcherFn<Wishlist | null>
export default function useWishlist<P extends Provider>(
input: UseWishlistInput<P> = {}
) {
const { providerRef, fetcherRef } = useCommerce<P>()
const provider = providerRef.current
const opts = provider.wishlist?.useWishlist
const fetcherFn = opts?.fetcher ?? fetcher
const useHook = opts?.useHook ?? ((ctx) => ctx.useData())
return useHook({
input,
useData(ctx) {
const response = useData(
{ ...opts!, fetcher: fetcherFn },
ctx?.input ?? [],
provider.fetcher ?? fetcherRef.current,
ctx?.swrOptions ?? input.swrOptions
)
return response
},
})
} }
export default useWishlist

View File

@ -0,0 +1,4 @@
COMMERCE_PROVIDER=shopify
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=

123
framework/shopify/README.md Normal file
View File

@ -0,0 +1,123 @@
## Shopify Provider
**Demo:** https://shopify.demo.vercel.store/
Before getting starter, a [Shopify](https://www.shopify.com/) account and store is required before using the provider.
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/shopify/.env.template .env.local
```
Then, set the environment variables in `.env.local` to match the ones from your store.
## Contribute
Our commitment to Open Source can be found [here](https://vercel.com/oss).
If you find an issue with the provider or want a new feature, feel free to open a PR or [create a new issue](https://github.com/vercel/commerce/issues).
## Modifications
These modifications are temporarily until contributions are made to remove them.
### Adding item to Cart
```js
// components/product/ProductView/ProductView.tsx
const ProductView: FC<Props> = ({ product }) => {
const addToCart = async () => {
setLoading(true)
try {
await addItem({
productId: product.id,
variantId: variant ? variant.id : product.variants[0].id,
})
openSidebar()
setLoading(false)
} catch (err) {
setLoading(false)
}
}
}
```
### Proceed to Checkout
```js
// components/cart/CartSidebarView/CartSidebarView.tsx
import { useCommerce } from '@framework'
const CartSidebarView: FC = () => {
const { checkout } = useCommerce()
return (
<Button href={checkout.webUrl} Component="a" width="100%">
Proceed to Checkout
</Button>
)
}
```
## APIs
Collections of APIs to fetch data from a Shopify store.
The data is fetched using the [Shopify JavaScript Buy SDK](https://github.com/Shopify/js-buy-sdk#readme). Read the [Shopify Storefront API reference](https://shopify.dev/docs/storefront-api/reference) for more information.
### getProduct
Get a single product by its `handle`.
```js
import getProduct from '@framework/product/get-product'
import { getConfig } from '@framework/api'
const config = getConfig()
const product = await getProduct({
variables: { slug },
config,
})
```
### getAllProducts
```js
import getAllProducts from '@framework/product/get-all-products'
import { getConfig } from '@framework/api'
const config = getConfig()
const { products } = await getAllProducts({
variables: { first: 12 },
config,
})
```
### getAllCollections
```js
import getAllCollections from '@framework/product/get-all-collections'
import { getConfig } from '@framework/api'
const config = getConfig()
const collections = await getAllCollections({
config,
})
```
### getAllPages
```js
import getAllPages from '@framework/common/get-all-pages'
import { getConfig } from '@framework/api'
const config = getConfig()
const pages = await getAllPages({
variables: { first: 12 },
config,
})
```

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1,46 @@
import isAllowedMethod from '../utils/is-allowed-method'
import createApiHandler, {
ShopifyApiHandler,
} from '../utils/create-api-handler'
import {
SHOPIFY_CHECKOUT_ID_COOKIE,
SHOPIFY_CHECKOUT_URL_COOKIE,
SHOPIFY_CUSTOMER_TOKEN_COOKIE,
} from '../../const'
import { getConfig } from '..'
import associateCustomerWithCheckoutMutation from '../../utils/mutations/associate-customer-with-checkout'
const METHODS = ['GET']
const checkoutApi: ShopifyApiHandler<any> = async (req, res, config) => {
if (!isAllowedMethod(req, res, METHODS)) return
config = getConfig()
const { cookies } = req
const checkoutUrl = cookies[SHOPIFY_CHECKOUT_URL_COOKIE]
const customerCookie = cookies[SHOPIFY_CUSTOMER_TOKEN_COOKIE]
if (customerCookie) {
try {
await config.fetch(associateCustomerWithCheckoutMutation, {
variables: {
checkoutId: cookies[SHOPIFY_CHECKOUT_ID_COOKIE],
customerAccessToken: cookies[SHOPIFY_CUSTOMER_TOKEN_COOKIE],
},
})
} catch (error) {
console.error(error)
}
}
if (checkoutUrl) {
res.redirect(checkoutUrl)
} else {
res.redirect('/cart')
}
}
export default createApiHandler(checkoutApi, {}, {})

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -0,0 +1 @@
export default function () {}

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