Files
SRCmail/components/email/calendar-invitation-banner.tsx
T

1023 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
ArrowRight,
Calendar,
CalendarCheck,
CalendarX,
Clock,
MapPin,
Users,
Loader2,
Check,
HelpCircle,
X,
AlertCircle,
ChevronDown,
} from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { Email, CalendarEvent } from '@/lib/jmap/types';
import {
findCalendarAttachment,
getInvitationMethod,
getInvitationActorSummary,
getInvitationTrustAssessment,
formatEventSummary,
findParticipantByEmail,
extractMethodFromRawIcs,
type InvitationMethod,
type InvitationTrustAssessment,
} from '@/lib/calendar-invitation';
import { cn } from '@/lib/utils';
import { sanitizeColor } from '@/components/calendar/event-card';
interface InvitationChangeItem {
label: string;
before: string;
after: string;
}
function getBannerTitle(t: ReturnType<typeof useTranslations>, method: InvitationMethod): string {
switch (method) {
case 'publish':
return t('published_title');
case 'reply':
return t('response_title');
case 'add':
return t('update_title');
case 'counter':
return t('counter_title');
case 'refresh':
return t('refresh_title');
case 'declinecounter':
return t('declined_counter_title');
case 'cancel':
return t('cancelled_title');
case 'request':
case 'unknown':
default:
return t('title');
}
}
function getActorMessage(
t: ReturnType<typeof useTranslations>,
method: InvitationMethod,
actorName: string,
actorStatus: string | null,
): string | null {
switch (method) {
case 'reply':
return actorStatus
? t('actor_response_info', { name: actorName, status: actorStatus })
: t('actor_sent_info', { name: actorName });
case 'counter':
return t('actor_counter_info', { name: actorName });
case 'refresh':
return t('actor_refresh_info', { name: actorName });
case 'declinecounter':
return t('actor_declined_counter_info', { name: actorName });
case 'request':
case 'publish':
case 'add':
case 'cancel':
return t('actor_sent_info', { name: actorName });
default:
return null;
}
}
function getBannerInfo(
t: ReturnType<typeof useTranslations>,
method: InvitationMethod,
userIsOrganizer: boolean,
supportsCalendar: boolean,
): string | null {
if (!supportsCalendar) {
return t('no_calendar');
}
switch (method) {
case 'request':
return t('request_info');
case 'publish':
return t('published_info');
case 'reply':
return t(userIsOrganizer ? 'response_info_organizer' : 'response_info');
case 'add':
return t('update_info');
case 'cancel':
return t('cancel_info');
case 'counter':
return t(userIsOrganizer ? 'counter_info_organizer' : 'counter_info');
case 'refresh':
return t(userIsOrganizer ? 'refresh_info_organizer' : 'refresh_info');
case 'declinecounter':
return t('declined_counter_info');
default:
return null;
}
}
function getTrustMessage(
t: ReturnType<typeof useTranslations>,
trustAssessment: InvitationTrustAssessment,
): string | null {
switch (trustAssessment.reason) {
case 'authentication_failed':
return t('authentication_failed_info');
case 'authentication_missing':
return t('authentication_missing_info');
case 'sender_mismatch':
return t('sender_mismatch_info', {
sender: trustAssessment.senderEmail ?? '',
organizer: trustAssessment.organizerEmail ?? '',
});
case 'sender_mismatch_unverified':
return t('sender_mismatch_unverified_info', {
sender: trustAssessment.senderEmail ?? '',
organizer: trustAssessment.organizerEmail ?? '',
});
default:
return null;
}
}
function getParticipationLabel(
t: ReturnType<typeof useTranslations>,
status: string | null,
): string | null {
switch (status) {
case 'accepted':
return t('response_accepted');
case 'tentative':
return t('response_tentative');
case 'declined':
return t('response_declined');
case 'delegated':
return t('response_delegated');
case 'needs-action':
return t('response_needed');
default:
return null;
}
}
function getParticipationTone(status: string | null): string {
switch (status) {
case 'accepted':
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
case 'tentative':
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
case 'declined':
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
default:
return 'bg-muted text-muted-foreground';
}
}
function hasMeaningfulDifference<T>(left: T | null | undefined, right: T | null | undefined): boolean {
return JSON.stringify(left ?? null) !== JSON.stringify(right ?? null);
}
function getViewActionLabel(
t: ReturnType<typeof useTranslations>,
method: InvitationMethod,
userIsOrganizer: boolean,
): string {
if (method === 'counter' && userIsOrganizer) {
return t('review_proposal');
}
if (method === 'refresh' && userIsOrganizer) {
return t('review_request');
}
return t('view_in_calendar');
}
function buildProposalPatch(
currentEvent: Partial<CalendarEvent> | null,
proposedEvent: Partial<CalendarEvent> | null,
): Partial<CalendarEvent> | null {
if (!currentEvent || !proposedEvent) {
return null;
}
const patch: Partial<CalendarEvent> = {};
if (typeof proposedEvent.title === 'string' && proposedEvent.title !== currentEvent.title) {
patch.title = proposedEvent.title;
}
if (typeof proposedEvent.description === 'string' && proposedEvent.description !== currentEvent.description) {
patch.description = proposedEvent.description;
}
if (typeof proposedEvent.descriptionContentType === 'string' && proposedEvent.descriptionContentType !== currentEvent.descriptionContentType) {
patch.descriptionContentType = proposedEvent.descriptionContentType;
}
if (typeof proposedEvent.start === 'string' && proposedEvent.start !== currentEvent.start) {
patch.start = proposedEvent.start;
}
if (typeof proposedEvent.duration === 'string' && proposedEvent.duration !== currentEvent.duration) {
patch.duration = proposedEvent.duration;
}
if ((proposedEvent.timeZone ?? null) !== (currentEvent.timeZone ?? null)) {
patch.timeZone = proposedEvent.timeZone ?? null;
}
if ((proposedEvent.showWithoutTime ?? false) !== (currentEvent.showWithoutTime ?? false)) {
patch.showWithoutTime = proposedEvent.showWithoutTime ?? false;
}
if (hasMeaningfulDifference(proposedEvent.locations, currentEvent.locations)) {
patch.locations = proposedEvent.locations ?? null;
}
if (hasMeaningfulDifference(proposedEvent.virtualLocations, currentEvent.virtualLocations)) {
patch.virtualLocations = proposedEvent.virtualLocations ?? null;
}
return Object.keys(patch).length > 0 ? patch : null;
}
function buildInvitationChangeItems(
t: ReturnType<typeof useTranslations>,
currentEvent: Partial<CalendarEvent> | null,
proposedEvent: Partial<CalendarEvent> | null,
formatDateTime: (dateStr: string | null) => string,
): InvitationChangeItem[] {
if (!currentEvent || !proposedEvent) {
return [];
}
const currentSummary = formatEventSummary(currentEvent);
const proposedSummary = formatEventSummary(proposedEvent);
const changes: InvitationChangeItem[] = [];
if (currentSummary.title !== proposedSummary.title && proposedSummary.title) {
changes.push({
label: t('change_title'),
before: currentSummary.title || t('change_empty'),
after: proposedSummary.title,
});
}
const currentSchedule = currentSummary.start
? `${formatDateTime(currentSummary.start)}${currentSummary.end ? ` - ${formatDateTime(currentSummary.end)}` : ''}`
: t('change_empty');
const proposedSchedule = proposedSummary.start
? `${formatDateTime(proposedSummary.start)}${proposedSummary.end ? ` - ${formatDateTime(proposedSummary.end)}` : ''}`
: t('change_empty');
if (currentSchedule !== proposedSchedule && proposedSummary.start) {
changes.push({
label: t('change_time'),
before: currentSchedule,
after: proposedSchedule,
});
}
if ((currentSummary.location ?? '') !== (proposedSummary.location ?? '') && proposedSummary.location) {
changes.push({
label: t('change_location'),
before: currentSummary.location || t('change_empty'),
after: proposedSummary.location,
});
}
if ((currentEvent.description ?? '') !== (proposedEvent.description ?? '') && proposedEvent.description) {
changes.push({
label: t('change_description'),
before: currentEvent.description || t('change_empty'),
after: proposedEvent.description,
});
}
return changes;
}
function buildParticipantsForRsvp(
event: Partial<CalendarEvent>,
participantId: string,
status: 'accepted' | 'tentative' | 'declined',
): Record<string, NonNullable<CalendarEvent['participants']>[string]> | null {
if (!event.participants) {
return null;
}
return Object.fromEntries(
Object.entries(event.participants).map(([id, participant]) => [
id,
{
...participant,
participationStatus: id === participantId ? status : participant.participationStatus,
},
]),
);
}
function getMethodAccentClass(method: InvitationMethod, actorStatus?: string | null): string {
switch (method) {
case 'cancel':
case 'declinecounter':
return 'border-l-red-500 dark:border-l-red-400';
case 'request':
case 'add':
return 'border-l-blue-500 dark:border-l-blue-400';
case 'counter':
return 'border-l-amber-500 dark:border-l-amber-400';
case 'reply':
switch (actorStatus) {
case 'accepted': return 'border-l-green-500 dark:border-l-green-400';
case 'tentative': return 'border-l-amber-500 dark:border-l-amber-400';
case 'declined': return 'border-l-red-500 dark:border-l-red-400';
default: return 'border-l-blue-500 dark:border-l-blue-400';
}
default:
return 'border-l-slate-400 dark:border-l-slate-500';
}
}
interface CalendarInvitationBannerProps {
email: Email;
}
type BannerState = 'loading' | 'parsed' | 'rsvp-sent' | 'imported' | 'error';
export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProps) {
const t = useTranslations('email_viewer.calendar_invitation');
const format = useFormatter();
const router = useRouter();
const client = useAuthStore((s) => s.client);
const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email);
const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore();
const [state, setState] = useState<BannerState>('loading');
const [parsedEvent, setParsedEvent] = useState<Partial<CalendarEvent> | null>(null);
const [rsvpStatus, setRsvpStatus] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
const attachment = findCalendarAttachment(email);
const parseEvent = useCallback(async () => {
if (!client || !attachment || !calendarInvitationParsingEnabled) return;
setState('loading');
setActionNotice(null);
setActionError(null);
try {
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId);
if (events.length > 0) {
const parsed = events[0];
setParsedEvent(parsed);
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback.
try {
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
const rawText = await blob.text();
const icsMethod = extractMethodFromRawIcs(rawText);
if (icsMethod !== 'unknown') {
setRawIcsMethod(icsMethod);
}
} catch { /* ignore - fall back to heuristic detection */ }
if (parsed.uid && supportsCalendar) {
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
if (!storeHasIt) {
try {
const serverEvents = await client.queryCalendarEvents({});
const matching = serverEvents.filter((e) => e.uid === parsed.uid);
if (matching.length > 0) {
useCalendarStore.setState((s) => {
const existingIds = new Set(s.events.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
});
}
} catch { /* ignore lookup failure */ }
}
}
setState('parsed');
} else {
setState('error');
}
} catch {
setState('error');
}
}, [calendarInvitationParsingEnabled, client, attachment, supportsCalendar]);
useEffect(() => {
if (attachment && calendarInvitationParsingEnabled) {
parseEvent();
}
}, [calendarInvitationParsingEnabled, email.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (calendars.length > 0 && !selectedCalendarId) {
const defaultCal = calendars.find((c) => c.isDefault) || calendars[0];
setSelectedCalendarId(defaultCal.id);
}
}, [calendars, selectedCalendarId]);
if (!attachment || !calendarInvitationParsingEnabled) return null;
const detectedMethod = parsedEvent ? getInvitationMethod(parsedEvent, { email, attachment }) : 'unknown';
const method = detectedMethod !== 'unknown' ? detectedMethod : rawIcsMethod;
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
const isCancellation = method === 'cancel';
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
const allowsRsvp = method === 'request';
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
const existingEvent = parsedEvent?.uid
? storeEvents.find((e) => e.uid === parsedEvent.uid)
: null;
const currentUserParticipant = existingEvent && currentUserEmail
? findParticipantByEmail(existingEvent, currentUserEmail)
: null;
const fallbackParsedParticipant = !currentUserParticipant && parsedEvent && currentUserEmail
? findParticipantByEmail(parsedEvent, currentUserEmail)
: null;
const participantForUser = currentUserParticipant || fallbackParsedParticipant;
// Accept participant if they have attendee role OR if they are not exclusively an organizer
// Some JMAP servers may not set roles.attendee explicitly in CalendarEvent/parse results
const isOnlyOrganizer = participantForUser?.participant.roles
? (participantForUser.participant.roles.owner || participantForUser.participant.roles.chair)
&& !participantForUser.participant.roles.attendee
: false;
const myParticipant = participantForUser && !isOnlyOrganizer ? participantForUser : null;
const currentRsvp = rsvpStatus
|| myParticipant?.participant.participationStatus
|| null;
const userIsOrganizer = Boolean(isOnlyOrganizer);
const userIsSender = Boolean(
currentUserEmail && email.from?.[0]?.email?.toLowerCase() === currentUserEmail.toLowerCase()
);
const bannerTitle = getBannerTitle(t, method);
const bannerInfo = getBannerInfo(t, method, userIsOrganizer, supportsCalendar);
const trustAssessment = parsedEvent && !userIsSender ? getInvitationTrustAssessment(parsedEvent, email, method) : null;
const trustMessage = trustAssessment ? getTrustMessage(t, trustAssessment) : null;
const participationLabel = getParticipationLabel(t, currentRsvp);
const actorSummary = parsedEvent ? getInvitationActorSummary(parsedEvent, method) : null;
const actorName = actorSummary?.name || actorSummary?.email || t('actor_unknown');
const actorStatus = getParticipationLabel(t, actorSummary?.participationStatus ?? null);
const actorMessage = actorSummary ? getActorMessage(t, method, actorName, actorStatus) : null;
const actionFeedback = actionNotice;
// For REQUEST method, allow RSVP even if we can't find the user in participants:
// the email was sent TO the user, so they are an attendee. handleRsvp handles
// the import-then-find-participant flow for this case.
const canRespond = supportsCalendar && allowsRsvp && !isResponseOnly && !userIsOrganizer
&& (Boolean(myParticipant) || method === 'request');
const proposalPatch = existingEvent && parsedEvent ? buildProposalPatch(existingEvent, parsedEvent) : null;
const resolveExistingEventForRsvp = async () => {
if (!client || !existingEvent) {
return existingEvent;
}
if (existingEvent.participants && Object.keys(existingEvent.participants).length > 0) {
return existingEvent;
}
const hydratedEvent = await client.getCalendarEvent(existingEvent.id);
if (!hydratedEvent) {
return existingEvent;
}
useCalendarStore.setState((state) => ({
events: state.events.map((event) => event.id === hydratedEvent.id ? hydratedEvent : event),
}));
return hydratedEvent;
};
const handleRsvp = async (status: 'accepted' | 'tentative' | 'declined') => {
if (!client || !parsedEvent || isProcessing) return;
const calId = selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
setActionNotice(null);
setActionError(null);
setIsProcessing(true);
const replyToForRsvp = parsedEvent.replyTo
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
const imipStatus = status.toUpperCase() as 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
const organizerEmail = parsedEvent?.replyTo?.imip?.replace('mailto:', '')
|| parsedEvent?.organizerCalendarAddress?.replace('mailto:', '')
|| summary?.organizerEmail
|| null;
// Send the iMIP REPLY email to the organizer (client-side scheduling).
// Called after updating the local calendar event. Best-effort — if it
// fails we still report the RSVP as sent since the calendar was updated.
const sendImipReply = async () => {
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
return;
}
try {
await client.sendImipReply({
organizerEmail,
organizerName: summary?.organizer || undefined,
attendeeEmail: currentUserEmail,
attendeeName: myParticipant?.participant.name || undefined,
uid: parsedEvent.uid,
summary: parsedEvent.title,
dtStart: parsedEvent.start || undefined,
dtEnd: summary?.end || undefined,
timeZone: parsedEvent.timeZone || undefined,
isAllDay: parsedEvent.showWithoutTime || false,
sequence: parsedEvent.sequence,
status: imipStatus,
});
} catch {
// Best-effort: don't block the RSVP success notification
}
};
try {
const eventForRsvp = existingEvent
? await resolveExistingEventForRsvp()
: null;
const existingEventParticipant = eventForRsvp && currentUserEmail
? findParticipantByEmail(eventForRsvp, currentUserEmail)
: null;
const canFallbackToParsedParticipant = Boolean(
existingEvent
&& myParticipant
&& (!eventForRsvp?.participants || Object.keys(eventForRsvp.participants).length === 0)
);
if (eventForRsvp && existingEventParticipant) {
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
} else if (eventForRsvp && canFallbackToParsedParticipant && myParticipant) {
const repairedParticipants = buildParticipantsForRsvp(parsedEvent, myParticipant.id, status);
if (!repairedParticipants) {
setActionError(t('action_failed'));
} else {
await updateEvent(client, eventForRsvp.id, {
participants: repairedParticipants,
replyTo: replyToForRsvp ?? undefined,
}, true);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
}
} else if (eventForRsvp) {
setActionError(t('action_failed'));
} else if (calId) {
const imported = await importEvents(client, [parsedEvent], calId);
if (imported > 0) {
const newEvent = useCalendarStore.getState().events.find(
(e) => e.uid === parsedEvent.uid
);
const participant = myParticipant
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
if (newEvent && participant) {
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
} else {
setActionNotice(t('added'));
}
setState('parsed');
} else {
setActionError(t('action_failed'));
}
} else {
setActionError(t('action_failed'));
}
} catch (err) {
console.error('[CalendarInvitation] RSVP failed:', err);
setActionError(t('action_failed'));
} finally {
setIsProcessing(false);
}
};
const handleViewInCalendar = () => {
const targetDate = existingEvent?.utcStart
|| existingEvent?.start
|| parsedEvent?.utcStart
|| parsedEvent?.start;
if (targetDate) {
const parsedDate = new Date(targetDate);
if (!Number.isNaN(parsedDate.getTime())) {
setSelectedDate(parsedDate);
}
}
setShowCalendarPicker(false);
router.push('/calendar');
};
const handleImport = async (calendarId?: string) => {
const calId = calendarId || selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
if (!client || !parsedEvent || !calId || isProcessing) {
if (!calId) setActionError(t('action_failed'));
return;
}
setActionNotice(null);
setActionError(null);
setIsProcessing(true);
try {
const count = await importEvents(client, [parsedEvent], calId);
if (count > 0) {
setActionNotice(t('added'));
setState('parsed');
} else {
setActionError(t('action_failed'));
}
} catch {
setActionError(t('action_failed'));
} finally {
setIsProcessing(false);
}
};
const isAllDayEvent = parsedEvent?.showWithoutTime ?? false;
const formatDateTime = (dateStr: string | null) => {
if (!dateStr) return '';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
if (isAllDayEvent) {
return format.dateTime(date, {
weekday: 'short',
month: 'short',
day: 'numeric',
});
}
return format.dateTime(date, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};
const proposedChanges = method === 'counter' && existingEvent && parsedEvent
? buildInvitationChangeItems(t, existingEvent, parsedEvent, formatDateTime)
: [];
const canApplyProposal = Boolean(
client
&& existingEvent?.id
&& userIsOrganizer
&& method === 'counter'
&& proposalPatch
);
const viewActionLabel = getViewActionLabel(t, method, userIsOrganizer);
const handleApplyProposal = async () => {
if (!client || !existingEvent?.id || !proposalPatch || isProcessing) {
return;
}
setActionNotice(null);
setActionError(null);
setIsProcessing(true);
try {
await updateEvent(client, existingEvent.id, proposalPatch, true);
setActionNotice(t('proposal_applied'));
setState('parsed');
setRsvpStatus(null);
} catch {
setActionError(t('action_failed'));
} finally {
setIsProcessing(false);
}
};
const accentClass = getMethodAccentClass(method, actorSummary?.participationStatus);
if (state === 'loading') {
return (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 flex items-center gap-2.5">
<Calendar className="w-4 h-4 text-primary flex-shrink-0" />
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">{t('loading')}</span>
</div>
);
}
if (state === 'error') {
return (
<div className="rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50/50 dark:bg-red-950/20 px-4 py-3 flex items-center gap-2.5">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
<span className="text-sm text-red-600 dark:text-red-400">{t('parse_error')}</span>
</div>
);
}
return (
<div className={cn("rounded-lg border border-border overflow-hidden border-l-4", accentClass)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-border">
<div className="flex items-center gap-2 min-w-0">
{isCancellation ? (
<CalendarX className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
) : (
<Calendar className="w-4 h-4 text-primary flex-shrink-0" />
)}
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
</div>
</div>
{/* Content */}
<div className="px-4 py-3 space-y-2.5">
{/* Event title */}
{summary?.title && (
<div className="flex items-start justify-between gap-2">
<h3 className={cn(
"text-base font-semibold leading-snug",
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
)}>
{summary.title}
</h3>
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
{t('event_updated', { sequence: parsedEvent.sequence })}
</span>
)}
</div>
)}
{/* Event details */}
<div className="space-y-1">
{summary?.start && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
</div>
)}
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span>{summary.location}</span>
</div>
)}
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
{summary?.organizer && (
<span className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
{t('organizer', { name: summary.organizer })}
</span>
)}
{summary && summary.attendeeCount > 0 && (
<span>{t('attendees', { count: summary.attendeeCount })}</span>
)}
</div>
</div>
{/* Status badges */}
{(existingEvent || userIsOrganizer || (participationLabel && myParticipant) || actionFeedback || (parsedEvent?.status && parsedEvent.status !== 'confirmed')) && (
<div className="flex items-center gap-1.5 flex-wrap">
{parsedEvent?.status && parsedEvent.status !== 'confirmed' && (
<span className={cn(
"rounded-full px-2 py-0.5 text-[11px] font-medium",
parsedEvent.status === 'cancelled'
? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
)}>
{t(`event_status_${parsedEvent.status}`)}
</span>
)}
{existingEvent && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
{t('already_in_calendar')}
</span>
)}
{userIsOrganizer && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{t('organizer_role')}
</span>
)}
{participationLabel && myParticipant && (
<span className={cn(
'rounded-full px-2 py-0.5 text-[11px] font-medium',
getParticipationTone(currentRsvp)
)}>
{t('your_response', { status: participationLabel })}
</span>
)}
{actionFeedback && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
{actionFeedback}
</span>
)}
</div>
)}
{/* Info text */}
{bannerInfo && (
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
{/* Actor message */}
{actorMessage && (
<p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{/* Actor comment */}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
)}
{/* Proposed changes */}
{proposedChanges.length > 0 && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
<div className="font-medium text-foreground mb-1.5">{t('proposed_changes')}</div>
<div className="space-y-1.5">
{proposedChanges.map((change) => (
<div key={change.label}>
<span className="font-medium text-foreground">{change.label}: </span>
<span className="text-muted-foreground">{t('change_from_to', { before: change.before, after: change.after })}</span>
</div>
))}
</div>
</div>
)}
{/* Trust warning */}
{trustMessage && trustAssessment && (
<div className={cn(
'flex items-start gap-1.5 text-xs rounded-md px-3 py-2',
trustAssessment.level === 'warning'
? 'bg-red-50 dark:bg-red-950/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-900/50'
: 'bg-amber-50 dark:bg-amber-950/20 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-900/50'
)}>
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{trustMessage}</span>
</div>
)}
{/* Action error */}
{actionError && (
<div className="flex items-start gap-1.5 text-xs text-red-600 dark:text-red-400 rounded-md px-3 py-2 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900/50">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{actionError}</span>
</div>
)}
</div>
{/* Actions */}
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
{canRespond && (
<>
<button
onClick={() => handleRsvp('accepted')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'accepted'}
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'accepted'
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800"
: "text-muted-foreground hover:text-green-700 dark:hover:text-green-400 border-border hover:border-green-300 dark:hover:border-green-800 hover:bg-green-50 dark:hover:bg-green-950/20"
)}
>
<Check className="w-3.5 h-3.5" />
{t('accept')}
</button>
<button
onClick={() => handleRsvp('tentative')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'tentative'}
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'tentative'
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border-amber-200 dark:border-amber-800"
: "text-muted-foreground hover:text-amber-700 dark:hover:text-amber-400 border-border hover:border-amber-300 dark:hover:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-950/20"
)}
>
<HelpCircle className="w-3.5 h-3.5" />
{t('maybe')}
</button>
<button
onClick={() => handleRsvp('declined')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'declined'}
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'declined'
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800"
: "text-muted-foreground hover:text-red-700 dark:hover:text-red-400 border-border hover:border-red-300 dark:hover:border-red-800 hover:bg-red-50 dark:hover:bg-red-950/20"
)}
>
<X className="w-3.5 h-3.5" />
{t('decline')}
</button>
<div className="w-px h-5 bg-border" />
</>
)}
{supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && (
<div className="relative">
<button
onClick={() => {
if (calendars.length <= 1) {
handleImport();
} else {
setShowCalendarPicker(!showCalendarPicker);
}
}}
disabled={isProcessing}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50"
>
<CalendarCheck className="w-3.5 h-3.5" />
{t('add_to_calendar')}
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
</button>
{showCalendarPicker && calendars.length > 1 && (
<div className="absolute left-0 top-full mt-1 w-52 bg-background rounded-lg shadow-lg border border-border z-10 py-1">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t('select_calendar')}
</div>
{calendars.map((cal) => (
<button
key={cal.id}
onClick={() => {
setShowCalendarPicker(false);
handleImport(cal.id);
}}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: sanitizeColor(cal.color) }}
/>
<span className="truncate text-foreground">{cal.name}</span>
</button>
))}
</div>
)}
</div>
)}
{canApplyProposal && (
<button
onClick={handleApplyProposal}
disabled={isProcessing}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50"
>
<CalendarCheck className="w-3.5 h-3.5" />
{t('apply_proposal')}
</button>
)}
{supportsCalendar && (existingEvent || parsedEvent) && (
<button
onClick={handleViewInCalendar}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md hover:bg-muted transition-colors min-h-[36px]"
>
<Calendar className="w-3.5 h-3.5" />
{viewActionLabel}
<ArrowRight className="w-3 h-3" />
</button>
)}
{!supportsCalendar && (
<span className="text-xs text-muted-foreground italic">{t('no_calendar')}</span>
)}
{isProcessing && (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
)}
</div>
</div>
);
}