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