mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-07 00:01:37 +00:00
201 lines
11 KiB
TypeScript
201 lines
11 KiB
TypeScript
// Enrol a segment's current members as leads of one campaign. A snapshot:
|
|
// contacts who join the segment later are not added automatically.
|
|
|
|
import React from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Loader2Icon, MegaphoneIcon, XIcon } from "lucide-react";
|
|
import toast from "react-hot-toast";
|
|
|
|
import { SearchInput } from "@/components/ui/field";
|
|
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
|
|
import { useAddSegmentToCampaign } from "@/lib/api/hooks/app/segments";
|
|
import type Segment 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";
|
|
|
|
export default function AddSegmentToCampaignDialog({
|
|
open,
|
|
onClose,
|
|
segment,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
segment: Segment;
|
|
}) {
|
|
const add = useAddSegmentToCampaign();
|
|
const [query, setQuery] = React.useState("");
|
|
const [picked, setPicked] = React.useState<string | null>(null);
|
|
const campaigns = useCampaigns({ query: query.trim(), folder: "", enabled: open });
|
|
|
|
React.useEffect(() => {
|
|
if (!open) {
|
|
setQuery("");
|
|
setPicked(null);
|
|
}
|
|
}, [open]);
|
|
|
|
const busy = add.isPending;
|
|
const requestClose = React.useCallback(() => {
|
|
if (!busy) onClose();
|
|
}, [busy, 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]);
|
|
|
|
async function submit() {
|
|
if (!picked || busy) return;
|
|
const target = campaigns.campaigns.find((c) => c.id === picked);
|
|
try {
|
|
const res = await add.mutateAsync({ id: segment.id, campaignId: picked });
|
|
toast.success(
|
|
res.added === 0
|
|
? `Every member of ${segment.name} is already a lead in ${target?.name ?? "the campaign"}`
|
|
: `Added ${res.added.toLocaleString()} lead${res.added === 1 ? "" : "s"} to ${target?.name ?? "the campaign"}`,
|
|
);
|
|
onClose();
|
|
} catch (err) {
|
|
toast.error(buildError(err as AppError));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
key="overlay"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
onMouseDown={requestClose}
|
|
className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
|
|
>
|
|
<motion.div
|
|
key="card"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Add segment to campaign"
|
|
initial={{ y: 8, opacity: 0, scale: 0.985 }}
|
|
animate={{ y: 0, opacity: 1, scale: 1 }}
|
|
exit={{ y: 8, opacity: 0, scale: 0.985 }}
|
|
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
className="w-full max-w-[520px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[80dvh]"
|
|
>
|
|
<header className="h-12 px-4 border-b border-slate-200 flex items-center gap-2.5 shrink-0">
|
|
<div className="size-5 rounded bg-slate-100 text-slate-600 flex items-center justify-center">
|
|
<MegaphoneIcon className="w-3 h-3" />
|
|
</div>
|
|
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Add to campaign</span>
|
|
<div className="h-4 w-px bg-slate-200" />
|
|
<span className="text-[12.5px] text-slate-900 font-medium truncate">{segment.name}</span>
|
|
<span className="hidden sm:inline-flex items-center h-5 px-1.5 rounded bg-sky-50 text-sky-700 text-[10px] font-medium">
|
|
{segment.contact_count.toLocaleString()} contact{segment.contact_count === 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>
|
|
</header>
|
|
<div className="px-4 py-3 border-b border-slate-100 shrink-0">
|
|
<SearchInput value={query} onChange={setQuery} placeholder="Search campaigns…" autoFocus className="w-full" />
|
|
</div>
|
|
<div className="flex-1 min-h-[200px] overflow-y-auto">
|
|
{campaigns.isPending ? (
|
|
<div className="p-3 space-y-1.5">
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} className="h-9 rounded-md bg-slate-100 animate-pulse" />
|
|
))}
|
|
</div>
|
|
) : campaigns.campaigns.length === 0 ? (
|
|
<div className="px-5 py-10 text-center">
|
|
<p className="text-[12.5px] text-slate-900 font-medium">No campaigns found</p>
|
|
<p className="text-[11.5px] text-slate-400 mt-0.5">Create a campaign first, then add this segment to it.</p>
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y divide-slate-100">
|
|
{campaigns.campaigns.map((c) => {
|
|
const on = picked === c.id;
|
|
return (
|
|
<li key={c.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPicked(c.id)}
|
|
aria-pressed={on}
|
|
className={cn(
|
|
"w-full px-4 h-10 flex items-center gap-3 text-left transition-colors",
|
|
on ? "bg-sky-50/60 hover:bg-sky-50" : "hover:bg-slate-50",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"size-3.5 rounded-full border flex items-center justify-center shrink-0",
|
|
on ? "border-sky-600 bg-sky-600" : "border-slate-300 bg-white",
|
|
)}
|
|
>
|
|
{on && <span className="size-1.5 rounded-full bg-white" />}
|
|
</span>
|
|
<span className="text-[12.5px] text-slate-900 font-medium truncate">{c.name}</span>
|
|
<span className="ml-auto text-[10.5px] uppercase tracking-[0.1em] text-slate-400">{c.status}</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
{campaigns.hasNextPage && (
|
|
<li className="p-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => campaigns.fetchNextPage()}
|
|
disabled={campaigns.isFetchingNextPage}
|
|
className="w-full h-8 rounded-md text-[12px] text-slate-600 hover:text-slate-900 hover:bg-slate-50 inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-60"
|
|
>
|
|
{campaigns.isFetchingNextPage && <Loader2Icon className="w-3 h-3 animate-spin" />}
|
|
Load more
|
|
</button>
|
|
</li>
|
|
)}
|
|
</ul>
|
|
)}
|
|
</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">
|
|
Adds today's members. Contacts already in the campaign are skipped.
|
|
</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={submit}
|
|
disabled={busy || !picked}
|
|
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" /> : <MegaphoneIcon className="w-3 h-3" />}
|
|
Add leads
|
|
</button>
|
|
</footer>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|