Initial push of Plunk Next
This commit is contained in:
@@ -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've reached the end of the activity feed</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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've used {Math.round(usage.percentage)}% of your{' '}
|
||||
{category.toLowerCase()} email limit.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
"v=spf1 include:amazonses.com ~all"
|
||||
</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';
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Add Field" 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 "plunk" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} "{String(data.config.value)}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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're looking for doesn'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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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's what'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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're looking for doesn'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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're unsubscribed</h1>
|
||||
<p className="text-neutral-500">
|
||||
{contact?.email} has been unsubscribed. You won'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'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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user