Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
+258
View File
@@ -0,0 +1,258 @@
import {Button} from '@plunk/ui';
import {network} from '../lib/network';
import {ActivityItem} from './ActivityItem';
import {Loader2} from 'lucide-react';
import {useCallback, useEffect, useMemo, useState} from 'react';
export enum ActivityType {
EVENT_TRIGGERED = 'event.triggered',
EMAIL_SENT = 'email.sent',
EMAIL_DELIVERED = 'email.delivered',
EMAIL_OPENED = 'email.opened',
EMAIL_CLICKED = 'email.clicked',
EMAIL_BOUNCED = 'email.bounced',
CAMPAIGN_SENT = 'campaign.sent',
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
WORKFLOW_STARTED = 'workflow.started',
WORKFLOW_COMPLETED = 'workflow.completed',
WORKFLOW_EMAIL_SCHEDULED = 'workflow.email.scheduled',
}
export interface Activity {
id: string;
type: ActivityType;
timestamp: string;
contactEmail?: string;
contactId?: string;
metadata: Record<string, unknown>;
}
interface PaginatedActivities {
activities: Activity[];
nextCursor?: string;
hasMore: boolean;
}
interface ActivityFeedProps {
typeFilter?: string;
dateRangeDays?: number;
contactId?: string;
}
export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: ActivityFeedProps) {
const [activities, setActivities] = useState<Activity[]>([]);
const [upcomingActivities, setUpcomingActivities] = useState<Activity[]>([]);
const [nextCursor, setNextCursor] = useState<string | undefined>();
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
// Memoize start date to prevent recreation on every render
const startDate = useMemo(() => {
const date = new Date();
date.setDate(date.getDate() - dateRangeDays);
return date.toISOString();
}, [dateRangeDays]);
// Fetch activities
const fetchActivities = useCallback(
async (cursor?: string) => {
try {
if (cursor) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
setActivities([]);
setNextCursor(undefined);
setHasMore(true);
}
setError(null);
const params = new URLSearchParams({
limit: '20', // Conservative limit to avoid overloading
startDate: startDate,
});
if (cursor) {
params.set('cursor', cursor);
}
if (typeFilter) {
params.set('types', typeFilter);
}
if (contactId) {
params.set('contactId', contactId);
}
const result = await network.fetch<PaginatedActivities>('GET', `/activity?${params.toString()}`);
if (cursor) {
// Append to existing activities
setActivities(prev => [...prev, ...result.activities]);
} else {
// Replace activities
setActivities(result.activities);
}
setNextCursor(result.nextCursor);
setHasMore(result.hasMore);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load activities');
console.error('Error fetching activities:', err);
} finally {
setIsLoading(false);
setIsLoadingMore(false);
}
},
[typeFilter, startDate, contactId],
);
// Fetch upcoming activities
const fetchUpcomingActivities = useCallback(async () => {
try {
// Don't fetch upcoming if we're filtering by contact
// (upcoming items aren't contact-specific)
if (contactId) {
setUpcomingActivities([]);
return;
}
const params = new URLSearchParams({
limit: '20',
daysAhead: dateRangeDays.toString(),
});
const result = await network.fetch<{activities: Activity[]}>('GET', `/activity/upcoming?${params.toString()}`);
setUpcomingActivities(result.activities);
} catch (err) {
console.error('Error fetching upcoming activities:', err);
// Don't set error state for upcoming - just fail silently
setUpcomingActivities([]);
}
}, [dateRangeDays, contactId]);
// Initial fetch - only run once when filters change
useEffect(() => {
void fetchActivities();
void fetchUpcomingActivities();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeFilter, startDate, contactId]);
// Auto-refresh every 30 seconds for real-time updates
useEffect(() => {
// Don't set up auto-refresh if still loading initial data
if (isLoading) {
return;
}
const interval = setInterval(() => {
// Only refresh if we're on the first page and not already loading
if (!isLoading && !isLoadingMore && activities.length > 0) {
void fetchActivities();
void fetchUpcomingActivities();
}
}, 30000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading, isLoadingMore, activities.length]);
const loadMore = () => {
if (nextCursor && hasMore && !isLoadingMore) {
void fetchActivities(nextCursor);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-neutral-400" />
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<p className="text-red-600 text-sm">{error}</p>
<Button onClick={() => fetchActivities()} variant="outline" className="mt-4">
Try Again
</Button>
</div>
);
}
if (activities.length === 0 && upcomingActivities.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-500 text-sm">
No activity found for the selected filters. Activities will appear here as they happen.
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Past Activity Timeline */}
{activities.length > 0 && (
<div className="space-y-4">
{activities.map((activity, index) => (
<div key={`${activity.id}-${index}`}>
<ActivityItem activity={activity} />
{index < activities.length - 1 && <div className="border-t border-neutral-100 my-4" />}
</div>
))}
</div>
)}
{/* Load More Button */}
{hasMore && (
<div className="flex justify-center pt-4">
<Button onClick={loadMore} variant="outline" disabled={isLoadingMore}>
{isLoadingMore ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading...
</>
) : (
'Load More'
)}
</Button>
</div>
)}
{/* Separator between past and upcoming */}
{activities.length > 0 && upcomingActivities.length > 0 && (
<div className="relative py-6">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-neutral-300" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-4 text-sm font-medium text-neutral-500">Upcoming Scheduled</span>
</div>
</div>
)}
{/* Upcoming Activities */}
{upcomingActivities.length > 0 && (
<div className="space-y-4">
{upcomingActivities.map((activity, index) => (
<div key={`${activity.id}-${index}`}>
<ActivityItem activity={activity} isUpcoming={true} />
{index < upcomingActivities.length - 1 && <div className="border-t border-neutral-100 my-4" />}
</div>
))}
</div>
)}
{/* End of results indicator */}
{!hasMore && activities.length > 0 && upcomingActivities.length === 0 && (
<div className="text-center pt-4">
<p className="text-sm text-neutral-400">You&apos;ve reached the end of the activity feed</p>
</div>
)}
</div>
);
}
+361
View File
@@ -0,0 +1,361 @@
import {Badge, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui';
import type {Activity} from './ActivityFeed';
import {
AlertCircle,
Calendar,
CheckCheck,
CheckCircle,
ChevronRight,
Eye,
MousePointerClick,
Send,
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'}`;
}
interface ActivityItemProps {
activity: Activity;
isUpcoming?: boolean;
}
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-blue-600',
bgColor: 'bg-blue-100',
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-green-600',
bgColor: 'bg-green-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-green-600',
bgColor: 'bg-green-100',
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.opened':
return {
icon: Eye,
color: 'text-purple-600',
bgColor: 'bg-purple-100',
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-orange-600',
bgColor: 'bg-orange-100',
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-600',
bgColor: 'bg-red-100',
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 'workflow.started':
return {
icon: Workflow,
color: 'text-indigo-600',
bgColor: 'bg-indigo-100',
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-green-600',
bgColor: 'bg-green-100',
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-blue-600',
bgColor: 'bg-blue-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-indigo-600',
bgColor: 'bg-indigo-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 function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) {
const config = getActivityConfig(activity);
const Icon = config.icon;
const timestamp = new Date(activity.timestamp);
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 ? 'ring-2 ring-offset-2 ring-blue-200' : ''}`}
>
<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>}
</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-blue-600 font-medium' : 'text-neutral-400'}`}
title={timestamp.toLocaleString()}
>
{relativeTime}
</span>
</div>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import {useState} from 'react';
import {Button} from '@plunk/ui';
import {Check, Copy, Eye, EyeOff, RefreshCw} from 'lucide-react';
interface ApiKeyDisplayProps {
label: string;
value: string;
description?: string;
isSecret?: boolean;
onRegenerate?: () => Promise<void>;
showRegenerate?: boolean;
}
export function ApiKeyDisplay({
label,
value,
description,
isSecret = false,
onRegenerate,
showRegenerate = false,
}: ApiKeyDisplayProps) {
const [showKey, setShowKey] = useState(!isSecret);
const [copied, setCopied] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
const handleRegenerate = async () => {
if (!onRegenerate) return;
try {
setIsRegenerating(true);
await onRegenerate();
} catch (error) {
console.error('Failed to regenerate:', error);
} finally {
setIsRegenerating(false);
}
};
const displayValue = showKey ? value : '•'.repeat(48);
return (
<div>
<label className="text-sm font-medium text-neutral-700 block mb-2">{label}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-neutral-50 rounded-lg text-xs font-mono text-neutral-900 border border-neutral-200 truncate">
{displayValue}
</code>
<div className="flex items-center gap-1">
{isSecret && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowKey(!showKey)}
title={showKey ? 'Hide key' : 'Show key'}
className="h-9 w-9"
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
)}
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
title="Copy to clipboard"
className="h-9 w-9"
>
{copied ? <Check className="h-4 w-4 text-green-600" /> : <Copy className="h-4 w-4" />}
</Button>
{showRegenerate && onRegenerate && (
<Button
type="button"
variant="outline"
size="icon"
onClick={handleRegenerate}
disabled={isRegenerating}
title="Regenerate key"
className="h-9 w-9"
>
<RefreshCw className={`h-4 w-4 ${isRegenerating ? 'animate-spin' : ''}`} />
</Button>
)}
</div>
</div>
{description && <p className="text-xs text-neutral-500 mt-1">{description}</p>}
</div>
);
}
@@ -0,0 +1,166 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert} from '@plunk/ui';
import {AlertCircle, TrendingUp} from 'lucide-react';
import {useBillingConsumption} from '../lib/hooks/useBillingConsumption';
import {useConfig} from '../lib/hooks/useConfig';
interface BillingConsumptionProps {
projectId: string;
hasSubscription: boolean;
}
export function BillingConsumption({projectId, hasSubscription}: BillingConsumptionProps) {
const {data: config} = useConfig();
const billingEnabled = config?.features.billing.enabled ?? false;
// Always call the hook to satisfy Rules of Hooks
const {consumptionData, isLoading, error} = useBillingConsumption(projectId, hasSubscription && billingEnabled);
if (!billingEnabled) {
// If billing is globally disabled, hide the card entirely
return null;
}
if (!hasSubscription) {
return (
<Card>
<CardHeader>
<CardTitle>Usage This Month</CardTitle>
<CardDescription>View your current month email consumption and costs</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
Usage tracking is only available with an active subscription. Start a subscription to track your email
consumption.
</p>
</div>
</Alert>
</CardContent>
</Card>
);
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Usage This Month</CardTitle>
<CardDescription>View your current month email consumption and costs</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-neutral-500">Loading...</p>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardHeader>
<CardTitle>Usage This Month</CardTitle>
<CardDescription>View your current month email consumption and costs</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">Failed to load consumption data. Please try again later.</p>
</div>
</Alert>
</CardContent>
</Card>
);
}
if (!consumptionData) {
return null;
}
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
return (
<Card>
<CardHeader>
<CardTitle>Usage This Month</CardTitle>
<CardDescription>
Billing period: {formatDate(consumptionData.period.start)} - {formatDate(consumptionData.period.end)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Total Usage */}
<div className="border border-neutral-200 rounded-lg p-6">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-50 rounded-lg">
<TrendingUp className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-neutral-500">Total Emails Sent</p>
<p className="text-3xl font-bold text-neutral-900">{consumptionData.usage.total.toLocaleString()}</p>
</div>
</div>
</div>
{/* Upcoming Invoice */}
{consumptionData.upcomingInvoice && (
<div className="border border-neutral-200 rounded-lg p-6">
<h3 className="font-semibold text-neutral-900 mb-4">Upcoming Invoice</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-neutral-600">Billing Period</span>
<span className="text-sm font-medium text-neutral-900">
{formatDate(consumptionData.upcomingInvoice.periodStart)} -{' '}
{formatDate(consumptionData.upcomingInvoice.periodEnd)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-neutral-600">Subtotal</span>
<span className="text-sm font-medium text-neutral-900">
{formatCurrency(consumptionData.upcomingInvoice.subtotal, consumptionData.upcomingInvoice.currency)}
</span>
</div>
<div className="border-t border-neutral-200 pt-3 mt-3">
<div className="flex justify-between items-center">
<span className="text-base font-semibold text-neutral-900">Amount Due</span>
<span className="text-base font-bold text-neutral-900">
{formatCurrency(
consumptionData.upcomingInvoice.amountDue,
consumptionData.upcomingInvoice.currency,
)}
</span>
</div>
</div>
</div>
</div>
)}
{/* No upcoming invoice message */}
{!consumptionData.upcomingInvoice && consumptionData.usage.total === 0 && (
<Alert>
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">No usage recorded yet for this billing period.</p>
</div>
</Alert>
)}
</div>
</CardContent>
</Card>
);
}
+240
View File
@@ -0,0 +1,240 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert, Badge, Button} from '@plunk/ui';
import {AlertCircle, Download, ExternalLink, FileText} from 'lucide-react';
import {useBillingInvoices} from '../lib/hooks/useBillingInvoices';
interface BillingInvoicesProps {
projectId: string;
hasSubscription: boolean;
onManageBilling?: () => void;
}
export function BillingInvoices({projectId, hasSubscription, onManageBilling}: BillingInvoicesProps) {
const {invoicesData, isLoading, error} = useBillingInvoices(projectId, hasSubscription);
if (!hasSubscription) {
return (
<Card>
<CardHeader>
<CardTitle>Past Invoices</CardTitle>
<CardDescription>View and download your billing history</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
Invoice history is only available with an active subscription. Start a subscription to view your
invoices.
</p>
</div>
</Alert>
</CardContent>
</Card>
);
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Past Invoices</CardTitle>
<CardDescription>View and download your billing history</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-neutral-500">Loading...</p>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardHeader>
<CardTitle>Past Invoices</CardTitle>
<CardDescription>View and download your billing history</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">Failed to load invoices. Please try again later.</p>
</div>
</Alert>
</CardContent>
</Card>
);
}
if (!invoicesData) {
return null;
}
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const getStatusBadge = (status: string, paid: boolean) => {
// Stripe invoices have status 'paid' when paid, or paid boolean is true
if (paid || status === 'paid') {
return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Paid</Badge>;
}
switch (status) {
case 'open':
return <Badge className="bg-orange-100 text-orange-800 hover:bg-orange-100">Unpaid</Badge>;
case 'draft':
return <Badge className="bg-neutral-100 text-neutral-800 hover:bg-neutral-100">Draft</Badge>;
case 'uncollectible':
return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">Uncollectible</Badge>;
case 'void':
return <Badge className="bg-neutral-100 text-neutral-800 hover:bg-neutral-100">Void</Badge>;
default:
return <Badge className="bg-neutral-100 text-neutral-800 hover:bg-neutral-100">{status}</Badge>;
}
};
const isPaid = (invoice: {status: string; paid: boolean; amountDue: number}) => {
// Check multiple conditions: paid flag, status, or zero amount due
return (
invoice.paid === true || invoice.status === 'paid' || (invoice.amountDue === 0 && invoice.status !== 'draft')
);
};
// Show only the 5 most recent invoices
const recentInvoices = invoicesData.invoices.slice(0, 5);
const hasMoreInvoices = invoicesData.invoices.length > 5;
return (
<Card>
<CardHeader>
<CardTitle>Recent Invoices</CardTitle>
<CardDescription>View your latest invoices (showing {recentInvoices.length} most recent)</CardDescription>
</CardHeader>
<CardContent>
{invoicesData.invoices.length === 0 ? (
<Alert>
<FileText className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">No invoices found. Invoices will appear here after your first billing period.</p>
</div>
</Alert>
) : (
<div className="space-y-4">
<div className="space-y-3">
{recentInvoices.map(invoice => (
<div
key={invoice.id}
className="border border-neutral-200 rounded-lg p-4 hover:bg-neutral-50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-medium text-neutral-900">
{invoice.number || `Invoice ${invoice.id.slice(-8)}`}
</h3>
{getStatusBadge(invoice.status, invoice.paid)}
</div>
<div className="space-y-1 text-sm text-neutral-600">
<p>
<span className="font-medium">Date:</span> {formatDate(invoice.created)}
</p>
{invoice.periodStart && invoice.periodEnd && (
<p>
<span className="font-medium">Period:</span> {formatDate(invoice.periodStart)} -{' '}
{formatDate(invoice.periodEnd)}
</p>
)}
{isPaid(invoice) ? (
<p>
<span className="font-medium">Amount Paid:</span>{' '}
{formatCurrency(invoice.amountPaid, invoice.currency)}
</p>
) : (
<>
<p>
<span className="font-medium">Total:</span>{' '}
{formatCurrency(invoice.total, invoice.currency)}
</p>
<p className="text-orange-600 font-medium">
<span className="font-medium">Amount Due:</span>{' '}
{formatCurrency(invoice.amountDue, invoice.currency)}
</p>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2 ml-4">
{invoice.hostedInvoiceUrl && (
<Button
variant="outline"
size="sm"
onClick={() => window.open(invoice.hostedInvoiceUrl!, '_blank')}
className="flex items-center gap-2"
>
<ExternalLink className="h-3 w-3" />
View
</Button>
)}
{invoice.invoicePdf && (
<Button
variant="outline"
size="sm"
onClick={() => window.open(invoice.invoicePdf!, '_blank')}
className="flex items-center gap-2"
>
<Download className="h-3 w-3" />
PDF
</Button>
)}
</div>
</div>
</div>
))}
</div>
{/* Message for viewing full history */}
{hasMoreInvoices && (
<Alert className="mt-4">
<FileText className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
Showing 5 most recent invoices. For complete billing history,{' '}
{onManageBilling ? (
<>
visit the{' '}
<button
type="button"
onClick={onManageBilling}
className="underline font-medium cursor-pointer hover:text-neutral-900"
>
Customer Portal
</button>
.
</>
) : (
'use the Manage Billing button above to access the Customer Portal.'
)}
</p>
</div>
</Alert>
)}
</div>
)}
</CardContent>
</Card>
);
}
+341
View File
@@ -0,0 +1,341 @@
import {useEffect, useState} from 'react';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {BillingLimitSchemas} from '@plunk/shared';
import {
Alert,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Progress,
} from '@plunk/ui';
import {AlertCircle, AlertTriangle, Check} from 'lucide-react';
import {AnimatePresence, motion} from 'framer-motion';
import type {z} from 'zod';
import {useBillingLimits, type BillingLimitsData, type CategoryLimit} from '../lib/hooks/useBillingLimits';
import {network} from '../lib/network';
interface BillingLimitsProps {
projectId: string;
hasSubscription: boolean;
}
type LimitsFormValues = z.infer<typeof BillingLimitSchemas.update>;
export function BillingLimits({projectId, hasSubscription}: BillingLimitsProps) {
const [isEditing, setIsEditing] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Fetch billing limits using SWR
const {limitsData, isLoading, mutate} = useBillingLimits(projectId, hasSubscription);
const form = useForm<LimitsFormValues>({
resolver: zodResolver(BillingLimitSchemas.update),
defaultValues: {
workflows: null,
campaigns: null,
transactional: null,
},
});
// Update form when limits data changes
useEffect(() => {
if (limitsData) {
form.reset({
workflows: limitsData.workflows.limit,
campaigns: limitsData.campaigns.limit,
transactional: limitsData.transactional.limit,
});
}
}, [limitsData, form]);
const onSubmit = async (values: LimitsFormValues) => {
try {
setErrorMessage(null);
setSuccessMessage(null);
await network.fetch<BillingLimitsData, typeof BillingLimitSchemas.update>(
'PUT',
`/users/@me/projects/${projectId}/billing-limits`,
values,
);
// Revalidate SWR cache
await mutate();
setIsEditing(false);
setSuccessMessage('Billing limits updated successfully');
// Clear success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to update billing limits');
}
};
const handleCancel = () => {
if (limitsData) {
form.reset({
workflows: limitsData.workflows.limit,
campaigns: limitsData.campaigns.limit,
transactional: limitsData.transactional.limit,
});
}
setIsEditing(false);
setErrorMessage(null);
};
if (!hasSubscription) {
return (
<Card>
<CardHeader>
<CardTitle>Billing Limits</CardTitle>
<CardDescription>Set monthly limits for each email category</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
Billing limits are only available with an active subscription. Start a subscription to set limits for
your email usage.
</p>
</div>
</Alert>
</CardContent>
</Card>
);
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Billing Limits</CardTitle>
<CardDescription>Set monthly limits for each email category</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-neutral-500">Loading...</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Billing Limits</CardTitle>
<CardDescription>
Set monthly limits for each email category. Limits reset on the 1st of each month.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Success/Error Messages */}
<AnimatePresence mode="wait">
{successMessage && (
<motion.div
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
>
{successMessage}
</motion.div>
)}
{errorMessage && (
<motion.div
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
>
{errorMessage}
</motion.div>
)}
</AnimatePresence>
{/* Usage Display (when not editing) */}
{!isEditing && limitsData && (
<div className="space-y-4">
<UsageDisplay category="Workflows" usage={limitsData.workflows} />
<UsageDisplay category="Campaigns" usage={limitsData.campaigns} />
<UsageDisplay category="Transactional" usage={limitsData.transactional} />
<div className="flex justify-end pt-4">
<Button onClick={() => setIsEditing(true)}>Edit Limits</Button>
</div>
</div>
)}
{/* Edit Form */}
{isEditing && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="workflows"
render={({field}) => (
<FormItem>
<FormLabel>Workflow Emails Limit</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Unlimited"
{...field}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
/>
</FormControl>
<FormDescription>Maximum workflow emails per month. Leave empty for unlimited.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="campaigns"
render={({field}) => (
<FormItem>
<FormLabel>Campaign Emails Limit</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Unlimited"
{...field}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
/>
</FormControl>
<FormDescription>Maximum campaign emails per month. Leave empty for unlimited.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="transactional"
render={({field}) => (
<FormItem>
<FormLabel>Transactional Emails Limit</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Unlimited"
{...field}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
/>
</FormControl>
<FormDescription>
Maximum transactional emails per month. Leave empty for unlimited.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</Form>
)}
</div>
</CardContent>
</Card>
);
}
interface UsageDisplayProps {
category: string;
usage: CategoryLimit;
}
function UsageDisplay({category, usage}: UsageDisplayProps) {
const getStatusColor = () => {
if (usage.isBlocked) return 'text-red-600';
if (usage.isWarning) return 'text-orange-600';
return 'text-green-600';
};
const getProgressColor = () => {
if (usage.isBlocked) return 'bg-red-600';
if (usage.isWarning) return 'bg-orange-500';
return 'bg-green-600';
};
const getStatusIcon = () => {
if (usage.isBlocked) return <AlertCircle className="h-4 w-4" />;
if (usage.isWarning) return <AlertTriangle className="h-4 w-4" />;
return <Check className="h-4 w-4" />;
};
const limitText = usage.limit === null ? 'Unlimited' : usage.limit.toLocaleString();
return (
<div className="border border-neutral-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium text-neutral-900">{category}</h3>
<p className="text-sm text-neutral-500">
{usage.usage.toLocaleString()} / {limitText} emails this month
</p>
</div>
<div className={`flex items-center gap-2 ${getStatusColor()}`}>
{getStatusIcon()}
<span className="text-sm font-medium">
{usage.limit === null ? 'Unlimited' : `${Math.round(usage.percentage)}%`}
</span>
</div>
</div>
{usage.limit !== null && (
<>
<Progress value={Math.min(usage.percentage, 100)} className="h-2" indicatorClassName={getProgressColor()} />
{usage.isBlocked && (
<Alert className="mt-3 bg-red-50 border-red-200 text-red-900">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-xs">
<strong>Limit reached:</strong> No more {category.toLowerCase()} emails can be sent this month.
</p>
</div>
</Alert>
)}
{usage.isWarning && !usage.isBlocked && (
<Alert className="mt-3 bg-orange-50 border-orange-200 text-orange-900">
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<p className="text-xs">
<strong>Warning:</strong> You&apos;ve used {Math.round(usage.percentage)}% of your{' '}
{category.toLowerCase()} email limit.
</p>
</div>
</Alert>
)}
</>
)}
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
import {useUser} from '../lib/hooks/useUser';
import {
Activity,
BarChart3,
ChevronDown,
FileText,
Layers,
LayoutDashboard,
LogOut,
Megaphone,
Plus,
Settings,
User,
Users,
Workflow,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useRef, useState} from 'react';
interface DashboardLayoutProps {
children: React.ReactNode;
}
interface NavItem {
name: string;
href: string;
icon: React.ComponentType<{className?: string}>;
}
interface NavSection {
title?: string;
items: NavItem[];
}
const navigation: NavSection[] = [
{
items: [
{name: 'Dashboard', href: '/', icon: LayoutDashboard},
{name: 'Contacts', href: '/contacts', icon: Users},
{name: 'Segments', href: '/segments', icon: Layers},
{name: 'Activity', href: '/activity', icon: Activity},
{name: 'Analytics', href: '/analytics', icon: BarChart3},
],
},
{
title: 'Automations',
items: [
{name: 'Templates', href: '/templates', icon: FileText},
{name: 'Workflows', href: '/workflows', icon: Workflow},
],
},
{
title: 'Campaigns',
items: [{name: 'Campaigns', href: '/campaigns', icon: Megaphone}],
},
];
export function DashboardLayout({children}: DashboardLayoutProps) {
const router = useRouter();
const {data: user} = useUser();
const {activeProject, availableProjects, setActiveProject} = useActiveProject();
const [showProjectMenu, setShowProjectMenu] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const projectMenuRef = useRef<HTMLDivElement>(null);
const userMenuRef = useRef<HTMLDivElement>(null);
// Handle click outside for project menu
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (projectMenuRef.current && !projectMenuRef.current.contains(event.target as Node)) {
setShowProjectMenu(false);
}
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
setShowUserMenu(false);
}
}
if (showProjectMenu || showUserMenu) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [showProjectMenu, showUserMenu]);
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('activeProjectId');
void router.push('/auth/login');
};
return (
<div className="flex h-screen bg-neutral-50">
{/* Sidebar */}
<div className="w-64 bg-white border-r border-neutral-200 flex flex-col">
{/* Logo */}
<div className="h-16 flex items-center gap-2 px-6 border-b border-neutral-200">
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
</div>
{/* Project Switcher */}
<div className="p-4 border-b border-neutral-200">
<div className="relative" ref={projectMenuRef}>
<button
onClick={() => setShowProjectMenu(!showProjectMenu)}
className="w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-neutral-50 transition-colors"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="h-8 w-8 rounded-lg bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
{activeProject?.name.charAt(0).toUpperCase() || 'P'}
</div>
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
</div>
<ChevronDown className="h-4 w-4 text-neutral-500 flex-shrink-0" />
</button>
{/* Project Dropdown */}
{showProjectMenu && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
{availableProjects.map(project => (
<button
key={project.id}
onClick={() => {
setActiveProject(project);
setShowProjectMenu(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors"
>
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
{project.name.charAt(0).toUpperCase()}
</div>
<span className="text-neutral-900">{project.name}</span>
{activeProject?.id === project.id && (
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
)}
</button>
))}
<div className="border-t border-neutral-200 my-1" />
<Link
href="/projects/create"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700"
>
<Plus className="h-4 w-4" />
<span>Create project</span>
</Link>
</div>
)}
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 overflow-y-auto">
{navigation.map((section, sectionIndex) => (
<div key={sectionIndex} className={sectionIndex > 0 ? 'mt-6' : ''}>
{section.title && (
<p className="px-3 mb-2 text-xs font-semibold text-neutral-500 uppercase tracking-wider">
{section.title}
</p>
)}
<div className="space-y-1">
{section.items.map(item => {
const isActive = router.pathname === item.href;
const Icon = item.icon;
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
isActive ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
}`}
>
<Icon className="h-5 w-5" />
{item.name}
</Link>
);
})}
</div>
</div>
))}
</nav>
{/* Settings & User Menu */}
<div className="border-t border-neutral-200 p-3 space-y-1">
<Link
href="/settings"
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
router.pathname.startsWith('/settings') ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
}`}
>
<Settings className="h-5 w-5" />
Settings
</Link>
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
>
<User className="h-5 w-5" />
<span className="flex-1 text-left truncate">{user?.email}</span>
<ChevronDown className="h-4 w-4 text-neutral-500" />
</button>
{/* User Dropdown */}
{showUserMenu && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600"
>
<LogOut className="h-4 w-4" />
<span>Log out</span>
</button>
</div>
)}
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
{/* Page Content */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-7xl mx-auto p-8">{children}</div>
</main>
</div>
</div>
);
}
+539
View File
@@ -0,0 +1,539 @@
import {useEffect, useState} from 'react';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {DomainSchemas} from '@plunk/shared';
import {
Alert,
AlertDescription,
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {Check, CheckCircle2, Copy, Loader2, RefreshCw, Trash2, XCircle} from 'lucide-react';
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
interface DomainsSettingsProps {
projectId: string;
}
export function DomainsSettings({projectId}: DomainsSettingsProps) {
const {domains, mutate: mutateDomains, isLoading} = useDomains(projectId);
const {addDomain} = useAddDomain();
const {checkVerification} = useCheckDomainVerification();
const {removeDomain} = useRemoveDomain();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedDomain, setSelectedDomain] = useState<string | null>(null);
const [verificationStatus, setVerificationStatus] = useState<{
[key: string]: boolean | string | {tokens: string[] | null; status: string; verified: boolean};
}>({});
const [checkingVerification, setCheckingVerification] = useState<string | null>(null);
const [copiedToken, setCopiedToken] = useState<string | null>(null);
const [lastVerificationCheck, setLastVerificationCheck] = useState<{[key: string]: number}>({});
const [cooldownSeconds, setCooldownSeconds] = useState<{[key: string]: number}>({});
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [domainToRemove, setDomainToRemove] = useState<{id: string; name: string} | null>(null);
const form = useForm<{domain: string}>({
resolver: zodResolver(DomainSchemas.create.omit({projectId: true})),
defaultValues: {
domain: '',
},
});
// Handle cooldown timer
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
const newCooldowns: {[key: string]: number} = {};
let hasActiveCooldowns = false;
Object.keys(lastVerificationCheck).forEach(domainId => {
const lastCheck = lastVerificationCheck[domainId];
if (lastCheck === undefined) return;
const elapsedSeconds = Math.floor((now - lastCheck) / 1000);
const remainingSeconds = 10 - elapsedSeconds;
if (remainingSeconds > 0) {
newCooldowns[domainId] = remainingSeconds;
hasActiveCooldowns = true;
}
});
setCooldownSeconds(newCooldowns);
// Clear interval if no active cooldowns
if (!hasActiveCooldowns && Object.keys(newCooldowns).length === 0) {
clearInterval(interval);
}
}, 100); // Update every 100ms for smooth countdown
return () => clearInterval(interval);
}, [lastVerificationCheck]);
const showMessage = (type: 'success' | 'error', message: string) => {
if (type === 'success') {
setSuccessMessage(message);
setErrorMessage(null);
setTimeout(() => setSuccessMessage(null), 5000);
} else {
setErrorMessage(message);
setSuccessMessage(null);
}
};
const onSubmit = async (values: {domain: string}) => {
try {
setErrorMessage(null);
const newDomain = await addDomain(projectId, values.domain);
// Store DKIM tokens for display
if (newDomain.dkimTokens) {
setVerificationStatus(prev => ({
...prev,
[newDomain.id]: {
tokens: newDomain.dkimTokens,
status: 'Pending',
verified: false,
},
}));
setSelectedDomain(newDomain.id);
}
await mutateDomains();
form.reset();
showMessage('success', `Domain ${values.domain} added successfully. Please configure DNS records.`);
} catch (error) {
showMessage('error', error instanceof Error ? error.message : 'Failed to add domain');
}
};
const handleCheckVerification = async (domainId: string) => {
// Check if cooldown is active
const now = Date.now();
const lastCheck = lastVerificationCheck[domainId];
if (lastCheck) {
const elapsedSeconds = Math.floor((now - lastCheck) / 1000);
if (elapsedSeconds < 10) {
return; // Still in cooldown, do nothing
}
}
try {
setCheckingVerification(domainId);
setLastVerificationCheck(prev => ({
...prev,
[domainId]: now,
}));
const status = await checkVerification(domainId);
setVerificationStatus(prev => ({
...prev,
[domainId]: status,
}));
await mutateDomains();
if (status.verified) {
showMessage('success', `Domain ${status.domain} is verified!`);
} else {
showMessage('error', `Domain ${status.domain} is not yet verified. Please check your DNS records.`);
}
} catch (error) {
showMessage('error', error instanceof Error ? error.message : 'Failed to check verification');
} finally {
setCheckingVerification(null);
}
};
const handleRemoveDomain = async () => {
if (!domainToRemove) return;
try {
await removeDomain(domainToRemove.id);
await mutateDomains();
if (selectedDomain === domainToRemove.id) {
setSelectedDomain(null);
}
showMessage('success', `Domain ${domainToRemove.name} removed successfully`);
} catch (error) {
showMessage('error', error instanceof Error ? error.message : 'Failed to remove domain');
} finally {
setDomainToRemove(null);
}
};
const handleCopyToken = async (token: string, index: number) => {
await navigator.clipboard.writeText(token);
setCopiedToken(`${token}-${index}`);
setTimeout(() => setCopiedToken(null), 2000);
};
const getDomainStatus = (domain: {
id: string;
verified: boolean;
dkimTokens: unknown;
}): {verified: boolean; tokens: unknown; status: string} => {
const status = verificationStatus[domain.id];
if (status && typeof status === 'object' && 'verified' in status) {
return status;
}
return {verified: domain.verified, tokens: domain.dkimTokens, status: domain.verified ? 'Success' : 'Pending'};
};
return (
<div className="space-y-6">
{/* Add Domain Form */}
<Card>
<CardHeader>
<CardTitle>Add Domain</CardTitle>
<CardDescription>Add a custom domain to send emails from</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="domain"
render={({field}) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Success/Error Messages */}
<AnimatePresence mode="wait">
{successMessage && (
<motion.div
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
>
{successMessage}
</motion.div>
)}
{errorMessage && (
<motion.div
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
>
{errorMessage}
</motion.div>
)}
</AnimatePresence>
<div className="flex justify-end">
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Adding...' : 'Add Domain'}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
{/* Domains List */}
<Card>
<CardHeader>
<CardTitle>Your Domains</CardTitle>
<CardDescription>Manage your verified domains</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-neutral-400" />
</div>
) : !domains || domains.length === 0 ? (
<div className="text-center py-8 text-neutral-500">
<p>No domains added yet</p>
</div>
) : (
<div className="space-y-4">
{domains.map(domain => {
const status = getDomainStatus(domain);
return (
<div key={domain.id} className="border border-neutral-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h3 className="font-medium text-neutral-900">{domain.domain}</h3>
{status.verified ? (
<Badge variant="success" className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Verified
</Badge>
) : (
<Badge variant="warning" className="flex items-center gap-1">
<XCircle className="h-3 w-3" />
Pending
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleCheckVerification(domain.id)}
disabled={checkingVerification === domain.id || (cooldownSeconds[domain.id] ?? 0) > 0}
className="min-w-[80px]"
>
{checkingVerification === domain.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (cooldownSeconds[domain.id] ?? 0) > 0 ? (
<span className="text-xs">{cooldownSeconds[domain.id]}s</span>
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setDomainToRemove({id: domain.id, name: domain.domain});
setShowRemoveDialog(true);
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{!status.verified && Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && (
<div className="mt-4 pt-4 border-t border-neutral-200">
<Alert>
<AlertDescription>
<div className="space-y-4">
<div>
<p className="font-medium text-sm mb-1">DNS Configuration Required</p>
<p className="text-xs text-neutral-600">
Add the following DNS records to verify your domain. DNS changes can take up to 48
hours to propagate.
</p>
</div>
{/* DNS Records Table */}
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-neutral-200">
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
Type
</th>
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
Name
</th>
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
Value
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200">
{/* DKIM Records */}
{status.tokens.map((token: string, index: number) => (
<tr key={index} className="hover:bg-neutral-50/50">
<td className="py-3 px-3">
<code className="text-xs font-medium text-neutral-900">CNAME</code>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
{token}._domainkey.{domain.domain}
</code>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleCopyToken(`${token}._domainkey.${domain.domain}`, index + 2000)
}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken ===
`${token}._domainkey.${domain.domain}-${index + 2000}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
{token}.dkim.amazonses.com
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken(`${token}.dkim.amazonses.com`, index)}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === `${token}.dkim.amazonses.com-${index}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
</tr>
))}
{/* MX Record */}
<tr className="hover:bg-neutral-50/50">
<td className="py-3 px-3">
<code className="text-xs font-medium text-neutral-900">MX</code>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
plunk.{domain.domain}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === `plunk.${domain.domain}-3000` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
10 feedback-smtp.eu-north-1.amazonses.com
</code>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleCopyToken('10 feedback-smtp.eu-north-1.amazonses.com', 1000)
}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === '10 feedback-smtp.eu-north-1.amazonses.com-1000' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
</tr>
{/* TXT Record (SPF) */}
<tr className="hover:bg-neutral-50/50">
<td className="py-3 px-3">
<code className="text-xs font-medium text-neutral-900">TXT</code>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
plunk.{domain.domain}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === `plunk.${domain.domain}-3001` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
&quot;v=spf1 include:amazonses.com ~all&quot;
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === '"v=spf1 include:amazonses.com ~all"-1001' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="text-blue-600 mt-0.5">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<p className="text-xs text-blue-900">
Click the copy icon to copy record values. After adding all records to your DNS
provider, use the refresh button above to verify your domain.
</p>
</div>
</div>
</AlertDescription>
</Alert>
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<ConfirmDialog
open={showRemoveDialog}
onOpenChange={setShowRemoveDialog}
onConfirm={handleRemoveDomain}
title="Remove Domain"
description={`Are you sure you want to remove ${domainToRemove?.name}?`}
confirmText="Remove"
variant="destructive"
/>
</div>
);
}
@@ -0,0 +1,167 @@
import {useEffect, useState} from 'react';
import {
Alert,
AlertDescription,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import {AlertCircle} from 'lucide-react';
import Link from 'next/link';
import {useDomains} from '../lib/hooks/useDomains';
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
interface EmailDomainInputProps {
value: string;
onChange: (value: string) => void;
id?: string;
placeholder?: string;
required?: boolean;
label?: string;
}
export function EmailDomainInput({value, onChange, id, placeholder, required, label}: EmailDomainInputProps) {
const {activeProject} = useActiveProject();
const {domains, isLoading} = useDomains(activeProject?.id);
// Split the email into local part and domain
const [localPart, setLocalPart] = useState('');
const [selectedDomain, setSelectedDomain] = useState('');
// Get verified domains only
const verifiedDomains = domains?.filter(d => d.verified) || [];
// Initialize from value
useEffect(() => {
if (value && value.includes('@')) {
const [local, domain] = value.split('@');
setLocalPart(local ?? '');
// Check if the domain is in our verified list
const domainPart = domain ?? '';
const matchingDomain = verifiedDomains.find(d => d.domain === domainPart);
if (matchingDomain) {
setSelectedDomain(domainPart);
} else {
// If domain not verified, keep it in the domain field
setSelectedDomain(domainPart);
}
} else if (value) {
// If no @ sign, treat entire value as local part
setLocalPart(value);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
// Initialize selected domain with first verified domain if none selected
useEffect(() => {
if (!selectedDomain && verifiedDomains.length > 0) {
const firstDomain = verifiedDomains[0];
if (firstDomain) {
setSelectedDomain(firstDomain.domain);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDomain]);
// Update parent component when local part or domain changes
const handleUpdate = (newLocal: string, newDomain: string) => {
if (newLocal && newDomain) {
onChange(`${newLocal}@${newDomain}`);
} else if (newLocal) {
onChange(newLocal);
} else {
onChange('');
}
};
const handleLocalPartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newLocal = e.target.value;
setLocalPart(newLocal);
handleUpdate(newLocal, selectedDomain);
};
const handleDomainChange = (newDomain: string) => {
setSelectedDomain(newDomain);
handleUpdate(localPart, newDomain);
};
if (isLoading) {
return (
<div>
{label && <Label htmlFor={id}>{label}</Label>}
<div className="flex items-center gap-2">
<Input id={id} type="text" placeholder="Loading..." disabled className="flex-1" />
</div>
</div>
);
}
if (verifiedDomains.length === 0) {
return (
<div>
{label && <Label htmlFor={id}>{label}</Label>}
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<p className="font-medium text-sm">No verified domains</p>
<p className="text-xs mt-1">
Please add and verify a domain in{' '}
<Link href="/settings?tab=domains" className="underline hover:text-red-800">
Settings Domains
</Link>{' '}
before creating templates.
</p>
</AlertDescription>
</Alert>
{/* Fallback to regular email input */}
<Input
id={id}
type="email"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder || '[email protected]'}
required={required}
className="mt-2"
/>
</div>
);
}
return (
<div>
{label && <Label htmlFor={id}>{label}</Label>}
<div className="flex items-center gap-2">
<Input
id={id}
type="text"
value={localPart}
onChange={handleLocalPartChange}
placeholder={placeholder || 'hello'}
required={required}
className="flex-1"
/>
<span className="text-neutral-500">@</span>
<Select value={selectedDomain} onValueChange={handleDomainChange} required={required}>
<SelectTrigger className="w-[200px] shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{verifiedDomains.map(domain => (
<SelectItem key={domain.id} value={domain.domain}>
{domain.domain}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {Node, mergeAttributes} from '@tiptap/core';
import {ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps} from '@tiptap/react';
import {useEffect, useRef, useState} from 'react';
interface ImageAttrs {
src: string;
alt?: string;
title?: string;
width?: number | string;
height?: number | string;
}
function ResizableImageComponent({node, updateAttributes, selected}: ReactNodeViewProps) {
const attrs = node.attrs as ImageAttrs;
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<'se' | 'sw' | 'ne' | 'nw' | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const startPos = useRef({x: 0, y: 0});
const startSize = useRef({width: 0, height: 0});
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (!imageRef.current || !resizeDirection) return;
const deltaX = e.clientX - startPos.current.x;
let newWidth = startSize.current.width;
let newHeight = startSize.current.height;
// Calculate new dimensions based on resize direction
if (resizeDirection.includes('e')) {
newWidth = startSize.current.width + deltaX;
} else if (resizeDirection.includes('w')) {
newWidth = startSize.current.width - deltaX;
}
// Maintain aspect ratio
const aspectRatio = startSize.current.width / startSize.current.height;
newHeight = newWidth / aspectRatio;
// Enforce minimum size
newWidth = Math.max(50, newWidth);
newHeight = Math.max(50, newHeight);
updateAttributes({
width: Math.round(newWidth),
height: Math.round(newHeight),
});
};
const handleMouseUp = () => {
setIsResizing(false);
setResizeDirection(null);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing, resizeDirection, updateAttributes]);
const handleResizeStart = (e: React.MouseEvent, direction: 'se' | 'sw' | 'ne' | 'nw') => {
e.preventDefault();
e.stopPropagation();
if (!imageRef.current) return;
const rect = imageRef.current.getBoundingClientRect();
startPos.current = {x: e.clientX, y: e.clientY};
startSize.current = {
width: rect.width,
height: rect.height,
};
setIsResizing(true);
setResizeDirection(direction);
};
const {src, alt, title, width, height} = attrs;
return (
<NodeViewWrapper className="resizable-image-wrapper">
<div
className={`resizable-image-container ${selected ? 'selected' : ''}`}
style={{display: 'inline-block', position: 'relative', maxWidth: '100%'}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imageRef}
src={src}
alt={alt || ''}
title={title || ''}
width={width}
height={height}
className="email-image"
style={{
display: 'block',
maxWidth: '100%',
height: 'auto',
width: width ? `${width}px` : 'auto',
}}
/>
{selected && (
<>
{/* Resize handles */}
<div
className="resize-handle resize-handle-se"
onMouseDown={e => handleResizeStart(e, 'se')}
style={{
position: 'absolute',
bottom: '-4px',
right: '-4px',
width: '12px',
height: '12px',
backgroundColor: '#3b82f6',
border: '2px solid white',
borderRadius: '50%',
cursor: 'se-resize',
zIndex: 10,
}}
/>
<div
className="resize-handle resize-handle-sw"
onMouseDown={e => handleResizeStart(e, 'sw')}
style={{
position: 'absolute',
bottom: '-4px',
left: '-4px',
width: '12px',
height: '12px',
backgroundColor: '#3b82f6',
border: '2px solid white',
borderRadius: '50%',
cursor: 'sw-resize',
zIndex: 10,
}}
/>
<div
className="resize-handle resize-handle-ne"
onMouseDown={e => handleResizeStart(e, 'ne')}
style={{
position: 'absolute',
top: '-4px',
right: '-4px',
width: '12px',
height: '12px',
backgroundColor: '#3b82f6',
border: '2px solid white',
borderRadius: '50%',
cursor: 'ne-resize',
zIndex: 10,
}}
/>
<div
className="resize-handle resize-handle-nw"
onMouseDown={e => handleResizeStart(e, 'nw')}
style={{
position: 'absolute',
top: '-4px',
left: '-4px',
width: '12px',
height: '12px',
backgroundColor: '#3b82f6',
border: '2px solid white',
borderRadius: '50%',
cursor: 'nw-resize',
zIndex: 10,
}}
/>
</>
)}
</div>
</NodeViewWrapper>
);
}
export const ResizableImage = Node.create({
name: 'image',
group: 'block',
draggable: true,
inline: false,
addAttributes() {
return {
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
width: {
default: null,
parseHTML: element => {
const width = element.getAttribute('width');
return width ? parseInt(width, 10) : null;
},
renderHTML: attributes => {
if (!attributes.width) {
return {};
}
return {
width: attributes.width,
};
},
},
height: {
default: null,
parseHTML: element => {
const height = element.getAttribute('height');
return height ? parseInt(height, 10) : null;
},
renderHTML: attributes => {
if (!attributes.height) {
return {};
}
return {
height: attributes.height,
};
},
},
};
},
parseHTML() {
return [
{
tag: 'img[src]',
},
];
},
renderHTML({HTMLAttributes}) {
return ['img', mergeAttributes(HTMLAttributes, {class: 'email-image'})];
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImageComponent);
},
addCommands(): any {
return {
setImage:
(options: {src: string; alt?: string; title?: string; width?: number; height?: number}) =>
({commands}: {commands: {insertContent: (content: unknown) => boolean}}) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
@@ -0,0 +1,506 @@
import {type Editor} from '@tiptap/react';
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
Code,
Heading1,
Heading2,
Heading3,
Image,
Italic,
Link,
List,
ListOrdered,
Palette,
Quote,
Redo,
Strikethrough,
Undo,
Variable,
} from 'lucide-react';
import {Button, Input} from '@plunk/ui';
import {useEffect, useState} from 'react';
interface ToolbarProps {
editor: Editor | null;
onInsertVariable: () => void;
onInsertImage: () => void;
canUploadImages: boolean;
}
export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImages}: ToolbarProps) {
const [showLinkInput, setShowLinkInput] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const [showColorPicker, setShowColorPicker] = useState(false);
const [selectedColor, setSelectedColor] = useState('#000000');
const [customColor, setCustomColor] = useState('');
const [, forceUpdate] = useState({});
// Force re-render when editor state changes
useEffect(() => {
if (!editor) return;
const updateHandler = () => {
forceUpdate({});
};
editor.on('selectionUpdate', updateHandler);
editor.on('transaction', updateHandler);
return () => {
editor.off('selectionUpdate', updateHandler);
editor.off('transaction', updateHandler);
};
}, [editor]);
if (!editor) {
return null;
}
const addLink = () => {
if (linkUrl) {
// If updating an existing link, extend selection to cover the entire link first
if (editor.isActive('link')) {
editor.chain().focus().extendMarkRange('link').setLink({href: linkUrl}).run();
} else {
editor.chain().focus().setLink({href: linkUrl}).run();
}
setLinkUrl('');
setShowLinkInput(false);
}
};
const removeLink = () => {
editor.chain().focus().unsetLink().run();
setLinkUrl('');
setShowLinkInput(false);
};
const setColor = (color: string) => {
editor.chain().focus().setColor(color).run();
setSelectedColor(color);
};
const applyCustomColor = () => {
if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) {
setColor(customColor);
setCustomColor('');
setShowColorPicker(false);
}
};
// Tailwind color palette organized by hue
const colorGroups = [
{
name: 'Neutrals',
colors: ['#000000', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB', '#F3F4F6', '#FFFFFF'],
},
{
name: 'Reds',
colors: ['#7F1D1D', '#991B1B', '#DC2626', '#EF4444', '#F87171', '#FCA5A5', '#FEE2E2'],
},
{
name: 'Oranges',
colors: ['#7C2D12', '#C2410C', '#EA580C', '#F97316', '#FB923C', '#FDBA74', '#FED7AA'],
},
{
name: 'Yellows',
colors: ['#713F12', '#A16207', '#CA8A04', '#EAB308', '#FACC15', '#FDE047', '#FEF08A'],
},
{
name: 'Greens',
colors: ['#14532D', '#15803D', '#16A34A', '#22C55E', '#4ADE80', '#86EFAC', '#BBF7D0'],
},
{
name: 'Blues',
colors: ['#1E3A8A', '#1D4ED8', '#2563EB', '#3B82F6', '#60A5FA', '#93C5FD', '#DBEAFE'],
},
{
name: 'Purples',
colors: ['#581C87', '#6B21A8', '#7C3AED', '#8B5CF6', '#A78BFA', '#C4B5FD', '#E9D5FF'],
},
{
name: 'Pinks',
colors: ['#831843', '#9F1239', '#DB2777', '#EC4899', '#F472B6', '#F9A8D4', '#FBCFE8'],
},
];
return (
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-10">
{/* History */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
className="h-8 w-8"
>
<Undo className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
className="h-8 w-8"
>
<Redo className="h-4 w-4" />
</Button>
</div>
{/* Text formatting */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleBold().run()}
data-active={editor.isActive('bold')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Bold className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleItalic().run()}
data-active={editor.isActive('italic')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Italic className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleStrike().run()}
data-active={editor.isActive('strike')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Strikethrough className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleCode().run()}
data-active={editor.isActive('code')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Code className="h-4 w-4" />
</Button>
</div>
{/* Headings */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleHeading({level: 1}).run()}
data-active={editor.isActive('heading', {level: 1})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleHeading({level: 2}).run()}
data-active={editor.isActive('heading', {level: 2})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleHeading({level: 3}).run()}
data-active={editor.isActive('heading', {level: 3})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading3 className="h-4 w-4" />
</Button>
</div>
{/* Lists */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleBulletList().run()}
data-active={editor.isActive('bulletList')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<List className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
data-active={editor.isActive('orderedList')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
data-active={editor.isActive('blockquote')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Quote className="h-4 w-4" />
</Button>
</div>
{/* Alignment */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('left').run()}
data-active={editor.isActive({textAlign: 'left'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('center').run()}
data-active={editor.isActive({textAlign: 'center'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignCenter className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('right').run()}
data-active={editor.isActive({textAlign: 'right'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignRight className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
data-active={editor.isActive({textAlign: 'justify'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignJustify className="h-4 w-4" />
</Button>
</div>
{/* Color picker */}
<div className="relative pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => setShowColorPicker(!showColorPicker)}
className="h-8 w-8"
>
<Palette className="h-4 w-4" />
</Button>
{showColorPicker && (
<div
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-20 max-h-96 overflow-y-auto"
style={{width: '280px'}}
>
{/* Custom color input */}
<div className="mb-3 pb-3 border-b border-neutral-200">
<label className="text-xs font-medium text-neutral-600 mb-1 block">Custom Color</label>
<div className="flex gap-2">
<Input
type="text"
value={customColor}
onChange={e => setCustomColor(e.target.value.toUpperCase())}
placeholder="#000000"
className="h-8 text-xs font-mono"
maxLength={7}
onKeyDown={e => {
if (e.key === 'Enter') {
applyCustomColor();
}
}}
/>
<Button
type="button"
size="sm"
onMouseDown={e => e.preventDefault()}
onClick={applyCustomColor}
disabled={!customColor || !/^#[0-9A-F]{6}$/i.test(customColor)}
className="h-8"
>
Apply
</Button>
</div>
</div>
{/* Color palette */}
<div className="space-y-3">
{colorGroups.map(group => (
<div key={group.name}>
<label className="text-xs font-medium text-neutral-600 mb-1.5 block">{group.name}</label>
<div className="grid grid-cols-7 gap-1.5">
{group.colors.map(color => (
<button
key={color}
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => {
setColor(color);
setShowColorPicker(false);
}}
className="w-8 h-8 rounded border-2 border-neutral-300 hover:border-neutral-500 hover:scale-110 transition-all relative group"
style={{backgroundColor: color}}
title={color}
>
{selectedColor === color && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white shadow-lg" />
</div>
)}
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Link */}
<div className="relative pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => {
if (editor.isActive('link')) {
// Get the current link URL and show the input to edit it
const previousUrl = editor.getAttributes('link').href || '';
setLinkUrl(previousUrl);
setShowLinkInput(true);
} else {
setShowLinkInput(!showLinkInput);
setLinkUrl('');
}
}}
data-active={editor.isActive('link')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Link className="h-4 w-4" />
</Button>
{showLinkInput && (
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-20 min-w-max">
<div className="flex gap-2 mb-2">
<input
type="url"
value={linkUrl}
onChange={e => setLinkUrl(e.target.value)}
placeholder="https://example.com"
className="px-2 py-1 text-sm border border-neutral-200 rounded w-64"
onKeyDown={e => {
if (e.key === 'Enter') {
addLink();
} else if (e.key === 'Escape') {
setShowLinkInput(false);
setLinkUrl('');
}
}}
autoFocus
/>
<Button type="button" size="sm" onMouseDown={e => e.preventDefault()} onClick={addLink}>
{editor.isActive('link') ? 'Update' : 'Add'}
</Button>
</div>
{editor.isActive('link') && (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="destructive"
onMouseDown={e => e.preventDefault()}
onClick={removeLink}
>
Remove Link
</Button>
</div>
)}
</div>
)}
</div>
{/* Image */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={onInsertImage}
disabled={!canUploadImages}
className="h-8 w-8"
title={canUploadImages ? 'Insert image' : 'Storage not configured'}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<Image className="h-4 w-4" />
</Button>
</div>
{/* Variable */}
<div className="flex gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={onInsertVariable}
className="h-8 w-8"
title="Insert variable"
>
<Variable className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
import {mergeAttributes, Node} from '@tiptap/core';
import {Plugin, PluginKey} from '@tiptap/pm/state';
import {Decoration, DecorationSet} from '@tiptap/pm/view';
export interface VariableOptions {
HTMLAttributes: Record<string, unknown>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
variable: {
/**
* Insert a variable at the current position
*/
insertVariable: (name: string) => ReturnType;
};
}
}
export const Variable = Node.create<VariableOptions>({
name: 'variable',
group: 'inline',
inline: true,
selectable: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
name: {
default: null,
parseHTML: element => element.getAttribute('data-variable'),
renderHTML: attributes => {
if (!attributes.name) {
return {};
}
return {
'data-variable': attributes.name,
};
},
},
};
},
parseHTML() {
return [
{
tag: 'span[data-variable]',
},
];
},
renderHTML({node, HTMLAttributes}) {
return [
'span',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-variable': node.attrs.name,
'class': 'variable-placeholder',
}),
`{{${node.attrs.name}}}`,
];
},
addCommands() {
return {
insertVariable:
(name: string) =>
({commands}) => {
return commands.insertContent({
type: this.name,
attrs: {name},
});
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('variableAutodetect'),
props: {
decorations: ({doc}) => {
const decorations: Decoration[] = [];
const regex = /\{\{([^}]+)\}\}/g;
doc.descendants((node, pos) => {
if (node.isText && node.text) {
let match;
while ((match = regex.exec(node.text)) !== null) {
const from = pos + match.index;
const to = from + match[0].length;
decorations.push(
Decoration.inline(from, to, {
class: 'variable-highlight',
}),
);
}
}
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
@@ -0,0 +1,220 @@
import {Mention} from '@tiptap/extension-mention';
import type {Editor, Range} from '@tiptap/core';
import tippy, {Instance as TippyInstance, sticky} from 'tippy.js';
import type {SuggestionProps} from '@tiptap/suggestion';
// This will be set from the component
let availableVariables: string[] = [];
export function setAvailableVariables(variables: string[]) {
availableVariables = variables || [];
}
// Suggestion component that will be rendered
class VariableSuggestionList {
public element: HTMLDivElement;
private items: string[];
private selectedIndex: number;
private command: (props: {id: string}) => void;
constructor(props: SuggestionProps) {
this.items = Array.isArray(props.items) ? props.items : [];
this.selectedIndex = 0;
this.command = props.command;
this.element = document.createElement('div');
this.element.className = 'variable-suggestion-list';
this.render();
}
render() {
if (!Array.isArray(this.items) || this.items.length === 0) {
this.element.innerHTML = '<div class="suggestion-item-empty">No variables found</div>';
return;
}
this.element.innerHTML = this.items
.map(
(item, index) => `
<div class="suggestion-item${index === this.selectedIndex ? ' is-selected' : ''}" data-index="${index}">
<code>{{${item}}}</code>
</div>
`,
)
.join('');
// Add click handlers
this.element.querySelectorAll('.suggestion-item').forEach((el, index) => {
el.addEventListener('click', () => {
this.selectItem(index);
});
});
}
selectItem(index: number) {
const item = this.items[index];
if (item && this.command) {
this.command({id: item});
}
}
onKeyDown(event: KeyboardEvent): boolean {
if (event.key === 'ArrowUp') {
this.upHandler();
return true;
}
if (event.key === 'ArrowDown') {
this.downHandler();
return true;
}
if (event.key === 'Enter') {
this.enterHandler();
return true;
}
return false;
}
upHandler() {
if (!Array.isArray(this.items) || this.items.length === 0) return;
this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length;
this.render();
}
downHandler() {
if (!Array.isArray(this.items) || this.items.length === 0) return;
this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
this.render();
}
enterHandler() {
if (!Array.isArray(this.items) || this.items.length === 0) return;
this.selectItem(this.selectedIndex);
}
update(props: SuggestionProps) {
this.items = Array.isArray(props.items) ? props.items : [];
this.selectedIndex = 0;
this.render();
}
destroy() {
this.element.remove();
}
}
export const VariableMention = Mention.configure({
HTMLAttributes: {
class: 'variable-mention',
},
renderLabel({node}) {
return `{{${node.attrs.id}}}`;
},
suggestion: {
char: '{{',
items: ({query}) => {
const safeVariables = Array.isArray(availableVariables) ? availableVariables : [];
const allVariables = ['email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl', ...safeVariables];
const uniqueVariables = Array.from(new Set(allVariables)).filter(v => typeof v === 'string');
if (!query) {
return uniqueVariables.slice(0, 10);
}
const lowerQuery = query.toLowerCase();
return uniqueVariables.filter(item => item.toLowerCase().startsWith(lowerQuery)).slice(0, 10);
},
command: ({editor, range, props}: {editor: Editor; range: Range; props: {id: string | null}}) => {
// Delete the {{ trigger characters and insert the variable as plain text
if (!props.id) return;
editor.chain().focus().deleteRange(range).insertContent(`{{${props.id}}}`).run();
},
render: () => {
let component: VariableSuggestionList;
let popup: TippyInstance[];
let scrollHandler: (() => void) | null = null;
return {
onStart: (props: SuggestionProps) => {
component = new VariableSuggestionList(props);
if (!props.clientRect) {
return;
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect as () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
theme: 'variable-suggestion',
plugins: [sticky],
sticky: 'reference',
popperOptions: {
strategy: 'fixed',
},
});
// Update position on scroll
scrollHandler = () => {
if (popup?.[0] && props.clientRect) {
popup[0].setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
});
}
};
// Find the scrolling editor container and add listener
const editorContainer = document.querySelector('.overflow-y-auto');
if (editorContainer) {
editorContainer.addEventListener('scroll', scrollHandler);
}
window.addEventListener('scroll', scrollHandler, true);
},
onUpdate(props: SuggestionProps) {
component?.update(props);
if (!props.clientRect) {
return;
}
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
});
},
onKeyDown(props: {event: KeyboardEvent}) {
if (props.event.key === 'Escape') {
popup?.[0]?.hide();
return true;
}
return component?.onKeyDown(props.event) || false;
},
onExit() {
// Clean up scroll listeners
if (scrollHandler) {
const editorContainer = document.querySelector('.overflow-y-auto');
if (editorContainer) {
editorContainer.removeEventListener('scroll', scrollHandler);
}
window.removeEventListener('scroll', scrollHandler, true);
}
popup?.[0]?.destroy();
component?.destroy();
},
};
},
},
});
@@ -0,0 +1,158 @@
import juice from 'juice';
/**
* Converts modern HTML from Tiptap to email-friendly HTML
* - Inlines CSS styles
* - Adds email-safe defaults
* - Preserves variable placeholders like {{email}}
*/
export function convertToEmailHtml(html: string): string {
// Wrap in email-safe container with basic styling
const wrappedHtml = `
<html>
<head>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #374151;
margin: 0;
padding: 0;
}
h1 {
font-size: 32px;
font-weight: 700;
margin: 0 0 16px 0;
color: #111827;
}
h2 {
font-size: 24px;
font-weight: 600;
margin: 0 0 12px 0;
color: #111827;
}
h3 {
font-size: 20px;
font-weight: 600;
margin: 0 0 8px 0;
color: #111827;
}
p {
margin: 0 0 16px 0;
}
a {
color: #3B82F6;
text-decoration: underline;
}
ul, ol {
margin: 0 0 16px 0;
padding-left: 24px;
}
li {
margin-bottom: 8px;
}
blockquote {
margin: 0 0 16px 0;
padding-left: 16px;
border-left: 4px solid #E5E7EB;
color: #6B7280;
}
code {
background-color: #F3F4F6;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
strong {
font-weight: 600;
}
em {
font-style: italic;
}
img {
max-width: 100%;
height: auto;
display: block;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0 0 16px 0;
}
th, td {
border: 1px solid #E5E7EB;
padding: 8px 12px;
text-align: left;
}
th {
background-color: #F3F4F6;
font-weight: 600;
}
.variable-placeholder {
display: inline;
background-color: #DBEAFE;
color: #1E40AF;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #3B82F6;
color: #FFFFFF;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
margin: 8px 0;
}
</style>
</head>
<body>
${html}
</body>
</html>
`;
// Inline CSS using juice
const inlined = juice(wrappedHtml, {
preserveMediaQueries: false,
preserveFontFaces: false,
removeStyleTags: true,
applyStyleTags: true,
});
// Extract just the body content
const bodyMatch = inlined.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const bodyContent = bodyMatch && bodyMatch[1] ? bodyMatch[1].trim() : inlined;
// Clean up Tiptap-specific artifacts
const cleaned = bodyContent
.replace(/\sdata-pm-slice="[^"]*"/g, '')
.replace(/\sclass=""/g, '')
.replace(/\sstyle=""/g, '');
return cleaned;
}
/**
* Wraps text content in a button element for CTAs
*/
export function createButtonHtml(text: string, href: string, color = '#3B82F6'): string {
return `<a href="${href}" class="button" style="display: inline-block; padding: 12px 24px; background-color: ${color}; color: #FFFFFF; text-decoration: none; border-radius: 6px; font-weight: 600; margin: 8px 0;">${text}</a>`;
}
/**
* Converts plain HTML back to a format suitable for Tiptap
* Preserves structure but removes email-specific inline styles
*/
export function convertFromEmailHtml(html: string): string {
// Remove inline styles added by juice
const cleaned = html.replace(/\sstyle="[^"]*"/g, '');
// Preserve basic structure elements
return cleaned.trim();
}
@@ -0,0 +1,2 @@
export {EmailEditor} from './EmailEditor';
export {convertToEmailHtml, convertFromEmailHtml, createButtonHtml} from './emailHtmlConverter';
+82
View File
@@ -0,0 +1,82 @@
import {Input, Label} from '@plunk/ui';
import {EmailDomainInput} from './EmailDomainInput';
interface EmailSettingsProps {
from: string;
fromName: string;
replyTo: string;
onFromChange: (value: string) => void;
onFromNameChange: (value: string) => void;
onReplyToChange: (value: string) => void;
fromPlaceholder?: string;
fromNamePlaceholder?: string;
replyToPlaceholder?: string;
showFromNameHelpText?: boolean;
layout?: 'vertical' | 'grid';
}
/**
* Reusable email settings component for from, fromName, and replyTo fields
* Used in campaign and template forms
*/
export function EmailSettings({
from,
fromName,
replyTo,
onFromChange,
onFromNameChange,
onReplyToChange,
fromPlaceholder = 'hello',
fromNamePlaceholder = 'Your Company',
replyToPlaceholder = 'support',
showFromNameHelpText = false,
layout = 'grid',
}: EmailSettingsProps) {
const GridWrapper = layout === 'grid' ? 'div' : 'div';
const gridClassName = layout === 'grid' ? 'grid gap-4 md:grid-cols-2' : 'space-y-4';
return (
<div className="space-y-4">
<GridWrapper className={gridClassName}>
<div>
<EmailDomainInput
id="from"
label="From Email *"
value={from}
onChange={onFromChange}
required
placeholder={fromPlaceholder}
/>
</div>
<div>
<Label htmlFor="fromName">From Name</Label>
<Input
id="fromName"
type="text"
value={fromName}
onChange={e => onFromNameChange(e.target.value)}
placeholder={fromNamePlaceholder}
/>
{showFromNameHelpText && (
<p className="text-xs text-neutral-500 mt-1">
The sender name that appears in the recipient&apos;s inbox. Defaults to your project name if not set.
</p>
)}
</div>
</GridWrapper>
<GridWrapper className={layout === 'grid' ? 'grid gap-4 md:grid-cols-2' : ''}>
<div>
<EmailDomainInput
id="replyTo"
label="Reply-To Email"
value={replyTo}
onChange={onReplyToChange}
placeholder={replyToPlaceholder}
/>
</div>
</GridWrapper>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import {Button, Input, Label} from '@plunk/ui';
import {Plus, Trash2} from 'lucide-react';
import {useState} from 'react';
interface KeyValuePair {
id: string;
key: string;
value: string;
}
interface KeyValueEditorProps {
initialData?: Record<string, string | number | boolean> | null;
onChange?: (data: Record<string, string | number | boolean> | null) => void;
}
export function KeyValueEditor({initialData, onChange}: KeyValueEditorProps) {
const [pairs, setPairs] = useState<KeyValuePair[]>(() => {
// Initialize state from initialData only once on mount
if (initialData && typeof initialData === 'object') {
return Object.entries(initialData).map(([key, value], index) => ({
id: `initial-${index}-${Date.now()}`,
key,
value: String(value),
}));
}
return [];
});
// Notify parent of changes based on given pairs
const notifyChange = (updatedPairs: KeyValuePair[]) => {
if (!onChange) return;
// Filter out empty pairs
const validPairs = updatedPairs.filter(pair => pair.key.trim() !== '');
if (validPairs.length === 0) {
onChange(null);
} else {
const data = validPairs.reduce(
(acc, pair) => {
// Try to parse as number or boolean, otherwise keep as string
let value: string | number | boolean = pair.value;
if (pair.value === 'true') value = true;
else if (pair.value === 'false') value = false;
else if (!isNaN(Number(pair.value)) && pair.value.trim() !== '') value = Number(pair.value);
acc[pair.key] = value;
return acc;
},
{} as Record<string, string | number | boolean>,
);
onChange(data);
}
};
const addPair = () => {
const newPairs = [...pairs, {id: `new-${Date.now()}`, key: '', value: ''}];
setPairs(newPairs);
notifyChange(newPairs);
};
const updateKey = (id: string, newKey: string) => {
const updated = pairs.map(pair => (pair.id === id ? {...pair, key: newKey} : pair));
setPairs(updated);
notifyChange(updated);
};
const updateValue = (id: string, newValue: string) => {
const updated = pairs.map(pair => (pair.id === id ? {...pair, value: newValue} : pair));
setPairs(updated);
notifyChange(updated);
};
const removePair = (id: string) => {
const updated = pairs.filter(pair => pair.id !== id);
setPairs(updated);
notifyChange(updated);
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Custom Data</Label>
<Button type="button" variant="outline" size="sm" onClick={addPair}>
<Plus className="h-3 w-3" />
Add Field
</Button>
</div>
{pairs.length === 0 ? (
<div className="text-center py-8 border border-dashed border-neutral-200 rounded-lg bg-neutral-50">
<p className="text-sm text-neutral-500">No custom fields yet</p>
<p className="text-xs text-neutral-400 mt-1">Click &quot;Add Field&quot; to create custom data fields</p>
</div>
) : (
<div className="space-y-2">
{pairs.map(pair => (
<div key={pair.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input
type="text"
placeholder="Key"
value={pair.key}
onChange={e => updateKey(pair.id, e.target.value)}
className="text-sm"
/>
</div>
<div className="flex-1">
<Input
type="text"
placeholder="Value"
value={pair.value}
onChange={e => updateValue(pair.id, e.target.value)}
className="text-sm"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removePair(pair.id)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
import {BookOpen, CheckCircle2, Mail, MessageCircle, Shield, Users, Zap} from 'lucide-react';
import Link from 'next/link';
import {useMemo} from 'react';
import {LANDING_URI, WIKI_URI} from '../lib/constants';
import type {ProjectSetupState} from '../lib/hooks/useProjectSetupState';
import {useConfig} from '../lib/hooks/useConfig';
interface QuickStartStep {
id: string;
icon: React.ElementType;
title: string;
description: string;
link: string;
linkText: string;
isCompleted: boolean;
}
interface QuickStartProps {
setupState: ProjectSetupState | undefined;
isLoading: boolean;
}
// Help resources that always appear
function HelpResources() {
return (
<div className="mt-4 pt-4 border-t border-neutral-200">
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
<div className="flex flex-col sm:flex-row gap-2">
<Link href={WIKI_URI} target="_blank" className="flex-1">
<button className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium text-neutral-700 bg-white border border-neutral-200 rounded-lg hover:bg-neutral-50 hover:border-neutral-300 transition-all">
<BookOpen className="h-3.5 w-3.5" />
Documentation
</button>
</Link>
<Link href={`${LANDING_URI}/discord`} target="_blank" className="flex-1">
<button className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium text-neutral-700 bg-white border border-neutral-200 rounded-lg hover:bg-neutral-50 hover:border-neutral-300 transition-all">
<MessageCircle className="h-3.5 w-3.5" />
Join Discord
</button>
</Link>
</div>
</div>
);
}
export function QuickStart({setupState, isLoading}: QuickStartProps) {
// Calculate days since last campaign using useMemo to avoid impure function during render
// Must be called before any early returns to follow Rules of Hooks
const daysSinceLastCampaign = useMemo(() => {
if (!setupState || !setupState.lastCampaignSentAt) return null;
const now = new Date();
const lastSent = new Date(setupState.lastCampaignSentAt);
return Math.floor((now.getTime() - lastSent.getTime()) / (1000 * 60 * 60 * 24));
}, [setupState]);
const {data: config} = useConfig();
const billingEnabled = config?.features.billing.enabled ?? false;
if (isLoading || !setupState) {
return (
<Card>
<CardHeader>
<CardTitle>Quick Start</CardTitle>
<CardDescription>Get started with Plunk in minutes</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2, 3].map(i => (
<div
key={i}
className="flex items-start gap-4 p-4 rounded-lg border border-neutral-200 bg-neutral-50/50 animate-pulse"
>
<div className="h-10 w-10 rounded-lg bg-neutral-200" />
<div className="flex-1 space-y-2 pt-1">
<div className="h-4 bg-neutral-200 rounded w-1/3" />
<div className="h-3 bg-neutral-200 rounded w-2/3" />
</div>
<div className="h-9 w-20 bg-neutral-200 rounded-lg" />
</div>
))}
</div>
<HelpResources />
</CardContent>
</Card>
);
}
const hasContacts = setupState.contactCount > 0;
const hasSentCampaign = setupState.lastCampaignSentAt !== null;
const hasRecentCampaign = daysSinceLastCampaign !== null && daysSinceLastCampaign <= 30;
// Build dynamic steps based on setup state with proper prioritization
// Priority order: Domain → Contacts → Campaign → Workflow → Subscription
const allSteps: QuickStartStep[] = [];
// Priority 1: Domain verification (critical for deliverability)
if (!setupState.hasVerifiedDomain) {
allSteps.push({
id: 'domain',
icon: Shield,
title: 'Verify Your Domain',
description: 'Essential for email deliverability and avoiding spam',
link: '/settings?tab=domains',
linkText: 'Add Domain',
isCompleted: false,
});
}
// Priority 2: Add contacts (can't send without contacts)
if (!hasContacts) {
allSteps.push({
id: 'contacts',
icon: Users,
title: 'Add Your First Contacts',
description: 'Import your subscriber list to start sending emails',
link: '/contacts',
linkText: 'Add Contacts',
isCompleted: false,
});
}
// Priority 3: Send campaign (core functionality, show if they have contacts but never sent)
if (hasContacts && !hasSentCampaign) {
allSteps.push({
id: 'campaign',
icon: Mail,
title: 'Send Your First Campaign',
description: 'Create and send your first email campaign',
link: '/campaigns',
linkText: 'Create Campaign',
isCompleted: false,
});
}
// Priority 4: Set up automation (advanced feature, only show if no workflow and have contacts)
if (hasContacts && !setupState.hasEnabledWorkflow) {
allSteps.push({
id: 'workflows',
icon: Zap,
title: 'Set Up Automation',
description: 'Create automated workflows to engage your audience',
link: '/workflows',
linkText: 'Create Workflow',
isCompleted: false,
});
}
// Priority 5: Subscription (nice to have, lower priority)
if (billingEnabled && !setupState.hasSubscription) {
allSteps.push({
id: 'subscription',
icon: Shield,
title: 'Upgrade Your Plan',
description: 'Remove Plunk branding and unlock more features',
link: '/settings?tab=billing',
linkText: 'Upgrade',
isCompleted: false,
});
}
// Priority 6: Re-engagement (show if they've been inactive)
if (hasSentCampaign && !hasRecentCampaign && daysSinceLastCampaign !== null && daysSinceLastCampaign > 30) {
allSteps.push({
id: 'campaign-reengagement',
icon: Mail,
title: 'Re-engage Your Audience',
description: `It's been ${daysSinceLastCampaign} days since your last campaign`,
link: '/campaigns',
linkText: 'Create Campaign',
isCompleted: false,
});
}
// If core setup is complete and they're actively sending, show success message
if (setupState.hasVerifiedDomain && hasContacts && hasSentCampaign && hasRecentCampaign) {
return (
<Card>
<CardHeader>
<CardTitle>Quick Start</CardTitle>
<CardDescription>Your project is fully set up</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-start gap-4 p-4 bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border border-green-200">
<div className="h-10 w-10 rounded-lg bg-green-100 border border-green-200 flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="h-5 w-5 text-green-700" />
</div>
<div className="flex-1 pt-0.5">
<p className="text-sm font-semibold text-green-900 mb-1">All set!</p>
<p className="text-xs text-green-700 leading-relaxed">
Your project is fully configured and you&apos;re actively engaging your audience. Keep up the great
work!
</p>
</div>
</div>
<HelpResources />
</CardContent>
</Card>
);
}
// Show only the first 3 most important steps
const visibleSteps = allSteps.slice(0, 3);
return (
<Card>
<CardHeader>
<CardTitle>Quick Start</CardTitle>
<CardDescription>
{visibleSteps.length === 0 ? 'Your project is set up' : 'Get started with Plunk in minutes'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{visibleSteps.map(step => {
const Icon = step.icon;
return (
<div
key={step.id}
className="group relative flex items-start gap-4 p-4 rounded-lg border border-neutral-200 bg-neutral-50/50 hover:bg-neutral-50 hover:border-neutral-300 transition-all duration-200"
>
<div
className={`h-10 w-10 rounded-lg ${step.isCompleted ? 'bg-green-100 border border-green-200' : 'bg-white border border-neutral-200'} flex items-center justify-center flex-shrink-0 transition-colors`}
>
<Icon
className={`h-5 w-5 ${step.isCompleted ? 'text-green-700' : 'text-neutral-600'} transition-colors`}
/>
</div>
<div className="flex-1 min-w-0 pt-0.5">
<div className="flex items-start justify-between gap-3 mb-1">
<p className="text-sm font-semibold text-neutral-900">{step.title}</p>
{step.isCompleted && <CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0 mt-0.5" />}
</div>
<p className="text-xs text-neutral-600 leading-relaxed">{step.description}</p>
</div>
<Link href={step.link} className="flex-shrink-0">
<Button
size="sm"
variant={step.isCompleted ? 'outline' : 'default'}
className="h-9 transition-all group-hover:shadow-sm"
>
{step.linkText}
</Button>
</Link>
</div>
);
})}
</div>
<HelpResources />
</CardContent>
</Card>
);
}
+145
View File
@@ -0,0 +1,145 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
import {Mail, Server} from 'lucide-react';
import {ApiKeyDisplay} from './ApiKeyDisplay';
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
interface SmtpSettingsProps {
smtpConfig: {
enabled: boolean;
domain?: string;
portSecure?: number;
portSubmission?: number;
};
}
export function SmtpSettings({smtpConfig}: SmtpSettingsProps) {
const {activeProject} = useActiveProject();
if (!smtpConfig.enabled) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
SMTP Server
</CardTitle>
<CardDescription>SMTP server is not configured on this instance</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-neutral-600">
The SMTP relay server is not enabled. Contact your administrator to enable SMTP support for sending emails
via standard mail clients.
</p>
</CardContent>
</Card>
);
}
const isDevelopment = smtpConfig.domain === 'localhost';
return (
<div className="space-y-6">
{isDevelopment && (
<Card className="border-yellow-200 bg-yellow-50">
<CardHeader>
<CardTitle className="text-yellow-900 text-base flex items-center gap-2">
<Server className="h-4 w-4" />
Development Mode
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-yellow-800">
SMTP server is running in development mode without TLS encryption. Only use for local testing. For
production, configure the <code className="bg-yellow-100 px-1 py-0.5 rounded text-xs">SMTP_DOMAIN</code>{' '}
environment variable and mount TLS certificates.
</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
SMTP Server Configuration
</CardTitle>
<CardDescription>
Send emails through standard email clients using SMTP protocol. Works with Outlook, Thunderbird, and any
SMTP-compatible application.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Server Details */}
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-neutral-700 block mb-2">SMTP Server</label>
<code className="flex-1 px-3 py-2 bg-neutral-50 rounded-lg text-xs font-mono text-neutral-900 border border-neutral-200 block">
{smtpConfig.domain}
</code>
<p className="text-xs text-neutral-500 mt-1">Use this hostname in your email client configuration</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-neutral-700 block mb-2">
Port (STARTTLS)
<span className="ml-2 text-xs font-normal text-neutral-500">
{isDevelopment ? 'Plaintext in dev' : 'Recommended'}
</span>
</label>
<code className="flex-1 px-3 py-2 bg-neutral-50 rounded-lg text-xs font-mono text-neutral-900 border border-neutral-200 block">
{smtpConfig.portSubmission}
</code>
<p className="text-xs text-neutral-500 mt-1">
{isDevelopment
? 'Runs without encryption in development'
: 'Submission port with STARTTLS encryption'}
</p>
</div>
<div>
<label className="text-sm font-medium text-neutral-700 block mb-2">
Port (SSL/TLS)
{isDevelopment && (
<span className="ml-2 text-xs font-normal text-neutral-500">Not available in dev</span>
)}
</label>
<code className="flex-1 px-3 py-2 bg-neutral-50 rounded-lg text-xs font-mono text-neutral-900 border border-neutral-200 block">
{smtpConfig.portSecure}
</code>
<p className="text-xs text-neutral-500 mt-1">
{isDevelopment ? 'Requires TLS certificates in production' : 'Implicit TLS encryption'}
</p>
</div>
</div>
</div>
{/* Credentials */}
<div className="pt-4 border-t border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Authentication Credentials</h3>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-neutral-700 block mb-2">Username</label>
<code className="flex-1 px-3 py-2 bg-neutral-50 rounded-lg text-xs font-mono text-neutral-900 border border-neutral-200 block">
plunk
</code>
<p className="text-xs text-neutral-500 mt-1">Always use &quot;plunk&quot; as the username</p>
</div>
{activeProject && (
<ApiKeyDisplay
label="Password"
value={activeProject.secret}
description="Use your project secret key as the SMTP password"
isSecret
/>
)}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import {CardDescription, CardTitle} from '@plunk/ui';
interface StepHeaderProps {
stepNumber: number;
title: string;
description: string;
}
/**
* Reusable step header component with numbered circle
* Used in multi-step forms like campaign creation
*/
export function StepHeader({stepNumber, title, description}: StepHeaderProps) {
return (
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground text-sm font-semibold">
{stepNumber}
</div>
<div>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import {Alert, Button} from '@plunk/ui';
import {AlertTriangle, ExternalLink} from 'lucide-react';
import {useBillingInvoices} from '../lib/hooks/useBillingInvoices';
interface UnpaidInvoiceBannerProps {
projectId: string;
hasSubscription: boolean;
}
export function UnpaidInvoiceBanner({projectId, hasSubscription}: UnpaidInvoiceBannerProps) {
const {invoicesData, isLoading} = useBillingInvoices(projectId, hasSubscription);
// Don't show banner if not loading and either no subscription or no unpaid invoices
if (!hasSubscription || isLoading || !invoicesData?.hasUnpaidInvoices) {
return null;
}
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100);
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Soon';
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const totalUnpaid = invoicesData.unpaidInvoices.reduce((sum, invoice) => sum + invoice.amountDue, 0);
const currency = invoicesData.unpaidInvoices[0]?.currency || 'usd';
const oldestDueDate = invoicesData.unpaidInvoices.reduce(
(oldest, invoice) => {
if (!invoice.dueDate) return oldest;
if (!oldest) return invoice.dueDate;
return new Date(invoice.dueDate) < new Date(oldest) ? invoice.dueDate : oldest;
},
null as string | null,
);
const handlePayNow = () => {
const firstInvoice = invoicesData.unpaidInvoices[0];
if (firstInvoice?.hostedInvoiceUrl) {
window.open(firstInvoice.hostedInvoiceUrl, '_blank');
}
};
return (
<Alert variant="destructive" className="mb-6 bg-red-50 border-red-300">
<AlertTriangle className="h-5 w-5 text-red-600" />
<div className="ml-3 flex-1">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-red-900 mb-1">
{invoicesData.unpaidInvoices.length === 1
? 'You have an unpaid invoice'
: `You have ${invoicesData.unpaidInvoices.length} unpaid invoices`}
</h3>
<p className="text-sm text-red-800">
Total amount due: <span className="font-semibold">{formatCurrency(totalUnpaid, currency)}</span>
{oldestDueDate && (
<>
{' '}
Due by: <span className="font-semibold">{formatDate(oldestDueDate)}</span>
</>
)}
</p>
<p className="text-xs text-red-700 mt-1">
Please pay your outstanding invoices to avoid service interruption.
</p>
</div>
<Button
variant="default"
size="sm"
onClick={handlePayNow}
className="ml-4 bg-red-600 hover:bg-red-700 text-white flex items-center gap-2"
>
Pay Now
<ExternalLink className="h-3 w-3" />
</Button>
</div>
</div>
</Alert>
);
}
+796
View File
@@ -0,0 +1,796 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Background,
Controls,
type Edge,
Handle,
MarkerType,
MiniMap,
type Node,
Panel,
Position,
ReactFlow,
useEdgesState,
useNodesState,
useReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
import {Clock, GitBranch, LogOut, Mail, Plus, Settings, Trash2, UserCog, Webhook} from 'lucide-react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import dagre from 'dagre';
import {network} from '../lib/network';
import {toast} from 'sonner';
import {Button, ConfirmDialog, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@plunk/ui';
import {WorkflowSchemas} from '@plunk/shared';
interface WorkflowBuilderProps {
workflowId: string;
steps: (WorkflowStep & {
template?: {id: string; name: string} | null;
outgoingTransitions: Array<{
id: string;
toStepId: string;
condition: unknown;
priority: number;
}>;
incomingTransitions: Array<{
id: string;
fromStepId: string;
condition: unknown;
priority: number;
}>;
})[];
onUpdate: () => void;
}
const STEP_TYPE_ICONS = {
TRIGGER: GitBranch,
SEND_EMAIL: Mail,
DELAY: Clock,
WAIT_FOR_EVENT: Clock,
CONDITION: GitBranch,
EXIT: LogOut,
WEBHOOK: Webhook,
UPDATE_CONTACT: UserCog,
};
const STEP_TYPE_COLORS = {
TRIGGER: '#9333ea',
SEND_EMAIL: '#2563eb',
DELAY: '#ea580c',
WAIT_FOR_EVENT: '#ca8a04',
CONDITION: '#9333ea',
EXIT: '#dc2626',
WEBHOOK: '#16a34a',
UPDATE_CONTACT: '#4f46e5',
};
const STEP_TYPE_BG = {
TRIGGER: '#f3e8ff',
SEND_EMAIL: '#dbeafe',
DELAY: '#ffedd5',
WAIT_FOR_EVENT: '#fef3c7',
CONDITION: '#f3e8ff',
EXIT: '#fee2e2',
WEBHOOK: '#dcfce7',
UPDATE_CONTACT: '#e0e7ff',
};
// Dagre layout function
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const nodeWidth = 280;
const nodeHeight = 120;
dagreGraph.setGraph({
rankdir: 'TB',
nodesep: 100,
ranksep: 150,
marginx: 50,
marginy: 50,
});
nodes.forEach(node => {
dagreGraph.setNode(node.id, {width: nodeWidth, height: nodeHeight});
});
edges.forEach(edge => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = nodes.map(node => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
},
};
});
return {nodes: layoutedNodes, edges};
}
// Add Step Node - appears at the end of flow paths
function AddStepNode({data}: {data: {label: string; onClick?: () => void}}) {
return (
<>
<Handle
type="target"
position={Position.Top}
style={{
background: '#94a3b8',
width: 14,
height: 14,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/>
<div className="cursor-pointer hover:scale-110 transition-transform" onClick={data.onClick}>
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-neutral-100 to-neutral-200 border-2 border-dashed border-neutral-400 hover:border-neutral-600 hover:from-blue-50 hover:to-blue-100 hover:border-blue-400 flex items-center justify-center shadow-md hover:shadow-lg transition-all">
<Plus className="h-8 w-8 text-neutral-500 hover:text-blue-600 transition-colors" />
</div>
{data.label && <div className="text-xs text-neutral-500 text-center mt-2 font-medium">{data.label}</div>}
</div>
</>
);
}
// Custom node component with action buttons
function CustomNode({
data,
}: {
data: {
label: string;
type: string;
stepId?: string;
icon?: any;
color?: string;
bgColor?: string;
onEdit?: () => void;
onDelete?: () => void;
template?: {name: string};
config?: any;
};
}) {
const Icon = data.icon;
const color = data.color;
const bgColor = data.bgColor;
const [showActions, setShowActions] = useState(false);
return (
<>
<Handle
type="target"
position={Position.Top}
style={{
background: color,
width: 14,
height: 14,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/>
<div
className="px-5 py-4 rounded-xl border-2 bg-white shadow-lg hover:shadow-xl transition-all relative group"
style={{
borderColor: color,
minWidth: '280px',
maxWidth: '280px',
}}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
{/* Action buttons - shown on hover */}
{showActions && data.type !== 'TRIGGER' && (
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
<button
onClick={e => {
e.stopPropagation();
data.onEdit?.();
}}
className="p-1.5 bg-white border-2 border-neutral-300 rounded-lg shadow-md hover:border-neutral-400 hover:bg-neutral-50 transition-all"
title="Edit step"
>
<Settings className="h-3.5 w-3.5 text-neutral-700" />
</button>
<button
onClick={e => {
e.stopPropagation();
data.onDelete?.();
}}
className="p-1.5 bg-white border-2 border-red-300 rounded-lg shadow-md hover:border-red-400 hover:bg-red-50 transition-all"
title="Delete step"
>
<Trash2 className="h-3.5 w-3.5 text-red-600" />
</button>
</div>
)}
{/* Header */}
<div className="flex items-start gap-3 mb-3">
<div
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center"
style={{backgroundColor: bgColor}}
>
<Icon className="h-5 w-5" style={{color}} />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-neutral-900 text-sm leading-tight mb-1 break-words">{data.label}</h4>
<span
className="inline-flex items-center text-xs px-2 py-0.5 rounded-full font-medium"
style={{
backgroundColor: bgColor,
color,
}}
>
{data.type}
</span>
</div>
</div>
{/* Details */}
{data.template && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<span className="font-medium">📧</span>
<span className="truncate">{data.template.name}</span>
</div>
</div>
)}
{data.type === 'DELAY' && data.config?.amount && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<span className="font-medium"></span>
<span>
Wait {data.config.amount} {data.config.unit}
</span>
</div>
</div>
)}
{data.type === 'CONDITION' && data.config && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<div className="text-xs text-neutral-600">
<div className="flex items-center gap-1 mb-1">
<span className="font-medium">🔀</span>
<span className="font-mono text-[10px] truncate">{data.config.field}</span>
</div>
<div className="text-[10px] text-neutral-500 ml-4">
{data.config.operator} &quot;{String(data.config.value)}&quot;
</div>
</div>
</div>
)}
{data.type === 'WAIT_FOR_EVENT' && data.config?.eventName && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<span className="font-medium"></span>
<span className="truncate">{data.config.eventName}</span>
</div>
</div>
)}
{data.type === 'WEBHOOK' && data.config?.url && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<span className="font-medium">🔗</span>
<span className="truncate text-[10px]">
{data.config.method || 'POST'} {data.config.url}
</span>
</div>
</div>
)}
</div>
<Handle
type="source"
position={Position.Bottom}
style={{
background: color,
width: 14,
height: 14,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/>
</>
);
}
const nodeTypes = {
custom: CustomNode,
addStep: AddStepNode,
};
// Step type options for adding new steps
const STEP_TYPE_OPTIONS = [
{value: 'SEND_EMAIL', label: 'Send Email', icon: Mail, color: STEP_TYPE_COLORS.SEND_EMAIL},
{value: 'DELAY', label: 'Delay', icon: Clock, color: STEP_TYPE_COLORS.DELAY},
{value: 'WAIT_FOR_EVENT', label: 'Wait for Event', icon: Clock, color: STEP_TYPE_COLORS.WAIT_FOR_EVENT},
{value: 'CONDITION', label: 'Condition', icon: GitBranch, color: STEP_TYPE_COLORS.CONDITION},
{value: 'WEBHOOK', label: 'Webhook', icon: Webhook, color: STEP_TYPE_COLORS.WEBHOOK},
{value: 'UPDATE_CONTACT', label: 'Update Contact', icon: UserCog, color: STEP_TYPE_COLORS.UPDATE_CONTACT},
{value: 'EXIT', label: 'Exit', icon: LogOut, color: STEP_TYPE_COLORS.EXIT},
];
export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderProps) {
const reactFlowInstance = useReactFlow();
const [addStepContext, setAddStepContext] = useState<{
fromStepId: string | null;
branch?: 'yes' | 'no';
} | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
// Convert workflow steps to React Flow nodes
const rawNodes: Node[] = useMemo(() => {
if (steps.length === 0) return [];
const nodes: Node[] = steps.map(step => {
const Icon = STEP_TYPE_ICONS[step.type as keyof typeof STEP_TYPE_ICONS] || GitBranch;
const color = STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] || '#6b7280';
const bgColor = STEP_TYPE_BG[step.type as keyof typeof STEP_TYPE_BG] || '#f3f4f6';
return {
id: step.id,
type: 'custom',
position: step.position ? (step.position as {x: number; y: number}) : {x: 0, y: 0},
data: {
label: step.name,
type: step.type,
icon: Icon,
color,
bgColor,
template: step.template,
config: step.config,
onEdit: () => handleEditStep(step.id),
onDelete: () => {
setStepToDelete(step.id);
setShowDeleteDialog(true);
},
},
};
});
// Add "Add Step" nodes at the end of each flow path
steps.forEach(step => {
if (step.type === 'EXIT') return; // Exit steps can't have next steps
if (step.type === 'CONDITION') {
// Check for yes and no branches
const hasYesBranch = step.outgoingTransitions?.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
});
const hasNoBranch = step.outgoingTransitions?.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
});
if (!hasYesBranch) {
nodes.push({
id: `${step.id}-add-yes`,
type: 'addStep',
position: {x: 0, y: 0},
data: {
label: 'Yes',
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
},
});
}
if (!hasNoBranch) {
nodes.push({
id: `${step.id}-add-no`,
type: 'addStep',
position: {x: 0, y: 0},
data: {
label: 'No',
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
},
});
}
} else {
// For non-condition steps, add + node if no outgoing transitions
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
nodes.push({
id: `${step.id}-add`,
type: 'addStep',
position: {x: 0, y: 0},
data: {
label: '',
onClick: () => setAddStepContext({fromStepId: step.id}),
},
});
}
}
});
return nodes;
}, [steps]);
// Convert transitions to React Flow edges
const rawEdges: Edge[] = useMemo(() => {
const edges: Edge[] = [];
steps.forEach(step => {
if (step.outgoingTransitions && step.outgoingTransitions.length > 0) {
step.outgoingTransitions.forEach(transition => {
const condition = transition.condition;
const isConditional = condition && typeof condition === 'object' && 'branch' in condition;
const branch =
condition && typeof condition === 'object' && 'branch' in condition ? condition.branch : undefined;
edges.push({
id: transition.id,
source: step.id,
target: transition.toStepId,
type: 'smoothstep',
animated: step.type === 'DELAY' || step.type === 'WAIT_FOR_EVENT',
label: isConditional ? (branch === 'yes' ? '✓ Yes' : '✗ No') : undefined,
labelStyle: {
fill: branch === 'yes' ? '#16a34a' : branch === 'no' ? '#dc2626' : '#64748b',
fontWeight: 600,
fontSize: 12,
},
labelBgStyle: {
fill: '#fff',
fillOpacity: 0.95,
},
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {
stroke: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
strokeWidth: 2.5,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
width: 22,
height: 22,
},
});
});
}
// Add edges from steps to their "Add Step" nodes
if (step.type === 'EXIT') return;
if (step.type === 'CONDITION') {
const hasYesBranch = step.outgoingTransitions?.some(
t =>
t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes',
);
const hasNoBranch = step.outgoingTransitions?.some(
t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no',
);
if (!hasYesBranch) {
edges.push({
id: `${step.id}-add-yes-edge`,
source: step.id,
target: `${step.id}-add-yes`,
type: 'smoothstep',
animated: false,
label: '✓ Yes',
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {stroke: '#16a34a', strokeWidth: 2.5, strokeDasharray: '5,5'},
markerEnd: {type: MarkerType.ArrowClosed, color: '#16a34a', width: 22, height: 22},
});
}
if (!hasNoBranch) {
edges.push({
id: `${step.id}-add-no-edge`,
source: step.id,
target: `${step.id}-add-no`,
type: 'smoothstep',
animated: false,
label: '✗ No',
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {stroke: '#dc2626', strokeWidth: 2.5, strokeDasharray: '5,5'},
markerEnd: {type: MarkerType.ArrowClosed, color: '#dc2626', width: 22, height: 22},
});
}
} else {
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
edges.push({
id: `${step.id}-add-edge`,
source: step.id,
target: `${step.id}-add`,
type: 'smoothstep',
animated: false,
style: {stroke: '#94a3b8', strokeWidth: 2.5, strokeDasharray: '5,5'},
markerEnd: {type: MarkerType.ArrowClosed, color: '#94a3b8', width: 22, height: 22},
});
}
}
});
return edges;
}, [steps]);
// Apply dagre layout
const {nodes: layoutedNodes, edges: layoutedEdges} = useMemo(() => {
if (rawNodes.length === 0) return {nodes: [], edges: []};
return getLayoutedElements(rawNodes, rawEdges);
}, [rawNodes, rawEdges]);
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
// Update nodes/edges when layout changes
useEffect(() => {
setNodes(layoutedNodes);
}, [layoutedNodes, setNodes]);
useEffect(() => {
setEdges(layoutedEdges);
}, [layoutedEdges, setEdges]);
// Handle creating a new step from the + node
const handleCreateStep = useCallback(
async (stepType: string) => {
if (!addStepContext?.fromStepId) return;
try {
// Validate that this branch doesn't already have a transition
const fromStep = steps.find(s => s.id === addStepContext.fromStepId);
if (!fromStep) {
toast.error('Parent step not found');
return;
}
// For CONDITION steps, check if the branch already exists
if (fromStep.type === 'CONDITION' && addStepContext.branch) {
const existingBranchTransition = fromStep.outgoingTransitions?.find(t => {
const condition = t.condition;
return (
condition &&
typeof condition === 'object' &&
'branch' in condition &&
condition.branch === addStepContext.branch
);
});
if (existingBranchTransition) {
toast.error(`The ${addStepContext.branch} branch already has a connection`);
return;
}
}
// Create the new step (autoConnect: false because we manually create the transition with branch info)
const newStep = await network.fetch<WorkflowStep, typeof WorkflowSchemas.addStep>(
'POST',
`/workflows/${workflowId}/steps`,
{
type: stepType as WorkflowStep['type'],
name: `New ${stepType.toLowerCase().replace('_', ' ')}`,
position: {x: 0, y: 0}, // Will be auto-positioned by dagre layout
config: {},
},
);
const newStepId = newStep.id;
// Create the transition with proper condition
const condition = addStepContext.branch ? {branch: addStepContext.branch} : null;
const priority = addStepContext.branch === 'yes' ? 0 : addStepContext.branch === 'no' ? 1 : 0;
await network.fetch<unknown, typeof WorkflowSchemas.createTransition>(
'POST',
`/workflows/${workflowId}/transitions`,
{
fromStepId: addStepContext.fromStepId,
toStepId: newStepId,
condition,
priority,
},
);
toast.success('Step added successfully');
setAddStepContext(null);
onUpdate();
// Trigger edit dialog for the new step after a short delay
setTimeout(() => {
const event = new CustomEvent('workflow-edit-step', {detail: {stepId: newStepId}});
window.dispatchEvent(event);
}, 100);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to add step');
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[addStepContext, workflowId, onUpdate],
);
const handleEditStep = (stepId: string) => {
// This will be handled by the parent component
const event = new CustomEvent('workflow-edit-step', {detail: {stepId}});
window.dispatchEvent(event);
};
const handleDeleteStep = async () => {
if (!stepToDelete) return;
try {
await network.fetch('DELETE', `/workflows/${workflowId}/steps/${stepToDelete}`);
toast.success('Step deleted');
onUpdate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete step');
} finally {
setStepToDelete(null);
}
};
// Auto-layout on demand
const handleAutoLayout = useCallback(() => {
const {nodes: newNodes, edges: newEdges} = getLayoutedElements(nodes, edges);
setNodes(newNodes);
setEdges(newEdges);
// Fit view after layout
setTimeout(() => {
reactFlowInstance?.fitView({padding: 0.3});
}, 10);
}, [nodes, edges, setNodes, setEdges, reactFlowInstance]);
if (steps.length === 0) {
return (
<div className="bg-neutral-50 border-2 border-dashed border-neutral-300 rounded-lg p-12 text-center">
<GitBranch className="h-16 w-16 text-neutral-400 mx-auto mb-4" />
<p className="text-neutral-600 font-medium">No workflow steps yet</p>
<p className="text-sm text-neutral-500 mt-2">Add your first step to get started</p>
</div>
);
}
return (
<>
<div className="w-full h-[800px] bg-gradient-to-br from-neutral-50 to-neutral-100 rounded-lg border border-neutral-200 shadow-inner relative">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{
padding: 0.3,
minZoom: 0.5,
maxZoom: 1.2,
}}
minZoom={0.1}
maxZoom={2}
nodesDraggable={true}
nodesConnectable={false}
elementsSelectable={true}
defaultEdgeOptions={{
type: 'smoothstep',
}}
deleteKeyCode={null}
proOptions={{hideAttribution: true}}
>
<Background color="#e5e7eb" gap={16} size={1} />
<Controls
showInteractive={false}
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
/>
<MiniMap
nodeColor={node => {
const step = steps.find(s => s.id === node.id);
return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280';
}}
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
maskColor="rgba(0, 0, 0, 0.05)"
/>
<Panel
position="top-left"
className="bg-white/95 backdrop-blur-sm px-4 py-2.5 rounded-lg shadow-lg border border-neutral-200"
>
<div className="flex items-center gap-3">
<GitBranch className="h-4 w-4 text-neutral-700" />
<div className="text-sm">
<span className="font-semibold text-neutral-900">{steps.length}</span>
<span className="text-neutral-600"> step{steps.length !== 1 ? 's' : ''}</span>
<span className="text-neutral-400 mx-2">·</span>
<span className="font-semibold text-neutral-900">{rawEdges.length}</span>
<span className="text-neutral-600"> connection{rawEdges.length !== 1 ? 's' : ''}</span>
</div>
</div>
</Panel>
<Panel position="top-right" className="flex gap-2">
<button
onClick={handleAutoLayout}
className="bg-white/95 backdrop-blur-sm px-4 py-2 rounded-lg shadow-lg border border-neutral-200 text-sm font-medium text-neutral-700 hover:bg-white hover:text-neutral-900 transition-all"
>
Auto Layout
</button>
</Panel>
{rawEdges.length === 0 && steps.length > 1 && (
<Panel
position="bottom-center"
className="bg-blue-50 border border-blue-200 px-4 py-2.5 rounded-lg shadow-lg"
>
<div className="flex items-center gap-2 text-sm text-blue-900">
<span>💡</span>
<span>Click the + buttons to add and connect steps!</span>
</div>
</Panel>
)}
</ReactFlow>
</div>
{/* Step type picker dialog */}
<Dialog open={!!addStepContext} onOpenChange={open => !open && setAddStepContext(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Step</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-4">
{STEP_TYPE_OPTIONS.map(option => {
const Icon = option.icon;
return (
<button
key={option.value}
onClick={() => handleCreateStep(option.value)}
className="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-neutral-200 hover:border-neutral-400 hover:bg-neutral-50 transition-all group"
style={{
borderColor: 'transparent',
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = option.color;
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'transparent';
}}
>
<div
className="w-12 h-12 rounded-lg flex items-center justify-center transition-transform group-hover:scale-110"
style={{
backgroundColor: `${option.color}15`,
}}
>
<Icon className="h-6 w-6" style={{color: option.color}} />
</div>
<span className="text-sm font-medium text-neutral-900">{option.label}</span>
</button>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddStepContext(null)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDeleteStep}
title="Delete Step"
description="Are you sure you want to delete this step?"
confirmText="Delete"
variant="destructive"
/>
</>
);
}
@@ -0,0 +1,529 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Background,
Controls,
type Edge,
Handle,
MarkerType,
type Node,
Panel,
Position,
ReactFlow,
useEdgesState,
useNodesState,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
import {Clock, GitBranch, LogOut, Mail, UserCog, Webhook} from 'lucide-react';
import {useEffect, useMemo} from 'react';
import dagre from 'dagre';
interface WorkflowVisualizerProps {
steps: (WorkflowStep & {
template?: {id: string; name: string} | null;
outgoingTransitions: Array<{
id: string;
toStepId: string;
condition: unknown;
priority: number;
}>;
incomingTransitions: Array<{
id: string;
fromStepId: string;
condition: unknown;
priority: number;
}>;
})[];
}
const STEP_TYPE_ICONS = {
TRIGGER: GitBranch,
SEND_EMAIL: Mail,
DELAY: Clock,
WAIT_FOR_EVENT: Clock,
CONDITION: GitBranch,
EXIT: LogOut,
WEBHOOK: Webhook,
UPDATE_CONTACT: UserCog,
};
const STEP_TYPE_COLORS = {
TRIGGER: '#9333ea', // purple-600
SEND_EMAIL: '#2563eb', // blue-600
DELAY: '#ea580c', // orange-600
WAIT_FOR_EVENT: '#ca8a04', // yellow-600
CONDITION: '#9333ea', // purple-600
EXIT: '#dc2626', // red-600
WEBHOOK: '#16a34a', // green-600
UPDATE_CONTACT: '#4f46e5', // indigo-600
};
const STEP_TYPE_BG = {
TRIGGER: '#f3e8ff', // purple-50
SEND_EMAIL: '#dbeafe', // blue-50
DELAY: '#ffedd5', // orange-50
WAIT_FOR_EVENT: '#fef3c7', // yellow-50
CONDITION: '#f3e8ff', // purple-50
EXIT: '#fee2e2', // red-50
WEBHOOK: '#dcfce7', // green-50
UPDATE_CONTACT: '#e0e7ff', // indigo-50
};
// Dagre layout function
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const nodeWidth = 250;
const nodeHeight = 100;
dagreGraph.setGraph({
rankdir: 'TB', // Top to Bottom
nodesep: 80, // Horizontal spacing
ranksep: 120, // Vertical spacing
marginx: 50,
marginy: 50,
});
nodes.forEach(node => {
dagreGraph.setNode(node.id, {width: nodeWidth, height: nodeHeight});
});
edges.forEach(edge => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = nodes.map(node => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
},
};
});
return {nodes: layoutedNodes, edges};
}
// Custom node component
function CustomNode({
data,
}: {
data: {
label: string;
type: string;
icon?: any;
color?: string;
bgColor?: string;
template?: {name: string};
config?: any;
};
}) {
const Icon = data.icon;
const color = data.color;
const bgColor = data.bgColor;
return (
<>
{/* Target Handle (top) - where edges come IN */}
<Handle
type="target"
position={Position.Top}
style={{
background: color,
width: 12,
height: 12,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/>
<div
className="px-5 py-4 rounded-xl border-2 bg-white shadow-lg hover:shadow-xl transition-all cursor-grab active:cursor-grabbing"
style={{
borderColor: color,
minWidth: '250px',
maxWidth: '250px',
}}
>
{/* Header */}
<div className="flex items-start gap-3 mb-3">
<div
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center"
style={{backgroundColor: bgColor}}
>
<Icon className="h-5 w-5" style={{color}} />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-neutral-900 text-sm leading-tight mb-1 break-words">{data.label}</h4>
<span
className="inline-flex items-center text-xs px-2 py-0.5 rounded-full font-medium"
style={{
backgroundColor: bgColor,
color,
}}
>
{data.type}
</span>
</div>
</div>
{/* Details */}
{data.template && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<span className="font-medium">📧</span>
<span className="truncate">{data.template.name}</span>
</div>
</div>
)}
{data.type === 'DELAY' && data.config?.amount && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<span className="font-medium"></span>
<span>
Wait {data.config.amount} {data.config.unit}
</span>
</div>
</div>
)}
{data.type === 'CONDITION' && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<div className="text-xs text-neutral-600">
<span className="font-medium">🔀</span> If/Else Branch
</div>
</div>
)}
{data.type === 'WAIT_FOR_EVENT' && data.config?.eventName && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<span className="font-medium"></span>
<span className="truncate">{data.config.eventName}</span>
</div>
</div>
)}
{data.type === 'WEBHOOK' && data.config?.url && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<span className="font-medium">🔗</span>
<span className="truncate">{data.config.method || 'POST'}</span>
</div>
</div>
)}
</div>
{/* Source Handle (bottom) - where edges go OUT */}
<Handle
type="source"
position={Position.Bottom}
style={{
background: color,
width: 12,
height: 12,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/>
</>
);
}
const nodeTypes = {
custom: CustomNode,
};
export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
// Convert workflow steps to React Flow nodes
const rawNodes: Node[] = useMemo(() => {
if (steps.length === 0) return [];
const nodes: Node[] = steps.map(step => {
const Icon = STEP_TYPE_ICONS[step.type as keyof typeof STEP_TYPE_ICONS] || GitBranch;
const color = STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] || '#6b7280';
const bgColor = STEP_TYPE_BG[step.type as keyof typeof STEP_TYPE_BG] || '#f3f4f6';
return {
id: step.id,
type: 'custom',
position: {x: 0, y: 0}, // Will be set by layout
data: {
label: step.name,
type: step.type,
icon: Icon,
color,
bgColor,
template: step.template,
config: step.config,
},
};
});
// Add END nodes for CONDITION steps with missing branches
steps.forEach(step => {
if (step.type === 'CONDITION') {
const hasYesBranch = step.outgoingTransitions?.some(
t =>
t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes',
);
const hasNoBranch = step.outgoingTransitions?.some(
t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no',
);
if (!hasYesBranch) {
nodes.push({
id: `${step.id}-yes-end`,
type: 'custom',
position: {x: 0, y: 0},
data: {
label: 'End Workflow',
type: 'END',
icon: LogOut,
color: '#9ca3af',
bgColor: '#f3f4f6',
template: null,
config: null,
},
});
}
if (!hasNoBranch) {
nodes.push({
id: `${step.id}-no-end`,
type: 'custom',
position: {x: 0, y: 0},
data: {
label: 'End Workflow',
type: 'END',
icon: LogOut,
color: '#9ca3af',
bgColor: '#f3f4f6',
template: null,
config: null,
},
});
}
}
});
return nodes;
}, [steps]);
// Convert transitions to React Flow edges
const rawEdges: Edge[] = useMemo(() => {
const edges: Edge[] = [];
steps.forEach(step => {
// Add edges for existing transitions
if (step.outgoingTransitions && step.outgoingTransitions.length > 0) {
step.outgoingTransitions.forEach(transition => {
const isConditional =
transition.condition && typeof transition.condition === 'object' && 'branch' in transition.condition;
const branch =
transition.condition && typeof transition.condition === 'object' && 'branch' in transition.condition
? transition.condition.branch
: undefined;
edges.push({
id: transition.id,
source: step.id,
target: transition.toStepId,
type: 'smoothstep',
animated: false,
label: isConditional ? (branch === 'yes' ? '✓ Yes' : '✗ No') : undefined,
labelStyle: {
fill: branch === 'yes' ? '#16a34a' : branch === 'no' ? '#dc2626' : '#64748b',
fontWeight: 600,
fontSize: 12,
},
labelBgStyle: {
fill: '#fff',
fillOpacity: 0.95,
},
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {
stroke: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
strokeWidth: 2,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
width: 20,
height: 20,
},
});
});
}
// Add edges to END nodes for CONDITION steps with missing branches
if (step.type === 'CONDITION') {
const hasYesBranch = step.outgoingTransitions?.some(
t =>
t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes',
);
const hasNoBranch = step.outgoingTransitions?.some(
t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no',
);
if (!hasYesBranch) {
edges.push({
id: `${step.id}-yes-end-edge`,
source: step.id,
target: `${step.id}-yes-end`,
type: 'smoothstep',
animated: false,
label: '✓ Yes',
labelStyle: {
fill: '#16a34a',
fontWeight: 600,
fontSize: 12,
},
labelBgStyle: {
fill: '#fff',
fillOpacity: 0.95,
},
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {
stroke: '#16a34a',
strokeWidth: 2,
strokeDasharray: '5,5',
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#16a34a',
width: 20,
height: 20,
},
});
}
if (!hasNoBranch) {
edges.push({
id: `${step.id}-no-end-edge`,
source: step.id,
target: `${step.id}-no-end`,
type: 'smoothstep',
animated: false,
label: '✗ No',
labelStyle: {
fill: '#dc2626',
fontWeight: 600,
fontSize: 12,
},
labelBgStyle: {
fill: '#fff',
fillOpacity: 0.95,
},
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {
stroke: '#dc2626',
strokeWidth: 2,
strokeDasharray: '5,5',
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#dc2626',
width: 20,
height: 20,
},
});
}
}
});
return edges;
}, [steps]);
// Apply dagre layout
const {nodes: layoutedNodes, edges: layoutedEdges} = useMemo(() => {
if (rawNodes.length === 0) return {nodes: [], edges: []};
return getLayoutedElements(rawNodes, rawEdges);
}, [rawNodes, rawEdges]);
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
// Update nodes/edges when layout changes
useEffect(() => {
setNodes(layoutedNodes);
}, [layoutedNodes, setNodes]);
useEffect(() => {
setEdges(layoutedEdges);
}, [layoutedEdges, setEdges]);
if (steps.length === 0) {
return (
<div className="bg-neutral-50 border-2 border-dashed border-neutral-300 rounded-lg p-12 text-center">
<GitBranch className="h-16 w-16 text-neutral-400 mx-auto mb-4" />
<p className="text-neutral-600 font-medium">No workflow steps yet</p>
<p className="text-sm text-neutral-500 mt-2">Add steps to your workflow to see the visualization</p>
</div>
);
}
return (
<div className="w-full h-[700px] bg-gradient-to-br from-neutral-50 to-neutral-100 rounded-lg border border-neutral-200 shadow-inner">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{
padding: 0.3,
minZoom: 0.5,
maxZoom: 1.2,
}}
minZoom={0.1}
maxZoom={2}
nodesDraggable={true}
nodesConnectable={false}
elementsSelectable={true}
defaultEdgeOptions={{
type: 'smoothstep',
}}
proOptions={{hideAttribution: true}}
>
<Background color="#e5e7eb" gap={16} size={1} />
<Controls
showInteractive={false}
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
/>
<Panel
position="top-left"
className="bg-white/95 backdrop-blur-sm px-4 py-2.5 rounded-lg shadow-lg border border-neutral-200"
>
<div className="flex items-center gap-3">
<GitBranch className="h-4 w-4 text-neutral-700" />
<div className="text-sm">
<span className="font-semibold text-neutral-900">{steps.length}</span>
<span className="text-neutral-600"> step{steps.length !== 1 ? 's' : ''}</span>
<span className="text-neutral-400 mx-2">·</span>
<span className="font-semibold text-neutral-900">{rawEdges.length}</span>
<span className="text-neutral-600"> transition{rawEdges.length !== 1 ? 's' : ''}</span>
</div>
</div>
</Panel>
{rawEdges.length === 0 && steps.length > 1 && (
<Panel
position="bottom-center"
className="bg-amber-50 border border-amber-200 px-4 py-2.5 rounded-lg shadow-lg"
>
<div className="flex items-center gap-2 text-sm text-amber-900">
<span></span>
<span>No transitions found. Connect your steps to see the flow.</span>
</div>
</Panel>
)}
</ReactFlow>
</div>
);
}