From 82e753735c3eb527d205d2f19e422939cd0bbf2b Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 8 Jun 2026 06:25:44 +0200 Subject: [PATCH] feat: add integration and meetings dashboard --- web/src/app/app/crm/meetings/page.tsx | 375 ++++++++++++++++++ .../_components/ConnectDrawer.tsx | 75 ++-- .../_components/ConnectionDetail.tsx | 312 ++++++++++++--- .../_components/FieldMapEditor.tsx | 226 +++++++++++ web/src/app/app/integrations/page.tsx | 25 +- web/src/app/app/settings/layout.tsx | 10 + .../components/app/contacts/ContactEdit.tsx | 36 +- .../components/app/contacts/ContactsTable.tsx | 86 ++++ .../app/contacts/contact-edit/ActivityTab.tsx | 61 ++- .../app/integrations/BookACallButton.tsx | 89 +++++ .../app/meetings/NewMeetingDialog.tsx | 184 +++++++++ .../app/unibox/ContactContextPanel.tsx | 30 ++ .../components/app/unibox/ReplyComposer.tsx | 43 ++ web/src/components/app/unibox/ThreadView.tsx | 2 + web/src/components/layout/AppNav.tsx | 59 ++- web/src/components/layout/Page.tsx | 41 +- web/src/hooks/useRealtimeEvents.ts | 15 +- .../app/integrations/getWebhookSecret.ts | 15 + .../app/integrations/listFieldMappings.ts | 12 + .../app/integrations/replaceFieldMappings.ts | 28 ++ .../client/app/integrations/testConnection.ts | 9 + .../integrations/updateConnectionConfig.ts | 23 ++ .../api/client/app/meetings/createMeeting.ts | 11 + .../api/client/app/meetings/deleteMeeting.ts | 9 + .../client/app/meetings/meetingsSummary.ts | 10 + .../api/client/app/meetings/searchMeetings.ts | 24 ++ .../integrations/useConnectionWebhookTools.ts | 13 + .../app/integrations/useFieldMappings.ts | 35 ++ .../hooks/app/meetings/useCreateMeeting.ts | 17 + .../hooks/app/meetings/useDeleteMeeting.ts | 13 + .../hooks/app/meetings/useMeetingsSummary.ts | 13 + .../hooks/app/meetings/useSearchMeetings.ts | 37 ++ .../app/contacts/ContactTimelineEvent.ts | 10 +- .../models/app/integrations/Integration.ts | 171 +++++++- web/src/main.tsx | 5 + 35 files changed, 2011 insertions(+), 113 deletions(-) create mode 100644 web/src/app/app/crm/meetings/page.tsx create mode 100644 web/src/app/app/integrations/_components/FieldMapEditor.tsx create mode 100644 web/src/components/app/integrations/BookACallButton.tsx create mode 100644 web/src/components/app/meetings/NewMeetingDialog.tsx create mode 100644 web/src/lib/api/client/app/integrations/getWebhookSecret.ts create mode 100644 web/src/lib/api/client/app/integrations/listFieldMappings.ts create mode 100644 web/src/lib/api/client/app/integrations/replaceFieldMappings.ts create mode 100644 web/src/lib/api/client/app/integrations/testConnection.ts create mode 100644 web/src/lib/api/client/app/integrations/updateConnectionConfig.ts create mode 100644 web/src/lib/api/client/app/meetings/createMeeting.ts create mode 100644 web/src/lib/api/client/app/meetings/deleteMeeting.ts create mode 100644 web/src/lib/api/client/app/meetings/meetingsSummary.ts create mode 100644 web/src/lib/api/client/app/meetings/searchMeetings.ts create mode 100644 web/src/lib/api/hooks/app/integrations/useConnectionWebhookTools.ts create mode 100644 web/src/lib/api/hooks/app/integrations/useFieldMappings.ts create mode 100644 web/src/lib/api/hooks/app/meetings/useCreateMeeting.ts create mode 100644 web/src/lib/api/hooks/app/meetings/useDeleteMeeting.ts create mode 100644 web/src/lib/api/hooks/app/meetings/useMeetingsSummary.ts create mode 100644 web/src/lib/api/hooks/app/meetings/useSearchMeetings.ts diff --git a/web/src/app/app/crm/meetings/page.tsx b/web/src/app/app/crm/meetings/page.tsx new file mode 100644 index 00000000..642667a6 --- /dev/null +++ b/web/src/app/app/crm/meetings/page.tsx @@ -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 = { + 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("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 ( + + + }> + Calendars + + setCreating(true)} icon={}> + New meeting + + + + + 0} value={} /> + } /> + } /> + } last /> + + + + +
+ {TABS.map((tab) => ( + + ))} +
+ setSearchRaw(v)} + placeholder="Search name, email, or event…" + className="w-56" + /> +
+ + {isLoading ? ( +
+ +
+ ) : rows.length === 0 ? ( + + setCreating(true)} icon={}> + New meeting + + }> + Connect a calendar + + + } + /> + ) : ( +
+
+ When + Contact + Meeting + Source + Status + Actions +
+ {rows.map((m) => ( + + ))} + {hasNextPage && ( +
+ +
+ )} +
+ )} +
+ + setCreating(false)} /> +
+ ); +} + +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 ( +
+
+
+ + {when.date} +
+
+ {when.time} + {when.rel ? ` · ${when.rel}` : ""} +
+
+ +
+
{contactLabel}
+
{m.invitee_email}
+
+ +
+
{m.event_name || "Meeting"}
+ {m.location &&
{m.location}
} +
+ +
+ + {isManual ? "Manual" : PROVIDER_LABELS[m.source as keyof typeof PROVIDER_LABELS] ?? m.source} + +
+ +
+ + {status.label} + +
+ +
+ {!canceled && m.scheduled_for && ( + + + + + + window.open(googleCalURL(m), "_blank", "noopener,noreferrer")} + > + Google Calendar + + downloadICS(m)}>Download .ics + + + )} + {!canceled && m.join_url && ( + + + + )} + {!canceled && m.reschedule_url && ( + + + + )} + {!canceled && !isManual && m.cancel_url && ( + + + + )} + {isManual && ( + + )} +
+
+ ); +} diff --git a/web/src/app/app/integrations/_components/ConnectDrawer.tsx b/web/src/app/app/integrations/_components/ConnectDrawer.tsx index 25e93d70..c618c9d6 100644 --- a/web/src/app/app/integrations/_components/ConnectDrawer.tsx +++ b/web/src/app/app/integrations/_components/ConnectDrawer.tsx @@ -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 = { 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({ )} + {isAutomation && ( +
+

+ No key needed to connect. After connecting, add an automation that + sends Warmbly events to your {entry.name} webhook URL. +

+

+ 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}. +

+
+ )} +
@@ -251,11 +246,21 @@ export default function ConnectDrawer({ {busy ? : } {busy ? "Creating…" : "Create inbound URL"} - ) : ( + ) : needsCredentials ? ( + ) : ( + )}
@@ -323,13 +328,21 @@ export function Drawer({ }) { return (
-
{children} - + ); } diff --git a/web/src/app/app/integrations/_components/ConnectionDetail.tsx b/web/src/app/app/integrations/_components/ConnectionDetail.tsx index 0d47ff93..39a71593 100644 --- a/web/src/app/app/integrations/_components/ConnectionDetail.tsx +++ b/web/src/app/app/integrations/_components/ConnectionDetail.tsx @@ -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 */} -
-
- Automations - {!adding && ( - + {(availableEvents.length > 0 || events.length > 0) && ( +
+
+ Automations + {!adding && availableEvents.length > 0 && ( + + )} +
+ + {events.length === 0 && !adding && ( +

+ 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. +

+ )} + + {events.map((ev) => ( + + deleteEvent + .mutateAsync({ connectionId: conn.id, eventId: ev.id }) + .then(() => detail.refetch()) + } + /> + ))} + + {adding && ( + setAdding(false)} + onAdd={addAutomation} + busy={createEvent.isPending} + /> )}
+ )} - {events.length === 0 && !adding && ( -

- No automations yet. Add a rule to push Warmbly events into {entry?.name ?? conn.label} — - e.g. ping a channel when a prospect replies. -

- )} + {/* Field mapping — control exactly what each CRM record gets */} + {crmObject && ( +
+ Field mapping + +
+ )} - {events.map((ev) => ( - - deleteEvent - .mutateAsync({ connectionId: conn.id, eventId: ev.id }) - .then(() => detail.refetch()) - } - /> - ))} + {/* Booking link — for scheduling providers (Calendly / Cal.com) */} + {capability?.supports_booking_link && ( +
+ Booking link + detail.refetch()} /> +
+ )} - {adding && ( - + Webhook delivery + setAdding(false)} - onAdd={addAutomation} - busy={createEvent.isPending} + hasAutomations={events.length > 0} /> - )} -
+
+ )} {/* Activity */}
@@ -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

Loading…

; + } + return ( + + ); +} + +// 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 ( +
+

+ Paste your public scheduling link. A “Book a call” button appears on contacts and inbox + threads, prefilled with the contact’s email. +

+ + {dirty && ( +
+ +
+ )} +
+ ); +} + function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
@@ -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(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 ( +
+

+ {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."} +

+ + + {SIGNING_PROVIDERS.includes(provider) && ( +
+
+ Signing secret +
+

+ Every delivery is signed with{" "} + X-Warmbly-Signature: t=<unix>,v1=<hmac> (HMAC-SHA256 + of {"{t}.{body}"}). Use this secret to verify it. +

+ {secret ? ( +
+ + {secret} + + +
+ ) : ( + + )} +
+ )} +
+ ); +} diff --git a/web/src/app/app/integrations/_components/FieldMapEditor.tsx b/web/src/app/app/integrations/_components/FieldMapEditor.tsx new file mode 100644 index 00000000..51aaad65 --- /dev/null +++ b/web/src/app/app/integrations/_components/FieldMapEditor.tsx @@ -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( + () => + 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(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) { + 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 ( +
+

+ Email, name, company and phone map automatically. Add rows to send more Warmbly data + into {object.label.toLowerCase()} fields, or override a default. +

+ + {rows.length > 0 && ( +
+ {rows.map((row, i) => ( +
+
+
+ {row.transform === "static" ? ( + patch(i, { static_value: v })} + placeholder="Static value" + /> + ) : ( + patch(i, { warmbly_field: v })} + options={warmblyOptions} + className="w-full" + aria-label="Warmbly field" + /> + )} +
+ +
+ + 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`} + /> +
+ +
+
+ {row.external_custom && ( + patch(i, { external_field: v })} + placeholder="Provider field API name" + className="flex-1 font-mono" + /> + )} + patch(i, { transform: v })} + options={TRANSFORMS} + className={row.external_custom ? "w-36" : "w-full"} + aria-label="Transform" + /> +
+
+ ))} +
+ )} + +
+ + {dirty && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/web/src/app/app/integrations/page.tsx b/web/src/app/app/integrations/page.tsx index 58233c35..80f7aab0 100644 --- a/web/src/app/app/integrations/page.tsx +++ b/web/src/app/app/integrations/page.tsx @@ -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() {
- {connections.map((c) => ( + {connections.map((c, i) => ( setManageTarget(c)} @@ -152,9 +154,10 @@ export default function IntegrationsPage() {
- {entries.map((entry) => ( + {entries.map((entry, i) => ( 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 ( - + ); } @@ -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)?.channel || ""; return ( -
- + ); } diff --git a/web/src/app/app/settings/layout.tsx b/web/src/app/app/settings/layout.tsx index 89eb1ce6..e024ae2d 100644 --- a/web/src/app/app/settings/layout.tsx +++ b/web/src/app/app/settings/layout.tsx @@ -19,6 +19,7 @@ import { AlertOctagonIcon, BellIcon, BriefcaseIcon, + CableIcon, CreditCardIcon, GaugeIcon, ShieldCheckIcon, @@ -106,6 +107,15 @@ export default function SettingsLayout() { )} ))} + {/* Integrations is a top-level surface, not a settings sub-page; + cross-link out to it so people who look here still find it. */} + + + Integrations +
diff --git a/web/src/components/app/contacts/ContactEdit.tsx b/web/src/components/app/contacts/ContactEdit.tsx index 147aa672..89acdaaf 100644 --- a/web/src/components/app/contacts/ContactEdit.tsx +++ b/web/src/components/app/contacts/ContactEdit.tsx @@ -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 ( -
+ <> +
{initials}
@@ -358,6 +362,21 @@ function ContactHeader({
+ + - + + setMeetingOpen(false)} + prefill={{ + title: displayName ? `Call with ${displayName}` : "Call", + name: displayName, + email: contact.email, + contactId: contact.id, + }} + /> + ); } diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index 4b16b247..9b0911e7 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -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( + () => + (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({ setBulkEdit(true)} onDelete={() => confirm?.show( @@ -445,6 +491,9 @@ export default function ContactsTable({ 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({ {count} selected
+ {pushTargets.length > 0 && ( + + + + + + Push {count} to + {pushTargets.map((t) => { + const label = PROVIDER_LABELS[t.provider]; + const custom = t.label && t.label.toLowerCase() !== t.provider ? ` · ${t.label}` : ""; + return ( + onPush(t.id, label)}> + {label} + {custom} + + ); + })} + + + )}
+ ); + } + 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 }; } diff --git a/web/src/components/app/integrations/BookACallButton.tsx b/web/src/components/app/integrations/BookACallButton.tsx new file mode 100644 index 00000000..e8c9818c --- /dev/null +++ b/web/src/components/app/integrations/BookACallButton.tsx @@ -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 ( + + ); + } + + return ( + + + + + + Send a scheduling link via + {targets.map((t) => ( + open(t.url)}> + {PROVIDER_LABELS[t.conn.provider]} + {t.conn.label && t.conn.label.toLowerCase() !== t.conn.provider + ? ` · ${t.conn.label}` + : ""} + + ))} + + + ); +} diff --git a/web/src/components/app/meetings/NewMeetingDialog.tsx b/web/src/components/app/meetings/NewMeetingDialog.tsx new file mode 100644 index 00000000..1af3b112 --- /dev/null +++ b/web/src/components/app/meetings/NewMeetingDialog.tsx @@ -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 +// 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 ( + + {open && ( + + e.stopPropagation()} + className="w-full max-w-md bg-white rounded-xl border border-slate-200 shadow-xl overflow-hidden" + > +
+ New meeting + +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ {!lockedContact && ( +

+ We link the meeting to a contact by email when one matches. +

+ )} +
+
+ + 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" + /> +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/web/src/components/app/unibox/ContactContextPanel.tsx b/web/src/components/app/unibox/ContactContextPanel.tsx index ee8185de..ba43436d 100644 --- a/web/src/components/app/unibox/ContactContextPanel.tsx +++ b/web/src/components/app/unibox/ContactContextPanel.tsx @@ -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({ + {/* 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. */} +
+ + +
{supp && (
@@ -232,6 +250,18 @@ export default function ContactContextPanel({
)} + {contact && ( + setMeetingOpen(false)} + prefill={{ + title: name ? `Call with ${name}` : "Call", + name, + email: contact.email, + contactId: contact.id, + }} + /> + )} ); } diff --git a/web/src/components/app/unibox/ReplyComposer.tsx b/web/src/components/app/unibox/ReplyComposer.tsx index c620c56d..7bd7c341 100644 --- a/web/src/components/app/unibox/ReplyComposer.tsx +++ b/web/src/components/app/unibox/ReplyComposer.tsx @@ -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 ( + + ); +} + 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 + + setBody((b) => (b.trim() ? `${b.trimEnd()}\n\n${text}` : text).slice(0, MAX_BODY_LEN)) + } + /> + {body && ( + } {item.indicator === "accounts" && !locked && } {item.indicator === "tasks" && !locked && } + {item.indicator === "meetings" && !locked && } {item.indicator === "contacts" && !locked && } {item.indicator === "deals" && !locked && } {item.indicator === "pipelines" && !locked && } {item.indicator === "templates" && !locked && } {item.indicator === "analytics" && !locked && } {item.indicator === "apikeys" && !locked && } + {item.indicator === "integrations" && !locked && } {planBadge ? ( + + + + } + 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 ( + + + + + } + title={`${conns.length} connected${attention > 0 ? `, ${attention} need attention` : ""}`} + /> + ); +} + function Section({ section, first = false }: { section: NavSection; first?: boolean }) { return (
diff --git a/web/src/components/layout/Page.tsx b/web/src/components/layout/Page.tsx index 580dd52c..41884bbc 100644 --- a/web/src/components/layout/Page.tsx +++ b/web/src/components/layout/Page.tsx @@ -19,8 +19,15 @@ // 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 ( + + {icon} + {children} + + ); + } return ( - + {icon} {children} @@ -197,7 +210,11 @@ export function Stat({ (href || onClick) && "hover:bg-slate-50 cursor-pointer", ); if (href) { - return ( + return isInternalHref(href) ? ( + + {inner} + + ) : ( {inner} @@ -286,7 +303,13 @@ export function Row({ (href || onClick) && "hover:bg-slate-50/80 cursor-pointer", className, ); - if (href) return {children}; + if (href) { + return isInternalHref(href) ? ( + {children} + ) : ( + {children} + ); + } if (onClick) return ; return
{children}
; } diff --git a/web/src/hooks/useRealtimeEvents.ts b/web/src/hooks/useRealtimeEvents.ts index edc36a36..360d2823 100644 --- a/web/src/hooks/useRealtimeEvents.ts +++ b/web/src/hooks/useRealtimeEvents.ts @@ -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'], diff --git a/web/src/lib/api/client/app/integrations/getWebhookSecret.ts b/web/src/lib/api/client/app/integrations/getWebhookSecret.ts new file mode 100644 index 00000000..744f561d --- /dev/null +++ b/web/src/lib/api/client/app/integrations/getWebhookSecret.ts @@ -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 { + return await Request({ + method: "GET", + url: `/integrations/connections/${connectionId}/webhook-secret`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/integrations/listFieldMappings.ts b/web/src/lib/api/client/app/integrations/listFieldMappings.ts new file mode 100644 index 00000000..12db03cd --- /dev/null +++ b/web/src/lib/api/client/app/integrations/listFieldMappings.ts @@ -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, + }); +} diff --git a/web/src/lib/api/client/app/integrations/replaceFieldMappings.ts b/web/src/lib/api/client/app/integrations/replaceFieldMappings.ts new file mode 100644 index 00000000..c5c5386a --- /dev/null +++ b/web/src/lib/api/client/app/integrations/replaceFieldMappings.ts @@ -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, + }); +} diff --git a/web/src/lib/api/client/app/integrations/testConnection.ts b/web/src/lib/api/client/app/integrations/testConnection.ts new file mode 100644 index 00000000..5b4a5a65 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/testConnection.ts @@ -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, + }); +} diff --git a/web/src/lib/api/client/app/integrations/updateConnectionConfig.ts b/web/src/lib/api/client/app/integrations/updateConnectionConfig.ts new file mode 100644 index 00000000..4e34a84a --- /dev/null +++ b/web/src/lib/api/client/app/integrations/updateConnectionConfig.ts @@ -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; + 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, + }); +} diff --git a/web/src/lib/api/client/app/meetings/createMeeting.ts b/web/src/lib/api/client/app/meetings/createMeeting.ts new file mode 100644 index 00000000..329ccf46 --- /dev/null +++ b/web/src/lib/api/client/app/meetings/createMeeting.ts @@ -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, + }); +} diff --git a/web/src/lib/api/client/app/meetings/deleteMeeting.ts b/web/src/lib/api/client/app/meetings/deleteMeeting.ts new file mode 100644 index 00000000..702a61d0 --- /dev/null +++ b/web/src/lib/api/client/app/meetings/deleteMeeting.ts @@ -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, + }); +} diff --git a/web/src/lib/api/client/app/meetings/meetingsSummary.ts b/web/src/lib/api/client/app/meetings/meetingsSummary.ts new file mode 100644 index 00000000..505a717c --- /dev/null +++ b/web/src/lib/api/client/app/meetings/meetingsSummary.ts @@ -0,0 +1,10 @@ +import type { MeetingsSummary } from "@/lib/api/models/app/integrations/Integration"; +import Request from "../../Request"; + +export default async function meetingsSummary(): Promise { + return await Request({ + method: "GET", + url: "/meetings/summary", + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/meetings/searchMeetings.ts b/web/src/lib/api/client/app/meetings/searchMeetings.ts new file mode 100644 index 00000000..2992d659 --- /dev/null +++ b/web/src/lib/api/client/app/meetings/searchMeetings.ts @@ -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 { + 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({ + method: "GET", + url: `/meetings${suffix}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useConnectionWebhookTools.ts b/web/src/lib/api/hooks/app/integrations/useConnectionWebhookTools.ts new file mode 100644 index 00000000..02bae600 --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useConnectionWebhookTools.ts @@ -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 }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useFieldMappings.ts b/web/src/lib/api/hooks/app/integrations/useFieldMappings.ts new file mode 100644 index 00000000..82fcc4b6 --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useFieldMappings.ts @@ -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"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/meetings/useCreateMeeting.ts b/web/src/lib/api/hooks/app/meetings/useCreateMeeting.ts new file mode 100644 index 00000000..1f12d3b4 --- /dev/null +++ b/web/src/lib/api/hooks/app/meetings/useCreateMeeting.ts @@ -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"] }); + } + }, + }); +} diff --git a/web/src/lib/api/hooks/app/meetings/useDeleteMeeting.ts b/web/src/lib/api/hooks/app/meetings/useDeleteMeeting.ts new file mode 100644 index 00000000..e091920d --- /dev/null +++ b/web/src/lib/api/hooks/app/meetings/useDeleteMeeting.ts @@ -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"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/meetings/useMeetingsSummary.ts b/web/src/lib/api/hooks/app/meetings/useMeetingsSummary.ts new file mode 100644 index 00000000..368a34b3 --- /dev/null +++ b/web/src/lib/api/hooks/app/meetings/useMeetingsSummary.ts @@ -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, + }); +} diff --git a/web/src/lib/api/hooks/app/meetings/useSearchMeetings.ts b/web/src/lib/api/hooks/app/meetings/useSearchMeetings.ts new file mode 100644 index 00000000..3795c00f --- /dev/null +++ b/web/src/lib/api/hooks/app/meetings/useSearchMeetings.ts @@ -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, + [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 => m != null); + + const total = queryResult.data?.pages[0]?.pagination.total ?? 0; + + return { ...queryResult, meetings, total }; +} diff --git a/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts b/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts index 237c09d3..1ccd2fb5 100644 --- a/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts +++ b/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts @@ -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; } diff --git a/web/src/lib/api/models/app/integrations/Integration.ts b/web/src/lib/api/models/app/integrations/Integration.ts index b0073108..0267f5cc 100644 --- a/web/src/lib/api/models/app/integrations/Integration.ts +++ b/web/src/lib/api/models/app/integrations/Integration.ts @@ -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; + /** 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 = { "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 = { + 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; + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx index d2be32e3..6d8d10f2 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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: , + }, + { + path: "meetings", + element: , } ] },