diff --git a/apps/web/components/Embed.tsx b/apps/web/components/Embed.tsx index 94c99d1c34..3ad578c85c 100644 --- a/apps/web/components/Embed.tsx +++ b/apps/web/components/Embed.tsx @@ -10,7 +10,7 @@ import { components } from "react-select"; import type { BookerLayout } from "@calcom/features/bookings/Booker/types"; import { useFlagMap } from "@calcom/features/flags/context/provider"; -import { APP_NAME, EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants"; +import { APP_NAME, EMBED_LIB_URL, IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { @@ -38,13 +38,21 @@ const enum Theme { dark = "dark", } +const EMBED_CAL_ORIGIN = WEBAPP_URL; +const EMBED_CAL_JS_URL = `${WEBAPP_URL}/embed/embed.js`; + type PreviewState = { inline: { width: string; height: string; }; theme: Theme; - floatingPopup: Record; + floatingPopup: { + config?: { + layout: BookerLayouts; + }; + [key: string]: string | boolean | undefined | Record; + }; elementClick: Record; palette: { brandColor: string; @@ -175,14 +183,24 @@ const Codes: Record string>> = { return code` import Cal, { getCalApi } from "@calcom/embed-react"; import { useEffect } from "react"; -function MyComponent() { +export default function MyApp() { useEffect(()=>{ (async function () { const cal = await getCalApi(); ${uiInstructionCode} })(); }, []) - return ; + return ; };`; }, "floating-popup": ({ @@ -193,38 +211,60 @@ function MyComponent() { uiInstructionCode: string; }) => { return code` -import Cal, { getCalApi } from "@calcom/embed-react"; +import { getCalApi } from "@calcom/embed-react"; import { useEffect } from "react"; -function MyComponent() { +export default function App() { useEffect(()=>{ (async function () { - const cal = await getCalApi(); + const cal = await getCalApi(${IS_SELF_HOSTED ? `"${EMBED_CAL_JS_URL}"` : ""}); cal("floatingButton", ${floatingButtonArg}); ${uiInstructionCode} })(); }, []) };`; }, - "element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => { + "element-click": ({ + calLink, + uiInstructionCode, + previewState, + }: { + calLink: string; + uiInstructionCode: string; + previewState: PreviewState; + }) => { return code` -import Cal, { getCalApi } from "@calcom/embed-react"; +import { getCalApi } from "@calcom/embed-react"; import { useEffect } from "react"; -function MyComponent() { +export default function App() { useEffect(()=>{ (async function () { - const cal = await getCalApi(); + const cal = await getCalApi(${IS_SELF_HOSTED ? `"${EMBED_CAL_JS_URL}"` : ""}); ${uiInstructionCode} })(); }, []) - return ; };`; }, }, HTML: { - inline: ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => { + inline: ({ + calLink, + uiInstructionCode, + previewState, + }: { + calLink: string; + uiInstructionCode: string; + previewState: PreviewState; + }) => { return code`Cal("inline", { elementOrSelector:"#my-cal-inline", - calLink: "${calLink}" + calLink: "${calLink}", + layout: "${previewState.layout}" }); ${uiInstructionCode}`; @@ -240,8 +280,22 @@ ${uiInstructionCode}`; return code`Cal("floatingButton", ${floatingButtonArg}); ${uiInstructionCode}`; }, - "element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => { - return code`// Important: Make sure to add \`data-cal-link="${calLink}"\` attribute to the element you want to open Cal on click + "element-click": ({ + calLink, + uiInstructionCode, + previewState, + }: { + calLink: string; + uiInstructionCode: string; + previewState: PreviewState; + }) => { + return code` +// Important: Please add following attributes to the element you want to open Cal on click +// \`data-cal-link="${calLink}"\` +// \`data-cal-config='${JSON.stringify({ + layout: previewState.layout, + })}'\` + ${uiInstructionCode}`; }, }, @@ -298,6 +352,7 @@ const getEmbedTypeSpecificString = ({ } else if (embedType === "floating-popup") { const floatingButtonArg = { calLink, + ...(IS_SELF_HOSTED ? { calOrigin: EMBED_CAL_ORIGIN } : null), ...previewState.floatingPopup, }; return frameworkCodes[embedType]({ @@ -710,13 +765,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({ height: "100%", }, theme: Theme.auto, + layout: BookerLayouts.MONTH_VIEW, floatingPopup: {}, elementClick: {}, hideEventTypeDetails: false, palette: { brandColor: "#000000", }, - layout: BookerLayouts.MONTH_VIEW, }); const close = () => { @@ -778,8 +833,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({ name: "ui", arg: { theme: previewState.theme, - hideEventTypeDetails: previewState.hideEventTypeDetails, layout: previewState.layout, + hideEventTypeDetails: previewState.hideEventTypeDetails, styles: { branding: { ...previewState.palette, @@ -1102,8 +1157,15 @@ const EmbedTypeCodeAndPreviewDialogContent = ({ return; } setPreviewState((previewState) => { + const config = { + ...(previewState.floatingPopup.config ?? {}), + layout: option.value, + }; return { ...previewState, + floatingPopup: { + config, + }, layout: option.value, }; }); diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 598c58d2b6..87882617fd 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -100,7 +100,7 @@ plugins.push(withAxiom); const userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`; const teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)"; const privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)"; -const embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`; +const embedUserTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type/embed`; const embedTeamTypeRouteRegExp = "/team/:slug/:type/embed"; /** @type {import("next").NextConfig} */ @@ -312,11 +312,11 @@ const nextConfig = { afterFiles.push( ...[ { - source: `/:user((?!${pages.join("|")}).*)/:type/embed`, + source: embedUserTypeRouteRegExp, destination: "/new-booker/:user/:type/embed", }, { - source: "/team/:slug/:type/embed", + source: embedTeamTypeRouteRegExp, destination: "/new-booker/team/:slug/:type/embed", }, ] diff --git a/apps/web/test/lib/next-config.test.ts b/apps/web/test/lib/next-config.test.ts index 16190584dd..d58790c8eb 100644 --- a/apps/web/test/lib/next-config.test.ts +++ b/apps/web/test/lib/next-config.test.ts @@ -47,8 +47,8 @@ describe('next.config.js - RegExp', ()=>{ // privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)"; privateLinkRouteRegExp = getRegExpFromNextJsRewriteRegExp("/d/(?[^/]+)/(?((?!book$)[^/]+))"); - // embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`; - embedUserTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?((?!${pages.join("|")}).*))/(?[^/]+)/embed`); + // embedUserTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type/embed`; + embedUserTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?((?!${pages.join("/|")})[^/]*))/(?[^/]+)/embed`); // embedTeamTypeRouteRegExp = "/team/:slug/:type/embed"; embedTeamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?[^/]+)/(?[^/]+)/embed"); @@ -94,6 +94,12 @@ describe('next.config.js - RegExp', ()=>{ type:'30' }) + // Edgecase of username starting with team also works + expect(embedUserTypeRouteRegExp.exec('/teampro/30/embed')?.groups).toContain({ + user: 'teampro', + type: '30' + }) + expect(teamTypeRouteRegExp.exec('/team/seeded/30')?.groups).toContain({ slug: 'seeded', type: '30' diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index 845f392ccc..c51dc1e91e 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -168,7 +168,9 @@ [Dark Theme][Guests(janedoe@example.com and test@example.com)](Default Namespace) - + + + You would see last Booking page action in my place
@@ -253,6 +255,14 @@ Note that one of the embeds would stay in loading state as they are using the same namespace and it is not supported to have more than 1 embeds using same namespace
+ + diff --git a/packages/embeds/embed-core/playground.ts b/packages/embeds/embed-core/playground.ts index 1b8ec53945..b492d7db54 100644 --- a/packages/embeds/embed-core/playground.ts +++ b/packages/embeds/embed-core/playground.ts @@ -344,3 +344,59 @@ if (only === "all" || only == "ns:floatingButton") { }, }); } + +if (only === "all" || only == "ns:weekView") { + // Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal + Cal("init", "weekView", { + debug: true, + origin: "http://localhost:3000", + }); + + Cal.ns.weekView( + "inline", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + { + elementOrSelector: "#cal-booking-place-weekView .place", + calLink: "pro/paid", + config: { + iframeAttrs: { + id: "cal-booking-place-weekView-iframe", + }, + layout: "week_view", + }, + } + ); + Cal.ns.weekView("on", { + action: "*", + callback, + }); +} + +if (only === "all" || only == "ns:columnView") { + // Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal + Cal("init", "columnView", { + debug: true, + origin: "http://localhost:3000", + }); + + Cal.ns.columnView( + "inline", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + { + elementOrSelector: "#cal-booking-place-columnView .place", + calLink: "pro/paid", + config: { + iframeAttrs: { + id: "cal-booking-place-columnView-iframe", + }, + layout: "column_view", + }, + } + ); + Cal.ns.columnView("on", { + action: "*", + callback, + }); +} diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index a07e94a220..11e98dd453 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -3,7 +3,7 @@ import type { CSSProperties } from "react"; import { useState, useEffect } from "react"; import type { BookerStore } from "@calcom/features/bookings/Booker/store"; -import type { BookerLayouts } from "@calcom/prisma/zod-utils"; +import { BookerLayouts } from "@calcom/prisma/zod-utils"; import type { Message } from "./embed"; import { sdkActionManager } from "./sdk-event"; @@ -23,6 +23,9 @@ export type UiConfig = { type SetStyles = React.Dispatch>; type setNonStylesConfig = React.Dispatch>; +/** + * 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, @@ -37,7 +40,11 @@ const embedStore = { setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined, theme: undefined as UiConfig["theme"], uiConfig: undefined as Omit | undefined, - setUiConfig: undefined as ((arg0: UiConfig) => void) | 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)[], + layout: BookerLayouts, }; declare global { @@ -183,9 +190,21 @@ export const useEmbedTheme = () => { 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 = setUiConfig; + 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; }; @@ -307,19 +326,18 @@ const methods = { } } - // Set the value here so that if setUiConfig state isn't available and later it's defined,it uses this value - embedStore.uiConfig = uiConfig; + // Merge new values over the old values + uiConfig = { + ...embedStore.uiConfig, + ...uiConfig, + }; if (uiConfig.cssVarsPerTheme) { window.CalEmbed.applyCssVars(uiConfig.cssVarsPerTheme); } if (embedStore.setUiConfig) { - embedStore.setUiConfig(uiConfig); - } - - if (uiConfig.layout) { - window.CalEmbed.setLayout?.(uiConfig.layout); + runAllUiSetters(uiConfig); } setEmbedStyles(stylesConfig || {}); @@ -440,6 +458,13 @@ 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 + // theme: + layout: url.searchParams.get("layout") as BookerLayouts, + }; + if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) { log("Initializing embed-iframe"); // HACK @@ -492,3 +517,9 @@ if (isBrowser) { }); } } + +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)); +} diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index d8b0c9fefe..9e5b3ac7df 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -1,4 +1,6 @@ /// +import type { BookerLayouts } from "@calcom/prisma/zod-utils"; + import { FloatingButton } from "./FloatingButton/FloatingButton"; import { Inline } from "./Inline/inline"; import { ModalBox } from "./ModalBox/ModalBox"; @@ -125,10 +127,21 @@ type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap]; export type Instruction = SingleInstruction | SingleInstruction[]; export type InstructionQueue = Instruction[]; +/** + * All types of config that are critical to be processed as soon as possible are provided as query params to the iframe + */ export type PrefillAndIframeAttrsConfig = Record> & { + // TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app. iframeAttrs?: Record & { id?: string; }; + + // TODO: It should have a dedicated prefill prop + // prefill: {}, + + // TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time. + // ui: {layout; theme} + layout?: `${BookerLayouts}`; theme?: EmbedThemeConfig; }; diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 15f710c731..43657f0077 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -1,11 +1,11 @@ import { LazyMotion, domAnimation, m, AnimatePresence } from "framer-motion"; import dynamic from "next/dynamic"; -import { useEffect, useRef, useMemo } from "react"; +import { useEffect, useRef } from "react"; import StickyBox from "react-sticky-box"; import { shallow } from "zustand/shallow"; import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager"; -import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe"; +import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe"; import classNames from "@calcom/lib/classNames"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils"; @@ -23,7 +23,6 @@ import { useBookerStore, useInitializeBookerStore } from "./store"; import type { BookerLayout, BookerProps } from "./types"; import { useEvent } from "./utils/event"; import { validateLayout } from "./utils/layout"; -import { getQueryParam } from "./utils/query-param"; import { useBrandColors } from "./utils/use-brand-colors"; const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy")); @@ -46,10 +45,14 @@ const BookerComponent = ({ const rescheduleUid = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduleUid") : null; const event = useEvent(); - const [layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow); - if (typeof window !== "undefined") { - window.CalEmbed.setLayout = setLayout; - } + const [_layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow); + + const isEmbed = useIsEmbed(); + const embedUiConfig = useEmbedUiConfig(); + + // In Embed we give preference to embed configuration for the layout.If that's not set, we use the App configuration for the event layout + const layout = isEmbed ? validateLayout(embedUiConfig.layout) || _layout : _layout; + const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow); const selectedDate = useBookerStore((state) => state.selectedDate); const [selectedTimeslot, setSelectedTimeslot] = useBookerStore( @@ -60,13 +63,10 @@ const BookerComponent = ({ const extraDays = isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop; const bookerLayouts = event.data?.profile?.bookerLayouts || defaultBookerLayoutSettings; const animationScope = useBookerResizeAnimation(layout, bookerState); - const isEmbed = typeof window !== "undefined" && window?.isEmbed?.(); - // We only want the initial url value, that's why we memo it. The embed seems to change the url, which sometimes drops - // the layout query param. - const layoutFromQueryParam = useMemo(() => validateLayout(getQueryParam("layout") as BookerLayouts), []); + // I would expect isEmbed to be not needed here as it's handled in derived variable layout, but somehow removing it breaks the views. const defaultLayout = isEmbed - ? layoutFromQueryParam || BookerLayouts.MONTH_VIEW + ? validateLayout(embedUiConfig.layout) || bookerLayouts.defaultLayout : bookerLayouts.defaultLayout; useBrandColors({ @@ -107,7 +107,6 @@ const BookerComponent = ({ } }, [layout]); - const embedUiConfig = useEmbedUiConfig(); const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false; if (event.isSuccess && !event.data) { @@ -149,7 +148,8 @@ const BookerComponent = ({ !isEmbed && "sm:transition-[width] sm:duration-300", isEmbed && layout === BookerLayouts.MONTH_VIEW && "border-booker sm:border-booker-width", !isEmbed && layout === BookerLayouts.MONTH_VIEW && "border-subtle", - layout === BookerLayouts.MONTH_VIEW && isEmbed && "mt-20" + // We don't want any margins for Embed. Any margin needed should be added by Embed user. + layout === BookerLayouts.MONTH_VIEW && isEmbed && "mt-0" )}>