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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user