feat: add booker lifecycle SDK events for embed tracking (#25569)

* feat: add embed prerendering support and enhance event handling

- Introduced `useIsEmbedPrerendering` hook to determine if the embed is in prerender mode.
- Updated `useAvailabilityEvents` to prevent firing events during prerendering.
- Added `bookerLoadedEvent` and `availabilityRefreshed` types to `EventDataMap`.
- Implemented `useFirebookerLoadedEvent` to manage firing the booker loaded event conditionally.
- Refactored event firing logic in `useSchedule` and `BookerWebWrapper` components to utilize new hooks.

* feat: add embed prerendering support and enhance event handling

- Introduced `useIsEmbedPrerendering` hook to determine if the embed is in prerender mode.
- Updated `useAvailabilityEvents` to prevent firing events during prerendering.
- Added `bookerLoadedEvent` and `availabilityRefreshed` types to `EventDataMap`.
- Implemented `useFirebookerLoadedEvent` to manage firing the booker loaded event conditionally.
- Refactored event firing logic in `useSchedule` and `BookerWebWrapper` components to utilize new hooks.

* feat: enhance embed event handling and introduce link reopening detection

- Added `useEmbedReopened` hook to track when the embed is reopened.
- Updated `BookerWebWrapper` to reset event firing state upon embed reopening.
- Refactored event firing logic to use `bookerViewed` instead of `bookerLoadedEvent`.
- Introduced scheduling for event firing in `useSchedule` to ensure correct order of events during prerendering.

* feat: add lifecycle diagrams for inline and modal embeds

- Introduced `inline-embed-lifecycle.mermaid` and `modal-embed-lifecycle.mermaid` files to visualize the lifecycle events and states of inline and modal embeds.
- Updated `LIFECYCLE.md` to reference the new diagrams and provide a clearer explanation of the embed lifecycle processes.
- Added `modal-prerendering-flow.mermaid` to illustrate the prerendering flow for modal embeds.
- Enhanced the routing playground with new features and improved event handling for availability and booking events.

* refactor: update event handling for booker lifecycle events

- Replaced `availabilityLoaded` event with `bookerReady` to better reflect the state when the booker view is fully loaded and ready for interaction.
- Updated related documentation and diagrams to reflect changes in event triggers and descriptions.
- Adjusted internal state management to track `viewId` instead of `reopenCount` for distinguishing between initial views and reopens.
- Added tests for new event handling logic to ensure correct firing of `bookerViewed`, `bookerReopened`, and `bookerReady` events.

* refactor: enhance embed event handling and state management

- Updated event handling for booker lifecycle events, replacing `resetViewVariables` with `resetPageData` to manage page-specific state.
- Introduced new utility functions for managing event firing states and reload initiation.
- Refactored `fireBookerViewedEvent` and `fireBookerReadyEvent` to utilize the new state management functions.
- Added comprehensive tests for the updated event handling logic and state resets to ensure correct functionality across various scenarios.

* refactor: update embed iframe configuration and utility functions

- Reduced `slotsStaleTimeMs` from 30 seconds to 10 seconds and `iframeForceReloadThresholdMs` from 100 seconds to 30 seconds for improved responsiveness.
- Refactored utility functions to use `isBrowser` for client-side checks instead of `isClientSide`.
- Removed unused `isPrerendering` function and updated related documentation for clarity.
- Enhanced event handling by exporting `useBookerEmbedEvents` from the appropriate module for better accessibility.

* refactor: update embed iframe configuration and utility functions

- Reduced `slotsStaleTimeMs` from 30 seconds to 10 seconds and `iframeForceReloadThresholdMs` from 100 seconds to 30 seconds for improved responsiveness.
- Refactored utility functions to use `isBrowser` for client-side checks instead of `isClientSide`.
- Removed unused `isPrerendering` function and updated related documentation for clarity.
- Enhanced event handling by exporting `useBookerEmbedEvents` from the appropriate module for better accessibility.

* fix cubic feedback
This commit is contained in:
Hariom Balhara
2025-12-11 09:11:38 -03:00
committed by GitHub
parent f6642c17f8
commit 6f0c09d2be
21 changed files with 2105 additions and 609 deletions
+174 -150
View File
@@ -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
@@ -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() {
@@ -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") {
@@ -97,35 +97,10 @@
<body>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div id="cal-booking-place-routingFormWithoutPrerender">
<a href="?only=ns:routingFormWithoutPrerender">Routing Form Without Prerender Demo</a>
<form id="cal-booking-place-routingFormWithoutPrerender-form">
<input type="text" name="name" placeholder="John Doe" />
<input type="email" name="email" placeholder="[email protected]" />
<select name="skills" placeholder="JavaScript, Node.js">
<option value="JavaScript">JavaScript</option>
<option value="Sales">Sales</option>
</select>
</form>
<button id="cal-booking-place-routingFormWithoutPrerender-submit" data-cal-namespace="routingFormWithoutPrerender" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots", "guests":["[email protected]", "[email protected]"]}'>Submit</button>
<script>
requestAnimationFrame(function updateSubmitButtonLink() {
const seededFormAcmeId = "948ae412-d995-4865-885a-48302588de03";
const form = document.getElementById("cal-booking-place-routingFormWithoutPrerender-form");
const name = form.querySelector("input[name='name']").value;
const email = form.querySelector("input[name='email']").value;
const skills = form.querySelector("select[name='skills']").value;
if (name && email) {
document.getElementById("cal-booking-place-routingFormWithoutPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Rating=5&skills=${skills}&Email=${email}`);
}
requestAnimationFrame(updateSubmitButtonLink);
});
</script>
</div>
<div id="cal-booking-routingFormFullPrerender">
<hr/>
<a href="?only=ns:routingFormFullPrerender&debug=1&[email protected]">Routing Form - Prerender Headless Router itself queuing the form response</a>
NOTE: Pass query param param.formId=FORM_UID_HERE to test with a particular routing form
<a style="display: block;" href="?only=ns:routingFormFullPrerender&debug=1&[email protected]">Routing Form - Prerender Headless Router itself queuing the form response</a>
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.
<p>1. As page is loading, we prerender the headless router for the email passed as param.email</p>
<p>2. Whenever email changes and onblur happens, the prerender is triggered for the new email and previous prerendered modal is removed</p>
<p>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</p>
@@ -133,6 +108,7 @@
<p>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</p>
<p>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</p>
<p>7. To avoid reaching the iframeForceReloadThresholdMs, user could prerender the router again and again judiciously</p>
<p>8. You can disable prerendering by passing the query param param.disablePrerender=true</p>
</p>
<div class="inline-embed-container">
<script>
@@ -144,21 +120,115 @@
debug: true,
calOrigin: window.calOrigin,
});
function trackLog(message) {
console.log(`${performance.now().toFixed(2)}ms [Tracking] ${message}`);
}
let listenersAdded = false;
let isAvailabilityLoaded = false;
// Track API start
let apiStartTime = null;
const linkPrerenderedCallback = (e) => {
trackLog("Link Prerendered");
};
Cal.ns["routingFormFullPrerender"]("on", { action: "linkPrerendered", callback: linkPrerenderedCallback });
// Track latency b/w API execution and availability loading
const trackLatencyInShowingSlots = (namespace) => {
isAvailabilityLoaded = false;
apiStartTime = performance.now();
const trackAvailabilityLoaded = (source) => {
if (!apiStartTime) {
trackLog(`API start time not set - ${source}`);
return;
}
if (isAvailabilityLoaded) {
trackLog(`Availability already loaded - triggering again by: ${source}`);
return;
}
isAvailabilityLoaded = true;
const endTime = performance.now();
const timeDiff = endTime - apiStartTime;
trackLog(`Embed Modal API to Booker Ready: ${timeDiff.toFixed(2)}ms - triggered by: ${source}`);
};
trackLog(`Embed Modal API started at: ${apiStartTime.toFixed(2)}ms`);
// Setup availability tracking
isAvailabilityLoaded = false;
const api = namespace ? Cal.ns[namespace] : Cal;
const bookerViewedCallback = (e) => {
const data = e.detail.data;
if (data.slotsLoaded === true) {
trackAvailabilityLoaded("bookerViewed (slotsLoaded: true)");
} else {
trackLog("bookerViewed(slotsLoaded: false). Booker is ready but waiting for slots to load");
}
};
const bookerReopenedCallback = (e) => {
const data = e.detail.data;
if (data.slotsLoaded === true) {
trackAvailabilityLoaded("bookerReopened (slotsLoaded: true)");
} else {
trackLog("Booker was reopened and waiting for slots to load");
}
};
const bookerReadyCallback = () => {
trackAvailabilityLoaded("bookerReady event");
};
const bookerReloadedCallback = (e) => {
const data = e.detail.data;
if (data.slotsLoaded === true) {
trackAvailabilityLoaded("bookerReloaded (slotsLoaded: true)");
} else {
trackLog("Booker was reloaded and waiting for slots to load");
}
};
if (!listenersAdded) {
api("on", { action: "bookerViewed", callback: bookerViewedCallback });
api("on", { action: "bookerReady", callback: bookerReadyCallback });
api("on", { action: "bookerReopened", callback: bookerReopenedCallback });
api("on", { action: "bookerReloaded", callback: bookerReloadedCallback });
listenersAdded = true;
}
};
window.routingFormFullPrerender = {
buildRouterUrl: ({skills, location, name, email, formId}) => {
// Send `email` as well as `Email` because E2e Tests currently use `Email` and `email` is needed by contact owner lookup. It doesn't accept Uppercase Email
return `router?form=${formId}&skills=${skills}&Location=${location}&name=${name}&Email=${email}&email=${email}&Rating=5`;
return `router?form=${formId}&Manager=fggfg&skills=${skills}&Location=${location}&name=${name}&Email=${email}&email=${email}&Rating=5`;
},
onFormSubmit: (e) => {
e.preventDefault();
if(!window.params.formId) {
alert("Form ID is not set. Please set the form ID using the param.formId query parameter. Easiest is to run seed-insights.ts script to create a seeded form and then use the form id here. ");
return false;
}
e.preventDefault();
const namespace = "routingFormFullPrerender";
trackLatencyInShowingSlots(namespace);
const skills = document.getElementById('cal-booking-place-routingFormFullPrerender-select-skills').value;
const location = document.getElementById('cal-booking-place-routingFormFullPrerender-select-location').value;
const name = document.getElementById('cal-booking-place-routingFormFullPrerender-input-name').value;
const email = document.getElementById('cal-booking-place-routingFormFullPrerender-input-email').value;
const routerUrl = window.routingFormFullPrerender.buildRouterUrl({skills, location, name, email, formId: window.params.formId});
Cal.ns.routingFormFullPrerender("modal", {
Cal.ns[namespace]("modal", {
calLink: routerUrl,
calOrigin: window.calOrigin,
config: {
"cal.embed.pageType": "team.event.booking.slots",
},
});
}
}
@@ -166,33 +236,44 @@
</script>
<div id="cal-booking-place-routingFormFullPrerender">
<div class="place"></div>
<form id="cal-booking-place-routingFormFullPrerender-form" onsubmit="window.routingFormFullPrerender.onFormSubmit(event)">
<label for="cal-booking-place-routingFormFullPrerender-input-name">Name</label>
<input required style="width: 400px" type="text" id="cal-booking-place-routingFormFullPrerender-input-name" placeholder="John Doe" />
<label for="cal-booking-place-routingFormFullPrerender-input-email">Email</label>
<input required style="width: 400px" type="email" id="cal-booking-place-routingFormFullPrerender-input-email" placeholder="[email protected]" />
<label for="cal-booking-place-routingFormFullPrerender-select-skills">Skills</label>
<select required style="width: 400px" id="cal-booking-place-routingFormFullPrerender-select-skills">
<option value="">Select a skill</option>
<option value="JavaScript">JavaScript</option>
<option value="React">React</option>
<option value="Node.js">Node.js</option>
<option value="Python">Python</option>
<option value="Sales">Sales</option>
</select>
<label for="cal-booking-place-routingFormFullPrerender-select-location">Location</label>
<select required style="width: 400px" id="cal-booking-place-routingFormFullPrerender-select-location">
<option value="">Select a location</option>
<option value="New York">New York</option>
<option value="London">London</option>
<option value="Tokyo">Tokyo</option>
<option value="Berlin">Berlin</option>
<option value="Remote">Remote</option>
</select>
<button type="submit" id="cta-routingFormFullPrerender">Submit</button>
</form>
</div>
<form id="cal-booking-place-routingFormFullPrerender-form" onsubmit="window.routingFormFullPrerender.onFormSubmit(event)" style="display: flex; flex-direction: column; gap: 16px; max-width: 500px; padding: 20px; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<div style="display: flex; flex-direction: column; gap: 6px;">
<label for="cal-booking-place-routingFormFullPrerender-input-name" style="font-weight: 500; color: #333; font-size: 14px;">Name</label>
<input required style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box;" type="text" id="cal-booking-place-routingFormFullPrerender-input-name" placeholder="John Doe" />
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<label for="cal-booking-place-routingFormFullPrerender-input-email" style="font-weight: 500; color: #333; font-size: 14px;">Email</label>
<input required style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box;" type="email" id="cal-booking-place-routingFormFullPrerender-input-email" placeholder="[email protected]" />
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<label for="cal-booking-place-routingFormFullPrerender-select-skills" style="font-weight: 500; color: #333; font-size: 14px;">Skills</label>
<select required style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; background: white;" id="cal-booking-place-routingFormFullPrerender-select-skills">
<option value="">Select a skill</option>
<option value="JavaScript">JavaScript</option>
<option value="React">React</option>
<option value="Node.js">Node.js</option>
<option value="Python">Python</option>
<option value="Sales">Sales</option>
</select>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<label for="cal-booking-place-routingFormFullPrerender-select-location" style="font-weight: 500; color: #333; font-size: 14px;">Location</label>
<select required style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; background: white;" id="cal-booking-place-routingFormFullPrerender-select-location">
<option value="">Select a location</option>
<option value="New York">New York</option>
<option value="London">London</option>
<option value="Tokyo">Tokyo</option>
<option value="Berlin">Berlin</option>
<option value="Remote">Remote</option>
</select>
</div>
<button type="submit" id="cta-routingFormFullPrerender" style="padding: 12px 24px; background: #007bff; color: white; border: none; border-radius: 4px; font-size: 16px; font-weight: 500; cursor: pointer; margin-top: 8px; transition: background 0.2s;">Submit</button>
</form>
</div>
</div>
@@ -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;
@@ -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<typeof vi.fn> | undefined;
let isBookerReadyMock: ReturnType<typeof vi.fn> | undefined;
let ensureQueryParamsInUrlMock: ReturnType<typeof vi.fn> | undefined;
let recordResponseIfQueuedMock: ReturnType<typeof vi.fn> | undefined;
beforeEach(async () => {
vi.useRealTimers();
fakeCurrentDocumentUrl();
vi.doMock("../embed-iframe/lib/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("../embed-iframe/lib/utils")>();
return {
...actual,
isLinkReady: isLinkReadyMock,
isBookerReady: isBookerReadyMock,
recordResponseIfQueued: recordResponseIfQueuedMock,
};
});
vi.doMock("../embed-iframe/lib/embedStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("../embed-iframe/lib/embedStore")>();
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");
});
});
});
@@ -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<typeof vi.fn> | undefined;
let isBookerReadyMock: ReturnType<typeof vi.fn> | undefined;
let ensureQueryParamsInUrlMock: ReturnType<typeof vi.fn> | undefined;
let recordResponseIfQueuedMock: ReturnType<typeof vi.fn> | undefined;
beforeEach(async () => {
vi.useRealTimers();
fakeCurrentDocumentUrl();
vi.doMock("../embed-iframe/lib/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("../embed-iframe/lib/utils")>();
return {
...actual,
isLinkReady: isLinkReadyMock,
isBookerReady: isBookerReadyMock,
recordResponseIfQueued: recordResponseIfQueuedMock,
};
});
vi.doMock("../embed-iframe/lib/embedStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("../embed-iframe/lib/embedStore")>();
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);
});
});
});
+25 -26
View File
@@ -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,
@@ -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");
});
});
});
@@ -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<typeof embedStore.pageData, "eventsState">;
/**
* Gets a direct property from pageData in a type-safe way
*/
export function getPageDataProp<K extends keyof PageDataDirectProps>(
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<K extends keyof PageDataDirectProps>(
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;
@@ -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<string, string | str
}
return routingFormResponseId;
};
/**
* embedStore dependency free version of getNamespace
* For internal use only
*/
function getNamespace() {
if (isBrowser) {
return window?.getEmbedNamespace?.() ?? null;
}
return null;
}
export 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") || process.env.INTEGRATION_TEST_MODE === "true") {
console.log("Child:", ...args);
}
}
}
@@ -0,0 +1,116 @@
"use client";
/**
* All hooks defined in this file must be client side hooks and must not be executed in server side.
* So, they should start with isBrowser check.
*/
import { sdkActionManager } from "../sdk-event";
import { embedStore, getEventHasFired, setEventHasFired, getReloadInitiated, setReloadInitiated } from "./lib/embedStore";
import { isBrowser, isPrerendering } from "./lib/utils";
/**
* Fires bookerViewed, bookerReopened, or bookerReloaded events
*/
const fireBookerViewedEvent = ({
eventId,
eventSlug,
slotsLoaded,
}: {
eventId: number | undefined;
eventSlug: string | undefined;
slotsLoaded: boolean;
}) => {
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,
});
}
};
+34 -7
View File
@@ -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;
};
@@ -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<string, never>;
/**
* 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<string, never>;
/**
* 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<string, never>;
/**
* 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<string, never>;
/**
* 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<string, never>;
/**
* Wildcard event that fires for all events.
* Purpose: Allow listening to all events with a single handler.
*/
"*": Record<string, unknown>;
/**
* 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<string, never>;
/**
* 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<string, never>;
/**
* Fired when iframe requests to be closed.
* Purpose: Request to close modal from within iframe.
* Actions: Parent sets modal state to "closed".
*/
__closeIframe: Record<string, never>;
/**
* 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.
+92
View File
@@ -0,0 +1,92 @@
sequenceDiagram
participant Parent as Parent Page<br/>(embed.ts)
participant PM as postMessage
participant Iframe as Iframe<br/>(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<br/>iframeDoQueue = []
Note over Iframe: Iframe starts loading
Note over Iframe: body tag hidden<br/>background: transparent
Note over Parent,Store: PHASE 2: Handshake Initiation
Iframe->>Store: main() called on load
Store->>Store: Initialize embedStore<br/>state: NOT_INITIALIZED
Store->>Store: Parse URL params<br/>(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<br/>renderState: "inProgress"
Note over Parent,Store: PHASE 3: __iframeReady Event
Iframe->>PM: fire("__iframeReady", { isPrerendering })
PM->>Parent: postMessage({ originator: "CAL",<br/>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",<br/>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<br/>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
@@ -0,0 +1,40 @@
flowchart TB
subgraph Parent["Parent Page (embed.ts)"]
direction TB
CalAPI["Cal API<br/>(Cal.inline, Cal.modal, Cal.ui)"]
ActionMgr["ActionManager<br/>(Event Listener)"]
DoInIframe["doInIframe()<br/>(Command Sender)"]
Queue["iframeDoQueue[]<br/>(Command Buffer)"]
CalAPI --> DoInIframe
DoInIframe -->|"if !iframeReady"| Queue
Queue -->|"flush on ready"| DoInIframe
end
subgraph Transport["postMessage Transport"]
direction TB
ToIframe["parent.postMessage()<br/>{originator: 'CAL', method, arg}"]
ToParent["iframe.postMessage()<br/>{originator: 'CAL', type, data}"]
end
subgraph Iframe["Iframe (embed-iframe.ts)"]
direction TB
MsgListener["window.addEventListener('message')"]
Interface["interfaceWithParent<br/>{ui, parentKnowsIframeReady, connect}"]
SDKAction["sdkActionManager<br/>(Event Emitter)"]
EmbedStore["embedStore<br/>(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
+6 -6
View File
@@ -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
@@ -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<br/>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<br/>to receive messages from parent<br/>Action: Makes iframe visible,<br/>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<br/>matched to content<br/>Triggers: Content size changes,<br/>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<br/>Ensures dimension calculations<br/>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<br/>is fully ready for interaction<br/>Requirements: Height known,<br/>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<br/>iframe readiness<br/>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<br/>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<br/>navigation within embed<br/>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<br/>when booker is first loaded<br/>Includes: Event info, slots status
UI->>Parent: bookerReady event
Note right of UI: Purpose: Signals booker view is loaded<br/>and slots are ready for interaction<br/>Includes: Event identification<br/>Only fires for booker pages
alt User navigates and returns
UI->>Parent: bookerReopened event
Note right of UI: Purpose: Tracks when booker is<br/>viewed again after navigation<br/>Distinguishes from first view
end
end
alt Error Handling
Embed->>Parent: linkFailed event
Note right of Embed: Purpose: Signals page load error<br/>Includes: Error code, URL info<br/>Action: Show error state
Note over Parent: Set loading="failed"
end
@@ -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<br/>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<br/>to receive messages from parent<br/>Action: Makes iframe visible<br/>(unless prerendering),<br/>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<br/>matched to content<br/>Triggers: Content size changes,<br/>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<br/>Ensures dimension calculations<br/>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<br/>is fully ready for interaction<br/>Requirements: Height known,<br/>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<br/>iframe readiness<br/>Action: Makes body visible,<br/>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<br/>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<br/>navigation within embed<br/>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<br/>on first modal open<br/>Includes: Event info, slots status<br/>Not fired during prerendering
UI->>Parent: bookerReady event
Note right of UI: Purpose: Signals booker view is loaded<br/>and slots are ready for interaction<br/>Includes: Event identification<br/>Only fires for booker pages
alt User closes and reopens modal
UI->>Parent: bookerReopened event
Note right of UI: Purpose: Tracks when booker is<br/>reopened after being closed<br/>Distinguishes from first view
end
alt Full reload scenario
Parent->>Embed: __reloadInitiated method
Note right of Parent: Purpose: Notify iframe that<br/>fullReload is happening<br/>Action: Set reloadInitiated flag
Note over Store: reloadInitiated: true
UI->>Parent: bookerReloaded event
Note right of UI: Purpose: Tracks when booker is<br/>reloaded (full page reload)<br/>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<br/>from within iframe<br/>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<br/>Includes: Error code, URL info<br/>Action: Show error state
Note over Parent: Set state="failed"
end
@@ -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<br/>State: NOT_INITIALIZED
Store->>Store: renderState: "inProgress"
Store->>Store: Initialize UI config & theme
Note over Store: Limited events allowed<br/>(__iframeReady, __dimensionChanged,<br/>__connectInitiated, linkPrerendered)<br/>No tracking events fired
Embed->>Parent: __iframeReady event
Note right of Embed: Purpose: Signals iframe is ready<br/>to receive messages<br/>Action: Sets iframeReady flag<br/>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<br/>matched to content<br/>Allows proper sizing<br/>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<br/>Ensures dimension calculations<br/>are reliable
Note over Embed: Load booker (slots may be skipped)
Embed->>Parent: linkReady event (internal)
Note right of Embed: Purpose: Internal signal for<br/>prerender completion<br/>Not exposed to user
Note over Parent: parentKnowsIframeReady called
Parent->>Embed: parentKnowsIframeReady event
Note right of Parent: Purpose: Parent acknowledges<br/>iframe readiness<br/>Action: Makes body visible,<br/>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<br/>is ready in background<br/>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<br/>to activate prerendered embed<br/>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<br/>has finished updating embed<br/>URL params updated, slots ready
Embed->>Parent: linkReady event (isPrerendered: true)
Note right of Embed: Purpose: Signals iframe content<br/>is fully ready for interaction<br/>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<br/>Includes: Event info, slots status<br/>Now enabled (was suppressed)
UI->>Parent: bookerReady event
Note right of UI: Purpose: Signals booker view is loaded<br/>and slots are ready for interaction<br/>Includes: Event identification<br/>Only fires for booker pages
alt User closes and reopens modal
UI->>Parent: bookerReopened event
Note right of UI: Purpose: Tracks when booker is<br/>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<br/>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<br/>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<br/>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<br/>Includes: Error code, URL info
Note over Parent: Set state="failed"
end
@@ -122,5 +122,6 @@ export const useScheduleForEvent = ({
isSuccess: schedule?.isSuccess,
isLoading: schedule?.isLoading,
invalidate: schedule?.invalidate,
dataUpdatedAt: schedule?.dataUpdatedAt,
};
};
@@ -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<Awaited<ReturnType<typeof getPublicEvent>>>;
};
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(() => {