Fix: Embed Fixes, UI configuration PRO Only, Tests (#2341)

pull/2369/head^2
Hariom Balhara 2022-04-04 21:14:04 +05:30 committed by GitHub
parent 7c08e946c6
commit 5138c676b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 628 additions and 209 deletions

10
.gitignore vendored
View File

@ -11,11 +11,11 @@ node_modules
# testing
coverage
/test-results/
playwright/videos
playwright/screenshots
playwright/artifacts
playwright/results
playwright/reports/*
**/playwright/videos
**/playwright/screenshots
**/playwright/artifacts
**/playwright/results
**/playwright/reports/*
# next.js
.next/

View File

@ -0,0 +1,8 @@
---
title: Embed Snippet
---
# Embed Snippet
The Embed Snippet allows your website visitors to book a meeting with you directly from your website. It works by you installing a small Javascript Snippet to your website.
[Mention possiblity of installation through tag managers as well]

View File

@ -19,6 +19,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { BASE_URL } from "@lib/config/constants";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
@ -41,13 +42,13 @@ dayjs.extend(customParseFormat);
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Props) => {
const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage }: Props) => {
const router = useRouter();
const { rescheduleUid } = router.query;
const { isReady, Theme } = useTheme(profile.theme);
const { t } = useLocale();
const { contracts } = useContracts();
useExposePlanGlobally(plan);
useEffect(() => {
if (eventType.metadata.smartContractAddress) {
const eventOwner = eventType.users[0];

View File

@ -0,0 +1,11 @@
import { useEffect } from "react";
import { UserPlan } from "@calcom/prisma/client";
export function useExposePlanGlobally(plan: UserPlan) {
// Don't wait for component to mount. Do it ASAP. Delaying it would delay UI Configuration.
if (typeof window !== "undefined") {
// This variable is used by embed-iframe to determine if we should allow UI configuration
window.CalComPlan = plan;
}
}

View File

@ -4,13 +4,14 @@ import { GetServerSidePropsContext } from "next";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import { JSONObject } from "superjson/dist/types";
import { sdkActionManager, useEmbedStyles } from "@calcom/embed-core";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import useTheme from "@lib/hooks/useTheme";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -35,7 +36,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem");
const query = { ...router.query };
delete query.user; // So it doesn't display in the Link (and make tests fail)
useExposePlanGlobally("PRO");
const nameOrUsername = user.name || user.username || "";
const [evtsToVerify, setEvtsToVerify] = useState<EvtsToVerify>({});
return (

View File

@ -225,6 +225,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
},
plan: user.plan,
date: dateParam,
eventType: eventTypeObject,
workingHours,

View File

@ -7,6 +7,7 @@ import React from "react";
import Button from "@calcom/ui/Button";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
@ -27,7 +28,7 @@ function TeamPage({ team }: TeamPageProps) {
const { isReady, Theme } = useTheme();
const showMembers = useToggleQuery("members");
const { t } = useLocale();
useExposePlanGlobally("PRO");
const eventTypes = (
<ul className="space-y-3">
{team.eventTypes.map((type) => (

View File

@ -1,6 +1,8 @@
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { UserPlan } from "@calcom/prisma/client";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import prisma from "@lib/prisma";
@ -109,6 +111,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
// Team is always pro
plan: "PRO" as UserPlan,
profile: {
name: team.name || team.slug,
slug: team.slug,

View File

@ -14,49 +14,50 @@ See [index.html](index.html) to understand how it can be used.
- `notes`
- `guests`
## How to use embed on any webpage no matter what framework.
## How to use embed on any webpage no matter what framework
- _Step-1._ Install the snippet
```javascript
(function (C, A, L) {
```javascript
(function(C, A, L) {
let p = function(a, ar) {
a.q.push(ar);
};
let d = C.document;
C.Cal =
C.Cal ||
function () {
let cal = C.Cal;
let ar = arguments;
if (!cal.loaded) {
cal.ns = {};
cal.q = cal.q || [];
d.head.appendChild(d.createElement("script")).src = A;
cal.loaded = true;
}
if (ar[0] === L) {
const api = function () {
api.q.push(arguments);
};
const namespace = arguments[1];
api.q = api.q || [];
namespace ? (cal.ns[namespace] = api) : null;
return;
}
cal.q.push(ar);
};
C.Cal = C.Cal || function() {
let cal = C.Cal;
let ar = arguments;
if (!cal.loaded) {
cal.ns = {};
cal.q = cal.q || [];
d.head.appendChild(d.createElement("script")).src = A;
cal.loaded = true;
}
if (ar[0] === L) {
const api = function() {
p(api, arguments);
};
const namespace = ar[1];
api.q = api.q || [];
typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar);
return;
}
p(cal, ar);
};
})(window, "https://cal.com/embed.js", "init");
```
- _Step-2_. Give `init` instruction to it. It creates a queue so that even without embed.js being fetched, you can give instructions to embed.
```javascript
Cal("init) // Creates default instance. Give instruction to it as Cal("instruction")
```
```javascript
Cal("init) // Creates default instance. Give instruction to it as Cal("instruction")
```
**Optionally** if you want to install another instance of embed you can do
**Optionally** if you want to install another instance of embed you can do
```javascript
Cal("init", "NAME_YOUR_OTHER_INSTANCE"); // Creates a named instance. Give instructions to it as Cal.ns.NAME_YOUR_OTHER_INSTANCE("instruction")
```
```javascript
Cal("init", "NAME_YOUR_OTHER_INSTANCE"); // Creates a named instance. Give instructions to it as Cal.ns.NAME_YOUR_OTHER_INSTANCE("instruction")
```
- Step-1 and Step-2 must be followed in same order. After that you can give various instructions to embed as you like.
@ -92,4 +93,53 @@ yarn dev
yarn build
```
Make `dist/embed.umd.js` servable on URL http://cal.com/embed.js
Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
## Upcoming Improvements
- Unsupported Browsers and versions. Documenting them and gracefully handling that.
- Accessibility and UI/UX Issues
- Loader on ModalBox/popup
- If website owner links the booking page directly for an event, should the user be able to go to events-listing page using back button ?
- Bundling Related
- Minify CSS in embed.js
- Debuggability
- Send log messages from iframe to parent so that all logs can exist in a single queue forming a timeline.
- user should be able to use "on" instruction to understand what's going on in the system
- Error Tracking for embed.js
- Know where exactly its failing if it does.
- Improved Demo
- Seeding might be done for team event so that such an example is also available readily in index.html
- Dev Experience/Ease of Installation
- Do we need a one liner(like `window.dataLayer.push`) to inform SDK of something even if snippet is not yet on the page but would be there e.g. through GTM it would come late on the page ?
- Might be better to pass all configuration using a single base64encoded query param to booking page.
- Embed Code Generator
- UI Config Features
- Theme switch dynamically - If user switches the theme on website, he should be able to do it on embed.
- Text Color
- Brand color
- At some places Text is colored by using the color specific tailwind class. e.g. `text-gray-400` is the color of disabled date. He has 2 options, If user wants to customize that
- He can go and override the color on the class which doesnt make sense
- He can identify the element and change the color by directly adding style, which might cause consistency issues if certain elements are missed.
- Challenges
- How would the user add on hover styles just using style attribute ?
- React Component
- `onClick` support with preloading
## Pending Documentation
- READMEs
- How to make a new element configurable using UI instruction ?
- Why do we NOT want to provide completely flexible CSS customization by adding whatever CSS user wants. ?
- docs.cal.com
- A complete document on how to use embed
- app.cal.com
- Get Embed code for each event-type

View File

@ -8,6 +8,9 @@
</script>
<script>
(function (C, A, L) {
let p = function (a, ar) {
a.q.push(ar);
};
let d = C.document;
C.Cal =
C.Cal ||
@ -22,25 +25,18 @@
}
if (ar[0] === L) {
const api = function () {
api.q.push(arguments);
p(api, arguments);
};
const namespace = arguments[1];
const namespace = ar[1];
api.q = api.q || [];
namespace ? (cal.ns[namespace] = api) : null;
typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar);
return;
}
cal.q.push(ar);
p(cal, ar);
};
})(window, "//localhost:3002/dist/embed.umd.js", "init");
</script>
<script>
Cal("init");
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "second");
// Create a namespace "third". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "third");
</script>
<style>
.debug {
/* border: 1px solid black; */
@ -54,7 +50,7 @@
</head>
<body>
<h3>This page has a non responsive version accessible <a href="?nonResponsive">here</a></h3>
<h3>Pre-render test page available at <a href="?prerender-test">here</a></h3>
<h3>Pre-render test page available at <a href="?only=prerender-test">here</a></h3>
<div>
<button data-cal-link="free">Book with Free User</button>
<div>
@ -67,13 +63,15 @@
</div>
</div>
<div id="namespaces-test">
<div class="debug">
<h2>Default Namespace(Cal)<i>[Black Theme][Guests(janedoe@gmail.com and test@gmail.com)]</i></h2>
<div class="debug" id="cal-booking-place-default">
<h2>
Default Namespace(Cal)<i>[Dark Theme][inline][Guests(janedoe@gmail.com and test@gmail.com)]</i>
</h2>
<div>
<i><a href="?only=ns:default">Test in Zen Mode</a></i>
</div>
<i id="booking-status-"> You would see last Booking page action in my place </i>
<div id="cal-booking-place-" style="max-height: 30vh; overflow: scroll">
<i class="last-action"> You would see last Booking page action in my place </i>
<div style="max-height: 30vh; overflow: scroll" class="place">
<div>
if you render booking embed in me, I would not let it be more than 30vh in height. So you would
have to scroll to see the entire content
@ -81,15 +79,15 @@
<div class="loader" id="cal-booking-loader-">Loading .....</div>
</div>
</div>
<div class="debug">
<h2>Namespace "second"(Cal.ns.second)[Custom Styling]</h2>
<div class="debug" id="cal-booking-place-second">
<h2>Namespace "second"(Cal.ns.second)[Custom Styling][inline]</h2>
<div>
<i><a href="?only=ns:second">Test in Zen Mode</a></i>
</div>
<i id="booking-status-second">
<i class="last-action">
<i>You would see last Booking page action in my place</i>
</i>
<div id="cal-booking-place-second">
<div class="place">
<div>If you render booking embed in me, I won't restrict you. The entire page is yours.</div>
<button
onclick="(function () {Cal.ns.second('ui', {styles:{eventTypeListItem:{backgroundColor:'blue'}}})})()">
@ -102,124 +100,182 @@
</div>
</div>
<div class="debug">
<h2>Namespace "third"(Cal.ns.third)</h2>
<div class="debug" id="cal-booking-place-third">
<h2>Namespace "third"(Cal.ns.third)[inline]</h2>
<div>
<i><a href="?only=ns:third">Test in Zen Mode</a></i>
</div>
<i id="booking-status-third">
<i class="last-action">
<i>You would see last Booking page action in my place</i>
</i>
<div id="cal-booking-place-third" style="width: 30%">
<div style="width: 30%" class="place">
<div>If you render booking embed in me, I would not let you be more than 30% wide</div>
<div class="loader" id="cal-booking-loader-third">Loading .....</div>
</div>
</div>
<div class="debug" id="cal-booking-place-fourth">
<h2>Namespace "fourth"(Cal.ns.fourth)[Team Event Test][inline]</h2>
<div>
<i><a href="?only=ns:fourth">Test in Zen Mode</a></i>
</div>
<i class="last-action">
<i>You would see last Booking page action in my place</i>
</i>
<div style="width: 30%" class="place">
<div>If you render booking embed in me, I would not let you be more than 30% wide</div>
<div class="loader" id="cal-booking-loader-third">Loading .....</div>
</div>
</div>
</div>
<script>
const searchParams = new URL(document.URL).searchParams;
const only = searchParams.get("only");
// In prerender-test, we would want to test just the prerender case and nothing else.
if (searchParams.get("prerender-test") === null) {
if (!only || only === "ns:default") {
Cal("inline", {
elementOrSelector: "#cal-booking-place-",
calLink: "pro?case=1",
config: {
name: "John Doe",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@gmail.com", "test@gmail.com"],
theme: "dark",
},
});
}
if (!only || only === "ns:second") {
// Bulk API is supported - Keep all configuration at one place.
Cal.ns.second(
[
"inline",
{
elementOrSelector: "#cal-booking-place-second",
calLink: "pro?case=2",
},
],
[
"ui",
{
styles: {
body: {
background: "white",
},
eventTypeListItem: {
backgroundColor: "#D3D3D3",
},
enabledDateButton: {
backgroundColor: "#D3D3D3",
},
disabledDateButton: {
backgroundColor: "lightslategray",
},
},
},
]
);
}
if (!only || only === "ns:third") {
Cal.ns.third(
[
"inline",
{
elementOrSelector: "#cal-booking-place-third",
calLink: "pro?case=3",
},
],
[
"ui",
{
styles: {
body: {
background: "white",
},
},
},
]
);
}
} else {
document.getElementById("namespaces-test").style.display = "none";
}
Cal("preload", {
calLink: "free",
});
</script>
<script>
const callback = function (e) {
const detail = e.detail;
const namespace = detail.namespace;
const namespace = detail.namespace || "default";
if (detail.type === "linkReady") {
document.getElementById("cal-booking-loader-" + namespace).remove();
document.querySelector(`#cal-booking-place-${namespace} .loader`).remove();
}
document.getElementById(`booking-status-${namespace}`).innerHTML = JSON.stringify(e.detail);
document.querySelector(`#cal-booking-place-${namespace} .last-action`).innerHTML = JSON.stringify(
e.detail
);
};
Cal("on", {
action: "*",
callback,
});
Cal.ns.second("on", {
action: "*",
callback,
});
Cal.ns.third("on", {
action: "*",
callback,
});
const searchParams = new URL(document.URL).searchParams;
const only = searchParams.get("only");
if (!only || only === "ns:default") {
Cal("init", {
debug: 1,
origin: "http://localhost:3000",
});
Cal("inline", {
elementOrSelector: "#cal-booking-place-default .place",
calLink: "pro?case=1",
config: {
name: "John Doe",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@gmail.com", "test@gmail.com"],
theme: "dark",
},
});
Cal("on", {
action: "*",
callback,
});
}
if (!only || only === "ns:second") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "second", {
debug: 1,
origin: "http://localhost:3000",
});
// Bulk API is supported - Keep all configuration at one place.
Cal.ns.second(
[
"inline",
{
elementOrSelector: "#cal-booking-place-second .place",
calLink: "pro?case=2",
},
],
[
"ui",
{
styles: {
body: {
background: "white",
},
eventTypeListItem: {
backgroundColor: "#D3D3D3",
},
enabledDateButton: {
backgroundColor: "#D3D3D3",
},
disabledDateButton: {
backgroundColor: "lightslategray",
},
},
},
]
);
Cal.ns.second("on", {
action: "*",
callback,
});
}
if (!only || only === "ns:third") {
// Create a namespace "third". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "third", {
debug: 1,
origin: "http://localhost:3000",
});
Cal.ns.third(
[
"inline",
{
elementOrSelector: "#cal-booking-place-third .place",
calLink: "pro/30min",
},
],
[
"ui",
{
styles: {
body: {
background: "white",
},
},
},
]
);
Cal.ns.third("on", {
action: "*",
callback,
});
}
if (!only || only === "ns:fourth") {
Cal("init", "fourth", {
debug: 1,
origin: "http://localhost:3000",
});
Cal.ns.fourth(
[
"inline",
{
elementOrSelector: "#cal-booking-place-fourth .place",
calLink: "team/test-team",
},
],
[
"ui",
{
styles: {
body: {
background: "white",
},
},
},
]
);
Cal.ns.fourth("on", {
action: "*",
callback,
});
}
if (!only || only === "prerender-test") {
Cal("preload", {
calLink: "free",
});
}
</script>
<script></script>
</body>
</html>

View File

@ -5,10 +5,12 @@
"main": "./index.ts",
"scripts": {
"build": "vite build",
"build:cal": "NEXT_PUBLIC_WEBSITE_URL='https://cal.com' yarn build",
"vite": "vite",
"dev": "run-p 'build --watch' 'vite --port 3002 --strict-port --open'",
"type-check": "tsc --pretty --noEmit",
"lint": "eslint --ext .ts,.js src"
"lint": "eslint --ext .ts,.js src",
"test-playwright": "yarn playwright test --config=playwright/config/playwright.config.ts"
},
"devDependencies": {
"vite": "^2.8.6",

View File

@ -0,0 +1,3 @@
async function globalSetup(/* config: FullConfig */) {}
export default globalSetup;

View File

@ -0,0 +1,119 @@
import { PlaywrightTestConfig, Frame, devices, expect } from "@playwright/test";
import * as path from "path";
const outputDir = path.join("../results");
const testDir = path.join("../tests");
const config: PlaywrightTestConfig = {
forbidOnly: !!process.env.CI,
retries: 1,
workers: 1,
timeout: 60_000,
reporter: [
[process.env.CI ? "github" : "list"],
[
"html",
{ outputFolder: path.join(__dirname, "..", "reports", "playwright-html-report"), open: "never" },
],
["junit", { outputFile: path.join(__dirname, "..", "reports", "results.xml") }],
],
globalSetup: require.resolve("./globalSetup"),
outputDir,
webServer: {
// Start App Server manually - Can't be handled here. See https://github.com/microsoft/playwright/issues/8206
command: "yarn workspace @calcom/embed-core dev",
port: 3002,
timeout: 60_000,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: "http://localhost:3002",
locale: "en-US",
trace: "retain-on-failure",
headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS,
},
projects: [
{
name: "chromium",
testDir,
use: { ...devices["Desktop Chrome"] },
},
/* {
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
}, */
],
};
export type ExpectedUrlDetails = {
searchParams?: Record<string, string | string[]>;
pathname?: string;
origin?: string;
};
declare global {
namespace PlaywrightTest {
//FIXME: how to restrict it to Frame only
interface Matchers<R> {
toBeEmbedCalLink(expectedUrlDetails?: ExpectedUrlDetails): R;
}
}
}
expect.extend({
async toBeEmbedCalLink(iframe: Frame, expectedUrlDetails: ExpectedUrlDetails = {}) {
if (!iframe || !iframe.url) {
return {
pass: false,
message: () => `Expected to provide an iframe, got ${iframe}`,
};
}
const u = new URL(iframe.url());
const frameElement = await iframe.frameElement();
if (!(await frameElement.isVisible())) {
return {
pass: false,
message: () => `Expected iframe to be visible`,
};
}
const pathname = u.pathname;
const expectedPathname = expectedUrlDetails.pathname;
if (expectedPathname && expectedPathname !== pathname) {
return {
pass: false,
message: () => `Expected pathname to be ${expectedPathname} but got ${pathname}`,
};
}
const origin = u.origin;
const expectedOrigin = expectedUrlDetails.origin;
if (expectedOrigin && expectedOrigin !== origin) {
return {
pass: false,
message: () => `Expected origin to be ${expectedOrigin} but got ${origin}`,
};
}
const searchParams = u.searchParams;
const expectedSearchParams = expectedUrlDetails.searchParams || {};
for (let [expectedKey, expectedValue] of Object.entries(expectedSearchParams)) {
const value = searchParams.get(expectedKey);
if (value !== expectedValue) {
return {
message: () => `${expectedKey} should have value ${expectedValue} but got value ${value}`,
pass: false,
};
}
}
return {
pass: true,
message: () => `passed`,
};
},
});
export default config;

View File

@ -0,0 +1,3 @@
import { test as base } from "@playwright/test";
export const test = base.extend({});

View File

@ -0,0 +1,21 @@
import { Page } from "@playwright/test";
export function todo(title: string) {
test.skip(title, () => {});
}
export const getEmbedIframe = async ({ page, pathname }: { page: Page; pathname: string }) => {
// FIXME: Need to wait for the iframe to be properly added to shadow dom. There should be a no time boundation way to do it.
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
let embedIframe = page.frame("cal-embed");
if (!embedIframe) {
return null;
}
const u = new URL(embedIframe.url());
if (u.pathname === pathname) {
return embedIframe;
}
return null;
};

View File

@ -0,0 +1,17 @@
import { expect } from "@playwright/test";
import { test } from "../fixtures/fixtures";
import { todo, getEmbedIframe } from "../lib/testUtils";
test("should open embed iframe on click", async ({ page }) => {
await page.goto("/?only=prerender-test");
let embedIframe = await getEmbedIframe({ page, pathname: "/free" });
expect(embedIframe).toBeFalsy();
await page.click('[data-cal-link="free"]');
embedIframe = await getEmbedIframe({ page, pathname: "/free" });
expect(embedIframe).toBeEmbedCalLink({
pathname: "/free",
});
});

View File

@ -0,0 +1,21 @@
import { expect, Frame } from "@playwright/test";
import { test } from "../fixtures/fixtures";
import { todo } from "../lib/testUtils";
test("Inline Iframe - Configured with Dark Theme", async ({ page }) => {
await page.goto("/?only=ns:default");
const embedIframe = page.frame({ url: /.*pro.*/ });
expect(embedIframe).toBeEmbedCalLink({
pathname: "/pro",
searchParams: {
theme: "dark",
},
});
});
todo(
"Ensure that on all pages - [user], [user]/[type], team/[slug], team/[slug]/book, UI styling works if these pages are directly linked in embed"
);
todo("Check that UI Configuration doesn't work for Free Plan");

View File

@ -2,6 +2,51 @@ import { useState, useEffect, CSSProperties } from "react";
import { sdkActionManager } from "./sdk-event";
let isSafariBrowser = false;
if (typeof window !== "undefined") {
const ua = navigator.userAgent.toLowerCase();
isSafariBrowser = ua.includes("safari") && !ua.includes("chrome");
if (isSafariBrowser) {
log("Safari Detected: Using setTimeout instead of rAF");
}
}
function keepRunningAsap(fn: (...arg: any) => void) {
if (isSafariBrowser) {
// https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/
return setTimeout(fn, 50);
}
return requestAnimationFrame(fn);
}
declare global {
interface Window {
CalEmbed: {
__logQueue?: any[];
};
CalComPlan: string;
}
}
function log(...args: any[]) {
let namespace;
if (typeof window !== "undefined") {
const searchParams = new URL(document.URL).searchParams;
namespace = typeof searchParams.get("embed") !== "undefined" ? "" : "_unknown_";
//TODO: Send postMessage to parent to get all log messages in the same queue.
window.CalEmbed = window.CalEmbed || {};
const logQueue = (window.CalEmbed.__logQueue = window.CalEmbed.__logQueue || []);
args.push({
ns: namespace,
});
args.unshift("CAL:");
logQueue.push(args);
if (searchParams.get("debug")) {
console.log(...args);
}
}
}
// Only allow certain styles to be modified so that when we make any changes to HTML, we know what all embed styles might be impacted.
// Keep this list to minimum, only adding those styles which are really needed.
interface EmbedStyles {
@ -66,15 +111,23 @@ export const useEmbedStyles = (elementName: ElementName) => {
return styles[elementName] || {};
};
function unhideBody() {
document.body.style.display = "block";
}
// If you add a method here, give type safety to parent manually by adding it to embed.ts. Look for "parentKnowsIframeReady" in it
export const methods = {
ui: function style(uiConfig: UiConfig) {
// TODO: Create automatic logger for all methods. Useful for debugging.
console.log("Method: ui called", uiConfig);
log("Method: ui called", uiConfig);
if (window.CalComPlan && window.CalComPlan !== "PRO") {
log(`Upgrade to PRO for "ui" instruction to work`, window.CalComPlan);
return;
}
const stylesConfig = uiConfig.styles;
// In case where parent gives instructions before setEmbedStyles is set.
if (!setEmbedStyles) {
// In case where parent gives instructions before CalComPlan is set.
// This is easily possible as React takes time to initialize and render components where this variable is set.
if (!window.CalComPlan) {
return requestAnimationFrame(() => {
style(uiConfig);
});
@ -88,7 +141,8 @@ export const methods = {
setEmbedStyles(stylesConfig);
},
parentKnowsIframeReady: () => {
document.body.style.display = "block";
log("Method: `parentKnowsIframeReady` called");
unhideBody();
sdkActionManager?.fire("linkReady", {});
},
};
@ -104,18 +158,41 @@ const messageParent = (data: any) => {
};
function keepParentInformedAboutDimensionChanges() {
let knownHiddenHeight: Number | null = null;
console.log("keepParentInformedAboutDimensionChanges executed");
let knownIframeHeight: Number | null = null;
let numDimensionChanges = 0;
requestAnimationFrame(function informAboutScroll() {
// Because of scroll="no", this much is hidden from the user.
const hiddenHeight = document.documentElement.scrollHeight - window.innerHeight;
let isFirstTime = true;
let isWindowLoadComplete = false;
keepRunningAsap(function informAboutScroll() {
if (document.readyState !== "complete") {
// Wait for window to load to correctly calculate the initial scroll height.
keepRunningAsap(informAboutScroll);
return;
}
if (!isWindowLoadComplete) {
// On Safari, even though document.readyState is complete, still the page is not rendered and we can't compute documentElement.scrollHeight correctly
// Postponing to just next cycle allow us to fix this.
setTimeout(() => {
isWindowLoadComplete = true;
informAboutScroll();
}, 10);
return;
}
const documentScrollHeight = document.documentElement.scrollHeight;
const contentHeight = document.documentElement.offsetHeight;
// During first render let iframe tell parent that how much is the expected height to avoid scroll.
// Parent would set the same value as the height of iframe which would prevent scroll.
// On subsequent renders, consider html height as the height of the iframe. If we don't do this, then if iframe get's bigger in height, it would never shrink
let iframeHeight = isFirstTime ? documentScrollHeight : contentHeight;
isFirstTime = false;
// TODO: Handle width as well.
if (knownHiddenHeight !== hiddenHeight) {
knownHiddenHeight = hiddenHeight;
if (knownIframeHeight !== iframeHeight) {
knownIframeHeight = iframeHeight;
numDimensionChanges++;
// FIXME: This event shouldn't be subscribable by the user. Only by the SDK.
sdkActionManager?.fire("dimension-changed", {
hiddenHeight,
iframeHeight,
});
}
// Parent Counterpart would change the dimension of iframe and thus page's dimension would be impacted which is recursive.
@ -125,28 +202,39 @@ function keepParentInformedAboutDimensionChanges() {
console.warn("Too many dimension changes detected.");
return;
}
requestAnimationFrame(informAboutScroll);
keepRunningAsap(informAboutScroll);
});
}
if (typeof window !== "undefined" && !location.search.includes("prerender=true")) {
sdkActionManager?.on("*", (e) => {
const detail = e.detail;
//console.log(detail.fullType, detail.type, detail.data);
messageParent(detail);
});
if (typeof window !== "undefined") {
const url = new URL(document.URL);
if (url.searchParams.get("prerender") !== "true" && typeof url.searchParams.get("embed") !== "undefined") {
log("Initializing embed-iframe");
window.addEventListener("message", (e) => {
const data: Record<string, any> = e.data;
if (!data) {
return;
// If embed link is opened in top, and not in iframe. Let the page be visible.
if (top === window) {
unhideBody();
}
const method: keyof typeof methods = data.method;
if (data.originator === "CAL" && typeof method === "string") {
methods[method]?.(data.arg);
}
});
keepParentInformedAboutDimensionChanges();
sdkActionManager?.fire("iframeReady", {});
sdkActionManager?.on("*", (e) => {
const detail = e.detail;
//console.log(detail.fullType, detail.type, detail.data);
log(detail);
messageParent(detail);
});
window.addEventListener("message", (e) => {
const data: Record<string, any> = e.data;
if (!data) {
return;
}
const method: keyof typeof methods = data.method;
if (data.originator === "CAL" && typeof method === "string") {
methods[method]?.(data.arg);
}
});
keepParentInformedAboutDimensionChanges();
sdkActionManager?.fire("iframeReady", {});
}
}

View File

@ -8,7 +8,10 @@ import { SdkActionManager } from "./sdk-action-manager";
declare module "*.css";
type Namespace = string;
type Config = Record<"origin", "string">;
type Config = {
origin: string;
debug: 1;
};
const globalCal = (window as CalWindow).Cal;
@ -135,9 +138,8 @@ export class Cal {
queryObject?: Record<string, string | string[]>;
}) {
const iframe = (this.iframe = document.createElement("iframe"));
// FIXME: scrolling seems deprecated, though it works on Chrome. What's the recommended way to do it?
iframe.scrolling = "no";
iframe.className = "cal-embed";
iframe.name = "cal-embed";
const config = this.getConfig();
// Prepare searchParams from config
@ -152,6 +154,9 @@ export class Cal {
const urlInstance = new URL(`${config.origin}/${calLink}`);
urlInstance.searchParams.set("embed", this.namespace);
if (config.debug) {
urlInstance.searchParams.set("debug", config.debug);
}
// Merge searchParams from config onto the URL which might have query params already
//@ts-ignore
@ -162,13 +167,14 @@ export class Cal {
return iframe;
}
init(namespaceOrConfig: string | Config, config: Config = {} as Config) {
if (namespaceOrConfig.hasOwnProperty("origin")) {
config = namespaceOrConfig as Config;
init(namespaceOrConfig?: string | Config, config: Config = {} as Config) {
if (typeof namespaceOrConfig !== "string") {
config = (namespaceOrConfig || {}) as Config;
}
if (config?.origin) {
this.__config.origin = config.origin;
}
this.__config.debug = config.debug;
}
getConfig() {
@ -307,7 +313,8 @@ export class Cal {
constructor(namespace: string, q: InstructionQueue) {
this.__config = {
origin: import.meta.env.NEXT_PUBLIC_WEBSITE_URL || "https://cal.com",
// Keep cal.com hardcoded till the time embed.js deployment to cal.com/embed.js is automated. This is to prevent accidentally pushing of localhost domain to production
origin: /*import.meta.env.NEXT_PUBLIC_WEBSITE_URL || */ "https://cal.com",
};
this.namespace = namespace;
this.actionManager = new SdkActionManager(namespace);
@ -323,12 +330,16 @@ export class Cal {
this.actionManager.on("dimension-changed", (e) => {
const { data } = e.detail;
const iframe = this.iframe!;
if (!iframe) {
// Iframe might be pre-rendering
return;
}
let proposedHeightByIframeWebsite = parseFloat(getComputedStyle(iframe).height) + data.hiddenHeight;
iframe.style.height = proposedHeightByIframeWebsite;
let proposedHeightByIframeWebsite = data.iframeHeight;
iframe.style.height = proposedHeightByIframeWebsite + "px";
// It ensures that if the iframe is so tall that it can't fit in the parent window without scroll. Then force the scroll by restricting the max-height to innerHeight
// This case is reproducible when viewing in ModalBox on Mobile.
iframe.style.maxHeight = window.innerHeight + "px";
});
this.actionManager.on("iframeReady", (e) => {

View File

@ -1,7 +1,7 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"module": "ESNext",
"module": "esnext",
"moduleResolution": "Node",
"baseUrl": "."
},

View File

@ -3,9 +3,7 @@
"version": "0.1.0",
"main": "src/index.ts",
"scripts": {
"dev": "vite --port=3002",
"build": "vite build",
"preview": "vite preview",
"type-check": "tsc --pretty --noEmit",
"lint": "eslint --ext .ts,.js src"
},

View File

@ -22,6 +22,9 @@ export interface CalWindow extends Window {
export default function EmbedSnippet(url = "https://cal.com/embed.js") {
/*! Copy the code below and paste it in script tag of your website */
(function (C: CalWindow, A, L) {
let p = function (a: any, ar: any) {
a.q.push(ar);
};
let d = C.document;
C.Cal =
C.Cal ||
@ -37,14 +40,14 @@ export default function EmbedSnippet(url = "https://cal.com/embed.js") {
if (ar[0] === L) {
const api: { (): void; q: any[] } = function () {
api.q.push(arguments);
p(api, arguments);
};
const namespace = arguments[1];
const namespace = ar[1];
api.q = api.q || [];
namespace ? (cal.ns![namespace] = api) : null;
typeof namespace === "string" ? (cal.ns![namespace] = api) && p(api, ar) : p(cal, ar);
return;
}
cal.q!.push(ar as unknown as Instruction);
p(cal, ar);
};
})(
window,

View File

@ -4,7 +4,7 @@ const { defineConfig } = require("vite");
module.exports = defineConfig({
build: {
lib: {
entry: path.resolve(__dirname, "index.ts"),
entry: path.resolve(__dirname, "src", "index.ts"),
name: "snippet",
fileName: (format) => `snippet.${format}.js`,
},