Agnostic UI (#199)

* changes

* Progress

* Normalized Products output

* Progress

* Restored Index Agnostic

* Progress

* Reordering

* Moved normalizer to BC function

* Removed Futures

* More Types

* More Types

* More Types

* Fix useCallback

* Progress, Changes types, readme and restoring functionality

* Changes

* TS Issues

* Changes

* Normalizer

* Normalizing more operations

* Normalizing more operations

* changes

* Merge Issues

* Cleanup

* change

* changes

* index.ts broke my tree shaking

* slug

* Normalized Options and Swatches

* Restored Add to cart

* Correct Variant Added to Cart

* Normalizing Cart Responses

* Changes

* changes breaking

* Adding immutable normalizer for Product

* Cart Normalized

* changes

* Progress

* More updates

* Removed some comments

* Add loading state for data hooks

* Bug fix

* Changed the way isEmpty works

* Improve navbar performance

* Added useResponse hook

* added useResponse to useWhishlist

* Added husky and lint-staged

* Ran prettier fix

* Added more cart types

* Moved types.d.ts to the commerce folder

* Minor changes

* Moved normalizer to happen after fetch

* updated useCart types

* Updated normalizer for useData

* Added new normalizer for the cart to the UI

* More corrections for useCart

* Updated cart update hooks

* Removed import

* Progress

* Switch away from global types

* Making multiple changes

* Improved types for operations

* Moved and updated cart types

* Updated the useAddItem and useRemoveItem hooks

* Minor life improvement

* Minor change

* Implement Shopify Provider

* Update README.md

* Update README.md

* normalizations & missing files

* Update index.ts

* fixes

* Update normalize.ts

* fix: cart error on first load

* shopify checkout redirect & api handler

* Update get-checkout-id.ts

* userAvatar

* Fix: color option

* Update normalize.ts

* changes

* Update next.config.js

* start customer auth & signup

* Update config.ts

* Login, Sign Up, Log Out, and checkout & customer association

* Automatic login after sign-up

* Update handle-login.ts

* MOving stuff around and adding temporal new files

* changes

* Replace use-cart with the new hook

* Removed old hook

* Improved HookHandler type

* Moved types

* Simplified useData types

* Updated Fetcher type

* Moved SwrOptions type

* Removed duplicated fetcher

* Moved provider to its own file

* Added proper type for fetch input

* Revert "Merge branch 'agnostic' of https://github.com/vercel/commerce into agnostic"

This reverts commit 23c8ed7c2d, reversing
changes made to bf50965a39.

* change readme

* Revert "Merge branch 'master' of https://github.com/vercel/commerce into agnostic"

This reverts commit bf50965a39, reversing
changes made to 0dad4ddedb.

* Revert "Revert "Merge branch 'agnostic' of https://github.com/vercel/commerce into agnostic""

This reverts commit c9a43f1bce.

* align with upstream changes

* Updated how the hook input is handled

* Add more options to the hook handler

* Final touches to the hook handler type

* Moved useWishlist to use new handler

* Move useCustomer to the new hook

* Added a default fetcher

* query all products for vendors & paths, improve search

* Update use-search.tsx

* fix cart after upstream changes

* Shopify Provider (#186)

* Start of Shopify provider

* add missing comment to documentation

* add missing env vars to documentation

* update reference to types file

* Moved useSearch to the new hook

* Removed old use-data lib

* Removed generics for result and body

* Removed normalizr

* Wishlist

* New changes and initial Features API

* Fixed some types

* Fixed more types

* fixes after upstream changes

* Fixed product types

* Fixed another product type

* Updated type

* Fixed remaining issues with types

* Added a MutationHandler

* Moved the handlers to each hook

* Moved the fetcher to its own file

* Moved handler to each hook

* Added initial version of useAddItem

* Added better mutation types, and moved some hooks

* Removed use-cart-actions

* Added initial version of useAddItem

* Updated types

* Update use-add-item.tsx

* changes

* Changes

* Reordering and changes

* Adding Features APO

* Adding wishlist api

* Implementing FeaturesAPI with Wishlist

* Removing bug with touchstart

* Adding tyni typing

* moved use-remove-item

* Removed MutationHandler type

* Moved more hooks and updated types to make them smaller

* Moved data hooks to new format

* Removed no longer required types

* Removed useResponse helper

* Updated useData type

* Moved wishlist use-add-item

* Moved wishlist use-remove-item to provider

* Moved use-login and use-logout

* Update use-signup

* Removed use-action helper

* Moved auth & cart hooks + several fixes

* Updated cart item, fixed deprecations

* Update next.config.js

* Updates to wishlist feature

* Moved the features to be environment variable only

* More changes for wishlist config

* Disable wishlist

* Removed useWishlistActions

* Updated readme

* updates

* typos

* Updated the way the provider config is set

* Removed features.ts

* Removed bootstrap.js

* Aligned with upstream changes

* Updates

* shopify: changes

* shopify: changes

* Update next.config.js

* Shopify Provider Updates (#209)

* Implement Shopify Provider

* Update README.md

* Update README.md

* normalizations & missing files

* Update index.ts

* fixes

* Update normalize.ts

* fix: cart error on first load

* shopify checkout redirect & api handler

* Update get-checkout-id.ts

* Fix: color option

* Update normalize.ts

* changes

* Update next.config.js

* start customer auth & signup

* Update config.ts

* Login, Sign Up, Log Out, and checkout & customer association

* Automatic login after sign-up

* Update handle-login.ts

* changes

* Revert "Merge branch 'agnostic' of https://github.com/vercel/commerce into agnostic"

This reverts commit 23c8ed7c2d, reversing
changes made to bf50965a39.

* change readme

* Revert "Merge branch 'master' of https://github.com/vercel/commerce into agnostic"

This reverts commit bf50965a39, reversing
changes made to 0dad4ddedb.

* Revert "Revert "Merge branch 'agnostic' of https://github.com/vercel/commerce into agnostic""

This reverts commit c9a43f1bce.

* align with upstream changes

* query all products for vendors & paths, improve search

* Update use-search.tsx

* fix cart after upstream changes

* fixes after upstream changes

* Moved handler to each hook

* Added initial version of useAddItem

* Updated types

* Update use-add-item.tsx

* Moved auth & cart hooks + several fixes

* Updated cart item, fixed deprecations

* Update next.config.js

* Aligned with upstream changes

* Updates

* Update next.config.js

* Updated the commerce config structure

* Removed @framework imports within framework providers

* Fixed types

* changes

* Adding extra config

* Adding shopify commit

* Adding env templates to the providers

* Ignore some types

* Adding link for Cart

* Adding customCheckout

* multiple changes to fix the wishlist

* Shopify Provier Updates (#212)

* changes

* Adding shopify commit

* Changed to query page by id

* Fixed page query, Changed use-search GraphQl query

* Update use-search.tsx

* remove unused util

* Changed cookie expiration

* Update tsconfig.json

Co-authored-by: okbel <curciobel@gmail.com>

* Bump and adding dependency

* Adding color checks

* Now colors work with lighter colors

* Stable commerce.config.json

* Updated main readme

* Readme changes

* Default to bigcommerce

Co-authored-by: bc <bc@bcs-MacBook-Pro.fibertel.com.ar>
Co-authored-by: Luis Alvarez <luis@vercel.com>
Co-authored-by: cond0r <pinte_catalin@yahoo.com>
Co-authored-by: Peter Mekhaeil <4616064+petermekhaeil@users.noreply.github.com>
This commit is contained in:
B
2021-03-04 07:57:25 -03:00
committed by GitHub
parent b121078151
commit 9b71bd77fc
232 changed files with 20545 additions and 1895 deletions

View File

@@ -0,0 +1,6 @@
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,27 +1,25 @@
# Table of Contents
Table of Contents
=================
* [BigCommerce Storefront Data Hooks](#bigcommerce-storefront-data-hooks)
* [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](#bigcommerce-storefront-data-hooks)
- [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
@@ -49,6 +47,7 @@ BIGCOMMERCE_STOREFRONT_API_TOKEN=<>
BIGCOMMERCE_STORE_API_URL=<>
BIGCOMMERCE_STORE_API_TOKEN=<>
BIGCOMMERCE_STORE_API_CLIENT_ID=<>
BIGCOMMERCE_CHANNEL_ID=<>
```
## General Usage
@@ -193,13 +192,11 @@ Returns the current cart data for use
...
import useCart from '@bigcommerce/storefront-data-hooks/cart/use-cart'
const countItem = (count: number, item: any) => count + item.quantity
const countItems = (count: number, items: any[]) =>
items.reduce(countItem, count)
const countItem = (count: number, item: LineItem) => count + item.quantity
const CartNumber = () => {
const { data } = useCart()
const itemsCount = Object.values(data?.line_items ?? {}).reduce(countItems, 0)
const itemsCount = data?.lineItems.reduce(countItem, 0) ?? 0
return itemsCount > 0 ? <span>{itemsCount}</span> : null
}
@@ -235,7 +232,7 @@ import useUpdateItem from '@bigcommerce/storefront-data-hooks/cart/use-update-it
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 })
@@ -264,7 +261,7 @@ import useRemoveItem from '@bigcommerce/storefront-data-hooks/cart/use-remove-it
const RemoveButton = ({ item }) => {
const removeItem = useRemoveItem()
const handleRemove = async () => {
await removeItem({ id: item.id })
}

View File

@@ -2,7 +2,6 @@ import { parseCartItem } from '../../utils/parse-item'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartHandlers } from '..'
// Return current cart info
const addItem: CartHandlers['addItem'] = async ({
res,
body: { cartId, item },
@@ -26,8 +25,14 @@ const addItem: CartHandlers['addItem'] = async ({
}),
}
const { data } = cartId
? await config.storeApiFetch(`/v3/carts/${cartId}/items?include=line_items.physical_items.options`, options)
: await config.storeApiFetch('/v3/carts?include=line_items.physical_items.options', options)
? await config.storeApiFetch(
`/v3/carts/${cartId}/items?include=line_items.physical_items.options`,
options
)
: await config.storeApiFetch(
'/v3/carts?include=line_items.physical_items.options',
options
)
// Create or update the cart cookie
res.setHeader(

View File

@@ -1,6 +1,7 @@
import type { BigcommerceCart } from '../../../types'
import { BigcommerceApiError } from '../../utils/errors'
import getCartCookie from '../../utils/get-cart-cookie'
import type { Cart, CartHandlers } from '..'
import type { CartHandlers } from '../'
// Return current cart info
const getCart: CartHandlers['getCart'] = async ({
@@ -8,11 +9,13 @@ const getCart: CartHandlers['getCart'] = async ({
body: { cartId },
config,
}) => {
let result: { data?: Cart } = {}
let result: { data?: BigcommerceCart } = {}
if (cartId) {
try {
result = await config.storeApiFetch(`/v3/carts/${cartId}?include=line_items.physical_items.options`)
result = await config.storeApiFetch(
`/v3/carts/${cartId}?include=line_items.physical_items.options`
)
} catch (error) {
if (error instanceof BigcommerceApiError && error.status === 404) {
// Remove the cookie if it exists but the cart wasn't found

View File

@@ -1,7 +1,6 @@
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartHandlers } from '..'
// Return current cart info
const removeItem: CartHandlers['removeItem'] = async ({
res,
body: { cartId, itemId },

View File

@@ -2,7 +2,6 @@ import { parseCartItem } from '../../utils/parse-item'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartHandlers } from '..'
// Return current cart info
const updateItem: CartHandlers['updateItem'] = async ({
res,
body: { cartId, itemId, item },

View File

@@ -8,63 +8,25 @@ import getCart from './handlers/get-cart'
import addItem from './handlers/add-item'
import updateItem from './handlers/update-item'
import removeItem from './handlers/remove-item'
type OptionSelections = {
option_id: Number
option_value: Number|String
}
export type ItemBody = {
productId: number
variantId: number
quantity?: number
optionSelections?: OptionSelections
}
export type AddItemBody = { item: ItemBody }
export type UpdateItemBody = { itemId: string; item: ItemBody }
export type RemoveItemBody = { itemId: string }
// TODO: this type should match:
// https://developer.bigcommerce.com/api-reference/cart-checkout/server-server-cart-api/cart/getacart#responses
export type Cart = {
id: string
parent_id?: string
customer_id: number
email: string
currency: { code: string }
tax_included: boolean
base_amount: number
discount_amount: number
cart_amount: number
line_items: {
custom_items: any[]
digital_items: any[]
gift_certificates: any[]
physical_items: any[]
}
// TODO: add missing fields
}
import type {
BigcommerceCart,
GetCartHandlerBody,
AddCartItemHandlerBody,
UpdateCartItemHandlerBody,
RemoveCartItemHandlerBody,
} from '../../types'
export type CartHandlers = {
getCart: BigcommerceHandler<Cart, { cartId?: string }>
addItem: BigcommerceHandler<Cart, { cartId?: string } & Partial<AddItemBody>>
updateItem: BigcommerceHandler<
Cart,
{ cartId?: string } & Partial<UpdateItemBody>
>
removeItem: BigcommerceHandler<
Cart,
{ cartId?: string } & Partial<RemoveItemBody>
>
getCart: BigcommerceHandler<BigcommerceCart, GetCartHandlerBody>
addItem: BigcommerceHandler<BigcommerceCart, AddCartItemHandlerBody>
updateItem: BigcommerceHandler<BigcommerceCart, UpdateCartItemHandlerBody>
removeItem: BigcommerceHandler<BigcommerceCart, RemoveCartItemHandlerBody>
}
const METHODS = ['GET', 'POST', 'PUT', 'DELETE']
// TODO: a complete implementation should have schema validation for `req.body`
const cartApi: BigcommerceApiHandler<Cart, CartHandlers> = async (
const cartApi: BigcommerceApiHandler<BigcommerceCart, CartHandlers> = async (
req,
res,
config,

View File

@@ -1,4 +1,5 @@
import getAllProducts, { ProductEdge } from '../../operations/get-all-products'
import { Product } from '@commerce/types'
import getAllProducts, { ProductEdge } from '../../../product/get-all-products'
import type { ProductsHandlers } from '../products'
const SORT: { [key: string]: string | undefined } = {
@@ -6,6 +7,7 @@ const SORT: { [key: string]: string | undefined } = {
trending: 'total_sold',
price: 'price',
}
const LIMIT = 12
// Return current cart info
@@ -44,21 +46,25 @@ const getProducts: ProductsHandlers['getProducts'] = async ({
const { data } = await config.storeApiFetch<{ data: { id: number }[] }>(
url.pathname + url.search
)
const entityIds = data.map((p) => p.id)
const found = entityIds.length > 0
// We want the GraphQL version of each product
const graphqlData = await getAllProducts({
variables: { first: LIMIT, entityIds },
config,
})
// Put the products in an object that we can use to get them by id
const productsById = graphqlData.products.reduce<{
[k: number]: ProductEdge
[k: number]: Product
}>((prods, p) => {
prods[p.node.entityId] = p
prods[Number(p.id)] = p
return prods
}, {})
const products: ProductEdge[] = found ? [] : graphqlData.products
const products: Product[] = found ? [] : graphqlData.products
// Populate the products array with the graphql products, in the order
// assigned by the list of entity ids

View File

@@ -1,27 +1,27 @@
import type { Product } from '@commerce/types'
import isAllowedMethod from '../utils/is-allowed-method'
import createApiHandler, {
BigcommerceApiHandler,
BigcommerceHandler,
} from '../utils/create-api-handler'
import { BigcommerceApiError } from '../utils/errors'
import type { ProductEdge } from '../operations/get-all-products'
import getProducts from './handlers/get-products'
export type SearchProductsData = {
products: ProductEdge[]
products: Product[]
found: boolean
}
export type ProductsHandlers = {
getProducts: BigcommerceHandler<
SearchProductsData,
{ search?: 'string'; category?: string; brand?: string; sort?: string }
{ search?: string; category?: string; brand?: string; sort?: string }
>
}
const METHODS = ['GET']
// TODO: a complete implementation should have schema validation for `req.body`
// TODO(lf): a complete implementation should have schema validation for `req.body`
const productsApi: BigcommerceApiHandler<
SearchProductsData,
ProductsHandlers

View File

@@ -1,5 +1,5 @@
import { FetcherError } from '@commerce/utils/errors'
import login from '../../operations/login'
import login from '../../../auth/login'
import type { LoginHandlers } from '../login'
const invalidCredentials = /invalid credentials/i

View File

@@ -1,5 +1,5 @@
import { BigcommerceApiError } from '../../utils/errors'
import login from '../../operations/login'
import login from '../../../auth/login'
import { SignupHandlers } from '../signup'
const signup: SignupHandlers['signup'] = async ({

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { WishlistHandlers } from '..'
import getCustomerId from '../../operations/get-customer-id'
import getCustomerWishlist from '../../operations/get-customer-wishlist'
import getCustomerId from '../../../customer/get-customer-id'
import getCustomerWishlist from '../../../customer/get-customer-wishlist'
import { parseWishlistItem } from '../../utils/parse-item'
// Returns the wishlist of the signed customer

View File

@@ -1,5 +1,5 @@
import getCustomerId from '../../operations/get-customer-id'
import getCustomerWishlist from '../../operations/get-customer-wishlist'
import getCustomerId from '../../../customer/get-customer-id'
import getCustomerWishlist from '../../../customer/get-customer-wishlist'
import type { Wishlist, WishlistHandlers } from '..'
// Return wishlist info

View File

@@ -1,7 +1,7 @@
import getCustomerId from '../../operations/get-customer-id'
import getCustomerId from '../../../customer/get-customer-id'
import getCustomerWishlist, {
Wishlist,
} from '../../operations/get-customer-wishlist'
} from '../../../customer/get-customer-wishlist'
import type { WishlistHandlers } from '..'
// Return current wishlist info

View File

@@ -7,24 +7,25 @@ import { BigcommerceApiError } from '../utils/errors'
import type {
Wishlist,
WishlistItem,
} from '../operations/get-customer-wishlist'
} from '../../customer/get-customer-wishlist'
import getWishlist from './handlers/get-wishlist'
import addItem from './handlers/add-item'
import removeItem from './handlers/remove-item'
import type { Product, ProductVariant, Customer } from '@commerce/types'
export type { Wishlist, WishlistItem }
export type ItemBody = {
productId: number
variantId: number
productId: Product['id']
variantId: ProductVariant['id']
}
export type AddItemBody = { item: ItemBody }
export type RemoveItemBody = { itemId: string }
export type RemoveItemBody = { itemId: Product['id'] }
export type WishlistBody = {
customer_id: number
customer_id: Customer['entityId']
is_public: number
name: string
items: any[]

View File

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

View File

@@ -1,8 +1,8 @@
import type { ServerResponse } from 'http'
import type { LoginMutation, LoginMutationVariables } from '../../schema'
import type { RecursivePartial } from '../utils/types'
import concatHeader from '../utils/concat-cookie'
import { BigcommerceConfig, getConfig } from '..'
import type { LoginMutation, LoginMutationVariables } from '../schema'
import type { RecursivePartial } from '../api/utils/types'
import concatHeader from '../api/utils/concat-cookie'
import { BigcommerceConfig, getConfig } from '../api'
export const loginMutation = /* GraphQL */ `
mutation login($email: String!, $password: String!) {
@@ -54,7 +54,7 @@ async function login({
if (process.env.NODE_ENV !== 'production') {
cookie = cookie.replace('; Secure', '')
// SameSite=none can't be set unless the cookie is Secure
// bc seems to sometimes send back SameSite=None rather than none so make
// bc seems to sometimes send back SameSite=None rather than none so make
// this case insensitive
cookie = cookie.replace(/; SameSite=none/gi, '; SameSite=lax')
}

View File

@@ -0,0 +1,40 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useLogin, { UseLogin } from '@commerce/auth/use-login'
import type { LoginBody } from '../api/customers/login'
import useCustomer from '../customer/use-customer'
export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<null, {}, LoginBody> = {
fetchOptions: {
url: '/api/bigcommerce/customers/login',
method: 'POST',
},
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message:
'A first name, last name, email and password are required to login',
})
}
return fetch({
...options,
body: { email, password },
})
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
return useCallback(
async function login(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

View File

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

View File

@@ -0,0 +1,44 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useSignup, { UseSignup } from '@commerce/auth/use-signup'
import type { SignupBody } from '../api/customers/signup'
import useCustomer from '../customer/use-customer'
export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<null, {}, SignupBody, SignupBody> = {
fetchOptions: {
url: '/api/bigcommerce/customers/signup',
method: 'POST',
},
async fetcher({
input: { firstName, lastName, email, password },
options,
fetch,
}) {
if (!(firstName && lastName && email && password)) {
throw new CommerceError({
message:
'A first name, last name, email and password are required to signup',
})
}
return fetch({
...options,
body: { firstName, lastName, email, password },
})
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
return useCallback(
async function signup(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

View File

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

View File

@@ -1,56 +1,50 @@
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 useCartAddItem from '@commerce/cart/use-add-item'
import type { ItemBody, AddItemBody } from '../api/cart'
import useCart, { Cart } from './use-cart'
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'
const defaultOpts = {
url: '/api/bigcommerce/cart',
method: 'POST',
}
export default useAddItem as UseAddItem<typeof handler>
export type AddItemInput = ItemBody
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',
})
}
export const fetcher: HookFetcher<Cart, AddItemBody> = (
options,
{ item },
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 fetch({
...defaultOpts,
...options,
body: { item },
})
}
export function extendHook(customFetcher: typeof fetcher) {
const useAddItem = () => {
return normalizeCart(data)
},
useHook: ({ fetch }) => () => {
const { mutate } = useCart()
const fn = useCartAddItem(defaultOpts, customFetcher)
return useCallback(
async function addItem(input: AddItemInput) {
const data = await fn({ item: input })
async function addItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fn, mutate]
[fetch, mutate]
)
}
useAddItem.extend = extendHook
return useAddItem
},
}
export default extendHook(fetcher)

View File

@@ -1,13 +0,0 @@
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() {
const addItem = useAddItem()
const updateItem = useUpdateItem()
const removeItem = useRemoveItem()
return { addItem, updateItem, removeItem }
}

View File

@@ -1,50 +1,41 @@
import type { HookFetcher } from '@commerce/utils/types'
import type { SwrOptions } from '@commerce/utils/use-data'
import useCommerceCart, { CartInput } from '@commerce/cart/use-cart'
import type { Cart } from '../api/cart'
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'
const defaultOpts = {
url: '/api/bigcommerce/cart',
method: 'GET',
}
export default useCart as UseCart<typeof handler>
export type { Cart }
export const fetcher: HookFetcher<Cart | null, CartInput> = (
options,
{ cartId },
fetch
) => {
return cartId ? fetch({ ...defaultOpts, ...options }) : null
}
export function extendHook(
customFetcher: typeof fetcher,
swrOptions?: SwrOptions<Cart | null, CartInput>
) {
const useCart = () => {
const response = useCommerceCart(defaultOpts, [], customFetcher, {
revalidateOnFocus: false,
...swrOptions,
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 },
})
// Uses a getter to only calculate the prop when required
// response.data is also a getter and it's better to not trigger it early
Object.defineProperty(response, 'isEmpty', {
get() {
return Object.values(response.data?.line_items ?? {}).every(
(items) => !items.length
)
},
set: (x) => x,
})
return response
}
useCart.extend = extendHook
return useCart
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}
export default extendHook(fetcher)

View File

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

View File

@@ -1,70 +1,97 @@
import { useCallback } from 'react'
import debounce from 'lodash.debounce'
import type { HookFetcher } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useCartUpdateItem from '@commerce/cart/use-update-item'
import type { ItemBody, UpdateItemBody } from '../api/cart'
import { fetcher as removeFetcher } from './use-remove-item'
import useCart, { Cart } from './use-cart'
import type {
MutationHookContext,
HookFetcherContext,
} from '@commerce/utils/types'
import { ValidationError } from '@commerce/utils/errors'
import useUpdateItem, {
UpdateItemInput as UpdateItemInputBase,
UseUpdateItem,
} from '@commerce/cart/use-update-item'
import { normalizeCart } from '../lib/normalize'
import type {
UpdateCartItemBody,
Cart,
BigcommerceCart,
LineItem,
} from '../types'
import { handler as removeItemHandler } from './use-remove-item'
import useCart from './use-cart'
const defaultOpts = {
url: '/api/bigcommerce/cart',
method: 'PUT',
}
export type UpdateItemInput<T = any> = T extends LineItem
? Partial<UpdateItemInputBase<LineItem>>
: UpdateItemInputBase<LineItem>
export type UpdateItemInput = Partial<{ id: string } & ItemBody>
export default useUpdateItem as UseUpdateItem<typeof handler>
export const fetcher: HookFetcher<Cart | null, UpdateItemBody> = (
options,
{ itemId, item },
fetch
) => {
if (Number.isInteger(item.quantity)) {
// Also allow the update hook to remove an item if the quantity is lower than 1
if (item.quantity! < 1) {
return removeFetcher(null, { itemId }, fetch)
export const handler = {
fetchOptions: {
url: '/api/bigcommerce/cart',
method: 'PUT',
},
async fetcher({
input: { itemId, item },
options,
fetch,
}: HookFetcherContext<UpdateCartItemBody>) {
if (Number.isInteger(item.quantity)) {
// Also allow the update hook to remove an item if the quantity is lower than 1
if (item.quantity! < 1) {
return removeItemHandler.fetcher({
options: removeItemHandler.fetchOptions,
input: { itemId },
fetch,
})
}
} else if (item.quantity) {
throw new ValidationError({
message: 'The item quantity has to be a valid integer',
})
}
} else if (item.quantity) {
throw new CommerceError({
message: 'The item quantity has to be a valid integer',
const data = await fetch<BigcommerceCart, UpdateCartItemBody>({
...options,
body: { itemId, item },
})
}
return fetch({
...defaultOpts,
...options,
body: { itemId, item },
})
}
function extendHook(customFetcher: typeof fetcher, cfg?: { wait?: number }) {
const useUpdateItem = (item?: any) => {
const { mutate } = useCart()
const fn = useCartUpdateItem<Cart | null, UpdateItemBody>(
defaultOpts,
customFetcher
)
return normalizeCart(data)
},
useHook: ({
fetch,
}: MutationHookContext<Cart | null, UpdateCartItemBody>) => <
T extends LineItem | undefined = undefined
>(
ctx: {
item?: T
wait?: number
} = {}
) => {
const { item } = ctx
const { mutate } = useCart() as any
return useCallback(
debounce(async (input: UpdateItemInput) => {
const data = await fn({
itemId: input.id ?? item?.id,
item: {
productId: input.productId ?? item?.product_id,
variantId: input.productId ?? item?.variant_id,
quantity: input.quantity,
debounce(async (input: UpdateItemInput<T>) => {
const itemId = input.id ?? item?.id
const productId = input.productId ?? item?.productId
const variantId = input.productId ?? item?.variantId
if (!itemId || !productId || !variantId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
})
}
const data = await fetch({
input: {
itemId,
item: { productId, variantId, quantity: input.quantity },
},
})
await mutate(data, false)
return data
}, cfg?.wait ?? 500),
[fn, mutate]
}, ctx.wait ?? 500),
[fetch, mutate]
)
}
useUpdateItem.extend = extendHook
return useUpdateItem
},
}
export default extendHook(fetcher)

View File

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

View File

@@ -1,6 +1,6 @@
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import { BigcommerceConfig, getConfig } from '..'
import { definitions } from '../definitions/store-content'
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import { BigcommerceConfig, getConfig } from '../api'
import { definitions } from '../api/definitions/store-content'
export type Page = definitions['page_Full']

View File

@@ -1,6 +1,6 @@
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import { BigcommerceConfig, getConfig } from '..'
import { definitions } from '../definitions/store-content'
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import { BigcommerceConfig, getConfig } from '../api'
import { definitions } from '../api/definitions/store-content'
export type Page = definitions['page_Full']
@@ -38,9 +38,9 @@ async function getPage({
config = getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `url`
const { data } = await config.storeApiFetch<RecursivePartial<{ data: Page[] }>>(
url || `/v3/content/pages?id=${variables.id}&include=body`
)
const { data } = await config.storeApiFetch<
RecursivePartial<{ data: Page[] }>
>(url || `/v3/content/pages?id=${variables.id}&include=body`)
const firstPage = data?.[0]
const page = firstPage as RecursiveRequired<typeof firstPage>

View File

@@ -1,8 +1,8 @@
import type { GetSiteInfoQuery, GetSiteInfoQueryVariables } from '../../schema'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import filterEdges from '../utils/filter-edges'
import { BigcommerceConfig, getConfig } from '..'
import { categoryTreeItemFragment } from '../fragments/category-tree'
import type { GetSiteInfoQuery, GetSiteInfoQueryVariables } from '../schema'
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import filterEdges from '../api/utils/filter-edges'
import { BigcommerceConfig, getConfig } from '../api'
import { categoryTreeItemFragment } from '../api/fragments/category-tree'
// Get 3 levels of categories
export const getSiteInfoQuery = /* GraphQL */ `

View File

@@ -1,5 +1,5 @@
import { GetCustomerIdQuery } from '../../schema'
import { BigcommerceConfig, getConfig } from '..'
import { GetCustomerIdQuery } from '../schema'
import { BigcommerceConfig, getConfig } from '../api'
export const getCustomerIdQuery = /* GraphQL */ `
query getCustomerId {

View File

@@ -1,7 +1,7 @@
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import { definitions } from '../definitions/wishlist'
import { BigcommerceConfig, getConfig } from '..'
import getAllProducts, { ProductEdge } from './get-all-products'
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import { definitions } from '../api/definitions/wishlist'
import { BigcommerceConfig, getConfig } from '../api'
import getAllProducts, { ProductEdge } from '../product/get-all-products'
export type Wishlist = Omit<definitions['wishlist_Full'], 'items'> & {
items?: WishlistItem[]
@@ -68,14 +68,15 @@ async function getCustomerWishlist({
const productsById = graphqlData.products.reduce<{
[k: number]: ProductEdge
}>((prods, p) => {
prods[p.node.entityId] = p
prods[Number(p.id)] = p as any
return prods
}, {})
// Populate the wishlist items with the graphql products
wishlist.items.forEach((item) => {
const product = item && productsById[item.product_id!]
if (item && product) {
item.product = product.node
// @ts-ignore Fix this type when the wishlist type is properly defined
item.product = product
}
})
}

View File

@@ -0,0 +1 @@
export { default as useCustomer } from './use-customer'

View File

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

View File

@@ -0,0 +1,41 @@
import { FetcherError } from '@commerce/utils/errors'
import type { Fetcher } from '@commerce/utils/types'
async function getText(res: Response) {
try {
return (await res.text()) || res.statusText
} catch (error) {
return res.statusText
}
}
async function getError(res: Response) {
if (res.headers.get('Content-Type')?.includes('application/json')) {
const data = await res.json()
return new FetcherError({ errors: data.errors, status: res.status })
}
return new FetcherError({ message: await getText(res), status: res.status })
}
const fetcher: Fetcher = async ({
url,
method = 'GET',
variables,
body: bodyObj,
}) => {
const hasBody = Boolean(variables || bodyObj)
const body = hasBody
? JSON.stringify(variables ? { variables } : bodyObj)
: undefined
const headers = hasBody ? { 'Content-Type': 'application/json' } : undefined
const res = await fetch(url!, { method, body, headers })
if (res.ok) {
const { data } = await res.json()
return data
}
throw await getError(res)
}
export default fetcher

View File

@@ -1,46 +1,17 @@
import { ReactNode } from 'react'
import * as React from 'react'
import type { ReactNode } from 'react'
import {
CommerceConfig,
CommerceProvider as CoreCommerceProvider,
useCommerce as useCoreCommerce,
} from '@commerce'
import { FetcherError } from '@commerce/utils/errors'
import { bigcommerceProvider, BigcommerceProvider } from './provider'
async function getText(res: Response) {
try {
return (await res.text()) || res.statusText
} catch (error) {
return res.statusText
}
}
async function getError(res: Response) {
if (res.headers.get('Content-Type')?.includes('application/json')) {
const data = await res.json()
return new FetcherError({ errors: data.errors, status: res.status })
}
return new FetcherError({ message: await getText(res), status: res.status })
}
export { bigcommerceProvider }
export type { BigcommerceProvider }
export const bigcommerceConfig: CommerceConfig = {
locale: 'en-us',
cartCookie: 'bc_cartId',
async fetcher({ url, method = 'GET', variables, body: bodyObj }) {
const hasBody = Boolean(variables || bodyObj)
const body = hasBody
? JSON.stringify(variables ? { variables } : bodyObj)
: undefined
const headers = hasBody ? { 'Content-Type': 'application/json' } : undefined
const res = await fetch(url!, { method, body, headers })
if (res.ok) {
const { data } = await res.json()
return data
}
throw await getError(res)
},
}
export type BigcommerceConfig = Partial<CommerceConfig>
@@ -52,10 +23,13 @@ export type BigcommerceProps = {
export function CommerceProvider({ children, ...config }: BigcommerceProps) {
return (
<CoreCommerceProvider config={{ ...bigcommerceConfig, ...config }}>
<CoreCommerceProvider
provider={bigcommerceProvider}
config={{ ...bigcommerceConfig, ...config }}
>
{children}
</CoreCommerceProvider>
)
}
export const useCommerce = () => useCoreCommerce()
export const useCommerce = () => useCoreCommerce<BigcommerceProvider>()

View File

@@ -0,0 +1,13 @@
import update, { Context } from 'immutability-helper'
const c = new Context()
c.extend('$auto', function (value, object) {
return object ? c.update(object, value) : c.update({}, value)
})
c.extend('$autoArray', function (value, object) {
return object ? c.update(object, value) : c.update([], value)
})
export default c.update

View File

@@ -0,0 +1,113 @@
import type { Product } from '@commerce/types'
import type { Cart, BigcommerceCart, LineItem } from '../types'
import update from './immutability'
function normalizeProductOption(productOption: any) {
const {
node: {
entityId,
values: { edges },
...rest
},
} = productOption
return {
id: entityId,
values: edges?.map(({ node }: any) => node),
...rest,
}
}
export function normalizeProduct(productNode: any): Product {
const {
entityId: id,
productOptions,
prices,
path,
id: _,
options: _0,
} = productNode
return update(productNode, {
id: { $set: String(id) },
images: {
$apply: ({ edges }: any) =>
edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({
url: urlOriginal,
alt: altText,
...rest,
})),
},
variants: {
$apply: ({ edges }: any) =>
edges?.map(({ node: { entityId, productOptions, ...rest } }: any) => ({
id: entityId,
options: productOptions?.edges
? productOptions.edges.map(normalizeProductOption)
: [],
...rest,
})),
},
options: {
$set: productOptions.edges
? productOptions?.edges.map(normalizeProductOption)
: [],
},
brand: {
$apply: (brand: any) => (brand?.entityId ? brand?.entityId : null),
},
slug: {
$set: path?.replace(/^\/+|\/+$/g, ''),
},
price: {
$set: {
value: prices?.price.value,
currencyCode: prices?.price.currencyCode,
},
},
$unset: ['entityId'],
})
}
export function normalizeCart(data: BigcommerceCart): Cart {
return {
id: data.id,
customerId: String(data.customer_id),
email: data.email,
createdAt: data.created_time,
currency: data.currency,
taxesIncluded: data.tax_included,
lineItems: data.line_items.physical_items.map(normalizeLineItem),
lineItemsSubtotalPrice: data.base_amount,
subtotalPrice: data.base_amount + data.discount_amount,
totalPrice: data.cart_amount,
discounts: data.discounts?.map((discount) => ({
value: discount.discounted_amount,
})),
}
}
function normalizeLineItem(item: any): LineItem {
return {
id: item.id,
variantId: String(item.variant_id),
productId: String(item.product_id),
name: item.name,
quantity: item.quantity,
variant: {
id: String(item.variant_id),
sku: item.sku,
name: item.name,
image: {
url: item.image_url,
},
requiresShipping: item.is_require_shipping,
price: item.sale_price,
listPrice: item.list_price,
},
path: item.url.split('/')[3],
discounts: item.discounts.map((discount: any) => ({
value: discount.discounted_amount,
})),
}
}

View File

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

View File

@@ -1,10 +1,10 @@
import type {
GetAllProductPathsQuery,
GetAllProductPathsQueryVariables,
} from '../../schema'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import filterEdges from '../utils/filter-edges'
import { BigcommerceConfig, getConfig } from '..'
} from '../schema'
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import filterEdges from '../api/utils/filter-edges'
import { BigcommerceConfig, getConfig } from '../api'
export const getAllProductPathsQuery = /* GraphQL */ `
query getAllProductPaths($first: Int = 100) {

View File

@@ -1,12 +1,14 @@
import type {
GetAllProductsQuery,
GetAllProductsQueryVariables,
} from '../../schema'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import filterEdges from '../utils/filter-edges'
import setProductLocaleMeta from '../utils/set-product-locale-meta'
import { productConnectionFragment } from '../fragments/product'
import { BigcommerceConfig, getConfig } from '..'
} from '../schema'
import type { Product } from '@commerce/types'
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import filterEdges from '../api/utils/filter-edges'
import setProductLocaleMeta from '../api/utils/set-product-locale-meta'
import { productConnectionFragment } from '../api/fragments/product'
import { BigcommerceConfig, getConfig } from '../api'
import { normalizeProduct } from '../lib/normalize'
export const getAllProductsQuery = /* GraphQL */ `
query getAllProducts(
@@ -72,7 +74,7 @@ async function getAllProducts(opts?: {
variables?: ProductVariables
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetAllProductsResult>
}): Promise<{ products: Product[] }>
async function getAllProducts<
T extends Record<keyof GetAllProductsResult, any[]>,
@@ -93,7 +95,8 @@ async function getAllProducts({
variables?: ProductVariables
config?: BigcommerceConfig
preview?: boolean
} = {}): Promise<GetAllProductsResult> {
// TODO: fix the product type here
} = {}): Promise<{ products: Product[] | any[] }> {
config = getConfig(config)
const locale = vars.locale || config.locale
@@ -126,7 +129,7 @@ async function getAllProducts({
})
}
return { products }
return { products: products.map(({ node }) => normalizeProduct(node as any)) }
}
export default getAllProducts

View File

@@ -1,7 +1,9 @@
import type { GetProductQuery, GetProductQueryVariables } from '../../schema'
import setProductLocaleMeta from '../utils/set-product-locale-meta'
import { productInfoFragment } from '../fragments/product'
import { BigcommerceConfig, getConfig } from '..'
import type { GetProductQuery, GetProductQueryVariables } from '../schema'
import setProductLocaleMeta from '../api/utils/set-product-locale-meta'
import { productInfoFragment } from '../api/fragments/product'
import { BigcommerceConfig, getConfig } from '../api'
import { normalizeProduct } from '../lib/normalize'
import type { Product } from '@commerce/types'
export const getProductQuery = /* GraphQL */ `
query getProduct(
@@ -92,7 +94,7 @@ async function getProduct({
variables: ProductVariables
config?: BigcommerceConfig
preview?: boolean
}): Promise<GetProductResult> {
}): Promise<Product | {} | any> {
config = getConfig(config)
const locale = vars.locale || config.locale
@@ -109,7 +111,8 @@ async function getProduct({
if (locale && config.applyLocale) {
setProductLocaleMeta(product)
}
return { product }
return { product: normalizeProduct(product as any) }
}
return {}

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
import { SWRHook } from '@commerce/utils/types'
import useSearch, { UseSearch } from '@commerce/product/use-search'
import type { SearchProductsData } from '../api/catalog/products'
export default useSearch as UseSearch<typeof handler>
export type SearchProductsInput = {
search?: string
categoryId?: number
brandId?: number
sort?: string
}
export const handler: SWRHook<
SearchProductsData,
SearchProductsInput,
SearchProductsInput
> = {
fetchOptions: {
url: '/api/bigcommerce/catalog/products',
method: 'GET',
},
fetcher({ input: { search, categoryId, brandId, sort }, options, fetch }) {
// Use a dummy base as we only care about the relative path
const url = new URL(options.url!, 'http://a')
if (search) url.searchParams.set('search', search)
if (Number.isInteger(categoryId))
url.searchParams.set('category', String(categoryId))
if (Number.isInteger(brandId))
url.searchParams.set('brand', String(brandId))
if (sort) url.searchParams.set('sort', sort)
return fetch({
url: url.pathname + url.search,
method: options.method,
})
},
useHook: ({ useData }) => (input = {}) => {
return useData({
input: [
['search', input.search],
['categoryId', input.categoryId],
['brandId', input.brandId],
['sort', input.sort],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
}

View File

@@ -1,63 +0,0 @@
import type { HookFetcher } from '@commerce/utils/types'
import type { SwrOptions } from '@commerce/utils/use-data'
import useCommerceSearch from '@commerce/products/use-search'
import type { SearchProductsData } from '../api/catalog/products'
const defaultOpts = {
url: '/api/bigcommerce/catalog/products',
method: 'GET',
}
export type SearchProductsInput = {
search?: string
categoryId?: number
brandId?: number
sort?: string
}
export const fetcher: HookFetcher<SearchProductsData, SearchProductsInput> = (
options,
{ search, categoryId, brandId, sort },
fetch
) => {
// Use a dummy base as we only care about the relative path
const url = new URL(options?.url ?? defaultOpts.url, 'http://a')
if (search) url.searchParams.set('search', search)
if (Number.isInteger(categoryId))
url.searchParams.set('category', String(categoryId))
if (Number.isInteger(brandId)) url.searchParams.set('brand', String(brandId))
if (sort) url.searchParams.set('sort', sort)
return fetch({
url: url.pathname + url.search,
method: options?.method ?? defaultOpts.method,
})
}
export function extendHook(
customFetcher: typeof fetcher,
swrOptions?: SwrOptions<SearchProductsData, SearchProductsInput>
) {
const useSearch = (input: SearchProductsInput = {}) => {
const response = useCommerceSearch(
defaultOpts,
[
['search', input.search],
['categoryId', input.categoryId],
['brandId', input.brandId],
['sort', input.sort],
],
customFetcher,
{ revalidateOnFocus: false, ...swrOptions }
)
return response
}
useSearch.extend = extendHook
return useSearch
}
export default extendHook(fetcher)

View File

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

View File

@@ -0,0 +1,58 @@
import * as Core from '@commerce/types'
// TODO: this type should match:
// https://developer.bigcommerce.com/api-reference/cart-checkout/server-server-cart-api/cart/getacart#responses
export type BigcommerceCart = {
id: string
parent_id?: string
customer_id: number
email: string
currency: { code: string }
tax_included: boolean
base_amount: number
discount_amount: number
cart_amount: number
line_items: {
custom_items: any[]
digital_items: any[]
gift_certificates: any[]
physical_items: any[]
}
created_time: string
discounts?: { id: number; discounted_amount: number }[]
// TODO: add missing fields
}
export type Cart = Core.Cart & {
lineItems: LineItem[]
}
export type LineItem = Core.LineItem
/**
* Cart mutations
*/
export type OptionSelections = {
option_id: number
option_value: number | string
}
export type CartItemBody = Core.CartItemBody & {
productId: string // The product id is always required for BC
optionSelections?: OptionSelections
}
export type GetCartHandlerBody = Core.GetCartHandlerBody
export type AddCartItemBody = Core.AddCartItemBody<CartItemBody>
export type AddCartItemHandlerBody = Core.AddCartItemHandlerBody<CartItemBody>
export type UpdateCartItemBody = Core.UpdateCartItemBody<CartItemBody>
export type UpdateCartItemHandlerBody = Core.UpdateCartItemHandlerBody<CartItemBody>
export type RemoveCartItemBody = Core.RemoveCartItemBody
export type RemoveCartItemHandlerBody = Core.RemoveCartItemHandlerBody

View File

@@ -1,38 +0,0 @@
import type { HookFetcher } from '@commerce/utils/types'
import type { SwrOptions } from '@commerce/utils/use-data'
import useCommerceCustomer from '@commerce/use-customer'
import type { Customer, CustomerData } from './api/customers'
const defaultOpts = {
url: '/api/bigcommerce/customers',
method: 'GET',
}
export type { Customer }
export const fetcher: HookFetcher<Customer | null> = async (
options,
_,
fetch
) => {
const data = await fetch<CustomerData | null>({ ...defaultOpts, ...options })
return data?.customer ?? null
}
export function extendHook(
customFetcher: typeof fetcher,
swrOptions?: SwrOptions<Customer | null>
) {
const useCustomer = () => {
return useCommerceCustomer(defaultOpts, [], customFetcher, {
revalidateOnFocus: false,
...swrOptions,
})
}
useCustomer.extend = extendHook
return useCustomer
}
export default extendHook(fetcher)

View File

@@ -1,54 +0,0 @@
import { useCallback } from 'react'
import type { HookFetcher } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useCommerceLogin from '@commerce/use-login'
import type { LoginBody } from './api/customers/login'
import useCustomer from './use-customer'
const defaultOpts = {
url: '/api/bigcommerce/customers/login',
method: 'POST',
}
export type LoginInput = LoginBody
export const fetcher: HookFetcher<null, LoginBody> = (
options,
{ email, password },
fetch
) => {
if (!(email && password)) {
throw new CommerceError({
message:
'A first name, last name, email and password are required to login',
})
}
return fetch({
...defaultOpts,
...options,
body: { email, password },
})
}
export function extendHook(customFetcher: typeof fetcher) {
const useLogin = () => {
const { revalidate } = useCustomer()
const fn = useCommerceLogin<null, LoginInput>(defaultOpts, customFetcher)
return useCallback(
async function login(input: LoginInput) {
const data = await fn(input)
await revalidate()
return data
},
[fn]
)
}
useLogin.extend = extendHook
return useLogin
}
export default extendHook(fetcher)

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
import { useCallback } from 'react'
import type { HookFetcher } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useCommerceSignup from '@commerce/use-signup'
import type { SignupBody } from './api/customers/signup'
import useCustomer from './use-customer'
const defaultOpts = {
url: '/api/bigcommerce/customers/signup',
method: 'POST',
}
export type SignupInput = SignupBody
export const fetcher: HookFetcher<null, SignupBody> = (
options,
{ firstName, lastName, email, password },
fetch
) => {
if (!(firstName && lastName && email && password)) {
throw new CommerceError({
message:
'A first name, last name, email and password are required to signup',
})
}
return fetch({
...defaultOpts,
...options,
body: { firstName, lastName, email, password },
})
}
export function extendHook(customFetcher: typeof fetcher) {
const useSignup = () => {
const { revalidate } = useCustomer()
const fn = useCommerceSignup<null, SignupInput>(defaultOpts, customFetcher)
return useCallback(
async function signup(input: SignupInput) {
const data = await fn(input)
await revalidate()
return data
},
[fn]
)
}
useSignup.extend = extendHook
return useSignup
}
export default extendHook(fetcher)

View File

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

View File

@@ -1,39 +1,24 @@
import { useCallback } from 'react'
import { HookFetcher } from '@commerce/utils/types'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useWishlistAddItem from '@commerce/wishlist/use-add-item'
import useAddItem, { UseAddItem } from '@commerce/wishlist/use-add-item'
import type { ItemBody, AddItemBody } from '../api/wishlist'
import useCustomer from '../use-customer'
import useWishlist, { UseWishlistOptions, Wishlist } from './use-wishlist'
import useCustomer from '../customer/use-customer'
import useWishlist from './use-wishlist'
const defaultOpts = {
url: '/api/bigcommerce/wishlist',
method: 'POST',
}
export default useAddItem as UseAddItem<typeof handler>
export type AddItemInput = ItemBody
export const fetcher: HookFetcher<Wishlist, 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?: UseWishlistOptions) => {
export const handler: MutationHook<any, {}, ItemBody, AddItemBody> = {
fetchOptions: {
url: '/api/bigcommerce/wishlist',
method: 'POST',
},
useHook: ({ fetch }) => () => {
const { data: customer } = useCustomer()
const { revalidate } = useWishlist(opts)
const fn = useWishlistAddItem(defaultOpts, customFetcher)
const { revalidate } = useWishlist()
return useCallback(
async function addItem(input: AddItemInput) {
async function addItem(item) {
if (!customer) {
// A signed customer is required in order to have a wishlist
throw new CommerceError({
@@ -41,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()
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 { HookFetcher } from '@commerce/utils/types'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useWishlistRemoveItem from '@commerce/wishlist/use-remove-item'
import type { RemoveItemBody } from '../api/wishlist'
import useCustomer from '../use-customer'
import useWishlist, { UseWishlistOptions, Wishlist } from './use-wishlist'
import useRemoveItem, {
RemoveItemInput,
UseRemoveItem,
} from '@commerce/wishlist/use-remove-item'
import type { RemoveItemBody, Wishlist } from '../api/wishlist'
import useCustomer from '../customer/use-customer'
import useWishlist, { UseWishlistInput } from './use-wishlist'
const defaultOpts = {
url: '/api/bigcommerce/wishlist',
method: 'DELETE',
}
export default useRemoveItem as UseRemoveItem<typeof handler>
export type RemoveItemInput = {
id: string | number
}
export const fetcher: HookFetcher<Wishlist | null, RemoveItemBody> = (
options,
{ itemId },
fetch
) => {
return fetch({
...defaultOpts,
...options,
body: { itemId },
})
}
export function extendHook(customFetcher: typeof fetcher) {
const useRemoveItem = (opts?: UseWishlistOptions) => {
export const handler: MutationHook<
Wishlist | null,
{ wishlist?: UseWishlistInput },
RemoveItemInput,
RemoveItemBody
> = {
fetchOptions: {
url: '/api/bigcommerce/wishlist',
method: 'DELETE',
},
useHook: ({ fetch }) => ({ wishlist } = {}) => {
const { data: customer } = useCustomer()
const { revalidate } = useWishlist(opts)
const fn = useWishlistRemoveItem<Wishlist | null, RemoveItemBody>(
defaultOpts,
customFetcher
)
const { revalidate } = useWishlist(wishlist)
return useCallback(
async function removeItem(input: RemoveItemInput) {
async function removeItem(input) {
if (!customer) {
// A signed customer is required in order to have a wishlist
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()
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,76 +1,60 @@
import { HookFetcher } from '@commerce/utils/types'
import { SwrOptions } from '@commerce/utils/use-data'
import useCommerceWishlist from '@commerce/wishlist/use-wishlist'
import { useMemo } from 'react'
import { SWRHook } from '@commerce/utils/types'
import useWishlist, { UseWishlist } from '@commerce/wishlist/use-wishlist'
import type { Wishlist } from '../api/wishlist'
import useCustomer from '../use-customer'
import useCustomer from '../customer/use-customer'
const defaultOpts = {
url: '/api/bigcommerce/wishlist',
method: 'GET',
}
export type UseWishlistInput = { includeProducts?: boolean }
export type { Wishlist }
export default useWishlist as UseWishlist<typeof handler>
export interface UseWishlistOptions {
includeProducts?: boolean
}
export const handler: SWRHook<
Wishlist | null,
UseWishlistInput,
{ customerId?: number } & UseWishlistInput,
{ isEmpty?: boolean }
> = {
fetchOptions: {
url: '/api/bigcommerce/wishlist',
method: 'GET',
},
async fetcher({ input: { customerId, includeProducts }, options, fetch }) {
if (!customerId) return null
export interface UseWishlistInput extends UseWishlistOptions {
customerId?: number
}
// Use a dummy base as we only care about the relative path
const url = new URL(options.url!, 'http://a')
export const fetcher: HookFetcher<Wishlist | null, UseWishlistInput> = (
options,
{ customerId, includeProducts },
fetch
) => {
if (!customerId) return null
if (includeProducts) url.searchParams.set('products', '1')
// Use a dummy base as we only care about the relative path
const url = new URL(options?.url ?? defaultOpts.url, 'http://a')
if (includeProducts) url.searchParams.set('products', '1')
return fetch({
url: url.pathname + url.search,
method: options?.method ?? defaultOpts.method,
})
}
export function extendHook(
customFetcher: typeof fetcher,
swrOptions?: SwrOptions<Wishlist | null, UseWishlistInput>
) {
const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => {
return fetch({
url: url.pathname + url.search,
method: options.method,
})
},
useHook: ({ useData }) => (input) => {
const { data: customer } = useCustomer()
const response = useCommerceWishlist(
defaultOpts,
[
const response = useData({
input: [
['customerId', customer?.entityId],
['includeProducts', includeProducts],
['includeProducts', input?.includeProducts],
],
customFetcher,
{
swrOptions: {
revalidateOnFocus: false,
...swrOptions,
}
)
// Uses a getter to only calculate the prop when required
// response.data is also a getter and it's better to not trigger it early
Object.defineProperty(response, 'isEmpty', {
get() {
return (response.data?.items?.length || 0) <= 0
...input?.swrOptions,
},
set: (x) => x,
})
return response
}
useWishlist.extend = extendHook
return useWishlist
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.items?.length || 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}
export default extendHook(fetcher)

View File

@@ -32,5 +32,3 @@ export interface CommerceAPIFetchOptions<Variables> {
variables?: Variables
preview?: boolean
}
// TODO: define interfaces for all the available operations and API endpoints

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,5 +1,23 @@
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, CartItemBody, AddCartItemBody } from '../types'
import type { Provider } from '..'
const useAddItem = useAction
export type UseAddItem<
H extends MutationHook<any, any, any> = MutationHook<Cart, {}, CartItemBody>
> = ReturnType<H['useHook']>
export const fetcher: HookFetcherFn<
Cart,
AddCartItemBody<CartItemBody>
> = mutationFetcher
const fn = (provider: Provider) => provider.cart?.useAddItem!
const useAddItem: UseAddItem = (...args) => {
const hook = useHook(fn)
return useMutationHook({ fetcher, ...hook })(...args)
}
export default useAddItem

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,31 +1,41 @@
import type { responseInterface } from 'swr'
import Cookies from 'js-cookie'
import type { HookInput, HookFetcher, HookFetcherOptions } from '../utils/types'
import useData, { SwrOptions } from '../utils/use-data'
import { useCommerce } from '..'
import { useHook, useSWRHook } from '../utils/use-hook'
import type { HookFetcherFn, SWRHook } from '../utils/types'
import type { Cart } from '../types'
import { Provider, useCommerce } from '..'
export type CartResponse<Result> = responseInterface<Result, Error> & {
isEmpty: boolean
export type FetchCartInput = {
cartId?: Cart['id']
}
export type CartInput = {
cartId: string | undefined
export type UseCart<
H extends SWRHook<any, any, any> = SWRHook<
Cart | null,
{},
FetchCartInput,
{ isEmpty?: boolean }
>
> = ReturnType<H['useHook']>
export const fetcher: HookFetcherFn<Cart | null, FetchCartInput> = async ({
options,
input: { cartId },
fetch,
}) => {
return cartId ? await fetch({ ...options }) : null
}
export default function useCart<Result>(
options: HookFetcherOptions,
input: HookInput,
fetcherFn: HookFetcher<Result, CartInput>,
swrOptions?: SwrOptions<Result, CartInput>
) {
const fn = (provider: Provider) => provider.cart?.useCart!
const useCart: UseCart = (input) => {
const hook = useHook(fn)
const { cartCookie } = useCommerce()
const fetcher: typeof fetcherFn = (options, input, fetch) => {
input.cartId = Cookies.get(cartCookie)
return fetcherFn(options, input, fetch)
const fetcherFn = hook.fetcher ?? fetcher
const wrapper: typeof fetcher = (context) => {
context.input.cartId = Cookies.get(cartCookie)
return fetcherFn(context)
}
const response = useData(options, input, fetcher, swrOptions)
return Object.assign(response, { isEmpty: true }) as CartResponse<Result>
return useSWRHook({ ...hook, fetcher: wrapper })(input)
}
export default useCart

View File

@@ -1,5 +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 '..'
const useRemoveItem = useAction
/**
* Input expected by the action returned by the `useRemoveItem` hook
*/
export type RemoveItemInput = {
id: string
}
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

View File

@@ -1,5 +1,38 @@
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, CartItemBody, LineItem, UpdateCartItemBody } from '../types'
import type { Provider } from '..'
const useUpdateItem = useAction
/**
* Input expected by the action returned by the `useUpdateItem` hook
*/
export type UpdateItemInput<T extends CartItemBody> = T & {
id: string
}
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

View File

@@ -0,0 +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 { Provider } from '..'
export type UseCustomer<
H extends SWRHook<any, any, any> = SWRHook<Customer | null>
> = ReturnType<H['useHook']>
export const fetcher: HookFetcherFn<Customer | null, any> = SWRFetcher
const fn = (provider: Provider) => provider.customer?.useCustomer!
const useCustomer: UseCustomer = (input) => {
const hook = useHook(fn)
return useSWRHook({ fetcher, ...hook })(input)
}
export default useCustomer

View File

@@ -6,37 +6,73 @@ import {
useMemo,
useRef,
} from 'react'
import * as React from 'react'
import { Fetcher } from './utils/types'
import { Fetcher, SWRHook, MutationHook } from './utils/types'
import type { FetchCartInput } from './cart/use-cart'
import type { Cart, Wishlist, Customer, SearchProductsData } from './types'
const Commerce = createContext<CommerceContextValue | {}>({})
const Commerce = createContext<CommerceContextValue<any> | {}>({})
export type CommerceProps = {
export type Provider = CommerceConfig & {
fetcher: Fetcher
cart?: {
useCart?: SWRHook<Cart | null, any, FetchCartInput>
useAddItem?: MutationHook<any, any, any>
useUpdateItem?: MutationHook<any, any, any>
useRemoveItem?: MutationHook<any, any, any>
}
wishlist?: {
useWishlist?: SWRHook<Wishlist | null, any, any>
useAddItem?: MutationHook<any, any, any>
useRemoveItem?: MutationHook<any, any, any>
}
customer?: {
useCustomer?: SWRHook<Customer | null, any, any>
}
products?: {
useSearch?: SWRHook<SearchProductsData, any, any>
}
auth?: {
useSignup?: MutationHook<any, any, any>
useLogin?: MutationHook<any, any, any>
useLogout?: MutationHook<any, any, any>
}
}
export type CommerceProps<P extends Provider> = {
children?: ReactNode
provider: P
config: CommerceConfig
}
export type CommerceConfig = { fetcher: Fetcher<any> } & Omit<
CommerceContextValue,
'fetcherRef'
export type CommerceConfig = Omit<
CommerceContextValue<any>,
'providerRef' | 'fetcherRef'
>
export type CommerceContextValue = {
fetcherRef: MutableRefObject<Fetcher<any>>
export type CommerceContextValue<P extends Provider> = {
providerRef: MutableRefObject<P>
fetcherRef: MutableRefObject<Fetcher>
locale: string
cartCookie: string
}
export function CommerceProvider({ children, config }: CommerceProps) {
export function CommerceProvider<P extends Provider>({
provider,
children,
config,
}: CommerceProps<P>) {
if (!config) {
throw new Error('CommerceProvider requires a valid config object')
}
const fetcherRef = useRef(config.fetcher)
const providerRef = useRef(provider)
// TODO: Remove the fetcherRef
const fetcherRef = useRef(provider.fetcher)
// Because the config is an object, if the parent re-renders this provider
// will re-render every consumer unless we memoize the config
const cfg = useMemo(
() => ({
providerRef,
fetcherRef,
locale: config.locale,
cartCookie: config.cartCookie,
@@ -47,6 +83,6 @@ export function CommerceProvider({ children, config }: CommerceProps) {
return <Commerce.Provider value={cfg}>{children}</Commerce.Provider>
}
export function useCommerce<T extends CommerceContextValue>() {
return useContext(Commerce) as T
export function useCommerce<P extends Provider>() {
return useContext(Commerce) as CommerceContextValue<P>
}

View File

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

View File

@@ -0,0 +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 { Provider } from '..'
export type UseSearch<
H extends SWRHook<any, any, any> = SWRHook<SearchProductsData>
> = ReturnType<H['useHook']>
export const fetcher: HookFetcherFn<SearchProductsData, any> = SWRFetcher
const fn = (provider: Provider) => provider.products?.useSearch!
const useSearch: UseSearch = (input) => {
const hook = useHook(fn)
return useSWRHook({ fetcher, ...hook })(input)
}
export default useSearch

View File

@@ -1,5 +0,0 @@
import useData from '../utils/use-data'
const useSearch = useData
export default useSearch

203
framework/commerce/types.ts Normal file
View File

@@ -0,0 +1,203 @@
import type { Wishlist as BCWishlist } from '../bigcommerce/api/wishlist'
import type { Customer as BCCustomer } from '../bigcommerce/api/customers'
import type { SearchProductsData as BCSearchProductsData } from '../bigcommerce/api/catalog/products'
export type Discount = {
// The value of the discount, can be an amount or percentage
value: number
}
export type LineItem = {
id: string
variantId: string
productId: string
name: string
quantity: number
discounts: Discount[]
// A human-friendly unique string automatically generated from the products name
path: string
variant: ProductVariant
}
export type Measurement = {
value: number
unit: 'KILOGRAMS' | 'GRAMS' | 'POUNDS' | 'OUNCES'
}
export type Image = {
url: string
altText?: string
width?: number
height?: number
}
export type ProductVariant = {
id: string
// The SKU (stock keeping unit) associated with the product variant.
sku: string
// The product variants title, or the product's name.
name: string
// Whether a customer needs to provide a shipping address when placing
// an order for the product variant.
requiresShipping: boolean
// The product variants price after all discounts are applied.
price: number
// Product variants price, as quoted by the manufacturer/distributor.
listPrice: number
// Image associated with the product variant. Falls back to the product image
// if no image is available.
image?: Image
// Indicates whether this product variant is in stock.
isInStock?: boolean
// Indicates if the product variant is available for sale.
availableForSale?: boolean
// The variant's weight. If a weight was not explicitly specified on the
// variant this will be the product's weight.
weight?: Measurement
// The variant's height. If a height was not explicitly specified on the
// variant, this will be the product's height.
height?: Measurement
// The variant's width. If a width was not explicitly specified on the
// variant, this will be the product's width.
width?: Measurement
// The variant's depth. If a depth was not explicitly specified on the
// variant, this will be the product's depth.
depth?: Measurement
}
// Shopping cart, a.k.a Checkout
export type Cart = {
id: string
// ID of the customer to which the cart belongs.
customerId?: string
// The email assigned to this cart
email?: string
// The date and time when the cart was created.
createdAt: string
// The currency used for this cart
currency: { code: string }
// Specifies if taxes are included in the line items.
taxesIncluded: boolean
lineItems: LineItem[]
// The sum of all the prices of all the items in the cart.
// Duties, taxes, shipping and discounts excluded.
lineItemsSubtotalPrice: number
// Price of the cart before duties, shipping and taxes.
subtotalPrice: number
// The sum of all the prices of all the items in the cart.
// Duties, taxes and discounts included.
totalPrice: number
// Discounts that have been applied on the cart.
discounts?: Discount[]
}
// TODO: Properly define this type
export interface Wishlist extends BCWishlist {}
// TODO: Properly define this type
export interface Customer extends BCCustomer {}
// TODO: Properly define this type
export interface SearchProductsData extends BCSearchProductsData {}
/**
* Cart mutations
*/
// Base cart item body used for cart mutations
export type CartItemBody = {
variantId: string
productId?: string
quantity?: number
}
// Body used by the `getCart` operation handler
export type GetCartHandlerBody = {
cartId?: string
}
// Body used by the add item to cart operation
export type AddCartItemBody<T extends CartItemBody> = {
item: T
}
// Body expected by the add item to cart operation handler
export type AddCartItemHandlerBody<T extends CartItemBody> = Partial<
AddCartItemBody<T>
> & {
cartId?: string
}
// Body used by the update cart item operation
export type UpdateCartItemBody<T extends CartItemBody> = {
itemId: string
item: T
}
// Body expected by the update cart item operation handler
export type UpdateCartItemHandlerBody<T extends CartItemBody> = Partial<
UpdateCartItemBody<T>
> & {
cartId?: string
}
// Body used by the remove cart item operation
export type RemoveCartItemBody = {
itemId: string
}
// Body expected by the remove cart item operation handler
export type RemoveCartItemHandlerBody = Partial<RemoveCartItemBody> & {
cartId?: string
}
/**
* Temporal types
*/
interface Entity {
id: string | number
[prop: string]: any
}
export interface Product extends Entity {
name: string
description: string
slug?: string
path?: string
images: ProductImage[]
variants: ProductVariant2[]
price: ProductPrice
options: ProductOption[]
sku?: string
}
interface ProductOption extends Entity {
displayName: string
values: ProductOptionValues[]
}
interface ProductOptionValues {
label: string
hexColors?: string[]
}
interface ProductImage {
url: string
alt?: string
}
interface ProductVariant2 {
id: string | number
options: ProductOption[]
}
interface ProductPrice {
value: number
currencyCode: 'USD' | 'ARS' | string | undefined
retailPrice?: number
salePrice?: number
listPrice?: number
extendedSalePrice?: number
extendedListPrice?: number
}

View File

@@ -1,5 +0,0 @@
import useData from './utils/use-data'
const useCustomer = useData
export default useCustomer

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

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

View File

@@ -0,0 +1,37 @@
// Taken from https://fettblog.eu/typescript-assertion-signatures/
type InferValue<Prop extends PropertyKey, Desc> = Desc extends {
get(): any
value: any
}
? never
: Desc extends { value: infer T }
? Record<Prop, T>
: Desc extends { get(): infer T }
? Record<Prop, T>
: never
type DefineProperty<
Prop extends PropertyKey,
Desc extends PropertyDescriptor
> = Desc extends { writable: any; set(val: any): any }
? never
: Desc extends { writable: any; get(): any }
? never
: Desc extends { writable: false }
? Readonly<InferValue<Prop, Desc>>
: Desc extends { writable: true }
? InferValue<Prop, Desc>
: Readonly<InferValue<Prop, Desc>>
export default function defineProperty<
Obj extends object,
Key extends PropertyKey,
PDesc extends PropertyDescriptor
>(
obj: Obj,
prop: Key,
val: PDesc
): asserts obj is Obj & DefineProperty<Key, PDesc> {
Object.defineProperty(obj, prop, val)
}

View File

@@ -26,6 +26,14 @@ export class CommerceError extends Error {
}
}
// Used for errors that come from a bad implementation of the hooks
export class ValidationError extends CommerceError {
constructor(options: ErrorProps) {
super(options)
this.code = 'validation_error'
}
}
export class FetcherError extends CommerceError {
status: number

View File

@@ -1,24 +1,131 @@
// Core fetcher added by CommerceProvider
export type Fetcher<T> = (options: FetcherOptions) => T | Promise<T>
import type { ConfigInterface } from 'swr'
import type { CommerceError } from './errors'
import type { ResponseState } from './use-data'
export type FetcherOptions = {
/**
* 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
/**
* 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> &
{
[P in K]-?: NonNullable<T[P]>
}
/**
* Core fetcher added by CommerceProvider
*/
export type Fetcher<T = any, B = any> = (
options: FetcherOptions<B>
) => T | Promise<T>
export type FetcherOptions<Body = any> = {
url?: string
query?: string
method?: string
variables?: any
body?: any
body?: Body
}
export type HookFetcher<Result, Input = null> = (
export type HookFetcher<Data, Input = null, Result = any> = (
options: HookFetcherOptions | null,
input: Input,
fetch: <T = Result>(options: FetcherOptions) => Promise<T>
) => Result | Promise<Result>
fetch: <T = Result, Body = any>(options: FetcherOptions<Body>) => Promise<T>
) => Data | Promise<Data>
export type HookFetcherOptions = {
query?: string
url?: string
method?: string
export type HookFetcherFn<Data, Input = undefined, Result = any, Body = any> = (
context: HookFetcherContext<Input, Result, Body>
) => Data | Promise<Data>
export type HookFetcherContext<Input = undefined, Result = any, Body = any> = {
options: HookFetcherOptions
input: Input
fetch: <T = Result, B = Body>(options: FetcherOptions<B>) => Promise<T>
}
export type HookInput = [string, string | number | boolean | undefined][]
export type HookFetcherOptions = { method?: string } & (
| { query: string; url?: string }
| { query?: string; url: string }
)
export type HookInputValue = string | number | boolean | undefined
export type HookSWRInput = [string, HookInputValue][]
export type HookFetchInput = { [k: string]: HookInputValue }
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 SWRHook<
// Data obj returned by the hook and fetch operation
Data,
// Input expected by the hook
Input extends { [k: string]: unknown } = {},
// Input expected before doing a fetch operation
FetchInput extends HookFetchInput = {},
// Custom state added to the response object of SWR
State = {}
> = {
useHook(
context: SWRHookContext<Data, FetchInput>
): HookFunction<
Input & { swrOptions?: SwrOptions<Data, FetchInput> },
ResponseState<Data> & State
>
fetchOptions: HookFetcherOptions
fetcher?: HookFetcherFn<Data, FetchInput>
}
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,
// Input expected by the hook
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
FetchInput extends { [k: string]: unknown } = ActionInput
> = {
useHook(
context: MutationHookContext<Data, FetchInput>
): HookFunction<Input, HookFunction<ActionInput, Data | Promise<Data>>>
fetchOptions: HookFetcherOptions
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<
Data,
CommerceError,
HookFetcher<Data, Input, Result>
>

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,39 +1,47 @@
import useSWR, { ConfigInterface, responseInterface } from 'swr'
import type { HookInput, HookFetcher, HookFetcherOptions } from './types'
import useSWR, { responseInterface } from 'swr'
import type {
HookSWRInput,
HookFetchInput,
Fetcher,
SwrOptions,
HookFetcherOptions,
HookFetcherFn,
} from './types'
import defineProperty from './define-property'
import { CommerceError } from './errors'
import { useCommerce } from '..'
export type SwrOptions<Result, Input = null> = ConfigInterface<
Result,
CommerceError,
HookFetcher<Result, Input>
>
export type ResponseState<Result> = responseInterface<Result, CommerceError> & {
isLoading: boolean
}
export type UseData = <Result = any, Input = null>(
options: HookFetcherOptions | (() => HookFetcherOptions | null),
input: HookInput,
fetcherFn: HookFetcher<Result, Input>,
swrOptions?: SwrOptions<Result, Input>
) => responseInterface<Result, CommerceError>
export type UseData = <Data = any, FetchInput extends HookFetchInput = {}>(
options: {
fetchOptions: HookFetcherOptions
fetcher: HookFetcherFn<Data, FetchInput>
},
input: HookFetchInput | HookSWRInput,
fetcherFn: Fetcher,
swrOptions?: SwrOptions<Data, FetchInput>
) => ResponseState<Data>
const useData: UseData = (options, input, fetcherFn, swrOptions) => {
const { fetcherRef } = useCommerce()
const hookInput = Array.isArray(input) ? input : Object.entries(input)
const fetcher = async (
url?: string,
url: string,
query?: string,
method?: string,
...args: any[]
) => {
try {
return await fetcherFn(
{ url, query, method },
return await options.fetcher({
options: { url, query, method },
// Transform the input array into an object
args.reduce((obj, val, i) => {
obj[input[i][0]!] = val
input: args.reduce((obj, val, i) => {
obj[hookInput[i][0]!] = val
return obj
}, {}),
fetcherRef.current
)
fetch: fetcherFn,
})
} catch (error) {
// SWR will not log errors, but any error that's not an instance
// of CommerceError is not welcomed by this hook
@@ -45,15 +53,24 @@ const useData: UseData = (options, input, fetcherFn, swrOptions) => {
}
const response = useSWR(
() => {
const opts = typeof options === 'function' ? options() : options
const opts = options.fetchOptions
return opts
? [opts.url, opts.query, opts.method, ...input.map((e) => e[1])]
? [opts.url, opts.query, opts.method, ...hookInput.map((e) => e[1])]
: null
},
fetcher,
swrOptions
)
if (!('isLoading' in response)) {
defineProperty(response, 'isLoading', {
get() {
return response.data === undefined
},
enumerable: true,
})
}
return response
}

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

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

View File

@@ -1,5 +1,19 @@
import useAction from '../utils/use-action'
import { useHook, useMutationHook } from '../utils/use-hook'
import { mutationFetcher } from '../utils/default-fetcher'
import type { MutationHook } from '../utils/types'
import type { Provider } from '..'
const useAddItem = useAction
export type UseAddItem<
H extends MutationHook<any, any, any> = MutationHook<any, {}, {}>
> = ReturnType<H['useHook']>
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

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

View File

@@ -1,17 +1,25 @@
import type { responseInterface } from 'swr'
import type { HookInput, HookFetcher, HookFetcherOptions } from '../utils/types'
import useData, { SwrOptions } from '../utils/use-data'
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 { Provider } from '..'
export type WishlistResponse<Result> = responseInterface<Result, Error> & {
isEmpty: boolean
export type UseWishlist<
H extends SWRHook<any, any, any> = SWRHook<
Wishlist | null,
{ includeProducts?: boolean },
{ customerId?: number; includeProducts: boolean },
{ isEmpty?: boolean }
>
> = ReturnType<H['useHook']>
export const fetcher: HookFetcherFn<Wishlist | null, any> = SWRFetcher
const fn = (provider: Provider) => provider.wishlist?.useWishlist!
const useWishlist: UseWishlist = (input) => {
const hook = useHook(fn)
return useSWRHook({ fetcher, ...hook })(input)
}
export default function useWishlist<Result, Input = null>(
options: HookFetcherOptions,
input: HookInput,
fetcherFn: HookFetcher<Result, Input>,
swrOptions?: SwrOptions<Result, Input>
) {
const response = useData(options, input, fetcherFn, swrOptions)
return Object.assign(response, { isEmpty: true }) as WishlistResponse<Result>
}
export default useWishlist

View File

@@ -0,0 +1,41 @@
/**
* This file is expected to be used in next.config.js only
*/
const merge = require('deepmerge')
const PROVIDERS = ['bigcommerce', 'shopify']
function getProviderName() {
// TODO: OSOT.
return process.env.BIGCOMMERCE_STOREFRONT_API_URL ? 'bigcommerce' : null
}
module.exports = (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(`../${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
})
return config
}

View File

@@ -0,0 +1,2 @@
SHOPIFY_STORE_DOMAIN=
SHOPIFY_STOREFRONT_ACCESS_TOKEN=

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

@@ -0,0 +1,260 @@
## Table of Contents
- [Getting Started](#getting-started)
- [Modifications](#modifications)
- [Adding item to Cart](#adding-item-to-cart)
- [Proceed to Checkout](#proceed-to-checkout)
- [General Usage](#general-usage)
- [CommerceProvider](#commerceprovider)
- [useCommerce](#usecommerce)
- [Hooks](#hooks)
- [usePrice](#useprice)
- [useAddItem](#useadditem)
- [useRemoveItem](#useremoveitem)
- [useUpdateItem](#useupdateitem)
- [APIs](#apis)
- [getProduct](#getproduct)
- [getAllProducts](#getallproducts)
- [getAllCollections](#getallcollections)
- [getAllPages](#getallpages)
# Shopify Storefront Data Hooks
Collection of hooks and data fetching functions to integrate Shopify in a React application. Designed to work with [Next.js Commerce](https://demo.vercel.store/).
## Getting Started
1. Install dependencies:
```
yarn install shopify-buy
yarn install -D @types/shopify-buy
```
3. Environment variables need to be set:
```
SHOPIFY_STORE_DOMAIN=
SHOPIFY_STOREFRONT_ACCESS_TOKEN=
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
```
4. Point the framework to `shopify` by updating `tsconfig.json`:
```
"@framework/*": ["framework/shopify/*"],
"@framework": ["framework/shopify"]
```
### 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>
)
}
```
## General Usage
### CommerceProvider
Provider component that creates the commerce context for children.
```js
import { CommerceProvider } from '@framework'
const App = ({ children }) => {
return <CommerceProvider locale={locale}>{children}</CommerceProvider>
}
export default App
```
### useCommerce
Returns the configs that are defined in the nearest `CommerceProvider`. Also provides access to Shopify's `checkout` and `shop`.
```js
import { useCommerce } from 'nextjs-commerce-shopify'
const { checkout, shop } = useCommerce()
```
- `checkout`: The information required to checkout items and pay ([Documentation](https://shopify.dev/docs/storefront-api/reference/checkouts/checkout)).
- `shop`: Represents a collection of the general settings and information about the shop ([Documentation](https://shopify.dev/docs/storefront-api/reference/online-store/shop/index)).
## Hooks
### usePrice
Display the product variant price according to currency and locale.
```js
import usePrice from '@framework/product/use-price'
const { price } = usePrice({
amount,
})
```
Takes in either `amount` or `variant`:
- `amount`: A price value for a particular item if the amount is known.
- `variant`: A shopify product variant. Price will be extracted from the variant.
### useAddItem
```js
import { useAddItem } from '@framework/cart'
const AddToCartButton = ({ variantId, quantity }) => {
const addItem = useAddItem()
const addToCart = async () => {
await addItem({
variantId,
})
}
return <button onClick={addToCart}>Add To Cart</button>
}
```
### useRemoveItem
```js
import { useRemoveItem } from '@framework/cart'
const RemoveButton = ({ item }) => {
const removeItem = useRemoveItem()
const handleRemove = async () => {
await removeItem({ id: item.id })
}
return <button onClick={handleRemove}>Remove</button>
}
```
### useUpdateItem
```js
import { useUpdateItem } from '@framework/cart'
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}
/>
)
}
```
## 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 () {}

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