mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-05-15 10:52:36 +02:00
Add product list sorting feature
Sort options: - Date added (default) - Product name - Price - Price change (7d) - Website Features: - Ascending/descending toggle - Preferences saved to localStorage - Works with search filtering Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
556a8df99d
commit
0c8ce22cc1
1 changed files with 172 additions and 13 deletions
|
|
@ -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<Product[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<SortOption>(() => {
|
||||
const saved = localStorage.getItem('dashboard_sort_by');
|
||||
return (saved as SortOption) || 'date_added';
|
||||
});
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(() => {
|
||||
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 (
|
||||
<Layout>
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sort-controls">
|
||||
<select
|
||||
className="sort-select"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className={`sort-order-btn ${sortOrder}`}
|
||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
title={sortOrder === 'asc' ? 'Ascending' : 'Descending'}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 5v14M5 12l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -245,7 +404,7 @@ export default function Dashboard() {
|
|||
Add your first product URL above to start tracking prices!
|
||||
</p>
|
||||
</div>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
) : filteredAndSortedProducts.length === 0 ? (
|
||||
<div className="no-results">
|
||||
<div className="no-results-icon">🔍</div>
|
||||
<h3 className="no-results-title">No matching products</h3>
|
||||
|
|
@ -256,12 +415,12 @@ export default function Dashboard() {
|
|||
) : (
|
||||
<>
|
||||
<p className="products-count">
|
||||
{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`}
|
||||
</p>
|
||||
<div className="products-list">
|
||||
{filteredProducts.map((product) => (
|
||||
{filteredAndSortedProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue