diff --git a/web/src/components/app/segments/SegmentEditor.tsx b/web/src/components/app/segments/SegmentEditor.tsx new file mode 100644 index 00000000..d07aa68d --- /dev/null +++ b/web/src/components/app/segments/SegmentEditor.tsx @@ -0,0 +1,492 @@ +// SegmentEditor — right-side drawer that creates or edits a segment: name, +// color, all/any match and the condition list, with a live "matches N +// contacts" preview fed by POST /segments/preview. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Loader2Icon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import { Label, NumberInput, TextInput } from "@/components/ui/field"; +import { SelectMenu, type SelectOption } from "@/components/ui/select-menu"; +import { DatePicker } from "@/components/ui/DatePicker"; +import { Segmented } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox"; +import CategoryPicker from "@/components/app/contacts/CategoryPicker"; +import { CampaignMultiPicker, EnumMultiPicker, SegmentMultiPicker } from "./SegmentPickers"; +import { useConfirm } from "@/hooks/context/confirm"; +import { + useCreateSegment, + useSegmentFields, + useSegmentPreview, + useUpdateSegment, +} from "@/lib/api/hooks/app/segments"; +import type Segment from "@/lib/api/models/app/segments/Segment"; +import { + SEGMENT_OPERATORS, + VALUELESS_OPERATORS, + type SegmentCondition, + type SegmentFieldSpec, + type SegmentMatch, +} from "@/lib/api/models/app/segments/Segment"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import { cn } from "@/lib/utils"; + +const COLORS = ["#0284c7", "#7c3aed", "#db2777", "#dc2626", "#ea580c", "#ca8a04", "#16a34a", "#0d9488", "#475569"]; + +interface Draft { + name: string; + description: string; + color: string; + match: SegmentMatch; + conditions: SegmentCondition[]; +} + +function draftFrom(segment?: Segment | null): Draft { + return { + name: segment?.name ?? "", + description: segment?.description ?? "", + color: segment?.color ?? COLORS[0], + match: segment?.match ?? "all", + conditions: segment?.conditions?.map((c) => ({ ...c, values: c.values ? [...c.values] : undefined })) ?? [], + }; +} + +function sameDraft(a: Draft, b: Draft): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +// A condition the server would accept: a field, an operator, and a value +// whenever the operator wants one. +function complete(c: SegmentCondition): boolean { + if (!c.field || !c.operator) return false; + if (VALUELESS_OPERATORS.has(c.operator)) return true; + if (c.operator === "in" || c.operator === "not_in") return (c.values?.length ?? 0) > 0; + return (c.value ?? "").trim() !== ""; +} + +export default function SegmentEditor({ + open, + onClose, + segment, + onSaved, +}: { + open: boolean; + onClose: () => void; + // Edit this segment; omit to create a new one. + segment?: Segment | null; + onSaved?: (segment: Segment) => void; +}) { + const confirm = useConfirm(); + const fields = useSegmentFields(open); + const create = useCreateSegment(); + const update = useUpdateSegment(); + + const [draft, setDraft] = React.useState(() => draftFrom(segment)); + const [initial, setInitial] = React.useState(() => draftFrom(segment)); + React.useEffect(() => { + if (open) { + const d = draftFrom(segment); + setDraft(d); + setInitial(d); + } + }, [open, segment]); + const dirty = !sameDraft(draft, initial); + + // Live count, debounced so typing a value does not fire a query per key. + const [debounced, setDebounced] = React.useState(null); + React.useEffect(() => { + if (!open) return; + const t = setTimeout(() => setDebounced(draft), 350); + return () => clearTimeout(t); + }, [draft, open]); + const previewInput = React.useMemo(() => { + if (!debounced) return null; + const conds = debounced.conditions.filter(complete); + return { id: segment?.id, match: debounced.match, conditions: conds }; + }, [debounced, segment?.id]); + const preview = useSegmentPreview(open ? previewInput : null); + + const busy = create.isPending || update.isPending; + const requestClose = React.useCallback(() => { + if (busy) return; + if (dirty) { + confirm.show("Discard your changes to this segment?", async () => onClose()); + return; + } + onClose(); + }, [busy, dirty, confirm, onClose]); + + React.useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (document.querySelector("[data-floating], [role='alertdialog']")) return; + requestClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, requestClose]); + + const incomplete = draft.conditions.filter((c) => !complete(c)).length; + const canSave = draft.name.trim() !== "" && incomplete === 0 && !busy; + const blocker = + draft.name.trim() === "" + ? "Give the segment a name." + : incomplete > 0 + ? `${incomplete} condition${incomplete === 1 ? " is" : "s are"} missing a value.` + : null; + + async function save() { + if (!canSave) return; + const body = { + name: draft.name.trim(), + description: draft.description.trim(), + color: draft.color, + match: draft.match, + conditions: draft.conditions, + }; + try { + const saved = segment + ? await update.mutateAsync({ id: segment.id, data: body }) + : await create.mutateAsync(body); + toast.success(segment ? "Segment updated" : "Segment created"); + setInitial(draft); + onSaved?.(saved); + onClose(); + } catch (err) { + toast.error(buildError(err as AppError)); + } + } + + function setCondition(i: number, next: SegmentCondition) { + setDraft((d) => ({ ...d, conditions: d.conditions.map((c, j) => (j === i ? next : c)) })); + } + + const specs = fields.data ?? []; + + return ( + + {open && ( + + e.stopPropagation()} + className="flex flex-col bg-white w-[560px] max-w-[95%] h-full border-l border-slate-200 shadow-[-8px_0_24px_-12px_rgba(15,23,42,0.12)]" + > +
+ + {segment ? "Edit segment" : "New segment"} + +
+ + {preview.isFetching ? ( + + ) : preview.isError ? ( + Cannot count + ) : ( + <> + Matches{" "} + + {(preview.data ?? 0).toLocaleString()} + {" "} + contact{preview.data === 1 ? "" : "s"} + + )} + + +
+ +
+
+
+
+ + setDraft((d) => ({ ...d, name: v }))} + placeholder="Warm leads in fintech" + autoFocus={!segment} + className="w-full" + /> +
+
+ +
+ {COLORS.map((c) => ( +
+
+
+
+ + setDraft((d) => ({ ...d, description: v }))} + placeholder="What this audience is for (optional)" + className="w-full" + /> +
+
+ +
+
+ Conditions + {draft.conditions.length} +
+ Match + + value={draft.match} + onChange={(v) => setDraft((d) => ({ ...d, match: v }))} + options={[ + { value: "all", label: "all" }, + { value: "any", label: "any" }, + ]} + /> +
+
+ + {draft.conditions.length === 0 && ( +
+

No conditions yet

+

+ Without conditions the segment only holds contacts you add by hand. +

+
+ )} + +
+ {draft.conditions.map((c, i) => ( + setCondition(i, next)} + onRemove={() => + setDraft((d) => ({ ...d, conditions: d.conditions.filter((_, j) => j !== i) })) + } + /> + ))} +
+ + +
+
+ +
+ + {blocker ?? (segment ? "Changes apply to every list using this segment." : "Membership stays live as contacts change.")} + + + +
+ + + )} + + ); +} + +function ConditionRow({ + index, + condition, + specs, + match, + selfId, + onChange, + onRemove, +}: { + index: number; + condition: SegmentCondition; + specs: SegmentFieldSpec[]; + match: SegmentMatch; + selfId?: string; + onChange: (next: SegmentCondition) => void; + onRemove: () => void; +}) { + const spec = specs.find((s) => s.field === condition.field); + const fieldOptions = React.useMemo( + () => specs.map((s) => ({ value: s.field, label: s.label, group: s.group })), + [specs], + ); + const operators = spec ? SEGMENT_OPERATORS[spec.kind] : []; + const operatorOptions: SelectOption[] = operators.map((o) => ({ value: o.id, label: o.label })); + + function pickField(field: string) { + const next = specs.find((s) => s.field === field); + const ops = next ? SEGMENT_OPERATORS[next.kind] : []; + onChange({ field, operator: ops[0]?.id ?? "", value: "", values: undefined }); + } + + function pickOperator(operator: string) { + onChange({ ...condition, operator, value: VALUELESS_OPERATORS.has(operator) ? "" : condition.value, values: condition.values }); + } + + return ( +
+
+ + {index === 0 ? "If" : match === "all" ? "and" : "or"} + + + + +
+ {spec && !VALUELESS_OPERATORS.has(condition.operator) && ( +
+ +
+ )} +
+ ); +} + +function ValueInput({ + spec, + condition, + selfId, + onChange, +}: { + spec: SegmentFieldSpec; + condition: SegmentCondition; + selfId?: string; + onChange: (next: SegmentCondition) => void; +}) { + const values = condition.values ?? []; + const setValues = (next: string[]) => onChange({ ...condition, values: next }); + const setValue = (next: string) => onChange({ ...condition, value: next }); + switch (spec.kind) { + case "text": + return ; + case "number": + return ( + setValue(String(Math.max(0, Math.round(n))))} + min={0} + className="w-40" + /> + ); + case "date": + if (condition.operator === "within_days" || condition.operator === "not_within_days") { + return ( + setValue(String(Math.min(3650, Math.max(1, Math.round(n)))))} + min={1} + max={3650} + suffix="days" + className="w-40" + /> + ); + } + return ( + + ); + case "enum": + return ; + case "category": + return ; + case "campaign": + return ; + case "segment": + return ; + default: + return null; + } +} diff --git a/web/src/components/app/segments/SegmentPickers.tsx b/web/src/components/app/segments/SegmentPickers.tsx new file mode 100644 index 00000000..b4cceebb --- /dev/null +++ b/web/src/components/app/segments/SegmentPickers.tsx @@ -0,0 +1,218 @@ +// Multi-select pickers used by the segment condition builder: campaigns, +// segments and enum options. Same chip box + dropdown language as +// CategoryPicker, without inline creation. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, PlusIcon, XIcon } from "lucide-react"; + +import useClickOutside from "@/hooks/useClickOutside"; +import useFlipPlacement from "@/hooks/useFlipPlacement"; +import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; +import { useSegments } from "@/lib/api/hooks/app/segments"; +import { cn } from "@/lib/utils"; + +export interface PickOption { + id: string; + label: string; + color?: string; +} + +export function MultiPicker({ + value, + onChange, + options, + placeholder = "Pick…", + searchable = true, + className, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: PickOption[]; + placeholder?: string; + searchable?: boolean; + className?: string; +}) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const ref = React.useRef(null); + const triggerRef = React.useRef(null); + useClickOutside(ref, () => setOpen(false)); + const placement = useFlipPlacement(triggerRef, open, 270); + + const byId = React.useMemo(() => new Map(options.map((o) => [o.id, o])), [options]); + const chips = value.map((id) => byId.get(id) ?? { id, label: "Unknown" }); + const filtered = React.useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return options; + return options.filter((o) => o.label.toLowerCase().includes(q)); + }, [options, query]); + + function toggle(id: string) { + onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]); + } + + return ( +
+
+ {chips.length === 0 ? ( + + ) : ( +
+ {chips.map((c) => ( + + {c.color && } + {c.label} + + + ))} + +
+ )} +
+ + {open && ( + + {searchable && ( +
+ setQuery(e.target.value)} + placeholder="Search…" + autoFocus + className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+ )} +
+ {filtered.length === 0 && ( +
Nothing to pick.
+ )} + {filtered.map((o) => { + const checked = value.includes(o.id); + return ( + + ); + })} +
+
+ )} +
+
+ ); +} + +export function CampaignMultiPicker({ value, onChange }: { value: string[]; onChange: (next: string[]) => void }) { + const campaigns = useCampaigns({ query: "", folder: "", limit: 100 }); + const options = React.useMemo( + () => campaigns.campaigns.map((c) => ({ id: c.id, label: c.name })), + [campaigns.campaigns], + ); + // Walk every page once so the picker holds the whole workspace. + const { hasNextPage, isFetchingNextPage, fetchNextPage } = campaigns; + React.useEffect(() => { + if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + return ; +} + +export function SegmentMultiPicker({ + value, + onChange, + exclude, +}: { + value: string[]; + onChange: (next: string[]) => void; + exclude?: string; +}) { + const segments = useSegments(); + const options = React.useMemo( + () => + (segments.data ?? []) + .filter((s) => s.id !== exclude) + .map((s) => ({ id: s.id, label: s.name, color: s.color })), + [segments.data, exclude], + ); + return ; +} + +const ENUM_LABELS: Record = { + unknown: "Unknown", + manual: "Added manually", + campaign: "Added from a campaign", + import: "Imported", + sheet_sync: "Google Sheets sync", + api: "API", + ai_assistant: "AI assistant", + valid: "Valid", + risky: "Risky", + invalid: "Invalid", + gmail: "Gmail", + outlook: "Outlook", + other: "Other", +}; + +export function EnumMultiPicker({ + value, + onChange, + options, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: string[]; +}) { + const opts = React.useMemo(() => options.map((o) => ({ id: o, label: ENUM_LABELS[o] ?? o })), [options]); + return ; +}