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); }