Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk> Co-authored-by: Peer Richelsen <peer@cal.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
558 lines
19 KiB
TypeScript
558 lines
19 KiB
TypeScript
import { useSearchParams } from "next/navigation";
|
|
import type { CSSProperties } from "react";
|
|
import { useEffect, useRef, useState, useCallback } from "react";
|
|
|
|
import type { Message } from "./embed";
|
|
import { sdkActionManager } from "./sdk-event";
|
|
|
|
type Theme = "dark" | "light";
|
|
|
|
export type BookerLayouts = "month_view" | "week_view" | "column_view";
|
|
|
|
export type EmbedThemeConfig = Theme | "auto";
|
|
export type UiConfig = {
|
|
hideEventTypeDetails?: boolean;
|
|
// If theme not provided we would get null
|
|
theme?: EmbedThemeConfig | null;
|
|
styles?: EmbedStyles & EmbedNonStylesConfig;
|
|
//TODO: Extract from tailwind the list of all custom variables and support them in auto-completion as well as runtime validation. Followup with listing all variables in Embed Snippet Generator UI.
|
|
cssVarsPerTheme?: Record<Theme, Record<string, string>>;
|
|
layout?: BookerLayouts;
|
|
colorScheme?: string | null;
|
|
};
|
|
|
|
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
|
|
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
|
|
|
|
/**
|
|
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
|
|
*/
|
|
const embedStore = {
|
|
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
|
|
styles: {} as EmbedStyles | undefined,
|
|
nonStyles: {} as EmbedNonStylesConfig | undefined,
|
|
namespace: null as string | null,
|
|
embedType: undefined as undefined | null | string,
|
|
// Store all React State setters here.
|
|
reactStylesStateSetters: {} as Record<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)[],
|
|
};
|
|
|
|
declare global {
|
|
interface Window {
|
|
CalEmbed: {
|
|
__logQueue?: unknown[];
|
|
embedStore: typeof embedStore;
|
|
applyCssVars: (cssVarsPerTheme: UiConfig["cssVarsPerTheme"]) => void;
|
|
};
|
|
CalComPageStatus: string;
|
|
isEmbed?: () => boolean;
|
|
getEmbedNamespace: () => string | null;
|
|
getEmbedTheme: () => EmbedThemeConfig | null;
|
|
}
|
|
}
|
|
|
|
let isSafariBrowser = false;
|
|
const isBrowser = typeof window !== "undefined";
|
|
|
|
if (isBrowser) {
|
|
window.CalEmbed = window?.CalEmbed || {};
|
|
window.CalEmbed.embedStore = embedStore;
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
isSafariBrowser = ua.includes("safari") && !ua.includes("chrome");
|
|
if (isSafariBrowser) {
|
|
log("Safari Detected: Using setTimeout instead of rAF");
|
|
}
|
|
}
|
|
|
|
function runAsap(fn: (...arg: unknown[]) => void) {
|
|
if (isSafariBrowser) {
|
|
// https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/
|
|
return setTimeout(fn, 50);
|
|
}
|
|
return requestAnimationFrame(fn);
|
|
}
|
|
|
|
function log(...args: unknown[]) {
|
|
if (isBrowser) {
|
|
const namespace = getNamespace();
|
|
|
|
const searchParams = new URL(document.URL).searchParams;
|
|
const logQueue = (window.CalEmbed.__logQueue = window.CalEmbed.__logQueue || []);
|
|
args.push({
|
|
ns: namespace,
|
|
url: document.URL,
|
|
});
|
|
args.unshift("CAL:");
|
|
logQueue.push(args);
|
|
if (searchParams.get("debug")) {
|
|
console.log(...args);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only allow certain styles to be modified so that when we make any changes to HTML, we know what all embed styles might be impacted.
|
|
// Keep this list to minimum, only adding those styles which are really needed.
|
|
interface EmbedStyles {
|
|
body?: Pick<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">;
|
|
}
|
|
interface EmbedNonStylesConfig {
|
|
/** Default would be center */
|
|
align?: "left";
|
|
branding?: {
|
|
brandColor?: string;
|
|
};
|
|
}
|
|
|
|
const setEmbedStyles = (stylesConfig: EmbedStyles) => {
|
|
embedStore.styles = stylesConfig;
|
|
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
|
|
setEmbedStyle((styles) => {
|
|
return {
|
|
...styles,
|
|
...stylesConfig,
|
|
};
|
|
});
|
|
}
|
|
};
|
|
|
|
const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => {
|
|
embedStore.nonStyles = stylesConfig;
|
|
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
|
|
setEmbedStyle((styles) => {
|
|
return {
|
|
...styles,
|
|
...stylesConfig,
|
|
};
|
|
});
|
|
}
|
|
};
|
|
|
|
const registerNewSetter = (
|
|
registration:
|
|
| {
|
|
elementName: keyof EmbedStyles;
|
|
setState: SetStyles;
|
|
styles: true;
|
|
}
|
|
| {
|
|
elementName: keyof EmbedNonStylesConfig;
|
|
setState: setNonStylesConfig;
|
|
styles: false;
|
|
}
|
|
) => {
|
|
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe.
|
|
// So, we should call the setter immediately with available embedStyles
|
|
if (registration.styles) {
|
|
embedStore.reactStylesStateSetters[registration.elementName as keyof EmbedStyles] = registration.setState;
|
|
registration.setState(embedStore.styles || {});
|
|
return () => {
|
|
delete embedStore.reactStylesStateSetters[registration.elementName];
|
|
};
|
|
} else {
|
|
embedStore.reactNonStylesStateSetters[registration.elementName as keyof EmbedNonStylesConfig] =
|
|
registration.setState;
|
|
registration.setState(embedStore.nonStyles || {});
|
|
|
|
return () => {
|
|
delete embedStore.reactNonStylesStateSetters[registration.elementName];
|
|
};
|
|
}
|
|
};
|
|
|
|
function isValidNamespace(ns: string | null | undefined) {
|
|
return typeof ns !== "undefined" && ns !== null;
|
|
}
|
|
|
|
/**
|
|
* It handles any URL change done through Web history API as well
|
|
* History API is currenty 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}`);
|
|
useEffect(() => {
|
|
const newUrl = `${pathname}?${searchParams}`;
|
|
if (lastKnownUrl.current !== newUrl) {
|
|
lastKnownUrl.current = newUrl;
|
|
callback(newUrl);
|
|
}
|
|
}, [pathname, searchParams, callback]);
|
|
};
|
|
|
|
export const useEmbedTheme = () => {
|
|
const searchParams = useSearchParams();
|
|
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();
|
|
sdkActionManager?.fire("linkReady", {});
|
|
});
|
|
},
|
|
};
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
if (isBrowser) {
|
|
log("Embed SDK loaded", { isEmbed: window?.isEmbed?.() || false });
|
|
const url = new URL(document.URL);
|
|
embedStore.theme = window?.getEmbedTheme?.();
|
|
|
|
embedStore.uiConfig = {
|
|
// TODO: Add theme as well here
|
|
colorScheme: url.searchParams.get("ui.color-scheme"),
|
|
layout: url.searchParams.get("layout") as BookerLayouts,
|
|
};
|
|
|
|
actOnColorScheme(embedStore.uiConfig.colorScheme);
|
|
|
|
if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) {
|
|
log("Initializing embed-iframe");
|
|
// HACK
|
|
const pageStatus = window.CalComPageStatus;
|
|
// If embed link is opened in top, and not in iframe. Let the page be visible.
|
|
if (top === window) {
|
|
unhideBody();
|
|
}
|
|
|
|
sdkActionManager?.on("*", (e) => {
|
|
const detail = e.detail;
|
|
log(detail);
|
|
messageParent(detail);
|
|
});
|
|
|
|
window.addEventListener("message", (e) => {
|
|
const data: Message = e.data;
|
|
if (!data) {
|
|
return;
|
|
}
|
|
const method: keyof typeof interfaceWithParent = data.method;
|
|
if (data.originator === "CAL" && typeof method === "string") {
|
|
interfaceWithParent[method]?.(data.arg as never);
|
|
}
|
|
});
|
|
|
|
document.addEventListener("click", (e) => {
|
|
if (!e.target || !(e.target instanceof Node)) {
|
|
return;
|
|
}
|
|
const mainElement =
|
|
document.getElementsByClassName("main")[0] ||
|
|
document.getElementsByTagName("main")[0] ||
|
|
document.documentElement;
|
|
if (e.target.contains(mainElement)) {
|
|
sdkActionManager?.fire("__closeIframe", {});
|
|
}
|
|
});
|
|
|
|
if (!pageStatus || pageStatus == "200") {
|
|
keepParentInformedAboutDimensionChanges();
|
|
sdkActionManager?.fire("__iframeReady", {});
|
|
} else
|
|
sdkActionManager?.fire("linkFailed", {
|
|
code: pageStatus,
|
|
msg: "Problem loading the link",
|
|
data: {
|
|
url: document.URL,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
function runAllUiSetters(uiConfig: UiConfig) {
|
|
// Update EmbedStore so that when a new react component mounts, useEmbedUiConfig can get the persisted value from embedStore.uiConfig
|
|
embedStore.uiConfig = uiConfig;
|
|
embedStore.setUiConfig.forEach((setUiConfig) => setUiConfig(uiConfig));
|
|
}
|
|
|
|
function actOnColorScheme(colorScheme: string | null | undefined) {
|
|
if (!colorScheme) {
|
|
return;
|
|
}
|
|
document.documentElement.style.colorScheme = colorScheme;
|
|
}
|