ui: Clean up empty states, modals, animations

This commit is contained in:
Dries Augustyns
2026-04-17 12:27:40 +02:00
parent 03a058ad48
commit 0803fd7bc4
32 changed files with 946 additions and 1047 deletions
+7 -6
View File
@@ -2,7 +2,8 @@ import {Button} from '@plunk/ui';
import type {Activity, CursorPaginatedResponse} from '@plunk/types'; import type {Activity, CursorPaginatedResponse} from '@plunk/types';
import {network} from '../lib/network'; import {network} from '../lib/network';
import {ActivityItem} from './ActivityItem'; import {ActivityItem} from './ActivityItem';
import {Loader2} from 'lucide-react'; import {EmptyState} from './EmptyState';
import {Activity as ActivityIcon, Loader2} from 'lucide-react';
import {useCallback, useEffect, useMemo, useState} from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react';
export interface ActivityFeedProps { export interface ActivityFeedProps {
@@ -186,11 +187,11 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
if (activities.length === 0 && upcomingActivities.length === 0) { if (activities.length === 0 && upcomingActivities.length === 0) {
return ( return (
<div className="text-center py-12"> <EmptyState
<p className="text-neutral-500 text-sm"> icon={ActivityIcon}
No activity found for the selected filters. Activities will appear here as they happen. title="No activity yet"
</p> description="Events will appear here as contacts interact with your emails."
</div> />
); );
} }
+30 -7
View File
@@ -1,6 +1,7 @@
import {AnimatePresence, motion} from 'framer-motion';
import {Check, Copy, Eye, EyeOff, RefreshCw} from 'lucide-react';
import {useState} from 'react'; import {useState} from 'react';
import {Button} from '@plunk/ui'; import {Button} from '@plunk/ui';
import {Check, Copy, Eye, EyeOff, RefreshCw} from 'lucide-react';
interface ApiKeyDisplayProps { interface ApiKeyDisplayProps {
label: string; label: string;
@@ -28,8 +29,8 @@ export function ApiKeyDisplay({
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch (error) { } catch {
console.error('Failed to copy:', error); // clipboard API unavailable — silent
} }
}; };
@@ -39,8 +40,8 @@ export function ApiKeyDisplay({
try { try {
setIsRegenerating(true); setIsRegenerating(true);
await onRegenerate(); await onRegenerate();
} catch (error) { } catch {
console.error('Failed to regenerate:', error); // error surfaced via isRegenerating state reset
} finally { } finally {
setIsRegenerating(false); setIsRegenerating(false);
} }
@@ -74,9 +75,31 @@ export function ApiKeyDisplay({
size="icon" size="icon"
onClick={handleCopy} onClick={handleCopy}
title="Copy to clipboard" title="Copy to clipboard"
className="h-9 w-9" className="h-9 w-9 overflow-hidden"
> >
{copied ? <Check className="h-4 w-4 text-green-600" /> : <Copy className="h-4 w-4" />} <AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.span
key="copied"
initial={{opacity: 0, y: 6}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -6}}
transition={{duration: 0.15}}
>
<Check className="h-4 w-4 text-green-600" />
</motion.span>
) : (
<motion.span
key="idle"
initial={{opacity: 0, y: 6}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -6}}
transition={{duration: 0.15}}
>
<Copy className="h-4 w-4" />
</motion.span>
)}
</AnimatePresence>
</Button> </Button>
{showRegenerate && onRegenerate && ( {showRegenerate && onRegenerate && (
<Button <Button
@@ -198,7 +198,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
{data?.data.map(campaign => ( {data?.data.map(campaign => (
<Card <Card
key={campaign.id} key={campaign.id}
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all" className="cursor-pointer hover:border-neutral-400 transition-colors"
onClick={() => handleCampaignClick(campaign)} onClick={() => handleCampaignClick(campaign)}
> >
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -266,30 +266,24 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
) : ( ) : (
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{/* Scrollable Content */} {/* Scrollable Content */}
<div className="flex-1 overflow-y-auto space-y-6 pr-2"> <div className="flex-1 overflow-y-auto pr-2">
{/* Campaign Preview */} {/* Campaign Preview */}
{selectedCampaign && ( {selectedCampaign && (
<Card className="bg-neutral-50"> <div className="pb-4 mb-1 border-b border-neutral-100">
<CardHeader className="pb-3"> <div className="flex items-center gap-2">
<div className="flex items-start justify-between gap-3"> <span className="text-sm font-medium text-neutral-900">{selectedCampaign.name}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base">{selectedCampaign.name}</CardTitle>
{getStatusBadge(selectedCampaign.status)} {getStatusBadge(selectedCampaign.status)}
</div> </div>
{selectedCampaign.description && ( {selectedCampaign.description && (
<CardDescription className="text-xs">{selectedCampaign.description}</CardDescription> <p className="text-xs text-neutral-500 mt-1">{selectedCampaign.description}</p>
)} )}
</div> </div>
</div>
</CardHeader>
</Card>
)} )}
{/* Field Selection */} {/* Field Selection */}
<div className="space-y-3"> <div className="divide-y divide-neutral-100">
<div <div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer" className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
onClick={() => toggleField('subject')} onClick={() => toggleField('subject')}
> >
<Checkbox <Checkbox
@@ -302,13 +296,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
Email Subject Email Subject
</Label> </Label>
{selectedCampaign?.subject && ( {selectedCampaign?.subject && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.subject}</p> <p className="text-xs text-neutral-400 mt-0.5 truncate">{selectedCampaign.subject}</p>
)} )}
</div> </div>
</div> </div>
<div <div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer" className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
onClick={() => toggleField('body')} onClick={() => toggleField('body')}
> >
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} /> <Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
@@ -316,12 +310,12 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
<Label htmlFor="body" className="text-sm font-medium cursor-pointer"> <Label htmlFor="body" className="text-sm font-medium cursor-pointer">
Email Body Email Body
</Label> </Label>
<p className="text-xs text-neutral-500 mt-0.5">The full email content and design</p> <p className="text-xs text-neutral-400 mt-0.5">Full email content and design</p>
</div> </div>
</div> </div>
<div <div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer" className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
onClick={() => toggleField('from')} onClick={() => toggleField('from')}
> >
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} /> <Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
@@ -330,13 +324,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
From Email From Email
</Label> </Label>
{selectedCampaign?.from && ( {selectedCampaign?.from && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.from}</p> <p className="text-xs text-neutral-400 mt-0.5">{selectedCampaign.from}</p>
)} )}
</div> </div>
</div> </div>
<div <div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer" className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
onClick={() => toggleField('fromName')} onClick={() => toggleField('fromName')}
> >
<Checkbox <Checkbox
@@ -349,13 +343,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
From Name From Name
</Label> </Label>
{selectedCampaign?.fromName && ( {selectedCampaign?.fromName && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.fromName}</p> <p className="text-xs text-neutral-400 mt-0.5">{selectedCampaign.fromName}</p>
)} )}
</div> </div>
</div> </div>
<div <div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer" className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
onClick={() => toggleField('replyTo')} onClick={() => toggleField('replyTo')}
> >
<Checkbox <Checkbox
@@ -368,13 +362,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
Reply-To Email Reply-To Email
</Label> </Label>
{selectedCampaign?.replyTo && ( {selectedCampaign?.replyTo && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.replyTo}</p> <p className="text-xs text-neutral-400 mt-0.5">{selectedCampaign.replyTo}</p>
)} )}
</div> </div>
</div> </div>
<div <div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer" className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
onClick={() => toggleField('audience')} onClick={() => toggleField('audience')}
> >
<Checkbox <Checkbox
@@ -387,7 +381,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
Audience Settings Audience Settings
</Label> </Label>
{selectedCampaign && ( {selectedCampaign && (
<p className="text-xs text-neutral-500 mt-0.5">{getAudienceLabel(selectedCampaign)}</p> <p className="text-xs text-neutral-400 mt-0.5">{getAudienceLabel(selectedCampaign)}</p>
)} )}
</div> </div>
</div> </div>
+26 -17
View File
@@ -15,7 +15,6 @@ import {
Menu, Menu,
Plus, Plus,
Settings, Settings,
User,
Users, Users,
Workflow, Workflow,
} from 'lucide-react'; } from 'lucide-react';
@@ -57,7 +56,6 @@ const navigation: NavSection[] = [
], ],
}, },
{ {
title: 'Campaigns',
items: [{name: 'Campaigns', href: '/campaigns', icon: Megaphone}], items: [{name: 'Campaigns', href: '/campaigns', icon: Megaphone}],
}, },
]; ];
@@ -127,8 +125,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
// Redirect to login // Redirect to login
await router.push('/auth/login'); await router.push('/auth/login');
} catch (error) { } catch {
console.error('Logout failed:', error);
// Even if the API call fails, try to redirect to login // Even if the API call fails, try to redirect to login
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('activeProjectId'); localStorage.removeItem('activeProjectId');
@@ -179,12 +176,14 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
</div> </div>
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span> <span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
</div> </div>
<ChevronDown className="h-4 w-4 text-neutral-500 flex-shrink-0" /> <ChevronDown
className={`h-4 w-4 text-neutral-500 flex-shrink-0 transition-transform duration-200 ${showProjectMenu ? 'rotate-180' : ''}`}
/>
</button> </button>
{/* Project Dropdown */} {/* Project Dropdown */}
{showProjectMenu && ( {showProjectMenu && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1 max-h-[400px] overflow-y-auto min-w-full w-max"> <div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-md z-50 py-1 max-h-[400px] overflow-y-auto min-w-full w-max">
{sortedProjects.map(project => ( {sortedProjects.map(project => (
<button <button
key={project.id} key={project.id}
@@ -196,7 +195,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
}} }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors whitespace-nowrap" className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors whitespace-nowrap"
> >
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0"> <div className="h-6 w-6 rounded-md bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
{project.name.charAt(0).toUpperCase()} {project.name.charAt(0).toUpperCase()}
</div> </div>
<span className="text-neutral-900 text-left flex-1">{project.name}</span> <span className="text-neutral-900 text-left flex-1">{project.name}</span>
@@ -230,15 +229,16 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
)} )}
<div className="space-y-1"> <div className="space-y-1">
{section.items.map(item => { {section.items.map(item => {
const isActive = router.pathname === item.href; const isActive =
item.href === '/' ? router.pathname === item.href : router.pathname.startsWith(item.href);
const Icon = item.icon; const Icon = item.icon;
return ( return (
<Link <Link
key={item.name} key={item.name}
href={item.href} href={item.href}
onClick={() => setShowMobileMenu(false)} onClick={() => setShowMobileMenu(false)}
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${ className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
isActive ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900' isActive ? 'bg-neutral-100 text-neutral-900' : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
}`} }`}
> >
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
@@ -257,7 +257,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
href={WIKI_URI} href={WIKI_URI}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900"
> >
<BookOpen className="h-5 w-5" /> <BookOpen className="h-5 w-5" />
Documentation Documentation
@@ -266,8 +266,10 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<Link <Link
href="/settings" href="/settings"
onClick={() => setShowMobileMenu(false)} onClick={() => setShowMobileMenu(false)}
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${ className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
router.pathname.startsWith('/settings') ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900' router.pathname.startsWith('/settings')
? 'bg-neutral-100 text-neutral-900'
: 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
}`} }`}
> >
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5" />
@@ -277,16 +279,23 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<div className="relative" ref={userMenuRef}> <div className="relative" ref={userMenuRef}>
<button <button
onClick={handleToggleUserMenu} onClick={handleToggleUserMenu}
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors" className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
> >
<User className="h-5 w-5" /> <div className="h-5 w-5 rounded-full bg-neutral-900 text-white flex items-center justify-center text-[10px] font-semibold flex-shrink-0">
{user?.email?.charAt(0).toUpperCase() ?? '?'}
</div>
<span className="flex-1 text-left truncate">{user?.email}</span> <span className="flex-1 text-left truncate">{user?.email}</span>
<ChevronDown className="h-4 w-4 text-neutral-500" /> <ChevronDown
className={`h-4 w-4 text-neutral-500 transition-transform duration-200 ${showUserMenu ? 'rotate-180' : ''}`}
/>
</button> </button>
{/* User Dropdown */} {/* User Dropdown */}
{showUserMenu && ( {showUserMenu && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1"> <div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-md z-50 py-1">
<div className="px-3 py-2 border-b border-neutral-100">
<p className="text-xs text-neutral-500 truncate">{user?.email}</p>
</div>
<button <button
onClick={handleLogoutClick} onClick={handleLogoutClick}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600" className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600"
@@ -151,7 +151,7 @@ export function DataManagementSettings() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{customFields.length === 0 ? ( {customFields.length === 0 ? (
<p className="text-sm text-muted-foreground">No custom fields found</p> <p className="text-sm text-neutral-500">No custom fields found</p>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -170,7 +170,7 @@ export function DataManagementSettings() {
<Badge variant="secondary">{field.type}</Badge> <Badge variant="secondary">{field.type}</Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-sm text-muted-foreground">{field.coverage}%</span> <span className="text-sm text-neutral-500">{field.coverage}%</span>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => openFieldDeleteDialog(field.field)}> <Button variant="ghost" size="sm" onClick={() => openFieldDeleteDialog(field.field)}>
@@ -196,7 +196,7 @@ export function DataManagementSettings() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{customEvents.length === 0 ? ( {customEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">No custom events found</p> <p className="text-sm text-neutral-500">No custom events found</p>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
+69 -54
View File
@@ -24,6 +24,34 @@ import {Check, CheckCircle2, ChevronDown, Copy, Loader2, RefreshCw, Trash2, XCir
import {useConfig} from '../lib/hooks/useConfig'; import {useConfig} from '../lib/hooks/useConfig';
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains'; import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
function AnimatedCopyIcon({isCopied}: {isCopied: boolean}) {
return (
<AnimatePresence mode="wait" initial={false}>
{isCopied ? (
<motion.span
key="copied"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Check className="h-3 w-3 text-green-600" />
</motion.span>
) : (
<motion.span
key="idle"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Copy className="h-3 w-3" />
</motion.span>
)}
</AnimatePresence>
);
}
interface DomainsSettingsProps { interface DomainsSettingsProps {
projectId: string; projectId: string;
} }
@@ -419,14 +447,13 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
onClick={() => onClick={() =>
handleCopyToken(`${token}._domainkey.${domain.domain}`, index + 2000) handleCopyToken(`${token}._domainkey.${domain.domain}`, index + 2000)
} }
className="shrink-0 h-6 w-6 p-0" className="shrink-0 h-6 w-6 p-0 overflow-hidden"
> >
{copiedToken === <AnimatedCopyIcon
`${token}._domainkey.${domain.domain}-${index + 2000}` ? ( isCopied={
<Check className="h-3 w-3 text-green-600" /> copiedToken === `${token}._domainkey.${domain.domain}-${index + 2000}`
) : ( }
<Copy className="h-3 w-3" /> />
)}
</Button> </Button>
</div> </div>
</td> </td>
@@ -439,13 +466,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleCopyToken(`${token}.dkim.amazonses.com`, index)} onClick={() => handleCopyToken(`${token}.dkim.amazonses.com`, index)}
className="shrink-0 h-6 w-6 p-0" className="shrink-0 h-6 w-6 p-0 overflow-hidden"
> >
{copiedToken === `${token}.dkim.amazonses.com-${index}` ? ( <AnimatedCopyIcon
<Check className="h-3 w-3 text-green-600" /> isCopied={copiedToken === `${token}.dkim.amazonses.com-${index}`}
) : ( />
<Copy className="h-3 w-3" />
)}
</Button> </Button>
</div> </div>
</td> </td>
@@ -500,13 +525,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)} onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
className="shrink-0 h-6 w-6 p-0" className="shrink-0 h-6 w-6 p-0 overflow-hidden"
> >
{copiedToken === `plunk.${domain.domain}-3000` ? ( <AnimatedCopyIcon
<Check className="h-3 w-3 text-green-600" /> isCopied={copiedToken === `plunk.${domain.domain}-3000`}
) : ( />
<Copy className="h-3 w-3" />
)}
</Button> </Button>
</div> </div>
</td> </td>
@@ -524,14 +547,14 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
1000, 1000,
) )
} }
className="shrink-0 h-6 w-6 p-0" className="shrink-0 h-6 w-6 p-0 overflow-hidden"
> >
{copiedToken === <AnimatedCopyIcon
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com-1000` ? ( isCopied={
<Check className="h-3 w-3 text-green-600" /> copiedToken ===
) : ( `10 feedback-smtp.${config.aws.sesRegion}.amazonses.com-1000`
<Copy className="h-3 w-3" /> }
)} />
</Button> </Button>
</div> </div>
</td> </td>
@@ -551,13 +574,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)} onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
className="shrink-0 h-6 w-6 p-0" className="shrink-0 h-6 w-6 p-0 overflow-hidden"
> >
{copiedToken === `plunk.${domain.domain}-3001` ? ( <AnimatedCopyIcon
<Check className="h-3 w-3 text-green-600" /> isCopied={copiedToken === `plunk.${domain.domain}-3001`}
) : ( />
<Copy className="h-3 w-3" />
)}
</Button> </Button>
</div> </div>
</td> </td>
@@ -572,13 +593,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
onClick={() => onClick={() =>
handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001) handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)
} }
className="shrink-0 h-6 w-6 p-0" className="shrink-0 h-6 w-6 p-0 overflow-hidden"
> >
{copiedToken === '"v=spf1 include:amazonses.com ~all"-1001' ? ( <AnimatedCopyIcon
<Check className="h-3 w-3 text-green-600" /> isCopied={copiedToken === '"v=spf1 include:amazonses.com ~all"-1001'}
) : ( />
<Copy className="h-3 w-3" />
)}
</Button> </Button>
</div> </div>
</td> </td>
@@ -632,13 +651,9 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleCopyToken(domain.domain, 3002)} onClick={() => handleCopyToken(domain.domain, 3002)}
className="shrink-0 h-6 w-6 p-0" className="shrink-0 h-6 w-6 p-0 overflow-hidden"
> >
{copiedToken === `${domain.domain}-3002` ? ( <AnimatedCopyIcon isCopied={copiedToken === `${domain.domain}-3002`} />
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button> </Button>
</div> </div>
</td> </td>
@@ -656,14 +671,14 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
1002, 1002,
) )
} }
className="shrink-0 h-6 w-6 p-0" className="shrink-0 h-6 w-6 p-0 overflow-hidden"
> >
{copiedToken === <AnimatedCopyIcon
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com-1002` ? ( isCopied={
<Check className="h-3 w-3 text-green-600" /> copiedToken ===
) : ( `10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com-1002`
<Copy className="h-3 w-3" /> }
)} />
</Button> </Button>
</div> </div>
</td> </td>
@@ -674,8 +689,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
</div> </div>
)} )}
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200 mt-3"> <div className="flex items-start gap-2 p-3 bg-neutral-50 rounded-lg border border-neutral-200 mt-3">
<div className="text-blue-600 mt-0.5"> <div className="text-neutral-500 mt-0.5">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path <path
fillRule="evenodd" fillRule="evenodd"
@@ -684,7 +699,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
/> />
</svg> </svg>
</div> </div>
<p className="text-xs text-blue-900"> <p className="text-xs text-neutral-600">
Click the copy icon to copy record values. After adding all records to your DNS Click the copy icon to copy record values. After adding all records to your DNS
provider, use the refresh button above to verify your domain. provider, use the refresh button above to verify your domain.
</p> </p>
+22
View File
@@ -0,0 +1,22 @@
import type {LucideIcon} from 'lucide-react';
import type {ReactNode} from 'react';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: ReactNode;
}
export function EmptyState({icon: Icon, title, description, action}: EmptyStateProps) {
return (
<div className="text-center py-14">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-md border border-neutral-200 bg-neutral-50 mb-4">
<Icon className="h-5 w-5 text-neutral-400" />
</div>
<h3 className="text-sm font-semibold text-neutral-900 mb-1">{title}</h3>
<p className="text-sm text-neutral-500 max-w-xs mx-auto leading-relaxed mb-5">{description}</p>
{action}
</div>
);
}
+2 -3
View File
@@ -51,7 +51,7 @@ function HelpResources() {
<motion.button <motion.button
onClick={copyEmail} onClick={copyEmail}
whileTap={{scale: 0.97}} whileTap={{scale: 0.97}}
className="flex-1 relative flex items-center justify-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-700 overflow-hidden transition-colors hover:bg-neutral-50 hover:text-neutral-900 hover:border-neutral-300" className="flex-1 relative flex items-center justify-center gap-1.5 h-9 rounded-md border border-neutral-200 bg-white px-3 text-sm font-medium text-neutral-700 overflow-hidden transition-colors hover:bg-neutral-50 hover:text-neutral-900 hover:border-neutral-300"
> >
<AnimatePresence mode="wait" initial={false}> <AnimatePresence mode="wait" initial={false}>
{copied ? ( {copied ? (
@@ -230,8 +230,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
<div className="flex-1 pt-0.5"> <div className="flex-1 pt-0.5">
<p className="text-sm font-semibold text-green-900 mb-1">All set!</p> <p className="text-sm font-semibold text-green-900 mb-1">All set!</p>
<p className="text-xs text-green-700 leading-relaxed"> <p className="text-xs text-green-700 leading-relaxed">
Your project is fully configured and you&apos;re actively engaging your audience. Keep up the great Domain verified, contacts imported, campaigns running. Everything is set up correctly.
work!
</p> </p>
</div> </div>
</div> </div>
@@ -775,19 +775,9 @@ interface SegmentFilterBuilderProps {
export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) { export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) {
const {fields, loading} = useAvailableOptions(); const {fields, loading} = useAvailableOptions();
return ( if (loading) {
<div className="space-y-4"> return <div className="text-sm text-neutral-500 py-4">Loading available fields and events...</div>;
<div className="flex items-center justify-between"> }
<div>
<h3 className="text-lg font-semibold text-neutral-900">Filter Conditions</h3> return <FilterConditionComponent condition={condition} onChange={onChange} availableFields={fields} />;
<p className="text-sm text-neutral-500 mt-1">Build complex audience filters with AND/OR logic</p>
</div>
</div>
{loading ? (
<div className="text-sm text-neutral-500 py-4">Loading available fields and events...</div>
) : (
<FilterConditionComponent condition={condition} onChange={onChange} availableFields={fields} />
)}
</div>
);
} }
+25 -23
View File
@@ -1,7 +1,6 @@
import {useState} from 'react'; import {useState} from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
Alert,
Badge, Badge,
Button, Button,
Card, Card,
@@ -40,7 +39,8 @@ import {
TableRow, TableRow,
} from '@plunk/ui'; } from '@plunk/ui';
import {MembershipSchemas} from '@plunk/shared'; import {MembershipSchemas} from '@plunk/shared';
import {AlertTriangle, Mail, MoreVertical, Trash2, UserPlus} from 'lucide-react'; import {MoreVertical, Trash2, UserPlus} from 'lucide-react';
import {AnimatePresence, motion} from 'framer-motion';
import {useForm} from 'react-hook-form'; import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod'; import {zodResolver} from '@hookform/resolvers/zod';
import type {z} from 'zod'; import type {z} from 'zod';
@@ -173,23 +173,30 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<AnimatePresence mode="wait">
{success && ( {success && (
<Alert> <motion.div
<Mail className="h-4 w-4" /> key="success"
<div className="ml-2"> initial={{opacity: 0, y: -10}}
<p className="text-sm font-medium">{success}</p> animate={{opacity: 1, y: 0}}
</div> exit={{opacity: 0}}
</Alert> className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
>
{success}
</motion.div>
)} )}
{error && ( {error && (
<Alert variant="destructive"> <motion.div
<AlertTriangle className="h-4 w-4" /> key="error"
<div className="ml-2"> initial={{opacity: 0, y: -10}}
<p className="text-sm font-medium">{error}</p> animate={{opacity: 1, y: 0}}
</div> exit={{opacity: 0}}
</Alert> className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
>
{error}
</motion.div>
)} )}
</AnimatePresence>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -214,10 +221,10 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600" /> <div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-900" />
</div> </div>
) : members.length === 0 ? ( ) : members.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500">No members found</div> <div className="py-8 text-center text-sm text-neutral-500">No members found</div>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -314,12 +321,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleAddMember)} className="space-y-4"> <form onSubmit={form.handleSubmit(handleAddMember)} className="space-y-4">
{error && ( {error && (
<Alert variant="destructive"> <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">{error}</div>
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{error}</p>
</div>
</Alert>
)} )}
<FormField <FormField
control={form.control} control={form.control}
@@ -236,7 +236,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
{data?.data.map(template => ( {data?.data.map(template => (
<Card <Card
key={template.id} key={template.id}
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all" className="cursor-pointer hover:border-neutral-400 transition-colors"
onClick={() => handleTemplateClick(template)} onClick={() => handleTemplateClick(template)}
> >
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -295,15 +295,12 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
) : ( ) : (
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{/* Scrollable Content */} {/* Scrollable Content */}
<div className="flex-1 overflow-y-auto space-y-6 pr-2"> <div className="flex-1 overflow-y-auto pr-2">
{/* Template Preview */} {/* Template Preview */}
{selectedTemplate && ( {selectedTemplate && (
<Card className="bg-neutral-50"> <div className="pb-4 mb-1 border-b border-neutral-100">
<CardHeader className="pb-3"> <div className="flex items-center gap-2">
<div className="flex items-start justify-between gap-3"> <span className="text-sm font-medium text-neutral-900">{selectedTemplate.name}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base">{selectedTemplate.name}</CardTitle>
<Badge <Badge
className="capitalize" className="capitalize"
variant={selectedTemplate.type === 'MARKETING' ? 'info' : 'success'} variant={selectedTemplate.type === 'MARKETING' ? 'info' : 'success'}
@@ -312,18 +309,15 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
</Badge> </Badge>
</div> </div>
{selectedTemplate.description && ( {selectedTemplate.description && (
<CardDescription className="text-xs">{selectedTemplate.description}</CardDescription> <p className="text-xs text-neutral-500 mt-1">{selectedTemplate.description}</p>
)} )}
</div> </div>
</div>
</CardHeader>
</Card>
)} )}
{/* Field Selection */} {/* Field Selection */}
<div className="space-y-3"> <div className="divide-y divide-neutral-100">
<div <div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer" className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
onClick={() => toggleField('subject')} onClick={() => toggleField('subject')}
> >
<Checkbox <Checkbox
@@ -336,13 +330,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
Email Subject Email Subject
</Label> </Label>
{selectedTemplate?.subject && ( {selectedTemplate?.subject && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.subject}</p> <p className="text-xs text-neutral-400 mt-0.5 truncate">{selectedTemplate.subject}</p>
)} )}
</div> </div>
</div> </div>
<div <div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer" className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
onClick={() => toggleField('body')} onClick={() => toggleField('body')}
> >
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} /> <Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
@@ -350,12 +344,12 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
<Label htmlFor="body" className="text-sm font-medium cursor-pointer"> <Label htmlFor="body" className="text-sm font-medium cursor-pointer">
Email Body Email Body
</Label> </Label>
<p className="text-xs text-neutral-500 mt-0.5">The full email content and design</p> <p className="text-xs text-neutral-400 mt-0.5">Full email content and design</p>
</div> </div>
</div> </div>
<div <div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer" className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
onClick={() => toggleField('from')} onClick={() => toggleField('from')}
> >
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} /> <Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
@@ -364,13 +358,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
From Email From Email
</Label> </Label>
{selectedTemplate?.from && ( {selectedTemplate?.from && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.from}</p> <p className="text-xs text-neutral-400 mt-0.5">{selectedTemplate.from}</p>
)} )}
</div> </div>
</div> </div>
<div <div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer" className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
onClick={() => toggleField('fromName')} onClick={() => toggleField('fromName')}
> >
<Checkbox <Checkbox
@@ -383,13 +377,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
From Name From Name
</Label> </Label>
{selectedTemplate?.fromName && ( {selectedTemplate?.fromName && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.fromName}</p> <p className="text-xs text-neutral-400 mt-0.5">{selectedTemplate.fromName}</p>
)} )}
</div> </div>
</div> </div>
<div <div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer" className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
onClick={() => toggleField('replyTo')} onClick={() => toggleField('replyTo')}
> >
<Checkbox <Checkbox
@@ -402,7 +396,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
Reply-To Email Reply-To Email
</Label> </Label>
{selectedTemplate?.replyTo && ( {selectedTemplate?.replyTo && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.replyTo}</p> <p className="text-xs text-neutral-400 mt-0.5">{selectedTemplate.replyTo}</p>
)} )}
</div> </div>
</div> </div>
+14 -23
View File
@@ -209,8 +209,8 @@ function AddStepNode({data}: {data: {label: string; onClick?: () => void}}) {
/> />
<div className="cursor-pointer hover:scale-105 transition-transform" onClick={data.onClick}> <div className="cursor-pointer hover:scale-105 transition-transform" onClick={data.onClick}>
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-neutral-100 to-neutral-200 border-2 border-dashed border-neutral-400 hover:border-neutral-600 hover:from-blue-50 hover:to-blue-100 hover:border-blue-400 flex items-center justify-center shadow-md transition-all"> <div className="w-16 h-16 rounded-full bg-neutral-100 border-2 border-dashed border-neutral-400 hover:border-neutral-600 hover:bg-white flex items-center justify-center transition-all">
<Plus className="h-8 w-8 text-neutral-500 transition-colors" /> <Plus className="h-8 w-8 text-neutral-500 hover:text-neutral-700 transition-colors" />
</div> </div>
{data.label && <div className="text-xs text-neutral-500 text-center mt-2 font-medium">{data.label}</div>} {data.label && <div className="text-xs text-neutral-500 text-center mt-2 font-medium">{data.label}</div>}
</div> </div>
@@ -255,7 +255,7 @@ function CustomNode({
/> />
<div <div
className="px-5 py-4 rounded-xl border-2 bg-white shadow-lg hover:shadow-xl transition-all relative group" className="px-5 py-4 rounded-xl border-2 bg-white shadow-sm hover:shadow-md transition-all relative group"
style={{ style={{
borderColor: color, borderColor: color,
minWidth: '280px', minWidth: '280px',
@@ -274,7 +274,7 @@ function CustomNode({
}} }}
variant="outline" variant="outline"
size="icon" size="icon"
className="h-7 w-7 shadow-md" className="h-7 w-7"
title="Edit trigger settings" title="Edit trigger settings"
> >
<Settings className="h-3.5 w-3.5" /> <Settings className="h-3.5 w-3.5" />
@@ -290,7 +290,7 @@ function CustomNode({
}} }}
variant="outline" variant="outline"
size="icon" size="icon"
className="h-7 w-7 shadow-md" className="h-7 w-7"
title="Edit step" title="Edit step"
> >
<Settings className="h-3.5 w-3.5" /> <Settings className="h-3.5 w-3.5" />
@@ -302,7 +302,7 @@ function CustomNode({
}} }}
variant="outline" variant="outline"
size="icon" size="icon"
className="h-7 w-7 shadow-md hover:bg-red-50 hover:border-red-400" className="h-7 w-7 hover:bg-red-50 hover:border-red-400"
title="Delete step" title="Delete step"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -905,19 +905,19 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
<Background color="#e5e7eb" gap={16} size={1} /> <Background color="#e5e7eb" gap={16} size={1} />
<Controls <Controls
showInteractive={false} showInteractive={false}
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg" className="bg-white border border-neutral-200 rounded-lg shadow-md"
/> />
<MiniMap <MiniMap
nodeColor={node => { nodeColor={node => {
const step = steps.find(s => s.id === node.id); const step = steps.find(s => s.id === node.id);
return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280'; return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280';
}} }}
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg" className="bg-white border border-neutral-200 rounded-lg shadow-md"
maskColor="rgba(0, 0, 0, 0.05)" maskColor="rgba(0, 0, 0, 0.05)"
/> />
<Panel <Panel
position="top-left" position="top-left"
className="bg-white/95 backdrop-blur-sm px-4 py-2.5 rounded-lg shadow-lg border border-neutral-200" className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<GitBranch className="h-4 w-4 text-neutral-700" /> <GitBranch className="h-4 w-4 text-neutral-700" />
@@ -933,7 +933,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
<Panel position="top-right" className="flex gap-2"> <Panel position="top-right" className="flex gap-2">
<button <button
onClick={handleAutoLayout} onClick={handleAutoLayout}
className="bg-white/95 backdrop-blur-sm px-4 py-2 rounded-lg shadow-lg border border-neutral-200 text-sm font-medium text-neutral-700 hover:bg-white hover:text-neutral-900 transition-all" className="bg-white px-4 py-2 rounded-lg shadow-md border border-neutral-200 text-sm font-medium text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
> >
Auto Layout Auto Layout
</button> </button>
@@ -941,11 +941,11 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
{rawEdges.length === 0 && steps.length > 1 && ( {rawEdges.length === 0 && steps.length > 1 && (
<Panel <Panel
position="bottom-center" position="bottom-center"
className="bg-blue-50 border border-blue-200 px-4 py-2.5 rounded-lg shadow-lg" className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
> >
<div className="flex items-center gap-2 text-sm text-blue-900"> <div className="flex items-center gap-2 text-sm text-neutral-600">
<Lightbulb className="h-4 w-4" /> <Lightbulb className="h-4 w-4" />
<span>Click the + buttons to add and connect steps!</span> <span>Click the + buttons to add and connect steps.</span>
</div> </div>
</Panel> </Panel>
)} )}
@@ -965,16 +965,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
<button <button
key={option.value} key={option.value}
onClick={() => handleCreateStep(option.value)} onClick={() => handleCreateStep(option.value)}
className="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-neutral-200 hover:border-neutral-400 hover:bg-neutral-50 transition-all group" className="flex flex-col items-center gap-2 p-4 rounded-lg border border-neutral-200 hover:border-neutral-400 hover:bg-neutral-50 transition-all group"
style={{
borderColor: 'transparent',
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = option.color;
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'transparent';
}}
> >
<div <div
className="w-12 h-12 rounded-lg flex items-center justify-center transition-transform group-hover:scale-110" className="w-12 h-12 rounded-lg flex items-center justify-center transition-transform group-hover:scale-110"
@@ -143,7 +143,7 @@ function CustomNode({
/> />
<div <div
className="px-5 py-4 rounded-xl border-2 bg-white shadow-lg hover:shadow-xl transition-all cursor-grab active:cursor-grabbing" className="px-5 py-4 rounded-xl border-2 bg-white shadow-sm hover:shadow-md transition-all cursor-grab active:cursor-grabbing"
style={{ style={{
borderColor: color, borderColor: color,
minWidth: '250px', minWidth: '250px',
@@ -496,11 +496,11 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
<Background color="#e5e7eb" gap={16} size={1} /> <Background color="#e5e7eb" gap={16} size={1} />
<Controls <Controls
showInteractive={false} showInteractive={false}
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg" className="bg-white border border-neutral-200 rounded-lg shadow-md"
/> />
<Panel <Panel
position="top-left" position="top-left"
className="bg-white/95 backdrop-blur-sm px-4 py-2.5 rounded-lg shadow-lg border border-neutral-200" className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<GitBranch className="h-4 w-4 text-neutral-700" /> <GitBranch className="h-4 w-4 text-neutral-700" />
@@ -516,9 +516,9 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
{rawEdges.length === 0 && steps.length > 1 && ( {rawEdges.length === 0 && steps.length > 1 && (
<Panel <Panel
position="bottom-center" position="bottom-center"
className="bg-amber-50 border border-amber-200 px-4 py-2.5 rounded-lg shadow-lg" className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
> >
<div className="flex items-center gap-2 text-sm text-amber-900"> <div className="flex items-center gap-2 text-sm text-neutral-600">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<span>No transitions found. Connect your steps to see the flow.</span> <span>No transitions found. Connect your steps to see the flow.</span>
</div> </div>
+7 -7
View File
@@ -271,9 +271,9 @@ export default function AnalyticsPage() {
{!hasData ? ( {!hasData ? (
<div className="flex h-[400px] w-full items-center justify-center"> <div className="flex h-[400px] w-full items-center justify-center">
<div className="text-center"> <div className="text-center">
<Mail className="mx-auto h-12 w-12 text-muted-foreground/50" /> <Mail className="mx-auto h-8 w-8 text-neutral-300" />
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No email data yet</h3> <h3 className="mt-3 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> <p className="mt-1 text-sm text-neutral-500">Send your first email to see analytics here.</p>
</div> </div>
</div> </div>
) : ( ) : (
@@ -380,10 +380,10 @@ export default function AnalyticsPage() {
{!hasData ? ( {!hasData ? (
<div className="flex h-[300px] w-full items-center justify-center"> <div className="flex h-[300px] w-full items-center justify-center">
<div className="text-center"> <div className="text-center">
<Eye className="mx-auto h-12 w-12 text-muted-foreground/50" /> <Eye className="mx-auto h-8 w-8 text-neutral-300" />
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No engagement data</h3> <h3 className="mt-3 text-sm font-semibold text-neutral-900">No engagement data</h3>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-neutral-500">
Engagement metrics will appear once emails are opened Engagement metrics will appear once emails are opened.
</p> </p>
</div> </div>
</div> </div>
+28 -46
View File
@@ -41,7 +41,6 @@ import {
ArrowLeft, ArrowLeft,
Calendar, Calendar,
ChevronDown, ChevronDown,
Info,
Mail, Mail,
MousePointer, MousePointer,
Save, Save,
@@ -622,25 +621,21 @@ export default function CampaignDetailsPage() {
{/* Show recipient count */} {/* Show recipient count */}
{draftRecipientCount > 0 && ( {draftRecipientCount > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-2"> <div className="mt-4 p-3 bg-neutral-50 border border-neutral-200 rounded-lg space-y-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-600" /> <Users className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-blue-900"> <span className="text-sm font-medium text-neutral-900">
{draftRecipientCount.toLocaleString()} recipients {draftRecipientCount.toLocaleString()} recipients
</span> </span>
</div> </div>
<div className="flex items-start gap-2"> <p className="text-xs text-neutral-500 pl-6">
<Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" /> Recalculated at send time. Final count may differ if contacts{' '}
<p className="text-xs text-blue-800">
This count will be recalculated right before sending to ensure accuracy. The final number may
differ if contacts{' '}
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL {(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'are added or removed, or segment membership changes.' ? 'are added or removed, or segment membership changes.'
: 'subscribe, unsubscribe, or segment membership changes.' : 'subscribe, unsubscribe, or segment membership changes.'
} }
</p> </p>
</div> </div>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -792,12 +787,10 @@ export default function CampaignDetailsPage() {
className="mt-2" className="mt-2"
/> />
{scheduledDateTime && ( {scheduledDateTime && (
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"> <div className="mt-2 p-3 bg-neutral-50 border border-neutral-200 rounded-lg">
<p className="text-xs font-medium text-blue-900 mb-1">Scheduled for:</p> <p className="text-xs font-medium text-neutral-500 mb-1">Scheduled for:</p>
<p className="text-sm text-blue-800"> <p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(scheduledDateTime))}</p>
<span className="font-medium">{formatFullDateTime(new Date(scheduledDateTime))}</span> <p className="text-xs text-neutral-500 mt-1">
</p>
<p className="text-xs text-blue-700 mt-1">
UTC: {formatUTCDateTime(new Date(scheduledDateTime))} UTC: {formatUTCDateTime(new Date(scheduledDateTime))}
</p> </p>
</div> </div>
@@ -884,30 +877,30 @@ export default function CampaignDetailsPage() {
{/* Sending Progress Banner */} {/* Sending Progress Banner */}
{c.status === CampaignStatus.SENDING && s && ( {c.status === CampaignStatus.SENDING && s && (
<Card className="bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200"> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="font-semibold text-neutral-900 text-lg">Sending in progress</h3> <h3 className="font-semibold text-neutral-900 text-lg">Sending in progress</h3>
<p className="text-sm text-neutral-600 mt-1"> <p className="text-sm text-neutral-500 mt-1">
{s.sentCount.toLocaleString()} of {s.totalRecipients.toLocaleString()} emails sent {s.sentCount.toLocaleString()} of {s.totalRecipients.toLocaleString()} emails sent
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-3xl font-bold text-blue-600"> <div className="text-3xl font-bold text-neutral-900">
{((s.sentCount / s.totalRecipients) * 100).toFixed(0)}% {((s.sentCount / s.totalRecipients) * 100).toFixed(0)}%
</div> </div>
<p className="text-xs text-neutral-500 mt-1">Complete</p> <p className="text-xs text-neutral-500 mt-1">Complete</p>
</div> </div>
</div> </div>
<div className="w-full bg-neutral-200 rounded-full h-3"> <div className="w-full bg-neutral-100 rounded-full h-2">
<div <div
className="bg-blue-500 h-3 rounded-full transition-all duration-500" className="bg-neutral-900 h-2 rounded-full transition-all duration-500"
style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}} style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}}
/> />
</div> </div>
<p className="text-xs text-neutral-500">This page updates automatically every 5 seconds</p> <p className="text-xs text-neutral-400">This page updates automatically every 5 seconds</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -916,12 +909,10 @@ export default function CampaignDetailsPage() {
{/* Stats Cards */} {/* Stats Cards */}
{s && ( {s && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <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"> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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> <CardTitle className="text-sm font-medium text-neutral-500">Total Recipients</CardTitle>
<div className="p-2 bg-blue-100 rounded-lg"> <Users className="h-4 w-4 text-neutral-400" />
<Users className="h-4 w-4 text-blue-600" />
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.totalRecipients.toLocaleString()}</div> <div className="text-3xl font-bold text-neutral-900">{s.totalRecipients.toLocaleString()}</div>
@@ -931,12 +922,10 @@ export default function CampaignDetailsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-l-4 border-l-green-500"> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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> <CardTitle className="text-sm font-medium text-neutral-500">Delivery Rate</CardTitle>
<div className="p-2 bg-green-100 rounded-lg"> <Mail className="h-4 w-4 text-neutral-400" />
<Mail className="h-4 w-4 text-green-600" />
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.deliveryRate.toFixed(1)}%</div> <div className="text-3xl font-bold text-neutral-900">{s.deliveryRate.toFixed(1)}%</div>
@@ -947,12 +936,10 @@ export default function CampaignDetailsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-l-4 border-l-purple-500"> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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> <CardTitle className="text-sm font-medium text-neutral-500">Open Rate</CardTitle>
<div className="p-2 bg-purple-100 rounded-lg"> <TrendingUp className="h-4 w-4 text-neutral-400" />
<TrendingUp className="h-4 w-4 text-purple-600" />
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.openRate.toFixed(1)}%</div> <div className="text-3xl font-bold text-neutral-900">{s.openRate.toFixed(1)}%</div>
@@ -960,12 +947,10 @@ export default function CampaignDetailsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-l-4 border-l-orange-500"> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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> <CardTitle className="text-sm font-medium text-neutral-500">Click Rate</CardTitle>
<div className="p-2 bg-orange-100 rounded-lg"> <MousePointer className="h-4 w-4 text-neutral-400" />
<MousePointer className="h-4 w-4 text-orange-600" />
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.clickRate.toFixed(1)}%</div> <div className="text-3xl font-bold text-neutral-900">{s.clickRate.toFixed(1)}%</div>
@@ -1063,10 +1048,7 @@ export default function CampaignDetailsPage() {
</p> </p>
</div> </div>
{c.status === CampaignStatus.SCHEDULED && ( {c.status === CampaignStatus.SCHEDULED && (
<div className="flex items-start gap-1.5 p-2 bg-blue-50 border border-blue-200 rounded"> <p className="text-xs text-neutral-500">Recipient count will be recalculated at send time</p>
<Info className="h-3 w-3 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-800">Recipient count will be recalculated at send time</p>
</div>
)} )}
</div> </div>
</div> </div>
+47 -213
View File
@@ -3,6 +3,7 @@ import {
Button, Button,
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
Input, Input,
@@ -12,7 +13,6 @@ import {
SelectItemWithDescription, SelectItemWithDescription,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Textarea,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Segment, Template} from '@plunk/db'; import type {Segment, Template} from '@plunk/db';
import {CampaignAudienceType, TemplateType} from '@plunk/db'; import {CampaignAudienceType, TemplateType} from '@plunk/db';
@@ -20,10 +20,9 @@ import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings'; import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor'; import {EmailEditor} from '../../components/EmailEditor';
import {StepHeader} from '../../components/StepHeader';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {EmailFormValidator} from '../../lib/validation'; import {EmailFormValidator} from '../../lib/validation';
import {ArrowLeft, Save, TriangleAlert, Users} from 'lucide-react'; import {ArrowLeft, TriangleAlert} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
@@ -50,7 +49,6 @@ export default function CreateCampaignPage() {
const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false}); const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false});
// Load template or campaign data if provided in query params
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
const { const {
@@ -65,36 +63,26 @@ export default function CreateCampaignPage() {
segmentId: querySegmentId, segmentId: querySegmentId,
} = router.query; } = router.query;
// Handle template loading
if (templateId && typeof templateId === 'string') { if (templateId && typeof templateId === 'string') {
setLoadingTemplate(true); setLoadingTemplate(true);
try { try {
// Fetch the full template to get the body content
const template = await network.fetch<Template>('GET', `/templates/${templateId}`); const template = await network.fetch<Template>('GET', `/templates/${templateId}`);
// Pre-fill form with template data
if (queryName && typeof queryName === 'string') setName(queryName); if (queryName && typeof queryName === 'string') setName(queryName);
if (querySubject && typeof querySubject === 'string') setSubject(querySubject); if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom); if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName); if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo); if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
setBody(template.body); setBody(template.body);
toast.success('Template loaded successfully'); toast.success('Template loaded successfully');
} catch { } catch {
toast.error('Failed to load template'); toast.error('Failed to load template');
} finally { } finally {
setLoadingTemplate(false); setLoadingTemplate(false);
} }
} } else if (campaignId && typeof campaignId === 'string') {
// Handle campaign loading
else if (campaignId && typeof campaignId === 'string') {
setLoadingTemplate(true); setLoadingTemplate(true);
try { try {
// Fetch the full campaign to get the body content
const campaign = await network.fetch<{data: {body: string}}>('GET', `/campaigns/${campaignId}`); const campaign = await network.fetch<{data: {body: string}}>('GET', `/campaigns/${campaignId}`);
// Pre-fill form with campaign data
if (queryName && typeof queryName === 'string') setName(queryName); if (queryName && typeof queryName === 'string') setName(queryName);
if (querySubject && typeof querySubject === 'string') setSubject(querySubject); if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom); if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
@@ -105,16 +93,13 @@ export default function CreateCampaignPage() {
} }
if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId); if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId);
setBody(campaign.data.body); setBody(campaign.data.body);
toast.success('Campaign loaded successfully'); toast.success('Campaign loaded successfully');
} catch { } catch {
toast.error('Failed to load campaign'); toast.error('Failed to load campaign');
} finally { } finally {
setLoadingTemplate(false); setLoadingTemplate(false);
} }
} } else {
// Handle query params without template/campaign ID (direct field values)
else {
if (queryName && typeof queryName === 'string') setName(queryName); if (queryName && typeof queryName === 'string') setName(queryName);
if (querySubject && typeof querySubject === 'string') setSubject(querySubject); if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom); if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
@@ -166,13 +151,12 @@ export default function CreateCampaignPage() {
} }
}; };
// Calculate estimated recipients
const getEstimatedRecipients = () => { const getEstimatedRecipients = () => {
if (audienceType === CampaignAudienceType.SEGMENT && segmentId && segments) { if (audienceType === CampaignAudienceType.SEGMENT && segmentId && segments) {
const segment = segments.find(s => s.id === segmentId); const segment = segments.find(s => s.id === segmentId);
return segment?.memberCount || 0; return segment?.memberCount || 0;
} }
return 0; // We don't have total contact count here, but in a real scenario you'd fetch it return 0;
}; };
const estimatedRecipients = getEstimatedRecipients(); const estimatedRecipients = getEstimatedRecipients();
@@ -217,19 +201,15 @@ export default function CreateCampaignPage() {
</div> </div>
</div> </div>
{/* Form */}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid gap-6 lg:grid-cols-3"> <div className="space-y-6">
{/* Left Column - Settings (2/3 width) */} {/* Row 1: Basic Info + Campaign Type */}
<div className="lg:col-span-2 space-y-6"> <div className="grid gap-6 md:grid-cols-2">
{/* Basic Information */} {/* Basic Information */}
<Card> <Card>
<CardHeader> <CardHeader>
<StepHeader <CardTitle>Basic Information</CardTitle>
stepNumber={1} <CardDescription>Name and describe your campaign</CardDescription>
title="Basic Information"
description="Name and describe your campaign"
/>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -246,14 +226,12 @@ export default function CreateCampaignPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description (Optional)</Label> <Label htmlFor="description">Description</Label>
<Textarea <Input
id="description" id="description"
placeholder="Internal notes about this campaign" placeholder="Internal notes about this campaign"
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={e => setDescription(e.target.value)}
rows={2}
className="resize-none"
/> />
</div> </div>
</CardContent> </CardContent>
@@ -262,56 +240,30 @@ export default function CreateCampaignPage() {
{/* Campaign Type */} {/* Campaign Type */}
<Card> <Card>
<CardHeader> <CardHeader>
<StepHeader <CardTitle>Campaign Type</CardTitle>
stepNumber={2} <CardDescription>Choose how this campaign should be treated</CardDescription>
title="Campaign Type"
description="Choose how this campaign should be treated"
/>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-3 gap-3"> <div className="flex flex-col gap-2">
{([
{value: TemplateType.MARKETING, label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
{value: TemplateType.TRANSACTIONAL, label: 'Transactional', description: 'All contacts, no subscription check or footer'},
{value: TemplateType.HEADLESS, label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
] as const).map(({value, label, description}) => (
<button <button
key={value}
type="button" type="button"
onClick={() => setCampaignType(TemplateType.MARKETING)} onClick={() => setCampaignType(value)}
className={`text-left p-4 rounded-lg border-2 transition-colors ${ className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
campaignType === TemplateType.MARKETING campaignType === value
? 'border-neutral-900 bg-neutral-50' ? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300' : 'border-neutral-200 hover:border-neutral-300'
}`} }`}
> >
<p className="font-medium text-sm text-neutral-900">Marketing</p> <span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
<p className="text-xs text-neutral-500 mt-1"> <span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
Sent to subscribed contacts only. Includes unsubscribe link.
</p>
</button>
<button
type="button"
onClick={() => setCampaignType(TemplateType.TRANSACTIONAL)}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
campaignType === TemplateType.TRANSACTIONAL
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Transactional</p>
<p className="text-xs text-neutral-500 mt-1">
Sent to all contacts regardless of subscription status. No unsubscribe footer.
</p>
</button>
<button
type="button"
onClick={() => setCampaignType(TemplateType.HEADLESS)}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
campaignType === TemplateType.HEADLESS
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Headless</p>
<p className="text-xs text-neutral-500 mt-1">
Sent to subscribed contacts only. No Plunk footer you provide the unsubscribe link.
</p>
</button> </button>
))}
</div> </div>
{campaignType === TemplateType.HEADLESS && !detectUnsubscribeSignal(body) && ( {campaignType === TemplateType.HEADLESS && !detectUnsubscribeSignal(body) && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden"> <div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
@@ -336,15 +288,13 @@ export default function CreateCampaignPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div>
{/* Email Settings */} {/* Email Settings */}
<Card> <Card>
<CardHeader> <CardHeader>
<StepHeader <CardTitle>Email Settings</CardTitle>
stepNumber={3} <CardDescription>Configure sender information and subject</CardDescription>
title="Email Settings"
description="Configure sender information and subject"
/>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<EmailSettings <EmailSettings
@@ -375,22 +325,19 @@ export default function CreateCampaignPage() {
{/* Email Content */} {/* Email Content */}
<Card className="overflow-visible"> <Card className="overflow-visible">
<CardHeader> <CardHeader>
<StepHeader stepNumber={4} title="Email Content" description="Design your email message" /> <CardTitle>Email Content</CardTitle>
<CardDescription>Design your email message</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2">
<Label htmlFor="body">
Email Body <span className="text-red-500">*</span>
</Label>
<EmailEditor value={body} onChange={setBody} /> <EmailEditor value={body} onChange={setBody} />
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Audience Selection */} {/* Audience */}
<Card> <Card>
<CardHeader> <CardHeader>
<StepHeader stepNumber={5} title="Audience" description="Choose who will receive this campaign" /> <CardTitle>Audience</CardTitle>
<CardDescription>Choose who will receive this campaign</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -441,144 +388,31 @@ export default function CreateCampaignPage() {
</SelectContent> </SelectContent>
</Select> </Select>
{segments?.length === 0 && ( {segments?.length === 0 && (
<p className="text-sm text-neutral-500 mt-2"> <p className="text-sm text-neutral-500">
No segments found.{' '} No segments found.{' '}
<Link href="/segments/new" className="text-primary hover:underline"> <Link href="/segments/new" className="underline">
Create one first Create one first
</Link> </Link>
</p> </p>
)} )}
</div> {estimatedRecipients > 0 && (
<p className="text-sm text-neutral-500">
<span className="font-medium text-neutral-900">{estimatedRecipients.toLocaleString()} recipients</span> in this segment
</p>
)} )}
{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>
)}
{audienceType === CampaignAudienceType.ALL && (
<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">
{campaignType === TemplateType.TRANSACTIONAL ? 'All contacts' : 'All subscribed contacts'}
</p>
<p className="text-xs text-blue-700 mt-1">
{campaignType === TemplateType.TRANSACTIONAL
? 'This campaign will be sent to all contacts regardless of subscription status'
: "This campaign will be sent to all contacts who haven't unsubscribed"}
</p>
</div>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </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">Type</span>
<span className="font-medium">
{campaignType === TemplateType.MARKETING ? 'Marketing' : campaignType === TemplateType.HEADLESS ? 'Headless' : 'Transactional'}
</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>
)}
{audienceType === CampaignAudienceType.ALL && (
<div className="flex justify-between py-2">
<span className="text-neutral-500">Recipients</span>
<span className="font-medium">All subscribed</span>
</div>
)}
</div>
{/* Info Note */}
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-3 mt-4">
<p className="text-xs text-neutral-600 leading-relaxed">
After creating this campaign, you&apos;ll be able to review it and choose when to send it.
</p>
</div>
{/* Actions */} {/* Actions */}
<div className="flex flex-col gap-2 pt-4"> <div className="flex justify-end gap-3">
<Button type="submit" disabled={saving} className="w-full"> <Link href="/campaigns">
{saving ? ( <Button type="button" variant="outline">Cancel</Button>
<>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> </Link>
</div> <Button type="submit" disabled={saving}>
</CardContent> {saving ? 'Creating...' : 'Create Campaign'}
</Card> </Button>
</div> </div>
</div> </div>
</form> </form>
+20 -22
View File
@@ -21,6 +21,7 @@ import type {Campaign, Template} from '@plunk/db';
import {CampaignStatus} from '@plunk/db'; import {CampaignStatus} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types'; import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog'; import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog';
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog'; import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
@@ -301,25 +302,22 @@ export default function CampaignsPage() {
{!isLoading && data?.data.length === 0 && ( {!isLoading && data?.data.length === 0 && (
<Card> <Card>
<CardContent className="py-16 text-center"> <CardContent>
<div className="max-w-md mx-auto"> <EmptyState
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4"> icon={Mail}
<Mail className="h-8 w-8 text-primary" /> title={statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
</div> description={
<h3 className="text-xl font-semibold text-neutral-900 mb-2"> statusFilter !== 'ALL'
{statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'} ? 'Adjust your filters or create a new campaign.'
</h3> : 'Send one-off emails to groups of contacts.'
<p className="text-neutral-500 mb-6"> }
{statusFilter !== 'ALL' action={
? 'Try adjusting your filters or create a new campaign.' statusFilter === 'ALL' ? (
: 'Create your first campaign to send emails to your contacts.'}
</p>
{statusFilter === 'ALL' && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button size="lg"> <Button>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Your First Campaign Create Campaign
<ChevronDown className="h-4 w-4 ml-1" /> <ChevronDown className="h-4 w-4 ml-1" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -359,16 +357,16 @@ export default function CampaignsPage() {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} ) : (
{statusFilter !== 'ALL' && (
<Link href="/campaigns/create"> <Link href="/campaigns/create">
<Button size="lg"> <Button>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Campaign Create Campaign
</Button> </Button>
</Link> </Link>
)} )
</div> }
/>
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -380,7 +378,7 @@ export default function CampaignsPage() {
campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0; campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0;
return ( return (
<Card key={campaign.id} className="hover:shadow-lg transition-all hover:border-primary/20"> <Card key={campaign.id} className="transition-colors hover:border-neutral-300">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
+89 -8
View File
@@ -10,14 +10,15 @@ import {
Label, Label,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Contact} from '@plunk/db'; import type {Contact} from '@plunk/db';
import {AnimatePresence, motion} from 'framer-motion';
import {ArrowLeft, Check, 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 {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {KeyValueEditor} from '../../components/KeyValueEditor'; import {KeyValueEditor} from '../../components/KeyValueEditor';
import {ActivityFeed} from '../../components/ActivityFeed'; import {ActivityFeed} from '../../components/ActivityFeed';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {ArrowLeft, 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 {toast} from 'sonner';
import useSWR from 'swr'; import useSWR from 'swr';
import {ContactSchemas} from '@plunk/shared'; import {ContactSchemas} from '@plunk/shared';
@@ -33,6 +34,7 @@ export default function ContactDetailPage() {
const [customData, setCustomData] = useState<Record<string, string | number | boolean> | null>(null); const [customData, setCustomData] = useState<Record<string, string | number | boolean> | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
// Initialize form when contact loads // Initialize form when contact loads
useEffect(() => { useEffect(() => {
@@ -73,9 +75,11 @@ export default function ContactDetailPage() {
} }
}; };
const copyToClipboard = async (url: string, label: string) => { const copyToClipboard = async (url: string, label: string, id: string) => {
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
toast.success(`${label} link copied to clipboard`); toast.success(`${label} link copied to clipboard`);
} catch { } catch {
toast.error('Failed to copy link'); toast.error('Failed to copy link');
@@ -325,9 +329,34 @@ export default function ContactDetailPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => copyToClipboard(`${window.location.origin}/subscribe/${contact.id}`, 'Subscribe')} className="overflow-hidden"
onClick={() =>
copyToClipboard(`${window.location.origin}/subscribe/${contact.id}`, 'Subscribe', 'subscribe')
}
>
<AnimatePresence mode="wait" initial={false}>
{copiedId === 'subscribe' ? (
<motion.span
key="copied"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Check className="h-3 w-3 text-green-600" />
</motion.span>
) : (
<motion.span
key="idle"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
> >
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
</motion.span>
)}
</AnimatePresence>
</Button> </Button>
</div> </div>
</div> </div>
@@ -347,11 +376,38 @@ export default function ContactDetailPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="overflow-hidden"
onClick={() => onClick={() =>
copyToClipboard(`${window.location.origin}/unsubscribe/${contact.id}`, 'Unsubscribe') copyToClipboard(
`${window.location.origin}/unsubscribe/${contact.id}`,
'Unsubscribe',
'unsubscribe',
)
} }
>
<AnimatePresence mode="wait" initial={false}>
{copiedId === 'unsubscribe' ? (
<motion.span
key="copied"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Check className="h-3 w-3 text-green-600" />
</motion.span>
) : (
<motion.span
key="idle"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
> >
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
</motion.span>
)}
</AnimatePresence>
</Button> </Button>
</div> </div>
</div> </div>
@@ -371,9 +427,34 @@ export default function ContactDetailPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => copyToClipboard(`${window.location.origin}/manage/${contact.id}`, 'Manage')} className="overflow-hidden"
onClick={() =>
copyToClipboard(`${window.location.origin}/manage/${contact.id}`, 'Manage', 'manage')
}
>
<AnimatePresence mode="wait" initial={false}>
{copiedId === 'manage' ? (
<motion.span
key="copied"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Check className="h-3 w-3 text-green-600" />
</motion.span>
) : (
<motion.span
key="idle"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
> >
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
</motion.span>
)}
</AnimatePresence>
</Button> </Button>
</div> </div>
</div> </div>
+63 -110
View File
@@ -19,6 +19,7 @@ import {
import type {Contact} from '@plunk/db'; import type {Contact} from '@plunk/db';
import type {CursorPaginatedResponse} from '@plunk/types'; import type {CursorPaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {KeyValueEditor} from '../../components/KeyValueEditor'; import {KeyValueEditor} from '../../components/KeyValueEditor';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils'; import {formatRelativeTime} from '../../lib/dateUtils';
@@ -282,19 +283,19 @@ export default function ContactsPage() {
</div> </div>
</div> </div>
) : contacts.length === 0 ? ( ) : contacts.length === 0 ? (
<div className="text-center py-12"> <EmptyState
<Mail className="h-12 w-12 text-neutral-400 mx-auto mb-4" /> icon={Mail}
<h3 className="text-lg font-medium text-neutral-900 mb-2">No contacts found</h3> title={search ? 'No contacts match' : 'No contacts yet'}
<p className="text-neutral-500 mb-6"> description={search ? 'Try a different search term.' : 'Add contacts to start tracking engagement.'}
{search ? 'Try adjusting your search terms' : 'Get started by creating your first contact'} action={
</p> !search ? (
{!search && (
<Button onClick={() => setShowCreateDialog(true)}> <Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Contact Add Contact
</Button> </Button>
)} ) : undefined
</div> }
/>
) : ( ) : (
<> <>
{/* Desktop Table View - Hidden on mobile */} {/* Desktop Table View - Hidden on mobile */}
@@ -547,7 +548,7 @@ function CreateContactDialog({open, onOpenChange, onSuccess}: CreateContactDialo
<DialogTitle>Create New Contact</DialogTitle> <DialogTitle>Create New Contact</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div className="space-y-1.5">
<Label htmlFor="email">Email Address *</Label> <Label htmlFor="email">Email Address *</Label>
<Input <Input
id="email" id="email"
@@ -559,21 +560,19 @@ function CreateContactDialog({open, onOpenChange, onSuccess}: CreateContactDialo
/> />
</div> </div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200"> <div className="flex items-center justify-between gap-4">
<Switch id="subscribed" checked={subscribed} onCheckedChange={setSubscribed} /> <div>
<div className="flex-1">
<Label htmlFor="subscribed" className="font-medium cursor-pointer"> <Label htmlFor="subscribed" className="font-medium cursor-pointer">
Subscribed Subscribed
</Label> </Label>
<p className="text-xs text-neutral-500 mt-1"> <p className="text-xs text-neutral-500 mt-0.5">
When enabled, this contact will receive emails from your campaigns and workflows. Receive emails from campaigns and workflows.
</p> </p>
</div> </div>
<Switch id="subscribed" checked={subscribed} onCheckedChange={setSubscribed} />
</div> </div>
<div>
<KeyValueEditor key={open ? 'create' : 'closed'} initialData={customData} onChange={setCustomData} /> <KeyValueEditor key={open ? 'create' : 'closed'} initialData={customData} onChange={setCustomData} />
</div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
@@ -778,19 +777,8 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
<div className="space-y-4"> <div className="space-y-4">
{/* Instructions */} {/* Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> <div className="text-sm text-neutral-500 space-y-1">
<h4 className="font-medium text-blue-900 mb-2">CSV Format Requirements</h4> <p>Required column: <code className="text-neutral-700 bg-neutral-100 px-1 py-0.5 rounded text-xs">email</code>. Optional: <code className="text-neutral-700 bg-neutral-100 px-1 py-0.5 rounded text-xs">subscribed</code> (true/false) and any custom fields. Max 5MB.</p>
<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: <code className="bg-blue-100 px-1 rounded">subscribed</code> (true/false, 1/0, yes/no)
</li>
<li>Optional: Add any custom fields (e.g., firstName, lastName, plan)</li>
<li>Maximum file size: 5MB</li>
</ul>
</div> </div>
{/* File Upload */} {/* File Upload */}
@@ -828,9 +816,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
</span> </span>
<span className="text-neutral-900 font-medium">{progress}%</span> <span className="text-neutral-900 font-medium">{progress}%</span>
</div> </div>
<div className="w-full bg-neutral-200 rounded-full h-2"> <div className="w-full bg-neutral-200 rounded-full h-1.5">
<div <div
className="bg-blue-600 h-2 rounded-full transition-all duration-300" className="bg-neutral-900 h-1.5 rounded-full transition-all duration-300"
style={{width: `${progress}%`}} style={{width: `${progress}%`}}
/> />
</div> </div>
@@ -840,50 +828,31 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
{/* Results */} {/* Results */}
{status === 'completed' && result && ( {status === 'completed' && result && (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-4 gap-3"> <div className="flex items-center gap-1.5 text-sm text-neutral-600">
<div className="bg-neutral-50 rounded-lg p-4"> <CheckCircle className="h-4 w-4 text-green-600 flex-shrink-0" />
<div className="text-2xl font-bold text-neutral-900">{result.totalRows}</div> <span>
<div className="text-sm text-neutral-600">Total</div> <span className="font-medium text-neutral-900">{result.totalRows}</span> processed {' '}
</div> <span className="text-neutral-900">{result.createdCount}</span> created,{' '}
<div className="bg-green-50 rounded-lg p-4"> <span className="text-neutral-900">{result.updatedCount}</span> updated
<div className="flex items-center gap-2"> {result.failureCount > 0 && (
<CheckCircle className="h-5 w-5 text-green-600" /> <>, <span className="text-red-600">{result.failureCount}</span> failed</>
<div className="text-2xl font-bold text-green-900">{result.createdCount}</div> )}
</div> </span>
<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> </div>
{/* Error Details */} {/* Error Details */}
{result.errors && result.errors.length > 0 && ( {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"> <div className="max-h-40 overflow-y-auto border border-neutral-200 rounded-md">
<h4 className="font-medium text-red-900 mb-2">Import Errors</h4> <div className="space-y-0 text-xs text-neutral-600">
<div className="space-y-1 text-sm text-red-800">
{result.errors.slice(0, 10).map((error, idx) => ( {result.errors.slice(0, 10).map((error, idx) => (
<div key={idx} className="flex gap-2"> <div key={idx} className="flex gap-3 px-3 py-2 border-b border-neutral-100 last:border-0">
<span className="font-mono text-xs">Row {error.row}:</span> <span className="font-mono text-neutral-400 flex-shrink-0">Row {error.row}</span>
<span> <span className="text-red-600">{error.email || 'N/A'} {error.error}</span>
{error.email || 'N/A'} - {error.error}
</span>
</div> </div>
))} ))}
{result.errors.length > 10 && ( {result.errors.length > 10 && (
<div className="text-red-700 font-medium mt-2"> <div className="px-3 py-2 text-neutral-500">
...and {result.errors.length - 10} more errors +{result.errors.length - 10} more errors
</div> </div>
)} )}
</div> </div>
@@ -893,14 +862,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
)} )}
{status === 'failed' && ( {status === 'failed' && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4"> <div className="flex items-start gap-2 text-sm">
<div className="flex items-center gap-2 text-red-900"> <XCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
<XCircle className="h-5 w-5" /> <p className="text-red-600">{errorMessage || 'Please check your CSV file and try again.'}</p>
<span className="font-medium">Import failed</span>
</div>
<p className="text-sm text-red-800 mt-1">
{errorMessage || 'Please check your CSV file and try again.'}
</p>
</div> </div>
)} )}
</div> </div>
@@ -1111,13 +1075,13 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
<div className="space-y-4"> <div className="space-y-4">
{status === 'idle' && ( {status === 'idle' && (
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-4"> <div className="space-y-1">
<p className="text-sm text-neutral-900"> <p className="text-sm text-neutral-700">
Are you sure you want to {operation} {contactIds.length} contact {operation === 'delete' ? 'Permanently delete' : operation === 'subscribe' ? 'Subscribe' : 'Unsubscribe'}{' '}
{contactIds.length !== 1 ? 's' : ''}? <span className="font-medium text-neutral-900">{contactIds.length} contact{contactIds.length !== 1 ? 's' : ''}</span>?
</p> </p>
{operation === 'delete' && ( {operation === 'delete' && (
<p className="text-sm text-red-600 mt-2 font-medium">This action cannot be undone.</p> <p className="text-xs text-red-500">This action cannot be undone.</p>
)} )}
</div> </div>
)} )}
@@ -1128,9 +1092,9 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
<span className="text-neutral-600">Processing contacts...</span> <span className="text-neutral-600">Processing contacts...</span>
<span className="text-neutral-900 font-medium">{progress}%</span> <span className="text-neutral-900 font-medium">{progress}%</span>
</div> </div>
<div className="w-full bg-neutral-200 rounded-full h-2"> <div className="w-full bg-neutral-200 rounded-full h-1.5">
<div <div
className={`bg-${getOperationColor()}-600 h-2 rounded-full transition-all duration-300`} className="bg-neutral-900 h-1.5 rounded-full transition-all duration-300"
style={{width: `${progress}%`}} style={{width: `${progress}%`}}
/> />
</div> </div>
@@ -1139,35 +1103,27 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
{status === 'completed' && result && ( {status === 'completed' && result && (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="flex items-center gap-1.5 text-sm text-neutral-600">
<div className="bg-green-50 rounded-lg p-4"> <CheckCircle className="h-4 w-4 text-green-600 flex-shrink-0" />
<div className="flex items-center gap-2"> <span>
<CheckCircle className="h-5 w-5 text-green-600" /> <span className="font-medium text-neutral-900">{result.successCount}</span> succeeded
<div className="text-2xl font-bold text-green-900">{result.successCount}</div>
</div>
<div className="text-sm text-green-700">Succeeded</div>
</div>
{result.failureCount > 0 && ( {result.failureCount > 0 && (
<div className="bg-red-50 rounded-lg p-4"> <>, <span className="text-red-600">{result.failureCount}</span> failed</>
<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>
)} )}
</span>
</div> </div>
{result.errors && result.errors.length > 0 && ( {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"> <div className="max-h-40 overflow-y-auto border border-neutral-200 rounded-md">
<h4 className="font-medium text-red-900 mb-2">Errors</h4> <div className="text-xs text-neutral-600">
<div className="space-y-1 text-sm text-red-800">
{result.errors.slice(0, 10).map((error, idx) => ( {result.errors.slice(0, 10).map((error, idx) => (
<div key={idx}>{error.error}</div> <div key={idx} className="px-3 py-2 border-b border-neutral-100 last:border-0 text-red-600">
{error.error}
</div>
))} ))}
{result.errors.length > 10 && ( {result.errors.length > 10 && (
<div className="text-red-700 font-medium mt-2"> <div className="px-3 py-2 text-neutral-500">
...and {result.errors.length - 10} more errors +{result.errors.length - 10} more errors
</div> </div>
)} )}
</div> </div>
@@ -1177,12 +1133,9 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
)} )}
{status === 'failed' && ( {status === 'failed' && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4"> <div className="flex items-start gap-2 text-sm">
<div className="flex items-center gap-2 text-red-900"> <XCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
<XCircle className="h-5 w-5" /> <p className="text-red-600">{errorMessage || 'Please try again.'}</p>
<span className="font-medium">Operation failed</span>
</div>
<p className="text-sm text-red-800 mt-1">{errorMessage || 'Please try again.'}</p>
</div> </div>
)} )}
</div> </div>
+11 -8
View File
@@ -38,22 +38,22 @@ export default function Index() {
const stats = [ const stats = [
{ {
name: 'Total Contacts', name: 'Total Contacts',
value: isLoading ? '-' : totalContacts.toLocaleString(), value: totalContacts.toLocaleString(),
icon: Users, icon: Users,
}, },
{ {
name: 'Emails Sent', name: 'Emails Sent',
value: isLoading ? '-' : totalEmailsSent.toLocaleString(), value: totalEmailsSent.toLocaleString(),
icon: Mail, icon: Mail,
}, },
{ {
name: 'Campaigns', name: 'Campaigns',
value: isLoading ? '-' : totalCampaigns.toLocaleString(), value: totalCampaigns.toLocaleString(),
icon: Send, icon: Send,
}, },
{ {
name: 'Open Rate', name: 'Open Rate',
value: isLoading ? '-' : `${openRate.toFixed(1)}%`, value: `${openRate.toFixed(1)}%`,
icon: TrendingUp, icon: TrendingUp,
}, },
]; ];
@@ -173,9 +173,6 @@ export default function Index() {
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1> <h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
Welcome back to {activeProject?.name || 'Plunk'}. Here&apos;s what&apos;s happening with your emails.
</p>
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
@@ -189,7 +186,13 @@ export default function Index() {
<CardDescription>{stat.name}</CardDescription> <CardDescription>{stat.name}</CardDescription>
<Icon className="h-4 w-4 text-neutral-500" /> <Icon className="h-4 w-4 text-neutral-500" />
</div> </div>
<CardTitle className="text-2xl">{stat.value}</CardTitle> <CardTitle className="text-2xl tabular-nums">
{isLoading ? (
<div className="h-7 w-16 bg-neutral-100 rounded animate-pulse" />
) : (
stat.value
)}
</CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
); );
+10 -4
View File
@@ -13,7 +13,7 @@ import type {Contact, Segment} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types'; import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, UserMinus, Users} from 'lucide-react'; import {ArrowLeft, Database, Filter, Layers, MailCheck, MailX, RefreshCw, Save, Trash2, UserMinus, Users} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
@@ -310,7 +310,11 @@ export default function SegmentDetailPage() {
{/* Filter Builder (DYNAMIC only) */} {/* Filter Builder (DYNAMIC only) */}
{!isStatic && ( {!isStatic && (
<Card> <Card>
<CardContent className="pt-6"> <CardHeader>
<CardTitle>Filter Conditions</CardTitle>
<CardDescription>Build complex audience filters with AND/OR logic</CardDescription>
</CardHeader>
<CardContent>
<SegmentFilterBuilder condition={condition} onChange={setCondition} /> <SegmentFilterBuilder condition={condition} onChange={setCondition} />
</CardContent> </CardContent>
</Card> </Card>
@@ -346,7 +350,9 @@ export default function SegmentDetailPage() {
> >
{isAddingMembers {isAddingMembers
? 'Adding...' ? 'Adding...'
: `Add ${pickedEmails.length > 0 ? pickedEmails.length : ''} Contact${pickedEmails.length !== 1 ? 's' : ''}`} : pickedEmails.length > 0
? `Add ${pickedEmails.length} Contact${pickedEmails.length !== 1 ? 's' : ''}`
: 'Add Contacts'}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -477,7 +483,7 @@ export default function SegmentDetailPage() {
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" /> <Layers className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Groups</span> <span className="text-sm text-neutral-600">Groups</span>
</div> </div>
<span className="text-lg font-semibold text-neutral-900"> <span className="text-lg font-semibold text-neutral-900">
+12 -11
View File
@@ -12,6 +12,7 @@ import {
import type {Segment} from '@plunk/db'; import type {Segment} from '@plunk/db';
import type {FilterCondition} from '@plunk/types'; import type {FilterCondition} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils'; import {formatRelativeTime} from '../../lib/dateUtils';
import {AlertTriangle, Calendar, Edit, Filter, Plus, Trash2, Users} from 'lucide-react'; import {AlertTriangle, Calendar, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
@@ -123,20 +124,20 @@ export default function SegmentsPage() {
</div> </div>
) : segments?.length === 0 ? ( ) : segments?.length === 0 ? (
<Card> <Card>
<CardContent className="py-12"> <CardContent>
<div className="text-center"> <EmptyState
<Filter className="h-12 w-12 text-neutral-400 mx-auto mb-4" /> icon={Filter}
<h3 className="text-lg font-medium text-neutral-900 mb-2">No segments yet</h3> title="No segments yet"
<p className="text-neutral-500 mb-6"> description="Group contacts by attributes to target specific audiences."
Create your first segment to group contacts based on attributes and behaviors action={
</p>
<Link href="/segments/new"> <Link href="/segments/new">
<Button> <Button>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Segment Create Segment
</Button> </Button>
</Link> </Link>
</div> }
/>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@@ -175,17 +176,17 @@ export default function SegmentsPage() {
<span className="text-lg font-semibold text-neutral-900">{segment.memberCount}</span> <span className="text-lg font-semibold text-neutral-900">{segment.memberCount}</span>
</div> </div>
{(segment as unknown as {type: string}).type !== 'STATIC' && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" /> <Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Filters</span> <span className="text-sm text-neutral-600">Filters</span>
</div> </div>
<span className="text-sm font-medium text-neutral-900"> <span className="text-sm font-medium text-neutral-900">
{(segment as unknown as {type: string}).type === 'STATIC' {countFiltersInCondition(segment.condition)}
? '—'
: countFiltersInCondition(segment.condition)}
</span> </span>
</div> </div>
)}
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-neutral-200"> <div className="flex items-center gap-2 pt-2 border-t border-neutral-200">
+5 -1
View File
@@ -176,7 +176,11 @@ export default function NewSegmentPage() {
{/* Filter Builder or Contact Picker */} {/* Filter Builder or Contact Picker */}
{segmentType === 'DYNAMIC' ? ( {segmentType === 'DYNAMIC' ? (
<Card> <Card>
<CardContent className="pt-6"> <CardHeader>
<CardTitle>Filter Conditions</CardTitle>
<CardDescription>Build complex audience filters with AND/OR logic</CardDescription>
</CardHeader>
<CardContent>
<SegmentFilterBuilder condition={condition} onChange={setCondition} /> <SegmentFilterBuilder condition={condition} onChange={setCondition} />
</CardContent> </CardContent>
</Card> </Card>
-4
View File
@@ -185,7 +185,6 @@ export default function TemplateEditorPage() {
{/* Template Editor */} {/* Template Editor */}
<div className="space-y-6"> <div className="space-y-6">
{/* Template Settings */} {/* Template Settings */}
<div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Template Settings</CardTitle> <CardTitle>Template Settings</CardTitle>
@@ -287,10 +286,8 @@ export default function TemplateEditorPage() {
/> />
</CardContent> </CardContent>
</Card> </Card>
</div>
{/* Email Body */} {/* Email Body */}
<div>
<Card className="overflow-visible"> <Card className="overflow-visible">
<CardHeader> <CardHeader>
<CardTitle>Email Body</CardTitle> <CardTitle>Email Body</CardTitle>
@@ -304,7 +301,6 @@ export default function TemplateEditorPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
</form> </form>
{/* Sticky Save Bar */} {/* Sticky Save Bar */}
+50 -33
View File
@@ -14,7 +14,7 @@ import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor'; import {EmailEditor} from '../../components/EmailEditor';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {EmailFormValidator} from '../../lib/validation'; import {EmailFormValidator} from '../../lib/validation';
import {ArrowLeft, Save, TriangleAlert} from 'lucide-react'; import {ArrowLeft, TriangleAlert} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {useState} from 'react'; import {useState} from 'react';
@@ -44,7 +44,6 @@ export default function CreateTemplatePage() {
return; return;
} }
setSaving(true); setSaving(true);
try { try {
@@ -71,9 +70,8 @@ export default function CreateTemplatePage() {
<> <>
<NextSeo title="Create Template" /> <NextSeo title="Create Template" />
<DashboardLayout> <DashboardLayout>
<div className="max-w-5xl mx-auto space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4"> <div className="flex items-center gap-3 sm:gap-4">
<Link href="/templates"> <Link href="/templates">
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
@@ -87,25 +85,18 @@ export default function CreateTemplatePage() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex justify-end">
<Button onClick={handleSubmit} disabled={saving} className="w-full sm:w-auto">
<Save className="h-4 w-4" />
<span className="hidden sm:inline">{saving ? 'Creating...' : 'Create Template'}</span>
<span className="sm:hidden">{saving ? 'Creating...' : 'Create'}</span>
</Button>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Template Settings */} {/* Row 1: Basic Info + Template Type */}
<div className="grid gap-6 md:grid-cols-2">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Template Settings</CardTitle> <CardTitle>Basic Information</CardTitle>
<CardDescription>Configure your template details and email settings</CardDescription> <CardDescription>Name and describe your template</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div className="space-y-2">
<Label htmlFor="name">Template Name *</Label> <Label htmlFor="name">Template Name <span className="text-red-500">*</span></Label>
<Input <Input
id="name" id="name"
type="text" type="text"
@@ -116,9 +107,26 @@ export default function CreateTemplatePage() {
/> />
</div> </div>
<div> <div className="space-y-2">
<Label>Template Type *</Label> <Label htmlFor="description">Description</Label>
<div className="flex flex-col gap-2 mt-2"> <Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Sent to new subscribers"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Template Type</CardTitle>
<CardDescription>Choose how this template should be treated</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
{([ {([
{value: 'MARKETING', label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'}, {value: 'MARKETING', label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
{value: 'TRANSACTIONAL', label: 'Transactional', description: 'All contacts, no subscription check or footer'}, {value: 'TRANSACTIONAL', label: 'Transactional', description: 'All contacts, no subscription check or footer'},
@@ -160,21 +168,19 @@ export default function CreateTemplatePage() {
</div> </div>
</div> </div>
)} )}
</CardContent>
</Card>
</div> </div>
<div> {/* Email Settings */}
<Label htmlFor="description">Description</Label> <Card>
<Input <CardHeader>
id="description" <CardTitle>Email Settings</CardTitle>
type="text" <CardDescription>Configure sender information and subject</CardDescription>
value={description} </CardHeader>
onChange={e => setDescription(e.target.value)} <CardContent className="space-y-4">
placeholder="Sent to new subscribers" <div className="space-y-2">
/> <Label htmlFor="subject">Subject Line <span className="text-red-500">*</span></Label>
</div>
<div>
<Label htmlFor="subject">Subject Line *</Label>
<Input <Input
id="subject" id="subject"
type="text" type="text"
@@ -183,6 +189,7 @@ export default function CreateTemplatePage() {
required required
placeholder="Welcome to our platform!" placeholder="Welcome to our platform!"
/> />
<p className="text-xs text-neutral-500">Use {'{{variableName}}'} for dynamic content</p>
</div> </div>
<EmailSettings <EmailSettings
@@ -207,6 +214,16 @@ export default function CreateTemplatePage() {
<EmailEditor value={body} onChange={setBody} /> <EmailEditor value={body} onChange={setBody} />
</CardContent> </CardContent>
</Card> </Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Link href="/templates">
<Button type="button" variant="outline">Cancel</Button>
</Link>
<Button type="submit" disabled={saving}>
{saving ? 'Creating...' : 'Create Template'}
</Button>
</div>
</form> </form>
</div> </div>
</DashboardLayout> </DashboardLayout>
+11 -10
View File
@@ -12,6 +12,7 @@ import {
import type {Template} from '@plunk/db'; import type {Template} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types'; import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils'; import {formatRelativeTime} from '../../lib/dateUtils';
import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react'; import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
@@ -185,22 +186,22 @@ export default function TemplatesPage() {
</Card> </Card>
) : data?.data.length === 0 ? ( ) : data?.data.length === 0 ? (
<Card> <Card>
<CardContent className="pt-6"> <CardContent>
<div className="text-center py-12"> <EmptyState
<FileText className="h-12 w-12 text-neutral-400 mx-auto mb-4" /> icon={FileText}
<h3 className="text-lg font-medium text-neutral-900 mb-2">No templates found</h3> title={search ? 'No templates match' : 'No templates yet'}
<p className="text-neutral-500 mb-6"> description={search ? 'Try a different search term.' : 'Create reusable email designs for campaigns.'}
{search ? 'Try adjusting your search terms' : 'Get started by creating your first template'} action={
</p> !search ? (
{!search && (
<Link href="/templates/create"> <Link href="/templates/create">
<Button> <Button>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Template Create Template
</Button> </Button>
</Link> </Link>
)} ) : undefined
</div> }
/>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
+46 -63
View File
@@ -35,6 +35,7 @@ import {
import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types'; import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import { import {
AlertTriangle, AlertTriangle,
@@ -597,13 +598,11 @@ export default function WorkflowEditorPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!executionsData?.executions.length ? ( {!executionsData?.executions.length ? (
<div className="text-center py-12"> <EmptyState
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" /> icon={Users}
<h3 className="text-lg font-medium text-neutral-900 mb-2">No executions yet</h3> title="No executions yet"
<p className="text-neutral-500 mb-6"> description="This workflow hasn't been executed yet. Enable it to start processing contacts."
This workflow hasn&apos;t been executed yet. Enable it to start processing contacts. />
</p>
</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
@@ -1254,8 +1253,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information Section */} {/* Basic Information Section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Basic Information</h3> <h3 className="text-sm font-semibold text-neutral-900">Basic Information</h3>
</div> </div>
@@ -1330,12 +1328,11 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* SEND_EMAIL Configuration */} {/* SEND_EMAIL Configuration */}
{type === 'SEND_EMAIL' && ( {type === 'SEND_EMAIL' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Email Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Email Configuration</h3>
</div> </div>
<div className="space-y-4 pl-3"> <div className="space-y-4">
<div> <div>
<Label htmlFor="template" className="text-sm font-medium"> <Label htmlFor="template" className="text-sm font-medium">
Email Template * Email Template *
@@ -1386,7 +1383,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
</div> </div>
{recipientType === 'CUSTOM' && ( {recipientType === 'CUSTOM' && (
<div className="pl-3 border-l-2 border-blue-200 bg-blue-50/50 -ml-3 py-3 pr-3"> <div className="p-3 bg-neutral-50 rounded-lg border border-neutral-200">
<Label htmlFor="customEmail" className="text-sm font-medium"> <Label htmlFor="customEmail" className="text-sm font-medium">
Email Address * Email Address *
</Label> </Label>
@@ -1411,12 +1408,11 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* DELAY Configuration */} {/* DELAY Configuration */}
{type === 'DELAY' && ( {type === 'DELAY' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Delay Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Delay Configuration</h3>
</div> </div>
<div className="grid grid-cols-2 gap-4 pl-3"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="delayAmount" className="text-sm font-medium"> <Label htmlFor="delayAmount" className="text-sm font-medium">
Amount * Amount *
@@ -1459,22 +1455,21 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
</Select> </Select>
</div> </div>
</div> </div>
<p className="text-xs text-neutral-500 pl-3">Maximum delay: 365 days</p> <p className="text-xs text-neutral-500">Maximum delay: 365 days</p>
</div> </div>
)} )}
{/* CONDITION Configuration */} {/* CONDITION Configuration */}
{type === 'CONDITION' && ( {type === 'CONDITION' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Condition Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Condition Configuration</h3>
</div> </div>
<p className="text-sm text-neutral-600 pl-3"> <p className="text-sm text-neutral-600">
Define the condition that determines which path contacts will follow Define the condition that determines which path contacts will follow
</p> </p>
<div className="space-y-4 pl-3"> <div className="space-y-4">
<div> <div>
<Label htmlFor="conditionField" className="text-sm font-medium"> <Label htmlFor="conditionField" className="text-sm font-medium">
Field to Check * Field to Check *
@@ -1628,12 +1623,11 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* WAIT_FOR_EVENT Configuration */} {/* WAIT_FOR_EVENT Configuration */}
{type === 'WAIT_FOR_EVENT' && ( {type === 'WAIT_FOR_EVENT' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Wait for Event Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Wait for Event Configuration</h3>
</div> </div>
<div className="space-y-4 pl-3"> <div className="space-y-4">
<div> <div>
<Label htmlFor="eventName" className="text-sm font-medium"> <Label htmlFor="eventName" className="text-sm font-medium">
Event Name * Event Name *
@@ -1736,12 +1730,11 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* WEBHOOK Configuration */} {/* WEBHOOK Configuration */}
{type === 'WEBHOOK' && ( {type === 'WEBHOOK' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Webhook Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Webhook Configuration</h3>
</div> </div>
<div className="space-y-4 pl-3"> <div className="space-y-4">
<div> <div>
<Label htmlFor="webhookUrl" className="text-sm font-medium"> <Label htmlFor="webhookUrl" className="text-sm font-medium">
Webhook URL * Webhook URL *
@@ -1799,12 +1792,11 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* UPDATE_CONTACT Configuration */} {/* UPDATE_CONTACT Configuration */}
{type === 'UPDATE_CONTACT' && ( {type === 'UPDATE_CONTACT' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Contact Update Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Contact Update Configuration</h3>
</div> </div>
<div className="pl-3"> <div>
<Label htmlFor="contactUpdates" className="text-sm font-medium"> <Label htmlFor="contactUpdates" className="text-sm font-medium">
Contact Data Updates (JSON) * Contact Data Updates (JSON) *
</Label> </Label>
@@ -1827,12 +1819,11 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* EXIT Configuration */} {/* EXIT Configuration */}
{type === 'EXIT' && ( {type === 'EXIT' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Exit Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Exit Configuration</h3>
</div> </div>
<div className="pl-3"> <div>
<Label htmlFor="exitReason" className="text-sm font-medium"> <Label htmlFor="exitReason" className="text-sm font-medium">
Exit Reason Exit Reason
</Label> </Label>
@@ -2322,12 +2313,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information */} {/* Basic Information */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Basic Information</h3> <h3 className="text-sm font-semibold text-neutral-900">Basic Information</h3>
</div> </div>
<div className="pl-3"> <div>
<Label htmlFor="editStepName" className="text-sm font-medium"> <Label htmlFor="editStepName" className="text-sm font-medium">
Step Name * Step Name *
</Label> </Label>
@@ -2349,12 +2339,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* SEND_EMAIL Configuration */} {/* SEND_EMAIL Configuration */}
{step.type === 'SEND_EMAIL' && ( {step.type === 'SEND_EMAIL' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Email Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Email Configuration</h3>
</div> </div>
<div className="space-y-4 pl-3"> <div className="space-y-4">
<div> <div>
<Label htmlFor="editTemplate" className="text-sm font-medium"> <Label htmlFor="editTemplate" className="text-sm font-medium">
Email Template * Email Template *
@@ -2405,7 +2394,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
</div> </div>
{recipientType === 'CUSTOM' && ( {recipientType === 'CUSTOM' && (
<div className="pl-3 border-l-2 border-blue-200 bg-blue-50/50 -ml-3 py-3 pr-3"> <div className="p-3 bg-neutral-50 rounded-lg border border-neutral-200">
<Label htmlFor="editCustomEmail" className="text-sm font-medium"> <Label htmlFor="editCustomEmail" className="text-sm font-medium">
Email Address * Email Address *
</Label> </Label>
@@ -2430,12 +2419,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* DELAY Configuration */} {/* DELAY Configuration */}
{step.type === 'DELAY' && ( {step.type === 'DELAY' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Delay Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Delay Configuration</h3>
</div> </div>
<div className="grid grid-cols-2 gap-4 pl-3"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="editDelayAmount" className="text-sm font-medium"> <Label htmlFor="editDelayAmount" className="text-sm font-medium">
Amount * Amount *
@@ -2478,19 +2466,18 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
</Select> </Select>
</div> </div>
</div> </div>
<p className="text-xs text-neutral-500 pl-3">Maximum delay: 365 days</p> <p className="text-xs text-neutral-500">Maximum delay: 365 days</p>
</div> </div>
)} )}
{/* CONDITION Configuration */} {/* CONDITION Configuration */}
{step.type === 'CONDITION' && ( {step.type === 'CONDITION' && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Condition Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Condition Configuration</h3>
</div> </div>
<div className="space-y-4 pl-3"> <div className="space-y-4">
{/* Mode toggle */} {/* Mode toggle */}
<div> <div>
<Label className="text-sm font-medium mb-2 block">Condition Mode</Label> <Label className="text-sm font-medium mb-2 block">Condition Mode</Label>
@@ -2501,10 +2488,10 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
disabled={conditionMode === 'multi' && hasMultiBranchConnections()} disabled={conditionMode === 'multi' && hasMultiBranchConnections()}
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${ className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
conditionMode === 'binary' conditionMode === 'binary'
? 'border-purple-500 bg-purple-50 text-purple-700' ? 'border-neutral-900 bg-neutral-900 text-white'
: conditionMode === 'multi' && hasMultiBranchConnections() : conditionMode === 'multi' && hasMultiBranchConnections()
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50' ? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300' : 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50'
}`} }`}
> >
Simple (If/Else) Simple (If/Else)
@@ -2515,10 +2502,10 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
disabled={conditionMode === 'binary' && hasBinaryConnections()} disabled={conditionMode === 'binary' && hasBinaryConnections()}
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${ className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
conditionMode === 'multi' conditionMode === 'multi'
? 'border-purple-500 bg-purple-50 text-purple-700' ? 'border-neutral-900 bg-neutral-900 text-white'
: conditionMode === 'binary' && hasBinaryConnections() : conditionMode === 'binary' && hasBinaryConnections()
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50' ? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300' : 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50'
}`} }`}
> >
Multi-branch (Switch) Multi-branch (Switch)
@@ -2832,12 +2819,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* WAIT_FOR_EVENT Configuration */} {/* WAIT_FOR_EVENT Configuration */}
{step.type === 'WAIT_FOR_EVENT' && ( {step.type === 'WAIT_FOR_EVENT' && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Event Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Event Configuration</h3>
</div> </div>
<div className="space-y-4 pl-3"> <div className="space-y-4">
<div> <div>
<Label htmlFor="editEventName">Event Name *</Label> <Label htmlFor="editEventName">Event Name *</Label>
<div className="relative"> <div className="relative">
@@ -2937,12 +2923,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* WEBHOOK Configuration */} {/* WEBHOOK Configuration */}
{step.type === 'WEBHOOK' && ( {step.type === 'WEBHOOK' && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Webhook Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Webhook Configuration</h3>
</div> </div>
<div className="space-y-4 pl-3"> <div className="space-y-4">
{/* Info Alert about webhook body */} {/* Info Alert about webhook body */}
<Alert> <Alert>
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
@@ -3097,12 +3082,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* UPDATE_CONTACT Configuration */} {/* UPDATE_CONTACT Configuration */}
{step.type === 'UPDATE_CONTACT' && ( {step.type === 'UPDATE_CONTACT' && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Contact Updates</h3> <h3 className="text-sm font-semibold text-neutral-900">Contact Updates</h3>
</div> </div>
<div className="space-y-4 pl-3"> <div className="space-y-4">
<div> <div>
<Label htmlFor="editContactUpdates">Contact Data Updates (JSON) *</Label> <Label htmlFor="editContactUpdates">Contact Data Updates (JSON) *</Label>
<textarea <textarea
@@ -3136,12 +3120,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* EXIT Configuration */} {/* EXIT Configuration */}
{step.type === 'EXIT' && ( {step.type === 'EXIT' && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200"> <div className="pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Exit Configuration</h3> <h3 className="text-sm font-semibold text-neutral-900">Exit Configuration</h3>
</div> </div>
<div className="space-y-4 pl-3"> <div className="space-y-4">
<div> <div>
<Label htmlFor="editExitReason">Exit Reason (optional)</Label> <Label htmlFor="editExitReason">Exit Reason (optional)</Label>
<Select value={exitReason} onValueChange={setExitReason}> <Select value={exitReason} onValueChange={setExitReason}>
+19 -19
View File
@@ -22,6 +22,7 @@ import {
import type {Workflow} from '@plunk/db'; import type {Workflow} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types'; import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils'; import {formatRelativeTime} from '../../lib/dateUtils';
import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react'; import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
@@ -157,20 +158,20 @@ export default function WorkflowsPage() {
</Card> </Card>
) : data?.data.length === 0 ? ( ) : data?.data.length === 0 ? (
<Card> <Card>
<CardContent className="pt-6"> <CardContent>
<div className="text-center py-12"> <EmptyState
<WorkflowIcon className="h-12 w-12 text-neutral-400 mx-auto mb-4" /> icon={WorkflowIcon}
<h3 className="text-lg font-medium text-neutral-900 mb-2">No workflows found</h3> title={search ? 'No workflows match' : 'No workflows yet'}
<p className="text-neutral-500 mb-6"> description={search ? 'Try a different search term.' : 'Automate emails triggered by contact events.'}
{search ? 'Try adjusting your search terms' : 'Get started by creating your first workflow'} action={
</p> !search ? (
{!search && (
<Button onClick={() => setShowCreateDialog(true)}> <Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Workflow Create Workflow
</Button> </Button>
)} ) : undefined
</div> }
/>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@@ -366,7 +367,7 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
<DialogTitle>Create New Workflow</DialogTitle> <DialogTitle>Create New Workflow</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div className="space-y-1.5">
<Label htmlFor="name">Name *</Label> <Label htmlFor="name">Name *</Label>
<Input <Input
id="name" id="name"
@@ -378,19 +379,19 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<textarea <textarea
id="description" id="description"
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={e => setDescription(e.target.value)}
placeholder="Send a series of welcome emails to new subscribers" 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" className="w-full px-3 py-2 border border-neutral-200 rounded-md text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
rows={3} rows={3}
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor="createEventName">Trigger Event *</Label> <Label htmlFor="createEventName">Trigger Event *</Label>
{/* Combobox: 可自由輸入 event name,同時提供已追蹤 event 的下拉建議 */} {/* Combobox: 可自由輸入 event name,同時提供已追蹤 event 的下拉建議 */}
<div className="relative"> <div className="relative">
@@ -453,21 +454,20 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
</p> </p>
</div> </div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200"> <div className="flex items-start gap-3">
<input <input
id="allowReentry" id="allowReentry"
type="checkbox" type="checkbox"
checked={allowReentry} checked={allowReentry}
onChange={e => setAllowReentry(e.target.checked)} onChange={e => setAllowReentry(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded" className="mt-0.5 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
/> />
<div className="flex-1"> <div className="flex-1">
<Label htmlFor="allowReentry" className="font-medium cursor-pointer"> <Label htmlFor="allowReentry" className="font-medium cursor-pointer">
Allow Re-entry Allow Re-entry
</Label> </Label>
<p className="text-xs text-neutral-500 mt-1"> <p className="text-xs text-neutral-500 mt-0.5">
When enabled, contacts can enter this workflow multiple times. When disabled, contacts can only enter When enabled, contacts can enter this workflow multiple times.
once, ever.
</p> </p>
</div> </div>
</div> </div>
+4 -4
View File
@@ -9,10 +9,10 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-neutral-900 text-neutral-50 shadow hover:bg-neutral-900/90 hover:shadow-md', default: 'bg-neutral-900 text-neutral-50 hover:bg-neutral-900/90',
destructive: 'bg-red-500 text-neutral-50 shadow-sm hover:bg-red-500/90 hover:shadow-md', destructive: 'bg-red-500 text-neutral-50 hover:bg-red-500/90',
outline: 'border border-neutral-200 bg-white shadow-sm hover:bg-neutral-100 hover:text-neutral-900 hover:shadow-md', outline: 'border border-neutral-200 bg-white hover:bg-neutral-100 hover:text-neutral-900',
secondary: 'bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-100/80 hover:shadow-md', secondary: 'bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80',
ghost: 'hover:bg-neutral-100 hover:text-neutral-900', ghost: 'hover:bg-neutral-100 hover:text-neutral-900',
link: 'text-neutral-900 underline-offset-4 hover:underline', link: 'text-neutral-900 underline-offset-4 hover:underline',
}, },
+1 -1
View File
@@ -5,7 +5,7 @@ import {cn} from '../../lib';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({className, ...props}, ref) => ( const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({className, ...props}, ref) => (
<div <div
ref={ref} ref={ref}
className={cn('rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow overflow-hidden', className)} className={cn('rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm overflow-hidden', className)}
{...props} {...props}
/> />
)); ));
+5 -5
View File
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className, className,
)} )}
{...props} {...props}
@@ -36,13 +36,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-neutral-200 bg-white p-4 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg max-h-[90vh] overflow-y-auto', 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-5 border border-neutral-200 bg-white p-6 shadow-md duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg max-h-[90vh] overflow-y-auto',
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-2.5 top-2.5 sm:right-4 sm:top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 p-1 sm:p-1.5"> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-60 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 p-1.5">
<X className="h-5 w-5 sm:h-5 sm:w-5" /> <X className="h-5 w-5 sm:h-5 sm:w-5" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
@@ -52,12 +52,12 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => ( const DialogHeader = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} /> <div className={cn('flex flex-col space-y-1', className)} {...props} />
); );
DialogHeader.displayName = 'DialogHeader'; DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => ( const DialogFooter = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} /> <div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end gap-2', className)} {...props} />
); );
DialogFooter.displayName = 'DialogFooter'; DialogFooter.displayName = 'DialogFooter';
@@ -46,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-neutral-200 bg-white p-1 text-neutral-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'z-50 min-w-[8rem] overflow-hidden rounded-md border border-neutral-200 bg-white p-1 text-neutral-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className, className,
)} )}
{...props} {...props}