diff --git a/.changeset/odd-oranges-wonder.md b/.changeset/odd-oranges-wonder.md new file mode 100644 index 000000000..493c8321c --- /dev/null +++ b/.changeset/odd-oranges-wonder.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +add wishlist book diff --git a/core/app/[locale]/(default)/account/[tab]/_actions/delete-wishlists.ts b/core/app/[locale]/(default)/account/[tab]/_actions/delete-wishlists.ts new file mode 100644 index 000000000..57336c7d5 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_actions/delete-wishlists.ts @@ -0,0 +1,31 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { + deleteWishlists as deleteWishlistsClient, + Input, +} from '~/client/mutations/delete-wishlists'; + +export const deleteWishlists = async (wishlists: Input) => { + try { + const result = await deleteWishlistsClient(wishlists); + + revalidatePath('/account/wishlists', 'page'); + + if (result === 'success') { + return { + status: 'success', + }; + } + } 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-content/create-wishlist-form/_actions/create-wishlist.ts b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-content/create-wishlist-form/_actions/create-wishlist.ts new file mode 100644 index 000000000..03066073f --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-content/create-wishlist-form/_actions/create-wishlist.ts @@ -0,0 +1,43 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +import { createWishlist as createWishlistClient } from '~/client/mutations/create-wishlist'; + +const CreateWishlistSchema = z.object({ + name: z.string(), +}); + +export const createWishlist = async (formData: FormData) => { + const parsedData = CreateWishlistSchema.parse({ + name: formData.get('name'), + }); + + const input = { + ...parsedData, + isPublic: true, + }; + + try { + const newWishlist = await createWishlistClient({ input }); + + revalidatePath('/account/wishlists', 'page'); + + if (newWishlist) { + return { + status: 'success', + data: newWishlist, + }; + } + } 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-content/create-wishlist-form/index.tsx b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-content/create-wishlist-form/index.tsx new file mode 100644 index 000000000..f122a06f7 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-content/create-wishlist-form/index.tsx @@ -0,0 +1,108 @@ +import { useTranslations } from 'next-intl'; +import { ChangeEvent, useState } from 'react'; +import { useFormStatus } from 'react-dom'; + +import { createWishlist as createWishlistMutation } from '~/client/mutations/create-wishlist'; +import { Button } from '~/components/ui/button'; +import { DialogCancel } from '~/components/ui/dialog'; +import { + Field, + FieldControl, + FieldLabel, + FieldMessage, + Form, + FormSubmit, +} from '~/components/ui/form'; +import { Input } from '~/components/ui/input'; + +import { Wishlists } from '..'; +import { useAccountStatusContext } from '../../account-status-provider'; + +import { createWishlist } from './_actions/create-wishlist'; + +type Wishlist = NonNullable>>; + +interface Props { + onWishlistCreated: (newWishlist: Wishlists[number] & Wishlist) => void; +} + +const SubmitButton = () => { + const { pending } = useFormStatus(); + const t = useTranslations('Account.Wishlist'); + + return ( + + + + ); +}; + +export const CreateWishlistForm = ({ onWishlistCreated }: Props) => { + const [isInputValid, setInputValidation] = useState(true); + const { setAccountState } = useAccountStatusContext(); + + const t = useTranslations('Account.Wishlist'); + + const handleInputValidation = (e: ChangeEvent) => { + const validationStatus = e.target.validity.valueMissing; + + setInputValidation(!validationStatus); + }; + + const onSubmit = async (formData: FormData) => { + const submit = await createWishlist(formData); + + if (submit.status === 'success') { + if (submit.data) { + onWishlistCreated(submit.data); + setAccountState({ + status: submit.status, + message: t('messages.created', { name: submit.data.name }), + }); + } + } + + if (submit.status === 'error') { + setAccountState({ status: submit.status, message: submit.message }); + } + }; + + return ( +
e.stopPropagation()}> + + {t('inputLabel')} + + + + + {t('emptyName')} + + +
+ + + + +
+
+ ); +}; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/wishlist-content/index.tsx b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-content/index.tsx new file mode 100644 index 000000000..637ab9c71 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-content/index.tsx @@ -0,0 +1,44 @@ +import { getLocale, getTranslations } from 'next-intl/server'; + +import { Pagination } from '../../../../(faceted)/_components/pagination'; +import { getWishlistQuery } from '../../page-data'; +import { TabHeading } from '../tab-heading'; + +import { WishlistBook } from './wishlist-book'; + +type WishlistsDetails = NonNullable>>; +export type Wishlists = WishlistsDetails['wishlists']; + +interface Props { + wishlists: Wishlists; + pageInfo: WishlistsDetails['pageInfo']; +} + +export interface FormStatus { + status: 'success' | 'error'; + message: string; +} + +export const WISHLISTS_PER_PAGE = 4; + +export const WishlistContent = async ({ pageInfo, wishlists }: Props) => { + const locale = await getLocale(); + const t = await getTranslations({ locale, namespace: 'Pagination' }); + + const { hasNextPage, hasPreviousPage, startCursor, endCursor } = pageInfo; + + return ( + <> + + + + + ); +}; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/wishlist-content/wishlist-book.tsx b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-content/wishlist-book.tsx new file mode 100644 index 000000000..4024cdf49 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/wishlist-content/wishlist-book.tsx @@ -0,0 +1,261 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; + +import { BcImage } from '~/components/bc-image'; +import { Link } from '~/components/link'; +import { Pricing } from '~/components/pricing'; +import { Button } from '~/components/ui/button'; +import { Message } from '~/components/ui/message/message'; + +import { deleteWishlists } from '../../_actions/delete-wishlists'; +import { useAccountStatusContext } from '../account-status-provider'; +import { Modal } from '../modal'; + +import { CreateWishlistForm } from './create-wishlist-form'; + +import { Wishlists, WISHLISTS_PER_PAGE } from '.'; + + +interface WishlistProps { + onDeleteWishlist?: (id: number, name: string) => Promise; + wishlist: Wishlists[number]; +} + +interface WishlistBook { + hasPreviousPage: boolean; + wishlists: Wishlists; +} + +interface WishlistItemsCount { + itemsQuantity: number; +} + +enum VisibleWishlistItemsPerDevice { + xs = 1, + md = 3, + lg = 4, + xl = 5, +} + +const HiddenQuantity = ({ itemsQuantity }: WishlistItemsCount) => { + const smItems = itemsQuantity - VisibleWishlistItemsPerDevice.xs; + const mdItems = itemsQuantity - VisibleWishlistItemsPerDevice.md; + const lgItems = itemsQuantity - VisibleWishlistItemsPerDevice.lg; + const xlItems = itemsQuantity - VisibleWishlistItemsPerDevice.xl; + + return ( + <> + {smItems > 0 && ( +
+
+ +{smItems} +
+
+ )} + {mdItems > 0 && ( +
+
+ +{mdItems} +
+
+ )} + {lgItems > 0 && ( +
+
+ +{lgItems} +
+
+ )} + {xlItems > 0 && ( +
+
+ +{xlItems} +
+
+ )} + + ); +}; + +const Wishlist = ({ onDeleteWishlist, wishlist: { items, entityId, name } }: WishlistProps) => { + const t = useTranslations('Account.Wishlist'); + + return ( + <> +

{name}

+
+ {items.length === 0 ? ( +

{t('noItems')}

+ ) : ( +
+
    + {items + .slice(0, VisibleWishlistItemsPerDevice.xl) + .map(({ entityId: productId, product }) => { + const defaultImage = product.images.find(({ isDefault }) => isDefault); + + return ( +
  • + +
    + {defaultImage ? ( + + ) : ( +
    + {t('noGalleryText')} +
    + )} +
    + + + {product.brand && ( + +

    {product.brand.name}

    + + )} + +

    {product.name}

    + + +
  • + ); + })} +
+ +
+ )} + {onDeleteWishlist && ( +
+ onDeleteWishlist(entityId, name)} + confirmationText={t('confirmDelete', { name })} + title={t('deleteTitle', { name })} + trigger={ + + } + /> +
+ )} +
+ + ); +}; + +export const WishlistBook = ({ hasPreviousPage, wishlists }: WishlistBook) => { + const t = useTranslations('Account.Wishlist'); + const [wishlistBook, setWishlistBook] = useState(wishlists); + const { accountState, setAccountState } = useAccountStatusContext(); + const [ceateWishlistModalOpen, setCreateWishlistModalOpen] = useState(false); + const router = useRouter(); + + useEffect(() => { + setWishlistBook(wishlists); + }, [wishlists]); + + useEffect(() => { + if (hasPreviousPage && wishlistBook.length === 0) { + const timer = setTimeout(() => { + router.back(); + }, 3000); + + return () => clearTimeout(timer); + } + }, [hasPreviousPage, router, wishlistBook]); + + const handleWishlistCreated = (newWishlist: Wishlists[number]) => { + setWishlistBook((prevWishlistBook) => { + if (prevWishlistBook.length < WISHLISTS_PER_PAGE) { + return [...prevWishlistBook, newWishlist]; + } + + return prevWishlistBook; + }); + setCreateWishlistModalOpen(false); + + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + const handleDeleteWishlist = async (id: number, name: string) => { + const result = await deleteWishlists({ entityIds: [id] }); + + if (result.status === 'success') { + setWishlistBook((prevWishlistBook) => + prevWishlistBook.filter(({ entityId }) => entityId !== id), + ); + + const message = t('messages.deleted', { name }); + + setAccountState({ status: 'success', message }); + } + + if (result.status === 'error') { + setAccountState({ status: result.status, message: result.message }); + } + + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + return ( +
+ {(accountState.status === 'error' || accountState.status === 'success') && ( + +

{accountState.message}

+
+ )} + +
    + {!hasPreviousPage && wishlistBook.length === 0 && ( +
  • + +
  • + )} + {wishlistBook.map((wishlist) => { + return ( +
  • + +
  • + ); + })} +
+ + {t('new')} + + } + > + + +
+ ); +}; diff --git a/core/app/[locale]/(default)/account/[tab]/page-data.ts b/core/app/[locale]/(default)/account/[tab]/page-data.ts index aa19539f5..07ee29824 100644 --- a/core/app/[locale]/(default)/account/[tab]/page-data.ts +++ b/core/app/[locale]/(default)/account/[tab]/page-data.ts @@ -1,9 +1,104 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; import { getSessionCustomerId } from '~/auth'; import { client } from '~/client'; import { FORM_FIELDS_FRAGMENT } from '~/client/fragments/form-fields'; import { graphql, VariablesOf } from '~/client/graphql'; +import { PricingFragment } from '~/components/pricing'; + +import { GalleryFragment } from '../../product/[slug]/_components/gallery/fragment'; + +const WishlistQuery = graphql( + ` + query WishlistQuery( + $filters: WishlistFiltersInput + $after: String + $before: String + $first: Int + $last: Int + ) { + customer { + wishlists(filters: $filters, after: $after, before: $before, first: $first, last: $last) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + node { + entityId + name + isPublic + items { + edges { + node { + entityId + product { + entityId + name + path + brand { + name + path + } + ...GalleryFragment + ...PricingFragment + } + } + } + } + } + } + } + } + } + `, + [GalleryFragment, PricingFragment], +); + +export interface WishlistArgs { + after?: string; + before?: string; + limit?: number; +} + +export const getWishlistQuery = cache(async ({ before, after, limit = 3 }: WishlistArgs) => { + const customerId = await getSessionCustomerId(); + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + + const response = await client.fetch({ + document: WishlistQuery, + variables: { ...paginationArgs }, + fetchOptions: { cache: 'no-store' }, + customerId, + }); + + const { customer } = response.data; + + if (!customer) { + return undefined; + } + + return { + pageInfo: customer.wishlists.pageInfo, + wishlists: removeEdgesAndNodes(customer.wishlists).map((wishlist) => { + return { + ...wishlist, + items: removeEdgesAndNodes(wishlist.items).map((item) => { + return { + ...item, + product: { + ...item.product, + images: removeEdgesAndNodes(item.product.images), + }, + }; + }), + }; + }), + }; +}); const CustomerSettingsQuery = graphql( ` diff --git a/core/app/[locale]/(default)/account/[tab]/page.tsx b/core/app/[locale]/(default)/account/[tab]/page.tsx index 635f64e49..60e35ccfc 100644 --- a/core/app/[locale]/(default)/account/[tab]/page.tsx +++ b/core/app/[locale]/(default)/account/[tab]/page.tsx @@ -7,8 +7,9 @@ import { getCustomerAddresses } from '~/client/queries/get-customer-addresses'; import { AddressesContent } from './_components/addresses-content'; import { SettingsContent } from './_components/settings-content'; import { TabHeading } from './_components/tab-heading'; +import { WishlistContent, WISHLISTS_PER_PAGE } from './_components/wishlist-content'; import { TabType } from './layout'; -import { getCustomerSettingsQuery } from './page-data'; +import { getCustomerSettingsQuery, getWishlistQuery } from './page-data'; interface Props { params: { @@ -65,8 +66,21 @@ export default async function AccountTabPage({ params: { tab }, searchParams }: ); } - case 'wishlists': - return ; + case 'wishlists': { + const { before, after } = searchParams; + + const wishlistDetails = await getWishlistQuery({ + ...(after && { after }), + ...(before && { before }), + limit: WISHLISTS_PER_PAGE, + }); + + if (!wishlistDetails) { + notFound(); + } + + return ; + } case 'recently-viewed': return ; diff --git a/core/app/[locale]/(default)/product/[slug]/_components/details.tsx b/core/app/[locale]/(default)/product/[slug]/_components/details.tsx index ffc1323ec..9bcc13db7 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/details.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/details.tsx @@ -5,6 +5,8 @@ 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,11 +70,14 @@ export const DetailsFragment = graphql( [ReviewSummaryFragment, ProductSchemaFragment, ProductFormFragment], ); +export type Wishlists = NonNullable>>['wishlists']; + interface Props { product: FragmentOf; + wishlists: Wishlists; } -export const Details = ({ product }: Props) => { +export const Details = ({ product, wishlists }: Props) => { const t = useTranslations('Product.Details'); const format = useFormatter(); @@ -155,7 +160,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 7c4331254..1c741862d 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -1,3 +1,4 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; import { getSessionCustomerId } from '~/auth'; @@ -5,6 +6,7 @@ import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; import { BreadcrumbsFragment } from '~/components/breadcrumbs'; +import { WishlistSheetFragment } from '~/components/wishlist-sheet/fragment'; import { DescriptionFragment } from './_components/description'; import { DetailsFragment } from './_components/details'; @@ -40,9 +42,21 @@ const ProductPageQuery = graphql( } } } + customer { + wishlists { + ...WishlistSheetFragment + } + } } `, - [BreadcrumbsFragment, GalleryFragment, DetailsFragment, DescriptionFragment, WarrantyFragment], + [ + BreadcrumbsFragment, + GalleryFragment, + DetailsFragment, + DescriptionFragment, + WarrantyFragment, + WishlistSheetFragment, + ], ); type ProductPageQueryVariables = VariablesOf; @@ -57,5 +71,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 05ad135c8..2c4087d24 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({ entityId: productId, optionValueIds }); + const { product } = await getProduct({ entityId: productId, optionValueIds }); if (!product) { return {}; @@ -66,7 +66,7 @@ export default async function Product({ params, searchParams }: ProductPageProps const optionValueIds = getOptionValueIds({ searchParams }); - const product = await getProduct({ entityId: productId, optionValueIds }); + const { product, wishlists } = await getProduct({ entityId: productId, optionValueIds }); if (!product) { return notFound(); @@ -79,9 +79,15 @@ export default async function Product({ params, searchParams }: ProductPageProps {category && }
- + -
+
diff --git a/core/client/mutations/add-wishlist-items.ts b/core/client/mutations/add-wishlist-items.ts new file mode 100644 index 000000000..ff17330da --- /dev/null +++ b/core/client/mutations/add-wishlist-items.ts @@ -0,0 +1,59 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; + +import { getSessionCustomerId } from '~/auth'; + +import { client } from '..'; +import { graphql, VariablesOf } from '../graphql'; + +const ADD_WISHLIST_ITEMS_MUTATION = graphql(` + mutation addWishlistItems($input: AddWishlistItemsInput!) { + wishlist { + addWishlistItems(input: $input) { + result { + entityId + name + items { + edges { + node { + entityId + product { + name + entityId + } + } + } + } + } + } + } + } +`); + +type Variables = VariablesOf; +type Input = Variables['input']; + +export interface AddWishlistItems { + input: Input; +} + +export const addWishlistItems = async ({ input }: AddWishlistItems) => { + const customerId = await getSessionCustomerId(); + + const response = await client.fetch({ + document: ADD_WISHLIST_ITEMS_MUTATION, + customerId, + fetchOptions: { cache: 'no-store' }, + variables: { input }, + }); + + const wishlist = response.data.wishlist.addWishlistItems?.result; + + if (!wishlist) { + return undefined; + } + + return { + ...wishlist, + items: removeEdgesAndNodes(wishlist.items), + }; +}; diff --git a/core/client/mutations/create-wishlist.ts b/core/client/mutations/create-wishlist.ts index e8f769bf7..6a687f81b 100644 --- a/core/client/mutations/create-wishlist.ts +++ b/core/client/mutations/create-wishlist.ts @@ -1,36 +1,24 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { GalleryFragment } from '~/app/[locale]/(default)/product/[slug]/_components/gallery/fragment'; import { getSessionCustomerId } from '~/auth'; -import { PricingFragment } from '~/components/pricing'; import { client } from '..'; import { graphql, VariablesOf } from '../graphql'; -const CREATE_WISHLIST_MUTATION = graphql( - ` - mutation CreateWishlist($input: CreateWishlistInput!) { - wishlist { - createWishlist(input: $input) { - result { - entityId - name - isPublic - items { - edges { - node { +const CREATE_WISHLIST_MUTATION = graphql(` + mutation CreateWishlist($input: CreateWishlistInput!) { + wishlist { + createWishlist(input: $input) { + result { + entityId + name + items { + edges { + node { + entityId + product { entityId - product { - entityId - name - path - brand { - name - path - } - ...GalleryFragment - ...PricingFragment - } + name } } } @@ -38,14 +26,17 @@ const CREATE_WISHLIST_MUTATION = graphql( } } } - `, - [GalleryFragment, PricingFragment], -); + } +`); type Variables = VariablesOf; type Input = Variables['input']; -export const createWishlist = async (input: Input) => { +export interface CreateWishlist { + input: Input; +} + +export const createWishlist = async ({ input }: CreateWishlist) => { const customerId = await getSessionCustomerId(); const response = await client.fetch({ @@ -63,14 +54,6 @@ export const createWishlist = async (input: Input) => { return { ...newWishlist, - items: removeEdgesAndNodes(newWishlist.items).map((item) => { - return { - ...item, - product: { - ...item.product, - images: removeEdgesAndNodes(item.product.images), - }, - }; - }), + items: removeEdgesAndNodes(newWishlist.items), }; }; diff --git a/core/client/mutations/delete-wishlist-items.ts b/core/client/mutations/delete-wishlist-items.ts new file mode 100644 index 000000000..7317e0c29 --- /dev/null +++ b/core/client/mutations/delete-wishlist-items.ts @@ -0,0 +1,59 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; + +import { getSessionCustomerId } from '~/auth'; + +import { client } from '..'; +import { graphql, VariablesOf } from '../graphql'; + +const DELETE_WISHLIST_ITEMS_MUTATION = graphql(` + mutation deleteWishlistItems($input: DeleteWishlistItemsInput!) { + wishlist { + deleteWishlistItems(input: $input) { + result { + entityId + name + items { + edges { + node { + entityId + product { + name + entityId + } + } + } + } + } + } + } + } +`); + +type Variables = VariablesOf; +type Input = Variables['input']; + +export interface DeleteWishlistItems { + input: Input; +} + +export const deleteWishlistItems = async ({ input }: DeleteWishlistItems) => { + const customerId = await getSessionCustomerId(); + + const response = await client.fetch({ + document: DELETE_WISHLIST_ITEMS_MUTATION, + customerId, + fetchOptions: { cache: 'no-store' }, + variables: { input }, + }); + + const wishlist = response.data.wishlist.deleteWishlistItems?.result; + + if (!wishlist) { + return undefined; + } + + return { + ...wishlist, + items: removeEdgesAndNodes(wishlist.items), + }; +}; diff --git a/core/client/queries/get-wishlists.ts b/core/client/queries/get-wishlists.ts new file mode 100644 index 000000000..e1c55493a --- /dev/null +++ b/core/client/queries/get-wishlists.ts @@ -0,0 +1,53 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { cache } from 'react'; + +import { getSessionCustomerId } from '~/auth'; + +import { client } from '..'; +import { graphql } from '../graphql'; + +const GET_WISHLISTS_QUERY = graphql(` + query WishlistQuery { + customer { + wishlists { + edges { + node { + name + entityId + items { + edges { + node { + product { + name + entityId + } + } + } + } + } + } + } + } + } +`); + +export const getWishlists = cache(async () => { + const customerId = await getSessionCustomerId(); + + const response = await client.fetch({ + document: GET_WISHLISTS_QUERY, + customerId, + fetchOptions: { cache: 'no-store' }, + }); + + const { customer } = response.data; + + if (!customer) { + return undefined; + } + + return removeEdgesAndNodes(customer.wishlists).map((wishlist) => ({ + ...wishlist, + items: removeEdgesAndNodes(wishlist.items), + })); +}); diff --git a/core/components/product-form/index.tsx b/core/components/product-form/index.tsx index 01a7c4898..526059d45 100644 --- a/core/components/product-form/index.tsx +++ b/core/components/product-form/index.tsx @@ -1,15 +1,16 @@ 'use client'; import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { AlertCircle, Check, Heart } from 'lucide-react'; +import { AlertCircle, Check } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { FormProvider } 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 { Link } from '../link'; +import { WishlistSheet } from '../wishlist-sheet'; import { handleAddToCart } from './_actions/add-to-cart'; import { AddToCart } from './add-to-cart'; @@ -25,9 +26,10 @@ import { ProductFormData, useProductForm } from './use-product-form'; interface Props { product: FragmentOf; + wishlists: Wishlists; } -export const ProductForm = ({ product }: Props) => { +export const ProductForm = ({ product, wishlists }: Props) => { const t = useTranslations('Product.Form'); const productOptions = removeEdgesAndNodes(product.productOptions); @@ -106,14 +108,7 @@ export const ProductForm = ({ product }: Props) => {
- - {/* NOT IMPLEMENTED YET */} -
- -
+
diff --git a/core/components/wishlist-sheet/_actions/get-wishlists.ts b/core/components/wishlist-sheet/_actions/get-wishlists.ts new file mode 100644 index 000000000..eb8fd9519 --- /dev/null +++ b/core/components/wishlist-sheet/_actions/get-wishlists.ts @@ -0,0 +1,23 @@ +'use server'; + +import { getWishlists as getWishlistsQuery } from '~/client/queries/get-wishlists'; + +export const getWishlists = async () => { + try { + const response = await getWishlistsQuery(); + + return { + status: 'success', + data: response, + }; + } catch (error: unknown) { + if (error instanceof Error) { + return { + status: 'error', + message: error.message, + }; + } + } + + return { status: 'error', message: 'Unknown error.' }; +}; diff --git a/core/components/wishlist-sheet/fragment.ts b/core/components/wishlist-sheet/fragment.ts new file mode 100644 index 000000000..a7db6627b --- /dev/null +++ b/core/components/wishlist-sheet/fragment.ts @@ -0,0 +1,23 @@ +import { graphql } from '~/client/graphql'; + +export const WishlistSheetFragment = graphql(` + fragment WishlistSheetFragment on WishlistConnection { + edges { + node { + name + entityId + items { + edges { + node { + entityId + product { + name + entityId + } + } + } + } + } + } + } +`); diff --git a/core/components/wishlist-sheet/index.tsx b/core/components/wishlist-sheet/index.tsx new file mode 100644 index 000000000..5bda0eeca --- /dev/null +++ b/core/components/wishlist-sheet/index.tsx @@ -0,0 +1,84 @@ +import { Heart } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { PropsWithChildren, useEffect, useState } from 'react'; + +import { createWishlist } from '~/client/mutations/create-wishlist'; +import { + Sheet, + SheetClose, + SheetContent, + SheetHeader, + SheetOverlay, + SheetTitle, + SheetTrigger, +} from '~/components/ui/sheet'; + +import { Button } from '../ui/button'; + +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.find((wishlist) => { + return !!wishlist.items.find((item) => item.product.entityId === productId); + }); + }); + + useEffect(() => { + const firstWishlistWithProduct = wishlists.find(({ items }) => + items.find(({ product }) => product.entityId === productId), + ); + + setSaved(!!firstWishlistWithProduct); + }, [productId, setSaved, wishlists]); + + return ( + + + + + + + + +

{t('title')}

+
+ +
+ +
+
+ ); +}; diff --git a/core/components/wishlist-sheet/update-wishlists-form/_actions/add-wishlist-items.ts b/core/components/wishlist-sheet/update-wishlists-form/_actions/add-wishlist-items.ts new file mode 100644 index 000000000..5a393646b --- /dev/null +++ b/core/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 response = await addWishlistItemsMutation({ input }); + + if (response) { + return { + status: 'success', + data: response, + }; + } + } catch (error: unknown) { + if (error instanceof Error) { + return { + status: 'error', + message: error.message, + }; + } + } + + return { status: 'error', message: 'Unknown error.' }; +}; diff --git a/core/components/wishlist-sheet/update-wishlists-form/_actions/create-wishlist.ts b/core/components/wishlist-sheet/update-wishlists-form/_actions/create-wishlist.ts new file mode 100644 index 000000000..2ef952ed3 --- /dev/null +++ b/core/components/wishlist-sheet/update-wishlists-form/_actions/create-wishlist.ts @@ -0,0 +1,26 @@ +'use server'; + +import { + CreateWishlist, + createWishlist as createWishlistMutation, +} from '~/client/mutations/create-wishlist'; + +export const createWishlist = async ({ input }: CreateWishlist) => { + try { + const newWishlist = await createWishlistMutation({ input }); + + return { + status: 'success', + data: newWishlist, + }; + } catch (error: unknown) { + if (error instanceof Error) { + return { + status: 'error', + message: error.message, + }; + } + } + + return { status: 'error', message: 'Unknown error.' }; +}; diff --git a/core/components/wishlist-sheet/update-wishlists-form/_actions/delete-wishlist-items.ts b/core/components/wishlist-sheet/update-wishlists-form/_actions/delete-wishlist-items.ts new file mode 100644 index 000000000..c658a5eb5 --- /dev/null +++ b/core/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 response = await deleteWishlistItemsMutation({ input }); + + if (response) { + return { + status: 'success', + data: response, + }; + } + } catch (error: unknown) { + if (error instanceof Error) { + return { + status: 'error', + message: error.message, + }; + } + } + + return { status: 'error', message: 'Unknown error.' }; +}; diff --git a/core/components/wishlist-sheet/update-wishlists-form/index.tsx b/core/components/wishlist-sheet/update-wishlists-form/index.tsx new file mode 100644 index 000000000..8aeff02c7 --- /dev/null +++ b/core/components/wishlist-sheet/update-wishlists-form/index.tsx @@ -0,0 +1,196 @@ +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 { createWishlist } from './_actions/create-wishlist'; +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.find(({ 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, name, items }) => { + if (checked) { + // favorites wishlist that was not realy created in db + if (entityId === 0) { + return createWishlist({ + input: { + name, + items: [{ productEntityId: productId }], + isPublic: true, + }, + }); + } + + 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/components/wishlist-sheet/wishlist-sheet-content.tsx b/core/components/wishlist-sheet/wishlist-sheet-content.tsx new file mode 100644 index 000000000..407925e23 --- /dev/null +++ b/core/components/wishlist-sheet/wishlist-sheet-content.tsx @@ -0,0 +1,75 @@ +import { useTranslations } from 'next-intl'; +import { Dispatch, SetStateAction, useState } from 'react'; + +import { AccountStatusProvider } from '~/app/[locale]/(default)/account/[tab]/_components/account-status-provider'; +import { Modal } from '~/app/[locale]/(default)/account/[tab]/_components/modal'; +import { CreateWishlistForm } from '~/app/[locale]/(default)/account/[tab]/_components/wishlist-content/create-wishlist-form'; + +import { Button } from '../ui/button'; + +import { UpdateWishlistsForm } from './update-wishlists-form'; + +import { Wishlist } from '.'; + +interface WishlistSheetContentProps { + productId: number; + setWishlists: Dispatch>; + wishlists: Wishlist[]; +} + +export const WishlistSheetContent = ({ + productId, + setWishlists, + wishlists, +}: WishlistSheetContentProps) => { + const t = useTranslations('Account.Wishlist.Sheet'); + + const [ceateWishlistModalOpen, setCreateWishlistModalOpen] = useState(false); + + const handleWishlistCreated = (newWishlist: Wishlist) => { + setCreateWishlistModalOpen(false); + setWishlists((prevWishlists) => { + const newWishlists = [...prevWishlists, newWishlist]; + + 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 ( + +
+

{t('selectCta')}

+ + {t('new')}} + > + + +
+
+ ); +}; diff --git a/core/messages/en.json b/core/messages/en.json index 3c1d50ca6..6f1a7b4ab 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -48,6 +48,7 @@ "loading": "Loading...", "condition": "Condition", "weight": "Weight", + "saveToWishlist": "Save to wishlist", "ReviewSummary": { "loading": "Loading...", "rating": "Rating:", @@ -214,7 +215,36 @@ "deleteModalTitle": "Are you sure you want to delete this address?", "submit": "Update address", "submitting": "In progress..." - }, + }, + "Wishlist": { + "noItems": "No items added", + "new": "New Wishlist", + "createTitle": "New wish list", + "inputLabel": "Wish list name", + "emptyName": "Provide wishlist name", + "submitFormText": "Create wishlist", + "onSubmitText": "Creating wishlist...", + "cancel": "Cancel", + "delete": "Delete", + "deleteTitle": "Are you sure you want to delete wish list {name}?", + "confirmDelete": "Delete {name}", + "favorites": "Favorites", + "noGalleryText": "Coming Soon", + "messages": { + "created": "Your wish list {name} was created successfully", + "deleted": "Your wish list {name} was deleted successfully" + }, + "Sheet": { + "title": "Add to list", + "new": "New list", + "save": "Save", + "saveToWishlist": "Save to wishlist", + "saved": "Saved", + "createTitle": "New wish list", + "selectCta": "Select from available lists:", + "favorites": "Favorites" + } + }, "Settings": { "emptyTextValidatoinMessage": "This field can not be empty", "cancel": "Cancel",