fix: Consistency across cards

This commit is contained in:
Dries Augustyns
2026-04-24 12:54:43 +02:00
parent ab3edd7dc9
commit 517753c420
5 changed files with 431 additions and 563 deletions
+112 -181
View File
@@ -3,9 +3,6 @@ import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
DropdownMenu,
DropdownMenuContent,
@@ -26,20 +23,7 @@ import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog'
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {
AlertCircle,
Calendar,
ChevronDown,
Copy,
FileText,
Info,
Mail,
MousePointerClick,
Plus,
RefreshCw,
Trash2,
Users,
} from 'lucide-react';
import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Trash2} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useRouter} from 'next/router';
@@ -65,23 +49,15 @@ export default function CampaignsPage() {
);
const getStatusBadge = (status: CampaignStatus) => {
const variants: Record<
CampaignStatus,
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className?: string}
> = {
DRAFT: {variant: 'secondary', label: 'Draft', className: 'bg-neutral-100 text-neutral-700'},
SCHEDULED: {variant: 'default', label: 'Scheduled', className: 'bg-blue-100 text-blue-700'},
SENDING: {variant: 'default', label: 'Sending', className: 'bg-purple-100 text-purple-700'},
SENT: {variant: 'default', label: 'Sent', className: 'bg-green-100 text-green-700'},
CANCELLED: {variant: 'destructive', label: 'Cancelled', className: 'bg-red-100 text-red-700'},
const config: Record<CampaignStatus, {label: string; className: string}> = {
DRAFT: {label: 'Draft', className: 'bg-neutral-100 text-neutral-600 border-transparent'},
SCHEDULED: {label: 'Scheduled', className: 'bg-blue-100 text-blue-700 border-transparent'},
SENDING: {label: 'Sending', className: 'bg-purple-100 text-purple-700 border-transparent'},
SENT: {label: 'Sent', className: 'bg-green-100 text-green-700 border-transparent'},
CANCELLED: {label: 'Cancelled', className: 'bg-red-100 text-red-700 border-transparent'},
};
const config = variants[status];
return (
<Badge variant={config.variant} className={config.className}>
{config.label}
</Badge>
);
const {label, className} = config[status];
return <Badge className={`${className} shrink-0`}>{label}</Badge>;
};
const handleCancel = async () => {
@@ -270,27 +246,21 @@ export default function CampaignsPage() {
</div>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex gap-4">
<div className="flex-1">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
<SelectItem value="SENDING">Sending</SelectItem>
<SelectItem value="SENT">Sent</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<div className="w-56">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
<SelectItem value="SENDING">Sending</SelectItem>
<SelectItem value="SENT">Sent</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
{/* Campaigns List */}
<div className="space-y-4">
@@ -374,152 +344,113 @@ export default function CampaignsPage() {
{data?.data.map(campaign => {
const openRate = campaign.sentCount > 0 ? (campaign.openedCount / campaign.sentCount) * 100 : 0;
const clickRate = campaign.sentCount > 0 ? (campaign.clickedCount / campaign.sentCount) * 100 : 0;
const deliveryProgress =
campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0;
const deliveryPct = campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0;
return (
<Card key={campaign.id} className="transition-colors hover:border-neutral-300">
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<Link
href={`/campaigns/${campaign.id}`}
className="hover:text-primary transition-colors flex-1 min-w-0"
>
<CardTitle className="text-xl truncate">{campaign.name}</CardTitle>
</Link>
{getStatusBadge(campaign.status)}
</div>
{campaign.description && (
<CardDescription className="line-clamp-2">{campaign.description}</CardDescription>
)}
</div>
<Card key={campaign.id} className="transition-colors hover:border-neutral-300 flex flex-col">
<Link
href={`/campaigns/${campaign.id}`}
className="flex-1 block p-6 pb-4 hover:bg-neutral-50/50 transition-colors rounded-t-xl"
aria-label={`Open ${campaign.name}`}
>
<div className="flex items-start justify-between gap-3 mb-3">
<h3 className="font-semibold text-neutral-900 leading-snug truncate">{campaign.name}</h3>
{getStatusBadge(campaign.status)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Recipients */}
<div className="bg-blue-50 border border-blue-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Users className="h-3.5 w-3.5 text-blue-600" />
<span className="text-xs font-medium text-blue-900">Recipients</span>
{(campaign.status === 'DRAFT' || campaign.status === 'SCHEDULED') && (
<div className="group relative">
<Info className="h-3 w-3 text-blue-600 cursor-help" />
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1">
This count will be recalculated before sending
</div>
</div>
<div className="flex items-center gap-3 text-sm flex-wrap">
{campaign.status === 'DRAFT' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{campaign.totalRecipients.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">estimated recipients</span>
</span>
</>
)}
{campaign.status === 'SCHEDULED' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{campaign.totalRecipients.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">recipients</span>
</span>
{campaign.scheduledFor && (
<>
<span className="h-3 w-px bg-neutral-200" />
<span className="text-xs text-neutral-500">
Sending {dayjs(campaign.scheduledFor).format('MMM D, YYYY [at] h:mm A')}
</span>
</>
)}
</div>
<p className="text-lg font-bold text-blue-900">{campaign.totalRecipients.toLocaleString()}</p>
{campaign.totalRecipients > 0 && campaign.status !== 'DRAFT' && (
<p className="text-xs text-blue-700 mt-1">{deliveryProgress.toFixed(0)}% sent</p>
)}
{campaign.status === 'DRAFT' && <p className="text-xs text-blue-600 mt-1">Estimated</p>}
</div>
{/* Open Rate */}
{campaign.sentCount > 0 && (
<div className="bg-purple-50 border border-purple-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Mail className="h-3.5 w-3.5 text-purple-600" />
<span className="text-xs font-medium text-purple-900">Opens</span>
</div>
<p className="text-lg font-bold text-purple-900">{openRate.toFixed(1)}%</p>
<p className="text-xs text-purple-700 mt-1">{campaign.openedCount.toLocaleString()} opened</p>
</div>
</>
)}
{/* Click Rate */}
{campaign.clickedCount > 0 && (
<div className="bg-orange-50 border border-orange-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<MousePointerClick className="h-3.5 w-3.5 text-orange-600" />
<span className="text-xs font-medium text-orange-900">Clicks</span>
</div>
<p className="text-lg font-bold text-orange-900">{clickRate.toFixed(1)}%</p>
<p className="text-xs text-orange-700 mt-1">
{campaign.clickedCount.toLocaleString()} clicked
</p>
</div>
{campaign.status === 'SENDING' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{deliveryPct.toFixed(0)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">delivered</span>
</span>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{openRate.toFixed(1)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">opens</span>
</span>
</>
)}
{/* Bounced Rate */}
{campaign.bouncedCount > 0 && campaign.sentCount > 0 && (
<div className="bg-red-50 border border-red-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<AlertCircle className="h-3.5 w-3.5 text-red-600" />
<span className="text-xs font-medium text-red-900">Bounced</span>
</div>
<p className="text-lg font-bold text-red-900">
{((campaign.bouncedCount / campaign.sentCount) * 100).toFixed(1)}%
</p>
<p className="text-xs text-red-700 mt-1">{campaign.bouncedCount.toLocaleString()} bounced</p>
</div>
{campaign.status === 'SENT' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{campaign.sentCount.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">sent</span>
</span>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{openRate.toFixed(1)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">opens</span>
</span>
{clickRate > 0 && (
<>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{clickRate.toFixed(1)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">clicks</span>
</span>
</>
)}
</>
)}
{/* Scheduled For */}
{campaign.scheduledFor && (
<div className="bg-green-50 border border-green-100 rounded-lg p-3 md:col-span-2">
<div className="flex items-center gap-2 mb-1">
<Calendar className="h-3.5 w-3.5 text-green-600" />
<span className="text-xs font-medium text-green-900">Scheduled</span>
</div>
<p className="text-sm font-semibold text-green-900">
{new Date(campaign.scheduledFor).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
<p className="text-xs text-green-700 mt-1">
{new Date(campaign.scheduledFor).toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}
</p>
</div>
{campaign.status === 'CANCELLED' && (
<span>
<strong className="font-semibold text-neutral-900">{campaign.totalRecipients.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">recipients</span>
</span>
)}
</div>
</Link>
{/* Metadata */}
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {formatRelativeTime(campaign.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(campaign.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="px-6 py-3 border-t border-neutral-100 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-neutral-400">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span> Updated {formatRelativeTime(campaign.updatedAt)}</span>
<span>Updated {formatRelativeTime(campaign.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(campaign.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-2 border-t border-neutral-100">
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
{campaign.status === 'DRAFT' ? 'Edit Campaign' : 'View Details'}
<div className="flex items-center gap-1">
<Link href={`/campaigns/${campaign.id}`} aria-label={campaign.status === 'DRAFT' ? 'Edit campaign' : 'View campaign'}>
<Button variant="ghost" size="sm" title={campaign.status === 'DRAFT' ? 'Edit campaign' : 'View campaign'}>
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button variant="outline" size="sm" onClick={() => handleDuplicate(campaign.id)}>
<Button variant="ghost" size="sm" title="Duplicate campaign" onClick={() => handleDuplicate(campaign.id)}>
<Copy className="h-4 w-4" />
</Button>
{campaign.status === 'DRAFT' && (
<Button
variant="destructive"
variant="ghost"
size="sm"
title="Delete campaign"
onClick={() => {
setCampaignToDelete(campaign.id);
setShowDeleteDialog(true);
@@ -528,21 +459,21 @@ export default function CampaignsPage() {
<Trash2 className="h-4 w-4" />
</Button>
)}
{(campaign.status === 'SCHEDULED' || campaign.status === 'SENDING') && (
<Button
variant="destructive"
variant="ghost"
size="sm"
title="Cancel campaign"
onClick={() => {
setCampaignToCancel(campaign.id);
setShowCancelDialog(true);
}}
>
Cancel
<Ban className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</div>
</Card>
);
})}
+39 -42
View File
@@ -37,6 +37,7 @@ import {
Search,
Trash2,
Upload,
X,
XCircle,
} from 'lucide-react';
import {NextSeo} from 'next-seo';
@@ -78,14 +79,16 @@ export default function ContactsPage() {
}
}, [data, cursor]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
};
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchInput);
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}, 350);
return () => clearTimeout(timer);
}, [searchInput]);
const handleNextPage = () => {
if (data?.cursor) {
@@ -187,40 +190,34 @@ export default function ContactsPage() {
</div>
</div>
{/* Search & Filters */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
<Input
type="text"
placeholder="Search by email..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10"
/>
</div>
<Button type="submit">Search</Button>
{search && (
<Button
type="button"
variant="outline"
onClick={() => {
setSearch('');
setSearchInput('');
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}}
>
Clear
</Button>
)}
</form>
</CardContent>
</Card>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder="Search by email..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setSearchInput('');
setSearch('');
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Bulk Actions Toolbar */}
{selectedContacts.size > 0 && (
+100 -95
View File
@@ -1,14 +1,13 @@
import {
Alert,
AlertDescription,
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
IconSpinner,
Input,
} from '@plunk/ui';
import type {Segment} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
@@ -16,10 +15,10 @@ import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {AlertTriangle, Calendar, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
import {AlertTriangle, Calendar, Edit, Filter, Plus, Search, Trash2, X} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {useMemo, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
@@ -53,10 +52,17 @@ export default function SegmentsPage() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [segmentToDelete, setSegmentToDelete] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState('');
// Show warning if there are many segments
const showLimitWarning = segments && segments.length >= 50;
const filteredSegments = useMemo(() => {
if (!segments || !searchInput.trim()) return segments;
const q = searchInput.toLowerCase();
return segments.filter(s => s.name.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q));
}, [segments, searchInput]);
const handleDelete = async () => {
if (!segmentToDelete) return;
@@ -93,6 +99,28 @@ export default function SegmentsPage() {
</Link>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder="Search segments..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => setSearchInput('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Warning if too many segments */}
{showLimitWarning && (
<Alert variant="default">
@@ -108,115 +136,92 @@ export default function SegmentsPage() {
<div className="flex items-center justify-center py-12">
<IconSpinner />
</div>
) : segments?.length === 0 ? (
) : filteredSegments?.length === 0 ? (
<Card>
<CardContent>
<EmptyState
icon={Filter}
title="No segments yet"
description="Group contacts by attributes to target specific audiences."
title={searchInput ? 'No segments match' : 'No segments yet'}
description={searchInput ? 'Try a different search term.' : 'Group contacts by attributes to target specific audiences.'}
action={
<Link href="/segments/new">
<Button>
<Plus className="h-4 w-4" />
Create Segment
</Button>
</Link>
!searchInput ? (
<Link href="/segments/new">
<Button>
<Plus className="h-4 w-4" />
Create Segment
</Button>
</Link>
) : undefined
}
/>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{segments?.map(segment => (
<Card key={segment.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<CardTitle className="text-lg">{segment.name}</CardTitle>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
(segment as unknown as {type: string}).type === 'STATIC'
? 'bg-purple-100 text-purple-700'
: 'bg-blue-100 text-blue-700'
}`}
>
{(segment as unknown as {type: string}).type === 'STATIC' ? 'Static' : 'Dynamic'}
</span>
</div>
{segment.description && (
<CardDescription className="mt-1">{segment.description}</CardDescription>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSegments?.map(segment => {
const isDynamic = (segment as unknown as {type: string}).type !== 'STATIC';
const filterCount = isDynamic ? countFiltersInCondition(segment.condition) : 0;
return (
<Card key={segment.id} className="transition-colors hover:border-neutral-300 flex flex-col">
<Link
href={`/segments/${segment.id}`}
className="flex-1 block p-6 pb-4 hover:bg-neutral-50/50 transition-colors rounded-t-xl"
aria-label={`Open ${segment.name}`}
>
<div className="flex items-start justify-between gap-3 mb-3">
<h3 className="font-semibold text-neutral-900 leading-snug">{segment.name}</h3>
<Badge variant={isDynamic ? 'info' : 'default'} className="shrink-0 mt-0.5">
{isDynamic ? 'Dynamic' : 'Static'}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm">
<span>
<strong className="font-semibold text-neutral-900">{segment.memberCount.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">members</span>
</span>
{isDynamic && (
<>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{filterCount}</strong>
<span className="text-neutral-400 ml-1 text-xs">filters</span>
</span>
</>
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Stats */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Members</span>
</div>
<span className="text-lg font-semibold text-neutral-900">{segment.memberCount}</span>
</div>
{(segment as unknown as {type: string}).type !== 'STATIC' && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Filters</span>
</div>
<span className="text-sm font-medium text-neutral-900">
{countFiltersInCondition(segment.condition)}
</span>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-neutral-200">
<Link href={`/segments/${segment.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
<Edit className="h-4 w-4" />
Edit
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSegmentToDelete(segment.id);
setShowDeleteDialog(true);
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Metadata */}
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {formatRelativeTime(segment.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</Link>
<div className="px-6 py-3 border-t border-neutral-100 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-neutral-400">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span> Updated {formatRelativeTime(segment.updatedAt)}</span>
<span>Updated {formatRelativeTime(segment.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Link href={`/segments/${segment.id}`} aria-label="Edit segment">
<Button variant="ghost" size="sm" title="Edit segment">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
title="Delete segment"
onClick={() => {
setSegmentToDelete(segment.id);
setShowDeleteDialog(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</Card>
);
})}
</div>
)}
</div>
+86 -138
View File
@@ -3,9 +3,6 @@ import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
IconSpinner,
Input,
@@ -16,10 +13,10 @@ import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2, X} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
@@ -37,11 +34,13 @@ export default function TemplatesPage() {
{revalidateOnFocus: false},
);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [searchInput]);
const handleDelete = async () => {
if (!templateToDelete) return;
@@ -91,77 +90,48 @@ export default function TemplatesPage() {
</div>
{/* Search & Filters */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
<Input
type="text"
placeholder="Search templates..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10"
/>
</div>
<Button type="submit">Search</Button>
{search && (
<Button
type="button"
variant="outline"
onClick={() => {
setSearch('');
setSearchInput('');
setPage(1);
}}
>
Clear
</Button>
)}
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder="Search templates..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setSearchInput('');
setSearch('');
setPage(1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="flex gap-1.5 shrink-0">
{(['ALL', 'MARKETING', 'TRANSACTIONAL', 'HEADLESS'] as const).map(type => (
<Button
key={type}
type="button"
onClick={() => { setTypeFilter(type); setPage(1); }}
variant={typeFilter === type ? 'default' : 'secondary'}
size="sm"
>
{type === 'ALL' ? 'All' : type.charAt(0) + type.slice(1).toLowerCase()}
</Button>
))}
</div>
</div>
{/* Type Filter */}
<div className="flex gap-2">
<Button
type="button"
onClick={() => setTypeFilter('ALL')}
variant={typeFilter === 'ALL' ? 'default' : 'secondary'}
size="sm"
>
All Templates
</Button>
<Button
type="button"
onClick={() => setTypeFilter('MARKETING')}
variant={typeFilter === 'MARKETING' ? 'default' : 'secondary'}
size="sm"
>
Marketing
</Button>
<Button
type="button"
onClick={() => setTypeFilter('TRANSACTIONAL')}
variant={typeFilter === 'TRANSACTIONAL' ? 'default' : 'secondary'}
size="sm"
>
Transactional
</Button>
<Button
type="button"
onClick={() => setTypeFilter('HEADLESS')}
variant={typeFilter === 'HEADLESS' ? 'default' : 'secondary'}
size="sm"
>
Headless
</Button>
</div>
</form>
</CardContent>
</Card>
{/* Templates Grid */}
<div className="grid gap-4">
{/* Templates */}
<div>
{isLoading ? (
<Card>
<CardContent className="pt-6">
@@ -192,36 +162,48 @@ export default function TemplatesPage() {
</Card>
) : (
<>
{data?.data.map(template => (
<Card key={template.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<CardTitle>{template.name}</CardTitle>
<Badge
className={'capitalize'}
variant={template.type === 'MARKETING' ? 'info' : template.type === 'HEADLESS' ? 'warning' : 'success'}
>
{template.type.toLowerCase()}
</Badge>
</div>
{template.description && (
<CardDescription className="mt-2">{template.description}</CardDescription>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data?.data.map(template => (
<Card key={template.id} className="transition-colors hover:border-neutral-300 flex flex-col">
<Link
href={`/templates/${template.id}`}
className="flex-1 block p-6 pb-4 hover:bg-neutral-50/50 transition-colors rounded-t-xl"
aria-label={`Edit ${template.name}`}
>
<div className="flex items-start justify-between gap-3 mb-3">
<h3 className="font-semibold text-neutral-900 leading-snug">{template.name}</h3>
<Badge
className="capitalize shrink-0 mt-0.5"
variant={template.type === 'MARKETING' ? 'info' : template.type === 'HEADLESS' ? 'warning' : 'success'}
>
{template.type.toLowerCase()}
</Badge>
</div>
<div className="flex items-center gap-2 ml-4">
<Link href={`/templates/${template.id}`}>
<Button variant="ghost" size="sm">
<p className="text-sm font-medium text-neutral-700 truncate">{template.subject}</p>
</Link>
<div className="px-6 py-3 border-t border-neutral-100 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-neutral-400">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Updated {formatRelativeTime(template.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(template.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Link href={`/templates/${template.id}`} aria-label="Edit template">
<Button variant="ghost" size="sm" title="Edit template">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={() => handleDuplicate(template.id)}>
<Button variant="ghost" size="sm" title="Duplicate template" onClick={() => handleDuplicate(template.id)}>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Delete template"
onClick={() => {
setTemplateToDelete(template.id);
setShowDeleteDialog(true);
@@ -231,43 +213,9 @@ export default function TemplatesPage() {
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div>
<p className="text-xs text-neutral-500 mb-1">Subject</p>
<p className="text-sm font-medium text-neutral-900">{template.subject}</p>
</div>
<div>
<p className="text-xs text-neutral-500 mb-1">From</p>
<p className="text-sm text-neutral-700">{template.from}</p>
</div>
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {formatRelativeTime(template.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(template.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="group relative inline-block cursor-help">
<span> Updated {formatRelativeTime(template.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(template.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
{template.replyTo && (
<div>
Reply to: <span className="text-neutral-700">{template.replyTo}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
))}
</Card>
))}
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (
+94 -107
View File
@@ -3,9 +3,6 @@ import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Command,
CommandGroup,
CommandItem,
@@ -26,10 +23,10 @@ import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network';
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, X, Zap} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {WorkflowSchemas} from '@plunk/shared';
@@ -47,11 +44,13 @@ export default function WorkflowsPage() {
PaginatedResponse<Workflow & {_count?: {steps: number; executions: number}}>
>(`/workflows?page=${page}&pageSize=20${search ? `&search=${search}` : ''}`, {revalidateOnFocus: false});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [searchInput]);
const handleDelete = async () => {
if (!workflowToDelete) return;
@@ -100,40 +99,34 @@ export default function WorkflowsPage() {
</Button>
</div>
{/* Search & Filters */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
<Input
type="text"
placeholder="Search workflows..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10"
/>
</div>
<Button type="submit">Search</Button>
{search && (
<Button
type="button"
variant="outline"
onClick={() => {
setSearch('');
setSearchInput('');
setPage(1);
}}
>
Clear
</Button>
)}
</form>
</CardContent>
</Card>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder="Search workflows..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setSearchInput('');
setSearch('');
setPage(1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Workflows Grid */}
<div className="grid gap-4">
{/* Workflows */}
<div>
{isLoading ? (
<Card>
<CardContent className="pt-6">
@@ -162,53 +155,75 @@ export default function WorkflowsPage() {
</Card>
) : (
<>
{data?.data.map(workflow => (
<Card key={workflow.id} className={workflow.enabled ? 'border-green-200' : ''}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<CardTitle>{workflow.name}</CardTitle>
<Badge variant={workflow.enabled ? 'green' : 'neutral'}>
{workflow.enabled ? (
<>
<Power className="h-3 w-3 mr-1" />
Active
</>
) : (
<>
<PowerOff className="h-3 w-3 mr-1" />
Disabled
</>
)}
</Badge>
{workflow.triggerConfig &&
typeof workflow.triggerConfig === 'object' &&
'eventName' in workflow.triggerConfig && (
<Badge variant={'info'}>{String(workflow.triggerConfig.eventName)}</Badge>
)}
</div>
{workflow.description && (
<CardDescription className="mt-2">{workflow.description}</CardDescription>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data?.data.map(workflow => (
<Card key={workflow.id} className="transition-colors hover:border-neutral-300 flex flex-col">
<Link
href={`/workflows/${workflow.id}`}
className="flex-1 block p-6 pb-4 hover:bg-neutral-50/50 transition-colors rounded-t-xl"
aria-label={`Open ${workflow.name}`}
>
<div className="flex items-start justify-between gap-3 mb-3">
<h3 className="font-semibold text-neutral-900 leading-snug">{workflow.name}</h3>
<Badge variant={workflow.enabled ? 'green' : 'neutral'} className="shrink-0 mt-0.5">
{workflow.enabled ? (
<><Power className="h-3 w-3 mr-1" />Active</>
) : (
<><PowerOff className="h-3 w-3 mr-1" />Disabled</>
)}
</Badge>
</div>
<div className="flex items-center gap-2 ml-4">
{workflow.triggerConfig &&
typeof workflow.triggerConfig === 'object' &&
'eventName' in workflow.triggerConfig && (
<div className="flex items-center gap-1.5 text-xs text-neutral-500 mb-3">
<Zap className="h-3 w-3 shrink-0" />
<span>Triggers on</span>
<code className="font-mono bg-neutral-100 px-1.5 py-0.5 rounded text-neutral-700">
{String(workflow.triggerConfig.eventName)}
</code>
</div>
)}
<div className="flex items-center gap-4 text-sm">
<span>
<strong className="font-semibold text-neutral-900">{workflow._count?.steps ?? 0}</strong>
<span className="text-neutral-400 ml-1 text-xs">steps</span>
</span>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{workflow._count?.executions ?? 0}</strong>
<span className="text-neutral-400 ml-1 text-xs">executions</span>
</span>
</div>
</Link>
<div className="px-6 py-3 border-t border-neutral-100 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-neutral-400">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Updated {formatRelativeTime(workflow.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(workflow.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
title={workflow.enabled ? 'Disable workflow' : 'Enable workflow'}
onClick={() => handleToggleEnabled(workflow.id, workflow.enabled)}
>
{workflow.enabled ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}
</Button>
<Link href={`/workflows/${workflow.id}`}>
<Button variant="ghost" size="sm">
<Link href={`/workflows/${workflow.id}`} aria-label="Edit workflow">
<Button variant="ghost" size="sm" title="Edit workflow">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
title="Delete workflow"
onClick={() => {
setWorkflowToDelete(workflow.id);
setShowDeleteDialog(true);
@@ -218,37 +233,9 @@ export default function WorkflowsPage() {
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-6 text-sm text-neutral-500">
<div>
<span className="font-medium text-neutral-900">{workflow._count?.steps ?? 0}</span> steps
</div>
<div>
<span className="font-medium text-neutral-900">{workflow._count?.executions ?? 0}</span>{' '}
executions
</div>
</div>
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {formatRelativeTime(workflow.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(workflow.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="group relative inline-block cursor-help">
<span> Updated {formatRelativeTime(workflow.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(workflow.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</CardContent>
</Card>
))}
</Card>
))}
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (