import type { CalWindow } from "@calcom/embed-snippet"; import { ModalBox } from "./ModalBox"; import { methods, UiConfig } from "./embed-iframe"; import css from "./embed.css"; import { SdkActionManager } from "./sdk-action-manager"; declare module "*.css"; type Namespace = string; type Config = Record<"origin", "string">; const globalCal = (window as CalWindow).Cal; if (!globalCal || !globalCal.q) { throw new Error("Cal is not defined. This shouldn't happen"); } document.head.appendChild(document.createElement("style")).innerHTML = css; function log(...args: any[]) { console.log(...args); } /** * A very simple data validator written with intention of keeping payload size low. * Extend the functionality of it as required by the embed. * @param data * @param schema */ function validate(data: any, schema: Record<"props" | "required", any>) { function checkType(value: any, expectedType: any) { if (typeof expectedType === "string") { return typeof value == expectedType; } else { return value instanceof expectedType; } } function isUndefined(data: any) { return typeof data === "undefined"; } if (schema.required && isUndefined(data)) { throw new Error("Argument is required"); } for (let [prop, propSchema] of Object.entries>(schema.props)) { if (propSchema.required && isUndefined(data[prop])) { throw new Error(`"${prop}" is required`); } let typeCheck = true; if (propSchema.type && !isUndefined(data[prop])) { if (propSchema.type instanceof Array) { propSchema.type.forEach((type) => { typeCheck = typeCheck || checkType(data[prop], type); }); } else { typeCheck = checkType(data[prop], propSchema.type); } } if (!typeCheck) { throw new Error(`"${prop}" is of wrong type.Expected type "${propSchema.type}"`); } } } export type Instruction = [method: string, argument: any] | [method: string, argument: any][]; export type InstructionQueue = Instruction[]; export class Cal { iframe?: HTMLIFrameElement; __config: any; namespace: string; actionManager: SdkActionManager; iframeReady!: boolean; iframeDoQueue: { method: keyof typeof methods; arg: any }[] = []; static actionsManagers: Record; static getQueryObject(config: Record) { config = config || {}; return { ...config, // guests is better for API but Booking Page accepts guest. So do the mapping guest: config.guests ?? "", }; } processInstruction(instruction: Instruction) { instruction = [].slice.call(instruction, 0); if (instruction[0] instanceof Array) { // It is an instruction instruction.forEach((instruction) => { this.processInstruction(instruction); }); return; } const [method, ...args] = instruction; if (!this[method]) { // Instead of throwing error, log and move forward in the queue log(`Instruction ${method} not FOUND`); } try { (this[method] as Function)(...args); } catch (e) { // Instead of throwing error, log and move forward in the queue log(`Instruction couldn't be executed`, e); } return instruction; } processQueue(queue: InstructionQueue) { queue.forEach((instruction) => { this.processInstruction(instruction); }); queue.splice(0); /** @ts-ignore */ // We changed the definition of push here. queue.push = (instruction) => { this.processInstruction(instruction); }; } createIframe({ calLink, queryObject = {}, }: { calLink: string; queryObject?: Record; }) { const iframe = (this.iframe = document.createElement("iframe")); // FIXME: scrolling seems deprecated, though it works on Chrome. What's the recommended way to do it? iframe.scrolling = "no"; iframe.className = "cal-embed"; const config = this.getConfig(); // Prepare searchParams from config const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(queryObject)) { if (value instanceof Array) { value.forEach((val) => searchParams.append(key, val)); } else { searchParams.set(key, value); } } const urlInstance = new URL(`${config.origin}/${calLink}`); urlInstance.searchParams.set("embed", this.namespace); // Merge searchParams from config onto the URL which might have query params already //@ts-ignore for (let [key, value] of searchParams) { urlInstance.searchParams.append(key, value); } iframe.src = urlInstance.toString(); return iframe; } init(namespaceOrConfig: string | Config, config: Config = {} as Config) { if (namespaceOrConfig.hasOwnProperty("origin")) { config = namespaceOrConfig as Config; } if (config?.origin) { this.__config.origin = config.origin; } } 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; }) { validate(arguments[0], { required: true, props: { calLink: { required: true, type: "string", }, elementOrSelector: { required: true, type: ["string", HTMLElement], }, config: { required: false, type: Object, }, }, }); const iframe = this.createIframe({ calLink, queryObject: Cal.getQueryObject(config) }); iframe.style.height = "100%"; iframe.style.width = "100%"; let element = elementOrSelector instanceof HTMLElement ? elementOrSelector : document.querySelector(elementOrSelector); if (!element) { throw new Error("Element not found"); } element.appendChild(iframe); } modal({ calLink }: { calLink: string }) { const iframe = this.createIframe({ calLink }); iframe.style.height = "100%"; iframe.style.width = "100%"; const template = document.createElement("template"); template.innerHTML = ``; template.content.children[0].appendChild(iframe); document.body.appendChild(template.content); } on({ action, callback, }: { action: Parameters[0]; callback: Parameters[1]; }) { validate(arguments[0], { required: true, props: { action: { required: true, type: "string", }, callback: { required: true, type: Function, }, }, }); this.actionManager.on(action, callback); } preload({ calLink }: { calLink: string }) { 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[0] } | { method: "parentKnowsIframeReady"; arg: undefined }) { if (!this.iframeReady) { this.iframeDoQueue.push({ method, arg }); 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 }, "*"); } constructor(namespace: string, q: InstructionQueue) { this.__config = { origin: import.meta.env.NEXT_PUBLIC_WEBSITE_URL || "https://cal.com", }; this.namespace = namespace; this.actionManager = new SdkActionManager(namespace); Cal.actionsManagers = Cal.actionsManagers || {}; Cal.actionsManagers[namespace] = this.actionManager; this.processQueue(q); // 1. Initial iframe width and height would be according to 100% value of the parent element // 2. Once webpage inside iframe renders, it would tell how much iframe height should be increased so that my entire content is visible without iframe scroll // 3. Parent window would check what iframe height can be set according to parent Element this.actionManager.on("dimension-changed", (e) => { const { data } = e.detail; const iframe = this.iframe!; if (!iframe) { // Iframe might be pre-rendering return; } let proposedHeightByIframeWebsite = parseFloat(getComputedStyle(iframe).height) + data.hiddenHeight; iframe.style.height = proposedHeightByIframeWebsite; }); this.actionManager.on("iframeReady", (e) => { this.iframeReady = true; this.doInIframe({ method: "parentKnowsIframeReady", arg: undefined }); this.iframeDoQueue.forEach(({ method, arg }) => { this.doInIframe({ method, arg }); }); }); } } globalCal.instance = new Cal("", globalCal.q!); for (let [ns, api] of Object.entries(globalCal.ns!)) { api.instance = new Cal(ns, api.q!); } /** * Intercepts all postmessages and fires action in corresponding actionManager */ window.addEventListener("message", (e) => { const detail = e.data; const fullType = detail.fullType; const parsedAction = SdkActionManager.parseAction(fullType); if (!parsedAction) { return; } const actionManager = Cal.actionsManagers[parsedAction.ns]; if (!actionManager) { throw new Error("Unhandled Action" + parsedAction); } actionManager.fire(parsedAction.type, detail.data); }); document.addEventListener("click", (e) => { const htmlElement = e.target; if (!(htmlElement instanceof HTMLElement)) { return; } const path = htmlElement.dataset.calLink; if (!path) { return; } // TODO: Add an option to check which cal instance should be used for this. globalCal("modal", { calLink: path, }); }); customElements.define("cal-modal-box", ModalBox);