modified commerce wishlist endpoint to extract item variable from req/body, added useAdminApi flag to commerce fetcherOptions, updated wishlist api constants, modified shopify fetcher, added wishlist handlers to shopify provider Polished frontend UI so that you can now correctly add product to wishlist if variant is not available for sale

This commit is contained in:
Christos Emmanouilidis 2022-03-31 19:02:46 +03:00
parent 890836bc24
commit 594b3ef16a
18 changed files with 315 additions and 140 deletions

View File

@ -34,13 +34,13 @@ const wishlistEndpoint: GetAPISchema<
// Add an item to the wishlist
if (req.method === 'POST') {
const body = { ...req.body, customerToken }
const body = { ...req.body.variables, customerToken }
return await handlers['addItem']({ ...ctx, body })
}
// Remove an item from the wishlist
if (req.method === 'DELETE') {
const body = { ...req.body, customerToken }
const body = { ...req.body.variables, customerToken }
return await handlers['removeItem']({ ...ctx, body })
}
} catch (error) {

View File

@ -27,6 +27,7 @@ export type FetcherOptions<Body = any> = {
method?: string
variables?: any
body?: Body
useAdminApi?: boolean
}
export type HookFetcher<Data, Input = null, Result = any> = (

View File

@ -5,7 +5,8 @@ import {
} from '@vercel/commerce/api'
import {
API_URL,
STOREFRONT_API_URL,
ADMIN_ACCESS_TOKEN,
API_TOKEN,
SHOPIFY_CUSTOMER_TOKEN_COOKIE,
SHOPIFY_CHECKOUT_ID_COOKIE,
@ -15,7 +16,7 @@ import fetchGraphqlApi from './utils/fetch-graphql-api'
import * as operations from './operations'
if (!API_URL) {
if (!STOREFRONT_API_URL) {
throw new Error(
`The environment variable NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN is missing and it's required to access your store`
)
@ -31,7 +32,7 @@ export interface ShopifyConfig extends CommerceAPIConfig {}
const ONE_DAY = 60 * 60 * 24
const config: ShopifyConfig = {
commerceUrl: API_URL,
commerceUrl: STOREFRONT_API_URL,
apiToken: API_TOKEN,
customerCookie: SHOPIFY_CUSTOMER_TOKEN_COOKIE,
cartCookie: SHOPIFY_CHECKOUT_ID_COOKIE,

View File

@ -1,36 +1,66 @@
import type { GraphQLFetcher } from '@vercel/commerce/api'
import fetch from './fetch'
import { API_URL, API_TOKEN } from '../../const'
import {
STOREFRONT_API_URL,
ADMIN_API_URL,
API_TOKEN,
ADMIN_ACCESS_TOKEN,
} from '../../const'
import { getError } from '../../utils/handle-fetch-response'
const fetchGraphqlApi: GraphQLFetcher = async (
query: string,
{ variables } = {},
fetchOptions
fetchOptions,
useAdminApi = false
) => {
try {
const res = await fetch(API_URL, {
...fetchOptions,
method: 'POST',
headers: {
'X-Shopify-Storefront-Access-Token': API_TOKEN!,
...fetchOptions?.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
})
if (useAdminApi) {
console.log('graphQL fetch from admin api')
const { data, errors, status } = await res.json()
const res = await fetch(ADMIN_API_URL, {
...fetchOptions,
method: 'POST',
headers: {
'X-Shopify-Access-Token': ADMIN_ACCESS_TOKEN!,
...fetchOptions?.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
})
const { data, errors, status } = await res.json()
if (errors) {
throw getError(errors, status)
}
if (errors) {
throw getError(errors, status)
return { data, res }
} else {
console.log('graphQL fetch from storefront api')
const res = await fetch(STOREFRONT_API_URL, {
...fetchOptions,
method: 'POST',
headers: {
'X-Shopify-Storefront-Access-Token': API_TOKEN!,
...fetchOptions?.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
})
const { data, errors, status } = await res.json()
if (errors) {
throw getError(errors, status)
}
return { data, res }
}
return { data, res }
} catch (err) {
throw getError(
[

View File

@ -8,6 +8,9 @@ export const STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN
export const SHOPIFY_COOKIE_EXPIRE = 30
export const API_URL = `https://${STORE_DOMAIN}/api/2021-07/graphql.json`
export const STOREFRONT_API_URL = `https://${STORE_DOMAIN}/api/2022-01/graphql.json`
export const ADMIN_API_URL = `https://${STORE_DOMAIN}/admin/api/2022-01/graphql.json`
export const API_TOKEN = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN
export const ADMIN_ACCESS_TOKEN =
process.env.NEXT_PUBLIC_SHOPIFY_ADMIN_ACCESS_TOKEN

View File

@ -1,27 +1,58 @@
import { Fetcher } from '@vercel/commerce/utils/types'
import { API_TOKEN, API_URL } from './const'
import {
API_TOKEN,
STOREFRONT_API_URL,
ADMIN_ACCESS_TOKEN,
ADMIN_API_URL,
} from './const'
import { handleFetchResponse } from './utils'
const fetcher: Fetcher = async ({
url = API_URL,
url = STOREFRONT_API_URL,
method = 'POST',
variables,
query,
useAdminApi = false,
}) => {
const { locale, ...vars } = variables ?? {}
return handleFetchResponse(
await fetch(url, {
method,
body: JSON.stringify({ query, variables: vars }),
headers: {
'X-Shopify-Storefront-Access-Token': API_TOKEN!,
'Content-Type': 'application/json',
...(locale && {
'Accept-Language': locale,
}),
},
})
)
if (method === 'POST' || method === 'DELETE') {
if (useAdminApi) {
url = ADMIN_API_URL
console.log('admin api', url, query, method)
return handleFetchResponse(
await fetch(url, {
method,
body: JSON.stringify({ query, variables: vars }),
headers: {
'X-Shopify-Access-Token': ADMIN_ACCESS_TOKEN!,
'Content-Type': 'application/json',
...(locale && {
'Accept-Language': locale,
}),
},
})
)
} else {
console.log('storefront api:', url, query, method)
return handleFetchResponse(
await fetch(url, {
method,
body: JSON.stringify({ query, variables: vars }),
headers: {
'X-Shopify-Storefront-Access-Token': API_TOKEN!,
'Content-Type': 'application/json',
...(locale && {
'Accept-Language': locale,
}),
},
})
)
}
}
return handleFetchResponse(await fetch(url))
}
export default fetcher

View File

@ -12,6 +12,10 @@ 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 { 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 fetcher from './fetcher'
export const shopifyProvider = {
@ -22,6 +26,11 @@ export const shopifyProvider = {
customer: { useCustomer },
products: { useSearch },
auth: { useLogin, useLogout, useSignup },
wishlist: {
useWishlist,
useAddItem: useWishlistAddItem,
useRemoveItem: useWishlistRemoveItem,
},
}
export type ShopifyProvider = typeof shopifyProvider

View File

@ -19,12 +19,6 @@ export type WishlistTypes = {
customer: Customer
}
export type RemoveItemHook<T extends WishlistTypes = WishlistTypes> = {
body: { item: T['itemBody'] }
fetcherInput: { item: T['itemBody'] }
actionInput: T['itemBody']
}
export type WishlistSchema = Core.WishlistSchema<WishlistTypes>
export type GetCustomerWishlistOperation =
Core.GetCustomerWishlistOperation<WishlistTypes>

View File

@ -16,6 +16,33 @@ export const productConnectionFragment = /* GraphQL */ `
currencyCode
}
}
variants(first: 250) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
id
title
sku
availableForSale
requiresShipping
selectedOptions {
name
value
}
priceV2 {
amount
currencyCode
}
compareAtPriceV2 {
amount
currencyCode
}
}
}
}
images(first: 1) {
pageInfo {
hasNextPage

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,13 +1,46 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useCallback } from 'react'
import { CommerceError } from '@vercel/commerce/utils/errors'
import useAddItem, { UseAddItem } from '@vercel/commerce/wishlist/use-add-item'
import type { AddItemHook } from '../types/wishlist'
import useCustomer from '../customer/use-customer'
import useWishlist from './use-wishlist'
import type { MutationHook } from '@vercel/commerce/utils/types'
export function emptyHook() {
const useEmptyHook = async (options = {}) => {
return useCallback(async function () {
return Promise.resolve()
}, [])
}
export default useAddItem as UseAddItem<typeof handler>
return useEmptyHook
export const handler: MutationHook<AddItemHook> = {
fetchOptions: {
url: '/api/wishlist',
method: 'POST',
},
async fetcher({ input: item, options, fetch }) {
const data = await fetch({ ...options, variables: item })
return data
},
useHook:
({ fetch }) =>
() => {
const { data: customer } = useCustomer()
const { mutate } = useWishlist()
return useCallback(
async function addItem(item) {
if (!customer) {
// A signed customer is required in order to have a wishlist
throw new CommerceError({
message: 'Signed customer not found',
})
}
// TODO: add validations before doing the fetch
const data = await fetch({ input: { item } })
await mutate()
return data
},
[fetch, mutate, customer]
)
},
}
export default emptyHook

View File

@ -1,17 +1,48 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { CommerceError } from '@vercel/commerce/utils/errors'
import type { RemoveItemHook } from '../types/wishlist'
import useCustomer from '../customer/use-customer'
import useWishlist from './use-wishlist'
import type { MutationHook } from '@vercel/commerce/utils/types'
import useRemoveItem, {
UseRemoveItem,
} from '@vercel/commerce/wishlist/use-remove-item'
import { useCallback } from 'react'
type Options = {
includeProducts?: boolean
export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler: MutationHook<RemoveItemHook> = {
fetchOptions: {
url: '/api/wishlist',
method: 'DELETE',
},
async fetcher({ input: item, options, fetch }) {
const data = await fetch({ ...options, variables: item })
return data
},
useHook:
({ fetch }) =>
() => {
const { data: customer } = useCustomer()
const { mutate } = useWishlist()
return useCallback(
async function removeItem(item) {
if (!customer) {
// A signed customer is required in order to have a wishlist
throw new CommerceError({
message: 'Signed customer not found',
})
}
// TODO: add validations before doing the fetch
const data = await fetch({ input: { item } })
await mutate()
return data
},
[fetch, mutate, customer]
)
},
}
export function emptyHook(options?: Options) {
const useEmptyHook = async ({ id }: { id: string | number }) => {
return useCallback(async function () {
return Promise.resolve()
}, [])
}
return useEmptyHook
}
export default emptyHook

View File

@ -1,46 +1,46 @@
// TODO: replace this hook and other wishlist hooks with a handler, or remove them if
// Shopify doesn't have a wishlist
/* eslint-disable react-hooks/rules-of-hooks */
import useWishlist, {
UseWishlist,
} from '@vercel/commerce/wishlist/use-wishlist'
import type { GetWishlistHook } from '../types/wishlist'
import { SWRHook } from '@vercel/commerce/utils/types'
import { HookFetcher } from '@vercel/commerce/utils/types'
import { Product } from '../../schema'
import { useMemo } from 'react'
const defaultOpts = {}
export default useWishlist as UseWishlist<typeof handler>
export type Wishlist = {
items: [
{
product_id: number
variant_id: number
id: number
product: Product
}
]
export const handler: SWRHook<GetWishlistHook> = {
fetchOptions: {
url: '/api/wishlist',
method: 'GET',
},
async fetcher({ options, fetch }) {
const data = await fetch({ ...options })
return data
},
useHook:
({ useData }) =>
(input) => {
const response = useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.items?.length || 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}
export interface UseWishlistOptions {
includeProducts?: boolean
}
export interface UseWishlistInput extends UseWishlistOptions {
customerId?: number
}
export const fetcher: HookFetcher<Wishlist | null, UseWishlistInput> = () => {
return null
}
export function extendHook(
customFetcher: typeof fetcher,
// swrOptions?: SwrOptions<Wishlist | null, UseWishlistInput>
swrOptions?: any
) {
const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => {
return { data: null }
}
useWishlist.extend = extendHook
return useWishlist
}
export default extendHook(fetcher)

View File

@ -105,7 +105,7 @@ const ProductCard: FC<Props> = ({
<WishlistButton
className={s.wishlistButton}
productId={product.id}
variant={product.variants[0] as any}
variant={product.variants[0]}
/>
)}
<ProductTag

View File

@ -9,6 +9,7 @@ import {
selectDefaultOptionFromProduct,
SelectedOptions,
} from '../helpers'
import { WishlistButton } from '@components/wishlist'
interface ProductSidebarProps {
product: Product
@ -70,6 +71,13 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
: 'Add To Cart'}
</Button>
)}
{!variant?.availableForSale && (
<WishlistButton
className={s.button}
productId={product.id}
variant={variant!}
/>
)}
</div>
<div className="mt-6">
<Collapse title="Care">

View File

@ -2,10 +2,10 @@ import React, { FC, useState } from 'react'
import cn from 'clsx'
import { useUI } from '@components/ui'
import { Heart } from '@components/icons'
import useAddItem from '@framework/wishlist/use-add-item'
import { useAddItem } from '@framework/wishlist'
import useCustomer from '@framework/customer/use-customer'
import useWishlist from '@framework/wishlist/use-wishlist'
import useRemoveItem from '@framework/wishlist/use-remove-item'
import { useWishlist } from '@framework/wishlist'
import { useRemoveItem } from '@framework/wishlist'
import s from './WishlistButton.module.css'
import type { Product, ProductVariant } from '@commerce/types/product'
@ -30,9 +30,7 @@ const WishlistButton: FC<Props> = ({
// @ts-ignore Wishlist is not always enabled
const itemInWishlist = data?.items?.find(
// @ts-ignore Wishlist is not always enabled
(item) =>
item.product_id === Number(productId) &&
item.variant_id === Number(variant.id)
(item) => item.productId === productId && item.variantId === variant.id
)
const handleWishlistChange = async (e: any) => {
@ -50,7 +48,7 @@ const WishlistButton: FC<Props> = ({
try {
if (itemInWishlist) {
await removeItem({ id: itemInWishlist.id! })
await removeItem({ productId, variantId: variant?.id! })
} else {
await addItem({
productId,

View File

@ -15,17 +15,19 @@ import type { Wishlist } from '@commerce/types/wishlist'
const placeholderImg = '/product-img-placeholder.svg'
const WishlistCard: React.FC<{
item: Wishlist
}> = ({ item }) => {
const product: Product = item.product
interface Props {
item: Product
variant: string | number
}
const WishlistCard: FC<Props> = ({ item, variant }) => {
const { price } = usePrice({
amount: product.price?.value,
baseAmount: product.price?.retailPrice,
currencyCode: product.price?.currencyCode!,
amount: item.price?.value,
baseAmount: item.price?.retailPrice,
currencyCode: item.price?.currencyCode!,
})
// @ts-ignore Wishlist is not always enabled
const removeItem = useRemoveItem({ wishlist: { includeProducts: true } })
const removeItem = useRemoveItem({ item })
const [loading, setLoading] = useState(false)
const [removing, setRemoving] = useState(false)
@ -40,7 +42,7 @@ const WishlistCard: React.FC<{
try {
// If this action succeeds then there's no need to do `setRemoving(true)`
// because the component will be removed from the view
await removeItem({ id: item.id! })
await removeItem({ productId: item.id, variantId: variant })
} catch (error) {
setRemoving(false)
}
@ -49,8 +51,8 @@ const WishlistCard: React.FC<{
setLoading(true)
try {
await addItem({
productId: String(product.id),
variantId: String(product.variants[0].id),
productId: String(item.id),
variantId: String(item.variants[0].id),
})
openSidebar()
setLoading(false)
@ -65,20 +67,20 @@ const WishlistCard: React.FC<{
<Image
width={230}
height={230}
src={product.images[0]?.url || placeholderImg}
alt={product.images[0]?.alt || 'Product Image'}
src={item.images[0]?.url || placeholderImg}
alt={item.images[0]?.alt || 'Product Image'}
/>
</div>
<div className={s.description}>
<div className="flex-1 mb-6">
<h3 className="text-2xl mb-2 -mt-1">
<Link href={`/product${product.path}`}>
<a>{product.name}</a>
<Link href={`/product${item.path}`}>
<a>{item.name}</a>
</Link>
</h3>
<div className="mb-4">
<Text html={product.description} />
<Text html={item.description} />
</div>
</div>
<div>

View File

@ -37,7 +37,7 @@ export async function getStaticProps({
export default function Wishlist() {
const { data: customer } = useCustomer()
// @ts-ignore Shopify - Fix this types
const { data, isLoading, isEmpty } = useWishlist({ includeProducts: true })
const { data: wishlist, isLoading, isEmpty } = useWishlist()
return (
<Container className="pt-4">
@ -66,10 +66,14 @@ export default function Wishlist() {
</div>
) : (
<div className="grid grid-cols-1 gap-6 ">
{data &&
{wishlist &&
// @ts-ignore - Wishlist Item Type
data.items?.map((item) => (
<WishlistCard key={item.id} item={item!} />
wishlist.items?.map((item) => (
<WishlistCard
key={item.productId}
item={item.product!}
variant={item.variantId}
/>
))}
</div>
)}