diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index fd9e073..d2033d0 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -64,6 +64,7 @@ export class Campaigns { private async list(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth; const status = req.query.status as CampaignStatus | undefined; + const search = typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined; const page = parseInt(req.query.page as string) || 1; const pageSize = parseInt(req.query.pageSize as string) || 20; @@ -74,6 +75,7 @@ export class Campaigns { const result = await CampaignService.list(auth.projectId, { status, + search, page, pageSize, }); diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index 95eb938..06a30c3 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -186,16 +186,26 @@ export class CampaignService { projectId: string, options: { status?: CampaignStatus; + search?: string; page?: number; pageSize?: number; } = {}, ): Promise> { - const {status, page = 1, pageSize = 20} = options; + const {status, search, page = 1, pageSize = 20} = options; const skip = (page - 1) * pageSize; const where: Prisma.CampaignWhereInput = { projectId, ...(status ? {status} : {}), + ...(search + ? { + OR: [ + {name: {contains: search, mode: 'insensitive' as const}}, + {subject: {contains: search, mode: 'insensitive' as const}}, + {from: {contains: search, mode: 'insensitive' as const}}, + ], + } + : {}), }; const [campaigns, total] = await Promise.all([ diff --git a/apps/web/src/pages/campaigns/index.tsx b/apps/web/src/pages/campaigns/index.tsx index d2edb17..e301834 100644 --- a/apps/web/src/pages/campaigns/index.tsx +++ b/apps/web/src/pages/campaigns/index.tsx @@ -8,11 +8,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Input, } from '@plunk/ui'; import type {Campaign, Template} from '@plunk/db'; import {CampaignStatus} from '@plunk/db'; @@ -23,11 +19,11 @@ import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog' import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog'; import {network} from '../../lib/network'; import {formatRelativeTime} from '../../lib/dateUtils'; -import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Trash2} from 'lucide-react'; +import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Search, Trash2, X} from 'lucide-react'; import {NextSeo} from 'next-seo'; import Link from 'next/link'; import {useRouter} from 'next/router'; -import {useState} from 'react'; +import {useEffect, useState} from 'react'; import {toast} from 'sonner'; import useSWR from 'swr'; import dayjs from 'dayjs'; @@ -35,7 +31,9 @@ import dayjs from 'dayjs'; export default function CampaignsPage() { const router = useRouter(); const [page, setPage] = useState(1); - const [statusFilter, setStatusFilter] = useState('ALL'); + const [search, setSearch] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const [statusFilter, setStatusFilter] = useState<'ALL' | 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'SENT' | 'CANCELLED'>('ALL'); const [showCancelDialog, setShowCancelDialog] = useState(false); const [campaignToCancel, setCampaignToCancel] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -44,10 +42,18 @@ export default function CampaignsPage() { const [showCampaignDialog, setShowCampaignDialog] = useState(false); const {data, mutate, isLoading} = useSWR>( - `/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`, + `/campaigns?page=${page}&pageSize=20${search ? `&search=${encodeURIComponent(search)}` : ''}${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`, {revalidateOnFocus: false}, ); + useEffect(() => { + const timer = setTimeout(() => { + setSearch(searchInput); + setPage(1); + }, 350); + return () => clearTimeout(timer); + }, [searchInput]); + const getStatusBadge = (status: CampaignStatus) => { const config: Record = { DRAFT: {label: 'Draft', variant: 'neutral'}, @@ -245,21 +251,45 @@ export default function CampaignsPage() { - {/* Filters */} -
- + {/* Search & Filters */} +
+
+ + setSearchInput(e.target.value)} + className="pl-10 pr-10" + /> + {searchInput && ( + + )} +
+
+ {(['ALL', 'DRAFT', 'SCHEDULED', 'SENDING', 'SENT', 'CANCELLED'] as const).map(status => ( + + ))} +
{/* Campaigns List */} @@ -275,66 +305,59 @@ export default function CampaignsPage() { - - - - - - - -
- Empty Campaign - - Start from scratch with a blank canvas - -
- -
- setShowTemplateDialog(true)} className="py-3 cursor-pointer"> -
- -
- From Template - - Use an existing template as a starting point - -
-
-
- setShowCampaignDialog(true)} className="py-3 cursor-pointer"> -
- -
- From Previous Campaign - - Copy content and settings from an existing campaign - -
-
-
-
- - ) : ( - - ) + + + + + + + +
+ Empty Campaign + + Start from scratch with a blank canvas + +
+ +
+ setShowTemplateDialog(true)} className="py-3 cursor-pointer"> +
+ +
+ From Template + + Use an existing template as a starting point + +
+
+
+ setShowCampaignDialog(true)} className="py-3 cursor-pointer"> +
+ +
+ From Previous Campaign + + Copy content and settings from an existing campaign + +
+
+
+
+ } />