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;
|
next_check_at: Date | null;
|
||||||
stock_status: StockStatus;
|
stock_status: StockStatus;
|
||||||
price_drop_threshold: number | null;
|
price_drop_threshold: number | null;
|
||||||
|
target_price: number | null;
|
||||||
notify_back_in_stock: boolean;
|
notify_back_in_stock: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -222,6 +223,7 @@ export interface SparklinePoint {
|
||||||
export interface ProductWithSparkline extends ProductWithLatestPrice {
|
export interface ProductWithSparkline extends ProductWithLatestPrice {
|
||||||
sparkline: SparklinePoint[];
|
sparkline: SparklinePoint[];
|
||||||
price_change_7d: number | null;
|
price_change_7d: number | null;
|
||||||
|
min_price: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const productQueries = {
|
export const productQueries = {
|
||||||
|
|
@ -272,6 +274,15 @@ export const productQueries = {
|
||||||
[productIds]
|
[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
|
// Group sparkline data by product
|
||||||
const sparklineMap = new Map<number, SparklinePoint[]>();
|
const sparklineMap = new Map<number, SparklinePoint[]>();
|
||||||
for (const row of sparklineResult.rows) {
|
for (const row of sparklineResult.rows) {
|
||||||
|
|
@ -280,6 +291,12 @@ export const productQueries = {
|
||||||
sparklineMap.set(row.product_id, points);
|
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
|
// Combine products with sparkline data
|
||||||
return products.map((product: ProductWithLatestPrice) => {
|
return products.map((product: ProductWithLatestPrice) => {
|
||||||
const sparkline = sparklineMap.get(product.id) || [];
|
const sparkline = sparklineMap.get(product.id) || [];
|
||||||
|
|
@ -297,6 +314,7 @@ export const productQueries = {
|
||||||
...product,
|
...product,
|
||||||
sparkline,
|
sparkline,
|
||||||
price_change_7d: priceChange7d,
|
price_change_7d: priceChange7d,
|
||||||
|
min_price: minPriceMap.get(product.id) || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -344,6 +362,7 @@ export const productQueries = {
|
||||||
name?: string;
|
name?: string;
|
||||||
refresh_interval?: number;
|
refresh_interval?: number;
|
||||||
price_drop_threshold?: number | null;
|
price_drop_threshold?: number | null;
|
||||||
|
target_price?: number | null;
|
||||||
notify_back_in_stock?: boolean;
|
notify_back_in_stock?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<Product | null> => {
|
): Promise<Product | null> => {
|
||||||
|
|
@ -363,6 +382,10 @@ export const productQueries = {
|
||||||
fields.push(`price_drop_threshold = $${paramIndex++}`);
|
fields.push(`price_drop_threshold = $${paramIndex++}`);
|
||||||
values.push(updates.price_drop_threshold);
|
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) {
|
if (updates.notify_back_in_stock !== undefined) {
|
||||||
fields.push(`notify_back_in_stock = $${paramIndex++}`);
|
fields.push(`notify_back_in_stock = $${paramIndex++}`);
|
||||||
values.push(updates.notify_back_in_stock);
|
values.push(updates.notify_back_in_stock);
|
||||||
|
|
|
||||||
|
|
@ -129,12 +129,13 @@ router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
return;
|
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, {
|
const product = await productQueries.update(productId, userId, {
|
||||||
name,
|
name,
|
||||||
refresh_interval,
|
refresh_interval,
|
||||||
price_drop_threshold,
|
price_drop_threshold,
|
||||||
|
target_price,
|
||||||
notify_back_in_stock,
|
notify_back_in_stock,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ import axios from 'axios';
|
||||||
export interface NotificationPayload {
|
export interface NotificationPayload {
|
||||||
productName: string;
|
productName: string;
|
||||||
productUrl: string;
|
productUrl: string;
|
||||||
type: 'price_drop' | 'back_in_stock';
|
type: 'price_drop' | 'back_in_stock' | 'target_price';
|
||||||
oldPrice?: number;
|
oldPrice?: number;
|
||||||
newPrice?: number;
|
newPrice?: number;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
threshold?: number;
|
threshold?: number;
|
||||||
|
targetPrice?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMessage(payload: NotificationPayload): string {
|
function formatMessage(payload: NotificationPayload): string {
|
||||||
|
|
@ -27,6 +28,16 @@ function formatMessage(payload: NotificationPayload): string {
|
||||||
`🔗 ${payload.productUrl}`;
|
`🔗 ${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') {
|
if (payload.type === 'back_in_stock') {
|
||||||
const priceStr = payload.newPrice ? ` at ${currencySymbol}${payload.newPrice.toFixed(2)}` : '';
|
const priceStr = payload.newPrice ? ` at ${currencySymbol}${payload.newPrice.toFixed(2)}` : '';
|
||||||
return `🎉 Back in Stock!\n\n` +
|
return `🎉 Back in Stock!\n\n` +
|
||||||
|
|
@ -85,6 +96,21 @@ export async function sendDiscordNotification(
|
||||||
url: payload.productUrl,
|
url: payload.productUrl,
|
||||||
timestamp: new Date().toISOString(),
|
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 {
|
} else {
|
||||||
const priceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'Check link';
|
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(
|
await priceHistoryQueries.create(
|
||||||
product.id,
|
product.id,
|
||||||
scrapedData.price.price,
|
scrapedData.price.price,
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS products (
|
||||||
next_check_at TIMESTAMP,
|
next_check_at TIMESTAMP,
|
||||||
stock_status VARCHAR(20) DEFAULT 'unknown',
|
stock_status VARCHAR(20) DEFAULT 'unknown',
|
||||||
price_drop_threshold DECIMAL(10,2),
|
price_drop_threshold DECIMAL(10,2),
|
||||||
|
target_price DECIMAL(10,2),
|
||||||
notify_back_in_stock BOOLEAN DEFAULT false,
|
notify_back_in_stock BOOLEAN DEFAULT false,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(user_id, url)
|
UNIQUE(user_id, url)
|
||||||
|
|
@ -107,6 +108,17 @@ BEGIN
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
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
|
-- Price history table
|
||||||
CREATE TABLE IF NOT EXISTS price_history (
|
CREATE TABLE IF NOT EXISTS price_history (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
|
||||||
|
|
@ -61,12 +61,14 @@ export interface Product {
|
||||||
last_checked: string | null;
|
last_checked: string | null;
|
||||||
stock_status: StockStatus;
|
stock_status: StockStatus;
|
||||||
price_drop_threshold: number | null;
|
price_drop_threshold: number | null;
|
||||||
|
target_price: number | null;
|
||||||
notify_back_in_stock: boolean;
|
notify_back_in_stock: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
current_price: number | null;
|
current_price: number | null;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
sparkline?: SparklinePoint[];
|
sparkline?: SparklinePoint[];
|
||||||
price_change_7d?: number | null;
|
price_change_7d?: number | null;
|
||||||
|
min_price?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductWithStats extends Product {
|
export interface ProductWithStats extends Product {
|
||||||
|
|
@ -98,6 +100,7 @@ export const productsApi = {
|
||||||
name?: string;
|
name?: string;
|
||||||
refresh_interval?: number;
|
refresh_interval?: number;
|
||||||
price_drop_threshold?: number | null;
|
price_drop_threshold?: number | null;
|
||||||
|
target_price?: number | null;
|
||||||
notify_back_in_stock?: boolean;
|
notify_back_in_stock?: boolean;
|
||||||
}) => api.put<Product>(`/products/${id}`, data),
|
}) => api.put<Product>(`/products/${id}`, data),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,12 @@ interface ProductCardProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
onRefresh: (id: number) => Promise<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 [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
|
|
@ -54,8 +57,14 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
|
||||||
|
|
||||||
const isOutOfStock = product.stock_status === 'out_of_stock';
|
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 (
|
return (
|
||||||
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''}`}>
|
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''} ${isSelected ? 'selected' : ''}`}>
|
||||||
<style>{`
|
<style>{`
|
||||||
.product-list-item {
|
.product-list-item {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|
@ -73,6 +82,19 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
|
||||||
transform: translateY(-1px);
|
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 {
|
.product-thumbnail {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
|
|
@ -199,6 +221,26 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
|
||||||
color: #f87171;
|
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 {
|
.product-list-item.out-of-stock {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
@ -284,6 +326,15 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
|
{showCheckbox && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="product-checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => onSelect?.(product.id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{product.image_url ? (
|
{product.image_url ? (
|
||||||
<img
|
<img
|
||||||
src={product.image_url}
|
src={product.image_url}
|
||||||
|
|
@ -297,7 +348,7 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
|
||||||
<div className="product-info">
|
<div className="product-info">
|
||||||
<h3 className="product-name">{product.name || 'Unknown Product'}</h3>
|
<h3 className="product-name">{product.name || 'Unknown Product'}</h3>
|
||||||
<p className="product-source">{truncateUrl(product.url)}</p>
|
<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">
|
<div className="product-notifications">
|
||||||
{product.price_drop_threshold && (
|
{product.price_drop_threshold && (
|
||||||
<span className="product-notification-badge" title="Price drop alert">
|
<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
|
${Number(product.price_drop_threshold).toFixed(2)} drop
|
||||||
</span>
|
</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 && (
|
{product.notify_back_in_stock && (
|
||||||
<span className="product-notification-badge" title="Back in stock alert">
|
<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">
|
<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)
|
{formatPriceChange(product.price_change_7d)} (7d)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isHistoricalLow && (
|
||||||
|
<span className="product-stock-badge historical-low">
|
||||||
|
Lowest Price
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isNearHistoricalLow && (
|
||||||
|
<span className="product-stock-badge near-low">
|
||||||
|
Near Low
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ export default function Dashboard() {
|
||||||
const saved = localStorage.getItem('dashboard_sort_order');
|
const saved = localStorage.getItem('dashboard_sort_order');
|
||||||
return (saved as SortOrder) || 'desc';
|
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 () => {
|
const fetchProducts = async () => {
|
||||||
try {
|
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) => {
|
const getWebsite = (url: string) => {
|
||||||
try {
|
try {
|
||||||
return new URL(url).hostname.replace('www.', '');
|
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(() => {
|
const filteredAndSortedProducts = useMemo(() => {
|
||||||
let result = [...products];
|
let result = [...products];
|
||||||
|
|
||||||
|
|
@ -321,6 +400,115 @@ export default function Dashboard() {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 4rem;
|
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>
|
`}</style>
|
||||||
|
|
||||||
<div className="dashboard-header">
|
<div className="dashboard-header">
|
||||||
|
|
@ -334,6 +522,43 @@ export default function Dashboard() {
|
||||||
|
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{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 && (
|
{!isLoading && products.length > 0 && (
|
||||||
<div className="dashboard-controls">
|
<div className="dashboard-controls">
|
||||||
<div className="search-container">
|
<div className="search-container">
|
||||||
|
|
@ -389,6 +614,39 @@ export default function Dashboard() {
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -426,6 +684,9 @@ export default function Dashboard() {
|
||||||
product={product}
|
product={product}
|
||||||
onDelete={handleDeleteProduct}
|
onDelete={handleDeleteProduct}
|
||||||
onRefresh={handleRefreshProduct}
|
onRefresh={handleRefreshProduct}
|
||||||
|
showCheckbox={bulkMode}
|
||||||
|
isSelected={selectedIds.has(product.id)}
|
||||||
|
onSelect={handleSelectProduct}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export default function ProductDetail() {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null);
|
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null);
|
||||||
const [priceDropThreshold, setPriceDropThreshold] = useState<string>('');
|
const [priceDropThreshold, setPriceDropThreshold] = useState<string>('');
|
||||||
|
const [targetPrice, setTargetPrice] = useState<string>('');
|
||||||
const [notifyBackInStock, setNotifyBackInStock] = useState(false);
|
const [notifyBackInStock, setNotifyBackInStock] = useState(false);
|
||||||
|
|
||||||
const REFRESH_INTERVALS = [
|
const REFRESH_INTERVALS = [
|
||||||
|
|
@ -50,6 +51,9 @@ export default function ProductDetail() {
|
||||||
if (productRes.data.price_drop_threshold !== null && productRes.data.price_drop_threshold !== undefined) {
|
if (productRes.data.price_drop_threshold !== null && productRes.data.price_drop_threshold !== undefined) {
|
||||||
setPriceDropThreshold(productRes.data.price_drop_threshold.toString());
|
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);
|
setNotifyBackInStock(productRes.data.notify_back_in_stock || false);
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load product details');
|
setError('Failed to load product details');
|
||||||
|
|
@ -121,13 +125,16 @@ export default function ProductDetail() {
|
||||||
setIsSavingNotifications(true);
|
setIsSavingNotifications(true);
|
||||||
try {
|
try {
|
||||||
const threshold = priceDropThreshold ? parseFloat(priceDropThreshold) : null;
|
const threshold = priceDropThreshold ? parseFloat(priceDropThreshold) : null;
|
||||||
|
const target = targetPrice ? parseFloat(targetPrice) : null;
|
||||||
await productsApi.update(productId, {
|
await productsApi.update(productId, {
|
||||||
price_drop_threshold: threshold,
|
price_drop_threshold: threshold,
|
||||||
|
target_price: target,
|
||||||
notify_back_in_stock: notifyBackInStock,
|
notify_back_in_stock: notifyBackInStock,
|
||||||
});
|
});
|
||||||
setProduct({
|
setProduct({
|
||||||
...product,
|
...product,
|
||||||
price_drop_threshold: threshold,
|
price_drop_threshold: threshold,
|
||||||
|
target_price: target,
|
||||||
notify_back_in_stock: notifyBackInStock,
|
notify_back_in_stock: notifyBackInStock,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -678,6 +685,23 @@ export default function ProductDetail() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="notification-form-group">
|
||||||
<label>Back in Stock Alert</label>
|
<label>Back in Stock Alert</label>
|
||||||
<label className="notification-checkbox-group">
|
<label className="notification-checkbox-group">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue