cal.com
cal.com copied to clipboard
Ideas for better react embed
Is your proposal related to a problem?
Unclear and unorthodox current react integration (this is opiniated). Especially this one (likely the most useful) from the docs.
Describe the solution you'd like
The best I can think of is a combination of Provider
pattern with hooks, inspired by the excellent react-google-recaptcha-v3
- A
<CalProvider>
(with options for the script) would be responsible of injecting the embeddable script
(with potential options โ as props โ for the script. Eg. embed url)const App = () => ( <CalProvider> <SomeComponent /> </CalProvider> )
- A
useCalApi
hook would gracefully retrieve the API. Ideally, internally it would retrieve it from the context consumer ofCalProvider
const MyComponent: FunctionalComponent = () => { const calApi = useCalApi() // `calApi` is a GlobalCal | null // depending of the loading state // use calApi(...) }
- A
useCalAction
hook, responsible of handling actions
const MyComponent: FunctionalComponent = () => {
const handleBookingSuccess = useCallback(() => {
console.log('booked !')
})
useCalAction('bookingSuccessful', handleBookingSuccess)
// ...
}
-
<Cal />
component would not change, except it would internally not auto-inject the script, and rely onuseCalApi
This whole setup would actually allow to rely on window.
on a single point, which would be the context Provider
. A reference to the Api would be kept here, and distributed for various usages, component and hooks.
Describe alternatives you've considered
I wrote my own versions of useCalApi
and useCalAction
; which are basically workarounds for now (rely on getCalApi
).
import { getCalApi } from '@calcom/embed-react'
import { GlobalCal } from '@calcom/embed-core'
import { useEffect, useState } from 'react'
export const useCalApi = () => {
const [calApi, setCalApi] = useState<GlobalCal | null>(null)
useEffect(() => {
;(async function () {
const calApi = await getCalApi()
setCalApi(() => calApi ?? null)
})()
}, [setCalApi])
return calApi
}
import { useEffect } from 'react'
import { useCalApi } from './use-cal-api'
const actionTypes = [
'eventTypeSelected',
'bookingSuccessful',
'linkReady',
'linkFailed',
] as const
type CalActionType = typeof actionTypes[number]
type CalActionDataEventTypeSelected = {
eventType: Record<string, unknown>
}
type CalActionDataBookingSuccessful = {
confirmed: boolean
eventType: Record<string, unknown>
date: string
duration: number
organizer: Record<string, unknown>
}
type CalActionDataLinkReady = Record<string, never>
type CalActionDataLinkFailed = {
code: number
msg: string
data: Record<string, unknown>
}
type CalActionDetailsEventTypeSelected = {
data: CalActionDataEventTypeSelected
type: 'eventTypeSelected'
namespace: string
}
type CalActionDetailsBookingSuccessful = {
data: CalActionDataBookingSuccessful
type: 'bookingSuccessful'
namespace: string
}
type CalActionDetailsLinkReady = {
data: CalActionDataLinkReady
type: 'linkReady'
namespace: string
}
type CalActionDetailsLinkFailed = {
data: CalActionDataLinkFailed
type: 'linkFailed'
namespace: string
}
// type CalActionDetails =
// | CalActionDetailsEventTypeSelected
// | CalActionDetailsBookingSuccessful
// | CalActionDetailsLinkReady
// | CalActionDetailsLinkFailed
type _CalActionDetails = {
eventTypeSelected: CalActionDetailsEventTypeSelected
bookingSuccessful: CalActionDetailsBookingSuccessful
linkReady: CalActionDetailsLinkReady
linkFailed: CalActionDetailsLinkFailed
}
type CalActionCallback<
T extends CalActionType,
D extends _CalActionDetails[T] = _CalActionDetails[T],
> = (e: { details: D }) => void
export const useCalAction = <
T extends CalActionType,
C extends CalActionCallback<T> = CalActionCallback<T>,
>(
action: T,
callback: C,
) => {
const calApi = useCalApi()
useEffect(() => {
if (calApi == null) {
return
}
calApi('on', {
action,
callback,
})
return () => {
calApi('off', {
action,
callback,
})
}
}, [action, callback, calApi])
}
Additional context
None
Here is a full implementation. Far from perfect, it allows to grasp the idea.
cal-context.ts
import {
createContext,
FunctionComponent,
PropsWithChildren,
useContext,
useEffect,
useState,
} from 'react'
import { GlobalCal, CalWindow } from '@calcom/embed-core'
import EmbedSnippet from '@calcom/embed-snippet'
export const getCalApi = (embedJsUrl?: string): Promise<GlobalCal> =>
new Promise(function tryReadingFromWindow(resolve) {
EmbedSnippet(embedJsUrl) // <== Updated here to accept optional URL
const api = (window as CalWindow).Cal
if (!api) {
setTimeout(() => {
tryReadingFromWindow(resolve)
}, 50)
return
}
resolve(api)
})
const CalContext = createContext<GlobalCal | null>(null)
export const _useCalApi = (embedJsUrl?: string) => {
const [calApi, setCalApi] = useState<GlobalCal | null>(null)
useEffect(() => {
;(async function () {
const calApi = await getCalApi(embedJsUrl)
setCalApi(() => calApi ?? null)
})()
}, [embedJsUrl, setCalApi])
return calApi
}
type CalProviderProps = PropsWithChildren<{
embedJsUrl?: string
}>
export const CalProvider: FunctionComponent<CalProviderProps> = (props) => {
const { children, embedJsUrl, ...restProps } = props
const calApi = _useCalApi(embedJsUrl)
return (
<CalContext.Provider {...restProps} value={calApi}>
{children}
</CalContext.Provider>
)
}
export const useCalApi = () => useContext(CalContext)
cal.tsx
import { useCalApi } from './cal-context'
import { useEffect, useRef } from 'react'
type CalProps = {
calOrigin?: string
calLink: string
initConfig?: {
debug?: boolean
uiDebug?: boolean
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
config?: any
} & React.HTMLAttributes<HTMLDivElement>
const Cal = function Cal(props: CalProps) {
const {
calLink,
calOrigin,
config,
initConfig = {},
...restProps
} = props
if (!calLink) {
throw new Error('calLink is required')
}
const initializedRef = useRef(false)
const calApi = useCalApi() // <== Updated here to use context instead of direct injection
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!calApi || initializedRef.current) {
return
}
initializedRef.current = true
const element = ref.current
calApi('init', {
...initConfig,
origin: calOrigin,
})
calApi('inline', {
elementOrSelector: element,
calLink,
config,
})
}, [calApi, calLink, config, calOrigin, initConfig])
if (!calApi) {
return null
}
return <div ref={ref} {...restProps} />
}
export default Cal
Usage :
const SomeCalComponent = () => {
// uses context
const calApi = useCalApi()
// [...] do something with `calApi`
// also uses context
return ( <Cal /> )
}
const App = () => (
<CalProvider>
<SomeCalComponent />
</CalProvider>
)
this is cool, check this out @hariombalhara
@cyrilchapon Wow!! Thanks for the ideas. Will surely review those. Looks great to me so far.
@PeerRich @hariombalhara thanks for sweet words.
I just tried in an internal project; it works great and allows some great stuff, like graceful fallback to a link while API is loading ๐ :
<Button
{...calApi != null ? ({
onClick: () => openDialogWithCal()
}) : ({
href: urls.calcom,
target: '_blank'
})}
>
Book me a meeting
</Button>
I might try to PR this, but gotta get used to the codebase, and perfect the idea.
Also some thoughts :
- I feel like some types are missing (potentially to embed/core too). Basically the ones I reimplemented in
useCalAction
- I'd like to improve the idea and provide a
loaded
boolean, usable just like thisconst [calApi, calApiLoaded] = useCalApi() // or maybe // const { api: calApi, calApiLoaded: loaded } = useCalApi() if (calApiLoaded) { // calApi resolved to GlobalCal, thanks to TS discriminated union }
- I'd like to try and wrap the event handling with an Emittery. The idea would be to attach just once to
calApi('on', { action: '*' })
and dispatch events with the Emittery.
@cyrilchapon Would love a PR, if you are interested. My goal is to keep the size to a minimum unless it provides huge benefits. So, won't want to use a third party library. I don't think we use any third party at the moment.
@hariombalhara gotcha about minimum size. The Emittery stuff was just a thrown idea.
Gonna try and PR the other ideas.
@hariombalhara I submitted my PR as a draft, because I'm struggling with unit tests. Any help in there ? ๐
@hariombalhara wanna revisit this? or should we wait for the new booking page from @JeroenReumkens first?
The new booking page won't matter here. But yeah I have plans to pick it up.
The new booking page won't matter here. But yeah I have plans to pick it up.
Do note that the new booker could make the react embed redundant. Since the main goal of this refactor was to make the booker being able to run standalone (that's for example why it runs all api calls client side) โ so as soon as we tackled a few other issues (like translations), we should be able to publish this as an NPM package which people can load directly into their site. Will only work for React of course, so the other embed stays relevant for sure.
we are working on cal atoms, our react components