Files
WooNooW/customer-spa/src/pages/Wishlist.tsx
Dwindi Ramadhana 10acb58f6e feat: Toast position control + Currency formatting + Dialog accessibility fixes
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)
2025-12-27 00:12:44 +07:00

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>
);
}