import { useState, useRef, useEffect } from "react"; import { RiAddLine, RiCloseLine, RiFilter3Line } from "react-icons/ri"; // ─── Field definitions ─────────────────────────────────────────────────────── export type FieldType = "enum" | "presence" | "boolean" | "number" | "text"; export interface FieldDef { key: string; label: string; type: FieldType; options?: { value: string; label: string }[]; } export const FILTER_FIELDS: FieldDef[] = [ { key: "connection_status", label: "Connection", type: "enum", options: [ { value: "not_contacted", label: "Not contacted" }, { value: "request_sent", label: "Request sent" }, { value: "connected", label: "Connected (1st)" }, { value: "messaged", label: "Messaged" }, { value: "replied", label: "Replied" }, ], }, { key: "seniority", label: "Seniority", type: "enum", options: [ { value: "owner", label: "Owner" }, { value: "founder", label: "Founder" }, { value: "c_suite", label: "C-Suite" }, { value: "partner", label: "Partner" }, { value: "vp", label: "VP" }, { value: "head", label: "Head" }, { value: "director", label: "Director" }, { value: "manager", label: "Manager" }, { value: "senior", label: "Senior" }, { value: "entry", label: "Entry" }, { value: "intern", label: "Intern" }, ], }, { key: "email_status", label: "Email status", type: "enum", options: [ { value: "verified", label: "Verified" }, { value: "unverified", label: "Unverified" }, { value: "invalid", label: "Invalid" }, { value: "catchall", label: "Catch-all" }, ], }, { key: "degree", label: "Degree", type: "enum", options: [ { value: "1", label: "1st" }, { value: "2", label: "2nd" }, { value: "3", label: "3rd" }, ], }, { key: "email", label: "Email", type: "presence", }, { key: "apollo_enriched_at", label: "Apollo enriched", type: "presence", }, { key: "connection_requested_at", label: "Connection request", type: "presence", }, { key: "connected_at", label: "Connected at", type: "presence", }, { key: "message_sent_at", label: "Message sent", type: "presence", }, { key: "last_replied_at", label: "Replied", type: "presence", }, { key: "open_link", label: "Open link", type: "boolean", }, { key: "email_domain_catchall", label: "Catch-all domain", type: "boolean", }, { key: "company_size", label: "Company size", type: "number", }, { key: "tenure_months", label: "Tenure (months)", type: "number", }, { key: "country", label: "Country", type: "text", }, { key: "company_industry", label: "Industry", type: "text", }, { key: "company", label: "Company", type: "text", }, ]; // ─── Filter model ───────────────────────────────────────────────────────────── export type FilterOp = | "is" | "is_not" // enum / text | "contains" // text | "is_set" | "is_not_set" // presence | "is_true" | "is_false" // boolean | "gt" | "lt"; // number export interface ActiveFilter { id: string; field: string; op: FilterOp; value?: string; } function opLabel(op: FilterOp): string { switch (op) { case "is": return "is"; case "is_not": return "is not"; case "contains": return "contains"; case "is_set": return "is set"; case "is_not_set": return "is not set"; case "is_true": return "is true"; case "is_false": return "is false"; case "gt": return ">"; case "lt": return "<"; } } function opsForField(field: FieldDef): FilterOp[] { switch (field.type) { case "enum": return ["is", "is_not"]; case "presence": return ["is_set", "is_not_set"]; case "boolean": return ["is_true", "is_false"]; case "number": return ["gt", "lt"]; case "text": return ["contains", "is", "is_not"]; } } function defaultOp(field: FieldDef): FilterOp { return opsForField(field)[0]; } // ─── Chip ───────────────────────────────────────────────────────────────────── function FilterChip({ filter, onRemove, onChange, }: { filter: ActiveFilter; onRemove: () => void; onChange: (updated: ActiveFilter) => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); const fieldDef = FILTER_FIELDS.find((f) => f.key === filter.field)!; useEffect(() => { function handler(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); } if (open) document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open]); const ops = opsForField(fieldDef); const needsValue = !["is_set", "is_not_set", "is_true", "is_false"].includes(filter.op); // Chip label const valueLabel = fieldDef.options?.find((o) => o.value === filter.value)?.label ?? filter.value ?? ""; return (
{open && (
{/* Field label */}
{fieldDef.label}
{/* Operator */}
{ops.map((op) => ( ))}
{/* Value input */} {needsValue && (
{fieldDef.type === "enum" && fieldDef.options ? (
{fieldDef.options.map((opt) => ( ))}
) : ( onChange({ ...filter, value: e.target.value })} onKeyDown={(e) => e.key === "Enter" && setOpen(false)} className="w-full bg-base-300/50 border border-base-300/80 rounded-lg px-2.5 py-1.5 text-xs text-base-content placeholder:text-base-content/30 focus:outline-none focus:border-primary/50" /> )}
)}
)}
); } // ─── Add filter dropdown ─────────────────────────────────────────────────────── function AddFilterButton({ onAdd, fields }: { onAdd: (field: string) => void; fields: FieldDef[] }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const ref = useRef(null); useEffect(() => { function handler(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); setSearch(""); } } if (open) document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open]); const filtered = fields.filter((f) => f.label.toLowerCase().includes(search.toLowerCase()) ); return (
{open && (
setSearch(e.target.value)} className="w-full bg-base-300/50 border border-base-300/60 rounded-lg px-2.5 py-1.5 text-xs text-base-content placeholder:text-base-content/30 focus:outline-none focus:border-primary/50" />
{filtered.length === 0 ? (
No fields found
) : ( filtered.map((f) => ( )) )}
)}
); } // ─── FilterBar ──────────────────────────────────────────────────────────────── interface FilterBarProps { filters: ActiveFilter[]; onChange: (filters: ActiveFilter[]) => void; fieldSubset?: string[]; } export default function FilterBar({ filters, onChange, fieldSubset }: FilterBarProps) { const visibleFields = fieldSubset ? FILTER_FIELDS.filter((f) => fieldSubset.includes(f.key)) : FILTER_FIELDS; function addFilter(fieldKey: string) { const fieldDef = FILTER_FIELDS.find((f) => f.key === fieldKey)!; const newFilter: ActiveFilter = { id: Math.random().toString(36).slice(2), field: fieldKey, op: defaultOp(fieldDef), }; onChange([...filters, newFilter]); } function updateFilter(id: string, updated: ActiveFilter) { onChange(filters.map((f) => (f.id === id ? updated : f))); } function removeFilter(id: string) { onChange(filters.filter((f) => f.id !== id)); } return (
{filters.length > 0 && ( )} {filters.map((f) => ( removeFilter(f.id)} onChange={(updated) => updateFilter(f.id, updated)} /> ))} {filters.length > 0 && ( )}
); } // ─── Client-side filter logic (for list detail page) ───────────────────────── // eslint-disable-next-line @typescript-eslint/no-explicit-any export function applyFiltersClient(items: T[], filters: ActiveFilter[]): T[] { if (filters.length === 0) return items; return items.filter((item) => filters.every((f) => matchesFilter(item as Record, f))); } function matchesFilter(item: Record, filter: ActiveFilter): boolean { // connection_status is a derived field — handle specially if (filter.field === "connection_status") { const degree = item.degree as number | null; const crAt = item.connection_requested_at as string | null; const msgAt = item.message_sent_at as string | null; const repAt = item.last_replied_at as string | null; let status: string; if (repAt) status = "replied"; else if (msgAt) status = "messaged"; else if (degree === 1) status = "connected"; else if (crAt) status = "request_sent"; else status = "not_contacted"; if (filter.op === "is") return status === filter.value; if (filter.op === "is_not") return status !== filter.value; return true; } const raw = item[filter.field]; switch (filter.op) { case "is_set": return raw !== null && raw !== undefined && raw !== ""; case "is_not_set": return raw === null || raw === undefined || raw === ""; case "is_true": return raw === 1 || raw === true || raw === "1"; case "is_false": return raw === 0 || raw === false || raw === "0" || raw === null || raw === undefined; case "is": return String(raw ?? "").toLowerCase() === String(filter.value ?? "").toLowerCase(); case "is_not": return String(raw ?? "").toLowerCase() !== String(filter.value ?? "").toLowerCase(); case "contains": return String(raw ?? "").toLowerCase().includes(String(filter.value ?? "").toLowerCase()); case "gt": return Number(raw) > Number(filter.value ?? 0); case "lt": return Number(raw) < Number(filter.value ?? 0); default: return true; } } // ─── Server-side filter → query params (for contacts page) ─────────────────── export function filtersToParams(filters: ActiveFilter[]): URLSearchParams { const params = new URLSearchParams(); filters.forEach((f, i) => { params.set(`f[${i}][field]`, f.field); params.set(`f[${i}][op]`, f.op); if (f.value !== undefined) params.set(`f[${i}][value]`, f.value); }); return params; }