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:
clucraft 2026-01-23 20:32:24 -05:00
parent 45363e4d97
commit 63fcaebfd8
12 changed files with 1244 additions and 16 deletions

View file

@ -7,6 +7,7 @@ import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import ProductDetail from './pages/ProductDetail';
import Settings from './pages/Settings';
import NotificationHistory from './pages/NotificationHistory';
function ThemeInitializer({ children }: { children: React.ReactNode }) {
useEffect(() => {
@ -111,6 +112,14 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
<ProtectedRoute>
<NotificationHistory />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);

View file

@ -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
export interface SystemSettings {
registration_enabled: string;

View file

@ -1,6 +1,7 @@
import { ReactNode, useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import NotificationBell from './NotificationBell';
interface LayoutProps {
children: ReactNode;
@ -310,6 +311,7 @@ export default function Layout({ children }: LayoutProps) {
>
{theme === 'light' ? '🌙' : '☀️'}
</button>
{user && <NotificationBell />}
{user && (
<div className="user-dropdown" ref={dropdownRef}>
<button

View 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>
);
}

View 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>
);
}