mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-01 08:59:46 +02:00
embed cal and chatwoot bubble missing fix (#483)
This commit is contained in:
parent
090d042a78
commit
075b1389f3
10 changed files with 194 additions and 48 deletions
30
ui/package-lock.json
generated
30
ui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue