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 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-16 13:00:27 +08:00
parent a41b48f016
commit 0f378ad386
2 changed files with 113 additions and 3 deletions

View File

@ -132,10 +132,28 @@ export default function NotificationToaster() {
useEffect(() => { useEffect(() => {
if (!notifications.length) return; 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) { if (!initializedRef.current) {
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id)); maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
initializedRef.current = true; 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; return;
} }

View File

@ -1,7 +1,7 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; 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 { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
@ -10,7 +10,7 @@ import { useSharedCalendars } from '@/hooks/useSharedCalendars';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import axios from 'axios'; import axios from 'axios';
import { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import { ListSkeleton } from '@/components/ui/skeleton'; import { ListSkeleton } from '@/components/ui/skeleton';
import type { AppNotification } from '@/types'; import type { AppNotification } from '@/types';
@ -20,6 +20,8 @@ const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
calendar_invite: { icon: Calendar, color: 'text-purple-400' }, calendar_invite: { icon: Calendar, color: 'text-purple-400' },
calendar_invite_accepted: { icon: Calendar, color: 'text-green-400' }, calendar_invite_accepted: { icon: Calendar, color: 'text-green-400' },
calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' }, 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' }, info: { icon: Info, color: 'text-blue-400' },
warning: { icon: AlertCircle, color: 'text-amber-400' }, warning: { icon: AlertCircle, color: 'text-amber-400' },
}; };
@ -41,6 +43,7 @@ export default function NotificationsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [filter, setFilter] = useState<Filter>('all'); const [filter, setFilter] = useState<Filter>('all');
const [respondingEventInvite, setRespondingEventInvite] = useState<number | null>(null);
// Build a set of pending connection request IDs for quick lookup // Build a set of pending connection request IDs for quick lookup
const pendingInviteIds = useMemo( const pendingInviteIds = useMemo(
@ -60,6 +63,10 @@ export default function NotificationsPage() {
if (notifications.some((n) => n.type === 'calendar_invite' && !n.is_read)) { if (notifications.some((n) => n.type === 'calendar_invite' && !n.is_read)) {
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] }); 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( const hasMissing = notifications.some(
(n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id), (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<string, unknown> | 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) => { const handleNotificationClick = async (notification: AppNotification) => {
// Don't navigate for pending connection requests — let user act inline // Don't navigate for pending connection requests — let user act inline
if ( if (
@ -150,6 +198,10 @@ export default function NotificationsPage() {
) { ) {
return; 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) { if (!notification.is_read) {
await markRead([notification.id]).catch(() => {}); await markRead([notification.id]).catch(() => {});
} }
@ -157,6 +209,10 @@ export default function NotificationsPage() {
if (notification.type === 'connection_request' || notification.type === 'connection_accepted') { if (notification.type === 'connection_request' || notification.type === 'connection_accepted') {
navigate('/people'); navigate('/people');
} }
// Navigate to Calendar for event-related notifications
if (notification.type === 'event_invite' || notification.type === 'event_invite_response') {
navigate('/calendar');
}
}; };
return ( return (
@ -311,6 +367,42 @@ export default function NotificationsPage() {
</Button> </Button>
</div> </div>
)} )}
{/* Event invite actions (inline) */}
{notification.type === 'event_invite' &&
!notification.is_read && (
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="sm"
onClick={(e) => { e.stopPropagation(); handleEventInviteRespond(notification, 'accepted'); }}
disabled={respondingEventInvite === notification.id}
className="gap-1 h-7 text-xs"
>
{respondingEventInvite === notification.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
Going
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleEventInviteRespond(notification, 'tentative'); }}
disabled={respondingEventInvite === notification.id}
className="h-7 text-xs gap-1 text-amber-400 hover:text-amber-300"
>
<Clock className="h-3 w-3" />
Maybe
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleEventInviteRespond(notification, 'declined'); }}
disabled={respondingEventInvite === notification.id}
className="h-7 text-xs"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* Timestamp + actions */} {/* Timestamp + actions */}
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">