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 {network} from '../lib/network';
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';
export interface ActivityFeedProps {
@@ -186,11 +187,11 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
if (activities.length === 0 && upcomingActivities.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-500 text-sm">
No activity found for the selected filters. Activities will appear here as they happen.
</p>
</div>
<EmptyState
icon={ActivityIcon}
title="No activity yet"
description="Events will appear here as contacts interact with your emails."
/>
);
}
+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 {Button} from '@plunk/ui';
import {Check, Copy, Eye, EyeOff, RefreshCw} from 'lucide-react';
interface ApiKeyDisplayProps {
label: string;
@@ -28,8 +29,8 @@ export function ApiKeyDisplay({
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
} catch {
// clipboard API unavailable — silent
}
};
@@ -39,8 +40,8 @@ export function ApiKeyDisplay({
try {
setIsRegenerating(true);
await onRegenerate();
} catch (error) {
console.error('Failed to regenerate:', error);
} catch {
// error surfaced via isRegenerating state reset
} finally {
setIsRegenerating(false);
}
@@ -74,9 +75,31 @@ export function ApiKeyDisplay({
size="icon"
onClick={handleCopy}
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>
{showRegenerate && onRegenerate && (
<Button
@@ -198,7 +198,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
{data?.data.map(campaign => (
<Card
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)}
>
<CardHeader className="pb-3">
@@ -266,30 +266,24 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* 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 */}
{selectedCampaign && (
<Card className="bg-neutral-50">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<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)}
</div>
{selectedCampaign.description && (
<CardDescription className="text-xs">{selectedCampaign.description}</CardDescription>
)}
</div>
</div>
</CardHeader>
</Card>
<div className="pb-4 mb-1 border-b border-neutral-100">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-900">{selectedCampaign.name}</span>
{getStatusBadge(selectedCampaign.status)}
</div>
{selectedCampaign.description && (
<p className="text-xs text-neutral-500 mt-1">{selectedCampaign.description}</p>
)}
</div>
)}
{/* Field Selection */}
<div className="space-y-3">
<div className="divide-y divide-neutral-100">
<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')}
>
<Checkbox
@@ -302,13 +296,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
Email Subject
</Label>
{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
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')}
>
<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">
Email Body
</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
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')}
>
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
@@ -330,13 +324,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
From Email
</Label>
{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
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')}
>
<Checkbox
@@ -349,13 +343,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
From Name
</Label>
{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
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')}
>
<Checkbox
@@ -368,13 +362,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
Reply-To Email
</Label>
{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
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')}
>
<Checkbox
@@ -387,7 +381,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
Audience Settings
</Label>
{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>
+26 -17
View File
@@ -15,7 +15,6 @@ import {
Menu,
Plus,
Settings,
User,
Users,
Workflow,
} from 'lucide-react';
@@ -57,7 +56,6 @@ const navigation: NavSection[] = [
],
},
{
title: 'Campaigns',
items: [{name: 'Campaigns', href: '/campaigns', icon: Megaphone}],
},
];
@@ -127,8 +125,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
// Redirect to login
await router.push('/auth/login');
} catch (error) {
console.error('Logout failed:', error);
} catch {
// Even if the API call fails, try to redirect to login
localStorage.removeItem('token');
localStorage.removeItem('activeProjectId');
@@ -179,12 +176,14 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
</div>
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
</div>
<ChevronDown className="h-4 w-4 text-neutral-500 flex-shrink-0" />
<ChevronDown
className={`h-4 w-4 text-neutral-500 flex-shrink-0 transition-transform duration-200 ${showProjectMenu ? 'rotate-180' : ''}`}
/>
</button>
{/* Project Dropdown */}
{showProjectMenu && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1 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 => (
<button
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"
>
<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()}
</div>
<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">
{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;
return (
<Link
key={item.name}
href={item.href}
onClick={() => setShowMobileMenu(false)}
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
isActive ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
isActive ? 'bg-neutral-100 text-neutral-900' : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
}`}
>
<Icon className="h-5 w-5" />
@@ -257,7 +257,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
href={WIKI_URI}
target="_blank"
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" />
Documentation
@@ -266,8 +266,10 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<Link
href="/settings"
onClick={() => setShowMobileMenu(false)}
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
router.pathname.startsWith('/settings') ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
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 text-neutral-900'
: 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
}`}
>
<Settings className="h-5 w-5" />
@@ -277,16 +279,23 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<div className="relative" ref={userMenuRef}>
<button
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>
<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>
{/* User Dropdown */}
{showUserMenu && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
<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
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"
@@ -151,7 +151,7 @@ export function DataManagementSettings() {
</CardHeader>
<CardContent>
{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>
<TableHeader>
@@ -170,7 +170,7 @@ export function DataManagementSettings() {
<Badge variant="secondary">{field.type}</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">{field.coverage}%</span>
<span className="text-sm text-neutral-500">{field.coverage}%</span>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => openFieldDeleteDialog(field.field)}>
@@ -196,7 +196,7 @@ export function DataManagementSettings() {
</CardHeader>
<CardContent>
{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>
<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 {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 {
projectId: string;
}
@@ -419,14 +447,13 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
onClick={() =>
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 ===
`${token}._domainkey.${domain.domain}-${index + 2000}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
<AnimatedCopyIcon
isCopied={
copiedToken === `${token}._domainkey.${domain.domain}-${index + 2000}`
}
/>
</Button>
</div>
</td>
@@ -439,13 +466,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost"
size="sm"
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}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
<AnimatedCopyIcon
isCopied={copiedToken === `${token}.dkim.amazonses.com-${index}`}
/>
</Button>
</div>
</td>
@@ -500,13 +525,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost"
size="sm"
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` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
<AnimatedCopyIcon
isCopied={copiedToken === `plunk.${domain.domain}-3000`}
/>
</Button>
</div>
</td>
@@ -524,14 +547,14 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
1000,
)
}
className="shrink-0 h-6 w-6 p-0"
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
>
{copiedToken ===
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com-1000` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
<AnimatedCopyIcon
isCopied={
copiedToken ===
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com-1000`
}
/>
</Button>
</div>
</td>
@@ -551,13 +574,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost"
size="sm"
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` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
<AnimatedCopyIcon
isCopied={copiedToken === `plunk.${domain.domain}-3001`}
/>
</Button>
</div>
</td>
@@ -572,13 +593,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
onClick={() =>
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' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
<AnimatedCopyIcon
isCopied={copiedToken === '"v=spf1 include:amazonses.com ~all"-1001'}
/>
</Button>
</div>
</td>
@@ -632,13 +651,9 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost"
size="sm"
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` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
<AnimatedCopyIcon isCopied={copiedToken === `${domain.domain}-3002`} />
</Button>
</div>
</td>
@@ -656,14 +671,14 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
1002,
)
}
className="shrink-0 h-6 w-6 p-0"
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
>
{copiedToken ===
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com-1002` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
<AnimatedCopyIcon
isCopied={
copiedToken ===
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com-1002`
}
/>
</Button>
</div>
</td>
@@ -674,8 +689,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
</div>
)}
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200 mt-3">
<div className="text-blue-600 mt-0.5">
<div className="flex items-start gap-2 p-3 bg-neutral-50 rounded-lg border border-neutral-200 mt-3">
<div className="text-neutral-500 mt-0.5">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
@@ -684,7 +699,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
/>
</svg>
</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
provider, use the refresh button above to verify your domain.
</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
onClick={copyEmail}
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}>
{copied ? (
@@ -230,8 +230,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
<div className="flex-1 pt-0.5">
<p className="text-sm font-semibold text-green-900 mb-1">All set!</p>
<p className="text-xs text-green-700 leading-relaxed">
Your project is fully configured and you&apos;re actively engaging your audience. Keep up the great
work!
Domain verified, contacts imported, campaigns running. Everything is set up correctly.
</p>
</div>
</div>
@@ -775,19 +775,9 @@ interface SegmentFilterBuilderProps {
export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) {
const {fields, loading} = useAvailableOptions();
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-neutral-900">Filter Conditions</h3>
<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>
);
if (loading) {
return <div className="text-sm text-neutral-500 py-4">Loading available fields and events...</div>;
}
return <FilterConditionComponent condition={condition} onChange={onChange} availableFields={fields} />;
}
+29 -27
View File
@@ -1,7 +1,6 @@
import {useState} from 'react';
import useSWR from 'swr';
import {
Alert,
Badge,
Button,
Card,
@@ -40,7 +39,8 @@ import {
TableRow,
} from '@plunk/ui';
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 {zodResolver} from '@hookform/resolvers/zod';
import type {z} from 'zod';
@@ -173,23 +173,30 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
return (
<div className="space-y-6">
{success && (
<Alert>
<Mail className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{success}</p>
</div>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{error}</p>
</div>
</Alert>
)}
<AnimatePresence mode="wait">
{success && (
<motion.div
key="success"
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
>
{success}
</motion.div>
)}
{error && (
<motion.div
key="error"
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
>
{error}
</motion.div>
)}
</AnimatePresence>
<Card>
<CardHeader>
@@ -214,10 +221,10 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
<CardContent>
{isLoading ? (
<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>
) : 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>
<TableHeader>
@@ -314,12 +321,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
<Form {...form}>
<form onSubmit={form.handleSubmit(handleAddMember)} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{error}</p>
</div>
</Alert>
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">{error}</div>
)}
<FormField
control={form.control}
@@ -236,7 +236,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
{data?.data.map(template => (
<Card
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)}
>
<CardHeader className="pb-3">
@@ -295,35 +295,29 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* 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 */}
{selectedTemplate && (
<Card className="bg-neutral-50">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base">{selectedTemplate.name}</CardTitle>
<Badge
className="capitalize"
variant={selectedTemplate.type === 'MARKETING' ? 'info' : 'success'}
>
{selectedTemplate.type.toLowerCase()}
</Badge>
</div>
{selectedTemplate.description && (
<CardDescription className="text-xs">{selectedTemplate.description}</CardDescription>
)}
</div>
</div>
</CardHeader>
</Card>
<div className="pb-4 mb-1 border-b border-neutral-100">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-900">{selectedTemplate.name}</span>
<Badge
className="capitalize"
variant={selectedTemplate.type === 'MARKETING' ? 'info' : 'success'}
>
{selectedTemplate.type.toLowerCase()}
</Badge>
</div>
{selectedTemplate.description && (
<p className="text-xs text-neutral-500 mt-1">{selectedTemplate.description}</p>
)}
</div>
)}
{/* Field Selection */}
<div className="space-y-3">
<div className="divide-y divide-neutral-100">
<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')}
>
<Checkbox
@@ -336,13 +330,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
Email Subject
</Label>
{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
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')}
>
<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">
Email Body
</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
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')}
>
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
@@ -364,13 +358,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
From Email
</Label>
{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
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')}
>
<Checkbox
@@ -383,13 +377,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
From Name
</Label>
{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
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')}
>
<Checkbox
@@ -402,7 +396,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
Reply-To Email
</Label>
{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>
+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="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">
<Plus className="h-8 w-8 text-neutral-500 transition-colors" />
<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 hover:text-neutral-700 transition-colors" />
</div>
{data.label && <div className="text-xs text-neutral-500 text-center mt-2 font-medium">{data.label}</div>}
</div>
@@ -255,7 +255,7 @@ function CustomNode({
/>
<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={{
borderColor: color,
minWidth: '280px',
@@ -274,7 +274,7 @@ function CustomNode({
}}
variant="outline"
size="icon"
className="h-7 w-7 shadow-md"
className="h-7 w-7"
title="Edit trigger settings"
>
<Settings className="h-3.5 w-3.5" />
@@ -290,7 +290,7 @@ function CustomNode({
}}
variant="outline"
size="icon"
className="h-7 w-7 shadow-md"
className="h-7 w-7"
title="Edit step"
>
<Settings className="h-3.5 w-3.5" />
@@ -302,7 +302,7 @@ function CustomNode({
}}
variant="outline"
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"
>
<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} />
<Controls
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
nodeColor={node => {
const step = steps.find(s => s.id === node.id);
return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280';
}}
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
className="bg-white border border-neutral-200 rounded-lg shadow-md"
maskColor="rgba(0, 0, 0, 0.05)"
/>
<Panel
position="top-left"
className="bg-white/95 backdrop-blur-sm px-4 py-2.5 rounded-lg shadow-lg border border-neutral-200"
className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200"
>
<div className="flex items-center gap-3">
<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">
<button
onClick={handleAutoLayout}
className="bg-white/95 backdrop-blur-sm px-4 py-2 rounded-lg shadow-lg border border-neutral-200 text-sm font-medium text-neutral-700 hover:bg-white hover:text-neutral-900 transition-all"
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
</button>
@@ -941,11 +941,11 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
{rawEdges.length === 0 && steps.length > 1 && (
<Panel
position="bottom-center"
className="bg-blue-50 border border-blue-200 px-4 py-2.5 rounded-lg shadow-lg"
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" />
<span>Click the + buttons to add and connect steps!</span>
<span>Click the + buttons to add and connect steps.</span>
</div>
</Panel>
)}
@@ -965,16 +965,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
<button
key={option.value}
onClick={() => handleCreateStep(option.value)}
className="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-neutral-200 hover:border-neutral-400 hover:bg-neutral-50 transition-all group"
style={{
borderColor: 'transparent',
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = option.color;
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'transparent';
}}
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"
>
<div
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
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={{
borderColor: color,
minWidth: '250px',
@@ -496,11 +496,11 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
<Background color="#e5e7eb" gap={16} size={1} />
<Controls
showInteractive={false}
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
className="bg-white border border-neutral-200 rounded-lg shadow-md"
/>
<Panel
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">
<GitBranch className="h-4 w-4 text-neutral-700" />
@@ -516,9 +516,9 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
{rawEdges.length === 0 && steps.length > 1 && (
<Panel
position="bottom-center"
className="bg-amber-50 border border-amber-200 px-4 py-2.5 rounded-lg shadow-lg"
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" />
<span>No transitions found. Connect your steps to see the flow.</span>
</div>