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:
@@ -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">
|
||||
|
||||
111
customer-spa/src/hooks/useWishlist.ts
Normal file
111
customer-spa/src/hooks/useWishlist.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
213
customer-spa/src/pages/Account/Wishlist.tsx
Normal file
213
customer-spa/src/pages/Account/Wishlist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user