Skip to content

Commit

Permalink
Wishlist functionality mods (#167)
Browse files Browse the repository at this point in the history
* Product detail wishlist functionality

* update product detail wishlist logic

* wishlist on product list page functionality

* Update wishlist logic to global store

* Check if user is authenticated

* Tooltip please log in to use this functionality

* Wishlist functionality

* Style enhancements

* Change metadata title

* wishlist enhancements

* wishlist page enhancements

* remove unused component

* refactor wishlist on products listing

* remove unused actions

* remove unused store

* refactor wishlist check for product

* fix for non logged users

* possible cache fix

* wishlist enhancements

* switch wishlist to client side rendering

* remove unused code

* switch wishlist to client side rendering

* fixed loading wishlist bug

* fixed loading wishlist bug

* fixed wishlist page

* Add isAuthenticated prop to ProductHighlight

---------

Co-authored-by: Felipe Cabrera <felipemartincabrera@gmail.com>
Co-authored-by: JoaquinEtchegaray <etchegarayjoaco@gmail.com>
  • Loading branch information
3 people authored Jun 20, 2023
1 parent 769f527 commit 79c2cd6
Show file tree
Hide file tree
Showing 24 changed files with 405 additions and 80 deletions.
35 changes: 20 additions & 15 deletions app/_components/Globals/ProductCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@ import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';

import { FaRegHeart } from 'react-icons/fa';
import ProductWishlist from './ProductWishlist';

import Button from '~/_components/Button';
import Tooltip from '~/_components/Globals/Tooltip';

import { formatCurrency } from '~/_utils/numbers';

interface Props {
product: Product;
isAuthenticated?: boolean;
isWishlistCard?: boolean;
}

const ProductCard = ({ product }: Props) => {
const ProductCard = ({ product, isAuthenticated, isWishlistCard }: Props) => {
const [isHovered, setIsHovered] = useState(false);

const image = product.images?.[0] || { src: '', alt: 'Not Found' };

return (
Expand All @@ -29,18 +31,21 @@ const ProductCard = ({ product }: Props) => {
}`}
>
<div className="px-5 py-4">
<div className="flex justify-end">
<Tooltip content="Feature coming soon!">
<div>
<FaRegHeart
className={`cursor-pointer mb-3 transition-all duration-300 hover:text-red-500 ${
isHovered ? 'md:-translate-x-0' : 'md:opacity-0 md:translate-x-3'
}`}
/>
</div>
</Tooltip>
</div>
<div className="flex mx-auto cursor-pointer relative max-w-full max-h-full h-[436px]">
{!isWishlistCard && (
<div className="flex justify-end h-8">
<ProductWishlist
product={product}
isAuthenticated={isAuthenticated}
isHovered={isHovered}
/>
</div>
)}

<div
className={`flex mx-auto cursor-pointer relative max-w-full max-h-full ${
isWishlistCard ? 'h-[200px]' : 'h-[436px]'
}`}
>
<Link href={`/products/${product.slug}`} data-cy="product-link">
<Image src={image.src} alt={image.alt} fill style={{ objectFit: 'cover' }} />
</Link>
Expand Down
55 changes: 38 additions & 17 deletions app/_components/Globals/ProductList.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,50 @@
'use client';

import { useEffect } from 'react';

import ProductCard from './ProductCard';

import useFetch from '~/_hooks/useFetch';
import { useWishlistState } from '~/_hooks/useStore';

import Container from '~/_layouts/Container';

interface Props {
relatedProducts?: boolean;
threeColumns?: boolean;
products?: Product[];
isAuthenticated?: boolean;
}

const ProductList = ({ relatedProducts, threeColumns, products }: Props) => (
<Container className="mb-10">
<div
className={`grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 ${
threeColumns
? 'lg:grid-cols-3'
: relatedProducts
? 'lg:flex justify-center'
: 'lg:grid-cols-4'
} gap-y-4 justify-items-center`}
>
{products?.map((product, i) => (
<ProductCard product={product} key={`card-${i}`} />
))}
</div>
</Container>
);
const ProductList = ({ relatedProducts, threeColumns, products, isAuthenticated }: Props) => {
const { setWishlist } = useWishlistState();

/** Get user wishlist */
const wishlistUrl = isAuthenticated ? '/api/wishlist/' : null;
const { data } = useFetch<{ wishlist: string[] }>(wishlistUrl);

/** Once wishlist is retrieved, set it to the store */
useEffect(() => {
if (data) setWishlist(data.wishlist);
}, [data, setWishlist]);

return (
<Container className="mb-10">
<div
className={`grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 ${
threeColumns
? 'lg:grid-cols-3'
: relatedProducts
? 'lg:flex justify-center'
: 'lg:grid-cols-4'
} gap-y-4 justify-items-center`}
>
{products?.map((product, i) => (
<ProductCard key={`card-${i}`} product={product} isAuthenticated={isAuthenticated} />
))}
</div>
</Container>
);
};

export default ProductList;
57 changes: 57 additions & 0 deletions app/_components/Globals/ProductWishlist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useState } from 'react';
import { FaRegHeart } from 'react-icons/fa';

import { Spinner } from '~/_components/Globals/Spinner';
import Tooltip from '~/_components/Globals/Tooltip';
import { useWishlistState } from '~/_hooks/useStore';

type Props = {
product: Product;
isAuthenticated?: boolean;
isHovered?: boolean;
};

export default function ProductWishlist({ product, isAuthenticated, isHovered }: Props) {
const { wishlist, setWishlist } = useWishlistState();

const [isWishlistLoading, setIsWishlistLoading] = useState(false);

/*****************************************************************************
* Toggle product from wishlist
****************************************************************************/
const handleToggleWishlist = async () => {
setIsWishlistLoading(true);
const wishlistReq = await fetch(`/api/wishlist/${product.id}`, { method: 'PUT' });
const { wishlist } = (await wishlistReq.json()) as { wishlist: string[] };
setWishlist(wishlist);
setIsWishlistLoading(false);
};

if (isWishlistLoading) {
return (
<span className="mb-3">
<Spinner size={4} />
</span>
);
}

return (
<Tooltip
content="Please log in to use this functionality"
className={`${isAuthenticated ? 'hidden' : ''}`}
>
<button
onClick={() => {
isAuthenticated && handleToggleWishlist();
}}
>
<FaRegHeart
className={`cursor-pointer mb-3 transition-all duration-300 hover:text-red-500
${wishlist?.includes(product.id) ? 'text-red-500' : ''}
${isHovered ? 'md:-translate-x-0' : 'md:opacity-0 md:translate-x-3'}
`}
/>
</button>
</Tooltip>
);
}
5 changes: 3 additions & 2 deletions app/_components/Globals/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
interface SpinnerProp {
position?: 'center' | 'left' | 'right';
size?: 4 | 5 | 6 | 8 | 10;
className?: string;
}

export const Spinner = ({ position = 'center', size = 10 }: SpinnerProp) => {
export const Spinner = ({ position = 'center', size = 10, className }: SpinnerProp) => {
return (
<div className={`text-${position}`}>
<div className={`text-${position} ${className ? className : ''}`}>
<div role="status">
<svg
className={`inline mr-2 w-${size} h-${size} text-gray-medium animate-spin dark:text-gray-dark fill-gray`}
Expand Down
12 changes: 10 additions & 2 deletions app/_components/Globals/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@ import 'tippy.js/dist/tippy.css';
interface Props {
content: string;
children: React.ReactElement;
className?: string;
}

const Tooltip = ({ children, content }: Props) => {
const Tooltip = ({ children, content, className }: Props) => {
return (
<Tippy hideOnClick={false} arrow content={content} trigger="mouseenter" maxWidth={200}>
<Tippy
hideOnClick={false}
arrow
content={content}
trigger="mouseenter"
maxWidth={200}
className={className}
>
{children}
</Tippy>
);
Expand Down
2 changes: 1 addition & 1 deletion app/_components/Home/ProductHighlight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const ProductHighlight = ({ products, title }: ProductHighlightProps) => {
<h5 className="text-2xl py-4 uppercase">{title}</h5>
</div>

<ProductList threeColumns products={randomProducts} />
<ProductList threeColumns products={randomProducts} isAuthenticated />

<div className="w-full flex justify-center my-10">
<Button
Expand Down
2 changes: 2 additions & 0 deletions app/_components/Navbar/MobileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const MobileMenu = ({
>
<ul aria-labelledby="navbarMenu">
{categories.map((category, i) => {
if (!isAuthenticated && category.name === 'WISHLIST') return null;

return (
<li key={`mobile-menu-category-${i}`}>
<Link
Expand Down
14 changes: 12 additions & 2 deletions app/_components/Navbar/UserButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from 'next/link';
import { FaSearch, FaShoppingCart, FaUser } from 'react-icons/fa';

import { FaHeart, FaSearch, FaShoppingCart, FaUser } from 'react-icons/fa';

import { Badge } from '~/_components/Globals/Badge';

Expand All @@ -24,6 +25,16 @@ const UserButtons = ({ toggleCart, isAuthenticated }: Props) => {
<FaSearch />
</Link>

<a
href="/account/wishlist"
title="Wishlist"
className={`text-black border-2 border-black self-center rounded-full px-2.5 py-2.5 hidden transition-all duration-300 hover:bg-black hover:text-white active:bg-black active:text-white ${
isAuthenticated ? 'lg:block' : 'lg:hidden'
}`}
>
<FaHeart />
</a>

<button
type="button"
className="relative text-black border-2 border-black self-center rounded-full px-2.5 py-2.5 hidden transition-all duration-300 lg:block hover:bg-black hover:text-white active:bg-black active:text-white"
Expand All @@ -33,7 +44,6 @@ const UserButtons = ({ toggleCart, isAuthenticated }: Props) => {
<FaShoppingCart data-cy="cart-icon" />
{typeof quantity == 'number' && quantity > 0 && <Badge itemsQuantity={quantity} />}
</button>

<Link
href={`/account/${isAuthenticated ? 'orders' : 'login'}`}
title={isAuthenticated ? 'My Orders' : 'Login'}
Expand Down
1 change: 1 addition & 0 deletions app/_data/partials.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
{ "name": "CONTACT", "slug": "contact", "hideOnDesktop": false },
{ "name": "SEARCH", "slug": "products", "hideOnDesktop": true },
{ "name": "CART", "slug": "", "hideOnDesktop": true },
{ "name": "WISHLIST", "slug": "account/wishlist", "hideOnDesktop": true },
{ "name": "LOGIN", "slug": "account/login", "hideOnDesktop": true }
]
}
43 changes: 43 additions & 0 deletions app/_hooks/useFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useState, useEffect } from 'react';

type State<T> = {
data: T | null;
loading: boolean;
error: Error | null;
};

const useFetch = <T>(url: string | null): State<T> => {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
const fetchResource = async () => {
setLoading(true);
setError(null);

try {
if (!url) return;

const response = await fetch(url);
if (!response.ok) throw new Error('An error occurred while fetching data.');

const result = (await response.json()) as T;

setData(result);
} catch (e) {
setError(e instanceof Error ? e : new Error('An unexpected error occurred.'));
} finally {
setLoading(false);
}
};

fetchResource().catch((e) =>
setError(e instanceof Error ? e : new Error('An unexpected error occurred.'))
);
}, [url]);

return { data, loading, error };
};

export default useFetch;
11 changes: 10 additions & 1 deletion app/_hooks/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export function useGlobalState() {
/*****************************************************************************
* Define and declare global state to be used on products
****************************************************************************/

type StateProduct = {
chosenOptions: { [key: string]: string };
};
Expand All @@ -40,6 +39,16 @@ export function useProductState(): {
return { productState, updateProductProp };
}

/*****************************************************************************
* Global state used for wishlist
****************************************************************************/
const stateWishlist = atom<string[] | null>(null);

export function useWishlistState() {
const [wishlist, setWishlist] = useAtom(stateWishlist);
return { wishlist, setWishlist };
}

/*****************************************************************************
* Global state used for UI
****************************************************************************/
Expand Down
1 change: 1 addition & 0 deletions app/_layouts/AccountLayout/AccountLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const AccountLayout = ({ account, children }: Props) => {
pathname="/account/payments"
label="Payment methods"
/>
<AccountLink href="/account/wishlist" pathname="/account/wishlist" label="Wishlist" />
</div>
<LogOutModal />
</div>
Expand Down
4 changes: 2 additions & 2 deletions app/_lib/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class Store {
salePrice: product.sale_price || null,
sku: product.sku || null,
images: this.transformImages(product),
categories: product.category_index.id
categories: product.category_index?.id
};
}

Expand Down Expand Up @@ -137,7 +137,7 @@ class Store {
* Convert SwellProduct variants to a Product variants format
****************************************************************************/
transformProductVariants(product: SwellProduct) {
return product.variants.results.map((variant) => ({
return product.variants?.results?.map((variant) => ({
name: variant.name,
active: variant.active,
value_ids: variant.option_value_ids
Expand Down
Loading

0 comments on commit 79c2cd6

Please sign in to comment.