diff --git a/apps/web/components/Embed.tsx b/apps/web/components/Embed.tsx index 8911cb536b..4d5a45f3e0 100644 --- a/apps/web/components/Embed.tsx +++ b/apps/web/components/Embed.tsx @@ -167,7 +167,7 @@ const Codes: Record string>> = { const height = getDimension(previewState.inline.height); return code` import Cal, { getCalApi } from "@calcom/embed-react"; - +import { useEffect } from "react"; function MyComponent() { useEffect(()=>{ (async function () { @@ -187,12 +187,12 @@ function MyComponent() { }) => { return code` import Cal, { getCalApi } from "@calcom/embed-react"; - +import { useEffect } from "react"; function MyComponent() { useEffect(()=>{ (async function () { const cal = await getCalApi(); - Cal("floatingButton", ${floatingButtonArg}); + cal("floatingButton", ${floatingButtonArg}); ${uiInstructionCode} })(); }, []) @@ -201,7 +201,7 @@ function MyComponent() { "element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => { return code` import Cal, { getCalApi } from "@calcom/embed-react"; - +import { useEffect } from "react"; function MyComponent() { useEffect(()=>{ (async function () { diff --git a/apps/web/playwright/fixtures/embeds.ts b/apps/web/playwright/fixtures/embeds.ts index db29b9e5df..35e33ae21a 100644 --- a/apps/web/playwright/fixtures/embeds.ts +++ b/apps/web/playwright/fixtures/embeds.ts @@ -45,7 +45,7 @@ export const createEmbedsFixture = (page: Page) => { } api("on", { action: "*", - callback: (e: any) => { + callback: (e) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index 84bd6e647e..be5f9b1908 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -4,6 +4,16 @@ - + diff --git a/packages/embeds/embed-core/playground.ts b/packages/embeds/embed-core/playground.ts new file mode 100644 index 0000000000..d4633cec7c --- /dev/null +++ b/packages/embeds/embed-core/playground.ts @@ -0,0 +1,321 @@ +import type { GlobalCal, GlobalCalWithoutNs } from "./src/embed"; + +type A = GlobalCalWithoutNs; +const Cal = window.Cal as GlobalCal; +const callback = function (e) { + const detail = e.detail; + const namespace = detail.namespace || "default"; + + if (detail.type === "linkReady") { + document.querySelector(`#cal-booking-place-${namespace} .loader`).remove(); + } else if (detail.type === "linkFailed") { + document.querySelector(`#cal-booking-place-${namespace} .loader`).remove(); + } + document.querySelector(`#cal-booking-place-${namespace} .last-action`).innerHTML = JSON.stringify(e.detail); +}; + +const searchParams = new URL(document.URL).searchParams; +const only = window.only; + +if (only === "all" || only === "ns:default") { + Cal("init", { + debug: true, + calOrigin: "http://localhost:3000", + }); + + Cal("inline", { + elementOrSelector: "#cal-booking-place-default .place", + calLink: "pro?case=1", + config: { + iframeAttrs: { + id: "cal-booking-place-default-iframe", + }, + name: "John", + email: "johndoe@gmail.com", + notes: "Test Meeting", + guests: ["janedoe@example.com", "test@example.com"], + theme: "dark", + }, + }); + Cal("on", { + action: "*", + callback, + }); +} +if (only === "all" || only === "ns:second") { + // Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal + Cal("init", "second", { + debug: true, + origin: "http://localhost:3000", + }); + + // Bulk API is supported - Keep all configuration at one place. + // Not able to type Bulk Api correctly when the first argument itself is an array. + Cal.ns.second([ + "inline", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + { + elementOrSelector: "#cal-booking-place-second .place", + calLink: "pro?case=2", + config: { + iframeAttrs: { + id: "cal-booking-place-second-iframe", + }, + }, + }, + ]); + Cal.ns.second("on", { + action: "*", + callback, + }); +} +if (only === "all" || only === "ns:third") { + // Create a namespace "third". It can be accessed as Cal.ns.second with the exact same API as Cal + Cal("init", "third", { + debug: true, + origin: "http://localhost:3000", + }); + + Cal.ns.third( + [ + "inline", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + { + elementOrSelector: "#cal-booking-place-third .place", + calLink: "pro/30min", + config: { + iframeAttrs: { + id: "cal-booking-place-third-iframe", + }, + }, + }, + ], + [ + "ui", + { + styles: { + body: { + background: "transparent", + }, + branding: { + brandColor: "#81e61c", + lightColor: "#494545", + lighterColor: "#4c4848", + lightestColor: "#7c7777", + highlightColor: "#9b0e0e", + medianColor: "black", + }, + enabledDateButton: { + backgroundColor: "red", + }, + disabledDateButton: { + backgroundColor: "green", + }, + }, + }, + ] + ); + Cal.ns.third("on", { + action: "*", + callback, + }); +} +if (only === "all" || only === "ns:fourth") { + Cal("init", "fourth", { + debug: true, + origin: "http://localhost:3000", + }); + Cal.ns.fourth( + [ + "inline", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + { + elementOrSelector: "#cal-booking-place-fourth .place", + calLink: "team/seeded-team", + config: { + iframeAttrs: { + id: "cal-booking-place-fourth-iframe", + }, + }, + }, + ], + [ + "ui", + { + styles: { + body: { + background: "transparent", + }, + branding: { + brandColor: "#81e61c", + lightColor: "#494545", + lighterColor: "#4c4848", + lightestColor: "#7c7777", + highlightColor: "#9b0e0e", + medianColor: "black", + }, + }, + }, + ] + ); + Cal.ns.fourth("on", { + action: "*", + callback, + }); +} +if (only === "all" || only === "ns:fifth") { + Cal("init", "fifth", { + debug: true, + origin: "http://localhost:3000", + }); + Cal.ns.fifth([ + "inline", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + { + elementOrSelector: "#cal-booking-place-fifth .place", + calLink: "team/seeded-team/collective-seeded-team-event", + config: { + iframeAttrs: { + id: "cal-booking-place-fifth-iframe", + }, + }, + }, + ]); + Cal.ns.fifth("on", { + action: "*", + callback, + }); +} +if (only === "all" || only === "prerender-test") { + Cal("init", "prerendertestLightTheme", { + debug: true, + origin: "http://localhost:3000", + }); + Cal.ns.prerendertestLightTheme("preload", { + calLink: "free", + }); +} + +if (only === "all" || only === "inline-routing-form") { + Cal("init", "inline-routing-form", { + debug: true, + origin: "http://localhost:3000", + }); + Cal.ns["inline-routing-form"]([ + "inline", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + { + elementOrSelector: "#cal-booking-place-inline-routing-form .place", + calLink: "forms/948ae412-d995-4865-875a-48302588de03", + config: { + iframeAttrs: { + id: "cal-booking-place-inline-routing-form-iframe", + }, + }, + }, + ]); +} + +if (only === "all" || only === "hideEventTypeDetails") { + const identifier = "hideEventTypeDetails"; + Cal("init", identifier, { + debug: true, + origin: "http://localhost:3000", + }); + + Cal.ns.hideEventTypeDetails( + [ + "inline", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + { + elementOrSelector: `#cal-booking-place-${identifier} .place`, + calLink: "free/30min", + config: { + iframeAttrs: { + id: `cal-booking-place-${identifier}-iframe`, + }, + }, + }, + ], + [ + "ui", + { + hideEventTypeDetails: true, + }, + ] + ); +} + +Cal("init", "popupDarkTheme", { + debug: true, + origin: "http://localhost:3000", +}); + +Cal("init", "popupHideEventTypeDetails", { + debug: true, + origin: "http://localhost:3000", +}); + +Cal.ns.popupHideEventTypeDetails("ui", { + hideEventTypeDetails: true, +}); + +Cal("init", "popupReschedule", { + debug: true, + origin: "http://localhost:3000", +}); + +Cal("init", "popupAutoTheme", { + debug: true, + origin: "http://localhost:3000", +}); +Cal("init", "popupTeamLinkLightTheme", { + debug: true, + origin: "http://localhost:3000", +}); +Cal("init", "popupTeamLinkDarkTheme", { + debug: true, + origin: "http://localhost:3000", +}); + +Cal("init", "popupTeamLinkDarkTheme", { + debug: true, + origin: "http://localhost:3000", +}); + +Cal("init", "popupTeamLinksList", { + debug: true, + origin: "http://localhost:3000", +}); + +Cal("init", "popupPaidEvent", { + debug: true, + origin: "http://localhost:3000", +}); + +Cal("init", "floatingButton", { + debug: true, + origin: "http://localhost:3000", +}); + +Cal("init", "routingFormAuto", { + debug: true, + origin: "http://localhost:3000", +}); + +Cal("init", "routingFormDark", { + debug: true, + origin: "http://localhost:3000", +}); + +if (only === "all" || only == "ns:floatingButton") { + Cal.ns.floatingButton("floatingButton", { + calLink: "pro", + }); +} diff --git a/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts b/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts index 79e83175ea..64dcb55f2a 100644 --- a/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts +++ b/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts @@ -1,5 +1,36 @@ import getFloatingButtonHtml from "./FloatingButtonHtml"; +type ModalTargetDatasetProps = { + calLink: string; + calNamespace: string; + calOrigin: string; +}; + +type CamelCase = T extends `${infer U}${infer V}` ? `${Uppercase}${V}` : T; + +type HyphenatedStringToCamelCase = S extends `${infer T}-${infer U}` + ? `${T}${HyphenatedStringToCamelCase>}` + : CamelCase; + +type HyphenatedDataStringToCamelCase = S extends `data-${infer U}` + ? HyphenatedStringToCamelCase + : S; + +const dataAttributes = [ + "data-button-text", + "data-hide-button-icon", + "data-button-position", + "data-button-color", + "data-button-text-color", +] as const; + +type DataAttributes = (typeof dataAttributes)[number]; +type DataAttributesCamelCase = HyphenatedDataStringToCamelCase; + +export type FloatingButtonDataset = { + [key in DataAttributesCamelCase]: string; +}; + export class FloatingButton extends HTMLElement { static updatedClassString(position: string, classString: string) { return [ @@ -8,66 +39,69 @@ export class FloatingButton extends HTMLElement { ].join(" "); } + // Button added here triggers the modal on click. So, it has to have the same data attributes as the modal target as well + dataset!: DOMStringMap & FloatingButtonDataset & ModalTargetDatasetProps; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore static get observedAttributes() { - return [ - "data-button-text", - "data-hide-button-icon", - "data-button-position", - "data-button-color", - "data-button-text-color", - ]; + return dataAttributes; } - attributeChangedCallback(name: string, oldValue: string, newValue: string) { + attributeChangedCallback(name: DataAttributes, oldValue: string, newValue: string) { + const buttonEl = this.shadowRoot?.querySelector("#button"); + const buttonWrapperEl = this.shadowRoot?.querySelector("button"); + const buttonIconEl = this.shadowRoot?.querySelector("#button-icon"); + + if (!buttonEl) { + throw new Error("#button not found"); + } + if (!buttonWrapperEl) { + throw new Error("button element not found"); + } + if (!buttonIconEl) { + throw new Error("#button-icon not found"); + } + if (name === "data-button-text") { - const buttonEl = this.shadowRoot?.querySelector("#button"); - if (!buttonEl) { - throw new Error("Button not found"); - } buttonEl.textContent = newValue; } else if (name === "data-hide-button-icon") { - const buttonIconEl = this.shadowRoot?.querySelector("#button-icon"); - if (!buttonIconEl) { - throw new Error("Button not found"); - } buttonIconEl.style.display = newValue == "true" ? "none" : "block"; } else if (name === "data-button-position") { - const buttonEl = this.shadowRoot?.querySelector("button"); - if (!buttonEl) { - throw new Error("Button not found"); - } - buttonEl.className = FloatingButton.updatedClassString(newValue, buttonEl.className); + buttonWrapperEl.className = FloatingButton.updatedClassString(newValue, buttonWrapperEl.className); } else if (name === "data-button-color") { - const buttonEl = this.shadowRoot?.querySelector("button"); - if (!buttonEl) { - throw new Error("Button not found"); - } - buttonEl.style.backgroundColor = newValue; + buttonWrapperEl.style.backgroundColor = newValue; } else if (name === "data-button-text-color") { - const buttonEl = this.shadowRoot?.querySelector("button"); - if (!buttonEl) { - throw new Error("Button not found"); - } - buttonEl.style.color = newValue; + buttonWrapperEl.style.color = newValue; + } else { + console.log("Unknown attribute changed", name, oldValue, newValue); } } + + assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } { + if (!this.shadowRoot) { + throw new Error("No shadow root"); + } + } + constructor() { super(); - const buttonText = this.dataset["buttonText"]; - const buttonPosition = this.dataset["buttonPosition"]; - const buttonColor = this.dataset["buttonColor"]; - const buttonTextColor = this.dataset["buttonTextColor"]; + const dataset = this.dataset as FloatingButtonDataset; + const buttonText = dataset["buttonText"]; + const buttonPosition = dataset["buttonPosition"]; + const buttonColor = dataset["buttonColor"]; + const buttonTextColor = dataset["buttonTextColor"]; //TODO: Logic is duplicated over HTML generation and attribute change, keep it at one place - const buttonHtml = ` ${getFloatingButtonHtml({ - buttonText: buttonText!, - buttonClasses: [FloatingButton.updatedClassString(buttonPosition!, "")], - buttonColor: buttonColor!, - buttonTextColor: buttonTextColor!, + const buttonHtml = ` ${getFloatingButtonHtml({ + buttonText: buttonText, + buttonClasses: [FloatingButton.updatedClassString(buttonPosition, "")], + buttonColor: buttonColor, + buttonTextColor: buttonTextColor, })}`; + this.attachShadow({ mode: "open" }); - this.shadowRoot!.innerHTML = buttonHtml; + this.assertHasShadowRoot(); + this.shadowRoot.innerHTML = buttonHtml; } } diff --git a/packages/embeds/embed-core/src/Inline/inline.ts b/packages/embeds/embed-core/src/Inline/inline.ts index 309913982d..25fcbcd6bb 100644 --- a/packages/embeds/embed-core/src/Inline/inline.ts +++ b/packages/embeds/embed-core/src/Inline/inline.ts @@ -1,31 +1,42 @@ -import loaderCss from "../loader.css"; +import loaderCss from "../loader.css?inline"; import { getErrorString } from "../utils"; import inlineHtml from "./inlineHtml"; export class Inline extends HTMLElement { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore static get observedAttributes() { return ["loading"]; } + + assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } { + if (!this.shadowRoot) { + throw new Error("No shadow root"); + } + } + attributeChangedCallback(name: string, oldValue: string, newValue: string) { + this.assertHasShadowRoot(); + const loaderEl = this.shadowRoot.querySelector(".loader"); + const errorEl = this.shadowRoot.querySelector("#error"); + const slotEl = this.shadowRoot.querySelector("slot"); + if (!loaderEl || !slotEl || !errorEl) { + throw new Error("One of loaderEl, slotEl or errorEl is missing"); + } if (name === "loading") { if (newValue == "done") { - this.shadowRoot!.querySelector(".loader")!.style.display = "none"; + loaderEl.style.display = "none"; } else if (newValue === "failed") { - this.shadowRoot!.querySelector(".loader")!.style.display = "none"; - this.shadowRoot!.querySelector("#error")!.style.display = "block"; - this.shadowRoot!.querySelector("slot")!.style.visibility = "hidden"; + loaderEl.style.display = "none"; + slotEl.style.visibility = "hidden"; + errorEl.style.display = "block"; const errorString = getErrorString(this.dataset.errorCode); - this.shadowRoot!.querySelector("#error")!.innerText = errorString; + errorEl.innerText = errorString; } } } constructor() { super(); this.attachShadow({ mode: "open" }); - this.shadowRoot!.innerHTML = `${inlineHtml}`; + this.assertHasShadowRoot(); + this.shadowRoot.innerHTML = `${inlineHtml}`; } } diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts index 12c85e4679..f6f5821907 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts @@ -101,7 +101,7 @@ export class ModalBox extends HTMLElement { constructor() { super(); - const modalHtml = `${modalBoxHtml}`; + const modalHtml = `${modalBoxHtml}`; this.attachShadow({ mode: "open" }); ModalBox.htmlOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index 6b411884f0..5522d91ad0 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -2,18 +2,40 @@ import { useRouter } from "next/router"; import type { CSSProperties } from "react"; import { useState, useEffect } from "react"; +import type { Message } from "./embed"; import { sdkActionManager } from "./sdk-event"; -export interface UiConfig { +export type UiConfig = { hideEventTypeDetails?: boolean; theme?: "dark" | "light" | "auto"; - styles?: EmbedStyles; -} + styles?: EmbedStyles & EmbedNonStylesConfig; +}; + +type SetStyles = React.Dispatch>; +type setNonStylesConfig = React.Dispatch>; + +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: string) => void) | undefined, + theme: undefined as UiConfig["theme"], + uiConfig: undefined as Omit | undefined, + setUiConfig: undefined as ((arg0: UiConfig) => void) | undefined, +}; + declare global { interface Window { CalEmbed: { - __logQueue?: any[]; - embedStore: any; + __logQueue?: unknown[]; + embedStore: typeof embedStore; }; CalComPageStatus: string; isEmbed: () => boolean; @@ -23,28 +45,6 @@ declare global { } } -const embedStore = { - // Store all embed styles here so that as and when new elements are mounted, styles can be applied to it. - styles: {}, - namespace: null, - embedType: undefined, - // Store all React State setters here. - reactStylesStateSetters: {}, - parentInformedAboutContentHeight: false, - windowLoadEventFired: false, -} as { - styles: UiConfig["styles"]; - namespace: string | null; - embedType: undefined | null | string; - reactStylesStateSetters: any; - parentInformedAboutContentHeight: boolean; - windowLoadEventFired: boolean; - theme?: UiConfig["theme"]; - uiConfig?: Omit; - setTheme?: (arg0: string) => void; - setUiConfig?: (arg0: UiConfig) => void; -}; - let isSafariBrowser = false; const isBrowser = typeof window !== "undefined"; @@ -58,7 +58,7 @@ if (isBrowser) { } } -function runAsap(fn: (...arg: any) => void) { +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); @@ -66,7 +66,7 @@ function runAsap(fn: (...arg: any) => void) { return requestAnimationFrame(fn); } -function log(...args: any[]) { +function log(...args: unknown[]) { if (isBrowser) { const namespace = getNamespace(); @@ -95,7 +95,7 @@ interface EmbedStyles { } interface EmbedNonStylesConfig { /** Default would be center */ - align: "left"; + align?: "left"; branding?: { brandColor?: string; lightColor?: string; @@ -108,10 +108,10 @@ interface EmbedNonStylesConfig { }; } -const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => { +const setEmbedStyles = (stylesConfig: EmbedStyles) => { embedStore.styles = stylesConfig; for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) { - (setEmbedStyle as any)((styles: any) => { + setEmbedStyle((styles) => { return { ...styles, ...stylesConfig, @@ -120,15 +120,48 @@ const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => { } }; -const registerNewSetter = (elementName: keyof EmbedStyles | keyof EmbedNonStylesConfig, setStyles: any) => { - embedStore.reactStylesStateSetters[elementName] = setStyles; - // 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 - setStyles(embedStore.styles); +const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => { + embedStore.nonStyles = stylesConfig; + for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) { + setEmbedStyle((styles) => { + return { + ...styles, + ...stylesConfig, + }; + }); + } }; -const removeFromEmbedStylesSetterMap = (elementName: keyof EmbedStyles | keyof EmbedNonStylesConfig) => { - delete embedStore.reactStylesStateSetters[elementName]; +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) { @@ -155,33 +188,28 @@ export const useEmbedUiConfig = () => { // 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 [styles, setStyles] = useState({} as EmbedStyles); + const [, setStyles] = useState({}); useEffect(() => { - registerNewSetter(elementName, setStyles); - // It's important to have an element's embed style be required in only one component. If due to any reason it is required in multiple components, we would override state setter. - return () => { - // Once the component is unmounted, we can remove that state setter. - removeFromEmbedStylesSetterMap(elementName); - }; + 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 [styles, setStyles] = useState({} as EmbedNonStylesConfig); + const [, setNonStyles] = useState({} as EmbedNonStylesConfig); useEffect(() => { - registerNewSetter(elementName, setStyles); - // It's important to have an element's embed style be required in only one component. If due to any reason it is required in multiple components, we would override state setter. - return () => { - // Once the component is unmounted, we can remove that state setter. - removeFromEmbedStylesSetterMap(elementName); - }; + return registerNewSetter({ elementName, setState: setNonStyles, styles: false }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return styles[elementName] || {}; + // 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 = () => { @@ -251,8 +279,8 @@ function unhideBody() { document.body.style.visibility = "visible"; } -// If you add a method here, give type safety to parent manually by adding it to embed.ts. Look for "parentKnowsIframeReady" in it -export const methods = { +// 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); @@ -277,8 +305,10 @@ export const methods = { } setEmbedStyles(stylesConfig || {}); + setEmbedNonStyles(stylesConfig || {}); }, - parentKnowsIframeReady: () => { + // 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 @@ -293,7 +323,14 @@ export const methods = { }, }; -const messageParent = (data: any) => { +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", @@ -396,35 +433,18 @@ if (isBrowser) { sdkActionManager?.on("*", (e) => { const detail = e.detail; - //console.log(detail.fullType, detail.type, detail.data); log(detail); messageParent(detail); }); - // This event should be fired whenever you want to let the content take automatic width which is available. - // Because on cal-iframe we set explicty width to make it look inline and part of page, there is never space available for content to automatically expand - // This is a HACK to quickly tell iframe to go full width and let iframe content adapt to that and set new width. - sdkActionManager?.on("__refreshWidth", () => { - // sdkActionManager?.fire("__dimensionChanged", { - // iframeWidth: 100, - // __unit: "%", - // }); - // runAsap(() => { - // sdkActionManager?.fire("__dimensionChanged", { - // iframeWidth: 100, - // __unit: "%", - // }); - // }); - }); - window.addEventListener("message", (e) => { - const data: Record = e.data; + const data: Message = e.data; if (!data) { return; } - const method: keyof typeof methods = data.method; + const method: keyof typeof interfaceWithParent = data.method; if (data.originator === "CAL" && typeof method === "string") { - methods[method]?.(data.arg); + interfaceWithParent[method]?.(data.arg as never); } }); diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index 253b004fba..87bf77f703 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -1,11 +1,20 @@ +/// import { FloatingButton } from "./FloatingButton/FloatingButton"; import { Inline } from "./Inline/inline"; import { ModalBox } from "./ModalBox/ModalBox"; -import type { methods, UiConfig } from "./embed-iframe"; +import type { InterfaceWithParent, interfaceWithParent, UiConfig } from "./embed-iframe"; import css from "./embed.css"; +import type { EventData, EventDataMap } from "./sdk-action-manager"; import { SdkActionManager } from "./sdk-action-manager"; -import allCss from "./tailwind.generated.css"; +import allCss from "./tailwind.generated.css?inline"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Rest = T extends [any, ...infer U] ? U : never; +export type Message = { + originator: string; + method: keyof InterfaceWithParent; + arg: InterfaceWithParent[keyof InterfaceWithParent]; +}; // HACK: Redefine and don't import WEBAPP_URL as it causes import statement to be present in built file. // This is happening because we are not able to generate an App and a lib using single Vite Config. const WEBAPP_URL = @@ -18,10 +27,20 @@ customElements.define("cal-inline", Inline); declare module "*.css"; type Namespace = string; type Config = { - origin: string; + calOrigin: string; debug?: boolean; uiDebug?: boolean; }; +type InitArgConfig = Partial & { + origin?: string; +}; + +type DoInIframeArg = { + [K in keyof typeof interfaceWithParent]: { + method: K; + arg?: Parameters<(typeof interfaceWithParent)[K]>[0]; + }; +}[keyof typeof interfaceWithParent]; const globalCal = window.Cal; if (!globalCal || !globalCal.q) { @@ -35,9 +54,22 @@ globalCal.fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as strin globalCal.__css = allCss; document.head.appendChild(document.createElement("style")).innerHTML = css; -function log(...args: any[]) { +function log(...args: unknown[]) { console.log(...args); } + +// eslint-disable-next-line @typescript-eslint/ban-types +type ValidationSchemaPropType = string | Function; + +type ValidationSchema = { + required?: boolean; + props?: Record< + string, + ValidationSchema & { + type: ValidationSchemaPropType | ValidationSchemaPropType[]; + } + >; +}; /** * //TODO: Warn about extra properties not part of schema. Helps in fixing wrong expectations * A very simple data validator written with intention of keeping payload size low. @@ -45,8 +77,8 @@ function log(...args: any[]) { * @param data * @param schema */ -function validate(data: any, schema: Record<"props" | "required", any>) { - function checkType(value: any, expectedType: any) { +function validate(data: Record, schema: ValidationSchema) { + function checkType(value: unknown, expectedType: ValidationSchemaPropType) { if (typeof expectedType === "string") { return typeof value == expectedType; } else { @@ -54,7 +86,7 @@ function validate(data: any, schema: Record<"props" | "required", any>) { } } - function isUndefined(data: any) { + function isUndefined(data: unknown) { return typeof data === "undefined"; } @@ -62,7 +94,7 @@ function validate(data: any, schema: Record<"props" | "required", any>) { throw new Error("Argument is required"); } - for (const [prop, propSchema] of Object.entries>(schema.props)) { + for (const [prop, propSchema] of Object.entries(schema.props || {})) { if (propSchema.required && isUndefined(data[prop])) { throw new Error(`"${prop}" is required`); } @@ -82,9 +114,23 @@ function validate(data: any, schema: Record<"props" | "required", any>) { } } -export type Instruction = [method: string, argument: any] | [method: string, argument: any][]; +type SingleInstructionMap = { + // TODO: This makes api("on", {}) loose it's generic type. Find a way to fix it. + // e.g. api("on", { action: "__dimensionChanged", callback: (e) => { /* `e.detail.data` has all possible values for all events/actions */} }); + [K in keyof CalApi]: CalApi[K] extends (...args: never[]) => void ? [K, ...Parameters] : never; +}; + +type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap]; + +export type Instruction = SingleInstruction | SingleInstruction[]; export type InstructionQueue = Instruction[]; +type PrefillAndIframeAttrsConfig = Record> & { + iframeAttrs?: Record & { + id?: string; + }; +}; + export class Cal { iframe?: HTMLIFrameElement; @@ -100,35 +146,42 @@ export class Cal { iframeReady!: boolean; - iframeDoQueue: { method: keyof typeof methods; arg: any }[] = []; + iframeDoQueue: DoInIframeArg[] = []; + + api: CalApi; static actionsManagers: Record; - static getQueryObject(config: Record) { + static getQueryObject(config: PrefillAndIframeAttrsConfig) { config = config || {}; return { ...config, // guests is better for API but Booking Page accepts guest. So do the mapping guest: config.guests ?? undefined, - }; + } as PrefillAndIframeAttrsConfig & { guest?: string | string[] }; } - processInstruction(instruction: Instruction) { - instruction = [].slice.call(instruction, 0); - if (instruction[0] instanceof Array) { + processInstruction(instructionAsArgs: IArguments | Instruction) { + // The instruction is actually an array-like object(arguments). Make it an array. + const instruction: Instruction = [].slice.call(instructionAsArgs); + // If there are multiple instructions in the array, process them one by one + if (typeof instruction[0] !== "string") { // It is an instruction instruction.forEach((instruction) => { this.processInstruction(instruction); }); return; } + const [method, ...args] = instruction; - if (!this[method]) { + if (!this.api[method]) { // Instead of throwing error, log and move forward in the queue log(`Instruction ${method} not FOUND`); } try { - (this[method] as (...args: any[]) => void)(...args); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore There can be any method which can have any number of arguments. + this.api[method](...args); } catch (e) { // Instead of throwing error, log and move forward in the queue log(`Instruction couldn't be executed`, e); @@ -136,7 +189,7 @@ export class Cal { return instruction; } - processQueue(queue: InstructionQueue) { + processQueue(queue: IArguments[]) { queue.forEach((instruction) => { this.processInstruction(instruction); }); @@ -153,9 +206,11 @@ export class Cal { createIframe({ calLink, queryObject = {}, + calOrigin, }: { calLink: string; - queryObject?: Record>; + queryObject?: PrefillAndIframeAttrsConfig & { guest?: string | string[] }; + calOrigin?: string; }) { const iframe = (this.iframe = document.createElement("iframe")); iframe.className = "cal-embed"; @@ -163,7 +218,7 @@ export class Cal { const config = this.getConfig(); const { iframeAttrs, ...restQueryObject } = queryObject; - if (iframeAttrs && typeof iframeAttrs !== "string" && !(iframeAttrs instanceof Array)) { + if (iframeAttrs && iframeAttrs.id) { iframe.setAttribute("id", iframeAttrs.id); } @@ -180,7 +235,7 @@ export class Cal { } } - const urlInstance = new URL(`${config.origin}/${calLink}`); + const urlInstance = new URL(`${calOrigin || config.calOrigin}/${calLink}`); if (!urlInstance.pathname.endsWith("embed")) { // TODO: Make a list of patterns that are embeddable. All except that should be allowed with a warning that "The page isn't optimized for embedding" urlInstance.pathname = `${urlInstance.pathname}/embed`; @@ -203,252 +258,34 @@ export class Cal { return iframe; } - init(namespaceOrConfig?: string | Config, config: Config = {} as Config) { - if (typeof namespaceOrConfig !== "string") { - config = (namespaceOrConfig || {}) as Config; - } - const { origin, ...restConfig } = config; - if (origin) { - this.__config.origin = origin; - } - this.__config = { ...this.__config, ...restConfig }; - } - getConfig() { return this.__config; } - // TODO: Maintain exposed methods in a separate namespace, so that unexpected methods don't become instructions - - /** - * It is an instruction that adds embed iframe inline as last child of the element - */ - inline({ - calLink, - elementOrSelector, - config, - }: { - calLink: string; - elementOrSelector: string | HTMLElement; - config: Record; - }) { - // eslint-disable-next-line prefer-rest-params - validate(arguments[0], { - required: true, - props: { - calLink: { - // TODO: Add a special type calLink for it and validate that it doesn't start with / or https?:// - required: true, - type: "string", - }, - elementOrSelector: { - required: true, - type: ["string", HTMLElement], - }, - config: { - required: false, - type: Object, - }, - }, - }); - config = config || {}; - // Keeping auto-scroll disabled for two reasons: - // - If user scrolls the content to an appropriate position, it again resets it to default position which might not be for the liking of the user - // - Sometimes, the position can be wrong(e.g. if there is a fixed position header on top coming above the iframe content). - // Best solution might be to autoscroll only if the iframe is not fully visible, detection of full visibility might be tough - - // We need to keep in mind that autoscroll is meant to solve the problem when on a certain view(which is availability page right now), the height goes too high and then suddenly it becomes normal - (config as unknown as any).__autoScroll = !!(config as unknown as any).__autoScroll; - config.embedType = "inline"; - const iframe = this.createIframe({ calLink, queryObject: Cal.getQueryObject(config) }); - iframe.style.height = "100%"; - iframe.style.width = "100%"; - const element = - elementOrSelector instanceof HTMLElement - ? elementOrSelector - : document.querySelector(elementOrSelector); - if (!element) { - throw new Error("Element not found"); - } - element.classList.add("cal-inline-container"); - const template = document.createElement("template"); - template.innerHTML = ``; - this.inlineEl = template.content.children[0]; - (this.inlineEl as unknown as any).__CalAutoScroll = config.__autoScroll; - this.inlineEl.appendChild(iframe); - element.appendChild(template.content); - } - - floatingButton({ - calLink, - buttonText = "Book my Cal", - hideButtonIcon = false, - attributes, - buttonPosition = "bottom-right", - buttonColor = "rgb(0, 0, 0)", - buttonTextColor = "rgb(255, 255, 255)", - }: { - calLink: string; - buttonText?: string; - attributes?: Record; - hideButtonIcon?: boolean; - buttonPosition?: "bottom-left" | "bottom-right"; - buttonColor: string; - buttonTextColor: string; - }) { - // validate(arguments[0], { - // required: true, - // props: { - // calLink: { - // required: true, - // type: "string", - // }, - // }, - // }); - let existingEl: HTMLElement | null = null; - - if (attributes?.id) { - existingEl = document.getElementById(attributes.id); - } - let el: HTMLElement; - if (!existingEl) { - el = document.createElement("cal-floating-button"); - el.dataset.calLink = calLink; - el.dataset.calNamespace = this.namespace; - if (attributes?.id) { - el.id = attributes.id; - } - - document.body.appendChild(el); - } else { - el = existingEl; - } - - if (buttonText) { - el.setAttribute("data-button-text", buttonText); - } - el.setAttribute("data-hide-button-icon", "" + hideButtonIcon); - el.setAttribute("data-button-position", "" + buttonPosition); - el.setAttribute("data-button-color", "" + buttonColor); - el.setAttribute("data-button-text-color", "" + buttonTextColor); - } - - modal({ calLink, config = {}, uid }: { calLink: string; config?: Record; uid: number }) { - const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`); - if (existingModalEl) { - existingModalEl.setAttribute("state", "started"); - return; - } - config.embedType = "modal"; - const iframe = this.createIframe({ calLink, queryObject: Cal.getQueryObject(config) }); - iframe.style.borderRadius = "8px"; - - iframe.style.height = "100%"; - iframe.style.width = "100%"; - const template = document.createElement("template"); - template.innerHTML = ``; - - this.modalBox = template.content.children[0]; - this.modalBox.appendChild(iframe); - this.actionManager.on("__closeIframe", () => { - this.modalBox.setAttribute("state", "closed"); - }); - document.body.appendChild(template.content); - } - - on({ - action, - callback, - }: { - action: Parameters[0]; - callback: Parameters[1]; - }) { - // eslint-disable-next-line prefer-rest-params - validate(arguments[0], { - required: true, - props: { - action: { - required: true, - type: "string", - }, - callback: { - required: true, - type: Function, - }, - }, - }); - this.actionManager.on(action, callback); - } - - off({ - action, - callback, - }: { - action: Parameters[0]; - callback: Parameters[1]; - }) { - this.actionManager.off(action, callback); - } - - preload({ calLink }: { calLink: string }) { - // eslint-disable-next-line prefer-rest-params - validate(arguments[0], { - required: true, - props: { - calLink: { - type: "string", - required: true, - }, - }, - }); - const iframe = document.body.appendChild(document.createElement("iframe")); - const config = this.getConfig(); - - const urlInstance = new URL(`${config.origin}/${calLink}`); - urlInstance.searchParams.set("prerender", "true"); - iframe.src = urlInstance.toString(); - iframe.style.width = "0"; - iframe.style.height = "0"; - iframe.style.display = "none"; - } - - ui(uiConfig: UiConfig) { - validate(uiConfig, { - required: true, - props: { - theme: { - required: false, - type: "string", - }, - styles: { - required: false, - type: Object, - }, - }, - }); - - this.doInIframe({ method: "ui", arg: uiConfig }); - } - - doInIframe({ - method, - arg, - }: // TODO: Need some TypeScript magic here to remove hardcoded types - | { method: "ui"; arg: Parameters<(typeof methods)["ui"]>[0] } - | { method: "parentKnowsIframeReady"; arg: undefined }) { + doInIframe(doInIframeArg: DoInIframeArg) { if (!this.iframeReady) { - this.iframeDoQueue.push({ method, arg }); + this.iframeDoQueue.push(doInIframeArg); return; } - // TODO: Ensure that origin is as defined by user. Generally it would be cal.com but in case of self hosting it can be anything. - this.iframe!.contentWindow!.postMessage({ originator: "CAL", method, arg }, "*"); + if (!this.iframe) { + throw new Error("iframe doesn't exist. `createIframe` must be called before `doInIframe`"); + } + if (this.iframe.contentWindow) { + // TODO: Ensure that targetOrigin is as defined by user(and not *). Generally it would be cal.com but in case of self hosting it can be anything. + // Maybe we can derive targetOrigin from __config.origin + this.iframe.contentWindow.postMessage( + { originator: "CAL", method: doInIframeArg.method, arg: doInIframeArg.arg }, + "*" + ); + } } - constructor(namespace: string, q: InstructionQueue) { + constructor(namespace: string, q: IArguments[]) { this.__config = { // Use WEBAPP_URL till full page reload problem with website URL is solved - origin: WEBAPP_URL, + calOrigin: WEBAPP_URL, }; + this.api = new CalApi(this); this.namespace = namespace; this.actionManager = new SdkActionManager(namespace); @@ -462,24 +299,17 @@ export class Cal { // 3. Parent window would check what iframe height can be set according to parent Element this.actionManager.on("__dimensionChanged", (e) => { const { data } = e.detail; - const iframe = this.iframe!; + const iframe = this.iframe; if (!iframe) { // Iframe might be pre-rendering return; } - let unit = "px"; - if (data.__unit) { - unit = data.__unit; - } + const unit = "px"; if (data.iframeHeight) { iframe.style.height = data.iframeHeight + unit; } - // if (data.iframeWidth) { - // iframe.style.width = data.iframeWidth + unit; - // } - if (this.modalBox) { // It ensures that if the iframe is so tall that it can't fit in the parent window without scroll. Then force the scroll by restricting the max-height to innerHeight // This case is reproducible when viewing in ModalBox on Mobile. @@ -490,9 +320,9 @@ export class Cal { this.actionManager.on("__iframeReady", () => { this.iframeReady = true; - this.doInIframe({ method: "parentKnowsIframeReady", arg: undefined }); - this.iframeDoQueue.forEach(({ method, arg }) => { - this.doInIframe({ method, arg }); + this.doInIframe({ method: "parentKnowsIframeReady" } as const); + this.iframeDoQueue.forEach((doInIframeArg) => { + this.doInIframe(doInIframeArg); }); }); @@ -523,34 +353,276 @@ export class Cal { } } -export interface GlobalCal { - (methodName: string, arg?: any): void; +class CalApi { + cal: Cal; + + constructor(cal: Cal) { + this.cal = cal; + } + + init(namespaceOrConfig?: string | InitArgConfig, config = {} as InitArgConfig) { + if (typeof namespaceOrConfig !== "string") { + config = (namespaceOrConfig || {}) as Config; + } + + const { calOrigin: calOrigin, origin: origin, ...restConfig } = config; + + this.cal.__config.calOrigin = calOrigin || origin || this.cal.__config.calOrigin; + + this.cal.__config = { ...this.cal.__config, ...restConfig }; + } + /** + * It is an instruction that adds embed iframe inline as last child of the element + */ + inline({ + calLink, + elementOrSelector, + config, + }: { + calLink: string; + elementOrSelector: string | HTMLElement; + config?: PrefillAndIframeAttrsConfig; + }) { + // eslint-disable-next-line prefer-rest-params + validate(arguments[0], { + required: true, + props: { + calLink: { + // TODO: Add a special type calLink for it and validate that it doesn't start with / or https?:// + required: true, + type: "string", + }, + elementOrSelector: { + required: true, + type: ["string", HTMLElement], + }, + config: { + required: false, + type: Object, + }, + }, + }); + + config = config || {}; + if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) { + throw new Error("iframeAttrs should be an object"); + } + config.embedType = "inline"; + const iframe = this.cal.createIframe({ calLink, queryObject: Cal.getQueryObject(config) }); + iframe.style.height = "100%"; + iframe.style.width = "100%"; + const element = + elementOrSelector instanceof HTMLElement + ? elementOrSelector + : document.querySelector(elementOrSelector); + if (!element) { + throw new Error("Element not found"); + } + element.classList.add("cal-inline-container"); + const template = document.createElement("template"); + template.innerHTML = ``; + this.cal.inlineEl = template.content.children[0]; + this.cal.inlineEl.appendChild(iframe); + element.appendChild(template.content); + } + + floatingButton({ + calLink, + buttonText = "Book my Cal", + hideButtonIcon = false, + attributes, + buttonPosition = "bottom-right", + buttonColor = "rgb(0, 0, 0)", + buttonTextColor = "rgb(255, 255, 255)", + calOrigin, + }: { + calLink: string; + buttonText?: string; + attributes?: Record<"id", string> & Record; + hideButtonIcon?: boolean; + buttonPosition?: "bottom-left" | "bottom-right"; + buttonColor?: string; + buttonTextColor?: string; + calOrigin?: string; + }) { + // validate(arguments[0], { + // required: true, + // props: { + // calLink: { + // required: true, + // type: "string", + // }, + // }, + // }); + let existingEl: HTMLElement | null = null; + + if (attributes?.id) { + existingEl = document.getElementById(attributes.id); + } + let el: FloatingButton; + if (!existingEl) { + el = document.createElement("cal-floating-button") as FloatingButton; + // It makes it a target element that opens up embed modal on click + el.dataset.calLink = calLink; + el.dataset.calNamespace = this.cal.namespace; + el.dataset.calOrigin = calOrigin ?? ""; + if (attributes?.id) { + el.id = attributes.id; + } + + document.body.appendChild(el); + } else { + el = existingEl as FloatingButton; + } + const dataset = el.dataset; + dataset["buttonText"] = buttonText; + dataset["hideButtonIcon"] = "" + hideButtonIcon; + dataset["buttonPosition"] = "" + buttonPosition; + dataset["buttonColor"] = "" + buttonColor; + dataset["buttonTextColor"] = "" + buttonTextColor; + } + + modal({ + calLink, + calOrigin, + config = {}, + uid, + }: { + calLink: string; + config?: PrefillAndIframeAttrsConfig; + uid?: string | number; + calOrigin?: string; + }) { + uid = uid || 0; + + const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`); + if (existingModalEl) { + existingModalEl.setAttribute("state", "started"); + return; + } + if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) { + throw new Error("iframeAttrs should be an object"); + } + config.embedType = "modal"; + const iframe = this.cal.createIframe({ calLink, calOrigin, queryObject: Cal.getQueryObject(config) }); + iframe.style.borderRadius = "8px"; + + iframe.style.height = "100%"; + iframe.style.width = "100%"; + const template = document.createElement("template"); + template.innerHTML = ``; + + this.cal.modalBox = template.content.children[0]; + this.cal.modalBox.appendChild(iframe); + this.cal.actionManager.on("__closeIframe", () => { + this.cal.modalBox.setAttribute("state", "closed"); + }); + document.body.appendChild(template.content); + } + + on({ + action, + callback, + }: { + action: T; + callback: (arg0: CustomEvent>) => void; + }) { + // eslint-disable-next-line prefer-rest-params + validate(arguments[0], { + required: true, + props: { + action: { + required: true, + type: "string", + }, + callback: { + required: true, + type: Function, + }, + }, + }); + this.cal.actionManager.on(action, callback); + } + + off({ action, callback }: { action: never; callback: never }) { + this.cal.actionManager.off(action, callback); + } + + preload({ calLink }: { calLink: string }) { + // eslint-disable-next-line prefer-rest-params + validate(arguments[0], { + required: true, + props: { + calLink: { + type: "string", + required: true, + }, + }, + }); + const iframe = document.body.appendChild(document.createElement("iframe")); + const config = this.cal.getConfig(); + + const urlInstance = new URL(`${config.calOrigin}/${calLink}`); + urlInstance.searchParams.set("prerender", "true"); + iframe.src = urlInstance.toString(); + iframe.style.width = "0"; + iframe.style.height = "0"; + iframe.style.display = "none"; + } + + ui(uiConfig: UiConfig) { + validate(uiConfig, { + required: true, + props: { + theme: { + required: false, + type: "string", + }, + styles: { + required: false, + type: Object, + }, + }, + }); + + this.cal.doInIframe({ method: "ui", arg: uiConfig }); + } +} + +// This is a full fledged Cal instance but doesn't have ns property because it would be nested inside an ns instance already +export interface GlobalCalWithoutNs { + (methodName: T, ...arg: Rest): void; /** Marks that the embed.js is loaded. Avoids re-downloading it. */ loaded?: boolean; /** Maintains a queue till the time embed.js isn't loaded */ - q?: InstructionQueue; + q: IArguments[]; /** If user registers multiple namespaces, those are available here */ - ns?: Record; instance?: Cal; __css?: string; fingerprint?: string; - __logQueue?: any[]; + __logQueue?: unknown[]; } +// Well Omit removes the Function Signature from a type if used. So, instead construct the types like this. +type GlobalCalWithNs = GlobalCalWithoutNs & { + ns: Record; +}; + +export type GlobalCal = GlobalCalWithNs; + declare global { interface Window { - Cal?: GlobalCal; + Cal: GlobalCal; } } export interface CalWindow extends Window { - Cal?: GlobalCal; + Cal: GlobalCal; } -globalCal.instance = new Cal("", globalCal.q!); - -for (const [ns, api] of Object.entries(globalCal.ns!)) { - api.instance = new Cal(ns, api.q!); +globalCal.instance = new Cal("", globalCal.q); +for (const [ns, api] of Object.entries(globalCal.ns)) { + api.instance = new Cal(ns, api.q); } /** @@ -571,38 +643,45 @@ window.addEventListener("message", (e) => { if (!actionManager) { throw new Error("Unhandled Action" + parsedAction); } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore actionManager.fire(parsedAction.type, detail.data); }); document.addEventListener("click", (e) => { - const htmlElement = e.target; - if (!(htmlElement instanceof HTMLElement)) { + const targetEl = e.target; + if (!(targetEl instanceof HTMLElement)) { return; } - const path = htmlElement.dataset.calLink; + const path = targetEl.dataset.calLink; if (!path) { return; } - const modalUniqueId = ((htmlElement as unknown as any).uniqueId = - (htmlElement as unknown as any).uniqueId || Date.now()); - const namespace = htmlElement.dataset.calNamespace; - const configString = htmlElement.dataset.calConfig || ""; + const modalUniqueId = (targetEl.dataset.uniqueId = targetEl.dataset.uniqueId || String(Date.now())); + const namespace = targetEl.dataset.calNamespace; + const configString = targetEl.dataset.calConfig || ""; + const calOrigin = targetEl.dataset.calOrigin || ""; let config; try { config = JSON.parse(configString); } catch (e) { config = {}; } - let api = globalCal; + + let api: GlobalCalWithoutNs = globalCal; + if (namespace) { - api = globalCal.ns![namespace]; + api = globalCal.ns[namespace]; } + if (!api) { throw new Error(`Namespace ${namespace} isn't defined`); } + api("modal", { calLink: path, config, uid: modalUniqueId, + calOrigin, }); }); diff --git a/packages/embeds/embed-core/src/preview.ts b/packages/embeds/embed-core/src/preview.ts index 2bd84c63ad..8f79f2e610 100644 --- a/packages/embeds/embed-core/src/preview.ts +++ b/packages/embeds/embed-core/src/preview.ts @@ -2,10 +2,9 @@ const WEBAPP_URL = import.meta.env.EMBED_PUBLIC_WEBAPP_URL || `https://${import.meta.env.EMBED_PUBLIC_VERCEL_URL}`; const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`; -(window as any).fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string; - // Install Cal Embed Code Snippet (function (C, A, L) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const p = function (a: any, ar: any) { a.q.push(ar); }; @@ -13,7 +12,7 @@ const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_UR C.Cal = C.Cal || function () { - const cal = C.Cal!; + const cal = C.Cal; // eslint-disable-next-line prefer-rest-params const ar = arguments; if (!cal.loaded) { @@ -23,13 +22,16 @@ const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_UR cal.loaded = true; } if (ar[0] === L) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const api: { (): void; q?: any[] } = function () { // eslint-disable-next-line prefer-rest-params p(api, arguments); }; const namespace = ar[1]; - api!.q = api.q || []; - typeof namespace === "string" ? (cal!.ns![namespace] = api) && p(api, ar) : p(cal, ar); + api.q = api.q || []; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar); return; } p(cal, ar); @@ -37,20 +39,24 @@ const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_UR })(window, EMBED_LIB_URL, "init"); const previewWindow = window; +previewWindow.Cal.fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string; -previewWindow.Cal!("init", { +previewWindow.Cal("init", { origin: WEBAPP_URL, }); const searchParams = new URL(document.URL).searchParams; const embedType = searchParams.get("embedType"); const calLink = searchParams.get("calLink"); -if (embedType! === "inline") { - previewWindow.Cal!("inline", { +if (!calLink) { + throw new Error('Missing "calLink" query parameter'); +} +if (embedType === "inline") { + previewWindow.Cal("inline", { elementOrSelector: "#my-embed", calLink: calLink, }); } else if (embedType === "floating-popup") { - previewWindow.Cal!("floatingButton", { + previewWindow.Cal("floatingButton", { calLink: calLink, attributes: { id: "my-floating-button", @@ -58,7 +64,7 @@ if (embedType! === "inline") { }); } else if (embedType === "element-click") { const button = document.createElement("button"); - button.setAttribute("data-cal-link", calLink!); + button.setAttribute("data-cal-link", calLink); button.innerHTML = "I am a button that exists on your website"; document.body.appendChild(button); } diff --git a/packages/embeds/embed-core/src/sdk-action-manager.ts b/packages/embeds/embed-core/src/sdk-action-manager.ts index f42de359e4..59adefe567 100644 --- a/packages/embeds/embed-core/src/sdk-action-manager.ts +++ b/packages/embeds/embed-core/src/sdk-action-manager.ts @@ -1,5 +1,5 @@ type Namespace = string; -type CustomEventDetail = Record; +type CustomEventDetail = Record; function _fireEvent(fullName: string, detail: CustomEventDetail) { const event = new window.CustomEvent(fullName, { @@ -9,6 +9,53 @@ function _fireEvent(fullName: string, detail: CustomEventDetail) { window.dispatchEvent(event); } +export type EventDataMap = { + eventTypeSelected: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventType: any; + }; + linkFailed: { + code: string; + msg: string; + data: { + url: string; + }; + }; + linkReady: Record; + bookingSuccessful: { + // TODO: Shouldn't send the entire booking and eventType objects, we should send specific fields from them. + booking: unknown; + eventType: unknown; + date: string; + duration: number | undefined; + organizer: { + name: string; + email: string; + timeZone: string; + }; + confirmed: boolean; + }; + "*": Record; + __routeChanged: Record; + __windowLoadComplete: Record; + __closeIframe: Record; + __iframeReady: Record; + __dimensionChanged: { + iframeHeight: number; + iframeWidth: number; + isFirstTime: boolean; + }; +}; + +export type EventData = { + [K in T]: { + type: string; + namespace: string; + fullType: string; + data: EventDataMap[K]; + }; +}[T]; + export class SdkActionManager { namespace: Namespace; @@ -31,7 +78,7 @@ export class SdkActionManager { return this.namespace ? `CAL:${this.namespace}:${name}` : `CAL::${name}`; } - fire(name: string, data: CustomEventDetail) { + fire(name: T, data: EventDataMap[T]) { const fullName = this.getFullActionName(name); const detail = { type: name, @@ -46,12 +93,12 @@ export class SdkActionManager { _fireEvent(this.getFullActionName("*"), detail); } - on(name: string, callback: (arg0: CustomEvent) => void) { + on(name: T, callback: (arg0: CustomEvent>) => void) { const fullName = this.getFullActionName(name); window.addEventListener(fullName, callback as EventListener); } - off(name: string, callback: (arg0: CustomEvent) => void) { + off(name: T, callback: (arg0: CustomEvent>) => void) { const fullName = this.getFullActionName(name); window.removeEventListener(fullName, callback as EventListener); } diff --git a/packages/embeds/embed-core/tsconfig.json b/packages/embeds/embed-core/tsconfig.json index 5e1617a473..fce92467a7 100644 --- a/packages/embeds/embed-core/tsconfig.json +++ b/packages/embeds/embed-core/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@calcom/tsconfig/base.json", "compilerOptions": { + "target": "ES2015", "module": "esnext", "paths": { "@lib/*": ["../../../apps/web/lib/*"] diff --git a/packages/embeds/embed-react/floating.html b/packages/embeds/embed-react/floating.html new file mode 100644 index 0000000000..fa2546640f --- /dev/null +++ b/packages/embeds/embed-react/floating.html @@ -0,0 +1,10 @@ + + + + + + + +
+ + diff --git a/packages/embeds/embed-react/floating.tsx b/packages/embeds/embed-react/floating.tsx new file mode 100644 index 0000000000..56c9285f87 --- /dev/null +++ b/packages/embeds/embed-react/floating.tsx @@ -0,0 +1,25 @@ +/* First make sure that you have installed the package */ + +/* If you are using yarn */ +// yarn add @calcom/embed-react + +/* If you are using npm */ +// npm install @calcom/embed-react +import { useEffect } from "react"; +import * as React from "react"; +import ReactDom from "react-dom"; + +import { getCalApi } from "./src/index"; + +function App() { + useEffect(() => { + (async function () { + const cal = await getCalApi("http://localhost:3000/embed/embed.js"); + cal("floatingButton", { calLink: "teampro/abc", calOrigin: "http://localhost:3000" }); + cal("ui", { styles: { branding: { brandColor: "#000000" } }, hideEventTypeDetails: false }); + })(); + }, []); + return null; +} + +ReactDom.render(, document.getElementById("root")); diff --git a/packages/embeds/embed-react/index.html b/packages/embeds/embed-react/index.html index e70064fa31..833271fe8c 100644 --- a/packages/embeds/embed-react/index.html +++ b/packages/embeds/embed-react/index.html @@ -2,7 +2,7 @@ - +
diff --git a/packages/embeds/embed-react/test-cal.tsx b/packages/embeds/embed-react/inline.tsx similarity index 92% rename from packages/embeds/embed-react/test-cal.tsx rename to packages/embeds/embed-react/inline.tsx index 34b4727a10..666d8c6c70 100644 --- a/packages/embeds/embed-react/test-cal.tsx +++ b/packages/embeds/embed-react/inline.tsx @@ -1,4 +1,6 @@ -import React, { useEffect, useState } from "react"; +import * as React from "react"; +import { useEffect } from "react"; +import { useState } from "react"; import ReactDom from "react-dom"; import Cal, { getCalApi } from "./src/index"; @@ -46,7 +48,6 @@ function App() { theme: "dark", }} /> - ); } diff --git a/packages/embeds/embed-react/src/Cal.tsx b/packages/embeds/embed-react/src/Cal.tsx index 8f1b03499d..5ed7b288b6 100644 --- a/packages/embeds/embed-react/src/Cal.tsx +++ b/packages/embeds/embed-react/src/Cal.tsx @@ -11,7 +11,7 @@ type CalProps = { debug?: boolean; uiDebug?: boolean; }; - config?: any; + config?: Record; embedJsUrl?: string; } & React.HTMLAttributes; @@ -24,7 +24,7 @@ const Cal = function Cal(props: CalProps) { const Cal = useEmbed(embedJsUrl); const ref = useRef(null); useEffect(() => { - if (!Cal || initializedRef.current) { + if (!Cal || initializedRef.current || !ref.current) { return; } initializedRef.current = true; diff --git a/packages/embeds/embed-react/src/index.ts b/packages/embeds/embed-react/src/index.ts index bbbc2a8747..c5690473db 100644 --- a/packages/embeds/embed-react/src/index.ts +++ b/packages/embeds/embed-react/src/index.ts @@ -5,9 +5,9 @@ import EmbedSnippet from "@calcom/embed-snippet"; import Cal from "./Cal"; -export const getCalApi = (): Promise => +export const getCalApi = (embedJsUrl?: string): Promise => new Promise(function tryReadingFromWindow(resolve) { - EmbedSnippet(); + EmbedSnippet(embedJsUrl); const api = window.Cal; if (!api) { setTimeout(() => { diff --git a/packages/embeds/embed-react/tsconfig.json b/packages/embeds/embed-react/tsconfig.json index 80c59646ad..26aa0d4b45 100644 --- a/packages/embeds/embed-react/tsconfig.json +++ b/packages/embeds/embed-react/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@calcom/tsconfig/base.json", "compilerOptions": { "module": "ESNext", + "target": "ES2015", "moduleResolution": "Node", "baseUrl": ".", "declaration": true, diff --git a/packages/embeds/embed-snippet/src/index.ts b/packages/embeds/embed-snippet/src/index.ts index 563d55e3da..3652f002a3 100644 --- a/packages/embeds/embed-snippet/src/index.ts +++ b/packages/embeds/embed-snippet/src/index.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/ban-ts-comment,prefer-rest-params,prefer-const */ +import type { GlobalCal, GlobalCalWithoutNs } from "@calcom/embed-core"; +import type { Optional } from "@calcom/types/utils"; /** * As we want to keep control on the size of this snippet but we want some portion of it to be still readable. * So, write the code that you need directly but keep it short. */ -import type { CalWindow } from "@calcom/embed-core"; const WEBAPP_URL = import.meta.env.EMBED_PUBLIC_WEBAPP_URL || `https://${import.meta.env.EMBED_PUBLIC_VERCEL_URL}`; @@ -12,37 +13,45 @@ const WEBAPP_URL = const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`; export default function EmbedSnippet(url = EMBED_LIB_URL) { - (function (C: CalWindow, A, L) { - let p = function (a: any, ar: any) { + (function (C, A, L) { + let p = function (a: GlobalCalWithoutNs, ar: IArguments) { a.q.push(ar); }; let d = C.document; C.Cal = C.Cal || function () { - let cal = C.Cal!; + let cal = C.Cal; let ar = arguments; if (!cal.loaded) { + // 'ns' and 'q' are now definitely set with the following 2 lines, so you can safely assert in TypeScript that it's GlobalCal now. cal.ns = {}; cal.q = cal.q || []; + //@ts-ignore d.head.appendChild(d.createElement("script")).src = A; cal.loaded = true; } if (ar[0] === L) { - const api: { (): void; q: any[] } = function () { + const api: GlobalCalWithoutNs = function () { p(api, arguments); }; const namespace = ar[1]; api.q = api.q || []; - typeof namespace === "string" ? (cal.ns![namespace] = api) && p(api, ar) : p(cal, ar); + typeof namespace === "string" + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (cal.ns![namespace] = api) && p(api, ar) + : p(cal as GlobalCal, ar); return; } - p(cal, ar); + p(cal as GlobalCal, ar); }; })( - window, + window as Omit & { + // Make 'ns' and 'q' optional as they are set through the snippet above + Cal: Optional; + }, //! Replace it with "https://cal.com/embed.js" or the URL where you have embed.js installed url, "init" diff --git a/packages/embeds/embed-snippet/tsconfig.json b/packages/embeds/embed-snippet/tsconfig.json index 05d7d4266b..675c8261d3 100644 --- a/packages/embeds/embed-snippet/tsconfig.json +++ b/packages/embeds/embed-snippet/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@calcom/tsconfig/base.json", "compilerOptions": { + "target": "ES2015", "baseUrl": ".", "module": "esnext", "declaration": true