1. Toast Position Control ✅ - Added toast_position setting to Appearance > General - 6 position options: top-left/center/right, bottom-left/center/right - Default: top-right - Backend: AppearanceController.php (save/load toast_position) - Frontend: Customer SPA reads from appearanceSettings and applies to Toaster - Admin UI: Select dropdown in General settings - Solves UX issue: toast blocking cart icon in header 2. Currency Formatting Fix ✅ - Changed formatPrice import from @/lib/utils to @/lib/currency - @/lib/currency respects WooCommerce currency settings (IDR, not USD) - Reads currency code, symbol, position, separators from window.woonoowCustomer.currency - Applies correct formatting for Indonesian Rupiah and any other currency 3. Dialog Accessibility Warnings Fixed ✅ - Added DialogDescription component to all taxonomy dialogs - Categories: 'Update category information' / 'Create a new product category' - Tags: 'Update tag information' / 'Create a new product tag' - Attributes: 'Update attribute information' / 'Create a new product attribute' - Fixes console warning: Missing Description or aria-describedby Note on React Key Warning: The warning about missing keys in ProductCategories is still appearing in console. All table rows already have proper key props (key={category.term_id}). This may be a dev server cache issue or a nested element without a key. The code is correct - keys are present on all mapped elements. Files Modified: - includes/Admin/AppearanceController.php (toast_position setting) - admin-spa/src/routes/Appearance/General.tsx (toast position UI) - customer-spa/src/App.tsx (apply toast position from settings) - customer-spa/src/pages/Wishlist.tsx (use correct formatPrice from currency) - admin-spa/src/routes/Products/Categories.tsx (DialogDescription) - admin-spa/src/routes/Products/Tags.tsx (DialogDescription) - admin-spa/src/routes/Products/Attributes.tsx (DialogDescription) Result: ✅ Toast notifications now configurable and won't block header elements ✅ Prices display in correct currency (IDR) with proper formatting ✅ All Dialog accessibility warnings resolved ⚠️ React key warning persists (but keys are correctly implemented)
254 lines
9.3 KiB
TypeScript
254 lines
9.3 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Trash2, ShoppingCart, Heart } from 'lucide-react';
|
|
import { useWishlist } from '@/hooks/useWishlist';
|
|
import { useCartStore } from '@/lib/cart/store';
|
|
import { Button } from '@/components/ui/button';
|
|
import { toast } from 'sonner';
|
|
import { apiClient } from '@/lib/api/client';
|
|
import { formatPrice } from '@/lib/currency';
|
|
|
|
interface ProductData {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
price: string;
|
|
regular_price?: string;
|
|
sale_price?: string;
|
|
image?: string;
|
|
on_sale?: boolean;
|
|
stock_status?: string;
|
|
}
|
|
|
|
/**
|
|
* Public Wishlist Page - Accessible to both guests and logged-in users
|
|
* Guests: Shows items from localStorage
|
|
* Logged-in: Shows items from database via API
|
|
*/
|
|
export default function Wishlist() {
|
|
const navigate = useNavigate();
|
|
const { items, isLoading, isLoggedIn, removeFromWishlist, productIds } = useWishlist();
|
|
const { addItem } = useCartStore();
|
|
const [guestProducts, setGuestProducts] = useState<ProductData[]>([]);
|
|
const [loadingGuest, setLoadingGuest] = useState(false);
|
|
|
|
// Fetch product details for guest wishlist
|
|
useEffect(() => {
|
|
const fetchGuestProducts = async () => {
|
|
if (!isLoggedIn && productIds.size > 0) {
|
|
setLoadingGuest(true);
|
|
try {
|
|
const ids = Array.from(productIds).join(',');
|
|
const response = await apiClient.get<any>(`/shop/products?include=${ids}`);
|
|
setGuestProducts(response.products || []);
|
|
} catch (error) {
|
|
console.error('Failed to fetch guest wishlist products:', error);
|
|
} finally {
|
|
setLoadingGuest(false);
|
|
}
|
|
}
|
|
};
|
|
fetchGuestProducts();
|
|
}, [isLoggedIn, productIds]);
|
|
|
|
const handleRemove = async (productId: number) => {
|
|
await removeFromWishlist(productId);
|
|
// Remove from guest products list
|
|
setGuestProducts(prev => prev.filter(p => p.id !== productId));
|
|
};
|
|
|
|
const handleAddToCart = (product: ProductData) => {
|
|
addItem({
|
|
key: `product-${product.id}`,
|
|
product_id: product.id,
|
|
name: product.name,
|
|
price: parseFloat(product.sale_price || product.regular_price || product.price.replace(/[^0-9.]/g, '')),
|
|
quantity: 1,
|
|
image: product.image,
|
|
});
|
|
toast.success(`${product.name} added to cart`);
|
|
};
|
|
|
|
if (isLoading || loadingGuest) {
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="text-center">
|
|
<p className="text-gray-600">Loading wishlist...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Guest mode: have product details fetched from API
|
|
const hasGuestItems = !isLoggedIn && guestProducts.length > 0;
|
|
const hasLoggedInItems = isLoggedIn && items.length > 0;
|
|
|
|
if (!hasGuestItems && !hasLoggedInItems) {
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="max-w-4xl mx-auto">
|
|
<h1 className="text-3xl font-bold mb-6">My Wishlist</h1>
|
|
|
|
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
|
<Heart className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
|
<h2 className="text-xl font-semibold mb-2">Your wishlist is empty</h2>
|
|
<p className="text-gray-600 mb-6">
|
|
Start adding products you love to your wishlist
|
|
</p>
|
|
<Button onClick={() => navigate('/shop')}>
|
|
Browse Products
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="max-w-4xl mx-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-3xl font-bold">My Wishlist</h1>
|
|
<p className="text-gray-600">
|
|
{isLoggedIn ? `${items.length} items` : `${guestProducts.length} items`}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Guest Mode: Show full product details */}
|
|
{!isLoggedIn && hasGuestItems && (
|
|
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<p className="text-sm text-blue-800">
|
|
<strong>Guest Wishlist:</strong> You have {guestProducts.length} items saved locally.
|
|
<a href="/wp-login.php" className="underline ml-1">Login</a> to sync your wishlist to your account.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Guest Wishlist Items (with full product details) */}
|
|
{!isLoggedIn && hasGuestItems && (
|
|
<div className="space-y-4">
|
|
{guestProducts.map((product) => (
|
|
<div key={`guest-${product.id}`} className="bg-white border rounded-lg p-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
{product.image ? (
|
|
<img
|
|
src={product.image}
|
|
alt={product.name}
|
|
className="w-20 h-20 object-cover rounded"
|
|
/>
|
|
) : (
|
|
<div className="w-20 h-20 bg-gray-200 rounded flex items-center justify-center">
|
|
<Heart className="w-8 h-8 text-gray-400" />
|
|
</div>
|
|
)}
|
|
<div>
|
|
<h3 className="font-semibold">{product.name}</h3>
|
|
<p className="text-sm text-gray-600">
|
|
{formatPrice(parseFloat(product.sale_price || product.regular_price || product.price.replace(/[^0-9.]/g, '')))}
|
|
</p>
|
|
{product.stock_status === 'outofstock' && (
|
|
<p className="text-sm text-red-600">Out of stock</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => handleAddToCart(product)}
|
|
disabled={product.stock_status === 'outofstock'}
|
|
>
|
|
<ShoppingCart className="w-4 h-4 mr-1" />
|
|
Add to Cart
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => navigate(`/product/${product.slug}`)}
|
|
>
|
|
View
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRemove(product.id)}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Logged-in Wishlist Items (full details from API) */}
|
|
{isLoggedIn && hasLoggedInItems && (
|
|
<div className="space-y-4">
|
|
{items.map((item) => (
|
|
<div key={item.product_id} className="bg-white border rounded-lg p-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
{item.image ? (
|
|
<img
|
|
src={item.image}
|
|
alt={item.name}
|
|
className="w-20 h-20 object-cover rounded"
|
|
/>
|
|
) : (
|
|
<div className="w-20 h-20 bg-gray-200 rounded flex items-center justify-center">
|
|
<Heart className="w-8 h-8 text-gray-400" />
|
|
</div>
|
|
)}
|
|
<div>
|
|
<h3 className="font-semibold">{item.name}</h3>
|
|
<p className="text-sm text-gray-600">
|
|
{formatPrice(parseFloat(item.price.replace(/[^0-9.]/g, '')))}
|
|
</p>
|
|
{item.stock_status === 'outofstock' && (
|
|
<p className="text-sm text-red-600">Out of stock</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => {
|
|
addItem({
|
|
key: `product-${item.product_id}`,
|
|
product_id: item.product_id,
|
|
name: item.name,
|
|
price: parseFloat(item.price.replace(/[^0-9.]/g, '')),
|
|
quantity: 1,
|
|
image: item.image,
|
|
});
|
|
toast.success(`${item.name} added to cart`);
|
|
}}
|
|
disabled={item.stock_status === 'outofstock'}
|
|
>
|
|
<ShoppingCart className="w-4 h-4 mr-1" />
|
|
Add to Cart
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => navigate(`/product/${item.slug}`)}
|
|
>
|
|
View
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRemove(item.product_id)}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|