cal.com icon indicating copy to clipboard operation
cal.com copied to clipboard

Ideas for better react embed

Open cyrilchapon opened this issue 2 years ago โ€ข 10 comments

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
    const App = () => (
      <CalProvider>
        <SomeComponent />
      </CalProvider>
    )
    
    (with potential options โ€” as props โ€” for the script. Eg. embed url)
  • A useCalApi hook would gracefully retrieve the API. Ideally, internally it would retrieve it from the context consumer of CalProvider
    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 on useCalApi

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

cyrilchapon avatar Nov 09 '22 13:11 cyrilchapon

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>
)

cyrilchapon avatar Nov 09 '22 14:11 cyrilchapon

this is cool, check this out @hariombalhara

PeerRich avatar Nov 09 '22 14:11 PeerRich

@cyrilchapon Wow!! Thanks for the ideas. Will surely review those. Looks great to me so far.

hariombalhara avatar Nov 09 '22 14:11 hariombalhara

@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 this
    const [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 avatar Nov 09 '22 15:11 cyrilchapon

@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 avatar Nov 09 '22 15:11 hariombalhara

@hariombalhara gotcha about minimum size. The Emittery stuff was just a thrown idea.

Gonna try and PR the other ideas.

cyrilchapon avatar Nov 10 '22 12:11 cyrilchapon

@hariombalhara I submitted my PR as a draft, because I'm struggling with unit tests. Any help in there ? ๐Ÿ™‚

cyrilchapon avatar Nov 16 '22 08:11 cyrilchapon

@hariombalhara wanna revisit this? or should we wait for the new booking page from @JeroenReumkens first?

PeerRich avatar May 17 '23 17:05 PeerRich

The new booking page won't matter here. But yeah I have plans to pick it up.

hariombalhara avatar May 18 '23 10:05 hariombalhara

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.

JeroenReumkens avatar May 22 '23 06:05 JeroenReumkens

we are working on cal atoms, our react components

PeerRich avatar Jan 04 '24 17:01 PeerRich