diff --git a/packages/embeds/README.md b/packages/embeds/README.md index 5af25a4846..dff42b8e5b 100644 --- a/packages/embeds/README.md +++ b/packages/embeds/README.md @@ -15,3 +15,25 @@ To publish the packages. Following steps should be followed. All commands are to 2. `yarn changeset version` -> Bumps the versions as required 3. Get the PR reviewed and merged 4. `yarn publish-embed` -> Releases all packages. We can't use `yarn changeset publish` because it doesn't support workspace: prefix removal yet. See https://github.com/changesets/changesets/issues/432#issuecomment-1016365428 + +## Skeleton Loader + +Skeleton loader is shown for supported page types. For all other page types, default non-skeleton loader is shown. +Status: +- Layout + - [x] Responsive + - [x] Mobile Layout + - [x] month_view Layout + - [ ] week_view Layout + - [ ] column_view Layout +- Theming + - [x] Dark and Light theme + NOTE: _If user has preference for theme configured within app, that has to be communicated clearly in the embed too for skeleton to work_ + - [x] Change in system theme should reflect without page refresh +- Page Types supported + - [x] user.event.booking.slots + - [x] team.event.booking.slots + - [x] [Partially supported] user.event.booking.form - Shows skeleton but of the slots page + - [x] [Partially supported] team.event.booking.form - Shows skeleton but of the slots page + - [ ] user.profile + - [ ] team.profile diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index 4d02bd7072..5ea011a562 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -4,8 +4,13 @@ @@ -93,7 +98,7 @@ function doubleInstallSnippetWithInlineEmbedWithNonDefaultNamespace(selector) { Cal("init", "doubleInstall", { debug: true, - calOrigin: "http://localhost:3000", + calOrigin: window.calOrigin, }); Cal.ns.doubleInstall("inline", { elementOrSelector: selector, @@ -105,7 +110,7 @@ embedSnippet(); Cal("init", "doubleInstall", { debug: true, - calOrigin: "http://localhost:3000", + calOrigin: window.calOrigin, }); Cal.ns.doubleInstall("inline", { elementOrSelector: selector, @@ -120,7 +125,7 @@ function doubleInstallSnippetWithInlineEmbed(selector) { Cal("init", { debug: true, - calOrigin: "http://localhost:3000", + calOrigin: window.calOrigin, }); Cal("inline", { elementOrSelector: selector, @@ -132,7 +137,7 @@ embedSnippet(); Cal("init", { debug: true, - calOrigin: "http://localhost:3000", + calOrigin: window.calOrigin, }); Cal("inline", { elementOrSelector: selector, @@ -146,7 +151,7 @@ function addTwoInlineEmbedWithTwoDifferentNamespacesWithDifferentInitConfig() { Cal("init", "namespace-init-test-1", { debug: true, - calOrigin: "http://localhost:3000", + calOrigin: window.calOrigin, }); Cal("init", "namespace-init-test-2", { debug: true, @@ -168,7 +173,7 @@ }); } - + -
- -
-
-
- -
-
-
- -
-
-
- -
-
-
+
+
+ +
-
- With Dark Color Scheme for the Page - Non responsive version of this page here - - - Page Params Forwarding(Prefills various fields by auto-forwarding query params) - - - Go to Prerender test page only + +
+
+
+ +
+
+
+ +
+
+
+
+
+ With Dark Color Scheme for the Page + Non responsive version of this page here + + + Page Params Forwarding(Prefills various fields by auto-forwarding query params) + + + Go to Prerender test page only + Go to Preload test page only - Go to Preload test page only - - -
- -
- Render All embeds together - It would be slow to load -
- Floating Popup + + +
+ +
+ Render All embeds together - It would be slow to load +
+ Floating Popup -

Popup Examples - Theme Tests

- - - - - - - -

Popup Examples - Reschedule Tests

- - -

Popup Examples - Different Configurations

- - - -

Popup Examples - Routing Form Tests

- - - -

Popup Examples - Child Element Target Tests

- - I am Anchor - [I am Bold inside anchor] - - I am span inside anchor - - - -
+

Popup Examples - Theme Tests

+ + + + + + + +

Popup Examples - Reschedule Tests

+ + +

Popup Examples - Different Configurations

+ + + +

Popup Examples - Routing Form Tests

+ + + +

Popup Examples - Child Element Target Tests

+ + I am Anchor + [I am Bold inside anchor] + + I am span inside anchor + + + +

Inline Embed Examples

@@ -425,7 +431,6 @@
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
-

Test Month View

@@ -451,6 +456,29 @@

Test Auto Scroll

+ +
+

Skeleton Loader INLINE Demo

+
+
+
+
+
+
+
+ Skeleton Loader MODAL Demo +

+
+
+
+
+
+
+ diff --git a/packages/embeds/embed-core/package.json b/packages/embeds/embed-core/package.json index 6c79e84414..ae26f49223 100644 --- a/packages/embeds/embed-core/package.json +++ b/packages/embeds/embed-core/package.json @@ -22,6 +22,8 @@ "buildWatchAndServer": "run-p '__dev' 'vite --port 3100 --strict-port --host --open'", "buildWatchAndServer-https": "run-p '__dev' 'vite --port 3100 --strict-port --host --open --https'", "dev": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer'", + "buildWatchAndServer-no-auto-open": "run-p '__dev' 'vite --port 3100 --strict-port --host'", + "dev-no-open": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer-no-auto-open'", "dev-https": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer-https'", "dev-real": "vite dev --port 3100", "type-check": "tsc --pretty --noEmit", diff --git a/packages/embeds/embed-core/playground.ts b/packages/embeds/embed-core/playground.ts index bfdb565c1c..da5692acda 100644 --- a/packages/embeds/embed-core/playground.ts +++ b/packages/embeds/embed-core/playground.ts @@ -10,7 +10,8 @@ const callback = function (e: any) { console.log("Event: ", e.type, detail); }; -const origin = `${new URL(document.URL).protocol}localhost:3000`; +// @ts-expect-error window.calOrigin is set in index.html +const origin = `${new URL(document.URL).protocol}//${window.calOrigin.split("//")[1]}`; document.addEventListener("click", (e) => { const target = e.target as HTMLElement; if ("href" in target && typeof target.href === "string") { @@ -629,3 +630,78 @@ Cal("on", { action: "bookingSuccessfulV2", callback: bookingSuccessfulV2Callback, }); + +if (only === "all" || only === "ns:skeletonDemo") { + Cal("init", "skeletonDemo", { + debug: true, + origin: origin, + }); + + // Example showing booking page skeleton + Cal.ns.skeletonDemo("inline", { + elementOrSelector: "#cal-booking-place-skeletonDemo .place", + calLink: "pro/30min", + config: { + theme: "auto", + iframeAttrs: { + id: "cal-booking-place-skeletonDemo-iframe", + }, + "flag.coep": "true", + "cal.embed.pageType": "user.event.booking.slots", + }, + }); + + Cal.ns.skeletonDemo("on", { + action: "*", + callback, + }); +} + +if (only === "all" || only === "ns:skeletonDemoElementClick") { + Cal("init", "skeletonDemoElementClick", { + debug: true, + origin: origin, + }); + + Cal.ns.skeletonDemoElementClick("on", { + action: "*", + callback, + }); +} + +// Keep it at the bottom as it works on the API defined above for various cases +(function ensureScrolledToCorrectIframe() { + // Reset the hash so that we can scroll to correct iframe + // Also, even if we need to scroll to the same iframe, we need to still reset it otherwise hashchange event will not fire + location.hash = ""; + let api: typeof Cal | (typeof Cal)["ns"][string] | null = null; + + const getNamespace = () => { + const url = new URL(document.URL); + const only = url.searchParams.get("only") || ""; + return only !== "all" ? only.replace("ns:", "") : null; + }; + + const updateApiOnNamespaceChange = () => { + const namespace = getNamespace(); + api = namespace && namespace !== "default" ? Cal.ns[namespace] : Cal; + api?.("on", { + action: "*", + callback: (e) => { + function scrollToIframeInPlayground() { + const namespace = getNamespace(); + if (namespace) { + location.hash = `#cal-booking-place-${namespace}-iframe`; + } + } + scrollToIframeInPlayground(); + }, + }); + }; + + // Initial namespace setup + updateApiOnNamespaceChange(); + + // Update namespace on hash change + window.addEventListener("hashchange", updateApiOnNamespaceChange); +})(); diff --git a/packages/embeds/embed-core/src/EmbedElement.test.ts b/packages/embeds/embed-core/src/EmbedElement.test.ts new file mode 100644 index 0000000000..86f7616646 --- /dev/null +++ b/packages/embeds/embed-core/src/EmbedElement.test.ts @@ -0,0 +1,361 @@ +import { fakeDeviceMatchesMediaQuery } from "../test/__mocks__/windowMatchMedia"; + +import type { AllPossibleLayouts, EmbedPageType } from "src/types"; +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import type { Mock } from "vitest"; + +import { EmbedElement } from "./EmbedElement"; +import inlineHTML from "./Inline/inlineHtml"; +import { getColorSchemeDarkQuery } from "./ui-utils"; + +(function defineEmbedTestElement() { + class TestEmbedElement extends EmbedElement { + constructor({ + isModal, + getSkeletonData, + dataset, + }: { + isModal?: boolean; + getSkeletonData: Mock; + dataset: Record; + }) { + super({ + isModal: isModal ?? false, + getSkeletonData, + // @ts-expect-error - Integration test mode + dataset, + }); + } + } + customElements.define("test-embed", TestEmbedElement); +})(); + +function buildMediaQueryListEvent({ type, matches }: { type: string; matches: boolean }) { + return { + type, + matches, + } as unknown as MediaQueryListEvent; +} + +function mockGetSkeletonData() { + return vi + .fn() + .mockImplementation( + ({ layout, pageType }: { layout: AllPossibleLayouts; pageType: EmbedPageType | null }) => { + return { + skeletonContent: `
Skeleton Content
`, + skeletonContainerStyle: "height: 500px", + skeletonStyle: "display: block", + }; + } + ); +} + +function createTestEmbedElement(data: { + dataset?: { pageType?: string; theme?: string; layout?: AllPossibleLayouts }; + getSkeletonData?: Mock; + isModal?: boolean; +}) { + const { dataset, getSkeletonData = mockGetSkeletonData() } = data; + const element = new (customElements.get("test-embed")!)({ + isModal: data.isModal ?? false, + getSkeletonData, + dataset: dataset || {}, + }) as EmbedElement; + + // Mock shadow root + const shadowRoot = { + querySelector: element.querySelector.bind(element), + host: element, + }; + + Object.defineProperty(element, "shadowRoot", { + value: shadowRoot, + writable: true, + }); + + element.innerHTML = inlineHTML({ + layout: dataset?.layout, + pageType: dataset?.pageType as EmbedPageType | null, + }); + + document.body.appendChild(element); + + return element; +} + +function expectSkeletonLoader(element: EmbedElement) { + const skeletonEl = element.getSkeletonElement(); + const loaderEl = element.getLoaderElement(); + + expect(skeletonEl.style.display).toBe("block"); + expect(loaderEl.style.display).toBe("none"); +} + +function expectDefaultLoader(element: EmbedElement) { + const skeletonEl = element.getSkeletonElement(); + const loaderEl = element.getLoaderElement(); + + expect(skeletonEl.style.display).toBe("none"); + expect(loaderEl.style.display).toBe("block"); +} + +function expectLoaderToBeHidden(element: EmbedElement) { + const skeletonEl = element.getSkeletonElement(); + const loaderEl = element.getLoaderElement(); + + expect(skeletonEl.style.display).toBe("none"); + expect(loaderEl.style.display).toBe("none"); +} + +function expectLayoutToBe(layout: AllPossibleLayouts, element: EmbedElement) { + expect(element.layout).toBe(layout); + const skeletonEl = element.getSkeletonElement(); + expect(skeletonEl.querySelector(`${layout}-layout`)).toBeDefined(); +} + +function mockGetComputedStyle() { + // getComputedStyle isn't properly implemented in JSDOM and doesn't return height property + window.getComputedStyle = vi.fn().mockImplementation((el: HTMLElement) => { + if (el.style.display === "none") { + return { + height: "0px", + }; + } else { + return { + height: "1010px", + }; + } + }); +} + +describe("EmbedElement", () => { + let element: EmbedElement; + let mockGetSkeletonData: Mock; + + beforeEach(() => { + // Register the custom element + mockGetComputedStyle(); + }); + + afterEach(() => { + if (!element) { + throw new Error("`element` not defined"); + } + document.body.removeChild(element); + vi.restoreAllMocks(); + }); + + describe("Loader Behavior", () => { + describe("Non-Modal Mode", () => { + it("should show skeleton loader for supported page types on creation", () => { + element = createTestEmbedElement({ + dataset: { + pageType: "user.event.booking.slots", + }, + }); + + expectSkeletonLoader(element); + }); + + it("should show default loader for when page type is not provided", () => { + element = createTestEmbedElement({ + dataset: {}, + }); + + expectDefaultLoader(element); + }); + + it("should show default loader for when page type is not supported", () => { + element = createTestEmbedElement({ + dataset: { + pageType: "unknown", + }, + }); + + expectDefaultLoader(element); + }); + + it("should hide skeleton loader when toggled off", () => { + element = createTestEmbedElement({ + dataset: { + pageType: "user.event.booking.slots", + }, + }); + + expectSkeletonLoader(element); + + element.toggleLoader(false); + expectLoaderToBeHidden(element); + }); + + it("should hide default loader when toggled off", () => { + element = createTestEmbedElement({}); + + expectDefaultLoader(element); + + element.toggleLoader(false); + expectLoaderToBeHidden(element); + }); + + it("should update container height correctly", () => { + element = createTestEmbedElement({ + dataset: { + pageType: "user.event.booking.slots", + }, + }); + const skeletonContainerEl = element.getSkeletonContainerElement(); + + // Initial height should be set based on skeleton height + expect(skeletonContainerEl.style.height).not.toBe(""); + + element.toggleLoader(false); + expect(skeletonContainerEl.style.height).toBe(""); + expect(skeletonContainerEl.style.display).toBe("none"); + }); + }); + + describe("Modal Mode", () => { + let isModal: boolean; + beforeEach(() => { + isModal = true; + }); + + it("should show default loader for unsupported page types", () => { + element = createTestEmbedElement({ + isModal, + }); + delete element.dataset.pageType; + + expectDefaultLoader(element); + }); + + it("should show skeleton loader for supported page types", () => { + element = createTestEmbedElement({ + isModal, + dataset: { + pageType: "user.event.booking.slots", + }, + }); + + expectSkeletonLoader(element); + }); + + it("should adjust skeletonContainer height correctly", () => { + element = createTestEmbedElement({ + isModal, + dataset: { + pageType: "user.event.booking.slots", + }, + }); + const skeletonContainerEl = element.getSkeletonContainerElement(); + + // Modal should have maxHeight set + expect(skeletonContainerEl.style.maxHeight).not.toBe(""); + expect(skeletonContainerEl.style.height).not.toBe(""); + + element.toggleLoader(false); + expect(skeletonContainerEl.style.height).toBe(""); + expect(skeletonContainerEl.style.display).toBe(""); + expect(skeletonContainerEl.style.maxHeight).toBe(""); + }); + }); + + describe("Theme Handling", () => { + it("should apply theme class correctly", () => { + element = createTestEmbedElement({ + dataset: { + theme: "dark", + }, + getSkeletonData: vi.fn(), + }); + expect(element.classList.contains("dark")).toBe(true); + expect(element.classList.contains("light")).toBe(false); + }); + + it("should update theme class when system preference changes as long as embed theme is not set", () => { + element = createTestEmbedElement({ + getSkeletonData: vi.fn(), + }); + getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true })); + + expect(element.classList.contains("dark")).toBe(true); + expect(element.classList.contains("light")).toBe(false); + }); + + it("should not update theme on system color scheme change when embed theme is set", () => { + element = createTestEmbedElement({ + dataset: { + theme: "light", + }, + getSkeletonData: vi.fn(), + }); + getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true })); + expect(element.classList.contains("light")).toBe(true); + }); + }); + + describe("Layout Handling", () => { + it("should set layout to month_view even if mobile is provided when device matches max-width: >768px", () => { + fakeDeviceMatchesMediaQuery("(max-width: 800px)"); + element = createTestEmbedElement({ + dataset: { layout: "mobile" }, + }); + + expectLayoutToBe("month_view", element); + }); + + it("should set layout to mobile even if month_view is provided when device matches max-width: 768px", () => { + fakeDeviceMatchesMediaQuery("(max-width: 768px)"); + element = createTestEmbedElement({ + dataset: { layout: "month_view" }, + }); + + expectLayoutToBe("mobile", element); + }); + + it("should set layout to month_view even if mobile is provided when device matches max-width: >768px", () => { + fakeDeviceMatchesMediaQuery("(max-width: 800px)"); + element = createTestEmbedElement({ + dataset: { layout: "mobile" }, + }); + + expectLayoutToBe("month_view", element); + }); + + it("should set layout from month_view to mobile when device changes its max-width from >768px to <=768px", () => { + fakeDeviceMatchesMediaQuery("(max-width: 800px)"); + element = createTestEmbedElement({ + dataset: { layout: "month_view" }, + }); + + expectLayoutToBe("month_view", element); + + fakeDeviceMatchesMediaQuery("(max-width: 768px)"); + // It hasn't changed because resize didn't happen + expectLayoutToBe("month_view", element); + + // Now resize the window + window.dispatchEvent(new Event("resize")); + expectLayoutToBe("mobile", element); + }); + + it("should set layout from mobile to month_view when device changes its max-width from <=768px to >768px", () => { + fakeDeviceMatchesMediaQuery("(max-width: 768px)"); + element = createTestEmbedElement({ + dataset: { layout: "mobile" }, + }); + + expectLayoutToBe("mobile", element); + + fakeDeviceMatchesMediaQuery("(max-width: 800px)"); + // It hasn't changed because resize didn't happen + expectLayoutToBe("mobile", element); + + // Now resize the window + window.dispatchEvent(new Event("resize")); + expectLayoutToBe("month_view", element); + }); + }); + }); +}); diff --git a/packages/embeds/embed-core/src/EmbedElement.ts b/packages/embeds/embed-core/src/EmbedElement.ts new file mode 100644 index 0000000000..250f738f07 --- /dev/null +++ b/packages/embeds/embed-core/src/EmbedElement.ts @@ -0,0 +1,218 @@ +import type { EmbedThemeConfig, AllPossibleLayouts, BookerLayouts, EmbedPageType } from "./types"; +import { + getThemeClassForEmbed, + getTrueLayout, + isThemePreferenceProvided, + removeDarkColorSchemeChangeListener, + addDarkColorSchemeChangeListener, + getMaxHeightForModal, +} from "./ui-utils"; + +type ShadowRootWithStyle = ShadowRoot & { + host: HTMLElement & { style: CSSStyleDeclaration }; +}; + +export class EmbedElement extends HTMLElement { + // Theme is set once by the user + public theme!: EmbedThemeConfig | null; + public isModal!: boolean; + public skeletonContainerHeightTimer: number | null = null; + // Theme Class is derived from `this.theme` as well as system color scheme preference + public themeClass!: string; + public layout!: AllPossibleLayouts; + public getSkeletonData!: (_args: { layout: AllPossibleLayouts; pageType: EmbedPageType | null }) => { + skeletonContent: string; + skeletonContainerStyle: string; + skeletonStyle: string; + }; + + private boundResizeHandler: () => void; + private boundPrefersDarkThemeChangedHandler: (e: MediaQueryListEvent) => void; + private isSkeletonSupportedPageType() { + const pageType = this.getPageType(); + return ( + pageType === "user.event.booking.slots" || + pageType === "team.event.booking.slots" || + pageType === "user.event.booking.form" || + pageType === "team.event.booking.form" + ); + } + public assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRootWithStyle } { + if (!this.shadowRoot) { + throw new Error("No shadow root"); + } + } + + public getPageType(): EmbedPageType { + return this.dataset.pageType as EmbedPageType; + } + public getLayout(): AllPossibleLayouts { + return getTrueLayout({ layout: (this.dataset.layout as BookerLayouts | undefined) ?? null }); + } + + getSkeletonContainerElement(): HTMLElement { + this.assertHasShadowRoot(); + const element = this.shadowRoot.querySelector("#skeleton-container"); + if (!element) { + throw new Error("No skeleton container element"); + } + return element; + } + + getSkeletonElement(): HTMLElement { + this.assertHasShadowRoot(); + const element = this.shadowRoot.querySelector("#skeleton"); + if (!element) { + throw new Error("No skeleton element"); + } + return element; + } + + private ensureContainerTakesSkeletonHeightWhenVisible() { + const skeletonEl = this.getSkeletonElement(); + const skeletonContainerEl = this.getSkeletonContainerElement(); + const isModal = this.isModal; + // skeletonEl is absolute positioned, so, we manually adjust the height of the Skeleton Container, so that the content around it doesn't do layout shift when actual iframe appears in its place + const heightOfSkeleton = parseFloat(getComputedStyle(skeletonEl).height); + if (isModal) { + const skeletonContainerContainingIframeToo = skeletonContainerEl; + if (heightOfSkeleton) { + // maxHeight need to be set only if skeleton inside the container is visible + skeletonContainerContainingIframeToo.style.maxHeight = `${getMaxHeightForModal()}px`; + skeletonContainerContainingIframeToo.style.height = `${heightOfSkeleton}px`; + } else { + // Reset props and be done with requestAnimationFrame + skeletonContainerContainingIframeToo.style.height = ""; + skeletonContainerContainingIframeToo.style.maxHeight = ""; + return; + } + } else { + const skeletonContainerNotContainingIframe = skeletonContainerEl; + const heightOfIframeByDefault = 300; + const diff = heightOfSkeleton - heightOfIframeByDefault; + if (heightOfSkeleton) { + if (diff > 0) { + // Skeleton is positioned overlapping the iframe(hidden with height of 300px already) so we add to container height only the difference + skeletonContainerNotContainingIframe.style.height = `${diff}px`; + } + } else { + // We will be here when skeleton is display:none + // Reset height - and be done with requestAnimationFrame + skeletonContainerNotContainingIframe.style.height = ""; + return; + } + } + const rafId = requestAnimationFrame(this.ensureContainerTakesSkeletonHeightWhenVisible.bind(this)); + this.skeletonContainerHeightTimer = rafId; + return rafId; + } + + getLoaderElement(): HTMLElement { + this.assertHasShadowRoot(); + const loaderEl = this.shadowRoot.querySelector(".loader"); + + if (!loaderEl) { + throw new Error("No loader element"); + } + + return loaderEl; + } + + public toggleLoader(show: boolean) { + const skeletonEl = this.getSkeletonElement(); + + const loaderEl = this.getLoaderElement(); + const skeletonContainerEl = this.getSkeletonContainerElement(); + const supportsSkeleton = this.isSkeletonSupportedPageType(); + + if (!supportsSkeleton) { + loaderEl.style.display = show ? "block" : "none"; + skeletonEl.style.display = "none"; + } else { + skeletonEl.style.display = show ? "block" : "none"; + loaderEl.style.display = "none"; + if (!this.isModal && !show) { + const skeletonContainerNotContainingIframe = skeletonContainerEl; + // In non-modal layout, skeletonContainerEl is static positioned before the slot(i.e. iframe) and it takes space even if its content is hidden. So, we explicitly set display to none + // Also, it doesn't contain anything other than skeleton, so it is safe to set display to none + skeletonContainerNotContainingIframe.style.display = "none"; + } + } + this.ensureContainerTakesSkeletonHeightWhenVisible(); + } + + constructor(data: { + isModal: boolean; + getSkeletonData: (_args: { layout: AllPossibleLayouts; pageType: EmbedPageType | null }) => { + skeletonContent: string; + skeletonContainerStyle: string; + skeletonStyle: string; + }; + }) { + super(); + if (process.env.INTEGRATION_TEST_MODE) { + // @ts-expect-error - Integration test mode + Object.assign(this.dataset, data.dataset); + } + this.isModal = data.isModal; + this.layout = this.getLayout(); + this.theme = this.dataset.theme as EmbedThemeConfig | null; + this.themeClass = getThemeClassForEmbed({ theme: this.theme }); + this.classList.add(this.themeClass); + + this.getSkeletonData = data.getSkeletonData; + this.boundResizeHandler = this.resizeHandler.bind(this); + this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this); + } + + public resizeHandler() { + const newLayout = getTrueLayout({ layout: this.layout ?? null }); + if (newLayout === this.layout) { + return; + } + this.layout = newLayout; + const { skeletonContent, skeletonContainerStyle, skeletonStyle } = this.getSkeletonData({ + layout: this.getLayout(), + pageType: this.getPageType() ?? null, + }); + + const skeletonContainerEl = this.getSkeletonContainerElement(); + const skeletonEl = this.getSkeletonElement(); + + skeletonContainerEl.setAttribute("style", skeletonContainerStyle); + skeletonEl.setAttribute("style", skeletonStyle); + + skeletonEl.innerHTML = skeletonContent; + } + + public prefersDarkThemeChangedHandler(e: MediaQueryListEvent) { + const isDarkPreferred = e.matches; + const allPossibleThemeClasses = ["dark", "light"]; + if (isThemePreferenceProvided(this.theme)) { + // User has provided a theme preference, so we stick to that and don't react to system theme change + return; + } + const newThemeClass = getThemeClassForEmbed({ + theme: isDarkPreferred ? "dark" : "light", + }); + if (newThemeClass !== this.themeClass) { + this.classList.remove(...allPossibleThemeClasses); + this.classList.add(newThemeClass); + } + } + connectedCallback() { + // Make sure to show the loader initially. + // toggleLoader takes care of deciding which loader default/skeleton to be shown + this.toggleLoader(true); + window.addEventListener("resize", this.boundResizeHandler); + addDarkColorSchemeChangeListener(this.boundPrefersDarkThemeChangedHandler); + } + + disconnectedCallback() { + if (this.skeletonContainerHeightTimer) { + cancelAnimationFrame(this.skeletonContainerHeightTimer); + } + window.removeEventListener("resize", this.boundResizeHandler); + removeDarkColorSchemeChangeListener(this.boundPrefersDarkThemeChangedHandler); + } +} diff --git a/packages/embeds/embed-core/src/Inline/inline.ts b/packages/embeds/embed-core/src/Inline/inline.ts index 25fcbcd6bb..883562f028 100644 --- a/packages/embeds/embed-core/src/Inline/inline.ts +++ b/packages/embeds/embed-core/src/Inline/inline.ts @@ -1,31 +1,25 @@ +import { EmbedElement } from "../EmbedElement"; import loaderCss from "../loader.css?inline"; import { getErrorString } from "../utils"; -import inlineHtml from "./inlineHtml"; +import inlineHtml, { getSkeletonData } from "./inlineHtml"; -export class Inline extends HTMLElement { +export class Inline extends EmbedElement { static get observedAttributes() { return ["loading"]; } - assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } { - if (!this.shadowRoot) { - throw new Error("No shadow root"); - } - } - attributeChangedCallback(name: string, oldValue: string, newValue: string) { this.assertHasShadowRoot(); - const loaderEl = this.shadowRoot.querySelector(".loader"); const errorEl = this.shadowRoot.querySelector("#error"); const slotEl = this.shadowRoot.querySelector("slot"); - if (!loaderEl || !slotEl || !errorEl) { + if (!slotEl || !errorEl) { throw new Error("One of loaderEl, slotEl or errorEl is missing"); } if (name === "loading") { if (newValue == "done") { - loaderEl.style.display = "none"; + this.toggleLoader(false); } else if (newValue === "failed") { - loaderEl.style.display = "none"; + this.toggleLoader(false); slotEl.style.visibility = "hidden"; errorEl.style.display = "block"; const errorString = getErrorString(this.dataset.errorCode); @@ -33,10 +27,14 @@ export class Inline extends HTMLElement { } } } + constructor() { - super(); + super({ isModal: false, getSkeletonData }); this.attachShadow({ mode: "open" }); this.assertHasShadowRoot(); - this.shadowRoot.innerHTML = `${inlineHtml}`; + this.shadowRoot.innerHTML = `${inlineHtml({ + layout: this.layout, + pageType: this.getPageType() ?? null, + })}`; } } diff --git a/packages/embeds/embed-core/src/Inline/inlineHtml.ts b/packages/embeds/embed-core/src/Inline/inlineHtml.ts index 715858a262..453f457a37 100644 --- a/packages/embeds/embed-core/src/Inline/inlineHtml.ts +++ b/packages/embeds/embed-core/src/Inline/inlineHtml.ts @@ -1,10 +1,58 @@ -const html = `
-
- +import type { AllPossibleLayouts, EmbedPageType } from "../types"; +import { generateSkeleton } from "../ui/skeleton"; + +const html = ({ + layout = "month_view", + pageType, +}: { + layout?: AllPossibleLayouts; + pageType: EmbedPageType | null; +}) => { + const { skeletonContent, skeletonContainerStyle, skeletonStyle } = getSkeletonData({ + layout, + pageType, + }); + + return ` +
+
+ ${skeletonContent} +
+
+
+ +
+ +
- -
-`; + +`; +}; + +export const getSkeletonData = ({ + layout, + pageType, +}: { + layout: AllPossibleLayouts; + pageType: EmbedPageType | null; +}) => { + // We keep width 100% for mobile because we want it to take the entire screen width, which it would take because skeletonContainer is taking entire screen width + const mobileStyleForSkeleton = "width:100%;"; + // We don't do width:100% here because we want skeleton to be centered horizontally and not take the entire screen width in desktop + const desktopStyleForSkeleton = "left:50%; transform:translate(-50%,0%)"; + + const styleForSkeleton = layout === "mobile" ? mobileStyleForSkeleton : desktopStyleForSkeleton; + + // height is set via JS to be same as skeleton inside it + // Width 100% so that the entire width is available just like for iframe + const styleForSkeletonContainer = "width:100%;"; + + return { + skeletonContent: generateSkeleton({ layout, pageType }), + skeletonContainerStyle: styleForSkeletonContainer, + skeletonStyle: styleForSkeleton, + }; +}; export default html; diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts index 606237f694..feb91c3817 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts @@ -1,25 +1,17 @@ +import { EmbedElement } from "../EmbedElement"; import loaderCss from "../loader.css"; import { getErrorString } from "../utils"; -import modalBoxHtml from "./ModalBoxHtml"; +import modalBoxHtml, { getSkeletonData } from "./ModalBoxHtml"; -type ShadowRootWithStyle = ShadowRoot & { - host: HTMLElement & { style: CSSStyleDeclaration }; -}; - -export class ModalBox extends HTMLElement { +export class ModalBox extends EmbedElement { static htmlOverflow: string; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore static get observedAttributes() { return ["state"]; } - assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRootWithStyle } { - if (!this.shadowRoot) { - throw new Error("No shadow root"); - } - } - show(show: boolean) { this.assertHasShadowRoot(); // We can't make it display none as that takes iframe width and height calculations to 0 @@ -68,18 +60,7 @@ export class ModalBox extends HTMLElement { } } - getLoaderElement() { - this.assertHasShadowRoot(); - const loaderEl = this.shadowRoot.querySelector(".loader"); - - if (!loaderEl) { - throw new Error("No loader element"); - } - - return loaderEl; - } - - getErrorElement() { + getErrorElement(): HTMLElement { this.assertHasShadowRoot(); const element = this.shadowRoot.querySelector("#error"); @@ -98,15 +79,16 @@ export class ModalBox extends HTMLElement { if (newValue === "loading") { this.open(); this.hideIframe(); - this.getLoaderElement().style.display = "block"; + this.toggleLoader(true); } else if (newValue == "loaded" || newValue === "reopening") { this.open(); this.showIframe(); - this.getLoaderElement().style.display = "none"; + this.toggleLoader(false); } else if (newValue == "closed") { this.explicitClose(); } else if (newValue === "failed") { this.getLoaderElement().style.display = "none"; + this.getSkeletonElement().style.display = "none"; this.getErrorElement().style.display = "inline-block"; const errorString = getErrorString(this.dataset.errorCode); this.getErrorElement().innerText = errorString; @@ -116,6 +98,7 @@ export class ModalBox extends HTMLElement { } connectedCallback() { + super.connectedCallback(); this.assertHasShadowRoot(); const closeEl = this.shadowRoot.querySelector(".close"); document.addEventListener( @@ -144,8 +127,11 @@ export class ModalBox extends HTMLElement { } constructor() { - super(); - const modalHtml = `${modalBoxHtml}`; + super({ isModal: true, getSkeletonData }); + const modalHtml = `${modalBoxHtml({ + layout: this.getLayout(), + pageType: this.getPageType() ?? null, + })}`; this.attachShadow({ mode: "open" }); ModalBox.htmlOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts b/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts index 5617d2c9d1..9c1e0ce82e 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts @@ -1,59 +1,103 @@ -const html = ` + `; } -.modal-box { - margin:0 auto; - margin-top:20px; - margin-bottom:20px; - position:absolute; - width:100%; - top:50%; - left:50%; - transform: translateY(-50%) translateX(-50%); - overflow: auto; -} - -.header { - position: relative; - float:right; - top: 10px; -} -.close { - font-size: 30px; - left: -20px; - position: relative; - color:white; - cursor: pointer; -} -/*Modal background is black only, so hardcode white */ -.loader { - --cal-brand-color:white; -} - +const html = ({ + layout = "month_view", + pageType, +}: { + layout?: AllPossibleLayouts; + pageType: EmbedPageType | null; +}) => { + const { skeletonContent, skeletonContainerStyle, skeletonStyle } = getSkeletonData({ + layout, + pageType, + }); + return ` +${getStyle()}
-
- -
-`; +}; +export const getSkeletonData = ({ + layout, + pageType, +}: { + layout: AllPossibleLayouts; + pageType: EmbedPageType | null; +}) => { + const mobileStyleForSkeleton = "width:100%;"; + const desktopStyleForSkeleton = "left:50%; transform:translate(-50%,0%)"; + const styleForSkeleton = layout === "mobile" ? mobileStyleForSkeleton : desktopStyleForSkeleton; + // height is set via JS to be same as skeleton inside it + // Width 100% so that the entire width is available just like for iframe + const styleForSkeletonContainer = "width:100%;"; + return { + skeletonContent: generateSkeleton({ layout, pageType }), + skeletonContainerStyle: styleForSkeletonContainer, + skeletonStyle: styleForSkeleton, + }; +}; export default html; diff --git a/packages/embeds/embed-core/src/__tests__/utils.test.ts b/packages/embeds/embed-core/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..25150a6aaa --- /dev/null +++ b/packages/embeds/embed-core/src/__tests__/utils.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { generateDataAttributes } from "../utils"; + +describe("generateDataAttributes", () => { + it("should handle PascalCase property names correctly", () => { + const props = { + TestKey: "testValue", + AnotherKey: "anotherValue", + }; + + expect(generateDataAttributes(props)).toBe('data-test-key="testValue" data-another-key="anotherValue"'); + }); + + it("should handle camelCase property names correctly", () => { + const props = { + camelCaseKey: "value", + superLongCamelCaseKey: "test", + }; + + expect(generateDataAttributes(props)).toBe( + 'data-camel-case-key="value" data-super-long-camel-case-key="test"' + ); + }); + + it("should filter out null and undefined values", () => { + const props = { + validKey: "value", + nullKey: null, + undefinedKey: undefined, + }; + + expect(generateDataAttributes(props)).toBe('data-valid-key="value"'); + }); + + it("should return empty string for empty object", () => { + const props = {}; + + expect(generateDataAttributes(props)).toBe(""); + }); + + it("should handle object with all null/undefined values", () => { + const props = { + key1: null, + key2: undefined, + }; + + expect(generateDataAttributes(props)).toBe(""); + }); + + it("should escape special characters in attribute values", () => { + const props = { + testKey: "", + testKey2: '', + }; + + expect(generateDataAttributes(props)).toBe( + 'data-test-key="<script>alert('XSS')</script>" data-test-key2="<script>alert("XSS")</script>"' + ); + }); +}); diff --git a/packages/embeds/embed-core/src/addAppCssVars.ts b/packages/embeds/embed-core/src/addAppCssVars.ts index 24899221f7..19b66322ae 100644 --- a/packages/embeds/embed-core/src/addAppCssVars.ts +++ b/packages/embeds/embed-core/src/addAppCssVars.ts @@ -1,6 +1,8 @@ export const addAppCssVars = () => { + const calElements = ["cal-floating-button", "cal-modal-box", "cal-inline"]; try { - const cssVarsStyle = `cal-floating-button, cal-modal-box, cal-inline { + const cssVarsStyle = ` + ${calElements.join(",")} { /* Spacing Scale */ --cal-spacing-0: 0px; --cal-spacing-px: 1px; @@ -144,7 +146,106 @@ export const addAppCssVars = () => { --cal-brand: hsla(221, 39%, 11%, 1); /* Keeping existing brand color */ --cal-brand-emphasis: hsla(0, 0%, 6%, 1); /* Keeping existing brand emphasis */ --cal-brand-text: hsla(0, 0%, 100%, 1); /* white */ - }`; + } + + ${calElements.map((element) => `${element}.dark`).join(",")} { + /* Background Standard */ + --cal-bg-emphasis: hsla(0, 0%, 25%, 1); /* stone-700 */ + --cal-bg: hsla(0, 0%, 6%, 1); /* stone-950 */ + --cal-bg-subtle: hsla(0, 0%, 15%, 1); /* stone-800 */ + --cal-bg-muted: hsla(0, 0%, 9%, 1); /* stone-900 */ + --cal-bg-inverted: hsla(0, 0%, 98%, 1); /* stone-50 */ + + /* Background Primary */ + --cal-bg-primary: hsla(0, 0%, 96%, 1); /* stone-100 */ + --cal-bg-primary-emphasis: hsla(0, 0%, 64%, 1); /* stone-800 */ + --cal-bg-primary-muted: hsla(0, 0%, 15%, 1); /* stone-400 */ + + /* Background Brand */ + --cal-bg-brand: hsla(0, 0%, 98%, 1); /* gray-50 */ + --cal-bg-brand-emphasis: hsla(0, 0%, 96%, 1); /* stone-100 */ + --cal-bg-brand-muted: hsla(0, 0%, 96%, 1); /* stone-100 */ + + /* Background Semantic */ + --cal-bg-semantic-info-subtle: hsla(236, 80%, 8%, 1); /* blue-800 */ + --cal-bg-semantic-info-emphasis: hsla(229, 90%, 74%, 1); /* blue-500 */ + --cal-bg-semantic-success-subtle: hsla(148, 88%, 16%, 1); /* green-800 */ + --cal-bg-semantic-success-emphasis: hsla(158, 74%, 38%, 1); /* green-500 */ + --cal-bg-semantic-attention-subtle: hsla(21, 86%, 8%, 1); /* orange-800 */ + --cal-bg-semantic-attention-emphasis: hsla(27, 96%, 61%, 1); /* orange-500 */ + --cal-bg-semantic-error-subtle: hsla(0, 70%, 8%, 1); /* red-800 */ + --cal-bg-semantic-error-emphasis: hsla(0, 91%, 71%, 1); /* red-500 */ + + /* Background Visualization */ + --cal-bg-visualization-1-subtle: hsla(336, 74%, 35%, 1); /* pink-200 */ + --cal-bg-visualization-1-emphasis: hsla(330, 81%, 60%, 1); /* pink-500 */ + --cal-bg-visualization-2-subtle: hsla(252, 83%, 23%, 1); /* purple-200 */ + --cal-bg-visualization-2-emphasis: hsla(256, 85%, 57%, 1); /* purple-500 */ + --cal-bg-visualization-3-subtle: hsla(236, 74%, 35%, 1); /* blue-200 */ + --cal-bg-visualization-3-emphasis: hsla(235, 100%, 63%, 1); /* blue-500 */ + --cal-bg-visualization-4-subtle: hsla(150, 84%, 22%, 1); /* green-200 */ + --cal-bg-visualization-4-emphasis: hsla(158, 74%, 38%, 1); /* green-500 */ + --cal-bg-visualization-5-subtle: hsla(28, 73%, 26%, 1); /* yellow-200 */ + --cal-bg-visualization-5-emphasis: hsla(45, 93%, 47%, 1); /* yellow-500 */ + --cal-bg-visualization-6-subtle: hsla(15, 75%, 23%, 1); /* orange-200 */ + --cal-bg-visualization-6-emphasis: hsla(25, 95%, 53%, 1); /* orange-500 */ + --cal-bg-visualization-7-subtle: hsla(0, 70%, 35%, 1); /* red-200 */ + --cal-bg-visualization-7-emphasis: hsla(0, 84%, 60%, 1); /* red-500 */ + + /* Legacy Background Components - Consider deprecating */ + --cal-bg-info: hsla(228, 56%, 33%, 1); /* Keeping existing info background */ + --cal-bg-success: hsla(133, 34%, 24%, 1); /* Keeping existing success background */ + --cal-bg-attention: hsla(16, 62%, 28%, 1); /* Keeping existing attention background */ + --cal-bg-error: hsla(2, 55%, 30%, 1); /* Keeping existing error background */ + --cal-bg-dark-error: hsla(2, 55%, 30%, 1); /* Keeping existing dark error */ + + /* Border Standard */ + --cal-border: hsla(0, 0%, 30%, 1); /* stone-600 */ + --cal-border-muted: hsla(0, 0%, 9%, 1); /* stone-500 */ + --cal-border-subtle: hsla(0, 0%, 15%, 1); /* stone-700 */ + --cal-border-emphasis: hsla(0, 0%, 45%, 1); /* stone-800 */ + --cal-border-booker: var(--cal-border-subtle); + /* Border Semantic */ + --cal-border-semantic-error: hsla(0, 63%, 24%, 1); /* red-800 */ + + /* Border/Semantic Subtle */ + --cal-border-semantic-attention-subtle: hsla(15, 75%, 23%, 1); + --cal-border-semantic-error-subtle: hsla(0, 63%, 24%, 1); + + /* Legacy Border - Consider deprecating */ + --cal-border-error: hsla(0, 63%, 24%, 1); /* Keeping existing error border */ + + /* Content/Text Standard */ + --cal-text-emphasis: hsla(0, 0%, 98%, 1); /* stone-50 */ + --cal-text: hsla(0, 0%, 83%, 1); /* stone-300 */ + --cal-text-subtle: hsla(0, 0%, 64%, 1); /* stone-400 */ + --cal-text-muted: hsla(0, 0%, 64%, 1); /* stone-400 */ + --cal-text-inverted: hsla(0, 0%, 0%, 1); /* black */ + + /* Content/Text Semantic */ + --cal-text-semantic-info: hsla(229, 90%, 74%, 1); /* blue-100 */ + --cal-text-semantic-success: hsla(161, 49%, 54%, 1); /* green-100 */ + --cal-text-semantic-attention: hsla(27, 96%, 61%, 1); /* orange-100 */ + --cal-text-semantic-error: hsla(0, 91%, 71%, 1); /* red-100 */ + + /* Content/Text Semantic Emphasis */ + --cal-text-semantic-info-emphasis: hsla(217, 91%, 25%, 1); /* blue-800 */ + --cal-text-semantic-success-emphasis: hsla(142, 71%, 25%, 1); /* green-800 */ + --cal-text-semantic-attention-emphasis: hsla(24, 95%, 25%, 1); /* orange-800 */ + --cal-text-semantic-error-emphasis: hsla(0, 84%, 25%, 1); /* red-800 */ + + /* Legacy Content/Text Components - Consider deprecating */ + --cal-text-info: hsla(218, 83%, 93%, 1); /* Keeping existing info text */ + --cal-text-success: hsla(134, 76%, 94%, 1); /* Keeping existing success text */ + --cal-text-attention: hsla(37, 86%, 92%, 1); /* Keeping existing attention text */ + --cal-text-error: hsla(3, 66%, 93%, 1); /* Keeping existing error text */ + + /* Brand */ + --cal-brand: hsla(0, 0%, 100%, 1); /* white */ + --cal-brand-emphasis: hsla(218, 11%, 65%, 1); /* Keeping existing brand emphasis */ + --cal-brand-text: hsla(0, 0%, 0%, 1); /* black */ +} + `; // Create a style rule for above css const style = document.createElement("style"); diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index ce7c7f500e..fe655ca25d 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -5,7 +5,14 @@ import { useEffect, useRef, useState, useCallback } from "react"; import type { Message } from "./embed"; import { sdkActionManager } from "./sdk-event"; -import type { EmbedThemeConfig, UiConfig, EmbedNonStylesConfig, BookerLayouts, EmbedStyles } from "./types"; +import type { + EmbedThemeConfig, + UiConfig, + EmbedNonStylesConfig, + BookerLayouts, + EmbedStyles, + KnownConfig, +} from "./types"; import { useCompatSearchParams } from "./useCompatSearchParams"; type SetStyles = React.Dispatch>; @@ -23,18 +30,7 @@ export type PrefillAndIframeAttrsConfig = Record & { id?: string; }; - - // TODO: It should have a dedicated prefill prop - // prefill: {}, - "flag.coep"?: "true" | "false"; - - // 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; - // TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time. - "ui.color-scheme"?: string; - theme?: EmbedThemeConfig; -}; +} & KnownConfig; declare global { interface Window { @@ -43,6 +39,8 @@ declare global { embedStore: typeof embedStore; applyCssVars: (cssVarsPerTheme: UiConfig["cssVarsPerTheme"]) => void; }; + // Marks that Booker has moved to some non-"loading" state + _embedBookerState?: "initializing" | "done"; } } @@ -81,6 +79,7 @@ const embedStore = { // Store all React State setters here. reactStylesStateSetters: {} as Record, reactNonStylesStateSetters: {} as Record, + // Embed can show itself only after this is set to true parentInformedAboutContentHeight: false, windowLoadEventFired: false, setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined, @@ -310,6 +309,14 @@ function getEmbedType() { } } +function isBookerReady() { + return window._embedBookerState === "done"; +} + +function isBookerPage() { + return !!window._embedBookerState; +} + export const useIsEmbed = (embedSsr?: boolean) => { const [isEmbed, setIsEmbed] = useState(embedSsr); useEffect(() => { @@ -334,11 +341,11 @@ export const useEmbedType = () => { }; function unhideBody() { + if (document.body.style.visibility !== "visible") { + document.body.style.visibility = "visible"; + } // Ensure that it stays visible and not reverted by React runAsap(() => { - if (document.body.style.visibility !== "visible") { - document.body.style.visibility = "visible"; - } unhideBody(); }); } @@ -392,12 +399,22 @@ const methods = { // eslint-disable-next-line @typescript-eslint/no-unused-vars parentKnowsIframeReady: (_unused: unknown) => { log("Method: `parentKnowsIframeReady` called"); + runAsap(function tryInformingLinkReady() { // TODO: Do it by attaching a listener for change in parentInformedAboutContentHeight if (!embedStore.parentInformedAboutContentHeight) { runAsap(tryInformingLinkReady); return; } + + // Let's wait for Booker to be ready before showing the embed + // It means that booker has loaded all its data and is ready to show + // TODO: We could try to mark the embed as ready earlier in this case not relying on document.readyState + if (isBookerPage() && !isBookerReady()) { + runAsap(tryInformingLinkReady); + return; + } + // No UI change should happen in sight. Let the parent height adjust and in next cycle show it. unhideBody(); if (!isPrerendering()) { @@ -460,10 +477,12 @@ function keepParentInformedAboutDimensionChanges() { }, 100); return; } + if (!embedStore.windowLoadEventFired) { sdkActionManager?.fire("__windowLoadComplete", {}); } embedStore.windowLoadEventFired = true; + // 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. const mainElement = @@ -495,7 +514,9 @@ function keepParentInformedAboutDimensionChanges() { // On subsequent renders, consider html height as the height of the iframe. If we don't do this, then if iframe gets bigger in height, it would never shrink const iframeHeight = isFirstTime ? documentScrollHeight : contentHeight; const iframeWidth = isFirstTime ? documentScrollWidth : contentWidth; + embedStore.parentInformedAboutContentHeight = true; + if (!iframeHeight || !iframeWidth) { runAsap(informAboutScroll); return; diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index 533cf534e8..8c31f99367 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -9,7 +9,8 @@ import { SdkActionManager } from "./sdk-action-manager"; import type { EventData, EventDataMap } from "./sdk-action-manager"; import tailwindCss from "./tailwindCss"; import type { UiConfig } from "./types"; -import { fromEntriesWithDuplicateKeys } from "./utils"; +import { getMaxHeightForModal } from "./ui-utils"; +import { fromEntriesWithDuplicateKeys, getConfigProp, generateDataAttributes } from "./utils"; export type { PrefillAndIframeAttrsConfig } from "./embed-iframe"; @@ -26,6 +27,8 @@ export type Message = { // HACK: Redefine and don't import WEBAPP_URL as it causes import statement to be present in built file. // This is happening because we are not able to generate an App and a lib using single Vite Config. const WEBAPP_URL = process.env.EMBED_PUBLIC_WEBAPP_URL || `https://${process.env.EMBED_PUBLIC_VERCEL_URL}`; +// Add App CSS Vars as soon as possible so that tailwind classes can work instantly. +addAppCssVars(); customElements.define("cal-modal-box", ModalBox); customElements.define("cal-floating-button", FloatingButton); @@ -373,10 +376,7 @@ export class Cal { } 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. - const spacingTopPlusBottom = 2 * 50; // 50 is the padding we want to keep to show close button comfortably. Make it same as top for bottom. - iframe.style.maxHeight = `${window.innerHeight - spacingTopPlusBottom}px`; + iframe.style.maxHeight = `${getMaxHeightForModal()}px`; } }); @@ -572,8 +572,21 @@ class CalApi { iframe.style.width = "100%"; containerEl.classList.add("cal-inline-container"); + const template = document.createElement("template"); - template.innerHTML = ``; + const layout = getConfigProp(config, "layout"); + const pageType = getConfigProp(config, "cal.embed.pageType"); + const theme = getConfigProp(config, "theme"); + + template.innerHTML = ` + + `; this.cal.inlineEl = template.content.children[0]; this.cal.inlineEl.appendChild(iframe); containerEl.appendChild(template.content); @@ -715,7 +728,18 @@ class CalApi { iframe.style.height = "100%"; iframe.style.width = "100%"; const template = document.createElement("template"); - template.innerHTML = ``; + const pageType = getConfigProp(configWithGuestKeyAndColorScheme, "cal.embed.pageType"); + const theme = getConfigProp(configWithGuestKeyAndColorScheme, "theme"); + const layout = getConfigProp(configWithGuestKeyAndColorScheme, "layout"); + + template.innerHTML = ` + `; this.cal.modalBox = template.content.children[0]; this.cal.modalBox.appendChild(iframe); if (__prerender) { @@ -1056,8 +1080,6 @@ function initializeGlobalCalProps() { globalCal.config.forwardQueryParams = globalCal.config.forwardQueryParams ?? false; } -addAppCssVars(); - function log(...args: unknown[]) { console.log(...args); } diff --git a/packages/embeds/embed-core/src/types.ts b/packages/embeds/embed-core/src/types.ts index 6e56223d49..0951d750c6 100644 --- a/packages/embeds/embed-core/src/types.ts +++ b/packages/embeds/embed-core/src/types.ts @@ -4,6 +4,7 @@ type Theme = "dark" | "light"; export type EmbedThemeConfig = Theme | "auto"; export type BookerLayouts = "month_view" | "week_view" | "column_view"; +export type AllPossibleLayouts = BookerLayouts | "mobile"; // 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. export interface EmbedStyles { @@ -42,4 +43,30 @@ declare global { } } +export type EmbedPageType = + // First page of booker for team event + | "team.event.booking.slots" + // First page of booker for user event + | "user.event.booking.slots" + // Booking Form(Second page of booker) for team event + | "team.event.booking.form" + // Booking Form(Second page of booker) for user event + | "user.event.booking.form"; + +export type KnownConfig = { + // TODO: It should have a dedicated prefill prop + // prefill: {}, + "flag.coep"?: "true" | "false"; + + // 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; + // TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time. + "ui.color-scheme"?: string; + theme?: EmbedThemeConfig; + // Prefixing with cal.embed because there could be more query params that aren't required by embed and are used for things like prefilling booking form, configuring dry run, and some other params simply to be forwarded to the booking success redirect URL. + // There are some cal. prefixed query params as well, not meant for embed specifically, but in general for cal.com + "cal.embed.pageType"?: EmbedPageType; +}; + export {}; diff --git a/packages/embeds/embed-core/src/ui-utils.ts b/packages/embeds/embed-core/src/ui-utils.ts new file mode 100644 index 0000000000..7cf95d87e9 --- /dev/null +++ b/packages/embeds/embed-core/src/ui-utils.ts @@ -0,0 +1,66 @@ +import type { AllPossibleLayouts, EmbedThemeConfig } from "./types"; + +export function getMaxHeightForModal() { + const spacingTopPlusBottom = 2 * 50; // 50 is the padding we want to keep to show close button comfortably. Make it same as top for bottom. + // 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. + return window.innerHeight - spacingTopPlusBottom; +} + +function matchesMediaQuery(query: string) { + return window.matchMedia(query).matches; +} + +export function getTrueLayout({ layout }: { layout: AllPossibleLayouts | null }) { + const isMobile = matchesMediaQuery("(max-width: 768px)"); + if (isMobile) { + return "mobile"; + } + const defaultLayout = "month_view"; + // If layout is mobile and isMobile false, then we need to reset to defaultLayout + if (layout === "mobile") { + return defaultLayout; + } + return layout ?? defaultLayout; +} + +function detectColorScheme() { + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + return "dark"; + } + return "light"; +} + +function getClassBasedOnTheme(theme: EmbedThemeConfig | undefined | null) { + return theme ?? "light"; +} + +export function isThemePreferenceProvided(theme: EmbedThemeConfig | undefined | null) { + return theme === "dark" || theme === "light"; +} + +export function getThemeClassForEmbed({ theme }: { theme: EmbedThemeConfig | undefined | null }) { + const systemTheme = detectColorScheme(); + if (isThemePreferenceProvided(theme)) { + return getClassBasedOnTheme(theme); + } + // NOTE: We don't support App configured theme as we can't fetch those details here. User has to explicitly set the theme in the embed snippet or use system theme + return getClassBasedOnTheme(systemTheme); +} + +let colorSchemeDarkQuery: MediaQueryList | null = null; + +export function getColorSchemeDarkQuery() { + if (!colorSchemeDarkQuery) { + colorSchemeDarkQuery = window.matchMedia("(prefers-color-scheme: dark)"); + } + return colorSchemeDarkQuery; +} + +export function addDarkColorSchemeChangeListener(listener: (e: MediaQueryListEvent) => void) { + getColorSchemeDarkQuery().addEventListener("change", listener); +} + +export function removeDarkColorSchemeChangeListener(listener: (e: MediaQueryListEvent) => void) { + getColorSchemeDarkQuery()?.removeEventListener("change", listener); +} diff --git a/packages/embeds/embed-core/src/ui/skeleton.ts b/packages/embeds/embed-core/src/ui/skeleton.ts new file mode 100644 index 0000000000..2d33841b78 --- /dev/null +++ b/packages/embeds/embed-core/src/ui/skeleton.ts @@ -0,0 +1,158 @@ +import type { AllPossibleLayouts, EmbedPageType } from "../types"; + +const generateEventMetaSkeleton = () => { + const eventMetaOptions = Array.from({ length: 4 }) + .map( + () => ` +
+
+
+
+ ` + ) + .join(""); + return ` +
+
+
    +
  • + +
    +
    +
  • +
+

+

+

+

+
+

+
+ ${eventMetaOptions} +
+
+
+ `; +}; + +const generateDatePickerSkeleton = () => { + const dayLabels = Array(7) + .fill(0) + .map( + () => ` +
+
+
+ ` + ) + .join(""); + + const datesToRender = 7 * 5; // columns * rows in a month view + const dates = Array.from({ length: datesToRender }) + .map( + () => ` +
+ +
` + ) + .join(""); + + return ` +
+
+ + + +
+
+ + +
+
+
+
+ ${dayLabels} +
+
+ ${dates} +
+
`; +}; + +const generateBookingSlotsPageSkeleton = ({ layout }: { layout: AllPossibleLayouts | null }) => { + if (layout === "mobile") { + return ` +
+
+
+ ${generateEventMetaSkeleton()} +
+ ${generateDatePickerSkeleton()} +
+
+
+
+
+
+`; + } + // TODO: Support different layouts. Right now we show month view skeleton for all layouts + return ` +
+
+ ${generateEventMetaSkeleton()} +
+
+ ${generateDatePickerSkeleton()} +
+
+`; +}; + +const generateSkeleton = ({ + layout = "month_view", + pageType, +}: { + layout: AllPossibleLayouts | null; + pageType: EmbedPageType | null; +}) => { + const defaultSkeleton = generateBookingSlotsPageSkeleton({ layout }); + if (!pageType) { + // Ideally we shouldn't be here if pageType is nullish, but just in case + return defaultSkeleton; + } + + if (pageType === "user.event.booking.slots" || pageType === "team.event.booking.slots") { + return generateBookingSlotsPageSkeleton({ layout }); + } + + // TODO: Support skeletons for other pages like user.event.booking.form, team.event.booking.form and profile pages + return defaultSkeleton; +}; + +export { generateSkeleton }; diff --git a/packages/embeds/embed-core/src/utils.ts b/packages/embeds/embed-core/src/utils.ts index 0e4821244a..83efffad1f 100644 --- a/packages/embeds/embed-core/src/utils.ts +++ b/packages/embeds/embed-core/src/utils.ts @@ -1,3 +1,5 @@ +import type { KnownConfig } from "./types"; + export const getErrorString = (errorCode: string | undefined) => { if (errorCode === "404") { return `Error Code: 404. Cal Link seems to be wrong.`; @@ -35,3 +37,64 @@ export function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string, } return result; } + +function listKnownConfigProps() { + const knownConfigProps: (keyof KnownConfig)[] = [ + "flag.coep", + "layout", + "ui.color-scheme", + "theme", + "cal.embed.pageType", + ]; + return knownConfigProps; +} + +export function getConfigProp(config: KnownConfig, prop: TProp) { + const knownConfigProps = listKnownConfigProps(); + if (!knownConfigProps.includes(prop)) { + console.warn(`Not reading unknown config prop: ${prop}`); + return null; + } + return config[prop] ?? null; +} + +export function getQueryParamProvidedByConfig( + queryParam: TProp +): NonNullable | null { + const knownConfigProps = listKnownConfigProps(); + if (!knownConfigProps.includes(queryParam)) { + console.warn(`Not reading unknown config prop from query: ${queryParam}`); + return null; + } + const url = new URL(document.URL); + const value = url.searchParams.get(queryParam); + if (typeof value === "string") { + return value as NonNullable; + } + return value; +} + +function hyphenate(str: string): string { + return str.charAt(0).toLowerCase() + str.slice(1).replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); +} + +/** + * Escapes special characters in HTML attribute values to prevent XSS + * @param value The string to escape + * @returns Escaped string safe for use in HTML attributes + */ +function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + +export function generateDataAttributes(props: Record): string { + return Object.entries(props) + .filter((pair): pair is [string, string] => !!pair[1]) + .map(([key, value]) => `data-${hyphenate(key)}="${escapeHtmlAttribute(value)}"`) + .join(" "); +} diff --git a/packages/embeds/embed-core/tailwind.config.js b/packages/embeds/embed-core/tailwind.config.js index 753dac72c2..5ded59ec4e 100644 --- a/packages/embeds/embed-core/tailwind.config.js +++ b/packages/embeds/embed-core/tailwind.config.js @@ -2,7 +2,7 @@ const base = require("@calcom/config/tailwind-preset"); module.exports = { ...base, - content: ["**/*Html.ts"], + content: ["**/*Html.ts", "**/ui/**/*.ts*"], theme: { ...base.theme, extend: { diff --git a/packages/embeds/embed-core/test/__mocks__/windowMatchMedia.ts b/packages/embeds/embed-core/test/__mocks__/windowMatchMedia.ts new file mode 100644 index 0000000000..83944a69c4 --- /dev/null +++ b/packages/embeds/embed-core/test/__mocks__/windowMatchMedia.ts @@ -0,0 +1,63 @@ +import { beforeEach, afterEach, vi } from "vitest"; + +let __matchMediaFakeQuery: string[] = []; +// Mock matchMedia - Not Provided by JSDOM +function mockWindowMatchMedia() { + window.matchMedia = vi.fn().mockImplementation((query: string) => { + const eventListeners: Record void)[]> = { + change: [], + }; + const matches = __matchMediaFakeQuery.includes(query); + return { + matches, + media: query, + onchange: null, + addEventListener: (event: string, listener: (event: MediaQueryListEvent) => void) => { + if (event === "change") { + console.log("addEventListener called", event, listener); + eventListeners.change.push(listener); + } + }, + removeEventListener: (event: string, listener: (event: MediaQueryListEvent) => void) => { + if (event === "change") { + eventListeners.change = eventListeners.change.filter((l) => l !== listener); + console.log("removeEventListener called, Remaining listeners", eventListeners.change.length); + } + }, + dispatchEvent: (event: MediaQueryListEvent) => { + if (event.type === "change") { + eventListeners.change.forEach((listener) => { + console.log("listener called", listener); + listener(event as MediaQueryListEvent); + }); + } + }, + }; + }); +} +// Call immediately so that imports accessing window.matchMedia on import can access the mock +mockWindowMatchMedia(); + +beforeEach(() => { + // Do on beforeEach because vi.fn() gets reset after each test + mockWindowMatchMedia(); +}); + +afterEach(() => { + __matchMediaFakeQuery = []; +}); +export function fakeDeviceMatchesMediaQuery(query: string) { + // Extract the query type (e.g., max-width or prefers-color-scheme) + const queryType = query.match(/(max-width|prefers-color-scheme)/)?.[0]; + + if (queryType) { + // Remove any existing queries of the same type + __matchMediaFakeQuery = __matchMediaFakeQuery.filter( + (existingQuery) => !existingQuery.includes(queryType) + ); + } + + // Add the new query + __matchMediaFakeQuery.push(query); + console.log("__mock__:fakeDeviceMatchesMediaQuery updated", { query, __matchMediaFakeQuery }); +} diff --git a/packages/embeds/embed-react/inline.html b/packages/embeds/embed-react/inline.html index 833271fe8c..e464eb8e86 100644 --- a/packages/embeds/embed-react/inline.html +++ b/packages/embeds/embed-react/inline.html @@ -1,5 +1,6 @@ + diff --git a/packages/embeds/embed-react/inline.tsx b/packages/embeds/embed-react/inline.tsx index 9c7641accb..c3780fbd7e 100644 --- a/packages/embeds/embed-react/inline.tsx +++ b/packages/embeds/embed-react/inline.tsx @@ -90,6 +90,7 @@ function App() { notes: "Test Meeting", guests: ["janedoe@gmail.com"], theme: "dark", + "cal.embed.pageType": "user.event.booking.slots", }} /> diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 173578c551..5952632041 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -38,10 +38,18 @@ import { useIsQuickAvailabilityCheckFeatureEnabled } from "./components/hooks/us import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config"; import framerFeatures from "./framer-features"; import { useBookerStore } from "./store"; -import type { BookerProps, WrappedBookerProps } from "./types"; +import type { BookerProps, WrappedBookerProps, BookerState } from "./types"; import { isBookingDryRun } from "./utils/isBookingDryRun"; import { isTimeSlotAvailable } from "./utils/isTimeslotAvailable"; +function updateEmbedBookerState({ bookerState }: { bookerState: BookerState }) { + // Ensure that only after the bookerState is reflected, we update the embedIsBookerReady + if (typeof window !== "undefined") { + (window as Window & { _embedBookerState?: "initializing" | "done" })._embedBookerState = + bookerState && bookerState !== "loading" ? "done" : "initializing"; + } +} + const BookerComponent = ({ username, eventSlug, @@ -178,6 +186,8 @@ const BookerComponent = ({ (bookerState === "booking" || (bookerState === "selecting_time" && skipConfirmStep)) ); + updateEmbedBookerState({ bookerState }); + useEffect(() => { if (event.isPending) return setBookerState("loading"); if (!selectedDate) return setBookerState("selecting_date");