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

This commit is contained in:
cond0r 2021-02-16 09:24:56 +02:00
commit 44081dddb6
29 changed files with 348 additions and 314 deletions

View File

@ -75,6 +75,8 @@ const CartItem = ({
setRemoving(false)
}
}
// TODO: Add a type for this
const options = (item as any).options
useEffect(() => {
// Reset the quantity state if the item quantity changes
@ -95,8 +97,8 @@ const CartItem = ({
className={s.productImage}
width={150}
height={150}
src={item.variant.image.url}
alt={item.variant.image.altText}
src={item.variant.image!.url}
alt={item.variant.image!.altText}
unoptimized
/>
</div>
@ -109,15 +111,15 @@ const CartItem = ({
{item.name}
</span>
</Link>
{item.options && item.options.length > 0 ? (
{options && options.length > 0 ? (
<div className="">
{item.options.map((option: ItemOption, i: number) => (
{options.map((option: ItemOption, i: number) => (
<span
key={`${item.id}-${option.name}`}
className="text-sm font-semibold text-accents-7"
>
{option.value}
{i === item.options.length - 1 ? '' : ', '}
{i === options.length - 1 ? '' : ', '}
</span>
))}
</div>

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import { useUI } from '@components/ui'
import { Swatch, ProductSlider } from '@components/product'
import { Button, Container, Text } from '@components/ui'
import type { Product } from '@commerce/types'
import usePrice from '@framework/product/use-price'
import { useAddItem } from '@framework/cart'
@ -41,8 +42,8 @@ const ProductView: FC<Props> = ({ product }) => {
setLoading(true)
try {
await addItem({
productId: product.id,
variantId: variant ? variant.id : product.variants[0].id,
productId: String(product.id),
variantId: String(variant ? variant.id : product.variants[0].id),
})
openSidebar()
setLoading(false)

View File

@ -1,3 +1,5 @@
import type { Product } from '@commerce/types'
export type SelectedOptions = {
size: string | null
color: string | null

View File

@ -3,6 +3,7 @@ import cn from 'classnames'
import { Heart } from '@components/icons'
import { useUI } from '@components/ui'
import type { Product, ProductVariant } from '@commerce/types'
import useCustomer from '@framework/customer/use-customer'
import useAddItem from '@framework/wishlist/use-add-item'
import useRemoveItem from '@framework/wishlist/use-remove-item'

View File

@ -7,6 +7,7 @@ import { Trash } from '@components/icons'
import { Button, Text } from '@components/ui'
import { useUI } from '@components/ui/context'
import type { Product } from '@commerce/types'
import usePrice from '@framework/product/use-price'
import useAddItem from '@framework/cart/use-add-item'
import useRemoveItem from '@framework/wishlist/use-remove-item'
@ -42,8 +43,8 @@ const WishlistCard: FC<Props> = ({ product }) => {
setLoading(true)
try {
await addItem({
productId: product.id,
variantId: product.variants[0].id,
productId: String(product.id),
variantId: String(product.variants[0].id),
})
openSidebar()
setLoading(false)

View File

@ -1,4 +1,4 @@
import { Product } from 'framework/types'
import { Product } from '@commerce/types'
import getAllProducts, { ProductEdge } from '../../../product/get-all-products'
import type { ProductsHandlers } from '../products'
@ -60,7 +60,7 @@ const getProducts: ProductsHandlers['getProducts'] = async ({
const productsById = graphqlData.products.reduce<{
[k: number]: Product
}>((prods, p) => {
prods[p.id] = p
prods[Number(p.id)] = p
return prods
}, {})

View File

@ -1,3 +1,4 @@
import type { Product } from '@commerce/types'
import isAllowedMethod from '../utils/is-allowed-method'
import createApiHandler, {
BigcommerceApiHandler,
@ -5,7 +6,6 @@ import createApiHandler, {
} from '../utils/create-api-handler'
import { BigcommerceApiError } from '../utils/errors'
import getProducts from './handlers/get-products'
import { Product } from 'framework/types'
export type SearchProductsData = {
products: Product[]

View File

@ -11,6 +11,7 @@ import type {
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 }
@ -24,7 +25,7 @@ export type AddItemBody = { item: ItemBody }
export type RemoveItemBody = { itemId: Product['id'] }
export type WishlistBody = {
customer_id: Customer['id']
customer_id: Customer['entityId']
is_public: number
name: string
items: any[]

View File

@ -1,4 +1,42 @@
import useCart, { UseCart } from '@commerce/cart/use-cart'
import { useMemo } from 'react'
import { HookHandler } from '@commerce/utils/types'
import useCart, { UseCart, FetchCartInput } from '@commerce/cart/use-cart'
import { normalizeCart } from '../lib/normalize'
import type { Cart } from '../types'
import type { BigcommerceProvider } from '..'
export default useCart as UseCart<BigcommerceProvider>
export const handler: HookHandler<
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({ input, useData }) {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input.swrOptions },
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}

View File

@ -68,7 +68,7 @@ async function getCustomerWishlist({
const productsById = graphqlData.products.reduce<{
[k: number]: ProductEdge
}>((prods, p) => {
prods[p.node.entityId] = p
prods[Number(p.node.entityId)] = p as any
return prods
}, {})
// Populate the wishlist items with the graphql products

View File

@ -1,4 +1,25 @@
import { HookHandler } from '@commerce/utils/types'
import useCustomer, { UseCustomer } from '@commerce/customer/use-customer'
import type { Customer, CustomerData } from '../api/customers'
import type { BigcommerceProvider } from '..'
export default useCustomer as UseCustomer<BigcommerceProvider>
export const handler: HookHandler<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({ input, useData }) {
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,3 +1,4 @@
import type { Product } from '@commerce/types'
import type { Cart, BigcommerceCart, LineItem } from '../types'
import update from './immutability'

View File

@ -2,6 +2,7 @@ import type {
GetAllProductsQuery,
GetAllProductsQueryVariables,
} from '../schema'
import type { Product } from '@commerce/types'
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import filterEdges from '../api/utils/filter-edges'
import setProductLocaleMeta from '../api/utils/set-product-locale-meta'
@ -94,6 +95,7 @@ async function getAllProducts({
variables?: ProductVariables
config?: BigcommerceConfig
preview?: boolean
// TODO: fix the product type here
} = {}): Promise<{ products: Product[] | any[] }> {
config = getConfig(config)

View File

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

View File

@ -1,4 +1,54 @@
import { HookHandler } from '@commerce/utils/types'
import useSearch, { UseSearch } from '@commerce/products/use-search'
import type { SearchProductsData } from '../api/catalog/products'
import type { BigcommerceProvider } from '..'
export default useSearch as UseSearch<BigcommerceProvider>
export type SearchProductsInput = {
search?: string
categoryId?: number
brandId?: number
sort?: string
}
export const handler: HookHandler<
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({ input, useData }) {
return useData({
input: [
['search', input.search],
['categoryId', input.categoryId],
['brandId', input.brandId],
['sort', input.sort],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
}

View File

@ -0,0 +1,17 @@
import { handler as useCart } from './cart/use-cart'
import { handler as useWishlist } from './wishlist/use-wishlist'
import { handler as useCustomer } from './customer/use-customer'
import { handler as useSearch } from './product/use-search'
import fetcher from './fetcher'
export const bigcommerceProvider = {
locale: 'en-us',
cartCookie: 'bc_cartId',
fetcher,
cart: { useCart },
wishlist: { useWishlist },
customer: { useCustomer },
products: { useSearch },
}
export type BigcommerceProvider = typeof bigcommerceProvider

View File

@ -1,212 +0,0 @@
import { useMemo } from 'react'
import { FetcherError } from '@commerce/utils/errors'
import type { Fetcher, HookHandler } from '@commerce/utils/types'
import type { FetchCartInput } from '@commerce/cart/use-cart'
import { normalizeCart } from './lib/normalize'
import type { Wishlist } from './api/wishlist'
import type { Customer, CustomerData } from './api/customers'
import type { SearchProductsData } from './api/catalog/products'
import useCustomer from './customer/use-customer'
import type { Cart } from './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)
}
const useCart: HookHandler<
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({ input, useData }) {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input.swrOptions },
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}
const useWishlist: HookHandler<
Wishlist | null,
{ includeProducts?: boolean },
{ customerId?: number; includeProducts: boolean },
{ isEmpty?: boolean }
> = {
fetchOptions: {
url: '/api/bigcommerce/wishlist',
method: 'GET',
},
fetcher({ input: { customerId, includeProducts }, options, fetch }) {
if (!customerId) return null
// Use a dummy base as we only care about the relative path
const url = new URL(options.url!, 'http://a')
if (includeProducts) url.searchParams.set('products', '1')
return fetch({
url: url.pathname + url.search,
method: options.method,
})
},
useHook({ input, useData }) {
const { data: customer } = useCustomer()
const response = useData({
input: [
['customerId', customer?.id],
['includeProducts', input.includeProducts],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.items?.length || 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}
const useCustomerHandler: HookHandler<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({ input, useData }) {
return useData({
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
}
export type SearchProductsInput = {
search?: string
categoryId?: number
brandId?: number
sort?: string
}
const useSearch: HookHandler<
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({ input, useData }) {
return useData({
input: [
['search', input.search],
['categoryId', input.categoryId],
['brandId', input.brandId],
['sort', input.sort],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
}
export const bigcommerceProvider = {
locale: 'en-us',
cartCookie: 'bc_cartId',
fetcher,
cartNormalizer: normalizeCart,
cart: { useCart },
wishlist: { useWishlist },
customer: { useCustomer: useCustomerHandler },
products: { useSearch },
}
export type BigcommerceProvider = typeof bigcommerceProvider

View File

@ -1,19 +1,23 @@
import { useCallback } from 'react'
import { HookFetcher } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useWishlistAddItem from '@commerce/wishlist/use-add-item'
import useWishlistAddItem, {
AddItemInput,
} from '@commerce/wishlist/use-add-item'
import { UseWishlistInput } from '@commerce/wishlist/use-wishlist'
import type { ItemBody, AddItemBody } from '../api/wishlist'
import useCustomer from '../customer/use-customer'
import useWishlist, { UseWishlistOptions, Wishlist } from './use-wishlist'
import useWishlist from './use-wishlist'
import type { BigcommerceProvider } from '..'
const defaultOpts = {
url: '/api/bigcommerce/wishlist',
method: 'POST',
}
export type AddItemInput = ItemBody
// export type AddItemInput = ItemBody
export const fetcher: HookFetcher<Wishlist, AddItemBody> = (
export const fetcher: HookFetcher<any, AddItemBody> = (
options,
{ item },
fetch
@ -27,13 +31,13 @@ export const fetcher: HookFetcher<Wishlist, AddItemBody> = (
}
export function extendHook(customFetcher: typeof fetcher) {
const useAddItem = (opts?: UseWishlistOptions) => {
const useAddItem = (opts?: UseWishlistInput<BigcommerceProvider>) => {
const { data: customer } = useCustomer()
const { revalidate } = useWishlist(opts)
const fn = useWishlistAddItem(defaultOpts, customFetcher)
return useCallback(
async function addItem(input: AddItemInput) {
async function addItem(input: AddItemInput<any>) {
if (!customer) {
// A signed customer is required in order to have a wishlist
throw new CommerceError({

View File

@ -4,7 +4,7 @@ import { CommerceError } from '@commerce/utils/errors'
import useWishlistRemoveItem from '@commerce/wishlist/use-remove-item'
import type { RemoveItemBody } from '../api/wishlist'
import useCustomer from '../customer/use-customer'
import useWishlist, { UseWishlistOptions, Wishlist } from './use-wishlist'
import useWishlist from './use-wishlist'
const defaultOpts = {
url: '/api/bigcommerce/wishlist',
@ -15,7 +15,7 @@ export type RemoveItemInput = {
id: string | number
}
export const fetcher: HookFetcher<Wishlist | null, RemoveItemBody> = (
export const fetcher: HookFetcher<any | null, RemoveItemBody> = (
options,
{ itemId },
fetch
@ -28,10 +28,10 @@ export const fetcher: HookFetcher<Wishlist | null, RemoveItemBody> = (
}
export function extendHook(customFetcher: typeof fetcher) {
const useRemoveItem = (opts?: UseWishlistOptions) => {
const useRemoveItem = (opts?: any) => {
const { data: customer } = useCustomer()
const { revalidate } = useWishlist(opts)
const fn = useWishlistRemoveItem<Wishlist | null, RemoveItemBody>(
const fn = useWishlistRemoveItem<any | null, RemoveItemBody>(
defaultOpts,
customFetcher
)

View File

@ -1,4 +1,59 @@
import { useMemo } from 'react'
import { HookHandler } from '@commerce/utils/types'
import useWishlist, { UseWishlist } from '@commerce/wishlist/use-wishlist'
import type { Wishlist } from '../api/wishlist'
import useCustomer from '../customer/use-customer'
import type { BigcommerceProvider } from '..'
export default useWishlist as UseWishlist<BigcommerceProvider>
export const handler: HookHandler<
Wishlist | null,
{ includeProducts?: boolean },
{ customerId?: number; includeProducts: boolean },
{ isEmpty?: boolean }
> = {
fetchOptions: {
url: '/api/bigcommerce/wishlist',
method: 'GET',
},
fetcher({ input: { customerId, includeProducts }, options, fetch }) {
if (!customerId) return null
// Use a dummy base as we only care about the relative path
const url = new URL(options.url!, 'http://a')
if (includeProducts) url.searchParams.set('products', '1')
return fetch({
url: url.pathname + url.search,
method: options.method,
})
},
useHook({ input, useData }) {
const { data: customer } = useCustomer()
const response = useData({
input: [
['customerId', (customer as any)?.id],
['includeProducts', input.includeProducts],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.items?.length || 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}

View File

@ -1,75 +0,0 @@
interface Entity {
id: string | number
[prop: string]: any
}
interface Product extends Entity {
name: string
description: string
slug?: string
path?: string
images: ProductImage[]
variants: ProductVariant[]
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 ProductVariant {
id: string | number
options: ProductOption[]
}
interface ProductPrice {
value: number
currencyCode: 'USD' | 'ARS' | string | undefined
retailPrice?: number
salePrice?: number
listPrice?: number
extendedSalePrice?: number
extendedListPrice?: number
}
interface CartItem extends Entity {
quantity: number
productId: Product['id']
variantId: ProductVariant['id']
images: ProductImage[]
}
interface Wishlist extends Entity {
products: Pick<Product, 'id' | 'name' | 'prices'>[]
}
interface Order {}
interface Customer extends Entity {}
type UseCustomerResponse = {
customer: Customer
} | null
interface Category extends Entity {
name: string
}
interface Brand extends Entity {
name: string
}
type Features = 'wishlist' | 'customer'

View File

@ -148,3 +148,54 @@ export interface RemoveCartItemBody {
export interface RemoveCartItemHandlerBody extends 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

@ -76,6 +76,29 @@ export type HookHandler<
fetcher?: HookFetcherFn<Data, FetchInput>
}
export type MutationHandler<
// 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: {
input: Input & { swrOptions?: SwrOptions<Data, FetchInput> }
useCallback(
fn: (context?: {
input?: HookFetchInput | HookSwrInput
swrOptions?: SwrOptions<Data, FetchInput>
}) => Data
): ResponseState<Data>
}): ResponseState<Data> & State
fetchOptions: HookFetcherOptions
fetcher?: HookFetcherFn<Data, FetchInput>
}
export type SwrOptions<Data, Input = null, Result = any> = ConfigInterface<
Data,
CommerceError,

View File

@ -1,4 +1,11 @@
import useAction from '../utils/use-action'
import type { CartItemBody } from '../types'
// Input expected by the action returned by the `useAddItem` hook
// export interface AddItemInput {
// includeProducts?: boolean
// }
export type AddItemInput<T extends CartItemBody> = T
const useAddItem = useAction

View File

@ -61,7 +61,7 @@ export default function Slug({
return router.isFallback ? (
<h1>Loading...</h1> // TODO (BC) Add Skeleton Views
) : (
<ProductView product={product} />
<ProductView product={product as any} />
)
}

View File

@ -44,7 +44,7 @@ export default function Wishlist() {
) : (
data &&
data.items?.map((item) => (
<WishlistCard key={item.id} item={item} />
<WishlistCard key={item.id} product={item as any} />
))
)}
</div>