feat: add integration and meetings dashboard

This commit is contained in:
Matthew Meszaros
2026-06-08 06:25:44 +02:00
parent e6b2f233a1
commit 82e753735c
35 changed files with 2011 additions and 113 deletions
+375
View File
@@ -0,0 +1,375 @@
// Meetings / Calls — booked calls as a first-class CRM surface.
//
// Two sources feed this page:
// 1. Manual — meetings the user schedules/logs here with "New meeting"
// (source "manual"); created instantly in Warmbly, no external site.
// 2. Auto — calls a prospect self-books through a connected Calendly / Cal.com
// link, captured over that provider's inbound webhook.
//
// Either way they land here + on the contact timeline, live (realtime meeting
// events invalidate this list + summary). Server-driven for scale: timeframe /
// status / text filters + offset infinite scroll; header totals are COUNTs over
// the whole set via /meetings/summary, not a reduce over the loaded page.
import React from "react";
import {
CalendarClockIcon,
CalendarPlusIcon,
CableIcon,
Loader2Icon,
PlusIcon,
Trash2Icon,
VideoIcon,
XCircleIcon,
RotateCcwIcon,
} from "lucide-react";
import toast from "react-hot-toast";
import {
Page,
PageBody,
PageTopbar,
SectionBar,
Stat,
StatStrip,
TopbarAction,
EmptyBlock,
} from "@/components/layout/Page";
import { SearchInput } from "@/components/ui/field";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
import AnimatedNumber from "@/components/ui/AnimatedNumber";
import NewMeetingDialog from "@/components/app/meetings/NewMeetingDialog";
import { useConfirm } from "@/hooks/context/confirm";
import useSearchMeetings from "@/lib/api/hooks/app/meetings/useSearchMeetings";
import useMeetingsSummary from "@/lib/api/hooks/app/meetings/useMeetingsSummary";
import useDeleteMeeting from "@/lib/api/hooks/app/meetings/useDeleteMeeting";
import {
PROVIDER_LABELS,
type MeetingBooking,
type MeetingStatus,
type MeetingsSearch,
} from "@/lib/api/models/app/integrations/Integration";
import { cn } from "@/lib/utils";
type Timeframe = "upcoming" | "past" | "all";
const TABS: { id: Timeframe; label: string }[] = [
{ id: "upcoming", label: "Upcoming" },
{ id: "past", label: "Past" },
{ id: "all", label: "All" },
];
const STATUS_STYLE: Record<MeetingStatus, { label: string; cls: string }> = {
booked: { label: "Booked", cls: "bg-sky-50 text-sky-700 border-sky-200" },
rescheduled: { label: "Rescheduled", cls: "bg-amber-50 text-amber-700 border-amber-200" },
canceled: { label: "Canceled", cls: "bg-slate-100 text-slate-500 border-slate-200" },
completed: { label: "Completed", cls: "bg-emerald-50 text-emerald-700 border-emerald-200" },
no_show: { label: "No-show", cls: "bg-red-50 text-red-700 border-red-200" },
};
function formatWhen(iso?: string): { date: string; time: string; rel: string } {
if (!iso) return { date: "No time set", time: "", rel: "" };
const d = new Date(iso);
if (isNaN(d.getTime())) return { date: "No time set", time: "", rel: "" };
const date = d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
const time = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
const diffDays = Math.round((d.getTime() - Date.now()) / 86_400_000);
let rel = "";
if (Math.abs(diffDays) <= 14) {
rel = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(diffDays, "day");
}
return { date, time, rel };
}
// --- Add to calendar (no API: a Google template link + a downloadable .ics) ---
function gcalStamp(iso: string): string {
return new Date(iso).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
function endStamp(m: MeetingBooking): string {
if (m.end_time) return gcalStamp(m.end_time);
if (m.scheduled_for) return gcalStamp(new Date(new Date(m.scheduled_for).getTime() + 30 * 60_000).toISOString());
return "";
}
function googleCalURL(m: MeetingBooking): string {
const params = new URLSearchParams({ action: "TEMPLATE", text: m.event_name || "Meeting" });
if (m.scheduled_for) params.set("dates", `${gcalStamp(m.scheduled_for)}/${endStamp(m)}`);
const details = [m.invitee_name && `With ${m.invitee_name}`, m.invitee_email, m.join_url]
.filter(Boolean)
.join("\n");
if (details) params.set("details", details);
if (m.location) params.set("location", m.location);
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}
function downloadICS(m: MeetingBooking) {
const esc = (s: string) => s.replace(/([,;\\])/g, "\\$1").replace(/\n/g, "\\n");
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Warmbly//Meetings//EN",
"BEGIN:VEVENT",
`UID:${m.id}@warmbly`,
m.scheduled_for ? `DTSTART:${gcalStamp(m.scheduled_for)}` : "",
`DTEND:${endStamp(m)}`,
`SUMMARY:${esc(m.event_name || "Meeting")}`,
m.location || m.join_url ? `LOCATION:${esc(m.location || m.join_url || "")}` : "",
m.join_url ? `URL:${m.join_url}` : "",
"END:VEVENT",
"END:VCALENDAR",
].filter(Boolean);
const blob = new Blob([lines.join("\r\n")], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${(m.event_name || "meeting").replace(/[^\w-]+/g, "_")}.ics`;
a.click();
URL.revokeObjectURL(url);
}
export default function MeetingsPage() {
const [timeframe, setTimeframe] = React.useState<Timeframe>("upcoming");
const [searchRaw, setSearchRaw] = React.useState("");
const [search, setSearch] = React.useState("");
const [creating, setCreating] = React.useState(false);
React.useEffect(() => {
const t = setTimeout(() => setSearch(searchRaw.trim()), 250);
return () => clearTimeout(t);
}, [searchRaw]);
const filters: MeetingsSearch = React.useMemo(
() => ({ timeframe: timeframe === "all" ? "" : timeframe, q: search || undefined }),
[timeframe, search],
);
const { meetings, total, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useSearchMeetings({ filters });
const { data: summary } = useMeetingsSummary();
const rows = meetings ?? [];
return (
<Page>
<PageTopbar eyebrow="Meetings / Calls" subtitle="Calls you schedule, and calls prospects book with you">
<TopbarAction href="/app/integrations" variant="ghost" icon={<CableIcon className="w-3.5 h-3.5" />}>
Calendars
</TopbarAction>
<TopbarAction onClick={() => setCreating(true)} icon={<PlusIcon className="w-3.5 h-3.5" />}>
New meeting
</TopbarAction>
</PageTopbar>
<StatStrip cols={4}>
<Stat label="Upcoming" accent={(summary?.upcoming ?? 0) > 0} value={<AnimatedNumber value={summary?.upcoming ?? 0} />} />
<Stat label="Today" value={<AnimatedNumber value={summary?.today ?? 0} />} />
<Stat label="Total booked" value={<AnimatedNumber value={summary?.total ?? 0} />} />
<Stat label="Canceled" value={<AnimatedNumber value={summary?.canceled ?? 0} />} last />
</StatStrip>
<PageBody>
<SectionBar label="Meetings" count={total ? `${rows.length} of ${total}` : undefined}>
<div className="flex items-center gap-0.5 rounded-md border border-slate-200 p-0.5">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setTimeframe(tab.id)}
className={cn(
"h-6 px-2.5 rounded text-[11.5px] font-medium transition-colors",
timeframe === tab.id
? "bg-sky-600 text-white"
: "text-slate-500 hover:text-slate-900 hover:bg-slate-100",
)}
>
{tab.label}
</button>
))}
</div>
<SearchInput
value={searchRaw}
onChange={(v) => setSearchRaw(v)}
placeholder="Search name, email, or event…"
className="w-56"
/>
</SectionBar>
{isLoading ? (
<div className="px-5 py-16 flex justify-center">
<Loader2Icon className="w-5 h-5 text-slate-300 animate-spin" />
</div>
) : rows.length === 0 ? (
<EmptyBlock
title={timeframe === "upcoming" ? "No upcoming meetings" : "No meetings yet"}
body="Schedule a call with a contact, or connect Calendly / Cal.com so calls prospects book land here automatically."
cta={
<>
<TopbarAction onClick={() => setCreating(true)} icon={<PlusIcon className="w-3.5 h-3.5" />}>
New meeting
</TopbarAction>
<TopbarAction href="/app/integrations" variant="ghost" icon={<CableIcon className="w-3.5 h-3.5" />}>
Connect a calendar
</TopbarAction>
</>
}
/>
) : (
<div>
<div className="h-8 px-5 flex items-center gap-3 border-b border-slate-200 bg-slate-50/60 text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
<span className="w-40 shrink-0">When</span>
<span className="flex-1 min-w-0">Contact</span>
<span className="hidden md:block flex-1 min-w-0">Meeting</span>
<span className="w-24 shrink-0">Source</span>
<span className="w-28 shrink-0">Status</span>
<span className="w-28 shrink-0 text-right">Actions</span>
</div>
{rows.map((m) => (
<MeetingRow key={m.id} m={m} />
))}
{hasNextPage && (
<div className="px-5 py-4 flex justify-center">
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-600 hover:text-slate-900 hover:border-slate-300 inline-flex items-center gap-1.5 disabled:opacity-60"
>
{isFetchingNextPage && <Loader2Icon className="w-3.5 h-3.5 animate-spin" />}
Load more
</button>
</div>
)}
</div>
)}
</PageBody>
<NewMeetingDialog open={creating} onClose={() => setCreating(false)} />
</Page>
);
}
function MeetingRow({ m }: { m: MeetingBooking }) {
const when = formatWhen(m.scheduled_for);
const status = STATUS_STYLE[m.status] ?? STATUS_STYLE.booked;
const contactLabel = m.contact_name || m.invitee_name || m.invitee_email || "Unknown";
const canceled = m.status === "canceled";
const isManual = m.source === "manual";
const confirm = useConfirm();
const del = useDeleteMeeting();
const remove = () =>
confirm.show("Delete this meeting? This only removes it from Warmbly.", async () => {
await del.mutateAsync(m.id);
toast.success("Meeting deleted");
});
return (
<div className="group min-h-11 px-5 py-1.5 flex items-center gap-3 border-b border-slate-200/60 hover:bg-slate-50/80 transition-colors">
<div className="w-40 shrink-0">
<div className="flex items-center gap-1.5 text-[12.5px] text-slate-800">
<CalendarClockIcon className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="truncate">{when.date}</span>
</div>
<div className="text-[11px] text-slate-400 pl-5 truncate">
{when.time}
{when.rel ? ` · ${when.rel}` : ""}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-[12.5px] text-slate-900 font-medium truncate">{contactLabel}</div>
<div className="text-[11px] text-slate-400 truncate">{m.invitee_email}</div>
</div>
<div className="hidden md:block flex-1 min-w-0">
<div className="text-[12.5px] text-slate-700 truncate">{m.event_name || "Meeting"}</div>
{m.location && <div className="text-[11px] text-slate-400 truncate">{m.location}</div>}
</div>
<div className="w-24 shrink-0">
<span className="text-[11.5px] text-slate-500">
{isManual ? "Manual" : PROVIDER_LABELS[m.source as keyof typeof PROVIDER_LABELS] ?? m.source}
</span>
</div>
<div className="w-28 shrink-0">
<span className={cn("inline-flex items-center h-5 px-2 rounded border text-[10.5px] font-medium", status.cls)}>
{status.label}
</span>
</div>
<div className="w-28 shrink-0 flex items-center justify-end gap-1">
{!canceled && m.scheduled_for && (
<PopoverMenu align="end" side="bottom">
<PopoverMenuTrigger asChild>
<button
type="button"
title="Add to your calendar"
className="h-6 w-6 rounded inline-flex items-center justify-center text-slate-400 hover:text-sky-600 hover:bg-sky-50"
>
<CalendarPlusIcon className="w-3.5 h-3.5" />
</button>
</PopoverMenuTrigger>
<PopoverMenuContent>
<PopoverMenuItem
onSelect={() => window.open(googleCalURL(m), "_blank", "noopener,noreferrer")}
>
Google Calendar
</PopoverMenuItem>
<PopoverMenuItem onSelect={() => downloadICS(m)}>Download .ics</PopoverMenuItem>
</PopoverMenuContent>
</PopoverMenu>
)}
{!canceled && m.join_url && (
<a
href={m.join_url}
target="_blank"
rel="noopener noreferrer"
title="Join call"
className="h-6 w-6 rounded inline-flex items-center justify-center text-slate-400 hover:text-sky-600 hover:bg-sky-50"
>
<VideoIcon className="w-3.5 h-3.5" />
</a>
)}
{!canceled && m.reschedule_url && (
<a
href={m.reschedule_url}
target="_blank"
rel="noopener noreferrer"
title="Reschedule"
className="h-6 w-6 rounded inline-flex items-center justify-center text-slate-400 hover:text-amber-600 hover:bg-amber-50"
>
<RotateCcwIcon className="w-3.5 h-3.5" />
</a>
)}
{!canceled && !isManual && m.cancel_url && (
<a
href={m.cancel_url}
target="_blank"
rel="noopener noreferrer"
title="Cancel"
className="h-6 w-6 rounded inline-flex items-center justify-center text-slate-400 hover:text-red-600 hover:bg-red-50"
>
<XCircleIcon className="w-3.5 h-3.5" />
</a>
)}
{isManual && (
<button
type="button"
onClick={remove}
title="Delete meeting"
className="h-6 w-6 rounded inline-flex items-center justify-center text-slate-400 hover:text-red-600 hover:bg-red-50"
>
<Trash2Icon className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
@@ -16,6 +16,7 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import {
ArrowRightIcon,
CheckCircle2Icon,
@@ -65,33 +66,10 @@ const FIELDS_BY_PROVIDER: Record<string, FieldDef[]> = {
helper: "Settings → Developer → API Keys in Close.",
},
],
zapier: [
{
key: "api_token",
label: "Warmbly API key",
type: "password",
required: true,
helper: "Create a scoped key under Settings → API keys, then paste it into Zapier.",
},
],
make: [
{
key: "api_token",
label: "Warmbly API key",
type: "password",
required: true,
helper: "Create a scoped key under Settings → API keys, then paste it into Make.",
},
],
n8n: [
{
key: "api_token",
label: "Warmbly API key",
type: "password",
required: true,
helper: "Create a scoped key under Settings → API keys, then paste it into n8n.",
},
],
// Zapier / Make / n8n need no credential to connect — see the note in the
// overview step. We fan events to a per-automation webhook URL, and the
// reverse direction authenticates with a Warmbly API key created in the
// API-keys page (pasted into the tool, not here).
discord: [
{ key: "server", label: "Server name", placeholder: "Acme" },
{
@@ -124,7 +102,11 @@ export default function ConnectDrawer({
const isOAuth = entry.auth_method === "oauth";
const isInbound = entry.provider === "calendly" || entry.provider === "cal_com";
const isAutomation =
entry.provider === "zapier" || entry.provider === "make" || entry.provider === "n8n";
const fields = FIELDS_BY_PROVIDER[entry.provider] ?? [];
// Only providers with real credential fields take the extra credentials step.
const needsCredentials = !isOAuth && !isInbound && fields.length > 0;
function update(key: string, value: string) {
setConfig((c) => ({ ...c, [key]: value }));
@@ -212,6 +194,19 @@ export default function ConnectDrawer({
</div>
)}
{isAutomation && (
<div className="rounded-md border border-sky-200 bg-sky-50/50 px-3 py-2.5 space-y-1.5">
<p className="text-[12px] text-slate-700 leading-relaxed">
No key needed to connect. After connecting, add an automation that
sends Warmbly events to your {entry.name} webhook URL.
</p>
<p className="text-[11px] text-slate-500 leading-relaxed">
Want {entry.name} to call Warmbly back (e.g. create a contact)? Create a
scoped key under Settings API keys and paste it into {entry.name}.
</p>
</div>
)}
<div>
<Label>Connection label (optional)</Label>
<TextInput value={label} onChange={setLabel} placeholder={entry.name} />
@@ -251,11 +246,21 @@ export default function ConnectDrawer({
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <ArrowRightIcon className="w-3.5 h-3.5" />}
{busy ? "Creating…" : "Create inbound URL"}
</button>
) : (
) : needsCredentials ? (
<button type="button" onClick={() => setStep("credentials")} className={primaryBtn}>
<KeyRoundIcon className="w-3.5 h-3.5" />
Continue
</button>
) : (
<button
type="button"
disabled={busy}
onClick={() => void submitCredentials(new Event("submit") as unknown as React.FormEvent)}
className={cn(primaryBtn, busy && "opacity-60")}
>
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <ArrowRightIcon className="w-3.5 h-3.5" />}
{busy ? "Connecting…" : `Connect ${entry.name}`}
</button>
)}
</DrawerFooter>
</div>
@@ -323,13 +328,21 @@ export function Drawer({
}) {
return (
<div className="fixed inset-0 z-40 flex">
<button
<motion.button
type="button"
aria-label="Close"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.18 }}
className="absolute inset-0 bg-slate-900/30 backdrop-blur-[2px]"
/>
<div className="ml-auto h-full w-[480px] max-w-[92vw] bg-white shadow-xl flex flex-col z-10 relative animate-[slidein_.18s_ease-out]">
<motion.div
initial={{ x: 28, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
className="ml-auto h-full w-[480px] 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">
<ProviderGlyph provider={provider} name={name} size={7} />
<div className="min-w-0 flex-1">
@@ -346,7 +359,7 @@ export function Drawer({
</button>
</div>
{children}
</div>
</motion.div>
</div>
);
}
@@ -17,9 +17,12 @@ import React from "react";
import {
AlertTriangleIcon,
CheckCircle2Icon,
CopyIcon,
EyeIcon,
Loader2Icon,
PlusIcon,
RefreshCwIcon,
SendIcon,
Trash2Icon,
UnplugIcon,
ZapIcon,
@@ -41,19 +44,29 @@ import {
} from "@/lib/api/hooks/app/integrations/useConnectionEvents";
import { openOAuthPopup } from "@/lib/integrations/oauthPopup";
import {
defaultActionForProvider,
EVENT_LABELS,
REPLY_INTENT_OPTIONS,
type CapabilityObject,
type IntegrationCatalogEntry,
type IntegrationConnection,
type IntegrationEventSubscription,
} from "@/lib/api/models/app/integrations/Integration";
import { useFieldMappings, useUpdateConnectionConfig } from "@/lib/api/hooks/app/integrations/useFieldMappings";
import { useRevealWebhookSecret, useTestConnection } from "@/lib/api/hooks/app/integrations/useConnectionWebhookTools";
import { cn } from "@/lib/utils";
import { Drawer, SectionLabel } from "./ConnectDrawer";
import FieldMapEditor from "./FieldMapEditor";
import StatusPill, { HealthDot } from "./StatusPill";
const REPLY_EVENT = "campaign.reply_received";
// Providers whose deliveries we can test (notify + generic webhook). Automation
// tools additionally expose an HMAC signing secret for verification.
const WEBHOOK_TOOL_PROVIDERS = ["slack", "discord", "zapier", "make", "n8n"];
const SIGNING_PROVIDERS = ["zapier", "make", "n8n"];
export default function ConnectionDetail({
connection,
entry,
@@ -78,7 +91,11 @@ export default function ConnectionDetail({
const events = detail.data?.events ?? [];
const runs = detail.data?.runs ?? [];
const availableEvents = entry?.events ?? Object.keys(EVENT_LABELS);
// Only offer events the provider actually has a handler for; an empty list
// means this connection has no automations (e.g. a meeting provider).
const availableEvents = entry?.events ?? [];
const capability = entry?.capability;
const crmObject = capability?.objects?.[0];
const isOAuth = conn.auth_method === "oauth";
const needsReauth = conn.status === "reauth_required";
@@ -114,7 +131,7 @@ export default function ConnectionDetail({
await createEvent.mutateAsync({
connectionId: conn.id,
event_type: eventType,
action: actionForProvider(conn.provider),
action: defaultActionForProvider(conn.provider),
config,
enabled: true,
});
@@ -181,50 +198,80 @@ export default function ConnectionDetail({
)}
{/* Automations — the core "how you use it" surface */}
<div className="px-5 py-4 border-b border-slate-200 space-y-3">
<div className="flex items-center justify-between">
<SectionLabel>Automations</SectionLabel>
{!adding && (
<button
type="button"
onClick={() => setAdding(true)}
className="h-6 px-2 rounded text-[11px] text-sky-700 hover:bg-sky-50 inline-flex items-center gap-1 transition-colors"
>
<PlusIcon className="w-3 h-3" />
New rule
</button>
{(availableEvents.length > 0 || events.length > 0) && (
<div className="px-5 py-4 border-b border-slate-200 space-y-3">
<div className="flex items-center justify-between">
<SectionLabel>Automations</SectionLabel>
{!adding && availableEvents.length > 0 && (
<button
type="button"
onClick={() => setAdding(true)}
className="h-6 px-2 rounded text-[11px] text-sky-700 hover:bg-sky-50 inline-flex items-center gap-1 transition-colors"
>
<PlusIcon className="w-3 h-3" />
New rule
</button>
)}
</div>
{events.length === 0 && !adding && (
<p className="text-[11.5px] text-slate-400 leading-relaxed">
No automations yet. Add a rule to push Warmbly events into {entry?.name ?? conn.label}
e.g. ping a channel when a prospect replies, or upsert a contact in your CRM.
</p>
)}
{events.map((ev) => (
<AutomationRow
key={ev.id}
sub={ev}
onDelete={() =>
deleteEvent
.mutateAsync({ connectionId: conn.id, eventId: ev.id })
.then(() => detail.refetch())
}
/>
))}
{adding && (
<AddAutomation
provider={conn.provider}
availableEvents={availableEvents}
onCancel={() => setAdding(false)}
onAdd={addAutomation}
busy={createEvent.isPending}
/>
)}
</div>
)}
{events.length === 0 && !adding && (
<p className="text-[11.5px] text-slate-400 leading-relaxed">
No automations yet. Add a rule to push Warmbly events into {entry?.name ?? conn.label}
e.g. ping a channel when a prospect replies.
</p>
)}
{/* Field mapping — control exactly what each CRM record gets */}
{crmObject && (
<div className="px-5 py-4 border-b border-slate-200 space-y-2.5">
<SectionLabel>Field mapping</SectionLabel>
<FieldMappingsBlock connectionId={conn.id} object={crmObject} />
</div>
)}
{events.map((ev) => (
<AutomationRow
key={ev.id}
sub={ev}
onDelete={() =>
deleteEvent
.mutateAsync({ connectionId: conn.id, eventId: ev.id })
.then(() => detail.refetch())
}
/>
))}
{/* Booking link — for scheduling providers (Calendly / Cal.com) */}
{capability?.supports_booking_link && (
<div className="px-5 py-4 border-b border-slate-200 space-y-2">
<SectionLabel>Booking link</SectionLabel>
<BookingLinkBlock connection={conn} onSaved={() => detail.refetch()} />
</div>
)}
{adding && (
<AddAutomation
{/* Webhook delivery — test wiring + (automation tools) signature */}
{WEBHOOK_TOOL_PROVIDERS.includes(conn.provider) && (
<div className="px-5 py-4 border-b border-slate-200 space-y-3">
<SectionLabel>Webhook delivery</SectionLabel>
<WebhookToolsBlock
connectionId={conn.id}
provider={conn.provider}
availableEvents={availableEvents}
onCancel={() => setAdding(false)}
onAdd={addAutomation}
busy={createEvent.isPending}
hasAutomations={events.length > 0}
/>
)}
</div>
</div>
)}
{/* Activity */}
<div className="px-5 py-4 space-y-2">
@@ -353,8 +400,8 @@ function AddAutomation({
const [template, setTemplate] = React.useState("");
const needsChannel = provider === "slack";
const needsURL = provider !== "slack" && provider !== "discord" &&
provider !== "hubspot" && provider !== "pipedrive";
const needsURL =
provider === "discord" || provider === "zapier" || provider === "make" || provider === "n8n";
const isReplyTrigger = eventType === REPLY_EVENT;
const destRequired = needsChannel || needsURL;
@@ -481,6 +528,75 @@ function AddAutomation({
);
}
// FieldMappingsBlock loads the connection's field maps and renders the editor.
function FieldMappingsBlock({ connectionId, object }: { connectionId: string; object: CapabilityObject }) {
const mappings = useFieldMappings(connectionId);
if (mappings.isPending) {
return <p className="text-[11.5px] text-slate-400 inline-flex items-center gap-1.5"><Loader2Icon className="w-3 h-3 animate-spin" /> Loading</p>;
}
return (
<FieldMapEditor
connectionId={connectionId}
object={object}
mappings={mappings.data?.mappings ?? []}
/>
);
}
// BookingLinkBlock lets the user set the public scheduling URL surfaced by the
// contextual "Book a call" buttons across the dashboard.
function BookingLinkBlock({ connection, onSaved }: { connection: IntegrationConnection; onSaved: () => void }) {
const update = useUpdateConnectionConfig();
const stored =
(connection.config_capabilities?.scheduling_url as string) ||
((connection.display_fields?.scheduling_url as string) ?? "");
const [url, setUrl] = React.useState(stored);
React.useEffect(() => setUrl(stored), [stored]);
const dirty = url.trim() !== stored.trim();
async function save() {
const v = url.trim();
if (v && !/^https?:\/\//i.test(v)) {
toast.error("Enter a full https:// booking link");
return;
}
await toast.promise(
update.mutateAsync({
connectionId: connection.id,
config_capabilities: { ...(connection.config_capabilities ?? {}), scheduling_url: v },
}),
{ loading: "Saving…", success: "Booking link saved", error: "Could not save" },
);
onSaved();
}
return (
<div className="space-y-1.5">
<p className="text-[11.5px] text-slate-500 leading-relaxed">
Paste your public scheduling link. A Book a call button appears on contacts and inbox
threads, prefilled with the contacts email.
</p>
<TextInput value={url} onChange={setUrl} placeholder="https://calendly.com/you/intro" className="font-mono" />
{dirty && (
<div className="flex justify-end">
<button
type="button"
onClick={save}
disabled={update.isPending}
className={cn(
"h-6 px-2.5 rounded text-[11.5px] font-medium text-white bg-sky-600 hover:bg-sky-700 inline-flex items-center gap-1.5 transition-colors",
update.isPending && "opacity-60",
)}
>
{update.isPending && <Loader2Icon className="w-3 h-3 animate-spin" />}
Save link
</button>
</div>
)}
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between gap-3">
@@ -490,24 +606,104 @@ function Row({ label, value, mono }: { label: string; value: string; mono?: bool
);
}
// actionForProvider maps a connection's provider to the action its automations
// perform. Mirrors defaultActionForProvider in the model module.
function actionForProvider(provider: string): string {
switch (provider) {
case "slack":
return "slack.notify";
case "discord":
return "discord.notify";
case "hubspot":
return "hubspot.upsert_contact";
case "pipedrive":
return "pipedrive.upsert_person";
default:
return "webhook.ping";
}
}
function msg(err: unknown): string | undefined {
const e = err as { response?: { data?: { message?: string; error?: string } }; message?: string };
return e.response?.data?.message ?? e.response?.data?.error ?? e.message;
}
// WebhookToolsBlock — "send a test event" for any notify/webhook provider, plus
// (for Zapier/Make/n8n) the HMAC signing secret used to verify our deliveries.
function WebhookToolsBlock({
connectionId,
provider,
hasAutomations,
}: {
connectionId: string;
provider: string;
hasAutomations: boolean;
}) {
const test = useTestConnection();
const reveal = useRevealWebhookSecret();
const [secret, setSecret] = React.useState<string | null>(null);
const runTest = () =>
test.mutate(connectionId, {
onSuccess: (r) => toast.success(`Sent ${r.sent} test event${r.sent === 1 ? "" : "s"}`),
onError: (e) => toast.error(msg(e) ?? "Test failed"),
});
const showSecret = () =>
reveal.mutate(connectionId, {
onSuccess: (r) => setSecret(r.signing_secret),
onError: (e) => toast.error(msg(e) ?? "Could not load secret"),
});
const copy = () => {
if (secret) void navigator.clipboard.writeText(secret).then(() => toast.success("Copied"));
};
return (
<div className="space-y-2.5">
<p className="text-[11.5px] text-slate-400 leading-relaxed">
{hasAutomations
? "Send a sample event to confirm your automation is wired correctly."
: "Add an automation above first, then send a test event to confirm it's wired."}
</p>
<button
type="button"
onClick={runTest}
disabled={!hasAutomations || test.isPending}
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 text-[12px] inline-flex items-center gap-1.5 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{test.isPending ? (
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
) : (
<SendIcon className="w-3.5 h-3.5" />
)}
Send test event
</button>
{SIGNING_PROVIDERS.includes(provider) && (
<div className="pt-1.5 space-y-1.5">
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Signing secret
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
Every delivery is signed with{" "}
<span className="font-mono">X-Warmbly-Signature: t=&lt;unix&gt;,v1=&lt;hmac&gt;</span> (HMAC-SHA256
of <span className="font-mono">{"{t}.{body}"}</span>). Use this secret to verify it.
</p>
{secret ? (
<div className="flex items-center gap-1.5">
<code className="flex-1 min-w-0 truncate rounded-md border border-slate-200 bg-slate-50 px-2 h-7 inline-flex items-center text-[11px] font-mono text-slate-700">
{secret}
</code>
<button
type="button"
onClick={copy}
title="Copy"
className="h-7 w-7 rounded-md border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 inline-flex items-center justify-center"
>
<CopyIcon className="w-3.5 h-3.5" />
</button>
</div>
) : (
<button
type="button"
onClick={showSecret}
disabled={reveal.isPending}
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 text-[12px] inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{reveal.isPending ? (
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
) : (
<EyeIcon className="w-3.5 h-3.5" />
)}
Reveal signing secret
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,226 @@
// FieldMapEditor — lets a user control exactly which Warmbly fields land in
// which provider fields for one CRM object. Standard fields (email/name/company/
// phone) map automatically; rows here ADD or OVERRIDE on top of that default,
// so the connection writes precisely what the user configured instead of a fixed
// shape. A full-replace save keeps it idempotent.
"use client";
import React from "react";
import { Loader2Icon, PlusIcon, Trash2Icon } from "lucide-react";
import toast from "react-hot-toast";
import { Label, TextInput } from "@/components/ui/field";
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
import { useReplaceFieldMappings } from "@/lib/api/hooks/app/integrations/useFieldMappings";
import type {
CapabilityObject,
IntegrationFieldMapping,
} from "@/lib/api/models/app/integrations/Integration";
import { cn } from "@/lib/utils";
const CUSTOM = "__custom__";
const TRANSFORMS: SelectOption[] = [
{ value: "none", label: "Copy value" },
{ value: "uppercase", label: "Uppercase" },
{ value: "lowercase", label: "Lowercase" },
{ value: "trim", label: "Trim spaces" },
{ value: "static", label: "Static value" },
];
interface Row {
warmbly_field: string;
external_field: string;
external_custom: boolean;
transform: string;
static_value: string;
}
export default function FieldMapEditor({
connectionId,
object,
mappings,
}: {
connectionId: string;
object: CapabilityObject;
mappings: IntegrationFieldMapping[];
}) {
const replace = useReplaceFieldMappings();
const initial = React.useMemo<Row[]>(
() =>
mappings
.filter((m) => m.object_name === object.name && !m.subscription_id && m.direction === "push")
.map((m) => ({
warmbly_field: m.warmbly_field,
external_field: m.external_field,
external_custom: !object.external_fields.some((f) => f.key === m.external_field),
transform: m.transform || "none",
static_value: m.static_value || "",
})),
[mappings, object],
);
const [rows, setRows] = React.useState<Row[]>(initial);
React.useEffect(() => setRows(initial), [initial]);
const dirty = React.useMemo(() => JSON.stringify(rows) !== JSON.stringify(initial), [rows, initial]);
const warmblyOptions: SelectOption[] = object.warmbly_fields.map((f) => ({ value: f.key, label: f.label }));
const externalOptions: SelectOption[] = [
...object.external_fields.map((f) => ({ value: f.key, label: f.label })),
{ value: CUSTOM, label: "Custom field…" },
];
function patch(i: number, p: Partial<Row>) {
setRows((r) => r.map((row, idx) => (idx === i ? { ...row, ...p } : row)));
}
function addRow() {
setRows((r) => [
...r,
{ warmbly_field: warmblyOptions[0]?.value ?? "", external_field: "", external_custom: false, transform: "none", static_value: "" },
]);
}
function removeRow(i: number) {
setRows((r) => r.filter((_, idx) => idx !== i));
}
async function save() {
const out: { warmbly_field: string; external_field: string; transform: string; static_value: string }[] = [];
for (const row of rows) {
const ext = row.external_field.trim();
if (!ext) continue; // skip incomplete rows silently
if (row.transform === "static") {
if (!row.static_value.trim()) {
toast.error(`The static mapping for "${ext}" needs a value`);
return;
}
} else if (!row.warmbly_field.trim()) {
toast.error(`The mapping for "${ext}" needs a Warmbly field`);
return;
}
out.push({
warmbly_field: row.transform === "static" ? "" : row.warmbly_field,
external_field: ext,
transform: row.transform,
static_value: row.transform === "static" ? row.static_value : "",
});
}
await toast.promise(replace.mutateAsync({ connectionId, object: object.name, mappings: out }), {
loading: "Saving field mapping…",
success: "Field mapping saved",
error: "Could not save mapping",
});
}
return (
<div className="space-y-2.5">
<p className="text-[11.5px] text-slate-500 leading-relaxed">
Email, name, company and phone map automatically. Add rows to send more Warmbly data
into {object.label.toLowerCase()} fields, or override a default.
</p>
{rows.length > 0 && (
<div className="space-y-2">
{rows.map((row, i) => (
<div key={i} className="rounded-md border border-slate-200 p-2 space-y-1.5">
<div className="flex items-center gap-1.5">
<div className="flex-1 min-w-0">
{row.transform === "static" ? (
<TextInput
value={row.static_value}
onChange={(v) => patch(i, { static_value: v })}
placeholder="Static value"
/>
) : (
<SelectMenu
value={row.warmbly_field}
onChange={(v) => patch(i, { warmbly_field: v })}
options={warmblyOptions}
className="w-full"
aria-label="Warmbly field"
/>
)}
</div>
<span className="text-slate-400 text-[11px] shrink-0"></span>
<div className="flex-1 min-w-0">
<SelectMenu
value={row.external_custom ? CUSTOM : row.external_field}
onChange={(v) =>
v === CUSTOM
? patch(i, { external_custom: true, external_field: "" })
: patch(i, { external_custom: false, external_field: v })
}
options={externalOptions}
className="w-full"
aria-label={`${object.label} field`}
/>
</div>
<button
type="button"
onClick={() => removeRow(i)}
aria-label="Remove mapping"
className="h-6 w-6 shrink-0 rounded 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 className="flex items-center gap-1.5">
{row.external_custom && (
<TextInput
value={row.external_field}
onChange={(v) => patch(i, { external_field: v })}
placeholder="Provider field API name"
className="flex-1 font-mono"
/>
)}
<SelectMenu
value={row.transform}
onChange={(v) => patch(i, { transform: v })}
options={TRANSFORMS}
className={row.external_custom ? "w-36" : "w-full"}
aria-label="Transform"
/>
</div>
</div>
))}
</div>
)}
<div className="flex items-center justify-between pt-0.5">
<button
type="button"
onClick={addRow}
className="h-6 px-2 rounded text-[11px] text-sky-700 hover:bg-sky-50 inline-flex items-center gap-1 transition-colors"
>
<PlusIcon className="w-3 h-3" />
Add field
</button>
{dirty && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setRows(initial)}
className="h-6 px-2.5 rounded text-[11.5px] text-slate-600 hover:text-slate-900"
>
Reset
</button>
<button
type="button"
onClick={save}
disabled={replace.isPending}
className={cn(
"h-6 px-2.5 rounded text-[11.5px] font-medium text-white bg-sky-600 hover:bg-sky-700 inline-flex items-center gap-1.5 transition-colors",
replace.isPending && "opacity-60",
)}
>
{replace.isPending && <Loader2Icon className="w-3 h-3 animate-spin" />}
Save mapping
</button>
</div>
)}
</div>
</div>
);
}
+19 -6
View File
@@ -9,6 +9,7 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import { CalendarCheckIcon, ExternalLinkIcon, RefreshCwIcon, SettingsIcon } from "lucide-react";
import toast from "react-hot-toast";
@@ -132,9 +133,10 @@ export default function IntegrationsPage() {
<section>
<SectionBar label="Your connections" count={connections.length} />
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-slate-200/60 border-b border-slate-200/60">
{connections.map((c) => (
{connections.map((c, i) => (
<ConnectionCard
key={c.id}
index={i}
connection={c}
entry={entryByProvider[c.provider]}
onManage={() => setManageTarget(c)}
@@ -152,9 +154,10 @@ export default function IntegrationsPage() {
<section key={category}>
<SectionBar label={CATEGORY_LABELS[category]} count={entries.length} />
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-slate-200/60 border-b border-slate-200/60">
{entries.map((entry) => (
{entries.map((entry, i) => (
<CatalogCard
key={entry.provider}
index={i}
entry={entry}
connection={firstConnByProvider[entry.provider]}
onClick={() => onCardClick(entry)}
@@ -239,17 +242,22 @@ function CatalogCard({
entry,
connection,
onClick,
index = 0,
}: {
entry: IntegrationCatalogEntry;
connection?: IntegrationConnection;
onClick: () => void;
index?: number;
}) {
const connected = !!connection;
const comingSoon = entry.auth_method === "oauth" && !entry.configured && !connected;
return (
<button
<motion.button
type="button"
onClick={onClick}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1], delay: Math.min(index, 12) * 0.03 }}
className="text-left bg-white p-5 flex flex-col min-h-[150px] hover:bg-slate-50/60 transition-colors group"
>
<div className="flex items-start justify-between gap-3">
@@ -305,7 +313,7 @@ function CatalogCard({
)}
</span>
</div>
</button>
</motion.button>
);
}
@@ -313,10 +321,12 @@ function ConnectionCard({
connection,
entry,
onManage,
index = 0,
}: {
connection: IntegrationConnection;
entry?: IntegrationCatalogEntry;
onManage: () => void;
index?: number;
}) {
const account =
connection.external_account_name ||
@@ -325,9 +335,12 @@ function ConnectionCard({
(connection.display_fields as Record<string, string>)?.channel ||
"";
return (
<button
<motion.button
type="button"
onClick={onManage}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1], delay: Math.min(index, 12) * 0.03 }}
className="text-left bg-white p-4 flex items-center gap-3 hover:bg-slate-50/60 transition-colors"
>
<ProviderGlyph provider={connection.provider} name={entry?.name ?? connection.label} />
@@ -336,7 +349,7 @@ function ConnectionCard({
<div className="text-[11px] text-slate-400 truncate">{account || (entry?.name ?? connection.provider)}</div>
</div>
<StatusPill status={connection.status} />
</button>
</motion.button>
);
}
+10
View File
@@ -19,6 +19,7 @@ import {
AlertOctagonIcon,
BellIcon,
BriefcaseIcon,
CableIcon,
CreditCardIcon,
GaugeIcon,
ShieldCheckIcon,
@@ -106,6 +107,15 @@ export default function SettingsLayout() {
)}
</NavLink>
))}
{/* Integrations is a top-level surface, not a settings sub-page;
cross-link out to it so people who look here still find it. */}
<NavLink
to="/app/integrations"
className="group shrink-0 md:w-[calc(100%-0.75rem)] md:mx-1.5 md:my-px flex items-center gap-2 px-3 md:px-2 h-7 rounded text-[12.5px] whitespace-nowrap text-left text-slate-600 hover:text-slate-900 hover:bg-slate-200/40 transition-colors"
>
<CableIcon className="w-[14px] h-[14px] shrink-0 text-slate-400 group-hover:text-slate-600" />
<span className="truncate">Integrations</span>
</NavLink>
</nav>
<div className="flex-1 min-w-0 overflow-y-auto">
@@ -17,7 +17,7 @@
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { CheckIcon, CopyIcon, Loader2Icon, XIcon } from "lucide-react";
import { CalendarPlusIcon, CheckIcon, CopyIcon, Loader2Icon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import useUpdateContact from "@/lib/api/hooks/app/contacts/useUpdateContact";
import useContact from "@/lib/api/hooks/app/contacts/useContact";
@@ -26,6 +26,8 @@ import type Contact from "@/lib/api/models/app/contacts/Contact";
import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import BookACallButton from "@/components/app/integrations/BookACallButton";
import NewMeetingDialog from "@/components/app/meetings/NewMeetingDialog";
import OverviewTab from "./contact-edit/OverviewTab";
import ActivityTab from "./contact-edit/ActivityTab";
import NotesTab from "./contact-edit/NotesTab";
@@ -298,6 +300,7 @@ function ContactHeader({
onClose: () => void;
}) {
const [copied, setCopied] = React.useState(false);
const [meetingOpen, setMeetingOpen] = React.useState(false);
function copy() {
navigator.clipboard.writeText(contact.email).then(() => {
setCopied(true);
@@ -324,7 +327,8 @@ function ContactHeader({
}
return (
<header className="px-4 pt-4 pb-3 border-b border-slate-200 flex items-start gap-3 shrink-0">
<>
<header className="px-4 pt-4 pb-3 border-b border-slate-200 flex items-start gap-3 shrink-0">
<div className="size-9 rounded-full bg-slate-100 flex items-center justify-center text-[12px] font-semibold text-slate-700 shrink-0">
{initials}
</div>
@@ -358,6 +362,21 @@ function ContactHeader({
</button>
</div>
</div>
<button
type="button"
onClick={() => setMeetingOpen(true)}
className="shrink-0 h-7 px-2 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center gap-1.5 transition-colors text-[12px]"
title="Schedule a call with this contact"
>
<CalendarPlusIcon className="w-3.5 h-3.5" />
Schedule call
</button>
<BookACallButton
email={contact.email}
name={displayName}
contactId={contact.id}
className="shrink-0"
/>
<button
type="button"
onClick={onClose}
@@ -366,7 +385,18 @@ function ContactHeader({
>
<XIcon className="w-3.5 h-3.5" />
</button>
</header>
</header>
<NewMeetingDialog
open={meetingOpen}
onClose={() => setMeetingOpen(false)}
prefill={{
title: displayName ? `Call with ${displayName}` : "Call",
name: displayName,
email: contact.email,
contactId: contact.id,
}}
/>
</>
);
}
@@ -15,6 +15,7 @@ import {
AlertTriangleIcon,
BanIcon,
Building2Icon,
CableIcon,
CheckIcon,
ClockIcon,
CornerUpLeftIcon,
@@ -36,6 +37,13 @@ import { useConfirm } from "@/hooks/context/confirm";
import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts";
import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts";
import useDeleteContacts from "@/lib/api/hooks/app/contacts/useDeleteContacts";
import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections";
import { usePushContacts } from "@/lib/api/hooks/app/integrations/usePushContacts";
import {
PROVIDER_LABELS,
PUSHABLE_PROVIDERS,
type IntegrationConnection,
} from "@/lib/api/models/app/integrations/Integration";
import toast from "react-hot-toast";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
@@ -108,6 +116,41 @@ export default function ContactsTable({
const contactsData = useSearchContacts({ options: searchProps });
const contactsBulkDelete = useDeleteContacts();
// Connected CRM targets the "Push to CRM" bulk action can reach. Driven by
// the org's live connections (backend enforces the push permission).
const connectionsQuery = useIntegrationConnections();
const pushContacts = usePushContacts();
const pushTargets = React.useMemo<IntegrationConnection[]>(
() =>
(connectionsQuery.data?.connections ?? []).filter(
(c) =>
PUSHABLE_PROVIDERS.includes(c.provider) &&
(c.status === "connected" || c.status === "degraded"),
),
[connectionsQuery.data],
);
async function pushToCRM(connectionId: string, providerLabel: string) {
if (selected.length === 0 || pushContacts.isPending) return;
const ids = selected;
const t = toast.loading(`Pushing ${ids.length} to ${providerLabel}`);
try {
const res = await pushContacts.mutateAsync({ connectionId, contact_ids: ids });
if (res.pushed === 0) {
toast.error(
`Couldn't push to ${providerLabel}${res.failed ? ` (${res.failed} failed)` : ""}`,
{ id: t },
);
} else if (res.failed > 0) {
toast.success(`Pushed ${res.pushed} to ${providerLabel}, ${res.failed} failed`, { id: t });
} else {
toast.success(`Pushed ${res.pushed} to ${providerLabel}`, { id: t });
}
} catch (err) {
toast.error(buildError(err as AppError), { id: t });
}
}
const contacts = contactsData.contacts;
const total = contactsData.data?.pages[0]?.pagination.total ?? 0;
const filtered = React.useMemo(() => {
@@ -266,6 +309,9 @@ export default function ContactsTable({
<SelectionBar
count={selected.length}
deleting={del}
pushTargets={pushTargets}
pushing={pushContacts.isPending}
onPush={pushToCRM}
onBulkEdit={() => setBulkEdit(true)}
onDelete={() =>
confirm?.show(
@@ -445,6 +491,9 @@ export default function ContactsTable({
<SelectionBar
count={selected.length}
deleting={del}
pushTargets={pushTargets}
pushing={pushContacts.isPending}
onPush={pushToCRM}
onBulkEdit={() => setBulkEdit(true)}
onDelete={() =>
confirm?.show(
@@ -968,12 +1017,18 @@ function StripChip({
function SelectionBar({
count,
deleting,
pushTargets,
pushing,
onPush,
onBulkEdit,
onDelete,
onClear,
}: {
count: number;
deleting: boolean;
pushTargets: IntegrationConnection[];
pushing: boolean;
onPush: (connectionId: string, providerLabel: string) => void;
onBulkEdit: () => void;
onDelete: () => void;
onClear: () => void;
@@ -985,6 +1040,37 @@ function SelectionBar({
<CheckIcon className="w-3 h-3" />
<span>{count} selected</span>
</div>
{pushTargets.length > 0 && (
<PopoverMenu side="top" align="center">
<PopoverMenuTrigger asChild>
<button
type="button"
disabled={pushing}
className="h-7 px-2.5 rounded text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{pushing ? (
<Loader2Icon className="w-3 h-3 animate-spin" />
) : (
<CableIcon className="w-3 h-3" />
)}
Push to CRM
</button>
</PopoverMenuTrigger>
<PopoverMenuContent>
<PopoverMenuLabel>Push {count} to</PopoverMenuLabel>
{pushTargets.map((t) => {
const label = PROVIDER_LABELS[t.provider];
const custom = t.label && t.label.toLowerCase() !== t.provider ? ` · ${t.label}` : "";
return (
<PopoverMenuItem key={t.id} onSelect={() => onPush(t.id, label)}>
{label}
{custom}
</PopoverMenuItem>
);
})}
</PopoverMenuContent>
</PopoverMenu>
)}
<button
type="button"
onClick={onBulkEdit}
@@ -19,6 +19,9 @@ import {
AlertOctagonIcon,
BanIcon,
CalendarIcon,
CalendarClockIcon,
CalendarPlusIcon,
CalendarXIcon,
Loader2Icon,
MailIcon,
MailOpenIcon,
@@ -36,7 +39,7 @@ import type { ContactTimelineEventType } from "@/lib/api/models/app/contacts/Con
import useClickOutside from "@/hooks/useClickOutside";
import { fmtAbsolute, fmtRelative } from "./format";
type FilterId = "all" | "emails" | "replies" | "deliv" | "notes";
type FilterId = "all" | "emails" | "replies" | "deliv" | "notes" | "meetings";
const FILTERS: { id: FilterId; label: string }[] = [
{ id: "all", label: "All" },
@@ -44,6 +47,7 @@ const FILTERS: { id: FilterId; label: string }[] = [
{ id: "replies", label: "Replies" },
{ id: "deliv", label: "Deliv." },
{ id: "notes", label: "Notes" },
{ id: "meetings", label: "Meetings" },
];
const EMAIL_TYPES: ContactTimelineEventType[] = [
@@ -53,6 +57,12 @@ const EMAIL_TYPES: ContactTimelineEventType[] = [
"email_bounced",
];
const MEETING_TYPES: ContactTimelineEventType[] = [
"meeting_booked",
"meeting_rescheduled",
"meeting_canceled",
];
export default function ActivityTab({ contactId }: { contactId: string }) {
const {
events,
@@ -215,6 +225,9 @@ function applyFilters(
case "notes":
if (e.type !== "note") return false;
break;
case "meetings":
if (!MEETING_TYPES.includes(e.type)) return false;
break;
case "all":
break;
}
@@ -521,6 +534,46 @@ function EventMeta({
event: ContactTimelineEvent;
highlight: string;
}) {
// Meetings get a dedicated meta line: when the call is set for, which
// calendar it came from, and a one-click join link (when not canceled).
if (event.type.startsWith("meeting_")) {
const when = event.scheduled_for
? new Date(event.scheduled_for).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
: null;
const providerLabel = event.source === "cal_com" ? "Cal.com" : event.source === "calendly" ? "Calendly" : event.source;
return (
<div className="text-[11px] text-slate-500 mt-0.5 flex gap-1.5 flex-wrap items-center">
{when && <span>for {when}</span>}
{when && providerLabel && <span className="text-slate-300">·</span>}
{providerLabel && <span>via {providerLabel}</span>}
{event.reason && (
<>
<span className="text-slate-300">·</span>
<span className="text-slate-700">{event.reason}</span>
</>
)}
{event.type !== "meeting_canceled" && event.join_url && (
<>
<span className="text-slate-300">·</span>
<a
href={event.join_url}
target="_blank"
rel="noopener noreferrer"
className="text-sky-600 hover:text-sky-700 font-medium"
>
Join
</a>
</>
)}
</div>
);
}
const parts: React.ReactNode[] = [];
if (event.email_account_email) {
@@ -618,6 +671,12 @@ function visualFor(type: ContactTimelineEventType): {
return { Icon: BanIcon, label: "Suppressed" };
case "note":
return { Icon: StickyNoteIcon, label: "Note added" };
case "meeting_booked":
return { Icon: CalendarPlusIcon, label: "Meeting booked" };
case "meeting_rescheduled":
return { Icon: CalendarClockIcon, label: "Meeting rescheduled" };
case "meeting_canceled":
return { Icon: CalendarXIcon, label: "Meeting canceled" };
default:
return { Icon: MailIcon, label: type };
}
@@ -0,0 +1,89 @@
// BookACallButton — a contextual "Book a call" affordance. It only renders when
// the org has a connected scheduling integration (Calendly / Cal.com) with a
// saved booking link, and opens that link prefilled with the contact's email +
// name. With several scheduling links it offers a picker. This is the contextual
// counterpart to the Integrations settings: configure the link once, book from
// anywhere a contact is in view (Unibox threads, contact detail).
"use client";
import { CalendarPlusIcon } from "lucide-react";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuLabel,
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections";
import {
bookingURL,
prefilledBookingURL,
PROVIDER_LABELS,
} from "@/lib/api/models/app/integrations/Integration";
import { cn } from "@/lib/utils";
const TRIGGER_CLASS =
"h-7 px-2 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center gap-1.5 transition-colors text-[12px]";
export default function BookACallButton({
email,
name,
contactId,
className,
}: {
email?: string;
name?: string;
/** When set, embedded in the link so the booking webhook attributes the
* meeting to this exact contact (even if they book with another email). */
contactId?: string;
className?: string;
}) {
const { data } = useIntegrationConnections();
const targets = (data?.connections ?? [])
.map((conn) => ({ conn, url: bookingURL(conn) }))
.filter((t): t is { conn: (typeof t)["conn"]; url: string } => !!t.url);
if (targets.length === 0) return null;
const open = (url: string) =>
window.open(prefilledBookingURL(url, email, name, contactId), "_blank", "noopener,noreferrer");
// One link → open directly; several → let the user pick which calendar.
if (targets.length === 1) {
return (
<button
type="button"
onClick={() => open(targets[0].url)}
className={cn(TRIGGER_CLASS, className)}
title="Open your scheduling page (prefilled) so the prospect can pick a time"
>
<CalendarPlusIcon className="w-3.5 h-3.5" />
Booking link
</button>
);
}
return (
<PopoverMenu align="end" side="bottom">
<PopoverMenuTrigger asChild>
<button type="button" className={cn(TRIGGER_CLASS, className)}>
<CalendarPlusIcon className="w-3.5 h-3.5" />
Booking link
</button>
</PopoverMenuTrigger>
<PopoverMenuContent>
<PopoverMenuLabel>Send a scheduling link via</PopoverMenuLabel>
{targets.map((t) => (
<PopoverMenuItem key={t.conn.id} onSelect={() => open(t.url)}>
{PROVIDER_LABELS[t.conn.provider]}
{t.conn.label && t.conn.label.toLowerCase() !== t.conn.provider
? ` · ${t.conn.label}`
: ""}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
);
}
@@ -0,0 +1,184 @@
// NewMeetingDialog — create a meeting/call natively, with no redirect to an
// external scheduler. Reused anywhere a contact is in view (Meetings page,
// inbox contact rail, contact detail). When opened with a prefill it locks the
// meeting to that contact (contact_id) so attribution is exact.
import React from "react";
import { Loader2Icon, XIcon } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import toast from "react-hot-toast";
import { Label, NumberInput, TextInput } from "@/components/ui/field";
import useCreateMeeting from "@/lib/api/hooks/app/meetings/useCreateMeeting";
export interface MeetingPrefill {
title?: string;
name?: string;
email?: string;
contactId?: string;
}
// toLocalInputValue renders a Date as the value a <input type="datetime-local">
// expects (local time, no timezone suffix).
function toLocalInputValue(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function NewMeetingDialog({
open,
onClose,
prefill,
}: {
open: boolean;
onClose: () => void;
prefill?: MeetingPrefill;
}) {
const create = useCreateMeeting();
const [title, setTitle] = React.useState("");
const [name, setName] = React.useState("");
const [email, setEmail] = React.useState("");
const [when, setWhen] = React.useState("");
const [duration, setDuration] = React.useState(30);
const [location, setLocation] = React.useState("");
const [joinURL, setJoinURL] = React.useState("");
// Seed fields (and default the time to the next hour) each time it opens.
React.useEffect(() => {
if (!open) return;
const d = new Date(Date.now() + 60 * 60_000);
d.setMinutes(0, 0, 0);
setWhen(toLocalInputValue(d));
setTitle(prefill?.title ?? "");
setName(prefill?.name ?? "");
setEmail(prefill?.email ?? "");
setDuration(30);
setLocation("");
setJoinURL("");
}, [open, prefill?.title, prefill?.name, prefill?.email]);
const lockedContact = !!prefill?.contactId;
const canSubmit = !!when && (!!name.trim() || !!email.trim()) && !create.isPending;
const submit = async () => {
if (!canSubmit) return;
const dt = new Date(when);
if (isNaN(dt.getTime())) {
toast.error("Pick a valid date and time");
return;
}
try {
await create.mutateAsync({
title: title.trim() || "Call",
invitee_name: name.trim(),
invitee_email: email.trim(),
scheduled_for: dt.toISOString(),
duration_minutes: duration > 0 ? duration : undefined,
location: location.trim() || undefined,
join_url: joinURL.trim() || undefined,
contact_id: prefill?.contactId,
});
toast.success("Meeting created");
onClose();
} catch {
toast.error("Could not create meeting");
}
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onMouseDown={onClose}
className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
>
<motion.div
initial={{ opacity: 0, scale: 0.97, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.15 }}
onMouseDown={(e) => e.stopPropagation()}
className="w-full max-w-md bg-white rounded-xl border border-slate-200 shadow-xl overflow-hidden"
>
<div className="h-11 px-4 flex items-center border-b border-slate-200">
<span className="text-[13px] font-medium text-slate-900">New meeting</span>
<button
type="button"
onClick={onClose}
className="ml-auto h-7 w-7 rounded-md inline-flex items-center justify-center text-slate-400 hover:text-slate-700 hover:bg-slate-100"
>
<XIcon className="w-4 h-4" />
</button>
</div>
<div className="p-4 space-y-3">
<div>
<Label>Title</Label>
<TextInput value={title} onChange={setTitle} placeholder="Discovery call" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Contact name</Label>
<TextInput value={name} onChange={setName} placeholder="Jane Doe" />
</div>
<div>
<Label>Contact email</Label>
<TextInput value={email} onChange={setEmail} placeholder="jane@acme.com" disabled={lockedContact} />
</div>
</div>
{!lockedContact && (
<p className="text-[11px] text-slate-400 -mt-1.5">
We link the meeting to a contact by email when one matches.
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label>When</Label>
<input
type="datetime-local"
value={when}
onChange={(e) => setWhen(e.target.value)}
className="w-full h-7 px-2 rounded-md border border-slate-200 text-[12.5px] text-slate-900 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100 tabular-nums"
/>
</div>
<div>
<Label>Duration</Label>
<NumberInput value={duration} onChange={setDuration} min={5} max={480} step={5} suffix="min" />
</div>
</div>
<div>
<Label>Location (optional)</Label>
<TextInput value={location} onChange={setLocation} placeholder="Office, phone, etc." />
</div>
<div>
<Label>Meeting link (optional)</Label>
<TextInput value={joinURL} onChange={setJoinURL} placeholder="https://meet.google.com/…" />
</div>
</div>
<div className="h-12 px-4 flex items-center justify-end gap-2 border-t border-slate-200 bg-slate-50/60">
<button
type="button"
onClick={onClose}
className="h-7 px-3 rounded-md text-[12px] text-slate-600 hover:text-slate-900 hover:bg-slate-100"
>
Cancel
</button>
<button
type="button"
onClick={submit}
disabled={!canSubmit}
className="h-7 px-3 rounded-md text-[12px] font-medium bg-sky-600 hover:bg-sky-700 text-white inline-flex items-center gap-1.5 disabled:opacity-60 disabled:cursor-not-allowed"
>
{create.isPending && <Loader2Icon className="w-3.5 h-3.5 animate-spin" />}
Create meeting
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -11,6 +11,7 @@ import React from "react";
import toast from "react-hot-toast";
import {
BanIcon,
CalendarPlusIcon,
CheckIcon,
ChevronDownIcon,
CircleDollarSignIcon,
@@ -27,6 +28,8 @@ import {
} from "lucide-react";
import { Link } from "react-router-dom";
import { TextInput } from "@/components/ui/field";
import NewMeetingDialog from "@/components/app/meetings/NewMeetingDialog";
import BookACallButton from "@/components/app/integrations/BookACallButton";
import {
PopoverMenu,
PopoverMenuContent,
@@ -84,6 +87,7 @@ export default function ContactContextPanel({
const lookup = useContactByEmail(email);
const contact = lookup.data ?? null;
const contactId = contact?.id;
const [meetingOpen, setMeetingOpen] = React.useState(false);
const detailQ = useContact(contactId ?? "", !!contactId);
const detail = detailQ.data;
@@ -164,6 +168,20 @@ export default function ContactContextPanel({
<ExternalLinkIcon className="w-2.5 h-2.5" />
</Link>
</div>
{/* Meeting actions: schedule a call right here (native,
no calendar needed), plus a self-serve booking link
when a Calendly / Cal.com calendar is connected. */}
<div className="mt-2.5 flex items-center gap-1.5">
<button
type="button"
onClick={() => setMeetingOpen(true)}
className="h-7 px-2 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[11.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<CalendarPlusIcon className="w-3 h-3" />
Schedule call
</button>
<BookACallButton email={contact.email} name={name} contactId={contact.id} />
</div>
{supp && (
<div className="mt-2 rounded-md border border-red-200 bg-red-50/60 px-2 py-1.5 flex items-start gap-1.5">
<MailWarningIcon className="w-3 h-3 text-red-600 mt-px shrink-0" />
@@ -232,6 +250,18 @@ export default function ContactContextPanel({
</div>
)}
</div>
{contact && (
<NewMeetingDialog
open={meetingOpen}
onClose={() => setMeetingOpen(false)}
prefill={{
title: name ? `Call with ${name}` : "Call",
name,
email: contact.email,
contactId: contact.id,
}}
/>
)}
</aside>
);
}
@@ -16,6 +16,7 @@
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
CalendarPlusIcon,
CheckIcon,
ChevronDownIcon,
ClockIcon,
@@ -34,6 +35,8 @@ import toast from "react-hot-toast";
import sendReply from "@/lib/api/client/app/unibox/sendReply";
import useTemplates from "@/lib/api/hooks/app/templates/useTemplates";
import useUniboxOverview from "@/lib/api/hooks/app/unibox/useUniboxOverview";
import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections";
import { bookingURL, prefilledBookingURL } from "@/lib/api/models/app/integrations/Integration";
import { useAppStore } from "@/stores";
import type Template from "@/lib/api/models/app/templates/Template";
import WriteWithAI from "@/components/app/campaigns/sequences/WriteWithAI";
@@ -121,6 +124,39 @@ function looksLikeEmail(s: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
}
// InsertBookingLink drops the org's scheduling link (prefilled with the
// recipient's email) into the reply body. Renders only when a Calendly /
// Cal.com link is configured, so the action never shows as a dead button.
function InsertBookingLink({
email,
onInsert,
}: {
email?: string;
onInsert: (text: string) => void;
}) {
const { data } = useIntegrationConnections();
const url = (data?.connections ?? [])
.map((c) => bookingURL(c))
.find((u): u is string => !!u);
if (!url) return null;
const cleanEmail = email ? bareEmail(email) : undefined;
return (
<button
type="button"
title="Insert your booking link, prefilled for this contact"
onClick={() => {
onInsert(prefilledBookingURL(url, cleanEmail));
toast.success("Booking link added");
}}
className="h-7 px-2 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 text-[12px] inline-flex items-center gap-1 transition-colors"
>
<CalendarPlusIcon className="w-3 h-3" />
Booking link
</button>
);
}
function nameFromAddr(s: string): string {
const m = s.match(/^"?([^"<]+)"?\s*<.+>$/);
if (m) return m[1].trim();
@@ -676,6 +712,13 @@ export function ReplyComposer({ threadId, replyTo, mode, onClose }: ReplyCompose
</PopoverMenuContent>
</PopoverMenu>
<InsertBookingLink
email={to[0]}
onInsert={(text) =>
setBody((b) => (b.trim() ? `${b.trimEnd()}\n\n${text}` : text).slice(0, MAX_BODY_LEN))
}
/>
{body && (
<button
type="button"
@@ -30,6 +30,7 @@ import { MessageBubble } from "./MessageBubble";
import { ReplyComposer, type ReplyMode } from "./ReplyComposer";
import { ThreadLabelMenu } from "./ThreadLabelMenu";
import ContactContextPanel from "./ContactContextPanel";
import BookACallButton from "@/components/app/integrations/BookACallButton";
import { CategoryChip } from "@/components/app/contacts/CategoryPicker";
import { SectionBar } from "@/components/layout/Page";
import useThread from "@/lib/api/hooks/app/unibox/useThread";
@@ -355,6 +356,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
>
<UserIcon className="w-3.5 h-3.5" />
</button>
<BookACallButton email={contactEmail} className="hidden sm:inline-flex" />
<ThreadLabelMenu
threadId={threadId}
open={labelMenuOpen}
+57 -2
View File
@@ -10,6 +10,7 @@ import { Link, useLocation } from "react-router-dom";
import {
BarChart3Icon,
CableIcon,
CalendarClockIcon,
CheckSquareIcon,
CircleDollarSignIcon,
FileTextIcon,
@@ -31,6 +32,7 @@ import useFeatureAccess from "@/hooks/useFeatureAccess";
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
import useEmails from "@/lib/api/hooks/app/emails/useEmails";
import useTasksSummary from "@/lib/api/hooks/app/crm/tasks/useTasksSummary";
import useMeetingsSummary from "@/lib/api/hooks/app/meetings/useMeetingsSummary";
import useDealsSummary from "@/lib/api/hooks/app/crm/deals/useDealsSummary";
import { EMPTY_TASK_SEARCH } from "@/lib/api/models/app/crm/SearchTasks";
import { EMPTY_DEAL_SEARCH } from "@/lib/api/models/app/crm/SearchDeals";
@@ -40,6 +42,7 @@ import usePipelines from "@/lib/api/hooks/app/crm/pipelines/usePipelines";
import useTemplates from "@/lib/api/hooks/app/templates/useTemplates";
import useUsageOverview from "@/lib/api/hooks/app/analytics/useUsageOverview";
import useAPIKeys from "@/lib/api/hooks/app/api-keys/useAPIKeys";
import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections";
import AnimatedNumber from "@/components/ui/AnimatedNumber";
import { UserNav } from "./UserNav";
import { Logo } from "@/components/svg";
@@ -76,9 +79,11 @@ interface NavItem {
| "contacts"
| "deals"
| "pipelines"
| "meetings"
| "templates"
| "analytics"
| "apikeys";
| "apikeys"
| "integrations";
}
// Plan badge shown on locked sidebar rows. Plan names + colors come
@@ -123,13 +128,14 @@ const sections: NavSection[] = [
{ title: "Pipelines", url: "/app/crm/pipelines", icon: GitBranchIcon, indicator: "pipelines" },
{ title: "Deals", url: "/app/crm/deals", icon: CircleDollarSignIcon, indicator: "deals" },
{ title: "Tasks", url: "/app/crm/tasks", icon: CheckSquareIcon, indicator: "tasks" },
{ title: "Meetings", url: "/app/crm/meetings", icon: CalendarClockIcon, indicator: "meetings" },
],
},
{
label: "Resources",
items: [
{ title: "Templates", url: "/app/templates", icon: FileTextIcon, indicator: "templates" },
{ title: "Integrations", url: "/app/integrations", icon: CableIcon },
{ title: "Integrations", url: "/app/integrations", icon: CableIcon, indicator: "integrations" },
{ title: "API Keys", url: "/app/api-keys", icon: KeyIcon, indicator: "apikeys" },
{ title: "Audit log", url: "/app/audit", icon: ListChecksIcon, rolesAllowed: "manage" },
],
@@ -188,12 +194,14 @@ function NavRow({ item }: { item: NavItem }) {
{item.indicator === "campaigns" && !locked && <CampaignActivity />}
{item.indicator === "accounts" && !locked && <MailboxActivity />}
{item.indicator === "tasks" && !locked && <TasksActivity />}
{item.indicator === "meetings" && !locked && <MeetingsActivity />}
{item.indicator === "contacts" && !locked && <ContactsActivity />}
{item.indicator === "deals" && !locked && <DealsActivity />}
{item.indicator === "pipelines" && !locked && <PipelinesActivity />}
{item.indicator === "templates" && !locked && <TemplatesActivity />}
{item.indicator === "analytics" && !locked && <AnalyticsActivity />}
{item.indicator === "apikeys" && !locked && <ApiKeysActivity />}
{item.indicator === "integrations" && !locked && <IntegrationsActivity />}
{planBadge ? (
<span
className={cn(
@@ -375,6 +383,28 @@ function TasksActivity() {
);
}
// MeetingsActivity — upcoming booked calls, with a live sky pulse on the ones
// happening today (a meeting today is the "act now" subset, like overdue tasks).
function MeetingsActivity() {
const { data } = useMeetingsSummary();
const upcoming = data?.upcoming ?? 0;
const today = data?.today ?? 0;
return (
<TabDualStat
total={upcoming}
active={today}
activeClass="text-sky-600"
activeGlyph={
<span className="relative inline-flex shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-sky-500" />
<span className="absolute inset-0 rounded-full bg-sky-500/40 animate-ping" />
</span>
}
title={`${upcoming} upcoming meeting${upcoming === 1 ? "" : "s"}${today > 0 ? `, ${today} today` : ""}`}
/>
);
}
// Contacts row: total contacts. Reads pagination.total from a small search — the
// limit MUST be >= the backend LimitMin (10) or validate.Limit rejects it (400)
// and the whole count comes back as 0.
@@ -438,6 +468,31 @@ function ApiKeysActivity() {
);
}
// Integrations row: total connected integrations + a coloured "needs attention"
// sub-count (degraded / reauth-required) so a broken connection is visible from
// the sidebar. Reads the shared connections cache the realtime layer invalidates.
function IntegrationsActivity() {
const { data } = useIntegrationConnections();
const conns = data?.connections ?? [];
const attention = conns.filter(
(c) => c.status === "degraded" || c.status === "reauth_required" || c.health === "down",
).length;
return (
<TabDualStat
total={conns.length}
active={attention}
activeClass="text-amber-600"
activeGlyph={
<span className="relative inline-flex shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
<span className="absolute inset-0 rounded-full bg-amber-500/40 animate-ping" />
</span>
}
title={`${conns.length} connected${attention > 0 ? `, ${attention} need attention` : ""}`}
/>
);
}
function Section({ section, first = false }: { section: NavSection; first?: boolean }) {
return (
<div className={first ? "" : "mt-4 pt-4 border-t border-slate-200/50"}>
+32 -9
View File
@@ -19,8 +19,15 @@
// </Page>
import React from "react";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
// Internal app paths navigate client-side (React Router Link, no full reload);
// external URLs (http..., mailto:, //) stay as a plain anchor.
function isInternalHref(href: string): boolean {
return href.startsWith("/") && !href.startsWith("//");
}
/**
* Page — outer frame. Fills its parent (the white content panel) without
* a max-width ceiling or padding. Sub-sections paint their own structure.
@@ -100,14 +107,20 @@ export function TopbarAction({
? "bg-sky-600 hover:bg-sky-700 text-white"
: "border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 bg-white";
if (href) {
const actionCls = cn(
"h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium transition-colors",
cls,
);
if (isInternalHref(href)) {
return (
<Link to={href} className={actionCls}>
{icon}
{children}
</Link>
);
}
return (
<a
href={href}
className={cn(
"h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium transition-colors",
cls,
)}
>
<a href={href} className={actionCls}>
{icon}
{children}
</a>
@@ -197,7 +210,11 @@ export function Stat({
(href || onClick) && "hover:bg-slate-50 cursor-pointer",
);
if (href) {
return (
return isInternalHref(href) ? (
<Link to={href} className={cls}>
{inner}
</Link>
) : (
<a href={href} className={cls}>
{inner}
</a>
@@ -286,7 +303,13 @@ export function Row({
(href || onClick) && "hover:bg-slate-50/80 cursor-pointer",
className,
);
if (href) return <a href={href} className={cls}>{children}</a>;
if (href) {
return isInternalHref(href) ? (
<Link to={href} className={cls}>{children}</Link>
) : (
<a href={href} className={cls}>{children}</a>
);
}
if (onClick) return <button onClick={onClick} className={cn(cls, "w-full text-left")}>{children}</button>;
return <div className={cls}>{children}</div>;
}
+14 -1
View File
@@ -162,7 +162,20 @@ export function useRealtimeEvents() {
return
}
if (includes('INTEGRATION', 'CONNECTION', 'BOOKING', 'MEETING')) {
// A meeting was booked / rescheduled / canceled (Calendly / Cal.com):
// refresh the Meetings page list + summary, the integrations bookings
// list, and the originating contact's timeline so the call appears live.
if (includes('MEETING', 'BOOKING')) {
invalidate([
['meetings'],
['meetings', 'summary'],
['integrations', 'bookings'],
])
if (contactId) invalidate([['contacts', contactId, 'timeline']])
return
}
if (includes('INTEGRATION', 'CONNECTION')) {
invalidate([
['integrations', 'connections'],
['integrations', 'catalog'],
@@ -0,0 +1,15 @@
import Request from "../../Request";
export interface WebhookSecretInfo {
signing_secret: string;
signature_header: string;
scheme: string;
}
export default async function getWebhookSecret(connectionId: string): Promise<WebhookSecretInfo> {
return await Request<WebhookSecretInfo>({
method: "GET",
url: `/integrations/connections/${connectionId}/webhook-secret`,
authorization: true,
});
}
@@ -0,0 +1,12 @@
import type { IntegrationFieldMapping } from "@/lib/api/models/app/integrations/Integration";
import Request from "../../Request";
export default async function listFieldMappings(
connectionId: string,
): Promise<{ mappings: IntegrationFieldMapping[] }> {
return await Request<{ mappings: IntegrationFieldMapping[] }>({
method: "GET",
url: `/integrations/connections/${connectionId}/field-mappings`,
authorization: true,
});
}
@@ -0,0 +1,28 @@
import type { IntegrationFieldMapping } from "@/lib/api/models/app/integrations/Integration";
import Request from "../../Request";
export interface FieldMappingInput {
warmbly_field: string;
external_field: string;
transform?: string;
static_value?: string;
}
export interface ReplaceFieldMappingsInput {
connectionId: string;
object: string;
mappings: FieldMappingInput[];
}
// Full-replace of a connection's default field map for one object. Idempotent.
export default async function replaceFieldMappings(
input: ReplaceFieldMappingsInput,
): Promise<{ mappings: IntegrationFieldMapping[] }> {
const { connectionId, ...body } = input;
return await Request<{ mappings: IntegrationFieldMapping[] }>({
method: "PUT",
url: `/integrations/connections/${connectionId}/field-mappings`,
data: body,
authorization: true,
});
}
@@ -0,0 +1,9 @@
import Request from "../../Request";
export default async function testConnection(connectionId: string): Promise<{ sent: number }> {
return await Request<{ sent: number }>({
method: "POST",
url: `/integrations/connections/${connectionId}/test`,
authorization: true,
});
}
@@ -0,0 +1,23 @@
import type { IntegrationConnection, SyncDirection } from "@/lib/api/models/app/integrations/Integration";
import Request from "../../Request";
export interface UpdateConnectionConfigInput {
connectionId: string;
config_capabilities?: Record<string, unknown>;
sync_direction?: SyncDirection;
}
// Saves a connection's onboarding/capability snapshot + sync direction (the
// "what is this integration for" settings, incl. a meeting provider's
// scheduling_url).
export default async function updateConnectionConfig(
input: UpdateConnectionConfigInput,
): Promise<{ connection: IntegrationConnection }> {
const { connectionId, ...body } = input;
return await Request<{ connection: IntegrationConnection }>({
method: "PATCH",
url: `/integrations/connections/${connectionId}/config`,
data: body,
authorization: true,
});
}
@@ -0,0 +1,11 @@
import type { CreateMeetingInput, MeetingBooking } from "@/lib/api/models/app/integrations/Integration";
import Request from "../../Request";
export default async function createMeeting(input: CreateMeetingInput): Promise<{ meeting: MeetingBooking }> {
return await Request<{ meeting: MeetingBooking }>({
method: "POST",
url: "/meetings",
data: input,
authorization: true,
});
}
@@ -0,0 +1,9 @@
import Request from "../../Request";
export default async function deleteMeeting(id: string): Promise<{ deleted: boolean }> {
return await Request<{ deleted: boolean }>({
method: "DELETE",
url: `/meetings/${id}`,
authorization: true,
});
}
@@ -0,0 +1,10 @@
import type { MeetingsSummary } from "@/lib/api/models/app/integrations/Integration";
import Request from "../../Request";
export default async function meetingsSummary(): Promise<MeetingsSummary> {
return await Request<MeetingsSummary>({
method: "GET",
url: "/meetings/summary",
authorization: true,
});
}
@@ -0,0 +1,24 @@
import type { MeetingsPage, MeetingsSearch } from "@/lib/api/models/app/integrations/Integration";
import Request from "../../Request";
// Meetings page list. Offset-paginated (the backend uses offset so nullable
// scheduled_for sorts don't drop rows), so the page param is the next offset.
export default async function searchMeetings(
filters: MeetingsSearch,
offset = 0,
limit = 50,
): Promise<MeetingsPage> {
const qs = new URLSearchParams();
if (filters.timeframe) qs.set("timeframe", filters.timeframe);
if (filters.status) qs.set("status", filters.status);
if (filters.q) qs.set("q", filters.q);
if (offset) qs.set("offset", String(offset));
if (limit) qs.set("limit", String(limit));
const suffix = qs.toString() ? `?${qs.toString()}` : "";
return await Request<MeetingsPage>({
method: "GET",
url: `/meetings${suffix}`,
authorization: true,
});
}
@@ -0,0 +1,13 @@
import { useMutation } from "@tanstack/react-query";
import getWebhookSecret from "@/lib/api/client/app/integrations/getWebhookSecret";
import testConnection from "@/lib/api/client/app/integrations/testConnection";
// Reveal (and lazily generate) the connection's outbound-webhook signing secret.
export function useRevealWebhookSecret() {
return useMutation({ mutationFn: getWebhookSecret });
}
// Fire a synthetic event through the connection's configured automations.
export function useTestConnection() {
return useMutation({ mutationFn: testConnection });
}
@@ -0,0 +1,35 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import listFieldMappings from "@/lib/api/client/app/integrations/listFieldMappings";
import replaceFieldMappings from "@/lib/api/client/app/integrations/replaceFieldMappings";
import updateConnectionConfig from "@/lib/api/client/app/integrations/updateConnectionConfig";
export function useFieldMappings(connectionId: string) {
return useQuery({
queryKey: ["integrations", "field-mappings", connectionId],
queryFn: () => listFieldMappings(connectionId),
enabled: !!connectionId,
staleTime: 10_000,
});
}
export function useReplaceFieldMappings() {
const qc = useQueryClient();
return useMutation({
mutationFn: replaceFieldMappings,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["integrations", "field-mappings", vars.connectionId] });
qc.invalidateQueries({ queryKey: ["integrations", "connection", vars.connectionId] });
},
});
}
export function useUpdateConnectionConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: updateConnectionConfig,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["integrations", "connection", vars.connectionId] });
qc.invalidateQueries({ queryKey: ["integrations", "connections"] });
},
});
}
@@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import createMeeting from "@/lib/api/client/app/meetings/createMeeting";
export default function useCreateMeeting() {
const qc = useQueryClient();
return useMutation({
mutationFn: createMeeting,
onSuccess: (res) => {
void qc.invalidateQueries({ queryKey: ["meetings"] });
void qc.invalidateQueries({ queryKey: ["meetings", "summary"] });
const contactId = res.meeting.contact_id;
if (contactId) {
void qc.invalidateQueries({ queryKey: ["contacts", contactId, "timeline"] });
}
},
});
}
@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import deleteMeeting from "@/lib/api/client/app/meetings/deleteMeeting";
export default function useDeleteMeeting() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteMeeting,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["meetings"] });
void qc.invalidateQueries({ queryKey: ["meetings", "summary"] });
},
});
}
@@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import meetingsSummary from "@/lib/api/client/app/meetings/meetingsSummary";
// Powers the Meetings page header stats and the sidebar upcoming count. Kept
// fresh by realtime meeting events (see useRealtimeEvents) rather than polling.
export default function useMeetingsSummary(enabled = true) {
return useQuery({
queryKey: ["meetings", "summary"],
queryFn: meetingsSummary,
staleTime: 30_000,
enabled,
});
}
@@ -0,0 +1,37 @@
import { useInfiniteQuery, type InfiniteData } from "@tanstack/react-query";
import type { MeetingsPage, MeetingsSearch } from "@/lib/api/models/app/integrations/Integration";
import searchMeetings from "@/lib/api/client/app/meetings/searchMeetings";
interface UseSearchMeetingsProps {
filters: MeetingsSearch;
limit?: number;
enabled?: boolean;
}
// Offset infinite scroll over booked meetings. Pages flatten into one list;
// `total` comes off the server so the UI can show "N of M".
export default function useSearchMeetings({ filters, limit = 50, enabled = true }: UseSearchMeetingsProps) {
const queryResult = useInfiniteQuery<
MeetingsPage,
Error,
InfiniteData<MeetingsPage, number>,
[string, string, MeetingsSearch, number],
number
>({
queryKey: ["meetings", "search", filters, limit],
queryFn: async ({ pageParam }) => searchMeetings(filters, pageParam, limit),
initialPageParam: 0,
getNextPageParam: (lastPage) =>
lastPage.pagination.has_more ? (lastPage.pagination.next_offset ?? undefined) : undefined,
staleTime: 15_000,
enabled,
});
const meetings = queryResult.data?.pages
.flatMap((p) => p.data ?? [])
.filter((m): m is NonNullable<typeof m> => m != null);
const total = queryResult.data?.pages[0]?.pagination.total ?? 0;
return { ...queryResult, meetings, total };
}
@@ -7,7 +7,10 @@ export type ContactTimelineEventType =
| "reply_received"
| "deliverability"
| "suppressed"
| "note";
| "note"
| "meeting_booked"
| "meeting_rescheduled"
| "meeting_canceled";
export default interface ContactTimelineEvent {
type: ContactTimelineEventType;
@@ -31,6 +34,11 @@ export default interface ContactTimelineEvent {
intent?: string | null;
content?: string | null;
// Meeting events (meeting_booked / rescheduled / canceled).
scheduled_for?: string | null;
join_url?: string | null;
meeting_state?: string | null;
user_id?: string | null;
}
@@ -53,10 +53,73 @@ export interface IntegrationCatalogEntry {
/** Whether this provider can be a target of the contextual "push contacts"
* action (CRM providers with an upsert handler). */
supports_push: boolean;
/** Configurable-action descriptor the onboarding + field-mapping UI renders
* from. Absent for providers with no descriptor. */
capability?: ProviderCapability;
/** Whether the server has OAuth client credentials wired for this provider. */
configured: boolean;
}
export type SyncDirection = "push" | "pull" | "both";
export interface FieldDef {
key: string;
label: string;
}
export interface CapabilityObject {
name: string;
label: string;
dedupe_keys: string[];
required: string[];
warmbly_fields: FieldDef[];
external_fields: FieldDef[];
dynamic_fields: boolean;
}
export interface CapabilityAction {
id: IntegrationAction;
label: string;
description: string;
object?: string;
needs_pipeline?: boolean;
needs_channel?: boolean;
needs_url?: boolean;
}
export interface CapabilityPicker {
key: string;
label: string;
endpoint?: string;
depends_on?: string;
}
export interface ProviderCapability {
provider: IntegrationProvider;
directions: SyncDirection[];
objects?: CapabilityObject[];
actions?: CapabilityAction[];
pickers?: CapabilityPicker[];
supports_booking_link: boolean;
}
export type FieldTransform = "none" | "static" | "uppercase" | "lowercase" | "trim";
export interface IntegrationFieldMapping {
id: string;
connection_id: string;
organization_id: string;
subscription_id?: string | null;
direction: string;
object_name: string;
warmbly_field: string;
external_field: string;
transform: string;
static_value: string;
is_default: boolean;
created_at: string;
}
export interface IntegrationConnection {
id: string;
organization_id: string;
@@ -79,6 +142,12 @@ export interface IntegrationConnection {
created_at: string;
updated_at: string;
/** Per-connection onboarding/capability snapshot (selected use-cases, picker
* selections, scheduling_url for meeting providers). */
config_capabilities?: Record<string, unknown>;
/** Data-flow direction: push | pull | both. */
sync_direction?: SyncDirection;
/** Returned once at create time for inbound-webhook providers. */
inbound_webhook_url?: string;
}
@@ -127,18 +196,67 @@ export interface IntegrationConnectionDetail {
runs: IntegrationSyncRun[];
}
export type MeetingStatus = "booked" | "rescheduled" | "canceled" | "completed" | "no_show";
export interface MeetingBooking {
id: string;
organization_id: string;
source: "calendly" | "cal_com";
source: "calendly" | "cal_com" | "manual";
external_event_id: string;
status: MeetingStatus;
invitee_email: string;
invitee_name: string;
event_name: string;
event_type?: string;
scheduled_for?: string;
end_time?: string;
join_url?: string;
location?: string;
cancel_url?: string;
reschedule_url?: string;
canceled_reason?: string;
contact_id?: string;
campaign_id?: string;
contact_name?: string;
created_at: string;
updated_at?: string;
}
export interface MeetingsSummary {
upcoming: number;
today: number;
total: number;
canceled: number;
}
export interface MeetingsPage {
data: MeetingBooking[];
pagination: {
total: number;
limit: number;
offset: number;
has_more: boolean;
next_offset?: number;
};
}
export interface MeetingsSearch {
timeframe?: "upcoming" | "past" | "";
status?: MeetingStatus | "";
q?: string;
}
// Payload for a manually-created meeting (source "manual"). The contact is
// attributed by an explicit id or, failing that, an org-scoped email match.
export interface CreateMeetingInput {
title: string;
invitee_name: string;
invitee_email: string;
scheduled_for: string; // RFC3339
duration_minutes?: number;
location?: string;
join_url?: string;
contact_id?: string;
}
// --- presentation helpers (shared by cards + drawers) ----------------------
@@ -176,6 +294,9 @@ export const EVENT_LABELS: Record<string, string> = {
"campaign.unsubscribed": "Contact unsubscribes",
"warmup.health_changed": "Warmup health changes",
"deliverability.complaint": "Spam complaint",
"meeting.booked": "Meeting booked",
"meeting.rescheduled": "Meeting rescheduled",
"meeting.canceled": "Meeting canceled",
};
// Which action a provider performs for an event subscription.
@@ -205,3 +326,51 @@ export const PUSHABLE_PROVIDERS: IntegrationProvider[] = [
"salesforce",
"close",
];
// Display names for providers, used by contextual menus that list connections.
export const PROVIDER_LABELS: Record<IntegrationProvider, string> = {
hubspot: "HubSpot",
salesforce: "Salesforce",
pipedrive: "Pipedrive",
close: "Close",
zapier: "Zapier",
make: "Make",
n8n: "n8n",
slack: "Slack",
discord: "Discord",
calendly: "Calendly",
cal_com: "Cal.com",
};
// A connection is bookable when it's a connected scheduling provider with a
// stored scheduling_url. Returns the URL or null.
export function bookingURL(conn: IntegrationConnection): string | null {
if (conn.provider !== "calendly" && conn.provider !== "cal_com") return null;
if (conn.status !== "connected" && conn.status !== "degraded") return null;
const fromConfig = conn.config_capabilities?.scheduling_url;
const fromDisplay = conn.display_fields?.scheduling_url;
const url = (typeof fromConfig === "string" && fromConfig) || (typeof fromDisplay === "string" && fromDisplay) || "";
return /^https?:\/\//i.test(url) ? url : null;
}
// prefilledBookingURL appends Calendly/Cal.com-style email + name prefill params
// to a scheduling link so the contact's details are filled in for them. When a
// contactId is given we also embed it as utm_content: both providers echo this
// back in the booking webhook, letting us attribute the meeting to the exact
// contact even if they book with a different email.
export function prefilledBookingURL(
base: string,
email?: string,
name?: string,
contactId?: string,
): string {
try {
const u = new URL(base);
if (email) u.searchParams.set("email", email);
if (name) u.searchParams.set("name", name);
if (contactId) u.searchParams.set("utm_content", contactId);
return u.toString();
} catch {
return base;
}
}
+5
View File
@@ -23,6 +23,7 @@ import AnalyticsPage from './app/app/analytics/page';
import PipelinesPage from './app/app/crm/pipelines/page';
import DealsPage from './app/app/crm/deals/page';
import TasksPage from './app/app/crm/tasks/page';
import MeetingsPage from './app/app/crm/meetings/page';
import TemplatesPage from './app/app/templates/page';
import APIKeysPage from './app/app/api-keys/page';
import IntegrationsPage from './app/app/integrations/page';
@@ -249,6 +250,10 @@ const router = createBrowserRouter([
{
path: "tasks",
element: <TasksPage />,
},
{
path: "meetings",
element: <MeetingsPage />,
}
]
},