From 0f378ad38679fe7652c93abaff8db4ed058782aa Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 16 Mar 2026 13:00:27 +0800 Subject: [PATCH] Add event invite actions to notification center + toast on login - NotificationsPage: Going/Maybe/Decline buttons for event_invite notifications - NotificationsPage: event_invite icon mapping, eager-refetch, click-to-calendar nav - NotificationToaster: toast actionable unread notifications on first load (max 3) so users see pending invites/requests when they sign in Co-Authored-By: Claude Opus 4.6 --- .../notifications/NotificationToaster.tsx | 20 +++- .../notifications/NotificationsPage.tsx | 96 ++++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index f01d395..a1cab26 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -132,10 +132,28 @@ export default function NotificationToaster() { useEffect(() => { if (!notifications.length) return; - // On first load, record the max ID without toasting + // On first load, record the max ID — but still toast actionable unread items if (!initializedRef.current) { maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id)); initializedRef.current = true; + + // Toast actionable unread notifications on login so the user can act immediately + const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite']); + const actionable = notifications.filter( + (n) => !n.is_read && actionableTypes.has(n.type), + ); + if (actionable.length === 0) return; + // Show at most 3 toasts on first load to avoid flooding + const toShow = actionable.slice(0, 3); + toShow.forEach((notification) => { + if (notification.type === 'connection_request' && notification.source_id) { + showConnectionRequestToast(notification); + } else if (notification.type === 'calendar_invite' && notification.source_id) { + showCalendarInviteToast(notification); + } else if (notification.type === 'event_invite' && notification.data) { + showEventInviteToast(notification); + } + }); return; } diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx index 7ad45f9..1a161d5 100644 --- a/frontend/src/components/notifications/NotificationsPage.tsx +++ b/frontend/src/components/notifications/NotificationsPage.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar } from 'lucide-react'; +import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { toast } from 'sonner'; import { useNotifications } from '@/hooks/useNotifications'; @@ -10,7 +10,7 @@ import { useSharedCalendars } from '@/hooks/useSharedCalendars'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import axios from 'axios'; -import { getErrorMessage } from '@/lib/api'; +import api, { getErrorMessage } from '@/lib/api'; import { ListSkeleton } from '@/components/ui/skeleton'; import type { AppNotification } from '@/types'; @@ -20,6 +20,8 @@ const typeIcons: Record = { calendar_invite: { icon: Calendar, color: 'text-purple-400' }, calendar_invite_accepted: { icon: Calendar, color: 'text-green-400' }, calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' }, + event_invite: { icon: Calendar, color: 'text-purple-400' }, + event_invite_response: { icon: Calendar, color: 'text-green-400' }, info: { icon: Info, color: 'text-blue-400' }, warning: { icon: AlertCircle, color: 'text-amber-400' }, }; @@ -41,6 +43,7 @@ export default function NotificationsPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); const [filter, setFilter] = useState('all'); + const [respondingEventInvite, setRespondingEventInvite] = useState(null); // Build a set of pending connection request IDs for quick lookup const pendingInviteIds = useMemo( @@ -60,6 +63,10 @@ export default function NotificationsPage() { if (notifications.some((n) => n.type === 'calendar_invite' && !n.is_read)) { queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] }); } + // Refresh event invitations + if (notifications.some((n) => n.type === 'event_invite' && !n.is_read)) { + queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); + } const hasMissing = notifications.some( (n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id), ); @@ -141,6 +148,47 @@ export default function NotificationsPage() { } } }; + const handleEventInviteRespond = async ( + notification: AppNotification, + status: 'accepted' | 'tentative' | 'declined', + ) => { + const data = notification.data as Record | undefined; + const eventId = data?.event_id as number | undefined; + if (!eventId) return; + + setRespondingEventInvite(notification.id); + try { + // Fetch pending invitations to resolve the invitation ID + const { data: pending } = await api.get('/event-invitations/pending'); + const inv = (pending as Array<{ id: number; event_id: number }>).find( + (p) => p.event_id === eventId, + ); + if (inv) { + await api.put(`/event-invitations/${inv.id}/respond`, { status }); + const successLabel = { accepted: 'Going', tentative: 'Tentative', declined: 'Declined' }; + toast.success(`Marked as ${successLabel[status]}`); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); + } else { + toast.success('Already responded'); + } + if (!notification.is_read) { + await markRead([notification.id]).catch(() => {}); + } + } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 409) { + toast.success('Already responded'); + if (!notification.is_read) { + await markRead([notification.id]).catch(() => {}); + } + } else { + toast.error(getErrorMessage(err, 'Failed to respond')); + } + } finally { + setRespondingEventInvite(null); + } + }; + const handleNotificationClick = async (notification: AppNotification) => { // Don't navigate for pending connection requests — let user act inline if ( @@ -150,6 +198,10 @@ export default function NotificationsPage() { ) { return; } + // Don't navigate for unread event invites — let user act inline + if (notification.type === 'event_invite' && !notification.is_read) { + return; + } if (!notification.is_read) { await markRead([notification.id]).catch(() => {}); } @@ -157,6 +209,10 @@ export default function NotificationsPage() { if (notification.type === 'connection_request' || notification.type === 'connection_accepted') { navigate('/people'); } + // Navigate to Calendar for event-related notifications + if (notification.type === 'event_invite' || notification.type === 'event_invite_response') { + navigate('/calendar'); + } }; return ( @@ -311,6 +367,42 @@ export default function NotificationsPage() { )} + + {/* Event invite actions (inline) */} + {notification.type === 'event_invite' && + !notification.is_read && ( +
+ + + +
+ )} + {/* Timestamp + actions */}