Initial push of Plunk Next
This commit is contained in:
@@ -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="[email protected]" {...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="[email protected]" {...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="[email protected]"
|
||||
/>
|
||||
</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="[email protected]"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user