diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 735f7375..8373de39 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -37,6 +37,7 @@ import { } from '@/lib/calendar-invitation'; import { cn } from '@/lib/utils'; import { sanitizeColor } from '@/components/calendar/event-card'; +import { RecipientPopover } from './recipient-popover'; interface InvitationChangeItem { label: string; @@ -327,25 +328,26 @@ function buildParticipantsForRsvp( ); } -function getMethodAccentClass(method: InvitationMethod, actorStatus?: string | null): string { +function getMethodIconTone(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'; + return 'bg-destructive/15 text-destructive'; case 'counter': - return 'border-l-amber-500 dark:border-l-amber-400'; + return 'bg-warning/15 text-warning'; 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'; + case 'accepted': return 'bg-success/15 text-success'; + case 'tentative': return 'bg-warning/15 text-warning'; + case 'declined': return 'bg-destructive/15 text-destructive'; + default: return 'bg-primary/15 text-primary'; } + case 'request': + case 'add': + case 'publish': + return 'bg-primary/15 text-primary'; default: - return 'border-l-slate-400 dark:border-l-slate-500'; + return 'bg-muted text-muted-foreground'; } } @@ -500,7 +502,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp 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. @@ -735,130 +736,151 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } }; - const accentClass = getMethodAccentClass(method, actorSummary?.participationStatus); - if (state === 'loading') { return ( -
- - - {t('loading')} +
+
+ +
+
+ + {t('loading')} +
); } if (state === 'error') { return ( -
- +
+
+ +
{t('parse_error')}
); } + const iconTone = getMethodIconTone(method, actorSummary?.participationStatus); + + const hasStatusPills = Boolean( + existingEvent + || userIsOrganizer + || (participationLabel && myParticipant) + || actionNotice + || (parsedEvent?.status && parsedEvent.status !== 'confirmed') + ); + + const showActionsRow = showDetails && ( + canRespond + || (supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation) + || canApplyProposal + || (supportsCalendar && (existingEvent || parsedEvent)) + || !supportsCalendar + ); + return ( -
- {/* Header */} -
-
- {isCancellation ? ( - - ) : ( - - )} - {bannerTitle} -
- {canCollapse && ( - +
+ {/* Avatar-style icon */} +
+ {isCancellation ? ( + + ) : ( + )}
- {/* Content */} - {showDetails && ( -
-
- {/* Left: Event info */} -
- {/* Event title */} - {summary?.title && ( -
-

- {summary.title} -

- {parsedEvent?.sequence != null && parsedEvent.sequence > 0 && ( - - {t('event_updated', { sequence: parsedEvent.sequence })} - - )} -
- )} - - {/* Event details */} -
- {summary?.start && ( -
- - - {formatDateTime(summary.start)} - {summary.end && ` – ${formatDateTime(summary.end)}`} - -
- )} - {summary?.location && ( -
- - {summary.location} -
- )} - {summary?.organizer && ( - - - {t('organizer', { name: summary.organizer })} - - )} - {summary && summary.attendeeCount > 0 && ( - {t('attendees', { count: summary.attendeeCount })} - )} + {/* Content column */} +
+ {/* Eyebrow + title + collapse */} +
+
+
+ {bannerTitle}
+ {summary?.title && ( +

+ {summary.title} +

+ )}
- {/* Right: Info & actor messages on large screens */} -
- {bannerInfo && ( -

{bannerInfo}

+
+ {parsedEvent?.sequence != null && parsedEvent.sequence > 0 && ( + + {t('event_updated', { sequence: parsedEvent.sequence })} + )} - {actorMessage && ( -

{actorMessage}

- )} - {actorSummary?.participationComment && ( -

- {t('actor_note', { comment: actorSummary.participationComment })} -

+ {canCollapse && ( + )}
- {/* Status badges */} - {(existingEvent || userIsOrganizer || (participationLabel && myParticipant) || actionFeedback || (parsedEvent?.status && parsedEvent.status !== 'confirmed')) && ( -
+ {/* Meta rows */} + {showDetails && summary && (summary.start || summary.location || summary.attendeeCount > 0) && ( +
+ {summary.start && ( + + + + {formatDateTime(summary.start)} + {summary.end && ` – ${formatDateTime(summary.end)}`} + + + )} + {summary.location && ( + + + {summary.location} + + )} + {summary.attendeeCount > 0 && ( + {t('attendees', { count: summary.attendeeCount })} + )} +
+ )} + + {/* Organizer row (clickable, left-aligned) */} + {showDetails && summary?.organizer && ( +
+ + {t('organizer_label')} + {summary.organizerEmail ? ( + + ) : ( + {summary.organizer} + )} +
+ )} + + {/* Status pills */} + {showDetails && hasStatusPills && ( +
{parsedEvent?.status && parsedEvent.status !== 'confirmed' && ( {t(`event_status_${parsedEvent.status}`)} @@ -876,21 +898,45 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {participationLabel && myParticipant && ( {t('your_response', { status: participationLabel })} )} - {actionFeedback && ( + {actionNotice && ( - {actionFeedback} + {actionNotice} )}
)} + {/* Info / actor messages */} + {showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && ( +
+ {bannerInfo &&

{bannerInfo}

} + {actorMessage &&

{actorMessage}

} + {actorSummary?.participationComment && ( +

{t('actor_note', { comment: actorSummary.participationComment })}

+ )} +
+ )} + + {/* Trust warning */} + {showDetails && trustMessage && trustAssessment && ( +
+ + {trustMessage} +
+ )} + {/* Proposed changes */} - {proposedChanges.length > 0 && ( + {showDetails && proposedChanges.length > 0 && (
{t('proposed_changes')}
@@ -904,169 +950,154 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
)} - {/* Trust warning */} - {trustMessage && trustAssessment && ( -
- - {trustMessage} -
- )} - {/* Action error */} - {actionError && ( -
- - {actionError} + {showDetails && actionError && ( +
+ + {actionError}
)} -
- )} - {/* Actions */} - {showDetails && ( -
- {canRespond && ( - <> - - - + {/* Actions */} + {showActionsRow && ( +
+ {canRespond && ( + <> + + + +
+ + )} -
- - )} + {supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && ( + <> + - {supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && ( - <> + {showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal( +
+
+ {t('select_calendar')} +
+ {calendars.map((cal) => ( + + ))} +
, + document.body, + )} + + )} + + {canApplyProposal && ( + )} - {showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal( -
-
- {t('select_calendar')} -
- {calendars.map((cal) => ( - - ))} -
, - document.body, - )} - - )} + {supportsCalendar && (existingEvent || parsedEvent) && ( + + )} - {canApplyProposal && ( - - )} + {!supportsCalendar && ( + {t('no_calendar')} + )} - {supportsCalendar && (existingEvent || parsedEvent) && ( - - )} - - {!supportsCalendar && ( - {t('no_calendar')} - )} - - {isProcessing && ( - + {isProcessing && ( + + )} +
)}
- )}
); } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index b37f5abe..ad5a52cf 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -4837,7 +4837,7 @@ export function EmailViewer({ {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || hasCalendarInvitation) && (
-
+
{/* External Content Controls */} {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( diff --git a/locales/cs/common.json b/locales/cs/common.json index 038e405e..04f73b42 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Návrh na změnu zamítnut", "cancelled_title": "Událost zrušena", "organizer": "Organizátor: {name}", + "organizer_label": "Organizátor:", "attendees": "{count, plural, one {1 účastník} few {# účastníci} other {# účastníků}}", "accept": "Přijmout", "maybe": "Možná", diff --git a/locales/de/common.json b/locales/de/common.json index f4a84719..e9318e59 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Gegenvorschlag abgelehnt", "cancelled_title": "Veranstaltung abgesagt", "organizer": "Organisiert von {name}", + "organizer_label": "Organisiert von", "attendees": "{count, plural, one {# Teilnehmer} other {# Teilnehmer}}", "accept": "Annehmen", "maybe": "Vielleicht", diff --git a/locales/en/common.json b/locales/en/common.json index 8f4b2338..6f7060e3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -415,6 +415,7 @@ "declined_counter_title": "Counter Proposal Declined", "cancelled_title": "Event Cancelled", "organizer": "Organized by {name}", + "organizer_label": "Organized by", "attendees": "{count, plural, one {# attendee} other {# attendees}}", "accept": "Accept", "maybe": "Maybe", diff --git a/locales/es/common.json b/locales/es/common.json index b30ecf6f..bf8ff4c3 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Contrapropuesta rechazada", "cancelled_title": "Evento cancelado", "organizer": "Organizado por {name}", + "organizer_label": "Organizado por", "attendees": "{count, plural, one {# asistente} other {# asistentes}}", "accept": "Aceptar", "maybe": "Quizás", diff --git a/locales/fr/common.json b/locales/fr/common.json index 9ee9d7a7..352fb138 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Contre-proposition refusée", "cancelled_title": "Événement annulé", "organizer": "Organisé par {name}", + "organizer_label": "Organisé par", "attendees": "{count, plural, one {# participant} other {# participants}}", "accept": "Accepter", "maybe": "Peut-être", diff --git a/locales/it/common.json b/locales/it/common.json index be102e55..aece9ba3 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Controproposta rifiutata", "cancelled_title": "Evento annullato", "organizer": "Organizzato da {name}", + "organizer_label": "Organizzato da", "attendees": "{count, plural, one {# partecipante} other {# partecipanti}}", "accept": "Accetta", "maybe": "Forse", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7e975d49..70588f99 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "対案は拒否されました", "cancelled_title": "イベントがキャンセルされました", "organizer": "{name} が主催", + "organizer_label": "主催者:", "attendees": "{count}名の参加者", "accept": "承諾", "maybe": "未定", diff --git a/locales/ko/common.json b/locales/ko/common.json index f1f03095..7652a347 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "시간 제안이 거절되었어요", "cancelled_title": "일정이 취소되었어요", "organizer": "주최자: {name}", + "organizer_label": "주최자:", "attendees": "{count, plural, other {참석자 #명}}", "accept": "수락", "maybe": "미정", diff --git a/locales/lv/common.json b/locales/lv/common.json index c1e73fd9..93144c04 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Pretpiedāvājums noraidīts", "cancelled_title": "Pasākums atcelts", "organizer": "Organizators: {name}", + "organizer_label": "Organizators:", "attendees": "{count, plural, one {# dalībnieks} other {# dalībnieki}}", "accept": "Pieņemt", "maybe": "Varbūt", diff --git a/locales/nl/common.json b/locales/nl/common.json index 46b2368a..eec97121 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Tegenvoorstel afgewezen", "cancelled_title": "Evenement geannuleerd", "organizer": "Georganiseerd door {name}", + "organizer_label": "Georganiseerd door", "attendees": "{count, plural, one {# deelnemer} other {# deelnemers}}", "accept": "Accepteren", "maybe": "Misschien", diff --git a/locales/pl/common.json b/locales/pl/common.json index d27e1cce..f9acb478 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Propozycja zmian odrzucona", "cancelled_title": "Wydarzenie anulowane", "organizer": "Organizator: {name}", + "organizer_label": "Organizator:", "attendees": "{count, plural, one {# uczestnik} other {# uczestników}}", "accept": "Akceptuj", "maybe": "Może", diff --git a/locales/pt/common.json b/locales/pt/common.json index 8ce30db6..5ad5ee3e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Contraproposta recusada", "cancelled_title": "Evento cancelado", "organizer": "Organizado por {name}", + "organizer_label": "Organizado por", "attendees": "{count, plural, one {# participante} other {# participantes}}", "accept": "Aceitar", "maybe": "Talvez", diff --git a/locales/ru/common.json b/locales/ru/common.json index b82f20bc..273c7300 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Встречное предложение отклонено", "cancelled_title": "Событие отменено", "organizer": "Организовано {name}", + "organizer_label": "Организовано", "attendees": "{count, plural, one {# участник} other {# участников}}", "accept": "Принять", "maybe": "Возможно", diff --git a/locales/tr/common.json b/locales/tr/common.json index 613a0751..a08cbb20 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Karşı Teklif Reddedildi", "cancelled_title": "Etkinlik İptal Edildi", "organizer": "{name} tarafından düzenleniyor", + "organizer_label": "Düzenleyen:", "attendees": "{count, plural, one {# katılımcı} other {# katılımcı}}", "accept": "Kabul et", "maybe": "Belki", diff --git a/locales/uk/common.json b/locales/uk/common.json index f518133a..9da85359 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Зустрічна пропозиція відхилена", "cancelled_title": "Подію скасовано", "organizer": "Організовано {name}", + "organizer_label": "Організовано", "attendees": "{count, plural, one {# учасник} few {# учасники} many {# учасників} other {# учасників}}", "accept": "прийняти", "maybe": "можливо", diff --git a/locales/zh/common.json b/locales/zh/common.json index b53542c8..442fe270 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "改期建议已被拒绝", "cancelled_title": "活动已取消", "organizer": "组织者:{name}", + "organizer_label": "组织者:", "attendees": "{count, plural, one {# 位参与者} other {# 位参与者}}", "accept": "接受", "maybe": "暂定",