Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
import LegalTerms from "./pages/LegalTerms";
import LegalPrivacy from "./pages/LegalPrivacy";
import DevAdminLogin from "./components/DevAdminLogin";
import WishlistPage from "./pages/WishlistPage";

export default function App() {
const queryClient = new QueryClient();
Expand All @@ -54,6 +55,7 @@ export default function App() {
<Route path="/exercises" element={<NonAdminRoute><ExercisePage /></NonAdminRoute>} />
<Route path="/terms" element={<NonAdminRoute><LegalTerms /></NonAdminRoute>} />
<Route path="/privacy-policy" element={<NonAdminRoute><LegalPrivacy /></NonAdminRoute>} />
<Route path="/wishlist" element={<NonAdminRoute><WishlistPage /></NonAdminRoute>} />

{/* Admin routes (guarded) */}
{import.meta.env.MODE === 'development' && (
Expand Down
25 changes: 23 additions & 2 deletions client/src/components/Navbar.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useNavigate, useLocation, Link } from "react-router-dom";
import { signOut } from "firebase/auth";
import { auth } from "../auth/firebase";
import { useAuth } from "../auth/useAuth";
Expand All @@ -13,13 +13,13 @@ export default function Navbar({
menuOpen,
setMenuOpen,
onSignOut,
wishlistIds = new Set(),
}) {
const navigate = useNavigate();
const location = useLocation();
const { user, loading: authLoading } = useAuth();
const [localMenuOpen, setLocalMenuOpen] = useState(false);

// Treat tracker and notes as limited nav routes (no "Track Fitness" option)
const isLimitedNavRoute =
location?.pathname === "/profile" ||
location?.pathname === "/tracker" ||
Expand Down Expand Up @@ -92,6 +92,27 @@ export default function Navbar({
</button>
)}

{/* Wishlist icon β€” only shown for logged-in non-admin users */}
{user && (
<Link
to="/wishlist"
aria-label={`Wishlist, ${wishlistIds.size} item${wishlistIds.size !== 1 ? "s" : ""}`}
className={`relative p-2 transition-colors min-w-10 min-h-10 flex items-center justify-center rounded-full ${iconColor}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={1.8} viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{wishlistIds.size > 0 && (
<span
className="absolute top-0.5 right-0.5 bg-stone-900 text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center font-semibold"
aria-hidden
>
{wishlistIds.size}
</span>
)}
</Link>
)}

{onCartOpen && (
<button
type="button"
Expand Down
36 changes: 36 additions & 0 deletions client/src/components/WishlistButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Heart, Loader2 } from 'lucide-react';

export default function WishlistButton({
productId,
wishlistIds,
toggle,
loadingIds = new Set(),
className = ''
}) {
const isWishlisted = wishlistIds.has(productId);
const isLoading = loadingIds.has(productId);

return (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!isLoading) toggle(productId);
}}
disabled={isLoading}
className={`p-2 rounded-full transition-colors duration-200
${isWishlisted
? 'bg-stone-900 text-white'
: 'bg-white text-stone-400 hover:text-stone-900 border border-stone-200'
}
${isLoading ? 'opacity-60 cursor-not-allowed' : ''}
${className}`}
aria-label={isWishlisted ? 'Remove from wishlist' : 'Add to wishlist'}
>
{isLoading
? <Loader2 size={16} className="animate-spin" />
: <Heart size={16} fill={isWishlisted ? 'currentColor' : 'none'} strokeWidth={1.5} />
}
</button>
);
}
78 changes: 78 additions & 0 deletions client/src/hooks/useWishlist.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getAuthHeaders } from '../utils/getAuthHeaders';

const API = import.meta.env.VITE_API_URL;

export function useWishlist(user) {
const [wishlistIds, setWishlistIds] = useState(new Set());
const [loadingIds, setLoadingIds] = useState(new Set()); // per-item loading
const [error, setError] = useState(null);

// useRef to always have fresh wishlistIds without it being a dependency
const wishlistRef = useRef(wishlistIds);
useEffect(() => { wishlistRef.current = wishlistIds; }, [wishlistIds]);

useEffect(() => {
if (!user) return;
const fetchWishlist = async () => {
try {
const headers = await getAuthHeaders(user);
const res = await window.fetch(`${API}/api/wishlist/${user.uid}`, { headers });
if (!res.ok) throw new Error('Failed to fetch wishlist');
const data = await res.json();
const ids = new Set(data.items.map(p => p.productId));
setWishlistIds(ids);
} catch (err) {
setError('Could not load wishlist');
console.error(err);
}
};
fetchWishlist();
}, [user]);

const toggle = useCallback(async (productId) => {
if (!user) return;

// Read from ref β€” always fresh, no stale closure
const isWishlisted = wishlistRef.current.has(productId);

// Optimistic update
setWishlistIds(prev => {
const next = new Set(prev);
isWishlisted ? next.delete(productId) : next.add(productId);
return next;
});

// Per-item loading state
setLoadingIds(prev => new Set(prev).add(productId));

try {
const headers = await getAuthHeaders(user);
const endpoint = isWishlisted ? 'remove' : 'add';
const res = await window.fetch(`${API}/api/wishlist/${user.uid}/${endpoint}`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ productId }),
});
if (!res.ok) throw new Error('Request failed');
setError(null);
} catch (err) {
// Revert optimistic update
setWishlistIds(prev => {
const next = new Set(prev);
isWishlisted ? next.add(productId) : next.delete(productId);
return next;
});
setError('Could not update wishlist. Try again.');
console.error(err);
} finally {
setLoadingIds(prev => {
const next = new Set(prev);
next.delete(productId);
return next;
});
}
}, [user]); // ← only `user` as dependency now, not wishlistIds

return { wishlistIds, toggle, loadingIds, error };
}
19 changes: 16 additions & 3 deletions client/src/pages/HomePage.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

// src/pages/HomePage.jsx
import { useState, useEffect, useRef, useMemo, memo } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
Expand All @@ -19,6 +18,9 @@ import Stars from "../components/Stars";
import ProductCardSkeleton from "../components/ProductCardSkeleton";
import useInfiniteProducts from "../hooks/useInfiniteProducts";
import CategoryPillsSkeleton from "../components/CategoryPillsSkeleton";
import WishlistButton from '../components/WishlistButton';
import { useWishlist } from '../hooks/useWishlist';




Expand Down Expand Up @@ -47,7 +49,7 @@ function mapCart(cartDoc, products) {
}

// ── ProductCard ───────────────────────────────────────────────────────────
const ProductCard = memo(function ProductCard({ product, onAdd, cartItems = [], updateQty }) {
const ProductCard = memo(function ProductCard({ product, onAdd, cartItems = [], updateQty, wishlistIds, toggleWishlist }) {
const navigate = useNavigate();
const [added, setAdded] = useState(false);
const cartItem = cartItems.find(item => item.id === (product.productId || product.id));
Expand Down Expand Up @@ -101,6 +103,15 @@ const ProductCard = memo(function ProductCard({ product, onAdd, cartItems = [],
βˆ’{discount}%
</span>
)}
{wishlistIds && toggleWishlist && (
<div className="absolute top-2 right-2 sm:top-3 sm:right-3">
<WishlistButton
productId={productId}
wishlistIds={wishlistIds}
toggle={toggleWishlist}
/>
</div>
)}
</div>

<div className="p-3 sm:p-5 flex flex-col flex-1">
Expand Down Expand Up @@ -195,6 +206,7 @@ export default function HomePage() {
const [showAll, setShowAll] = useState(false);

const { showBanner, dismissBanner } = useWelcomeDiscount(user);
const { wishlistIds, toggle: toggleWishlist } = useWishlist(user);

useEffect(() => {
clearTimeout(debounceRef.current);
Expand Down Expand Up @@ -388,7 +400,7 @@ export default function HomePage() {
<div className={`fade-in d3 ${visible ? "show" : ""}
grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-5`}>
{displayedProducts.map(p => (
<ProductCard key={p.id} product={p} onAdd={addToCart} cartItems={cart} updateQty={updateQty} />
<ProductCard key={p.id} product={p} onAdd={addToCart} cartItems={cart} updateQty={updateQty} wishlistIds={wishlistIds} toggleWishlist={toggleWishlist} />
))}
</div>
);
Expand Down Expand Up @@ -445,6 +457,7 @@ export default function HomePage() {
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
onSignOut={handleSignOut}
wishlistIds={wishlistIds}
/>

{/* Hero banner */}
Expand Down
Loading
Loading