mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
feat: add integration and meetings dashboard
This commit is contained in:
@@ -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 contact’s 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=<unix>,v1=<hmac></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />,
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user