186 lines
9.4 KiB
TypeScript
186 lines
9.4 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import type { MasterGroceryItem } from '../types';
|
|
import { EyeIcon } from './icons/EyeIcon';
|
|
import { LoadingSpinner } from './LoadingSpinner';
|
|
import { SortAscIcon } from './icons/SortAscIcon';
|
|
import { SortDescIcon } from './icons/SortDescIcon';
|
|
import { CATEGORIES } from '../types';
|
|
import { Session } from '@supabase/supabase-js';
|
|
import { TrashIcon } from './icons/TrashIcon';
|
|
import { UserIcon } from './icons/UserIcon';
|
|
import { PlusCircleIcon } from './icons/PlusCircleIcon';
|
|
|
|
interface WatchedItemsListProps {
|
|
items: MasterGroceryItem[];
|
|
onAddItem: (itemName: string, category: string) => Promise<void>;
|
|
onRemoveItem: (masterItemId: number) => Promise<void>;
|
|
session: Session | null;
|
|
activeListId: number | null;
|
|
onAddItemToList: (masterItemId: number) => void;
|
|
}
|
|
|
|
export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({ items, onAddItem, onRemoveItem, session, activeListId, onAddItemToList }) => {
|
|
const [newItemName, setNewItemName] = useState('');
|
|
const [newCategory, setNewCategory] = useState('');
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newItemName.trim() || !newCategory) return;
|
|
|
|
setIsAdding(true);
|
|
try {
|
|
await onAddItem(newItemName, newCategory);
|
|
setNewItemName('');
|
|
setNewCategory('');
|
|
} catch (error) {
|
|
// Error is handled in the parent component
|
|
console.error(error);
|
|
} finally {
|
|
setIsAdding(false);
|
|
}
|
|
};
|
|
|
|
const handleSortToggle = () => {
|
|
setSortOrder(prev => (prev === 'asc' ? 'desc' : 'asc'));
|
|
};
|
|
|
|
const availableCategories = useMemo(() => {
|
|
const cats = new Set(items.map(i => i.category_name).filter((c): c is string => !!c));
|
|
return Array.from(cats).sort();
|
|
}, [items]);
|
|
|
|
const sortedAndFilteredItems = useMemo(() => {
|
|
const filteredItems = categoryFilter === 'all'
|
|
? items
|
|
: items.filter(item => item.category_name === categoryFilter);
|
|
|
|
return [...filteredItems].sort((a, b) => {
|
|
if (sortOrder === 'asc') {
|
|
return a.name.localeCompare(b.name);
|
|
} else {
|
|
return b.name.localeCompare(a.name);
|
|
}
|
|
});
|
|
}, [items, sortOrder, categoryFilter]);
|
|
|
|
if (!session) {
|
|
return (
|
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
|
<div className="flex flex-col items-center justify-center h-full min-h-[150px]">
|
|
<UserIcon className="w-10 h-10 text-gray-400 mb-3" />
|
|
<h4 className="font-semibold text-gray-700 dark:text-gray-300">Personalize Your Deals</h4>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
Please log in to create and manage your personal watchlist.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
<div className="flex justify-between items-center mb-3">
|
|
<h3 className="text-lg font-bold text-gray-800 dark:text-white flex items-center">
|
|
<EyeIcon className="w-6 h-6 mr-2 text-brand-primary" />
|
|
Your Watched Items
|
|
</h3>
|
|
<div className="flex items-center space-x-2">
|
|
{items.length > 0 && (
|
|
<select
|
|
value={categoryFilter}
|
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
|
className="block w-full pl-3 pr-8 py-1.5 text-xs bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary"
|
|
aria-label="Filter by category"
|
|
>
|
|
<option value="all">All Categories</option>
|
|
{availableCategories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
|
</select>
|
|
)}
|
|
{items.length > 1 && (
|
|
<button
|
|
onClick={handleSortToggle}
|
|
className="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
|
|
aria-label={`Sort items ${sortOrder === 'asc' ? 'descending' : 'ascending'}`}
|
|
title={`Sort ${sortOrder === 'asc' ? 'Z-A' : 'A-Z'}`}
|
|
>
|
|
{sortOrder === 'asc' ?
|
|
<SortAscIcon className="w-5 h-5" /> :
|
|
<SortDescIcon className="w-5 h-5" />
|
|
}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-2 mb-4">
|
|
<input
|
|
type="text"
|
|
value={newItemName}
|
|
onChange={(e) => setNewItemName(e.target.value)}
|
|
placeholder="Add item (e.g., Avocados)"
|
|
className="flex-grow block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm"
|
|
disabled={isAdding}
|
|
/>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<select
|
|
value={newCategory}
|
|
onChange={(e) => setNewCategory(e.target.value)}
|
|
required
|
|
className="col-span-2 block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm"
|
|
disabled={isAdding}
|
|
>
|
|
<option value="" disabled>Select a category</option>
|
|
{CATEGORIES.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
|
</select>
|
|
<button
|
|
type="submit"
|
|
disabled={isAdding || !newItemName.trim() || !newCategory}
|
|
className="col-span-1 bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-bold py-2 px-3 rounded-lg transition-colors duration-300 flex items-center justify-center"
|
|
>
|
|
{isAdding ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Add'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{sortedAndFilteredItems.length > 0 ? (
|
|
<ul className="space-y-2 max-h-60 overflow-y-auto">
|
|
{sortedAndFilteredItems.map(item => (
|
|
<li key={item.id} className="group text-sm bg-gray-50 dark:bg-gray-800 p-2 rounded text-gray-700 dark:text-gray-300 flex justify-between items-center">
|
|
<div className="flex-grow">
|
|
<span>{item.name}</span>
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 italic ml-2">{item.category_name}</span>
|
|
</div>
|
|
<div className="flex items-center flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={() => onAddItemToList(item.id)}
|
|
disabled={!activeListId}
|
|
className="p-1 text-gray-400 hover:text-brand-primary disabled:text-gray-300 disabled:cursor-not-allowed"
|
|
title={activeListId ? `Add ${item.name} to list` : 'Select a shopping list first'}
|
|
>
|
|
<PlusCircleIcon className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => onRemoveItem(item.id)}
|
|
className="text-red-500 hover:text-red-700 dark:hover:text-red-400 p-1"
|
|
aria-label={`Remove ${item.name}`}
|
|
title={`Remove ${item.name}`}
|
|
>
|
|
<TrashIcon className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
{categoryFilter === 'all'
|
|
? 'Your watchlist is empty. Add items above to start tracking prices.'
|
|
: `No watched items in the "${categoryFilter}" category.`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}; |