diff --git a/backend/src/index.ts b/backend/src/index.ts index 0d02ae7..c6867b9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,6 +5,7 @@ import dotenv from 'dotenv'; import authRoutes from './routes/auth'; import productRoutes from './routes/products'; import priceRoutes from './routes/prices'; +import settingsRoutes from './routes/settings'; import { startScheduler } from './services/scheduler'; // Load environment variables @@ -26,6 +27,7 @@ app.get('/health', (_, res) => { app.use('/api/auth', authRoutes); app.use('/api/products', productRoutes); app.use('/api/products', priceRoutes); +app.use('/api/settings', settingsRoutes); // Error handling middleware app.use( diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 3d0e2ae..6347475 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -5,9 +5,18 @@ export interface User { id: number; email: string; password_hash: string; + telegram_bot_token: string | null; + telegram_chat_id: string | null; + discord_webhook_url: string | null; created_at: Date; } +export interface NotificationSettings { + telegram_bot_token: string | null; + telegram_chat_id: string | null; + discord_webhook_url: string | null; +} + export const userQueries = { findByEmail: async (email: string): Promise => { const result = await pool.query( @@ -32,6 +41,46 @@ export const userQueries = { ); return result.rows[0]; }, + + getNotificationSettings: async (id: number): Promise => { + const result = await pool.query( + 'SELECT telegram_bot_token, telegram_chat_id, discord_webhook_url FROM users WHERE id = $1', + [id] + ); + return result.rows[0] || null; + }, + + updateNotificationSettings: async ( + id: number, + settings: Partial + ): Promise => { + const fields: string[] = []; + const values: (string | null)[] = []; + let paramIndex = 1; + + if (settings.telegram_bot_token !== undefined) { + fields.push(`telegram_bot_token = $${paramIndex++}`); + values.push(settings.telegram_bot_token); + } + if (settings.telegram_chat_id !== undefined) { + fields.push(`telegram_chat_id = $${paramIndex++}`); + values.push(settings.telegram_chat_id); + } + if (settings.discord_webhook_url !== undefined) { + fields.push(`discord_webhook_url = $${paramIndex++}`); + values.push(settings.discord_webhook_url); + } + + if (fields.length === 0) return null; + + values.push(id.toString()); + const result = await pool.query( + `UPDATE users SET ${fields.join(', ')} WHERE id = $${paramIndex} + RETURNING telegram_bot_token, telegram_chat_id, discord_webhook_url`, + values + ); + return result.rows[0] || null; + }, }; // Product types and queries @@ -46,6 +95,8 @@ export interface Product { refresh_interval: number; last_checked: Date | null; stock_status: StockStatus; + price_drop_threshold: number | null; + notify_back_in_stock: boolean; created_at: Date; } @@ -177,10 +228,15 @@ export const productQueries = { update: async ( id: number, userId: number, - updates: { name?: string; refresh_interval?: number } + updates: { + name?: string; + refresh_interval?: number; + price_drop_threshold?: number | null; + notify_back_in_stock?: boolean; + } ): Promise => { const fields: string[] = []; - const values: (string | number)[] = []; + const values: (string | number | boolean | null)[] = []; let paramIndex = 1; if (updates.name !== undefined) { @@ -191,6 +247,14 @@ export const productQueries = { fields.push(`refresh_interval = $${paramIndex++}`); values.push(updates.refresh_interval); } + if (updates.price_drop_threshold !== undefined) { + fields.push(`price_drop_threshold = $${paramIndex++}`); + values.push(updates.price_drop_threshold); + } + if (updates.notify_back_in_stock !== undefined) { + fields.push(`notify_back_in_stock = $${paramIndex++}`); + values.push(updates.notify_back_in_stock); + } if (fields.length === 0) return null; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts new file mode 100644 index 0000000..9ed46f7 --- /dev/null +++ b/backend/src/routes/settings.ts @@ -0,0 +1,130 @@ +import { Router, Response } from 'express'; +import { AuthRequest, authMiddleware } from '../middleware/auth'; +import { userQueries } from '../models'; + +const router = Router(); + +// All routes require authentication +router.use(authMiddleware); + +// Get notification settings +router.get('/notifications', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const settings = await userQueries.getNotificationSettings(userId); + + if (!settings) { + res.status(404).json({ error: 'User not found' }); + return; + } + + // Don't expose full bot token, just indicate if it's set + res.json({ + telegram_configured: !!(settings.telegram_bot_token && settings.telegram_chat_id), + telegram_chat_id: settings.telegram_chat_id, + discord_configured: !!settings.discord_webhook_url, + }); + } catch (error) { + console.error('Error fetching notification settings:', error); + res.status(500).json({ error: 'Failed to fetch notification settings' }); + } +}); + +// Update notification settings +router.put('/notifications', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const { telegram_bot_token, telegram_chat_id, discord_webhook_url } = req.body; + + const settings = await userQueries.updateNotificationSettings(userId, { + telegram_bot_token, + telegram_chat_id, + discord_webhook_url, + }); + + if (!settings) { + res.status(400).json({ error: 'No settings to update' }); + return; + } + + res.json({ + telegram_configured: !!(settings.telegram_bot_token && settings.telegram_chat_id), + telegram_chat_id: settings.telegram_chat_id, + discord_configured: !!settings.discord_webhook_url, + message: 'Notification settings updated successfully', + }); + } catch (error) { + console.error('Error updating notification settings:', error); + res.status(500).json({ error: 'Failed to update notification settings' }); + } +}); + +// Test Telegram notification +router.post('/notifications/test/telegram', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const settings = await userQueries.getNotificationSettings(userId); + + if (!settings?.telegram_bot_token || !settings?.telegram_chat_id) { + res.status(400).json({ error: 'Telegram not configured' }); + return; + } + + const { sendTelegramNotification } = await import('../services/notifications'); + const success = await sendTelegramNotification( + settings.telegram_bot_token, + settings.telegram_chat_id, + { + productName: 'Test Product', + productUrl: 'https://example.com', + type: 'price_drop', + oldPrice: 29.99, + newPrice: 19.99, + currency: 'USD', + } + ); + + if (success) { + res.json({ message: 'Test notification sent successfully' }); + } else { + res.status(500).json({ error: 'Failed to send test notification' }); + } + } catch (error) { + console.error('Error sending test Telegram notification:', error); + res.status(500).json({ error: 'Failed to send test notification' }); + } +}); + +// Test Discord notification +router.post('/notifications/test/discord', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const settings = await userQueries.getNotificationSettings(userId); + + if (!settings?.discord_webhook_url) { + res.status(400).json({ error: 'Discord not configured' }); + return; + } + + const { sendDiscordNotification } = await import('../services/notifications'); + const success = await sendDiscordNotification(settings.discord_webhook_url, { + productName: 'Test Product', + productUrl: 'https://example.com', + type: 'price_drop', + oldPrice: 29.99, + newPrice: 19.99, + currency: 'USD', + }); + + if (success) { + res.json({ message: 'Test notification sent successfully' }); + } else { + res.status(500).json({ error: 'Failed to send test notification' }); + } + } catch (error) { + console.error('Error sending test Discord notification:', error); + res.status(500).json({ error: 'Failed to send test notification' }); + } +}); + +export default router; diff --git a/backend/src/services/notifications.ts b/backend/src/services/notifications.ts new file mode 100644 index 0000000..a463366 --- /dev/null +++ b/backend/src/services/notifications.ts @@ -0,0 +1,137 @@ +import axios from 'axios'; + +export interface NotificationPayload { + productName: string; + productUrl: string; + type: 'price_drop' | 'back_in_stock'; + oldPrice?: number; + newPrice?: number; + currency?: string; + threshold?: number; +} + +function formatMessage(payload: NotificationPayload): string { + const currencySymbol = payload.currency === 'EUR' ? '€' : payload.currency === 'GBP' ? '£' : '$'; + + if (payload.type === 'price_drop') { + const oldPriceStr = payload.oldPrice ? `${currencySymbol}${payload.oldPrice.toFixed(2)}` : 'N/A'; + const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A'; + const dropAmount = payload.oldPrice && payload.newPrice + ? `${currencySymbol}${(payload.oldPrice - payload.newPrice).toFixed(2)}` + : ''; + + return `🔔 Price Drop Alert!\n\n` + + `📦 ${payload.productName}\n\n` + + `💰 Price dropped from ${oldPriceStr} to ${newPriceStr}` + + (dropAmount ? ` (-${dropAmount})` : '') + `\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` + + `📦 ${payload.productName}\n\n` + + `✅ This item is now available${priceStr}\n\n` + + `🔗 ${payload.productUrl}`; + } + + return ''; +} + +export async function sendTelegramNotification( + botToken: string, + chatId: string, + payload: NotificationPayload +): Promise { + try { + const message = formatMessage(payload); + const url = `https://api.telegram.org/bot${botToken}/sendMessage`; + + await axios.post(url, { + chat_id: chatId, + text: message, + parse_mode: 'HTML', + disable_web_page_preview: false, + }); + + console.log(`Telegram notification sent to chat ${chatId}`); + return true; + } catch (error) { + console.error('Failed to send Telegram notification:', error); + return false; + } +} + +export async function sendDiscordNotification( + webhookUrl: string, + payload: NotificationPayload +): Promise { + try { + const currencySymbol = payload.currency === 'EUR' ? '€' : payload.currency === 'GBP' ? '£' : '$'; + + let embed; + if (payload.type === 'price_drop') { + const oldPriceStr = payload.oldPrice ? `${currencySymbol}${payload.oldPrice.toFixed(2)}` : 'N/A'; + const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A'; + + embed = { + title: '🔔 Price Drop Alert!', + description: payload.productName, + color: 0x10b981, // Green + fields: [ + { name: 'Old Price', value: oldPriceStr, inline: true }, + { name: 'New Price', value: newPriceStr, inline: true }, + ], + url: payload.productUrl, + timestamp: new Date().toISOString(), + }; + } else { + const priceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'Check link'; + + embed = { + title: '🎉 Back in Stock!', + description: payload.productName, + color: 0x6366f1, // Indigo + fields: [ + { name: 'Price', value: priceStr, inline: true }, + { name: 'Status', value: '✅ Available', inline: true }, + ], + url: payload.productUrl, + timestamp: new Date().toISOString(), + }; + } + + await axios.post(webhookUrl, { + embeds: [embed], + }); + + console.log('Discord notification sent'); + return true; + } catch (error) { + console.error('Failed to send Discord notification:', error); + return false; + } +} + +export async function sendNotifications( + settings: { + telegram_bot_token: string | null; + telegram_chat_id: string | null; + discord_webhook_url: string | null; + }, + payload: NotificationPayload +): Promise { + const promises: Promise[] = []; + + if (settings.telegram_bot_token && settings.telegram_chat_id) { + promises.push( + sendTelegramNotification(settings.telegram_bot_token, settings.telegram_chat_id, payload) + ); + } + + if (settings.discord_webhook_url) { + promises.push(sendDiscordNotification(settings.discord_webhook_url, payload)); + } + + await Promise.allSettled(promises); +} diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index 74069db..4bccf6f 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -1,6 +1,7 @@ import cron from 'node-cron'; -import { productQueries, priceHistoryQueries } from '../models'; +import { productQueries, priceHistoryQueries, userQueries } from '../models'; import { scrapeProduct } from './scraper'; +import { sendNotifications, NotificationPayload } from './notifications'; let isRunning = false; @@ -24,12 +25,36 @@ async function checkPrices(): Promise { const scrapedData = await scrapeProduct(product.url); + // Check for back-in-stock notification + const wasOutOfStock = product.stock_status === 'out_of_stock'; + const nowInStock = scrapedData.stockStatus === 'in_stock'; + // Update stock status if (scrapedData.stockStatus !== product.stock_status) { await productQueries.updateStockStatus(product.id, scrapedData.stockStatus); console.log( `Stock status changed for product ${product.id}: ${product.stock_status} -> ${scrapedData.stockStatus}` ); + + // Send back-in-stock notification + if (wasOutOfStock && nowInStock && product.notify_back_in_stock) { + try { + const userSettings = await userQueries.getNotificationSettings(product.user_id); + if (userSettings) { + const payload: NotificationPayload = { + productName: product.name || 'Unknown Product', + productUrl: product.url, + type: 'back_in_stock', + newPrice: scrapedData.price?.price, + currency: scrapedData.price?.currency || 'USD', + }; + await sendNotifications(userSettings, payload); + console.log(`Back-in-stock notification sent for product ${product.id}`); + } + } catch (notifyError) { + console.error(`Failed to send back-in-stock notification for product ${product.id}:`, notifyError); + } + } } if (scrapedData.price) { @@ -38,6 +63,34 @@ async function checkPrices(): Promise { // Only record if price has changed or it's the first entry if (!latestPrice || latestPrice.price !== scrapedData.price.price) { + // Check for price drop notification before recording + if (latestPrice && product.price_drop_threshold) { + const oldPrice = parseFloat(String(latestPrice.price)); + const newPrice = scrapedData.price.price; + const priceDrop = oldPrice - newPrice; + + if (priceDrop >= product.price_drop_threshold) { + try { + const userSettings = await userQueries.getNotificationSettings(product.user_id); + if (userSettings) { + const payload: NotificationPayload = { + productName: product.name || 'Unknown Product', + productUrl: product.url, + type: 'price_drop', + oldPrice: oldPrice, + newPrice: newPrice, + currency: scrapedData.price.currency, + threshold: product.price_drop_threshold, + }; + await sendNotifications(userSettings, payload); + console.log(`Price drop notification sent for product ${product.id}: ${priceDrop} drop`); + } + } catch (notifyError) { + console.error(`Failed to send price drop notification for product ${product.id}:`, notifyError); + } + } + } + await priceHistoryQueries.create( product.id, scrapedData.price.price, diff --git a/database/init.sql b/database/init.sql index b0820ce..93f81d5 100644 --- a/database/init.sql +++ b/database/init.sql @@ -5,9 +5,25 @@ CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, + telegram_bot_token VARCHAR(255), + telegram_chat_id VARCHAR(255), + discord_webhook_url TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Migration: Add notification columns to users if they don't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'telegram_bot_token' + ) THEN + ALTER TABLE users ADD COLUMN telegram_bot_token VARCHAR(255); + ALTER TABLE users ADD COLUMN telegram_chat_id VARCHAR(255); + ALTER TABLE users ADD COLUMN discord_webhook_url TEXT; + END IF; +END $$; + -- Products table CREATE TABLE IF NOT EXISTS products ( id SERIAL PRIMARY KEY, @@ -18,6 +34,8 @@ CREATE TABLE IF NOT EXISTS products ( refresh_interval INTEGER DEFAULT 3600, last_checked TIMESTAMP, stock_status VARCHAR(20) DEFAULT 'unknown', + price_drop_threshold DECIMAL(10,2), + notify_back_in_stock BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, url) ); @@ -33,6 +51,18 @@ BEGIN END IF; END $$; +-- Migration: Add notification columns to products if they don't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'products' AND column_name = 'price_drop_threshold' + ) THEN + ALTER TABLE products ADD COLUMN price_drop_threshold DECIMAL(10,2); + ALTER TABLE products ADD COLUMN notify_back_in_stock BOOLEAN DEFAULT false; + END IF; +END $$; + -- Price history table CREATE TABLE IF NOT EXISTS price_history ( id SERIAL PRIMARY KEY, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 239386d..6269233 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import Login from './pages/Login'; import Register from './pages/Register'; import Dashboard from './pages/Dashboard'; import ProductDetail from './pages/ProductDetail'; +import Settings from './pages/Settings'; function ThemeInitializer({ children }: { children: React.ReactNode }) { useEffect(() => { @@ -101,6 +102,14 @@ function AppRoutes() { } /> + + + + } + /> } /> ); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 086c676..e15eb66 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -57,6 +57,8 @@ export interface Product { refresh_interval: number; last_checked: string | null; stock_status: StockStatus; + price_drop_threshold: number | null; + notify_back_in_stock: boolean; created_at: string; current_price: number | null; currency: string | null; @@ -89,8 +91,12 @@ export const productsApi = { create: (url: string, refreshInterval?: number) => api.post('/products', { url, refresh_interval: refreshInterval }), - update: (id: number, data: { name?: string; refresh_interval?: number }) => - api.put(`/products/${id}`, data), + update: (id: number, data: { + name?: string; + refresh_interval?: number; + price_drop_threshold?: number | null; + notify_back_in_stock?: boolean; + }) => api.put(`/products/${id}`, data), delete: (id: number) => api.delete(`/products/${id}`), }; @@ -109,4 +115,28 @@ export const pricesApi = { ), }; +// Settings API +export interface NotificationSettings { + telegram_configured: boolean; + telegram_chat_id: string | null; + discord_configured: boolean; +} + +export const settingsApi = { + getNotifications: () => + api.get('/settings/notifications'), + + updateNotifications: (data: { + telegram_bot_token?: string | null; + telegram_chat_id?: string | null; + discord_webhook_url?: string | null; + }) => api.put('/settings/notifications', data), + + testTelegram: () => + api.post<{ message: string }>('/settings/notifications/test/telegram'), + + testDiscord: () => + api.post<{ message: string }>('/settings/notifications/test/discord'), +}; + export default api; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index de7ab11..6ee2ea9 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useState, useEffect } from 'react'; +import { ReactNode, useState, useEffect, useRef } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; @@ -13,12 +13,25 @@ export default function Layout({ children }: LayoutProps) { const saved = localStorage.getItem('theme'); return (saved as 'light' | 'dark') || 'light'; }); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const dropdownRef = useRef(null); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); }, [theme]); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + const toggleTheme = () => { setTheme((prev) => (prev === 'light' ? 'dark' : 'light')); }; @@ -110,8 +123,113 @@ export default function Layout({ children }: LayoutProps) { margin: 0 auto; } + .user-dropdown { + position: relative; + } + + .user-dropdown-trigger { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--background); + border: 1px solid var(--border); + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s; + } + + .user-dropdown-trigger:hover { + border-color: var(--primary); + } + + .user-dropdown-avatar { + width: 28px; + height: 28px; + background: var(--primary); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + } + + .user-dropdown-email { + color: var(--text); + font-size: 0.875rem; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .user-dropdown-arrow { + color: var(--text-muted); + transition: transform 0.2s; + } + + .user-dropdown-trigger.open .user-dropdown-arrow { + transform: rotate(180deg); + } + + .user-dropdown-menu { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + min-width: 200px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + box-shadow: var(--shadow-lg); + overflow: hidden; + z-index: 1000; + } + + .user-dropdown-menu-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: var(--text); + text-decoration: none; + transition: background 0.2s; + border: none; + background: none; + width: 100%; + text-align: left; + cursor: pointer; + font-size: 0.875rem; + } + + .user-dropdown-menu-item:hover { + background: var(--background); + text-decoration: none; + } + + .user-dropdown-menu-item svg { + width: 18px; + height: 18px; + color: var(--text-muted); + } + + .user-dropdown-divider { + height: 1px; + background: var(--border); + margin: 0.25rem 0; + } + + .user-dropdown-menu-item.danger { + color: var(--danger); + } + + .user-dropdown-menu-item.danger svg { + color: var(--danger); + } + @media (max-width: 640px) { - .navbar-email { + .navbar-email, .user-dropdown-email { display: none; } } @@ -133,12 +251,60 @@ export default function Layout({ children }: LayoutProps) { {theme === 'light' ? '🌙' : '☀️'} {user && ( - <> - {user.email} - - + {isDropdownOpen && ( +
+ setIsDropdownOpen(false)} + > + + + + + Settings + +
+ +
+ )} +
)} diff --git a/frontend/src/components/ProductCard.tsx b/frontend/src/components/ProductCard.tsx index c81964a..4502b7a 100644 --- a/frontend/src/components/ProductCard.tsx +++ b/frontend/src/components/ProductCard.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; import { Product } from '../api/client'; import Sparkline from './Sparkline'; @@ -5,9 +6,20 @@ import Sparkline from './Sparkline'; interface ProductCardProps { product: Product; onDelete: (id: number) => void; + onRefresh: (id: number) => Promise; } -export default function ProductCard({ product, onDelete }: ProductCardProps) { +export default function ProductCard({ product, onDelete, onRefresh }: ProductCardProps) { + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + await onRefresh(product.id); + } finally { + setIsRefreshing(false); + } + }; const formatPrice = (price: number | string | null, currency: string | null) => { if (price === null || price === undefined) return 'N/A'; const numPrice = typeof price === 'string' ? parseFloat(price) : price; @@ -185,6 +197,28 @@ export default function ProductCard({ product, onDelete }: ProductCardProps) { font-size: 0.8125rem; } + .product-actions .btn-icon { + padding: 0.5rem; + min-width: unset; + display: flex; + align-items: center; + justify-content: center; + } + + .product-actions .btn-icon svg { + width: 16px; + height: 16px; + } + + .product-actions .btn-icon.refreshing svg { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + @media (max-width: 768px) { .product-list-item { flex-wrap: wrap; @@ -269,15 +303,30 @@ export default function ProductCard({ product, onDelete }: ProductCardProps) {
+ View
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 409c36d..ce6e275 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from 'react'; import Layout from '../components/Layout'; import ProductCard from '../components/ProductCard'; import ProductForm from '../components/ProductForm'; -import { productsApi, Product } from '../api/client'; +import { productsApi, pricesApi, Product } from '../api/client'; export default function Dashboard() { const [products, setProducts] = useState([]); @@ -43,6 +43,16 @@ export default function Dashboard() { } }; + const handleRefreshProduct = async (id: number) => { + try { + await pricesApi.refresh(id); + // Refresh the products list to get updated data + await fetchProducts(); + } catch { + alert('Failed to refresh price'); + } + }; + const filteredProducts = useMemo(() => { if (!searchQuery.trim()) return products; const query = searchQuery.toLowerCase(); @@ -256,6 +266,7 @@ export default function Dashboard() { key={product.id} product={product} onDelete={handleDeleteProduct} + onRefresh={handleRefreshProduct} /> ))} diff --git a/frontend/src/pages/ProductDetail.tsx b/frontend/src/pages/ProductDetail.tsx index 94b5a9a..d2968b0 100644 --- a/frontend/src/pages/ProductDetail.tsx +++ b/frontend/src/pages/ProductDetail.tsx @@ -5,8 +5,10 @@ import PriceChart from '../components/PriceChart'; import { productsApi, pricesApi, + settingsApi, ProductWithStats, PriceHistory, + NotificationSettings, } from '../api/client'; export default function ProductDetail() { @@ -17,7 +19,22 @@ export default function ProductDetail() { const [prices, setPrices] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isSavingNotifications, setIsSavingNotifications] = useState(false); const [error, setError] = useState(''); + const [notificationSettings, setNotificationSettings] = useState(null); + const [priceDropThreshold, setPriceDropThreshold] = useState(''); + const [notifyBackInStock, setNotifyBackInStock] = useState(false); + + const REFRESH_INTERVALS = [ + { value: 1800, label: '30 minutes' }, + { value: 3600, label: '1 hour' }, + { value: 7200, label: '2 hours' }, + { value: 14400, label: '4 hours' }, + { value: 21600, label: '6 hours' }, + { value: 43200, label: '12 hours' }, + { value: 86400, label: '24 hours' }, + ]; const productId = parseInt(id || '0', 10); @@ -29,6 +46,11 @@ export default function ProductDetail() { ]); setProduct(productRes.data); setPrices(pricesRes.data.prices); + // Initialize notification form fields from product data + if (productRes.data.price_drop_threshold !== null && productRes.data.price_drop_threshold !== undefined) { + setPriceDropThreshold(productRes.data.price_drop_threshold.toString()); + } + setNotifyBackInStock(productRes.data.notify_back_in_stock || false); } catch { setError('Failed to load product details'); } finally { @@ -36,9 +58,19 @@ export default function ProductDetail() { } }; + const fetchNotificationSettings = async () => { + try { + const response = await settingsApi.getNotifications(); + setNotificationSettings(response.data); + } catch { + // Silently fail - notifications just won't be shown + } + }; + useEffect(() => { if (productId) { fetchData(30); + fetchNotificationSettings(); } }, [productId]); @@ -71,6 +103,40 @@ export default function ProductDetail() { fetchData(days); }; + const handleRefreshIntervalChange = async (newInterval: number) => { + if (!product) return; + setIsSaving(true); + try { + await productsApi.update(productId, { refresh_interval: newInterval }); + setProduct({ ...product, refresh_interval: newInterval }); + } catch { + alert('Failed to update refresh interval'); + } finally { + setIsSaving(false); + } + }; + + const handleSaveNotifications = async () => { + if (!product) return; + setIsSavingNotifications(true); + try { + const threshold = priceDropThreshold ? parseFloat(priceDropThreshold) : null; + await productsApi.update(productId, { + price_drop_threshold: threshold, + notify_back_in_stock: notifyBackInStock, + }); + setProduct({ + ...product, + price_drop_threshold: threshold, + notify_back_in_stock: notifyBackInStock, + }); + } catch { + alert('Failed to save notification settings'); + } finally { + setIsSavingNotifications(false); + } + }; + const formatPrice = (price: number | string | null, currency: string | null) => { if (price === null || price === undefined) return 'N/A'; const numPrice = typeof price === 'string' ? parseFloat(price) : price; @@ -285,6 +351,33 @@ export default function ProductDetail() { gap: 0.75rem; margin-top: 1.5rem; } + + .product-detail-meta-select { + padding: 0.375rem 0.5rem; + border: 1px solid var(--border); + border-radius: 0.375rem; + background: var(--surface); + color: var(--text); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: border-color 0.2s; + } + + .product-detail-meta-select:hover { + border-color: var(--primary); + } + + .product-detail-meta-select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1); + } + + .product-detail-meta-select:disabled { + opacity: 0.6; + cursor: not-allowed; + } `}
@@ -355,11 +448,18 @@ export default function ProductDetail() {
Check Interval - - {product.refresh_interval < 3600 - ? `${product.refresh_interval / 60} minutes` - : `${product.refresh_interval / 3600} hour(s)`} - +
Tracking Since @@ -400,6 +500,212 @@ export default function ProductDetail() { currency={product.currency || 'USD'} onRangeChange={handleRangeChange} /> + + {notificationSettings && (notificationSettings.telegram_configured || notificationSettings.discord_configured) && ( + <> + + +
+
+ 🔔 +

Notification Settings

+
+ {notificationSettings.telegram_configured && ( + Telegram + )} + {notificationSettings.discord_configured && ( + Discord + )} +
+
+

+ Get notified when this product's price drops or comes back in stock. + Notifications will be sent to your configured channels. +

+ +
+
+ + setPriceDropThreshold(e.target.value)} + placeholder="Enter amount (e.g., 5.00)" + /> + + Notify when price drops by at least this amount ({product.currency || 'USD'}) + +
+ +
+ + +
+
+ +
+ +
+
+ + )} ); } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..299fb2f --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,365 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import Layout from '../components/Layout'; +import { settingsApi, NotificationSettings } from '../api/client'; + +export default function Settings() { + const [settings, setSettings] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState<'telegram' | 'discord' | null>(null); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + // Form state + const [telegramBotToken, setTelegramBotToken] = useState(''); + const [telegramChatId, setTelegramChatId] = useState(''); + const [discordWebhookUrl, setDiscordWebhookUrl] = useState(''); + + useEffect(() => { + fetchSettings(); + }, []); + + const fetchSettings = async () => { + try { + const response = await settingsApi.getNotifications(); + setSettings(response.data); + if (response.data.telegram_chat_id) { + setTelegramChatId(response.data.telegram_chat_id); + } + } catch { + setError('Failed to load settings'); + } finally { + setIsLoading(false); + } + }; + + const handleSaveTelegram = async () => { + setIsSaving(true); + setError(''); + setSuccess(''); + try { + const response = await settingsApi.updateNotifications({ + telegram_bot_token: telegramBotToken || null, + telegram_chat_id: telegramChatId || null, + }); + setSettings(response.data); + setTelegramBotToken(''); + setSuccess('Telegram settings saved successfully'); + } catch { + setError('Failed to save Telegram settings'); + } finally { + setIsSaving(false); + } + }; + + const handleSaveDiscord = async () => { + setIsSaving(true); + setError(''); + setSuccess(''); + try { + const response = await settingsApi.updateNotifications({ + discord_webhook_url: discordWebhookUrl || null, + }); + setSettings(response.data); + setDiscordWebhookUrl(''); + setSuccess('Discord settings saved successfully'); + } catch { + setError('Failed to save Discord settings'); + } finally { + setIsSaving(false); + } + }; + + const handleTestTelegram = async () => { + setIsTesting('telegram'); + setError(''); + setSuccess(''); + try { + await settingsApi.testTelegram(); + setSuccess('Test notification sent to Telegram!'); + } catch { + setError('Failed to send test notification. Check your settings.'); + } finally { + setIsTesting(null); + } + }; + + const handleTestDiscord = async () => { + setIsTesting('discord'); + setError(''); + setSuccess(''); + try { + await settingsApi.testDiscord(); + setSuccess('Test notification sent to Discord!'); + } catch { + setError('Failed to send test notification. Check your webhook URL.'); + } finally { + setIsTesting(null); + } + }; + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + return ( + + + +
+ + ← Back to Dashboard + +

Settings

+

Configure notifications and preferences

+
+ + {error &&
{error}
} + {success &&
{success}
} + +
+
+ 📱 +

Telegram Notifications

+ + {settings?.telegram_configured ? 'Configured' : 'Not configured'} + +
+

+ Receive price drop and back-in-stock alerts via Telegram. You'll need to create a Telegram bot + and get your chat ID. +

+ +
+ + setTelegramBotToken(e.target.value)} + placeholder={settings?.telegram_configured ? '••••••••••••••••' : 'Enter your bot token'} + /> +

Create a bot via @BotFather on Telegram to get a token

+
+ +
+ + setTelegramChatId(e.target.value)} + placeholder="Enter your chat ID" + /> +

Send /start to @userinfobot to get your chat ID

+
+ +
+ + {settings?.telegram_configured && ( + + )} +
+
+ +
+
+ 💬 +

Discord Notifications

+ + {settings?.discord_configured ? 'Configured' : 'Not configured'} + +
+

+ Receive price drop and back-in-stock alerts in a Discord channel. Create a webhook in your + Discord server settings. +

+ +
+ + setDiscordWebhookUrl(e.target.value)} + placeholder={settings?.discord_configured ? '••••••••••••••••' : 'https://discord.com/api/webhooks/...'} + /> +

Server Settings → Integrations → Webhooks → New Webhook

+
+ +
+ + {settings?.discord_configured && ( + + )} +
+
+
+ ); +}