diff --git a/components/wishlist/WishlistButton/WishlistButton.tsx b/components/wishlist/WishlistButton/WishlistButton.tsx index 153b64f59..6dc59b900 100644 --- a/components/wishlist/WishlistButton/WishlistButton.tsx +++ b/components/wishlist/WishlistButton/WishlistButton.tsx @@ -2,10 +2,10 @@ import React, { FC, useState } from 'react' import cn from 'classnames' import { useUI } from '@components/ui' import { Heart } from '@components/icons' -// import useAddItem from '@framework/wishlist/use-add-item' +import useAddItem from '@framework/wishlist/use-add-item' import useCustomer from '@framework/customer/use-customer' -// import useWishlist from '@framework/wishlist/use-wishlist' -// import useRemoveItem from '@framework/wishlist/use-remove-item' +import useWishlist from '@framework/wishlist/use-wishlist' +import useRemoveItem from '@framework/wishlist/use-remove-item' import type { Product, ProductVariant } from '@commerce/types' type Props = { @@ -19,11 +19,8 @@ const WishlistButton: FC = ({ className, ...props }) => { - // @ts-ignore const { data } = useWishlist() - // @ts-ignore const addItem = useAddItem() - // @ts-ignore const removeItem = useRemoveItem() const { data: customer } = useCustomer() const { openModal, setModalView } = useUI() diff --git a/components/wishlist/WishlistCard/WishlistCard.tsx b/components/wishlist/WishlistCard/WishlistCard.tsx index 1e80ea115..1568d9e7e 100644 --- a/components/wishlist/WishlistCard/WishlistCard.tsx +++ b/components/wishlist/WishlistCard/WishlistCard.tsx @@ -10,7 +10,7 @@ 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' +import useRemoveItem from '@framework/wishlist/use-remove-item' interface Props { product: Product diff --git a/framework/aquilacms/api/wishlist/handlers/add-item.ts b/framework/aquilacms/api/wishlist/handlers/add-item.ts new file mode 100644 index 000000000..00d7b06bd --- /dev/null +++ b/framework/aquilacms/api/wishlist/handlers/add-item.ts @@ -0,0 +1,56 @@ +import type { WishlistHandlers } from '..' +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 +const addItem: WishlistHandlers['addItem'] = async ({ + res, + body: { customerToken, item }, + config, +}) => { + if (!item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing item' }], + }) + } + + const customerId = + customerToken && (await getCustomerId({ customerToken, config })) + + if (!customerId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + const { wishlist } = await getCustomerWishlist({ + variables: { customerId }, + config, + }) + const options = { + method: 'POST', + body: JSON.stringify( + wishlist + ? { + items: [parseWishlistItem(item)], + } + : { + name: 'Wishlist', + customer_id: customerId, + items: [parseWishlistItem(item)], + is_public: false, + } + ), + } + + const { data } = wishlist + ? await config.storeApiFetch(`/v3/wishlists/${wishlist.id}/items`, options) + : await config.storeApiFetch('/v3/wishlists', options) + + res.status(200).json({ data }) +} + +export default addItem diff --git a/framework/aquilacms/api/wishlist/handlers/get-wishlist.ts b/framework/aquilacms/api/wishlist/handlers/get-wishlist.ts new file mode 100644 index 000000000..3737c033a --- /dev/null +++ b/framework/aquilacms/api/wishlist/handlers/get-wishlist.ts @@ -0,0 +1,37 @@ +import getCustomerId from '../../../customer/get-customer-id' +import getCustomerWishlist from '../../../customer/get-customer-wishlist' +import type { Wishlist, WishlistHandlers } from '..' + +// Return wishlist info +const getWishlist: WishlistHandlers['getWishlist'] = async ({ + res, + body: { customerToken, includeProducts }, + config, +}) => { + let result: { data?: Wishlist } = {} + + if (customerToken) { + const customerId = + customerToken && (await getCustomerId({ customerToken, config })) + + if (!customerId) { + // If the customerToken is invalid, then this request is too + return res.status(404).json({ + data: null, + errors: [{ message: 'Wishlist not found' }], + }) + } + + const { wishlist } = await getCustomerWishlist({ + variables: { customerId }, + includeProducts, + config, + }) + + result = { data: wishlist } + } + + res.status(200).json({ data: result.data ?? null }) +} + +export default getWishlist diff --git a/framework/aquilacms/api/wishlist/handlers/remove-item.ts b/framework/aquilacms/api/wishlist/handlers/remove-item.ts new file mode 100644 index 000000000..a9cfd9db5 --- /dev/null +++ b/framework/aquilacms/api/wishlist/handlers/remove-item.ts @@ -0,0 +1,39 @@ +import getCustomerId from '../../../customer/get-customer-id' +import getCustomerWishlist, { + Wishlist, +} from '../../../customer/get-customer-wishlist' +import type { WishlistHandlers } from '..' + +// Return current wishlist info +const removeItem: WishlistHandlers['removeItem'] = async ({ + res, + body: { customerToken, itemId }, + config, +}) => { + const customerId = + customerToken && (await getCustomerId({ customerToken, config })) + const { wishlist } = + (customerId && + (await getCustomerWishlist({ + variables: { customerId }, + config, + }))) || + {} + + if (!wishlist || !itemId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + const result = await config.storeApiFetch<{ data: Wishlist } | null>( + `/v3/wishlists/${wishlist.id}/items/${itemId}`, + { method: 'DELETE' } + ) + const data = result?.data ?? null + + res.status(200).json({ data }) +} + +export default removeItem diff --git a/framework/aquilacms/api/wishlist/index.ts b/framework/aquilacms/api/wishlist/index.ts new file mode 100644 index 000000000..2512aece7 --- /dev/null +++ b/framework/aquilacms/api/wishlist/index.ts @@ -0,0 +1,104 @@ +import isAllowedMethod from '../utils/is-allowed-method' +import createApiHandler, { + AquilacmsApiHandler, + AquilacmsHandler, +} from '../utils/create-api-handler' +import { AquilacmsApiError } from '../utils/errors' +import type { + Wishlist, + WishlistItem, +} 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: Product['id'] + variantId: ProductVariant['id'] +} + +export type AddItemBody = { item: ItemBody } + +export type RemoveItemBody = { itemId: Product['id'] } + +export type WishlistBody = { + customer_id: Customer['entityId'] + is_public: number + name: string + items: any[] +} + +export type AddWishlistBody = { wishlist: WishlistBody } + +export type WishlistHandlers = { + getWishlist: AquilacmsHandler< + Wishlist, + { customerToken?: string; includeProducts?: boolean } + > + addItem: AquilacmsHandler< + Wishlist, + { customerToken?: string } & Partial + > + removeItem: AquilacmsHandler< + Wishlist, + { customerToken?: string } & Partial + > +} + +const METHODS = ['GET', 'POST', 'DELETE'] + +// TODO: a complete implementation should have schema validation for `req.body` +const wishlistApi: AquilacmsApiHandler = async ( + req, + res, + config, + handlers +) => { + if (!isAllowedMethod(req, res, METHODS)) return + + const { cookies } = req + const customerToken = cookies[config.customerCookie] + + try { + // Return current wishlist info + if (req.method === 'GET') { + const body = { + customerToken, + includeProducts: req.query.products === '1', + } + return await handlers['getWishlist']({ req, res, config, body }) + } + + // Add an item to the wishlist + if (req.method === 'POST') { + const body = { ...req.body, customerToken } + return await handlers['addItem']({ req, res, config, body }) + } + + // Remove an item from the wishlist + if (req.method === 'DELETE') { + const body = { ...req.body, customerToken } + return await handlers['removeItem']({ req, res, config, body }) + } + } catch (error) { + console.error(error) + + const message = + error instanceof AquilacmsApiError + ? 'An unexpected error ocurred with the Bigcommerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } +} + +export const handlers = { + getWishlist, + addItem, + removeItem, +} + +export default createApiHandler(wishlistApi, handlers, {}) diff --git a/framework/aquilacms/customer/get-customer-wishlist.ts b/framework/aquilacms/customer/get-customer-wishlist.ts new file mode 100644 index 000000000..a2457d120 --- /dev/null +++ b/framework/aquilacms/customer/get-customer-wishlist.ts @@ -0,0 +1,50 @@ +import { AquilacmsConfig, getConfig } from '../api' + +export type Wishlist = Omit & { + items?: WishlistItem[] +} + +export type WishlistItem = NonNullable[0] & { + product?: any +} + +export type GetCustomerWishlistResult< + T extends { wishlist?: any } = { wishlist?: Wishlist } +> = T + +export type GetCustomerWishlistVariables = { + customerId: number +} + +async function getCustomerWishlist(opts: { + variables: GetCustomerWishlistVariables + config?: AquilacmsConfig + includeProducts?: boolean +}): Promise + +async function getCustomerWishlist< + T extends { wishlist?: any }, + V = any +>(opts: { + url: string + variables: V + config?: AquilacmsConfig + includeProducts?: boolean +}): Promise> + +async function getCustomerWishlist({ + config, + variables, + includeProducts, +}: { + url?: string + variables: GetCustomerWishlistVariables + config?: AquilacmsConfig + includeProducts?: boolean +}): Promise { + config = getConfig(config) + + return { wishlist: [] } +} + +export default getCustomerWishlist diff --git a/framework/aquilacms/wishlist/index.ts b/framework/aquilacms/wishlist/index.ts new file mode 100644 index 000000000..241af3c7e --- /dev/null +++ b/framework/aquilacms/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/framework/aquilacms/wishlist/use-add-item.tsx b/framework/aquilacms/wishlist/use-add-item.tsx new file mode 100644 index 000000000..402e7da8b --- /dev/null +++ b/framework/aquilacms/wishlist/use-add-item.tsx @@ -0,0 +1,37 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@commerce/utils/types' +import { CommerceError } from '@commerce/utils/errors' +import useAddItem, { UseAddItem } from '@commerce/wishlist/use-add-item' +import type { ItemBody, AddItemBody } from '../api/wishlist' +import useCustomer from '../customer/use-customer' +import useWishlist from './use-wishlist' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/bigcommerce/wishlist', + method: 'POST', + }, + useHook: ({ fetch }) => () => { + const { data: customer } = useCustomer() + const { revalidate } = 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 revalidate() + return data + }, + [fetch, revalidate, customer] + ) + }, +} diff --git a/framework/aquilacms/wishlist/use-remove-item.tsx b/framework/aquilacms/wishlist/use-remove-item.tsx new file mode 100644 index 000000000..622f321db --- /dev/null +++ b/framework/aquilacms/wishlist/use-remove-item.tsx @@ -0,0 +1,44 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@commerce/utils/types' +import { CommerceError } from '@commerce/utils/errors' +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' + +export default useRemoveItem as UseRemoveItem + +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(wishlist) + + return useCallback( + async function removeItem(input) { + if (!customer) { + // A signed customer is required in order to have a wishlist + throw new CommerceError({ + message: 'Signed customer not found', + }) + } + + const data = await fetch({ input: { itemId: String(input.id) } }) + await revalidate() + return data + }, + [fetch, revalidate, customer] + ) + }, +} diff --git a/framework/aquilacms/wishlist/use-wishlist.tsx b/framework/aquilacms/wishlist/use-wishlist.tsx new file mode 100644 index 000000000..4850d1cd9 --- /dev/null +++ b/framework/aquilacms/wishlist/use-wishlist.tsx @@ -0,0 +1,60 @@ +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 '../customer/use-customer' + +export type UseWishlistInput = { includeProducts?: boolean } + +export default useWishlist as UseWishlist + +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 + + // 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: ({ useData }) => (input) => { + const { data: customer } = useCustomer() + const response = useData({ + input: [ + ['customerId', customer?.entityId], + ['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] + ) + }, +} diff --git a/pages/api/bigcommerce/wishlist.ts b/pages/api/bigcommerce/wishlist.ts index 94e40ea11..0d6a895a5 100644 --- a/pages/api/bigcommerce/wishlist.ts +++ b/pages/api/bigcommerce/wishlist.ts @@ -1,4 +1,3 @@ -export {} -// import wishlistApi from '@framework/api/wishlist' +import wishlistApi from '@framework/api/wishlist' -// export default wishlistApi() +export default wishlistApi() diff --git a/pages/wishlist.tsx b/pages/wishlist.tsx index bce881337..0dddaf23d 100644 --- a/pages/wishlist.tsx +++ b/pages/wishlist.tsx @@ -6,7 +6,7 @@ import { defaultPageProps } from '@lib/defaults' import { getConfig } from '@framework/api' import { useCustomer } from '@framework/customer' import { WishlistCard } from '@components/wishlist' -// import useWishlist from '@framework/wishlist/use-wishlist' +import useWishlist from '@framework/wishlist/use-wishlist' import getAllPages from '@framework/common/get-all-pages' export async function getStaticProps({ @@ -56,7 +56,6 @@ export default function Wishlist() { data && // @ts-ignore Shopify - Fix this types data.items?.map((item) => ( - // @ts-ignore )) )}