tcampb / react-calendly

Calendly integration for React apps
MIT License
240 stars 56 forks source link

Events not properly encoded, only PAGE_HEIGHT works #190

Closed wsdt closed 1 month ago

wsdt commented 1 month ago
    PROFILE_PAGE_VIEWED = "calendly.profile_page_viewed",
    EVENT_TYPE_VIEWED = "calendly.event_type_viewed",
    DATE_AND_TIME_SELECTED = "calendly.date_and_time_selected",
    EVENT_SCHEDULED = "calendly.event_scheduled",
    PAGE_HEIGHT = "calendly.page_height",

When using this we consistently only receive the onPageHeight event such as On page height {"height": "602px"} when logging to the console.

Other than that we only receive events of type "undefined" such as:

{"canGoBack": false, "canGoForward": false, "data": "\"{\\\"originatingScript\\\":\\\"m2\\\",\\\"payload\\\":{\\\"guid\\\":\\\"5fed1a94-6532-49f5-81bc-fa79318799530 f36f3\\\",\\\"muid\\\":\\\"0e990be4-3f1f-4e35-a502-e5f66a32da67a2e3a0\\\",\\\"sid\\\":\\\"e5b8189a-0928-4837-be2f-31357f50385a692760\\\"}}\"", "loading": false, "target": 89, "title": "Select a Date & Time - Calendly", "url": "https://calendly.com/.............?hide_event_type_details=1&hide_landing_page_details=1&primary_color=36797d&hide_gdpr_banner=1&utm_source=MobileApp&embed_type=Inline&embed_domain=1&month=2024-09"}

Are these CalendlyEvent selectors still up-to-date? Thank you in advance!

wsdt commented 1 month ago

They are basically just not fired.

tcampb commented 1 month ago

Hey @wsdt,

Would you be able to provide the calendly URL you are testing? I'm not able to reproduce the issue; when using my link I'm seeing all events fired.

wsdt commented 1 month ago

https://calendly.com/wavect/safety-call

We were able to capture it ourselves by listening to onSubmit but not the Calendly Events.

tcampb commented 1 month ago

Thanks for sending this over, I tested the URL in this project - https://stackblitz.com/edit/stackblitz-starters-7cdiye?file=src%2FApp.tsx - and I was able to see the events in the console. Can you confirm that this example project works for you as well?

wsdt commented 1 month ago
window.addEventListener('message', function(e) {
        window.ReactNativeWebView.postMessage(JSON.stringify(e.data));
      });

We are capturing the events through this right now as we are using it through a WebView/IFrame.

The PageHeight works, but the others don't. Are they emitted differently?

tcampb commented 1 month ago

Yes, Calendly only sends the other events if the embed_domain query parameter is defined. If you are embedding this in a mobile app it's possible that the domain is undefined.

Could you try adding this query param to your url, for example:

https://calendly.com/wavect/safety-call?embed_domain=1

tcampb commented 1 month ago

Also, I believe they require an embed_type as well, so the full url would be:

https://calendly.com/wavect/safety-call?embed_domain=1&embed_type=Inline

wsdt commented 1 month ago

https://calendly.com/wavect/safety-call?hide_event_type_details=1&hide_landing_page_details=1&primary_color=36797d&hide_gdpr_banner=1&utm_source=MobileApp&embed_type=Inline&embed_domain=1&month=2024-09

That's the one I'm using to be more specific.

tcampb commented 1 month ago

Looking at Calendly's client code I do see a difference in how the events are emitted.

Page height event:

window.parent.postMessage(data, '*')

Other events:

if (window.parent !== window) {
   window.parent.postMessage(data, '*')
}

I'm not too familiar with react native, but I'm wondering if the window.parent and window are the same object in this context?

wsdt commented 1 month ago

Interesting!

Tried it now this way, but it also didn't work interestingly.

       function captureMessage(event) {
    window.ReactNativeWebView.postMessage(JSON.stringify(event.data));
  }

  // Capture messages sent to window
  window.addEventListener('message', captureMessage);

  // Capture messages sent to window.parent
  var originalPostMessage = window.parent.postMessage;
  window.parent.postMessage = function(message, targetOrigin, transfer) {
    captureMessage({data: message});
    originalPostMessage.call(this, message, targetOrigin, transfer);
  };
tcampb commented 1 month ago

@wsdt - how are you rendering the iframe in your mobile app? Are you using react-native-webview (https://docs.expo.dev/versions/latest/sdk/webview/)?

If so, are you passing in the html or a uri?

wsdt commented 1 month ago

This is what I'm doing, yes:


import { TR, i18n } from "@/localization/translate.service";
import React, { FC, useRef, useState } from "react";
import { DimensionValue } from "react-native";
import { WebView, WebViewMessageEvent } from "react-native-webview";
import {
    CalendlyEvent,
    IframeTitle,
    LoadingSpinner,
    PageSettings,
    Prefill,
    Utm,
    formatCalendlyUrl,
} from "./OCalendlyBase";
import styles from "./OCalendlyInline.styles";
import CalendlyLoadingSpinner from "./OCalendlyLoadingSpinner";

export interface Props {
    url: string;
    prefill?: Prefill;
    utm?: Utm;
    styles?: React.CSSProperties | undefined;
    pageSettings?: PageSettings;
    iframeTitle?: IframeTitle;
    LoadingSpinner?: LoadingSpinner;
    onEventScheduled?: (event: any) => void;
    onDateAndTimeSelected?: (event: any) => void;
    onEventTypeViewed?: (event: any) => void;
    onProfilePageViewed?: (event: any) => void;
    onPageHeight?: (event: any) => void;
}

const OCalendlyInline: FC<Props> = ({
    url,
    prefill,
    utm,
    pageSettings,
    iframeTitle,
    LoadingSpinner = CalendlyLoadingSpinner,
    onEventScheduled,
    onDateAndTimeSelected,
    onEventTypeViewed,
    onProfilePageViewed,
    onPageHeight,
}) => {
    const [isLoading, setIsLoading] = useState(true);
    const [webViewHeight, setWebViewHeight] = useState<DimensionValue>();
    const webViewRef = useRef<WebView>(null);

    // @dev This one might be more native/cleaner, but doesn't work for the Calendly events (except of PageHeight somehow)
    const injectedJavaScript = `
    (function() {
      window.addEventListener('message', function(e) {
        window.ReactNativeWebView.postMessage(JSON.stringify(e.data));
      });
      window.addEventListener('submit', event => {
                window.ReactNativeWebView.postMessage(JSON.stringify({event: event.type,payload:event}));
            });
      true;
    })();
  `;

    const handleMessage = (event: WebViewMessageEvent) => {
        try {
            const data = JSON.parse(event.nativeEvent.data);

            switch (data.event) {
                /** @dev Calendly is not correctly emitting the events except of PageHeight, for that reason we also just listen to "submit" */
                case "submit" || CalendlyEvent.EVENT_SCHEDULED:
                    onEventScheduled?.(data.payload);
                    break;
                case CalendlyEvent.DATE_AND_TIME_SELECTED:
                    onDateAndTimeSelected?.(data.payload);
                    break;
                case CalendlyEvent.EVENT_TYPE_VIEWED:
                    onEventTypeViewed?.(data.payload);
                    break;
                case CalendlyEvent.PROFILE_PAGE_VIEWED:
                    onProfilePageViewed?.(data.payload);
                    break;
                case CalendlyEvent.PAGE_HEIGHT:
                    setWebViewHeight(
                        parseInt(data.payload.height.replace("px", "")),
                    );
                    onPageHeight?.(data.payload);
                    break;
            }
        } catch (error) {
            console.error("Failed to parse WebView message:", error);
        }
    };

    const src = formatCalendlyUrl({
        url,
        pageSettings,
        prefill,
        utm,
        embedType: "Inline",
    });

    return (
        <>
            {isLoading && <LoadingSpinner />}
            <WebView
                ref={webViewRef}
                style={[styles.webView, { height: webViewHeight }]}
                source={{ uri: src }}
                originWhitelist={["*"]}
                onLoadEnd={() => setIsLoading(false)}
                injectedJavaScript={injectedJavaScript}
                onMessage={handleMessage}
                javaScriptEnabled={true}
                domStorageEnabled={true}
                startInLoadingState={true}
                scalesPageToFit={true}
                onError={(syntheticEvent) => {
                    const { nativeEvent } = syntheticEvent;
                    console.error("WebView error: ", nativeEvent);
                }}
                onHttpError={(syntheticEvent) => {
                    const { nativeEvent } = syntheticEvent;
                    console.error("WebView HTTP error: ", nativeEvent);
                }}
                title={iframeTitle || i18n.t(TR.calendlySchedulingPageDefault)}
            />
        </>
    );
};

export default OCalendlyInline;
tcampb commented 1 month ago

@wsdt - would you be able to use the html prop instead of injectedJavaScript? I'm not sure why this is the case, but I was able to get the events to fire using the following code:

const html = `
<script>
window.addEventListener('message', function(e) {
        window.ReactNativeWebView.postMessage(JSON.stringify(e.data));
      });
</script>
<iframe width="100%" height="100%" src="https://calendly.com/calforce-support?embed_domain=1&embed_type=Inline"
`

<Webview source={{ html }} onMessage={(event) => alert(event.nativeEvent.data)} />
wsdt commented 1 month ago

    const src = formatCalendlyUrl({
        url,
        pageSettings,
        prefill,
        utm,
        embedType: "Inline",
    });

    const html = `
<script>
window.addEventListener('message', function(e) {
        window.ReactNativeWebView.postMessage(JSON.stringify(e.data));
      });
</script>
<iframe width="100%" height="100%" src={src} />
`

    return (
        <>
            {isLoading && <LoadingSpinner />}
            <WebView
                ref={webViewRef}
                style={[styles.webView, { height: webViewHeight }]}
                source={{ html }}
                originWhitelist={["*"]}
                onLoadEnd={() => setIsLoading(false)}
                onMessage={handleMessage}
                javaScriptEnabled={true}
                domStorageEnabled={true}
                startInLoadingState={true}
                scalesPageToFit={true}
                onError={(syntheticEvent) => {
                    const { nativeEvent } = syntheticEvent;
                    console.error("WebView error: ", nativeEvent);
                }}
                onHttpError={(syntheticEvent) => {
                    const { nativeEvent } = syntheticEvent;
                    console.error("WebView HTTP error: ", nativeEvent);
                }}
                title={iframeTitle || i18n.t(TR.calendlySchedulingPageDefault)}
            />
        </>
    );
Unfortunately I just receive a blank screen with this approach.
tcampb commented 1 month ago

@wsdt - from the code I see above I'm not able to reproduce the issue. Do you happen to have a public github repository that I could pull down to recreate the issue? I'm using the latest version of RN and webview, not sure if that changes anything.

wsdt commented 1 month ago

@tcampb unfortunately not. But here otherwise the full code:

import { TR, i18n } from "@/localization/translate.service";
import React, { FC, useRef, useState } from "react";
import { DimensionValue } from "react-native";
import { WebView, WebViewMessageEvent } from "react-native-webview";
import {
    CalendlyEvent,
    IframeTitle,
    LoadingSpinner,
    PageSettings,
    Prefill,
    Utm,
    formatCalendlyUrl,
} from "./OCalendlyBase";
import styles from "./OCalendlyInline.styles";
import CalendlyLoadingSpinner from "./OCalendlyLoadingSpinner";

export interface Props {
    url: string;
    prefill?: Prefill;
    utm?: Utm;
    styles?: React.CSSProperties | undefined;
    pageSettings?: PageSettings;
    iframeTitle?: IframeTitle;
    LoadingSpinner?: LoadingSpinner;
    onEventScheduled?: (event: any) => void;
    onDateAndTimeSelected?: (event: any) => void;
    onEventTypeViewed?: (event: any) => void;
    onProfilePageViewed?: (event: any) => void;
    onPageHeight?: (event: any) => void;
}

const OCalendlyInline: FC<Props> = ({
    url,
    prefill,
    utm,
    pageSettings,
    iframeTitle,
    LoadingSpinner = CalendlyLoadingSpinner,
    onEventScheduled,
    onDateAndTimeSelected,
    onEventTypeViewed,
    onProfilePageViewed,
    onPageHeight,
}) => {
    const [isLoading, setIsLoading] = useState(true);
    const [webViewHeight, setWebViewHeight] = useState<DimensionValue>();
    const webViewRef = useRef<WebView>(null);

    // @dev This one might be more native/cleaner, but doesn't work for the Calendly events (except of PageHeight somehow)
    const injectedJavaScript = `
    (function() {
      window.addEventListener('message', function(e) {
        window.ReactNativeWebView.postMessage(JSON.stringify(e.data));
      });
      window.addEventListener('submit', event => {
                window.ReactNativeWebView.postMessage(JSON.stringify({event: event.type,payload:event}));
            });
      true;
    })();
  `;

    const handleMessage = (event: WebViewMessageEvent) => {
        try {
            const data = JSON.parse(event.nativeEvent.data);

            switch (data.event) {
                /** @dev Calendly is not correctly emitting the events except of PageHeight, for that reason we also just listen to "submit" */
                case "submit" || CalendlyEvent.EVENT_SCHEDULED:
                    onEventScheduled?.(data.payload);
                    break;
                case CalendlyEvent.DATE_AND_TIME_SELECTED:
                    onDateAndTimeSelected?.(data.payload);
                    break;
                case CalendlyEvent.EVENT_TYPE_VIEWED:
                    onEventTypeViewed?.(data.payload);
                    break;
                case CalendlyEvent.PROFILE_PAGE_VIEWED:
                    onProfilePageViewed?.(data.payload);
                    break;
                case CalendlyEvent.PAGE_HEIGHT:
                    setWebViewHeight(
                        parseInt(data.payload.height.replace("px", "")),
                    );
                    onPageHeight?.(data.payload);
                    break;
            }
        } catch (error) {
            console.error("Failed to parse WebView message:", error);
        }
    };

    const src = formatCalendlyUrl({
        url,
        pageSettings,
        prefill,
        utm,
        embedType: "Inline",
    });

    return (
        <>
            {isLoading && <LoadingSpinner />}
            <WebView
                ref={webViewRef}
                style={[styles.webView, { height: webViewHeight }]}
                source={{ uri: src }}
                originWhitelist={["*"]}
                onLoadEnd={() => setIsLoading(false)}
                injectedJavaScript={injectedJavaScript}
                onMessage={handleMessage}
                javaScriptEnabled={true}
                domStorageEnabled={true}
                startInLoadingState={true}
                scalesPageToFit={true}
                onError={(syntheticEvent) => {
                    const { nativeEvent } = syntheticEvent;
                    console.error("WebView error: ", nativeEvent);
                }}
                onHttpError={(syntheticEvent) => {
                    const { nativeEvent } = syntheticEvent;
                    console.error("WebView HTTP error: ", nativeEvent);
                }}
                title={iframeTitle || i18n.t(TR.calendlySchedulingPageDefault)}
            />
        </>
    );
};

export default OCalendlyInline;
import { sanitizePageSettingsProps } from "./OPropHelpers";

type Optional<T extends object> = {
    [P in keyof T]?: T[P];
};

export type Prefill = Optional<{
    name: string;
    email: string;
    firstName: string;
    lastName: string;
    smsReminderNumber: string;
    location: string;
    guests: string[];
    customAnswers: Optional<{
        a1: string;
        a2: string;
        a3: string;
        a4: string;
        a5: string;
        a6: string;
        a7: string;
        a8: string;
        a9: string;
        a10: string;
    }>;
    date: Date;
}>;

export enum CalendlyEvent {
    PROFILE_PAGE_VIEWED = "calendly.profile_page_viewed",
    EVENT_TYPE_VIEWED = "calendly.event_type_viewed",
    DATE_AND_TIME_SELECTED = "calendly.date_and_time_selected",
    EVENT_SCHEDULED = "calendly.event_scheduled",
    PAGE_HEIGHT = "calendly.page_height",
}

export type Utm = Optional<{
    utmCampaign: string;
    utmSource: string;
    utmMedium: string;
    utmContent: string;
    utmTerm: string;
    salesforce_uuid: string;
}>;

/**
 * @description The default title is Calendly Scheduling Page
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title}
 */
export type IframeTitle = string;

/**
 * @description LoadingSpinner is a React component that will be displayed while the Calendly iframe is loading. If no component is provided, the default Calendly loading spinner will be used.
 */
export type LoadingSpinner = React.FunctionComponent;

export type PageSettings = Optional<{
    /**
     * @description Use this setting to hide your profile picture, name, event duration, location, and description when Calendly is embedded. This will help reduce duplicate information that you may already have on your web page.
     * @see {@link https://help.calendly.com/hc/en-us/articles/360020052833-Advanced-embed-options#2} for further information.
     */
    hideLandingPageDetails: boolean;
    /**
     * @description Use this setting to hide your profile picture, name, event duration, location, and description when Calendly is embedded. This will help reduce duplicate information that you may already have on your web page.
     * @see {@link https://help.calendly.com/hc/en-us/articles/360020052833-Advanced-embed-options#2} for further information.
     */
    hideEventTypeDetails: boolean;
    /**
     * @description This setting is only available for Calendly users on the Pro plan. Use this setting to change your Calendly scheduling page's background color.
     * @example 00a2ff
     * @see {@link https://help.calendly.com/hc/en-us/articles/223147027-Embed-options-overview#3} for further information.
     */
    backgroundColor: string;
    /**
     * @description This setting is only available for Calendly users on the Pro plan. Use this setting to change your Calendly scheduling page's text color.
     * @example ffffff
     * @see {@link https://help.calendly.com/hc/en-us/articles/223147027-Embed-options-overview#3} for further information.
     */
    textColor: string;
    /**
     * @description This setting is only available for Calendly users on the Pro plan. Use this setting to change your Calendly scheduling page's primary color.
     * @example 4d5055
     * @see {@link https://help.calendly.com/hc/en-us/articles/223147027-Embed-options-overview#3} for further information.
     */
    primaryColor: string;
    /**
     * @description The General Data Protection Regulation governs data protection in the EU and EEA. Certain Calendly integrations require access to cookies with user information. If you do not embed the GDPR banner, users in those areas will not have the ability to give their consent in order to access integrations such as Google Analytics, Facebook Pixel, PayPal, and Stripe.
     * @see {@link https://help.calendly.com/hc/en-us/articles/360007385493-Cookie-FAQs} for further information.
     */
    hideGdprBanner: boolean;
}>;

export const formatCalendlyUrl = ({
    url,
    prefill = {},
    pageSettings = {},
    utm = {},
    embedType,
}: {
    url: string;
    prefill?: Prefill;
    pageSettings?: PageSettings;
    utm?: Utm;
    embedType?: "Inline" | "PopupWidget" | "PopupButton";
}) => {
    const sanitizedPageSettings = sanitizePageSettingsProps(pageSettings);

    const {
        backgroundColor,
        hideEventTypeDetails,
        hideLandingPageDetails,
        primaryColor,
        textColor,
        hideGdprBanner,
    } = sanitizedPageSettings;

    const {
        customAnswers,
        date,
        email,
        firstName,
        guests,
        lastName,
        location,
        smsReminderNumber,
        name,
    } = prefill;

    const {
        utmCampaign,
        utmContent,
        utmMedium,
        utmSource,
        utmTerm,
        salesforce_uuid,
    } = utm;

    const queryStringIndex = url.indexOf("?");
    const hasQueryString = queryStringIndex > -1;
    const queryString = url.slice(queryStringIndex + 1);
    const baseUrl = hasQueryString ? url.slice(0, queryStringIndex) : url;

    const updatedQueryString = [
        hasQueryString ? queryString : null,
        backgroundColor ? `background_color=${backgroundColor}` : null,
        hideEventTypeDetails ? `hide_event_type_details=1` : null,
        hideLandingPageDetails ? `hide_landing_page_details=1` : null,
        primaryColor ? `primary_color=${primaryColor}` : null,
        textColor ? `text_color=${textColor}` : null,
        hideGdprBanner ? `hide_gdpr_banner=1` : null,
        name ? `name=${encodeURIComponent(name)}` : null,
        smsReminderNumber
            ? `phone_number=${encodeURIComponent(smsReminderNumber)}`
            : null,
        location ? `location=${encodeURIComponent(location)}` : null,
        firstName ? `first_name=${encodeURIComponent(firstName)}` : null,
        lastName ? `last_name=${encodeURIComponent(lastName)}` : null,
        guests ? `guests=${guests.map(encodeURIComponent).join(",")}` : null,
        email ? `email=${encodeURIComponent(email)}` : null,
        date && date instanceof Date ? `date=${formatDate(date)}` : null,
        utmCampaign ? `utm_campaign=${encodeURIComponent(utmCampaign)}` : null,
        utmContent ? `utm_content=${encodeURIComponent(utmContent)}` : null,
        utmMedium ? `utm_medium=${encodeURIComponent(utmMedium)}` : null,
        utmSource ? `utm_source=${encodeURIComponent(utmSource)}` : null,
        utmTerm ? `utm_term=${encodeURIComponent(utmTerm)}` : null,
        salesforce_uuid
            ? `salesforce_uuid=${encodeURIComponent(salesforce_uuid)}`
            : null,
        embedType ? `embed_type=${embedType}` : null,
        /*
         * https://github.com/tcampb/react-calendly/pull/31
         * embed_domain must be defined to receive messages from the Calendly iframe.
         */
        `embed_domain=1`,
    ]
        .concat(customAnswers ? formatCustomAnswers(customAnswers) : [])
        .filter((item) => item !== null)
        .join("&");

    return `${baseUrl}?${updatedQueryString}`;
};

const formatDate = (d: Date) => {
    const month = d.getMonth() + 1;
    const day = d.getDate();
    const year = d.getFullYear();

    return [
        year,
        month < 10 ? `0${month}` : month,
        day < 10 ? `0${day}` : day,
    ].join("-");
};

const CUSTOM_ANSWER_PATTERN = /^a\d{1,2}$/;
const formatCustomAnswers = (customAnswers: object) => {
    const customAnswersFiltered = Object.keys(customAnswers).filter((key) =>
        key.match(CUSTOM_ANSWER_PATTERN),
    );

    if (!customAnswersFiltered.length) return [];

    return customAnswersFiltered.map(
        (key) => `${key}=${encodeURIComponent(customAnswers[key])}`,
    );
};

package.json:

 "react": "18.2.0",
  "react-dom": "18.2.0",
  "react-native": "0.74.5",
   "react-native-web": "~0.19.10",
    "react-native-webview": "13.8.6"
wsdt commented 1 month ago

btw if we fully get it to work, happy to create an open source npm package for react-native

tcampb commented 1 month ago

@wsdt - what are you passing in for these props?

onEventScheduled
onDateAndTimeSelected
onEventTypeViewed
onProfilePageViewed

When I changed source={{ uri: src }} to

source={{ html: `<iframe src="${src}" width="100%" height="100%"></iframe>` }}

the code worked; so the only other difference I can think of is the props you are passing in.

wsdt commented 1 month ago
source={{ html: `<iframe src="${src}" width="100%" height="100%"></iframe>` }}

oh nice!! Worked thank you @tcampb !

This is the code I have now to keep the exact same styling as by default from WebView:

  source={{  html: `
      <!DOCTYPE html>
      <html>
        <head>
          <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
          <style>
            body, html {
              margin: 0;
              padding: 0;
              height: 100%;
              overflow: hidden;
            }
            iframe {
              border: none;
              width: 100%;
              height: 100%;
            }
          </style>
        </head>
        <body>
          <iframe src="${src}" width="100%" height="100%"></iframe>
        </body>
      </html>
    ` }}

Created a ticket on our end, to create an official react-native-calendly package. Will add you as a significant contributor etc :-)

Thanks again!

wsdt commented 1 month ago

https://github.com/wavect/react-native-calendly :-)

https://www.npmjs.com/package/@wavect/react-native-calendly

tcampb commented 1 month ago

This is great, thanks for publishing a react native version of the Calendly package 🎉 !