diff --git a/packages/embeds/LIFECYCLE.md b/packages/embeds/LIFECYCLE.md index 8999afbcf5..d241d0a6b9 100644 --- a/packages/embeds/LIFECYCLE.md +++ b/packages/embeds/LIFECYCLE.md @@ -2,119 +2,59 @@ This document details the lifecycle events and states of Cal.com embeds, showing the interaction flow between the parent page and the iframe. -## Core Lifecycle Sequence +## Embed Handshake (Core Communication) -```mermaid -sequenceDiagram - participant Parent as Parent (User Page) - participant Embed as Embed (iframe) - participant Store as EmbedStore(iframe) - participant UI as UI Components(iframe) - - Note over Parent: embed.js loads - - alt Inline Embed - Parent->>Parent: Create cal-element - Parent->>Parent: Create iframe (visibility: hidden) - Parent->>Parent: Show loader - else Modal Embed - Note over Parent: No action unless prerender - end +The handshake is the foundational communication protocol that establishes a bi-directional channel between the parent page and the iframe. All other embed functionality depends on this handshake being completed successfully. - alt Prerender Flow - Note over Parent: Set prerender=true in URL - Parent->>Store: Set prerenderState="inProgress" - Note over Store: Limited events allowed - Parent->>Parent: Create hidden iframe - Parent->>Parent: load booker(no slots) - Note over Parent: Wait for connect() call - end +See [embed-handshake.mermaid](./embed-handshake.mermaid) for the detailed handshake sequence. +See [embed-message-protocol.mermaid](./embed-message-protocol.mermaid) for the message passing architecture. - alt Modal CTA clicked - Parent->>Parent: Create cal-modal-box - Parent->>Parent: Create iframe (visibility: hidden) - Parent->>Parent: Show loader - end - Note over Embed: background: transparent(stays transparent) - Note over Embed: body tag set to visibility: hidden waiting to be shown - Note over Embed: iframe webpage starts rendering +### Handshake Summary - Note over Store: Initialize EmbedStore state - Store->>Store: Set NOT_INITIALIZED state - Store->>Store: Initialize UI config & theme +1. **Parent creates iframe** (hidden) with embed parameters +2. **Iframe fires `__iframeReady`** via postMessage when ready to receive commands +3. **Parent acknowledges** by sending `parentKnowsIframeReady` back to iframe +4. **Iframe makes body visible** and fires `linkReady` (or `linkPrerendered`) +5. **Parent flushes queued commands** that were called before handshake completed - Note over Parent: Process URL params for prefill - Parent->>Store: Set prefill data from URL - Note over Store: Auto-populate form fields +### Message Format - Embed->>Parent: __iframeReady event - Note over Parent,Embed: Embed ready to receive messages - Note over Store: Set state to INITIALIZED +All messages use the `originator: "CAL"` identifier to distinguish Cal.com embed messages: - Store->>UI: Apply theme configuration - Note over UI: DEPRECATED: styles prop - Note over UI: Use cssVarsPerTheme instead - Store->>UI: Apply cssVarsPerTheme +```javascript +// Parent → Iframe (Commands) +{ originator: "CAL", method: "ui", arg: { theme: "dark" } } - Embed->>Parent: __dimensionChanged event - Note over Parent: Calculate and adjust iframe dimensions - Note over Store: Update parentInformedAboutContentHeight - - alt isBookerPage - Note over Embed: Wait for booker ready state - end - - Embed->>Parent: linkReady event - Note over Parent: Changes loading state to done - Note over Parent: Removes loader - Note over Parent: Sets iframe visibility to visible - - Parent->>Embed: parentKnowsIframeReady event - Note over Embed: Makes body visible - Note over Store: Update UI configuration - - alt Prerendering Active - Note over Store: Set prerenderState to completed - Parent->>Store: connect() with new config - Store->>Store: Reset parentInformedAboutContentHeight - Store->>Parent: Update iframe with new params - Note over Store: Remove prerender params - end - - loop Dimension Monitoring - Embed->>Embed: Monitor content size changes - alt Dimensions Changed - Embed->>Parent: __dimensionChanged event - Parent->>Parent: Adjust iframe size to avoid scrollbar - end - end - - alt Route Changes - UI->>Store: Update UI state - Store->>Parent: __routeChanged event - Parent->>Parent: Handle navigation - Note over Store: Preserve prefill data - end +// Iframe → Parent (Events) +{ originator: "CAL", type: "__iframeReady", data: { isPrerendering: false } } ``` -## Detailed State Management +## Inline Embed Lifecycle -### EmbedStore States -- `NOT_INITIALIZED`: Initial state when iframe is created -- `INITIALIZED`: After __iframeReady event is processed -- `prerenderState`: Can be null | "inProgress" | "completed" +Inline embeds are created immediately when `Cal.inline()` is called. They have a simpler lifecycle without prerendering support. -### Visibility States -1. Initial Creation: - - iframe.style.visibility = "hidden" - - body.style.visibility = "hidden" - -2. After __iframeReady: - - iframe becomes visible (unless prerendering) - -3. After parentKnowsIframeReady: - - body becomes visible - - Background remains transparent +See [inline-embed-lifecycle.mermaid](./inline-embed-lifecycle.mermaid) for the complete sequence diagram. + +## Modal Embed Lifecycle + +Modal embeds are created when a CTA is clicked or `Cal.modal()` is called. They support reuse, state management, and prerendering. + +See [modal-embed-lifecycle.mermaid](./modal-embed-lifecycle.mermaid) for the complete sequence diagram. + +## Modal Prerendering Flow + +Prerendering allows loading the booking page in the background before the user opens the modal, enabling instant display when the CTA is clicked. + +See [modal-prerendering-flow.mermaid](./modal-prerendering-flow.mermaid) for the complete sequence diagram. + +## Visibility Flow + +The embed system carefully manages visibility to prevent visual glitches: + +1. **Initial Creation**: Both iframe and body start hidden while the page loads +2. **After Communication Established**: iframe becomes visible once it's ready to communicate +3. **After Content Ready**: Loader is removed and iframe is fully visible +4. **After Parent Acknowledges**: Body content becomes visible, background stays transparent ## Event Details @@ -124,75 +64,159 @@ sequenceDiagram - For modal embeds: Waits for CTA click (unless prerendering) 2. **iframe Creation** - - iframe is created with `visibility: hidden` - - Loader is shown (default or skeleton) - - EmbedStore initialized + - iframe is created hidden + - Loader is shown to the user + - Embed system initializes 3. **__iframeReady Event** - Fired by: Iframe - - Indicates: Embed is ready to receive messages - - Actions: - - Sets iframeReady flag to true - - Makes iframe visible (unless prerendering) - - Processes queued iframe commands + - Indicates: Embed is ready to receive messages from parent + - Actions: Makes iframe visible (unless prerendering) and processes any queued commands 4. **__dimensionChanged Event** - Fired by: Iframe - - Purpose: Maintain proper iframe sizing - - Triggers: - - On initial load - - When content size changes - - After window load completes + - Purpose: Keeps iframe size matched to content + - Triggers: When content size changes or page finishes loading + - Note: Parent adjusts iframe dimensions to prevent scrollbars -5. **linkReady Event** +5. **__windowLoadComplete Event** - Fired by: Iframe - - Indicates: iframe is fully ready for use - - Requirements: - - parentInformedAboutContentHeight must be true - - For booker pages: booker must be in ready state - - Actions: - - Parent removes loader - - Parent makes iframe visible + - Indicates: Page has fully loaded + - Purpose: Signals that dimension calculations are reliable -6. **parentKnowsIframeReady Event** +6. **linkReady Event** + - Fired by: Iframe + - Indicates: iframe content is fully ready for user interaction + - Requirements: Content height is known, and for booker pages, slots are loaded (if skeleton loader is used) + - Actions: Parent removes loader and makes iframe visible + +7. **parentKnowsIframeReady Event** - Fired by: Parent - - Indicates: Parent acknowledges iframe readiness - - Actions: - - Makes body visible - - For prerendering: marks prerenderState as "completed" + - Indicates: Parent acknowledges that iframe is ready + - Actions: Makes body content visible + - Note: During prerendering, this triggers linkPrerendered event instead + +8. **__connectInitiated Event** + - Fired by: Iframe + - Indicates: Prerendered embed is being connected with new configuration + - Triggers: When connect() is called to activate a prerendered embed + +9. **__connectCompleted Event** + - Fired by: Iframe + - Indicates: Connect flow has finished updating the embed + - Triggers: After URL params are updated and slots are ready (if needed) + +10. **linkPrerendered Event** + - Fired by: Iframe + - Indicates: Prerendered embed is ready in the background + - Note: Embed stays hidden until user opens it via connect() + +11. **bookerViewed Event** + - Fired by: Iframe + - Indicates: Booker has been viewed for the first time in current page view + - Triggers: On first linkReady event (viewId === 1) + - Note: Not fired during prerendering. Includes event information and slots loading status. + +12. **bookerReopened Event** + - Fired by: Iframe + - Indicates: Booker has been reopened after modal was closed + - Triggers: On subsequent linkReady events (viewId > 1) when modal is reopened without reload + - Note: Distinguishes between first view (bookerViewed) and reopen (bookerReopened). Uses viewId to determine if it's a reopen. + +13. **bookerReloaded Event** + - Fired by: Iframe + - Indicates: Booker has been reloaded (full page reload within modal) + - Triggers: On linkReady after fullReload action is taken (when reloadInitiated flag is set) + - Note: Distinguishes between first view (bookerViewed), reopen (bookerReopened), and reload (bookerReloaded). Fires only once per reload. + +14. **bookerReady Event** + - Fired by: Iframe + - Indicates: Booker view is loaded and slots are fully ready for user interaction + - Triggers: When booker view is loaded and slots are successfully loaded + - Note: Only fires for booker pages (not booking success view or other non-booker pages). This is different from linkReady which fires for any embed page. The bookerReady event signals that users can now select a slot. ## Prerendering Flow -The prerendering flow follows a special path: +Prerendering loads the booking page in the background before the user needs it: -1. Initial State: - - prerenderState: null +1. **Prerender Phase**: + - Embed is loaded with `prerender=true` parameter + - Only essential events are allowed (communication and sizing) + - Embed stays hidden from the user + - No tracking events are fired -2. During Prerender: - - prerenderState: "inProgress" - - Limited events allowed (only __iframeReady, __dimensionChanged) - - iframe and body remain hidden - -3. After Connect: - - prerenderState: "completed" - - Full event flow enabled - - Visibility states updated +2. **Connect Phase** (when user opens the modal): + - Parent calls `connect()` with user's configuration + - URL parameters are updated to match user's input + - Slots may be refreshed if needed + - Embed becomes visible and ready for interaction + - Full event tracking is enabled ## Command Queue System -The embed system implements a command queue to handle instructions before the iframe is ready: +The embed system queues commands sent before the iframe is ready. Once the iframe is ready, all queued commands are processed in order, and new commands execute immediately. -1. Commands are queued if iframe isn't ready: - ```typescript - if (!this.iframeReady) { - this.iframeDoQueue.push(doInIframeArg); - return; - } - ``` +### How Command Queuing Works -2. Queue is processed after __iframeReady event: - - All queued commands are executed in order - - New commands are executed immediately +``` +┌──────────────────────────────────────────────────────────────┐ +│ doInIframe(cmd) │ +└─────────────────────────┬────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ iframeReady? │ + └─────────┬───────────┘ + │ + ┌──────────────┼──────────────┐ + │ No │ │ Yes + ▼ │ ▼ + ┌───────────────┐ │ ┌───────────────┐ + │ Push to queue │ │ │ postMessage │ + │ (iframeDoQueue)│ │ │ to iframe │ + └───────────────┘ │ └───────────────┘ + │ + │ On __iframeReady event: + ▼ + ┌─────────────────────┐ + │ Flush queue: │ + │ forEach → postMessage│ + │ Clear queue │ + └─────────────────────┘ +``` + +### Available Commands (Parent → Iframe) + +| Method | Purpose | Example | +|--------|---------|---------| +| `ui` | Apply styles, theme, branding | `{ method: "ui", arg: { theme: "dark" } }` | +| `parentKnowsIframeReady` | Acknowledge handshake | `{ method: "parentKnowsIframeReady" }` | +| `connect` | Activate prerendered embed | `{ method: "connect", arg: { config, params } }` | + +## Popup Window Analogy + +Think of the embed like `window.open("url", "cal-booker")` with a hypothetical enhancement - when you close the popup, it stays in the background ready to spring back: + +- **`bookerViewed`** = Opening a new popup window (first time, or after previous was destroyed due to staleness) +- **`bookerReopened`** = Clicking CTA that targets the same window name, bringing back the hidden popup +- **`bookerReloaded`** = Popup window navigating to a new URL (full page reload within the same popup) +- **Staleness/full reload** = When the popup was actually destroyed (not just hidden), so CTA opens a fresh one + +This mental model helps understand the lifecycle: +1. User clicks CTA → Embed modal opens → `bookerViewed` event (similar to opening a new popup) +2. User closes modal and clicks CTA again (short time) → Existing embed springs back → `bookerReopened` event (similar to focusing a hidden popup) +3. User clicks CTA after long time → Embed was destroyed due to staleness → Fresh load → `bookerViewed` event (similar to opening a new popup after the old one was closed) +4. Modal stays open but iframe content reloads (fullReload) → `bookerReloaded` event (similar to popup navigating to new URL) + +## Event Tracking System + +The embed system tracks user interactions and page views: + +- **Page View Tracking**: Distinguishes between first view and refocus events when users navigate within the embed +- **Booker View Events**: Fires when the booker is viewed for the first time or focused +- **Availability Events**: Tracks when slots/availability data is loaded or refreshed +- **Event Deduplication**: Prevents duplicate events for the same page view +- **Prerendering**: Tracking events are suppressed during prerendering phase ## Error Handling diff --git a/packages/embeds/embed-core/playground/lib/playground-init.ts b/packages/embeds/embed-core/playground/lib/playground-init.ts index 517c3bb697..1779760fe1 100644 --- a/packages/embeds/embed-core/playground/lib/playground-init.ts +++ b/packages/embeds/embed-core/playground/lib/playground-init.ts @@ -7,6 +7,7 @@ const emailQueryParam = url.searchParams.get("param.email"); window.params = { email: emailQueryParam, formId: url.searchParams.get("param.formId"), + disablePrerender: url.searchParams.get("param.disablePrerender") === "true", }; window.generateRandomHexColor = function generateRandomHexColor() { diff --git a/packages/embeds/embed-core/playground/lib/playground.ts b/packages/embeds/embed-core/playground/lib/playground.ts index 720dd1ed2f..6d9ce279de 100644 --- a/packages/embeds/embed-core/playground/lib/playground.ts +++ b/packages/embeds/embed-core/playground/lib/playground.ts @@ -7,7 +7,6 @@ Cal.config.forwardQueryParams = true; // eslint-disable-next-line @typescript-eslint/no-explicit-any const callback = function (e: any) { const detail = e.detail; - console.log("Event: ", e.type, detail); }; // @ts-expect-error window.calOrigin is set in index.html @@ -693,22 +692,22 @@ Cal("on", { callback: bookingSuccessfulV2Callback, }); -const availabilityLoadedCallback = (e: EmbedEvent<"availabilityLoaded">) => { +const bookerReadyCallback = (e: EmbedEvent<"bookerReady">) => { const data = e.detail.data; - console.log("availabilityLoaded", { + console.log("bookerReady", { eventId: data.eventId, eventSlug: data.eventSlug, }); Cal("off", { - action: "availabilityLoaded", - callback: availabilityLoadedCallback, + action: "bookerReady", + callback: bookerReadyCallback, }); }; Cal("on", { - action: "availabilityLoaded", - callback: availabilityLoadedCallback, + action: "bookerReady", + callback: bookerReadyCallback, }); if (only === "all" || only === "ns:skeletonDemo") { diff --git a/packages/embeds/embed-core/routing-playground.html b/packages/embeds/embed-core/routing-playground.html index 9b7de5b483..0968f96335 100644 --- a/packages/embeds/embed-core/routing-playground.html +++ b/packages/embeds/embed-core/routing-playground.html @@ -97,35 +97,10 @@
-
- Routing Form Without Prerender Demo -
- - - -
- - -

- Routing Form - Prerender Headless Router itself queuing the form response - NOTE: Pass query param param.formId=FORM_UID_HERE to test with a particular routing form + Routing Form - Prerender Headless Router itself queuing the form response + NOTE: Pass query param param.formId=FORM_UID_HERE to test with a particular routing form. Easiest is to run seed-insights.ts script to create a seeded form and then use the form id here.

1. As page is loading, we prerender the headless router for the email passed as param.email

2. Whenever email changes and onblur happens, the prerender is triggered for the new email and previous prerendered modal is removed

3. The prerender of the headless router queues the form response and doesn't really record it. When the actual booking is made, the form response is recorded from the queue

@@ -133,6 +108,7 @@

5. If the CTA click happens after iframeForceReloadThresholdMs has passed, then fresh headless router request is sent which could be really slow. It is important to do force reload after a certain time because the Routing Form itself could have changed in the meantime or Salesforce ownership might be available or some other change might have occured in Cal.com's side

6. slotsStaleTimeMs is set to 10 seconds(default is 1 minute) and iframeForceReloadThresholdMs is set to 30 seconds(default is 15 minutes) and they are considered from the time when the prerender/modal call was made

7. To avoid reaching the iframeForceReloadThresholdMs, user could prerender the router again and again judiciously

+

8. You can disable prerendering by passing the query param param.disablePrerender=true

-
- - - - - - - - - - -
-
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
@@ -206,7 +287,7 @@ pageType: "team.event.booking.slots", options: { slotsStaleTimeMs: 10 * 1000, - iframeForceReloadThresholdMs: 30 * 1000, + iframeForceReloadThresholdMs: 100 * 1000, }, }); } @@ -223,6 +304,10 @@ return; } + if (window.params.disablePrerender) { + return; + } + prerender({skills, location, email, formId: window.params.formId}); } document.getElementById('cal-booking-place-routingFormFullPrerender-select-skills').onchange = onSelectChange; diff --git a/packages/embeds/embed-core/src/__tests__/embed-iframe-methods.test.ts b/packages/embeds/embed-core/src/__tests__/embed-iframe-methods.test.ts new file mode 100644 index 0000000000..55f690074e --- /dev/null +++ b/packages/embeds/embed-core/src/__tests__/embed-iframe-methods.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { fakeCurrentDocumentUrl, nextTick } from "../embed-iframe/__tests__/test-utils"; + +describe("embed-iframe.methods", async () => { + let methods: typeof import("../embed-iframe").methods; + let embedStore: typeof import("../embed-iframe/lib/embedStore").embedStore; + let isLinkReadyMock: ReturnType | undefined; + let isBookerReadyMock: ReturnType | undefined; + let ensureQueryParamsInUrlMock: ReturnType | undefined; + let recordResponseIfQueuedMock: ReturnType | undefined; + beforeEach(async () => { + vi.useRealTimers(); + fakeCurrentDocumentUrl(); + vi.doMock("../embed-iframe/lib/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isLinkReady: isLinkReadyMock, + isBookerReady: isBookerReadyMock, + recordResponseIfQueued: recordResponseIfQueuedMock, + }; + }); + vi.doMock("../embed-iframe/lib/embedStore", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + embedStore: { + __MOCKED__: true, + ...actual.embedStore, + router: { + ensureQueryParamsInUrl: ensureQueryParamsInUrlMock, + }, + }, + }; + }); + isLinkReadyMock = vi.fn(); + isBookerReadyMock = vi.fn(); + ensureQueryParamsInUrlMock = vi.fn().mockImplementation(() => { + console.log("Fake ensureQueryParamsInUrl called"); + return { + stopEnsuringQueryParamsInUrl: vi.fn(), + }; + }); + recordResponseIfQueuedMock = vi.fn().mockResolvedValue(1); + ({ methods } = await import("../embed-iframe")); + ({ embedStore } = await import("../embed-iframe/lib/embedStore")); + }); + afterEach(() => { + vi.resetAllMocks(); + vi.resetModules(); + console.log('After each of first describe'); + }); + + describe("methods.connect", async () => { + it("should ensure that 'cal.embed.connectVersion' is incremented in query params", async () => { + embedStore.renderState = "completed"; + const currentConnectVersion = 1; + embedStore.connectVersion = currentConnectVersion; + fakeCurrentDocumentUrl({ params: { "cal.embed.connectVersion": "1" } }); + isLinkReadyMock?.mockReturnValue(true); + isBookerReadyMock?.mockReturnValue(true); + await methods.connect({ + config: {}, + params: {}, + }); + expect(ensureQueryParamsInUrlMock).toHaveBeenCalledWith({ + toBeThereParams: { + "cal.embed.connectVersion": (currentConnectVersion + 1).toString(), + }, + toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"], + }); + }); + + it("should ensure that 'cal.embed.connectVersion' is not incremented in query params when 'cal.embed.noSlotsFetchOnConnect' is true", async () => { + embedStore.renderState = "completed"; + const currentConnectVersion = 1; + embedStore.connectVersion = currentConnectVersion; + fakeCurrentDocumentUrl({ params: { "cal.embed.connectVersion": "1" } }); + isLinkReadyMock?.mockReturnValue(true); + isBookerReadyMock?.mockReturnValue(true); + await methods.connect({ + config: { + "cal.embed.noSlotsFetchOnConnect": "true", + }, + params: {}, + }); + expect(embedStore.router.ensureQueryParamsInUrl).toHaveBeenCalledWith({ + toBeThereParams: { + "cal.embed.connectVersion": currentConnectVersion.toString(), + }, + toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"], + }); + }); + + it("should set/update 'cal.routingFormResponseId' if the current iframe has 'cal.queuedFormResponse' in the query params", async () => { + embedStore.renderState = "completed"; + const currentConnectVersion = 1; + embedStore.connectVersion = currentConnectVersion; + fakeCurrentDocumentUrl({ + params: { + "cal.queuedFormResponse": "true", + "cal.routingFormResponseId": "1", + "cal.embed.connectVersion": currentConnectVersion.toString(), + }, + }); + const convertedRoutingFormResponseId = 101; + recordResponseIfQueuedMock?.mockResolvedValue(convertedRoutingFormResponseId); + isLinkReadyMock?.mockReturnValue(true); + isBookerReadyMock?.mockReturnValue(true); + await methods.connect({ + config: {}, + params: {}, + }); + + expect(ensureQueryParamsInUrlMock).toHaveBeenCalledWith({ + toBeThereParams: { + "cal.embed.connectVersion": (currentConnectVersion + 1).toString(), + "cal.routingFormResponseId": convertedRoutingFormResponseId.toString(), + }, + toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"], + }); + }); + }); + + describe("methods.parentKnowsIframeReady", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should set renderState to 'completed' on link ready", () => { + isLinkReadyMock?.mockReturnValue(true); + methods.parentKnowsIframeReady({}); + expect(embedStore.renderState).not.toBe("completed"); + nextTick(); + expect(embedStore.renderState).toBe("completed"); + }); + }); +}); \ No newline at end of file diff --git a/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts b/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts index ad382f3b3a..3e5a3b659a 100644 --- a/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts +++ b/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts @@ -2,362 +2,358 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { fakeCurrentDocumentUrl, nextTick } from "../embed-iframe/__tests__/test-utils"; -beforeEach(() => { - // Ensure that we have it globally so that unexpected errors like 'document is not defined' don't happen due to timer being fired when test is shutting down - vi.useFakeTimers(); -}); - -afterEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - vi.useRealTimers(); - vi.clearAllTimers(); -}); - -describe("embedStore.router.ensureQueryParamsInUrl", async () => { - let embedStore: typeof import("../embed-iframe/lib/embedStore").embedStore; - const originalHistory = window.history; - const originalURL = window.URL; +describe("embed-iframe", async () => { + let sdkActionManager: typeof import("../sdk-event").sdkActionManager; + let embedStore: typeof import("../embed-iframe/lib/embedStore").embedStore + let resetPageData: typeof import("../embed-iframe/lib/embedStore").resetPageData; beforeEach(async () => { - // Mock window.history and URL + ({ sdkActionManager } = await import("../sdk-event")); ({ embedStore } = await import("../embed-iframe/lib/embedStore")); - + ({ resetPageData } = await import("../embed-iframe/lib/embedStore")); + // Ensure that we have it globally so that unexpected errors like 'document is not defined' don't happen due to timer being fired when test is shutting down vi.useFakeTimers(); - // Mock requestAnimationFrame and cancelAnimationFrame - window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - console.log("mockRequestAnimationFrame called"); - const timeoutId = setTimeout(() => { - callback(performance.now()); - }, 100) as unknown as number; - return timeoutId; - }); - - // Mock history.replaceState - window.history.replaceState = (...args) => { - const url = args[2]; - if (!url) { - throw new Error("url is not provided"); - } - vi.spyOn(document, "URL", "get").mockReturnValue(url.toString()); - }; + // It is provided by App before embed-iframe loads. So, meet this requirement here. + window.getEmbedNamespace = vi.fn(() => "default"); + // Ensure document.URL is always defined for tests + fakeCurrentDocumentUrl(); }); afterEach(() => { + vi.clearAllMocks(); + vi.resetModules(); vi.useRealTimers(); - // Cleanup - window.history = originalHistory; - window.URL = originalURL; - vi.resetAllMocks(); - }); - - it("should add missing parameters to URL", async () => { - // Setup + vi.clearAllTimers(); + embedStore.viewId = null; + // Ensure document.URL is defined before calling resetPageData which uses log() fakeCurrentDocumentUrl(); - // Execute - const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ - toBeThereParams: { - theme: "dark", - layout: "month", - }, - toRemoveParams: [], - }); - - // Assert - expect(document.URL).toContain("theme=dark"); - expect(document.URL).toContain("layout=month"); - - // Cleanup - stopEnsuringQueryParamsInUrl(); + resetPageData(); }); - it("should ensure that no existing value of param exists as is", () => { - fakeCurrentDocumentUrl({ params: { guest: "initial" } }); - const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ - toBeThereParams: { guest: ["ax.com", "bx.com"] }, - toRemoveParams: [], - }); - expect(document.URL).toContain("guest=ax.com"); - expect(document.URL).toContain("guest=bx.com"); - expect(document.URL).not.toContain("guest=initial"); - - stopEnsuringQueryParamsInUrl(); - }); - - it("should remove specified parameters from URL", async () => { - // Setup - fakeCurrentDocumentUrl({ params: { remove: "true", keep: "yes" } }); - - // Execute - const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ - toBeThereParams: {}, - toRemoveParams: ["remove"], - }); - - // Assert - expect(document.URL).not.toContain("remove=true"); - expect(document.URL).toContain("keep=yes"); - - // Cleanup - stopEnsuringQueryParamsInUrl(); - }); - - it("should handle empty parameters", async () => { - fakeCurrentDocumentUrl(); - const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ - toBeThereParams: {}, - toRemoveParams: [], - }); - nextTick(); - stopEnsuringQueryParamsInUrl(); - }); - - it("should restore parameters if they are changed before cleanup, otherwise not", async () => { - fakeCurrentDocumentUrl(); - const initialTheme = "dark"; - - const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ - toBeThereParams: { theme: initialTheme }, - toRemoveParams: [], - }); - - // First interval - should add the parameter - expect(document.URL).toContain(`theme=${initialTheme}`); - - const changedThemeByReact = "light"; - // Simulate React code changing the URL - fakeCurrentDocumentUrl({ params: { theme: changedThemeByReact } }); - - // Next interval - should restore our parameter - nextTick(); - expect(document.URL).toContain(`theme=${initialTheme}`); - - // After cleanup, changes should not be restored - stopEnsuringQueryParamsInUrl(); - fakeCurrentDocumentUrl({ params: { theme: "light" } }); - nextTick(); - expect(document.URL).toContain("theme=light"); - expect(document.URL).not.toContain(`theme=${initialTheme}`); - }); -}); - -describe("getEmbedBookerState", async () => { - let getEmbedBookerState: typeof import("../embed-iframe").getEmbedBookerState; - beforeEach(async () => { - fakeCurrentDocumentUrl(); - ({ getEmbedBookerState } = await import("../embed-iframe")); - }); - - it("should return 'initializing' when bookerState is 'loading'", () => { - const result = getEmbedBookerState({ - bookerState: "loading", - slotsQuery: { - isLoading: false, - isPending: false, - isSuccess: false, - isError: false, - }, - }); - expect(result).toBe("initializing"); - }); - - it("should return 'slotsLoading' when slotsQuery.isLoading is true", () => { - const result = getEmbedBookerState({ - bookerState: "selecting_date", - slotsQuery: { - isLoading: true, - isPending: false, - isSuccess: false, - isError: false, - }, - }); - expect(result).toBe("slotsLoading"); - }); - - it("should return 'slotsDone' when slotsQuery.isPending is true but not loading", () => { - const result = getEmbedBookerState({ - bookerState: "selecting_date", - slotsQuery: { - isLoading: false, - isPending: true, - isSuccess: false, - isError: false, - }, - }); - expect(result).toBe("slotsDone"); - }); - - it("should return 'slotsDone' when slotsQuery.isSuccess is true", () => { - const result = getEmbedBookerState({ - bookerState: "selecting_date", - slotsQuery: { - isLoading: false, - isPending: false, - isSuccess: true, - isError: false, - }, - }); - expect(result).toBe("slotsDone"); - }); - - it("should return 'slotsLoadingError' when slotsQuery.isError is true", () => { - const result = getEmbedBookerState({ - bookerState: "selecting_date", - slotsQuery: { - isLoading: false, - isPending: false, - isSuccess: false, - isError: true, - }, - }); - expect(result).toBe("slotsLoadingError"); - }); - - it("should return 'slotsPending' when no other conditions are met", () => { - const result = getEmbedBookerState({ - bookerState: "selecting_date", - slotsQuery: { - isLoading: false, - isPending: false, - isSuccess: false, - isError: false, - }, - }); - expect(result).toBe("slotsPending"); - }); -}); - -describe("methods", async () => { - let methods: typeof import("../embed-iframe").methods; - let embedStore: typeof import("../embed-iframe/lib/embedStore").embedStore; - let isLinkReadyMock: ReturnType | undefined; - let isBookerReadyMock: ReturnType | undefined; - let ensureQueryParamsInUrlMock: ReturnType | undefined; - let recordResponseIfQueuedMock: ReturnType | undefined; - beforeEach(async () => { - vi.useRealTimers(); - fakeCurrentDocumentUrl(); - vi.doMock("../embed-iframe/lib/utils", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isLinkReady: isLinkReadyMock, - isBookerReady: isBookerReadyMock, - recordResponseIfQueued: recordResponseIfQueuedMock, - }; - }); - vi.doMock("../embed-iframe/lib/embedStore", async (importOriginal) => { - const actual = await importOriginal(); - - return { - ...actual, - embedStore: { - ...actual.embedStore, - router: { - ensureQueryParamsInUrl: ensureQueryParamsInUrlMock, - }, - }, - }; - }); - isLinkReadyMock = vi.fn(); - isBookerReadyMock = vi.fn(); - ensureQueryParamsInUrlMock = vi.fn().mockImplementation(() => { - console.log("Fake ensureQueryParamsInUrl called"); - return { - stopEnsuringQueryParamsInUrl: vi.fn(), - }; - }); - recordResponseIfQueuedMock = vi.fn().mockResolvedValue(1); - ({ methods } = await import("../embed-iframe")); - ({ embedStore } = await import("../embed-iframe/lib/embedStore")); - }); - - describe("methods.connect", async () => { - it("should ensure that 'cal.embed.connectVersion' is incremented in query params", async () => { - embedStore.renderState = "completed"; - const currentConnectVersion = 1; - embedStore.connectVersion = currentConnectVersion; - fakeCurrentDocumentUrl({ params: { "cal.embed.connectVersion": "1" } }); - isLinkReadyMock?.mockReturnValue(true); - isBookerReadyMock?.mockReturnValue(true); - await methods.connect({ - config: {}, - params: {}, - }); - expect(ensureQueryParamsInUrlMock).toHaveBeenCalledWith({ - toBeThereParams: { - "cal.embed.connectVersion": (currentConnectVersion + 1).toString(), - }, - toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"], - }); - }); - - it("should ensure that 'cal.embed.connectVersion' is not incremented in query params when 'cal.embed.noSlotsFetchOnConnect' is true", async () => { - embedStore.renderState = "completed"; - const currentConnectVersion = 1; - embedStore.connectVersion = currentConnectVersion; - fakeCurrentDocumentUrl({ params: { "cal.embed.connectVersion": "1" } }); - isLinkReadyMock?.mockReturnValue(true); - isBookerReadyMock?.mockReturnValue(true); - await methods.connect({ - config: { - "cal.embed.noSlotsFetchOnConnect": "true", - }, - params: {}, - }); - expect(embedStore.router.ensureQueryParamsInUrl).toHaveBeenCalledWith({ - toBeThereParams: { - "cal.embed.connectVersion": currentConnectVersion.toString(), - }, - toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"], - }); - }); - - it("should set/update 'cal.routingFormResponseId' if the current iframe has 'cal.queuedFormResponse' in the query params", async () => { - embedStore.renderState = "completed"; - const currentConnectVersion = 1; - embedStore.connectVersion = currentConnectVersion; - fakeCurrentDocumentUrl({ - params: { - "cal.queuedFormResponse": "true", - "cal.routingFormResponseId": "1", - "cal.embed.connectVersion": currentConnectVersion.toString(), - }, - }); - const convertedRoutingFormResponseId = 101; - recordResponseIfQueuedMock?.mockResolvedValue(convertedRoutingFormResponseId); - isLinkReadyMock?.mockReturnValue(true); - isBookerReadyMock?.mockReturnValue(true); - await methods.connect({ - config: {}, - params: {}, - }); - - expect(ensureQueryParamsInUrlMock).toHaveBeenCalledWith({ - toBeThereParams: { - "cal.embed.connectVersion": (currentConnectVersion + 1).toString(), - "cal.routingFormResponseId": convertedRoutingFormResponseId.toString(), - }, - toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"], - }); - }); - }); - - describe("methods.parentKnowsIframeReady", () => { - beforeEach(() => { + describe("embedStore.router.ensureQueryParamsInUrl", async () => { + const originalHistory = window.history; + const originalURL = window.URL; + beforeEach(async () => { vi.useFakeTimers(); + window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + const timeoutId = setTimeout(() => { + callback(performance.now()); + }, 100) as unknown as number; + return timeoutId; + }); + + window.history.replaceState = (...args) => { + const url = args[2]; + if (!url) { + throw new Error("url is not provided"); + } + vi.spyOn(document, "URL", "get").mockReturnValue(url.toString()); + }; }); afterEach(() => { vi.useRealTimers(); + // Cleanup + window.history = originalHistory; + window.URL = originalURL; + vi.resetAllMocks(); }); - it("should set renderState to 'completed' on link ready", () => { - isLinkReadyMock?.mockReturnValue(true); - methods.parentKnowsIframeReady({}); - expect(embedStore.renderState).not.toBe("completed"); + it("should add missing parameters to URL", async () => { + fakeCurrentDocumentUrl(); + const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ + toBeThereParams: { + theme: "dark", + layout: "month", + }, + toRemoveParams: [], + }); + + expect(document.URL).toContain("theme=dark"); + expect(document.URL).toContain("layout=month"); + + stopEnsuringQueryParamsInUrl(); + }); + + it("should ensure that no existing value of param exists as is", () => { + fakeCurrentDocumentUrl({ params: { guest: "initial" } }); + const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ + toBeThereParams: { guest: ["ax.com", "bx.com"] }, + toRemoveParams: [], + }); + expect(document.URL).toContain("guest=ax.com"); + expect(document.URL).toContain("guest=bx.com"); + expect(document.URL).not.toContain("guest=initial"); + + stopEnsuringQueryParamsInUrl(); + }); + + it("should remove specified parameters from URL", async () => { + // Setup + fakeCurrentDocumentUrl({ params: { remove: "true", keep: "yes" } }); + + // Execute + const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ + toBeThereParams: {}, + toRemoveParams: ["remove"], + }); + + // Assert + expect(document.URL).not.toContain("remove=true"); + expect(document.URL).toContain("keep=yes"); + + // Cleanup + stopEnsuringQueryParamsInUrl(); + }); + + it("should handle empty parameters", async () => { + fakeCurrentDocumentUrl(); + const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ + toBeThereParams: {}, + toRemoveParams: [], + }); nextTick(); - expect(embedStore.renderState).toBe("completed"); + stopEnsuringQueryParamsInUrl(); + }); + + it("should restore parameters if they are changed before cleanup, otherwise not", async () => { + fakeCurrentDocumentUrl(); + const initialTheme = "dark"; + + const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ + toBeThereParams: { theme: initialTheme }, + toRemoveParams: [], + }); + + // First interval - should add the parameter + expect(document.URL).toContain(`theme=${initialTheme}`); + + const changedThemeByReact = "light"; + // Simulate React code changing the URL + fakeCurrentDocumentUrl({ params: { theme: changedThemeByReact } }); + + // Next interval - should restore our parameter + nextTick(); + expect(document.URL).toContain(`theme=${initialTheme}`); + + // After cleanup, changes should not be restored + stopEnsuringQueryParamsInUrl(); + fakeCurrentDocumentUrl({ params: { theme: "light" } }); + nextTick(); + expect(document.URL).toContain("theme=light"); + expect(document.URL).not.toContain(`theme=${initialTheme}`); + }); + }); + + describe("getEmbedBookerState", async () => { + let getEmbedBookerState: typeof import("../embed-iframe").getEmbedBookerState; + beforeEach(async () => { + fakeCurrentDocumentUrl(); + ({ getEmbedBookerState } = await import("../embed-iframe")); + }); + + it("should return 'initializing' when bookerState is 'loading'", () => { + const result = getEmbedBookerState({ + bookerState: "loading", + slotsQuery: { + isLoading: false, + isPending: false, + isSuccess: false, + isError: false, + }, + }); + expect(result).toBe("initializing"); + }); + + it("should return 'slotsLoading' when slotsQuery.isLoading is true", () => { + const result = getEmbedBookerState({ + bookerState: "selecting_date", + slotsQuery: { + isLoading: true, + isPending: false, + isSuccess: false, + isError: false, + }, + }); + expect(result).toBe("slotsLoading"); + }); + + it("should return 'slotsDone' when slotsQuery.isPending is true but not loading", () => { + const result = getEmbedBookerState({ + bookerState: "selecting_date", + slotsQuery: { + isLoading: false, + isPending: true, + isSuccess: false, + isError: false, + }, + }); + expect(result).toBe("slotsDone"); + }); + + it("should return 'slotsDone' when slotsQuery.isSuccess is true", () => { + const result = getEmbedBookerState({ + bookerState: "selecting_date", + slotsQuery: { + isLoading: false, + isPending: false, + isSuccess: true, + isError: false, + }, + }); + expect(result).toBe("slotsDone"); + }); + + it("should return 'slotsLoadingError' when slotsQuery.isError is true", () => { + const result = getEmbedBookerState({ + bookerState: "selecting_date", + slotsQuery: { + isLoading: false, + isPending: false, + isSuccess: false, + isError: true, + }, + }); + expect(result).toBe("slotsLoadingError"); + }); + + it("should return 'slotsPending' when no other conditions are met", () => { + const result = getEmbedBookerState({ + bookerState: "selecting_date", + slotsQuery: { + isLoading: false, + isPending: false, + isSuccess: false, + isError: false, + }, + }); + expect(result).toBe("slotsPending"); + }); + }); + + describe("linkReady event handler", async () => { + const createTestEmbedState = (overrides?: { + viewId?: number | null; + bookerViewedHasFired?: boolean; + bookerReopenedHasFired?: boolean; + bookerReloadedHasFired?: boolean; + bookerReadyHasFired?: boolean; + }) => { + embedStore.viewId = overrides?.viewId ?? null; + embedStore.pageData.eventsState.bookerViewed.hasFired = + overrides?.bookerViewedHasFired ?? false; + embedStore.pageData.eventsState.bookerReopened.hasFired = + overrides?.bookerReopenedHasFired ?? false; + embedStore.pageData.eventsState.bookerReloaded.hasFired = + overrides?.bookerReloadedHasFired ?? false; + embedStore.pageData.eventsState.bookerReady.hasFired = overrides?.bookerReadyHasFired ?? false; + }; + let embedStore: typeof import("../embed-iframe/lib/embedStore").embedStore; + + beforeEach(async () => { + ({ embedStore } = await import("../embed-iframe/lib/embedStore")); + vi.useRealTimers(); + fakeCurrentDocumentUrl(); + const mockTop = {}; + Object.defineProperty(window, "top", { + value: mockTop, + writable: true, + configurable: true, + }); + Object.defineProperty(window, "isEmbed", { + value: () => true, + writable: true, + configurable: true, + }); + console.log('Importing embed-iframe'); + await import("../embed-iframe"); + embedStore.viewId = null; + resetPageData(); + }); + + it("should set viewId to 1 when linkReady fires for the first time", () => { + embedStore.viewId = null; + resetPageData(); + const initialViewId = embedStore.viewId; + + sdkActionManager?.fire("linkReady", {}); + + expect(initialViewId).toBeNull(); + expect(embedStore.viewId).toBe(1); + }); + + it("should increment viewId when linkReady fires on subsequent views", () => { + embedStore.viewId = 1; + resetPageData(); + const initialViewId = embedStore.viewId; + expect(initialViewId).toBe(1); + + sdkActionManager?.fire("linkReady", {}); + + expect(embedStore.viewId).toBe(initialViewId + 1); + }); + + it("should reset hasFired flags when linkReady fires", async () => { + const { embedStore } = await import("../embed-iframe/lib/embedStore"); + + embedStore.viewId = 1; + const oldPageDataReference = embedStore.pageData; + embedStore.pageData.eventsState.bookerViewed.hasFired = true; + embedStore.pageData.eventsState.bookerReopened.hasFired = true; + embedStore.pageData.eventsState.bookerReloaded.hasFired = true; + embedStore.pageData.eventsState.bookerReady.hasFired = true; + + const initialViewId = embedStore.viewId; + + sdkActionManager?.fire("linkReady", {}); + + expect(embedStore.viewId).toBe(initialViewId + 1); + // Verify resetPageData was called (pageData should be a new object reference) + expect(embedStore.pageData).not.toEqual(oldPageDataReference); + // Verify flags were reset + expect(embedStore.pageData.eventsState.bookerViewed.hasFired).toBe(false); + expect(embedStore.pageData.eventsState.bookerReopened.hasFired).toBe(false); + expect(embedStore.pageData.eventsState.bookerReloaded.hasFired).toBe(false); + expect(embedStore.pageData.eventsState.bookerReady.hasFired).toBe(false); + }); + + it("should not update viewId or reset flags when linkReady fires during prerendering", () => { + fakeCurrentDocumentUrl({ params: { prerender: "true" } }); + createTestEmbedState({ + viewId: null, + bookerViewedHasFired: true, + bookerReopenedHasFired: true, + bookerReloadedHasFired: true, + bookerReadyHasFired: true, + }); + + sdkActionManager?.fire("linkReady", {}); + + expect(embedStore.viewId).toBeNull(); + expect(embedStore.pageData.eventsState.bookerViewed.hasFired).toBe(true); + expect(embedStore.pageData.eventsState.bookerReopened.hasFired).toBe(true); + expect(embedStore.pageData.eventsState.bookerReloaded.hasFired).toBe(true); + expect(embedStore.pageData.eventsState.bookerReady.hasFired).toBe(true); + }); + + it("should increment viewId and reset flags on multiple linkReady events", () => { + embedStore.viewId = null; + resetPageData(); + + sdkActionManager?.fire("linkReady", {}); + expect(embedStore.viewId).toBe(1); + embedStore.pageData.eventsState.bookerViewed.hasFired = true; + embedStore.pageData.eventsState.bookerReady.hasFired = true; + + sdkActionManager?.fire("linkReady", {}); + expect(embedStore.viewId).toBe(2); + expect(embedStore.pageData.eventsState.bookerViewed.hasFired).toBe(false); + expect(embedStore.pageData.eventsState.bookerReopened.hasFired).toBe(false); + expect(embedStore.pageData.eventsState.bookerReloaded.hasFired).toBe(false); + expect(embedStore.pageData.eventsState.bookerReady.hasFired).toBe(false); + + sdkActionManager?.fire("linkReady", {}); + expect(embedStore.viewId).toBe(3); }); }); }); diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index b27e736acc..dce7161acc 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -3,13 +3,16 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { mapOldToNewCssVars } from "./ui/cssVarsMap"; import type { Message } from "./embed"; -import { embedStore, EMBED_IFRAME_STATE } from "./embed-iframe/lib/embedStore"; +import { embedStore, EMBED_IFRAME_STATE, resetPageData, setReloadInitiated, incrementView } from "./embed-iframe/lib/embedStore"; import { runAsap, isBookerReady, isLinkReady, recordResponseIfQueued, keepParentInformedAboutDimensionChanges, + isPrerendering, + isBrowser, + log, } from "./embed-iframe/lib/utils"; import { sdkActionManager } from "./sdk-event"; import type { @@ -24,6 +27,7 @@ import type { setNonStylesConfig, } from "./types"; import { useCompatSearchParams } from "./useCompatSearchParams"; +export { useBookerEmbedEvents } from "./embed-iframe/react-hooks"; // We don't import it from Booker/types because the types from this module are published to npm and we can't import packages that aren't published type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking"; @@ -34,10 +38,11 @@ const eventsAllowedInPrerendering = [ "__iframeReady", // so that iframe height is adjusted according to the content, and iframe is ready to be shown when needed "__dimensionChanged", - // When this event is fired, the iframe is still in prerender state but is going to be moved out of prerender state "__connectInitiated", + "linkPrerendered", + // For other events, we should consider introducing prerender specific events and not reuse existing events ]; @@ -54,7 +59,6 @@ declare global { } let isSafariBrowser = false; -const isBrowser = typeof window !== "undefined"; if (isBrowser) { window.CalEmbed = window?.CalEmbed || {}; @@ -66,24 +70,6 @@ if (isBrowser) { } } -function log(...args: unknown[]) { - if (isBrowser) { - const namespace = getNamespace(); - - const searchParams = new URL(document.URL).searchParams; - const logQueue = (window.CalEmbed.__logQueue = window.CalEmbed.__logQueue || []); - args.push({ - ns: namespace, - url: document.URL, - }); - args.unshift("CAL:"); - logQueue.push(args); - if (searchParams.get("debug")) { - console.log("Child:", ...args); - } - } -} - const setEmbedStyles = (stylesConfig: EmbedStyles) => { embedStore.styles = stylesConfig; for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) { @@ -431,7 +417,11 @@ export const methods = { makeBodyVisible(); log("renderState is 'completed'"); embedStore.renderState = "completed"; - sdkActionManager?.fire("linkReady", {}); + if (isPrerendering()) { + sdkActionManager?.fire("linkPrerendered", {}); + } else { + sdkActionManager?.fire("linkReady", {}); + } }); }, /** @@ -496,6 +486,10 @@ export const methods = { toRemoveParams, }); }, + __reloadInitiated: function __reloadInitiated(_unused: unknown) { + log("Method: __reloadInitiated called"); + setReloadInitiated(true); + }, }; export type InterfaceWithParent = { @@ -568,6 +562,15 @@ function main() { } }); + sdkActionManager?.on("linkReady", () => { + // Even though linkReady isn't fired in prerendering phase, this is a safe guard for future + if (isPrerendering()) { + return; + } + resetPageData(); + incrementView(); + }); + sdkActionManager?.on("*", (e) => { if (isPrerendering() && !eventsAllowedInPrerendering.includes(e.detail.type)) { return; @@ -676,10 +679,6 @@ async function connectPreloadedEmbed({ }; } -const isPrerendering = () => { - return new URL(document.URL).searchParams.get("prerender") === "true"; -}; - export function getEmbedBookerState({ bookerState, slotsQuery, diff --git a/packages/embeds/embed-core/src/embed-iframe/__tests__/react-hooks.test.ts b/packages/embeds/embed-core/src/embed-iframe/__tests__/react-hooks.test.ts new file mode 100644 index 0000000000..8cb0b79d8c --- /dev/null +++ b/packages/embeds/embed-core/src/embed-iframe/__tests__/react-hooks.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; + +import { sdkActionManager } from "../../sdk-event"; +import { embedStore, resetPageData } from "../lib/embedStore"; +import { useBookerEmbedEvents } from "../react-hooks"; +import { fakeCurrentDocumentUrl } from "./test-utils"; + +const createTestSchedule = (overrides?: { + isSuccess?: boolean; + dataUpdatedAt?: number; +}) => ({ + isSuccess: overrides?.isSuccess ?? true, + dataUpdatedAt: overrides?.dataUpdatedAt ?? Date.now(), +}); + +const createTestEventConfig = (overrides?: { + eventId?: number | undefined; + eventSlug?: string | undefined; +}) => ({ + eventId: overrides && "eventId" in overrides ? overrides.eventId : 123, + eventSlug: overrides && "eventSlug" in overrides ? overrides.eventSlug : "test-event", +}); + +const createTestEmbedState = (overrides?: { + viewId?: number | null; + bookerViewedHasFired?: boolean; + bookerReopenedHasFired?: boolean; + bookerReloadedHasFired?: boolean; + bookerReadyHasFired?: boolean; + reloadInitiated?: boolean; +}) => { + embedStore.viewId = overrides?.viewId ?? null; + embedStore.pageData.eventsState.bookerViewed.hasFired = + overrides?.bookerViewedHasFired ?? false; + embedStore.pageData.eventsState.bookerReopened.hasFired = + overrides?.bookerReopenedHasFired ?? false; + embedStore.pageData.eventsState.bookerReloaded.hasFired = + overrides?.bookerReloadedHasFired ?? false; + embedStore.pageData.eventsState.bookerReady.hasFired = overrides?.bookerReadyHasFired ?? false; + embedStore.pageData.reloadInitiated = overrides?.reloadInitiated ?? false; +}; + +const expectEventFired = ( + firedEvents: Array<{ type: string; data: unknown }>, + eventType: string, + expectedData?: unknown +) => { + const event = firedEvents.find((e) => e.type === eventType); + expect(event).toBeDefined(); + if (expectedData) { + expect(event).toMatchObject({ type: eventType, data: expectedData }); + } + return event; +}; + +const expectEventNotFired = ( + firedEvents: Array<{ type: string; data: unknown }>, + eventType: string +) => { + const event = firedEvents.find((e) => e.type === eventType); + expect(event).toBeUndefined(); +}; + +const expectEventCount = ( + firedEvents: Array<{ type: string; data: unknown }>, + eventType: string, + expectedCount: number +) => { + const events = firedEvents.filter((e) => e.type === eventType); + expect(events).toHaveLength(expectedCount); +}; + +describe("useBookerEmbedEvents", () => { + let firedEvents: Array<{ type: string; data: unknown }> = []; + + beforeEach(() => { + createTestEmbedState(); + firedEvents = []; + + if (!sdkActionManager) { + throw new Error("sdkActionManager is not defined"); + } + vi.spyOn(sdkActionManager, "fire").mockImplementation((type, data) => { + firedEvents.push({ type, data }); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("bookerViewed event", () => { + it("should fire bookerViewed when first view opens with slots loaded", () => { + fakeCurrentDocumentUrl(); + createTestEmbedState({ viewId: 1 }); + + const eventConfig = createTestEventConfig(); + const schedule = createTestSchedule({ isSuccess: true }); + + renderHook(() => useBookerEmbedEvents({ ...eventConfig, schedule })); + + expect(firedEvents).toHaveLength(2); + expectEventFired(firedEvents, "bookerViewed", { + eventId: eventConfig.eventId, + eventSlug: eventConfig.eventSlug, + slotsLoaded: true, + }); + }); + + it("should fire bookerViewed when first view opens without slots loaded", () => { + fakeCurrentDocumentUrl(); + createTestEmbedState({ viewId: 1 }); + + const eventConfig = createTestEventConfig(); + const schedule = createTestSchedule({ isSuccess: false }); + + renderHook(() => useBookerEmbedEvents({ ...eventConfig, schedule })); + + expect(firedEvents).toHaveLength(1); + expectEventFired(firedEvents, "bookerViewed", { + eventId: null, + eventSlug: null, + slotsLoaded: false, + }); + }); + + it("should fire bookerReopened when embed is reopened after being closed", () => { + fakeCurrentDocumentUrl(); + createTestEmbedState({ viewId: 2 }); + + const eventConfig = createTestEventConfig(); + const schedule = createTestSchedule({ isSuccess: true }); + + renderHook(() => useBookerEmbedEvents({ ...eventConfig, schedule })); + + expect(firedEvents).toHaveLength(2); + expectEventFired(firedEvents, "bookerReopened", { + eventId: eventConfig.eventId, + eventSlug: eventConfig.eventSlug, + slotsLoaded: true, + }); + }); + + it("should fire bookerReloaded when reload is initiated", () => { + fakeCurrentDocumentUrl(); + createTestEmbedState({ viewId: 1, reloadInitiated: true }); + + const eventConfig = createTestEventConfig(); + const schedule = createTestSchedule({ isSuccess: true }); + + renderHook(() => useBookerEmbedEvents({ ...eventConfig, schedule })); + + expect(firedEvents).toHaveLength(2); + expectEventFired(firedEvents, "bookerReloaded", { + eventId: eventConfig.eventId, + eventSlug: eventConfig.eventSlug, + slotsLoaded: true, + }); + }); + + it("should not fire events multiple times when component rerenders", () => { + fakeCurrentDocumentUrl(); + createTestEmbedState({ viewId: 1 }); + + const eventConfig = createTestEventConfig(); + const schedule = createTestSchedule({ isSuccess: true }); + + const { rerender } = renderHook(() => + useBookerEmbedEvents({ ...eventConfig, schedule }) + ); + + expect(firedEvents).toHaveLength(2); + + rerender(); + expect(firedEvents).toHaveLength(2); + }); + }); + + describe("bookerReady event", () => { + it("should fire bookerReady once per view when slots are loaded", () => { + fakeCurrentDocumentUrl(); + createTestEmbedState({ viewId: 1 }); + + const eventConfig = createTestEventConfig(); + const schedule = createTestSchedule({ isSuccess: true }); + + const { rerender } = renderHook(() => + useBookerEmbedEvents({ ...eventConfig, schedule }) + ); + + expectEventFired(firedEvents, "bookerReady", { + eventId: eventConfig.eventId, + eventSlug: eventConfig.eventSlug, + }); + + rerender(); + expectEventCount(firedEvents, "bookerReady", 1); + }); + + it("should not fire bookerReady when slots are not loaded", () => { + fakeCurrentDocumentUrl(); + createTestEmbedState({ viewId: 1 }); + + const eventConfig = createTestEventConfig(); + const schedule = createTestSchedule({ isSuccess: false }); + + renderHook(() => useBookerEmbedEvents({ ...eventConfig, schedule })); + + expectEventNotFired(firedEvents, "bookerReady"); + }); + + it("should not fire bookerReady when eventId is missing", () => { + fakeCurrentDocumentUrl(); + createTestEmbedState({ viewId: 1 }); + + const eventConfig = createTestEventConfig({ eventId: undefined }); + const schedule = createTestSchedule({ isSuccess: true }); + + renderHook(() => useBookerEmbedEvents({ ...eventConfig, schedule })); + + expectEventNotFired(firedEvents, "bookerReady"); + }); + + it("should not fire bookerReady when eventSlug is missing", () => { + fakeCurrentDocumentUrl(); + createTestEmbedState({ viewId: 1 }); + + const eventConfig = createTestEventConfig({ eventSlug: undefined }); + const schedule = createTestSchedule({ isSuccess: true }); + + renderHook(() => useBookerEmbedEvents({ ...eventConfig, schedule })); + + expectEventNotFired(firedEvents, "bookerReady"); + }); + }); + + describe("prerendering", () => { + it("should not fire events when embed is in prerendering mode", () => { + fakeCurrentDocumentUrl({ params: { prerender: "true" } }); + createTestEmbedState({ viewId: 1 }); + + const eventConfig = createTestEventConfig(); + const schedule = createTestSchedule({ isSuccess: true }); + + renderHook(() => useBookerEmbedEvents({ ...eventConfig, schedule })); + + expect(firedEvents).toHaveLength(0); + }); + }); + + describe("resetPageData", () => { + it("should reset all page-specific data including hasFired flags", () => { + createTestEmbedState({ + bookerViewedHasFired: true, + bookerReopenedHasFired: true, + bookerReloadedHasFired: true, + bookerReadyHasFired: true, + reloadInitiated: true, + }); + + resetPageData(); + + expect(embedStore.pageData.eventsState.bookerViewed.hasFired).toBe(false); + expect(embedStore.pageData.eventsState.bookerReopened.hasFired).toBe(false); + expect(embedStore.pageData.eventsState.bookerReloaded.hasFired).toBe(false); + expect(embedStore.pageData.eventsState.bookerReady.hasFired).toBe(false); + expect(embedStore.pageData.reloadInitiated).toBe(false); + }); + + it("should allow events to fire again after reset when new view starts", () => { + fakeCurrentDocumentUrl(); + createTestEmbedState({ viewId: 1 }); + + const eventConfig = createTestEventConfig(); + const schedule = createTestSchedule({ isSuccess: true }); + + renderHook(() => useBookerEmbedEvents({ ...eventConfig, schedule })); + + expect(firedEvents.length).toBeGreaterThan(0); + firedEvents = []; + + resetPageData(); + createTestEmbedState({ viewId: 2 }); + + renderHook(() => useBookerEmbedEvents({ ...eventConfig, schedule })); + + expect(firedEvents.length).toBeGreaterThan(0); + expectEventFired(firedEvents, "bookerReopened"); + }); + }); +}); diff --git a/packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts b/packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts index 44a73044f0..962152e3cd 100644 --- a/packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts +++ b/packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts @@ -7,7 +7,7 @@ import type { SetStyles, setNonStylesConfig, } from "../../types"; -import { runAsap } from "./utils"; +import { runAsap, log } from "./utils"; export const enum EMBED_IFRAME_STATE { NOT_INITIALIZED, @@ -115,6 +115,153 @@ export const embedStore = { * 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)[], + /** + * Unique identifier for the current link view. Incremented on each `linkReady` event (non-prerendering). + * - null = not yet initialized (before first linkReady) + * - 1 = first view (triggers bookerViewed) + * - 2+ = subsequent views/reopens (triggers bookerReopened) + * + * `linkReady` fires when iframe content is fully ready for user interaction + * (after content height is known and slots are loaded if skeleton loader is used). + */ + viewId: null as number | null, + /** + * Page-specific state that gets reset when a new page/view loads. + * All state that should be cleared between page views should be stored here. + */ + pageData: { + /** + * State for tracking embed events (bookerViewed, bookerReady, etc.) + */ + eventsState: { + /** + * Tracks whether bookerViewed event has been fired for the current view. + * Reset to false when linkReady fires (via resetPageData). + */ + bookerViewed: { + hasFired: false, + }, + /** + * Tracks whether bookerReopened event has been fired for the current view. + * Reset to false when linkReady fires (via resetPageData). + */ + bookerReopened: { + hasFired: false, + }, + /** + * Tracks whether bookerReloaded event has been fired for the current view. + * Reset to false when linkReady fires (via resetPageData). + */ + bookerReloaded: { + hasFired: false, + }, + /** + * Tracks whether bookerReady event has been fired for the current view. + * Reset to false when linkReady fires (via resetPageData). + */ + bookerReady: { + hasFired: false, + }, + }, + /** + * Flag to indicate that a reload was initiated and bookerReloaded should fire on next linkReady. + * Set to true when __reloadInitiated is received. + * Reset after firing bookerReloaded on linkReady. + */ + reloadInitiated: false, + }, }; +/** + * Resets all page-specific data + */ +export function resetPageData() { + log('Resetting page data'); + embedStore.pageData = { + eventsState: { + bookerViewed: { + hasFired: false, + }, + bookerReopened: { + hasFired: false, + }, + bookerReloaded: { + hasFired: false, + }, + bookerReady: { + hasFired: false, + }, + }, + reloadInitiated: false, + }; +} + +/** + * Type for direct properties of pageData (excluding nested objects like eventsState) + */ +type PageDataDirectProps = Omit; + +/** + * Gets a direct property from pageData in a type-safe way + */ +export function getPageDataProp( + key: K +): PageDataDirectProps[K] { + return embedStore.pageData[key] as PageDataDirectProps[K]; +} + +/** + * Sets a direct property on pageData in a type-safe way + */ +export function setPageDataProp( + key: K, + value: PageDataDirectProps[K] +): void { + embedStore.pageData[key] = value; +} + +/** + * Event names that have a hasFired state + */ +type EventNameWithHasFiredState = "bookerViewed" | "bookerReopened" | "bookerReloaded" | "bookerReady"; + +/** + * Gets whether a particular event has fired + */ +export function getEventHasFired(eventName: EventNameWithHasFiredState): boolean { + return embedStore.pageData.eventsState[eventName].hasFired; +} + +/** + * Sets whether a particular event has fired + */ +export function setEventHasFired(eventName: EventNameWithHasFiredState, value: boolean): void { + embedStore.pageData.eventsState[eventName].hasFired = value; +} + +/** + * Gets whether reload was initiated + */ +export function getReloadInitiated(): boolean { + return getPageDataProp("reloadInitiated"); +} + +/** + * Sets whether reload was initiated + */ +export function setReloadInitiated(value: boolean): void { + setPageDataProp("reloadInitiated", value); +} + +/** + * Increments the viewId counter (1 = first view, 2+ = reopens). + */ +export function incrementView(): void { + if (!embedStore.viewId) { + embedStore.viewId = 1; + } else { + embedStore.viewId++; + } +} + export type EmbedStore = typeof embedStore; diff --git a/packages/embeds/embed-core/src/embed-iframe/lib/utils.ts b/packages/embeds/embed-core/src/embed-iframe/lib/utils.ts index c0606c7d39..1884b95225 100644 --- a/packages/embeds/embed-core/src/embed-iframe/lib/utils.ts +++ b/packages/embeds/embed-core/src/embed-iframe/lib/utils.ts @@ -1,6 +1,8 @@ import { sdkActionManager } from "../../sdk-event"; import { type EmbedStore } from "../lib/embedStore"; +export const isBrowser = typeof window !== "undefined"; + export function runAsap(fn: (...arg: unknown[]) => void) { // We don't use rAF because it runs slower in Safari plus doesn't run if the iframe is hidden sometimes return setTimeout(fn, 50); @@ -14,6 +16,10 @@ export function isBookerReady() { return window._embedBookerState === "slotsDone"; } +export const isPrerendering = () => { + return new URL(document.URL).searchParams.get("prerender") === "true"; +}; + function isSkeletonSupportedPageType() { const url = new URL(document.URL); const pageType = url.searchParams.get("cal.embed.pageType"); @@ -98,13 +104,13 @@ export function keepParentInformedAboutDimensionChanges({ embedStore }: { embedS // Use, .height as that gives more accurate value in floating point. Also, do a ceil on the total sum so that whatever happens there is enough iframe size to avoid scroll. const contentHeight = Math.ceil( parseFloat(mainElementStyles.height) + - parseFloat(mainElementStyles.marginTop) + - parseFloat(mainElementStyles.marginBottom) + parseFloat(mainElementStyles.marginTop) + + parseFloat(mainElementStyles.marginBottom) ); const contentWidth = Math.ceil( parseFloat(mainElementStyles.width) + - parseFloat(mainElementStyles.marginLeft) + - parseFloat(mainElementStyles.marginRight) + parseFloat(mainElementStyles.marginLeft) + + parseFloat(mainElementStyles.marginRight) ); // During first render let iframe tell parent that how much is the expected height to avoid scroll. @@ -176,3 +182,31 @@ export const recordResponseIfQueued = async (params: Record { + const isFirstTime = embedStore.viewId === 1; + const isReload = getReloadInitiated(); + const eventName: "bookerViewed" | "bookerReopened" | "bookerReloaded" = isReload ? "bookerReloaded" : isFirstTime ? "bookerViewed" : "bookerReopened"; + + if (getEventHasFired(eventName)) { + return; + } + + fireEvent(eventName); + + function fireEvent(eventName: "bookerViewed" | "bookerReopened" | "bookerReloaded") { + setEventHasFired(eventName, true); + if (slotsLoaded) { + if (eventId && eventSlug) { + sdkActionManager?.fire(eventName, { + eventId, + eventSlug, + slotsLoaded: true, + }); + } else { + // This situation shouldn't arise but not throwing error because for a tracking event we don't want to crash booker + console.error("BookerViewed event not fired because slotsLoaded is true but eventId or eventSlug are falsy"); + } + } else { + sdkActionManager?.fire(eventName, { + eventId: null, + eventSlug: null, + slotsLoaded: false, + }); + } + } +}; + +/** + * Fires bookerReady event when booker view is loaded and slots are ready. + * Only fires once per link view when slots are successfully loaded. + */ +const fireBookerReadyEvent = ({ + eventId, + eventSlug, + slotsLoaded, +}: { + eventId: number; + eventSlug: string; + slotsLoaded: boolean; +}) => { + if (!slotsLoaded) { + return; + } + if (!getEventHasFired("bookerReady")) { + sdkActionManager?.fire("bookerReady", { eventId, eventSlug }); + setEventHasFired("bookerReady", true); + } +}; + +/** + * Hook that fires embed events (bookerViewed/bookerReopened/bookerReloaded and bookerReady). + * Manages event state using embedStore to prevent duplicate events and track modal reopens/reloads. + */ +export const useBookerEmbedEvents = ({ + eventId, + eventSlug, + schedule, +}: { + eventId: number | undefined; + eventSlug: string | undefined; + schedule: { + isSuccess: boolean; + dataUpdatedAt: number; + }; +}) => { + if (!isBrowser) { + return; + } + const viewId = embedStore.viewId; + + if (isPrerendering() || !viewId) { + return; + } + // first BookerViewed event happens, followed by bookerReady if slots are loaded + fireBookerViewedEvent({ + eventId, + eventSlug, + slotsLoaded: schedule.isSuccess, + }); + + if (eventId && eventSlug) { + fireBookerReadyEvent({ + eventId, + eventSlug, + slotsLoaded: schedule.isSuccess, + }); + } +}; + diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index 0c46dba8f5..8e45e6ec39 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -369,8 +369,8 @@ export class Cal { urlInstance.searchParams.append(key, value); } - // Very Important:Reset iframe ready flag, as iframe might load a fresh URL and we need to check when it is ready. - this.iframeReady = false; + // Very Important:Reset iframe ready flag and clear queue, as iframe might load a fresh URL and we need to check when it is ready. + this.iframeReset(); if (iframe.src === urlInstance.toString()) { // Ensure reload occurs even if the url is same - Though browser normally does it, but would be better to ensure it @@ -409,6 +409,11 @@ export class Cal { } } + iframeReset() { + this.iframeReady = false; + this.iframeDoQueue = []; + } + constructor(namespace: string, q: Queue) { this.__config = { // Use WEBAPP_URL till full page reload problem with website URL is solved @@ -458,6 +463,7 @@ export class Cal { this.iframeDoQueue.forEach((doInIframeArg) => { this.doInIframe(doInIframeArg); }); + this.iframeDoQueue = []; }); this.actionManager.on("__routeChanged", () => { @@ -477,6 +483,7 @@ export class Cal { this.actionManager.on("linkReady", () => { if (this.isPrerendering) { // Ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed could show-up without any user action + // linkReady event isn't received anyway by parent as it isn't whitelisted to be sent to parent but it is a safe guard return; } this.iframe!.style.visibility = ""; @@ -1180,6 +1187,9 @@ class CalApi { iframe: this.cal.iframe, config: enrichedConfig, }); + // Send reloadInitiated message to iframe so it can track it + // Send it after loadInIframe so that new iframe can process it. + this.cal.doInIframe({ method: "__reloadInitiated", arg: {} }); } else if (actionToTake === "connect" || actionToTake === "connect-no-slots-fetch") { const paramsToAdd = fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries()); this.cal.connect({ @@ -1423,16 +1433,33 @@ class CalApi { calOrigin?: string; options?: { /** - * Time in milliseconds after which the prerendered slots/availability should be considered stale and would be requested again when the modal is opened. This could slow down the booking page load by the time taken by slots loading request which shouldn't be too high + * Time in milliseconds after which the prerendered slots/availability should be considered stale and would be requested again when the modal is opened. * - * Default value is 1 min + * The threshold is measured from the time of the last prerender or modal API call (not from when the embed was first opened). + * **Important**: Each time the modal is opened, the timer resets. This means if you frequently close and reopen the modal + * (before the threshold is crossed), the slots will never be considered stale. + * + * When slots are considered stale, only the availability/slots are refetched (not a full iframe reload), which could + * slow down the booking page load by the time taken by the slots loading request. + * + * Default value is 1 min (60000 ms) */ slotsStaleTimeMs?: number; /** - * Time in milliseconds after which the iframe would be forcefully reloaded when the modal is opened and thus booking page load could slow down drastically showing the skeleton loader in the meantime - * To avoid reaching this threshold, the user could prerender before this time is reached in usecases where prerender has been done long time ago. + * Time in milliseconds after which the iframe would be forcefully reloaded when the modal is opened. * - * Default value is 15 mins + * The threshold is measured from the time of the last prerender or modal API call (not from when the embed was first opened). + * **Important**: Each time the modal is opened, the timer resets. This means if you frequently close and reopen the modal + * (before the threshold is crossed), the iframe will never be forcefully reloaded. + * + * When this threshold is crossed, a full iframe reload occurs (not just a slots refetch), which causes the booking page + * load to slow down drastically, showing the skeleton loader in the meantime. This full reload also means that any form + * response data would be resubmitted. + * + * To avoid reaching this threshold, you could prerender again before this time is reached in usecases where prerender + * has been done a long time ago. + * + * Default value is 15 mins (900000 ms) */ iframeForceReloadThresholdMs?: number; }; diff --git a/packages/embeds/embed-core/src/sdk-action-manager.ts b/packages/embeds/embed-core/src/sdk-action-manager.ts index b0f02162ae..acc6dc9926 100644 --- a/packages/embeds/embed-core/src/sdk-action-manager.ts +++ b/packages/embeds/embed-core/src/sdk-action-manager.ts @@ -25,10 +25,20 @@ type BaseBookingEventPayload = { }; export type EventDataMap = { + /** + * Fired when an event type is selected in the booker. + * Purpose: Track user selection of event types. + */ eventTypeSelected: { // eslint-disable-next-line @typescript-eslint/no-explicit-any eventType: any; }; + /** + * Fired when the embed page fails to load. + * Purpose: Signal page load errors to parent. + * Triggers: On non-200 HTTP status from iframe. + * Includes: Error code, message, and URL information. + */ linkFailed: { code: string; msg: string; @@ -36,19 +46,121 @@ export type EventDataMap = { url: string; }; }; + /** + * Fired when iframe content is fully ready for user interaction. + * Purpose: Signal that embed is ready and loader should be removed. + * Triggers: After content height is known and slots are loaded (if skeleton loader is used). + * Actions: Parent removes loader and makes iframe visible. + */ linkReady: Record; + /** + * Fired when prerendered embed is ready in the background. + * Purpose: Signal that prerender phase is complete. + * Triggers: When parentKnowsIframeReady is called during prerendering. + * Note: Embed stays hidden until connect() is called. + */ + linkPrerendered: Record; + /** + * Fired when booker view is loaded and slots are fully ready for user interaction. + * Purpose: Signal that booker is ready with slots loaded, allowing users to select a slot. + * Triggers: When booker view is loaded and slots are successfully loaded. + * Note: Only fires for booker pages (not booking success view or other non-booker pages). + * This is different from linkReady which fires for any embed page. + */ + bookerReady: { + eventId: number; + eventSlug: string; + }; + /** + * @deprecated Use `bookerReady` instead. This event is kept for backward compatibility. + * Fired when availability/slots data has been loaded for the first time. + * Purpose: Track first successful slots load. + * Triggers: On first successful slots load. + * Note: Only fires when slots are successfully loaded. + */ availabilityLoaded: { eventId: number; eventSlug: string; }; + /** + * Fired when booker has been viewed for the first time on first modal open. + * Purpose: Track first view of booker in current session. + * Triggers: On first linkReady event for a page view. + * Note: Not fired during prerendering. Includes event information and slots loading status. + */ + bookerViewed: + | { + eventId: number; + eventSlug: string; + slotsLoaded: true; + } + | { + eventId: null; + eventSlug: null; + slotsLoaded: false; + }; + /** + * Fired when booker has been reopened after being closed. + * Purpose: Track when booker is reopened, distinguishing from first view. + * Triggers: When modal is reopened after being closed (no reload). + * Note: Distinguishes between first view (bookerViewed) and reopen (bookerReopened). + */ + bookerReopened: + | { + eventId: number; + eventSlug: string; + slotsLoaded: true; + } + | { + eventId: null; + eventSlug: null; + slotsLoaded: false; + }; + /** + * Fired when booker has been reloaded (full page reload within modal). + * Purpose: Track when booker is reloaded, distinguishing from first view and reopen. + * Triggers: When fullReload action is taken (modal stays but iframe content is reloaded). + * Note: Distinguishes between first view (bookerViewed), reopen (bookerReopened), and reload (bookerReloaded). + */ + bookerReloaded: + | { + eventId: number; + eventSlug: string; + slotsLoaded: true; + } + | { + eventId: null; + eventSlug: null; + slotsLoaded: false; + }; + /** + * Fired when connect() method has been called to update prerendered embed. + * Purpose: Signal that prerendered embed is being connected with new configuration. + * Triggers: When connect() is invoked on a prerendered embed. + * Note: Only fires during prerender -> non-prerender transition. + */ __connectInitiated: Record; + /** + * Fired when connect flow has completed successfully. + * Purpose: Signal that connect flow has finished updating the embed. + * Triggers: After connect() finishes updating URL params and slots are ready. + * Actions: Fires linkReady event with isPrerendered: true. + */ __connectCompleted: Record; + /** + * Fired when a booking is successfully created. + * Purpose: Notify parent of successful booking creation. + * Triggers: After booking is confirmed and saved. + * Note: Lightweight version with restricted data fields. + */ bookingSuccessfulV2: BaseBookingEventPayload & { uid: string | undefined; }; /** * @deprecated Use `bookingSuccessfulV2` instead. We restrict the data heavily there, only sending what is absolutely needed and keeping it light as well. Plus, more importantly that can be documented well. + * Fired when a booking is successfully created. + * Purpose: Notify parent of successful booking creation. */ bookingSuccessful: { // TODO: Shouldn't send the entire booking and eventType objects, we should send specific fields from them. @@ -63,13 +175,31 @@ export type EventDataMap = { }; confirmed: boolean; }; + /** + * Fired when a booking is successfully rescheduled. + * Purpose: Notify parent of successful booking reschedule. + * Triggers: After reschedule is confirmed and saved. + * Note: Lightweight version with restricted data fields. + */ rescheduleBookingSuccessfulV2: BaseBookingEventPayload & { uid: string | undefined; }; + /** + * Fired when a dry-run booking validation is successful. + * Purpose: Notify parent of successful booking validation without creating the booking. + * Triggers: After booking validation passes in dry-run mode. + */ dryRunBookingSuccessfulV2: BaseBookingEventPayload; + /** + * Fired when a dry-run reschedule validation is successful. + * Purpose: Notify parent of successful reschedule validation without updating the booking. + * Triggers: After reschedule validation passes in dry-run mode. + */ dryRunRescheduleBookingSuccessfulV2: BaseBookingEventPayload; /** * @deprecated Use `rescheduleBookingSuccessfulV2` instead. We restrict the data heavily there, only sending what is absolutely needed and keeping it light as well. Plus, more importantly that can be documented well. + * Fired when a booking is successfully rescheduled. + * Purpose: Notify parent of successful booking reschedule. */ rescheduleBookingSuccessful: { booking: unknown; @@ -83,6 +213,10 @@ export type EventDataMap = { }; confirmed: boolean; }; + /** + * Fired when a booking is cancelled. + * Purpose: Notify parent of booking cancellation. + */ bookingCancelled: { booking: unknown; organizer: { @@ -92,23 +226,69 @@ export type EventDataMap = { }; eventType: unknown; }; + /** + * Fired when routing rules redirect to a custom action. + * Purpose: Notify parent of routing action taken. + */ routed: { actionType: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl"; actionValue: string; }; + /** + * Fired when navigation to booker page occurs. + * Purpose: Track navigation to booker. + */ navigatedToBooker: Record; + /** + * Wildcard event that fires for all events. + * Purpose: Allow listening to all events with a single handler. + */ "*": Record; + /** + * Fired when route changes within the embed. + * Purpose: Notify parent of navigation within embed. + * Triggers: When URL changes via history API or navigation. + * Actions: Parent can handle navigation (e.g., scroll into view for inline embeds). + */ __routeChanged: Record; + /** + * Fired when window load event has completed. + * Purpose: Signal that dimension calculations are now reliable. + * Triggers: After document.readyState is "complete" and Safari rendering delay has passed. + * Note: Used internally to ensure accurate dimension calculations. + */ __windowLoadComplete: Record; + /** + * Fired when iframe requests to be closed. + * Purpose: Request to close modal from within iframe. + * Actions: Parent sets modal state to "closed". + */ __closeIframe: Record; + /** + * Fired when iframe is ready to receive messages from parent. + * Purpose: Signal that embed is ready to receive messages. + * Triggers: When embed SDK initializes and is ready for communication. + * Actions: Makes iframe visible (unless prerendering) and processes queued commands. + */ __iframeReady: { isPrerendering: boolean; }; + /** + * Fired when iframe content dimensions change. + * Purpose: Keep iframe size matched to content to prevent scrollbars. + * Triggers: On initial load, when content size changes, or after window load completes. + * Actions: Parent adjusts iframe dimensions. + */ __dimensionChanged: { iframeHeight: number; iframeWidth: number; isFirstTime: boolean; }; + /** + * Fired to request scrolling by a specific distance. + * Purpose: Allow iframe to request parent to scroll. + * Actions: Parent scrolls the scrollable ancestor by the specified distance. + */ __scrollByDistance: { /** * Distance in pixels to scroll by. diff --git a/packages/embeds/embed-handshake.mermaid b/packages/embeds/embed-handshake.mermaid new file mode 100644 index 0000000000..62da41a2c1 --- /dev/null +++ b/packages/embeds/embed-handshake.mermaid @@ -0,0 +1,92 @@ +sequenceDiagram + participant Parent as Parent Page
(embed.ts) + participant PM as postMessage + participant Iframe as Iframe
(embed-iframe.ts) + participant Store as EmbedStore + + Note over Parent,Store: PHASE 1: Iframe Creation + + Parent->>Parent: Create iframe element + Note over Parent: iframe.style.visibility = "hidden" + Parent->>Parent: Set iframe src with embed params + Note over Parent: iframeReady = false
iframeDoQueue = [] + + Note over Iframe: Iframe starts loading + Note over Iframe: body tag hidden
background: transparent + + Note over Parent,Store: PHASE 2: Handshake Initiation + + Iframe->>Store: main() called on load + Store->>Store: Initialize embedStore
state: NOT_INITIALIZED + Store->>Store: Parse URL params
(theme, layout, colorScheme) + + alt Iframe is in top window (not embedded) + Iframe->>Iframe: showPageAsNonEmbed() + Note over Iframe: Skip embed initialization + else Iframe is embedded properly + Iframe->>Store: initializeAndSetupEmbed() + Store->>Store: state: INITIALIZED
renderState: "inProgress" + + Note over Parent,Store: PHASE 3: __iframeReady Event + + Iframe->>PM: fire("__iframeReady", { isPrerendering }) + PM->>Parent: postMessage({ originator: "CAL",
type: "__iframeReady", ... }) + + Note over Parent: Received __iframeReady + Parent->>Parent: iframeReady = true + + alt Not prerendering + Parent->>Parent: iframe.style.visibility = "" + Note over Parent: Iframe becomes visible + end + + Note over Parent,Store: PHASE 4: parentKnowsIframeReady Response + + Parent->>PM: doInIframe({ method: "parentKnowsIframeReady" }) + PM->>Iframe: postMessage({ originator: "CAL",
method: "parentKnowsIframeReady" }) + + Note over Iframe: Received parentKnowsIframeReady + Iframe->>Iframe: Wait for isLinkReady() + Iframe->>Iframe: makeBodyVisible() + Note over Iframe: body.style.visibility = "visible" + Store->>Store: renderState: "completed" + + alt Is prerendering + Iframe->>PM: fire("linkPrerendered") + PM->>Parent: linkPrerendered event + else Normal load + Iframe->>PM: fire("linkReady") + PM->>Parent: linkReady event + end + + Note over Parent,Store: PHASE 5: Queue Flush + + Parent->>Parent: Process iframeDoQueue + + loop For each queued command + Parent->>PM: doInIframe(queuedCommand) + PM->>Iframe: postMessage({ method, arg }) + Iframe->>Store: interfaceWithParent[method](arg) + end + + Parent->>Parent: iframeDoQueue = [] + Note over Parent: Handshake complete
Communication established + end + + Note over Parent,Store: ONGOING: Bi-directional Communication + + rect rgb(240, 248, 255) + Note over Parent,Store: Parent → Iframe (Commands) + Parent->>PM: doInIframe({ method: "ui", arg: uiConfig }) + PM->>Iframe: postMessage({ method: "ui", arg: {...} }) + Iframe->>Store: methods.ui(uiConfig) + Note over Store: Apply styles, theme, etc. + end + + rect rgb(255, 248, 240) + Note over Parent,Store: Iframe → Parent (Events) + Iframe->>PM: fire("__dimensionChanged", { height, width }) + PM->>Parent: postMessage with event data + Parent->>Parent: Adjust iframe dimensions + end + diff --git a/packages/embeds/embed-message-protocol.mermaid b/packages/embeds/embed-message-protocol.mermaid new file mode 100644 index 0000000000..5788c51999 --- /dev/null +++ b/packages/embeds/embed-message-protocol.mermaid @@ -0,0 +1,40 @@ +flowchart TB + subgraph Parent["Parent Page (embed.ts)"] + direction TB + CalAPI["Cal API
(Cal.inline, Cal.modal, Cal.ui)"] + ActionMgr["ActionManager
(Event Listener)"] + DoInIframe["doInIframe()
(Command Sender)"] + Queue["iframeDoQueue[]
(Command Buffer)"] + + CalAPI --> DoInIframe + DoInIframe -->|"if !iframeReady"| Queue + Queue -->|"flush on ready"| DoInIframe + end + + subgraph Transport["postMessage Transport"] + direction TB + ToIframe["parent.postMessage()
{originator: 'CAL', method, arg}"] + ToParent["iframe.postMessage()
{originator: 'CAL', type, data}"] + end + + subgraph Iframe["Iframe (embed-iframe.ts)"] + direction TB + MsgListener["window.addEventListener('message')"] + Interface["interfaceWithParent
{ui, parentKnowsIframeReady, connect}"] + SDKAction["sdkActionManager
(Event Emitter)"] + EmbedStore["embedStore
(State)"] + + MsgListener --> Interface + Interface --> EmbedStore + SDKAction -->|"fire()"| ToParent + end + + DoInIframe -->|"Commands"| ToIframe + ToIframe --> MsgListener + + ToParent --> ActionMgr + + style Parent fill:#e1f5fe,stroke:#01579b + style Iframe fill:#fff3e0,stroke:#e65100 + style Transport fill:#f3e5f5,stroke:#7b1fa2 + diff --git a/packages/embeds/embed-react/inline.tsx b/packages/embeds/embed-react/inline.tsx index 50504fdeb3..b07529d85b 100644 --- a/packages/embeds/embed-react/inline.tsx +++ b/packages/embeds/embed-react/inline.tsx @@ -50,22 +50,22 @@ function App() { callback, }); - const availabilityLoadedCallback = (e: EmbedEvent<"availabilityLoaded">) => { + const bookerReadyCallback = (e: EmbedEvent<"bookerReady">) => { const data = e.detail.data; - console.log("availabilityLoaded", { + console.log("bookerReady", { eventId: data.eventId, eventSlug: data.eventSlug, }); api("off", { - action: "availabilityLoaded", - callback: availabilityLoadedCallback, + action: "bookerReady", + callback: bookerReadyCallback, }); }; api("on", { - action: "availabilityLoaded", - callback: availabilityLoadedCallback, + action: "bookerReady", + callback: bookerReadyCallback, }); // Also, validates the type of e.detail.data as TS runs on this file diff --git a/packages/embeds/inline-embed-lifecycle.mermaid b/packages/embeds/inline-embed-lifecycle.mermaid new file mode 100644 index 0000000000..a40d845334 --- /dev/null +++ b/packages/embeds/inline-embed-lifecycle.mermaid @@ -0,0 +1,86 @@ +sequenceDiagram + participant Parent as Parent (User Page) + participant Embed as Embed (iframe) + participant Store as EmbedStore(iframe) + participant UI as UI Components(iframe) + + Note over Parent: embed.js loads + Parent->>Parent: Cal.inline() called + Parent->>Parent: Create cal-inline element + Parent->>Parent: Create iframe (hidden) + Parent->>Parent: Show loader + + Note over Embed: background: transparent + Note over Embed: body tag hidden + Note over Embed: iframe webpage starts rendering + + Note over Store: EmbedStore pre-initialized
State: NOT_INITIALIZED + Store->>Store: Initialize UI config & theme + + Note over Parent: Process URL params for prefill + Parent->>Store: Set prefill data from URL + + Embed->>Parent: __iframeReady event + Note right of Embed: Purpose: Signals iframe is ready
to receive messages from parent
Action: Makes iframe visible,
processes queued commands + Note over Parent: Sets iframeReady flag + Note over Parent: Makes iframe visible + Note over Store: Set state to INITIALIZED + + Store->>UI: Apply theme & cssVarsPerTheme + + Embed->>Parent: __dimensionChanged event + Note right of Embed: Purpose: Keeps iframe size
matched to content
Triggers: Content size changes,
page finishes loading + Note over Parent: Adjust iframe dimensions + Note over Store: Update providedCorrectHeightToParent + + Embed->>Parent: __windowLoadComplete event + Note right of Embed: Purpose: Signals page fully loaded
Ensures dimension calculations
are reliable + + alt isBookerPage with skeleton + Note over Embed: Wait for slotsDone state + end + + Embed->>Parent: linkReady event + Note right of Embed: Purpose: Signals iframe content
is fully ready for interaction
Requirements: Height known,
slots loaded (if skeleton) + Note over Parent: Remove loader + Note over Parent: Set loading="done" + Note over Parent: Make iframe visible + + Parent->>Embed: parentKnowsIframeReady event + Note right of Parent: Purpose: Parent acknowledges
iframe readiness
Action: Makes body visible + Note over Embed: Makes body visible + Note over Store: renderState: "completed" + + loop Dimension Monitoring + Embed->>Embed: Monitor content size changes + alt Dimensions Changed + Embed->>Parent: __dimensionChanged event + Note right of Embed: Purpose: Maintain proper sizing
as content changes + Parent->>Parent: Adjust iframe size + end + end + + alt Route Changes + UI->>Store: Update UI state + Store->>Parent: __routeChanged event + Note right of Store: Purpose: Notifies parent of
navigation within embed
Action: Scroll into view if needed + Note over Parent: Scroll into view if needed + end + + alt Booker Events (Tracking) + UI->>Store: Update viewId on linkReady + UI->>Parent: bookerViewed event + Note right of UI: Purpose: Tracks first view of booker
when booker is first loaded
Includes: Event info, slots status + UI->>Parent: bookerReady event + Note right of UI: Purpose: Signals booker view is loaded
and slots are ready for interaction
Includes: Event identification
Only fires for booker pages + alt User navigates and returns + UI->>Parent: bookerReopened event + Note right of UI: Purpose: Tracks when booker is
viewed again after navigation
Distinguishes from first view + end + end + + alt Error Handling + Embed->>Parent: linkFailed event + Note right of Embed: Purpose: Signals page load error
Includes: Error code, URL info
Action: Show error state + Note over Parent: Set loading="failed" + end diff --git a/packages/embeds/modal-embed-lifecycle.mermaid b/packages/embeds/modal-embed-lifecycle.mermaid new file mode 100644 index 0000000000..d88ef97f10 --- /dev/null +++ b/packages/embeds/modal-embed-lifecycle.mermaid @@ -0,0 +1,109 @@ +sequenceDiagram + participant Parent as Parent (User Page) + participant Embed as Embed (iframe) + participant Store as EmbedStore(iframe) + participant UI as UI Components(iframe) + + Note over Parent: embed.js loads + + alt CTA clicked or Cal.modal() called + Parent->>Parent: Check for existing modal + alt Modal exists & can reuse + Parent->>Parent: Reopen modal (state: "reopened") + Note over Parent: May use connect() or reload + else Create new modal + Parent->>Parent: Create cal-modal-box + Parent->>Parent: Create iframe (hidden) + Parent->>Parent: Set state="loading" + Parent->>Parent: Show loader + end + end + + Note over Embed: background: transparent + Note over Embed: body tag hidden + Note over Embed: iframe webpage starts rendering + + Note over Store: EmbedStore pre-initialized
State: NOT_INITIALIZED + Store->>Store: Initialize UI config & theme + + Note over Parent: Process URL params for prefill + Parent->>Store: Set prefill data from URL + + Embed->>Parent: __iframeReady event + Note right of Embed: Purpose: Signals iframe is ready
to receive messages from parent
Action: Makes iframe visible
(unless prerendering),
processes queued commands + Note over Parent: Sets iframeReady flag + Note over Parent: Makes iframe visible (unless prerendering) + Note over Store: Set state to INITIALIZED + + Store->>UI: Apply theme & cssVarsPerTheme + + Embed->>Parent: __dimensionChanged event + Note right of Embed: Purpose: Keeps iframe size
matched to content
Triggers: Content size changes,
page finishes loading + Note over Parent: Adjust iframe dimensions + Note over Store: Update providedCorrectHeightToParent + + Embed->>Parent: __windowLoadComplete event + Note right of Embed: Purpose: Signals page fully loaded
Ensures dimension calculations
are reliable + + alt isBookerPage with skeleton + Note over Embed: Wait for slotsDone state + end + + Embed->>Parent: linkReady event + Note right of Embed: Purpose: Signals iframe content
is fully ready for interaction
Requirements: Height known,
slots loaded (if skeleton) + Note over Parent: Remove loader + Note over Parent: Set state="loaded" + Note over Parent: Make iframe visible + + Parent->>Embed: parentKnowsIframeReady event + Note right of Parent: Purpose: Parent acknowledges
iframe readiness
Action: Makes body visible,
sets renderState completed + Note over Embed: Makes body visible + Note over Store: renderState: "completed" + + loop Dimension Monitoring + Embed->>Embed: Monitor content size changes + alt Dimensions Changed + Embed->>Parent: __dimensionChanged event + Note right of Embed: Purpose: Maintain proper sizing
as content changes + Parent->>Parent: Adjust iframe size + end + end + + alt Route Changes + UI->>Store: Update UI state + Store->>Parent: __routeChanged event + Note right of Store: Purpose: Notifies parent of
navigation within embed
Action: Handle navigation + Note over Parent: Handle navigation + end + + alt Booker Events (Tracking) + UI->>Store: Update viewId on linkReady + UI->>Parent: bookerViewed event + Note right of UI: Purpose: Tracks first view of booker
on first modal open
Includes: Event info, slots status
Not fired during prerendering + UI->>Parent: bookerReady event + Note right of UI: Purpose: Signals booker view is loaded
and slots are ready for interaction
Includes: Event identification
Only fires for booker pages + alt User closes and reopens modal + UI->>Parent: bookerReopened event + Note right of UI: Purpose: Tracks when booker is
reopened after being closed
Distinguishes from first view + end + alt Full reload scenario + Parent->>Embed: __reloadInitiated method + Note right of Parent: Purpose: Notify iframe that
fullReload is happening
Action: Set reloadInitiated flag + Note over Store: reloadInitiated: true + UI->>Parent: bookerReloaded event + Note right of UI: Purpose: Tracks when booker is
reloaded (full page reload)
Distinguishes from reopen + Note over Store: Reset reloadInitiated flag + end + end + + alt Close Request + Embed->>Parent: __closeIframe event + Note right of Embed: Purpose: Request to close modal
from within iframe
Action: Set state="closed" + Note over Parent: Set state="closed" + end + + alt Error Handling + Embed->>Parent: linkFailed event + Note right of Embed: Purpose: Signals page load error
Includes: Error code, URL info
Action: Show error state + Note over Parent: Set state="failed" + end diff --git a/packages/embeds/modal-prerendering-flow.mermaid b/packages/embeds/modal-prerendering-flow.mermaid new file mode 100644 index 0000000000..ffcf70d36f --- /dev/null +++ b/packages/embeds/modal-prerendering-flow.mermaid @@ -0,0 +1,115 @@ +sequenceDiagram + participant Parent as Parent (User Page) + participant Embed as Embed (iframe) + participant Store as EmbedStore(iframe) + participant UI as UI Components(iframe) + + Note over Parent: Prerender Phase + Parent->>Parent: Cal.prerender() or Cal.preload() called + Note over Parent: Set prerender=true in URL + Parent->>Parent: Create cal-modal-box (hidden) + Parent->>Parent: Create iframe (hidden) + Parent->>Parent: Set state="prerendering" + + Note over Embed: background: transparent + Note over Embed: body tag hidden + Note over Embed: iframe webpage starts rendering + + Note over Store: EmbedStore pre-initialized
State: NOT_INITIALIZED + Store->>Store: renderState: "inProgress" + Store->>Store: Initialize UI config & theme + + Note over Store: Limited events allowed
(__iframeReady, __dimensionChanged,
__connectInitiated, linkPrerendered)
No tracking events fired + + Embed->>Parent: __iframeReady event + Note right of Embed: Purpose: Signals iframe is ready
to receive messages
Action: Sets iframeReady flag
iframe stays hidden + Note over Parent: Sets iframeReady flag + Note over Parent: iframe stays hidden + Note over Store: Set state to INITIALIZED + + Store->>UI: Apply theme & cssVarsPerTheme + + Embed->>Parent: __dimensionChanged event + Note right of Embed: Purpose: Keeps iframe size
matched to content
Allows proper sizing
even when hidden + Note over Parent: Adjust iframe dimensions + Note over Store: Update providedCorrectHeightToParent + + Embed->>Parent: __windowLoadComplete event + Note right of Embed: Purpose: Signals page fully loaded
Ensures dimension calculations
are reliable + + Note over Embed: Load booker (slots may be skipped) + + Embed->>Parent: linkReady event (internal) + Note right of Embed: Purpose: Internal signal for
prerender completion
Not exposed to user + Note over Parent: parentKnowsIframeReady called + + Parent->>Embed: parentKnowsIframeReady event + Note right of Parent: Purpose: Parent acknowledges
iframe readiness
Action: Makes body visible,
sets renderState completed + Note over Embed: Makes body visible + Note over Store: renderState: "completed" + Embed->>Parent: linkPrerendered event + Note right of Embed: Purpose: Signals prerendered embed
is ready in background
Embed stays hidden until connect() + Note over Parent: Prerender complete, waiting for connect() + + Note over Parent: User clicks CTA or Cal.modal() called + + Note over Parent: Connect Phase + Parent->>Embed: connect() with new config/params + Embed->>Parent: __connectInitiated event + Note right of Embed: Purpose: Signals connect() called
to activate prerendered embed
Triggers: URL param updates + Note over Store: Reset providedCorrectHeightToParent + + Store->>Store: Update URL params via router + Note over Store: Remove prerender params + Note over Store: May fetch slots (unless noSlotsFetchOnConnect=true) + + alt Slots need fetching + Note over Embed: Wait for slotsDone state + end + + Embed->>Parent: __connectCompleted event + Note right of Embed: Purpose: Signals connect flow
has finished updating embed
URL params updated, slots ready + Embed->>Parent: linkReady event (isPrerendered: true) + Note right of Embed: Purpose: Signals iframe content
is fully ready for interaction
Fired with isPrerendered flag + Note over Parent: Remove loader + Note over Parent: Set state="loaded" + Note over Parent: Make modal visible + Note over Store: viewId set to 1 + + Note over Store: Full event flow now enabled + UI->>Parent: bookerViewed event + Note right of UI: Purpose: Tracks first view of booker
Includes: Event info, slots status
Now enabled (was suppressed) + UI->>Parent: bookerReady event + Note right of UI: Purpose: Signals booker view is loaded
and slots are ready for interaction
Includes: Event identification
Only fires for booker pages + alt User closes and reopens modal + UI->>Parent: bookerReopened event + Note right of UI: Purpose: Tracks when booker is
reopened after being closed + end + + loop Dimension Monitoring + Embed->>Embed: Monitor content size changes + alt Dimensions Changed + Embed->>Parent: __dimensionChanged event + Note right of Embed: Purpose: Maintain proper sizing
as content changes + Parent->>Parent: Adjust iframe size + end + end + + alt Route Changes + UI->>Store: Update UI state + Store->>Parent: __routeChanged event + Note right of Store: Purpose: Notifies parent of
navigation within embed + Note over Parent: Handle navigation + end + + alt Close Request + Embed->>Parent: __closeIframe event + Note right of Embed: Purpose: Request to close modal
from within iframe + Note over Parent: Set state="closed" + end + + alt Error Handling + Embed->>Parent: linkFailed event + Note right of Embed: Purpose: Signals page load error
Includes: Error code, URL info + Note over Parent: Set state="failed" + end diff --git a/packages/features/bookings/Booker/utils/event.ts b/packages/features/bookings/Booker/utils/event.ts index a289b1c04e..450ac6ee9c 100644 --- a/packages/features/bookings/Booker/utils/event.ts +++ b/packages/features/bookings/Booker/utils/event.ts @@ -122,5 +122,6 @@ export const useScheduleForEvent = ({ isSuccess: schedule?.isSuccess, isLoading: schedule?.isLoading, invalidate: schedule?.invalidate, + dataUpdatedAt: schedule?.dataUpdatedAt, }; }; diff --git a/packages/platform/atoms/booker/BookerWebWrapper.tsx b/packages/platform/atoms/booker/BookerWebWrapper.tsx index 92fd5667ef..1084f404ae 100644 --- a/packages/platform/atoms/booker/BookerWebWrapper.tsx +++ b/packages/platform/atoms/booker/BookerWebWrapper.tsx @@ -1,15 +1,17 @@ "use client"; import { useSession } from "next-auth/react"; -import { useSearchParams } from "next/navigation"; -import { usePathname, useRouter } from "next/navigation"; -import { useMemo, useCallback, useEffect } from "react"; +import { useSearchParams, usePathname, useRouter } from "next/navigation"; +import { useMemo, useCallback, useEffect, useRef } from "react"; import React from "react"; import { shallow } from "zustand/shallow"; import dayjs from "@calcom/dayjs"; -import { sdkActionManager } from "@calcom/embed-core/embed-iframe"; -import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; +import { + sdkActionManager, + useIsEmbed, +} from "@calcom/embed-core/embed-iframe"; +import { useBookerEmbedEvents } from "@calcom/embed-core/src/embed-iframe/react-hooks"; import type { BookerProps } from "@calcom/features/bookings/Booker"; import { Booker as BookerComponent } from "@calcom/features/bookings/Booker"; import { @@ -37,6 +39,7 @@ export type BookerWebWrapperAtomProps = BookerProps & { eventData?: NonNullable>>; }; + const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => { const router = useRouter(); const pathname = usePathname(); @@ -47,11 +50,11 @@ const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => { }); const event = props.eventData ? { - data: props.eventData, - isSuccess: true, - isError: false, - isPending: false, - } + data: props.eventData, + isSuccess: true, + isError: false, + isPending: false, + } : clientFetchedEvent; const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts); @@ -173,6 +176,12 @@ const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => { teamMemberEmail: props.teamMemberEmail, }); + useBookerEmbedEvents({ + eventId: event.data?.id, + eventSlug: event.data?.slug, + schedule, + }); + const verifyCode = useVerifyCode({ onSuccess: () => { if (!bookerForm.formEmail) return; @@ -206,10 +215,10 @@ const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => { const areInstantMeetingParametersSet = Boolean( event.data?.instantMeetingParameters && - searchParams && - event.data.instantMeetingParameters?.every?.((param) => - Array.from(searchParams.values()).includes(param) - ) + searchParams && + event.data.instantMeetingParameters?.every?.((param) => + Array.from(searchParams.values()).includes(param) + ) ); useEffect(() => {