resolve todos in website and api

This commit is contained in:
2025-12-20 10:45:56 +01:00
parent 656ec62426
commit 7bbad511e2
62 changed files with 2036 additions and 611 deletions

View File

@@ -1,7 +1,5 @@
'use client';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { Notification } from '@core/notifications/application';
import {
AlertTriangle,
Bell,
@@ -36,33 +34,14 @@ const notificationColors: Record<string, string> = {
race_reminder: 'text-warning-amber bg-warning-amber/10',
};
import { useNotifications } from './NotificationProvider';
import type { Notification } from './NotificationProvider';
export default function NotificationCenter() {
const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const currentDriverId = useEffectiveDriverId();
// Polling for new notifications
// TODO
// useEffect(() => {
// const loadNotifications = async () => {
// try {
// const repo = getNotificationRepository();
// const allNotifications = await repo.findByRecipientId(currentDriverId);
// setNotifications(allNotifications);
// } catch (error) {
// console.error('Failed to load notifications:', error);
// }
// };
// loadNotifications();
// // Poll every 5 seconds
// const interval = setInterval(loadNotifications, 5000);
// return () => clearInterval(interval);
// }, [currentDriverId]);
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications();
// Close panel when clicking outside
useEffect(() => {
@@ -81,42 +60,9 @@ export default function NotificationCenter() {
};
}, [isOpen]);
const unreadCount = notifications.filter((n) => n.isUnread()).length;
const handleNotificationClick = (notification: Notification) => {
markAsRead(notification.id);
const handleMarkAsRead = async (notification: Notification) => {
if (!notification.isUnread()) return;
try {
const markRead = getMarkNotificationReadUseCase();
await markRead.execute({
notificationId: notification.id,
recipientId: currentDriverId,
});
// Update local state
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? n.markAsRead() : n))
);
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const handleMarkAllAsRead = async () => {
try {
const repo = getNotificationRepository();
await repo.markAllAsReadByRecipientId(currentDriverId);
// Update local state
setNotifications((prev) => prev.map((n) => n.markAsRead()));
} catch (error) {
console.error('Failed to mark all as read:', error);
}
};
const handleNotificationClick = async (notification: Notification) => {
await handleMarkAsRead(notification);
if (notification.actionUrl) {
router.push(notification.actionUrl);
setIsOpen(false);
@@ -176,7 +122,7 @@ export default function NotificationCenter() {
</div>
{unreadCount > 0 && (
<button
onClick={handleMarkAllAsRead}
onClick={markAllAsRead}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-white transition-colors"
>
<CheckCheck className="w-3.5 h-3.5" />
@@ -209,7 +155,7 @@ export default function NotificationCenter() {
onClick={() => handleNotificationClick(notification)}
className={`
w-full text-left px-4 py-3 transition-colors hover:bg-iron-gray/30
${notification.isUnread() ? 'bg-primary-blue/5' : ''}
${!notification.read ? 'bg-primary-blue/5' : ''}
`}
>
<div className="flex gap-3">
@@ -219,16 +165,16 @@ export default function NotificationCenter() {
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={`text-sm font-medium truncate ${
notification.isUnread() ? 'text-white' : 'text-gray-300'
!notification.read ? 'text-white' : 'text-gray-300'
}`}>
{notification.title}
</p>
{notification.isUnread() && (
{!notification.read && (
<span className="w-2 h-2 bg-primary-blue rounded-full flex-shrink-0 mt-1.5" />
)}
</div>
<p className="text-xs text-gray-500 line-clamp-2 mt-0.5">
{notification.body}
{notification.message}
</p>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-[10px] text-gray-600">

View File

@@ -1,21 +1,55 @@
'use client';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { v4 as uuid } from 'uuid';
import type { Notification } from '@core/notifications/application';
import ModalNotification from './ModalNotification';
import ToastNotification from './ToastNotification';
export type NotificationVariant = 'toast' | 'modal' | 'center';
export interface NotificationAction {
id: string;
label: string;
type?: 'primary' | 'secondary' | 'danger';
href?: string;
}
export interface Notification {
id: string;
type: string;
title?: string;
message: string;
createdAt: Date;
variant: NotificationVariant;
actionUrl?: string;
requiresResponse?: boolean;
actions?: NotificationAction[];
data?: Record<string, unknown>;
read: boolean;
}
interface AddNotificationInput {
id?: string;
type: string;
title?: string;
message: string;
createdAt?: Date;
variant?: NotificationVariant;
actionUrl?: string;
requiresResponse?: boolean;
actions?: NotificationAction[];
data?: Record<string, unknown>;
}
interface NotificationContextValue {
notifications: Notification[];
unreadCount: number;
toastNotifications: Notification[];
modalNotification: Notification | null;
markAsRead: (notification: Notification) => Promise<void>;
dismissToast: (notification: Notification) => void;
respondToModal: (notification: Notification, actionId?: string) => Promise<void>;
dismissModal: (notification: Notification) => Promise<void>;
addNotification: (input: AddNotificationInput) => string;
dismissNotification: (id: string) => void;
clearNotifications: () => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
}
const NotificationContext = createContext<NotificationContextValue | null>(null);
@@ -34,133 +68,85 @@ interface NotificationProviderProps {
export default function NotificationProvider({ children }: NotificationProviderProps) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [toastNotifications, setToastNotifications] = useState<Notification[]>([]);
const [modalNotification, setModalNotification] = useState<Notification | null>(null);
const [seenNotificationIds, setSeenNotificationIds] = useState<Set<string>>(new Set());
const currentDriverId = useEffectiveDriverId();
// Poll for new notifications
// TODO
// useEffect(() => {
// const loadNotifications = async () => {
// try {
// const repo = getNotificationRepository();
// const allNotifications = await repo.findByRecipientId(currentDriverId);
// setNotifications(allNotifications);
const addNotification = useCallback((input: AddNotificationInput): string => {
const id = input.id ?? uuid();
// // Check for new notifications that need toast/modal display
// allNotifications.forEach((notification) => {
// // Check both unread and action_required status for modals
// const shouldDisplay = (notification.isUnread() || notification.isActionRequired()) &&
// !seenNotificationIds.has(notification.id);
// if (shouldDisplay) {
// // Mark as seen to prevent duplicate displays
// setSeenNotificationIds((prev) => new Set([...prev, notification.id]));
const notification: Notification = {
id,
type: input.type,
title: input.title,
message: input.message,
createdAt: input.createdAt ?? new Date(),
variant: input.variant ?? 'toast',
actionUrl: input.actionUrl,
requiresResponse: input.requiresResponse,
actions: input.actions,
data: input.data,
read: false,
};
// // Handle based on urgency
// if (notification.isModal()) {
// // Modal takes priority - show immediately
// setModalNotification(notification);
// } else if (notification.isToast()) {
// // Add to toast queue
// setToastNotifications((prev) => [...prev, notification]);
// }
// // Silent notifications just appear in the notification center
// }
// });
// } catch (error) {
// console.error('Failed to load notifications:', error);
// }
// };
setNotifications((prev) => [notification, ...prev]);
// loadNotifications();
// // Poll every 2 seconds for responsiveness
// const interval = setInterval(loadNotifications, 2000);
// return () => clearInterval(interval);
// }, [currentDriverId, seenNotificationIds]);
return id;
}, []);
const dismissNotification = useCallback((id: string) => {
setNotifications((prev) => prev.filter((notification) => notification.id !== id));
}, []);
const clearNotifications = useCallback(() => {
setNotifications([]);
}, []);
const markAsRead = useCallback((id: string) => {
setNotifications((prev) =>
prev.map((notification) =>
notification.id === id ? { ...notification, read: true } : notification,
),
);
}, []);
const markAllAsRead = useCallback(() => {
setNotifications((prev) => prev.map((notification) => ({ ...notification, read: true })));
}, []);
const unreadCount = useMemo(
() => notifications.filter((notification) => !notification.read).length,
[notifications],
);
const modalNotification = useMemo(
() => notifications.find((notification) => notification.variant === 'modal' && !notification.read) ?? null,
[notifications],
);
const toastNotifications = useMemo(
() => notifications.filter((notification) => notification.variant === 'toast' && !notification.read),
[notifications],
);
// Prevent body scroll when modal is open
useEffect(() => {
if (modalNotification) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
if (!modalNotification) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [modalNotification]);
const markAsRead = useCallback(async (notification: Notification) => {
try {
const markRead = getMarkNotificationReadUseCase();
await markRead.execute({
notificationId: notification.id,
recipientId: currentDriverId,
});
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? n.markAsRead() : n))
);
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
}, [currentDriverId]);
const dismissToast = useCallback((notification: Notification) => {
setToastNotifications((prev) => prev.filter((n) => n.id !== notification.id));
}, []);
const respondToModal = useCallback(async (notification: Notification, actionId?: string) => {
try {
// Mark as responded
const repo = getNotificationRepository();
const updated = notification.markAsResponded(actionId);
await repo.update(updated);
// Update local state
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? updated : n))
);
// Clear modal
setModalNotification(null);
} catch (error) {
console.error('Failed to respond to notification:', error);
}
}, []);
const dismissModal = useCallback(async (notification: Notification) => {
try {
// Dismiss the notification
const repo = getNotificationRepository();
const updated = notification.dismiss();
await repo.update(updated);
// Update local state
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? updated : n))
);
// Clear modal
setModalNotification(null);
} catch (error) {
console.error('Failed to dismiss notification:', error);
}
}, []);
const unreadCount = notifications.filter((n) => n.isUnread() || n.isActionRequired()).length;
const value: NotificationContextValue = {
notifications,
unreadCount,
toastNotifications,
modalNotification,
addNotification,
dismissNotification,
clearNotifications,
markAsRead,
dismissToast,
respondToModal,
dismissModal,
markAllAsRead,
};
return (
@@ -173,8 +159,8 @@ export default function NotificationProvider({ children }: NotificationProviderP
<ToastNotification
key={notification.id}
notification={notification}
onDismiss={dismissToast}
onRead={markAsRead}
onDismiss={() => dismissNotification(notification.id)}
onRead={() => markAsRead(notification.id)}
/>
))}
</div>
@@ -183,8 +169,17 @@ export default function NotificationProvider({ children }: NotificationProviderP
{modalNotification && (
<ModalNotification
notification={modalNotification}
onAction={respondToModal}
onDismiss={dismissModal}
onAction={(notification, actionId) => {
// For now we just mark as read and optionally navigate via ModalNotification
markAsRead(notification.id);
if (actionId === 'dismiss') {
dismissNotification(notification.id);
}
}}
onDismiss={(notification) => {
markAsRead(notification.id);
dismissNotification(notification.id);
}}
/>
)}
</NotificationContext.Provider>