first commit
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import getFloatingButtonHtml from "./FloatingButtonHtml";
|
||||
|
||||
type ModalTargetDatasetProps = {
|
||||
calLink: string;
|
||||
calNamespace: string;
|
||||
calOrigin: string;
|
||||
calConfig: string;
|
||||
};
|
||||
|
||||
type CamelCase<T extends string> = T extends `${infer U}${infer V}` ? `${Uppercase<U>}${V}` : T;
|
||||
|
||||
type HyphenatedStringToCamelCase<S extends string> = S extends `${infer T}-${infer U}`
|
||||
? `${T}${HyphenatedStringToCamelCase<CamelCase<U>>}`
|
||||
: CamelCase<S>;
|
||||
|
||||
type HyphenatedDataStringToCamelCase<S extends string> = S extends `data-${infer U}`
|
||||
? HyphenatedStringToCamelCase<U>
|
||||
: S;
|
||||
|
||||
const dataAttributes = [
|
||||
"data-button-text",
|
||||
"data-hide-button-icon",
|
||||
"data-button-position",
|
||||
"data-button-color",
|
||||
"data-button-text-color",
|
||||
"data-toggle-off",
|
||||
] as const;
|
||||
|
||||
type DataAttributes = (typeof dataAttributes)[number];
|
||||
type DataAttributesCamelCase = HyphenatedDataStringToCamelCase<DataAttributes>;
|
||||
|
||||
export type FloatingButtonDataset = {
|
||||
[key in DataAttributesCamelCase]: string;
|
||||
};
|
||||
|
||||
export class FloatingButton extends HTMLElement {
|
||||
static updatedClassString(position: string, classString: string) {
|
||||
return [
|
||||
classString.replace(/hidden|md:right-10|md:left-10|left-4|right-4/g, ""),
|
||||
position === "bottom-right" ? "md:right-10 right-4" : "md:left-10 left-4",
|
||||
].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;
|
||||
buttonWrapperStyleDisplay!: HTMLElement["style"]["display"];
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
static get observedAttributes() {
|
||||
return dataAttributes;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: DataAttributes, oldValue: string, newValue: string) {
|
||||
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("#button");
|
||||
const buttonWrapperEl = this.shadowRoot?.querySelector<HTMLElement>("button");
|
||||
const buttonIconEl = this.shadowRoot?.querySelector<HTMLElement>("#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") {
|
||||
buttonEl.textContent = newValue;
|
||||
} else if (name === "data-hide-button-icon") {
|
||||
buttonIconEl.style.display = newValue == "true" ? "none" : "block";
|
||||
} else if (name === "data-button-position") {
|
||||
buttonWrapperEl.className = FloatingButton.updatedClassString(newValue, buttonWrapperEl.className);
|
||||
} else if (name === "data-button-color") {
|
||||
buttonWrapperEl.style.backgroundColor = newValue;
|
||||
} else if (name === "data-button-text-color") {
|
||||
buttonWrapperEl.style.color = newValue;
|
||||
} else if (name === "data-toggle-off") {
|
||||
const off = newValue == "true";
|
||||
if (off) {
|
||||
// When toggling off, back up the original display value so that it can be restored when toggled back on
|
||||
this.buttonWrapperStyleDisplay = buttonWrapperEl.style.display;
|
||||
}
|
||||
buttonWrapperEl.style.display = off ? "none" : this.buttonWrapperStyleDisplay;
|
||||
} 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 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 = `<style>${window.Cal.__css}</style> ${getFloatingButtonHtml({
|
||||
buttonText: buttonText,
|
||||
buttonClasses: [FloatingButton.updatedClassString(buttonPosition, "")],
|
||||
buttonColor: buttonColor,
|
||||
buttonTextColor: buttonTextColor,
|
||||
})}`;
|
||||
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.assertHasShadowRoot();
|
||||
this.shadowRoot.innerHTML = buttonHtml;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const getHtml = ({
|
||||
buttonText,
|
||||
buttonClasses,
|
||||
buttonColor,
|
||||
buttonTextColor,
|
||||
}: {
|
||||
buttonText: string;
|
||||
buttonClasses: string[];
|
||||
buttonColor: string;
|
||||
buttonTextColor: string;
|
||||
}) => {
|
||||
// IT IS A REQUIREMENT THAT ALL POSSIBLE CLASSES ARE HERE OTHERWISE TAILWIND WONT GENERATE THE CSS FOR CONDITIONAL CLASSES
|
||||
// To not let all these classes apply and visible, keep it hidden initially
|
||||
return `<button class="z-[999999999999] hidden fixed md:bottom-6 bottom-4 md:right-10 right-4 md:left-10 left-4 ${buttonClasses.join(
|
||||
" "
|
||||
)} flex h-16 origin-center bg-red-50 transform cursor-pointer items-center
|
||||
rounded-full py-4 px-6 text-base outline-none drop-shadow-md transition focus:outline-none fo
|
||||
cus:ring-4 focus:ring-gray-600 focus:ring-opacity-50 active:scale-95"
|
||||
style="background-color:${buttonColor}; color:${buttonTextColor} z-index: 10001">
|
||||
<div id="button-icon" class="mr-3 flex items-center justify-center">
|
||||
<svg
|
||||
class="h-7 w-7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="button" class="font-semibold leading-5 antialiased">${buttonText}</div>
|
||||
</button>`;
|
||||
};
|
||||
|
||||
export default getHtml;
|
||||
42
calcom/packages/embeds/embed-core/src/Inline/inline.ts
Normal file
42
calcom/packages/embeds/embed-core/src/Inline/inline.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import loaderCss from "../loader.css?inline";
|
||||
import { getErrorString } from "../utils";
|
||||
import inlineHtml from "./inlineHtml";
|
||||
|
||||
export class Inline extends HTMLElement {
|
||||
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<HTMLElement>(".loader");
|
||||
const errorEl = this.shadowRoot.querySelector<HTMLElement>("#error");
|
||||
const slotEl = this.shadowRoot.querySelector<HTMLElement>("slot");
|
||||
if (!loaderEl || !slotEl || !errorEl) {
|
||||
throw new Error("One of loaderEl, slotEl or errorEl is missing");
|
||||
}
|
||||
if (name === "loading") {
|
||||
if (newValue == "done") {
|
||||
loaderEl.style.display = "none";
|
||||
} else if (newValue === "failed") {
|
||||
loaderEl.style.display = "none";
|
||||
slotEl.style.visibility = "hidden";
|
||||
errorEl.style.display = "block";
|
||||
const errorString = getErrorString(this.dataset.errorCode);
|
||||
errorEl.innerText = errorString;
|
||||
}
|
||||
}
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.assertHasShadowRoot();
|
||||
this.shadowRoot.innerHTML = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${inlineHtml}`;
|
||||
}
|
||||
}
|
||||
10
calcom/packages/embeds/embed-core/src/Inline/inlineHtml.ts
Normal file
10
calcom/packages/embeds/embed-core/src/Inline/inlineHtml.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const html = `<div id="wrapper" style="top:50%; left:50%;transform:translate(-50%,-50%)" class="absolute z-highest">
|
||||
<div class="loader border-brand-default dark:border-darkmodebrand">
|
||||
<span class="loader-inner bg-brand dark:bg-darkmodebrand"></span>
|
||||
</div>
|
||||
<div id="error" style="transform:translate(-50%,-50%)" class="hidden">
|
||||
Something went wrong.
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>`;
|
||||
export default html;
|
||||
141
calcom/packages/embeds/embed-core/src/ModalBox/ModalBox.ts
Normal file
141
calcom/packages/embeds/embed-core/src/ModalBox/ModalBox.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import loaderCss from "../loader.css";
|
||||
import { getErrorString } from "../utils";
|
||||
import modalBoxHtml from "./ModalBoxHtml";
|
||||
|
||||
type ShadowRootWithStyle = ShadowRoot & {
|
||||
host: HTMLElement & { style: CSSStyleDeclaration };
|
||||
};
|
||||
|
||||
export class ModalBox extends HTMLElement {
|
||||
static htmlOverflow: string;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
static get observedAttributes() {
|
||||
return ["state"];
|
||||
}
|
||||
|
||||
assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRootWithStyle } {
|
||||
if (!this.shadowRoot) {
|
||||
throw new Error("No shadow root");
|
||||
}
|
||||
}
|
||||
|
||||
show(show: boolean) {
|
||||
this.assertHasShadowRoot();
|
||||
// We can't make it display none as that takes iframe width and height calculations to 0
|
||||
this.shadowRoot.host.style.visibility = show ? "visible" : "hidden";
|
||||
if (!show) {
|
||||
document.body.style.overflow = ModalBox.htmlOverflow;
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.show(true);
|
||||
const event = new Event("open");
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.show(false);
|
||||
const event = new Event("close");
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
hideIframe() {
|
||||
const iframe = this.querySelector("iframe");
|
||||
if (iframe) {
|
||||
iframe.style.visibility = "hidden";
|
||||
}
|
||||
}
|
||||
|
||||
showIframe() {
|
||||
const iframe = this.querySelector("iframe");
|
||||
if (iframe) {
|
||||
// Don't use visibility visible as that will make the iframe visible even when the modal is closed
|
||||
iframe.style.visibility = "";
|
||||
}
|
||||
}
|
||||
|
||||
getLoaderElement() {
|
||||
this.assertHasShadowRoot();
|
||||
const loaderEl = this.shadowRoot.querySelector<HTMLElement>(".loader");
|
||||
|
||||
if (!loaderEl) {
|
||||
throw new Error("No loader element");
|
||||
}
|
||||
|
||||
return loaderEl;
|
||||
}
|
||||
|
||||
getErrorElement() {
|
||||
this.assertHasShadowRoot();
|
||||
const element = this.shadowRoot.querySelector<HTMLElement>("#error");
|
||||
|
||||
if (!element) {
|
||||
throw new Error("No error element");
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
|
||||
if (name !== "state") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue === "loading") {
|
||||
this.open();
|
||||
this.hideIframe();
|
||||
this.getLoaderElement().style.display = "block";
|
||||
} else if (newValue == "loaded" || newValue === "reopening") {
|
||||
this.open();
|
||||
this.showIframe();
|
||||
this.getLoaderElement().style.display = "none";
|
||||
} else if (newValue == "closed") {
|
||||
this.close();
|
||||
} else if (newValue === "failed") {
|
||||
this.getLoaderElement().style.display = "none";
|
||||
this.getErrorElement().style.display = "inline-block";
|
||||
const errorString = getErrorString(this.dataset.errorCode);
|
||||
this.getErrorElement().innerText = errorString;
|
||||
} else if (newValue === "prerendering") {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.assertHasShadowRoot();
|
||||
const closeEl = this.shadowRoot.querySelector<HTMLElement>(".close");
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key === "Escape") {
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
);
|
||||
this.shadowRoot.host.addEventListener("click", () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
if (closeEl) {
|
||||
closeEl.onclick = () => {
|
||||
this.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const modalHtml = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${modalBoxHtml}`;
|
||||
this.attachShadow({ mode: "open" });
|
||||
ModalBox.htmlOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
this.open();
|
||||
this.assertHasShadowRoot();
|
||||
this.shadowRoot.innerHTML = modalHtml;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
const html = `<style>
|
||||
.my-backdrop {
|
||||
position:fixed;
|
||||
width:100%;
|
||||
height:100%;
|
||||
top:0;
|
||||
left:0;
|
||||
z-index:999999999999;
|
||||
display:block;
|
||||
background-color:rgb(5,5,5, 0.8)
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
margin:0 auto;
|
||||
margin-top:20px;
|
||||
margin-bottom:20px;
|
||||
position:absolute;
|
||||
width:100%;
|
||||
top:50%;
|
||||
left:50%;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
float:right;
|
||||
top: 10px;
|
||||
}
|
||||
.close {
|
||||
font-size: 30px;
|
||||
left: -20px;
|
||||
position: relative;
|
||||
color:white;
|
||||
cursor: pointer;
|
||||
}
|
||||
/*Modal background is black only, so hardcode white */
|
||||
.loader {
|
||||
--cal-brand-color:white;
|
||||
}
|
||||
</style>
|
||||
<div class="my-backdrop">
|
||||
<div class="header">
|
||||
<span class="close">×</span>
|
||||
</div>
|
||||
<div class="modal-box">
|
||||
<div class="body">
|
||||
<div id="wrapper" class="z-[999999999999] absolute flex w-full items-center">
|
||||
<div class="loader modal-loader border-brand-default dark:border-darkmodebrand">
|
||||
<span class="loader-inner bg-brand dark:bg-darkmodebrand"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="error" class="hidden left-1/2 -translate-x-1/2 relative text-inverted"></div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default html;
|
||||
628
calcom/packages/embeds/embed-core/src/embed-iframe.ts
Normal file
628
calcom/packages/embeds/embed-core/src/embed-iframe.ts
Normal file
@@ -0,0 +1,628 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
import type { Message } from "./embed";
|
||||
import { sdkActionManager } from "./sdk-event";
|
||||
import type { EmbedThemeConfig, UiConfig, EmbedNonStylesConfig, BookerLayouts, EmbedStyles } from "./types";
|
||||
import { useCompatSearchParams } from "./useCompatSearchParams";
|
||||
|
||||
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
|
||||
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
|
||||
const enum EMBED_IFRAME_STATE {
|
||||
NOT_INITIALIZED,
|
||||
INITIALIZED,
|
||||
}
|
||||
/**
|
||||
* All types of config that are critical to be processed as soon as possible are provided as query params to the iframe
|
||||
*/
|
||||
export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
|
||||
// TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
|
||||
iframeAttrs?: Record<string, string> & {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
// TODO: It should have a dedicated prefill prop
|
||||
// prefill: {},
|
||||
|
||||
// TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time.
|
||||
// ui: {layout; theme}
|
||||
layout?: BookerLayouts;
|
||||
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
|
||||
"ui.color-scheme"?: string;
|
||||
theme?: EmbedThemeConfig;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CalEmbed: {
|
||||
__logQueue?: unknown[];
|
||||
embedStore: typeof embedStore;
|
||||
applyCssVars: (cssVarsPerTheme: UiConfig["cssVarsPerTheme"]) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
|
||||
*/
|
||||
const embedStore = {
|
||||
// Handles the commands of routing received from parent even when React hasn't initialized and nextRouter isn't available
|
||||
router: {
|
||||
setNextRouter(nextRouter: ReturnType<typeof useRouter>) {
|
||||
this.nextRouter = nextRouter;
|
||||
|
||||
// Empty the queue after running push on nextRouter. This is important because setNextRouter is be called multiple times
|
||||
this.queue.forEach((url) => {
|
||||
nextRouter.push(url);
|
||||
this.queue.splice(0, 1);
|
||||
});
|
||||
},
|
||||
nextRouter: null as null | ReturnType<typeof useRouter>,
|
||||
queue: [] as string[],
|
||||
goto(url: string) {
|
||||
if (this.nextRouter) {
|
||||
this.nextRouter.push(url.toString());
|
||||
} else {
|
||||
this.queue.push(url);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
state: EMBED_IFRAME_STATE.NOT_INITIALIZED,
|
||||
// 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<keyof EmbedStyles, SetStyles>,
|
||||
reactNonStylesStateSetters: {} as Record<keyof EmbedNonStylesConfig, setNonStylesConfig>,
|
||||
parentInformedAboutContentHeight: false,
|
||||
windowLoadEventFired: false,
|
||||
setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined,
|
||||
theme: undefined as UiConfig["theme"],
|
||||
uiConfig: undefined as Omit<UiConfig, "styles" | "theme"> | 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)[],
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* It handles any URL change done through Web history API as well
|
||||
* History API is currently being used by Booker/utils/query-param
|
||||
*/
|
||||
const useUrlChange = (callback: (newUrl: string) => void) => {
|
||||
const currentFullUrl = isBrowser ? new URL(document.URL) : null;
|
||||
const pathname = currentFullUrl?.pathname ?? "";
|
||||
const searchParams = currentFullUrl?.searchParams ?? null;
|
||||
const lastKnownUrl = useRef(`${pathname}?${searchParams}`);
|
||||
const router = useRouter();
|
||||
embedStore.router.setNextRouter(router);
|
||||
useEffect(() => {
|
||||
const newUrl = `${pathname}?${searchParams}`;
|
||||
if (lastKnownUrl.current !== newUrl) {
|
||||
lastKnownUrl.current = newUrl;
|
||||
callback(newUrl);
|
||||
}
|
||||
}, [pathname, searchParams, callback]);
|
||||
};
|
||||
|
||||
export const useEmbedTheme = () => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const [theme, setTheme] = useState(
|
||||
embedStore.theme || (searchParams?.get("theme") as typeof embedStore.theme)
|
||||
);
|
||||
|
||||
const onUrlChange = useCallback(() => {
|
||||
sdkActionManager?.fire("__routeChanged", {});
|
||||
}, []);
|
||||
useUrlChange(onUrlChange);
|
||||
|
||||
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<EmbedStyles>({});
|
||||
|
||||
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<string | null | undefined>(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 (uiConfig.colorScheme) {
|
||||
actOnColorScheme(uiConfig.colorScheme);
|
||||
}
|
||||
|
||||
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();
|
||||
if (!isPrerendering()) {
|
||||
sdkActionManager?.fire("linkReady", {});
|
||||
}
|
||||
});
|
||||
},
|
||||
connect: function connect(queryObject: PrefillAndIframeAttrsConfig) {
|
||||
const currentUrl = new URL(document.URL);
|
||||
const searchParams = currentUrl.searchParams;
|
||||
searchParams.delete("preload");
|
||||
for (const [key, value] of Object.entries(queryObject)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (value instanceof Array) {
|
||||
value.forEach((val) => searchParams.append(key, val));
|
||||
} else {
|
||||
searchParams.set(key, value as string);
|
||||
}
|
||||
}
|
||||
|
||||
connectPreloadedEmbed({ url: currentUrl });
|
||||
},
|
||||
};
|
||||
|
||||
export type InterfaceWithParent = {
|
||||
[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);
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!isBrowser) {
|
||||
return;
|
||||
}
|
||||
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
|
||||
colorScheme: url.searchParams.get("ui.color-scheme"),
|
||||
layout: url.searchParams.get("layout") as BookerLayouts,
|
||||
};
|
||||
|
||||
actOnColorScheme(embedStore.uiConfig.colorScheme);
|
||||
// If embed link is opened in top, and not in iframe. Let the page be visible.
|
||||
if (top === window) {
|
||||
unhideBody();
|
||||
// We would want to avoid a situation where Cal.com embeds cal.com and then embed-iframe is in the top as well. In such case, we would want to avoid infinite loop of events being passed.
|
||||
log("Embed SDK Skipped as we are in top");
|
||||
return;
|
||||
}
|
||||
|
||||
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", {});
|
||||
}
|
||||
});
|
||||
|
||||
sdkActionManager?.on("*", (e) => {
|
||||
const detail = e.detail;
|
||||
log(detail);
|
||||
messageParent(detail);
|
||||
});
|
||||
|
||||
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
|
||||
initializeAndSetupEmbed();
|
||||
} else {
|
||||
log(`Preloaded scenario - Skipping initialization and setup`);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeAndSetupEmbed() {
|
||||
sdkActionManager?.fire("__iframeReady", {});
|
||||
|
||||
// Only NOT_INITIALIZED -> INITIALIZED transition is allowed
|
||||
if (embedStore.state !== EMBED_IFRAME_STATE.NOT_INITIALIZED) {
|
||||
log("Embed Iframe already initialized");
|
||||
return;
|
||||
}
|
||||
embedStore.state = EMBED_IFRAME_STATE.INITIALIZED;
|
||||
log("Initializing embed-iframe");
|
||||
// HACK
|
||||
const pageStatus = window.CalComPageStatus;
|
||||
|
||||
if (!pageStatus || pageStatus == "200") {
|
||||
keepParentInformedAboutDimensionChanges();
|
||||
} 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));
|
||||
}
|
||||
|
||||
function actOnColorScheme(colorScheme: string | null | undefined) {
|
||||
if (!colorScheme) {
|
||||
return;
|
||||
}
|
||||
document.documentElement.style.colorScheme = colorScheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply configurations to the preloaded page and then ask parent to show the embed
|
||||
* url has the config as params
|
||||
*/
|
||||
function connectPreloadedEmbed({ url }: { url: URL }) {
|
||||
// TODO: Use a better way to detect that React has initialized. Currently, we are using setTimeout which is a hack.
|
||||
const MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES = 700;
|
||||
// It can be fired before React has initialized, so use embedStore.router(which is a nextRouter wrapper that supports a queue)
|
||||
embedStore.router.goto(url.toString());
|
||||
setTimeout(() => {
|
||||
// Firing this event would stop the loader and show the embed
|
||||
sdkActionManager?.fire("linkReady", {});
|
||||
}, MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES);
|
||||
}
|
||||
|
||||
const isPrerendering = () => {
|
||||
return new URL(document.URL).searchParams.get("prerender") === "true";
|
||||
};
|
||||
|
||||
main();
|
||||
9
calcom/packages/embeds/embed-core/src/embed.css
Normal file
9
calcom/packages/embeds/embed-core/src/embed.css
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
These styles are applied to the entire page, so the selectors need to be specific
|
||||
*/
|
||||
.cal-embed {
|
||||
border: 0px;
|
||||
min-height: 300px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
958
calcom/packages/embeds/embed-core/src/embed.ts
Normal file
958
calcom/packages/embeds/embed-core/src/embed.ts
Normal file
@@ -0,0 +1,958 @@
|
||||
/// <reference types="../env" />
|
||||
import { FloatingButton } from "./FloatingButton/FloatingButton";
|
||||
import { Inline } from "./Inline/inline";
|
||||
import { ModalBox } from "./ModalBox/ModalBox";
|
||||
import type { InterfaceWithParent, interfaceWithParent, PrefillAndIframeAttrsConfig } from "./embed-iframe";
|
||||
import css from "./embed.css";
|
||||
import { SdkActionManager } from "./sdk-action-manager";
|
||||
import type { EventData, EventDataMap } from "./sdk-action-manager";
|
||||
import allCss from "./tailwind.generated.css?inline";
|
||||
import type { UiConfig } from "./types";
|
||||
|
||||
export type { PrefillAndIframeAttrsConfig } from "./embed-iframe";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Rest<T extends any[]> = 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 = process.env.EMBED_PUBLIC_WEBAPP_URL || `https://${process.env.EMBED_PUBLIC_VERCEL_URL}`;
|
||||
|
||||
customElements.define("cal-modal-box", ModalBox);
|
||||
customElements.define("cal-floating-button", FloatingButton);
|
||||
customElements.define("cal-inline", Inline);
|
||||
|
||||
declare module "*.css";
|
||||
type Namespace = string;
|
||||
type Config = {
|
||||
calOrigin: string;
|
||||
debug?: boolean;
|
||||
uiDebug?: boolean;
|
||||
};
|
||||
type InitArgConfig = Partial<Config> & {
|
||||
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) {
|
||||
throw new Error("Cal is not defined. This shouldn't happen");
|
||||
}
|
||||
|
||||
// Store Commit Hash to know exactly what version of the code is running
|
||||
// TODO: Ideally it should be the version as per package.json and then it can be renamed to version.
|
||||
// But because it is built on local machine right now, it is much more reliable to have the commit hash.
|
||||
globalCal.fingerprint = process.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string;
|
||||
globalCal.__css = allCss;
|
||||
document.head.appendChild(document.createElement("style")).innerHTML = css;
|
||||
|
||||
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.
|
||||
* Extend the functionality of it as required by the embed.
|
||||
* @param data
|
||||
* @param schema
|
||||
*/
|
||||
function validate(data: Record<string, unknown>, schema: ValidationSchema) {
|
||||
function checkType(value: unknown, expectedType: ValidationSchemaPropType) {
|
||||
if (typeof expectedType === "string") {
|
||||
return typeof value == expectedType;
|
||||
} else {
|
||||
return value instanceof expectedType;
|
||||
}
|
||||
}
|
||||
|
||||
function isUndefined(data: unknown) {
|
||||
return typeof data === "undefined";
|
||||
}
|
||||
|
||||
if (schema.required && isUndefined(data)) {
|
||||
throw new Error("Argument is required");
|
||||
}
|
||||
|
||||
for (const [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}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getColorScheme(el: Element) {
|
||||
const pageColorScheme = getComputedStyle(el).colorScheme;
|
||||
if (pageColorScheme === "dark" || pageColorScheme === "light") {
|
||||
return pageColorScheme;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function withColorScheme(
|
||||
queryObject: PrefillAndIframeAttrsConfig & { guest?: string | string[] },
|
||||
containerEl: Element
|
||||
) {
|
||||
// If color-scheme not explicitly configured, keep it same as the webpage that has the iframe
|
||||
// This is done to avoid having an opaque background of iframe that arises when they aren't same. We really need to have a transparent background to make embed part of the page
|
||||
// https://fvsch.com/transparent-iframes#:~:text=the%20resolution%20was%3A-,If%20the%20color%20scheme%20of%20an%20iframe%20differs%20from%20embedding%20document%2C%20iframe%20gets%20an%20opaque%20canvas%20background%20appropriate%20to%20its%20color%20scheme.,-So%20the%20dark
|
||||
if (!queryObject["ui.color-scheme"]) {
|
||||
const colorScheme = getColorScheme(containerEl);
|
||||
// Only handle two color-schemes for now. We don't want to have unintented affect by always explicitly adding color-scheme
|
||||
if (colorScheme) {
|
||||
queryObject["ui.color-scheme"] = colorScheme;
|
||||
}
|
||||
}
|
||||
return queryObject;
|
||||
}
|
||||
|
||||
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<CalApi[K]>] : never;
|
||||
};
|
||||
|
||||
type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
|
||||
|
||||
export type Instruction = SingleInstruction | SingleInstruction[];
|
||||
export type InstructionQueue = Instruction[];
|
||||
|
||||
export class Cal {
|
||||
iframe?: HTMLIFrameElement;
|
||||
|
||||
__config: Config;
|
||||
|
||||
modalBox?: Element;
|
||||
|
||||
inlineEl?: Element;
|
||||
|
||||
namespace: string;
|
||||
|
||||
actionManager: SdkActionManager;
|
||||
|
||||
iframeReady!: boolean;
|
||||
|
||||
iframeDoQueue: DoInIframeArg[] = [];
|
||||
|
||||
api: CalApi;
|
||||
|
||||
isPerendering?: boolean;
|
||||
|
||||
static actionsManagers: Record<Namespace, SdkActionManager>;
|
||||
|
||||
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(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.api[method]) {
|
||||
// Instead of throwing error, log and move forward in the queue
|
||||
log(`Instruction ${method} not FOUND`);
|
||||
}
|
||||
try {
|
||||
// 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);
|
||||
}
|
||||
return instruction;
|
||||
}
|
||||
|
||||
processQueue(queue: Queue) {
|
||||
queue.forEach((instruction) => {
|
||||
this.processInstruction(instruction);
|
||||
});
|
||||
|
||||
queue.splice(0);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
/** @ts-ignore */ // We changed the definition of push here.
|
||||
queue.push = (instruction) => {
|
||||
this.processInstruction(instruction);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Iframe is added invisible and shown only after color-scheme is set by the embedded calLink to avoid flash of non-transparent(white/black) background
|
||||
*/
|
||||
createIframe({
|
||||
calLink,
|
||||
queryObject = {},
|
||||
calOrigin,
|
||||
}: {
|
||||
calLink: string;
|
||||
queryObject?: PrefillAndIframeAttrsConfig & { guest?: string | string[] };
|
||||
calOrigin: string | null;
|
||||
}) {
|
||||
const iframe = (this.iframe = document.createElement("iframe"));
|
||||
iframe.className = "cal-embed";
|
||||
iframe.name = `cal-embed=${this.namespace}`;
|
||||
const config = this.getConfig();
|
||||
const { iframeAttrs, ...restQueryObject } = queryObject;
|
||||
|
||||
if (iframeAttrs && iframeAttrs.id) {
|
||||
iframe.setAttribute("id", iframeAttrs.id);
|
||||
}
|
||||
|
||||
// Prepare searchParams from config
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(restQueryObject)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (value instanceof Array) {
|
||||
value.forEach((val) => searchParams.append(key, val));
|
||||
} else {
|
||||
searchParams.set(key, value as string);
|
||||
}
|
||||
}
|
||||
|
||||
// cal.com has rewrite issues on Safari that sometimes cause 404 for assets.
|
||||
const originToUse = (calOrigin || config.calOrigin || "").replace(
|
||||
"https://cal.com",
|
||||
"https://app.cal.com"
|
||||
);
|
||||
const urlInstance = new URL(`${originToUse}/${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`;
|
||||
}
|
||||
urlInstance.searchParams.set("embed", this.namespace);
|
||||
|
||||
if (config.debug) {
|
||||
urlInstance.searchParams.set("debug", `${config.debug}`);
|
||||
}
|
||||
|
||||
// Keep iframe invisible, till the embedded calLink sets its color-scheme. This is so that there is no flash of non-transparent(white/black) background
|
||||
iframe.style.visibility = "hidden";
|
||||
|
||||
if (config.uiDebug) {
|
||||
iframe.style.border = "1px solid green";
|
||||
}
|
||||
|
||||
// Merge searchParams from config onto the URL which might have query params already
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
for (const [key, value] of searchParams) {
|
||||
urlInstance.searchParams.append(key, value);
|
||||
}
|
||||
iframe.src = urlInstance.toString();
|
||||
return iframe;
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
return this.__config;
|
||||
}
|
||||
|
||||
doInIframe(doInIframeArg: DoInIframeArg) {
|
||||
if (!this.iframeReady) {
|
||||
this.iframeDoQueue.push(doInIframeArg);
|
||||
return;
|
||||
}
|
||||
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: Queue) {
|
||||
this.__config = {
|
||||
// Use WEBAPP_URL till full page reload problem with website URL is solved
|
||||
calOrigin: WEBAPP_URL,
|
||||
};
|
||||
this.api = new CalApi(this);
|
||||
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("__dimensionChanged", (e) => {
|
||||
const { data } = e.detail;
|
||||
const iframe = this.iframe;
|
||||
|
||||
if (!iframe) {
|
||||
// Iframe might be pre-rendering
|
||||
return;
|
||||
}
|
||||
const unit = "px";
|
||||
if (data.iframeHeight) {
|
||||
iframe.style.height = data.iframeHeight + 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.
|
||||
const spacingTopPlusBottom = 2 * 50; // 50 is the padding we want to keep to show close button comfortably. Make it same as top for bottom.
|
||||
iframe.style.maxHeight = `${window.innerHeight - spacingTopPlusBottom}px`;
|
||||
}
|
||||
});
|
||||
|
||||
this.actionManager.on("__iframeReady", () => {
|
||||
this.iframeReady = true;
|
||||
if (this.iframe) {
|
||||
// It's a bit late to make the iframe visible here. We just needed to wait for the HTML tag of the embedded calLink to be rendered(which then informs the browser of the color-scheme)
|
||||
// Right now it would wait for embed-iframe.js bundle to be loaded as well. We can speed that up by inlining the JS that informs about color-scheme being set in the HTML.
|
||||
// But it's okay to do it here for now because the embedded calLink also keeps itself hidden till it receives `parentKnowsIframeReady` message(It has it's own reasons for that)
|
||||
// Once the embedded calLink starts not hiding the document, we should optimize this line to make the iframe visible earlier than this.
|
||||
|
||||
// Imp: Don't use visiblity:visible as that would make the iframe show even if the host element(A paren tof the iframe) has visiblity:hidden set. Just reset the visibility to default
|
||||
this.iframe.style.visibility = "";
|
||||
}
|
||||
this.doInIframe({ method: "parentKnowsIframeReady" } as const);
|
||||
this.iframeDoQueue.forEach((doInIframeArg) => {
|
||||
this.doInIframe(doInIframeArg);
|
||||
});
|
||||
});
|
||||
|
||||
this.actionManager.on("__routeChanged", () => {
|
||||
if (!this.inlineEl) {
|
||||
return;
|
||||
}
|
||||
const { top, height } = this.inlineEl.getBoundingClientRect();
|
||||
// Try to readjust and scroll into view if more than 25% is hidden.
|
||||
// Otherwise we assume that user might have positioned the content appropriately already
|
||||
if (top < 0 && Math.abs(top / height) >= 0.25) {
|
||||
this.inlineEl.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
|
||||
this.actionManager.on("linkReady", () => {
|
||||
if (this.isPerendering) {
|
||||
// Absolute check to ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed would showup without any user action
|
||||
return;
|
||||
}
|
||||
this.modalBox?.setAttribute("state", "loaded");
|
||||
this.inlineEl?.setAttribute("loading", "done");
|
||||
});
|
||||
|
||||
this.actionManager.on("linkFailed", (e) => {
|
||||
const iframe = this.iframe;
|
||||
if (!iframe) {
|
||||
return;
|
||||
}
|
||||
this.inlineEl?.setAttribute("data-error-code", e.detail.data.code);
|
||||
this.modalBox?.setAttribute("data-error-code", e.detail.data.code);
|
||||
this.inlineEl?.setAttribute("loading", "failed");
|
||||
this.modalBox?.setAttribute("state", "failed");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class CalApi {
|
||||
cal: Cal;
|
||||
static initializedNamespaces = [] as string[];
|
||||
modalUid?: string;
|
||||
preloadedModalUid?: string;
|
||||
constructor(cal: Cal) {
|
||||
this.cal = cal;
|
||||
}
|
||||
|
||||
/**
|
||||
* If namespaceOrConfig is a string, config is available in config argument
|
||||
* If namespaceOrConfig is an object, namespace is assumed to be default and config isn't provided
|
||||
*/
|
||||
init(namespaceOrConfig?: string | InitArgConfig, config = {} as InitArgConfig) {
|
||||
let initForNamespace = "";
|
||||
if (typeof namespaceOrConfig !== "string") {
|
||||
config = (namespaceOrConfig || {}) as Config;
|
||||
} else {
|
||||
initForNamespace = namespaceOrConfig;
|
||||
}
|
||||
|
||||
// Just in case 'init' instruction belongs to another namespace, ignore it
|
||||
// Though it shouldn't happen normally as the snippet takes care of delegating the init instruction to appropriate namespace queue
|
||||
if (initForNamespace !== this.cal.namespace) {
|
||||
return;
|
||||
}
|
||||
|
||||
CalApi.initializedNamespaces.push(this.cal.namespace);
|
||||
|
||||
const { calOrigin: calOrigin, origin: origin, ...restConfig } = config;
|
||||
|
||||
this.cal.__config.calOrigin = calOrigin || origin || this.cal.__config.calOrigin;
|
||||
|
||||
this.cal.__config = { ...this.cal.__config, ...restConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Used when a non-default namespace is to be initialized
|
||||
* It allows default queue to take care of instantiation of the non-default namespace queue
|
||||
*/
|
||||
initNamespace(namespace: string) {
|
||||
// Creating this instance automatically starts processing the queue for the namespace
|
||||
globalCal.ns[namespace].instance =
|
||||
globalCal.ns[namespace].instance || new Cal(namespace, globalCal.ns[namespace].q);
|
||||
}
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// If someone re-executes inline embed instruction, we want to ensure that duplicate inlineEl isn't added to the page per namespace
|
||||
if (this.cal.inlineEl && document.body.contains(this.cal.inlineEl)) {
|
||||
console.warn("Inline embed already exists. Ignoring this call");
|
||||
return;
|
||||
}
|
||||
|
||||
config = config || {};
|
||||
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
|
||||
throw new Error("iframeAttrs should be an object");
|
||||
}
|
||||
const containerEl =
|
||||
elementOrSelector instanceof HTMLElement
|
||||
? elementOrSelector
|
||||
: document.querySelector(elementOrSelector);
|
||||
|
||||
if (!containerEl) {
|
||||
throw new Error("Element not found");
|
||||
}
|
||||
|
||||
config.embedType = "inline";
|
||||
const calConfig = this.cal.getConfig();
|
||||
|
||||
const iframe = this.cal.createIframe({
|
||||
calLink,
|
||||
queryObject: withColorScheme(Cal.getQueryObject(config), containerEl),
|
||||
calOrigin: calConfig.calOrigin,
|
||||
});
|
||||
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.width = "100%";
|
||||
|
||||
containerEl.classList.add("cal-inline-container");
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = `<cal-inline style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap;width:100%"></cal-inline><style>.cal-inline-container::-webkit-scrollbar{display:none}.cal-inline-container{scrollbar-width:none}</style>`;
|
||||
this.cal.inlineEl = template.content.children[0];
|
||||
this.cal.inlineEl.appendChild(iframe);
|
||||
containerEl.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,
|
||||
config,
|
||||
}: {
|
||||
calLink: string;
|
||||
buttonText?: string;
|
||||
attributes?: Record<"id", string> & Record<string | "id", string>;
|
||||
hideButtonIcon?: boolean;
|
||||
buttonPosition?: "bottom-left" | "bottom-right";
|
||||
buttonColor?: string;
|
||||
buttonTextColor?: string;
|
||||
calOrigin?: string;
|
||||
config?: PrefillAndIframeAttrsConfig;
|
||||
}) {
|
||||
// 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 (config) {
|
||||
el.dataset.calConfig = JSON.stringify(config);
|
||||
}
|
||||
|
||||
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,
|
||||
config = {},
|
||||
calOrigin,
|
||||
__prerender = false,
|
||||
}: {
|
||||
calLink: string;
|
||||
config?: PrefillAndIframeAttrsConfig;
|
||||
calOrigin?: string;
|
||||
__prerender?: boolean;
|
||||
}) {
|
||||
const uid = this.modalUid || this.preloadedModalUid || String(Date.now()) || "0";
|
||||
const isConnectingToPreloadedModal = this.preloadedModalUid && !this.modalUid;
|
||||
|
||||
const containerEl = document.body;
|
||||
|
||||
this.cal.isPerendering = !!__prerender;
|
||||
|
||||
if (__prerender) {
|
||||
// Add preload query param
|
||||
config.prerender = "true";
|
||||
}
|
||||
|
||||
const queryObject = withColorScheme(Cal.getQueryObject(config), containerEl);
|
||||
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
|
||||
|
||||
if (existingModalEl) {
|
||||
if (isConnectingToPreloadedModal) {
|
||||
this.cal.doInIframe({
|
||||
method: "connect",
|
||||
arg: queryObject,
|
||||
});
|
||||
this.modalUid = uid;
|
||||
existingModalEl.setAttribute("state", "loading");
|
||||
return;
|
||||
} else {
|
||||
existingModalEl.setAttribute("state", "reopening");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (__prerender) {
|
||||
this.preloadedModalUid = uid;
|
||||
}
|
||||
|
||||
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
|
||||
throw new Error("iframeAttrs should be an object");
|
||||
}
|
||||
|
||||
config.embedType = "modal";
|
||||
let iframe = null;
|
||||
|
||||
if (!iframe) {
|
||||
iframe = this.cal.createIframe({
|
||||
calLink,
|
||||
queryObject,
|
||||
calOrigin: calOrigin || null,
|
||||
});
|
||||
}
|
||||
|
||||
iframe.style.borderRadius = "8px";
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.width = "100%";
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
|
||||
this.cal.modalBox = template.content.children[0];
|
||||
this.cal.modalBox.appendChild(iframe);
|
||||
if (__prerender) {
|
||||
this.cal.modalBox.setAttribute("state", "prerendering");
|
||||
}
|
||||
this.handleClose();
|
||||
containerEl.appendChild(template.content);
|
||||
}
|
||||
|
||||
private handleClose() {
|
||||
// A request, to close from the iframe, should close the modal
|
||||
this.cal.actionManager.on("__closeIframe", () => {
|
||||
this.cal.modalBox?.setAttribute("state", "closed");
|
||||
});
|
||||
}
|
||||
|
||||
on<T extends keyof EventDataMap>({
|
||||
action,
|
||||
callback,
|
||||
}: {
|
||||
action: T;
|
||||
callback: (arg0: CustomEvent<EventData<T>>) => 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<T extends keyof EventDataMap>({
|
||||
action,
|
||||
callback,
|
||||
}: {
|
||||
action: T;
|
||||
callback: (arg0: CustomEvent<EventData<T>>) => void;
|
||||
}) {
|
||||
this.cal.actionManager.off(action, callback);
|
||||
}
|
||||
/**
|
||||
*
|
||||
* type is provided and prerenderIframe not set. We would assume prerenderIframe to be true
|
||||
* type is provided and prerenderIframe set to false. We would ignore the type and preload assets only
|
||||
* type is not provided and prerenderIframe set to true. We would throw error as we don't know what to prerender
|
||||
* type is not provided and prerenderIframe set to false. We would preload assets only
|
||||
*/
|
||||
preload({
|
||||
calLink,
|
||||
type,
|
||||
options = {},
|
||||
}: {
|
||||
calLink: string;
|
||||
type?: "modal" | "floatingButton";
|
||||
options?: {
|
||||
prerenderIframe?: boolean;
|
||||
};
|
||||
}) {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
validate(arguments[0], {
|
||||
required: true,
|
||||
props: {
|
||||
calLink: {
|
||||
type: "string",
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
let api: GlobalCalWithoutNs = globalCal;
|
||||
const namespace = this.cal.namespace;
|
||||
if (namespace) {
|
||||
api = globalCal.ns[namespace];
|
||||
}
|
||||
|
||||
if (!api) {
|
||||
throw new Error(`Namespace ${namespace} isn't defined`);
|
||||
}
|
||||
|
||||
const config = this.cal.getConfig();
|
||||
let prerenderIframe = options.prerenderIframe;
|
||||
if (type && prerenderIframe === undefined) {
|
||||
prerenderIframe = true;
|
||||
}
|
||||
|
||||
if (!type && prerenderIframe) {
|
||||
throw new Error("You should provide 'type'");
|
||||
}
|
||||
|
||||
if (prerenderIframe) {
|
||||
if (type === "modal" || type === "floatingButton") {
|
||||
this.cal.isPerendering = true;
|
||||
this.modal({
|
||||
calLink,
|
||||
calOrigin: config.calOrigin,
|
||||
__prerender: true,
|
||||
});
|
||||
} else {
|
||||
console.warn("Ignoring - full preload for inline embed and instead preloading assets only");
|
||||
preloadAssetsForCalLink({ calLink, config });
|
||||
}
|
||||
} else {
|
||||
preloadAssetsForCalLink({ calLink, config });
|
||||
}
|
||||
}
|
||||
|
||||
prerender({ calLink, type }: { calLink: string; type: "modal" | "floatingButton" }) {
|
||||
this.preload({
|
||||
calLink,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type Queue = any[];
|
||||
|
||||
// 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 {
|
||||
<T extends keyof SingleInstructionMap>(methodName: T, ...arg: Rest<SingleInstructionMap[T]>): 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: Queue;
|
||||
/** If user registers multiple namespaces, those are available here */
|
||||
instance?: Cal;
|
||||
__css?: string;
|
||||
fingerprint?: string;
|
||||
__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<string, GlobalCalWithoutNs>;
|
||||
};
|
||||
|
||||
export type GlobalCal = GlobalCalWithNs;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Cal: GlobalCal;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CalWindow extends Window {
|
||||
Cal: GlobalCal;
|
||||
}
|
||||
|
||||
const DEFAULT_NAMESPACE = "";
|
||||
|
||||
globalCal.instance = new Cal(DEFAULT_NAMESPACE, globalCal.q);
|
||||
|
||||
// Namespaces created before embed.js executes are instantiated here for old Embed Snippets which don't use 'initNamespace' instruction
|
||||
// Snippets that support 'initNamespace' instruction don't really need this but it is okay if it's done because it's idempotent
|
||||
for (const [ns, api] of Object.entries(globalCal.ns)) {
|
||||
api.instance = 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];
|
||||
globalCal.__logQueue = globalCal.__logQueue || [];
|
||||
globalCal.__logQueue.push({ ...parsedAction, data: detail.data });
|
||||
|
||||
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 targetEl = e.target;
|
||||
|
||||
const calLinkEl = getCalLinkEl(targetEl);
|
||||
const path = calLinkEl?.dataset?.calLink;
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const namespace = calLinkEl.dataset.calNamespace;
|
||||
const configString = calLinkEl.dataset.calConfig || "";
|
||||
const calOrigin = calLinkEl.dataset.calOrigin || "";
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(configString);
|
||||
} catch (e) {
|
||||
config = {};
|
||||
}
|
||||
|
||||
let api: GlobalCalWithoutNs = globalCal;
|
||||
|
||||
if (namespace) {
|
||||
api = globalCal.ns[namespace];
|
||||
}
|
||||
|
||||
if (!api) {
|
||||
throw new Error(`Namespace ${namespace} isn't defined`);
|
||||
}
|
||||
|
||||
api("modal", {
|
||||
calLink: path,
|
||||
config,
|
||||
calOrigin,
|
||||
});
|
||||
|
||||
function getCalLinkEl(target: EventTarget | null) {
|
||||
let calLinkEl;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
if (target?.dataset.calLink) {
|
||||
calLinkEl = target;
|
||||
} else {
|
||||
// If the element clicked is a child of the cal-link element, then return the cal-link element
|
||||
calLinkEl = Array.from(document.querySelectorAll("[data-cal-link]")).find((el) => el.contains(target));
|
||||
}
|
||||
|
||||
if (!(calLinkEl instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return calLinkEl;
|
||||
}
|
||||
});
|
||||
|
||||
let currentColorScheme: string | null = null;
|
||||
|
||||
(function watchAndActOnColorSchemeChange() {
|
||||
// TODO: Maybe find a better way to identify change in color-scheme, a mutation observer seems overkill for this. Settle with setInterval for now.
|
||||
setInterval(() => {
|
||||
const colorScheme = getColorScheme(document.body);
|
||||
if (colorScheme && colorScheme !== currentColorScheme) {
|
||||
currentColorScheme = colorScheme;
|
||||
// Go through all the embeds on the same page and update all of them with this info
|
||||
CalApi.initializedNamespaces.forEach((ns) => {
|
||||
const api = getEmbedApiFn(ns);
|
||||
api("ui", {
|
||||
colorScheme: colorScheme,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
function getEmbedApiFn(ns: string) {
|
||||
let api;
|
||||
if (ns === DEFAULT_NAMESPACE) {
|
||||
api = globalCal;
|
||||
} else {
|
||||
api = globalCal.ns[ns];
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
function preloadAssetsForCalLink({ config, calLink }: { config: Config; calLink: string }) {
|
||||
const iframe = document.body.appendChild(document.createElement("iframe"));
|
||||
|
||||
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
|
||||
urlInstance.searchParams.set("preload", "true");
|
||||
iframe.src = urlInstance.toString();
|
||||
iframe.style.width = "0";
|
||||
iframe.style.height = "0";
|
||||
iframe.style.display = "none";
|
||||
}
|
||||
65
calcom/packages/embeds/embed-core/src/loader.css
Normal file
65
calcom/packages/embeds/embed-core/src/loader.css
Normal file
@@ -0,0 +1,65 @@
|
||||
@keyframes loader {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader-inner {
|
||||
0% {
|
||||
height: 0%;
|
||||
}
|
||||
|
||||
25% {
|
||||
height: 0%;
|
||||
}
|
||||
|
||||
50% {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
75% {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
100% {
|
||||
height: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.loader-inner {
|
||||
vertical-align: top;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
animation: loader-inner 2s infinite ease-in;
|
||||
}
|
||||
|
||||
.loader {
|
||||
display: block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
border-width: 4px;
|
||||
border-style: solid;
|
||||
-webkit-animation: loader 2s infinite ease;
|
||||
animation: loader 2s infinite ease;
|
||||
}
|
||||
|
||||
.loader.modal-loader {
|
||||
margin: 60px auto;
|
||||
}
|
||||
126
calcom/packages/embeds/embed-core/src/preview.ts
Normal file
126
calcom/packages/embeds/embed-core/src/preview.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
const searchParams = new URL(document.URL).searchParams;
|
||||
const embedType = searchParams.get("embedType");
|
||||
const calLink = searchParams.get("calLink");
|
||||
const bookerUrl = searchParams.get("bookerUrl");
|
||||
const embedLibUrl = searchParams.get("embedLibUrl");
|
||||
if (!bookerUrl || !embedLibUrl) {
|
||||
throw new Error('Can\'t Preview: Missing "bookerUrl" or "embedLibUrl" query parameter');
|
||||
}
|
||||
// TODO: Reuse the embed code snippet from the embed-snippet package - Not able to use it because of circular dependency
|
||||
// Install Cal Embed Code Snippet
|
||||
(function (C, A, L) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
const p = function (a, ar) {
|
||||
a.q.push(ar);
|
||||
};
|
||||
const d = C.document;
|
||||
C.Cal =
|
||||
C.Cal ||
|
||||
function () {
|
||||
const cal = C.Cal;
|
||||
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
const ar = arguments;
|
||||
if (!cal.loaded) {
|
||||
cal.ns = {};
|
||||
cal.q = cal.q || [];
|
||||
d.head.appendChild(d.createElement("script")).src = A;
|
||||
cal.loaded = true;
|
||||
}
|
||||
if (ar[0] === L) {
|
||||
const api = function () {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
p(api, arguments);
|
||||
};
|
||||
const namespace = ar[1];
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
api.q = api.q || [];
|
||||
if (typeof namespace === "string") {
|
||||
// Make sure that even after re-execution of the snippet, the namespace is not overridden
|
||||
cal.ns[namespace] = cal.ns[namespace] || api;
|
||||
p(cal.ns[namespace], ar);
|
||||
p(cal, ["initNamespace", namespace]);
|
||||
} else p(cal, ar);
|
||||
return;
|
||||
}
|
||||
p(cal, ar);
|
||||
};
|
||||
})(window, embedLibUrl, "init");
|
||||
const previewWindow = window;
|
||||
previewWindow.Cal.fingerprint = process.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string;
|
||||
|
||||
previewWindow.Cal("init", {
|
||||
origin: bookerUrl,
|
||||
});
|
||||
|
||||
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", {
|
||||
calLink: calLink,
|
||||
attributes: {
|
||||
id: "my-floating-button",
|
||||
},
|
||||
});
|
||||
} else if (embedType === "element-click") {
|
||||
const button = document.createElement("button");
|
||||
button.setAttribute("data-cal-link", calLink);
|
||||
button.innerHTML = "I am a button that exists on your website";
|
||||
document.body.appendChild(button);
|
||||
}
|
||||
|
||||
previewWindow.addEventListener("message", (e) => {
|
||||
const data = e.data;
|
||||
if (data.mode !== "cal:preview") {
|
||||
return;
|
||||
}
|
||||
|
||||
const globalCal = window.Cal;
|
||||
if (!globalCal) {
|
||||
throw new Error("Cal is not defined yet");
|
||||
}
|
||||
if (data.type == "instruction") {
|
||||
globalCal(data.instruction.name, data.instruction.arg);
|
||||
}
|
||||
if (data.type == "inlineEmbedDimensionUpdate") {
|
||||
const inlineEl = document.querySelector<HTMLElement>("#my-embed");
|
||||
if (inlineEl) {
|
||||
inlineEl.style.width = data.data.width;
|
||||
inlineEl.style.height = data.data.height;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function makePreviewPageUseSystemPreference() {
|
||||
const colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
function handleColorSchemeChange(e: MediaQueryListEvent) {
|
||||
if (e.matches) {
|
||||
// Dark color scheme
|
||||
document.body.classList.remove("light");
|
||||
document.body.classList.add("dark");
|
||||
} else {
|
||||
// Light color scheme
|
||||
document.body.classList.add("light");
|
||||
document.body.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
|
||||
colorSchemeQuery.addEventListener("change", handleColorSchemeChange);
|
||||
|
||||
// Initial check
|
||||
handleColorSchemeChange(new MediaQueryListEvent("change", { matches: colorSchemeQuery.matches }));
|
||||
}
|
||||
|
||||
// This makes preview page behave like a website that has system preference enabled. This provides a better experience of preview when user switch their system theme to dark
|
||||
makePreviewPageUseSystemPreference();
|
||||
|
||||
export {};
|
||||
161
calcom/packages/embeds/embed-core/src/sdk-action-manager.ts
Normal file
161
calcom/packages/embeds/embed-core/src/sdk-action-manager.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
type Namespace = string;
|
||||
type CustomEventDetail = Record<string, unknown>;
|
||||
|
||||
function _fireEvent(fullName: string, detail: CustomEventDetail) {
|
||||
const event = new window.CustomEvent(fullName, {
|
||||
detail: detail,
|
||||
});
|
||||
|
||||
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<string, never>;
|
||||
bookingSuccessfulV2: {
|
||||
uid: string | undefined;
|
||||
title: string | undefined;
|
||||
startTime: string | undefined;
|
||||
endTime: string | undefined;
|
||||
eventTypeId: number | null | undefined;
|
||||
status: string | undefined;
|
||||
paymentRequired: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use `bookingSuccessfulV2` instead. We restrict the data heavily there, only sending what is absolutely needed and keeping it light as well. Plus, more importantly that can be documented well.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
rescheduleBookingSuccessfulV2: {
|
||||
uid: string | undefined;
|
||||
title: string | undefined;
|
||||
startTime: string | undefined;
|
||||
endTime: string | undefined;
|
||||
eventTypeId: number | null | undefined;
|
||||
status: string | undefined;
|
||||
paymentRequired: boolean;
|
||||
};
|
||||
/**
|
||||
* @deprecated Use `rescheduleBookingSuccessfulV2` instead. We restrict the data heavily there, only sending what is absolutely needed and keeping it light as well. Plus, more importantly that can be documented well.
|
||||
*/
|
||||
rescheduleBookingSuccessful: {
|
||||
booking: unknown;
|
||||
eventType: unknown;
|
||||
date: string;
|
||||
duration: number | undefined;
|
||||
organizer: {
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
};
|
||||
confirmed: boolean;
|
||||
};
|
||||
bookingCancelled: {
|
||||
booking: unknown;
|
||||
organizer: {
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone?: string | undefined;
|
||||
};
|
||||
eventType: unknown;
|
||||
};
|
||||
routed: {
|
||||
actionType: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl";
|
||||
actionValue: string;
|
||||
};
|
||||
navigatedToBooker: Record<string, never>;
|
||||
"*": Record<string, unknown>;
|
||||
__routeChanged: Record<string, never>;
|
||||
__windowLoadComplete: Record<string, never>;
|
||||
__closeIframe: Record<string, never>;
|
||||
__iframeReady: Record<string, never>;
|
||||
__dimensionChanged: {
|
||||
iframeHeight: number;
|
||||
iframeWidth: number;
|
||||
isFirstTime: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type EventData<T extends keyof EventDataMap> = {
|
||||
[K in T]: {
|
||||
type: string;
|
||||
namespace: string;
|
||||
fullType: string;
|
||||
data: EventDataMap[K];
|
||||
};
|
||||
}[T];
|
||||
|
||||
export class SdkActionManager {
|
||||
namespace: Namespace;
|
||||
|
||||
static parseAction(fullType: string) {
|
||||
if (!fullType) {
|
||||
return null;
|
||||
}
|
||||
//FIXME: Ensure that any action if it has :, it is properly encoded.
|
||||
const [cal, calNamespace, type] = fullType.split(":");
|
||||
if (cal !== "CAL") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ns: calNamespace,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
getFullActionName(name: string) {
|
||||
return this.namespace ? `CAL:${this.namespace}:${name}` : `CAL::${name}`;
|
||||
}
|
||||
|
||||
fire<T extends keyof EventDataMap>(name: T, data: EventDataMap[T]) {
|
||||
const fullName = this.getFullActionName(name);
|
||||
const detail = {
|
||||
type: name,
|
||||
namespace: this.namespace,
|
||||
fullType: fullName,
|
||||
data,
|
||||
};
|
||||
|
||||
_fireEvent(fullName, detail);
|
||||
|
||||
// Wildcard Event
|
||||
_fireEvent(this.getFullActionName("*"), detail);
|
||||
}
|
||||
|
||||
on<T extends keyof EventDataMap>(name: T, callback: (arg0: CustomEvent<EventData<T>>) => void) {
|
||||
const fullName = this.getFullActionName(name);
|
||||
window.addEventListener(fullName, callback as EventListener);
|
||||
}
|
||||
|
||||
off<T extends keyof EventDataMap>(name: T, callback: (arg0: CustomEvent<EventData<T>>) => void) {
|
||||
const fullName = this.getFullActionName(name);
|
||||
window.removeEventListener(fullName, callback as EventListener);
|
||||
}
|
||||
|
||||
constructor(ns: string | null) {
|
||||
ns = ns || "";
|
||||
this.namespace = ns;
|
||||
}
|
||||
}
|
||||
13
calcom/packages/embeds/embed-core/src/sdk-event.ts
Normal file
13
calcom/packages/embeds/embed-core/src/sdk-event.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @file
|
||||
* This module is supposed to instantiate the SDK with appropriate namespace
|
||||
*/
|
||||
import embedInit from "@calcom/embed-core/embed-iframe-init";
|
||||
|
||||
import { SdkActionManager } from "./sdk-action-manager";
|
||||
|
||||
export let sdkActionManager: SdkActionManager | null = null;
|
||||
if (typeof window !== "undefined") {
|
||||
embedInit();
|
||||
sdkActionManager = new SdkActionManager(window.getEmbedNamespace());
|
||||
}
|
||||
25
calcom/packages/embeds/embed-core/src/styles.css
Normal file
25
calcom/packages/embeds/embed-core/src/styles.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@font-face {
|
||||
font-family: "Cal Sans";
|
||||
src: url("https://cal.com/cal.ttf");
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: "Cal Sans";
|
||||
font-weight: normal;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
:host {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue,
|
||||
Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
||||
}
|
||||
45
calcom/packages/embeds/embed-core/src/types.ts
Normal file
45
calcom/packages/embeds/embed-core/src/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
export type EmbedThemeConfig = Theme | "auto";
|
||||
|
||||
export type BookerLayouts = "month_view" | "week_view" | "column_view";
|
||||
// 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.
|
||||
export interface EmbedStyles {
|
||||
body?: Pick<CSSProperties, "background">;
|
||||
eventTypeListItem?: Pick<CSSProperties, "background" | "color" | "backgroundColor">;
|
||||
enabledDateButton?: Pick<CSSProperties, "background" | "color" | "backgroundColor">;
|
||||
disabledDateButton?: Pick<CSSProperties, "background" | "color" | "backgroundColor">;
|
||||
availabilityDatePicker?: Pick<CSSProperties, "background" | "color" | "backgroundColor">;
|
||||
}
|
||||
|
||||
export interface EmbedNonStylesConfig {
|
||||
/** Default would be center */
|
||||
align?: "left";
|
||||
branding?: {
|
||||
brandColor?: string;
|
||||
};
|
||||
}
|
||||
|
||||
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<Theme, Record<string, string>>;
|
||||
layout?: BookerLayouts;
|
||||
colorScheme?: string | null;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CalComPageStatus: string;
|
||||
isEmbed?: () => boolean;
|
||||
getEmbedNamespace: () => string | null;
|
||||
getEmbedTheme: () => EmbedThemeConfig | null;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,21 @@
|
||||
// this file is copied from '@calcom/lib/hooks/useCompatSearchParams.tsx'
|
||||
import { ReadonlyURLSearchParams, useParams, useSearchParams } from "next/navigation";
|
||||
|
||||
export const useCompatSearchParams = () => {
|
||||
const _searchParams = useSearchParams() ?? new URLSearchParams();
|
||||
const params = useParams() ?? {};
|
||||
|
||||
const searchParams = new URLSearchParams(_searchParams.toString());
|
||||
Object.getOwnPropertyNames(params).forEach((key) => {
|
||||
searchParams.delete(key);
|
||||
|
||||
const param = params[key];
|
||||
const paramArr = typeof param === "string" ? param.split("/") : param;
|
||||
|
||||
paramArr?.forEach((p) => {
|
||||
searchParams.append(key, p);
|
||||
});
|
||||
});
|
||||
|
||||
return new ReadonlyURLSearchParams(searchParams);
|
||||
};
|
||||
7
calcom/packages/embeds/embed-core/src/utils.ts
Normal file
7
calcom/packages/embeds/embed-core/src/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const getErrorString = (errorCode: string | undefined) => {
|
||||
if (errorCode === "404") {
|
||||
return `Error Code: 404. Cal Link seems to be wrong.`;
|
||||
} else {
|
||||
return `Error Code: ${errorCode}. Something went wrong.`;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user