feat: add contacts sheet sync UI

This commit is contained in:
Matthew Meszaros
2026-06-04 11:50:14 +02:00
parent 7fe63a98eb
commit 203a3e0471
4 changed files with 1367 additions and 0 deletions
@@ -23,6 +23,7 @@ import {
PlusIcon,
RefreshCcwIcon,
Settings2Icon,
SheetIcon,
TrashIcon,
UploadIcon,
UserPlusIcon,
@@ -43,6 +44,7 @@ import ContactsEditBulk from "./ContactsEditBulk";
import { NewContactDialog } from "./NewContactDialog";
import ExportDialog from "./ExportDialog";
import ImportWizard from "./ImportWizard";
import SyncSourcesPanel from "./SyncSourcesPanel";
import { CategoryChip } from "./CategoryPicker";
import {
@@ -90,6 +92,7 @@ export default function ContactsTable({
const [newOpen, setNewOpen] = React.useState<boolean>(false);
const [exportOpen, setExportOpen] = React.useState<boolean>(false);
const [importOpen, setImportOpen] = React.useState<boolean>(false);
const [syncOpen, setSyncOpen] = React.useState<boolean>(false);
const [searchProps, setSearchProps] = React.useState<SearchContacts>({
query: "",
@@ -227,6 +230,13 @@ export default function ContactsTable({
>
Filters
</TopbarAction>
<TopbarAction
variant="ghost"
icon={<SheetIcon className="w-3 h-3" />}
onClick={() => setSyncOpen(true)}
>
Sheet sync
</TopbarAction>
<TopbarAction
icon={<UserPlusIcon className="w-3 h-3" />}
onClick={() => setNewOpen(true)}
@@ -264,6 +274,11 @@ export default function ContactsTable({
/>
<ContactsEditBulk active={bulkEdit} setActive={setBulkEdit} selected={selected} />
<NewContactDialog open={newOpen} onClose={() => setNewOpen(false)} />
<SyncSourcesPanel
open={syncOpen}
onClose={() => setSyncOpen(false)}
campaign={current_campaign}
/>
</>
);
}
@@ -287,6 +302,13 @@ export default function ContactsTable({
>
Import
</TopbarAction>
<TopbarAction
variant="ghost"
icon={<SheetIcon className="w-3 h-3" />}
onClick={() => setSyncOpen(true)}
>
Sheet sync
</TopbarAction>
<TopbarAction
variant="ghost"
icon={<DownloadIcon className="w-3 h-3" />}
@@ -435,6 +457,7 @@ export default function ContactsTable({
open={importOpen}
onClose={() => setImportOpen(false)}
/>
<SyncSourcesPanel open={syncOpen} onClose={() => setSyncOpen(false)} />
</Page>
);
}
@@ -0,0 +1,726 @@
// SheetSyncWizard — multi-step modal for creating (or editing) an on-demand
// Google-Sheet → contacts "sync source", and optionally running the first
// "Sync now". Mirrors ImportWizard's dialog shell + house theme, and REUSES
// its column-mapper verbatim (TargetPicker + MapStep + DEDUP_OPTIONS) so the
// /lead-sync/google/preview ImportPreview is mapped with the exact same UI as
// a CSV import.
//
// Steps:
// 1. connect — if no hidden google_sheets OAuth connection exists, run the
// EXISTING integration OAuth popup (provider "google_sheets").
// 2. sheet — paste a Sheet ID, fetch its tabs, pick a tab.
// 3. map — preview first rows + map columns (reused MapStep).
// 4. options — dedup strategy, optional target campaign, optional categories.
// 5. save — POST /lead-sync/sources, then optionally Sync now → result.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
AlertTriangleIcon,
ArrowLeftIcon,
ArrowRightIcon,
CheckIcon,
Loader2Icon,
PlugZapIcon,
RefreshCwIcon,
SaveIcon,
SheetIcon,
XIcon,
} from "lucide-react";
import toast from "react-hot-toast";
import { MapStep, ResultStep } from "./ImportWizard";
import { DEDUP_OPTIONS, announceResult, describeError } from "./importShared";
import CategoryPicker from "./CategoryPicker";
import { Label, TextInput } from "@/components/ui/field";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuLabel,
PopoverMenuTrigger,
SelectButton,
} from "@/components/ui/popover-menu";
import {
useFinishIntegrationOAuth,
useStartIntegrationOAuth,
} from "@/lib/api/hooks/app/integrations/useIntegrationOAuth";
import { openOAuthPopup } from "@/lib/integrations/oauthPopup";
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
import useGoogleConnection from "@/lib/api/hooks/app/leadsync/useGoogleConnection";
import {
useGetSpreadsheet,
usePreviewSheet,
} from "@/lib/api/hooks/app/leadsync/useSheetMeta";
import useCreateLeadSyncSource from "@/lib/api/hooks/app/leadsync/useCreateLeadSyncSource";
import useSyncLeadSyncSource from "@/lib/api/hooks/app/leadsync/useSyncLeadSyncSource";
import { useQueryClient } from "@tanstack/react-query";
import type {
ImportColumnMapping,
ImportDedupStrategy,
ImportPreview,
ImportResult,
LeadSyncSource,
SheetMeta,
} from "@/lib/api/models/app/leadsync/LeadSync";
type Step = "connect" | "sheet" | "map" | "options" | "result";
interface Props {
open: boolean;
onClose: () => void;
// When set, the source is pre-targeted to this campaign and the campaign
// picker is hidden — used by the per-campaign "Connect a Google Sheet".
lockedCampaign?: { id: string; name: string };
// Notified after a source is saved so callers can refresh their list.
onSaved?: (source: LeadSyncSource) => void;
}
const STEP_ORDER: Step[] = ["connect", "sheet", "map", "options", "result"];
export default function SheetSyncWizard({ open, onClose, lockedCampaign, onSaved }: Props) {
const connection = useGoogleConnection();
const connectionId = connection.data?.connection?.id ?? null;
const connected = !!connection.data?.connected && !!connectionId;
const [step, setStep] = React.useState<Step>("connect");
const [sheetId, setSheetId] = React.useState("");
const [meta, setMeta] = React.useState<SheetMeta | null>(null);
const [tabTitle, setTabTitle] = React.useState("");
const [preview, setPreview] = React.useState<ImportPreview | null>(null);
const [mapping, setMapping] = React.useState<ImportColumnMapping[]>([]);
const [hasHeader, setHasHeader] = React.useState(true);
const [dedup, setDedup] = React.useState<ImportDedupStrategy>("update");
const [campaignId, setCampaignId] = React.useState<string | null>(lockedCampaign?.id ?? null);
const [campaignName, setCampaignName] = React.useState<string>(lockedCampaign?.name ?? "");
const [categoryIds, setCategoryIds] = React.useState<string[]>([]);
const [label, setLabel] = React.useState("");
const [result, setResult] = React.useState<ImportResult | null>(null);
const [busy, setBusy] = React.useState(false);
const startOAuth = useStartIntegrationOAuth();
const finishOAuth = useFinishIntegrationOAuth();
const getSpreadsheet = useGetSpreadsheet();
const previewSheet = usePreviewSheet();
const createSource = useCreateLeadSyncSource();
const syncSource = useSyncLeadSyncSource();
const queryClient = useQueryClient();
const reset = React.useCallback(() => {
setStep("connect");
setSheetId("");
setMeta(null);
setTabTitle("");
setPreview(null);
setMapping([]);
setHasHeader(true);
setDedup("update");
setCampaignId(lockedCampaign?.id ?? null);
setCampaignName(lockedCampaign?.name ?? "");
setCategoryIds([]);
setLabel("");
setResult(null);
setBusy(false);
}, [lockedCampaign]);
React.useEffect(() => {
if (!open) reset();
}, [open, reset]);
// Skip straight to the sheet step once we know a connection exists.
React.useEffect(() => {
if (open && step === "connect" && connected) setStep("sheet");
}, [open, step, connected]);
async function runConnect() {
setBusy(true);
try {
const { url } = await startOAuth.mutateAsync({
provider: "google_sheets",
label: "Google Sheets",
});
const { code, state } = await openOAuthPopup(url);
await finishOAuth.mutateAsync({ code, state });
await connection.refetch();
await queryClient.invalidateQueries({ queryKey: ["lead-sync", "google", "connection"] });
toast.success("Connected to Google Sheets");
setStep("sheet");
} catch (err) {
toast.error(describeError(err, "Connection failed."));
} finally {
setBusy(false);
}
}
async function loadTabs() {
if (!connectionId) return;
const id = sheetId.trim();
if (!id) {
toast.error("Paste a Sheet ID first.");
return;
}
setBusy(true);
try {
const m = await getSpreadsheet.mutateAsync({ connection_id: connectionId, sheet_id: id });
setMeta(m);
// Auto-select the first tab so the picker is never empty.
const first = m.tabs[0]?.title ?? "";
setTabTitle(first);
if (!label.trim()) setLabel(m.title);
} catch (err) {
toast.error(describeError(err, "Couldn't read that spreadsheet."));
setMeta(null);
} finally {
setBusy(false);
}
}
async function loadPreview() {
if (!connectionId || !meta) return;
if (!tabTitle) {
toast.error("Pick a tab first.");
return;
}
setBusy(true);
try {
const p = await previewSheet.mutateAsync({
connection_id: connectionId,
sheet_id: meta.sheet_id,
tab_title: tabTitle,
});
setPreview(p);
setMapping(p.suggested_mapping);
setHasHeader(p.has_header);
setStep("map");
} catch (err) {
toast.error(describeError(err, "Couldn't read that tab."));
} finally {
setBusy(false);
}
}
const emailMapped = mapping.some((m) => m.target === "email");
async function save(runSync: boolean) {
if (!connectionId || !meta) return;
setBusy(true);
try {
const source = await createSource.mutateAsync({
connection_id: connectionId,
sheet_id: meta.sheet_id,
sheet_title: meta.title,
tab_title: tabTitle,
has_header: hasHeader,
column_mapping: mapping,
dedup,
target_campaign_id: campaignId ?? undefined,
category_ids: categoryIds,
subscribed_default: true,
label: label.trim() || meta.title,
});
onSaved?.(source);
if (runSync) {
const res = await syncSource.mutateAsync(source.id);
setResult(res.result);
setStep("result");
announceResult(res.result);
} else {
toast.success("Sync source saved");
onClose();
}
} catch (err) {
toast.error(describeError(err, "Couldn't save the sync source."));
} finally {
setBusy(false);
}
}
function stepIndex(): number {
// Hide the connect dot once connected — the visible flow is 4 steps.
const visible = connected ? STEP_ORDER.filter((s) => s !== "connect") : STEP_ORDER;
return Math.max(0, visible.indexOf(step));
}
const visibleSteps = connected ? STEP_ORDER.filter((s) => s !== "connect") : STEP_ORDER;
return (
<AnimatePresence>
{open && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={onClose}
className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
>
<motion.div
key="card"
initial={{ y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 8, opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-[760px] 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-[90vh]"
>
<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-emerald-50 text-emerald-600 flex items-center justify-center">
<SheetIcon className="w-3 h-3" />
</div>
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Sync source
</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-900 font-medium">Google Sheet</span>
<div className="flex items-center gap-1 ml-2">
{visibleSteps.map((_, idx) => (
<span
key={idx}
className={`h-1 w-5 rounded-full transition-colors ${
idx <= stepIndex() ? "bg-slate-900" : "bg-slate-200"
}`}
/>
))}
</div>
<button
type="button"
onClick={onClose}
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="flex-1 min-h-0 overflow-y-auto px-5 py-4">
{step === "connect" && (
<ConnectStep busy={busy || connection.isLoading} onConnect={runConnect} />
)}
{step === "sheet" && (
<SheetStep
sheetId={sheetId}
setSheetId={setSheetId}
meta={meta}
tabTitle={tabTitle}
setTabTitle={setTabTitle}
onLoadTabs={loadTabs}
busy={busy}
/>
)}
{step === "map" && preview && (
<MapStep
preview={preview}
mapping={mapping}
setMapping={setMapping}
hasHeader={hasHeader}
setHasHeader={setHasHeader}
/>
)}
{step === "options" && (
<OptionsStep
dedup={dedup}
setDedup={setDedup}
label={label}
setLabel={setLabel}
campaignId={campaignId}
campaignName={campaignName}
onCampaign={(id, name) => {
setCampaignId(id);
setCampaignName(name);
}}
lockedCampaign={lockedCampaign}
categoryIds={categoryIds}
setCategoryIds={setCategoryIds}
/>
)}
{step === "result" && result && (
<ResultStep result={result} filename={meta?.title ?? "sync"} />
)}
</div>
<footer className="h-12 px-3 border-t border-slate-200 flex items-center gap-1.5 shrink-0 bg-slate-50/30">
{step === "connect" && (
<span className="ml-auto text-[11px] text-slate-400">
We use your existing Google Sheets authorization.
</span>
)}
{step === "sheet" && (
<>
{connected && !lockedCampaign && (
<span className="text-[11px] text-slate-400">
Connected to Google.
</span>
)}
<button
type="button"
onClick={loadPreview}
disabled={busy || !meta || !tabTitle}
className="ml-auto h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 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" />
) : (
<ArrowRightIcon className="w-3 h-3" />
)}
Preview rows
</button>
</>
)}
{step === "map" && (
<>
<button
type="button"
onClick={() => setStep("sheet")}
className="h-7 px-2.5 rounded-md text-[12px] text-slate-600 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center gap-1.5 transition-colors"
>
<ArrowLeftIcon className="w-3 h-3" />
Back
</button>
{!emailMapped && (
<span className="text-[11px] text-amber-700 inline-flex items-center gap-1">
<AlertTriangleIcon className="w-3 h-3" />
Map a column to Email
</span>
)}
<button
type="button"
onClick={() => setStep("options")}
disabled={!emailMapped}
className="ml-auto h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
>
Continue
<ArrowRightIcon className="w-3 h-3" />
</button>
</>
)}
{step === "options" && (
<>
<button
type="button"
onClick={() => setStep("map")}
className="h-7 px-2.5 rounded-md text-[12px] text-slate-600 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center gap-1.5 transition-colors"
>
<ArrowLeftIcon className="w-3 h-3" />
Back
</button>
<button
type="button"
onClick={() => save(false)}
disabled={busy}
className="ml-auto h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 hover:border-slate-300 hover:text-slate-900 inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
<SaveIcon className="w-3 h-3" />
Save only
</button>
<button
type="button"
onClick={() => save(true)}
disabled={busy}
className="h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{busy ? (
<Loader2Icon className="w-3 h-3 animate-spin" />
) : (
<RefreshCwIcon className="w-3 h-3" />
)}
Save & sync now
</button>
</>
)}
{step === "result" && (
<button
type="button"
onClick={onClose}
className="ml-auto h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium transition-colors"
>
Done
</button>
)}
</footer>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
// ----- Connect step ----------------------------------------------
function ConnectStep({ busy, onConnect }: { busy: boolean; onConnect: () => void }) {
return (
<div className="space-y-4">
<div className="rounded-lg border border-slate-200 p-6 text-center">
<div className="mx-auto size-10 rounded-md bg-emerald-50 text-emerald-600 flex items-center justify-center">
<SheetIcon className="w-5 h-5" />
</div>
<p className="text-[13px] text-slate-900 font-medium mt-3">Connect Google Sheets</p>
<p className="text-[11.5px] text-slate-500 mt-1 max-w-[42ch] mx-auto leading-relaxed">
Authorize Warmbly to read your spreadsheets. We only read the rows of the tab
you choose nothing is written back.
</p>
<button
type="button"
onClick={onConnect}
disabled={busy}
className="mt-4 h-8 px-4 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{busy ? (
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
) : (
<PlugZapIcon className="w-3.5 h-3.5" />
)}
{busy ? "Connecting…" : "Connect with Google"}
</button>
</div>
<div className="rounded-md border border-slate-200 bg-slate-50/40 p-3">
<ul className="text-[11px] text-slate-500 space-y-0.5 list-disc pl-4 leading-snug">
<li>One row per contact first row should be the column headers.</li>
<li>At minimum a column with email addresses.</li>
<li>This is on-demand: nothing syncs until you press Sync now.</li>
</ul>
</div>
</div>
);
}
// ----- Sheet + tab step ------------------------------------------
function SheetStep({
sheetId,
setSheetId,
meta,
tabTitle,
setTabTitle,
onLoadTabs,
busy,
}: {
sheetId: string;
setSheetId: (v: string) => void;
meta: SheetMeta | null;
tabTitle: string;
setTabTitle: (v: string) => void;
onLoadTabs: () => void;
busy: boolean;
}) {
return (
<div className="space-y-4">
<section>
<Label>Spreadsheet ID</Label>
<div className="flex items-center gap-1.5">
<TextInput
value={sheetId}
onChange={setSheetId}
placeholder="1AbC…XyZ"
className="font-mono flex-1"
/>
<button
type="button"
onClick={onLoadTabs}
disabled={busy || !sheetId.trim()}
className="h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50 shrink-0"
>
{busy ? <Loader2Icon className="w-3 h-3 animate-spin" /> : null}
Load tabs
</button>
</div>
<p className="text-[10.5px] text-slate-400 mt-1 leading-relaxed">
The long ID in the sheet URL between <code className="font-mono">/d/</code> and{" "}
<code className="font-mono">/edit</code>.
</p>
</section>
{meta && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<SheetIcon className="w-3.5 h-3.5 text-emerald-600 shrink-0" />
<span className="text-[12.5px] text-slate-900 font-medium truncate">{meta.title}</span>
<span className="text-[11px] text-slate-400">{meta.tabs.length} tabs</span>
</div>
<Label>Tab to import</Label>
<PopoverMenu align="start">
<PopoverMenuTrigger asChild>
<SelectButton label={tabTitle || "Select a tab…"} className="w-full" />
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={260}>
<PopoverMenuLabel>Tabs</PopoverMenuLabel>
{meta.tabs.map((t) => (
<PopoverMenuItem
key={`${t.index}-${t.title}`}
selected={t.title === tabTitle}
onSelect={() => setTabTitle(t.title)}
>
{t.title}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
</section>
)}
</div>
);
}
// ----- Options step ----------------------------------------------
function OptionsStep({
dedup,
setDedup,
label,
setLabel,
campaignId,
campaignName,
onCampaign,
lockedCampaign,
categoryIds,
setCategoryIds,
}: {
dedup: ImportDedupStrategy;
setDedup: (v: ImportDedupStrategy) => void;
label: string;
setLabel: (v: string) => void;
campaignId: string | null;
campaignName: string;
onCampaign: (id: string | null, name: string) => void;
lockedCampaign?: { id: string; name: string };
categoryIds: string[];
setCategoryIds: (v: string[]) => void;
}) {
return (
<div className="space-y-5">
<section>
<Label>Source label</Label>
<TextInput value={label} onChange={setLabel} placeholder="My leads sheet" />
<p className="text-[10.5px] text-slate-400 mt-1">
Shown in your Sync sources list. Defaults to the spreadsheet title.
</p>
</section>
<section>
<h2 className="text-[10px] uppercase tracking-[0.14em] font-semibold text-slate-500 mb-2">
Duplicate handling
</h2>
<p className="text-[11px] text-slate-400 leading-tight mb-3">
We dedupe on lowercased email each time you sync. Decide what happens when a row
matches a contact you already have.
</p>
<div className="space-y-2">
{DEDUP_OPTIONS.map((opt) => (
<label
key={opt.id}
className={`block rounded-md border p-2.5 cursor-pointer transition-colors ${
dedup === opt.id
? "border-slate-900 bg-slate-50"
: "border-slate-200 hover:border-slate-300"
}`}
>
<div className="flex items-start gap-2">
<input
type="radio"
name="leadsync-dedup"
className="mt-0.5 accent-slate-900"
checked={dedup === opt.id}
onChange={() => setDedup(opt.id)}
/>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium text-slate-900 leading-tight">
{opt.label}
</div>
<div className="text-[11px] text-slate-500 leading-snug mt-0.5">
{opt.hint}
</div>
</div>
</div>
</label>
))}
</div>
</section>
<section>
<h2 className="text-[10px] uppercase tracking-[0.14em] font-semibold text-slate-500 mb-2">
Enroll in campaign
</h2>
{lockedCampaign ? (
<div className="rounded-md border border-sky-200 bg-sky-50/60 px-3 py-2 flex items-center gap-2">
<CheckIcon className="w-3.5 h-3.5 text-sky-600 shrink-0" />
<span className="text-[12px] text-slate-800">
New & updated leads join{" "}
<span className="font-medium">{lockedCampaign.name}</span>.
</span>
</div>
) : (
<>
<p className="text-[11px] text-slate-400 leading-tight mb-2">
Optionally enroll every synced contact into a campaign.
</p>
<CampaignPicker
campaignId={campaignId}
campaignName={campaignName}
onChange={onCampaign}
/>
</>
)}
</section>
<section>
<h2 className="text-[10px] uppercase tracking-[0.14em] font-semibold text-slate-500 mb-2">
Apply categories
</h2>
<p className="text-[11px] text-slate-400 leading-tight mb-2">
Every synced contact gets these categories. Skip to leave them untagged.
</p>
<CategoryPicker value={categoryIds} onChange={setCategoryIds} />
</section>
</div>
);
}
// CampaignPicker — house-theme PopoverMenu campaign selector backed by the
// existing campaigns list query. Single-select with an explicit "None".
function CampaignPicker({
campaignId,
campaignName,
onChange,
}: {
campaignId: string | null;
campaignName: string;
onChange: (id: string | null, name: string) => void;
}) {
const [query, setQuery] = React.useState("");
const campaigns = useCampaigns({ query, folder: "" });
const label = campaignId ? campaignName || "Selected campaign" : "No campaign";
return (
<PopoverMenu align="start">
<PopoverMenuTrigger asChild>
<SelectButton label={label} className="w-full" />
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={280}>
<div className="px-2 py-1.5 border-b border-slate-200">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search campaigns…"
className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none"
/>
</div>
<PopoverMenuItem selected={!campaignId} onSelect={() => onChange(null, "")}>
No campaign
</PopoverMenuItem>
{campaigns.campaigns.map((c) => (
<PopoverMenuItem
key={c.id}
selected={c.id === campaignId}
onSelect={() => onChange(c.id, c.name)}
>
{c.name}
</PopoverMenuItem>
))}
{campaigns.campaigns.length === 0 && (
<div className="px-3 py-2 text-[11.5px] text-slate-400 text-center">
{campaigns.isPending ? "Loading…" : "No campaigns found."}
</div>
)}
</PopoverMenuContent>
</PopoverMenu>
);
}
@@ -0,0 +1,260 @@
// SyncSourceEditDrawer — edit a saved sync source's options without re-running
// the column mapper. Editing the sheet/tab/mapping is a "make a new source"
// operation conceptually, so here we only expose the safe, common edits:
// label, dedup, target campaign (with detach), categories, and the header flag.
// Sheet/tab/mapping are shown read-only for context.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import { DEDUP_OPTIONS, describeError } from "./importShared";
import CategoryPicker from "./CategoryPicker";
import { Label, TextInput } from "@/components/ui/field";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuTrigger,
SelectButton,
} from "@/components/ui/popover-menu";
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
import useUpdateLeadSyncSource from "@/lib/api/hooks/app/leadsync/useUpdateLeadSyncSource";
import type {
ImportDedupStrategy,
LeadSyncSource,
UpdateLeadSyncSource,
} from "@/lib/api/models/app/leadsync/LeadSync";
export default function SyncSourceEditDrawer({
source,
onClose,
}: {
source: LeadSyncSource;
onClose: () => void;
}) {
const update = useUpdateLeadSyncSource();
const [query, setQuery] = React.useState("");
const campaigns = useCampaigns({ query, folder: "" });
const [label, setLabel] = React.useState(source.label ?? "");
const [dedup, setDedup] = React.useState<ImportDedupStrategy>(source.dedup);
const [hasHeader, setHasHeader] = React.useState(source.has_header);
const [campaignId, setCampaignId] = React.useState<string | null>(
source.target_campaign_id ?? null,
);
const [categoryIds, setCategoryIds] = React.useState<string[]>(source.category_ids ?? []);
const [busy, setBusy] = React.useState(false);
const campaignName =
campaigns.campaigns.find((c) => c.id === campaignId)?.name ??
(campaignId ? "Selected campaign" : "No campaign");
async function save() {
setBusy(true);
try {
const body: UpdateLeadSyncSource = {
label: label.trim() || source.sheet_title || "Sync source",
dedup,
has_header: hasHeader,
category_ids: categoryIds,
};
// A nil pointer can't express "clear", so detach explicitly.
if (campaignId) {
body.target_campaign_id = campaignId;
} else if (source.target_campaign_id) {
body.clear_campaign = true;
}
await update.mutateAsync({ id: source.id, body });
toast.success("Sync source updated");
onClose();
} catch (err) {
toast.error(describeError(err, "Couldn't update the sync source."));
} finally {
setBusy(false);
}
}
return (
<AnimatePresence>
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-[130] flex"
>
<button
type="button"
aria-label="Close"
onClick={onClose}
className="absolute inset-0 bg-slate-900/30 backdrop-blur-[2px]"
/>
<motion.div
key="panel"
initial={{ x: 24, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 24, opacity: 0 }}
transition={{ duration: 0.18 }}
className="ml-auto h-full w-[440px] max-w-[92vw] bg-white shadow-xl flex flex-col z-10 relative"
>
<div className="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0">
<div className="min-w-0 flex-1">
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Edit sync source
</div>
<div className="text-[12.5px] text-slate-900 font-medium truncate">
{source.label || source.sheet_title || "Sync source"}
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="h-7 w-7 rounded border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 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 px-5 py-4 space-y-5">
<div className="rounded-md border border-slate-200 bg-slate-50/40 p-3 space-y-1">
<Row label="Spreadsheet" value={source.sheet_title || source.sheet_id} />
{source.tab_title && <Row label="Tab" value={source.tab_title} />}
<Row
label="Mapped columns"
value={`${source.column_mapping.filter((m) => m.target !== "ignore").length} fields`}
/>
</div>
<section>
<Label>Source label</Label>
<TextInput value={label} onChange={setLabel} placeholder="My leads sheet" />
</section>
<section>
<label className="inline-flex items-center gap-1.5 text-[11.5px] text-slate-700 cursor-pointer">
<input
type="checkbox"
className="w-3.5 h-3.5 rounded accent-slate-900"
checked={hasHeader}
onChange={(e) => setHasHeader(e.target.checked)}
/>
First row is a header
</label>
</section>
<section>
<h2 className="text-[10px] uppercase tracking-[0.14em] font-semibold text-slate-500 mb-2">
Duplicate handling
</h2>
<div className="space-y-2">
{DEDUP_OPTIONS.map((opt) => (
<label
key={opt.id}
className={`block rounded-md border p-2.5 cursor-pointer transition-colors ${
dedup === opt.id
? "border-slate-900 bg-slate-50"
: "border-slate-200 hover:border-slate-300"
}`}
>
<div className="flex items-start gap-2">
<input
type="radio"
name="leadsync-edit-dedup"
className="mt-0.5 accent-slate-900"
checked={dedup === opt.id}
onChange={() => setDedup(opt.id)}
/>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium text-slate-900 leading-tight">
{opt.label}
</div>
<div className="text-[11px] text-slate-500 leading-snug mt-0.5">
{opt.hint}
</div>
</div>
</div>
</label>
))}
</div>
</section>
<section>
<h2 className="text-[10px] uppercase tracking-[0.14em] font-semibold text-slate-500 mb-2">
Enroll in campaign
</h2>
<PopoverMenu align="start">
<PopoverMenuTrigger asChild>
<SelectButton label={campaignName} className="w-full" />
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={280}>
<div className="px-2 py-1.5 border-b border-slate-200">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search campaigns…"
className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none"
/>
</div>
<PopoverMenuItem
selected={!campaignId}
onSelect={() => setCampaignId(null)}
>
No campaign
</PopoverMenuItem>
{campaigns.campaigns.map((c) => (
<PopoverMenuItem
key={c.id}
selected={c.id === campaignId}
onSelect={() => setCampaignId(c.id)}
>
{c.name}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
</section>
<section>
<h2 className="text-[10px] uppercase tracking-[0.14em] font-semibold text-slate-500 mb-2">
Apply categories
</h2>
<CategoryPicker value={categoryIds} onChange={setCategoryIds} />
</section>
</div>
<div className="mt-auto border-t border-slate-200 px-5 py-3 flex items-center justify-end gap-2 shrink-0">
<button
type="button"
onClick={onClose}
className="h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 hover:border-slate-300 hover:text-slate-900 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={save}
disabled={busy}
className="h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <CheckIcon className="w-3.5 h-3.5" />}
Save changes
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3">
<span className="text-[10.5px] uppercase tracking-[0.1em] text-slate-400">{label}</span>
<span className="text-[12px] text-slate-700 truncate">{value}</span>
</div>
);
}
@@ -0,0 +1,358 @@
// SyncSourcesPanel — the management surface for on-demand Google-Sheet → leads
// "sync sources". Lists saved sources with their last-sync result + status, and
// offers per-row Sync now / Edit / Delete plus a "New sync" entry that opens the
// SheetSyncWizard. Works in two placements:
// - global Contacts page (no campaignId): lists every source.
// - per-campaign leads view (campaignId set): lists that campaign's sources
// and pre-targets the wizard to it.
//
// Rendered as a centered modal mirroring ImportWizard's dialog shell + theme.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
AlertTriangleIcon,
CheckCircle2Icon,
Loader2Icon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
SheetIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import toast from "react-hot-toast";
import { announceResult, describeError } from "./importShared";
import SheetSyncWizard from "./SheetSyncWizard";
import SyncSourceEditDrawer from "./SyncSourceEditDrawer";
import { useConfirm } from "@/hooks/context/confirm";
import useLeadSyncSources from "@/lib/api/hooks/app/leadsync/useLeadSyncSources";
import useDeleteLeadSyncSource from "@/lib/api/hooks/app/leadsync/useDeleteLeadSyncSource";
import useSyncLeadSyncSource from "@/lib/api/hooks/app/leadsync/useSyncLeadSyncSource";
import type { LeadSyncSource } from "@/lib/api/models/app/leadsync/LeadSync";
export default function SyncSourcesPanel({
open,
onClose,
campaign,
}: {
open: boolean;
onClose: () => void;
// When set, scopes the list + pre-targets new sources to this campaign.
campaign?: { id: string; name: string };
}) {
const sources = useLeadSyncSources(campaign?.id);
const deleteSource = useDeleteLeadSyncSource();
const syncSource = useSyncLeadSyncSource();
const confirm = useConfirm();
const [wizardOpen, setWizardOpen] = React.useState(false);
const [editing, setEditing] = React.useState<LeadSyncSource | null>(null);
const [syncingId, setSyncingId] = React.useState<string | null>(null);
const list = sources.data?.data ?? [];
async function runSync(src: LeadSyncSource) {
setSyncingId(src.id);
try {
const res = await syncSource.mutateAsync(src.id);
announceResult(res.result);
} catch (err) {
toast.error(describeError(err, "Sync failed."));
} finally {
setSyncingId(null);
}
}
function confirmDelete(src: LeadSyncSource) {
confirm.show(
`Delete sync source "${src.label || src.sheet_title || "this sheet"}"? Contacts already imported stay.`,
async () => {
try {
await deleteSource.mutateAsync(src.id);
toast.success("Sync source deleted");
} catch (err) {
toast.error(describeError(err, "Delete failed."));
}
},
);
}
return (
<>
<AnimatePresence>
{open && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={onClose}
className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
>
<motion.div
key="card"
initial={{ y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 8, opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-[720px] 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-[85vh]"
>
<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-emerald-50 text-emerald-600 flex items-center justify-center">
<SheetIcon className="w-3 h-3" />
</div>
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Sync sources
</span>
{campaign && (
<>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-900 font-medium truncate max-w-[200px]">
{campaign.name}
</span>
</>
)}
<button
type="button"
onClick={() => setWizardOpen(true)}
className="ml-auto h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<PlusIcon className="w-3 h-3" />
{campaign ? "Connect a Google Sheet" : "New sync"}
</button>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="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="flex-1 min-h-0 overflow-y-auto">
{sources.isPending ? (
<div className="divide-y divide-slate-200/60">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 px-5 flex items-center gap-3">
<div className="w-8 h-8 rounded-md bg-slate-100" />
<div className="flex-1 space-y-2">
<div className="h-3 w-40 bg-slate-100 rounded animate-pulse" />
<div className="h-2.5 w-56 bg-slate-100 rounded animate-pulse" />
</div>
</div>
))}
</div>
) : sources.isError ? (
<div className="px-5 py-12 text-center">
<div className="mx-auto mb-3 size-8 rounded-md bg-red-50 text-red-600 flex items-center justify-center">
<AlertTriangleIcon className="w-4 h-4" />
</div>
<p className="text-[12.5px] text-slate-900 font-medium">
Couldn&apos;t load sync sources
</p>
<button
type="button"
onClick={() => sources.refetch()}
className="mt-3 h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<RefreshCwIcon className="w-3 h-3" />
Try again
</button>
</div>
) : list.length === 0 ? (
<div className="px-5 py-14 text-center">
<div className="mx-auto size-10 rounded-md bg-slate-100 text-slate-400 flex items-center justify-center">
<SheetIcon className="w-5 h-5" />
</div>
<p className="text-[13px] text-slate-900 font-medium mt-3">
No sync sources yet
</p>
<p className="text-[11.5px] text-slate-500 mt-1 max-w-[42ch] mx-auto leading-relaxed">
Connect a Google Sheet and re-run it on demand to pull new and
updated leads into Warmbly{campaign ? ` and into ${campaign.name}` : ""}.
</p>
<button
type="button"
onClick={() => setWizardOpen(true)}
className="mt-4 h-8 px-4 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<PlusIcon className="w-3.5 h-3.5" />
{campaign ? "Connect a Google Sheet" : "New sync"}
</button>
</div>
) : (
<div className="divide-y divide-slate-200/60">
{list.map((src) => (
<SourceRow
key={src.id}
source={src}
syncing={syncingId === src.id}
onSync={() => runSync(src)}
onEdit={() => setEditing(src)}
onDelete={() => confirmDelete(src)}
/>
))}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<SheetSyncWizard
open={wizardOpen}
onClose={() => setWizardOpen(false)}
lockedCampaign={campaign}
onSaved={() => sources.refetch()}
/>
{editing && (
<SyncSourceEditDrawer source={editing} onClose={() => setEditing(null)} />
)}
</>
);
}
function SourceRow({
source,
syncing,
onSync,
onEdit,
onDelete,
}: {
source: LeadSyncSource;
syncing: boolean;
onSync: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const r = source.last_result;
return (
<div className="group px-5 py-3 flex items-center gap-3 hover:bg-slate-50/80 transition-colors">
<div className="size-8 rounded-md bg-emerald-50 text-emerald-600 flex items-center justify-center shrink-0">
<SheetIcon className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-[12.5px] text-slate-900 font-medium truncate">
{source.label || source.sheet_title || "Untitled sheet"}
</span>
<StatusBadge status={source.status} hasError={!!source.last_error} />
</div>
<div className="text-[11px] text-slate-500 truncate flex items-center gap-1.5 mt-0.5">
{source.sheet_title && (
<span className="truncate">{source.sheet_title}</span>
)}
{source.tab_title && (
<>
<span className="text-slate-300">·</span>
<span className="truncate">{source.tab_title}</span>
</>
)}
<span className="text-slate-300">·</span>
<span className="shrink-0">
{source.last_synced_at
? `synced ${new Date(source.last_synced_at).toLocaleString()}`
: "never synced"}
</span>
</div>
{r && (
<div className="mt-1 flex items-center gap-1.5 text-[10.5px] tabular-nums">
<Count label="imported" value={r.imported} tone="emerald" />
<Count label="updated" value={r.updated} tone="sky" />
<Count label="skipped" value={r.skipped} tone="slate" />
{r.failed > 0 && <Count label="failed" value={r.failed} tone="red" />}
</div>
)}
{source.last_error && (
<p className="mt-1 text-[10.5px] text-rose-600 truncate">{source.last_error}</p>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={onSync}
disabled={syncing}
className="h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{syncing ? (
<Loader2Icon className="w-3 h-3 animate-spin" />
) : (
<RefreshCwIcon className="w-3 h-3" />
)}
Sync now
</button>
<button
type="button"
onClick={onEdit}
aria-label="Edit sync source"
className="size-7 rounded-md text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<PencilIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={onDelete}
aria-label="Delete sync source"
className="size-7 rounded-md text-slate-400 hover:text-rose-600 hover:bg-rose-50 inline-flex items-center justify-center transition-colors"
>
<Trash2Icon className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
function StatusBadge({ status, hasError }: { status: string; hasError: boolean }) {
if (status === "syncing") {
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-sky-700 uppercase tracking-[0.08em]">
<Loader2Icon className="w-2.5 h-2.5 animate-spin" />
syncing
</span>
);
}
if (status === "error" || hasError) {
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-rose-700 uppercase tracking-[0.08em]">
<AlertTriangleIcon className="w-2.5 h-2.5" />
error
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-emerald-700 uppercase tracking-[0.08em]">
<CheckCircle2Icon className="w-2.5 h-2.5" />
idle
</span>
);
}
function Count({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "emerald" | "sky" | "slate" | "red";
}) {
const cls = {
emerald: "bg-emerald-50 text-emerald-700",
sky: "bg-sky-50 text-sky-700",
slate: "bg-slate-100 text-slate-600",
red: "bg-red-50 text-red-700",
}[tone];
return (
<span className={`inline-flex items-center gap-1 h-4 px-1.5 rounded ${cls}`}>
<span className="font-semibold">{value.toLocaleString()}</span>
<span className="opacity-70">{label}</span>
</span>
);
}