mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 00:04:27 +00:00
feat: add the segment condition builder drawer with a live match count and the campaign, segment and enum multi-pickers it uses
This commit is contained in:
@@ -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<Draft>(() => draftFrom(segment));
|
||||
const [initial, setInitial] = React.useState<Draft>(() => 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<Draft | null>(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 (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
onMouseDown={requestClose}
|
||||
className="fixed inset-0 z-[100] flex justify-end bg-slate-900/30 backdrop-blur-[2px]"
|
||||
>
|
||||
<motion.aside
|
||||
key="panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={segment ? "Edit segment" : "New segment"}
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 32 }}
|
||||
onMouseDown={(e) => 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)]"
|
||||
>
|
||||
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-3 shrink-0">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
|
||||
{segment ? "Edit segment" : "New segment"}
|
||||
</span>
|
||||
<div className="h-4 w-px bg-slate-200" />
|
||||
<span className="text-[12.5px] text-slate-700 inline-flex items-center gap-1.5">
|
||||
{preview.isFetching ? (
|
||||
<Loader2Icon className="w-3 h-3 animate-spin text-slate-400" />
|
||||
) : preview.isError ? (
|
||||
<span className="text-rose-600">Cannot count</span>
|
||||
) : (
|
||||
<>
|
||||
Matches{" "}
|
||||
<span className="font-mono tabular-nums text-slate-900">
|
||||
{(preview.data ?? 0).toLocaleString()}
|
||||
</span>{" "}
|
||||
contact{preview.data === 1 ? "" : "s"}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestClose}
|
||||
aria-label="Close"
|
||||
className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<XIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-4 py-4 space-y-4 border-b border-slate-200/60">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Label>Name</Label>
|
||||
<TextInput
|
||||
value={draft.name}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, name: v }))}
|
||||
placeholder="Warm leads in fintech"
|
||||
autoFocus={!segment}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Color</Label>
|
||||
<div className="flex items-center gap-1 h-7">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setDraft((d) => ({ ...d, color: c }))}
|
||||
aria-label={`Color ${c}`}
|
||||
aria-pressed={draft.color === c}
|
||||
className={cn(
|
||||
"size-4 rounded-full border-2 transition-transform",
|
||||
draft.color === c ? "border-slate-900 scale-110" : "border-transparent hover:scale-110",
|
||||
)}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<TextInput
|
||||
value={draft.description}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, description: v }))}
|
||||
placeholder="What this audience is for (optional)"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Conditions</span>
|
||||
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">{draft.conditions.length}</span>
|
||||
<div className="ml-auto flex items-center gap-2 text-[12px] text-slate-600">
|
||||
<span>Match</span>
|
||||
<Segmented<SegmentMatch>
|
||||
value={draft.match}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, match: v }))}
|
||||
options={[
|
||||
{ value: "all", label: "all" },
|
||||
{ value: "any", label: "any" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.conditions.length === 0 && (
|
||||
<div className="rounded-md border border-dashed border-slate-200 px-3 py-4 text-center">
|
||||
<p className="text-[12.5px] text-slate-900 font-medium">No conditions yet</p>
|
||||
<p className="text-[11.5px] text-slate-400 mt-0.5">
|
||||
Without conditions the segment only holds contacts you add by hand.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{draft.conditions.map((c, i) => (
|
||||
<ConditionRow
|
||||
key={i}
|
||||
index={i}
|
||||
condition={c}
|
||||
specs={specs}
|
||||
match={draft.match}
|
||||
selfId={segment?.id}
|
||||
onChange={(next) => setCondition(i, next)}
|
||||
onRemove={() =>
|
||||
setDraft((d) => ({ ...d, conditions: d.conditions.filter((_, j) => j !== i) }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={draft.conditions.length >= 50}
|
||||
onClick={() =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
conditions: [...d.conditions, { field: "", operator: "", value: "" }],
|
||||
}))
|
||||
}
|
||||
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 bg-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="w-3 h-3" />
|
||||
Add condition
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="px-3 h-12 border-t border-slate-200 flex items-center gap-2 shrink-0 bg-slate-50/30">
|
||||
<span className="text-[11px] text-slate-400 min-w-0 truncate">
|
||||
{blocker ?? (segment ? "Changes apply to every list using this segment." : "Membership stays live as contacts change.")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestClose}
|
||||
disabled={busy}
|
||||
className="ml-auto h-7 px-2.5 rounded-md text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!canSave}
|
||||
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{busy && <Loader2Icon className="w-3 h-3 animate-spin" />}
|
||||
{segment ? "Save segment" : "Create segment"}
|
||||
</button>
|
||||
</footer>
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
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<SelectOption[]>(
|
||||
() => 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 (
|
||||
<div className="rounded-md border border-slate-200 bg-white p-2 space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-8 shrink-0 text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
|
||||
{index === 0 ? "If" : match === "all" ? "and" : "or"}
|
||||
</span>
|
||||
<SelectMenu
|
||||
value={condition.field}
|
||||
onChange={pickField}
|
||||
options={fieldOptions}
|
||||
placeholder="Pick a field…"
|
||||
className="min-w-0 flex-1"
|
||||
fullWidth
|
||||
aria-label="Field"
|
||||
/>
|
||||
<SelectMenu
|
||||
value={condition.operator}
|
||||
onChange={pickOperator}
|
||||
options={operatorOptions}
|
||||
placeholder="Operator"
|
||||
disabled={!spec}
|
||||
className="min-w-0 flex-1"
|
||||
fullWidth
|
||||
aria-label="Operator"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove condition"
|
||||
className="size-7 shrink-0 rounded-md text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Trash2Icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{spec && !VALUELESS_OPERATORS.has(condition.operator) && (
|
||||
<div className="pl-[38px]">
|
||||
<ValueInput spec={spec} condition={condition} selfId={selfId} onChange={onChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <TextInput value={condition.value ?? ""} onChange={setValue} placeholder="Value" className="w-full" />;
|
||||
case "number":
|
||||
return (
|
||||
<NumberInput
|
||||
value={Number(condition.value ?? 0) || 0}
|
||||
onChange={(n) => 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 (
|
||||
<NumberInput
|
||||
value={Number(condition.value ?? 0) || 0}
|
||||
onChange={(n) => setValue(String(Math.min(3650, Math.max(1, Math.round(n)))))}
|
||||
min={1}
|
||||
max={3650}
|
||||
suffix="days"
|
||||
className="w-40"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DatePicker
|
||||
value={(condition.value ?? "").slice(0, 10)}
|
||||
onChange={setValue}
|
||||
clearable={false}
|
||||
placeholder="Pick a date"
|
||||
className="w-48"
|
||||
/>
|
||||
);
|
||||
case "enum":
|
||||
return <EnumMultiPicker value={values} onChange={setValues} options={spec.options ?? []} />;
|
||||
case "category":
|
||||
return <CategoryPicker value={values} onChange={setValues} placeholder="Pick categories…" allowCreate={false} />;
|
||||
case "campaign":
|
||||
return <CampaignMultiPicker value={values} onChange={setValues} />;
|
||||
case "segment":
|
||||
return <SegmentMultiPicker value={values} onChange={setValues} exclude={selfId} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLDivElement>(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 (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<div ref={triggerRef} className="rounded-md border border-slate-200 bg-white min-h-[28px]">
|
||||
{chips.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full text-left px-2.5 h-7 text-[12px] text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{placeholder}
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-1.5 py-1 flex flex-wrap gap-1">
|
||||
{chips.map((c) => (
|
||||
<span
|
||||
key={c.id}
|
||||
className="inline-flex items-center gap-1 h-5 pl-1.5 pr-1 rounded text-[11px] font-medium bg-slate-100 text-slate-700"
|
||||
>
|
||||
{c.color && <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />}
|
||||
<span className="truncate max-w-[140px]">{c.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle(c.id);
|
||||
}}
|
||||
className="opacity-70 hover:opacity-100"
|
||||
aria-label={`Remove ${c.label}`}
|
||||
>
|
||||
<XIcon className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[11px] font-medium border border-dashed border-slate-300 text-slate-500 hover:border-slate-400 hover:text-slate-700"
|
||||
>
|
||||
<PlusIcon className="w-2.5 h-2.5" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
data-floating
|
||||
initial={{ opacity: 0, y: placement === "top" ? 4 : -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: placement === "top" ? 4 : -4 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className={cn(
|
||||
"absolute left-0 right-0 z-30 rounded-md border border-slate-200 bg-white shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)] overflow-hidden",
|
||||
placement === "top" ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
>
|
||||
{searchable && (
|
||||
<div className="px-2 py-1.5 border-b border-slate-200">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-56 overflow-y-auto py-1">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-3 text-[11.5px] text-slate-400 text-center">Nothing to pick.</div>
|
||||
)}
|
||||
{filtered.map((o) => {
|
||||
const checked = value.includes(o.id);
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => toggle(o.id)}
|
||||
className="w-full px-2.5 h-7 flex items-center gap-2 text-[12px] text-slate-700 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-3.5 rounded border flex items-center justify-center transition-colors shrink-0",
|
||||
checked ? "border-slate-900 bg-slate-900" : "border-slate-300 bg-white",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="w-2 h-2 text-white" />}
|
||||
</span>
|
||||
{o.color && <span className="size-2.5 rounded-full shrink-0" style={{ backgroundColor: o.color }} />}
|
||||
<span className="truncate">{o.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CampaignMultiPicker({ value, onChange }: { value: string[]; onChange: (next: string[]) => void }) {
|
||||
const campaigns = useCampaigns({ query: "", folder: "", limit: 100 });
|
||||
const options = React.useMemo<PickOption[]>(
|
||||
() => 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 <MultiPicker value={value} onChange={onChange} options={options} placeholder="Pick campaigns…" />;
|
||||
}
|
||||
|
||||
export function SegmentMultiPicker({
|
||||
value,
|
||||
onChange,
|
||||
exclude,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
exclude?: string;
|
||||
}) {
|
||||
const segments = useSegments();
|
||||
const options = React.useMemo<PickOption[]>(
|
||||
() =>
|
||||
(segments.data ?? [])
|
||||
.filter((s) => s.id !== exclude)
|
||||
.map((s) => ({ id: s.id, label: s.name, color: s.color })),
|
||||
[segments.data, exclude],
|
||||
);
|
||||
return <MultiPicker value={value} onChange={onChange} options={options} placeholder="Pick segments…" />;
|
||||
}
|
||||
|
||||
const ENUM_LABELS: Record<string, string> = {
|
||||
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<PickOption[]>(() => options.map((o) => ({ id: o, label: ENUM_LABELS[o] ?? o })), [options]);
|
||||
return <MultiPicker value={value} onChange={onChange} options={opts} placeholder="Pick values…" searchable={false} />;
|
||||
}
|
||||
Reference in New Issue
Block a user