feat: Email preview in contact and activity feed

This commit is contained in:
Dries Augustyns
2025-12-29 15:26:53 +01:00
parent 2d353ef839
commit 72dffe12e5
3 changed files with 215 additions and 2 deletions
+37 -2
View File
@@ -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}
</p>
{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>
{config.description && <p className="text-sm text-neutral-500 line-clamp-2">{config.description}</p>}
{activity.contactEmail && (
@@ -357,6 +377,21 @@ export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = f
</span>
</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>
);
});
@@ -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>
);
}