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:
@@ -13,6 +13,7 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
|||||||
interface CustomerSettings {
|
interface CustomerSettings {
|
||||||
auto_register_members: boolean;
|
auto_register_members: boolean;
|
||||||
multiple_addresses_enabled: boolean;
|
multiple_addresses_enabled: boolean;
|
||||||
|
wishlist_enabled: boolean;
|
||||||
vip_min_spent: number;
|
vip_min_spent: number;
|
||||||
vip_min_orders: number;
|
vip_min_orders: number;
|
||||||
vip_timeframe: 'all' | '30' | '90' | '365';
|
vip_timeframe: 'all' | '30' | '90' | '365';
|
||||||
@@ -24,6 +25,7 @@ export default function CustomersSettings() {
|
|||||||
const [settings, setSettings] = useState<CustomerSettings>({
|
const [settings, setSettings] = useState<CustomerSettings>({
|
||||||
auto_register_members: false,
|
auto_register_members: false,
|
||||||
multiple_addresses_enabled: true,
|
multiple_addresses_enabled: true,
|
||||||
|
wishlist_enabled: true,
|
||||||
vip_min_spent: 1000,
|
vip_min_spent: 1000,
|
||||||
vip_min_orders: 10,
|
vip_min_orders: 10,
|
||||||
vip_timeframe: 'all',
|
vip_timeframe: 'all',
|
||||||
@@ -137,6 +139,14 @@ export default function CustomersSettings() {
|
|||||||
checked={settings.multiple_addresses_enabled}
|
checked={settings.multiple_addresses_enabled}
|
||||||
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ToggleField
|
||||||
|
id="wishlist_enabled"
|
||||||
|
label={__('Enable wishlist')}
|
||||||
|
description={__('Allow customers to save products to their wishlist for later purchase. Customers can add products to wishlist from product cards and manage them in their account.')}
|
||||||
|
checked={settings.wishlist_enabled}
|
||||||
|
onCheckedChange={(checked) => setSettings({ ...settings, wishlist_enabled: checked })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { ShoppingCart, Heart } from 'lucide-react';
|
import { ShoppingCart, Heart } from 'lucide-react';
|
||||||
import { formatPrice, formatDiscount } from '@/lib/currency';
|
import { formatPrice, formatDiscount } from '@/lib/currency';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { useLayout } from '@/contexts/ThemeContext';
|
import { useLayout } from '@/contexts/ThemeContext';
|
||||||
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
||||||
|
import { useWishlist } from '@/hooks/useWishlist';
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: {
|
product: {
|
||||||
@@ -23,8 +24,18 @@ interface ProductCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { isClassic, isModern, isBoutique, isLaunch } = useLayout();
|
const { isClassic, isModern, isBoutique, isLaunch } = useLayout();
|
||||||
const { layout, elements, addToCart, saleBadge, isLoading } = useShopSettings();
|
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
|
// Aspect ratio classes
|
||||||
const aspectRatioClass = {
|
const aspectRatioClass = {
|
||||||
@@ -41,7 +52,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
|
|
||||||
// Variable products need to go to product page for attribute selection
|
// Variable products need to go to product page for attribute selection
|
||||||
if (isVariable) {
|
if (isVariable) {
|
||||||
window.location.href = `/product/${product.slug}`;
|
navigate(`/product/${product.slug}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,12 +141,22 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Wishlist Button */}
|
||||||
<div className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
{wishlistEnabled && (
|
||||||
<button className="font-[inherit] p-2 bg-white rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center">
|
<div className="absolute top-2 left-2 z-10">
|
||||||
<Heart className="w-4 h-4 block" />
|
<button
|
||||||
</button>
|
onClick={handleWishlistClick}
|
||||||
</div>
|
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 */}
|
{/* Hover/Overlay Button */}
|
||||||
{showButtonOnHover && (
|
{showButtonOnHover && (
|
||||||
@@ -224,6 +245,23 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
</div>
|
</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 */}
|
{/* Hover Overlay - Only show if position is hover/overlay */}
|
||||||
{showButtonOnHover && (
|
{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">
|
<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}
|
{discount}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -383,6 +438,23 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
No Image
|
No Image
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="p-4 text-center">
|
<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 React, { ReactNode } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
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 {
|
interface AccountLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -9,15 +9,22 @@ interface AccountLayoutProps {
|
|||||||
export function AccountLayout({ children }: AccountLayoutProps) {
|
export function AccountLayout({ children }: AccountLayoutProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
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: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
|
||||||
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
|
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
|
||||||
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
|
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
|
||||||
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
{ 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 },
|
{ 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 = () => {
|
const handleLogout = () => {
|
||||||
window.location.href = '/wp-login.php?action=logout';
|
window.location.href = '/wp-login.php?action=logout';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Orders from './Orders';
|
|||||||
import OrderDetails from './OrderDetails';
|
import OrderDetails from './OrderDetails';
|
||||||
import Downloads from './Downloads';
|
import Downloads from './Downloads';
|
||||||
import Addresses from './Addresses';
|
import Addresses from './Addresses';
|
||||||
|
import Wishlist from './Wishlist';
|
||||||
import AccountDetails from './AccountDetails';
|
import AccountDetails from './AccountDetails';
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
@@ -27,6 +28,7 @@ export default function Account() {
|
|||||||
<Route path="orders/:orderId" element={<OrderDetails />} />
|
<Route path="orders/:orderId" element={<OrderDetails />} />
|
||||||
<Route path="downloads" element={<Downloads />} />
|
<Route path="downloads" element={<Downloads />} />
|
||||||
<Route path="addresses" element={<Addresses />} />
|
<Route path="addresses" element={<Addresses />} />
|
||||||
|
<Route path="wishlist" element={<Wishlist />} />
|
||||||
<Route path="account-details" element={<AccountDetails />} />
|
<Route path="account-details" element={<AccountDetails />} />
|
||||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use WooNooW\Frontend\ShopController;
|
|||||||
use WooNooW\Frontend\CartController as FrontendCartController;
|
use WooNooW\Frontend\CartController as FrontendCartController;
|
||||||
use WooNooW\Frontend\AccountController;
|
use WooNooW\Frontend\AccountController;
|
||||||
use WooNooW\Frontend\AddressController;
|
use WooNooW\Frontend\AddressController;
|
||||||
|
use WooNooW\Frontend\WishlistController;
|
||||||
use WooNooW\Frontend\HookBridge;
|
use WooNooW\Frontend\HookBridge;
|
||||||
use WooNooW\Api\Controllers\SettingsController;
|
use WooNooW\Api\Controllers\SettingsController;
|
||||||
use WooNooW\Api\Controllers\CartController as ApiCartController;
|
use WooNooW\Api\Controllers\CartController as ApiCartController;
|
||||||
@@ -127,6 +128,7 @@ class Routes {
|
|||||||
FrontendCartController::register_routes();
|
FrontendCartController::register_routes();
|
||||||
AccountController::register_routes();
|
AccountController::register_routes();
|
||||||
AddressController::register_routes();
|
AddressController::register_routes();
|
||||||
|
WishlistController::register_routes();
|
||||||
HookBridge::register_routes();
|
HookBridge::register_routes();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class CustomerSettingsProvider {
|
|||||||
// General
|
// General
|
||||||
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
|
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
|
||||||
'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes',
|
'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes',
|
||||||
|
'wishlist_enabled' => get_option('woonoow_wishlist_enabled', 'yes') === 'yes',
|
||||||
|
|
||||||
// VIP Customer Qualification
|
// VIP Customer Qualification
|
||||||
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
|
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
|
||||||
@@ -49,6 +50,10 @@ class CustomerSettingsProvider {
|
|||||||
$updated = $updated && update_option('woonoow_multiple_addresses_enabled', $settings['multiple_addresses_enabled'] ? 'yes' : 'no');
|
$updated = $updated && update_option('woonoow_multiple_addresses_enabled', $settings['multiple_addresses_enabled'] ? 'yes' : 'no');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($settings['wishlist_enabled'])) {
|
||||||
|
$updated = $updated && update_option('woonoow_wishlist_enabled', $settings['wishlist_enabled'] ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
|
||||||
// VIP settings
|
// VIP settings
|
||||||
if (isset($settings['vip_min_spent'])) {
|
if (isset($settings['vip_min_spent'])) {
|
||||||
$updated = $updated && update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent']));
|
$updated = $updated && update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent']));
|
||||||
|
|||||||
176
includes/Frontend/WishlistController.php
Normal file
176
includes/Frontend/WishlistController.php
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
namespace WooNooW\Frontend;
|
||||||
|
|
||||||
|
use WP_REST_Request;
|
||||||
|
use WP_REST_Response;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
class WishlistController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register REST API routes
|
||||||
|
*/
|
||||||
|
public static function register_routes() {
|
||||||
|
$namespace = 'woonoow/v1';
|
||||||
|
|
||||||
|
// Get wishlist
|
||||||
|
register_rest_route($namespace, '/account/wishlist', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_wishlist'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add to wishlist
|
||||||
|
register_rest_route($namespace, '/account/wishlist', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'add_to_wishlist'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||||
|
'args' => [
|
||||||
|
'product_id' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'integer',
|
||||||
|
'sanitize_callback' => 'absint',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Remove from wishlist
|
||||||
|
register_rest_route($namespace, '/account/wishlist/(?P<product_id>\d+)', [
|
||||||
|
'methods' => 'DELETE',
|
||||||
|
'callback' => [__CLASS__, 'remove_from_wishlist'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Clear wishlist
|
||||||
|
register_rest_route($namespace, '/account/wishlist/clear', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'clear_wishlist'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is logged in
|
||||||
|
*/
|
||||||
|
public static function check_permission() {
|
||||||
|
return is_user_logged_in();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get wishlist items with product details
|
||||||
|
*/
|
||||||
|
public static function get_wishlist(WP_REST_Request $request) {
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$wishlist = get_user_meta($user_id, 'woonoow_wishlist', true);
|
||||||
|
|
||||||
|
if (!$wishlist || !is_array($wishlist)) {
|
||||||
|
return new WP_REST_Response([], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($wishlist as $item) {
|
||||||
|
$product_id = $item['product_id'];
|
||||||
|
$product = wc_get_product($product_id);
|
||||||
|
|
||||||
|
if (!$product) {
|
||||||
|
continue; // Skip if product doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'product_id' => $product_id,
|
||||||
|
'name' => $product->get_name(),
|
||||||
|
'slug' => $product->get_slug(),
|
||||||
|
'price' => $product->get_price(),
|
||||||
|
'regular_price'=> $product->get_regular_price(),
|
||||||
|
'sale_price' => $product->get_sale_price(),
|
||||||
|
'image' => wp_get_attachment_url($product->get_image_id()),
|
||||||
|
'on_sale' => $product->is_on_sale(),
|
||||||
|
'stock_status' => $product->get_stock_status(),
|
||||||
|
'type' => $product->get_type(),
|
||||||
|
'added_at' => $item['added_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response($items, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add product to wishlist
|
||||||
|
*/
|
||||||
|
public static function add_to_wishlist(WP_REST_Request $request) {
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$product_id = $request->get_param('product_id');
|
||||||
|
|
||||||
|
// Validate product exists
|
||||||
|
$product = wc_get_product($product_id);
|
||||||
|
if (!$product) {
|
||||||
|
return new WP_Error('invalid_product', 'Product not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$wishlist = get_user_meta($user_id, 'woonoow_wishlist', true);
|
||||||
|
if (!is_array($wishlist)) {
|
||||||
|
$wishlist = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already in wishlist
|
||||||
|
foreach ($wishlist as $item) {
|
||||||
|
if ($item['product_id'] === $product_id) {
|
||||||
|
return new WP_Error('already_in_wishlist', 'Product already in wishlist', ['status' => 400]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to wishlist
|
||||||
|
$wishlist[] = [
|
||||||
|
'product_id' => $product_id,
|
||||||
|
'added_at' => current_time('mysql'),
|
||||||
|
];
|
||||||
|
|
||||||
|
update_user_meta($user_id, 'woonoow_wishlist', $wishlist);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'message' => 'Product added to wishlist',
|
||||||
|
'count' => count($wishlist),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove product from wishlist
|
||||||
|
*/
|
||||||
|
public static function remove_from_wishlist(WP_REST_Request $request) {
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$product_id = (int) $request->get_param('product_id');
|
||||||
|
|
||||||
|
$wishlist = get_user_meta($user_id, 'woonoow_wishlist', true);
|
||||||
|
if (!is_array($wishlist)) {
|
||||||
|
return new WP_Error('empty_wishlist', 'Wishlist is empty', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove product from wishlist
|
||||||
|
$wishlist = array_filter($wishlist, function($item) use ($product_id) {
|
||||||
|
return $item['product_id'] !== $product_id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-index array
|
||||||
|
$wishlist = array_values($wishlist);
|
||||||
|
|
||||||
|
update_user_meta($user_id, 'woonoow_wishlist', $wishlist);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'message' => 'Product removed from wishlist',
|
||||||
|
'count' => count($wishlist),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear entire wishlist
|
||||||
|
*/
|
||||||
|
public static function clear_wishlist(WP_REST_Request $request) {
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
delete_user_meta($user_id, 'woonoow_wishlist');
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'message' => 'Wishlist cleared',
|
||||||
|
'count' => 0,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user