Add target price alerts, historical low indicator, bulk actions, and dashboard summary

Features:
- Target price alerts: Set a specific price target and get notified when reached
- Historical low indicator: Badge showing when current price is at/near all-time low
- Bulk actions: Select multiple products to delete at once
- Dashboard summary: Shows total products, items at lowest price, at target, biggest drops

Backend changes:
- Add target_price column to products table
- Add target_price notification type with Telegram/Discord support
- Include min_price in product queries for historical low detection
- Update scheduler to check target price conditions

Frontend changes:
- Add target price input to ProductDetail notification settings
- Show target price badge on product cards
- Add "Lowest Price" and "Near Low" badges to product cards
- Add bulk selection mode with checkboxes
- Add dashboard summary cards at top of product list

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
clucraft 2026-01-21 13:40:39 -05:00
parent 2acc47c21c
commit a85e22d8bc
9 changed files with 454 additions and 5 deletions

View file

@ -7,9 +7,12 @@ interface ProductCardProps {
product: Product;
onDelete: (id: number) => void;
onRefresh: (id: number) => Promise<void>;
isSelected?: boolean;
onSelect?: (id: number, selected: boolean) => void;
showCheckbox?: boolean;
}
export default function ProductCard({ product, onDelete, onRefresh }: ProductCardProps) {
export default function ProductCard({ product, onDelete, onRefresh, isSelected, onSelect, showCheckbox }: ProductCardProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
@ -54,8 +57,14 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
const isOutOfStock = product.stock_status === 'out_of_stock';
// Check if current price is at or near historical low
const isHistoricalLow = product.current_price && product.min_price &&
product.current_price <= product.min_price;
const isNearHistoricalLow = !isHistoricalLow && product.current_price && product.min_price &&
product.current_price <= product.min_price * 1.05; // Within 5% of low
return (
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''}`}>
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''} ${isSelected ? 'selected' : ''}`}>
<style>{`
.product-list-item {
background: var(--surface);
@ -73,6 +82,19 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
transform: translateY(-1px);
}
.product-list-item.selected {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.product-checkbox {
flex-shrink: 0;
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--primary);
}
.product-thumbnail {
width: 64px;
height: 64px;
@ -199,6 +221,26 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
color: #f87171;
}
.product-stock-badge.historical-low {
background: #ecfdf5;
color: #059669;
}
[data-theme="dark"] .product-stock-badge.historical-low {
background: rgba(5, 150, 105, 0.2);
color: #34d399;
}
.product-stock-badge.near-low {
background: #fef3c7;
color: #d97706;
}
[data-theme="dark"] .product-stock-badge.near-low {
background: rgba(217, 119, 6, 0.2);
color: #fbbf24;
}
.product-list-item.out-of-stock {
opacity: 0.7;
}
@ -284,6 +326,15 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
}
`}</style>
{showCheckbox && (
<input
type="checkbox"
className="product-checkbox"
checked={isSelected}
onChange={(e) => onSelect?.(product.id, e.target.checked)}
/>
)}
{product.image_url ? (
<img
src={product.image_url}
@ -297,7 +348,7 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
<div className="product-info">
<h3 className="product-name">{product.name || 'Unknown Product'}</h3>
<p className="product-source">{truncateUrl(product.url)}</p>
{(product.price_drop_threshold || product.notify_back_in_stock) && (
{(product.price_drop_threshold || product.target_price || product.notify_back_in_stock) && (
<div className="product-notifications">
{product.price_drop_threshold && (
<span className="product-notification-badge" title="Price drop alert">
@ -308,6 +359,16 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
${Number(product.price_drop_threshold).toFixed(2)} drop
</span>
)}
{product.target_price && (
<span className="product-notification-badge" title="Target price alert">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
Target: ${Number(product.target_price).toFixed(2)}
</span>
)}
{product.notify_back_in_stock && (
<span className="product-notification-badge" title="Back in stock alert">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -336,6 +397,16 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
{formatPriceChange(product.price_change_7d)} (7d)
</span>
)}
{isHistoricalLow && (
<span className="product-stock-badge historical-low">
Lowest Price
</span>
)}
{isNearHistoricalLow && (
<span className="product-stock-badge near-low">
Near Low
</span>
)}
</>
)}
</div>