embed cal and chatwoot bubble missing fix (#483)

This commit is contained in:
PK 2026-06-29 13:32:55 -04:00 committed by GitHub
parent 090d042a78
commit 075b1389f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 194 additions and 48 deletions

30
ui/package-lock.json generated
View file

@ -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",

View file

@ -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",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

@ -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/<id> 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;

View file

@ -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<string | null>(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<string | null>(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<EnterpriseFieldsValue>) => {
@ -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 (
<LeadModalShell
open={open}
onOpenChange={(o) => { 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. */}
<div className="overflow-hidden" style={{ height: "440px" }}>
<Cal
calLink={calLink}
config={{ layout: "month_view", name: value.name, email: value.workEmail }}
style={{ width: "113.64%", height: "500px", overflow: "auto", transform: "scale(0.88)", transformOrigin: "top left" }}
/>
</div>
</LeadModalShell>
);
}
return (
<LeadModalShell
open={open}

View file

@ -1,5 +1,6 @@
"use client";
import Cal from "@calcom/embed-react";
import { Sparkles } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@ -50,6 +51,8 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
const [volume, setVolume] = useState("");
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<string | null>(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 (
<LeadModalShell
open={open}
onOpenChange={(o) => { 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. */}
<div className="overflow-hidden" style={{ height: "440px" }}>
<Cal
calLink={calLink}
config={{ layout: "month_view", name, email }}
style={{ width: "113.64%", height: "500px", overflow: "auto", transform: "scale(0.88)", transformOrigin: "top left" }}
/>
</div>
</LeadModalShell>
);
}
return (
<LeadModalShell
open={open}

View file

@ -139,7 +139,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
});
// Only the on-prem/enterprise lead path sends an email; plain onboarding
// does not. Confirm the email just for this path.
toast.success("Check your inbox we just emailed you the next steps (give it a minute).");
toast.success("Check your inbox - we just emailed you the next steps (give it a minute).");
}
} catch {
// Swallowed — the user is already in the product; calls are timeout-bounded.

View file

@ -66,6 +66,17 @@ function localeRegion(): string | undefined {
}
}
// Raw IANA browser timezone (e.g. "America/New_York"). A neutral analytics value sent with
// leads; the BACKEND alone decides anything from it — this app holds no such logic.
// Returns undefined if Intl is unavailable.
export function detectTimezone(): string | undefined {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
} catch {
return undefined;
}
}
export function detectCountry(): string | undefined {
let iso: string | undefined;
try {

View file

@ -13,8 +13,17 @@ const BASE_URL = process.env.NEXT_PUBLIC_ONBOARDING_API_URL || "https://api-lead
// failures are surfaced via console.error (Sentry breadcrumbs) but never thrown.
const TIMEOUT_MS = 6000;
// POST a JSON body to the onboarding service (public — no auth header).
async function post(path: string, body: unknown): Promise<void> {
// 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<LeadResult | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
@ -28,10 +37,13 @@ async function post(path: string, body: unknown): Promise<void> {
// 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<string, unknown>,
): Promise<void> {
await post(LEAD_PATH[kind], body);
): Promise<LeadResult | null> {
return post(LEAD_PATH[kind], body);
}
// Persist an onboarding submission (or skip — body carries `skipped`).

View file

@ -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<LeadKind, string> = {
hire_expert: PostHogEvent.HIRE_EXPERT_SUBMITTED,
@ -25,12 +25,13 @@ export interface SubmitLeadArgs {
payload: Record<string, unknown>;
}
export async function submitLead({ kind, source, origin, payload }: SubmitLeadArgs): Promise<void> {
// `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<LeadResult | null> {
// `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);
}