feat: batch campaigns (#227)
This commit is contained in:
@@ -1,43 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@usesend/ui/src/table";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { CampaignStatus } from "@prisma/client";
|
||||
import DeleteCampaign from "./delete-campaign";
|
||||
import Link from "next/link";
|
||||
import DuplicateCampaign from "./duplicate-campaign";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@usesend/ui/src/select";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import CampaignCard from "./campaign-card";
|
||||
|
||||
export default function CampaignList() {
|
||||
const [page, setPage] = useUrlState("page", "1");
|
||||
const [status, setStatus] = useUrlState("status");
|
||||
const [searchTerm, setSearchTerm] = useUrlState("search");
|
||||
const [search, setSearch] = useUrlState("search");
|
||||
|
||||
const debouncedSearch = useDebouncedCallback((value: string) => {
|
||||
setSearch(value);
|
||||
}, 1000);
|
||||
|
||||
const onSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
debouncedSearch(value);
|
||||
};
|
||||
|
||||
const pageNumber = Number(page);
|
||||
|
||||
const campaignsQuery = api.campaign.getCampaigns.useQuery({
|
||||
page: pageNumber,
|
||||
status: status as CampaignStatus | null,
|
||||
});
|
||||
const campaignsQuery = api.campaign.getCampaigns.useQuery(
|
||||
{
|
||||
page: pageNumber,
|
||||
status: status as CampaignStatus | null,
|
||||
search,
|
||||
},
|
||||
{
|
||||
refetchInterval: (query) => {
|
||||
const c = query.state.data?.campaigns;
|
||||
if (!c) return false;
|
||||
const shouldPoll = c.some(
|
||||
(campaign) =>
|
||||
campaign.status === CampaignStatus.RUNNING ||
|
||||
campaign.status === CampaignStatus.SCHEDULED
|
||||
);
|
||||
return shouldPoll ? 5000 : false;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between">
|
||||
{/* Search input */}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search campaigns..."
|
||||
value={searchTerm || ""}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<Select
|
||||
value={status ?? "all"}
|
||||
onValueChange={(val) => setStatus(val === "all" ? null : val)}
|
||||
@@ -58,82 +88,38 @@ export default function CampaignList() {
|
||||
>
|
||||
Scheduled
|
||||
</SelectItem>
|
||||
<SelectItem value={CampaignStatus.RUNNING} className=" capitalize">
|
||||
Running
|
||||
</SelectItem>
|
||||
<SelectItem value={CampaignStatus.PAUSED} className=" capitalize">
|
||||
Paused
|
||||
</SelectItem>
|
||||
<SelectItem value={CampaignStatus.SENT} className=" capitalize">
|
||||
Sent
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border border-border shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="">Created At</TableHead>
|
||||
<TableHead className="rounded-tr-xl">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{campaignsQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : campaignsQuery.data?.campaigns.length ? (
|
||||
campaignsQuery.data?.campaigns.map((campaign) => (
|
||||
<TableRow key={campaign.id} className="">
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-foreground"
|
||||
href={
|
||||
campaign.status === CampaignStatus.DRAFT
|
||||
? `/campaigns/${campaign.id}/edit`
|
||||
: `/campaigns/${campaign.id}`
|
||||
}
|
||||
>
|
||||
{campaign.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
|
||||
campaign.status === CampaignStatus.DRAFT
|
||||
? "bg-gray/15 text-gray border border-gray/25"
|
||||
: campaign.status === CampaignStatus.SENT
|
||||
? "bg-green/15 text-green border border-green/25"
|
||||
: "bg-yellow/15 text-yellow border border-yellow/25"
|
||||
}`}
|
||||
>
|
||||
{campaign.status.toLowerCase()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
{formatDistanceToNow(new Date(campaign.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<DuplicateCampaign campaign={campaign} />
|
||||
<DeleteCampaign campaign={campaign} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
No campaigns found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/* Campaign cards */}
|
||||
<div className="flex flex-col gap-8">
|
||||
{campaignsQuery.isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="w-6 h-6" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
) : campaignsQuery.data?.campaigns.length ? (
|
||||
campaignsQuery.data?.campaigns.map((campaign) => (
|
||||
<CampaignCard key={campaign.id} campaign={campaign} />
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No campaigns found
|
||||
{(search || status) && (
|
||||
<div className="text-sm mt-2">
|
||||
Try adjusting your search or filters
|
||||
</div>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user