Replace Webex bot with in-app notification system
Org blocks external Webex bots, so replaced the DM approach with an in-app notification bell. GitLab webhook still fires on issue close, but now writes to a notifications table instead of calling Webex API. - New: notifications table + migration - New: GET/PATCH/POST /api/notifications endpoints - New: NotificationBell component (bell icon + badge + dropdown) - Removed: backend/helpers/webexBot.js (org-blocked) - Removed: WEBEX_BOT_TOKEN from .env
This commit is contained in:
@@ -80,6 +80,3 @@ GITLAB_PAT=
|
||||
# Generate with: openssl rand -hex 20
|
||||
GITLAB_WEBHOOK_SECRET=changeme_generate_a_random_secret
|
||||
|
||||
# Webex Bot — used to DM users when their feedback issues are resolved.
|
||||
# Create a bot at https://developer.webex.com/my-apps and use its access token.
|
||||
WEBEX_BOT_TOKEN=
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// Webex Bot DM Helper
|
||||
// Fire-and-forget pattern — logs success/failure but never throws.
|
||||
// Used to notify users when their feedback issues are resolved.
|
||||
|
||||
const https = require('https');
|
||||
|
||||
const WEBEX_API_URL = 'https://webexapis.com/v1/messages';
|
||||
const WEBEX_BOT_TOKEN = process.env.WEBEX_BOT_TOKEN || '';
|
||||
|
||||
/**
|
||||
* Send a direct message to a user via Webex Teams bot.
|
||||
* @param {string} email - Recipient's email address
|
||||
* @param {string} markdownMessage - Message body (Webex markdown supported)
|
||||
*/
|
||||
function sendDirectMessage(email, markdownMessage) {
|
||||
if (!WEBEX_BOT_TOKEN) {
|
||||
console.warn('[WebexBot] WEBEX_BOT_TOKEN not configured — skipping DM');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email || !markdownMessage) {
|
||||
console.warn('[WebexBot] Missing email or message — skipping DM');
|
||||
return;
|
||||
}
|
||||
|
||||
const postData = JSON.stringify({
|
||||
toPersonEmail: email,
|
||||
markdown: markdownMessage,
|
||||
});
|
||||
|
||||
const parsed = new URL(WEBEX_API_URL);
|
||||
|
||||
const reqOpts = {
|
||||
method: 'POST',
|
||||
hostname: parsed.hostname,
|
||||
path: parsed.pathname,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${WEBEX_BOT_TOKEN}`,
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(reqOpts, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
console.log(`[WebexBot] DM sent to ${email} (${res.statusCode})`);
|
||||
} else {
|
||||
console.error(`[WebexBot] Failed to DM ${email} — ${res.statusCode}: ${data}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error(`[WebexBot] Request error sending DM to ${email}:`, err.message);
|
||||
});
|
||||
|
||||
req.write(postData);
|
||||
req.end();
|
||||
}
|
||||
|
||||
module.exports = { sendDirectMessage };
|
||||
45
backend/migrations/add_notifications_table.js
Normal file
45
backend/migrations/add_notifications_table.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting notifications table migration...');
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
username TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'issue_resolved',
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
issue_number INTEGER,
|
||||
read BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
console.log('✓ notifications table created (or already exists)');
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_username
|
||||
ON notifications(username)
|
||||
`);
|
||||
console.log('✓ username index created');
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_read
|
||||
ON notifications(username, read)
|
||||
`);
|
||||
console.log('✓ username/read index created');
|
||||
|
||||
console.log('Migration complete.');
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
// Self-execute when run directly
|
||||
if (require.main === module) {
|
||||
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
||||
}
|
||||
98
backend/routes/notifications.js
Normal file
98
backend/routes/notifications.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// Notifications route — in-app notification management for users
|
||||
// Provides unread notifications, counts, and mark-as-read operations.
|
||||
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
function createNotificationsRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth());
|
||||
|
||||
/**
|
||||
* GET /api/notifications
|
||||
* Returns unread notifications for the current user, ordered by newest first.
|
||||
* Limited to 50 results.
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, type, title, message, issue_number, read, created_at
|
||||
FROM notifications
|
||||
WHERE username = $1 AND read = FALSE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[req.user.username]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('[Notifications] Error fetching notifications:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/notifications/count
|
||||
* Returns the unread notification count for the current user (for badge display).
|
||||
*/
|
||||
router.get('/count', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS unread
|
||||
FROM notifications
|
||||
WHERE username = $1 AND read = FALSE`,
|
||||
[req.user.username]
|
||||
);
|
||||
res.json({ unread: rows[0].unread });
|
||||
} catch (err) {
|
||||
console.error('[Notifications] Error fetching count:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch notification count' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/notifications/:id/read
|
||||
* Marks a single notification as read. Only the owning user can mark their own.
|
||||
*/
|
||||
router.patch('/:id/read', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE notifications SET read = TRUE
|
||||
WHERE id = $1 AND username = $2`,
|
||||
[id, req.user.username]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ error: 'Notification not found' });
|
||||
}
|
||||
res.json({ status: 'ok' });
|
||||
} catch (err) {
|
||||
console.error('[Notifications] Error marking read:', err.message);
|
||||
res.status(500).json({ error: 'Failed to mark notification as read' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/notifications/read-all
|
||||
* Marks all notifications as read for the current user.
|
||||
*/
|
||||
router.post('/read-all', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE notifications SET read = TRUE
|
||||
WHERE username = $1 AND read = FALSE`,
|
||||
[req.user.username]
|
||||
);
|
||||
res.json({ status: 'ok', marked: result.rowCount });
|
||||
} catch (err) {
|
||||
console.error('[Notifications] Error marking all read:', err.message);
|
||||
res.status(500).json({ error: 'Failed to mark notifications as read' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createNotificationsRouter;
|
||||
@@ -1,16 +1,32 @@
|
||||
// GitLab Webhook Routes — receives issue lifecycle events from GitLab
|
||||
// Used to notify users via Webex when their feedback issues are closed.
|
||||
// Used to create in-app notifications when feedback issues are closed.
|
||||
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { sendDirectMessage } = require('../helpers/webexBot');
|
||||
|
||||
const GITLAB_WEBHOOK_SECRET = process.env.GITLAB_WEBHOOK_SECRET || '';
|
||||
|
||||
function createWebhooksRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/webhooks/gitlab — GitLab issue webhook receiver
|
||||
/**
|
||||
* POST /api/webhooks/gitlab
|
||||
*
|
||||
* Receives GitLab issue webhook events. When an issue is closed, parses the
|
||||
* submitter username from the issue description and creates an in-app notification.
|
||||
*
|
||||
* Always returns HTTP 200 to prevent GitLab from retrying on app-level failures.
|
||||
*
|
||||
* @header {string} x-gitlab-token - Webhook secret token (must match GITLAB_WEBHOOK_SECRET env var)
|
||||
* @body {object} object_attributes - GitLab issue event payload
|
||||
* @body {string} object_attributes.action - The issue action (only 'close' is processed)
|
||||
* @body {string} object_attributes.title - The issue title
|
||||
* @body {number} object_attributes.iid - The issue number
|
||||
* @body {string} object_attributes.description - The issue description (parsed for "**Submitted by:** username")
|
||||
* @returns {object} 200 - { status: 'ok', notified: username }
|
||||
* @returns {object} 200 - { status: 'ignored', reason: 'invalid token' | 'not a close event' | 'no submitter in description' | 'user not found' }
|
||||
* @returns {object} 200 - { status: 'error', message: string }
|
||||
*/
|
||||
router.post('/gitlab', express.json(), async (req, res) => {
|
||||
// Always return 200 — webhooks should not retry on app-level failures
|
||||
try {
|
||||
@@ -36,30 +52,34 @@ function createWebhooksRouter() {
|
||||
// Format: **Submitted by:** username
|
||||
const submitterMatch = description.match(/\*\*Submitted by:\*\*\s*(\S+)/);
|
||||
if (!submitterMatch) {
|
||||
console.log('[Webhook] No submitter found in issue description — skipping DM');
|
||||
console.log('[Webhook] No submitter found in issue description — skipping notification');
|
||||
return res.status(200).json({ status: 'ignored', reason: 'no submitter in description' });
|
||||
}
|
||||
|
||||
const username = submitterMatch[1];
|
||||
|
||||
// Look up user email in database
|
||||
// Verify user exists in database
|
||||
const { rows } = await pool.query(
|
||||
'SELECT email FROM users WHERE username = $1',
|
||||
'SELECT id FROM users WHERE username = $1',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (!rows || rows.length === 0 || !rows[0].email) {
|
||||
console.log(`[Webhook] No email found for user "${username}" — skipping DM`);
|
||||
return res.status(200).json({ status: 'ignored', reason: 'user email not found' });
|
||||
if (!rows || rows.length === 0) {
|
||||
console.log(`[Webhook] No user found for "${username}" — skipping notification`);
|
||||
return res.status(200).json({ status: 'ignored', reason: 'user not found' });
|
||||
}
|
||||
|
||||
const email = rows[0].email;
|
||||
const userId = rows[0].id;
|
||||
|
||||
// Send Webex DM notification
|
||||
const message = `Hey! Your bug report **${issueTitle}** (Issue #${issueNumber}) has been resolved and deployed. — Patches O'Houlihan`;
|
||||
sendDirectMessage(email, message);
|
||||
// Insert in-app notification
|
||||
const message = `Your bug report **${issueTitle}** (Issue #${issueNumber}) has been resolved and deployed.`;
|
||||
await pool.query(
|
||||
`INSERT INTO notifications (user_id, username, type, title, message, issue_number)
|
||||
VALUES ($1, $2, 'issue_resolved', $3, $4, $5)`,
|
||||
[userId, username, issueTitle, message, issueNumber]
|
||||
);
|
||||
|
||||
console.log(`[Webhook] Issue #${issueNumber} closed — notified ${username} (${email})`);
|
||||
console.log(`[Webhook] Issue #${issueNumber} closed — notification created for ${username}`);
|
||||
return res.status(200).json({ status: 'ok', notified: username });
|
||||
} catch (err) {
|
||||
console.error('[Webhook] Error processing GitLab webhook:', err.message);
|
||||
|
||||
@@ -34,6 +34,7 @@ const createJiraTicketsRouter = require('./routes/jiraTickets');
|
||||
const createCardApiRouter = require('./routes/cardApi');
|
||||
const createFeedbackRouter = require('./routes/feedback');
|
||||
const createWebhooksRouter = require('./routes/webhooks');
|
||||
const createNotificationsRouter = require('./routes/notifications');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -231,6 +232,9 @@ app.use('/api/card', createCardApiRouter());
|
||||
// Feedback routes — bug reports and feature requests to GitLab
|
||||
app.use('/api/feedback', createFeedbackRouter());
|
||||
|
||||
// In-app notifications routes (authenticated users)
|
||||
app.use('/api/notifications', createNotificationsRouter());
|
||||
|
||||
// GitLab webhook routes — receives issue lifecycle events (no auth required)
|
||||
app.use('/api/webhooks', createWebhooksRouter());
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import JiraPage from './components/pages/JiraPage';
|
||||
import AdminPage from './components/pages/AdminPage';
|
||||
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
||||
import FeedbackModal from './components/FeedbackModal';
|
||||
import NotificationBell from './components/NotificationBell';
|
||||
import './App.css';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
@@ -1051,6 +1052,7 @@ export default function App() {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m8 2 1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-9"/><path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/></svg>
|
||||
Bug
|
||||
</button>
|
||||
<NotificationBell />
|
||||
<UserMenu onManageUsers={() => setShowUserManagement(true)} onAuditLog={() => setShowAuditLog(true)} onFeatureRequest={() => { setFeedbackType('feature'); setShowFeedback(true); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
271
frontend/src/components/NotificationBell.js
Normal file
271
frontend/src/components/NotificationBell.js
Normal file
@@ -0,0 +1,271 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Bell } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
const bellButtonStyle = {
|
||||
position: 'relative',
|
||||
background: 'none',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
color: '#94A3B8',
|
||||
transition: 'all 0.15s',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
};
|
||||
|
||||
const badgeStyle = {
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
background: '#EF4444',
|
||||
color: '#fff',
|
||||
fontSize: '0.6rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: '700',
|
||||
minWidth: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
};
|
||||
|
||||
const dropdownStyle = {
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 8px)',
|
||||
right: 0,
|
||||
width: '360px',
|
||||
maxHeight: '420px',
|
||||
overflowY: 'auto',
|
||||
background: 'linear-gradient(135deg, #0F1A2E 0%, #1E293B 100%)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.7)',
|
||||
zIndex: 100,
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
};
|
||||
|
||||
const dropdownHeaderStyle = {
|
||||
padding: '0.75rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
const dropdownTitleStyle = {
|
||||
color: '#E2E8F0',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: '600',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
};
|
||||
|
||||
const markAllButtonStyle = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#0EA5E9',
|
||||
fontSize: '0.7rem',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
transition: 'all 0.15s',
|
||||
};
|
||||
|
||||
const notificationItemStyle = {
|
||||
padding: '0.75rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
};
|
||||
|
||||
const notificationMessageStyle = {
|
||||
color: '#CBD5E1',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: '1.5',
|
||||
marginBottom: '0.35rem',
|
||||
};
|
||||
|
||||
const notificationTimeStyle = {
|
||||
color: '#64748B',
|
||||
fontSize: '0.65rem',
|
||||
};
|
||||
|
||||
const emptyStyle = {
|
||||
padding: '2rem 1rem',
|
||||
textAlign: 'center',
|
||||
color: '#64748B',
|
||||
fontSize: '0.75rem',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
function NotificationBell() {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const fetchCount = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/notifications/count`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUnreadCount(data.unread);
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently ignore — polling will retry
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/notifications`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNotifications(data);
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Poll for unread count every 60 seconds
|
||||
useEffect(() => {
|
||||
fetchCount();
|
||||
const interval = setInterval(fetchCount, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchCount]);
|
||||
|
||||
// Fetch full list when dropdown opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [open, fetchNotifications]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
const markAsRead = async (id) => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/notifications/${id}/read`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
});
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
} catch (err) {
|
||||
// Silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/notifications/read-all`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
} catch (err) {
|
||||
// Silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
// Render markdown bold (**text**) as <strong>
|
||||
const renderMessage = (msg) => {
|
||||
const parts = msg.split(/\*\*(.*?)\*\*/g);
|
||||
return parts.map((part, i) =>
|
||||
i % 2 === 1 ? <strong key={i} style={{ color: '#E2E8F0' }}>{part}</strong> : part
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<button
|
||||
style={bellButtonStyle}
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.6)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span style={badgeStyle}>{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={dropdownStyle}>
|
||||
<div style={dropdownHeaderStyle}>
|
||||
<span style={dropdownTitleStyle}>Notifications</span>
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
style={markAllButtonStyle}
|
||||
onClick={markAllRead}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; }}
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div style={emptyStyle}>No unread notifications</div>
|
||||
) : (
|
||||
notifications.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
style={notificationItemStyle}
|
||||
onClick={() => markAsRead(n.id)}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={notificationMessageStyle}>{renderMessage(n.message)}</div>
|
||||
<div style={notificationTimeStyle}>{formatTime(n.created_at)}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationBell;
|
||||
Reference in New Issue
Block a user