feat: add search functionality to campaigns list with debounce effect
This commit is contained in:
@@ -64,6 +64,7 @@ export class Campaigns {
|
|||||||
private async list(req: Request, res: Response, _next: NextFunction) {
|
private async list(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const status = req.query.status as CampaignStatus | undefined;
|
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 page = parseInt(req.query.page as string) || 1;
|
||||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ export class Campaigns {
|
|||||||
|
|
||||||
const result = await CampaignService.list(auth.projectId, {
|
const result = await CampaignService.list(auth.projectId, {
|
||||||
status,
|
status,
|
||||||
|
search,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -186,16 +186,26 @@ export class CampaignService {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
options: {
|
options: {
|
||||||
status?: CampaignStatus;
|
status?: CampaignStatus;
|
||||||
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<PaginatedResponse<Campaign>> {
|
): Promise<PaginatedResponse<Campaign>> {
|
||||||
const {status, page = 1, pageSize = 20} = options;
|
const {status, search, page = 1, pageSize = 20} = options;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: Prisma.CampaignWhereInput = {
|
const where: Prisma.CampaignWhereInput = {
|
||||||
projectId,
|
projectId,
|
||||||
...(status ? {status} : {}),
|
...(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([
|
const [campaigns, total] = await Promise.all([
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
Select,
|
Input,
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Campaign, Template} from '@plunk/db';
|
import type {Campaign, Template} from '@plunk/db';
|
||||||
import {CampaignStatus} from '@plunk/db';
|
import {CampaignStatus} from '@plunk/db';
|
||||||
@@ -23,11 +19,11 @@ 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 {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 {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
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';
|
||||||
@@ -35,7 +31,9 @@ import dayjs from 'dayjs';
|
|||||||
export default function CampaignsPage() {
|
export default function CampaignsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('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 [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||||
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
@@ -44,10 +42,18 @@ export default function CampaignsPage() {
|
|||||||
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
|
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
|
||||||
|
|
||||||
const {data, mutate, isLoading} = useSWR<PaginatedResponse<Campaign>>(
|
const {data, mutate, isLoading} = useSWR<PaginatedResponse<Campaign>>(
|
||||||
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
`/campaigns?page=${page}&pageSize=20${search ? `&search=${encodeURIComponent(search)}` : ''}${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
||||||
{revalidateOnFocus: false},
|
{revalidateOnFocus: false},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSearch(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
}, 350);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
const getStatusBadge = (status: CampaignStatus) => {
|
const getStatusBadge = (status: CampaignStatus) => {
|
||||||
const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = {
|
const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = {
|
||||||
DRAFT: {label: 'Draft', variant: 'neutral'},
|
DRAFT: {label: 'Draft', variant: 'neutral'},
|
||||||
@@ -245,21 +251,45 @@ export default function CampaignsPage() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Search & Filters */}
|
||||||
<div className="w-56">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<div className="relative flex-1">
|
||||||
<SelectTrigger>
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||||
<SelectValue placeholder="All Statuses" />
|
<Input
|
||||||
</SelectTrigger>
|
type="text"
|
||||||
<SelectContent>
|
placeholder="Search campaigns..."
|
||||||
<SelectItem value="ALL">All Statuses</SelectItem>
|
value={searchInput}
|
||||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
|
className="pl-10 pr-10"
|
||||||
<SelectItem value="SENDING">Sending</SelectItem>
|
/>
|
||||||
<SelectItem value="SENT">Sent</SelectItem>
|
{searchInput && (
|
||||||
<SelectItem value="CANCELLED">Cancelled</SelectItem>
|
<button
|
||||||
</SelectContent>
|
type="button"
|
||||||
</Select>
|
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 flex-wrap">
|
||||||
|
{(['ALL', 'DRAFT', 'SCHEDULED', 'SENDING', 'SENT', 'CANCELLED'] as const).map(status => (
|
||||||
|
<Button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setStatusFilter(status); setPage(1); }}
|
||||||
|
variant={statusFilter === status ? 'default' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{status === 'ALL' ? 'All' : status.charAt(0) + status.slice(1).toLowerCase()}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Campaigns List */}
|
{/* Campaigns List */}
|
||||||
@@ -275,66 +305,59 @@ export default function CampaignsPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Mail}
|
icon={Mail}
|
||||||
title={statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
|
title={search ? 'No campaigns match' : statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
|
||||||
description={
|
description={
|
||||||
statusFilter !== 'ALL'
|
search
|
||||||
? 'Adjust your filters or create a new campaign.'
|
? 'Try a different search term.'
|
||||||
: 'Send one-off emails to groups of contacts.'
|
: statusFilter !== 'ALL'
|
||||||
|
? 'Adjust your filters or create a new campaign.'
|
||||||
|
: 'Send one-off emails to groups of contacts.'
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
statusFilter === 'ALL' ? (
|
<DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<Button>
|
||||||
<Button>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Create Campaign
|
|
||||||
<ChevronDown className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="center" className="w-80">
|
|
||||||
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
|
||||||
<Link href="/campaigns/create" className="flex items-start gap-3">
|
|
||||||
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
|
||||||
<div className="flex flex-col gap-0.5 flex-1">
|
|
||||||
<span className="font-medium text-sm">Empty Campaign</span>
|
|
||||||
<span className="text-xs text-neutral-500 leading-snug">
|
|
||||||
Start from scratch with a blank canvas
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
|
||||||
<div className="flex flex-col gap-0.5 flex-1">
|
|
||||||
<span className="font-medium text-sm">From Template</span>
|
|
||||||
<span className="text-xs text-neutral-500 leading-snug">
|
|
||||||
Use an existing template as a starting point
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
|
||||||
<div className="flex flex-col gap-0.5 flex-1">
|
|
||||||
<span className="font-medium text-sm">From Previous Campaign</span>
|
|
||||||
<span className="text-xs text-neutral-500 leading-snug">
|
|
||||||
Copy content and settings from an existing campaign
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : (
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/campaigns/create">
|
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Create Campaign
|
Create Campaign
|
||||||
</Link>
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="w-80">
|
||||||
|
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
||||||
|
<Link href="/campaigns/create" className="flex items-start gap-3">
|
||||||
|
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Empty Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Start from scratch with a blank canvas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Template</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Use an existing template as a starting point
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Previous Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Copy content and settings from an existing campaign
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user