feat: implement wishlist feature with admin toggle

- Add WishlistController with full CRUD API
- Create wishlist page in My Account
- Add heart icon to all product card layouts (always visible)
- Implement useWishlist hook for state management
- Add wishlist toggle in admin Settings > Customer
- Fix wishlist menu visibility based on admin settings
- Fix double navigation in wishlist page
- Fix variable product navigation to use React Router
- Add TypeScript type casting fix for addresses
This commit is contained in:
Dwindi Ramadhana
2025-12-26 01:44:15 +07:00
parent 100f9cce55
commit 0b08ddefa1
9 changed files with 608 additions and 10 deletions

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { ShoppingCart, Heart } from 'lucide-react';
import { formatPrice, formatDiscount } from '@/lib/currency';
import { Button } from './ui/button';
import { useLayout } from '@/contexts/ThemeContext';
import { useShopSettings } from '@/hooks/useAppearanceSettings';
import { useWishlist } from '@/hooks/useWishlist';
interface ProductCardProps {
product: {
@@ -23,8 +24,18 @@ interface ProductCardProps {
}
export function ProductCard({ product, onAddToCart }: ProductCardProps) {
const navigate = useNavigate();
const { isClassic, isModern, isBoutique, isLaunch } = useLayout();
const { layout, elements, addToCart, saleBadge, isLoading } = useShopSettings();
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist } = useWishlist();
const inWishlist = wishlistEnabled && isInWishlist(product.id);
const handleWishlistClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
await toggleWishlist(product.id);
};
// Aspect ratio classes
const aspectRatioClass = {
@@ -41,7 +52,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
// Variable products need to go to product page for attribute selection
if (isVariable) {
window.location.href = `/product/${product.slug}`;
navigate(`/product/${product.slug}`);
return;
}
@@ -130,12 +141,22 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</div>
)}
{/* Quick Actions */}
<div className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="font-[inherit] p-2 bg-white rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center">
<Heart className="w-4 h-4 block" />
</button>
</div>
{/* Wishlist Button */}
{wishlistEnabled && (
<div className="absolute top-2 left-2 z-10">
<button
onClick={handleWishlistClick}
className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${
inWishlist ? 'bg-red-50' : 'bg-white'
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
>
<Heart className={`w-4 h-4 block transition-all ${
inWishlist ? 'fill-red-500 text-red-500' : ''
}`} />
</button>
</div>
)}
{/* Hover/Overlay Button */}
{showButtonOnHover && (
@@ -224,6 +245,23 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</div>
)}
{/* Wishlist Button */}
{wishlistEnabled && (
<div className="absolute top-4 right-4 z-10">
<button
onClick={handleWishlistClick}
className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${
inWishlist ? 'bg-red-50' : 'bg-white'
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
>
<Heart className={`w-4 h-4 block transition-all ${
inWishlist ? 'fill-red-500 text-red-500' : ''
}`} />
</button>
</div>
)}
{/* Hover Overlay - Only show if position is hover/overlay */}
{showButtonOnHover && (
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-300 flex items-center justify-center">
@@ -326,6 +364,23 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
{discount}
</div>
)}
{/* Wishlist Button */}
{wishlistEnabled && (
<div className="absolute top-6 left-6 z-10">
<button
onClick={handleWishlistClick}
className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${
inWishlist ? 'bg-red-50' : 'bg-white'
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
>
<Heart className={`w-4 h-4 block transition-all ${
inWishlist ? 'fill-red-500 text-red-500' : ''
}`} />
</button>
</div>
)}
</div>
{/* Content */}
@@ -383,6 +438,23 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
No Image
</div>
)}
{/* Wishlist Button */}
{wishlistEnabled && (
<div className="absolute top-3 right-3 z-10">
<button
onClick={handleWishlistClick}
className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${
inWishlist ? 'bg-red-50' : 'bg-white'
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
>
<Heart className={`w-4 h-4 block transition-all ${
inWishlist ? 'fill-red-500 text-red-500' : ''
}`} />
</button>
</div>
)}
</div>
<div className="p-4 text-center">

View File

@@ -0,0 +1,111 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api/client';
import { toast } from 'sonner';
interface WishlistItem {
product_id: number;
name: string;
slug: string;
price: string;
regular_price?: string;
sale_price?: string;
image?: string;
on_sale?: boolean;
stock_status?: string;
type?: string;
added_at: string;
}
export function useWishlist() {
const [items, setItems] = useState<WishlistItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [productIds, setProductIds] = useState<Set<number>>(new Set());
// Check if wishlist is enabled
const isEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
// Load wishlist on mount
useEffect(() => {
if (isEnabled && isLoggedIn) {
loadWishlist();
}
}, [isEnabled, isLoggedIn]);
const loadWishlist = useCallback(async () => {
if (!isLoggedIn) return;
try {
setIsLoading(true);
const data = await api.get<WishlistItem[]>('/account/wishlist');
setItems(data);
setProductIds(new Set(data.map(item => item.product_id)));
} catch (error) {
console.error('Failed to load wishlist:', error);
} finally {
setIsLoading(false);
}
}, [isLoggedIn]);
const addToWishlist = useCallback(async (productId: number) => {
if (!isLoggedIn) {
toast.error('Please login to add items to wishlist');
return false;
}
try {
await api.post('/account/wishlist', { product_id: productId });
await loadWishlist(); // Reload to get full product details
toast.success('Added to wishlist');
return true;
} catch (error: any) {
const message = error?.message || 'Failed to add to wishlist';
toast.error(message);
return false;
}
}, [isLoggedIn, loadWishlist]);
const removeFromWishlist = useCallback(async (productId: number) => {
if (!isLoggedIn) return false;
try {
await api.delete(`/account/wishlist/${productId}`);
setItems(items.filter(item => item.product_id !== productId));
setProductIds(prev => {
const newSet = new Set(prev);
newSet.delete(productId);
return newSet;
});
toast.success('Removed from wishlist');
return true;
} catch (error) {
toast.error('Failed to remove from wishlist');
return false;
}
}, [isLoggedIn, items]);
const toggleWishlist = useCallback(async (productId: number) => {
if (productIds.has(productId)) {
return await removeFromWishlist(productId);
} else {
return await addToWishlist(productId);
}
}, [productIds, addToWishlist, removeFromWishlist]);
const isInWishlist = useCallback((productId: number) => {
return productIds.has(productId);
}, [productIds]);
return {
items,
isLoading,
isEnabled,
isLoggedIn,
count: items.length,
addToWishlist,
removeFromWishlist,
toggleWishlist,
isInWishlist,
refresh: loadWishlist,
};
}

View File

@@ -0,0 +1,213 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Heart, ShoppingCart, Trash2, X } from 'lucide-react';
import { api } from '@/lib/api/client';
import { useCartStore } from '@/lib/cart/store';
import { Button } from '@/components/ui/button';
import { formatPrice } from '@/lib/currency';
import { toast } from 'sonner';
interface WishlistItem {
product_id: number;
name: string;
slug: string;
price: string;
regular_price?: string;
sale_price?: string;
image?: string;
on_sale?: boolean;
stock_status?: string;
type?: string;
added_at: string;
}
export default function Wishlist() {
const navigate = useNavigate();
const { addItem } = useCartStore();
const [items, setItems] = useState<WishlistItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadWishlist();
}, []);
const loadWishlist = async () => {
try {
const data = await api.get<WishlistItem[]>('/account/wishlist');
setItems(data);
} catch (error) {
console.error('Failed to load wishlist:', error);
toast.error('Failed to load wishlist');
} finally {
setLoading(false);
}
};
const handleRemove = async (productId: number) => {
try {
await api.delete(`/account/wishlist/${productId}`);
setItems(items.filter(item => item.product_id !== productId));
toast.success('Removed from wishlist');
} catch (error) {
console.error('Failed to remove from wishlist:', error);
toast.error('Failed to remove from wishlist');
}
};
const handleAddToCart = async (item: WishlistItem) => {
if (item.type === 'variable') {
navigate(`/product/${item.slug}`);
return;
}
try {
const response = await api.post('/cart/add', {
product_id: item.product_id,
quantity: 1,
});
toast.success('Added to cart');
} catch (error) {
console.error('Failed to add to cart:', error);
toast.error('Failed to add to cart');
}
};
const handleClearAll = async () => {
if (!confirm('Are you sure you want to clear your entire wishlist?')) {
return;
}
try {
await api.post('/account/wishlist/clear', {});
setItems([]);
toast.success('Wishlist cleared');
} catch (error) {
console.error('Failed to clear wishlist:', error);
toast.error('Failed to clear wishlist');
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">My Wishlist</h1>
<p className="text-gray-600 text-sm mt-1">
{items.length} {items.length === 1 ? 'item' : 'items'}
</p>
</div>
{items.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleClearAll}
className="text-red-600 hover:text-red-700 hover:border-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Clear All
</Button>
)}
</div>
{/* Empty State */}
{items.length === 0 ? (
<div className="text-center py-16 bg-white border rounded-lg">
<Heart className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<h2 className="text-xl font-semibold mb-2">Your wishlist is empty</h2>
<p className="text-gray-600 mb-6">
Save your favorite products to buy them later
</p>
<Button onClick={() => navigate('/shop')}>
Browse Products
</Button>
</div>
) : (
/* Wishlist Grid */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{items.map((item) => (
<div
key={item.product_id}
className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow group"
>
{/* Image */}
<div className="relative aspect-square bg-gray-100">
<button
onClick={() => handleRemove(item.product_id)}
className="absolute top-3 right-3 z-10 p-2 bg-white rounded-full shadow-md hover:bg-red-50 transition-colors"
title="Remove from wishlist"
>
<X className="w-4 h-4 text-red-600" />
</button>
<img
src={item.image || '/placeholder.png'}
alt={item.name}
className="w-full h-full object-cover cursor-pointer"
onClick={() => navigate(`/product/${item.slug}`)}
/>
{item.on_sale && (
<div className="absolute top-3 left-3 bg-red-600 text-white text-xs font-bold px-2 py-1 rounded">
SALE
</div>
)}
</div>
{/* Content */}
<div className="p-4">
<h3
className="font-medium text-gray-900 mb-2 line-clamp-2 cursor-pointer hover:text-primary transition-colors"
onClick={() => navigate(`/product/${item.slug}`)}
>
{item.name}
</h3>
{/* Price */}
<div className="flex items-center gap-2 mb-4">
{item.on_sale && item.regular_price ? (
<>
<span className="text-lg font-bold text-primary">
{formatPrice(item.sale_price || item.price)}
</span>
<span className="text-sm text-gray-500 line-through">
{formatPrice(item.regular_price)}
</span>
</>
) : (
<span className="text-lg font-bold text-gray-900">
{formatPrice(item.price)}
</span>
)}
</div>
{/* Actions */}
<Button
onClick={() => handleAddToCart(item)}
disabled={item.stock_status === 'outofstock'}
className="w-full"
size="sm"
>
<ShoppingCart className="w-4 h-4 mr-2" />
{item.stock_status === 'outofstock'
? 'Out of Stock'
: item.type === 'variable'
? 'Select Options'
: 'Add to Cart'}
</Button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import React, { ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, ShoppingBag, Download, MapPin, User, LogOut } from 'lucide-react';
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut } from 'lucide-react';
interface AccountLayoutProps {
children: ReactNode;
@@ -9,14 +9,21 @@ interface AccountLayoutProps {
export function AccountLayout({ children }: AccountLayoutProps) {
const location = useLocation();
const user = (window as any).woonoowCustomer?.user;
const wishlistEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
const menuItems = [
const allMenuItems = [
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
];
// Filter out wishlist if disabled
const menuItems = allMenuItems.filter(item =>
item.id !== 'wishlist' || wishlistEnabled
);
const handleLogout = () => {
window.location.href = '/wp-login.php?action=logout';

View File

@@ -7,6 +7,7 @@ import Orders from './Orders';
import OrderDetails from './OrderDetails';
import Downloads from './Downloads';
import Addresses from './Addresses';
import Wishlist from './Wishlist';
import AccountDetails from './AccountDetails';
export default function Account() {
@@ -27,6 +28,7 @@ export default function Account() {
<Route path="orders/:orderId" element={<OrderDetails />} />
<Route path="downloads" element={<Downloads />} />
<Route path="addresses" element={<Addresses />} />
<Route path="wishlist" element={<Wishlist />} />
<Route path="account-details" element={<AccountDetails />} />
<Route path="*" element={<Navigate to="/my-account" replace />} />
</Routes>