import { useRouter } from "next/router"; import type { CSSProperties } from "react"; import { useState, useEffect } from "react"; import type { BookerStore } from "@calcom/features/bookings/Booker/store"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; import type { Message } from "./embed"; import { sdkActionManager } from "./sdk-event"; type Theme = "dark" | "light"; export type EmbedThemeConfig = Theme | "auto"; export type UiConfig = { hideEventTypeDetails?: boolean; // If theme not provided we would get null theme?: EmbedThemeConfig | null; styles?: EmbedStyles & EmbedNonStylesConfig; //TODO: Extract from tailwind the list of all custom variables and support them in auto-completion as well as runtime validation. Followup with listing all variables in Embed Snippet Generator UI. cssVarsPerTheme?: Record>; layout?: BookerLayouts; }; type SetStyles = React.Dispatch>; type setNonStylesConfig = React.Dispatch>; /** * This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost. */ const embedStore = { // Store all embed styles here so that as and when new elements are mounted, styles can be applied to it. styles: {} as EmbedStyles | undefined, nonStyles: {} as EmbedNonStylesConfig | undefined, namespace: null as string | null, embedType: undefined as undefined | null | string, // Store all React State setters here. reactStylesStateSetters: {} as Record, reactNonStylesStateSetters: {} as Record, parentInformedAboutContentHeight: false, windowLoadEventFired: false, setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined, theme: undefined as UiConfig["theme"], uiConfig: undefined as Omit | undefined, /** * We maintain a list of all setUiConfig setters that are in use at the moment so that we can update all those components. */ setUiConfig: [] as ((arg0: UiConfig) => void)[], layout: BookerLayouts, }; declare global { interface Window { CalEmbed: { __logQueue?: unknown[]; embedStore: typeof embedStore; applyCssVars: (cssVarsPerTheme: UiConfig["cssVarsPerTheme"]) => void; setLayout?: BookerStore["setLayout"]; }; CalComPageStatus: string; isEmbed?: () => boolean; getEmbedNamespace: () => string | null; getEmbedTheme: () => EmbedThemeConfig | null; } } let isSafariBrowser = false; const isBrowser = typeof window !== "undefined"; if (isBrowser) { window.CalEmbed = window?.CalEmbed || {}; window.CalEmbed.embedStore = embedStore; const ua = navigator.userAgent.toLowerCase(); isSafariBrowser = ua.includes("safari") && !ua.includes("chrome"); if (isSafariBrowser) { log("Safari Detected: Using setTimeout instead of rAF"); } } function runAsap(fn: (...arg: unknown[]) => void) { if (isSafariBrowser) { // https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/ return setTimeout(fn, 50); } return requestAnimationFrame(fn); } function log(...args: unknown[]) { if (isBrowser) { const namespace = getNamespace(); const searchParams = new URL(document.URL).searchParams; const logQueue = (window.CalEmbed.__logQueue = window.CalEmbed.__logQueue || []); args.push({ ns: namespace, url: document.URL, }); args.unshift("CAL:"); logQueue.push(args); if (searchParams.get("debug")) { console.log(...args); } } } // Only allow certain styles to be modified so that when we make any changes to HTML, we know what all embed styles might be impacted. // Keep this list to minimum, only adding those styles which are really needed. interface EmbedStyles { body?: Pick; eventTypeListItem?: Pick; enabledDateButton?: Pick; disabledDateButton?: Pick; availabilityDatePicker?: Pick; } interface EmbedNonStylesConfig { /** Default would be center */ align?: "left"; branding?: { brandColor?: string; }; } const setEmbedStyles = (stylesConfig: EmbedStyles) => { embedStore.styles = stylesConfig; for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) { setEmbedStyle((styles) => { return { ...styles, ...stylesConfig, }; }); } }; const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => { embedStore.nonStyles = stylesConfig; for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) { setEmbedStyle((styles) => { return { ...styles, ...stylesConfig, }; }); } }; const registerNewSetter = ( registration: | { elementName: keyof EmbedStyles; setState: SetStyles; styles: true; } | { elementName: keyof EmbedNonStylesConfig; setState: setNonStylesConfig; styles: false; } ) => { // It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe. // So, we should call the setter immediately with available embedStyles if (registration.styles) { embedStore.reactStylesStateSetters[registration.elementName as keyof EmbedStyles] = registration.setState; registration.setState(embedStore.styles || {}); return () => { delete embedStore.reactStylesStateSetters[registration.elementName]; }; } else { embedStore.reactNonStylesStateSetters[registration.elementName as keyof EmbedNonStylesConfig] = registration.setState; registration.setState(embedStore.nonStyles || {}); return () => { delete embedStore.reactNonStylesStateSetters[registration.elementName]; }; } }; function isValidNamespace(ns: string | null | undefined) { return typeof ns !== "undefined" && ns !== null; } export const useEmbedTheme = () => { const router = useRouter(); const [theme, setTheme] = useState(embedStore.theme || (router.query.theme as typeof embedStore.theme)); useEffect(() => { router.events.on("routeChangeComplete", () => { sdkActionManager?.fire("__routeChanged", {}); }); }, [router.events]); embedStore.setTheme = setTheme; return theme; }; /** * It serves following purposes * - Gives consistent values for ui config even after Soft Navigation. When a new React component mounts, it would ensure that the component get's the correct value of ui config * - Ensures that all the components using useEmbedUiConfig are updated when ui config changes. It is done by maintaining a list of all non-stale setters. */ export const useEmbedUiConfig = () => { const [uiConfig, setUiConfig] = useState(embedStore.uiConfig || {}); embedStore.setUiConfig.push(setUiConfig); useEffect(() => { return () => { const foundAtIndex = embedStore.setUiConfig.findIndex((item) => item === setUiConfig); // Keep removing the setters that are stale embedStore.setUiConfig.splice(foundAtIndex, 1); }; }); return uiConfig; }; // TODO: Make it usable as an attribute directly instead of styles value. It would allow us to go beyond styles e.g. for debugging we can add a special attribute indentifying the element on which UI config has been applied export const useEmbedStyles = (elementName: keyof EmbedStyles) => { const [, setStyles] = useState({}); useEffect(() => { return registerNewSetter({ elementName, setState: setStyles, styles: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const styles = embedStore.styles || {}; // Always read the data from global embedStore so that even across components, the same data is used. return styles[elementName] || {}; }; export const useEmbedNonStylesConfig = (elementName: keyof EmbedNonStylesConfig) => { const [, setNonStyles] = useState({} as EmbedNonStylesConfig); useEffect(() => { return registerNewSetter({ elementName, setState: setNonStyles, styles: false }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Always read the data from global embedStore so that even across components, the same data is used. const nonStyles = embedStore.nonStyles || {}; return nonStyles[elementName] || {}; }; export const useIsBackgroundTransparent = () => { let isBackgroundTransparent = false; // TODO: Background should be read as ui.background and not ui.body.background const bodyEmbedStyles = useEmbedStyles("body"); if (bodyEmbedStyles.background === "transparent") { isBackgroundTransparent = true; } return isBackgroundTransparent; }; export const useBrandColors = () => { // TODO: Branding shouldn't be part of ui.styles. It should exist as ui.branding. const brandingColors = useEmbedNonStylesConfig("branding") as EmbedNonStylesConfig["branding"]; return brandingColors || {}; }; function getNamespace() { if (isValidNamespace(embedStore.namespace)) { // Persist this so that even if query params changed, we know that it is an embed. return embedStore.namespace; } if (isBrowser) { const namespace = window?.getEmbedNamespace?.() ?? null; embedStore.namespace = namespace; return namespace; } } function getEmbedType() { if (embedStore.embedType) { return embedStore.embedType; } if (isBrowser) { const url = new URL(document.URL); const embedType = (embedStore.embedType = url.searchParams.get("embedType")); return embedType; } } export const useIsEmbed = (embedSsr?: boolean) => { const [isEmbed, setIsEmbed] = useState(embedSsr); useEffect(() => { const namespace = getNamespace(); const _isValidNamespace = isValidNamespace(namespace); if (parent !== window && !_isValidNamespace) { log( "Looks like you have iframed cal.com but not using Embed Snippet. Directly using an iframe isn't recommended." ); } setIsEmbed(window?.isEmbed?.() || false); }, []); return isEmbed; }; export const useEmbedType = () => { const [state, setState] = useState(null); useEffect(() => { setState(getEmbedType()); }, []); return state; }; function unhideBody() { document.body.style.visibility = "visible"; } // It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"}) const methods = { ui: function style(uiConfig: UiConfig) { // TODO: Create automatic logger for all methods. Useful for debugging. log("Method: ui called", uiConfig); const stylesConfig = uiConfig.styles; if (stylesConfig) { console.warn( "Cal.com Embed: `styles` prop is deprecated. Use `cssVarsPerTheme` instead to achieve the same effect. Here is a list of CSS variables that are supported. https://github.com/calcom/cal.com/blob/main/packages/config/tailwind-preset.js#L19" ); } // body can't be styled using React state hook as it is generated by _document.tsx which doesn't support hooks. if (stylesConfig?.body?.background) { document.body.style.background = stylesConfig.body.background as string; } if (uiConfig.theme) { embedStore.theme = uiConfig.theme as UiConfig["theme"]; if (embedStore.setTheme) { embedStore.setTheme(uiConfig.theme); } } // Merge new values over the old values uiConfig = { ...embedStore.uiConfig, ...uiConfig, }; if (uiConfig.cssVarsPerTheme) { window.CalEmbed.applyCssVars(uiConfig.cssVarsPerTheme); } if (embedStore.setUiConfig) { runAllUiSetters(uiConfig); } setEmbedStyles(stylesConfig || {}); setEmbedNonStyles(stylesConfig || {}); }, // eslint-disable-next-line @typescript-eslint/no-unused-vars parentKnowsIframeReady: (_unused: unknown) => { log("Method: `parentKnowsIframeReady` called"); runAsap(function tryInformingLinkReady() { // TODO: Do it by attaching a listener for change in parentInformedAboutContentHeight if (!embedStore.parentInformedAboutContentHeight) { runAsap(tryInformingLinkReady); return; } // No UI change should happen in sight. Let the parent height adjust and in next cycle show it. unhideBody(); sdkActionManager?.fire("linkReady", {}); }); }, }; export type InterfaceWithParent = { // Ensure that only one argument is read by the method [key in keyof typeof methods]: (firstAndOnlyArg: Parameters<(typeof methods)[key]>[number]) => void; }; export const interfaceWithParent: InterfaceWithParent = methods; const messageParent = (data: CustomEvent["detail"]) => { parent.postMessage( { originator: "CAL", ...data, }, "*" ); }; function keepParentInformedAboutDimensionChanges() { let knownIframeHeight: number | null = null; let knownIframeWidth: number | null = null; let isFirstTime = true; let isWindowLoadComplete = false; runAsap(function informAboutScroll() { if (document.readyState !== "complete") { // Wait for window to load to correctly calculate the initial scroll height. runAsap(informAboutScroll); return; } if (!isWindowLoadComplete) { // On Safari, even though document.readyState is complete, still the page is not rendered and we can't compute documentElement.scrollHeight correctly // Postponing to just next cycle allow us to fix this. setTimeout(() => { isWindowLoadComplete = true; informAboutScroll(); }, 100); return; } if (!embedStore.windowLoadEventFired) { sdkActionManager?.fire("__windowLoadComplete", {}); } embedStore.windowLoadEventFired = true; // Use the dimensions of main element as in most places there is max-width restriction on it and we just want to show the main content. // It avoids the unwanted padding outside main tag. const mainElement = document.getElementsByClassName("main")[0] || document.getElementsByTagName("main")[0] || document.documentElement; const documentScrollHeight = document.documentElement.scrollHeight; const documentScrollWidth = document.documentElement.scrollWidth; if (!(mainElement instanceof HTMLElement)) { throw new Error("Main element should be an HTMLElement"); } const mainElementStyles = getComputedStyle(mainElement); // Use, .height as that gives more accurate value in floating point. Also, do a ceil on the total sum so that whatever happens there is enough iframe size to avoid scroll. const contentHeight = Math.ceil( parseFloat(mainElementStyles.height) + parseFloat(mainElementStyles.marginTop) + parseFloat(mainElementStyles.marginBottom) ); const contentWidth = Math.ceil( parseFloat(mainElementStyles.width) + parseFloat(mainElementStyles.marginLeft) + parseFloat(mainElementStyles.marginRight) ); // During first render let iframe tell parent that how much is the expected height to avoid scroll. // Parent would set the same value as the height of iframe which would prevent scroll. // On subsequent renders, consider html height as the height of the iframe. If we don't do this, then if iframe get's bigger in height, it would never shrink const iframeHeight = isFirstTime ? documentScrollHeight : contentHeight; const iframeWidth = isFirstTime ? documentScrollWidth : contentWidth; embedStore.parentInformedAboutContentHeight = true; if (!iframeHeight || !iframeWidth) { runAsap(informAboutScroll); return; } if (knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth) { knownIframeHeight = iframeHeight; knownIframeWidth = iframeWidth; // FIXME: This event shouldn't be subscribable by the user. Only by the SDK. sdkActionManager?.fire("__dimensionChanged", { iframeHeight, iframeWidth, isFirstTime, }); } isFirstTime = false; // Parent Counterpart would change the dimension of iframe and thus page's dimension would be impacted which is recursive. // It should stop ideally by reaching a hiddenHeight value of 0. // FIXME: If 0 can't be reached we need to just abandon our quest for perfect iframe and let scroll be there. Such case can be logged in the wild and fixed later on. runAsap(informAboutScroll); }); } if (isBrowser) { log("Embed SDK loaded", { isEmbed: window?.isEmbed?.() || false }); const url = new URL(document.URL); embedStore.theme = window?.getEmbedTheme?.(); embedStore.uiConfig = { // TODO: Add theme as well here // theme: layout: url.searchParams.get("layout") as BookerLayouts, }; if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) { log("Initializing embed-iframe"); // HACK const pageStatus = window.CalComPageStatus; // If embed link is opened in top, and not in iframe. Let the page be visible. if (top === window) { unhideBody(); } sdkActionManager?.on("*", (e) => { const detail = e.detail; log(detail); messageParent(detail); }); window.addEventListener("message", (e) => { const data: Message = e.data; if (!data) { return; } const method: keyof typeof interfaceWithParent = data.method; if (data.originator === "CAL" && typeof method === "string") { interfaceWithParent[method]?.(data.arg as never); } }); document.addEventListener("click", (e) => { if (!e.target || !(e.target instanceof Node)) { return; } const mainElement = document.getElementsByClassName("main")[0] || document.getElementsByTagName("main")[0] || document.documentElement; if (e.target.contains(mainElement)) { sdkActionManager?.fire("__closeIframe", {}); } }); if (!pageStatus || pageStatus == "200") { keepParentInformedAboutDimensionChanges(); sdkActionManager?.fire("__iframeReady", {}); } else sdkActionManager?.fire("linkFailed", { code: pageStatus, msg: "Problem loading the link", data: { url: document.URL, }, }); } } function runAllUiSetters(uiConfig: UiConfig) { // Update EmbedStore so that when a new react component mounts, useEmbedUiConfig can get the persisted value from embedStore.uiConfig embedStore.uiConfig = uiConfig; embedStore.setUiConfig.forEach((setUiConfig) => setUiConfig(uiConfig)); }