Skip to content

Commit

Permalink
[AC-312]: Improve Add-to-Cart Interaction (#110)
Browse files Browse the repository at this point in the history
* [AC-312]: Improve Add-to-Cart Interaction

* fix: add zustand

* feat: Open cart dropdown when product is added from product page

* fix: modify duration for auto-closing cart dropdown

* fix: adjust toast notification position
  • Loading branch information
KamilNahotko authored Dec 5, 2024
1 parent 2a6b864 commit 13b7e11
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 131 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
"unist-util-visit": "^5.0.0",
"validator": "^13.12.0",
"webpack": "^5",
"yup": "^1.4.0"
"yup": "^1.4.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@babel/core": "^7.17.5",
Expand Down
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function RootLayout(props: { children: React.ReactNode }) {
// disableTransitionOnChange
>
<ProgressBar />
<Toaster position="top-right" offset={65} closeButton />
<Toaster position="bottom-right" offset={65} closeButton />
<main className="relative">{props.children}</main>
</ThemeProvider>
</body>
Expand Down
21 changes: 21 additions & 0 deletions src/lib/store/useCartStore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { create } from 'zustand'

interface CartStore {
isOpenCartDropdown: boolean
openCartDropdown: () => void
closeCartDropdown: () => void
}

export const useCartStore = create<CartStore>((set) => {
return {
isOpenCartDropdown: false,
openCartDropdown: () =>
set(() => ({
isOpenCartDropdown: true,
})),
closeCartDropdown: () =>
set(() => ({
isOpenCartDropdown: false,
})),
}
})
19 changes: 2 additions & 17 deletions src/modules/layout/components/cart-button/index.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,9 @@
import { enrichLineItems, retrieveCart } from '@lib/data/cart'
import { retrieveCart } from '@lib/data/cart'

import CartDropdown from '../cart-dropdown'

const fetchCart = async () => {
const cart = await retrieveCart()

if (!cart) {
return null
}

if (cart?.items?.length) {
const enrichedItems = await enrichLineItems(cart.items, cart.region_id!)
cart.items = enrichedItems
}

return cart
}

export default async function CartButton() {
const cart = await fetchCart()
const cart = await retrieveCart()

return <CartDropdown cart={cart} />
}
219 changes: 110 additions & 109 deletions src/modules/layout/components/cart-dropdown/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client'

import React, { Fragment, useEffect, useRef, useState } from 'react'
import { usePathname } from 'next/navigation'
import { Fragment, useEffect, useState } from 'react'

import { Popover, Transition } from '@headlessui/react'
import { enrichLineItems } from '@lib/data/cart'
import { useCartStore } from '@lib/store/useCartStore'
import { convertToLocale } from '@lib/util/money'
import { HttpTypes } from '@medusajs/types'
import { Box } from '@modules/common/components/box'
Expand All @@ -16,68 +17,64 @@ import LocalizedClientLink from '@modules/common/components/localized-client-lin
import { Text } from '@modules/common/components/text'
import { BagIcon } from '@modules/common/icons/bag'
import Thumbnail from '@modules/products/components/thumbnail'
import SkeletonCartDropdownItems from '@modules/skeletons/components/skeleton-cart-dropdown-items'

const CartDropdown = ({
cart: cartState,
}: {
cart?: HttpTypes.StoreCart | null
}) => {
const [activeTimer, setActiveTimer] = useState<NodeJS.Timer | undefined>(
undefined
)
const [cartDropdownOpen, setCartDropdownOpen] = useState(false)

const open = () => setCartDropdownOpen(true)
const close = () => setCartDropdownOpen(false)

const totalItems =
cartState?.items?.reduce((acc, item) => {
return acc + item.quantity
}, 0) || 0
const { isOpenCartDropdown, openCartDropdown, closeCartDropdown } =
useCartStore()

const subtotal = cartState?.subtotal ?? 0
const itemRef = useRef<number>(totalItems || 0)
const [cart, setCart] = useState<HttpTypes.StoreCart | null>(cartState)
const [totalItems, setTotalItems] = useState(0)
const [isLoading, setIsLoading] = useState(false)

const timedOpen = () => {
open()

const timer = setTimeout(close, 5000)
useEffect(() => {
const fetchCart = async () => {
setIsLoading(true)
if (!cartState) {
return null
}

setActiveTimer(timer)
}
if (cartState?.items?.length) {
const enrichedItems = await enrichLineItems(
cartState.items,
cartState.region_id!
)
cartState.items = enrichedItems
}

const openAndCancel = () => {
if (activeTimer) {
clearTimeout(activeTimer)
setCart(cartState)
setTotalItems(
cartState.items?.reduce((acc, item) => {
return acc + item.quantity
}, 0) || 0
)
setIsLoading(false)
}

open()
}

// Clean up the timer when the component unmounts
useEffect(() => {
return () => {
if (activeTimer) {
clearTimeout(activeTimer)
}
}
}, [activeTimer])
fetchCart()
}, [cartState])

const pathname = usePathname()
const subtotal = cart?.subtotal ?? 0

// open cart dropdown when modifying the cart items, but only if we're not on the cart page
useEffect(() => {
if (itemRef.current !== totalItems && !pathname.includes('/cart')) {
timedOpen()
if (isOpenCartDropdown) {
const timer = setTimeout(() => {
closeCartDropdown()
}, 3000)

return () => clearTimeout(timer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [totalItems, itemRef.current])
}, [totalItems])

return (
<Box
className="z-50 h-full"
onMouseEnter={openAndCancel}
onMouseLeave={close}
onMouseEnter={openCartDropdown}
onMouseLeave={closeCartDropdown}
>
<Popover className="relative h-full">
<Popover.Button className="rounded-full bg-transparent !p-2 text-action-primary hover:bg-fg-secondary-hover hover:text-action-primary-hover active:bg-fg-secondary-pressed active:text-action-primary-pressed xsmall:!p-3.5">
Expand All @@ -91,7 +88,7 @@ const CartDropdown = ({
</LocalizedClientLink>
</Popover.Button>
<Transition
show={cartDropdownOpen}
show={isOpenCartDropdown}
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
Expand All @@ -110,79 +107,83 @@ const CartDropdown = ({
</Box>
{cartState && cartState.items?.length ? (
<>
<Box className="no-scrollbar grid max-h-[402px] grid-cols-1 gap-y-3 overflow-y-scroll overscroll-contain p-5">
{cartState.items
.sort((a, b) => {
return (a.created_at ?? '') > (b.created_at ?? '')
? -1
: 1
})
.map((item) => (
<Box
className="flex"
key={item.id}
data-testid="cart-item"
>
<LocalizedClientLink
href={`/products/${item.variant?.product?.handle}`}
{isLoading ? (
<SkeletonCartDropdownItems />
) : (
<Box className="no-scrollbar grid max-h-[402px] grid-cols-1 gap-y-3 overflow-y-scroll overscroll-contain p-5">
{cartState.items
.sort((a, b) => {
return (a.created_at ?? '') > (b.created_at ?? '')
? -1
: 1
})
.map((item) => (
<Box
className="flex"
key={item.id}
data-testid="cart-item"
>
<Thumbnail
thumbnail={item.variant?.product?.thumbnail}
images={item.variant?.product?.images}
size="square"
className="h-[90px] w-[80px] rounded-none"
/>
</LocalizedClientLink>
<Box className="flex w-full justify-between px-4 py-3">
<Box className="flex flex-1 flex-col justify-between">
<Box className="flex flex-1 flex-col">
<Box className="flex items-start justify-between">
<Box className="mr-4 flex w-[220px] flex-col">
<Box className="flex flex-col gap-1">
<h3 className="line-clamp-2 text-md font-medium">
<LocalizedClientLink
href={`/products/${item.variant?.product?.handle}`}
data-testid="product-link"
<LocalizedClientLink
href={`/products/${item.variant?.product?.handle}`}
>
<Thumbnail
thumbnail={item.variant?.product?.thumbnail}
images={item.variant?.product?.images}
size="square"
className="h-[90px] w-[80px] rounded-none"
/>
</LocalizedClientLink>
<Box className="flex w-full justify-between px-4 py-3">
<Box className="flex flex-1 flex-col justify-between">
<Box className="flex flex-1 flex-col">
<Box className="flex items-start justify-between">
<Box className="mr-4 flex w-[220px] flex-col">
<Box className="flex flex-col gap-1">
<h3 className="line-clamp-2 text-md font-medium">
<LocalizedClientLink
href={`/products/${item.variant?.product?.handle}`}
data-testid="product-link"
>
{item.product_title}
</LocalizedClientLink>
</h3>
<Box className="whitespace-nowrap">
<LineItemOptions
variant={item.variant}
data-testid="cart-item-variant"
data-value={item.variant}
/>
</Box>
<span
className="text-md text-secondary"
data-testid="cart-item-quantity"
data-value={item.quantity}
>
{item.product_title}
</LocalizedClientLink>
</h3>
<Box className="whitespace-nowrap">
<LineItemOptions
variant={item.variant}
data-testid="cart-item-variant"
data-value={item.variant}
{item.quantity}{' '}
{item.quantity > 1 ? 'items' : 'item'}
</span>
</Box>
<Box className="mt-3 flex">
<LineItemPrice
item={item}
style="tight"
isInCartDropdown
/>
</Box>
<span
className="text-md text-secondary"
data-testid="cart-item-quantity"
data-value={item.quantity}
>
{item.quantity}{' '}
{item.quantity > 1 ? 'items' : 'item'}
</span>
</Box>
<Box className="mt-3 flex">
<LineItemPrice
item={item}
style="tight"
isInCartDropdown
/>
</Box>
</Box>
</Box>
</Box>
</Box>

<DeleteButton
id={item.id}
data-testid="cart-item-remove-button"
/>
<DeleteButton
id={item.id}
data-testid="cart-item-remove-button"
/>
</Box>
</Box>
</Box>
))}
</Box>
))}
</Box>
)}
<Box className="text-small-regular flex flex-col gap-y-4 border-t-[0.5px] border-basic-primary p-5">
<Box className="flex items-center justify-between">
<Text className="text-md text-secondary">Total </Text>
Expand Down Expand Up @@ -215,7 +216,7 @@ const CartDropdown = ({
Are you looking for inspiration?
</Text>
</Box>
<Button onClick={close} asChild className="w-full">
<Button onClick={closeCartDropdown} asChild className="w-full">
<LocalizedClientLink href="/">
Explore Home page
</LocalizedClientLink>
Expand Down
10 changes: 8 additions & 2 deletions src/modules/products/components/product-actions/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client'

import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { useParams, usePathname } from 'next/navigation'

import { addToCart } from '@lib/data/cart'
import { useCartStore } from '@lib/store/useCartStore'
import { HttpTypes } from '@medusajs/types'
import ItemQtySelect from '@modules/cart/components/item-qty-select'
import { Box } from '@modules/common/components/box'
Expand Down Expand Up @@ -40,6 +41,7 @@ export default function ProductActions({
colors,
disabled,
}: ProductActionsProps) {
const { openCartDropdown } = useCartStore()
const actionsRef = useRef<HTMLDivElement>(null)
const [qty, setQty] = useState(1)
const [options, setOptions] = useState<Record<string, string | undefined>>({})
Expand Down Expand Up @@ -68,7 +70,11 @@ export default function ProductActions({
} catch (error) {
toast('error', error)
} finally {
toast('success', 'Product was added to cart!')
setTimeout(() => {
openCartDropdown()
toast('success', 'Product was added to cart!')
}, 1000)

setIsAdding(false)
}
}
Expand Down
Loading

0 comments on commit 13b7e11

Please sign in to comment.