From 72dffe12e53ff9d74ef43da2cf653d31e7a4df25 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Mon, 29 Dec 2025 15:26:53 +0100 Subject: [PATCH] feat: Email preview in contact and activity feed --- apps/api/src/services/ActivityService.ts | 5 + apps/web/src/components/ActivityItem.tsx | 39 +++- apps/web/src/components/EmailPreviewModal.tsx | 173 ++++++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/EmailPreviewModal.tsx diff --git a/apps/api/src/services/ActivityService.ts b/apps/api/src/services/ActivityService.ts index f46e47e..6e3ef61 100644 --- a/apps/api/src/services/ActivityService.ts +++ b/apps/api/src/services/ActivityService.ts @@ -454,6 +454,11 @@ export class ActivityService { for (const email of emails) { const baseMetadata = { subject: email.subject, + body: email.body, + from: email.from, + fromName: email.fromName, + replyTo: email.replyTo, + toName: email.toName, sourceType: email.sourceType, campaignName: email.campaign?.name, workflowName: email.workflowExecution?.workflow?.name, diff --git a/apps/web/src/components/ActivityItem.tsx b/apps/web/src/components/ActivityItem.tsx index cf32c14..5f934e0 100644 --- a/apps/web/src/components/ActivityItem.tsx +++ b/apps/web/src/components/ActivityItem.tsx @@ -1,6 +1,7 @@ -import {Badge, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui'; +import {Badge, Button, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui'; import type {Activity} from './ActivityFeed'; -import {memo} from 'react'; +import {memo, useState} from 'react'; +import {EmailPreviewModal} from './EmailPreviewModal'; import { AlertCircle, Calendar, @@ -90,6 +91,13 @@ function getUpcomingTime(date: Date): string { return `in ${diffInMonths} ${diffInMonths === 1 ? 'month' : 'months'}`; } +/** + * Check if an activity is an email activity + */ +function isEmailActivity(type: string): boolean { + return ['email.sent', 'email.delivered', 'email.opened', 'email.clicked', 'email.bounced'].includes(type); +} + interface ActivityItemProps { activity: Activity; isUpcoming?: boolean; @@ -295,6 +303,7 @@ function getActivityConfig(activity: Activity): ActivityConfig { } export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) { + const [showPreviewModal, setShowPreviewModal] = useState(false); const config = getActivityConfig(activity); const Icon = config.icon; const timestamp = new Date(activity.timestamp); @@ -318,6 +327,17 @@ export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = f {config.title}

{config.badge && {config.badge.label}} + {isEmailActivity(activity.type) && activity.metadata.subject && activity.metadata.body ? ( + + ) : null} {config.description &&

{config.description}

} {activity.contactEmail && ( @@ -357,6 +377,21 @@ export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = f + + {/* Email Preview Modal */} + {showPreviewModal && activity.metadata.subject && activity.metadata.body ? ( + + ) : null} ); }); diff --git a/apps/web/src/components/EmailPreviewModal.tsx b/apps/web/src/components/EmailPreviewModal.tsx new file mode 100644 index 0000000..52df741 --- /dev/null +++ b/apps/web/src/components/EmailPreviewModal.tsx @@ -0,0 +1,173 @@ +import {Button, Dialog, DialogContent, DialogHeader, DialogTitle} from '@plunk/ui'; +import {Monitor, Smartphone, Tablet} from 'lucide-react'; +import {useState} from 'react'; + +interface EmailPreviewModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + subject: string; + body: string; + from?: string; + fromName?: string; + replyTo?: string; + toName?: string; + toEmail?: string; +} + +type PreviewDevice = 'mobile' | 'tablet' | 'desktop'; + +export function EmailPreviewModal({ + open, + onOpenChange, + subject, + body, + from, + fromName, + replyTo, + toName, + toEmail, +}: EmailPreviewModalProps) { + const [previewDevice, setPreviewDevice] = useState('desktop'); + + const getPreviewContainerWidth = () => { + switch (previewDevice) { + case 'mobile': + return '375px'; + case 'tablet': + return '768px'; + case 'desktop': + return '100%'; + } + }; + + const getDeviceLabel = () => { + switch (previewDevice) { + case 'mobile': + return '375px'; + case 'tablet': + return '768px'; + case 'desktop': + return '1200px'; + } + }; + + const displayFrom = fromName ? `${fromName} <${from}>` : from; + + return ( + + + + Email Preview + + + {/* Device Selector */} +
+
+
+

Preview

+ ({getDeviceLabel()}) +
+
+ + + +
+
+
+ + {/* Preview Area - Single scroll container */} +
+
+
+
+ {/* Email Header Preview */} +
+ {/* To Section */} + {(toName || toEmail) && ( +
+

To

+

+ {toName && toEmail ? `${toName} <${toEmail}>` : toName || toEmail} +

+
+ )} + + {/* Subject Section */} +
+
+

Subject

+

{subject}

+
+
+ + {/* From and Reply-To Section */} + {(from || replyTo) && ( +
+ {from && ( +
+

From

+

{displayFrom}

+
+ )} + {replyTo && ( +
+

Reply-To

+

{replyTo}

+
+ )} +
+ )} +
+ + {/* Email Body */} +
+