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@example.com'}
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>
);
}
+8
View File
@@ -0,0 +1,8 @@
// Environment configuration
// In development: Uses NEXT_PUBLIC_* env vars from .env file
// In production (Docker): Build-time values are replaced at container startup via sed
export const API_URI = process.env.NEXT_PUBLIC_API_URI || 'http://localhost:8080';
export const DASHBOARD_URI = process.env.NEXT_PUBLIC_DASHBOARD_URI || 'http://localhost:3000';
export const LANDING_URI = process.env.NEXT_PUBLIC_LANDING_URI || 'http://localhost:4000';
export const WIKI_URI = process.env.NEXT_PUBLIC_WIKI_URI || 'http://localhost:1000';
@@ -0,0 +1,74 @@
import type {Project} from '@plunk/db';
import {createContext, type ReactNode, useContext, useEffect, useState} from 'react';
import {useSWRConfig} from 'swr';
import {useProjects} from '../hooks/useProject';
interface ActiveProjectContextValue {
activeProject: Project | null;
setActiveProject: (project: Project) => void;
availableProjects: Project[];
isLoading: boolean;
}
const ActiveProjectContext = createContext<ActiveProjectContextValue | undefined>(undefined);
export function ActiveProjectProvider({children}: {children: ReactNode}) {
const {data: projects, isLoading} = useProjects();
const {mutate} = useSWRConfig();
// State is null until projects load, but localStorage is read synchronously by network.ts
// This ensures consistent behavior: either all calls use stored ID, or all fall back to projects[0]
const [activeProject, setActiveProjectState] = useState<Project | null>(null);
// Initialize active project from localStorage or use first project
useEffect(() => {
if (!projects || projects.length === 0) return;
const storedProjectId = localStorage.getItem('activeProjectId');
if (storedProjectId) {
// Find the stored project in available projects
const project = projects.find(p => p.id === storedProjectId);
if (project) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveProjectState(project);
} else if (projects[0]) {
// Stored project not found (user might have been removed), use first project
setActiveProjectState(projects[0]);
localStorage.setItem('activeProjectId', projects[0].id);
}
} else if (projects[0]) {
// No stored project, initialize with first one
setActiveProjectState(projects[0]);
localStorage.setItem('activeProjectId', projects[0].id);
}
}, [projects]);
const setActiveProject = (project: Project) => {
setActiveProjectState(project);
localStorage.setItem('activeProjectId', project.id);
// Invalidate all SWR cache to refetch data for new project
void mutate(() => true, undefined, {revalidate: true});
};
const value: ActiveProjectContextValue = {
activeProject,
setActiveProject,
availableProjects: projects ?? [],
isLoading,
};
return <ActiveProjectContext.Provider value={value}>{children}</ActiveProjectContext.Provider>;
}
export function useActiveProject() {
const context = useContext(ActiveProjectContext);
if (context === undefined) {
throw new Error('useActiveProject must be used within an ActiveProjectProvider');
}
return context;
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Shared date formatting and manipulation utilities
*/
/**
* Get the user's timezone
*/
export function getUserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
/**
* Format a Date to datetime-local input format (YYYY-MM-DDTHH:mm)
*/
export function formatDateTimeLocal(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
/**
* Schedule presets for quick date/time selection
*/
export const schedulePresets = {
/**
* In 1 hour from now
*/
inOneHour: (): string => {
const date = new Date();
date.setHours(date.getHours() + 1);
return formatDateTimeLocal(date);
},
/**
* In 3 hours from now
*/
inThreeHours: (): string => {
const date = new Date();
date.setHours(date.getHours() + 3);
return formatDateTimeLocal(date);
},
/**
* Tomorrow at 9 AM
*/
tomorrowAt9AM: (): string => {
const date = new Date();
date.setDate(date.getDate() + 1);
date.setHours(9, 0, 0, 0);
return formatDateTimeLocal(date);
},
/**
* Tomorrow at 2 PM
*/
tomorrowAt2PM: (): string => {
const date = new Date();
date.setDate(date.getDate() + 1);
date.setHours(14, 0, 0, 0);
return formatDateTimeLocal(date);
},
/**
* Next Monday at 9 AM
*/
nextMonday: (): string => {
const date = new Date();
const dayOfWeek = date.getDay();
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
date.setDate(date.getDate() + daysUntilMonday);
date.setHours(9, 0, 0, 0);
return formatDateTimeLocal(date);
},
/**
* In 1 week at 9 AM
*/
inOneWeek: (): string => {
const date = new Date();
date.setDate(date.getDate() + 7);
date.setHours(9, 0, 0, 0);
return formatDateTimeLocal(date);
},
};
/**
* Format a date for display (full date and time)
*/
export function formatFullDateTime(date: Date): string {
return date.toLocaleString(undefined, {
dateStyle: 'full',
timeStyle: 'short',
});
}
/**
* Format a date for display in UTC
*/
export function formatUTCDateTime(date: Date): string {
return date.toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'UTC',
});
}
+83
View File
@@ -0,0 +1,83 @@
import {useMemo} from 'react';
import useSWR from 'swr';
export interface ActivityStats {
totalEvents: number;
totalEmailsSent: number;
totalEmailsOpened: number;
totalEmailsClicked: number;
totalWorkflowsStarted: number;
openRate: number;
clickRate: number;
}
export interface TimeSeriesDataPoint {
date: string;
emails: number;
opens: number;
clicks: number;
bounces: number;
}
export interface AnalyticsData {
stats: ActivityStats | null;
timeSeries: TimeSeriesDataPoint[] | null;
isLoading: boolean;
error: Error | undefined;
}
interface UseAnalyticsOptions {
startDate?: string;
endDate?: string;
days?: number;
}
/**
* Hook to fetch analytics data including activity stats and time series data
*/
export function useAnalytics(options: UseAnalyticsOptions = {}): AnalyticsData {
const {days = 30} = options;
// Calculate date range - memoized to prevent infinite re-renders
// Only recalculate when days or explicit dates change
/* eslint-disable react-hooks/purity */
const {startDate, endDate} = useMemo(() => {
const end = options.endDate || new Date().toISOString();
const now = Date.now();
const start = options.startDate || new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
return {startDate: start, endDate: end};
}, [days, options.startDate, options.endDate]);
/* eslint-enable react-hooks/purity */
// Fetch activity stats
const {
data: stats,
error: statsError,
isLoading: statsLoading,
} = useSWR<ActivityStats>(`/activity/stats?startDate=${startDate}&endDate=${endDate}`, {
revalidateOnFocus: false,
refreshInterval: 300000, // Refresh every 5 minutes
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
});
// Fetch time series data (if endpoint exists)
const {
data: timeSeries,
error: timeSeriesError,
isLoading: timeSeriesLoading,
} = useSWR<TimeSeriesDataPoint[]>(`/analytics/timeseries?startDate=${startDate}&endDate=${endDate}`, {
revalidateOnFocus: false,
refreshInterval: 300000, // Refresh every 5 minutes
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
shouldRetryOnError: false, // Don't error out if endpoint doesn't exist yet
});
return {
stats: stats || null,
timeSeries: timeSeries || null,
isLoading: statsLoading || timeSeriesLoading,
error: statsError || timeSeriesError,
};
}
@@ -0,0 +1,49 @@
import useSWR from 'swr';
export interface BillingPeriod {
start: string;
end: string;
}
export interface UsageRecord {
period: BillingPeriod;
totalUsage: number;
}
export interface UpcomingInvoice {
amountDue: number;
currency: string;
periodStart: string;
periodEnd: string;
subtotal: number;
total: number;
}
export interface BillingConsumptionData {
period: BillingPeriod;
usage: {
total: number;
records: UsageRecord[];
};
upcomingInvoice: UpcomingInvoice | null;
}
/**
* Hook to fetch current month billing consumption from Stripe
*/
export function useBillingConsumption(projectId: string | undefined, hasSubscription: boolean) {
const {data, error, mutate, isLoading} = useSWR<BillingConsumptionData>(
projectId && hasSubscription ? `/users/@me/projects/${projectId}/billing-consumption` : null,
{
revalidateOnFocus: false,
refreshInterval: 60000, // Refresh every minute
},
);
return {
consumptionData: data,
error,
isLoading,
mutate,
};
}
@@ -0,0 +1,53 @@
import useSWR from 'swr';
export interface Invoice {
id: string;
number: string | null;
status: string;
amountDue: number;
amountPaid: number;
currency: string;
created: string;
periodStart: string | null;
periodEnd: string | null;
hostedInvoiceUrl: string | null;
invoicePdf: string | null;
subtotal: number;
total: number;
paid: boolean;
}
export interface UnpaidInvoice {
id: string;
number: string | null;
amountDue: number;
currency: string;
dueDate: string | null;
hostedInvoiceUrl: string | null;
}
export interface BillingInvoicesData {
invoices: Invoice[];
hasUnpaidInvoices: boolean;
unpaidInvoices: UnpaidInvoice[];
}
/**
* Hook to fetch billing invoices from Stripe
*/
export function useBillingInvoices(projectId: string | undefined, hasSubscription: boolean) {
const {data, error, mutate, isLoading} = useSWR<BillingInvoicesData>(
projectId && hasSubscription ? `/users/@me/projects/${projectId}/billing-invoices` : null,
{
revalidateOnFocus: true,
refreshInterval: 300000, // Refresh every 5 minutes
},
);
return {
invoicesData: data,
error,
isLoading,
mutate,
};
}
@@ -0,0 +1,35 @@
import useSWR from 'swr';
export interface CategoryLimit {
usage: number;
limit: number | null;
percentage: number;
isWarning: boolean;
isBlocked: boolean;
}
export interface BillingLimitsData {
workflows: CategoryLimit;
campaigns: CategoryLimit;
transactional: CategoryLimit;
}
/**
* Hook to fetch billing limits for a project
*/
export function useBillingLimits(projectId: string | undefined, hasSubscription: boolean) {
const {data, error, mutate, isLoading} = useSWR<BillingLimitsData>(
projectId && hasSubscription ? `/users/@me/projects/${projectId}/billing-limits` : null,
{
revalidateOnFocus: false,
refreshInterval: 30000, // Refresh every 30 seconds to keep usage updated
},
);
return {
limitsData: data,
error,
isLoading,
mutate,
};
}
@@ -0,0 +1,36 @@
import {useBeforeUnload} from '@plunk/ui';
import {useRouter} from 'next/router';
import {useEffect} from 'react';
/**
* Custom hook to handle unsaved changes warning
* Warns user before navigating away (browser or Next.js navigation) when there are unsaved changes
*/
export function useChangeTracking(hasChanges: boolean, enabled: boolean = true) {
const router = useRouter();
// Warn before leaving page with unsaved changes (browser navigation)
useBeforeUnload(enabled && hasChanges);
// Warn before Next.js route changes
useEffect(() => {
if (!enabled || !hasChanges) return;
const handleRouteChange = (url: string) => {
// Only show confirmation if navigating to a different page
if (router.asPath !== url) {
const confirmed = window.confirm('You have unsaved changes. Are you sure you want to leave?');
if (!confirmed) {
router.events.emit('routeChangeError');
throw 'Route change aborted by user';
}
}
};
router.events.on('routeChangeStart', handleRouteChange);
return () => {
router.events.off('routeChangeStart', handleRouteChange);
};
}, [hasChanges, enabled, router]);
}
+32
View File
@@ -0,0 +1,32 @@
import useSWR from 'swr';
export interface ConfigResponse {
environment: string;
urls: {
api: string;
dashboard: string;
landing: string;
wiki: string | null;
};
features: {
billing: {enabled: boolean};
storage: {s3Enabled: boolean};
authProviders: {github: boolean; google: boolean};
email: {trackingToggleEnabled: boolean};
smtp: {
enabled: boolean;
domain: string | null;
ports: {secure: number; submission: number} | null;
};
};
}
/**
* Fetch global instance configuration and feature flags.
*
* - `data` is undefined while loading, then a ConfigResponse on success.
* - Errors do not retry by default.
*/
export function useConfig() {
return useSWR<ConfigResponse>('/config', {shouldRetryOnError: false});
}
+67
View File
@@ -0,0 +1,67 @@
import useSWR from 'swr';
export interface Contact {
id: string;
email: string;
data?: Record<string, unknown>;
}
export interface ContactsResponse {
contacts: Contact[];
total: number;
}
interface UseContactsOptions {
limit?: number;
search?: string;
}
/**
* Hook to fetch contacts with optional search
*/
export function useContacts(options: UseContactsOptions = {}) {
const {limit = 50, search} = options;
const params = new URLSearchParams();
params.set('limit', limit.toString());
if (search) {
params.set('search', search);
}
const {data, error, mutate, isLoading} = useSWR<ContactsResponse>(
`/contacts?${params.toString()}`,
{
revalidateOnFocus: false,
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
},
);
return {
contacts: data?.contacts || [],
total: data?.total || 0,
error,
isLoading,
mutate,
};
}
/**
* Hook to fetch available contact fields for variable usage
*/
export function useContactFields() {
const {data, error, mutate, isLoading} = useSWR<{fields: string[]}>(
'/contacts/fields',
{
revalidateOnFocus: false,
// Cache fields for longer since they don't change often
dedupingInterval: 60000, // 1 minute
},
);
return {
fields: data?.fields || [],
error,
isLoading,
mutate,
};
}
@@ -0,0 +1,62 @@
import useSWR from 'swr';
export interface ActivityStats {
totalEvents: number;
totalEmailsSent: number;
totalEmailsOpened: number;
totalEmailsClicked: number;
totalWorkflowsStarted: number;
openRate: number;
clickRate: number;
}
export interface ContactsResponse {
contacts: unknown[];
total: number;
cursor?: string;
hasMore: boolean;
}
export interface CampaignsResponse {
campaigns: unknown[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface DashboardStats {
totalContacts: number;
totalEmailsSent: number;
totalCampaigns: number;
openRate: number;
isLoading: boolean;
error: Error | undefined;
}
/**
* Hook to fetch dashboard statistics
* Fetches activity stats, contact count, and campaign count in parallel
*/
export function useDashboardStats(): DashboardStats {
// Fetch activity stats (last 30 days by default)
const {data: activityStats, error: activityError} = useSWR<ActivityStats>('/activity/stats');
// Fetch contacts (only need the total count)
const {data: contactsData, error: contactsError} = useSWR<ContactsResponse>('/contacts?limit=1');
// Fetch campaigns (only need the total count)
const {data: campaignsData, error: campaignsError} = useSWR<CampaignsResponse>('/campaigns?pageSize=1');
const isLoading = !activityStats && !contactsData && !campaignsData;
const error = activityError || contactsError || campaignsError;
return {
totalContacts: contactsData?.total ?? 0,
totalEmailsSent: activityStats?.totalEmailsSent ?? 0,
totalCampaigns: campaignsData?.total ?? 0,
openRate: activityStats?.openRate ?? 0,
isLoading,
error,
};
}
+71
View File
@@ -0,0 +1,71 @@
import useSWR from 'swr';
import {DomainSchemas} from '@plunk/shared';
import {network} from '../network';
export interface Domain {
id: string;
domain: string;
verified: boolean;
dkimTokens: string[] | null;
projectId: string;
createdAt: string;
updatedAt: string;
}
export interface DomainVerificationStatus {
domain: string;
tokens: string[];
status: string;
verified: boolean;
}
/**
* Hook to fetch domains for a project
*/
export function useDomains(projectId: string | undefined) {
const {data, error, mutate, isLoading} = useSWR<Domain[]>(projectId ? `/domains/project/${projectId}` : null);
return {
domains: data,
error,
isLoading,
mutate,
};
}
/**
* Hook to add a domain
*/
export function useAddDomain() {
const addDomain = async (projectId: string, domain: string) => {
return network.fetch<Domain, typeof DomainSchemas.create>('POST', '/domains', {
projectId,
domain,
});
};
return {addDomain};
}
/**
* Hook to check domain verification status
*/
export function useCheckDomainVerification() {
const checkVerification = async (domainId: string) => {
return network.fetch<DomainVerificationStatus>('GET', `/domains/${domainId}/verify`);
};
return {checkVerification};
}
/**
* Hook to remove a domain
*/
export function useRemoveDomain() {
const removeDomain = async (domainId: string) => {
return network.fetch<{success: boolean}>('DELETE', `/domains/${domainId}`);
};
return {removeDomain};
}
+9
View File
@@ -0,0 +1,9 @@
import type {Project} from '@plunk/db';
import useSWR from 'swr';
/**
* Fetch all projects for the current user
*/
export function useProjects() {
return useSWR<Project[]>('/users/@me/projects', {shouldRetryOnError: false});
}
@@ -0,0 +1,29 @@
import useSWR from 'swr';
export interface ProjectSetupState {
hasSubscription: boolean;
hasVerifiedDomain: boolean;
contactCount: number;
lastCampaignSentAt: string | null;
hasEnabledWorkflow: boolean;
}
export interface SetupStateResponse {
success: boolean;
data: ProjectSetupState;
}
/**
* Hook to fetch project setup state for dashboard quick start
*/
export function useProjectSetupState(projectId: string | undefined) {
const {data, error, isLoading} = useSWR<SetupStateResponse>(
projectId ? `/projects/${projectId}/setup-state` : null,
);
return {
setupState: data?.data,
isLoading,
error,
};
}
+9
View File
@@ -0,0 +1,9 @@
import useSWR from 'swr';
/**
* Fetch the current user. undefined means loading, null means logged out
*
*/
export function useUser() {
return useSWR('/users/@me', {shouldRetryOnError: false});
}
+92
View File
@@ -0,0 +1,92 @@
import type {infer as ZodInfer, ZodSchema} from 'zod';
import {API_URI} from './constants';
interface Json {
[x: string]: string | number | boolean | Date | Json | JsonArray;
}
type JsonArray = (string | number | boolean | Date | Json | JsonArray)[];
interface TypedSchema extends ZodSchema {
_type: unknown;
}
interface ApiResponse {
message?: string;
[key: string]: unknown;
}
export class network {
public static async fetch<T, Schema extends TypedSchema | void = void>(
method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'PATCH',
path: string,
body?: Schema extends TypedSchema ? ZodInfer<Schema> : never,
): Promise<T> {
const url = path.startsWith('http') ? path : API_URI + path;
// Get active project ID from localStorage
const activeProjectId = typeof window !== 'undefined' ? localStorage.getItem('activeProjectId') : null;
const headers: Record<string, string> = {};
if (body) {
headers['Content-Type'] = 'application/json';
}
if (activeProjectId) {
headers['X-Project-Id'] = activeProjectId;
}
const response = await fetch(url, {
method,
body: body && JSON.stringify(body),
headers,
credentials: 'include',
});
// Handle 204 No Content responses (no body to parse)
if (response.status === 204) {
return {} as T;
}
const res = (await response.json()) as ApiResponse;
if (response.status >= 400) {
throw new Error(res.message ?? 'Something went wrong!');
}
return res as T;
}
/**
* Upload file using FormData (multipart/form-data)
* Used for file uploads where Content-Type must be set by browser
*/
public static async upload<T>(method: 'POST' | 'PUT' | 'PATCH', path: string, formData: FormData): Promise<T> {
const url = path.startsWith('http') ? path : API_URI + path;
// Get active project ID from localStorage
const activeProjectId = typeof window !== 'undefined' ? localStorage.getItem('activeProjectId') : null;
const headers: Record<string, string> = {};
// DO NOT set Content-Type - browser will set it automatically with boundary
if (activeProjectId) {
headers['X-Project-Id'] = activeProjectId;
}
const response = await fetch(url, {
method,
body: formData,
headers,
credentials: 'include',
});
const res = (await response.json()) as ApiResponse;
if (response.status >= 400) {
throw new Error(res.message ?? 'Something went wrong!');
}
return res as T;
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Shared validation utilities for email-related forms
*/
export interface EmailFormValidation {
name?: string;
subject?: string;
body?: string;
from?: string;
segmentId?: string;
}
export class EmailFormValidator {
/**
* Validate email form fields (campaigns, templates)
*/
static validate(fields: EmailFormValidation, options: {requireSegment?: boolean} = {}): string | null {
if (fields.name !== undefined && !fields.name.trim()) {
return 'Name is required';
}
if (fields.subject !== undefined && !fields.subject.trim()) {
return 'Email subject is required';
}
if (fields.body !== undefined && !fields.body.trim()) {
return 'Email body is required';
}
if (fields.from !== undefined && !fields.from.trim()) {
return 'From address is required';
}
if (options.requireSegment && fields.segmentId !== undefined && !fields.segmentId) {
return 'Please select a segment';
}
return null;
}
/**
* Validate campaign-specific fields
*/
static validateCampaign(fields: EmailFormValidation & {segmentId?: string}, audienceType: string): string | null {
const baseError = this.validate(fields);
if (baseError) return baseError;
if (audienceType === 'SEGMENT' && !fields.segmentId) {
return 'Please select a segment';
}
return null;
}
/**
* Validate template-specific fields
*/
static validateTemplate(fields: EmailFormValidation): string | null {
return this.validate(fields);
}
}
+161
View File
@@ -0,0 +1,161 @@
import '../styles/globals.css';
import type {AppProps} from 'next/app';
import {useRouter} from 'next/router';
import React, {useEffect} from 'react';
import {Toaster} from 'sonner';
import {SWRConfig} from 'swr';
import {DefaultSeo} from 'next-seo';
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
import {useProjects} from '../lib/hooks/useProject';
import {useUser} from '../lib/hooks/useUser';
import {network} from '../lib/network';
// Routes that don't require authentication
const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset'];
// Routes that don't require a project
const NO_PROJECT_ROUTES = ['/projects/create'];
function App({Component, pageProps}: AppProps) {
return (
<>
<Component {...pageProps} />
</>
);
}
function AuthGuard({children}: {children: React.ReactNode}) {
const {data: user, isLoading} = useUser();
const router = useRouter();
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
useEffect(() => {
// If not loading, no user, and trying to access a protected route, redirect to login
if (!isLoading && !user && !isPublicRoute) {
void router.push('/auth/login');
}
// If user is logged in and trying to access login/signup, redirect to home
if (!isLoading && user && (router.pathname === '/auth/login' || router.pathname === '/auth/signup')) {
void router.push('/');
}
}, [user, isLoading, router, isPublicRoute]);
// Show loading state while checking authentication (only for protected routes)
if (isLoading && !isPublicRoute) {
return (
<div className="h-screen flex items-center justify-center">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading...</p>
</div>
</div>
);
}
// Don't render protected content if redirecting
if (!user && !isPublicRoute) {
return null;
}
return <>{children}</>;
}
function ProjectGuard({children}: {children: React.ReactNode}) {
const {data: projects, isLoading} = useProjects();
const router = useRouter();
const isNoProjectRoute = NO_PROJECT_ROUTES.includes(router.pathname);
useEffect(() => {
// If not loading, user has no projects, and not already on project creation page
if (!isLoading && projects && projects.length === 0 && !isNoProjectRoute) {
void router.push('/projects/create');
}
}, [projects, isLoading, router, isNoProjectRoute]);
// Show loading state while checking projects (only for routes that need a project)
if (isLoading && !isNoProjectRoute) {
return (
<div className="h-screen flex items-center justify-center">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading...</p>
</div>
</div>
);
}
// Don't render protected content if redirecting
if (!isLoading && projects && projects.length === 0 && !isNoProjectRoute) {
return null;
}
return <>{children}</>;
}
/**
* Main app root component that houses all components
* @param props Default nextjs props
*/
export default function WithProviders(props: AppProps) {
return (
<SWRConfig
value={{
fetcher: (url: string) => network.fetch('GET', url),
shouldRetryOnError: false,
}}
>
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
<ActiveProjectProvider>
<Root {...props} />
</ActiveProjectProvider>
</SWRConfig>
);
}
function Root(props: AppProps) {
const router = useRouter();
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
return (
<>
<Toaster position={'top-right'} />
<div>
<AuthGuard>
{isPublicRoute ? (
<App {...props} />
) : (
<ProjectGuard>
<App {...props} />
</ProjectGuard>
)}
</AuthGuard>
</div>
</>
);
}
+60
View File
@@ -0,0 +1,60 @@
import {Head, Html, Main, NextScript} from 'next/document';
function Document({locale}: {locale: string}) {
return (
<Html lang={locale}>
<Head>
{/* Primary Meta Tags */}
<meta name="title" content="Plunk | Email Platform Dashboard" />
<meta
name="description"
content="Manage your email campaigns, contacts, and analytics with Plunk - the open-source email platform."
/>
{/* Open Graph / Facebook */}
<meta property="og:type" content="website" />
<meta property="og:title" content="Plunk | Email Platform Dashboard" />
<meta
property="og:description"
content="Manage your email campaigns, contacts, and analytics with Plunk - the open-source email platform."
/>
<meta property="og:image" content="/assets/card.png" />
{/* Twitter */}
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="Plunk | Email Platform Dashboard" />
<meta
property="twitter:description"
content="Manage your email campaigns, contacts, and analytics with Plunk - the open-source email platform."
/>
<meta property="twitter:image" content="/assets/card.png" />
{/* Fonts */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
{/* Favicon */}
<link rel="icon" type="image/png" href="/favicon/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon/favicon-16x16.png" sizes="16x16" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="apple-mobile-web-app-title" content="Plunk" />
<meta name="application-name" content="Plunk" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
<link rel="manifest" href="/favicon/site.webmanifest" />
</Head>
<body className="antialiased cursor-default scroll-smooth text-neutral-800">
<Main />
<NextScript />
</body>
</Html>
);
}
export default Document;
+168
View File
@@ -0,0 +1,168 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout';
import {ActivityFeed} from '../../components/ActivityFeed';
import {Eye, MousePointerClick, Send, Zap} from 'lucide-react';
import {NextSeo} from 'next-seo';
import {useState} from 'react';
import useSWR from 'swr';
interface ActivityStats {
totalEvents: number;
totalEmailsSent: number;
totalEmailsOpened: number;
totalEmailsClicked: number;
totalWorkflowsStarted: number;
openRate: number;
clickRate: number;
}
export default function ActivityPage() {
const [typeFilter, setTypeFilter] = useState<string>('ALL');
const [dateRange, setDateRange] = useState<string>('30');
// Fetch activity stats
const {data: stats} = useSWR<ActivityStats>(`/activity/stats`, {
revalidateOnFocus: false,
});
const statsCards = [
{
name: 'Events Triggered',
value: stats?.totalEvents?.toLocaleString() || '0',
icon: Zap,
description: 'Last 30 days',
color: 'text-blue-600',
bgColor: 'bg-blue-100',
},
{
name: 'Emails Sent',
value: stats?.totalEmailsSent?.toLocaleString() || '0',
icon: Send,
description: 'Last 30 days',
color: 'text-green-600',
bgColor: 'bg-green-100',
},
{
name: 'Open Rate',
value: stats?.openRate ? `${stats.openRate.toFixed(1)}%` : '0%',
icon: Eye,
description: `${stats?.totalEmailsOpened?.toLocaleString() || '0'} opens`,
color: 'text-purple-600',
bgColor: 'bg-purple-100',
},
{
name: 'Click Rate',
value: stats?.clickRate ? `${stats.clickRate.toFixed(1)}%` : '0%',
icon: MousePointerClick,
description: `${stats?.totalEmailsClicked?.toLocaleString() || '0'} clicks`,
color: 'text-orange-600',
bgColor: 'bg-orange-100',
},
];
return (
<>
<NextSeo title="Activity" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-neutral-900">Activity</h1>
<p className="text-neutral-500 mt-2">
Real-time overview of events, emails, and workflow executions across your project.
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statsCards.map(stat => {
const Icon = stat.icon;
return (
<Card key={stat.name}>
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription>
<div className={`h-10 w-10 rounded-lg ${stat.bgColor} flex items-center justify-center`}>
<Icon className={`h-5 w-5 ${stat.color}`} />
</div>
</div>
<CardTitle className="text-2xl">{stat.value}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-neutral-500">{stat.description}</p>
</CardContent>
</Card>
);
})}
</div>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger>
<SelectValue placeholder="All Activity Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Activity Types</SelectItem>
<SelectItem value="event.triggered">Events</SelectItem>
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced">
Emails
</SelectItem>
<SelectItem value="email.sent">Emails Sent</SelectItem>
<SelectItem value="email.opened">Emails Opened</SelectItem>
<SelectItem value="email.clicked">Emails Clicked</SelectItem>
<SelectItem value="workflow.started,workflow.completed">Workflows</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger>
<SelectValue placeholder="Last 30 days" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Last 24 hours</SelectItem>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Activity Feed */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Live feed of all activities happening across your project. Updates automatically as new activities occur.
</CardDescription>
</CardHeader>
<CardContent>
<ActivityFeed
typeFilter={typeFilter === 'ALL' ? undefined : typeFilter}
dateRangeDays={parseInt(dateRange)}
/>
</CardContent>
</Card>
</div>
</DashboardLayout>
</>
);
}
+388
View File
@@ -0,0 +1,388 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout';
import {useAnalytics} from '../../lib/hooks/useAnalytics';
import {AlertCircle, BarChart3, CheckCircle2, Eye, Mail, MousePointerClick, Send, TrendingUp, Zap} from 'lucide-react';
import {NextSeo} from 'next-seo';
import {useMemo, useState} from 'react';
import {Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis} from 'recharts';
// Chart configurations
const volumeChartConfig = {
emails: {
label: 'Emails Sent',
color: 'hsl(var(--chart-1))',
},
opens: {
label: 'Opens',
color: 'hsl(var(--chart-2))',
},
clicks: {
label: 'Clicks',
color: 'hsl(var(--chart-3))',
},
} satisfies ChartConfig;
const engagementChartConfig = {
openRate: {
label: 'Open Rate',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
export default function AnalyticsPage() {
const [dateRange, setDateRange] = useState<string>('30');
const days = parseInt(dateRange);
const {stats, timeSeries, isLoading, error} = useAnalytics({days});
// Calculate engagement rate
const engagementRate = useMemo(() => {
if (!stats?.totalEmailsSent) return 0;
const engaged = stats.totalEmailsOpened + stats.totalEmailsClicked;
return (engaged / stats.totalEmailsSent) * 100;
}, [stats]);
// Process time series data for charts
const chartData = useMemo(() => {
if (timeSeries && timeSeries.length > 0) {
return timeSeries.map(point => ({
date: new Date(point.date).toLocaleDateString('en-US', {month: 'short', day: 'numeric'}),
emails: point.emails,
opens: point.opens,
clicks: point.clicks,
openRate: point.emails > 0 ? Number(((point.opens / point.emails) * 100).toFixed(1)) : 0,
}));
}
// Return empty array if no data
return [];
}, [timeSeries]);
// Check if we have any real data
const hasData = useMemo(() => {
return chartData.some(point => point.emails > 0 || point.opens > 0 || point.clicks > 0);
}, [chartData]);
// Calculate cumulative totals
const cumulativeTotals = useMemo(() => {
return chartData.reduce(
(acc, day) => ({
emails: acc.emails + (day.emails || 0),
opens: acc.opens + (day.opens || 0),
clicks: acc.clicks + (day.clicks || 0),
}),
{emails: 0, opens: 0, clicks: 0},
);
}, [chartData]);
const statsCards = [
{
name: 'Total Emails',
value: stats?.totalEmailsSent?.toLocaleString() || cumulativeTotals.emails.toLocaleString(),
icon: Send,
description: `Last ${days} days`,
color: 'text-blue-600',
bgColor: 'bg-blue-100',
trend: stats?.totalEmailsSent ? (stats.totalEmailsSent > 0 ? 'positive' : 'neutral') : 'neutral',
},
{
name: 'Open Rate',
value: stats?.openRate ? `${stats.openRate.toFixed(1)}%` : '0%',
icon: Eye,
description: `${stats?.totalEmailsOpened?.toLocaleString() || cumulativeTotals.opens.toLocaleString()} opens`,
color: 'text-green-600',
bgColor: 'bg-green-100',
trend: stats?.openRate && stats.openRate > 20 ? 'positive' : 'neutral',
},
{
name: 'Click Rate',
value: stats?.clickRate ? `${stats.clickRate.toFixed(1)}%` : '0%',
icon: MousePointerClick,
description: `${stats?.totalEmailsClicked?.toLocaleString() || cumulativeTotals.clicks.toLocaleString()} clicks`,
color: 'text-purple-600',
bgColor: 'bg-purple-100',
trend: stats?.clickRate && stats.clickRate > 3 ? 'positive' : 'neutral',
},
{
name: 'Engagement',
value: `${engagementRate.toFixed(1)}%`,
icon: TrendingUp,
description: 'Opens + Clicks',
color: 'text-orange-600',
bgColor: 'bg-orange-100',
trend: engagementRate > 20 ? 'positive' : 'neutral',
},
];
return (
<>
<NextSeo title="Analytics" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-neutral-900">Analytics</h1>
<p className="text-neutral-500 mt-2">
Comprehensive insights into your email performance, engagement metrics, and delivery statistics.
</p>
</div>
<div className="flex gap-3">
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Error State */}
{error && (
<Card className="border-red-200 bg-red-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3 text-red-700">
<AlertCircle className="h-5 w-5" />
<span>Failed to load analytics data. Please try again.</span>
</div>
</CardContent>
</Card>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statsCards.map(stat => {
const Icon = stat.icon;
return (
<Card key={stat.name}>
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription>
<div className={`h-10 w-10 rounded-lg ${stat.bgColor} flex items-center justify-center`}>
<Icon className={`h-5 w-5 ${stat.color}`} />
</div>
</div>
<CardTitle className="text-2xl">{isLoading ? '-' : stat.value}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-neutral-500">{stat.description}</p>
</CardContent>
</Card>
);
})}
</div>
{/* Email Volume Chart */}
<Card>
<CardHeader>
<CardTitle>Email Volume Trends</CardTitle>
<CardDescription>Daily email sends, opens, and clicks over the selected period</CardDescription>
</CardHeader>
<CardContent>
{!hasData ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="text-center">
<Mail className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No email data yet</h3>
<p className="mt-2 text-sm text-muted-foreground">Send your first email to see analytics here</p>
</div>
</div>
) : (
<ChartContainer config={volumeChartConfig} className="h-[400px] w-full">
<AreaChart data={chartData} margin={{top: 10, right: 10, left: 10, bottom: 0}}>
<defs>
<linearGradient id="fillEmails" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-emails)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-emails)" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="fillOpens" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-opens)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-opens)" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="fillClicks" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-clicks)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-clicks)" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} />
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip content={<ChartTooltipContent className="w-[150px]" />} />
<Area
dataKey="emails"
type="monotone"
fill="url(#fillEmails)"
stroke="var(--color-emails)"
stackId="a"
/>
<Area
dataKey="opens"
type="monotone"
fill="url(#fillOpens)"
stroke="var(--color-opens)"
stackId="a"
/>
<Area
dataKey="clicks"
type="monotone"
fill="url(#fillClicks)"
stroke="var(--color-clicks)"
stackId="a"
/>
<ChartLegend content={ChartLegendContent as any} />
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
{/* Engagement Rate Chart */}
<Card>
<CardHeader>
<CardTitle>Engagement Rate Trends</CardTitle>
<CardDescription>Open rate percentage over time</CardDescription>
</CardHeader>
<CardContent>
{!hasData ? (
<div className="flex h-[300px] w-full items-center justify-center">
<div className="text-center">
<Eye className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No engagement data</h3>
<p className="mt-2 text-sm text-muted-foreground">
Engagement metrics will appear once emails are opened
</p>
</div>
</div>
) : (
<ChartContainer config={engagementChartConfig} className="h-[300px] w-full">
<LineChart data={chartData} margin={{top: 10, right: 10, left: 10, bottom: 0}}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} />
<YAxis
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={value => `${value}%`}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[150px]" hideLabel />} cursor={false} />
<Line
dataKey="openRate"
type="monotone"
stroke="var(--color-openRate)"
strokeWidth={2}
dot={{
fill: 'var(--color-openRate)',
r: 4,
}}
activeDot={{
r: 6,
}}
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</Card>
{/* Key Insights */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Performance Insights</CardTitle>
<CardDescription>Key metrics and recommendations</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-green-100 flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="h-4 w-4 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Open Rate</p>
<p className="text-sm text-neutral-500">
{stats?.openRate && stats.openRate > 20
? 'Your open rate is above industry average!'
: 'Consider improving subject lines to increase open rates.'}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
<BarChart3 className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Click Rate</p>
<p className="text-sm text-neutral-500">
{stats?.clickRate && stats.clickRate > 3
? 'Great click-through performance!'
: 'Add more compelling calls-to-action to boost clicks.'}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-purple-100 flex items-center justify-center flex-shrink-0">
<Zap className="h-4 w-4 text-purple-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Engagement</p>
<p className="text-sm text-neutral-500">
{stats?.totalWorkflowsStarted
? `${stats.totalWorkflowsStarted.toLocaleString()} workflows started`
: 'Set up workflows to automate your email campaigns.'}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Event Activity</CardTitle>
<CardDescription>Custom events and triggers</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-2xl font-bold text-neutral-900">{stats?.totalEvents?.toLocaleString() || '0'}</p>
<p className="text-sm text-neutral-500">Total Events</p>
</div>
<div className="h-12 w-12 rounded-lg bg-orange-100 flex items-center justify-center">
<Zap className="h-6 w-6 text-orange-600" />
</div>
</div>
<div className="pt-4 border-t">
<p className="text-sm text-neutral-500">
Events triggered by your contacts over the last {days} days. These can trigger workflows and
automations.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</DashboardLayout>
</>
);
}
+334
View File
@@ -0,0 +1,334 @@
import {zodResolver} from '@hookform/resolvers/zod';
import {AuthenticationSchemas} from '@plunk/shared';
import {
Button,
Card,
CardContent,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useRouter} from 'next/router';
import React, {useState} from 'react';
import {useForm} from 'react-hook-form';
import type {z} from 'zod';
import {API_URI} from '../../lib/constants';
import {useProjects} from '../../lib/hooks/useProject';
import {useUser} from '../../lib/hooks/useUser';
import {useConfig} from '../../lib/hooks/useConfig';
import {network} from '../../lib/network';
export default function Login() {
const {mutate: userMutate} = useUser();
const {mutate: projectsMutate} = useProjects();
const router = useRouter();
const form = useForm<z.infer<typeof AuthenticationSchemas.login>>({
resolver: zodResolver(AuthenticationSchemas.login),
defaultValues: {
email: '',
password: '',
},
});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showReset, setShowReset] = useState(false);
const [resetEmail, setResetEmail] = useState('');
const [resetStatus, setResetStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [resetError, setResetError] = useState<string | null>(null);
const {data: config} = useConfig();
const oauthConfig = {
github: config?.features.authProviders.github ?? false,
google: config?.features.authProviders.google ?? false,
};
async function onSubmit(values: z.infer<typeof AuthenticationSchemas.login>) {
try {
const response = await network.fetch<
{
success: boolean;
data: {id: string; email: string};
},
typeof AuthenticationSchemas.login
>('POST', '/auth/login', values);
if (!response.success) {
setErrorMessage('Email or password is not correct');
} else {
setErrorMessage(null);
await userMutate();
await projectsMutate();
await router.push('/');
}
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong');
}
}
async function handleResetPassword(e: React.FormEvent) {
e.preventDefault();
setResetStatus('loading');
setResetError(null);
try {
const response = await network.fetch<{success: boolean}, typeof AuthenticationSchemas.resetPassword>(
'POST',
'/users/reset-password',
{
email: resetEmail,
},
);
if (response.success) {
setResetStatus('success');
} else {
setResetStatus('error');
setResetError('Something went wrong.');
}
} catch {
setResetStatus('error');
setResetError('Something went wrong.');
}
}
return (
<>
<NextSeo title="Login" />
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
<Card>
<CardContent className="p-0">
<Form {...form}>
<form
onSubmit={e => {
e.preventDefault();
void form.handleSubmit(onSubmit)(e);
}}
className="p-8"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Welcome back</h1>
<p className="text-neutral-600">Enter your credentials to access your account</p>
</div>
{(oauthConfig.github || oauthConfig.google) && (
<>
<div className="grid gap-2">
{oauthConfig.google && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/google/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
)}
{oauthConfig.github && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/github/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
</Button>
)}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
</div>
</div>
</>
)}
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="hello@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password" type={'password'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className="text-xs underline mt-1 text-left text-neutral-500"
onClick={() => setShowReset(true)}
>
Forgot password?
</button>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)}
</AnimatePresence>
<motion.div layout>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Login'
)}
</Button>
</motion.div>
<div className="text-center text-sm text-neutral-500">
Don&apos;t have an account?{' '}
<Link href="/auth/signup" className="underline underline-offset-4 hover:text-neutral-900">
Sign up
</Link>
</div>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
<Dialog
open={showReset}
onOpenChange={open => {
setShowReset(open);
if (!open) {
setResetStatus('idle');
setResetEmail('');
setResetError(null);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Reset your password</DialogTitle>
<DialogDescription>Enter your email to receive a password reset link.</DialogDescription>
</DialogHeader>
<form
onSubmit={e => {
void handleResetPassword(e);
}}
className="flex flex-col gap-3 mt-2"
>
<Input
type="email"
placeholder="Enter your email"
value={resetEmail}
onChange={e => setResetEmail(e.target.value)}
required
/>
<DialogFooter>
<div className={'w-full space-y-2'}>
<Button className={'w-full block'} type="submit" disabled={resetStatus === 'loading'}>
{resetStatus === 'loading' ? 'Sending...' : 'Send reset link'}
</Button>
{resetStatus === 'success' && (
<p className="text-green-600 text-sm">
If an account exists, a reset link has been sent to your email.
</p>
)}
{resetStatus === 'error' && <p className="text-red-500 text-sm">{resetError}</p>}
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</>
);
}
+247
View File
@@ -0,0 +1,247 @@
import {zodResolver} from '@hookform/resolvers/zod';
import {AuthenticationSchemas} from '@plunk/shared';
import {
Button,
Card,
CardContent,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useRouter} from 'next/router';
import React, {useState} from 'react';
import {useForm} from 'react-hook-form';
import type {z} from 'zod';
import {API_URI} from '../../lib/constants';
import {useProjects} from '../../lib/hooks/useProject';
import {useUser} from '../../lib/hooks/useUser';
import {useConfig} from '../../lib/hooks/useConfig';
import {network} from '../../lib/network';
export default function Signup() {
const {mutate: userMutate} = useUser();
const {mutate: projectsMutate} = useProjects();
const router = useRouter();
const form = useForm<z.infer<typeof AuthenticationSchemas.signup>>({
resolver: zodResolver(AuthenticationSchemas.signup),
defaultValues: {
email: '',
password: '',
},
});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const {data: config} = useConfig();
const oauthConfig = {
github: config?.features.authProviders.github ?? false,
google: config?.features.authProviders.google ?? false,
};
async function onSubmit(values: z.infer<typeof AuthenticationSchemas.signup>) {
try {
const response = await network.fetch<
{
success: boolean;
data: {id: string; email: string} | string;
},
typeof AuthenticationSchemas.signup
>('POST', '/auth/signup', values);
if (!response.success) {
// Handle error message from API
const errorData = typeof response.data === 'string' ? response.data : 'Something went wrong';
setErrorMessage(errorData);
} else {
setErrorMessage(null);
await userMutate();
await projectsMutate();
await router.push('/projects/create');
}
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong');
}
}
return (
<>
<NextSeo title="Sign Up" />
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
<Card>
<CardContent className="p-0">
<Form {...form}>
<form
onSubmit={e => {
e.preventDefault();
void form.handleSubmit(onSubmit)(e);
}}
className="p-8"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Create an account</h1>
<p className="text-neutral-600">Get started with Plunk today</p>
</div>
{(oauthConfig.github || oauthConfig.google) && (
<>
<div className="grid gap-2">
{oauthConfig.google && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/google/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
)}
{oauthConfig.github && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/github/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
</Button>
)}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
</div>
</div>
</>
)}
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="hello@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password (min. 6 characters)" type={'password'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)}
</AnimatePresence>
<motion.div layout>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Sign up'
)}
</Button>
</motion.div>
<div className="text-center text-sm text-neutral-500">
Already have an account?{' '}
<Link href="/auth/login" className="underline underline-offset-4 hover:text-neutral-900">
Login
</Link>
</div>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
</div>
</>
);
}
+934
View File
@@ -0,0 +1,934 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
StickySaveBar,
} from '@plunk/ui';
import type {Campaign, Segment} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignSchemas} from '@plunk/shared';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor';
import {network} from '../../lib/network';
import {formatFullDateTime, formatUTCDateTime, getUserTimezone, schedulePresets} from '../../lib/dateUtils';
import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
import {ArrowLeft, Calendar, Mail, MousePointer, Save, Send, TestTube, TrendingUp, Users, XCircle} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
interface CampaignStats {
totalRecipients: number;
sentCount: number;
deliveredCount: number;
openedCount: number;
clickedCount: number;
bouncedCount: number;
openRate: number;
clickRate: number;
bounceRate: number;
deliveryRate: number;
}
export default function CampaignDetailsPage() {
const router = useRouter();
const {id} = router.query;
const {activeProject} = useActiveProject();
const {
data: campaign,
mutate,
isLoading,
} = useSWR<{data: Campaign}>(id ? `/campaigns/${id}` : null, {revalidateOnFocus: false});
const {data: stats} = useSWR<{data: CampaignStats}>(
id && campaign?.data.status !== CampaignStatus.DRAFT ? `/campaigns/${id}/stats` : null,
{
revalidateOnFocus: false,
refreshInterval: campaign?.data.status === CampaignStatus.SENDING ? 5000 : 0, // Refresh every 5s if sending
},
);
// Fetch segments for audience selection
const {data: segments} = useSWR<Segment[]>('/segments', {
revalidateOnFocus: false,
});
// Fetch project members for test email
const {data: projectMembers} = useSWR<{data: Array<{userId: string; email: string; role: string}>}>(
id ? `/projects/${campaign?.data.projectId}/members` : null,
{revalidateOnFocus: false},
);
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false);
const [scheduledDateTime, setScheduledDateTime] = useState('');
const [isTestEmailDialogOpen, setIsTestEmailDialogOpen] = useState(false);
const [testEmailAddress, setTestEmailAddress] = useState('');
const [sendingTestEmail, setSendingTestEmail] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [showSendDialog, setShowSendDialog] = useState(false);
// Automatically initialize edit fields when campaign is loaded and is a draft
const isEditMode = campaign?.data.status === CampaignStatus.DRAFT;
const handleCancel = async () => {
try {
await network.fetch('POST', `/campaigns/${id}/cancel`);
toast.success('Campaign cancelled successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to cancel campaign');
}
};
const handleSend = async () => {
try {
await network.fetch<void>('POST', `/campaigns/${id}/send`);
toast.success('Campaign is being sent!');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send campaign');
}
};
const handleSchedule = async () => {
if (!scheduledDateTime) {
toast.error('Please select a date and time');
return;
}
// Parse the datetime-local value as local time, then convert to UTC
const scheduledDate = new Date(scheduledDateTime);
const now = new Date();
if (scheduledDate.getTime() <= now.getTime()) {
toast.error('Scheduled time must be in the future');
return;
}
try {
// Send as ISO string (UTC)
await network.fetch<void, typeof CampaignSchemas.schedule>('POST', `/campaigns/${id}/send`, {
scheduledFor: scheduledDate.toISOString(),
});
// Show confirmation with user's local time
const localTimeString = formatFullDateTime(scheduledDate);
toast.success(`Campaign scheduled for ${localTimeString}`);
setIsScheduleDialogOpen(false);
setScheduledDateTime('');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign');
}
};
const handleSendTestEmail = async () => {
if (!testEmailAddress) {
toast.error('Please select a project member');
return;
}
setSendingTestEmail(true);
try {
await network.fetch<{success: boolean; message: string}>('POST', `/campaigns/${id}/test`, {
email: testEmailAddress,
} as any);
toast.success(`Test email sent to ${testEmailAddress}`);
setIsTestEmailDialogOpen(false);
setTestEmailAddress('');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send test email');
} finally {
setSendingTestEmail(false);
}
};
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const handleSave = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
setIsSubmitting(true);
try {
await network.fetch<Campaign, typeof CampaignSchemas.update>('PUT', `/campaigns/${id}`, {
name: editedCampaign.name,
description: editedCampaign.description || undefined,
subject: editedCampaign.subject,
body: editedCampaign.body,
from: editedCampaign.from,
fromName: editedCampaign.fromName || undefined,
replyTo: editedCampaign.replyTo || undefined,
audienceType: editedCampaign.audienceType,
segmentId: editedCampaign.segmentId || undefined,
});
// Silent save - no toast notification
setHasChanges(false);
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update campaign');
} finally {
setIsSubmitting(false);
}
};
// Initialize edit fields when campaign loads and is a draft
useEffect(() => {
if (campaign?.data && isEditMode && Object.keys(editedCampaign).length === 0) {
setEditedCampaign({
name: campaign.data.name,
description: campaign.data.description || '',
subject: campaign.data.subject,
body: campaign.data.body,
from: campaign.data.from,
fromName: campaign.data.fromName || '',
replyTo: campaign.data.replyTo || '',
audienceType: campaign.data.audienceType,
segmentId: campaign.data.segmentId || undefined,
});
// Reset hasChanges when loading fresh data
setHasChanges(false);
}
}, [campaign, isEditMode, editedCampaign]);
// Track changes
useEffect(() => {
if (!campaign?.data || Object.keys(editedCampaign).length === 0) return;
const changed =
editedCampaign.name !== campaign.data.name ||
(editedCampaign.description || '') !== (campaign.data.description || '') ||
editedCampaign.subject !== campaign.data.subject ||
editedCampaign.body !== campaign.data.body ||
editedCampaign.from !== campaign.data.from ||
(editedCampaign.fromName || '') !== (campaign.data.fromName || '') ||
(editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') ||
editedCampaign.audienceType !== campaign.data.audienceType ||
(editedCampaign.segmentId || null) !== (campaign.data.segmentId || null);
setHasChanges(changed);
}, [editedCampaign, campaign]);
// Warn before leaving page with unsaved changes (only in edit mode)
useChangeTracking(hasChanges, isEditMode);
const getStatusBadge = (status: CampaignStatus) => {
const variants: Record<
CampaignStatus,
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string}
> = {
DRAFT: {variant: 'secondary', label: 'Draft'},
SCHEDULED: {variant: 'default', label: 'Scheduled'},
SENDING: {variant: 'default', label: 'Sending'},
SENT: {variant: 'default', label: 'Sent'},
CANCELLED: {variant: 'destructive', label: 'Cancelled'},
};
const config = variants[status];
return <Badge variant={config.variant}>{config.label}</Badge>;
};
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="h-8 w-8 animate-spin mx-auto border-4 border-neutral-200 border-t-neutral-900 rounded-full" />
<p className="mt-2 text-sm text-neutral-500">Loading campaign...</p>
</div>
</div>
</DashboardLayout>
);
}
if (!campaign) {
return (
<DashboardLayout>
<Card>
<CardContent className="py-12 text-center">
<p className="text-neutral-500">Campaign not found</p>
</CardContent>
</Card>
</DashboardLayout>
);
}
const c = campaign.data;
const s = stats?.data;
// Get recipient count for draft campaigns
const getDraftRecipientCount = () => {
if (!campaign?.data) return 0;
const c = campaign.data;
if (c.audienceType === CampaignAudienceType.SEGMENT && c.segmentId && segments) {
const segment = segments.find(s => s.id === c.segmentId);
return segment?.memberCount || 0;
}
return 0; // We'd need total contact count for ALL audience type
};
const draftRecipientCount = isEditMode ? getDraftRecipientCount() : 0;
// Render edit form for drafts
if (isEditMode) {
return (
<DashboardLayout>
<form onSubmit={handleSave} className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/campaigns">
<Button type="button" variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-neutral-900">{c.name}</h1>
<Badge variant="secondary">Draft</Badge>
</div>
<p className="text-neutral-500 mt-1">Make changes to your campaign before sending</p>
</div>
</div>
<div className="flex items-center gap-3">
{!hasChanges && !isSubmitting && <span className="text-sm text-neutral-500">All changes saved</span>}
{hasChanges && !isSubmitting && <span className="text-sm text-amber-600">Unsaved changes</span>}
<div className="flex gap-2">
<Button type="submit" disabled={!hasChanges || isSubmitting} variant="outline">
<Save className="h-4 w-4" />
{isSubmitting ? 'Saving...' : 'Save'}
</Button>
<Button type="button" variant="outline" onClick={() => setIsTestEmailDialogOpen(true)}>
<TestTube className="h-4 w-4" />
Send Test
</Button>
<Button type="button" onClick={() => setShowSendDialog(true)}>
<Send className="h-4 w-4" />
Send Now
</Button>
<Button type="button" variant="outline" onClick={() => setIsScheduleDialogOpen(true)}>
<Calendar className="h-4 w-4" />
Schedule
</Button>
</div>
</div>
</div>
{/* Campaign Settings - Horizontal Layout */}
<div className="grid gap-6 md:grid-cols-2">
{/* Campaign Settings */}
<Card>
<CardHeader>
<CardTitle>Campaign Settings</CardTitle>
<CardDescription>Basic information about your campaign</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Campaign Name *</Label>
<Input
id="name"
type="text"
value={editedCampaign.name || ''}
onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})}
required
placeholder="Spring Sale Campaign"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={editedCampaign.description || ''}
onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})}
placeholder="Optional description for internal use"
/>
</div>
<div>
<Label htmlFor="subject">Subject Line *</Label>
<Input
id="subject"
type="text"
value={editedCampaign.subject || ''}
onChange={e => setEditedCampaign({...editedCampaign, subject: e.target.value})}
required
placeholder="Introducing our Spring Sale!"
/>
</div>
<EmailSettings
from={editedCampaign.from || ''}
fromName={editedCampaign.fromName || ''}
replyTo={editedCampaign.replyTo || ''}
onFromChange={value => setEditedCampaign({...editedCampaign, from: value})}
onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})}
onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})}
fromNamePlaceholder={activeProject?.name || 'Your Company'}
showFromNameHelpText
layout="vertical"
/>
</CardContent>
</Card>
{/* Audience Settings */}
<Card>
<CardHeader>
<CardTitle>Audience</CardTitle>
<CardDescription>Who will receive this campaign</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="audienceType">Audience Type *</Label>
<Select
value={editedCampaign.audienceType ?? c.audienceType}
onValueChange={(value: CampaignAudienceType) => {
setEditedCampaign({
...editedCampaign,
audienceType: value,
// Clear segmentId if changing away from SEGMENT
segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined,
});
}}
>
<SelectTrigger id="audienceType">
<SelectValue placeholder="Select audience type" />
</SelectTrigger>
<SelectContent>
<SelectItem value={CampaignAudienceType.ALL}>All Subscribed Contacts</SelectItem>
<SelectItem value={CampaignAudienceType.SEGMENT}>Segment</SelectItem>
<SelectItem value={CampaignAudienceType.FILTERED}>Filtered</SelectItem>
</SelectContent>
</Select>
</div>
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
<div>
<Label htmlFor="segment">Select Segment *</Label>
<Select
value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
onValueChange={(value: string) => {
setEditedCampaign({
...editedCampaign,
segmentId: value,
});
}}
disabled={!segments || segments.length === 0}
>
<SelectTrigger id="segment">
<SelectValue
placeholder={segments && segments.length > 0 ? 'Choose a segment' : 'No segments available'}
/>
</SelectTrigger>
<SelectContent>
{segments &&
segments.length > 0 &&
segments.map(segment => (
<SelectItem key={segment.id} value={segment.id}>
{segment.name} ({segment.memberCount.toLocaleString()} contacts)
</SelectItem>
))}
</SelectContent>
</Select>
{segments && segments.length === 0 && (
<p className="text-xs text-neutral-500 mt-1">Create a segment first to use this option</p>
)}
</div>
)}
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
<p className="text-sm text-neutral-500">
Filtered audiences are configured with advanced filter conditions
</p>
)}
{/* Show recipient count */}
{draftRecipientCount > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-900">
{draftRecipientCount.toLocaleString()} recipients
</span>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Email Editor - Full Width */}
<Card>
<CardHeader>
<CardTitle>Email Content</CardTitle>
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor
value={editedCampaign.body || ''}
onChange={body => {
setEditedCampaign({...editedCampaign, body});
setHasChanges(true);
}}
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
canUploadImages={true}
subject={editedCampaign.subject}
from={editedCampaign.from}
replyTo={editedCampaign.replyTo || undefined}
/>
</CardContent>
</Card>
{/* Test Email Dialog */}
<Dialog open={isTestEmailDialogOpen} onOpenChange={setIsTestEmailDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Send Test Email</DialogTitle>
<DialogDescription>
Send a test version of this campaign to a project member to verify how it looks. The test email will
be prefixed with [TEST] in the subject line.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="testEmail">Project Member</Label>
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
<SelectTrigger id="testEmail" className="mt-2">
<SelectValue placeholder="Select a project member..." />
</SelectTrigger>
<SelectContent>
{projectMembers?.data.map(member => (
<SelectItem key={member.userId} value={member.email}>
{member.email}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-neutral-500 mt-2">
For security reasons, test emails can only be sent to project members.
</p>
<p className="text-xs text-neutral-500 mt-1">
Note: Variables will not be replaced in test emails. The email will be sent exactly as designed.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setIsTestEmailDialogOpen(false);
setTestEmailAddress('');
}}
>
Cancel
</Button>
<Button type="button" onClick={handleSendTestEmail} disabled={sendingTestEmail || !testEmailAddress}>
{sendingTestEmail ? 'Sending...' : 'Send Test Email'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Schedule Dialog */}
<Dialog open={isScheduleDialogOpen} onOpenChange={setIsScheduleDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Schedule Campaign</DialogTitle>
<DialogDescription>
Choose when you want this campaign to be sent (times shown in your local timezone: {getUserTimezone()}
)
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Quick Presets */}
<div>
<Label>Quick Schedule</Label>
<div className="grid grid-cols-2 gap-2 mt-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.inOneHour())}
>
In 1 hour
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.inThreeHours())}
>
In 3 hours
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt9AM())}
>
Tomorrow at 9 AM
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt2PM())}
>
Tomorrow at 2 PM
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.nextMonday())}
>
Next Monday
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.inOneWeek())}
>
In 1 week
</Button>
</div>
</div>
{/* Custom Date/Time */}
<div>
<Label htmlFor="scheduledDateTime">Or choose a specific time</Label>
<Input
id="scheduledDateTime"
type="datetime-local"
value={scheduledDateTime}
onChange={e => setScheduledDateTime(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
className="mt-2"
/>
{scheduledDateTime && (
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-xs font-medium text-blue-900 mb-1">Scheduled for:</p>
<p className="text-sm text-blue-800">
<span className="font-medium">{formatFullDateTime(new Date(scheduledDateTime))}</span>
</p>
<p className="text-xs text-blue-700 mt-1">
UTC: {formatUTCDateTime(new Date(scheduledDateTime))}
</p>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setIsScheduleDialogOpen(false);
setScheduledDateTime('');
}}
>
Cancel
</Button>
<Button type="button" onClick={handleSchedule}>
Schedule Campaign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
{/* Sticky Save Bar */}
<StickySaveBar hasChanges={hasChanges} isSubmitting={isSubmitting} onSave={handleSave} />
<ConfirmDialog
open={showSendDialog}
onOpenChange={setShowSendDialog}
onConfirm={handleSend}
title="Send Campaign"
description="Are you sure you want to send this campaign now? This action cannot be undone."
confirmText="Send Now"
variant="default"
/>
</DashboardLayout>
);
}
// Render stats view for sent/scheduled campaigns
return (
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/campaigns">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-neutral-900">{c.name}</h1>
{getStatusBadge(c.status)}
</div>
{c.description && <p className="text-neutral-500">{c.description}</p>}
</div>
</div>
{/* Actions */}
{(c.status === CampaignStatus.SCHEDULED || c.status === CampaignStatus.SENDING) && (
<Button variant="destructive" onClick={() => setShowCancelDialog(true)}>
<XCircle className="h-4 w-4" />
Cancel Campaign
</Button>
)}
</div>
{/* Sending Progress Banner */}
{c.status === CampaignStatus.SENDING && s && (
<Card className="bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200">
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-neutral-900 text-lg">Sending in progress</h3>
<p className="text-sm text-neutral-600 mt-1">
{s.sentCount.toLocaleString()} of {s.totalRecipients.toLocaleString()} emails sent
</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-blue-600">
{((s.sentCount / s.totalRecipients) * 100).toFixed(0)}%
</div>
<p className="text-xs text-neutral-500 mt-1">Complete</p>
</div>
</div>
<div className="w-full bg-neutral-200 rounded-full h-3">
<div
className="bg-gradient-to-r from-blue-500 to-indigo-500 h-3 rounded-full transition-all duration-500"
style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}}
/>
</div>
<p className="text-xs text-neutral-500">This page updates automatically every 5 seconds</p>
</div>
</CardContent>
</Card>
)}
{/* Stats Cards */}
{s && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-l-4 border-l-blue-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-600">Total Recipients</CardTitle>
<div className="p-2 bg-blue-100 rounded-lg">
<Users className="h-4 w-4 text-blue-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.totalRecipients.toLocaleString()}</div>
<p className="text-xs text-neutral-500 mt-2">
{s.sentCount.toLocaleString()} sent ({((s.sentCount / s.totalRecipients) * 100).toFixed(1)}%)
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-600">Delivery Rate</CardTitle>
<div className="p-2 bg-green-100 rounded-lg">
<Mail className="h-4 w-4 text-green-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.deliveryRate.toFixed(1)}%</div>
<p className="text-xs text-neutral-500 mt-2">
{s.deliveredCount.toLocaleString()} delivered
{s.bouncedCount > 0 && `, ${s.bouncedCount} bounced`}
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-600">Open Rate</CardTitle>
<div className="p-2 bg-purple-100 rounded-lg">
<TrendingUp className="h-4 w-4 text-purple-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.openRate.toFixed(1)}%</div>
<p className="text-xs text-neutral-500 mt-2">{s.openedCount.toLocaleString()} opened</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-orange-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-600">Click Rate</CardTitle>
<div className="p-2 bg-orange-100 rounded-lg">
<MousePointer className="h-4 w-4 text-orange-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.clickRate.toFixed(1)}%</div>
<p className="text-xs text-neutral-500 mt-2">{s.clickedCount.toLocaleString()} clicked</p>
</CardContent>
</Card>
</div>
)}
{/* Campaign Details in Grid */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Email Content - Takes 2 columns */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Email Preview</CardTitle>
<CardDescription>How your email will appear to recipients</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Email Header Info */}
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-4 space-y-2">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-xs text-neutral-500 uppercase tracking-wide font-medium">Subject</p>
<p className="text-base font-semibold text-neutral-900 mt-1">{c.subject}</p>
</div>
</div>
<div className="flex gap-6 pt-2 border-t border-neutral-200">
<div>
<p className="text-xs text-neutral-500">From</p>
<p className="text-sm text-neutral-900 mt-0.5">{c.from}</p>
</div>
{c.replyTo && (
<div>
<p className="text-xs text-neutral-500">Reply-To</p>
<p className="text-sm text-neutral-900 mt-0.5">{c.replyTo}</p>
</div>
)}
</div>
</div>
{/* Email Body Preview */}
<div>
<p className="text-sm font-medium text-neutral-700 mb-3">Message Content</p>
<div className="border-2 border-neutral-200 rounded-lg overflow-hidden bg-white">
<div className="p-6 max-h-96 overflow-y-auto">
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{__html: c.body}} />
</div>
</div>
</div>
</CardContent>
</Card>
{/* Campaign Details */}
<Card>
<CardHeader>
<CardTitle>Campaign Info</CardTitle>
<CardDescription>Configuration and metadata</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Audience */}
<div className="pb-3 border-b border-neutral-100">
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Audience</p>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-neutral-400" />
<div>
<p className="text-sm font-medium text-neutral-900">
{c.audienceType === CampaignAudienceType.ALL && 'All Subscribed Contacts'}
{c.audienceType === CampaignAudienceType.SEGMENT &&
(segments?.find(s => s.id === c.segmentId)?.name || 'Selected Segment')}
{c.audienceType === CampaignAudienceType.FILTERED && 'Filtered Contacts'}
</p>
{c.audienceType === CampaignAudienceType.SEGMENT &&
segments?.find(s => s.id === c.segmentId)?.memberCount && (
<p className="text-xs text-neutral-500">
{segments.find(s => s.id === c.segmentId)!.memberCount.toLocaleString()} contacts
</p>
)}
</div>
</div>
</div>
{/* Scheduling Info */}
{c.scheduledFor && (
<div className="pb-3 border-b border-neutral-100">
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Scheduled For</p>
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-neutral-400 mt-0.5" />
<div>
<p className="text-sm font-medium text-neutral-900">
{formatFullDateTime(new Date(c.scheduledFor))}
</p>
<p className="text-xs text-neutral-500 mt-1">
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
</p>
</div>
</div>
</div>
)}
{/* Sent At */}
{c.sentAt && (
<div className="pb-3 border-b border-neutral-100">
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent At</p>
<div className="flex items-center gap-2">
<Send className="h-4 w-4 text-neutral-400" />
<p className="text-sm font-medium text-neutral-900">
{new Date(c.sentAt).toLocaleDateString()} at {new Date(c.sentAt).toLocaleTimeString()}
</p>
</div>
</div>
)}
{/* Created */}
<div>
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Created</p>
<p className="text-sm text-neutral-900">{new Date(c.createdAt).toLocaleDateString()}</p>
</div>
</CardContent>
</Card>
</div>
</div>
<ConfirmDialog
open={showCancelDialog}
onOpenChange={setShowCancelDialog}
onConfirm={handleCancel}
title="Cancel Campaign"
description="Are you sure you want to cancel this campaign?"
confirmText="Cancel Campaign"
variant="destructive"
/>
</DashboardLayout>
);
}
+367
View File
@@ -0,0 +1,367 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import type {Segment} from '@plunk/db';
import {CampaignAudienceType} from '@plunk/db';
import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor';
import {StepHeader} from '../../components/StepHeader';
import {network} from '../../lib/network';
import {EmailFormValidator} from '../../lib/validation';
import {ArrowLeft, Save, Users} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
export default function CreateCampaignPage() {
const router = useRouter();
const {activeProject} = useActiveProject();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [from, setFrom] = useState('');
const [fromName, setFromName] = useState('');
const [replyTo, setReplyTo] = useState('');
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
const [segmentId, setSegmentId] = useState('');
const [saving, setSaving] = useState(false);
const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = EmailFormValidator.validateCampaign({name, subject, body, from, segmentId}, audienceType);
if (validationError) {
toast.error(validationError);
return;
}
setSaving(true);
try {
const response = await network.fetch<{data: {id: string}}>('POST', '/campaigns', {
name,
description: description || undefined,
subject,
body,
from,
fromName: fromName || undefined,
replyTo: replyTo || undefined,
audienceType,
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
} as any);
toast.success('Campaign created successfully');
void router.push(`/campaigns/${response.data.id}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create campaign');
setSaving(false);
}
};
// Calculate estimated recipients
const getEstimatedRecipients = () => {
if (audienceType === CampaignAudienceType.SEGMENT && segmentId && segments) {
const segment = segments.find(s => s.id === segmentId);
return segment?.memberCount || 0;
}
return 0; // We don't have total contact count here, but in a real scenario you'd fetch it
};
const estimatedRecipients = getEstimatedRecipients();
return (
<>
<NextSeo title="Create Campaign" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/campaigns">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold text-neutral-900">Create Campaign</h1>
<p className="text-neutral-500 mt-1">Create a new email campaign to send to your contacts</p>
</div>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit}>
<div className="grid gap-6 lg:grid-cols-3">
{/* Left Column - Settings (2/3 width) */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Information */}
<Card>
<CardHeader>
<StepHeader stepNumber={1} title="Basic Information" description="Name and describe your campaign" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">
Campaign Name <span className="text-red-500">*</span>
</Label>
<Input
id="name"
placeholder="e.g., Spring Sale Announcement"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (Optional)</Label>
<textarea
id="description"
placeholder="Internal notes about this campaign"
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-neutral-200 rounded-lg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</CardContent>
</Card>
{/* Email Settings */}
<Card>
<CardHeader>
<StepHeader
stepNumber={2}
title="Email Settings"
description="Configure sender information and subject"
/>
</CardHeader>
<CardContent className="space-y-4">
<EmailSettings
from={from}
fromName={fromName}
replyTo={replyTo}
onFromChange={setFrom}
onFromNameChange={setFromName}
onReplyToChange={setReplyTo}
fromNamePlaceholder={activeProject?.name || 'Your Company'}
/>
<div className="space-y-2">
<Label htmlFor="subject">
Email Subject <span className="text-red-500">*</span>
</Label>
<Input
id="subject"
placeholder="e.g., Introducing our Spring Sale!"
value={subject}
onChange={e => setSubject(e.target.value)}
required
/>
</div>
</CardContent>
</Card>
{/* Email Content */}
<Card>
<CardHeader>
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="body">
Email Body <span className="text-red-500">*</span>
</Label>
<EmailEditor
value={body}
onChange={setBody}
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
canUploadImages={true}
subject={subject}
from={from}
replyTo={replyTo}
/>
</div>
</CardContent>
</Card>
{/* Audience Selection */}
<Card>
<CardHeader>
<StepHeader stepNumber={4} title="Audience" description="Choose who will receive this campaign" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="audienceType">
Audience Type <span className="text-red-500">*</span>
</Label>
<Select
value={audienceType}
onValueChange={value => setAudienceType(value as CampaignAudienceType)}
required
>
<SelectTrigger>
<SelectValue placeholder="Select audience type" />
</SelectTrigger>
<SelectContent>
<SelectItem value={CampaignAudienceType.ALL}>All Subscribed Contacts</SelectItem>
<SelectItem value={CampaignAudienceType.SEGMENT}>Specific Segment</SelectItem>
</SelectContent>
</Select>
</div>
{audienceType === CampaignAudienceType.SEGMENT && (
<div className="space-y-2">
<Label htmlFor="segment">
Select Segment <span className="text-red-500">*</span>
</Label>
<Select value={segmentId} onValueChange={setSegmentId} required>
<SelectTrigger>
<SelectValue placeholder="Choose a segment" />
</SelectTrigger>
<SelectContent>
{segments?.map(segment => (
<SelectItem key={segment.id} value={segment.id}>
{segment.name} ({segment.memberCount.toLocaleString()} contacts)
</SelectItem>
))}
</SelectContent>
</Select>
{segments?.length === 0 && (
<p className="text-sm text-neutral-500 mt-2">
No segments found.{' '}
<Link href="/segments/new" className="text-primary hover:underline">
Create one first
</Link>
</p>
)}
</div>
)}
{audienceType === CampaignAudienceType.SEGMENT && estimatedRecipients > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-900">
{estimatedRecipients.toLocaleString()} recipients
</p>
<p className="text-xs text-blue-700 mt-1">
This campaign will be sent to all contacts in the selected segment
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Right Column - Summary & Actions (1/3 width) */}
<div className="space-y-6">
{/* Campaign Summary */}
<Card className="sticky top-6">
<CardHeader>
<CardTitle className="text-lg">Campaign Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 text-sm">
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Status</span>
<span className="font-medium">Draft</span>
</div>
{name && (
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Name</span>
<span className="font-medium text-right truncate ml-2" title={name}>
{name}
</span>
</div>
)}
{subject && (
<div className="py-2 border-b border-neutral-100">
<span className="text-neutral-500 block mb-1">Subject</span>
<span className="font-medium text-sm">{subject}</span>
</div>
)}
{from && (
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">From</span>
<span className="font-medium text-right truncate ml-2" title={from}>
{from}
</span>
</div>
)}
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Audience</span>
<span className="font-medium">
{audienceType === CampaignAudienceType.ALL ? 'All Contacts' : 'Segment'}
</span>
</div>
{audienceType === CampaignAudienceType.SEGMENT && estimatedRecipients > 0 && (
<div className="flex justify-between py-2">
<span className="text-neutral-500">Recipients</span>
<span className="font-medium">{estimatedRecipients.toLocaleString()}</span>
</div>
)}
</div>
{/* Info Note */}
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-3 mt-4">
<p className="text-xs text-neutral-600 leading-relaxed">
After creating this campaign, you&apos;ll be able to review it and choose when to send it.
</p>
</div>
{/* Actions */}
<div className="flex flex-col gap-2 pt-4">
<Button type="submit" disabled={saving} className="w-full">
{saving ? (
<>Creating...</>
) : (
<>
<Save className="h-4 w-4" />
Create Campaign
</>
)}
</Button>
<Link href="/campaigns" className="w-full">
<Button type="button" variant="outline" className="w-full">
Cancel
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
</form>
</div>
</DashboardLayout>
</>
);
}
+320
View File
@@ -0,0 +1,320 @@
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import type {Campaign} from '@plunk/db';
import {CampaignStatus} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {Calendar, Copy, Mail, Plus, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
interface PaginatedCampaigns {
campaigns: Campaign[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export default function CampaignsPage() {
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>('ALL');
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
const {data, mutate, isLoading} = useSWR<PaginatedCampaigns>(
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
{revalidateOnFocus: false},
);
const getStatusBadge = (status: CampaignStatus) => {
const variants: Record<
CampaignStatus,
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className?: string}
> = {
DRAFT: {variant: 'secondary', label: 'Draft', className: 'bg-neutral-100 text-neutral-700'},
SCHEDULED: {variant: 'default', label: 'Scheduled', className: 'bg-blue-100 text-blue-700'},
SENDING: {variant: 'default', label: 'Sending', className: 'bg-purple-100 text-purple-700'},
SENT: {variant: 'default', label: 'Sent', className: 'bg-green-100 text-green-700'},
CANCELLED: {variant: 'destructive', label: 'Cancelled', className: 'bg-red-100 text-red-700'},
};
const config = variants[status];
return (
<Badge variant={config.variant} className={config.className}>
{config.label}
</Badge>
);
};
const handleCancel = async () => {
if (!campaignToCancel) return;
try {
await network.fetch('POST', `/campaigns/${campaignToCancel}/cancel`);
toast.success('Campaign cancelled successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to cancel campaign');
} finally {
setCampaignToCancel(null);
}
};
const handleDuplicate = async (campaignId: string) => {
try {
await network.fetch('POST', `/campaigns/${campaignId}/duplicate`);
toast.success('Campaign duplicated successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to duplicate campaign');
}
};
return (
<>
<NextSeo title="Campaigns" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-neutral-900">Campaigns</h1>
<p className="text-neutral-500 mt-2">
Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''}
</p>
</div>
<Link href="/campaigns/create">
<Button>
<Plus className="h-4 w-4" />
Create Campaign
</Button>
</Link>
</div>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex gap-4">
<div className="flex-1">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
<SelectItem value="SENDING">Sending</SelectItem>
<SelectItem value="SENT">Sent</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Campaigns List */}
<div className="space-y-4">
{isLoading && (
<Card>
<CardContent className="py-8 text-center text-neutral-500">Loading campaigns...</CardContent>
</Card>
)}
{!isLoading && data?.campaigns.length === 0 && (
<Card className="border-2 border-dashed">
<CardContent className="py-16 text-center">
<div className="max-w-md mx-auto">
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Mail className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-semibold text-neutral-900 mb-2">
{statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
</h3>
<p className="text-neutral-500 mb-6">
{statusFilter !== 'ALL'
? 'Try adjusting your filters or create a new campaign.'
: 'Create your first campaign to send emails to your contacts.'}
</p>
<Link href="/campaigns/create">
<Button size="lg">
<Plus className="h-4 w-4" />
Create Your First Campaign
</Button>
</Link>
</div>
</CardContent>
</Card>
)}
{data?.campaigns.map(campaign => {
const openRate = campaign.sentCount > 0 ? (campaign.openedCount / campaign.sentCount) * 100 : 0;
const clickRate = campaign.sentCount > 0 ? (campaign.clickedCount / campaign.sentCount) * 100 : 0;
const deliveryProgress =
campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0;
return (
<Card key={campaign.id} className="hover:shadow-lg transition-all hover:border-primary/20">
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<Link
href={`/campaigns/${campaign.id}`}
className="hover:text-primary transition-colors flex-1 min-w-0"
>
<CardTitle className="text-xl truncate">{campaign.name}</CardTitle>
</Link>
{getStatusBadge(campaign.status)}
</div>
{campaign.description && (
<CardDescription className="line-clamp-2">{campaign.description}</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Recipients */}
<div className="bg-blue-50 border border-blue-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Users className="h-3.5 w-3.5 text-blue-600" />
<span className="text-xs font-medium text-blue-900">Recipients</span>
</div>
<p className="text-lg font-bold text-blue-900">{campaign.totalRecipients.toLocaleString()}</p>
{campaign.totalRecipients > 0 && (
<p className="text-xs text-blue-700 mt-1">{deliveryProgress.toFixed(0)}% sent</p>
)}
</div>
{/* Open Rate */}
{campaign.sentCount > 0 && (
<div className="bg-purple-50 border border-purple-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Mail className="h-3.5 w-3.5 text-purple-600" />
<span className="text-xs font-medium text-purple-900">Opens</span>
</div>
<p className="text-lg font-bold text-purple-900">{openRate.toFixed(1)}%</p>
<p className="text-xs text-purple-700 mt-1">{campaign.openedCount.toLocaleString()} opened</p>
</div>
)}
{/* Click Rate */}
{campaign.clickedCount > 0 && (
<div className="bg-orange-50 border border-orange-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Mail className="h-3.5 w-3.5 text-orange-600" />
<span className="text-xs font-medium text-orange-900">Clicks</span>
</div>
<p className="text-lg font-bold text-orange-900">{clickRate.toFixed(1)}%</p>
<p className="text-xs text-orange-700 mt-1">{campaign.clickedCount.toLocaleString()} clicked</p>
</div>
)}
{/* Scheduled For */}
{campaign.scheduledFor && (
<div className="bg-green-50 border border-green-100 rounded-lg p-3 md:col-span-2">
<div className="flex items-center gap-2 mb-1">
<Calendar className="h-3.5 w-3.5 text-green-600" />
<span className="text-xs font-medium text-green-900">Scheduled</span>
</div>
<p className="text-sm font-semibold text-green-900">
{new Date(campaign.scheduledFor).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
<p className="text-xs text-green-700 mt-1">
{new Date(campaign.scheduledFor).toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}
</p>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-2 border-t border-neutral-100">
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
{campaign.status === 'DRAFT' ? 'Edit Campaign' : 'View Details'}
</Button>
</Link>
<Button variant="outline" size="sm" onClick={() => handleDuplicate(campaign.id)}>
<Copy className="h-4 w-4" />
</Button>
{(campaign.status === 'SCHEDULED' || campaign.status === 'SENDING') && (
<Button
variant="destructive"
size="sm"
onClick={() => {
setCampaignToCancel(campaign.id);
setShowCancelDialog(true);
}}
>
Cancel
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex justify-center gap-2">
<Button variant="outline" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
Previous
</Button>
<span className="flex items-center px-4 text-sm text-neutral-600">
Page {page} of {data.totalPages}
</span>
<Button
variant="outline"
onClick={() => setPage(p => Math.min(data.totalPages, p + 1))}
disabled={page === data.totalPages}
>
Next
</Button>
</div>
)}
</div>
<ConfirmDialog
open={showCancelDialog}
onOpenChange={setShowCancelDialog}
onConfirm={handleCancel}
title="Cancel Campaign"
description="Are you sure you want to cancel this campaign?"
confirmText="Cancel Campaign"
variant="destructive"
/>
</DashboardLayout>
</>
);
}
+395
View File
@@ -0,0 +1,395 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
Input,
Label,
} from '@plunk/ui';
import type {Contact} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {KeyValueEditor} from '../../components/KeyValueEditor';
import {network} from '../../lib/network';
import {ArrowLeft, Calendar, Copy, Database, ExternalLink, Mail, Save, Settings, Trash2} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {ContactSchemas} from '@plunk/shared';
export default function ContactDetailPage() {
const router = useRouter();
const {id} = router.query;
const {data: contact, mutate, isLoading} = useSWR<Contact>(id ? `/contacts/${id}` : null);
const [email, setEmail] = useState('');
const [subscribed, setSubscribed] = useState(true);
const [customData, setCustomData] = useState<Record<string, string | number | boolean> | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Initialize form when contact loads
useEffect(() => {
if (contact) {
setEmail(contact.email);
setSubscribed(contact.subscribed);
setCustomData(contact.data as Record<string, string | number | boolean> | null);
}
}, [contact]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await network.fetch<
{
success: boolean;
},
typeof ContactSchemas.create
>('PATCH', `/contacts/${id}`, {email, subscribed, data: customData});
toast.success('Contact updated successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update contact');
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
try {
await network.fetch('DELETE', `/contacts/${id}`);
toast.success('Contact deleted successfully');
void router.push('/contacts');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete contact');
}
};
const copyToClipboard = async (url: string, label: string) => {
try {
await navigator.clipboard.writeText(url);
toast.success(`${label} link copied to clipboard`);
} catch {
toast.error('Failed to copy link');
}
};
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading contact...</p>
</div>
</div>
</DashboardLayout>
);
}
if (!contact) {
return (
<DashboardLayout>
<div className="text-center py-12">
<h3 className="text-lg font-medium text-neutral-900 mb-2">Contact not found</h3>
<p className="text-neutral-500 mb-6">
The contact you&apos;re looking for doesn&apos;t exist or has been deleted.
</p>
<Link href="/contacts">
<Button>
<ArrowLeft className="h-4 w-4" />
Back to Contacts
</Button>
</Link>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/contacts">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold text-neutral-900">{contact.email}</h1>
<p className="text-neutral-500 mt-1">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
contact.subscribed ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{contact.subscribed ? 'Subscribed' : 'Unsubscribed'}
</span>
</p>
</div>
</div>
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
<Trash2 className="h-4 w-4" />
Delete Contact
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Edit Form */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
<CardDescription>Update contact details and subscription status</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email Address *</Label>
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="contact@example.com"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex-1">
<Label htmlFor="subscribed" className="text-sm font-medium text-neutral-900 cursor-pointer">
Subscribed to emails
</Label>
<p className="text-xs text-neutral-500 mt-0.5">
{subscribed ? 'Contact will receive emails' : 'Contact will not receive emails'}
</p>
</div>
<button
type="button"
id="subscribed"
onClick={() => setSubscribed(!subscribed)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2 ${
subscribed ? 'bg-neutral-900' : 'bg-neutral-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
subscribed ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div>
{contact && (
<KeyValueEditor
key={`${contact.id}-${JSON.stringify(contact.data)}`}
initialData={contact.data as Record<string, string | number | boolean> | null}
onChange={setCustomData}
/>
)}
</div>
<Button type="submit" disabled={isSubmitting}>
<Save className="h-4 w-4" />
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</form>
</CardContent>
</Card>
</div>
{/* Metadata Sidebar */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<Mail className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-900">Email</p>
<p className="text-sm text-neutral-500 break-all">{contact.email}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Created</p>
<p className="text-sm text-neutral-500">
{new Date(contact.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<p className="text-sm text-neutral-500">
{new Date(contact.updatedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-900">Contact ID</p>
<p className="text-xs text-neutral-500 font-mono break-all">{contact.id}</p>
</div>
</div>
</CardContent>
</Card>
{/* Stats Card */}
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>Email engagement statistics</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">Emails Sent</span>
<span className="text-sm font-medium text-neutral-900">0</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">Emails Opened</span>
<span className="text-sm font-medium text-neutral-900">0</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">Links Clicked</span>
<span className="text-sm font-medium text-neutral-900">0</span>
</div>
</CardContent>
</Card>
{/* Public Links Card */}
<Card>
<CardHeader>
<CardTitle>Public Links</CardTitle>
<CardDescription>Share these links with the contact</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<p className="text-xs font-medium text-neutral-700">Subscribe Page</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start text-xs"
onClick={() => window.open(`${window.location.origin}/subscribe/${contact.id}`, '_blank')}
>
<ExternalLink className="h-3 w-3" />
Open
</Button>
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(`${window.location.origin}/subscribe/${contact.id}`, 'Subscribe')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-neutral-700">Unsubscribe Page</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start text-xs"
onClick={() => window.open(`${window.location.origin}/unsubscribe/${contact.id}`, '_blank')}
>
<ExternalLink className="h-3 w-3" />
Open
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(`${window.location.origin}/unsubscribe/${contact.id}`, 'Unsubscribe')
}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-neutral-700">Manage Preferences</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start text-xs"
onClick={() => window.open(`${window.location.origin}/manage/${contact.id}`, '_blank')}
>
<Settings className="h-3 w-3" />
Open
</Button>
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(`${window.location.origin}/manage/${contact.id}`, 'Manage')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="pt-2 border-t">
<p className="text-xs text-neutral-500">
These public links allow the contact to manage their subscription without logging in.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Contact"
description="Are you sure you want to delete this contact? This action cannot be undone."
confirmText="Delete"
variant="destructive"
/>
</DashboardLayout>
);
}
+767
View File
@@ -0,0 +1,767 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Switch,
} from '@plunk/ui';
import type {Contact} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {KeyValueEditor} from '../../components/KeyValueEditor';
import {network} from '../../lib/network';
import {
CheckCircle,
ChevronLeft,
ChevronRight,
Edit,
FileUp,
Mail,
MailCheck,
MailX,
Plus,
Search,
Trash2,
Upload,
XCircle,
} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useEffect, useRef, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {ContactSchemas} from '@plunk/shared';
interface PaginatedContacts {
contacts: Contact[];
total: number;
cursor?: string;
hasMore: boolean;
}
export default function ContactsPage() {
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorHistory, setCursorHistory] = useState<(string | undefined)[]>([undefined]);
const [currentPage, setCurrentPage] = useState(0);
const [contacts, setContacts] = useState<Contact[]>([]);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [contactToDelete, setContactToDelete] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState<number>(0);
const pageSize = 50;
const {data, mutate, isLoading} = useSWR<PaginatedContacts>(
`/contacts?limit=${pageSize}${cursor ? `&cursor=${cursor}` : ''}${search ? `&search=${search}` : ''}`,
{revalidateOnFocus: false},
);
// Update contacts when data changes
useEffect(() => {
if (data) {
setContacts(data.contacts);
if (!cursor) {
setTotalCount(data.total || data.contacts.length);
}
}
}, [data, cursor]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
};
const handleNextPage = () => {
if (data?.cursor) {
const newPage = currentPage + 1;
setCursor(data.cursor);
setCurrentPage(newPage);
// Store cursor in history if not already there
if (cursorHistory.length <= newPage) {
setCursorHistory(prev => [...prev, data.cursor]);
}
}
};
const handlePreviousPage = () => {
if (currentPage > 0) {
const newPage = currentPage - 1;
const previousCursor = cursorHistory[newPage];
setCursor(previousCursor);
setCurrentPage(newPage);
}
};
const promptDelete = (contactId: string) => {
setContactToDelete(contactId);
setShowDeleteDialog(true);
};
const handleDelete = async () => {
if (!contactToDelete) return;
try {
await network.fetch('DELETE', `/contacts/${contactToDelete}`);
toast.success('Contact deleted successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete contact');
} finally {
setContactToDelete(null);
}
};
return (
<>
<NextSeo title="Contacts" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-neutral-900">Contacts</h1>
<p className="text-neutral-500 mt-2">
Manage your email subscribers and their data.{' '}
{totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowImportDialog(true)}>
<Upload className="h-4 w-4" />
Import CSV
</Button>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Add Contact
</Button>
</div>
</div>
{/* Search & Filters */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
<Input
type="text"
placeholder="Search by email..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10"
/>
</div>
<Button type="submit">Search</Button>
{search && (
<Button
type="button"
variant="outline"
onClick={() => {
setSearch('');
setSearchInput('');
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}}
>
Clear
</Button>
)}
</form>
</CardContent>
</Card>
{/* Contacts Table */}
<Card>
<CardHeader>
<CardTitle>All Contacts</CardTitle>
<CardDescription>
View and manage your contact list.
{totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading && contacts.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading contacts...</p>
</div>
</div>
) : contacts.length === 0 ? (
<div className="text-center py-12">
<Mail className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No contacts found</h3>
<p className="text-neutral-500 mb-6">
{search ? 'Try adjusting your search terms' : 'Get started by creating your first contact'}
</p>
{!search && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Add Contact
</Button>
)}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{contacts.map(contact => (
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{contact.subscribed ? (
<MailCheck className="h-4 w-4 text-green-600" />
) : (
<MailX className="h-4 w-4 text-red-600" />
)}
<span className="text-sm font-medium text-neutral-900">{contact.email}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
contact.subscribed ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{contact.subscribed ? 'Subscribed' : 'Unsubscribed'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(contact.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
<Link href={`/contacts/${contact.id}`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={() => promptDelete(contact.id)}>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Controls */}
{(currentPage > 0 || data?.hasMore) && (
<div className="flex items-center justify-between mt-6 pt-6 border-t border-neutral-200">
<div className="text-sm text-neutral-600">
Showing <span className="font-medium text-neutral-900">{currentPage * pageSize + 1}</span> to{' '}
<span className="font-medium text-neutral-900">{currentPage * pageSize + contacts.length}</span>
{totalCount > 0 && (
<>
{' '}
of <span className="font-medium text-neutral-900">{totalCount.toLocaleString()}</span>
</>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handlePreviousPage} disabled={currentPage === 0 || isLoading}>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button variant="outline" onClick={handleNextPage} disabled={!data?.hasMore || isLoading}>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
{/* Create Contact Dialog */}
<CreateContactDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} onSuccess={() => mutate()} />
{/* Import Contacts Dialog */}
<ImportContactsDialog open={showImportDialog} onOpenChange={setShowImportDialog} onSuccess={() => mutate()} />
{/* Delete Confirmation Dialog */}
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Contact"
description="Are you sure you want to delete this contact? This action cannot be undone."
confirmText="Delete"
variant="destructive"
/>
</DashboardLayout>
</>
);
}
interface CreateContactDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
function CreateContactDialog({open, onOpenChange, onSuccess}: CreateContactDialogProps) {
const [email, setEmail] = useState('');
const [subscribed, setSubscribed] = useState(true);
const [customData, setCustomData] = useState<Record<string, string | number | boolean> | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const response = await network.fetch<
{
_meta?: {isNew: boolean; isUpdate: boolean};
email: string;
},
typeof ContactSchemas.create
>('POST', '/contacts', {email, subscribed, data: customData});
// Show appropriate message based on whether contact was new or updated
if (response._meta?.isUpdate) {
toast.success(`Contact ${response.email} already existed and was updated with new data`);
} else {
toast.success('Contact created successfully');
}
setEmail('');
setSubscribed(true);
setCustomData(null);
onOpenChange(false);
onSuccess();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to save contact');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Contact</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email Address *</Label>
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="contact@example.com"
/>
</div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<Switch id="subscribed" checked={subscribed} onCheckedChange={setSubscribed} />
<div className="flex-1">
<Label htmlFor="subscribed" className="font-medium cursor-pointer">
Subscribed
</Label>
<p className="text-xs text-neutral-500 mt-1">
When enabled, this contact will receive emails from your campaigns and workflows.
</p>
</div>
</div>
<div>
<KeyValueEditor key={open ? 'create' : 'closed'} initialData={customData} onChange={setCustomData} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Contact'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
interface ImportContactsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
interface ImportResult {
totalRows: number;
successCount: number;
createdCount: number;
updatedCount: number;
failureCount: number;
errors: Array<{row: number; email: string; error: string}>;
}
function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDialogProps) {
const [file, setFile] = useState<File | null>(null);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [jobId, setJobId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'completed' | 'failed'>('idle');
const [result, setResult] = useState<ImportResult | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
// Clean up polling on unmount or dialog close
useEffect(() => {
if (!open) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
// Reset state when dialog closes
setTimeout(() => {
setFile(null);
setJobId(null);
setProgress(0);
setStatus('idle');
setResult(null);
}, 300);
}
}, [open]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
// Validate file type
if (!selectedFile.name.endsWith('.csv')) {
toast.error('Please select a CSV file');
return;
}
// Validate file size (5MB max)
if (selectedFile.size > 5 * 1024 * 1024) {
toast.error('File size must be less than 5MB');
return;
}
setFile(selectedFile);
setStatus('idle');
}
};
const pollJobStatus = async (jobId: string) => {
try {
const response = await network.fetch<{
id: string;
state: string;
progress: number;
result: ImportResult | null;
}>('GET', `/contacts/import/${jobId}`);
setProgress(response.progress || 0);
if (response.state === 'completed') {
setStatus('completed');
setResult(response.result);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
// Show success message
if (response.result) {
const {createdCount, updatedCount, failureCount} = response.result;
const parts = [];
if (createdCount > 0) parts.push(`${createdCount} created`);
if (updatedCount > 0) parts.push(`${updatedCount} updated`);
if (failureCount > 0) parts.push(`${failureCount} failed`);
toast.success(`Import completed: ${parts.join(', ')}`);
}
onSuccess();
} else if (response.state === 'failed') {
setStatus('failed');
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
toast.error('Import failed. Please try again.');
} else if (response.state === 'active') {
setStatus('processing');
}
} catch (error) {
console.error('Failed to poll job status:', error);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setStatus('failed');
toast.error('Failed to check import status');
}
};
const handleUpload = async () => {
if (!file) {
toast.error('Please select a file to upload');
return;
}
setIsUploading(true);
setStatus('uploading');
try {
const formData = new FormData();
formData.append('file', file);
const data = await network.upload<{jobId: string; message: string}>('POST', '/contacts/import', formData);
setJobId(data.jobId);
setStatus('processing');
// Start polling for job status
pollIntervalRef.current = setInterval(() => {
void pollJobStatus(data.jobId);
}, 1000); // Poll every second
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to upload file');
setStatus('failed');
} finally {
setIsUploading(false);
}
};
const handleClose = () => {
if (status === 'processing') {
setShowCloseConfirmDialog(true);
return;
}
onOpenChange(false);
};
const confirmClose = () => {
onOpenChange(false);
};
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Import Contacts from CSV</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-2">CSV Format Requirements</h4>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>First row must contain column headers</li>
<li>
Required column: <code className="bg-blue-100 px-1 rounded">email</code>
</li>
<li>Optional: Add any custom fields (e.g., firstName, lastName, plan)</li>
<li>Maximum file size: 5MB</li>
</ul>
</div>
{/* File Upload */}
{status === 'idle' || status === 'failed' ? (
<div>
<Label htmlFor="csv-file">Select CSV File</Label>
<div className="mt-2">
<input
ref={fileInputRef}
id="csv-file"
type="file"
accept=".csv"
onChange={handleFileChange}
className="hidden"
/>
<Button
variant="outline"
className="w-full"
onClick={() => fileInputRef.current?.click()}
type="button"
>
<FileUp className="h-4 w-4 mr-2" />
{file ? file.name : 'Choose CSV File'}
</Button>
</div>
</div>
) : null}
{/* Progress */}
{(status === 'uploading' || status === 'processing') && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-600">
{status === 'uploading' ? 'Uploading file...' : 'Processing contacts...'}
</span>
<span className="text-neutral-900 font-medium">{progress}%</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{width: `${progress}%`}}
/>
</div>
</div>
)}
{/* Results */}
{status === 'completed' && result && (
<div className="space-y-3">
<div className="grid grid-cols-4 gap-3">
<div className="bg-neutral-50 rounded-lg p-4">
<div className="text-2xl font-bold text-neutral-900">{result.totalRows}</div>
<div className="text-sm text-neutral-600">Total</div>
</div>
<div className="bg-green-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<div className="text-2xl font-bold text-green-900">{result.createdCount}</div>
</div>
<div className="text-sm text-green-700">Created</div>
</div>
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-blue-600" />
<div className="text-2xl font-bold text-blue-900">{result.updatedCount}</div>
</div>
<div className="text-sm text-blue-700">Updated</div>
</div>
<div className="bg-red-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<XCircle className="h-5 w-5 text-red-600" />
<div className="text-2xl font-bold text-red-900">{result.failureCount}</div>
</div>
<div className="text-sm text-red-700">Failed</div>
</div>
</div>
{/* Error Details */}
{result.errors && result.errors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 max-h-48 overflow-y-auto">
<h4 className="font-medium text-red-900 mb-2">Import Errors</h4>
<div className="space-y-1 text-sm text-red-800">
{result.errors.slice(0, 10).map((error, idx) => (
<div key={idx} className="flex gap-2">
<span className="font-mono text-xs">Row {error.row}:</span>
<span>
{error.email || 'N/A'} - {error.error}
</span>
</div>
))}
{result.errors.length > 10 && (
<div className="text-red-700 font-medium mt-2">
...and {result.errors.length - 10} more errors
</div>
)}
</div>
</div>
)}
</div>
)}
{status === 'failed' && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-900">
<XCircle className="h-5 w-5" />
<span className="font-medium">Import failed</span>
</div>
<p className="text-sm text-red-800 mt-1">Please check your CSV file and try again.</p>
</div>
)}
</div>
<DialogFooter>
{status === 'idle' || status === 'failed' ? (
<>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="button" onClick={handleUpload} disabled={!file || isUploading}>
{isUploading ? 'Uploading...' : 'Import Contacts'}
</Button>
</>
) : status === 'completed' ? (
<Button type="button" onClick={handleClose}>
Close
</Button>
) : (
<Button type="button" variant="outline" onClick={handleClose}>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={showCloseConfirmDialog}
onOpenChange={setShowCloseConfirmDialog}
onConfirm={confirmClose}
title="Close Import"
description="Import is still in progress. Are you sure you want to close?"
confirmText="Close Anyway"
variant="destructive"
/>
</>
);
}
+146
View File
@@ -0,0 +1,146 @@
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@plunk/ui';
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
import {DashboardLayout} from '../components/DashboardLayout';
import {QuickStart} from '../components/QuickStart';
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
export default function Index() {
const {activeProject} = useActiveProject();
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
const stats = [
{
name: 'Total Contacts',
value: isLoading ? '-' : totalContacts.toLocaleString(),
icon: Users,
},
{
name: 'Emails Sent',
value: isLoading ? '-' : totalEmailsSent.toLocaleString(),
icon: Mail,
},
{
name: 'Campaigns',
value: isLoading ? '-' : totalCampaigns.toLocaleString(),
icon: Send,
},
{
name: 'Open Rate',
value: isLoading ? '-' : `${openRate.toFixed(1)}%`,
icon: TrendingUp,
},
];
return (
<>
<NextSeo title="Dashboard" />
<DashboardLayout>
<div className="space-y-8">
{/* Project Disabled Banner */}
{activeProject && activeProject.disabled && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Project Disabled - Read-Only Mode</AlertTitle>
<AlertDescription>
This project has been disabled due to security violations (high bounce or complaint rates). All scheduled
campaigns and workflows have been cancelled. The project is now in read-only mode - you can view your data
but cannot create, update, or delete anything. Please contact support to resolve this issue.
</AlertDescription>
</Alert>
)}
{/* Subscription Warning Banner */}
{activeProject && !activeProject.disabled && !activeProject.subscription && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Upgrade to remove Plunk branding</AlertTitle>
<AlertDescription className="flex items-center justify-between">
<span>Your emails currently include Plunk branding. Upgrade to a subscription to remove it.</span>
<Link href="/settings?tab=billing">
<Button size="sm">Upgrade Now</Button>
</Link>
</AlertDescription>
</Alert>
)}
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-neutral-900">Dashboard</h1>
<p className="text-neutral-500 mt-2">
Welcome back to {activeProject?.name || 'Plunk'}. Here&apos;s what&apos;s happening with your emails.
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map(stat => {
const Icon = stat.icon;
return (
<Card key={stat.name}>
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription>
<Icon className="h-4 w-4 text-neutral-500" />
</div>
<CardTitle className="text-2xl">{stat.value}</CardTitle>
</CardHeader>
</Card>
);
})}
</div>
{/* Quick Actions & API Keys */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Quick Start */}
<QuickStart setupState={setupState} isLoading={isLoadingSetupState} />
{/* API Keys */}
<Card>
<CardHeader>
<CardTitle>API Keys</CardTitle>
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activeProject ? (
<>
<ApiKeyDisplay
label="Public Key"
value={activeProject.public}
description="Use this key for client-side integrations"
/>
<ApiKeyDisplay
label="Secret Key"
value={activeProject.secret}
description="Keep this key secure and never expose it publicly"
isSecret
/>
</>
) : (
<p className="text-sm text-neutral-500">No project selected</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</DashboardLayout>
</>
);
}
+222
View File
@@ -0,0 +1,222 @@
import {Button, Card, CardContent} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {useRouter} from 'next/router';
import React, {useEffect, useState} from 'react';
import {network} from '../../lib/network';
interface ContactInfo {
id: string;
email: string;
subscribed: boolean;
}
export default function Manage() {
const router = useRouter();
const {id} = router.query;
const [contact, setContact] = useState<ContactInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [updating, setUpdating] = useState(false);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
useEffect(() => {
if (!id || typeof id !== 'string') return;
const fetchContact = async () => {
try {
setLoading(true);
const data = await network.fetch<ContactInfo>('GET', `/contacts/public/${id}`);
setContact(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load contact information');
} finally {
setLoading(false);
}
};
void fetchContact();
}, [id]);
const handleToggleSubscription = async () => {
if (!id || typeof id !== 'string' || !contact) return;
try {
setUpdating(true);
setSaveMessage(null);
const endpoint = contact.subscribed ? `/contacts/public/${id}/unsubscribe` : `/contacts/public/${id}/subscribe`;
const data = await network.fetch<ContactInfo>('POST', endpoint);
setContact(data);
setSaveMessage(data.subscribed ? 'Subscribed successfully!' : 'Unsubscribed successfully!');
setError(null);
// Clear success message after 3 seconds
setTimeout(() => setSaveMessage(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update subscription');
} finally {
setUpdating(false);
}
};
if (loading) {
return (
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4">
<svg
className="h-8 w-8 animate-spin text-neutral-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="text-sm text-neutral-500">Loading...</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
if (error && !contact) {
return (
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="h-12 w-12 rounded-full bg-red-100 flex items-center justify-center">
<svg
className="h-6 w-6 text-red-600"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-2xl font-bold text-neutral-900">Error</h1>
<p className="text-neutral-500">{error}</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
<Card>
<CardContent className="p-8">
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center text-center gap-2">
<h1 className="text-2xl font-bold text-neutral-900">Manage Preferences</h1>
<p className="text-neutral-500">
Manage email preferences for <strong>{contact?.email}</strong>
</p>
</div>
<div className="border rounded-lg p-6 bg-white">
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="font-medium text-neutral-900">Email Subscription</h3>
<p className="text-sm text-neutral-500 mt-1">
{contact?.subscribed
? 'You are currently subscribed to receive emails'
: 'You are currently unsubscribed from emails'}
</p>
</div>
<button
onClick={() => void handleToggleSubscription()}
disabled={updating}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2 ${
contact?.subscribed ? 'bg-neutral-900' : 'bg-neutral-200'
} ${updating ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
contact?.subscribed ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
<AnimatePresence>
{saveMessage && (
<motion.div
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-green-600 text-center bg-green-50 p-3 rounded-lg"
>
{saveMessage}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{error && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500 text-center"
>
{error}
</motion.p>
)}
</AnimatePresence>
<div className="flex gap-3">
{contact?.subscribed ? (
<Button
variant="outline"
className="w-full"
onClick={() => router.push(`/unsubscribe/${id as string}`)}
>
Unsubscribe completely
</Button>
) : (
<Button
variant="outline"
className="w-full"
onClick={() => router.push(`/subscribe/${id as string}`)}
>
Subscribe to emails
</Button>
)}
</div>
<div className="text-center text-xs text-neutral-400 mt-2">
<p>
This page allows you to manage your email preferences. Your subscription status is updated in
real-time.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
import {zodResolver} from '@hookform/resolvers/zod';
import {ProjectSchemas} from '@plunk/shared';
import {
Button,
Card,
CardContent,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import {useRouter} from 'next/router';
import React, {useState} from 'react';
import {useForm} from 'react-hook-form';
import type {z} from 'zod';
import {useProjects} from '../../lib/hooks/useProject';
import {network} from '../../lib/network';
export default function CreateProject() {
const {mutate: projectsMutate} = useProjects();
const router = useRouter();
const form = useForm<z.infer<typeof ProjectSchemas.create>>({
resolver: zodResolver(ProjectSchemas.create),
defaultValues: {
name: '',
},
});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
async function onSubmit(values: z.infer<typeof ProjectSchemas.create>) {
try {
await network.fetch<{success: boolean}, typeof ProjectSchemas.create>('POST', '/users/@me/projects', values);
// Refresh the projects list
await projectsMutate();
// Redirect to dashboard
await router.push('/');
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong');
}
}
return (
<>
<NextSeo title="Create Project" />
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
<Card>
<CardContent className="p-0">
<Form {...form}>
<form
onSubmit={e => {
e.preventDefault();
void form.handleSubmit(onSubmit)(e);
}}
className="p-6 md:p-8"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold">Create your first project</h1>
<p className="text-balance text-neutral-500">
Get started by creating a project to organize your emails
</p>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input placeholder="My Awesome Project" {...field} />
</FormControl>
<FormDescription>
Choose a descriptive name for your project. You can always change it later.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)}
</AnimatePresence>
<motion.div layout>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Create Project'
)}
</Button>
</motion.div>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
</div>
</>
);
}
+590
View File
@@ -0,0 +1,590 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import type {Contact, Segment} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {
ArrowLeft,
Calendar,
Database,
Filter,
MailCheck,
MailX,
Plus,
RefreshCw,
Save,
Trash2,
Users,
} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import type {SegmentFilter} from '@plunk/types';
import {SegmentSchemas} from '@plunk/shared';
const FILTER_OPERATORS = [
{value: 'equals', label: 'Equals'},
{value: 'notEquals', label: 'Not equals'},
{value: 'contains', label: 'Contains'},
{value: 'notContains', label: 'Does not contain'},
{value: 'greaterThan', label: 'Greater than'},
{value: 'lessThan', label: 'Less than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Within (time)'},
] as const;
const TIME_UNITS = [
{value: 'minutes', label: 'Minutes'},
{value: 'hours', label: 'Hours'},
{value: 'days', label: 'Days'},
] as const;
const FIELD_PRESETS = [
{value: 'email', label: 'Email', type: 'string'},
{value: 'subscribed', label: 'Subscribed', type: 'boolean'},
{value: 'createdAt', label: 'Created At', type: 'date'},
{value: 'updatedAt', label: 'Updated At', type: 'date'},
{value: 'data.firstName', label: 'First Name (custom)', type: 'string'},
{value: 'data.lastName', label: 'Last Name (custom)', type: 'string'},
{value: 'data.plan', label: 'Plan (custom)', type: 'string'},
] as const;
interface PaginatedContacts {
contacts: Contact[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export default function SegmentDetailPage() {
const router = useRouter();
const {id} = router.query;
const {data: segment, mutate, isLoading} = useSWR<Segment>(id ? `/segments/${id}` : null);
const [contactsPage, setContactsPage] = useState(1);
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedContacts>(
id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null,
);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [trackMembership, setTrackMembership] = useState(false);
const [filters, setFilters] = useState<SegmentFilter[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isComputing, setIsComputing] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Initialize form when segment loads
useEffect(() => {
if (segment) {
setName(segment.name);
setDescription(segment.description || '');
setTrackMembership(segment.trackMembership);
setFilters((segment.filters as unknown as SegmentFilter[]) || []);
}
}, [segment]);
const addFilter = () => {
setFilters([...filters, {field: 'email', operator: 'contains', value: ''}]);
};
const removeFilter = (index: number) => {
setFilters(filters.filter((_, i) => i !== index));
};
const updateFilter = (index: number, updates: Partial<SegmentFilter>) => {
setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter)));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
name,
description: description || undefined,
filters,
trackMembership,
});
toast.success('Segment updated successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update segment');
} finally {
setIsSubmitting(false);
}
};
const handleComputeMembership = async () => {
if (!trackMembership) {
toast.error('Membership tracking must be enabled to compute membership');
return;
}
setIsComputing(true);
try {
const result = await network.fetch<{added: number; removed: number; total: number}>(
'POST',
`/segments/${id}/compute`,
);
toast.success(`Membership updated: ${result.added} added, ${result.removed} removed, ${result.total} total`);
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to compute membership');
} finally {
setIsComputing(false);
}
};
const handleDelete = async () => {
try {
await network.fetch('DELETE', `/segments/${id}`);
toast.success('Segment deleted successfully');
void router.push('/segments');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete segment');
}
};
const needsValue = (operator: string) => {
return !['exists', 'notExists'].includes(operator);
};
const needsUnit = (operator: string) => {
return operator === 'within';
};
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading segment...</p>
</div>
</div>
</DashboardLayout>
);
}
if (!segment) {
return (
<DashboardLayout>
<div className="text-center py-12">
<h3 className="text-lg font-medium text-neutral-900 mb-2">Segment not found</h3>
<p className="text-neutral-500 mb-6">
The segment you&apos;re looking for doesn&apos;t exist or has been deleted.
</p>
<Link href="/segments">
<Button>
<ArrowLeft className="h-4 w-4" />
Back to Segments
</Button>
</Link>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/segments">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
<p className="text-neutral-500 mt-1">{segment.description}</p>
</div>
</div>
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
<Trash2 className="h-4 w-4" />
Delete Segment
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Edit Form */}
<div className="lg:col-span-2 space-y-6">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Segment Details</CardTitle>
<CardDescription>Update segment name and description</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Segment Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g., Active Pro Users"
maxLength={100}
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="e.g., Users on pro plan who have been active in the last 30 days"
maxLength={500}
/>
</div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<input
id="trackMembership"
type="checkbox"
checked={trackMembership}
onChange={e => setTrackMembership(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
Track membership changes
</Label>
<p className="text-xs text-neutral-500 mt-1">
When enabled, segment entry and exit events will be tracked for use in workflows and analytics
</p>
</div>
</div>
</CardContent>
</Card>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Filters</CardTitle>
<CardDescription>Define conditions to match contacts</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add Filter
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{filters.map((filter, index) => (
<div key={index} className="flex items-start gap-2 p-4 border border-neutral-200 rounded-lg">
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
{/* Field */}
<div>
<Label className="text-xs">Field</Label>
<Select value={filter.field} onValueChange={value => updateFilter(index, {field: value})}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_PRESETS.map(preset => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
<SelectItem value="custom">Custom field...</SelectItem>
</SelectContent>
</Select>
{filter.field === 'custom' && (
<Input
type="text"
placeholder="e.g., data.customField"
className="mt-2"
onChange={e => updateFilter(index, {field: e.target.value})}
/>
)}
</div>
{/* Operator */}
<div>
<Label className="text-xs">Operator</Label>
<Select
value={filter.operator}
onValueChange={value => updateFilter(index, {operator: value as SegmentFilter['operator']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Value */}
{needsValue(filter.operator) && (
<div>
<Label className="text-xs">Value</Label>
{filter.field === 'subscribed' ? (
<Select
value={filter.value?.toString()}
onValueChange={value => updateFilter(index, {value: value === 'true'})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type="text"
value={filter.value ?? ''}
onChange={e => updateFilter(index, {value: e.target.value})}
placeholder="Enter value"
/>
)}
</div>
)}
{/* Unit */}
{needsUnit(filter.operator) && (
<div>
<Label className="text-xs">Unit</Label>
<Select
value={filter.unit ?? 'days'}
onValueChange={value => updateFilter(index, {unit: value as SegmentFilter['unit']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map(unit => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(index)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 mt-6"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end">
<Button type="submit" disabled={isSubmitting || filters.length === 0}>
<Save className="h-4 w-4" />
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
{/* Contacts */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Matching Contacts</CardTitle>
<CardDescription>Contacts that match this segment&apos;s filters</CardDescription>
</div>
{trackMembership && (
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
{isComputing ? 'Computing...' : 'Recompute'}
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoadingContacts ? (
<div className="text-center py-8">
<p className="text-sm text-neutral-500">Loading contacts...</p>
</div>
) : contactsData?.contacts.length === 0 ? (
<div className="text-center py-8">
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<p className="text-neutral-500">No contacts match this segment</p>
</div>
) : (
<>
<div className="space-y-2">
{contactsData?.contacts.map(contact => (
<div key={contact.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-2">
{contact.subscribed ? (
<MailCheck className="h-4 w-4 text-green-600" />
) : (
<MailX className="h-4 w-4 text-red-600" />
)}
<span className="text-sm font-medium">{contact.email}</span>
</div>
<Link href={`/contacts/${contact.id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
</div>
))}
</div>
{/* Pagination */}
{contactsData && contactsData.totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-sm text-neutral-500">
Page {contactsPage} of {contactsData.totalPages} ({contactsData.total} total)
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setContactsPage(p => p - 1)}
disabled={contactsPage === 1}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setContactsPage(p => p + 1)}
disabled={contactsPage === contactsData.totalPages}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
{/* Metadata Sidebar */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Members</span>
</div>
<span className="text-2xl font-bold text-neutral-900">{segment.memberCount}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Filters</span>
</div>
<span className="text-lg font-semibold text-neutral-900">
{Array.isArray(segment.filters) ? segment.filters.length : 0}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Created</p>
<p className="text-sm text-neutral-500">{new Date(segment.createdAt).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<p className="text-sm text-neutral-500">{new Date(segment.updatedAt).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-900">Segment ID</p>
<p className="text-xs text-neutral-500 font-mono break-all">{segment.id}</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Segment"
description="Are you sure you want to delete this segment? This action cannot be undone."
confirmText="Delete"
variant="destructive"
/>
</DashboardLayout>
);
}
+199
View File
@@ -0,0 +1,199 @@
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
} from '@plunk/ui';
import type {Segment} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {AlertTriangle, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
export default function SegmentsPage() {
// Limit to 50 segments to avoid loading thousands into the browser
const {
data: segments,
mutate,
isLoading,
} = useSWR<Segment[]>('/segments', {
revalidateOnFocus: false,
});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [segmentToDelete, setSegmentToDelete] = useState<string | null>(null);
// Show warning if there are many segments
const showLimitWarning = segments && segments.length >= 50;
const handleDelete = async () => {
if (!segmentToDelete) return;
try {
await network.fetch('DELETE', `/segments/${segmentToDelete}`);
toast.success('Segment deleted successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete segment');
} finally {
setSegmentToDelete(null);
}
};
return (
<>
<NextSeo title="Segments" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-neutral-900">Segments</h1>
<p className="text-neutral-500 mt-2">
Create dynamic audience groups based on contact attributes and behaviors
</p>
</div>
<Link href="/segments/new">
<Button>
<Plus className="h-4 w-4" />
Create Segment
</Button>
</Link>
</div>
{/* Warning if too many segments */}
{showLimitWarning && (
<Alert variant="default">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Showing first {segments?.length} segments. Consider archiving old segments to improve performance.
</AlertDescription>
</Alert>
)}
{/* Segments Grid */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading segments...</p>
</div>
</div>
) : segments?.length === 0 ? (
<Card>
<CardContent className="py-12">
<div className="text-center">
<Filter className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No segments yet</h3>
<p className="text-neutral-500 mb-6">
Create your first segment to group contacts based on attributes and behaviors
</p>
<Link href="/segments/new">
<Button>
<Plus className="h-4 w-4" />
Create Segment
</Button>
</Link>
</div>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{segments?.map(segment => (
<Card key={segment.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg">{segment.name}</CardTitle>
{segment.description && <CardDescription className="mt-1">{segment.description}</CardDescription>}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Stats */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Members</span>
</div>
<span className="text-lg font-semibold text-neutral-900">{segment.memberCount}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Filters</span>
</div>
<span className="text-sm font-medium text-neutral-900">
{Array.isArray(segment.filters) ? segment.filters.length : 0}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-neutral-200">
<Link href={`/segments/${segment.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
<Edit className="h-4 w-4" />
Edit
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSegmentToDelete(segment.id);
setShowDeleteDialog(true);
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Metadata */}
<div className="text-xs text-neutral-500 pt-2 border-t border-neutral-200">
Created {new Date(segment.createdAt).toLocaleDateString()}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Segment"
description="Are you sure you want to delete this segment? This action cannot be undone."
confirmText="Delete"
variant="destructive"
/>
</DashboardLayout>
</>
);
}
+335
View File
@@ -0,0 +1,335 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {ArrowLeft, Filter, Plus, Save, Trash2} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useState} from 'react';
import {toast} from 'sonner';
import type {SegmentFilter} from '@plunk/types';
import type {Segment} from '@plunk/db';
import {SegmentSchemas} from '@plunk/shared';
const FILTER_OPERATORS = [
{value: 'equals', label: 'Equals'},
{value: 'notEquals', label: 'Not equals'},
{value: 'contains', label: 'Contains'},
{value: 'notContains', label: 'Does not contain'},
{value: 'greaterThan', label: 'Greater than'},
{value: 'lessThan', label: 'Less than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Within (time)'},
] as const;
const TIME_UNITS = [
{value: 'minutes', label: 'Minutes'},
{value: 'hours', label: 'Hours'},
{value: 'days', label: 'Days'},
] as const;
const FIELD_PRESETS = [
{value: 'email', label: 'Email', type: 'string'},
{value: 'subscribed', label: 'Subscribed', type: 'boolean'},
{value: 'createdAt', label: 'Created At', type: 'date'},
{value: 'updatedAt', label: 'Updated At', type: 'date'},
{value: 'data.firstName', label: 'First Name (custom)', type: 'string'},
{value: 'data.lastName', label: 'Last Name (custom)', type: 'string'},
{value: 'data.plan', label: 'Plan (custom)', type: 'string'},
] as const;
export default function NewSegmentPage() {
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [trackMembership, setTrackMembership] = useState(false);
const [filters, setFilters] = useState<SegmentFilter[]>([{field: 'subscribed', operator: 'equals', value: true}]);
const [isSubmitting, setIsSubmitting] = useState(false);
const addFilter = () => {
setFilters([...filters, {field: 'email', operator: 'contains', value: ''}]);
};
const removeFilter = (index: number) => {
setFilters(filters.filter((_, i) => i !== index));
};
const updateFilter = (index: number, updates: Partial<SegmentFilter>) => {
setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter)));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
name,
description: description || undefined,
filters,
trackMembership,
});
toast.success('Segment created successfully');
void router.push('/segments');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
} finally {
setIsSubmitting(false);
}
};
const needsValue = (operator: string) => {
return !['exists', 'notExists'].includes(operator);
};
const needsUnit = (operator: string) => {
return operator === 'within';
};
return (
<>
<NextSeo title="Create Segment" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/segments">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
<p className="text-neutral-500 mt-1">Define filters to automatically group contacts</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Segment Details</CardTitle>
<CardDescription>Give your segment a name and description</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Segment Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g., Active Pro Users"
maxLength={100}
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="e.g., Users on pro plan who have been active in the last 30 days"
maxLength={500}
/>
</div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<input
id="trackMembership"
type="checkbox"
checked={trackMembership}
onChange={e => setTrackMembership(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
Track membership changes
</Label>
<p className="text-xs text-neutral-500 mt-1">
When enabled, segment entry and exit events will be tracked for use in workflows and analytics
</p>
</div>
</div>
</CardContent>
</Card>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Filters</CardTitle>
<CardDescription>Define conditions to match contacts (all filters must match)</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add Filter
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{filters.length === 0 ? (
<div className="text-center py-8">
<Filter className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<p className="text-neutral-500 mb-4">No filters defined. Add at least one filter.</p>
<Button type="button" variant="outline" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add First Filter
</Button>
</div>
) : (
filters.map((filter, index) => (
<div key={index} className="flex items-start gap-2 p-4 border border-neutral-200 rounded-lg">
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
{/* Field */}
<div>
<Label className="text-xs">Field</Label>
<Select value={filter.field} onValueChange={value => updateFilter(index, {field: value})}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_PRESETS.map(preset => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
<SelectItem value="custom">Custom field...</SelectItem>
</SelectContent>
</Select>
{filter.field === 'custom' && (
<Input
type="text"
placeholder="e.g., data.customField"
className="mt-2"
onChange={e => updateFilter(index, {field: e.target.value})}
/>
)}
</div>
{/* Operator */}
<div>
<Label className="text-xs">Operator</Label>
<Select
value={filter.operator}
onValueChange={value => updateFilter(index, {operator: value as SegmentFilter['operator']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Value */}
{needsValue(filter.operator) && (
<div>
<Label className="text-xs">Value</Label>
{filter.field === 'subscribed' ? (
<Select
value={filter.value?.toString()}
onValueChange={value => updateFilter(index, {value: value === 'true'})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type="text"
value={filter.value ?? ''}
onChange={e => updateFilter(index, {value: e.target.value})}
placeholder="Enter value"
/>
)}
</div>
)}
{/* Unit (for within operator) */}
{needsUnit(filter.operator) && (
<div>
<Label className="text-xs">Unit</Label>
<Select
value={filter.unit ?? 'days'}
onValueChange={value => updateFilter(index, {unit: value as SegmentFilter['unit']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map(unit => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* Remove button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(index)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 mt-6"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<Link href="/segments">
<Button type="button" variant="outline" disabled={isSubmitting}>
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting || filters.length === 0}>
<Save className="h-4 w-4" />
{isSubmitting ? 'Creating...' : 'Create Segment'}
</Button>
</div>
</form>
</div>
</DashboardLayout>
</>
);
}
+551
View File
@@ -0,0 +1,551 @@
import {useEffect, useState} from 'react';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {ProjectSchemas} from '@plunk/shared';
import {
Alert,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Switch,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import {AlertTriangle, CreditCard, Globe, Mail, Settings as SettingsIcon} from 'lucide-react';
import type {z} from 'zod';
import {useRouter} from 'next/router';
import {DashboardLayout} from '../../components/DashboardLayout';
import {DomainsSettings} from '../../components/DomainsSettings';
import {BillingLimits} from '../../components/BillingLimits';
import {BillingConsumption} from '../../components/BillingConsumption';
import {BillingInvoices} from '../../components/BillingInvoices';
import {UnpaidInvoiceBanner} from '../../components/UnpaidInvoiceBanner';
import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
import {SmtpSettings} from '../../components/SmtpSettings';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
import {network} from '../../lib/network';
import {useProjects} from '../../lib/hooks/useProject';
import {useConfig} from '../../lib/hooks/useConfig';
type TabId = 'general' | 'billing' | 'domains' | 'smtp';
interface Tab {
id: TabId;
label: string;
icon: typeof SettingsIcon;
condition?: boolean;
}
const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Tab[] => {
const {billingEnabled, smtpEnabled} = options;
const allTabs: Tab[] = [
{id: 'general', label: 'General', icon: SettingsIcon},
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
{id: 'domains', label: 'Domains', icon: Globe},
{id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled},
];
return allTabs.filter(tab => tab.condition !== false);
};
export default function Settings() {
const router = useRouter();
const {activeProject, setActiveProject} = useActiveProject();
const {mutate: projectsMutate} = useProjects();
const {data: config} = useConfig();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
const [isLoadingBilling, setIsLoadingBilling] = useState(false);
const billingEnabled = config?.features.billing.enabled ?? false;
const smtpEnabled = config?.features.smtp.enabled ?? false;
const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false;
const smtpConfig = smtpEnabled
? {
enabled: true as const,
domain: config?.features.smtp.domain ?? undefined,
portSecure: config?.features.smtp.ports?.secure,
portSubmission: config?.features.smtp.ports?.submission,
}
: {enabled: false as const};
// Get current tab from URL or default to 'general'
const currentTab = (router.query.tab as TabId) || 'general';
// Set default tab in URL if none is present
useEffect(() => {
if (!router.query.tab && router.isReady) {
router.replace('/settings?tab=general', undefined, {shallow: true});
}
}, [router]);
// Handler to change tabs and update URL
const handleTabChange = (newTab: string) => {
router.push(`/settings?tab=${newTab}`, undefined, {shallow: true});
};
// Handle Stripe redirect success/cancel messages
useEffect(() => {
if (!router.isReady) return;
if (router.query.success === 'true') {
// Use setTimeout to defer state update, avoiding synchronous setState in effect
const timer = setTimeout(() => {
setSuccessMessage('Subscription activated successfully! It may take a moment to update.');
// Clear message and URL after 5 seconds
setTimeout(() => {
setSuccessMessage(null);
router.replace('/settings?tab=billing', undefined, {shallow: true});
}, 5000);
}, 0);
return () => clearTimeout(timer);
} else if (router.query.canceled === 'true') {
// Use setTimeout to defer state update, avoiding synchronous setState in effect
const timer = setTimeout(() => {
setErrorMessage('Checkout was canceled. You can try again anytime.');
// Clear message and URL after 5 seconds
setTimeout(() => {
setErrorMessage(null);
router.replace('/settings?tab=billing', undefined, {shallow: true});
}, 5000);
}, 0);
return () => clearTimeout(timer);
}
}, [router]);
const form = useForm<z.infer<typeof ProjectSchemas.update>>({
resolver: zodResolver(ProjectSchemas.update),
defaultValues: {
name: activeProject?.name || '',
trackingEnabled: activeProject?.trackingEnabled ?? true,
},
});
// Update form when active project changes
useEffect(() => {
if (activeProject) {
form.reset({
name: activeProject.name,
trackingEnabled: activeProject.trackingEnabled ?? true,
});
}
}, [activeProject, form]);
const onSubmit = async (values: z.infer<typeof ProjectSchemas.update>) => {
if (!activeProject) return;
try {
setErrorMessage(null);
setSuccessMessage(null);
const updatedProject = await network.fetch<typeof activeProject, typeof ProjectSchemas.update>(
'PATCH',
`/users/@me/projects/${activeProject.id}`,
values,
);
// Update the active project in context
setActiveProject(updatedProject);
// Refresh projects list
await projectsMutate();
setSuccessMessage('Project settings updated successfully');
// Clear success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to update project settings');
}
};
const handleRegenerateKeys = async () => {
if (!activeProject) return;
try {
setErrorMessage(null);
setSuccessMessage(null);
const updatedProject = await network.fetch<typeof activeProject>(
'POST',
`/users/@me/projects/${activeProject.id}/regenerate-keys`,
);
// Update the active project in context
setActiveProject(updatedProject);
// Refresh projects list
await projectsMutate();
setSuccessMessage('API keys regenerated successfully');
setShowRegenerateDialog(false);
// Clear success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate API keys');
setShowRegenerateDialog(false);
}
};
const promptRegenerateKeys = () => {
setShowRegenerateDialog(true);
};
const handleStartSubscription = async () => {
if (!activeProject) return;
if (!billingEnabled) {
setErrorMessage('Billing is disabled on this instance.');
return;
}
try {
setIsLoadingBilling(true);
setErrorMessage(null);
const response = await network.fetch<{url: string}>('POST', `/users/@me/projects/${activeProject.id}/checkout`);
// Redirect to Stripe checkout
if (response.url) {
window.location.href = response.url;
}
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to start checkout');
setIsLoadingBilling(false);
}
};
const handleManageBilling = async () => {
if (!activeProject) return;
if (!billingEnabled) {
setErrorMessage('Billing is disabled on this instance.');
return;
}
try {
setIsLoadingBilling(true);
setErrorMessage(null);
const response = await network.fetch<{url: string}>(
'POST',
`/users/@me/projects/${activeProject.id}/billing-portal`,
);
// Redirect to Stripe billing portal
if (response.url) {
window.location.href = response.url;
}
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to open billing portal');
setIsLoadingBilling(false);
}
};
if (!activeProject) {
return (
<>
<NextSeo title="Settings" />
<DashboardLayout>
<div className="flex items-center justify-center h-96">
<p className="text-neutral-500">No project selected</p>
</div>
</DashboardLayout>
</>
);
}
return (
<>
<NextSeo title="Settings" />
<DashboardLayout>
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-neutral-900">Settings</h1>
<p className="text-neutral-500 mt-2">Manage your project settings and preferences</p>
</div>
{/* Tabs */}
<Tabs value={currentTab} onValueChange={handleTabChange} className="max-w-4xl">
<TabsList>
{buildTabs({billingEnabled, smtpEnabled}).map(tab => {
const Icon = tab.icon;
return (
<TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
);
})}
</TabsList>
{/* General Tab */}
<TabsContent value="general">
<Card>
<CardHeader>
<CardTitle>Project Settings</CardTitle>
<CardDescription>Update your project name and basic information</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input placeholder="My Awesome Project" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Email Tracking Toggle - only show if feature is available */}
{trackingToggleEnabled && (
<FormField
control={form.control}
name="trackingEnabled"
render={({field}) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-neutral-200 p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Email Tracking</FormLabel>
<FormDescription>
Enable open and click tracking for emails sent from this project. When disabled, emails
will be sent without tracking pixels.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
<div className="flex justify-end">
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
{/* API Keys */}
<div className="space-y-4 pt-4 border-t border-neutral-200">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-neutral-900">API Keys</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={promptRegenerateKeys}
className="text-xs"
>
Regenerate Keys
</Button>
</div>
<ApiKeyDisplay
label="Public API Key"
value={activeProject.public}
description="Use this key for client-side integrations"
/>
<ApiKeyDisplay
label="Secret API Key"
value={activeProject.secret}
description="Keep this key secure and never expose it publicly"
isSecret
/>
</div>
{/* 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>
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
{/* Billing Tab */}
<TabsContent value="billing">
<div className="space-y-6">
{/* Unpaid Invoice Banner */}
<UnpaidInvoiceBanner projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
<Card>
<CardHeader>
<CardTitle>Billing & Subscription</CardTitle>
<CardDescription>Manage your subscription and billing information</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>
{activeProject.subscription ? (
// Has subscription - show billing portal button
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-2 text-green-800 mb-2">
<CreditCard className="h-5 w-5" />
<span className="font-medium">Active Subscription</span>
</div>
<p className="text-sm text-green-700">
Your subscription is active. Manage your billing details, update payment methods, or cancel
your subscription through the billing portal.
</p>
</div>
<div className="flex justify-start">
<Button onClick={handleManageBilling} disabled={isLoadingBilling}>
{isLoadingBilling ? 'Loading...' : 'Manage Billing'}
</Button>
</div>
</div>
) : (
// No subscription - show start subscription button
<div className="space-y-4">
<div className="p-4 bg-neutral-50 border border-neutral-200 rounded-lg">
<div className="flex items-center gap-2 text-neutral-800 mb-2">
<CreditCard className="h-5 w-5" />
<span className="font-medium">No Active Subscription</span>
</div>
<p className="text-sm text-neutral-600">
Start a subscription to unlock premium features and support the development of Plunk.
</p>
</div>
<div className="flex justify-start">
<Button onClick={handleStartSubscription} disabled={isLoadingBilling}>
{isLoadingBilling ? 'Loading...' : 'Start Subscription'}
</Button>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Billing Limits */}
<BillingLimits projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
{/* Current Month Consumption */}
<BillingConsumption projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
{/* Past Invoices */}
<BillingInvoices
projectId={activeProject.id}
hasSubscription={!!activeProject.subscription}
onManageBilling={handleManageBilling}
/>
</div>
</TabsContent>
{/* Domains Tab */}
<TabsContent value="domains">
<DomainsSettings projectId={activeProject.id} />
</TabsContent>
{/* SMTP Tab */}
<TabsContent value="smtp">
<SmtpSettings smtpConfig={smtpConfig} />
</TabsContent>
</Tabs>
</div>
{/* Regenerate Keys Confirmation Dialog */}
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-500" />
Regenerate API Keys
</DialogTitle>
<DialogDescription className="space-y-2">
<p>Are you sure you want to regenerate your API keys?</p>
<Alert className="bg-orange-50 border-orange-200 text-orange-900 text-xs">
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<strong>Warning:</strong> This action will immediately invalidate your current API keys. Any
applications using the old keys will stop working until you update them with the new keys.
</div>
</Alert>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRegenerateDialog(false)}>
Cancel
</Button>
<Button variant="default" onClick={handleRegenerateKeys} className="bg-orange-600 hover:bg-orange-700">
Regenerate Keys
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardLayout>
</>
);
}
+207
View File
@@ -0,0 +1,207 @@
import {Button, Card, CardContent} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {useRouter} from 'next/router';
import React, {useEffect, useState} from 'react';
import {network} from '../../lib/network';
interface ContactInfo {
id: string;
email: string;
subscribed: boolean;
}
export default function Subscribe() {
const router = useRouter();
const {id} = router.query;
const [contact, setContact] = useState<ContactInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [subscribing, setSubscribing] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!id || typeof id !== 'string') return;
const fetchContact = async () => {
try {
setLoading(true);
const data = await network.fetch<ContactInfo>('GET', `/contacts/public/${id}`);
setContact(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load contact information');
} finally {
setLoading(false);
}
};
void fetchContact();
}, [id]);
const handleSubscribe = async () => {
if (!id || typeof id !== 'string') return;
try {
setSubscribing(true);
const data = await network.fetch<ContactInfo>('POST', `/contacts/public/${id}/subscribe`);
setContact(data);
setSuccess(true);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to subscribe');
} finally {
setSubscribing(false);
}
};
if (loading) {
return (
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4">
<svg
className="h-8 w-8 animate-spin text-neutral-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="text-sm text-neutral-500">Loading...</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
if (error && !contact) {
return (
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="h-12 w-12 rounded-full bg-red-100 flex items-center justify-center">
<svg
className="h-6 w-6 text-red-600"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-2xl font-bold text-neutral-900">Error</h1>
<p className="text-neutral-500">{error}</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
if (success || (contact && contact.subscribed)) {
return (
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<motion.div
initial={{scale: 0}}
animate={{scale: 1}}
transition={{type: 'spring', stiffness: 200, damping: 15}}
className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center"
>
<svg
className="h-6 w-6 text-green-600"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7" />
</svg>
</motion.div>
<h1 className="text-2xl font-bold text-neutral-900">You&apos;re subscribed!</h1>
<p className="text-neutral-500">{contact?.email} is now subscribed to receive emails from us.</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
<Card>
<CardContent className="p-8">
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center text-center gap-2">
<h1 className="text-2xl font-bold text-neutral-900">Subscribe to updates</h1>
<p className="text-neutral-500">
Would you like to subscribe <strong>{contact?.email}</strong> to receive emails?
</p>
</div>
<AnimatePresence>
{error && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500 text-center"
>
{error}
</motion.p>
)}
</AnimatePresence>
<Button onClick={() => void handleSubscribe()} className="w-full" disabled={subscribing}>
{subscribing ? (
<div className="flex items-center gap-2">
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Subscribing...</span>
</div>
) : (
'Subscribe'
)}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
StickySaveBar,
} from '@plunk/ui';
import type {Template} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor';
import {network} from '../../lib/network';
import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
import {ArrowLeft, Save} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {TemplateSchemas} from '@plunk/shared';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
export default function TemplateEditorPage() {
const router = useRouter();
const {id} = router.query;
const {activeProject} = useActiveProject();
const {data: template, mutate} = useSWR<Template>(id ? `/templates/${id}` : null, {
revalidateOnFocus: false,
});
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [subject, setSubject] = useState('');
const [from, setFrom] = useState('');
const [fromName, setFromName] = useState('');
const [replyTo, setReplyTo] = useState('');
const [body, setBody] = useState('');
const [type, setType] = useState<'MARKETING' | 'TRANSACTIONAL'>('MARKETING');
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Load template data into form
useEffect(() => {
if (template) {
setName(template.name);
setDescription(template.description ?? '');
setSubject(template.subject);
setFrom(template.from);
setFromName(template.fromName ?? '');
setReplyTo(template.replyTo ?? '');
setBody(template.body);
setType(template.type);
// Reset hasChanges when loading fresh data
setHasChanges(false);
}
}, [template]);
// Track changes
useEffect(() => {
if (!template) return;
const changed =
name !== template.name ||
description !== (template.description ?? '') ||
subject !== template.subject ||
from !== template.from ||
fromName !== (template.fromName ?? '') ||
replyTo !== (template.replyTo ?? '') ||
body !== template.body ||
type !== template.type;
setHasChanges(changed);
}, [name, description, subject, from, fromName, replyTo, body, type, template]);
// Warn before leaving page with unsaved changes
useChangeTracking(hasChanges);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await network.fetch<Template, typeof TemplateSchemas.update>('PATCH', `/templates/${id}`, {
name,
description: description || undefined,
subject,
body,
from,
fromName: fromName || undefined,
replyTo: replyTo || undefined,
type,
});
// Silent save - no toast notification
setHasChanges(false);
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to save template');
} finally {
setIsSubmitting(false);
}
};
if (!template) {
return (
<DashboardLayout>
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading template...</p>
</div>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<form onSubmit={handleSave} className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/templates">
<Button type="button" variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold text-neutral-900">Edit Template</h1>
<p className="text-neutral-500 mt-1">Make changes to your email template</p>
</div>
</div>
<div className="flex items-center gap-3">
{!hasChanges && !isSubmitting && <span className="text-sm text-neutral-500">All changes saved</span>}
{hasChanges && !isSubmitting && <span className="text-sm text-amber-600">Unsaved changes</span>}
<Button type="submit" disabled={!hasChanges || isSubmitting}>
<Save className="h-4 w-4" />
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
{/* Template Editor */}
<div className="max-w-5xl mx-auto space-y-6">
{/* Template Settings */}
<div>
<Card>
<CardHeader>
<CardTitle>Template Settings</CardTitle>
<CardDescription>Configure the basic settings for your template</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Template Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Welcome Email"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Sent to new subscribers"
/>
</div>
<div>
<Label htmlFor="type">Type *</Label>
<Select value={type} onValueChange={value => setType(value as 'MARKETING' | 'TRANSACTIONAL')}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MARKETING">Marketing</SelectItem>
<SelectItem value="TRANSACTIONAL">Transactional</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-neutral-500 mt-1">
Marketing templates will automatically include a Plunk-hosted unsubscribe link.
</p>
</div>
<div>
<Label htmlFor="subject">Subject Line *</Label>
<Input
id="subject"
type="text"
value={subject}
onChange={e => setSubject(e.target.value)}
required
placeholder="Welcome to our platform!"
/>
<p className="text-xs text-neutral-500 mt-1">Use {'{{variableName}}'} for dynamic content</p>
</div>
<EmailSettings
from={from}
fromName={fromName}
replyTo={replyTo}
onFromChange={setFrom}
onFromNameChange={setFromName}
onReplyToChange={setReplyTo}
fromNamePlaceholder={activeProject?.name || 'Your Company'}
showFromNameHelpText
layout="vertical"
/>
</CardContent>
</Card>
</div>
{/* Email Body */}
<div>
<Card>
<CardHeader>
<CardTitle>Email Body</CardTitle>
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor
value={body}
onChange={newBody => {
setBody(newBody);
setHasChanges(true);
}}
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
canUploadImages={true}
subject={subject}
from={from}
replyTo={replyTo}
/>
</CardContent>
</Card>
</div>
</div>
</form>
{/* Sticky Save Bar */}
<StickySaveBar hasChanges={hasChanges} isSubmitting={isSubmitting} onSave={handleSave} />
</DashboardLayout>
);
}
+189
View File
@@ -0,0 +1,189 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor';
import {network} from '../../lib/network';
import {EmailFormValidator} from '../../lib/validation';
import {ArrowLeft, Save} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useState} from 'react';
import {toast} from 'sonner';
import {TemplateSchemas} from '@plunk/shared';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
export default function CreateTemplatePage() {
const router = useRouter();
const {activeProject} = useActiveProject();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [from, setFrom] = useState('');
const [fromName, setFromName] = useState('');
const [replyTo, setReplyTo] = useState('');
const [type, setType] = useState<'MARKETING' | 'TRANSACTIONAL'>('MARKETING');
const [saving, setSaving] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = EmailFormValidator.validateTemplate({name, subject, body, from});
if (validationError) {
toast.error(validationError);
return;
}
setSaving(true);
try {
const template = await network.fetch<{id: string}, typeof TemplateSchemas.create>('POST', '/templates', {
name,
description: description || undefined,
subject,
body,
from,
fromName: fromName || undefined,
replyTo: replyTo || undefined,
type,
});
toast.success('Template created successfully');
void router.push(`/templates/${template.id}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create template');
setSaving(false);
}
};
return (
<>
<NextSeo title="Create Template" />
<DashboardLayout>
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/templates">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1">
<h1 className="text-3xl font-bold text-neutral-900">Create Template</h1>
<p className="text-neutral-500 mt-1">Create a reusable email template for campaigns and workflows</p>
</div>
<Button onClick={handleSubmit} disabled={saving}>
<Save className="h-4 w-4" />
{saving ? 'Creating...' : 'Create Template'}
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Template Settings */}
<Card>
<CardHeader>
<CardTitle>Template Settings</CardTitle>
<CardDescription>Configure your template details and email settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="name">Template Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Welcome Email"
/>
</div>
<div>
<Label htmlFor="type">Template Type *</Label>
<Select value={type} onValueChange={value => setType(value as 'MARKETING' | 'TRANSACTIONAL')}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MARKETING">Marketing</SelectItem>
<SelectItem value="TRANSACTIONAL">Transactional</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Sent to new subscribers"
/>
</div>
<div>
<Label htmlFor="subject">Subject Line *</Label>
<Input
id="subject"
type="text"
value={subject}
onChange={e => setSubject(e.target.value)}
required
placeholder="Welcome to our platform!"
/>
</div>
<EmailSettings
from={from}
fromName={fromName}
replyTo={replyTo}
onFromChange={setFrom}
onFromNameChange={setFromName}
onReplyToChange={setReplyTo}
fromNamePlaceholder={activeProject?.name || 'Your Company'}
/>
</CardContent>
</Card>
{/* Email Body */}
<Card>
<CardHeader>
<CardTitle>Email Body</CardTitle>
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor
value={body}
onChange={setBody}
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
canUploadImages={true}
subject={subject}
from={from}
replyTo={replyTo}
/>
</CardContent>
</Card>
</form>
</div>
</DashboardLayout>
</>
);
}
+311
View File
@@ -0,0 +1,311 @@
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ConfirmDialog, Input} from '@plunk/ui';
import type {Template} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
interface PaginatedTemplates {
templates: Template[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export default function TemplatesPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING'>('ALL');
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [templateToDelete, setTemplateToDelete] = useState<string | null>(null);
const {data, mutate, isLoading} = useSWR<PaginatedTemplates>(
`/templates?page=${page}&pageSize=20${search ? `&search=${search}` : ''}${typeFilter !== 'ALL' ? `&type=${typeFilter}` : ''}`,
{revalidateOnFocus: false},
);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
const handleDelete = async () => {
if (!templateToDelete) return;
try {
await network.fetch('DELETE', `/templates/${templateToDelete}`);
toast.success('Template deleted successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete template');
} finally {
setTemplateToDelete(null);
}
};
const handleDuplicate = async (templateId: string) => {
try {
await network.fetch('POST', `/templates/${templateId}/duplicate`);
toast.success('Template duplicated successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to duplicate template');
}
};
return (
<>
<NextSeo title="Templates" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-neutral-900">Email Templates</h1>
<p className="text-neutral-500 mt-2">
Create and manage reusable email templates for your campaigns and workflows.{' '}
{data?.total ? `${data.total} total templates` : ''}
</p>
</div>
<Link href="/templates/create">
<Button>
<Plus className="h-4 w-4" />
Create Template
</Button>
</Link>
</div>
{/* Search & Filters */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
<Input
type="text"
placeholder="Search templates..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10"
/>
</div>
<Button type="submit">Search</Button>
{search && (
<Button
type="button"
variant="outline"
onClick={() => {
setSearch('');
setSearchInput('');
setPage(1);
}}
>
Clear
</Button>
)}
</div>
{/* Type Filter */}
<div className="flex gap-2">
<button
type="button"
onClick={() => setTypeFilter('ALL')}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
typeFilter === 'ALL'
? 'bg-neutral-900 text-white'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
}`}
>
All Templates
</button>
<button
type="button"
onClick={() => setTypeFilter('MARKETING')}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
typeFilter === 'MARKETING'
? 'bg-neutral-900 text-white'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
}`}
>
Marketing
</button>
<button
type="button"
onClick={() => setTypeFilter('TRANSACTIONAL')}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
typeFilter === 'TRANSACTIONAL'
? 'bg-neutral-900 text-white'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
}`}
>
Transactional
</button>
</div>
</form>
</CardContent>
</Card>
{/* Templates Grid */}
<div className="grid gap-4">
{isLoading ? (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading templates...</p>
</div>
</div>
</CardContent>
</Card>
) : data?.templates.length === 0 ? (
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<FileText className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No templates found</h3>
<p className="text-neutral-500 mb-6">
{search ? 'Try adjusting your search terms' : 'Get started by creating your first template'}
</p>
{!search && (
<Link href="/templates/create">
<Button>
<Plus className="h-4 w-4" />
Create Template
</Button>
</Link>
)}
</div>
</CardContent>
</Card>
) : (
<>
{data?.templates.map(template => (
<Card key={template.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<CardTitle>{template.name}</CardTitle>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
template.type === 'MARKETING'
? 'bg-blue-100 text-blue-800'
: 'bg-purple-100 text-purple-800'
}`}
>
{template.type}
</span>
</div>
{template.description && (
<CardDescription className="mt-2">{template.description}</CardDescription>
)}
</div>
<div className="flex items-center gap-2 ml-4">
<Link href={`/templates/${template.id}`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={() => handleDuplicate(template.id)}>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setTemplateToDelete(template.id);
setShowDeleteDialog(true);
}}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div>
<p className="text-xs text-neutral-500 mb-1">Subject</p>
<p className="text-sm font-medium text-neutral-900">{template.subject}</p>
</div>
<div>
<p className="text-xs text-neutral-500 mb-1">From</p>
<p className="text-sm text-neutral-700">{template.from}</p>
</div>
<div className="flex items-center gap-6 text-sm text-neutral-500 pt-2 border-t border-neutral-100">
<div>Created {new Date(template.createdAt).toLocaleDateString()}</div>
{template.replyTo && (
<div>
Reply to: <span className="text-neutral-700">{template.replyTo}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
))}
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-neutral-500">
Showing {(page - 1) * data.pageSize + 1} to {Math.min(page * data.pageSize, data.total)} of{' '}
{data.total} templates
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</Button>
<span className="text-sm text-neutral-700">
Page {page} of {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => p + 1)}
disabled={page === data.totalPages}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Template"
description="Are you sure you want to delete this template? This action cannot be undone."
confirmText="Delete"
variant="destructive"
/>
</DashboardLayout>
</>
);
}
+229
View File
@@ -0,0 +1,229 @@
import {Button, Card, CardContent} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {useRouter} from 'next/router';
import React, {useEffect, useState} from 'react';
import {network} from '../../lib/network';
interface ContactInfo {
id: string;
email: string;
subscribed: boolean;
}
export default function Unsubscribe() {
const router = useRouter();
const {id} = router.query;
const [contact, setContact] = useState<ContactInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [unsubscribing, setUnsubscribing] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!id || typeof id !== 'string') return;
const fetchContact = async () => {
try {
setLoading(true);
const data = await network.fetch<ContactInfo>('GET', `/contacts/public/${id}`);
setContact(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load contact information');
} finally {
setLoading(false);
}
};
void fetchContact();
}, [id]);
const handleUnsubscribe = async () => {
if (!id || typeof id !== 'string') return;
try {
setUnsubscribing(true);
const data = await network.fetch<ContactInfo>('POST', `/contacts/public/${id}/unsubscribe`);
setContact(data);
setSuccess(true);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to unsubscribe');
} finally {
setUnsubscribing(false);
}
};
if (loading) {
return (
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4">
<svg
className="h-8 w-8 animate-spin text-neutral-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="text-sm text-neutral-500">Loading...</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
if (error && !contact) {
return (
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="h-12 w-12 rounded-full bg-red-100 flex items-center justify-center">
<svg
className="h-6 w-6 text-red-600"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-2xl font-bold text-neutral-900">Error</h1>
<p className="text-neutral-500">{error}</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
if (success || (contact && !contact.subscribed)) {
return (
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<motion.div
initial={{scale: 0}}
animate={{scale: 1}}
transition={{type: 'spring', stiffness: 200, damping: 15}}
className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center"
>
<svg
className="h-6 w-6 text-green-600"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7" />
</svg>
</motion.div>
<h1 className="text-2xl font-bold text-neutral-900">You&apos;re unsubscribed</h1>
<p className="text-neutral-500">
{contact?.email} has been unsubscribed. You won&apos;t receive any more emails from us.
</p>
<p className="text-sm text-neutral-400 mt-2">
Changed your mind?{' '}
<button
onClick={() => router.push(`/subscribe/${id as string}`)}
className="underline hover:text-neutral-600"
>
Subscribe again
</button>
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
<Card>
<CardContent className="p-8">
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center text-center gap-2">
<h1 className="text-2xl font-bold text-neutral-900">Unsubscribe</h1>
<p className="text-neutral-500">
We&apos;re sorry to see you go. Are you sure you want to unsubscribe <strong>{contact?.email}</strong>{' '}
from receiving emails?
</p>
</div>
<AnimatePresence>
{error && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500 text-center"
>
{error}
</motion.p>
)}
</AnimatePresence>
<div className="flex flex-col gap-3">
<Button
onClick={() => void handleUnsubscribe()}
variant="destructive"
className="w-full"
disabled={unsubscribing}
>
{unsubscribing ? (
<div className="flex items-center gap-2">
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Unsubscribing...</span>
</div>
) : (
'Unsubscribe'
)}
</Button>
<Button variant="outline" className="w-full" onClick={() => router.push(`/manage/${id as string}`)}>
Manage preferences instead
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+453
View File
@@ -0,0 +1,453 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import type {Workflow} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {WorkflowSchemas} from '@plunk/shared';
interface PaginatedWorkflows {
workflows: (Workflow & {_count?: {steps: number; executions: number}})[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export default function WorkflowsPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [workflowToDelete, setWorkflowToDelete] = useState<string | null>(null);
const {data, mutate, isLoading} = useSWR<PaginatedWorkflows>(
`/workflows?page=${page}&pageSize=20${search ? `&search=${search}` : ''}`,
{revalidateOnFocus: false},
);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
const handleDelete = async () => {
if (!workflowToDelete) return;
try {
await network.fetch('DELETE', `/workflows/${workflowToDelete}`);
toast.success('Workflow deleted successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete workflow');
} finally {
setWorkflowToDelete(null);
}
};
const handleToggleEnabled = async (workflowId: string, currentlyEnabled: boolean) => {
try {
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${workflowId}`, {
enabled: !currentlyEnabled,
});
toast.success(`Workflow ${!currentlyEnabled ? 'enabled' : 'disabled'} successfully`);
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to toggle workflow');
}
};
return (
<>
<NextSeo title="Workflows" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-neutral-900">Workflows</h1>
<p className="text-neutral-500 mt-2">
Automate your email campaigns with powerful workflows.{' '}
{data?.total ? `${data.total} total workflows` : ''}
</p>
</div>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Create Workflow
</Button>
</div>
{/* Search & Filters */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
<Input
type="text"
placeholder="Search workflows..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10"
/>
</div>
<Button type="submit">Search</Button>
{search && (
<Button
type="button"
variant="outline"
onClick={() => {
setSearch('');
setSearchInput('');
setPage(1);
}}
>
Clear
</Button>
)}
</form>
</CardContent>
</Card>
{/* Workflows Grid */}
<div className="grid gap-4">
{isLoading ? (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading workflows...</p>
</div>
</div>
</CardContent>
</Card>
) : data?.workflows.length === 0 ? (
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<WorkflowIcon className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No workflows found</h3>
<p className="text-neutral-500 mb-6">
{search ? 'Try adjusting your search terms' : 'Get started by creating your first workflow'}
</p>
{!search && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Create Workflow
</Button>
)}
</div>
</CardContent>
</Card>
) : (
<>
{data?.workflows.map(workflow => (
<Card key={workflow.id} className={workflow.enabled ? 'border-green-200' : ''}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<CardTitle>{workflow.name}</CardTitle>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
workflow.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{workflow.enabled ? (
<>
<Power className="h-3 w-3 mr-1" />
Active
</>
) : (
<>
<PowerOff className="h-3 w-3 mr-1" />
Disabled
</>
)}
</span>
{workflow.triggerConfig &&
typeof workflow.triggerConfig === 'object' &&
'eventName' in workflow.triggerConfig && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{String(workflow.triggerConfig.eventName)}
</span>
)}
</div>
{workflow.description && (
<CardDescription className="mt-2">{workflow.description}</CardDescription>
)}
</div>
<div className="flex items-center gap-2 ml-4">
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleEnabled(workflow.id, workflow.enabled)}
>
{workflow.enabled ? (
<PowerOff className="h-4 w-4 text-orange-600" />
) : (
<Power className="h-4 w-4 text-green-600" />
)}
</Button>
<Link href={`/workflows/${workflow.id}`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => {
setWorkflowToDelete(workflow.id);
setShowDeleteDialog(true);
}}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-6 text-sm text-neutral-500">
<div>
<span className="font-medium text-neutral-900">{workflow._count?.steps ?? 0}</span> steps
</div>
<div>
<span className="font-medium text-neutral-900">{workflow._count?.executions ?? 0}</span>{' '}
executions
</div>
<div>Created {new Date(workflow.createdAt).toLocaleDateString()}</div>
</div>
</CardContent>
</Card>
))}
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-neutral-500">
Showing {(page - 1) * data.pageSize + 1} to {Math.min(page * data.pageSize, data.total)} of{' '}
{data.total} workflows
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</Button>
<span className="text-sm text-neutral-700">
Page {page} of {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => p + 1)}
disabled={page === data.totalPages}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
{/* Create Workflow Dialog */}
<CreateWorkflowDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} onSuccess={() => mutate()} />
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Workflow"
description="Are you sure you want to delete this workflow? This action cannot be undone."
confirmText="Delete"
variant="destructive"
/>
</DashboardLayout>
</>
);
}
interface CreateWorkflowDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDialogProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [eventName, setEventName] = useState('');
const [allowReentry, setAllowReentry] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Fetch available event names
const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, {
revalidateOnFocus: false,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const workflow = await network.fetch<Workflow, typeof WorkflowSchemas.create>('POST', '/workflows', {
name,
description: description || undefined,
eventName: eventName.trim(),
allowReentry,
enabled: false,
});
toast.success('Workflow created successfully');
setName('');
setDescription('');
setEventName('');
setAllowReentry(false);
onOpenChange(false);
onSuccess();
// Redirect to the workflow editor
window.location.href = `/workflows/${workflow.id}`;
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create workflow');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Workflow</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Welcome Email Sequence"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<textarea
id="description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Send a series of welcome emails to new subscribers"
className="w-full px-3 py-2 border border-neutral-200 rounded-lg text-sm"
rows={3}
/>
</div>
<div>
<Label htmlFor="eventName">Trigger Event *</Label>
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
<Select value={eventName} onValueChange={setEventName} required>
<SelectTrigger id="eventName">
<SelectValue placeholder="Select an event..." />
</SelectTrigger>
<SelectContent>
{eventNamesData.eventNames.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="eventName"
type="text"
value={eventName}
onChange={e => setEventName(e.target.value)}
required
placeholder="e.g., contact.created, email.opened"
/>
)}
<p className="text-xs text-neutral-500 mt-1">
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0
? 'Select from previously tracked events'
: 'No events tracked yet. Enter the event name that will trigger this workflow.'}
</p>
</div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<input
id="allowReentry"
type="checkbox"
checked={allowReentry}
onChange={e => setAllowReentry(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="allowReentry" className="font-medium cursor-pointer">
Allow Re-entry
</Label>
<p className="text-xs text-neutral-500 mt-1">
When enabled, contacts can enter this workflow multiple times. When disabled, contacts can only enter
once, ever.
</p>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Workflow'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+166
View File
@@ -0,0 +1,166 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
@custom-variant dark (&:is(.dark *));
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1400px) {
max-width: 1400px;
}
}
@theme {
--font-*: initial;
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215.4 16.3% 46.9%;
--radius: 0.5rem;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 215.4 16.3% 56.9%;
--radius: 0.5rem;
}
}
@layer base {
:root {
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-neutral-800 overflow-hidden;
font-feature-settings: "rlig" 1, "calt" 1;
}
}