feat: Email preview in contact and activity feed
This commit is contained in:
@@ -454,6 +454,11 @@ export class ActivityService {
|
|||||||
for (const email of emails) {
|
for (const email of emails) {
|
||||||
const baseMetadata = {
|
const baseMetadata = {
|
||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
|
body: email.body,
|
||||||
|
from: email.from,
|
||||||
|
fromName: email.fromName,
|
||||||
|
replyTo: email.replyTo,
|
||||||
|
toName: email.toName,
|
||||||
sourceType: email.sourceType,
|
sourceType: email.sourceType,
|
||||||
campaignName: email.campaign?.name,
|
campaignName: email.campaign?.name,
|
||||||
workflowName: email.workflowExecution?.workflow?.name,
|
workflowName: email.workflowExecution?.workflow?.name,
|
||||||
|
|||||||
@@ -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 type {Activity} from './ActivityFeed';
|
||||||
import {memo} from 'react';
|
import {memo, useState} from 'react';
|
||||||
|
import {EmailPreviewModal} from './EmailPreviewModal';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -90,6 +91,13 @@ function getUpcomingTime(date: Date): string {
|
|||||||
return `in ${diffInMonths} ${diffInMonths === 1 ? 'month' : 'months'}`;
|
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 {
|
interface ActivityItemProps {
|
||||||
activity: Activity;
|
activity: Activity;
|
||||||
isUpcoming?: boolean;
|
isUpcoming?: boolean;
|
||||||
@@ -295,6 +303,7 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) {
|
export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) {
|
||||||
|
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||||
const config = getActivityConfig(activity);
|
const config = getActivityConfig(activity);
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
const timestamp = new Date(activity.timestamp);
|
const timestamp = new Date(activity.timestamp);
|
||||||
@@ -318,6 +327,17 @@ export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = f
|
|||||||
{config.title}
|
{config.title}
|
||||||
</p>
|
</p>
|
||||||
{config.badge && <Badge variant={config.badge.variant}>{config.badge.label}</Badge>}
|
{config.badge && <Badge variant={config.badge.variant}>{config.badge.label}</Badge>}
|
||||||
|
{isEmailActivity(activity.type) && activity.metadata.subject && activity.metadata.body ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowPreviewModal(true)}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{config.description && <p className="text-sm text-neutral-500 line-clamp-2">{config.description}</p>}
|
{config.description && <p className="text-sm text-neutral-500 line-clamp-2">{config.description}</p>}
|
||||||
{activity.contactEmail && (
|
{activity.contactEmail && (
|
||||||
@@ -357,6 +377,21 @@ export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = f
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email Preview Modal */}
|
||||||
|
{showPreviewModal && activity.metadata.subject && activity.metadata.body ? (
|
||||||
|
<EmailPreviewModal
|
||||||
|
open={showPreviewModal}
|
||||||
|
onOpenChange={setShowPreviewModal}
|
||||||
|
subject={String(activity.metadata.subject)}
|
||||||
|
body={String(activity.metadata.body)}
|
||||||
|
from={activity.metadata.from ? String(activity.metadata.from) : undefined}
|
||||||
|
fromName={activity.metadata.fromName ? String(activity.metadata.fromName) : undefined}
|
||||||
|
replyTo={activity.metadata.replyTo ? String(activity.metadata.replyTo) : undefined}
|
||||||
|
toName={activity.metadata.toName ? String(activity.metadata.toName) : undefined}
|
||||||
|
toEmail={activity.contactEmail}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<PreviewDevice>('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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-6xl h-[90vh] flex flex-col p-0">
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
|
||||||
|
<DialogTitle className="pr-8">Email Preview</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Device Selector */}
|
||||||
|
<div className="border-t border-b border-neutral-200 bg-neutral-100 px-6 py-3 flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-xs font-medium text-neutral-600">Preview</p>
|
||||||
|
<span className="text-xs text-neutral-500">({getDeviceLabel()})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={previewDevice === 'mobile' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPreviewDevice('mobile')}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
title="Mobile (375px)"
|
||||||
|
>
|
||||||
|
<Smartphone className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={previewDevice === 'tablet' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPreviewDevice('tablet')}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
title="Tablet (768px)"
|
||||||
|
>
|
||||||
|
<Tablet className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={previewDevice === 'desktop' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPreviewDevice('desktop')}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
title="Desktop (1200px)"
|
||||||
|
>
|
||||||
|
<Monitor className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Area - Single scroll container */}
|
||||||
|
<div className="flex-1 overflow-y-auto bg-neutral-50">
|
||||||
|
<div className="p-6 flex justify-center items-start min-h-full">
|
||||||
|
<div
|
||||||
|
className="transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: getPreviewContainerWidth(),
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-lg border border-neutral-300 shadow-lg overflow-hidden">
|
||||||
|
{/* Email Header Preview */}
|
||||||
|
<div className="bg-neutral-50 border-b border-neutral-200 p-4 space-y-2">
|
||||||
|
{/* To Section */}
|
||||||
|
{(toName || toEmail) && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-neutral-500 uppercase tracking-wide font-medium">To</p>
|
||||||
|
<p className="text-sm text-neutral-900 mt-1">
|
||||||
|
{toName && toEmail ? `${toName} <${toEmail}>` : toName || toEmail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subject Section */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-neutral-500 uppercase tracking-wide font-medium">Subject</p>
|
||||||
|
<p className="text-base font-semibold text-neutral-900 mt-1">{subject}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* From and Reply-To Section */}
|
||||||
|
{(from || replyTo) && (
|
||||||
|
<div className="flex gap-6 pt-2 border-t border-neutral-200">
|
||||||
|
{from && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-neutral-500">From</p>
|
||||||
|
<p className="text-sm text-neutral-900 mt-0.5">{displayFrom}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{replyTo && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-neutral-500">Reply-To</p>
|
||||||
|
<p className="text-sm text-neutral-900 mt-0.5">{replyTo}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Body */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<iframe
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
srcDoc={body}
|
||||||
|
className="w-full border-0 block"
|
||||||
|
style={{minHeight: '500px'}}
|
||||||
|
title="Email preview"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user