diff --git a/.changeset/rich-camels-bake.md b/.changeset/rich-camels-bake.md new file mode 100644 index 000000000..9f135138a --- /dev/null +++ b/.changeset/rich-camels-bake.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +add wishlist drawer diff --git a/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/fragment.ts b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/fragment.ts new file mode 100644 index 000000000..5da80fb2f --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/fragment.ts @@ -0,0 +1,23 @@ +import { graphql } from '~/client/graphql'; + +export const WishlistSheetFragment = graphql(` + fragment WishlistSheetFragment on WishlistConnection { + edges { + node { + entityId + name + items { + edges { + node { + entityId + product { + name + entityId + } + } + } + } + } + } + } +`); diff --git a/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/index.tsx b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/index.tsx new file mode 100644 index 000000000..e5b13d149 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/index.tsx @@ -0,0 +1,73 @@ +import { Heart } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { PropsWithChildren, useEffect, useState } from 'react'; + +import { addWishlistItems } from '~/client/mutations/add-wishlist-items'; +import { Sheet } from '~/components/ui/sheet'; + +import { Button } from '../../../../../../../components/ui/button'; +import { AccountStatusProvider } from '../account-status-provider'; + +import { WishlistSheetContent } from './wishlist-sheet-content'; + +export type Wishlist = NonNullable>>; + +interface WishlistSheetProps extends PropsWithChildren { + productId: number; + wishlistsData: Wishlist[]; +} + +export const WishlistSheet = ({ productId, wishlistsData }: WishlistSheetProps) => { + const t = useTranslations('Account.Wishlist.Sheet'); + + const [wishlists, setWishlists] = useState(() => { + if (wishlistsData.length === 0) { + return [{ items: [], entityId: 0, name: t('favorites') }]; + } + + return wishlistsData; + }); + + const [saved, setSaved] = useState(() => { + if (wishlistsData.length === 0) { + return false; + } + + return wishlistsData.some(({ items }) => { + return items.some(({ product }) => product.entityId === productId); + }); + }); + + useEffect(() => { + const firstWishlistWithProduct = wishlists.find(({ items }) => + items.find(({ product }) => product.entityId === productId), + ); + + setSaved(!!firstWishlistWithProduct); + }, [productId, setSaved, wishlists]); + + return ( + + + ); +}; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/update-wishlists-form/_actions/add-wishlist-items.ts b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/update-wishlists-form/_actions/add-wishlist-items.ts new file mode 100644 index 000000000..907b36d31 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/update-wishlists-form/_actions/add-wishlist-items.ts @@ -0,0 +1,28 @@ +'use server'; + +import { + AddWishlistItems, + addWishlistItems as addWishlistItemsMutation, +} from '~/client/mutations/add-wishlist-items'; + +export const addWishlistItems = async ({ input }: AddWishlistItems) => { + try { + const wishlist = await addWishlistItemsMutation({ input }); + + if (wishlist) { + return { + status: 'success' as const, + data: wishlist, + }; + } + } catch (error: unknown) { + if (error instanceof Error) { + return { + status: 'error' as const, + message: error.message, + }; + } + } + + return { status: 'error' as const, message: 'Unknown error.' }; +}; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/update-wishlists-form/_actions/delete-wishlist-items.ts b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/update-wishlists-form/_actions/delete-wishlist-items.ts new file mode 100644 index 000000000..de53a8376 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/update-wishlists-form/_actions/delete-wishlist-items.ts @@ -0,0 +1,28 @@ +'use server'; + +import { + DeleteWishlistItems, + deleteWishlistItems as deleteWishlistItemsMutation, +} from '~/client/mutations/delete-wishlist-items'; + +export const deleteWishlistItems = async ({ input }: DeleteWishlistItems) => { + try { + const wishlist = await deleteWishlistItemsMutation({ input }); + + if (wishlist) { + return { + status: 'success', + data: wishlist, + }; + } + } catch (error: unknown) { + if (error instanceof Error) { + return { + status: 'error', + message: error.message, + }; + } + } + + return { status: 'error', message: 'Unknown error.' }; +}; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/update-wishlists-form/index.tsx b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/update-wishlists-form/index.tsx new file mode 100644 index 000000000..015fa7813 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/update-wishlists-form/index.tsx @@ -0,0 +1,184 @@ +import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; +import { useFormStatus } from 'react-dom'; + +import { Button } from '~/components/ui/button'; +import { Checkbox } from '~/components/ui/checkbox'; +import { Field, Form, FormSubmit } from '~/components/ui/form'; +import { Label } from '~/components/ui/label'; + +import { Wishlist } from '..'; + +import { addWishlistItems } from './_actions/add-wishlist-items'; +import { deleteWishlistItems } from './_actions/delete-wishlist-items'; + +interface SubmitButtonProps { + disabled: boolean; +} + +const SubmitButton = ({ disabled }: SubmitButtonProps) => { + const { pending } = useFormStatus(); + const t = useTranslations('Account.Wishlist.Sheet'); + + return ( + + ); +}; + +interface UpdateWishlistsFormProps { + onWishlistsUpdated: (updatedWishlists: Wishlist[]) => void; + productId: number; + wishlists: Wishlist[]; +} + +export const UpdateWishlistsForm = ({ + onWishlistsUpdated, + productId, + wishlists, +}: UpdateWishlistsFormProps) => { + const [wishlistsList, setWishlistsList] = useState(() => { + return wishlists.map((wishlist) => { + return { + ...wishlist, + checked: wishlist.items.some(({ product }) => product.entityId === productId), + upToDate: true, + }; + }); + }); + + useEffect(() => { + setWishlistsList((prevWishlistsList) => { + return wishlists.map((wishlist) => { + const prevWishlistsItem = prevWishlistsList.find( + ({ entityId }) => entityId === wishlist.entityId, + ); + + if (prevWishlistsItem) { + return { + ...prevWishlistsItem, + }; + } + + return { + ...wishlist, + checked: false, + upToDate: true, + }; + }); + }); + }, [productId, setWishlistsList, wishlists]); + + const handleCheckboxChange = (checked: boolean, wishlistId: number) => { + setWishlistsList((prevWishlistsList) => { + const newWishlistItemIdx = prevWishlistsList.findIndex( + (wishlist) => wishlist.entityId === wishlistId, + ); + + const prevWishlistItem = prevWishlistsList[newWishlistItemIdx]; + + if (!prevWishlistItem) { + return prevWishlistsList; + } + + const newWishlistItem = { + ...prevWishlistItem, + checked, + upToDate: !prevWishlistsList[newWishlistItemIdx]?.upToDate, + }; + + const newWishlistsList = [ + ...prevWishlistsList.slice(0, newWishlistItemIdx), + newWishlistItem, + ...prevWishlistsList.slice(newWishlistItemIdx + 1), + ]; + + return newWishlistsList; + }); + }; + + const onSubmit = async () => { + const wishlistsToUpdate = wishlistsList.filter((wishlist) => { + return !wishlist.upToDate; + }); + + const result = await Promise.all( + wishlistsToUpdate.map(({ checked, entityId, items }) => { + if (checked) { + return addWishlistItems({ + input: { + entityId, + items: [{ productEntityId: productId }], + }, + }); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const itemEntityId = items.find(({ product }) => product.entityId === productId)!.entityId; + + return deleteWishlistItems({ + input: { + entityId, + itemEntityIds: [itemEntityId], + }, + }); + }), + ); + + const updatedWishlists = result.reduce((acc, curr) => { + if (curr.data) { + acc.push(curr.data); + } + + return acc; + }, []); + + setWishlistsList((prevWishlistsList) => { + return prevWishlistsList.map((wishlist) => { + const updatedWishlist = updatedWishlists.find( + ({ entityId }) => entityId === wishlist.entityId, + ); + + return { + ...wishlist, + items: updatedWishlist ? [...updatedWishlist.items] : [...wishlist.items], + upToDate: true, + }; + }); + }); + + onWishlistsUpdated(updatedWishlists); + }; + + return ( +
e.stopPropagation()}> +
+ {wishlistsList.map(({ entityId, name, checked }) => { + return ( + + handleCheckboxChange(!!_checked, entityId)} + /> + + + ); + })} +
+ + !wishlist.upToDate)} /> + +
+ ); +}; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/wishlist-sheet-content.tsx b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/wishlist-sheet-content.tsx new file mode 100644 index 000000000..aaa0ee586 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/wishlist-sheet-content.tsx @@ -0,0 +1,85 @@ +import { useTranslations } from 'next-intl'; +import { Dispatch, SetStateAction, useState } from 'react'; + +import { CreateWishlistForm } from '~/app/[locale]/(default)/account/[tab]/_components/create-wishlist-form'; +import { Modal } from '~/app/[locale]/(default)/account/[tab]/_components/modal'; +import { createWishlist } from '~/client/mutations/create-wishlist'; +import { Message } from '~/components/ui/message'; + +import { Button } from '../../../../../../../components/ui/button'; +import { useAccountStatusContext } from '../account-status-provider'; + +import { UpdateWishlistsForm } from './update-wishlists-form'; + +import { Wishlist } from '.'; + +export type NewWishlist = NonNullable>>; + +interface WishlistSheetContentProps { + productId: number; + setWishlists: Dispatch>; + wishlists: Wishlist[]; +} + +export const WishlistSheetContent = ({ + productId, + setWishlists, + wishlists, +}: WishlistSheetContentProps) => { + const t = useTranslations('Account.Wishlist.Sheet'); + + const { accountState } = useAccountStatusContext(); + const [ceateWishlistModalOpen, setCreateWishlistModalOpen] = useState(false); + + const handleWishlistCreated = (newWishlist: NewWishlist) => { + setCreateWishlistModalOpen(false); + + setWishlists((prevWishlists) => { + const newWishlists = [...prevWishlists, { ...newWishlist, items: [] }]; + + return newWishlists; + }); + }; + + const handleWishlistsUpdated = (updatedWishlists: Wishlist[]) => { + setWishlists((prevWishlists) => { + return prevWishlists.map((wishlist) => { + const updatedWishlist = updatedWishlists.find( + ({ entityId }) => entityId === wishlist.entityId, + ); + + return { + ...wishlist, + items: updatedWishlist ? [...updatedWishlist.items] : [...wishlist.items], + }; + }); + }); + }; + + return ( +
+ {(accountState.status === 'error' || accountState.status === 'success') && ( + +

{accountState.message}

+
+ )} + +

{t('selectCta')}

+ + {t('new')}} + > + + +
+ ); +}; diff --git a/core/app/[locale]/(default)/product/[slug]/_components/details.tsx b/core/app/[locale]/(default)/product/[slug]/_components/details.tsx index 1b12c5def..2eb76705a 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/details.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/details.tsx @@ -1,10 +1,13 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { useFormatter, useTranslations } from 'next-intl'; +import { getFormatter, getTranslations } from 'next-intl/server'; +import { auth } from '~/auth'; import { FragmentOf, graphql } from '~/client/graphql'; import { ProductForm } from '~/components/product-form'; import { ProductFormFragment } from '~/components/product-form/fragment'; +import { getProduct } from '../page-data'; + import { ProductSchema, ProductSchemaFragment } from './product-schema'; import { ReviewSummary, ReviewSummaryFragment } from './review-summary'; @@ -68,13 +71,17 @@ export const DetailsFragment = graphql( [ReviewSummaryFragment, ProductSchemaFragment, ProductFormFragment], ); +export type Wishlists = NonNullable>>['wishlists']; + interface Props { product: FragmentOf; + wishlists: Wishlists; } -export const Details = ({ product }: Props) => { - const t = useTranslations('Product.Details'); - const format = useFormatter(); +export const Details = async ({ product, wishlists }: Props) => { + const t = await getTranslations('Product.Details'); + const format = await getFormatter(); + const session = await auth(); const customFields = removeEdgesAndNodes(product.customFields); @@ -155,7 +162,7 @@ export const Details = ({ product }: Props) => { )} - +

{t('additionalDetails')}

diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 2c9da4d7e..deb236583 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -1,5 +1,7 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; +import { WishlistSheetFragment } from '~/app/[locale]/(default)/account/[tab]/_components/wishlist-sheet/fragment'; import { getSessionCustomerId } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; @@ -48,9 +50,21 @@ const ProductPageQuery = graphql( } } } + customer { + wishlists(first: 50) { + ...WishlistSheetFragment + } + } } `, - [BreadcrumbsFragment, GalleryFragment, DetailsFragment, DescriptionFragment, WarrantyFragment], + [ + BreadcrumbsFragment, + GalleryFragment, + DetailsFragment, + DescriptionFragment, + WarrantyFragment, + WishlistSheetFragment, + ], ); type ProductPageQueryVariables = VariablesOf; @@ -65,5 +79,14 @@ export const getProduct = cache(async (variables: ProductPageQueryVariables) => fetchOptions: customerId ? { cache: 'no-store' } : { next: { revalidate } }, }); - return data.site.product; + const wishlists = data.customer?.wishlists + ? removeEdgesAndNodes(data.customer.wishlists).map((wishlist) => { + return { + ...wishlist, + items: removeEdgesAndNodes(wishlist.items), + }; + }) + : []; + + return { product: data.site.product, wishlists }; }); diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index b6a7f25d1..1a01a6989 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -28,7 +28,7 @@ export async function generateMetadata({ const productId = Number(params.slug); const optionValueIds = getOptionValueIds({ searchParams }); - const product = await getProduct({ + const { product } = await getProduct({ entityId: productId, optionValueIds, useDefaultOptionSelections: optionValueIds.length === 0 ? true : undefined, @@ -70,7 +70,7 @@ export default async function Product({ params, searchParams }: ProductPageProps const optionValueIds = getOptionValueIds({ searchParams }); - const product = await getProduct({ + const { product, wishlists } = await getProduct({ entityId: productId, optionValueIds, useDefaultOptionSelections: optionValueIds.length === 0 ? true : undefined, @@ -89,10 +89,14 @@ export default async function Product({ params, searchParams }: ProductPageProps
-
+
diff --git a/core/components/product-form/index.tsx b/core/components/product-form/index.tsx index 8d4148ca9..24356bf85 100644 --- a/core/components/product-form/index.tsx +++ b/core/components/product-form/index.tsx @@ -6,11 +6,13 @@ import { useTranslations } from 'next-intl'; import { FormProvider, useFormContext } from 'react-hook-form'; import { toast } from 'react-hot-toast'; +import { Wishlists } from '~/app/[locale]/(default)/product/[slug]/_components/details'; import { FragmentOf } from '~/client/graphql'; -import { Button } from '~/components/ui/button'; +import { WishlistSheet } from '../../app/[locale]/(default)/account/[tab]/_components/wishlist-sheet'; import { AddToCartButton } from '../add-to-cart-button'; import { Link } from '../link'; +import { Button } from '../ui/button'; import { handleAddToCart } from './_actions/add-to-cart'; import { CheckboxField } from './fields/checkbox-field'; @@ -23,11 +25,17 @@ import { TextField } from './fields/text-field'; import { ProductFormFragment } from './fragment'; import { ProductFormData, useProductForm } from './use-product-form'; -interface Props { +interface SubmitProps { data: FragmentOf; } -export const Submit = ({ data: product }: Props) => { +interface ProductFormProps { + data: FragmentOf; + isLogged?: boolean; + wishlists?: Wishlists; +} + +export const Submit = ({ data: product }: SubmitProps) => { const { formState } = useFormContext(); const { isSubmitting } = formState; @@ -38,9 +46,8 @@ export const Submit = ({ data: product }: Props) => { ); }; -export const ProductForm = ({ data: product }: Props) => { - const t = useTranslations('Product.Form'); - const m = useTranslations('AddToCart'); +export const ProductForm = ({ data: product, isLogged, wishlists }: ProductFormProps) => { + const t = useTranslations('AddToCart'); const productOptions = removeEdgesAndNodes(product.productOptions); const { handleSubmit, register, ...methods } = useProductForm(); @@ -50,7 +57,7 @@ export const ProductForm = ({ data: product }: Props) => { const quantity = Number(data.quantity); if (result.error) { - toast.error(m('errorAddingProductToCart'), { + toast.error(t('errorAddingProductToCart'), { icon: , }); @@ -61,7 +68,7 @@ export const ProductForm = ({ data: product }: Props) => { () => (
- {m.rich('addedProductQuantity', { + {t.rich('addedProductQuantity', { cartItems: quantity, cartLink: (chunks) => ( {
- - {/* NOT IMPLEMENTED YET */} -
- -
+ )}
diff --git a/core/messages/en.json b/core/messages/en.json index 9720a91f2..10fb072c8 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -221,6 +221,16 @@ "messages": { "created": "Your wishlist {name} was created successfully", "deleted": "Your wishlist {name} was deleted successfully" + }, + "Sheet": { + "title": "Add to list", + "new": "New list", + "save": "Save", + "saveToWishlist": "Save to wishlist", + "saved": "Saved", + "createTitle": "New wishlist", + "selectCta": "Select from available lists:", + "favorites": "Favorites" } }, "Settings": { @@ -418,8 +428,9 @@ "unavailable": "Unavailable", "processing": "Processing...", "addedProductQuantity": "{cartItems, plural, =1 {1 Item} other {# Items}} added to your cart", - "errorAddingProductToCart": "Error adding product to cart. Please try again." - }, + "errorAddingProductToCart": "Error adding product to cart. Please try again.", + "saveToWishlist": "Save to wishlist" +}, "StoreSelector": { "heading": "Select a language and region" }