mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-04-25 00:36:32 +02:00
Add notification history feature with bell icon and history page
- Add notification_history database table for logging all triggered notifications - Create API endpoints for fetching recent and historical notifications - Add NotificationBell component in navbar with badge showing recent count - Dropdown shows 5 most recent notifications with links to products - Create full NotificationHistory page with filtering by notification type - Log notifications when sent: price drops, target prices, back-in-stock - Track which channels (telegram, discord, pushover, ntfy) received each notification - Update sendNotifications to return which channels succeeded - Bump version to 1.0.3 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
45363e4d97
commit
63fcaebfd8
12 changed files with 1244 additions and 16 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -5,6 +5,20 @@ All notable changes to PriceGhost will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.3] - 2026-01-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Notification History** - Complete log of all triggered notifications accessible via bell icon in navbar
|
||||||
|
- Bell icon with badge showing recent notification count (last 24 hours)
|
||||||
|
- Dropdown preview showing 5 most recent notifications
|
||||||
|
- Full history page at `/notifications` with filtering by type
|
||||||
|
- Tracks price drops, target price alerts, and back-in-stock notifications
|
||||||
|
- Shows which channels (Telegram, Discord, Pushover, ntfy) received each notification
|
||||||
|
- Links to product detail pages from notification entries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.0.2] - 2026-01-23
|
## [1.0.2] - 2026-01-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
@ -114,6 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
| Version | Date | Description |
|
| Version | Date | Description |
|
||||||
|---------|------|-------------|
|
|---------|------|-------------|
|
||||||
|
| 1.0.3 | 2026-01-23 | Notification history with bell icon dropdown and full history page |
|
||||||
| 1.0.2 | 2026-01-23 | Fixed stock status false positives for in-stock items |
|
| 1.0.2 | 2026-01-23 | Fixed stock status false positives for in-stock items |
|
||||||
| 1.0.1 | 2026-01-23 | Bug fixes, JS-rendered price support, pre-order detection |
|
| 1.0.1 | 2026-01-23 | Bug fixes, JS-rendered price support, pre-order detection |
|
||||||
| 1.0.0 | 2026-01-23 | Initial public release |
|
| 1.0.0 | 2026-01-23 | Initial public release |
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import priceRoutes from './routes/prices';
|
||||||
import settingsRoutes from './routes/settings';
|
import settingsRoutes from './routes/settings';
|
||||||
import profileRoutes from './routes/profile';
|
import profileRoutes from './routes/profile';
|
||||||
import adminRoutes from './routes/admin';
|
import adminRoutes from './routes/admin';
|
||||||
|
import notificationRoutes from './routes/notifications';
|
||||||
import { startScheduler } from './services/scheduler';
|
import { startScheduler } from './services/scheduler';
|
||||||
import pool from './config/database';
|
import pool from './config/database';
|
||||||
|
|
||||||
|
|
@ -147,6 +148,33 @@ async function runMigrations() {
|
||||||
END $$;
|
END $$;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Create notification_history table for tracking all triggered notifications
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
notification_type VARCHAR(50) NOT NULL,
|
||||||
|
triggered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
old_price DECIMAL(10,2),
|
||||||
|
new_price DECIMAL(10,2),
|
||||||
|
currency VARCHAR(10),
|
||||||
|
price_change_percent DECIMAL(5,2),
|
||||||
|
target_price DECIMAL(10,2),
|
||||||
|
old_stock_status VARCHAR(20),
|
||||||
|
new_stock_status VARCHAR(20),
|
||||||
|
channels_notified JSONB,
|
||||||
|
product_name VARCHAR(500),
|
||||||
|
product_url TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_history_user_date
|
||||||
|
ON notification_history(user_id, triggered_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_history_product
|
||||||
|
ON notification_history(product_id);
|
||||||
|
`);
|
||||||
|
|
||||||
console.log('Database migrations completed');
|
console.log('Database migrations completed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Migration error:', error);
|
console.error('Migration error:', error);
|
||||||
|
|
@ -178,6 +206,7 @@ app.use('/api/products', priceRoutes);
|
||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/profile', profileRoutes);
|
app.use('/api/profile', profileRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
|
app.use('/api/notifications', notificationRoutes);
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use(
|
app.use(
|
||||||
|
|
|
||||||
|
|
@ -773,3 +773,127 @@ export const stockStatusHistoryQueries = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Notification History types and queries
|
||||||
|
export type NotificationType = 'price_drop' | 'price_target' | 'stock_change';
|
||||||
|
|
||||||
|
export interface NotificationHistory {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
product_id: number;
|
||||||
|
notification_type: NotificationType;
|
||||||
|
triggered_at: Date;
|
||||||
|
old_price: number | null;
|
||||||
|
new_price: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
price_change_percent: number | null;
|
||||||
|
target_price: number | null;
|
||||||
|
old_stock_status: string | null;
|
||||||
|
new_stock_status: string | null;
|
||||||
|
channels_notified: string[];
|
||||||
|
product_name: string | null;
|
||||||
|
product_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateNotificationHistory {
|
||||||
|
user_id: number;
|
||||||
|
product_id: number;
|
||||||
|
notification_type: NotificationType;
|
||||||
|
old_price?: number;
|
||||||
|
new_price?: number;
|
||||||
|
currency?: string;
|
||||||
|
price_change_percent?: number;
|
||||||
|
target_price?: number;
|
||||||
|
old_stock_status?: string;
|
||||||
|
new_stock_status?: string;
|
||||||
|
channels_notified: string[];
|
||||||
|
product_name?: string;
|
||||||
|
product_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationHistoryQueries = {
|
||||||
|
// Create a new notification history record
|
||||||
|
create: async (data: CreateNotificationHistory): Promise<NotificationHistory> => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO notification_history
|
||||||
|
(user_id, product_id, notification_type, old_price, new_price, currency,
|
||||||
|
price_change_percent, target_price, old_stock_status, new_stock_status,
|
||||||
|
channels_notified, product_name, product_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.user_id,
|
||||||
|
data.product_id,
|
||||||
|
data.notification_type,
|
||||||
|
data.old_price || null,
|
||||||
|
data.new_price || null,
|
||||||
|
data.currency || null,
|
||||||
|
data.price_change_percent || null,
|
||||||
|
data.target_price || null,
|
||||||
|
data.old_stock_status || null,
|
||||||
|
data.new_stock_status || null,
|
||||||
|
JSON.stringify(data.channels_notified),
|
||||||
|
data.product_name || null,
|
||||||
|
data.product_url || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get notifications for a user with pagination
|
||||||
|
getByUserId: async (
|
||||||
|
userId: number,
|
||||||
|
limit: number = 50,
|
||||||
|
offset: number = 0
|
||||||
|
): Promise<NotificationHistory[]> => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM notification_history
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY triggered_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`,
|
||||||
|
[userId, limit, offset]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get recent notifications (for bell dropdown)
|
||||||
|
getRecent: async (userId: number, limit: number = 10): Promise<NotificationHistory[]> => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM notification_history
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY triggered_at DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
[userId, limit]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Count notifications in last 24 hours (for badge)
|
||||||
|
countRecent: async (userId: number, hours: number = 24): Promise<number> => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT COUNT(*) FROM notification_history
|
||||||
|
WHERE user_id = $1 AND triggered_at > NOW() - INTERVAL '1 hour' * $2`,
|
||||||
|
[userId, hours]
|
||||||
|
);
|
||||||
|
return parseInt(result.rows[0].count, 10);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get total count for pagination
|
||||||
|
getTotalCount: async (userId: number): Promise<number> => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT COUNT(*) FROM notification_history WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return parseInt(result.rows[0].count, 10);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete old notifications (for cleanup)
|
||||||
|
deleteOlderThan: async (days: number): Promise<number> => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM notification_history
|
||||||
|
WHERE triggered_at < NOW() - INTERVAL '1 day' * $1`,
|
||||||
|
[days]
|
||||||
|
);
|
||||||
|
return result.rowCount || 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
72
backend/src/routes/notifications.ts
Normal file
72
backend/src/routes/notifications.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { authMiddleware, AuthRequest } from '../middleware/auth';
|
||||||
|
import { notificationHistoryQueries } from '../models';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// Get recent notifications (for bell dropdown)
|
||||||
|
router.get('/recent', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId!;
|
||||||
|
const limit = Math.min(parseInt(req.query.limit as string) || 10, 20);
|
||||||
|
|
||||||
|
const notifications = await notificationHistoryQueries.getRecent(userId, limit);
|
||||||
|
const recentCount = await notificationHistoryQueries.countRecent(userId, 24);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
notifications,
|
||||||
|
recentCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recent notifications:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch notifications' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get notification history with pagination
|
||||||
|
router.get('/history', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId!;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [notifications, totalCount] = await Promise.all([
|
||||||
|
notificationHistoryQueries.getByUserId(userId, limit, offset),
|
||||||
|
notificationHistoryQueries.getTotalCount(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
notifications,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalCount,
|
||||||
|
totalPages: Math.ceil(totalCount / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notification history:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch notification history' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get count of recent notifications (for badge)
|
||||||
|
router.get('/count', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId!;
|
||||||
|
const hours = parseInt(req.query.hours as string) || 24;
|
||||||
|
|
||||||
|
const count = await notificationHistoryQueries.countRecent(userId, hours);
|
||||||
|
|
||||||
|
res.json({ count });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error counting notifications:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to count notifications' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -241,6 +241,11 @@ export async function sendNtfyNotification(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotificationResult {
|
||||||
|
channelsNotified: string[];
|
||||||
|
channelsFailed: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendNotifications(
|
export async function sendNotifications(
|
||||||
settings: {
|
settings: {
|
||||||
telegram_bot_token: string | null;
|
telegram_bot_token: string | null;
|
||||||
|
|
@ -255,29 +260,51 @@ export async function sendNotifications(
|
||||||
ntfy_enabled?: boolean;
|
ntfy_enabled?: boolean;
|
||||||
},
|
},
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Promise<void> {
|
): Promise<NotificationResult> {
|
||||||
const promises: Promise<boolean>[] = [];
|
const channelPromises: { channel: string; promise: Promise<boolean> }[] = [];
|
||||||
|
|
||||||
// Only send if channel is configured AND enabled (default to true if not specified)
|
// Only send if channel is configured AND enabled (default to true if not specified)
|
||||||
if (settings.telegram_bot_token && settings.telegram_chat_id && settings.telegram_enabled !== false) {
|
if (settings.telegram_bot_token && settings.telegram_chat_id && settings.telegram_enabled !== false) {
|
||||||
promises.push(
|
channelPromises.push({
|
||||||
sendTelegramNotification(settings.telegram_bot_token, settings.telegram_chat_id, payload)
|
channel: 'telegram',
|
||||||
);
|
promise: sendTelegramNotification(settings.telegram_bot_token, settings.telegram_chat_id, payload),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.discord_webhook_url && settings.discord_enabled !== false) {
|
if (settings.discord_webhook_url && settings.discord_enabled !== false) {
|
||||||
promises.push(sendDiscordNotification(settings.discord_webhook_url, payload));
|
channelPromises.push({
|
||||||
|
channel: 'discord',
|
||||||
|
promise: sendDiscordNotification(settings.discord_webhook_url, payload),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.pushover_user_key && settings.pushover_app_token && settings.pushover_enabled !== false) {
|
if (settings.pushover_user_key && settings.pushover_app_token && settings.pushover_enabled !== false) {
|
||||||
promises.push(
|
channelPromises.push({
|
||||||
sendPushoverNotification(settings.pushover_user_key, settings.pushover_app_token, payload)
|
channel: 'pushover',
|
||||||
);
|
promise: sendPushoverNotification(settings.pushover_user_key, settings.pushover_app_token, payload),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.ntfy_topic && settings.ntfy_enabled !== false) {
|
if (settings.ntfy_topic && settings.ntfy_enabled !== false) {
|
||||||
promises.push(sendNtfyNotification(settings.ntfy_topic, payload));
|
channelPromises.push({
|
||||||
|
channel: 'ntfy',
|
||||||
|
promise: sendNtfyNotification(settings.ntfy_topic, payload),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.allSettled(promises);
|
const results = await Promise.allSettled(channelPromises.map(c => c.promise));
|
||||||
|
|
||||||
|
const channelsNotified: string[] = [];
|
||||||
|
const channelsFailed: string[] = [];
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const channel = channelPromises[index].channel;
|
||||||
|
if (result.status === 'fulfilled' && result.value === true) {
|
||||||
|
channelsNotified.push(channel);
|
||||||
|
} else {
|
||||||
|
channelsFailed.push(channel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { channelsNotified, channelsFailed };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { productQueries, priceHistoryQueries, userQueries, stockStatusHistoryQueries } from '../models';
|
import { productQueries, priceHistoryQueries, userQueries, stockStatusHistoryQueries, notificationHistoryQueries, NotificationType } from '../models';
|
||||||
import { scrapeProduct } from './scraper';
|
import { scrapeProduct } from './scraper';
|
||||||
import { sendNotifications, NotificationPayload } from './notifications';
|
import { sendNotifications, NotificationPayload } from './notifications';
|
||||||
|
|
||||||
|
|
@ -52,8 +52,24 @@ async function checkPrices(): Promise<void> {
|
||||||
newPrice: scrapedData.price?.price,
|
newPrice: scrapedData.price?.price,
|
||||||
currency: scrapedData.price?.currency || 'USD',
|
currency: scrapedData.price?.currency || 'USD',
|
||||||
};
|
};
|
||||||
await sendNotifications(userSettings, payload);
|
const result = await sendNotifications(userSettings, payload);
|
||||||
console.log(`Back-in-stock notification sent for product ${product.id}`);
|
console.log(`Back-in-stock notification sent for product ${product.id}`);
|
||||||
|
|
||||||
|
// Log notification to history
|
||||||
|
if (result.channelsNotified.length > 0) {
|
||||||
|
await notificationHistoryQueries.create({
|
||||||
|
user_id: product.user_id,
|
||||||
|
product_id: product.id,
|
||||||
|
notification_type: 'stock_change' as NotificationType,
|
||||||
|
old_stock_status: product.stock_status,
|
||||||
|
new_stock_status: scrapedData.stockStatus,
|
||||||
|
new_price: scrapedData.price?.price,
|
||||||
|
currency: scrapedData.price?.currency || 'USD',
|
||||||
|
channels_notified: result.channelsNotified,
|
||||||
|
product_name: product.name || 'Unknown Product',
|
||||||
|
product_url: product.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (notifyError) {
|
} catch (notifyError) {
|
||||||
console.error(`Failed to send back-in-stock notification for product ${product.id}:`, notifyError);
|
console.error(`Failed to send back-in-stock notification for product ${product.id}:`, notifyError);
|
||||||
|
|
@ -86,8 +102,25 @@ async function checkPrices(): Promise<void> {
|
||||||
currency: scrapedData.price.currency,
|
currency: scrapedData.price.currency,
|
||||||
threshold: product.price_drop_threshold,
|
threshold: product.price_drop_threshold,
|
||||||
};
|
};
|
||||||
await sendNotifications(userSettings, payload);
|
const result = await sendNotifications(userSettings, payload);
|
||||||
console.log(`Price drop notification sent for product ${product.id}: ${priceDrop} drop`);
|
console.log(`Price drop notification sent for product ${product.id}: ${priceDrop} drop`);
|
||||||
|
|
||||||
|
// Log notification to history
|
||||||
|
if (result.channelsNotified.length > 0) {
|
||||||
|
const priceChangePercent = ((oldPrice - newPrice) / oldPrice) * 100;
|
||||||
|
await notificationHistoryQueries.create({
|
||||||
|
user_id: product.user_id,
|
||||||
|
product_id: product.id,
|
||||||
|
notification_type: 'price_drop' as NotificationType,
|
||||||
|
old_price: oldPrice,
|
||||||
|
new_price: newPrice,
|
||||||
|
currency: scrapedData.price.currency,
|
||||||
|
price_change_percent: Math.round(priceChangePercent * 100) / 100,
|
||||||
|
channels_notified: result.channelsNotified,
|
||||||
|
product_name: product.name || 'Unknown Product',
|
||||||
|
product_url: product.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (notifyError) {
|
} catch (notifyError) {
|
||||||
console.error(`Failed to send price drop notification for product ${product.id}:`, notifyError);
|
console.error(`Failed to send price drop notification for product ${product.id}:`, notifyError);
|
||||||
|
|
@ -114,8 +147,24 @@ async function checkPrices(): Promise<void> {
|
||||||
currency: scrapedData.price.currency,
|
currency: scrapedData.price.currency,
|
||||||
targetPrice: targetPrice,
|
targetPrice: targetPrice,
|
||||||
};
|
};
|
||||||
await sendNotifications(userSettings, payload);
|
const result = await sendNotifications(userSettings, payload);
|
||||||
console.log(`Target price notification sent for product ${product.id}: ${newPrice} <= ${targetPrice}`);
|
console.log(`Target price notification sent for product ${product.id}: ${newPrice} <= ${targetPrice}`);
|
||||||
|
|
||||||
|
// Log notification to history
|
||||||
|
if (result.channelsNotified.length > 0) {
|
||||||
|
await notificationHistoryQueries.create({
|
||||||
|
user_id: product.user_id,
|
||||||
|
product_id: product.id,
|
||||||
|
notification_type: 'price_target' as NotificationType,
|
||||||
|
old_price: oldPrice || undefined,
|
||||||
|
new_price: newPrice,
|
||||||
|
currency: scrapedData.price.currency,
|
||||||
|
target_price: targetPrice,
|
||||||
|
channels_notified: result.channelsNotified,
|
||||||
|
product_name: product.name || 'Unknown Product',
|
||||||
|
product_url: product.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (notifyError) {
|
} catch (notifyError) {
|
||||||
console.error(`Failed to send target price notification for product ${product.id}:`, notifyError);
|
console.error(`Failed to send target price notification for product ${product.id}:`, notifyError);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"releaseDate": "2026-01-23"
|
"releaseDate": "2026-01-23"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import Register from './pages/Register';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import ProductDetail from './pages/ProductDetail';
|
import ProductDetail from './pages/ProductDetail';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
import NotificationHistory from './pages/NotificationHistory';
|
||||||
|
|
||||||
function ThemeInitializer({ children }: { children: React.ReactNode }) {
|
function ThemeInitializer({ children }: { children: React.ReactNode }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -111,6 +112,14 @@ function AppRoutes() {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/notifications"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<NotificationHistory />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,59 @@ export const profileApi = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Notification History API
|
||||||
|
export type NotificationType = 'price_drop' | 'price_target' | 'stock_change';
|
||||||
|
|
||||||
|
export interface NotificationHistoryEntry {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
product_id: number;
|
||||||
|
notification_type: NotificationType;
|
||||||
|
triggered_at: string;
|
||||||
|
old_price: number | null;
|
||||||
|
new_price: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
price_change_percent: number | null;
|
||||||
|
target_price: number | null;
|
||||||
|
old_stock_status: string | null;
|
||||||
|
new_stock_status: string | null;
|
||||||
|
channels_notified: string[];
|
||||||
|
product_name: string | null;
|
||||||
|
product_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationHistoryResponse {
|
||||||
|
notifications: NotificationHistoryEntry[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalCount: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentNotificationsResponse {
|
||||||
|
notifications: NotificationHistoryEntry[];
|
||||||
|
recentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
getRecent: (limit?: number) =>
|
||||||
|
api.get<RecentNotificationsResponse>('/notifications/recent', {
|
||||||
|
params: limit ? { limit } : undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getHistory: (page?: number, limit?: number) =>
|
||||||
|
api.get<NotificationHistoryResponse>('/notifications/history', {
|
||||||
|
params: { page: page || 1, limit: limit || 20 },
|
||||||
|
}),
|
||||||
|
|
||||||
|
getCount: (hours?: number) =>
|
||||||
|
api.get<{ count: number }>('/notifications/count', {
|
||||||
|
params: hours ? { hours } : undefined,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// Admin API
|
// Admin API
|
||||||
export interface SystemSettings {
|
export interface SystemSettings {
|
||||||
registration_enabled: string;
|
registration_enabled: string;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ReactNode, useState, useEffect, useRef } from 'react';
|
import { ReactNode, useState, useEffect, useRef } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import NotificationBell from './NotificationBell';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
@ -310,6 +311,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||||
>
|
>
|
||||||
{theme === 'light' ? '🌙' : '☀️'}
|
{theme === 'light' ? '🌙' : '☀️'}
|
||||||
</button>
|
</button>
|
||||||
|
{user && <NotificationBell />}
|
||||||
{user && (
|
{user && (
|
||||||
<div className="user-dropdown" ref={dropdownRef}>
|
<div className="user-dropdown" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
361
frontend/src/components/NotificationBell.tsx
Normal file
361
frontend/src/components/NotificationBell.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { notificationsApi, NotificationHistoryEntry } from '../api/client';
|
||||||
|
|
||||||
|
function formatTimeAgo(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays === 1) return 'Yesterday';
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationIcon(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'price_drop':
|
||||||
|
return '\u{1F4C9}'; // Chart decreasing
|
||||||
|
case 'price_target':
|
||||||
|
return '\u{1F3AF}'; // Target
|
||||||
|
case 'stock_change':
|
||||||
|
return '\u{1F4E6}'; // Package
|
||||||
|
default:
|
||||||
|
return '\u{1F514}'; // Bell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationTitle(notification: NotificationHistoryEntry): string {
|
||||||
|
switch (notification.notification_type) {
|
||||||
|
case 'price_drop':
|
||||||
|
const percent = notification.price_change_percent
|
||||||
|
? `${Math.abs(notification.price_change_percent).toFixed(0)}%`
|
||||||
|
: '';
|
||||||
|
return `Price dropped ${percent}`;
|
||||||
|
case 'price_target':
|
||||||
|
return 'Target price reached';
|
||||||
|
case 'stock_change':
|
||||||
|
return 'Back in stock';
|
||||||
|
default:
|
||||||
|
return 'Notification';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number | null, currency: string | null): string {
|
||||||
|
if (price === null) return '';
|
||||||
|
const symbol = currency === 'EUR' ? '\u20AC' : currency === 'GBP' ? '\u00A3' : '$';
|
||||||
|
return `${symbol}${price.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationBell() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [notifications, setNotifications] = useState<NotificationHistoryEntry[]>([]);
|
||||||
|
const [recentCount, setRecentCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
// Poll for new notifications every 60 seconds
|
||||||
|
const interval = setInterval(fetchNotifications, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const response = await notificationsApi.getRecent(5);
|
||||||
|
setNotifications(response.data.notifications);
|
||||||
|
setRecentCount(response.data.recentCount);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch notifications:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
setLoading(true);
|
||||||
|
await fetchNotifications();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="notification-bell" ref={dropdownRef}>
|
||||||
|
<style>{`
|
||||||
|
.notification-bell {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell-button {
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell-button:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
right: 0;
|
||||||
|
width: 320px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-dropdown-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-dropdown-header svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:hover {
|
||||||
|
background: var(--background);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-product {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-price {
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-empty {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-empty-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-footer {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-footer a {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-loading {
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.notification-dropdown {
|
||||||
|
width: calc(100vw - 2rem);
|
||||||
|
right: -1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="notification-bell-button"
|
||||||
|
onClick={handleOpen}
|
||||||
|
title="Notifications"
|
||||||
|
>
|
||||||
|
{'\u{1F514}'}
|
||||||
|
{recentCount > 0 && (
|
||||||
|
<span className="notification-badge">
|
||||||
|
{recentCount > 99 ? '99+' : recentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="notification-dropdown">
|
||||||
|
<div className="notification-dropdown-header">
|
||||||
|
<span>Notifications</span>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="notification-loading">Loading...</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="notification-empty">
|
||||||
|
<div className="notification-empty-icon">{'\u{1F514}'}</div>
|
||||||
|
<div>No notifications yet</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', marginTop: '0.25rem' }}>
|
||||||
|
You'll be notified when prices drop
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="notification-list">
|
||||||
|
{notifications.map((notification) => (
|
||||||
|
<Link
|
||||||
|
key={notification.id}
|
||||||
|
to={`/product/${notification.product_id}`}
|
||||||
|
className="notification-item"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<span className="notification-icon">
|
||||||
|
{getNotificationIcon(notification.notification_type)}
|
||||||
|
</span>
|
||||||
|
<div className="notification-content">
|
||||||
|
<div className="notification-title">
|
||||||
|
{getNotificationTitle(notification)}
|
||||||
|
</div>
|
||||||
|
<div className="notification-product">
|
||||||
|
{notification.product_name || 'Unknown Product'}
|
||||||
|
</div>
|
||||||
|
<div className="notification-meta">
|
||||||
|
{notification.new_price && (
|
||||||
|
<span className="notification-price">
|
||||||
|
{formatPrice(notification.new_price, notification.currency)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="notification-time">
|
||||||
|
{formatTimeAgo(notification.triggered_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="notification-footer">
|
||||||
|
<Link to="/notifications" onClick={() => setIsOpen(false)}>
|
||||||
|
View All History
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
487
frontend/src/pages/NotificationHistory.tsx
Normal file
487
frontend/src/pages/NotificationHistory.tsx
Normal file
|
|
@ -0,0 +1,487 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { notificationsApi, NotificationHistoryEntry, NotificationType } from '../api/client';
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationIcon(type: NotificationType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'price_drop':
|
||||||
|
return '\u{1F4C9}';
|
||||||
|
case 'price_target':
|
||||||
|
return '\u{1F3AF}';
|
||||||
|
case 'stock_change':
|
||||||
|
return '\u{1F4E6}';
|
||||||
|
default:
|
||||||
|
return '\u{1F514}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationTypeLabel(type: NotificationType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'price_drop':
|
||||||
|
return 'Price Drop';
|
||||||
|
case 'price_target':
|
||||||
|
return 'Target Reached';
|
||||||
|
case 'stock_change':
|
||||||
|
return 'Back in Stock';
|
||||||
|
default:
|
||||||
|
return 'Notification';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number | null, currency: string | null): string {
|
||||||
|
if (price === null) return '-';
|
||||||
|
const symbol = currency === 'EUR' ? '\u20AC' : currency === 'GBP' ? '\u00A3' : currency === 'CHF' ? 'CHF ' : '$';
|
||||||
|
return `${symbol}${price.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChannelIcon(channel: string): string {
|
||||||
|
switch (channel) {
|
||||||
|
case 'telegram':
|
||||||
|
return '\u{1F4AC}';
|
||||||
|
case 'discord':
|
||||||
|
return '\u{1F4AC}';
|
||||||
|
case 'pushover':
|
||||||
|
return '\u{1F4F1}';
|
||||||
|
case 'ntfy':
|
||||||
|
return '\u{1F4E2}';
|
||||||
|
default:
|
||||||
|
return '\u{1F4E8}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationHistory() {
|
||||||
|
const [notifications, setNotifications] = useState<NotificationHistoryEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [filter, setFilter] = useState<NotificationType | 'all'>('all');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await notificationsApi.getHistory(page, 20);
|
||||||
|
setNotifications(response.data.notifications);
|
||||||
|
setTotalPages(response.data.pagination.totalPages);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch notification history:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredNotifications = filter === 'all'
|
||||||
|
? notifications
|
||||||
|
: notifications.filter(n => n.notification_type === filter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<style>{`
|
||||||
|
.notifications-page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-table {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-table-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 120px 120px 100px 140px;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--background);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 120px 120px 100px 140px;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row:hover {
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-product {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-product-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-product-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-product-name {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-product-name a {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-product-name a:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-type-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-type-badge.price_drop {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-type-badge.price_target {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-type-badge.stock_change {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-price {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-price-old {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-price-new {
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-price-change {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-channels {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
background: var(--background);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-date {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-empty {
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-empty-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.notifications-table-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-product {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="notifications-page">
|
||||||
|
<div className="notifications-header">
|
||||||
|
<h1 className="notifications-title">
|
||||||
|
{'\u{1F514}'} Notification History
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="notifications-filters">
|
||||||
|
<button
|
||||||
|
className={`filter-btn ${filter === 'all' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`filter-btn ${filter === 'price_drop' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilter('price_drop')}
|
||||||
|
>
|
||||||
|
{'\u{1F4C9}'} Price Drops
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`filter-btn ${filter === 'price_target' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilter('price_target')}
|
||||||
|
>
|
||||||
|
{'\u{1F3AF}'} Targets
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`filter-btn ${filter === 'stock_change' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilter('stock_change')}
|
||||||
|
>
|
||||||
|
{'\u{1F4E6}'} Stock
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="notifications-table">
|
||||||
|
<div className="notifications-table-header">
|
||||||
|
<div>Product</div>
|
||||||
|
<div>Price</div>
|
||||||
|
<div>Change</div>
|
||||||
|
<div>Channels</div>
|
||||||
|
<div>Date</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="notifications-empty">Loading...</div>
|
||||||
|
) : filteredNotifications.length === 0 ? (
|
||||||
|
<div className="notifications-empty">
|
||||||
|
<div className="notifications-empty-icon">{'\u{1F514}'}</div>
|
||||||
|
<div>No notifications yet</div>
|
||||||
|
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem' }}>
|
||||||
|
Notifications will appear here when price drops, targets are reached, or items come back in stock.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredNotifications.map((notification) => (
|
||||||
|
<div key={notification.id} className="notification-row">
|
||||||
|
<div className="notification-product">
|
||||||
|
<span className="notification-product-icon">
|
||||||
|
{getNotificationIcon(notification.notification_type)}
|
||||||
|
</span>
|
||||||
|
<div className="notification-product-info">
|
||||||
|
<div className="notification-product-name">
|
||||||
|
<Link to={`/product/${notification.product_id}`}>
|
||||||
|
{notification.product_name || 'Unknown Product'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<span className={`notification-type-badge ${notification.notification_type}`}>
|
||||||
|
{getNotificationTypeLabel(notification.notification_type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="notification-price">
|
||||||
|
{notification.old_price && (
|
||||||
|
<div className="notification-price-old">
|
||||||
|
{formatPrice(notification.old_price, notification.currency)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="notification-price-new">
|
||||||
|
{formatPrice(notification.new_price, notification.currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{notification.price_change_percent && (
|
||||||
|
<span className="notification-price-change">
|
||||||
|
-{Math.abs(notification.price_change_percent).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{notification.target_price && (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
|
Target: {formatPrice(notification.target_price, notification.currency)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{notification.notification_type === 'stock_change' && (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--success)' }}>
|
||||||
|
Now available
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="notification-channels">
|
||||||
|
{(notification.channels_notified || []).map((channel) => (
|
||||||
|
<span key={channel} className="channel-badge">
|
||||||
|
{getChannelIcon(channel)} {channel}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="notification-date">
|
||||||
|
{formatDate(notification.triggered_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="pagination">
|
||||||
|
<button
|
||||||
|
className="pagination-btn"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="pagination-info">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="pagination-btn"
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue