fix: Consistency across cards
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user