mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-04-25 00:36:32 +02:00
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:
parent
2acc47c21c
commit
a85e22d8bc
9 changed files with 454 additions and 5 deletions
|
|
@ -200,6 +200,7 @@ export interface Product {
|
|||
next_check_at: Date | null;
|
||||
stock_status: StockStatus;
|
||||
price_drop_threshold: number | null;
|
||||
target_price: number | null;
|
||||
notify_back_in_stock: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
|
@ -222,6 +223,7 @@ export interface SparklinePoint {
|
|||
export interface ProductWithSparkline extends ProductWithLatestPrice {
|
||||
sparkline: SparklinePoint[];
|
||||
price_change_7d: number | null;
|
||||
min_price: number | null;
|
||||
}
|
||||
|
||||
export const productQueries = {
|
||||
|
|
@ -272,6 +274,15 @@ export const productQueries = {
|
|||
[productIds]
|
||||
);
|
||||
|
||||
// Get min prices for all products (all-time low)
|
||||
const minPriceResult = await pool.query(
|
||||
`SELECT product_id, MIN(price) as min_price
|
||||
FROM price_history
|
||||
WHERE product_id = ANY($1)
|
||||
GROUP BY product_id`,
|
||||
[productIds]
|
||||
);
|
||||
|
||||
// Group sparkline data by product
|
||||
const sparklineMap = new Map<number, SparklinePoint[]>();
|
||||
for (const row of sparklineResult.rows) {
|
||||
|
|
@ -280,6 +291,12 @@ export const productQueries = {
|
|||
sparklineMap.set(row.product_id, points);
|
||||
}
|
||||
|
||||
// Map min prices by product
|
||||
const minPriceMap = new Map<number, number>();
|
||||
for (const row of minPriceResult.rows) {
|
||||
minPriceMap.set(row.product_id, parseFloat(row.min_price));
|
||||
}
|
||||
|
||||
// Combine products with sparkline data
|
||||
return products.map((product: ProductWithLatestPrice) => {
|
||||
const sparkline = sparklineMap.get(product.id) || [];
|
||||
|
|
@ -297,6 +314,7 @@ export const productQueries = {
|
|||
...product,
|
||||
sparkline,
|
||||
price_change_7d: priceChange7d,
|
||||
min_price: minPriceMap.get(product.id) || null,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
@ -344,6 +362,7 @@ export const productQueries = {
|
|||
name?: string;
|
||||
refresh_interval?: number;
|
||||
price_drop_threshold?: number | null;
|
||||
target_price?: number | null;
|
||||
notify_back_in_stock?: boolean;
|
||||
}
|
||||
): Promise<Product | null> => {
|
||||
|
|
@ -363,6 +382,10 @@ export const productQueries = {
|
|||
fields.push(`price_drop_threshold = $${paramIndex++}`);
|
||||
values.push(updates.price_drop_threshold);
|
||||
}
|
||||
if (updates.target_price !== undefined) {
|
||||
fields.push(`target_price = $${paramIndex++}`);
|
||||
values.push(updates.target_price);
|
||||
}
|
||||
if (updates.notify_back_in_stock !== undefined) {
|
||||
fields.push(`notify_back_in_stock = $${paramIndex++}`);
|
||||
values.push(updates.notify_back_in_stock);
|
||||
|
|
|
|||
|
|
@ -129,12 +129,13 @@ router.put('/:id', async (req: AuthRequest, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const { name, refresh_interval, price_drop_threshold, notify_back_in_stock } = req.body;
|
||||
const { name, refresh_interval, price_drop_threshold, target_price, notify_back_in_stock } = req.body;
|
||||
|
||||
const product = await productQueries.update(productId, userId, {
|
||||
name,
|
||||
refresh_interval,
|
||||
price_drop_threshold,
|
||||
target_price,
|
||||
notify_back_in_stock,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import axios from 'axios';
|
|||
export interface NotificationPayload {
|
||||
productName: string;
|
||||
productUrl: string;
|
||||
type: 'price_drop' | 'back_in_stock';
|
||||
type: 'price_drop' | 'back_in_stock' | 'target_price';
|
||||
oldPrice?: number;
|
||||
newPrice?: number;
|
||||
currency?: string;
|
||||
threshold?: number;
|
||||
targetPrice?: number;
|
||||
}
|
||||
|
||||
function formatMessage(payload: NotificationPayload): string {
|
||||
|
|
@ -27,6 +28,16 @@ function formatMessage(payload: NotificationPayload): string {
|
|||
`🔗 ${payload.productUrl}`;
|
||||
}
|
||||
|
||||
if (payload.type === 'target_price') {
|
||||
const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A';
|
||||
const targetPriceStr = payload.targetPrice ? `${currencySymbol}${payload.targetPrice.toFixed(2)}` : 'N/A';
|
||||
|
||||
return `🎯 Target Price Reached!\n\n` +
|
||||
`📦 ${payload.productName}\n\n` +
|
||||
`💰 Price is now ${newPriceStr} (your target: ${targetPriceStr})\n\n` +
|
||||
`🔗 ${payload.productUrl}`;
|
||||
}
|
||||
|
||||
if (payload.type === 'back_in_stock') {
|
||||
const priceStr = payload.newPrice ? ` at ${currencySymbol}${payload.newPrice.toFixed(2)}` : '';
|
||||
return `🎉 Back in Stock!\n\n` +
|
||||
|
|
@ -85,6 +96,21 @@ export async function sendDiscordNotification(
|
|||
url: payload.productUrl,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} else if (payload.type === 'target_price') {
|
||||
const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A';
|
||||
const targetPriceStr = payload.targetPrice ? `${currencySymbol}${payload.targetPrice.toFixed(2)}` : 'N/A';
|
||||
|
||||
embed = {
|
||||
title: '🎯 Target Price Reached!',
|
||||
description: payload.productName,
|
||||
color: 0xf59e0b, // Amber
|
||||
fields: [
|
||||
{ name: 'Current Price', value: newPriceStr, inline: true },
|
||||
{ name: 'Your Target', value: targetPriceStr, inline: true },
|
||||
],
|
||||
url: payload.productUrl,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
const priceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'Check link';
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,34 @@ async function checkPrices(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// Check for target price notification
|
||||
if (product.target_price) {
|
||||
const newPrice = scrapedData.price.price;
|
||||
const targetPrice = parseFloat(String(product.target_price));
|
||||
const oldPrice = latestPrice ? parseFloat(String(latestPrice.price)) : null;
|
||||
|
||||
// Only notify if price just dropped to or below target (wasn't already below)
|
||||
if (newPrice <= targetPrice && (!oldPrice || oldPrice > targetPrice)) {
|
||||
try {
|
||||
const userSettings = await userQueries.getNotificationSettings(product.user_id);
|
||||
if (userSettings) {
|
||||
const payload: NotificationPayload = {
|
||||
productName: product.name || 'Unknown Product',
|
||||
productUrl: product.url,
|
||||
type: 'target_price',
|
||||
newPrice: newPrice,
|
||||
currency: scrapedData.price.currency,
|
||||
targetPrice: targetPrice,
|
||||
};
|
||||
await sendNotifications(userSettings, payload);
|
||||
console.log(`Target price notification sent for product ${product.id}: ${newPrice} <= ${targetPrice}`);
|
||||
}
|
||||
} catch (notifyError) {
|
||||
console.error(`Failed to send target price notification for product ${product.id}:`, notifyError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await priceHistoryQueries.create(
|
||||
product.id,
|
||||
scrapedData.price.price,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS products (
|
|||
next_check_at TIMESTAMP,
|
||||
stock_status VARCHAR(20) DEFAULT 'unknown',
|
||||
price_drop_threshold DECIMAL(10,2),
|
||||
target_price DECIMAL(10,2),
|
||||
notify_back_in_stock BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, url)
|
||||
|
|
@ -107,6 +108,17 @@ BEGIN
|
|||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add target_price column for price alerts
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'products' AND column_name = 'target_price'
|
||||
) THEN
|
||||
ALTER TABLE products ADD COLUMN target_price DECIMAL(10,2);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Price history table
|
||||
CREATE TABLE IF NOT EXISTS price_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -61,12 +61,14 @@ export interface Product {
|
|||
last_checked: string | null;
|
||||
stock_status: StockStatus;
|
||||
price_drop_threshold: number | null;
|
||||
target_price: number | null;
|
||||
notify_back_in_stock: boolean;
|
||||
created_at: string;
|
||||
current_price: number | null;
|
||||
currency: string | null;
|
||||
sparkline?: SparklinePoint[];
|
||||
price_change_7d?: number | null;
|
||||
min_price?: number | null;
|
||||
}
|
||||
|
||||
export interface ProductWithStats extends Product {
|
||||
|
|
@ -98,6 +100,7 @@ export const productsApi = {
|
|||
name?: string;
|
||||
refresh_interval?: number;
|
||||
price_drop_threshold?: number | null;
|
||||
target_price?: number | null;
|
||||
notify_back_in_stock?: boolean;
|
||||
}) => api.put<Product>(`/products/${id}`, data),
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ export default function Dashboard() {
|
|||
const saved = localStorage.getItem('dashboard_sort_order');
|
||||
return (saved as SortOrder) || 'desc';
|
||||
});
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [bulkMode, setBulkMode] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
|
|
@ -80,6 +83,50 @@ export default function Dashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSelectProduct = (id: number, selected: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedIds.size === filteredAndSortedProducts.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredAndSortedProducts.map(p => p.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (!confirm(`Are you sure you want to delete ${selectedIds.size} product${selectedIds.size > 1 ? 's' : ''}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await Promise.all(Array.from(selectedIds).map(id => productsApi.delete(id)));
|
||||
setProducts(prev => prev.filter(p => !selectedIds.has(p.id)));
|
||||
setSelectedIds(new Set());
|
||||
setBulkMode(false);
|
||||
} catch {
|
||||
alert('Failed to delete some products');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exitBulkMode = () => {
|
||||
setBulkMode(false);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const getWebsite = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname.replace('www.', '');
|
||||
|
|
@ -88,6 +135,38 @@ export default function Dashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
// Dashboard summary calculations
|
||||
const dashboardSummary = useMemo(() => {
|
||||
if (products.length === 0) return null;
|
||||
|
||||
const totalProducts = products.length;
|
||||
|
||||
// Find biggest drops this week (negative price_change_7d)
|
||||
const biggestDrops = products
|
||||
.filter(p => p.price_change_7d && p.price_change_7d < 0)
|
||||
.sort((a, b) => (a.price_change_7d || 0) - (b.price_change_7d || 0))
|
||||
.slice(0, 3);
|
||||
|
||||
// Find products at or below target price
|
||||
const atTargetPrice = products.filter(p =>
|
||||
p.target_price && p.current_price &&
|
||||
parseFloat(String(p.current_price)) <= parseFloat(String(p.target_price))
|
||||
);
|
||||
|
||||
// Find products at historical low
|
||||
const atHistoricalLow = products.filter(p =>
|
||||
p.current_price && p.min_price &&
|
||||
parseFloat(String(p.current_price)) <= parseFloat(String(p.min_price))
|
||||
);
|
||||
|
||||
return {
|
||||
totalProducts,
|
||||
biggestDrops,
|
||||
atTargetPrice,
|
||||
atHistoricalLow,
|
||||
};
|
||||
}, [products]);
|
||||
|
||||
const filteredAndSortedProducts = useMemo(() => {
|
||||
let result = [...products];
|
||||
|
||||
|
|
@ -321,6 +400,115 @@ export default function Dashboard() {
|
|||
justify-content: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.dashboard-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--surface);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.summary-card-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-card-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.summary-card-value.highlight {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.summary-card-value.success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.summary-card-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary-card-list li {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text);
|
||||
padding: 0.25rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summary-card-list li span.drop {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bulk-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bulk-action-bar .selected-count {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bulk-action-bar .bulk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.bulk-action-bar .btn {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.bulk-action-bar .btn:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.bulk-action-bar .btn.btn-danger {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.bulk-action-bar .btn.btn-danger:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.bulk-mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bulk-mode-toggle input {
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="dashboard-header">
|
||||
|
|
@ -334,6 +522,43 @@ export default function Dashboard() {
|
|||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{/* Dashboard Summary */}
|
||||
{!isLoading && dashboardSummary && dashboardSummary.totalProducts > 0 && (
|
||||
<div className="dashboard-summary">
|
||||
<div className="summary-card">
|
||||
<div className="summary-card-title">Total Products</div>
|
||||
<div className="summary-card-value">{dashboardSummary.totalProducts}</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="summary-card-title">At Lowest Price</div>
|
||||
<div className={`summary-card-value ${dashboardSummary.atHistoricalLow.length > 0 ? 'success' : ''}`}>
|
||||
{dashboardSummary.atHistoricalLow.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="summary-card-title">At Target Price</div>
|
||||
<div className={`summary-card-value ${dashboardSummary.atTargetPrice.length > 0 ? 'highlight' : ''}`}>
|
||||
{dashboardSummary.atTargetPrice.length}
|
||||
</div>
|
||||
</div>
|
||||
{dashboardSummary.biggestDrops.length > 0 && (
|
||||
<div className="summary-card">
|
||||
<div className="summary-card-title">Biggest Drops (7d)</div>
|
||||
<ul className="summary-card-list">
|
||||
{dashboardSummary.biggestDrops.map(p => (
|
||||
<li key={p.id}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginRight: '0.5rem' }}>
|
||||
{p.name?.slice(0, 20) || 'Unknown'}
|
||||
</span>
|
||||
<span className="drop">{p.price_change_7d?.toFixed(1)}%</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && products.length > 0 && (
|
||||
<div className="dashboard-controls">
|
||||
<div className="search-container">
|
||||
|
|
@ -389,6 +614,39 @@ export default function Dashboard() {
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<label className="bulk-mode-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkMode}
|
||||
onChange={(e) => {
|
||||
setBulkMode(e.target.checked);
|
||||
if (!e.target.checked) setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
Select multiple
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{bulkMode && selectedIds.size > 0 && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="selected-count">{selectedIds.size} selected</span>
|
||||
<button className="btn btn-sm" onClick={handleSelectAll}>
|
||||
{selectedIds.size === filteredAndSortedProducts.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
<div className="bulk-actions">
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleBulkDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Selected'}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={exitBulkMode}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -426,6 +684,9 @@ export default function Dashboard() {
|
|||
product={product}
|
||||
onDelete={handleDeleteProduct}
|
||||
onRefresh={handleRefreshProduct}
|
||||
showCheckbox={bulkMode}
|
||||
isSelected={selectedIds.has(product.id)}
|
||||
onSelect={handleSelectProduct}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export default function ProductDetail() {
|
|||
const [error, setError] = useState('');
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null);
|
||||
const [priceDropThreshold, setPriceDropThreshold] = useState<string>('');
|
||||
const [targetPrice, setTargetPrice] = useState<string>('');
|
||||
const [notifyBackInStock, setNotifyBackInStock] = useState(false);
|
||||
|
||||
const REFRESH_INTERVALS = [
|
||||
|
|
@ -50,6 +51,9 @@ export default function ProductDetail() {
|
|||
if (productRes.data.price_drop_threshold !== null && productRes.data.price_drop_threshold !== undefined) {
|
||||
setPriceDropThreshold(productRes.data.price_drop_threshold.toString());
|
||||
}
|
||||
if (productRes.data.target_price !== null && productRes.data.target_price !== undefined) {
|
||||
setTargetPrice(productRes.data.target_price.toString());
|
||||
}
|
||||
setNotifyBackInStock(productRes.data.notify_back_in_stock || false);
|
||||
} catch {
|
||||
setError('Failed to load product details');
|
||||
|
|
@ -121,13 +125,16 @@ export default function ProductDetail() {
|
|||
setIsSavingNotifications(true);
|
||||
try {
|
||||
const threshold = priceDropThreshold ? parseFloat(priceDropThreshold) : null;
|
||||
const target = targetPrice ? parseFloat(targetPrice) : null;
|
||||
await productsApi.update(productId, {
|
||||
price_drop_threshold: threshold,
|
||||
target_price: target,
|
||||
notify_back_in_stock: notifyBackInStock,
|
||||
});
|
||||
setProduct({
|
||||
...product,
|
||||
price_drop_threshold: threshold,
|
||||
target_price: target,
|
||||
notify_back_in_stock: notifyBackInStock,
|
||||
});
|
||||
} catch {
|
||||
|
|
@ -678,6 +685,23 @@ export default function ProductDetail() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div className="notification-form-group">
|
||||
<label>Target Price</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={targetPrice}
|
||||
onChange={(e) => setTargetPrice(e.target.value)}
|
||||
placeholder="Enter target price (e.g., 49.99)"
|
||||
/>
|
||||
<span className="hint">
|
||||
Notify when price drops to or below this amount ({product.currency || 'USD'})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="notification-form-row">
|
||||
<div className="notification-form-group">
|
||||
<label>Back in Stock Alert</label>
|
||||
<label className="notification-checkbox-group">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue