diff --git a/admin-spa/src/routes/Settings/Customers.tsx b/admin-spa/src/routes/Settings/Customers.tsx index 5d492bc..041d79c 100644 --- a/admin-spa/src/routes/Settings/Customers.tsx +++ b/admin-spa/src/routes/Settings/Customers.tsx @@ -13,6 +13,7 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency'; interface CustomerSettings { auto_register_members: boolean; multiple_addresses_enabled: boolean; + wishlist_enabled: boolean; vip_min_spent: number; vip_min_orders: number; vip_timeframe: 'all' | '30' | '90' | '365'; @@ -24,6 +25,7 @@ export default function CustomersSettings() { const [settings, setSettings] = useState({ auto_register_members: false, multiple_addresses_enabled: true, + wishlist_enabled: true, vip_min_spent: 1000, vip_min_orders: 10, vip_timeframe: 'all', @@ -137,6 +139,14 @@ export default function CustomersSettings() { checked={settings.multiple_addresses_enabled} onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })} /> + + setSettings({ ...settings, wishlist_enabled: checked })} + /> diff --git a/customer-spa/src/components/ProductCard.tsx b/customer-spa/src/components/ProductCard.tsx index 2d9f1d4..c221002 100644 --- a/customer-spa/src/components/ProductCard.tsx +++ b/customer-spa/src/components/ProductCard.tsx @@ -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) { )} - {/* Quick Actions */} -
- -
+ {/* Wishlist Button */} + {wishlistEnabled && ( +
+ +
+ )} {/* Hover/Overlay Button */} {showButtonOnHover && ( @@ -224,6 +245,23 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { )} + {/* Wishlist Button */} + {wishlistEnabled && ( +
+ +
+ )} + {/* Hover Overlay - Only show if position is hover/overlay */} {showButtonOnHover && (
@@ -326,6 +364,23 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { {discount}
)} + + {/* Wishlist Button */} + {wishlistEnabled && ( +
+ +
+ )} {/* Content */} @@ -383,6 +438,23 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { No Image )} + + {/* Wishlist Button */} + {wishlistEnabled && ( +
+ +
+ )}
diff --git a/customer-spa/src/hooks/useWishlist.ts b/customer-spa/src/hooks/useWishlist.ts new file mode 100644 index 0000000..c146b8f --- /dev/null +++ b/customer-spa/src/hooks/useWishlist.ts @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [productIds, setProductIds] = useState>(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('/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, + }; +} diff --git a/customer-spa/src/pages/Account/Wishlist.tsx b/customer-spa/src/pages/Account/Wishlist.tsx new file mode 100644 index 0000000..d460f6f --- /dev/null +++ b/customer-spa/src/pages/Account/Wishlist.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadWishlist(); + }, []); + + const loadWishlist = async () => { + try { + const data = await api.get('/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 ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

My Wishlist

+

+ {items.length} {items.length === 1 ? 'item' : 'items'} +

+
+ {items.length > 0 && ( + + )} +
+ + {/* Empty State */} + {items.length === 0 ? ( +
+ +

Your wishlist is empty

+

+ Save your favorite products to buy them later +

+ +
+ ) : ( + /* Wishlist Grid */ +
+ {items.map((item) => ( +
+ {/* Image */} +
+ + + {item.name} navigate(`/product/${item.slug}`)} + /> + + {item.on_sale && ( +
+ SALE +
+ )} +
+ + {/* Content */} +
+

navigate(`/product/${item.slug}`)} + > + {item.name} +

+ + {/* Price */} +
+ {item.on_sale && item.regular_price ? ( + <> + + {formatPrice(item.sale_price || item.price)} + + + {formatPrice(item.regular_price)} + + + ) : ( + + {formatPrice(item.price)} + + )} +
+ + {/* Actions */} + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/customer-spa/src/pages/Account/components/AccountLayout.tsx b/customer-spa/src/pages/Account/components/AccountLayout.tsx index 74d2dc9..5602bb0 100644 --- a/customer-spa/src/pages/Account/components/AccountLayout.tsx +++ b/customer-spa/src/pages/Account/components/AccountLayout.tsx @@ -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'; diff --git a/customer-spa/src/pages/Account/index.tsx b/customer-spa/src/pages/Account/index.tsx index 8199dab..a32b20e 100644 --- a/customer-spa/src/pages/Account/index.tsx +++ b/customer-spa/src/pages/Account/index.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/includes/Api/Routes.php b/includes/Api/Routes.php index a368537..b2c6f55 100644 --- a/includes/Api/Routes.php +++ b/includes/Api/Routes.php @@ -25,6 +25,7 @@ use WooNooW\Frontend\ShopController; use WooNooW\Frontend\CartController as FrontendCartController; use WooNooW\Frontend\AccountController; use WooNooW\Frontend\AddressController; +use WooNooW\Frontend\WishlistController; use WooNooW\Frontend\HookBridge; use WooNooW\Api\Controllers\SettingsController; use WooNooW\Api\Controllers\CartController as ApiCartController; @@ -127,6 +128,7 @@ class Routes { FrontendCartController::register_routes(); AccountController::register_routes(); AddressController::register_routes(); + WishlistController::register_routes(); HookBridge::register_routes(); }); } diff --git a/includes/Compat/CustomerSettingsProvider.php b/includes/Compat/CustomerSettingsProvider.php index 22e219f..cac6000 100644 --- a/includes/Compat/CustomerSettingsProvider.php +++ b/includes/Compat/CustomerSettingsProvider.php @@ -21,6 +21,7 @@ class CustomerSettingsProvider { // General 'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === '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_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'); } + if (isset($settings['wishlist_enabled'])) { + $updated = $updated && update_option('woonoow_wishlist_enabled', $settings['wishlist_enabled'] ? 'yes' : 'no'); + } + // VIP settings if (isset($settings['vip_min_spent'])) { $updated = $updated && update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent'])); diff --git a/includes/Frontend/WishlistController.php b/includes/Frontend/WishlistController.php new file mode 100644 index 0000000..12226fd --- /dev/null +++ b/includes/Frontend/WishlistController.php @@ -0,0 +1,176 @@ + '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\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); + } +}