diff --git a/packages/commerce/src/api/endpoints/wishlist.ts b/packages/commerce/src/api/endpoints/wishlist.ts index 233ac5294..0ce9de8ab 100644 --- a/packages/commerce/src/api/endpoints/wishlist.ts +++ b/packages/commerce/src/api/endpoints/wishlist.ts @@ -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) { diff --git a/packages/commerce/src/utils/types.ts b/packages/commerce/src/utils/types.ts index 317fea165..cb79efa37 100644 --- a/packages/commerce/src/utils/types.ts +++ b/packages/commerce/src/utils/types.ts @@ -27,6 +27,7 @@ export type FetcherOptions = { method?: string variables?: any body?: Body + useAdminApi?: boolean } export type HookFetcher = ( diff --git a/packages/shopify/src/api/index.ts b/packages/shopify/src/api/index.ts index 7ae6a4206..b277f9b25 100644 --- a/packages/shopify/src/api/index.ts +++ b/packages/shopify/src/api/index.ts @@ -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, diff --git a/packages/shopify/src/api/utils/fetch-graphql-api.ts b/packages/shopify/src/api/utils/fetch-graphql-api.ts index 1970db572..4cd3bb9f8 100644 --- a/packages/shopify/src/api/utils/fetch-graphql-api.ts +++ b/packages/shopify/src/api/utils/fetch-graphql-api.ts @@ -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( [ diff --git a/packages/shopify/src/const.ts b/packages/shopify/src/const.ts index a8ee70586..fc230a61a 100644 --- a/packages/shopify/src/const.ts +++ b/packages/shopify/src/const.ts @@ -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 diff --git a/packages/shopify/src/fetcher.ts b/packages/shopify/src/fetcher.ts index 64f492620..bc6fa5a6b 100644 --- a/packages/shopify/src/fetcher.ts +++ b/packages/shopify/src/fetcher.ts @@ -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 diff --git a/packages/shopify/src/provider.ts b/packages/shopify/src/provider.ts index 00db5c1d3..289b76f33 100644 --- a/packages/shopify/src/provider.ts +++ b/packages/shopify/src/provider.ts @@ -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 diff --git a/packages/shopify/src/types/wishlist.ts b/packages/shopify/src/types/wishlist.ts index 3778ff7e4..a2e77dc06 100644 --- a/packages/shopify/src/types/wishlist.ts +++ b/packages/shopify/src/types/wishlist.ts @@ -19,12 +19,6 @@ export type WishlistTypes = { customer: Customer } -export type RemoveItemHook = { - body: { item: T['itemBody'] } - fetcherInput: { item: T['itemBody'] } - actionInput: T['itemBody'] -} - export type WishlistSchema = Core.WishlistSchema export type GetCustomerWishlistOperation = Core.GetCustomerWishlistOperation diff --git a/packages/shopify/src/utils/queries/get-all-products-query.ts b/packages/shopify/src/utils/queries/get-all-products-query.ts index 179cf9812..f2035a094 100644 --- a/packages/shopify/src/utils/queries/get-all-products-query.ts +++ b/packages/shopify/src/utils/queries/get-all-products-query.ts @@ -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 diff --git a/packages/shopify/src/wishlist/index.ts b/packages/shopify/src/wishlist/index.ts new file mode 100644 index 000000000..241af3c7e --- /dev/null +++ b/packages/shopify/src/wishlist/index.ts @@ -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' diff --git a/packages/shopify/src/wishlist/use-add-item.tsx b/packages/shopify/src/wishlist/use-add-item.tsx index 75f067c3a..91385b029 100644 --- a/packages/shopify/src/wishlist/use-add-item.tsx +++ b/packages/shopify/src/wishlist/use-add-item.tsx @@ -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 - return useEmptyHook +export const handler: MutationHook = { + 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 diff --git a/packages/shopify/src/wishlist/use-remove-item.tsx b/packages/shopify/src/wishlist/use-remove-item.tsx index a2d3a8a05..9a6e36622 100644 --- a/packages/shopify/src/wishlist/use-remove-item.tsx +++ b/packages/shopify/src/wishlist/use-remove-item.tsx @@ -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 + +export const handler: MutationHook = { + 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 diff --git a/packages/shopify/src/wishlist/use-wishlist.tsx b/packages/shopify/src/wishlist/use-wishlist.tsx index c95195040..4089683bf 100644 --- a/packages/shopify/src/wishlist/use-wishlist.tsx +++ b/packages/shopify/src/wishlist/use-wishlist.tsx @@ -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 -export type Wishlist = { - items: [ - { - product_id: number - variant_id: number - id: number - product: Product - } - ] +export const handler: SWRHook = { + 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 = () => { - return null -} - -export function extendHook( - customFetcher: typeof fetcher, - // swrOptions?: SwrOptions - swrOptions?: any -) { - const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => { - return { data: null } - } - - useWishlist.extend = extendHook - - return useWishlist -} - -export default extendHook(fetcher) diff --git a/site/components/product/ProductCard/ProductCard.tsx b/site/components/product/ProductCard/ProductCard.tsx index c70461f6c..49c30928a 100644 --- a/site/components/product/ProductCard/ProductCard.tsx +++ b/site/components/product/ProductCard/ProductCard.tsx @@ -105,7 +105,7 @@ const ProductCard: FC = ({ )} = ({ product, className }) => { : 'Add To Cart'} )} + {!variant?.availableForSale && ( + + )}
diff --git a/site/components/wishlist/WishlistButton/WishlistButton.tsx b/site/components/wishlist/WishlistButton/WishlistButton.tsx index f4e0fb31f..386b23d97 100644 --- a/site/components/wishlist/WishlistButton/WishlistButton.tsx +++ b/site/components/wishlist/WishlistButton/WishlistButton.tsx @@ -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 = ({ // @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 = ({ try { if (itemInWishlist) { - await removeItem({ id: itemInWishlist.id! }) + await removeItem({ productId, variantId: variant?.id! }) } else { await addItem({ productId, diff --git a/site/components/wishlist/WishlistCard/WishlistCard.tsx b/site/components/wishlist/WishlistCard/WishlistCard.tsx index 6af6c914e..c8e058b99 100644 --- a/site/components/wishlist/WishlistCard/WishlistCard.tsx +++ b/site/components/wishlist/WishlistCard/WishlistCard.tsx @@ -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 = ({ 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<{ {product.images[0]?.alt
diff --git a/site/pages/wishlist.tsx b/site/pages/wishlist.tsx index 1b8edb31f..c8dba3330 100644 --- a/site/pages/wishlist.tsx +++ b/site/pages/wishlist.tsx @@ -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 ( @@ -66,10 +66,14 @@ export default function Wishlist() {
) : (
- {data && + {wishlist && // @ts-ignore - Wishlist Item Type - data.items?.map((item) => ( - + wishlist.items?.map((item) => ( + ))}
)}