diff --git a/ui/package-lock.json b/ui/package-lock.json
index 006cd1b7..909d5ee3 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -8,6 +8,7 @@
"name": "ui",
"version": "1.39.0",
"dependencies": {
+ "@calcom/embed-react": "^1.5.3",
"@dagrejs/dagre": "^1.1.4",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
@@ -970,6 +971,35 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@calcom/embed-core": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/@calcom/embed-core/-/embed-core-1.5.3.tgz",
+ "integrity": "sha512-GeId9gaByJ5EWiPmuvelZOvFWPOTWkcWZr5vGTCbIUTX125oE5yn0n8lDF1MJk5Xj1WO+/dk9jKIE08Ad9ytiQ==",
+ "license": "SEE LICENSE IN LICENSE"
+ },
+ "node_modules/@calcom/embed-react": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/@calcom/embed-react/-/embed-react-1.5.3.tgz",
+ "integrity": "sha512-JCgge04pc8fhdvUmPNVLhW8/lCWK+AAziKecKWWPfv1nn2s+qKP2BwsEAnxhxK9yPOBgE1EIEgmYkrrNB1iajA==",
+ "license": "SEE LICENSE IN LICENSE",
+ "dependencies": {
+ "@calcom/embed-core": "1.5.3",
+ "@calcom/embed-snippet": "1.3.3"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0 || ^19.0.0",
+ "react-dom": "^18.2.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@calcom/embed-snippet": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@calcom/embed-snippet/-/embed-snippet-1.3.3.tgz",
+ "integrity": "sha512-pqqKaeLB8R6BvyegcpI9gAyY6Xyx1bKYfWvIGOvIbTpguWyM1BBBVcT9DCeGe8Zw7Ujp5K56ci7isRUrT2Uadg==",
+ "license": "SEE LICENSE IN LICENSE",
+ "dependencies": {
+ "@calcom/embed-core": "1.5.3"
+ }
+ },
"node_modules/@dagrejs/dagre": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz",
diff --git a/ui/package.json b/ui/package.json
index f93345fc..aad0d28c 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -13,6 +13,7 @@
"lint:lead-flow": "bash ../../user_onboarding/scripts/check_lead_flow.sh"
},
"dependencies": {
+ "@calcom/embed-react": "^1.5.3",
"@dagrejs/dagre": "^1.1.4",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
diff --git a/ui/public/brand-imprint-light.svg b/ui/public/brand-imprint-light.svg
index a22a0c3b..5d0e8201 100644
--- a/ui/public/brand-imprint-light.svg
+++ b/ui/public/brand-imprint-light.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/ui/src/components/ChatwootWidget.tsx b/ui/src/components/ChatwootWidget.tsx
index 4328611b..7872e666 100644
--- a/ui/src/components/ChatwootWidget.tsx
+++ b/ui/src/components/ChatwootWidget.tsx
@@ -16,29 +16,28 @@ declare global {
type?: "standard" | "expanded_bubble";
launcherTitle?: string;
};
+ $chatwoot?: {
+ toggleBubbleVisibility?: (visibility: "hide" | "show") => void;
+ toggle?: (state?: "open" | "close") => void;
+ };
}
}
const CHATWOOT_BASE_URL = process.env.NEXT_PUBLIC_CHATWOOT_URL;
const CHATWOOT_WEBSITE_TOKEN = process.env.NEXT_PUBLIC_CHATWOOT_TOKEN;
+// Hide the support bubble only on the workflow builder (/workflow/ and its
+// sub-routes), where the in-app chat tester occupies the same bottom-right
+// corner. It stays visible everywhere else, including the /workflow list and
+// /workflow/create.
+const isBuilderPath = (pathname: string) =>
+ /^\/workflow\/(?!create(?:$|\/))[^/]+(?:\/.*)?$/.test(pathname);
+
export default function ChatwootWidget() {
const pathname = usePathname();
+ // Load the Chatwoot SDK exactly once for the lifetime of the app.
useEffect(() => {
- const isWorkflowPage = /^\/workflow\/[^/]+(?:\/.*)?$/.test(pathname);
-
- if (isWorkflowPage) {
- document.getElementById("cw-widget-holder")?.remove();
- document.getElementById("cw-bubble-holder")?.remove();
- document.getElementById("cw-widget-styles")?.remove();
- document
- .querySelector(`script[src="${CHATWOOT_BASE_URL}/packs/js/sdk.js"]`)
- ?.remove();
- delete window.chatwootSettings;
- return;
- }
-
// Don't initialize if environment variables are not set
if (!CHATWOOT_BASE_URL || !CHATWOOT_WEBSITE_TOKEN) {
console.warn("Chatwoot not configured: Missing NEXT_PUBLIC_CHATWOOT_URL or NEXT_PUBLIC_CHATWOOT_TOKEN");
@@ -64,12 +63,10 @@ export default function ChatwootWidget() {
if (existingScript) {
// Script already exists, just initialize if SDK is available
- if (window.chatwootSDK) {
- window.chatwootSDK.run({
- websiteToken: CHATWOOT_WEBSITE_TOKEN,
- baseUrl: CHATWOOT_BASE_URL,
- });
- }
+ window.chatwootSDK?.run({
+ websiteToken: CHATWOOT_WEBSITE_TOKEN,
+ baseUrl: CHATWOOT_BASE_URL,
+ });
return;
}
@@ -79,15 +76,38 @@ export default function ChatwootWidget() {
script.async = true;
script.defer = true;
script.onload = () => {
- if (window.chatwootSDK) {
- window.chatwootSDK.run({
- websiteToken: CHATWOOT_WEBSITE_TOKEN,
- baseUrl: CHATWOOT_BASE_URL,
- });
- }
+ window.chatwootSDK?.run({
+ websiteToken: CHATWOOT_WEBSITE_TOKEN,
+ baseUrl: CHATWOOT_BASE_URL,
+ });
};
document.body.appendChild(script);
+ }, []);
+
+ // Show/hide the bubble per route using Chatwoot's native API. We never tear
+ // down and recreate the SDK — doing so left the bubble permanently hidden
+ // once a user had visited the builder.
+ useEffect(() => {
+ const applyVisibility = () => {
+ if (!window.$chatwoot) return;
+ if (isBuilderPath(pathname)) {
+ window.$chatwoot.toggle?.("close");
+ window.$chatwoot.toggleBubbleVisibility?.("hide");
+ } else {
+ window.$chatwoot.toggleBubbleVisibility?.("show");
+ }
+ };
+
+ // The SDK may not be ready on first navigation; apply once it fires
+ // `chatwoot:ready`, otherwise apply immediately.
+ if (window.$chatwoot) {
+ applyVisibility();
+ return;
+ }
+
+ window.addEventListener("chatwoot:ready", applyVisibility, { once: true });
+ return () => window.removeEventListener("chatwoot:ready", applyVisibility);
}, [pathname]);
return null;
diff --git a/ui/src/components/lead-forms/EnterpriseModal.tsx b/ui/src/components/lead-forms/EnterpriseModal.tsx
index 052b2871..56af0eee 100644
--- a/ui/src/components/lead-forms/EnterpriseModal.tsx
+++ b/ui/src/components/lead-forms/EnterpriseModal.tsx
@@ -1,5 +1,6 @@
"use client";
+import Cal from "@calcom/embed-react";
import { ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -35,6 +36,8 @@ export function EnterpriseModal({ open, onOpenChange, source, prefill }: Enterpr
const [emailError, setEmailError] = useState(null);
const [captchaActive, setCaptchaActive] = useState(false);
const [submitting, setSubmitting] = useState(false);
+ // Cal.com booking link from the server's response (the server decides; the app only renders).
+ const [calLink, setCalLink] = useState(null);
// The deployment question is only surfaced for custom-volume / Contact-Us /
// pricing-custom-volume entry points; elsewhere it is hidden and the payload
@@ -46,6 +49,7 @@ export function EnterpriseModal({ open, onOpenChange, source, prefill }: Enterpr
setEmailError(null);
setCaptchaActive(false);
setSubmitting(false);
+ setCalLink(null);
};
const onFieldsChange = (patch: Partial) => {
@@ -89,7 +93,7 @@ export function EnterpriseModal({ open, onOpenChange, source, prefill }: Enterpr
setCaptchaActive(false);
setSubmitting(true);
try {
- await submitLead({
+ const result = await submitLead({
kind: "enterprise",
source,
origin,
@@ -105,15 +109,46 @@ export function EnterpriseModal({ open, onOpenChange, source, prefill }: Enterpr
agentGoal: value.agentGoal,
},
});
- toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
- reset();
- onOpenChange(false);
+ // The server decides whether to return a booking link; if it does, show the calendar
+ // inline, else the email note. The app only reads the response — no logic of its own.
+ if (result?.show_calendar && result.cal_link) {
+ setSubmitting(false);
+ setCalLink(result.cal_link);
+ } else {
+ toast.success("Check your inbox - we just emailed you the next steps (give it a minute).");
+ reset();
+ onOpenChange(false);
+ }
} catch {
toast.error("Something went wrong. Please try again.");
setSubmitting(false);
}
};
+ // Booking state: the server returned a booking link — show the inline calendar in the modal.
+ if (calLink) {
+ return (
+ { if (!o) reset(); onOpenChange(o); }}
+ icon={ShieldCheck}
+ eyebrow="Enterprise"
+ title="Book a Strategy Call"
+ description="Pick a time that works for you."
+ primary={{ label: "Done", onClick: () => { reset(); onOpenChange(false); } }}
+ >
+ {/* Compact, zoomed-out calendar: render it larger, scale to 0.8, and clip the layout box left behind. */}
+
+
+
+
+ );
+ }
+
return (
(null);
// Prefill the email from the logged-in user when the modal opens (don't clobber edits).
useEffect(() => {
@@ -59,6 +62,7 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
const reset = () => {
setName(""); setCompany(""); setEmail(""); setJobTitle(""); setAgentGoal("");
setPhone(""); setVolume(""); setCaptchaActive(false); setSubmitting(false);
+ setCalLink(null);
};
// Required fields, independent of the anti-spam check (which is revealed only
@@ -88,21 +92,52 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
setCaptchaActive(false);
setSubmitting(true);
try {
- await submitLead({
+ const result = await submitLead({
kind: "hire_expert",
source,
origin,
payload: { name, company, email, jobTitle, agentGoal, phone, volume },
});
- toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
- reset();
- onOpenChange(false);
+ // The server decides whether to return a booking link; if it does, show the calendar
+ // inline, else the email note. The app only reads the response — no logic of its own.
+ if (result?.show_calendar && result.cal_link) {
+ setSubmitting(false);
+ setCalLink(result.cal_link);
+ } else {
+ toast.success("Check your inbox - we just emailed you the next steps (give it a minute).");
+ reset();
+ onOpenChange(false);
+ }
} catch {
toast.error("Something went wrong. Please try again.");
setSubmitting(false);
}
};
+ // Booking state: the server returned a booking link — show the inline calendar in the modal.
+ if (calLink) {
+ return (
+ { if (!o) reset(); onOpenChange(o); }}
+ icon={Sparkles}
+ eyebrow="Done-for-you"
+ title="Grab a time with our team"
+ description="Pick a time that works for you."
+ primary={{ label: "Done", onClick: () => { reset(); onOpenChange(false); } }}
+ >
+ {/* Compact, zoomed-out calendar: render it larger, scale to 0.8, and clip the layout box left behind. */}
+
+
+
+
+ );
+ }
+
return (
{
+// Shape the lead endpoints return: ok + the server's calendar verdict. The decision is
+// server-side — the app only RENDERS show_calendar; it holds no qualification logic.
+export type LeadResult = {
+ ok: boolean;
+ show_calendar?: boolean;
+ cal_link?: string | null;
+};
+
+// POST a JSON body to the onboarding service (public — no auth header). Returns the parsed
+// body on success, or null on a non-2xx / network error / timeout (best-effort, never throws).
+async function post(path: string, body: unknown): Promise {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
@@ -28,10 +37,13 @@ async function post(path: string, body: unknown): Promise {
// at least observable.
if (!res.ok) {
console.error(`[onboarding] POST ${path} failed with HTTP ${res.status}`);
+ return null;
}
+ return (await res.json()) as LeadResult;
} catch (err) {
// Network error, or the timeout aborted the request. Never block the user.
console.error(`[onboarding] POST ${path} did not complete:`, err);
+ return null;
} finally {
clearTimeout(timer);
}
@@ -43,12 +55,13 @@ const LEAD_PATH: Record<"hire_expert" | "enterprise", string> = {
enterprise: "/api/v1/leads/enterprise",
};
-// Persist a lead submission (hire-expert / enterprise). Email is in the body.
+// Persist a lead submission (hire-expert / enterprise). Email is in the body. Returns the
+// server's verdict (show_calendar / cal_link) so the modal can embed the calendar.
export async function postLeadToService(
kind: "hire_expert" | "enterprise",
body: Record,
-): Promise {
- await post(LEAD_PATH[kind], body);
+): Promise {
+ return post(LEAD_PATH[kind], body);
}
// Persist an onboarding submission (or skip — body carries `skipped`).
diff --git a/ui/src/components/lead-forms/submitLead.ts b/ui/src/components/lead-forms/submitLead.ts
index 70579328..3456f90a 100644
--- a/ui/src/components/lead-forms/submitLead.ts
+++ b/ui/src/components/lead-forms/submitLead.ts
@@ -7,9 +7,9 @@ import posthog from "posthog-js";
import { PostHogEvent } from "@/constants/posthog-events";
-import { detectCountry } from "./detectCountry";
+import { detectCountry, detectTimezone } from "./detectCountry";
import type { LeadKind, LeadOrigin, LeadSource } from "./leadFieldOptions";
-import { postLeadToService } from "./onboardingServiceClient";
+import { type LeadResult, postLeadToService } from "./onboardingServiceClient";
const SUBMIT_EVENT: Record = {
hire_expert: PostHogEvent.HIRE_EXPERT_SUBMITTED,
@@ -25,12 +25,13 @@ export interface SubmitLeadArgs {
payload: Record;
}
-export async function submitLead({ kind, source, origin, payload }: SubmitLeadArgs): Promise {
- // `country` is detected silently (timezone/locale) and sent in the body — no visible
- // field. It feeds the founders-notification email subject server-side.
- const body = { source, origin, country: detectCountry(), ...payload };
+export async function submitLead({ kind, source, origin, payload }: SubmitLeadArgs): Promise {
+ // `country` (name) + `timezone` (raw IANA) are detected silently — no visible fields.
+ // Both are neutral analytics in the body; the backend alone decides anything from them
+ // . `country` also feeds the email subject.
+ const body = { source, origin, country: detectCountry(), timezone: detectTimezone(), ...payload };
// PostHog capture — the durable record, always fired.
posthog.capture(SUBMIT_EVENT[kind], body);
- // Persist to the separate user_onboarding service (best-effort, public).
- await postLeadToService(kind, body);
+ // Persist to the separate user_onboarding service (best-effort, public); return its verdict.
+ return postLeadToService(kind, body);
}