2022-03-31 08:45:47 +00:00
import type { CalWindow } from "@calcom/embed-snippet" ;
2022-04-25 04:33:00 +00:00
import { FloatingButton } from "./FloatingButton/FloatingButton" ;
import { Inline } from "./Inline/inline" ;
import { ModalBox } from "./ModalBox/ModalBox" ;
2022-03-31 08:45:47 +00:00
import { methods , UiConfig } from "./embed-iframe" ;
import css from "./embed.css" ;
import { SdkActionManager } from "./sdk-action-manager" ;
2022-04-25 04:33:00 +00:00
import allCss from "./tailwind.generated.css" ;
2022-03-31 08:45:47 +00:00
2022-04-25 04:33:00 +00:00
customElements . define ( "cal-modal-box" , ModalBox ) ;
customElements . define ( "cal-floating-button" , FloatingButton ) ;
customElements . define ( "cal-inline" , Inline ) ;
2022-03-31 08:45:47 +00:00
2022-04-25 04:33:00 +00:00
declare module "*.css" ;
2022-03-31 08:45:47 +00:00
type Namespace = string ;
2022-04-04 15:44:04 +00:00
type Config = {
origin : string ;
2022-04-25 04:33:00 +00:00
debug? : boolean ;
2022-04-04 15:44:04 +00:00
} ;
2022-03-31 08:45:47 +00:00
const globalCal = ( window as CalWindow ) . Cal ;
if ( ! globalCal || ! globalCal . q ) {
throw new Error ( "Cal is not defined. This shouldn't happen" ) ;
}
2022-04-25 04:33:00 +00:00
globalCal . __css = allCss ;
2022-03-31 08:45:47 +00:00
document . head . appendChild ( document . createElement ( "style" ) ) . innerHTML = css ;
function log ( . . . args : any [ ] ) {
console . log ( . . . args ) ;
}
/ * *
* A very simple data validator written with intention of keeping payload size low .
* Extend the functionality of it as required by the embed .
* @param data
* @param schema
* /
function validate ( data : any , schema : Record < "props" | "required" , any > ) {
function checkType ( value : any , expectedType : any ) {
if ( typeof expectedType === "string" ) {
return typeof value == expectedType ;
} else {
return value instanceof expectedType ;
}
}
function isUndefined ( data : any ) {
return typeof data === "undefined" ;
}
if ( schema . required && isUndefined ( data ) ) {
throw new Error ( "Argument is required" ) ;
}
for ( let [ prop , propSchema ] of Object . entries < Record < " type " | " required " , any > > ( schema . props ) ) {
if ( propSchema . required && isUndefined ( data [ prop ] ) ) {
throw new Error ( ` " ${ prop } " is required ` ) ;
}
let typeCheck = true ;
if ( propSchema . type && ! isUndefined ( data [ prop ] ) ) {
if ( propSchema . type instanceof Array ) {
propSchema . type . forEach ( ( type ) = > {
typeCheck = typeCheck || checkType ( data [ prop ] , type ) ;
} ) ;
} else {
typeCheck = checkType ( data [ prop ] , propSchema . type ) ;
}
}
if ( ! typeCheck ) {
throw new Error ( ` " ${ prop } " is of wrong type.Expected type " ${ propSchema . type } " ` ) ;
}
}
}
export type Instruction = [ method : string , argument : any ] | [ method : string , argument : any ] [ ] ;
export type InstructionQueue = Instruction [ ] ;
export class Cal {
iframe? : HTMLIFrameElement ;
2022-04-25 04:33:00 +00:00
__config : Config ;
2022-03-31 08:45:47 +00:00
2022-04-08 05:33:24 +00:00
modalBox ! : Element ;
2022-04-14 02:47:34 +00:00
inlineEl ! : Element ;
2022-03-31 08:45:47 +00:00
namespace : string ;
actionManager : SdkActionManager ;
iframeReady ! : boolean ;
iframeDoQueue : { method : keyof typeof methods ; arg : any } [ ] = [ ] ;
static actionsManagers : Record < Namespace , SdkActionManager > ;
static getQueryObject ( config : Record < string , string > ) {
config = config || { } ;
return {
. . . config ,
// guests is better for API but Booking Page accepts guest. So do the mapping
2022-04-25 04:33:00 +00:00
guest : config.guests ? ? undefined ,
2022-03-31 08:45:47 +00:00
} ;
}
processInstruction ( instruction : Instruction ) {
instruction = [ ] . slice . call ( instruction , 0 ) ;
if ( instruction [ 0 ] instanceof Array ) {
// It is an instruction
instruction . forEach ( ( instruction ) = > {
this . processInstruction ( instruction ) ;
} ) ;
return ;
}
const [ method , . . . args ] = instruction ;
if ( ! this [ method ] ) {
// Instead of throwing error, log and move forward in the queue
log ( ` Instruction ${ method } not FOUND ` ) ;
}
try {
( this [ method ] as Function ) ( . . . args ) ;
} catch ( e ) {
// Instead of throwing error, log and move forward in the queue
log ( ` Instruction couldn't be executed ` , e ) ;
}
return instruction ;
}
processQueue ( queue : InstructionQueue ) {
queue . forEach ( ( instruction ) = > {
this . processInstruction ( instruction ) ;
} ) ;
queue . splice ( 0 ) ;
/** @ts-ignore */ // We changed the definition of push here.
queue . push = ( instruction ) = > {
this . processInstruction ( instruction ) ;
} ;
}
createIframe ( {
calLink ,
queryObject = { } ,
} : {
calLink : string ;
2022-04-25 04:33:00 +00:00
queryObject? : Record < string , string | string [ ] | Record < string , string > > ;
2022-03-31 08:45:47 +00:00
} ) {
const iframe = ( this . iframe = document . createElement ( "iframe" ) ) ;
iframe . className = "cal-embed" ;
2022-04-04 15:44:04 +00:00
iframe . name = "cal-embed" ;
2022-03-31 08:45:47 +00:00
const config = this . getConfig ( ) ;
2022-04-25 04:33:00 +00:00
const { iframeAttrs , . . . restQueryObject } = queryObject ;
if ( iframeAttrs && typeof iframeAttrs !== "string" && ! ( iframeAttrs instanceof Array ) ) {
iframe . setAttribute ( "id" , iframeAttrs . id ) ;
}
2022-03-31 08:45:47 +00:00
// Prepare searchParams from config
const searchParams = new URLSearchParams ( ) ;
2022-04-25 04:33:00 +00:00
for ( const [ key , value ] of Object . entries ( restQueryObject ) ) {
if ( value === undefined ) {
continue ;
}
2022-03-31 08:45:47 +00:00
if ( value instanceof Array ) {
value . forEach ( ( val ) = > searchParams . append ( key , val ) ) ;
} else {
2022-04-25 04:33:00 +00:00
searchParams . set ( key , value as string ) ;
2022-03-31 08:45:47 +00:00
}
}
const urlInstance = new URL ( ` ${ config . origin } / ${ calLink } ` ) ;
urlInstance . searchParams . set ( "embed" , this . namespace ) ;
2022-04-04 15:44:04 +00:00
if ( config . debug ) {
2022-04-25 04:33:00 +00:00
urlInstance . searchParams . set ( "debug" , "" + config . debug ) ;
2022-04-04 15:44:04 +00:00
}
2022-03-31 08:45:47 +00:00
// Merge searchParams from config onto the URL which might have query params already
//@ts-ignore
for ( let [ key , value ] of searchParams ) {
urlInstance . searchParams . append ( key , value ) ;
}
iframe . src = urlInstance . toString ( ) ;
return iframe ;
}
2022-04-04 15:44:04 +00:00
init ( namespaceOrConfig? : string | Config , config : Config = { } as Config ) {
if ( typeof namespaceOrConfig !== "string" ) {
config = ( namespaceOrConfig || { } ) as Config ;
2022-03-31 08:45:47 +00:00
}
if ( config ? . origin ) {
this . __config . origin = config . origin ;
}
2022-04-04 15:44:04 +00:00
this . __config . debug = config . debug ;
2022-03-31 08:45:47 +00:00
}
getConfig() {
return this . __config ;
}
// TODO: Maintain exposed methods in a separate namespace, so that unexpected methods don't become instructions
/ * *
* It is an instruction that adds embed iframe inline as last child of the element
* /
inline ( {
calLink ,
elementOrSelector ,
config ,
} : {
calLink : string ;
elementOrSelector : string | HTMLElement ;
config : Record < string , string > ;
} ) {
validate ( arguments [ 0 ] , {
required : true ,
props : {
calLink : {
2022-04-14 02:47:34 +00:00
// TODO: Add a special type calLink for it and validate that it doesn't start with / or https?://
2022-03-31 08:45:47 +00:00
required : true ,
type : "string" ,
} ,
elementOrSelector : {
required : true ,
type : [ "string" , HTMLElement ] ,
} ,
config : {
required : false ,
type : Object ,
} ,
} ,
} ) ;
2022-04-25 04:33:00 +00:00
config = config || { } ;
// Keeping auto-scroll disabled for two reasons:
// - If user scrolls the content to an appropriate position, it again resets it to default position which might not be for the liking of the user
// - Sometimes, the position can be wrong(e.g. if there is a fixed position header on top coming above the iframe content).
// Best solution might be to autoscroll only if the iframe is not fully visible, detection of full visibility might be tough
// We need to keep in mind that autoscroll is meant to solve the problem when on a certain view(which is availability page right now), the height goes too high and then suddenly it becomes normal
( config as unknown as any ) . __autoScroll = ! ! ( config as unknown as any ) . __autoScroll ;
config . embedType = "inline" ;
2022-03-31 08:45:47 +00:00
const iframe = this . createIframe ( { calLink , queryObject : Cal.getQueryObject ( config ) } ) ;
iframe . style . height = "100%" ;
iframe . style . width = "100%" ;
let element =
elementOrSelector instanceof HTMLElement
? elementOrSelector
: document . querySelector ( elementOrSelector ) ;
if ( ! element ) {
throw new Error ( "Element not found" ) ;
}
2022-04-14 02:47:34 +00:00
const template = document . createElement ( "template" ) ;
2022-04-25 04:33:00 +00:00
template . innerHTML = ` <cal-inline style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap"></cal-inline> ` ;
2022-04-14 02:47:34 +00:00
this . inlineEl = template . content . children [ 0 ] ;
2022-04-25 04:33:00 +00:00
( this . inlineEl as unknown as any ) . __CalAutoScroll = config . __autoScroll ;
2022-04-14 02:47:34 +00:00
this . inlineEl . appendChild ( iframe ) ;
element . appendChild ( template . content ) ;
2022-03-31 08:45:47 +00:00
}
2022-04-14 02:47:34 +00:00
floatingButton ( { calLink } : { calLink : string } ) {
validate ( arguments [ 0 ] , {
required : true ,
props : {
calLink : {
required : true ,
type : "string" ,
} ,
} ,
} ) ;
const template = document . createElement ( "template" ) ;
2022-04-25 04:33:00 +00:00
template . innerHTML = ` <cal-floating-button data-cal-namespace=" ${ this . namespace } " data-cal-link=" ${ calLink } "></cal-floating-button> ` ;
2022-04-14 02:47:34 +00:00
document . body . appendChild ( template . content ) ;
}
modal ( { calLink , config = { } , uid } : { calLink : string ; config? : Record < string , string > ; uid : number } ) {
const existingModalEl = document . querySelector ( ` cal-modal-box[uid=" ${ uid } "] ` ) ;
if ( existingModalEl ) {
existingModalEl . setAttribute ( "state" , "started" ) ;
return ;
}
2022-04-25 04:33:00 +00:00
config . embedType = "modal" ;
2022-04-08 05:33:24 +00:00
const iframe = this . createIframe ( { calLink , queryObject : Cal.getQueryObject ( config ) } ) ;
2022-04-14 02:47:34 +00:00
iframe . style . borderRadius = "8px" ;
2022-03-31 08:45:47 +00:00
iframe . style . height = "100%" ;
iframe . style . width = "100%" ;
const template = document . createElement ( "template" ) ;
2022-04-14 02:47:34 +00:00
template . innerHTML = ` <cal-modal-box uid=" ${ uid } "></cal-modal-box> ` ;
2022-04-25 04:33:00 +00:00
2022-04-08 05:33:24 +00:00
this . modalBox = template . content . children [ 0 ] ;
this . modalBox . appendChild ( iframe ) ;
2022-04-25 04:33:00 +00:00
this . actionManager . on ( "__closeIframe" , ( ) = > {
this . modalBox . setAttribute ( "state" , "closed" ) ;
} ) ;
2022-03-31 08:45:47 +00:00
document . body . appendChild ( template . content ) ;
}
on ( {
action ,
callback ,
} : {
action : Parameters < SdkActionManager [ " on " ] > [ 0 ] ;
callback : Parameters < SdkActionManager [ " on " ] > [ 1 ] ;
} ) {
validate ( arguments [ 0 ] , {
required : true ,
props : {
action : {
required : true ,
type : "string" ,
} ,
callback : {
required : true ,
type : Function ,
} ,
} ,
} ) ;
this . actionManager . on ( action , callback ) ;
}
preload ( { calLink } : { calLink : string } ) {
validate ( arguments [ 0 ] , {
required : true ,
props : {
calLink : {
type : "string" ,
required : true ,
} ,
} ,
} ) ;
const iframe = document . body . appendChild ( document . createElement ( "iframe" ) ) ;
const config = this . getConfig ( ) ;
const urlInstance = new URL ( ` ${ config . origin } / ${ calLink } ` ) ;
urlInstance . searchParams . set ( "prerender" , "true" ) ;
iframe . src = urlInstance . toString ( ) ;
iframe . style . width = "0" ;
iframe . style . height = "0" ;
iframe . style . display = "none" ;
}
ui ( uiConfig : UiConfig ) {
validate ( uiConfig , {
required : true ,
props : {
theme : {
required : false ,
type : "string" ,
} ,
styles : {
required : false ,
type : Object ,
} ,
} ,
} ) ;
this . doInIframe ( { method : "ui" , arg : uiConfig } ) ;
}
doInIframe ( {
method ,
arg ,
} : // TODO: Need some TypeScript magic here to remove hardcoded types
| { method : "ui" ; arg : Parameters < typeof methods [ " ui " ] > [ 0 ] }
| { method : "parentKnowsIframeReady" ; arg : undefined } ) {
if ( ! this . iframeReady ) {
this . iframeDoQueue . push ( { method , arg } ) ;
return ;
}
// TODO: Ensure that origin is as defined by user. Generally it would be cal.com but in case of self hosting it can be anything.
this . iframe ! . contentWindow ! . postMessage ( { originator : "CAL" , method , arg } , "*" ) ;
}
constructor ( namespace : string , q : InstructionQueue ) {
this . __config = {
2022-04-04 15:44:04 +00:00
// Keep cal.com hardcoded till the time embed.js deployment to cal.com/embed.js is automated. This is to prevent accidentally pushing of localhost domain to production
2022-04-25 04:33:00 +00:00
origin : /*import.meta.env.NEXT_PUBLIC_WEBSITE_URL || */ "https://app.cal.com" ,
2022-03-31 08:45:47 +00:00
} ;
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
2022-04-08 16:59:08 +00:00
this . actionManager . on ( "__dimensionChanged" , ( e ) = > {
2022-03-31 08:45:47 +00:00
const { data } = e . detail ;
const iframe = this . iframe ! ;
2022-04-04 15:44:04 +00:00
2022-03-31 08:45:47 +00:00
if ( ! iframe ) {
// Iframe might be pre-rendering
return ;
}
2022-04-14 02:47:34 +00:00
let unit = "px" ;
if ( data . __unit ) {
unit = data . __unit ;
}
if ( data . iframeHeight ) {
iframe . style . height = data . iframeHeight + unit ;
}
2022-04-25 04:33:00 +00:00
// if (data.iframeWidth) {
// iframe.style.width = data.iframeWidth + unit;
// }
2022-04-14 02:47:34 +00:00
2022-04-08 05:33:24 +00:00
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.
iframe . style . maxHeight = window . innerHeight + "px" ;
2022-04-14 02:47:34 +00:00
// Automatically setting the height of modal-box as per iframe creates problem in managing width of iframe.
// if (iframe.style.width !== "100%") {
// this.modalBox!.shadowRoot!.querySelector(".modal-box")!.style.width = iframe.style.width;
// }
2022-04-08 05:33:24 +00:00
}
2022-03-31 08:45:47 +00:00
} ) ;
2022-04-08 16:59:08 +00:00
this . actionManager . on ( "__iframeReady" , ( e ) = > {
2022-03-31 08:45:47 +00:00
this . iframeReady = true ;
this . doInIframe ( { method : "parentKnowsIframeReady" , arg : undefined } ) ;
this . iframeDoQueue . forEach ( ( { method , arg } ) = > {
this . doInIframe ( { method , arg } ) ;
} ) ;
} ) ;
2022-04-25 04:33:00 +00:00
this . actionManager . on ( "__routeChanged" , ( ) = > {
if ( this . inlineEl && ( this . inlineEl as unknown as any ) . __CalAutoScroll ) {
this . inlineEl . scrollIntoView ( ) ;
}
} ) ;
2022-04-08 05:33:24 +00:00
this . actionManager . on ( "linkReady" , ( e ) = > {
2022-04-14 02:47:34 +00:00
this . modalBox ? . setAttribute ( "state" , "loaded" ) ;
this . inlineEl ? . setAttribute ( "loading" , "done" ) ;
2022-04-08 05:33:24 +00:00
} ) ;
this . actionManager . on ( "linkFailed" , ( e ) = > {
this . iframe ? . remove ( ) ;
} ) ;
2022-03-31 08:45:47 +00:00
}
}
globalCal . instance = new Cal ( "" , globalCal . q ! ) ;
for ( let [ ns , api ] of Object . entries ( globalCal . ns ! ) ) {
api . instance = new Cal ( ns , api . q ! ) ;
}
/ * *
* Intercepts all postmessages and fires action in corresponding actionManager
* /
window . addEventListener ( "message" , ( e ) = > {
const detail = e . data ;
const fullType = detail . fullType ;
const parsedAction = SdkActionManager . parseAction ( fullType ) ;
if ( ! parsedAction ) {
return ;
}
const actionManager = Cal . actionsManagers [ parsedAction . ns ] ;
if ( ! actionManager ) {
throw new Error ( "Unhandled Action" + parsedAction ) ;
}
actionManager . fire ( parsedAction . type , detail . data ) ;
} ) ;
document . addEventListener ( "click" , ( e ) = > {
const htmlElement = e . target ;
if ( ! ( htmlElement instanceof HTMLElement ) ) {
return ;
}
const path = htmlElement . dataset . calLink ;
if ( ! path ) {
return ;
}
2022-04-14 02:47:34 +00:00
const modalUniqueId = ( ( htmlElement as unknown as any ) . uniqueId =
( htmlElement as unknown as any ) . uniqueId || Date . now ( ) ) ;
2022-04-08 05:33:24 +00:00
const namespace = htmlElement . dataset . calNamespace ;
const configString = htmlElement . dataset . calConfig || "" ;
let config ;
try {
config = JSON . parse ( configString ) ;
} catch ( e ) {
config = { } ;
}
let api = globalCal ;
if ( namespace ) {
api = globalCal . ns ! [ namespace ] ;
}
2022-04-25 04:33:00 +00:00
if ( ! api ) {
throw new Error ( ` Namespace ${ namespace } isn't defined ` ) ;
}
2022-04-08 05:33:24 +00:00
api ( "modal" , {
2022-03-31 08:45:47 +00:00
calLink : path ,
2022-04-08 05:33:24 +00:00
config ,
2022-04-14 02:47:34 +00:00
uid : modalUniqueId ,
2022-03-31 08:45:47 +00:00
} ) ;
} ) ;