mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 16:04:55 +00:00
feat: add contacts sheet sync UI
This commit is contained in:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user