diff --git a/packages/app-store/BookingPageTagManager.test.tsx b/packages/app-store/BookingPageTagManager.test.tsx
new file mode 100644
index 0000000000..1e55a05999
--- /dev/null
+++ b/packages/app-store/BookingPageTagManager.test.tsx
@@ -0,0 +1,207 @@
+import { render, screen, cleanup } from "@testing-library/react";
+import { vi } from "vitest";
+
+import BookingPageTagManager, { handleEvent } from "./BookingPageTagManager";
+
+// NOTE: We don't intentionally mock appStoreMetadata as that also tests config.json and generated files for us for no cost. If it becomes a pain in future, we could just start mocking it.
+
+vi.mock("next/script", () => {
+ return {
+ default: ({ ...props }) => {
+ return
;
+ },
+ };
+});
+
+const windowProps: string[] = [];
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function setOnWindow(prop: any, value: any) {
+ window[prop] = value;
+ windowProps.push(prop);
+}
+
+afterEach(() => {
+ windowProps.forEach((prop) => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ //@ts-ignore
+ delete window[prop];
+ });
+ windowProps.splice(0);
+ cleanup();
+});
+
+describe("BookingPageTagManager", () => {
+ it("GTM App when enabled should have its scripts added with appropriate trackingID and $pushEvent replacement", () => {
+ const GTM_CONFIG = {
+ enabled: true,
+ trackingId: "GTM-123",
+ };
+ render(
+
+ );
+ const scripts = screen.getAllByTestId("cal-analytics-app-gtm");
+ const trackingScript = scripts[0];
+ const pushEventScript = scripts[1];
+ expect(trackingScript.innerHTML).toContain(GTM_CONFIG.trackingId);
+ expect(pushEventScript.innerHTML).toContain("cal_analytics_app__gtm");
+ });
+
+ it("GTM App when disabled should not have its scripts added", () => {
+ const GTM_CONFIG = {
+ enabled: false,
+ trackingId: "GTM-123",
+ };
+ render(
+
+ );
+ const scripts = screen.queryAllByTestId("cal-analytics-app-gtm");
+ expect(scripts.length).toBe(0);
+ });
+
+ it("should not add scripts for an app that doesnt have tag defined(i.e. non-analytics app)", () => {
+ render(
+
+ );
+ const scripts = screen.queryAllByTestId("cal-analytics-app-zoomvideo");
+ expect(scripts.length).toBe(0);
+ });
+
+ it("should not crash for an app that doesnt exist", () => {
+ render(
+
+ );
+ const scripts = screen.queryAllByTestId("cal-analytics-app-zoomvideo");
+ expect(scripts.length).toBe(0);
+ });
+});
+
+describe("handleEvent", () => {
+ it("should not push internal events to analytics apps", () => {
+ expect(
+ handleEvent({
+ detail: {
+ // Internal event
+ type: "__abc",
+ },
+ })
+ ).toBe(false);
+
+ expect(
+ handleEvent({
+ detail: {
+ // Not an internal event
+ type: "_abc",
+ },
+ })
+ ).toBe(true);
+ });
+
+ it("should call the function on window with the event name and data", () => {
+ const pushEventXyz = vi.fn();
+ const pushEventAnything = vi.fn();
+ const pushEventRandom = vi.fn();
+ const pushEventNotme = vi.fn();
+
+ setOnWindow("cal_analytics_app__xyz", pushEventXyz);
+ setOnWindow("cal_analytics_app__anything", pushEventAnything);
+ setOnWindow("cal_analytics_app_random", pushEventRandom);
+ setOnWindow("cal_analytics_notme", pushEventNotme);
+
+ handleEvent({
+ detail: {
+ type: "abc",
+ key: "value",
+ },
+ });
+
+ expect(pushEventXyz).toHaveBeenCalledWith({
+ name: "abc",
+ data: {
+ key: "value",
+ },
+ });
+
+ expect(pushEventAnything).toHaveBeenCalledWith({
+ name: "abc",
+ data: {
+ key: "value",
+ },
+ });
+
+ expect(pushEventRandom).toHaveBeenCalledWith({
+ name: "abc",
+ data: {
+ key: "value",
+ },
+ });
+
+ expect(pushEventNotme).not.toHaveBeenCalled();
+ });
+
+ it("should not error if accidentally the value is not a function", () => {
+ const pushEventNotAfunction = "abc";
+ const pushEventAnything = vi.fn();
+ setOnWindow("cal_analytics_app__notafun", pushEventNotAfunction);
+ setOnWindow("cal_analytics_app__anything", pushEventAnything);
+
+ handleEvent({
+ detail: {
+ type: "abc",
+ key: "value",
+ },
+ });
+
+ // No error for cal_analytics_app__notafun and pushEventAnything is called
+ expect(pushEventAnything).toHaveBeenCalledWith({
+ name: "abc",
+ data: {
+ key: "value",
+ },
+ });
+ });
+});
diff --git a/packages/app-store/BookingPageTagManager.tsx b/packages/app-store/BookingPageTagManager.tsx
index e8349e80f7..b8dcefba97 100644
--- a/packages/app-store/BookingPageTagManager.tsx
+++ b/packages/app-store/BookingPageTagManager.tsx
@@ -2,28 +2,93 @@ import Script from "next/script";
import { getEventTypeAppData } from "@calcom/app-store/_utils/getEventTypeAppData";
import { appStoreMetadata } from "@calcom/app-store/bookerAppsMetaData";
+import type { Tag } from "@calcom/app-store/types";
+import { sdkActionManager } from "@calcom/lib/sdk-event";
+import type { AppMeta } from "@calcom/types/App";
import type { appDataSchemas } from "./apps.schemas.generated";
+const PushEventPrefix = "cal_analytics_app_";
+
+// AnalyticApp has appData.tag always set
+type AnalyticApp = Omit & {
+ appData: Omit, "tag"> & {
+ tag: NonNullable["tag"]>;
+ };
+};
+
+const getPushEventScript = ({ tag, appId }: { tag: Tag; appId: string }) => {
+ if (!tag.pushEventScript) {
+ return tag.pushEventScript;
+ }
+
+ return {
+ ...tag.pushEventScript,
+ // In case of complex pushEvent implementations, we could think about exporting a pushEvent function from the analytics app maybe but for now this should suffice
+ content: tag.pushEventScript?.content?.replace("$pushEvent", `${PushEventPrefix}_${appId}`),
+ };
+};
+
+function getAnalyticsApps(eventType: Parameters[0]) {
+ return Object.entries(appStoreMetadata).reduce(
+ (acc, entry) => {
+ const [appId, app] = entry;
+ const eventTypeAppData = getEventTypeAppData(eventType, appId as keyof typeof appDataSchemas);
+
+ if (!eventTypeAppData || !app.appData?.tag) {
+ return acc;
+ }
+
+ acc[appId] = {
+ meta: app as AnalyticApp,
+ eventTypeAppData: eventTypeAppData,
+ };
+ return acc;
+ },
+ {} as Record<
+ string,
+ {
+ meta: AnalyticApp;
+ eventTypeAppData: ReturnType;
+ }
+ >
+ );
+}
+
+export function handleEvent(event: { detail: Record & { type: string } }) {
+ const { type: name, ...data } = event.detail;
+ // Don't push internal events to analytics apps
+ // They are meant for internal use like helping embed make some decisions
+ if (name.startsWith("__")) {
+ return false;
+ }
+
+ Object.entries(window).forEach(([prop, value]) => {
+ if (!prop.startsWith(PushEventPrefix) || typeof value !== "function") {
+ return;
+ }
+ // Find the pushEvent if defined by the analytics app
+ const pushEvent = window[prop as keyof typeof window];
+
+ pushEvent({
+ name,
+ data,
+ });
+ });
+
+ return true;
+}
+
export default function BookingPageTagManager({
eventType,
}: {
eventType: Parameters[0];
}) {
+ const analyticsApps = getAnalyticsApps(eventType);
return (
<>
- {Object.entries(appStoreMetadata).map(([appId, app]) => {
- const tag = app.appData?.tag;
- if (!tag) {
- return null;
- }
-
- const appData = getEventTypeAppData(eventType, appId as keyof typeof appDataSchemas);
-
- if (!appData) {
- return null;
- }
-
+ {Object.entries(analyticsApps).map(([appId, { meta: app, eventTypeAppData }]) => {
+ const tag = app.appData.tag;
const parseValue = (val: T): T => {
if (!val) {
return val;
@@ -34,18 +99,19 @@ export default function BookingPageTagManager({
let matches;
while ((matches = regex.exec(val))) {
const variableName = matches[1];
- if (appData[variableName]) {
+ if (eventTypeAppData[variableName]) {
// Replace if value is available. It can possible not be a template variable that just matches the regex.
val = val.replace(
new RegExp(`{${variableName}}`, "g"),
- appData[variableName]
+ eventTypeAppData[variableName]
) as NonNullable;
}
}
return val;
};
- return tag.scripts.map((script, index) => {
+ const pushEventScript = getPushEventScript({ tag, appId });
+ return tag.scripts.concat(pushEventScript ? [pushEventScript] : []).map((script, index) => {
const parsedAttributes: NonNullable<(typeof tag.scripts)[number]["attrs"]> = {};
const attrs = script.attrs || {};
Object.entries(attrs).forEach(([name, value]) => {
@@ -57,6 +123,7 @@ export default function BookingPageTagManager({
return (
);
}
+
+if (typeof window !== "undefined") {
+ // Attach listener outside React as it has to be attached only once per page load
+ // Setup listener for all events to push to analytics apps
+ sdkActionManager?.on("*", handleEvent);
+}
diff --git a/packages/app-store/gtm/config.json b/packages/app-store/gtm/config.json
index f535c680e8..dd9cfc958f 100644
--- a/packages/app-store/gtm/config.json
+++ b/packages/app-store/gtm/config.json
@@ -16,7 +16,10 @@
{
"content": "(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','{TRACKING_ID}');"
}
- ]
+ ],
+ "pushEventScript": {
+ "content": "function $pushEvent(event) {window.dataLayer.push({ event: event.name, ...event.data })}"
+ }
}
},
"isTemplate": false,
diff --git a/packages/app-store/types.d.ts b/packages/app-store/types.d.ts
index 0eb598525f..060bbd4750 100644
--- a/packages/app-store/types.d.ts
+++ b/packages/app-store/types.d.ts
@@ -32,6 +32,7 @@ type AppScript = { attrs?: Record } & { src?: string; content?:
export type Tag = {
scripts: AppScript[];
+ pushEventScript?: AppScript;
};
export interface InstallAppButtonProps {
diff --git a/packages/embeds/embed-core/src/sdk-action-manager.ts b/packages/embeds/embed-core/src/sdk-action-manager.ts
index 95d82aecf4..246cd7f63f 100644
--- a/packages/embeds/embed-core/src/sdk-action-manager.ts
+++ b/packages/embeds/embed-core/src/sdk-action-manager.ts
@@ -22,6 +22,19 @@ export type EventDataMap = {
};
};
linkReady: Record;
+ bookingSuccessfulV2: {
+ uid: string | undefined;
+ title: string | undefined;
+ startTime: string | undefined;
+ endTime: string | undefined;
+ eventTypeId: number | null | undefined;
+ status: string | undefined;
+ paymentRequired: boolean;
+ };
+
+ /**
+ * @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.
+ */
bookingSuccessful: {
// TODO: Shouldn't send the entire booking and eventType objects, we should send specific fields from them.
booking: unknown;
@@ -35,6 +48,18 @@ export type EventDataMap = {
};
confirmed: boolean;
};
+ rescheduleBookingSuccessfulV2: {
+ uid: string | undefined;
+ title: string | undefined;
+ startTime: string | undefined;
+ endTime: string | undefined;
+ eventTypeId: number | null | undefined;
+ status: string | undefined;
+ paymentRequired: boolean;
+ };
+ /**
+ * @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.
+ */
rescheduleBookingSuccessful: {
booking: unknown;
eventType: unknown;
diff --git a/packages/features/bookings/Booker/components/hooks/useBookings.ts b/packages/features/bookings/Booker/components/hooks/useBookings.ts
index c348ccfb5e..f0b15f4817 100644
--- a/packages/features/bookings/Booker/components/hooks/useBookings.ts
+++ b/packages/features/bookings/Booker/components/hooks/useBookings.ts
@@ -104,7 +104,15 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
: duration && event.data?.metadata?.multipleDuration?.includes(duration)
? duration
: event.data?.length;
-
+ const eventPayload = {
+ uid: responseData.uid,
+ title: responseData.title,
+ startTime: responseData.startTime,
+ endTime: responseData.endTime,
+ eventTypeId: responseData.eventTypeId,
+ status: responseData.status,
+ paymentRequired: responseData.paymentRequired,
+ };
if (isRescheduling) {
sdkActionManager?.fire("rescheduleBookingSuccessful", {
booking: responseData,
@@ -118,6 +126,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
},
confirmed: !(responseData.status === BookingStatus.PENDING && event.data?.requiresConfirmation),
});
+ sdkActionManager?.fire("rescheduleBookingSuccessfulV2", eventPayload);
} else {
sdkActionManager?.fire("bookingSuccessful", {
booking: responseData,
@@ -131,6 +140,8 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
},
confirmed: !(responseData.status === BookingStatus.PENDING && event.data?.requiresConfirmation),
});
+
+ sdkActionManager?.fire("bookingSuccessfulV2", eventPayload);
}
if (paymentUid) {
diff --git a/packages/features/bookings/lib/create-booking.ts b/packages/features/bookings/lib/create-booking.ts
index e7931723c3..cd0bc28491 100644
--- a/packages/features/bookings/lib/create-booking.ts
+++ b/packages/features/bookings/lib/create-booking.ts
@@ -3,6 +3,12 @@ import { post } from "@calcom/lib/fetch-wrapper";
import type { BookingCreateBody, BookingResponse } from "../types";
export const createBooking = async (data: BookingCreateBody) => {
- const response = await post("/api/book/event", data);
+ const response = await post<
+ Omit,
+ BookingResponse & {
+ startTime: string;
+ endTime: string;
+ }
+ >("/api/book/event", data);
return response;
};
diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts
index 6b45ed5b58..cef165bc7b 100644
--- a/packages/features/bookings/lib/handleNewBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking.ts
@@ -1731,8 +1731,8 @@ async function handler(
...newBooking.user,
email: null,
},
+ paymentRequired: false,
};
-
return {
...bookingResponse,
...luckyUserResponse,
@@ -2348,7 +2348,7 @@ async function handler(
req.statusCode = 201;
// TODO: Refactor better so this booking object is not passed
// all around and instead the individual fields are sent as args.
- const bookingReponse = {
+ const bookingResponse = {
...booking,
user: {
...booking.user,
@@ -2357,9 +2357,10 @@ async function handler(
};
return {
- ...bookingReponse,
+ ...bookingResponse,
...luckyUserResponse,
message: "Payment required",
+ paymentRequired: true,
paymentUid: payment?.uid,
paymentId: payment?.id,
};
@@ -2499,6 +2500,7 @@ async function handler(
...booking.user,
email: null,
},
+ paymentRequired: false,
};
return {
diff --git a/packages/lib/sdk-event.ts b/packages/lib/sdk-event.ts
new file mode 100644
index 0000000000..27ff7653e5
--- /dev/null
+++ b/packages/lib/sdk-event.ts
@@ -0,0 +1,4 @@
+// We can't use sdkActionManager without embed because sdkActionManager needs embed namespace to be able to inform the correct embed namespace in parent
+// We should plan to create 2 sdkActionManager instances and fire events on both of them if we want to remove dependency on embed
+// Because right now(and in near future) we need only those events which are fired from embed, we are fine
+export { sdkActionManager } from "@calcom/embed-core/embed-iframe";
diff --git a/packages/platform/atoms/booker/BookerWebWrapper.tsx b/packages/platform/atoms/booker/BookerWebWrapper.tsx
index b8cb2616be..68727a3b78 100644
--- a/packages/platform/atoms/booker/BookerWebWrapper.tsx
+++ b/packages/platform/atoms/booker/BookerWebWrapper.tsx
@@ -41,6 +41,7 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
const date = dayjs(selectedDate).format("YYYY-MM-DD");
useEffect(() => {
+ // This event isn't processed by BookingPageTagManager because BookingPageTagManager hasn't loaded when it is fired. I think we should have a queue in fire method to handle this.
sdkActionManager?.fire("navigatedToBooker", {});
}, []);
diff --git a/vitest.workspace.ts b/vitest.workspace.ts
index cfb6070c38..9226ff0f37 100644
--- a/vitest.workspace.ts
+++ b/vitest.workspace.ts
@@ -120,6 +120,15 @@ const workspaces = packagedEmbedTestsOnly
setupFiles: ["packages/app-store/closecom/test/globals.ts"],
},
},
+ {
+ test: {
+ globals: true,
+ name: "@calcom/app-store-core",
+ include: ["packages/app-store/*.{test,spec}.[jt]s?(x)"],
+ environment: "jsdom",
+ setupFiles: ["packages/ui/components/test-setup.ts"],
+ },
+ },
{
test: {
globals: true,