diff --git a/apps/web/src/pages/campaigns/index.tsx b/apps/web/src/pages/campaigns/index.tsx index 6af6974..bd6ac8f 100644 --- a/apps/web/src/pages/campaigns/index.tsx +++ b/apps/web/src/pages/campaigns/index.tsx @@ -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 = { + 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 ( - - {config.label} - - ); + const {label, className} = config[status]; + return {label}; }; const handleCancel = async () => { @@ -270,27 +246,21 @@ export default function CampaignsPage() { {/* Filters */} - - -
-
- -
-
-
-
+
+ +
{/* Campaigns List */}
@@ -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 ( - - -
-
-
- - {campaign.name} - - {getStatusBadge(campaign.status)} -
- {campaign.description && ( - {campaign.description} - )} -
+ + +
+

{campaign.name}

+ {getStatusBadge(campaign.status)}
- - - {/* Stats Grid */} -
- {/* Recipients */} -
-
- - Recipients - {(campaign.status === 'DRAFT' || campaign.status === 'SCHEDULED') && ( -
- -
- This count will be recalculated before sending -
-
+ +
+ {campaign.status === 'DRAFT' && ( + <> + + {campaign.totalRecipients.toLocaleString()} + estimated recipients + + + )} + {campaign.status === 'SCHEDULED' && ( + <> + + {campaign.totalRecipients.toLocaleString()} + recipients + + {campaign.scheduledFor && ( + <> + + + Sending {dayjs(campaign.scheduledFor).format('MMM D, YYYY [at] h:mm A')} + + )} -
-

{campaign.totalRecipients.toLocaleString()}

- {campaign.totalRecipients > 0 && campaign.status !== 'DRAFT' && ( -

{deliveryProgress.toFixed(0)}% sent

- )} - {campaign.status === 'DRAFT' &&

Estimated

} -
- - {/* Open Rate */} - {campaign.sentCount > 0 && ( -
-
- - Opens -
-

{openRate.toFixed(1)}%

-

{campaign.openedCount.toLocaleString()} opened

-
+ )} - - {/* Click Rate */} - {campaign.clickedCount > 0 && ( -
-
- - Clicks -
-

{clickRate.toFixed(1)}%

-

- {campaign.clickedCount.toLocaleString()} clicked -

-
+ {campaign.status === 'SENDING' && ( + <> + + {deliveryPct.toFixed(0)}% + delivered + + + + {openRate.toFixed(1)}% + opens + + )} - - {/* Bounced Rate */} - {campaign.bouncedCount > 0 && campaign.sentCount > 0 && ( -
-
- - Bounced -
-

- {((campaign.bouncedCount / campaign.sentCount) * 100).toFixed(1)}% -

-

{campaign.bouncedCount.toLocaleString()} bounced

-
+ {campaign.status === 'SENT' && ( + <> + + {campaign.sentCount.toLocaleString()} + sent + + + + {openRate.toFixed(1)}% + opens + + {clickRate > 0 && ( + <> + + + {clickRate.toFixed(1)}% + clicks + + + )} + )} - - {/* Scheduled For */} - {campaign.scheduledFor && ( -
-
- - Scheduled -
-

- {new Date(campaign.scheduledFor).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - })} -

-

- {new Date(campaign.scheduledFor).toLocaleTimeString(undefined, { - hour: 'numeric', - minute: '2-digit', - })} -

-
+ {campaign.status === 'CANCELLED' && ( + + {campaign.totalRecipients.toLocaleString()} + recipients + )}
+ - {/* Metadata */} -
-
- -
- Created {formatRelativeTime(campaign.createdAt)} -
- {dayjs(campaign.createdAt).format('DD MMMM YYYY, hh:mm')} -
-
-
+
+
+
- • Updated {formatRelativeTime(campaign.updatedAt)} + Updated {formatRelativeTime(campaign.updatedAt)}
{dayjs(campaign.updatedAt).format('DD MMMM YYYY, hh:mm')}
- - {/* Actions */} -
- - - - - {campaign.status === 'DRAFT' && ( )} - {(campaign.status === 'SCHEDULED' || campaign.status === 'SENDING') && ( )}
- +
); })} diff --git a/apps/web/src/pages/contacts/index.tsx b/apps/web/src/pages/contacts/index.tsx index 624e76c..6dab53a 100644 --- a/apps/web/src/pages/contacts/index.tsx +++ b/apps/web/src/pages/contacts/index.tsx @@ -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() {
- {/* Search & Filters */} - - -
-
- - setSearchInput(e.target.value)} - className="pl-10" - /> -
- - {search && ( - - )} -
-
-
+ {/* Search */} +
+ + setSearchInput(e.target.value)} + className="pl-10 pr-10" + /> + {searchInput && ( + + )} +
{/* Bulk Actions Toolbar */} {selectedContacts.size > 0 && ( diff --git a/apps/web/src/pages/segments/index.tsx b/apps/web/src/pages/segments/index.tsx index 30fd6a8..0d11d17 100644 --- a/apps/web/src/pages/segments/index.tsx +++ b/apps/web/src/pages/segments/index.tsx @@ -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(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() {
+ {/* Search */} +
+ + setSearchInput(e.target.value)} + className="pl-10 pr-10" + /> + {searchInput && ( + + )} +
+ {/* Warning if too many segments */} {showLimitWarning && ( @@ -108,115 +136,92 @@ export default function SegmentsPage() {
- ) : segments?.length === 0 ? ( + ) : filteredSegments?.length === 0 ? ( - - + !searchInput ? ( + + + + ) : undefined } /> ) : ( -
- {segments?.map(segment => ( - - -
-
-
- {segment.name} - - {(segment as unknown as {type: string}).type === 'STATIC' ? 'Static' : 'Dynamic'} - -
- {segment.description && ( - {segment.description} +
+ {filteredSegments?.map(segment => { + const isDynamic = (segment as unknown as {type: string}).type !== 'STATIC'; + const filterCount = isDynamic ? countFiltersInCondition(segment.condition) : 0; + return ( + + +
+

{segment.name}

+ + {isDynamic ? 'Dynamic' : 'Static'} + +
+
+ + {segment.memberCount.toLocaleString()} + members + + {isDynamic && ( + <> + + + {filterCount} + filters + + )}
-
- - -
- {/* Stats */} -
-
- - Members -
- {segment.memberCount} -
- - {(segment as unknown as {type: string}).type !== 'STATIC' && ( -
-
- - Filters -
- - {countFiltersInCondition(segment.condition)} - -
- )} - - {/* Actions */} -
- - - - -
- - {/* Metadata */} -
-
- -
- Created {formatRelativeTime(segment.createdAt)} -
- {dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')} -
-
-
+ +
+
+
- • Updated {formatRelativeTime(segment.updatedAt)} + Updated {formatRelativeTime(segment.updatedAt)}
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
+
+ + + + +
- - - ))} + + ); + })}
)}
diff --git a/apps/web/src/pages/templates/index.tsx b/apps/web/src/pages/templates/index.tsx index e07d333..13d9cbb 100644 --- a/apps/web/src/pages/templates/index.tsx +++ b/apps/web/src/pages/templates/index.tsx @@ -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() {
{/* Search & Filters */} - - -
-
-
- - setSearchInput(e.target.value)} - className="pl-10" - /> -
- - {search && ( - - )} -
+
+
+ + setSearchInput(e.target.value)} + className="pl-10 pr-10" + /> + {searchInput && ( + + )} +
+
+ {(['ALL', 'MARKETING', 'TRANSACTIONAL', 'HEADLESS'] as const).map(type => ( + + ))} +
+
- {/* Type Filter */} -
- - - - -
-
-
-
- - {/* Templates Grid */} -
+ {/* Templates */} +
{isLoading ? ( @@ -192,36 +162,48 @@ export default function TemplatesPage() { ) : ( <> - {data?.data.map(template => ( - - -
-
-
- {template.name} - - {template.type.toLowerCase()} - -
- {template.description && ( - {template.description} - )} +
+ {data?.data.map(template => ( + + +
+

{template.name}

+ + {template.type.toLowerCase()} +
-
- - -
- - -
-
-

Subject

-

{template.subject}

-
-
-

From

-

{template.from}

-
-
-
- -
- Created {formatRelativeTime(template.createdAt)} -
- {dayjs(template.createdAt).format('DD MMMM YYYY, hh:mm')} -
-
-
-
- • Updated {formatRelativeTime(template.updatedAt)} -
- {dayjs(template.updatedAt).format('DD MMMM YYYY, hh:mm')} -
-
- {template.replyTo && ( -
- Reply to: {template.replyTo} -
- )} -
-
-
- - ))} + + ))} +
{/* Pagination */} {data && data.totalPages > 1 && ( diff --git a/apps/web/src/pages/workflows/index.tsx b/apps/web/src/pages/workflows/index.tsx index 24dd628..b8697a9 100644 --- a/apps/web/src/pages/workflows/index.tsx +++ b/apps/web/src/pages/workflows/index.tsx @@ -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 >(`/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() {
- {/* Search & Filters */} - - -
-
- - setSearchInput(e.target.value)} - className="pl-10" - /> -
- - {search && ( - - )} -
-
-
+ {/* Search */} +
+ + setSearchInput(e.target.value)} + className="pl-10 pr-10" + /> + {searchInput && ( + + )} +
- {/* Workflows Grid */} -
+ {/* Workflows */} +
{isLoading ? ( @@ -162,53 +155,75 @@ export default function WorkflowsPage() { ) : ( <> - {data?.data.map(workflow => ( - - -
-
-
- {workflow.name} - - {workflow.enabled ? ( - <> - - Active - - ) : ( - <> - - Disabled - - )} - - - {workflow.triggerConfig && - typeof workflow.triggerConfig === 'object' && - 'eventName' in workflow.triggerConfig && ( - {String(workflow.triggerConfig.eventName)} - )} -
- {workflow.description && ( - {workflow.description} - )} +
+ {data?.data.map(workflow => ( + + +
+

{workflow.name}

+ + {workflow.enabled ? ( + <>Active + ) : ( + <>Disabled + )} +
-
+ {workflow.triggerConfig && + typeof workflow.triggerConfig === 'object' && + 'eventName' in workflow.triggerConfig && ( +
+ + Triggers on + + {String(workflow.triggerConfig.eventName)} + +
+ )} +
+ + {workflow._count?.steps ?? 0} + steps + + + + {workflow._count?.executions ?? 0} + executions + +
+ +
+
+ +
+ Updated {formatRelativeTime(workflow.updatedAt)} +
+ {dayjs(workflow.updatedAt).format('DD MMMM YYYY, hh:mm')} +
+
+
+
- -
- - -
-
- {workflow._count?.steps ?? 0} steps -
-
- {workflow._count?.executions ?? 0}{' '} - executions -
-
-
-
- -
- Created {formatRelativeTime(workflow.createdAt)} -
- {dayjs(workflow.createdAt).format('DD MMMM YYYY, hh:mm')} -
-
-
-
- • Updated {formatRelativeTime(workflow.updatedAt)} -
- {dayjs(workflow.updatedAt).format('DD MMMM YYYY, hh:mm')} -
-
-
-
- - ))} + + ))} +
{/* Pagination */} {data && data.totalPages > 1 && (