notifciations issues fixed final

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-28 00:52:14 +00:00
parent 28a60f8141
commit 0605f650b1
12 changed files with 1384 additions and 18 deletions

View File

@@ -68,6 +68,7 @@ const AccountSettingsPage = lazy(() => import("./pages/account/AccountSettingsPa
// TeamManagementPage - Now integrated as tab in AccountSettingsPage
const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage"));
const ContentSettingsPage = lazy(() => import("./pages/account/ContentSettingsPage"));
const NotificationsPage = lazy(() => import("./pages/account/NotificationsPage"));
// Reference Data - Lazy loaded
const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords"));
@@ -192,6 +193,9 @@ export default function App() {
<Route path="/billing/usage" element={<Usage />} />
{/* Account Section - Billing & Management Pages */}
{/* Notifications */}
<Route path="/account/notifications" element={<NotificationsPage />} />
{/* Account Settings - with sub-routes for sidebar navigation */}
<Route path="/account/settings" element={<AccountSettingsPage />} />
<Route path="/account/settings/profile" element={<AccountSettingsPage />} />

View File

@@ -293,7 +293,7 @@ export default function NotificationDropdown() {
{/* Footer */}
{notifications.length > 0 && (
<Link
to="/notifications"
to="/account/notifications"
onClick={closeDropdown}
className="block px-4 py-2 mt-3 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
>

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Bell } from "lucide-react";
// Assume these icons are imported from an icon library
import {
@@ -174,6 +175,11 @@ const AppSidebar: React.FC = () => {
{
label: "ACCOUNT",
items: [
{
icon: <Bell className="w-5 h-5" />,
name: "Notifications",
path: "/account/notifications",
},
{
icon: <UserCircleIcon />,
name: "Account Settings",

View File

@@ -0,0 +1,434 @@
import { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet-async';
import { Link } from 'react-router-dom';
import {
Bell,
CheckCircle,
AlertTriangle,
XCircle,
Info,
Trash2,
CheckCheck,
Filter,
Calendar,
Globe,
} from 'lucide-react';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import { useNotificationStore } from '../../store/notificationStore';
import type { NotificationAPI } from '../../services/notifications.api';
import { deleteNotification as deleteNotificationAPI } from '../../services/notifications.api';
interface FilterState {
severity: string;
notification_type: string;
is_read: string;
}
export default function NotificationsPage() {
const [apiNotifications, setApiNotifications] = useState<NotificationAPI[]>([]);
const [loading, setLoading] = useState(true);
const {
unreadCount,
fetchNotifications: storeFetchNotifications,
markAsRead: storeMarkAsRead,
markAllAsRead: storeMarkAllAsRead,
syncUnreadCount,
} = useNotificationStore();
const [filters, setFilters] = useState<FilterState>({
severity: '',
notification_type: '',
is_read: '',
});
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
loadNotifications();
}, []);
const loadNotifications = async () => {
setLoading(true);
try {
// Import here to avoid circular dependencies
const { fetchNotifications } = await import('../../services/notifications.api');
const data = await fetchNotifications();
setApiNotifications(data.results);
await syncUnreadCount();
} catch (error) {
console.error('Failed to load notifications:', error);
} finally {
setLoading(false);
}
};
// Get severity icon and color
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'warning':
return <AlertTriangle className="w-5 h-5 text-yellow-500" />;
case 'error':
return <XCircle className="w-5 h-5 text-red-500" />;
default:
return <Info className="w-5 h-5 text-blue-500" />;
}
};
// Get notification type label
const getTypeLabel = (type: string) => {
return type
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
// Format timestamp
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 7) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
} else if (days > 0) {
return `${days} day${days !== 1 ? 's' : ''} ago`;
} else if (hours > 0) {
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
} else if (minutes > 0) {
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
} else {
return 'Just now';
}
};
// Handle notification click
const handleNotificationClick = async (id: number, isRead: boolean) => {
if (!isRead) {
try {
const { markNotificationRead } = await import('../../services/notifications.api');
await markNotificationRead(id);
storeMarkAsRead(`api_${id}`);
await loadNotifications();
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
}
};
// Handle delete
const handleDelete = async (id: number) => {
if (window.confirm('Delete this notification?')) {
try {
await deleteNotificationAPI(id);
await loadNotifications();
} catch (error) {
console.error('Failed to delete notification:', error);
}
}
};
// Handle mark all read
const handleMarkAllRead = async () => {
try {
const { markAllNotificationsRead } = await import('../../services/notifications.api');
await markAllNotificationsRead();
await storeMarkAllAsRead();
await loadNotifications();
} catch (error) {
console.error('Failed to mark all as read:', error);
}
};
// Filter notifications
const filteredNotifications = apiNotifications.filter((notification) => {
if (filters.severity && notification.severity !== filters.severity) {
return false;
}
if (
filters.notification_type &&
notification.notification_type !== filters.notification_type
) {
return false;
}
if (filters.is_read !== '' && String(notification.is_read) !== filters.is_read) {
return false;
}
return true;
});
// Get unique notification types for filter
const notificationTypes = Array.from(
new Set(apiNotifications.map((n) => n.notification_type))
);
return (
<>
<Helmet>
<title>Notifications - IGNY8</title>
</Helmet>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Notifications
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{unreadCount > 0 ? (
<span className="font-medium text-blue-600 dark:text-blue-400">
{unreadCount} unread notification{unreadCount !== 1 ? 's' : ''}
</span>
) : (
'All caught up!'
)}
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
<Filter className="w-4 h-4" />
Filters
</Button>
{unreadCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleMarkAllRead}
className="flex items-center gap-2"
>
<CheckCheck className="w-4 h-4" />
Mark All Read
</Button>
)}
</div>
</div>
{/* Filters */}
{showFilters && (
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Severity Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Severity
</label>
<select
value={filters.severity}
onChange={(e) =>
setFilters({ ...filters, severity: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="">All</option>
<option value="info">Info</option>
<option value="success">Success</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</div>
{/* Type Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Type
</label>
<select
value={filters.notification_type}
onChange={(e) =>
setFilters({ ...filters, notification_type: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="">All</option>
{notificationTypes.map((type) => (
<option key={type} value={type}>
{getTypeLabel(type)}
</option>
))}
</select>
</div>
{/* Read Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Status
</label>
<select
value={filters.is_read}
onChange={(e) =>
setFilters({ ...filters, is_read: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="">All</option>
<option value="false">Unread</option>
<option value="true">Read</option>
</select>
</div>
</div>
{/* Clear Filters */}
{Object.values(filters).some((v) => v !== '') && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
variant="ghost"
size="sm"
onClick={() =>
setFilters({
severity: '',
notification_type: '',
is_read: '',
})
}
>
Clear All Filters
</Button>
</div>
)}
</Card>
)}
{/* Notifications List */}
<Card className="overflow-hidden">
{loading ? (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : filteredNotifications.length === 0 ? (
<div className="text-center p-12">
<Bell className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 dark:text-gray-400">
{apiNotifications.length === 0
? 'No notifications yet'
: 'No notifications match your filters'}
</p>
</div>
) : (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredNotifications.map((notification) => (
<div
key={notification.id}
className={`p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
!notification.is_read ? 'bg-blue-50 dark:bg-blue-900/10' : ''
}`}
>
<div className="flex items-start gap-4">
{/* Icon */}
<div className="flex-shrink-0 mt-1">
{getSeverityIcon(notification.severity)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div>
<h3
className={`text-base font-medium ${
notification.is_read
? 'text-gray-900 dark:text-white'
: 'text-blue-900 dark:text-blue-100'
}`}
>
{notification.title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{notification.message}
</p>
{/* Metadata */}
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-gray-500 dark:text-gray-500">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatTimestamp(notification.created_at)}
</span>
<span className="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">
{getTypeLabel(notification.notification_type)}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{!notification.is_read && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleNotificationClick(notification.id, false)
}
>
<CheckCircle className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(notification.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Action Button */}
{notification.action_url && notification.action_label && (
<div className="mt-3">
<Link to={notification.action_url}>
<Button
variant="outline"
size="sm"
onClick={() =>
handleNotificationClick(
notification.id,
notification.is_read
)
}
>
{notification.action_label}
</Button>
</Link>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</Card>
{/* Footer Info */}
{filteredNotifications.length > 0 && (
<div className="text-center text-sm text-gray-500 dark:text-gray-500">
Showing {filteredNotifications.length} of {apiNotifications.length}{' '}
notification{apiNotifications.length !== 1 ? 's' : ''}
</div>
)}
</div>
</>
);
}

View File

@@ -9,7 +9,43 @@ import { fetchAPI } from './api';
// TYPES
// ============================================================================
export type NotificationTypeAPI = 'ai_task' | 'system' | 'credit' | 'billing' | 'integration' | 'content' | 'info';
// Notification types - match backend NotificationType choices
export type NotificationTypeAPI =
// AI Operations
| 'ai_cluster_complete'
| 'ai_cluster_failed'
| 'ai_ideas_complete'
| 'ai_ideas_failed'
| 'ai_content_complete'
| 'ai_content_failed'
| 'ai_images_complete'
| 'ai_images_failed'
| 'ai_prompts_complete'
| 'ai_prompts_failed'
// Workflow
| 'content_ready_review'
| 'content_published'
| 'content_publish_failed'
// WordPress Sync
| 'wordpress_sync_success'
| 'wordpress_sync_failed'
// Credits/Billing
| 'credits_low'
| 'credits_depleted'
// Setup
| 'site_setup_complete'
| 'keywords_imported'
// System
| 'system_info'
// Legacy/fallback
| 'ai_task'
| 'system'
| 'credit'
| 'billing'
| 'integration'
| 'content'
| 'info';
export type NotificationSeverityAPI = 'info' | 'success' | 'warning' | 'error';
export interface NotificationAPI {
@@ -59,7 +95,7 @@ export async function fetchNotifications(params?: {
if (params?.notification_type) searchParams.set('notification_type', params.notification_type);
const queryString = searchParams.toString();
const url = `v1/notifications/${queryString ? `?${queryString}` : ''}`;
const url = `/v1/notifications/${queryString ? `?${queryString}` : ''}`;
return fetchAPI(url);
}
@@ -68,14 +104,14 @@ export async function fetchNotifications(params?: {
* Get unread notification count
*/
export async function fetchUnreadCount(): Promise<UnreadCountResponse> {
return fetchAPI('v1/notifications/unread-count/');
return fetchAPI('/v1/notifications/unread-count/');
}
/**
* Mark a single notification as read
*/
export async function markNotificationRead(id: number): Promise<NotificationAPI> {
return fetchAPI(`v1/notifications/${id}/read/`, {
return fetchAPI(`/v1/notifications/${id}/read/`, {
method: 'POST',
});
}
@@ -84,7 +120,7 @@ export async function markNotificationRead(id: number): Promise<NotificationAPI>
* Mark all notifications as read
*/
export async function markAllNotificationsRead(): Promise<{ message: string; count: number }> {
return fetchAPI('v1/notifications/read-all/', {
return fetchAPI('/v1/notifications/read-all/', {
method: 'POST',
});
}
@@ -93,7 +129,7 @@ export async function markAllNotificationsRead(): Promise<{ message: string; cou
* Delete a notification
*/
export async function deleteNotification(id: number): Promise<void> {
await fetchAPI(`v1/notifications/${id}/`, {
await fetchAPI(`/v1/notifications/${id}/`, {
method: 'DELETE',
});
}

View File

@@ -83,21 +83,30 @@ const generateId = () => `notif_${Date.now()}_${Math.random().toString(36).slice
*/
function apiToStoreNotification(api: NotificationAPI): Notification {
// Map API notification_type to store category
const categoryMap: Record<string, NotificationCategory> = {
'ai_task': 'ai_task',
'system': 'system',
'credit': 'system',
'billing': 'system',
'integration': 'system',
'content': 'ai_task',
'info': 'info',
// All ai_* types map to 'ai_task', everything else to appropriate category
const getCategory = (type: string): NotificationCategory => {
if (type.startsWith('ai_')) return 'ai_task';
if (type.startsWith('content_') || type === 'keywords_imported') return 'ai_task';
if (type.startsWith('wordpress_') || type.startsWith('credits_') || type.startsWith('site_')) return 'system';
if (type === 'system_info' || type === 'system') return 'system';
// Legacy mappings
const legacyMap: Record<string, NotificationCategory> = {
'ai_task': 'ai_task',
'system': 'system',
'credit': 'system',
'billing': 'system',
'integration': 'system',
'content': 'ai_task',
'info': 'info',
};
return legacyMap[type] || 'info';
};
return {
id: `api_${api.id}`,
apiId: api.id,
type: api.severity as NotificationType,
category: categoryMap[api.notification_type] || 'info',
category: getCategory(api.notification_type),
title: api.title,
message: api.message,
timestamp: new Date(api.created_at),