feat: add suppression list (#192)

This commit is contained in:
KM Koushik
2025-07-27 23:51:59 +10:00
committed by GitHub
parent a28f132428
commit e6dd8673b4
20 changed files with 2026 additions and 58 deletions

View File

@@ -0,0 +1,243 @@
"use client";
import { useState } from "react";
import { api } from "~/trpc/react";
import { useUrlState } from "~/hooks/useUrlState";
import { useDebouncedCallback } from "use-debounce";
import { SuppressionReason } from "@prisma/client";
import { formatDistanceToNow } from "date-fns";
import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unsend/ui/src/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@unsend/ui/src/table";
import { Trash2, Download } from "lucide-react";
import RemoveSuppressionDialog from "./remove-suppression";
import Spinner from "@unsend/ui/src/spinner";
const reasonLabels = {
HARD_BOUNCE: "Hard Bounce",
COMPLAINT: "Complaint",
MANUAL: "Manual",
} as const;
export default function SuppressionList() {
const [search, setSearch] = useUrlState("search");
const [reason, setReason] = useUrlState("reason");
const [page, setPage] = useUrlState("page", "1");
const [emailToRemove, setEmailToRemove] = useState<string | null>(null);
const suppressionsQuery = api.suppression.getSuppressions.useQuery({
page: parseInt(page || "1"),
limit: 20,
search: search || undefined,
reason: reason as SuppressionReason | undefined,
sortBy: "createdAt",
sortOrder: "desc",
});
const exportQuery = api.suppression.exportSuppressions.useQuery(
{
search: search || undefined,
reason: reason as SuppressionReason | undefined,
},
{ enabled: false }
);
const utils = api.useUtils();
const removeMutation = api.suppression.removeSuppression.useMutation({
onSuccess: () => {
utils.suppression.getSuppressions.invalidate();
utils.suppression.getSuppressionStats.invalidate();
setEmailToRemove(null);
},
});
const debouncedSearch = useDebouncedCallback((value: string) => {
setSearch(value || null);
setPage("1");
}, 1000);
const handleReasonFilter = (value: string) => {
setReason(value === "all" ? null : value);
setPage("1");
};
const handleExport = async () => {
const resp = await exportQuery.refetch();
if (resp.data) {
const csv = [
"Email,Reason,Created At",
...resp.data.map(
(suppression) =>
`${suppression.email},${suppression.reason},${suppression.createdAt}`
),
].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `suppressions-${new Date().toISOString().split("T")[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
};
const handleRemove = (email: string) => {
setEmailToRemove(email);
};
const confirmRemove = () => {
if (emailToRemove) {
removeMutation.mutate({ email: emailToRemove });
}
};
return (
<div className="mt-10 flex flex-col gap-4">
{/* Header and Export */}
<div className="flex justify-between items-center">
{/* Filters */}
<div className="flex gap-4">
<Input
placeholder="Search by email address..."
className="max-w-sm"
defaultValue={search || ""}
onChange={(e) => debouncedSearch(e.target.value)}
/>
<Select value={reason || "all"} onValueChange={handleReasonFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by reason" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Reasons</SelectItem>
<SelectItem value="HARD_BOUNCE">Hard Bounce</SelectItem>
<SelectItem value="COMPLAINT">Complaint</SelectItem>
<SelectItem value="MANUAL">Manual</SelectItem>
</SelectContent>
</Select>
</div>{" "}
<Button
variant="outline"
onClick={handleExport}
disabled={exportQuery.isFetching}
>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</div>
{/* Table */}
<div className="flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableHead className="rounded-tl-xl">Email</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Added</TableHead>
<TableHead className="rounded-tr-xl">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suppressionsQuery.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>
) : suppressionsQuery.data?.suppressions.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
No suppressed emails found
</TableCell>
</TableRow>
) : (
suppressionsQuery.data?.suppressions.map((suppression) => (
<TableRow key={suppression.id}>
<TableCell className="font-medium">
{suppression.email}
</TableCell>
<TableCell>
<div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
suppression.reason === "HARD_BOUNCE"
? "bg-red/15 text-red border border-red/20"
: suppression.reason === "COMPLAINT"
? "bg-yellow/15 text-yellow border border-yellow/20"
: "bg-blue/15 text-blue border border-blue/20"
}`}
>
{reasonLabels[suppression.reason]}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{formatDistanceToNow(new Date(suppression.createdAt), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(suppression.email)}
disabled={removeMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex gap-4 justify-end">
<Button
size="sm"
onClick={() => setPage(String(parseInt(page || "1") - 1))}
disabled={parseInt(page || "1") === 1}
>
Previous
</Button>
<Button
size="sm"
onClick={() => setPage(String(parseInt(page || "1") + 1))}
disabled={!suppressionsQuery.data?.pagination?.hasNext}
>
Next
</Button>
</div>
<RemoveSuppressionDialog
email={emailToRemove}
open={!!emailToRemove}
onOpenChange={(open) => !open && setEmailToRemove(null)}
onConfirm={confirmRemove}
isLoading={removeMutation.isPending}
/>
</div>
);
}