Files
plunk/apps/web/src/components/ActivityItem.tsx
T

439 lines
14 KiB
TypeScript

import {Badge, Button, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui';
import type {Activity} from '@plunk/types';
import {memo, useState} from 'react';
import {EmailPreviewModal} from './EmailPreviewModal';
import {
AlertCircle,
Calendar,
CheckCheck,
CheckCircle,
ChevronRight,
Eye,
Inbox,
MousePointerClick,
Send,
ShieldAlert,
Workflow,
XCircle,
Zap,
} from 'lucide-react';
import Link from 'next/link';
/**
* Simple relative time formatter for past events
*/
function getRelativeTime(date: Date): string {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return 'just now';
}
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return `${diffInMinutes} ${diffInMinutes === 1 ? 'minute' : 'minutes'} ago`;
}
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return `${diffInHours} ${diffInHours === 1 ? 'hour' : 'hours'} ago`;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 30) {
return `${diffInDays} ${diffInDays === 1 ? 'day' : 'days'} ago`;
}
const diffInMonths = Math.floor(diffInDays / 30);
if (diffInMonths < 12) {
return `${diffInMonths} ${diffInMonths === 1 ? 'month' : 'months'} ago`;
}
const diffInYears = Math.floor(diffInMonths / 12);
return `${diffInYears} ${diffInYears === 1 ? 'year' : 'years'} ago`;
}
/**
* Format upcoming time (for future events)
*/
function getUpcomingTime(date: Date): string {
const now = new Date();
const diffInSeconds = Math.floor((date.getTime() - now.getTime()) / 1000);
if (diffInSeconds < 60) {
return 'in a moment';
}
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return `in ${diffInMinutes} ${diffInMinutes === 1 ? 'minute' : 'minutes'}`;
}
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return `in ${diffInHours} ${diffInHours === 1 ? 'hour' : 'hours'}`;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays === 1) {
return `tomorrow at ${date.toLocaleTimeString('en-US', {hour: 'numeric', minute: '2-digit', hour12: true})}`;
}
if (diffInDays < 7) {
return `in ${diffInDays} days`;
}
if (diffInDays < 30) {
const weeks = Math.floor(diffInDays / 7);
return `in ${weeks} ${weeks === 1 ? 'week' : 'weeks'}`;
}
const diffInMonths = Math.floor(diffInDays / 30);
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.received',
'email.opened',
'email.clicked',
'email.bounced',
'email.complaint',
].includes(type);
}
interface ActivityItemProps {
activity: Activity;
status?: 'upcoming' | 'completed';
}
interface ActivityConfig {
icon: React.ComponentType<{className?: string}>;
color: string;
bgColor: string;
title: string;
description?: string;
badge?: {
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
};
jsonData?: Record<string, unknown>;
}
function getActivityConfig(activity: Activity): ActivityConfig {
const {type, metadata} = activity;
switch (type) {
case 'event.triggered':
return {
icon: Zap,
color: 'text-amber-700',
bgColor: 'bg-amber-50',
title: (typeof metadata.eventName === 'string' ? metadata.eventName : undefined) || 'Event triggered',
description: undefined,
badge: {
label: 'Event',
variant: 'default',
},
jsonData:
metadata.eventData && typeof metadata.eventData === 'object' && !Array.isArray(metadata.eventData)
? (metadata.eventData as Record<string, unknown>)
: undefined,
};
case 'email.sent':
return {
icon: Send,
color: 'text-neutral-700',
bgColor: 'bg-neutral-100',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email sent',
description: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}`
: metadata.workflowName
? `Workflow: ${String(metadata.workflowName)}`
: typeof metadata.sourceType === 'string'
? metadata.sourceType
: undefined,
badge: {
label: 'Sent',
variant: 'default',
},
};
case 'email.delivered':
return {
icon: CheckCircle,
color: 'text-emerald-700',
bgColor: 'bg-emerald-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email delivered',
description: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}`
: metadata.workflowName
? `Workflow: ${String(metadata.workflowName)}`
: undefined,
badge: {
label: 'Delivered',
variant: 'default',
},
};
case 'email.received':
return {
icon: Inbox,
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email received',
description: typeof metadata.from === 'string' ? `From: ${metadata.from}` : 'Inbound email',
badge: {
label: 'Received',
variant: 'default',
},
};
case 'email.opened':
return {
icon: Eye,
color: 'text-emerald-700',
bgColor: 'bg-emerald-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email opened',
description:
typeof metadata.totalOpens === 'number' && metadata.totalOpens > 1
? `Opened ${metadata.totalOpens} times`
: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}`
: metadata.workflowName
? `Workflow: ${String(metadata.workflowName)}`
: undefined,
badge: {
label: 'Opened',
variant: 'secondary',
},
};
case 'email.clicked':
return {
icon: MousePointerClick,
color: 'text-sky-700',
bgColor: 'bg-sky-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email clicked',
description:
typeof metadata.totalClicks === 'number' && metadata.totalClicks > 1
? `Clicked ${metadata.totalClicks} times`
: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}`
: metadata.workflowName
? `Workflow: ${String(metadata.workflowName)}`
: undefined,
badge: {
label: 'Clicked',
variant: 'default',
},
};
case 'email.bounced':
return {
icon: XCircle,
color: 'text-red-700',
bgColor: 'bg-red-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email bounced',
description: (typeof metadata.error === 'string' ? metadata.error : undefined) || 'Email failed to deliver',
badge: {
label: 'Bounced',
variant: 'destructive',
},
};
case 'email.complaint':
return {
icon: ShieldAlert,
color: 'text-red-700',
bgColor: 'bg-red-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Spam complaint',
description: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}`
: metadata.workflowName
? `Workflow: ${String(metadata.workflowName)}`
: 'Recipient marked as spam',
badge: {
label: 'Complaint',
variant: 'destructive',
},
};
case 'workflow.started':
return {
icon: Workflow,
color: 'text-amber-700',
bgColor: 'bg-amber-50',
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow started',
description: `Status: ${String(metadata.status || 'unknown')}`,
badge: {
label: 'Workflow',
variant: 'default',
},
};
case 'workflow.completed':
return {
icon: CheckCheck,
color: 'text-amber-700',
bgColor: 'bg-amber-50',
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow completed',
description: metadata.exitReason
? `Exit: ${String(metadata.exitReason)}`
: `Status: ${String(metadata.status || 'unknown')}`,
badge: {
label: 'Completed',
variant: 'default',
},
};
case 'campaign.scheduled':
return {
icon: Calendar,
color: 'text-sky-700',
bgColor: 'bg-sky-50',
title: (typeof metadata.campaignName === 'string' ? metadata.campaignName : undefined) || 'Campaign scheduled',
description: metadata.subject
? `${String(metadata.subject)}${metadata.totalRecipients ? ` • ${metadata.totalRecipients} recipients` : ''}`
: metadata.totalRecipients
? `${metadata.totalRecipients} recipients`
: undefined,
badge: {
label: 'Scheduled',
variant: 'outline',
},
};
case 'workflow.email.scheduled':
return {
icon: Calendar,
color: 'text-amber-700',
bgColor: 'bg-amber-50',
title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled',
description: metadata.workflowName
? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? ` • ${String(metadata.subject)}` : ''}`
: typeof metadata.subject === 'string'
? metadata.subject
: undefined,
badge: {
label: 'Scheduled',
variant: 'outline',
},
};
default:
return {
icon: AlertCircle,
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
title: 'Unknown activity',
badge: {
label: 'Unknown',
variant: 'outline',
},
};
}
}
export const ActivityItem = memo(function ActivityItem({activity, status = 'completed'}: ActivityItemProps) {
const [showPreviewModal, setShowPreviewModal] = useState(false);
const config = getActivityConfig(activity);
const Icon = config.icon;
const timestamp = new Date(activity.timestamp);
const isUpcoming = status === 'upcoming';
const relativeTime = isUpcoming ? getUpcomingTime(timestamp) : getRelativeTime(timestamp);
return (
<div className={`flex items-start gap-4 ${isUpcoming ? 'opacity-80' : ''}`}>
{/* Icon */}
<div
className={`h-10 w-10 rounded-lg ${config.bgColor} flex items-center justify-center flex-shrink-0 ${isUpcoming ? 'border-2 border-dashed border-neutral-300' : ''}`}
>
<Icon className={`h-5 w-5 ${config.color}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className={`text-sm font-medium truncate ${isUpcoming ? 'text-neutral-700' : 'text-neutral-900'}`}>
{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 && (
<div className="flex items-center gap-2 mt-2">
{activity.contactId ? (
<Link
href={`/contacts/${activity.contactId}`}
className="text-xs text-neutral-600 hover:text-neutral-900 hover:underline"
>
{activity.contactEmail}
</Link>
) : (
<span className="text-xs text-neutral-600">{activity.contactEmail}</span>
)}
</div>
)}
{/* Collapsible JSON Data */}
{config.jsonData && (
<Collapsible className="mt-2">
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-neutral-600 hover:text-neutral-900 transition-colors group">
<ChevronRight className="h-3 w-3 transition-transform group-data-[state=open]:rotate-90" />
<span className="font-medium">Event Data</span>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 p-3 bg-neutral-50 rounded-md border border-neutral-200 text-xs overflow-x-auto">
<code className="text-neutral-700">{JSON.stringify(config.jsonData, null, 2)}</code>
</pre>
</CollapsibleContent>
</Collapsible>
)}
</div>
<span
className={`text-xs flex-shrink-0 whitespace-nowrap ${isUpcoming ? 'text-neutral-700 font-medium' : 'text-neutral-400'}`}
title={timestamp.toLocaleString()}
>
{relativeTime}
</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>
);
});