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:
parent
a41b48f016
commit
0f378ad386
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user