2022-04-08 05:33:24 +00:00
import { useRouter } from "next/router" ;
2022-03-31 08:45:47 +00:00
import { useState , useEffect , CSSProperties } from "react" ;
import { sdkActionManager } from "./sdk-event" ;
2022-04-08 05:33:24 +00:00
export interface UiConfig {
2022-05-05 14:29:49 +00:00
theme ? : "dark" | "light" | "auto" ;
styles? : EmbedStyles ;
2022-04-08 05:33:24 +00:00
}
2022-05-06 15:56:26 +00:00
declare global {
interface Window {
CalEmbed : {
__logQueue? : any [ ] ;
embedStore : any ;
} ;
CalComPageStatus : string ;
CalComPlan : string ;
}
}
2022-04-08 05:33:24 +00:00
const embedStore = {
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles : { } ,
namespace : null ,
2022-04-25 04:33:00 +00:00
embedType : undefined ,
2022-04-08 05:33:24 +00:00
// Store all React State setters here.
reactStylesStateSetters : { } ,
parentInformedAboutContentHeight : false ,
windowLoadEventFired : false ,
} as {
styles : UiConfig [ "styles" ] ;
namespace : string | null ;
2022-04-25 04:33:00 +00:00
embedType : undefined | null | string ;
2022-04-08 05:33:24 +00:00
reactStylesStateSetters : any ;
parentInformedAboutContentHeight : boolean ;
windowLoadEventFired : boolean ;
2022-05-05 14:29:49 +00:00
theme? : UiConfig [ "theme" ] ;
setTheme : ( arg0 : string ) = > void ;
2022-04-08 05:33:24 +00:00
} ;
2022-04-04 15:44:04 +00:00
let isSafariBrowser = false ;
2022-04-08 05:33:24 +00:00
const isBrowser = typeof window !== "undefined" ;
2022-04-04 15:44:04 +00:00
2022-04-08 05:33:24 +00:00
if ( isBrowser ) {
2022-04-04 15:44:04 +00:00
const ua = navigator . userAgent . toLowerCase ( ) ;
isSafariBrowser = ua . includes ( "safari" ) && ! ua . includes ( "chrome" ) ;
if ( isSafariBrowser ) {
log ( "Safari Detected: Using setTimeout instead of rAF" ) ;
}
2022-05-06 15:56:26 +00:00
window . CalEmbed = window . CalEmbed || { } ;
//TODO: Send postMessage to parent to get all log messages in the same queue.
window . CalEmbed . embedStore = embedStore ;
2022-04-04 15:44:04 +00:00
}
2022-04-08 05:33:24 +00:00
function runAsap ( fn : ( . . . arg : any ) = > void ) {
2022-04-04 15:44:04 +00:00
if ( isSafariBrowser ) {
// https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/
return setTimeout ( fn , 50 ) ;
}
return requestAnimationFrame ( fn ) ;
}
2022-04-08 05:33:24 +00:00
2022-04-04 15:44:04 +00:00
function log ( . . . args : any [ ] ) {
2022-04-08 05:33:24 +00:00
if ( isBrowser ) {
const namespace = getNamespace ( ) ;
2022-04-04 15:44:04 +00:00
const searchParams = new URL ( document . URL ) . searchParams ;
const logQueue = ( window . CalEmbed . __logQueue = window . CalEmbed . __logQueue || [ ] ) ;
args . push ( {
ns : namespace ,
2022-04-08 05:33:24 +00:00
url : document.URL ,
2022-04-04 15:44:04 +00:00
} ) ;
args . unshift ( "CAL:" ) ;
logQueue . push ( args ) ;
if ( searchParams . get ( "debug" ) ) {
console . log ( . . . args ) ;
}
}
}
2022-03-31 08:45:47 +00:00
// 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 {
2022-04-08 05:33:24 +00:00
body? : Pick < CSSProperties , " background " > ;
2022-03-31 08:45:47 +00:00
eventTypeListItem? : Pick < CSSProperties , " background " | " color " | " backgroundColor " > ;
enabledDateButton? : Pick < CSSProperties , " background " | " color " | " backgroundColor " > ;
disabledDateButton? : Pick < CSSProperties , " background " | " color " | " backgroundColor " > ;
2022-04-08 05:33:24 +00:00
availabilityDatePicker? : Pick < CSSProperties , " background " | " color " | " backgroundColor " > ;
2022-03-31 08:45:47 +00:00
}
2022-04-25 04:33:00 +00:00
interface EmbedNonStylesConfig {
/** Default would be center */
align : "left" ;
2022-04-08 05:33:24 +00:00
branding ? : {
brandColor? : string ;
lightColor? : string ;
lighterColor? : string ;
lightestColor? : string ;
highlightColor? : string ;
darkColor? : string ;
darkerColor? : string ;
medianColor? : string ;
} ;
2022-03-31 08:45:47 +00:00
}
2022-04-25 04:33:00 +00:00
type ReactEmbedStylesSetter = React . Dispatch < React.SetStateAction < EmbedStyles | EmbedNonStylesConfig > > ;
2022-03-31 08:45:47 +00:00
const setEmbedStyles = ( stylesConfig : UiConfig [ "styles" ] ) = > {
embedStore . styles = stylesConfig ;
for ( let [ , setEmbedStyle ] of Object . entries ( embedStore . reactStylesStateSetters ) ) {
2022-04-08 05:33:24 +00:00
( setEmbedStyle as any ) ( ( styles : any ) = > {
2022-03-31 08:45:47 +00:00
return {
. . . styles ,
. . . stylesConfig ,
} ;
} ) ;
}
} ;
2022-04-25 04:33:00 +00:00
const registerNewSetter = ( elementName : keyof EmbedStyles | keyof EmbedNonStylesConfig , setStyles : any ) = > {
2022-03-31 08:45:47 +00:00
embedStore . reactStylesStateSetters [ elementName ] = setStyles ;
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe.
// So, we should call the setter immediately with available embedStyles
setStyles ( embedStore . styles ) ;
} ;
2022-04-25 04:33:00 +00:00
const removeFromEmbedStylesSetterMap = ( elementName : keyof EmbedStyles | keyof EmbedNonStylesConfig ) = > {
2022-03-31 08:45:47 +00:00
delete embedStore . reactStylesStateSetters [ elementName ] ;
} ;
2022-04-08 05:33:24 +00:00
function isValidNamespace ( ns : string | null | undefined ) {
return typeof ns !== "undefined" && ns !== null ;
}
export const useEmbedTheme = ( ) = > {
const router = useRouter ( ) ;
2022-05-05 14:29:49 +00:00
let [ theme , setTheme ] = useState ( embedStore . theme || ( router . query . theme as string ) ) ;
2022-04-25 04:33:00 +00:00
useEffect ( ( ) = > {
router . events . on ( "routeChangeComplete" , ( ) = > {
sdkActionManager ? . fire ( "__routeChanged" , { } ) ;
} ) ;
} , [ router . events ] ) ;
2022-05-05 14:29:49 +00:00
embedStore . setTheme = setTheme ;
return theme === "auto" ? null : theme ;
2022-04-08 05:33:24 +00:00
} ;
2022-03-31 08:45:47 +00:00
// 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
2022-04-08 05:33:24 +00:00
export const useEmbedStyles = ( elementName : keyof EmbedStyles ) = > {
2022-03-31 08:45:47 +00:00
const [ styles , setStyles ] = useState ( { } as EmbedStyles ) ;
useEffect ( ( ) = > {
registerNewSetter ( elementName , setStyles ) ;
// It's important to have an element's embed style be required in only one component. If due to any reason it is required in multiple components, we would override state setter.
return ( ) = > {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap ( elementName ) ;
} ;
} , [ ] ) ;
return styles [ elementName ] || { } ;
} ;
2022-04-25 04:33:00 +00:00
export const useEmbedNonStylesConfig = ( elementName : keyof EmbedNonStylesConfig ) = > {
const [ styles , setStyles ] = useState ( { } as EmbedNonStylesConfig ) ;
2022-04-08 05:33:24 +00:00
useEffect ( ( ) = > {
registerNewSetter ( elementName , setStyles ) ;
// It's important to have an element's embed style be required in only one component. If due to any reason it is required in multiple components, we would override state setter.
return ( ) = > {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap ( elementName ) ;
} ;
} , [ ] ) ;
return styles [ elementName ] || { } ;
} ;
export const useIsBackgroundTransparent = ( ) = > {
let isBackgroundTransparent = false ;
// TODO: Background should be read as ui.background and not ui.body.background
const bodyEmbedStyles = useEmbedStyles ( "body" ) ;
2022-04-25 04:33:00 +00:00
if ( bodyEmbedStyles . background === "transparent" ) {
2022-04-08 05:33:24 +00:00
isBackgroundTransparent = true ;
}
return isBackgroundTransparent ;
} ;
export const useBrandColors = ( ) = > {
// TODO: Branding shouldn't be part of ui.styles. It should exist as ui.branding.
2022-04-25 04:33:00 +00:00
const brandingColors = useEmbedNonStylesConfig ( "branding" ) as EmbedNonStylesConfig [ "branding" ] ;
return brandingColors || { } ;
2022-04-08 05:33:24 +00:00
} ;
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 url = new URL ( document . URL ) ;
const namespace = url . searchParams . get ( "embed" ) ;
embedStore . namespace = namespace ;
return namespace ;
}
}
2022-04-25 04:33:00 +00:00
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 ;
}
}
2022-04-08 05:33:24 +00:00
const isEmbed = ( ) = > {
const namespace = getNamespace ( ) ;
2022-04-08 16:59:08 +00:00
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."
) ;
}
2022-04-08 05:33:24 +00:00
return isValidNamespace ( namespace ) ;
} ;
export const useIsEmbed = ( ) = > {
// We can't simply return isEmbed() from this method.
// isEmbed() returns different values on server and browser, which messes up the hydration.
// TODO: We can avoid using document.URL and instead use Router.
const [ _isEmbed , setIsEmbed ] = useState ( false ) ;
useEffect ( ( ) = > {
setIsEmbed ( isEmbed ( ) ) ;
} , [ ] ) ;
return _isEmbed ;
} ;
2022-04-25 04:33:00 +00:00
export const useEmbedType = ( ) = > {
const [ state , setState ] = useState < string | null | undefined > ( null ) ;
useEffect ( ( ) = > {
setState ( getEmbedType ( ) ) ;
} , [ ] ) ;
return state ;
} ;
2022-04-04 15:44:04 +00:00
function unhideBody() {
document . body . style . display = "block" ;
}
2022-04-08 05:33:24 +00:00
2022-03-31 08:45:47 +00:00
// If you add a method here, give type safety to parent manually by adding it to embed.ts. Look for "parentKnowsIframeReady" in it
export const methods = {
ui : function style ( uiConfig : UiConfig ) {
// TODO: Create automatic logger for all methods. Useful for debugging.
2022-04-04 15:44:04 +00:00
log ( "Method: ui called" , uiConfig ) ;
if ( window . CalComPlan && window . CalComPlan !== "PRO" ) {
log ( ` Upgrade to PRO for "ui" instruction to work ` , window . CalComPlan ) ;
return ;
}
2022-03-31 08:45:47 +00:00
const stylesConfig = uiConfig . styles ;
2022-04-04 15:44:04 +00:00
// In case where parent gives instructions before CalComPlan is set.
// This is easily possible as React takes time to initialize and render components where this variable is set.
if ( ! window . CalComPlan ) {
2022-03-31 08:45:47 +00:00
return requestAnimationFrame ( ( ) = > {
style ( uiConfig ) ;
} ) ;
}
// body can't be styled using React state hook as it is generated by _document.tsx which doesn't support hooks.
2022-05-05 14:29:49 +00:00
if ( stylesConfig ? . body ? . background ) {
2022-03-31 08:45:47 +00:00
document . body . style . background = stylesConfig . body . background as string ;
}
2022-05-05 14:29:49 +00:00
if ( uiConfig . theme ) {
embedStore . theme = uiConfig . theme as UiConfig [ "theme" ] ;
embedStore . setTheme ( uiConfig . theme ) ;
}
setEmbedStyles ( stylesConfig || { } ) ;
2022-03-31 08:45:47 +00:00
} ,
parentKnowsIframeReady : ( ) = > {
2022-04-04 15:44:04 +00:00
log ( "Method: `parentKnowsIframeReady` called" ) ;
2022-04-08 05:33:24 +00:00
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.
requestAnimationFrame ( unhideBody ) ;
sdkActionManager ? . fire ( "linkReady" , { } ) ;
} ) ;
2022-03-31 08:45:47 +00:00
} ,
} ;
const messageParent = ( data : any ) = > {
parent . postMessage (
{
originator : "CAL" ,
. . . data ,
} ,
"*"
) ;
} ;
function keepParentInformedAboutDimensionChanges() {
2022-04-04 15:44:04 +00:00
let knownIframeHeight : Number | null = null ;
2022-04-14 02:47:34 +00:00
let knownIframeWidth : Number | null = null ;
2022-03-31 08:45:47 +00:00
let numDimensionChanges = 0 ;
2022-04-04 15:44:04 +00:00
let isFirstTime = true ;
let isWindowLoadComplete = false ;
2022-04-08 05:33:24 +00:00
runAsap ( function informAboutScroll() {
2022-04-04 15:44:04 +00:00
if ( document . readyState !== "complete" ) {
// Wait for window to load to correctly calculate the initial scroll height.
2022-04-08 05:33:24 +00:00
runAsap ( informAboutScroll ) ;
2022-04-04 15:44:04 +00:00
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 ( ) ;
2022-04-14 02:47:34 +00:00
} , 100 ) ;
2022-04-04 15:44:04 +00:00
return ;
}
2022-04-08 05:33:24 +00:00
if ( ! embedStore . windowLoadEventFired ) {
2022-04-08 16:59:08 +00:00
sdkActionManager ? . fire ( "__windowLoadComplete" , { } ) ;
2022-04-08 05:33:24 +00:00
}
embedStore . windowLoadEventFired = true ;
2022-04-14 02:47:34 +00:00
// 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.
2022-04-25 04:33:00 +00:00
const mainElement =
( document . getElementsByClassName ( "main" ) [ 0 ] as HTMLElement ) ||
document . getElementsByTagName ( "main" ) [ 0 ] ||
document . documentElement ;
2022-04-04 15:44:04 +00:00
const documentScrollHeight = document . documentElement . scrollHeight ;
2022-04-08 05:33:24 +00:00
const documentScrollWidth = document . documentElement . scrollWidth ;
2022-04-25 04:33:00 +00:00
2022-04-14 02:47:34 +00:00
const contentHeight = mainElement . offsetHeight ;
const contentWidth = mainElement . offsetWidth ;
2022-04-08 05:33:24 +00:00
2022-04-04 15:44:04 +00:00
// 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
let iframeHeight = isFirstTime ? documentScrollHeight : contentHeight ;
2022-04-08 05:33:24 +00:00
let iframeWidth = isFirstTime ? documentScrollWidth : contentWidth ;
embedStore . parentInformedAboutContentHeight = true ;
2022-04-14 02:47:34 +00:00
if ( ! iframeHeight || ! iframeWidth ) {
runAsap ( informAboutScroll ) ;
return ;
}
if ( knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth ) {
2022-04-04 15:44:04 +00:00
knownIframeHeight = iframeHeight ;
2022-04-14 02:47:34 +00:00
knownIframeWidth = iframeWidth ;
2022-03-31 08:45:47 +00:00
numDimensionChanges ++ ;
// FIXME: This event shouldn't be subscribable by the user. Only by the SDK.
2022-04-08 16:59:08 +00:00
sdkActionManager ? . fire ( "__dimensionChanged" , {
2022-04-04 15:44:04 +00:00
iframeHeight ,
2022-04-08 05:33:24 +00:00
iframeWidth ,
isFirstTime ,
2022-03-31 08:45:47 +00:00
} ) ;
}
2022-04-08 05:33:24 +00:00
isFirstTime = false ;
2022-03-31 08:45:47 +00:00
// 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.
2022-04-08 05:33:24 +00:00
runAsap ( informAboutScroll ) ;
2022-03-31 08:45:47 +00:00
} ) ;
}
2022-04-08 05:33:24 +00:00
if ( isBrowser ) {
2022-04-04 15:44:04 +00:00
const url = new URL ( document . URL ) ;
2022-05-05 14:29:49 +00:00
embedStore . theme = ( url . searchParams . get ( "theme" ) || "auto" ) as UiConfig [ "theme" ] ;
2022-04-08 05:33:24 +00:00
if ( url . searchParams . get ( "prerender" ) !== "true" && isEmbed ( ) ) {
2022-04-04 15:44:04 +00:00
log ( "Initializing embed-iframe" ) ;
2022-04-08 05:33:24 +00:00
// HACK
const pageStatus = window . CalComPageStatus ;
2022-04-04 15:44:04 +00:00
// If embed link is opened in top, and not in iframe. Let the page be visible.
if ( top === window ) {
unhideBody ( ) ;
2022-03-31 08:45:47 +00:00
}
2022-04-04 15:44:04 +00:00
sdkActionManager ? . on ( "*" , ( e ) = > {
const detail = e . detail ;
//console.log(detail.fullType, detail.type, detail.data);
log ( detail ) ;
messageParent ( detail ) ;
} ) ;
2022-04-14 02:47:34 +00:00
// This event should be fired whenever you want to let the content take automatic width which is available.
// Because on cal-iframe we set explicty width to make it look inline and part of page, there is never space available for content to automatically expand
// This is a HACK to quickly tell iframe to go full width and let iframe content adapt to that and set new width.
sdkActionManager ? . on ( "__refreshWidth" , ( ) = > {
2022-04-25 04:33:00 +00:00
// sdkActionManager?.fire("__dimensionChanged", {
// iframeWidth: 100,
// __unit: "%",
// });
// runAsap(() => {
// sdkActionManager?.fire("__dimensionChanged", {
// iframeWidth: 100,
// __unit: "%",
// });
// });
2022-04-14 02:47:34 +00:00
} ) ;
2022-04-04 15:44:04 +00:00
window . addEventListener ( "message" , ( e ) = > {
const data : Record < string , any > = e . data ;
if ( ! data ) {
return ;
}
const method : keyof typeof methods = data . method ;
if ( data . originator === "CAL" && typeof method === "string" ) {
methods [ method ] ? . ( data . arg ) ;
}
} ) ;
2022-04-25 04:33:00 +00:00
document . addEventListener ( "click" , ( e ) = > {
if ( ! e . target ) {
return ;
}
const mainElement =
( document . getElementsByClassName ( "main" ) [ 0 ] as HTMLElement ) ||
document . getElementsByTagName ( "main" ) [ 0 ] ||
document . documentElement ;
if ( ( e . target as HTMLElement ) . contains ( mainElement ) ) {
sdkActionManager ? . fire ( "__closeIframe" , { } ) ;
}
} ) ;
2022-04-08 05:33:24 +00:00
if ( ! pageStatus || pageStatus == "200" ) {
keepParentInformedAboutDimensionChanges ( ) ;
2022-04-08 16:59:08 +00:00
sdkActionManager ? . fire ( "__iframeReady" , { } ) ;
2022-04-08 05:33:24 +00:00
} else
sdkActionManager ? . fire ( "linkFailed" , {
code : pageStatus ,
msg : "Problem loading the link" ,
data : {
url : document.URL ,
} ,
} ) ;
2022-04-04 15:44:04 +00:00
}
2022-03-31 08:45:47 +00:00
}