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

@@ -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>