diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ce6e275..540aa30 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,11 +4,30 @@ import ProductCard from '../components/ProductCard'; import ProductForm from '../components/ProductForm'; import { productsApi, pricesApi, Product } from '../api/client'; +type SortOption = 'date_added' | 'name' | 'price' | 'price_change' | 'website'; +type SortOrder = 'asc' | 'desc'; + +const SORT_OPTIONS: { value: SortOption; label: string }[] = [ + { value: 'date_added', label: 'Date Added' }, + { value: 'name', label: 'Product Name' }, + { value: 'price', label: 'Price' }, + { value: 'price_change', label: 'Price Change (7d)' }, + { value: 'website', label: 'Website' }, +]; + export default function Dashboard() { const [products, setProducts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState(() => { + const saved = localStorage.getItem('dashboard_sort_by'); + return (saved as SortOption) || 'date_added'; + }); + const [sortOrder, setSortOrder] = useState(() => { + const saved = localStorage.getItem('dashboard_sort_order'); + return (saved as SortOrder) || 'desc'; + }); const fetchProducts = async () => { try { @@ -25,6 +44,14 @@ export default function Dashboard() { fetchProducts(); }, []); + useEffect(() => { + localStorage.setItem('dashboard_sort_by', sortBy); + }, [sortBy]); + + useEffect(() => { + localStorage.setItem('dashboard_sort_order', sortOrder); + }, [sortOrder]); + const handleAddProduct = async (url: string, refreshInterval: number) => { const response = await productsApi.create(url, refreshInterval); setProducts((prev) => [response.data, ...prev]); @@ -53,15 +80,57 @@ export default function Dashboard() { } }; - const filteredProducts = useMemo(() => { - if (!searchQuery.trim()) return products; - const query = searchQuery.toLowerCase(); - return products.filter( - (p) => - p.name?.toLowerCase().includes(query) || - p.url.toLowerCase().includes(query) - ); - }, [products, searchQuery]); + const getWebsite = (url: string) => { + try { + return new URL(url).hostname.replace('www.', ''); + } catch { + return url; + } + }; + + const filteredAndSortedProducts = useMemo(() => { + let result = [...products]; + + // Filter by search query + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (p) => + p.name?.toLowerCase().includes(query) || + p.url.toLowerCase().includes(query) + ); + } + + // Sort + result.sort((a, b) => { + let comparison = 0; + + switch (sortBy) { + case 'date_added': + comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); + break; + case 'name': + comparison = (a.name || '').localeCompare(b.name || ''); + break; + case 'price': { + const priceA = typeof a.current_price === 'string' ? parseFloat(a.current_price) : (a.current_price || 0); + const priceB = typeof b.current_price === 'string' ? parseFloat(b.current_price) : (b.current_price || 0); + comparison = priceA - priceB; + break; + } + case 'price_change': + comparison = (a.price_change_7d || 0) - (b.price_change_7d || 0); + break; + case 'website': + comparison = getWebsite(a.url).localeCompare(getWebsite(b.url)); + break; + } + + return sortOrder === 'asc' ? comparison : -comparison; + }); + + return result; + }, [products, searchQuery, sortBy, sortOrder]); return ( @@ -125,6 +194,67 @@ export default function Dashboard() { color: var(--text-muted); } + .sort-controls { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .sort-select { + padding: 0.75rem 2rem 0.75rem 0.875rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--surface); + color: var(--text); + font-size: 0.9375rem; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + transition: border-color 0.2s, box-shadow 0.2s; + } + + .sort-select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + } + + .sort-order-btn { + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--surface); + color: var(--text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.2s, box-shadow 0.2s, background 0.2s; + } + + .sort-order-btn:hover { + background: var(--background); + border-color: var(--primary); + } + + .sort-order-btn:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + } + + .sort-order-btn svg { + width: 16px; + height: 16px; + transition: transform 0.2s; + } + + .sort-order-btn.desc svg { + transform: rotate(180deg); + } + .products-count { color: var(--text-muted); font-size: 0.875rem; @@ -230,6 +360,35 @@ export default function Dashboard() { onChange={(e) => setSearchQuery(e.target.value)} /> +
+ + +
)} @@ -245,7 +404,7 @@ export default function Dashboard() { Add your first product URL above to start tracking prices!

- ) : filteredProducts.length === 0 ? ( + ) : filteredAndSortedProducts.length === 0 ? (
🔍

No matching products

@@ -256,12 +415,12 @@ export default function Dashboard() { ) : ( <>

- {filteredProducts.length === products.length + {filteredAndSortedProducts.length === products.length ? `${products.length} product${products.length !== 1 ? 's' : ''}` - : `${filteredProducts.length} of ${products.length} products`} + : `${filteredAndSortedProducts.length} of ${products.length} products`}

- {filteredProducts.map((product) => ( + {filteredAndSortedProducts.map((product) => (