2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["../../.eslintrc.js"],
rules: {
"no-restricted-imports": [
"error",
{
// Ensure that embed packages(They are published) can't access unpublished packages which is basically all @calcom/* packages except embed packages
patterns: ["@calcom/*", "!@calcom/embed-*"],
},
],
},
overrides: [
{
files: ["embed-core/playwright/**/*"],
rules: {
"no-restricted-imports": "off",
},
},
],
};

View File

@@ -0,0 +1,17 @@
# Embeds
This folder contains all the various flavours of embeds.
`core` contains the core library written in vanilla JS that manages the embed.
`snippet` contains the Vanilla JS Code Snippet that can be installed on any website and would automatically fetch the `core` library.
Please see the respective folder READMEs for details on them.
## Publishing to NPM. It will soon be automated using changesets github action
To publish the packages. Following steps should be followed. All commands are to be run at the root.
1. `yarn changeset` -> Creates changelog files and adds summary to changelog. Select embed packages only here.
2. `yarn changeset version` -> Bumps the versions as required
3. Get the PR reviewed and merged
4. `yarn publish-embed` -> Releases all packages. We can't use `yarn changeset publish` because it doesn't support workspace: prefix removal yet. See https://github.com/changesets/changesets/issues/432#issuecomment-1016365428

View File

@@ -0,0 +1 @@
src/tailwind.generated.css

View File

@@ -0,0 +1,49 @@
# @calcom/embed-core
## 1.5.0
### Minor Changes
- Added namespacing support throughout
## 1.4.0
### Minor Changes
- Added a few more events
## 1.3.2
### Patch Changes
- Improve UI instruction layout typings
## 1.3.1
### Patch Changes
- layout type fix as zod-utils can't be used in npm package
## 1.3.0
### Minor Changes
- Supports new booker layout
## 1.2.1
### Patch Changes
- Fix the build for embed-react
## 1.2.0
### Minor Changes
- Fix missing types for @calcom/embed-react. Also, release support for floatingButton config parameter. Though the support is available using embed.js already, for users using getCalApi the TypeScript types would report that config isn't supported.
## 1.1.5
### Patch Changes
- Add changesets. Use prepack instead of prePublish and prepublish only as that works with both yarn and npm

View File

@@ -0,0 +1,42 @@
The Cal.com Commercial License (EE) license (the “EE License”)
Copyright (c) 2020-present Cal.com, Inc
With regard to the Cal.com Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Cal.com Subscription Terms available
at https://cal.com/terms (the “EE Terms”), or other agreements governing
the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"),
and otherwise have a valid Cal.com Commercial License subscription ("EE Subscription")
for the correct number of hosts as defined in the EE Terms ("Hosts"). Subject to the foregoing sentence,
you are free to modify this Software and publish patches to the Software. You agree
that Cal.com and/or its licensors (as applicable) retain all right, title and interest in
and to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid EE Subscription for the correct number of hosts.
Notwithstanding the foregoing, you may copy and modify the Software for development
and testing purposes, without requiring a subscription. You agree that Cal.com and/or
its licensors (as applicable) retain all right, title and interest in and to all such
modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
This EE License applies only to the part of this Software that is not distributed under
the AGPLv3 license. Any part of this Software distributed under the AGPLv3 license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
in part, is copyrighted under the AGPLv3 license. The full text of this EE License shall
be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Cal.com Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@@ -0,0 +1,132 @@
# embed-core
This is the vanilla JS core script that embeds Cal Link.
## How to use embed on any webpage no matter what framework
See <https://developer.cal.com/embed/install-with-javascript>
You can also see various example usages [here](https://github.com/calcom/cal.com/blob/main/packages/embeds/embed-core/index.html)
## Development
Run the following command and then you can test the embed in the automatically opened page `http://localhost:3100`
```bash
yarn dev
```
## Running Tests
Ensure that the main App is running on port 3000 (e.g. yarn dx) already. Also ensure dev server for embed-core is running and then run the following command:
Start the server on 3100 port
```bash
yarn dev
```
And from another terminal you can run the following command to execute tests:
```bash
yarn embed-tests-quick
```
Note: `getEmbedIframe` and `addEmbedListeners` work as a team but they only support opening up embed in a fresh load. Opening an embed closing it and then opening another embed isn't supported yet.
## Shipping to Production
```bash
yarn build
```
Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
## DX
- Hot reload doesn't work with CSS files in the way we use vite.
## Steps to make a page compatible with Embed
- Define `main` class on the element that has the entire content of the page with no auto margins
- Adding `main` class allows iframe height to adjust according to it, making sure that the content within `main` is visible without scrolling as long as device dimensions permit it.
- It also becomes the area beyond which if the user clicks, modal-box would close.
## Known Bugs and Upcoming Improvements
- Unsupported Browsers and versions. Documenting them and gracefully handling that.
- Need to create a booking Shell so that common changes for embed can be applied there.
- Accessibility and UI/UX Issues
let the user choose the loader for ModalBox
If the website owner links the booking page directly for an event, should the user be able to go to events-listing page using back button ?
Let the user specify both dark and light theme colors. Right now the colors specified are for light theme.
- Transparent support is not properly done for team links
- Maybe don't set border-radius in inline mode or give an option to configure border-radius.
- Branding
- Powered by Cal.com and 'Try it for free'. Should they be shown only for FREE account.
- Branding at the bottom has been removed for UI improvements, need to see where to add it.
- API
- Allow loader color customization using UI command itself too. Right now it's possible using CSS only.
- Automation Tests
- Run automation tests in CI
- Automation Tests are using snapshots of Booking Page which has current month which requires us to regenerate snapshots every month.
- Bundling Related
- Comments in CSS aren't stripped off
- 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.
- Color Scheme
- Need to reduce the number of colors on booking page, so that UI configuration is simpler
- 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 ?
- Option to disable redirect banner and let parent handle redirect.
- Release Issues
- Compatibility Issue - When embed-iframe.js is updated in such a way that it is not compatible with embed.js, doing a release might break the embed for some time. e.g. iframeReady event let's say get's changed to something else
- Best Case scenario - App and Website goes live at the same time. A website using embed loads the same updated and thus compatible versions of embed.js and embed-iframe.js
- Worst case scenario - App goes live first, website PR isn't merged yet and thus a website using the embed would load updated version of embed-iframe but outdated version of embed.js possibly breaking the embed.
- Ideal Solution: It would be to keep the libraries versioned and embed.js should instruct app within iframe to load a particular version. But if we push a security fix, it is possible that someone is still enforcing embed to load version with security issue. Need to handle this.
- Quick Solution: Serve embed.js also from app, so that they go live together and there is only a slight chance of compatibility issues on going live. Note, that they can still occur as 2 different requests are sent at different times to fetch the libraries and deployments can go live in between,
- UI Config Features
- How would the user add on hover styles just using style attribute ?
- If just iframe refreshes due to some reason, embed script can't replay the applied instructions.
- React Component
- `onClick` support with automatic preloading
- Shadow DOM is currently in open state, which probably means that any styling change on website can impact loader.
## 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. ?
- Feature Documentation
- Inline mode doesn't cause any scroll in iframe by default. It looks like it is part of the website.
- cal.com/docs
- A complete document on how to use embed
- app.cal.com
- Get Embed code for each event-type

Binary file not shown.

View File

@@ -0,0 +1,73 @@
import type { EmbedThemeConfig } from "./src/types";
export default function EmbedInitIframe() {
if (typeof window === "undefined" || window.isEmbed) {
return;
}
const url = new URL(document.URL);
const embedNameSpaceFromQueryParam = url.searchParams.get("embed");
const hasEmbedPath = url.pathname.endsWith("/embed");
const defaultNamespace = "";
// Namespace is initially set in query param `embed` but the query param might get lost during soft navigation
// So, we also check for the namespace in `window.name` which is set when iframe is created by embed.ts and persists for the duration of iframe's life
// Note that, window.name isn't lost during hard navigation as well. Though, hard navigation isn't something that would happen in the app, but it's critical to be able to detect embed mode even after that(just in case)
// We might just use window.name but if just in case something resets the `window.name`, we will still have the namespace in query param
// It must be null for non-embed scenario.
const embedNamespace =
typeof embedNameSpaceFromQueryParam === "string"
? embedNameSpaceFromQueryParam
: window.name.includes("cal-embed=")
? window.name.replace(/cal-embed=(.*)/, "$1").trim()
: hasEmbedPath
? defaultNamespace // If query param is not there but /embed is used then assume default namespace.
: null;
window.isEmbed = () => {
// By default namespace is "". That would also work if we just check the type of variable
return typeof embedNamespace == "string";
};
window.getEmbedTheme = () => {
// Note that embedStore.theme is lost if hard navigation occurs.(Though, it isn't something that we expect to happen normally)
if (window.CalEmbed.embedStore.theme) {
// It is important to ensure that the theme is consistent during browsing so that ThemeProvider doesn't get different themes to show and it avoids theme switching.
return window.CalEmbed.embedStore.theme;
}
const url = new URL(document.URL);
return url.searchParams.get("theme") as EmbedThemeConfig | null;
};
window.getEmbedNamespace = () => {
return embedNamespace;
};
window.CalEmbed = window.CalEmbed || {};
window.CalEmbed.applyCssVars = (cssVarsPerTheme) => {
const cssVarsStyle = [];
if (cssVarsPerTheme) {
for (const [themeName, cssVars] of Object.entries(cssVarsPerTheme)) {
cssVarsStyle.push(`.${themeName} {`);
for (const [cssVarName, value] of Object.entries(cssVars)) {
// The styles are applied inline on .light/.dark elements by the codebase(useCalcomTheme). So, to make sure embed styles take precedence, we add !important
cssVarsStyle.push(`--${cssVarName}: ${value} !important;`);
}
cssVarsStyle.push(`}`);
}
}
const existingStyleEl = document.head.querySelector("#embed-css-vars") as HTMLStyleElement;
if (existingStyleEl) {
console.warn("Existing embed CSS Vars are being reset");
existingStyleEl.innerText = cssVarsStyle.join("\n");
return;
}
const style = document.createElement("style");
style.id = "embed-css-vars";
style.innerText = cssVarsStyle.join("\n");
document.head.appendChild(style);
};
}

View File

@@ -0,0 +1,2 @@
export * from "./src/embed-iframe";
export * from "./src/sdk-event";

View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly EMBED_PUBLIC_WEBAPP_URL: string;
readonly EMBED_PUBLIC_VERCEL_URL: string;
readonly EMBED_PUBLIC_EMBED_LIB_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,394 @@
<html>
<head>
<title>Embed Playground</title>
<!-- <link rel="prerender" href="http://localhost:3000/free"> -->
<!-- <script src="./src/embed.ts" type="module"></script> -->
<script>
function generateRandomHexColor() {
// Generate a random integer between 0 and 16777215 (FFFFFF in hex)
const randomInt = Math.floor(Math.random() * 16777216);
// Convert the integer to a hex string with 6 digits and add leading zeros if necessary
const hexString = randomInt.toString(16).padStart(6, "0");
// Return the hex string with a '#' prefix
return `#${hexString}`;
}
if (!location.search.includes("nonResponsive")) {
document.write('<meta name="viewport" content="width=device-width"/>');
}
(() => {
const url = new URL(document.URL);
// Only run the example specified by only=, avoids distraction and faster to test.
const only = (window.only = url.searchParams.get("only") || '');
const elementIdentifier = only !== "all" ? only.replace("ns:", "") : null;
if (elementIdentifier) {
location.hash = "#cal-booking-place-" + elementIdentifier + "-iframe";
}
})();
function addInlineEmbedInNewNamespaceWithoutReload(selector) {
Cal("init", "withoutReloadNamespace", {
debug: true,
calOrigin: "http://localhost:3000",
});
Cal.ns.withoutReloadNamespace("inline", {
elementOrSelector: selector,
calLink: "pro?case=addInlineEmbedInANewNamespaceWithoutReload",
});
};
</script>
<script>
// Put the snippet in a function so that it can be re-executed for scenario testing
// TODO: How to reuse embed-snippet export here?
function embedSnippet() {
(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 () {
p(api, arguments);
};
const namespace = ar[1];
api.q = api.q || [];
if (typeof namespace === "string"){
// Make sure that even after re-execution of the snippet, the namespace is not overridden
cal.ns[namespace] = cal.ns[namespace] || api;
p(cal.ns[namespace], ar);
p(cal, ['initNamespace', namespace])
} else p(cal, ar);
return;
}
p(cal, ar);
};
})(window, "//localhost:3000/embed/embed.js", "init");
}
embedSnippet();
</script>
<script>
function doubleInstallSnippetWithInlineEmbedWithNonDefaultNamespace(selector) {
Cal("init", "doubleInstall", {
debug: true,
calOrigin: "http://localhost:3000",
});
Cal.ns.doubleInstall("inline", {
elementOrSelector: selector,
calLink: "pro?case=doubleInstallSnippetWithInlineEmbedWithNonDefaultNamespace",
});
embedSnippet();
Cal("init", "doubleInstall", {
debug: true,
calOrigin: "http://localhost:3000",
});
Cal.ns.doubleInstall("inline", {
elementOrSelector: selector,
calLink: "pro",
});
}
function doubleInstallSnippetWithInlineEmbed(selector) {
Cal("init", {
debug: true,
calOrigin: "http://localhost:3000",
});
Cal("inline", {
elementOrSelector: selector,
calLink: "pro?case=doubleInstallSnippetWithInlineEmbed",
});
embedSnippet();
Cal("init", {
debug: true,
calOrigin: "http://localhost:3000",
});
Cal("inline", {
elementOrSelector: selector,
calLink: "pro",
});
}
function addTwoInlineEmbedWithTwoDifferentNamespacesWithDifferentInitConfig() {
Cal("init", "namespace-init-test-1", {
debug: true,
calOrigin: "http://localhost:3000",
});
Cal("init", "namespace-init-test-2", {
debug: true,
calOrigin: "http://127.0.0.1:3000",
});
Cal.ns['namespace-init-test-2']("inline", {
elementOrSelector: '#two-different-namespace-with-different-init-config-content-2',
calLink: "pro",
});
Cal.ns['namespace-init-test-1']("inline", {
elementOrSelector: '#two-different-namespace-with-different-init-config-content-1',
calLink: "pro",
});
}
</script>
<style>
body {
background: linear-gradient(
90deg,
rgba(120, 116, 186, 1) 0%,
rgba(221, 221, 255, 1) 41%,
rgba(148, 232, 249, 1) 100%
);
}
.inline-embed-container {
/* border: 1px solid black; */
margin-bottom: 5px;
border-bottom: 1px solid;
}
.loader {
color: green;
}
* {
--cal-brand-color: gray;
}
</style>
</head>
<body>
<div>
<button id="add-inline-embed-in-a-new-namespace-without-reload-button" onclick="addInlineEmbedInNewNamespaceWithoutReload('#add-inline-embed-in-a-new-namespace-without-reload-button-content')">Add Inline Embed in a new namespace without reload</button>
<div id="add-inline-embed-in-a-new-namespace-without-reload-button-content"></div>
</div>
<div>
<button id="double-install-snippet-with-inline-embed-default-namespace-button" onclick="doubleInstallSnippetWithInlineEmbed('#double-install-snippet-with-inline-embed-default-namespace-content')">Double Install Embed Snippet with inline embed - Default Namespace</button>
<div id="double-install-snippet-with-inline-embed-default-namespace-content"></div>
</div>
<div>
<button id="double-install-snippet-with-inline-embed-non-default-namespace-button" onclick="doubleInstallSnippetWithInlineEmbedWithNonDefaultNamespace('#double-install-snippet-with-inline-embed-non-default-namespace-content')">Double Install Embed Snippet with inline embed - Non Default Namespace</button>
<div id="double-install-snippet-with-inline-embed-non-default-namespace-content"></div>
</div>
<div>
<button id="two-different-namespace-with-different-init-config" onclick="addTwoInlineEmbedWithTwoDifferentNamespacesWithDifferentInitConfig()">Two different namespace with two different config</button>
<div id="two-different-namespace-with-different-init-config-area">
<div id="two-different-namespace-with-different-init-config-content-1"></div>
<div id="two-different-namespace-with-different-init-config-content-2"></div>
</div>
</div>
<span style="display: block"><a href="?color-scheme=dark">With Dark Color Scheme for the Page</a></span>
<span style="display: block"><a href="?nonResponsive">Non responsive version of this page here</a></span>
<span style="display: block"
><a href="?only=prerender-test">Go to Prerender test page only</a><small></small
></span>
<span style="display: block"
><a href="?only=preload-test">Go to Preload test page only</a><small></small
></span>
<button onclick="document.documentElement.style.colorScheme='dark'">Toggle Dark Scheme</button>
<button onclick="document.documentElement.style.colorScheme='light'">Toggle Light Scheme</button>
<div>
<script>
if (only === "all" || only === "prerender-test") {
document.write(`
<button data-cal-namespace="e2ePrerenderLightTheme" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
<i
>Corresponding Cal Link is being prerendered. Assuming that it would take you some time to click this
as you are reading this text, it would open up super fast[If you are running a production build on
local]. Try switching to slow 3G or create a custom Network configuration which is impossibly
slow. This should be used if you know beforehand which type of embed is going to be opened.</i
>`);
}
if (only === "all" || only === "preload-test") {
document.write(`
<button data-cal-namespace="preloadTest" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
<i
>Corresponding Cal Link is being preloaded. That means that all the resources would be preloaded. This could be useful in preloading possible resources if you don't know before hand which type of embed you want to show</i
>`);
}
</script>
</div>
<span style="display: block"
><a href="?only=all">Render All embeds together</a><small> - It would be slow to load</small></span
>
<div>
<a href="?only=ns:floatingButton">Floating Popup</a>
<h2>Popup Examples</h2>
<button data-cal-namespace="e2ePopupLightTheme" data-cal-link="free" data-cal-config='{"theme":"light"}'>Book an event with Free[Light Theme]</button>
<button data-cal-namespace="popupAutoTheme" data-cal-link="free">
Book with Free User[Auto Theme]
</button>
<button data-cal-namespace="popupDarkTheme" data-cal-config='{"theme":"dark"}' data-cal-link="free">
Book with Free User[Dark Theme]
</button>
<button
data-cal-namespace="popupTeamLinkLightTheme"
data-cal-config='{"theme":"light"}'
data-cal-link="team/seeded-team/collective-seeded-team-event">
Book with Test Team[Light Theme]
</button>
<button
data-cal-namespace="popupTeamLinkDarkTheme"
data-cal-config='{"theme":"dark"}'
data-cal-link="team/seeded-team/collective-seeded-team-event">
Book with Test Team[Dark Theme]
</button>
<button data-cal-namespace="popupTeamLinksList" data-cal-link="team/seeded-team/">
See Team Links [Auto Theme]
</button>
<script>
let popupRescheduleId =
new URL(document.URL).searchParams.get("popupRescheduleId") || "qm3kwt3aTnVD7vmP9tiT2f";
document.write(
`<button data-cal-namespace="popupReschedule" data-cal-link="reschedule/${popupRescheduleId}">Reschedule Event[Auto Theme]</button>`
);
</script>
<button data-cal-namespace="popupPaidEvent" data-cal-link="pro/paid">
Book Paid Event [Auto Theme]
</button>
<button data-cal-namespace="popupHideEventTypeDetails" data-cal-link="free/30min">
Book Free Event [Auto Theme][uiConfig.hideEventTypeDetails=true]
</button>
<button data-cal-namespace="routingFormAuto" data-cal-link="forms/948ae412-d995-4865-875a-48302588de03">
Book through Routing Form [Auto Theme] - Test for 'routed' event
</button>
<button
data-cal-namespace="routingFormDark"
data-cal-config='{"theme":"dark"}'
data-cal-link="forms/948ae412-d995-4865-875a-48302588de03">
Book through Routing Form [Dark Theme]
</button>
<button data-cal-namespace="popupPaidEvent" data-cal-config='{"layout":"week_view"}' data-cal-link="pro/paid">
Book Paid Event - weekly view
</button>
<button data-cal-namespace="popupPaidEvent" data-cal-config='{"layout":"column_view"}' data-cal-link="pro/paid">
Book Paid Event - column view
</button>
<a style="display: block;" data-cal-namespace="childElementTarget" href="javascript:void(0)" data-cal-link="free/30min">
I am Anchor
<b>[I am Bold inside anchor]</b>
<span>
I am span inside anchor
</span>
</a>
</div>
<h2>Inline Embed Examples</h2>
<div id="namespaces-test">
<div class="inline-embed-container" id="cal-booking-place-default">
<h3>
<a href="?only=ns:default">[Dark Theme][Guests(janedoe@example.com and test@example.com)](Default Namespace)</a>
</h3>
<button onclick="Cal('ui',{theme:'light'})">Toggle to Light</button>
<button onclick="Cal('ui',{layout:'week_view'})">Toggle to Week View</button>
<button onclick="Cal('ui',{layout:'month_view'})">Toggle to Month View</button>
<button onclick="Cal('ui',{layout:'column_view'})">Toggle to Column View</button>
<i class="last-action"> You would see last Booking page action in my place </i>
<div>
<div class="place" style="width: 100%"></div>
<div class="loader" id="cal-booking-loader-"></div>
</div>
</div>
<div class="inline-embed-container" id="cal-booking-place-second">
<h3><a href="?only=ns:second">[Custom Styling]</a></h3>
<div class="place">
<div>
If you render booking embed in me, I won't restrict you. The entire page is yours. Content is by
default aligned center
</div>
<button
onclick="(function () {Cal.ns.second('ui', {cssVarsPerTheme:{light:{'cal-border-booker':'green', 'cal-border-booker-width':'20px'},dark:{'cal-border-booker':'red', 'cal-border-booker-width':'5px'}}})})()">
Change booker border for dark and light themes
</button>
<button
onclick="(function () {Cal.ns.second('ui', {cssVarsPerTheme:{light:{'cal-brand':'green'},dark:{'cal-brand':'red'}}})})()">
Change Brand color for dark and light theme
</button>
<button
onclick="(function () {Cal.ns.second('ui', {styles:{eventTypeListItem:{backgroundColor:'blue'}}})})()">
Change <code>eventTypeListItem</code> bg color[Deprecated]
</button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{body:{background:'red'}}})})()">
Change <code>body</code> bg color[Deprecated]
</button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{align:'left'}})})()">
Align left[Deprecated]
</button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{align:'center'}})})()">
Align Center[Deprecated]
</button>
<button
onclick="(function () {Cal.ns.second('ui', {styles:{enabledDateButton: {
backgroundColor: generateRandomHexColor(),
},
disabledDateButton: {
backgroundColor: generateRandomHexColor(),
},}})})()">
Change 'enabledDateButton` and `disabledDateButton` background Color[Deprecated]
</button>
</div>
</div>
<div class="inline-embed-container" id="cal-booking-place-third">
<h3><a href="?only=ns:third">[Custom Styling - Transparent Background]</a></h3>
<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>
</div>
<div class="inline-embed-container" id="cal-booking-place-fourth">
<h3><a href="?only=ns:fourth">[Team Event Test][inline taking entire width]</a></h3>
<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>
</div>
</div>
<div class="inline-embed-container" id="cal-booking-place-fifth">
<h3><a href="?only=ns:fifth">[Team Event Test][inline along with some content]</a></h3>
<div style="display: flex; align-items: center">
<h4 style="width: 30%">On the right side you can book a team meeting =></h4>
<div style="width: 70%" class="place"></div>
</div>
</div>
<div class="inline-embed-container" id="cal-booking-place-inline-routing-form">
<h3><a href="?only=inline-routing-form">Inline Routing Form</a></h3>
<div style="display: flex; align-items: center">
<h4 style="width: 30%">On the right side you can book a team meeting =></h4>
<div style="width: 70%" class="place"></div>
</div>
</div>
<div class="inline-embed-container" id="cal-booking-place-hideEventTypeDetails">
<h3><a href="?only=hideEventTypeDetails">Hide EventType Details Test</a></h3>
<div class="place"></div>
</div>
<div class="inline-embed-container" id="cal-booking-place-conflicting-theme">
<h3><a href="?only=conflicting-theme">You would be able to test out conflicting themes for the same namespace here.</a></h3>
<div class="light"></div>
<div class="dark"></div>
<i>Note that one of the embeds would stay in loading state as they are using the same namespace and it is not supported to have more than 1 embeds using same namespace</i>
</div>
</div>
<div class="inline-embed-container" id="cal-booking-place-monthView">
<h3><a href="?only=ns:monthView">Test Month View</a></h3>
<div class="place" style="width: 100%"></div>
</div>
<div class="inline-embed-container" id="cal-booking-place-weekView">
<h3><a href="?only=ns:weekView">Test Week View</a></h3>
<div class="place" style="width: 100%"></div>
</div>
<div class="inline-embed-container" id="cal-booking-place-columnView">
<h3><a href="?only=ns:columnView">Test Column View</a></h3>
<div class="place" style="width: 100%"></div>
</div>
<script type="module" src="./playground.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,2 @@
export * from "./src/sdk-event";
export * from "./src/embed";

View File

@@ -0,0 +1,59 @@
{
"name": "@calcom/embed-core",
"version": "1.5.0",
"description": "This is the vanilla JS core script that embeds Cal Link",
"main": "./dist/embed/embed.js",
"types": "./dist/index.d.ts",
"license": "SEE LICENSE IN LICENSE",
"repository": {
"type": "git",
"url": "https://github.com/calcom/cal.com",
"directory": "packages/embeds/embed-core"
},
"scripts": {
"embed-dev": "yarn workspace @calcom/embed-core dev",
"embed-web-start": "yarn workspace @calcom/web start",
"__build": "yarn tailwind && vite build && tsc --emitDeclarationOnly --declarationDir dist && cp -r ../../../apps/web/public/embed ./dist/",
"__dev": "yarn tailwind && vite build --mode development",
"build": "rm -rf dist && NEXT_PUBLIC_EMBED_FINGER_PRINT=$(git rev-parse --short HEAD) yarn __build",
"build-preview": "PREVIEW_BUILD=1 yarn __build ",
"vite": "vite",
"tailwind": "yarn tailwindcss -i ./src/styles.css -o ./src/tailwind.generated.css",
"buildWatchAndServer": "run-p '__dev' 'vite --port 3100 --strict-port --host --open'",
"buildWatchAndServer-https": "run-p '__dev' 'vite --port 3100 --strict-port --host --open --https'",
"dev": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer'",
"dev-https": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer-https'",
"dev-real": "vite dev --port 3100",
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint --ext .ts,.js src",
"lint:fix": "eslint --ext .ts,.js src --fix",
"embed-tests": "yarn playwright test --config=playwright/config/playwright.config.ts",
"embed-tests-quick": "QUICK=true yarn embed-tests",
"embed-tests-update-snapshots:ci": "yarn embed-tests-quick --update-snapshots",
"withEmbedPublishEnv": "NEXT_PUBLIC_EMBED_LIB_URL='https://app.cal.com/embed/embed.js' NEXT_PUBLIC_WEBAPP_URL='https://app.cal.com' yarn",
"prepack": "yarn ../../../ lint --filter='@calcom/embed-core' && yarn withEmbedPublishEnv build",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf ../../../apps/web/public/embed"
},
"files": [
"dist"
],
"postcss": {
"map": false,
"plugins": {
"tailwindcss": {},
"autoprefixer": {}
}
},
"devDependencies": {
"@playwright/test": "^1.31.2",
"@vitejs/plugin-basic-ssl": "^1.1.0",
"autoprefixer": "^10.4.12",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.18",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.4",
"vite": "^4.1.2",
"vite-plugin-environment": "^1.1.3"
}
}

View File

@@ -0,0 +1,498 @@
import type { GlobalCal } from "./src/embed";
const Cal = window.Cal as GlobalCal;
const callback = function (e) {
const detail = e.detail;
console.log("Event: ", e.type, detail);
};
const origin = `${new URL(document.URL).protocol}localhost:3000`;
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if ("href" in target && typeof target.href === "string") {
const toUrl = new URL(target.href);
const pageUrl = new URL(document.URL);
for (const [name, value] of pageUrl.searchParams.entries()) {
if (toUrl.searchParams.get(name) === null) {
toUrl.searchParams.append(decodeURIComponent(name), value);
}
}
location.href = `?${toUrl.searchParams.toString()}#${toUrl.hash}`;
e.preventDefault();
}
});
const searchParams = new URL(document.URL).searchParams;
const only = searchParams.get("only");
const colorScheme = searchParams.get("color-scheme");
const prerender = searchParams.get("prerender");
if (colorScheme) {
document.documentElement.style.colorScheme = colorScheme;
}
const themeInParam = searchParams.get("theme");
const validThemes = ["light", "dark", "auto"] as const;
const theme = validThemes.includes((themeInParam as (typeof validThemes)[number]) || "")
? (themeInParam as (typeof validThemes)[number])
: null;
if (themeInParam && !theme) {
throw new Error(`Invalid theme: ${themeInParam}`);
}
const calLink = searchParams.get("cal-link");
if (only === "all" || only === "ns:default") {
Cal("init", {
debug: true,
calOrigin: origin,
});
Cal("inline", {
elementOrSelector: "#cal-booking-place-default .place",
calLink: "pro?case=1",
config: {
iframeAttrs: {
id: "cal-booking-place-default-iframe",
},
name: "John",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@example.com", "test@example.com"],
theme: "dark",
},
});
Cal("on", {
action: "*",
callback,
});
}
if (only === "all" || 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: true,
origin: origin,
});
Cal.ns.second(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-second .place",
calLink: "pro?case=2",
config: {
iframeAttrs: {
id: "cal-booking-place-second-iframe",
},
theme: "auto",
},
}
);
Cal.ns.second("on", {
action: "*",
callback,
});
}
if (only === "all" || 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: true,
origin: origin,
});
Cal.ns.third(
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-third .place",
calLink: "pro/30min",
config: {
iframeAttrs: {
id: "cal-booking-place-third-iframe",
},
},
},
],
[
"ui",
{
styles: {
body: {
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
enabledDateButton: {
backgroundColor: "red",
},
disabledDateButton: {
backgroundColor: "green",
},
},
},
]
);
Cal.ns.third("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:fourth") {
Cal("init", "fourth", {
debug: true,
origin: origin,
});
Cal.ns.fourth(
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-fourth .place",
calLink: "team/seeded-team",
config: {
iframeAttrs: {
id: "cal-booking-place-fourth-iframe",
},
},
},
],
[
"ui",
{
styles: {
body: {
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
},
},
]
);
Cal.ns.fourth("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:fifth") {
Cal("init", "fifth", {
debug: true,
origin: origin,
});
Cal.ns.fifth([
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-fifth .place",
calLink: "team/seeded-team/collective-seeded-team-event",
config: {
iframeAttrs: {
id: "cal-booking-place-fifth-iframe",
},
},
},
]);
Cal.ns.fifth("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "prerender-test") {
Cal("init", "e2ePrerenderLightTheme", {
debug: true,
origin: origin,
});
Cal.ns.e2ePrerenderLightTheme("prerender", {
calLink: "free/30min",
type: "modal",
});
}
if (only === "all" || only === "preload-test") {
Cal("init", "preloadTest", {
debug: true,
origin: origin,
});
Cal.ns.preloadTest("preload", {
calLink: "free/30min",
});
}
if (only === "all" || only === "inline-routing-form") {
Cal("init", "inline-routing-form", {
debug: true,
origin: origin,
});
Cal.ns["inline-routing-form"]([
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-inline-routing-form .place",
calLink: "forms/948ae412-d995-4865-875a-48302588de03",
config: {
iframeAttrs: {
id: "cal-booking-place-inline-routing-form-iframe",
},
},
},
]);
}
if (only === "all" || only === "hideEventTypeDetails") {
const identifier = "hideEventTypeDetails";
Cal("init", identifier, {
debug: true,
origin: origin,
});
Cal.ns.hideEventTypeDetails(
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: `#cal-booking-place-${identifier} .place`,
calLink: "free/30min",
config: {
iframeAttrs: {
id: `cal-booking-place-${identifier}-iframe`,
},
},
},
],
[
"ui",
{
hideEventTypeDetails: true,
},
]
);
}
if (only === "conflicting-theme") {
Cal("init", "conflictingTheme", {
debug: true,
origin: origin,
});
Cal.ns.conflictingTheme("inline", {
elementOrSelector: "#cal-booking-place-conflicting-theme .dark",
calLink: "pro/30min",
config: {
theme: "dark",
},
});
Cal.ns.conflictingTheme("inline", {
elementOrSelector: "#cal-booking-place-conflicting-theme .light",
calLink: "pro/30min",
config: {
theme: "light",
},
});
}
Cal("init", "popupDarkTheme", {
debug: true,
origin: origin,
});
Cal("init", "e2ePopupLightTheme", {
debug: true,
origin: origin,
});
Cal("init", "popupHideEventTypeDetails", {
debug: true,
origin: origin,
});
Cal.ns.popupHideEventTypeDetails("ui", {
hideEventTypeDetails: true,
});
Cal("init", "popupReschedule", {
debug: true,
origin: origin,
});
Cal("init", "popupAutoTheme", {
debug: true,
origin: origin,
});
Cal("init", "popupTeamLinkLightTheme", {
debug: true,
origin: origin,
});
Cal("init", "popupTeamLinkDarkTheme", {
debug: true,
origin: origin,
});
Cal("init", "popupTeamLinkDarkTheme", {
debug: true,
origin: origin,
});
Cal("init", "popupTeamLinksList", {
debug: true,
origin: origin,
});
Cal("init", "popupPaidEvent", {
debug: true,
origin: origin,
});
Cal("init", "childElementTarget", {
debug: true,
origin: origin,
});
Cal("init", "floatingButton", {
debug: true,
origin: origin,
});
Cal("init", "routingFormAuto", {
debug: true,
origin: origin,
});
Cal.ns.routingFormAuto("on", {
action: "routed",
callback: (e) => {
const detail = e.detail;
console.log("`routed` event data:", detail.data);
alert(`Routing Done - Check console for 'routed' event data`);
},
});
Cal("init", "routingFormDark", {
debug: true,
origin: origin,
});
if (only === "all" || only == "ns:floatingButton") {
if (prerender == "true") {
Cal.ns.floatingButton("prerender", {
calLink: calLink || "pro",
type: "floatingButton",
});
}
Cal.ns.floatingButton("floatingButton", {
calLink: calLink || "pro",
config: {
iframeAttrs: {
id: "floatingtest",
},
name: "John",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@example.com", "test@example.com"],
...(theme ? { theme } : {}),
},
});
}
if (only === "all" || only == "ns:monthView") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "monthView", {
debug: true,
origin: origin,
});
Cal.ns.monthView(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-monthView .place",
calLink: "pro/paid",
config: {
iframeAttrs: {
id: "cal-booking-place-monthView-iframe",
},
layout: "month_view",
},
}
);
Cal.ns.monthView("on", {
action: "*",
callback,
});
}
if (only === "all" || only == "ns:weekView") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "weekView", {
debug: true,
origin: origin,
});
Cal.ns.weekView(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-weekView .place",
calLink: "pro/paid",
config: {
iframeAttrs: {
id: "cal-booking-place-weekView-iframe",
},
layout: "week_view",
},
}
);
Cal.ns.weekView("on", {
action: "*",
callback,
});
}
if (only === "all" || only == "ns:columnView") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "columnView", {
debug: true,
origin: origin,
});
Cal.ns.columnView(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-columnView .place",
calLink: "pro/paid",
config: {
iframeAttrs: {
id: "cal-booking-place-columnView-iframe",
},
layout: "column_view",
},
}
);
Cal.ns.columnView("on", {
action: "*",
callback,
});
}

View File

@@ -0,0 +1,167 @@
import type { Page, Frame } from "@playwright/test";
import { test, expect } from "@playwright/test";
// eslint-disable-next-line no-restricted-imports
import prisma from "@calcom/prisma";
export function todo(title: string) {
// eslint-disable-next-line @typescript-eslint/no-empty-function, playwright/no-skipped-test
test.skip(title, () => {});
}
export const deleteAllBookingsByEmail = async (email: string) =>
await prisma.booking.deleteMany({
where: {
attendees: {
some: {
email: email,
},
},
},
});
export const getBooking = async (bookingId: string) => {
const booking = await prisma.booking.findUnique({
where: {
uid: bookingId,
},
include: {
attendees: true,
},
});
if (!booking) {
throw new Error("Booking not found");
}
return booking;
};
export const getEmbedIframe = async ({
calNamespace,
page,
pathname,
}: {
calNamespace: string;
page: Page;
pathname: string;
}) => {
// We can't seem to access page.frame till contentWindow is available. So wait for that.
const iframeReady = await page.evaluate(
(hardTimeout) => {
return new Promise((resolve) => {
const interval = setInterval(() => {
const iframe = document.querySelector<HTMLIFrameElement>(".cal-embed");
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (iframe && iframe.contentWindow && window.iframeReady) {
clearInterval(interval);
resolve(true);
} else {
console.log("Waiting for all three to be true:", {
iframeElement: iframe,
contentWindow: iframe?.contentWindow,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
iframeReady: window.iframeReady,
});
}
}, 500);
// A hard timeout if iframe isn't ready in that time. Avoids infinite wait
setTimeout(() => {
clearInterval(interval);
resolve(false);
// This is the time embed-iframe.ts loads in the iframe and fires atleast one event. Also, it is a load of entire React Application so it can sometime take more time even on CI.
}, hardTimeout);
});
},
!process.env.CI ? 150000 : 15000
);
if (!iframeReady) {
return null;
}
// We just verified that iframeReady is true here, so obviously embedIframe is not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const embedIframe = page.frame(`cal-embed=${calNamespace}`)!;
const u = new URL(embedIframe.url());
if (u.pathname === `${pathname}/embed`) {
return embedIframe;
}
console.log(`Embed iframe url pathname match. Expected: "${pathname}/embed"`, `Actual: ${u.pathname}`);
return null;
};
async function selectFirstAvailableTimeSlotNextMonth(frame: Frame, page: Page) {
await frame.click('[data-testid="incrementMonth"]');
// @TODO: Find a better way to make test wait for full month change render to end
// so it can click up on the right day, also when done, resolve other todos as well
// The problem is that the Month Text changes instantly but we don't know when the corresponding dates are visible
// Waiting for full month increment
await frame.waitForTimeout(1000);
// expect(await page.screenshot()).toMatchSnapshot("availability-page-2.png");
// TODO: Find out why the first day is always booked on tests
await frame.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
await frame.click('[data-testid="time"]');
}
export async function bookFirstEvent(username: string, frame: Frame, page: Page) {
// Click first event type on Profile Page
await frame.click('[data-testid="event-type-link"]');
await frame.waitForURL((url) => {
// Wait for reaching the event page
const matches = url.pathname.match(new RegExp(`/${username}/(.+)$`));
if (!matches || !matches[1]) {
return false;
}
if (matches[1] === "embed") {
return false;
}
return true;
});
// Let current month dates fully render.
// There is a bug where if we don't let current month fully render and quickly click go to next month, current month get's rendered
// This doesn't seem to be replicable with the speed of a person, only during automation.
// It would also allow correct snapshot to be taken for current month.
await frame.waitForTimeout(1000);
// expect(await page.screenshot()).toMatchSnapshot("availability-page-1.png");
// Remove /embed from the end if present.
const eventSlug = new URL(frame.url()).pathname.replace(/\/embed$/, "");
await selectFirstAvailableTimeSlotNextMonth(frame, page);
// expect(await page.screenshot()).toMatchSnapshot("booking-page.png");
// --- fill form
await frame.fill('[name="name"]', "Embed User");
await frame.fill('[name="email"]', "embed-user@example.com");
await frame.press('[name="email"]', "Enter");
const response = await page.waitForResponse("**/api/book/event");
const booking = (await response.json()) as { uid: string; eventSlug: string };
booking.eventSlug = eventSlug;
// Make sure we're navigated to the success page
await expect(frame.locator("[data-testid=success-page]")).toBeVisible();
// expect(await page.screenshot()).toMatchSnapshot("success-page.png");
return booking;
}
export async function rescheduleEvent(username: string, frame: Frame, page: Page) {
await selectFirstAvailableTimeSlotNextMonth(frame, page);
// --- fill form
await frame.press('[name="email"]', "Enter");
await frame.click("[data-testid=confirm-reschedule-button]");
const response = await page.waitForResponse("**/api/book/event");
const responseObj = await response.json();
const booking = responseObj.uid;
// Make sure we're navigated to the success page
await expect(frame.locator("[data-testid=success-page]")).toBeVisible();
return booking;
}
export async function installAppleCalendar(page: Page) {
await page.goto("/apps/categories/calendar");
await page.click('[data-testid="app-store-app-card-apple-calendar"]');
await page.waitForURL("/apps/apple-calendar");
await page.click('[data-testid="install-app-button"]');
}

View File

@@ -0,0 +1,310 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { test } from "@calcom/web/playwright/lib/fixtures";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils";
import {
todo,
getEmbedIframe,
bookFirstEvent,
getBooking,
deleteAllBookingsByEmail,
rescheduleEvent,
} from "../lib/testUtils";
// in parallel mode sometimes handleNewBooking endpoint throws "No available users found" error, this never happens in serial mode.
test.describe.configure({ mode: "serial" });
async function bookFirstFreeUserEventThroughEmbed({
addEmbedListeners,
page,
getActionFiredDetails,
}: {
addEmbedListeners: Fixtures["embeds"]["addEmbedListeners"];
page: Page;
getActionFiredDetails: Fixtures["embeds"]["getActionFiredDetails"];
}) {
const embedButtonLocator = page.locator('[data-cal-link="free"]').first();
await page.goto("/");
// Obtain cal namespace from the element being clicked itself, so that addEmbedListeners always listen to correct namespace
const calNamespace = (await embedButtonLocator.getAttribute("data-cal-namespace")) || "";
await addEmbedListeners(calNamespace);
// Goto / again so that initScript attached using addEmbedListeners can work now.
await page.goto("/");
await embedButtonLocator.click();
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/free",
});
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
const booking = await bookFirstEvent("free", embedIframe, page);
return booking;
}
//TODO: Change these tests to use a user/eventType per embed type atleast. This is so that we can test different themes,layouts configured in App or per EventType
test.describe("Popup Tests", () => {
test.afterEach(async () => {
await deleteAllBookingsByEmail("embed-user@example.com");
});
test("should open embed iframe on click - Configured with light theme", async ({ page, embeds }) => {
await deleteAllBookingsByEmail("embed-user@example.com");
const calNamespace = "e2ePopupLightTheme";
await embeds.gotoPlayground({ calNamespace, url: "/" });
await page.click(`[data-cal-namespace="${calNamespace}"]`);
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: "/free",
});
// expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
const { uid: bookingId } = await bookFirstEvent("free", embedIframe, page);
const booking = await getBooking(bookingId);
expect(booking.attendees.length).toBe(1);
await deleteAllBookingsByEmail("embed-user@example.com");
});
test("should be able to reschedule", async ({
page,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const booking = await test.step("Create a booking", async () => {
return await bookFirstFreeUserEventThroughEmbed({
page,
addEmbedListeners,
getActionFiredDetails,
});
});
await test.step("Reschedule the booking", async () => {
await addEmbedListeners("popupReschedule");
await page.goto(`/?popupRescheduleId=${booking.uid}`);
await page.click('[data-cal-namespace="popupReschedule"]');
const calNamespace = "popupReschedule";
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: booking.eventSlug });
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
await rescheduleEvent("free", embedIframe, page);
});
});
todo("Add snapshot test for embed iframe");
test("should open Routing Forms embed on click", async ({
page,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
await deleteAllBookingsByEmail("embed-user@example.com");
const calNamespace = "routingFormAuto";
await addEmbedListeners(calNamespace);
await page.goto("/?only=prerender-test");
let embedIframe = await getEmbedIframe({
calNamespace,
page,
pathname: "/forms/948ae412-d995-4865-875a-48302588de03",
});
expect(embedIframe).toBeFalsy();
await page.click(
`[data-cal-namespace=${calNamespace}][data-cal-link="forms/948ae412-d995-4865-875a-48302588de03"]`
);
embedIframe = await getEmbedIframe({
calNamespace,
page,
pathname: "/forms/948ae412-d995-4865-875a-48302588de03",
});
if (!embedIframe) {
throw new Error("Routing Form embed iframe not found");
}
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/forms/948ae412-d995-4865-875a-48302588de03",
});
await expect(embedIframe.locator("text=Seeded Form - Pro")).toBeVisible();
});
test.describe("Floating Button Popup", () => {
test.describe("Pro User - Configured in App with default setting of system theme", () => {
test("should open embed iframe according to system theme when no theme is configured through Embed API", async ({
page,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
await page.goto("/?only=ns:floatingButton");
await page.click('[data-cal-namespace="floatingButton"] > button');
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/pro",
});
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
const html = embedIframe.locator("html");
// Expect "light" theme as configured in App for pro user.
await expect(html).toHaveAttribute("class", "light");
const { uid: bookingId } = await bookFirstEvent("pro", embedIframe, page);
const booking = await getBooking(bookingId);
expect(booking.attendees.length).toBe(3);
await test.step("Close the modal", async () => {
await page.locator("cal-modal-box .close").click();
await expect(page.locator("cal-modal-box")).toBeHidden();
await expect(page.locator("cal-modal-box iframe")).toBeHidden();
});
});
test("should open embed iframe according to system theme when configured with 'auto' theme using Embed API", async ({
page,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
await page.goto("/?only=ns:floatingButton");
await page.click('[data-cal-namespace="floatingButton"] > button');
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/pro",
});
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
const html = embedIframe.locator("html");
const prefersDarkScheme = await page.evaluate(() => {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
});
// Detect browser preference and expect accordingly
await expect(html).toHaveAttribute("class", prefersDarkScheme ? "dark" : "light");
});
test("should open embed iframe(Booker Profile Page) with dark theme when configured with dark theme using Embed API", async ({
page,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
await page.goto("/?only=ns:floatingButton&theme=dark");
await page.click('[data-cal-namespace="floatingButton"] > button');
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/pro",
});
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
const html = embedIframe.locator("html");
await expect(html).toHaveAttribute("class", "dark");
});
test("should open embed iframe(Event Booking Page) with dark theme when configured with dark theme using Embed API", async ({
page,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
await page.goto("/?only=ns:floatingButton&cal-link=pro/30min&theme=dark");
await page.click('[data-cal-namespace="floatingButton"] > button');
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro/30min" });
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/pro/30min",
});
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
const html = embedIframe.locator("html");
await expect(html).toHaveAttribute("class", "dark");
});
});
});
test("prendered embed should be loaded and apply the config given to it", async ({ page, embeds }) => {
const calNamespace = "e2ePrerenderLightTheme";
const calLink = "/free/30min";
await embeds.gotoPlayground({ calNamespace, url: "/?only=prerender-test" });
await expectPrerenderedIframe({ calNamespace, calLink, embeds, page });
await page.click(`[data-cal-namespace="${calNamespace}"]`);
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink });
// eslint-disable-next-line playwright/no-conditional-in-test
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
await selectFirstAvailableTimeSlotNextMonth(embedIframe);
await expect(embedIframe.locator('[name="name"]')).toHaveValue("Preloaded Prefilled");
await expect(embedIframe.locator('[name="email"]')).toHaveValue("preloaded-prefilled@example.com");
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: calLink,
});
});
test("should open on clicking child element", async ({ page, embeds }) => {
await deleteAllBookingsByEmail("embed-user@example.com");
const calNamespace = "childElementTarget";
const configuredLink = "/free/30min";
await embeds.gotoPlayground({ calNamespace, url: "/" });
await page.click(`[data-cal-namespace="${calNamespace}"] b`);
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: configuredLink });
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: configuredLink,
});
});
});
async function expectPrerenderedIframe({
page,
calNamespace,
calLink,
embeds,
}: {
page: Page;
calNamespace: string;
calLink: string;
embeds: Fixtures["embeds"];
}) {
const prerenderedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink });
if (!prerenderedIframe) {
throw new Error("Prerendered iframe not found");
}
await expect(prerenderedIframe).toBeEmbedCalLink(
calNamespace,
embeds.getActionFiredDetails,
{
pathname: calLink,
},
true
);
}

View File

@@ -0,0 +1,130 @@
import { expect } from "@playwright/test";
// eslint-disable-next-line no-restricted-imports
import { test } from "@calcom/web/playwright/lib/fixtures";
import "../../src/types";
test.describe("Embed Pages", () => {
test("Event Type Page: should not have margin top on embed page", async ({ page }) => {
await page.goto("http://localhost:3000/free/30min/embed");
// Checks the margin from top by checking the distance between the div inside main from the viewport
const marginFromTop = await page.evaluate(async () => {
return await new Promise<{
bookerContainer: number;
mainEl: number;
}>((resolve) => {
(function tryGettingBoundingRect() {
const mainElement = document.querySelector(".main");
const bookerContainer = document.querySelector('[data-testid="booker-container"]');
if (mainElement && bookerContainer) {
// This returns the distance of the div element from the viewport
const mainElBoundingRect = mainElement.getBoundingClientRect();
const bookerContainerBoundingRect = bookerContainer.getBoundingClientRect();
resolve({ bookerContainer: bookerContainerBoundingRect.top, mainEl: mainElBoundingRect.top });
} else {
setTimeout(tryGettingBoundingRect, 500);
}
})();
});
});
expect(marginFromTop.bookerContainer).toBe(0);
expect(marginFromTop.mainEl).toBe(0);
});
test("Event Type Page: should have margin top on non embed page", async ({ page }) => {
await page.goto("http://localhost:3000/free/30min");
// Checks the margin from top by checking the distance between the div inside main from the viewport
const marginFromTop = await page.evaluate(() => {
const mainElement = document.querySelector("main");
const divElement = mainElement?.querySelector("div");
if (mainElement && divElement) {
// This returns the distance of the div element from the viewport
const divRect = divElement.getBoundingClientRect();
return divRect.top;
}
return null;
});
expect(marginFromTop).not.toBe(0);
});
test.describe("isEmbed, getEmbedNamespace, getEmbedTheme testing", () => {
test("when `window.name` is set to 'cal-embed=' and `theme` is supplied as a query param", async ({
page,
}) => {
const queryParamTheme = "dark";
await page.evaluate(() => {
window.name = "cal-embed=";
});
await page.goto(`http://localhost:3000/free/30min?theme=${queryParamTheme}`);
const isEmbed = await page.evaluate(() => {
return window?.isEmbed?.();
});
const embedNamespace = await page.evaluate(() => {
return window?.getEmbedNamespace?.();
});
expect(embedNamespace).toBe("");
expect(isEmbed).toBe(true);
const embedTheme = await page.evaluate(() => {
return window?.getEmbedTheme?.();
});
expect(embedTheme).toBe(queryParamTheme);
const embedStoreTheme = await page.evaluate(() => {
return window.CalEmbed.embedStore.theme;
});
// Verify that the theme is set on embedStore.
expect(embedStoreTheme).toBe(queryParamTheme);
});
test("when `window.name` does not contain `cal-embed=`", async ({ page }) => {
await page.evaluate(() => {
window.name = "testing";
});
await page.goto(`http://localhost:3000/free/30min`);
const isEmbed = await page.evaluate(() => {
return window?.isEmbed?.();
});
const embedNamespace = await page.evaluate(() => {
return window?.getEmbedNamespace?.();
});
expect(isEmbed).toBe(false);
expect(embedNamespace).toBe(null);
});
test("`getEmbedTheme` should use `window.CalEmbed.embedStore.theme` instead of `theme` query param if set", async ({
page,
}) => {
const theme = "dark";
await page.evaluate(() => {
window.name = "cal-embed=";
});
await page.goto("http://localhost:3000/free/30min?theme=dark");
let embedTheme = await page.evaluate(() => {
return window?.getEmbedTheme?.();
});
expect(embedTheme).toBe(theme);
// Fake a scenario where theme query param is lost during navigation
await page.evaluate(() => {
history.pushState({}, "", "/free/30min");
});
embedTheme = await page.evaluate(() => {
return window?.getEmbedTheme?.();
});
// Theme should still remain same as it's read from `window.CalEmbed.embedStore.theme` which is updated by getEmbedTheme itself
expect(embedTheme).toBe(theme);
});
});
});

View File

@@ -0,0 +1,36 @@
import { expect } from "@playwright/test";
import { test } from "@calcom/web/playwright/lib/fixtures";
import { bookFirstEvent, deleteAllBookingsByEmail, getEmbedIframe, todo } from "../lib/testUtils";
test.describe("Inline Iframe", () => {
test("Inline Iframe - Configured with Dark Theme", async ({
page,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
await deleteAllBookingsByEmail("embed-user@example.com");
await addEmbedListeners("");
await page.goto("/?only=ns:default");
const calNamespace = "";
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/pro",
searchParams: {
theme: "dark",
},
});
// expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
await bookFirstEvent("pro", embedIframe, page);
await deleteAllBookingsByEmail("embed-user@example.com");
});
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

@@ -0,0 +1,67 @@
import { expect } from "@playwright/test";
// eslint-disable-next-line no-restricted-imports
import { test } from "@calcom/web/playwright/lib/fixtures";
import { getEmbedIframe } from "../lib/testUtils";
test.describe("Namespacing", () => {
test.describe("Inline Embed", () => {
test("Add inline embed using a namespace without reload", async ({ page, embeds }) => {
const calNamespace = "withoutReloadNamespace";
await embeds.gotoPlayground({ calNamespace, url: "/" });
await page.click("#add-inline-embed-in-a-new-namespace-without-reload-button");
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: "/pro",
searchParams: {
case: "addInlineEmbedInANewNamespaceWithoutReload",
},
});
});
test("Double install Embed Snippet with inline embed using a namespace", async ({ page, embeds }) => {
const calNamespace = "doubleInstall";
await embeds.gotoPlayground({ calNamespace, url: "/" });
await page.click("#double-install-snippet-with-inline-embed-non-default-namespace-button");
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: "/pro",
searchParams: {
case: "doubleInstallSnippetWithInlineEmbedWithNonDefaultNamespace",
},
});
expect(await page.locator("iframe").count()).toBe(1);
});
test("Double install Embed Snippet with inline embed without a namespace(i.e. default namespace)", async ({
page,
embeds,
}) => {
const calNamespace = "";
await embeds.gotoPlayground({ calNamespace, url: "/" });
await page.click("#double-install-snippet-with-inline-embed-default-namespace-button");
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: "/pro",
searchParams: {
case: "doubleInstallSnippetWithInlineEmbed",
},
});
expect(await page.locator("iframe").count()).toBe(1);
});
});
test("Different namespaces can have different init configs", async ({ page, embeds }) => {
await Promise.all([
embeds.addEmbedListeners("namespace-init-test-1"),
embeds.addEmbedListeners("namespace-init-test-2"),
]);
await page.goto("/");
await page.click("#two-different-namespace-with-different-init-config");
const namespace1IframeSrc = await page.locator("iframe").nth(0).getAttribute("src");
const namespace2IframeSrc = await page.locator("iframe").nth(1).getAttribute("src");
expect(namespace1IframeSrc).toContain("http://localhost:3000/pro");
expect(namespace2IframeSrc).toContain("http://127.0.0.1:3000/pro");
});
});

View File

@@ -0,0 +1,45 @@
import { expect } from "@playwright/test";
import { test } from "@calcom/web/playwright/lib/fixtures";
test.describe("Preview", () => {
test("Preview - embed-core should load if correct embedLibUrl is provided", async ({ page }) => {
await page.goto(
"http://localhost:3000/embed/preview.html?embedLibUrl=http://localhost:3000/embed/embed.js&bookerUrl=http://localhost:3000&calLink=pro/30min"
);
const libraryLoaded = await page.evaluate(() => {
return new Promise((resolve) => {
setInterval(() => {
if (
(
window as unknown as {
Cal: {
__css: string;
};
}
).Cal.__css
) {
resolve(true);
}
}, 1000);
});
});
expect(libraryLoaded).toBe(true);
});
test("Preview - embed-core should load from embedLibUrl", async ({ page }) => {
// Intentionally pass a URL that will not load to be able to easily test that the embed was loaded from there
page.goto(
"http://localhost:3000/embed/preview.html?embedLibUrl=http://wronglocalhost:3000/embed/embed.js&bookerUrl=http://localhost:3000&calLink=pro/30min"
);
const failedRequestUrl = await new Promise<string>((resolve) =>
page.on("requestfailed", (request) => {
console.log("request failed");
resolve(request.url());
})
);
expect(failedRequestUrl).toBe("http://wronglocalhost:3000/embed/embed.js");
});
});

View File

@@ -0,0 +1,29 @@
<html>
<head>
<style>
.row {
display: flex;
}
.cell-1 {
border-right: 1px solid #ded9d9;
padding-right: 10px;
}
.cell-2 {
margin: 10px;
}
.dark {
background-color: rgb(16 16 16);
}
</style>
<script>
const searchParams = new URL(document.URL).searchParams;
const embedType = searchParams.get("embedType");
const calLink = searchParams.get("calLink");
</script>
</head>
<script type="module" src="./src/preview.ts"></script>
<body>
<div id="my-embed" style="width: 100%; height: 90%; overflow: scroll"></div>
</body>
</html>

View File

@@ -0,0 +1,116 @@
import getFloatingButtonHtml from "./FloatingButtonHtml";
type ModalTargetDatasetProps = {
calLink: string;
calNamespace: string;
calOrigin: string;
calConfig: string;
};
type CamelCase<T extends string> = T extends `${infer U}${infer V}` ? `${Uppercase<U>}${V}` : T;
type HyphenatedStringToCamelCase<S extends string> = S extends `${infer T}-${infer U}`
? `${T}${HyphenatedStringToCamelCase<CamelCase<U>>}`
: CamelCase<S>;
type HyphenatedDataStringToCamelCase<S extends string> = S extends `data-${infer U}`
? HyphenatedStringToCamelCase<U>
: S;
const dataAttributes = [
"data-button-text",
"data-hide-button-icon",
"data-button-position",
"data-button-color",
"data-button-text-color",
"data-toggle-off",
] as const;
type DataAttributes = (typeof dataAttributes)[number];
type DataAttributesCamelCase = HyphenatedDataStringToCamelCase<DataAttributes>;
export type FloatingButtonDataset = {
[key in DataAttributesCamelCase]: string;
};
export class FloatingButton extends HTMLElement {
static updatedClassString(position: string, classString: string) {
return [
classString.replace(/hidden|md:right-10|md:left-10|left-4|right-4/g, ""),
position === "bottom-right" ? "md:right-10 right-4" : "md:left-10 left-4",
].join(" ");
}
// Button added here triggers the modal on click. So, it has to have the same data attributes as the modal target as well
dataset!: DOMStringMap & FloatingButtonDataset & ModalTargetDatasetProps;
buttonWrapperStyleDisplay!: HTMLElement["style"]["display"];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
static get observedAttributes() {
return dataAttributes;
}
attributeChangedCallback(name: DataAttributes, oldValue: string, newValue: string) {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("#button");
const buttonWrapperEl = this.shadowRoot?.querySelector<HTMLElement>("button");
const buttonIconEl = this.shadowRoot?.querySelector<HTMLElement>("#button-icon");
if (!buttonEl) {
throw new Error("#button not found");
}
if (!buttonWrapperEl) {
throw new Error("button element not found");
}
if (!buttonIconEl) {
throw new Error("#button-icon not found");
}
if (name === "data-button-text") {
buttonEl.textContent = newValue;
} else if (name === "data-hide-button-icon") {
buttonIconEl.style.display = newValue == "true" ? "none" : "block";
} else if (name === "data-button-position") {
buttonWrapperEl.className = FloatingButton.updatedClassString(newValue, buttonWrapperEl.className);
} else if (name === "data-button-color") {
buttonWrapperEl.style.backgroundColor = newValue;
} else if (name === "data-button-text-color") {
buttonWrapperEl.style.color = newValue;
} else if (name === "data-toggle-off") {
const off = newValue == "true";
if (off) {
// When toggling off, back up the original display value so that it can be restored when toggled back on
this.buttonWrapperStyleDisplay = buttonWrapperEl.style.display;
}
buttonWrapperEl.style.display = off ? "none" : this.buttonWrapperStyleDisplay;
} else {
console.log("Unknown attribute changed", name, oldValue, newValue);
}
}
assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } {
if (!this.shadowRoot) {
throw new Error("No shadow root");
}
}
constructor() {
super();
const dataset = this.dataset as FloatingButtonDataset;
const buttonText = dataset["buttonText"];
const buttonPosition = dataset["buttonPosition"];
const buttonColor = dataset["buttonColor"];
const buttonTextColor = dataset["buttonTextColor"];
//TODO: Logic is duplicated over HTML generation and attribute change, keep it at one place
const buttonHtml = `<style>${window.Cal.__css}</style> ${getFloatingButtonHtml({
buttonText: buttonText,
buttonClasses: [FloatingButton.updatedClassString(buttonPosition, "")],
buttonColor: buttonColor,
buttonTextColor: buttonTextColor,
})}`;
this.attachShadow({ mode: "open" });
this.assertHasShadowRoot();
this.shadowRoot.innerHTML = buttonHtml;
}
}

View File

@@ -0,0 +1,38 @@
const getHtml = ({
buttonText,
buttonClasses,
buttonColor,
buttonTextColor,
}: {
buttonText: string;
buttonClasses: string[];
buttonColor: string;
buttonTextColor: string;
}) => {
// IT IS A REQUIREMENT THAT ALL POSSIBLE CLASSES ARE HERE OTHERWISE TAILWIND WONT GENERATE THE CSS FOR CONDITIONAL CLASSES
// To not let all these classes apply and visible, keep it hidden initially
return `<button class="z-[999999999999] hidden fixed md:bottom-6 bottom-4 md:right-10 right-4 md:left-10 left-4 ${buttonClasses.join(
" "
)} flex h-16 origin-center bg-red-50 transform cursor-pointer items-center
rounded-full py-4 px-6 text-base outline-none drop-shadow-md transition focus:outline-none fo
cus:ring-4 focus:ring-gray-600 focus:ring-opacity-50 active:scale-95"
style="background-color:${buttonColor}; color:${buttonTextColor} z-index: 10001">
<div id="button-icon" class="mr-3 flex items-center justify-center">
<svg
class="h-7 w-7"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<div id="button" class="font-semibold leading-5 antialiased">${buttonText}</div>
</button>`;
};
export default getHtml;

View File

@@ -0,0 +1,42 @@
import loaderCss from "../loader.css?inline";
import { getErrorString } from "../utils";
import inlineHtml from "./inlineHtml";
export class Inline extends HTMLElement {
static get observedAttributes() {
return ["loading"];
}
assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } {
if (!this.shadowRoot) {
throw new Error("No shadow root");
}
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
this.assertHasShadowRoot();
const loaderEl = this.shadowRoot.querySelector<HTMLElement>(".loader");
const errorEl = this.shadowRoot.querySelector<HTMLElement>("#error");
const slotEl = this.shadowRoot.querySelector<HTMLElement>("slot");
if (!loaderEl || !slotEl || !errorEl) {
throw new Error("One of loaderEl, slotEl or errorEl is missing");
}
if (name === "loading") {
if (newValue == "done") {
loaderEl.style.display = "none";
} else if (newValue === "failed") {
loaderEl.style.display = "none";
slotEl.style.visibility = "hidden";
errorEl.style.display = "block";
const errorString = getErrorString(this.dataset.errorCode);
errorEl.innerText = errorString;
}
}
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.assertHasShadowRoot();
this.shadowRoot.innerHTML = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${inlineHtml}`;
}
}

View File

@@ -0,0 +1,10 @@
const html = `<div id="wrapper" style="top:50%; left:50%;transform:translate(-50%,-50%)" class="absolute z-highest">
<div class="loader border-brand-default dark:border-darkmodebrand">
<span class="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
<div id="error" style="transform:translate(-50%,-50%)" class="hidden">
Something went wrong.
</div>
</div>
<slot></slot>`;
export default html;

View File

@@ -0,0 +1,141 @@
import loaderCss from "../loader.css";
import { getErrorString } from "../utils";
import modalBoxHtml from "./ModalBoxHtml";
type ShadowRootWithStyle = ShadowRoot & {
host: HTMLElement & { style: CSSStyleDeclaration };
};
export class ModalBox extends HTMLElement {
static htmlOverflow: string;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
static get observedAttributes() {
return ["state"];
}
assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRootWithStyle } {
if (!this.shadowRoot) {
throw new Error("No shadow root");
}
}
show(show: boolean) {
this.assertHasShadowRoot();
// We can't make it display none as that takes iframe width and height calculations to 0
this.shadowRoot.host.style.visibility = show ? "visible" : "hidden";
if (!show) {
document.body.style.overflow = ModalBox.htmlOverflow;
}
}
open() {
this.show(true);
const event = new Event("open");
this.dispatchEvent(event);
}
close() {
this.show(false);
const event = new Event("close");
this.dispatchEvent(event);
}
hideIframe() {
const iframe = this.querySelector("iframe");
if (iframe) {
iframe.style.visibility = "hidden";
}
}
showIframe() {
const iframe = this.querySelector("iframe");
if (iframe) {
// Don't use visibility visible as that will make the iframe visible even when the modal is closed
iframe.style.visibility = "";
}
}
getLoaderElement() {
this.assertHasShadowRoot();
const loaderEl = this.shadowRoot.querySelector<HTMLElement>(".loader");
if (!loaderEl) {
throw new Error("No loader element");
}
return loaderEl;
}
getErrorElement() {
this.assertHasShadowRoot();
const element = this.shadowRoot.querySelector<HTMLElement>("#error");
if (!element) {
throw new Error("No error element");
}
return element;
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (name !== "state") {
return;
}
if (newValue === "loading") {
this.open();
this.hideIframe();
this.getLoaderElement().style.display = "block";
} else if (newValue == "loaded" || newValue === "reopening") {
this.open();
this.showIframe();
this.getLoaderElement().style.display = "none";
} else if (newValue == "closed") {
this.close();
} else if (newValue === "failed") {
this.getLoaderElement().style.display = "none";
this.getErrorElement().style.display = "inline-block";
const errorString = getErrorString(this.dataset.errorCode);
this.getErrorElement().innerText = errorString;
} else if (newValue === "prerendering") {
this.close();
}
}
connectedCallback() {
this.assertHasShadowRoot();
const closeEl = this.shadowRoot.querySelector<HTMLElement>(".close");
document.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") {
this.close();
}
},
{
once: true,
}
);
this.shadowRoot.host.addEventListener("click", () => {
this.close();
});
if (closeEl) {
closeEl.onclick = () => {
this.close();
};
}
}
constructor() {
super();
const modalHtml = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${modalBoxHtml}`;
this.attachShadow({ mode: "open" });
ModalBox.htmlOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
this.open();
this.assertHasShadowRoot();
this.shadowRoot.innerHTML = modalHtml;
}
}

View File

@@ -0,0 +1,59 @@
const html = `<style>
.my-backdrop {
position:fixed;
width:100%;
height:100%;
top:0;
left:0;
z-index:999999999999;
display:block;
background-color:rgb(5,5,5, 0.8)
}
.modal-box {
margin:0 auto;
margin-top:20px;
margin-bottom:20px;
position:absolute;
width:100%;
top:50%;
left:50%;
transform: translateY(-50%) translateX(-50%);
overflow: auto;
}
.header {
position: relative;
float:right;
top: 10px;
}
.close {
font-size: 30px;
left: -20px;
position: relative;
color:white;
cursor: pointer;
}
/*Modal background is black only, so hardcode white */
.loader {
--cal-brand-color:white;
}
</style>
<div class="my-backdrop">
<div class="header">
<span class="close">&times;</span>
</div>
<div class="modal-box">
<div class="body">
<div id="wrapper" class="z-[999999999999] absolute flex w-full items-center">
<div class="loader modal-loader border-brand-default dark:border-darkmodebrand">
<span class="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
</div>
<div id="error" class="hidden left-1/2 -translate-x-1/2 relative text-inverted"></div>
<slot></slot>
</div>
</div>
</div>`;
export default html;

View File

@@ -0,0 +1,628 @@
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, useCallback } from "react";
import type { Message } from "./embed";
import { sdkActionManager } from "./sdk-event";
import type { EmbedThemeConfig, UiConfig, EmbedNonStylesConfig, BookerLayouts, EmbedStyles } from "./types";
import { useCompatSearchParams } from "./useCompatSearchParams";
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
const enum EMBED_IFRAME_STATE {
NOT_INITIALIZED,
INITIALIZED,
}
/**
* All types of config that are critical to be processed as soon as possible are provided as query params to the iframe
*/
export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
// TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
iframeAttrs?: Record<string, string> & {
id?: string;
};
// TODO: It should have a dedicated prefill prop
// prefill: {},
// TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time.
// ui: {layout; theme}
layout?: BookerLayouts;
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
"ui.color-scheme"?: string;
theme?: EmbedThemeConfig;
};
declare global {
interface Window {
CalEmbed: {
__logQueue?: unknown[];
embedStore: typeof embedStore;
applyCssVars: (cssVarsPerTheme: UiConfig["cssVarsPerTheme"]) => void;
};
}
}
/**
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
*/
const embedStore = {
// Handles the commands of routing received from parent even when React hasn't initialized and nextRouter isn't available
router: {
setNextRouter(nextRouter: ReturnType<typeof useRouter>) {
this.nextRouter = nextRouter;
// Empty the queue after running push on nextRouter. This is important because setNextRouter is be called multiple times
this.queue.forEach((url) => {
nextRouter.push(url);
this.queue.splice(0, 1);
});
},
nextRouter: null as null | ReturnType<typeof useRouter>,
queue: [] as string[],
goto(url: string) {
if (this.nextRouter) {
this.nextRouter.push(url.toString());
} else {
this.queue.push(url);
}
},
},
state: EMBED_IFRAME_STATE.NOT_INITIALIZED,
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {} as EmbedStyles | undefined,
nonStyles: {} as EmbedNonStylesConfig | undefined,
namespace: null as string | null,
embedType: undefined as undefined | null | string,
// Store all React State setters here.
reactStylesStateSetters: {} as Record<keyof EmbedStyles, SetStyles>,
reactNonStylesStateSetters: {} as Record<keyof EmbedNonStylesConfig, setNonStylesConfig>,
parentInformedAboutContentHeight: false,
windowLoadEventFired: false,
setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined,
theme: undefined as UiConfig["theme"],
uiConfig: undefined as Omit<UiConfig, "styles" | "theme"> | undefined,
/**
* 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)[],
};
let isSafariBrowser = false;
const isBrowser = typeof window !== "undefined";
if (isBrowser) {
window.CalEmbed = window?.CalEmbed || {};
window.CalEmbed.embedStore = embedStore;
const ua = navigator.userAgent.toLowerCase();
isSafariBrowser = ua.includes("safari") && !ua.includes("chrome");
if (isSafariBrowser) {
log("Safari Detected: Using setTimeout instead of rAF");
}
}
function runAsap(fn: (...arg: unknown[]) => void) {
if (isSafariBrowser) {
// https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/
return setTimeout(fn, 50);
}
return requestAnimationFrame(fn);
}
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(...args);
}
}
}
const setEmbedStyles = (stylesConfig: EmbedStyles) => {
embedStore.styles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
setEmbedStyle((styles) => {
return {
...styles,
...stylesConfig,
};
});
}
};
const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => {
embedStore.nonStyles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
setEmbedStyle((styles) => {
return {
...styles,
...stylesConfig,
};
});
}
};
const registerNewSetter = (
registration:
| {
elementName: keyof EmbedStyles;
setState: SetStyles;
styles: true;
}
| {
elementName: keyof EmbedNonStylesConfig;
setState: setNonStylesConfig;
styles: false;
}
) => {
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe.
// So, we should call the setter immediately with available embedStyles
if (registration.styles) {
embedStore.reactStylesStateSetters[registration.elementName as keyof EmbedStyles] = registration.setState;
registration.setState(embedStore.styles || {});
return () => {
delete embedStore.reactStylesStateSetters[registration.elementName];
};
} else {
embedStore.reactNonStylesStateSetters[registration.elementName as keyof EmbedNonStylesConfig] =
registration.setState;
registration.setState(embedStore.nonStyles || {});
return () => {
delete embedStore.reactNonStylesStateSetters[registration.elementName];
};
}
};
function isValidNamespace(ns: string | null | undefined) {
return typeof ns !== "undefined" && ns !== null;
}
/**
* It handles any URL change done through Web history API as well
* History API is currently being used by Booker/utils/query-param
*/
const useUrlChange = (callback: (newUrl: string) => void) => {
const currentFullUrl = isBrowser ? new URL(document.URL) : null;
const pathname = currentFullUrl?.pathname ?? "";
const searchParams = currentFullUrl?.searchParams ?? null;
const lastKnownUrl = useRef(`${pathname}?${searchParams}`);
const router = useRouter();
embedStore.router.setNextRouter(router);
useEffect(() => {
const newUrl = `${pathname}?${searchParams}`;
if (lastKnownUrl.current !== newUrl) {
lastKnownUrl.current = newUrl;
callback(newUrl);
}
}, [pathname, searchParams, callback]);
};
export const useEmbedTheme = () => {
const searchParams = useCompatSearchParams();
const [theme, setTheme] = useState(
embedStore.theme || (searchParams?.get("theme") as typeof embedStore.theme)
);
const onUrlChange = useCallback(() => {
sdkActionManager?.fire("__routeChanged", {});
}, []);
useUrlChange(onUrlChange);
embedStore.setTheme = setTheme;
return theme;
};
/**
* It serves following purposes
* - Gives consistent values for ui config even after Soft Navigation. When a new React component mounts, it would ensure that the component get's the correct value of ui config
* - Ensures that all the components using useEmbedUiConfig are updated when ui config changes. It is done by maintaining a list of all non-stale setters.
*/
export const useEmbedUiConfig = () => {
const [uiConfig, setUiConfig] = useState(embedStore.uiConfig || {});
embedStore.setUiConfig.push(setUiConfig);
useEffect(() => {
return () => {
const foundAtIndex = embedStore.setUiConfig.findIndex((item) => item === setUiConfig);
// Keep removing the setters that are stale
embedStore.setUiConfig.splice(foundAtIndex, 1);
};
});
return uiConfig;
};
// TODO: Make it usable as an attribute directly instead of styles value. It would allow us to go beyond styles e.g. for debugging we can add a special attribute indentifying the element on which UI config has been applied
export const useEmbedStyles = (elementName: keyof EmbedStyles) => {
const [, setStyles] = useState<EmbedStyles>({});
useEffect(() => {
return registerNewSetter({ elementName, setState: setStyles, styles: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const styles = embedStore.styles || {};
// Always read the data from global embedStore so that even across components, the same data is used.
return styles[elementName] || {};
};
export const useEmbedNonStylesConfig = (elementName: keyof EmbedNonStylesConfig) => {
const [, setNonStyles] = useState({} as EmbedNonStylesConfig);
useEffect(() => {
return registerNewSetter({ elementName, setState: setNonStyles, styles: false });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Always read the data from global embedStore so that even across components, the same data is used.
const nonStyles = embedStore.nonStyles || {};
return nonStyles[elementName] || {};
};
export const useIsBackgroundTransparent = () => {
let isBackgroundTransparent = false;
// TODO: Background should be read as ui.background and not ui.body.background
const bodyEmbedStyles = useEmbedStyles("body");
if (bodyEmbedStyles.background === "transparent") {
isBackgroundTransparent = true;
}
return isBackgroundTransparent;
};
export const useBrandColors = () => {
// TODO: Branding shouldn't be part of ui.styles. It should exist as ui.branding.
const brandingColors = useEmbedNonStylesConfig("branding") as EmbedNonStylesConfig["branding"];
return brandingColors || {};
};
function getNamespace() {
if (isValidNamespace(embedStore.namespace)) {
// Persist this so that even if query params changed, we know that it is an embed.
return embedStore.namespace;
}
if (isBrowser) {
const namespace = window?.getEmbedNamespace?.() ?? null;
embedStore.namespace = namespace;
return namespace;
}
}
function getEmbedType() {
if (embedStore.embedType) {
return embedStore.embedType;
}
if (isBrowser) {
const url = new URL(document.URL);
const embedType = (embedStore.embedType = url.searchParams.get("embedType"));
return embedType;
}
}
export const useIsEmbed = (embedSsr?: boolean) => {
const [isEmbed, setIsEmbed] = useState(embedSsr);
useEffect(() => {
const namespace = getNamespace();
const _isValidNamespace = isValidNamespace(namespace);
if (parent !== window && !_isValidNamespace) {
log(
"Looks like you have iframed cal.com but not using Embed Snippet. Directly using an iframe isn't recommended."
);
}
setIsEmbed(window?.isEmbed?.() || false);
}, []);
return isEmbed;
};
export const useEmbedType = () => {
const [state, setState] = useState<string | null | undefined>(null);
useEffect(() => {
setState(getEmbedType());
}, []);
return state;
};
function unhideBody() {
document.body.style.visibility = "visible";
}
// It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"})
const methods = {
ui: function style(uiConfig: UiConfig) {
// TODO: Create automatic logger for all methods. Useful for debugging.
log("Method: ui called", uiConfig);
const stylesConfig = uiConfig.styles;
if (stylesConfig) {
console.warn(
"Cal.com Embed: `styles` prop is deprecated. Use `cssVarsPerTheme` instead to achieve the same effect. Here is a list of CSS variables that are supported. https://github.com/calcom/cal.com/blob/main/packages/config/tailwind-preset.js#L19"
);
}
// body can't be styled using React state hook as it is generated by _document.tsx which doesn't support hooks.
if (stylesConfig?.body?.background) {
document.body.style.background = stylesConfig.body.background as string;
}
if (uiConfig.theme) {
embedStore.theme = uiConfig.theme as UiConfig["theme"];
if (embedStore.setTheme) {
embedStore.setTheme(uiConfig.theme);
}
}
// Merge new values over the old values
uiConfig = {
...embedStore.uiConfig,
...uiConfig,
};
if (uiConfig.cssVarsPerTheme) {
window.CalEmbed.applyCssVars(uiConfig.cssVarsPerTheme);
}
if (uiConfig.colorScheme) {
actOnColorScheme(uiConfig.colorScheme);
}
if (embedStore.setUiConfig) {
runAllUiSetters(uiConfig);
}
setEmbedStyles(stylesConfig || {});
setEmbedNonStyles(stylesConfig || {});
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
parentKnowsIframeReady: (_unused: unknown) => {
log("Method: `parentKnowsIframeReady` called");
runAsap(function tryInformingLinkReady() {
// TODO: Do it by attaching a listener for change in parentInformedAboutContentHeight
if (!embedStore.parentInformedAboutContentHeight) {
runAsap(tryInformingLinkReady);
return;
}
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
unhideBody();
if (!isPrerendering()) {
sdkActionManager?.fire("linkReady", {});
}
});
},
connect: function connect(queryObject: PrefillAndIframeAttrsConfig) {
const currentUrl = new URL(document.URL);
const searchParams = currentUrl.searchParams;
searchParams.delete("preload");
for (const [key, value] of Object.entries(queryObject)) {
if (value === undefined) {
continue;
}
if (value instanceof Array) {
value.forEach((val) => searchParams.append(key, val));
} else {
searchParams.set(key, value as string);
}
}
connectPreloadedEmbed({ url: currentUrl });
},
};
export type InterfaceWithParent = {
[key in keyof typeof methods]: (firstAndOnlyArg: Parameters<(typeof methods)[key]>[number]) => void;
};
export const interfaceWithParent: InterfaceWithParent = methods;
const messageParent = (data: CustomEvent["detail"]) => {
parent.postMessage(
{
originator: "CAL",
...data,
},
"*"
);
};
function keepParentInformedAboutDimensionChanges() {
let knownIframeHeight: number | null = null;
let knownIframeWidth: number | null = null;
let isFirstTime = true;
let isWindowLoadComplete = false;
runAsap(function informAboutScroll() {
if (document.readyState !== "complete") {
// Wait for window to load to correctly calculate the initial scroll height.
runAsap(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();
}, 100);
return;
}
if (!embedStore.windowLoadEventFired) {
sdkActionManager?.fire("__windowLoadComplete", {});
}
embedStore.windowLoadEventFired = true;
// Use the dimensions of main element as in most places there is max-width restriction on it and we just want to show the main content.
// It avoids the unwanted padding outside main tag.
const mainElement =
document.getElementsByClassName("main")[0] ||
document.getElementsByTagName("main")[0] ||
document.documentElement;
const documentScrollHeight = document.documentElement.scrollHeight;
const documentScrollWidth = document.documentElement.scrollWidth;
if (!(mainElement instanceof HTMLElement)) {
throw new Error("Main element should be an HTMLElement");
}
const mainElementStyles = getComputedStyle(mainElement);
// 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)
);
const contentWidth = Math.ceil(
parseFloat(mainElementStyles.width) +
parseFloat(mainElementStyles.marginLeft) +
parseFloat(mainElementStyles.marginRight)
);
// 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
const iframeHeight = isFirstTime ? documentScrollHeight : contentHeight;
const iframeWidth = isFirstTime ? documentScrollWidth : contentWidth;
embedStore.parentInformedAboutContentHeight = true;
if (!iframeHeight || !iframeWidth) {
runAsap(informAboutScroll);
return;
}
if (knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth) {
knownIframeHeight = iframeHeight;
knownIframeWidth = iframeWidth;
// FIXME: This event shouldn't be subscribable by the user. Only by the SDK.
sdkActionManager?.fire("__dimensionChanged", {
iframeHeight,
iframeWidth,
isFirstTime,
});
}
isFirstTime = false;
// Parent Counterpart would change the dimension of iframe and thus page's dimension would be impacted which is recursive.
// It should stop ideally by reaching a hiddenHeight value of 0.
// FIXME: If 0 can't be reached we need to just abandon our quest for perfect iframe and let scroll be there. Such case can be logged in the wild and fixed later on.
runAsap(informAboutScroll);
});
}
function main() {
if (!isBrowser) {
return;
}
log("Embed SDK loaded", { isEmbed: window?.isEmbed?.() || false });
const url = new URL(document.URL);
embedStore.theme = window?.getEmbedTheme?.();
embedStore.uiConfig = {
// TODO: Add theme as well here
colorScheme: url.searchParams.get("ui.color-scheme"),
layout: url.searchParams.get("layout") as BookerLayouts,
};
actOnColorScheme(embedStore.uiConfig.colorScheme);
// If embed link is opened in top, and not in iframe. Let the page be visible.
if (top === window) {
unhideBody();
// We would want to avoid a situation where Cal.com embeds cal.com and then embed-iframe is in the top as well. In such case, we would want to avoid infinite loop of events being passed.
log("Embed SDK Skipped as we are in top");
return;
}
window.addEventListener("message", (e) => {
const data: Message = e.data;
if (!data) {
return;
}
const method: keyof typeof interfaceWithParent = data.method;
if (data.originator === "CAL" && typeof method === "string") {
interfaceWithParent[method]?.(data.arg as never);
}
});
document.addEventListener("click", (e) => {
if (!e.target || !(e.target instanceof Node)) {
return;
}
const mainElement =
document.getElementsByClassName("main")[0] ||
document.getElementsByTagName("main")[0] ||
document.documentElement;
if (e.target.contains(mainElement)) {
sdkActionManager?.fire("__closeIframe", {});
}
});
sdkActionManager?.on("*", (e) => {
const detail = e.detail;
log(detail);
messageParent(detail);
});
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
initializeAndSetupEmbed();
} else {
log(`Preloaded scenario - Skipping initialization and setup`);
}
}
function initializeAndSetupEmbed() {
sdkActionManager?.fire("__iframeReady", {});
// Only NOT_INITIALIZED -> INITIALIZED transition is allowed
if (embedStore.state !== EMBED_IFRAME_STATE.NOT_INITIALIZED) {
log("Embed Iframe already initialized");
return;
}
embedStore.state = EMBED_IFRAME_STATE.INITIALIZED;
log("Initializing embed-iframe");
// HACK
const pageStatus = window.CalComPageStatus;
if (!pageStatus || pageStatus == "200") {
keepParentInformedAboutDimensionChanges();
} else
sdkActionManager?.fire("linkFailed", {
code: pageStatus,
msg: "Problem loading the link",
data: {
url: document.URL,
},
});
}
function runAllUiSetters(uiConfig: UiConfig) {
// Update EmbedStore so that when a new react component mounts, useEmbedUiConfig can get the persisted value from embedStore.uiConfig
embedStore.uiConfig = uiConfig;
embedStore.setUiConfig.forEach((setUiConfig) => setUiConfig(uiConfig));
}
function actOnColorScheme(colorScheme: string | null | undefined) {
if (!colorScheme) {
return;
}
document.documentElement.style.colorScheme = colorScheme;
}
/**
* Apply configurations to the preloaded page and then ask parent to show the embed
* url has the config as params
*/
function connectPreloadedEmbed({ url }: { url: URL }) {
// TODO: Use a better way to detect that React has initialized. Currently, we are using setTimeout which is a hack.
const MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES = 700;
// It can be fired before React has initialized, so use embedStore.router(which is a nextRouter wrapper that supports a queue)
embedStore.router.goto(url.toString());
setTimeout(() => {
// Firing this event would stop the loader and show the embed
sdkActionManager?.fire("linkReady", {});
}, MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES);
}
const isPrerendering = () => {
return new URL(document.URL).searchParams.get("prerender") === "true";
};
main();

View File

@@ -0,0 +1,9 @@
/**
These styles are applied to the entire page, so the selectors need to be specific
*/
.cal-embed {
border: 0px;
min-height: 300px;
margin: 0 auto;
width: 100%;
}

View File

@@ -0,0 +1,958 @@
/// <reference types="../env" />
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
import type { InterfaceWithParent, interfaceWithParent, PrefillAndIframeAttrsConfig } from "./embed-iframe";
import css from "./embed.css";
import { SdkActionManager } from "./sdk-action-manager";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import allCss from "./tailwind.generated.css?inline";
import type { UiConfig } from "./types";
export type { PrefillAndIframeAttrsConfig } from "./embed-iframe";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Rest<T extends any[]> = T extends [any, ...infer U] ? U : never;
export type Message = {
originator: string;
method: keyof InterfaceWithParent;
arg: InterfaceWithParent[keyof InterfaceWithParent];
};
// HACK: Redefine and don't import WEBAPP_URL as it causes import statement to be present in built file.
// This is happening because we are not able to generate an App and a lib using single Vite Config.
const WEBAPP_URL = process.env.EMBED_PUBLIC_WEBAPP_URL || `https://${process.env.EMBED_PUBLIC_VERCEL_URL}`;
customElements.define("cal-modal-box", ModalBox);
customElements.define("cal-floating-button", FloatingButton);
customElements.define("cal-inline", Inline);
declare module "*.css";
type Namespace = string;
type Config = {
calOrigin: string;
debug?: boolean;
uiDebug?: boolean;
};
type InitArgConfig = Partial<Config> & {
origin?: string;
};
type DoInIframeArg = {
[K in keyof typeof interfaceWithParent]: {
method: K;
arg?: Parameters<(typeof interfaceWithParent)[K]>[0];
};
}[keyof typeof interfaceWithParent];
const globalCal = window.Cal;
if (!globalCal || !globalCal.q) {
throw new Error("Cal is not defined. This shouldn't happen");
}
// Store Commit Hash to know exactly what version of the code is running
// TODO: Ideally it should be the version as per package.json and then it can be renamed to version.
// But because it is built on local machine right now, it is much more reliable to have the commit hash.
globalCal.fingerprint = process.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string;
globalCal.__css = allCss;
document.head.appendChild(document.createElement("style")).innerHTML = css;
function log(...args: unknown[]) {
console.log(...args);
}
// eslint-disable-next-line @typescript-eslint/ban-types
type ValidationSchemaPropType = string | Function;
type ValidationSchema = {
required?: boolean;
props?: Record<
string,
ValidationSchema & {
type: ValidationSchemaPropType | ValidationSchemaPropType[];
}
>;
};
/**
* //TODO: Warn about extra properties not part of schema. Helps in fixing wrong expectations
* A very simple data validator written with intention of keeping payload size low.
* Extend the functionality of it as required by the embed.
* @param data
* @param schema
*/
function validate(data: Record<string, unknown>, schema: ValidationSchema) {
function checkType(value: unknown, expectedType: ValidationSchemaPropType) {
if (typeof expectedType === "string") {
return typeof value == expectedType;
} else {
return value instanceof expectedType;
}
}
function isUndefined(data: unknown) {
return typeof data === "undefined";
}
if (schema.required && isUndefined(data)) {
throw new Error("Argument is required");
}
for (const [prop, propSchema] of Object.entries(schema.props || {})) {
if (propSchema.required && isUndefined(data[prop])) {
throw new Error(`"${prop}" is required`);
}
let typeCheck = true;
if (propSchema.type && !isUndefined(data[prop])) {
if (propSchema.type instanceof Array) {
propSchema.type.forEach((type) => {
typeCheck = typeCheck || checkType(data[prop], type);
});
} else {
typeCheck = checkType(data[prop], propSchema.type);
}
}
if (!typeCheck) {
throw new Error(`"${prop}" is of wrong type.Expected type "${propSchema.type}"`);
}
}
}
function getColorScheme(el: Element) {
const pageColorScheme = getComputedStyle(el).colorScheme;
if (pageColorScheme === "dark" || pageColorScheme === "light") {
return pageColorScheme;
}
return null;
}
function withColorScheme(
queryObject: PrefillAndIframeAttrsConfig & { guest?: string | string[] },
containerEl: Element
) {
// If color-scheme not explicitly configured, keep it same as the webpage that has the iframe
// This is done to avoid having an opaque background of iframe that arises when they aren't same. We really need to have a transparent background to make embed part of the page
// https://fvsch.com/transparent-iframes#:~:text=the%20resolution%20was%3A-,If%20the%20color%20scheme%20of%20an%20iframe%20differs%20from%20embedding%20document%2C%20iframe%20gets%20an%20opaque%20canvas%20background%20appropriate%20to%20its%20color%20scheme.,-So%20the%20dark
if (!queryObject["ui.color-scheme"]) {
const colorScheme = getColorScheme(containerEl);
// Only handle two color-schemes for now. We don't want to have unintented affect by always explicitly adding color-scheme
if (colorScheme) {
queryObject["ui.color-scheme"] = colorScheme;
}
}
return queryObject;
}
type SingleInstructionMap = {
// TODO: This makes api("on", {}) loose it's generic type. Find a way to fix it.
// e.g. api("on", { action: "__dimensionChanged", callback: (e) => { /* `e.detail.data` has all possible values for all events/actions */} });
[K in keyof CalApi]: CalApi[K] extends (...args: never[]) => void ? [K, ...Parameters<CalApi[K]>] : never;
};
type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
export type Instruction = SingleInstruction | SingleInstruction[];
export type InstructionQueue = Instruction[];
export class Cal {
iframe?: HTMLIFrameElement;
__config: Config;
modalBox?: Element;
inlineEl?: Element;
namespace: string;
actionManager: SdkActionManager;
iframeReady!: boolean;
iframeDoQueue: DoInIframeArg[] = [];
api: CalApi;
isPerendering?: boolean;
static actionsManagers: Record<Namespace, SdkActionManager>;
static getQueryObject(config: PrefillAndIframeAttrsConfig) {
config = config || {};
return {
...config,
// guests is better for API but Booking Page accepts guest. So do the mapping
guest: config.guests ?? undefined,
} as PrefillAndIframeAttrsConfig & { guest?: string | string[] };
}
processInstruction(instructionAsArgs: IArguments | Instruction) {
// The instruction is actually an array-like object(arguments). Make it an array.
const instruction: Instruction = [].slice.call(instructionAsArgs);
// If there are multiple instructions in the array, process them one by one
if (typeof instruction[0] !== "string") {
// It is an instruction
instruction.forEach((instruction) => {
this.processInstruction(instruction);
});
return;
}
const [method, ...args] = instruction;
if (!this.api[method]) {
// Instead of throwing error, log and move forward in the queue
log(`Instruction ${method} not FOUND`);
}
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore There can be any method which can have any number of arguments.
this.api[method](...args);
} catch (e) {
// Instead of throwing error, log and move forward in the queue
log(`Instruction couldn't be executed`, e);
}
return instruction;
}
processQueue(queue: Queue) {
queue.forEach((instruction) => {
this.processInstruction(instruction);
});
queue.splice(0);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/** @ts-ignore */ // We changed the definition of push here.
queue.push = (instruction) => {
this.processInstruction(instruction);
};
}
/**
* Iframe is added invisible and shown only after color-scheme is set by the embedded calLink to avoid flash of non-transparent(white/black) background
*/
createIframe({
calLink,
queryObject = {},
calOrigin,
}: {
calLink: string;
queryObject?: PrefillAndIframeAttrsConfig & { guest?: string | string[] };
calOrigin: string | null;
}) {
const iframe = (this.iframe = document.createElement("iframe"));
iframe.className = "cal-embed";
iframe.name = `cal-embed=${this.namespace}`;
const config = this.getConfig();
const { iframeAttrs, ...restQueryObject } = queryObject;
if (iframeAttrs && iframeAttrs.id) {
iframe.setAttribute("id", iframeAttrs.id);
}
// Prepare searchParams from config
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(restQueryObject)) {
if (value === undefined) {
continue;
}
if (value instanceof Array) {
value.forEach((val) => searchParams.append(key, val));
} else {
searchParams.set(key, value as string);
}
}
// cal.com has rewrite issues on Safari that sometimes cause 404 for assets.
const originToUse = (calOrigin || config.calOrigin || "").replace(
"https://cal.com",
"https://app.cal.com"
);
const urlInstance = new URL(`${originToUse}/${calLink}`);
if (!urlInstance.pathname.endsWith("embed")) {
// TODO: Make a list of patterns that are embeddable. All except that should be allowed with a warning that "The page isn't optimized for embedding"
urlInstance.pathname = `${urlInstance.pathname}/embed`;
}
urlInstance.searchParams.set("embed", this.namespace);
if (config.debug) {
urlInstance.searchParams.set("debug", `${config.debug}`);
}
// Keep iframe invisible, till the embedded calLink sets its color-scheme. This is so that there is no flash of non-transparent(white/black) background
iframe.style.visibility = "hidden";
if (config.uiDebug) {
iframe.style.border = "1px solid green";
}
// Merge searchParams from config onto the URL which might have query params already
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
for (const [key, value] of searchParams) {
urlInstance.searchParams.append(key, value);
}
iframe.src = urlInstance.toString();
return iframe;
}
getConfig() {
return this.__config;
}
doInIframe(doInIframeArg: DoInIframeArg) {
if (!this.iframeReady) {
this.iframeDoQueue.push(doInIframeArg);
return;
}
if (!this.iframe) {
throw new Error("iframe doesn't exist. `createIframe` must be called before `doInIframe`");
}
if (this.iframe.contentWindow) {
// TODO: Ensure that targetOrigin is as defined by user(and not *). Generally it would be cal.com but in case of self hosting it can be anything.
// Maybe we can derive targetOrigin from __config.origin
this.iframe.contentWindow.postMessage(
{ originator: "CAL", method: doInIframeArg.method, arg: doInIframeArg.arg },
"*"
);
}
}
constructor(namespace: string, q: Queue) {
this.__config = {
// Use WEBAPP_URL till full page reload problem with website URL is solved
calOrigin: WEBAPP_URL,
};
this.api = new CalApi(this);
this.namespace = namespace;
this.actionManager = new SdkActionManager(namespace);
Cal.actionsManagers = Cal.actionsManagers || {};
Cal.actionsManagers[namespace] = this.actionManager;
this.processQueue(q);
// 1. Initial iframe width and height would be according to 100% value of the parent element
// 2. Once webpage inside iframe renders, it would tell how much iframe height should be increased so that my entire content is visible without iframe scroll
// 3. Parent window would check what iframe height can be set according to parent Element
this.actionManager.on("__dimensionChanged", (e) => {
const { data } = e.detail;
const iframe = this.iframe;
if (!iframe) {
// Iframe might be pre-rendering
return;
}
const unit = "px";
if (data.iframeHeight) {
iframe.style.height = data.iframeHeight + unit;
}
if (this.modalBox) {
// 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.
const spacingTopPlusBottom = 2 * 50; // 50 is the padding we want to keep to show close button comfortably. Make it same as top for bottom.
iframe.style.maxHeight = `${window.innerHeight - spacingTopPlusBottom}px`;
}
});
this.actionManager.on("__iframeReady", () => {
this.iframeReady = true;
if (this.iframe) {
// It's a bit late to make the iframe visible here. We just needed to wait for the HTML tag of the embedded calLink to be rendered(which then informs the browser of the color-scheme)
// Right now it would wait for embed-iframe.js bundle to be loaded as well. We can speed that up by inlining the JS that informs about color-scheme being set in the HTML.
// But it's okay to do it here for now because the embedded calLink also keeps itself hidden till it receives `parentKnowsIframeReady` message(It has it's own reasons for that)
// Once the embedded calLink starts not hiding the document, we should optimize this line to make the iframe visible earlier than this.
// Imp: Don't use visiblity:visible as that would make the iframe show even if the host element(A paren tof the iframe) has visiblity:hidden set. Just reset the visibility to default
this.iframe.style.visibility = "";
}
this.doInIframe({ method: "parentKnowsIframeReady" } as const);
this.iframeDoQueue.forEach((doInIframeArg) => {
this.doInIframe(doInIframeArg);
});
});
this.actionManager.on("__routeChanged", () => {
if (!this.inlineEl) {
return;
}
const { top, height } = this.inlineEl.getBoundingClientRect();
// Try to readjust and scroll into view if more than 25% is hidden.
// Otherwise we assume that user might have positioned the content appropriately already
if (top < 0 && Math.abs(top / height) >= 0.25) {
this.inlineEl.scrollIntoView({ behavior: "smooth" });
}
});
this.actionManager.on("linkReady", () => {
if (this.isPerendering) {
// Absolute check to ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed would showup without any user action
return;
}
this.modalBox?.setAttribute("state", "loaded");
this.inlineEl?.setAttribute("loading", "done");
});
this.actionManager.on("linkFailed", (e) => {
const iframe = this.iframe;
if (!iframe) {
return;
}
this.inlineEl?.setAttribute("data-error-code", e.detail.data.code);
this.modalBox?.setAttribute("data-error-code", e.detail.data.code);
this.inlineEl?.setAttribute("loading", "failed");
this.modalBox?.setAttribute("state", "failed");
});
}
}
class CalApi {
cal: Cal;
static initializedNamespaces = [] as string[];
modalUid?: string;
preloadedModalUid?: string;
constructor(cal: Cal) {
this.cal = cal;
}
/**
* If namespaceOrConfig is a string, config is available in config argument
* If namespaceOrConfig is an object, namespace is assumed to be default and config isn't provided
*/
init(namespaceOrConfig?: string | InitArgConfig, config = {} as InitArgConfig) {
let initForNamespace = "";
if (typeof namespaceOrConfig !== "string") {
config = (namespaceOrConfig || {}) as Config;
} else {
initForNamespace = namespaceOrConfig;
}
// Just in case 'init' instruction belongs to another namespace, ignore it
// Though it shouldn't happen normally as the snippet takes care of delegating the init instruction to appropriate namespace queue
if (initForNamespace !== this.cal.namespace) {
return;
}
CalApi.initializedNamespaces.push(this.cal.namespace);
const { calOrigin: calOrigin, origin: origin, ...restConfig } = config;
this.cal.__config.calOrigin = calOrigin || origin || this.cal.__config.calOrigin;
this.cal.__config = { ...this.cal.__config, ...restConfig };
}
/**
* Used when a non-default namespace is to be initialized
* It allows default queue to take care of instantiation of the non-default namespace queue
*/
initNamespace(namespace: string) {
// Creating this instance automatically starts processing the queue for the namespace
globalCal.ns[namespace].instance =
globalCal.ns[namespace].instance || new Cal(namespace, globalCal.ns[namespace].q);
}
/**
* It is an instruction that adds embed iframe inline as last child of the element
*/
inline({
calLink,
elementOrSelector,
config,
}: {
calLink: string;
elementOrSelector: string | HTMLElement;
config?: PrefillAndIframeAttrsConfig;
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
calLink: {
// TODO: Add a special type calLink for it and validate that it doesn't start with / or https?://
required: true,
type: "string",
},
elementOrSelector: {
required: true,
type: ["string", HTMLElement],
},
config: {
required: false,
type: Object,
},
},
});
// If someone re-executes inline embed instruction, we want to ensure that duplicate inlineEl isn't added to the page per namespace
if (this.cal.inlineEl && document.body.contains(this.cal.inlineEl)) {
console.warn("Inline embed already exists. Ignoring this call");
return;
}
config = config || {};
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
const containerEl =
elementOrSelector instanceof HTMLElement
? elementOrSelector
: document.querySelector(elementOrSelector);
if (!containerEl) {
throw new Error("Element not found");
}
config.embedType = "inline";
const calConfig = this.cal.getConfig();
const iframe = this.cal.createIframe({
calLink,
queryObject: withColorScheme(Cal.getQueryObject(config), containerEl),
calOrigin: calConfig.calOrigin,
});
iframe.style.height = "100%";
iframe.style.width = "100%";
containerEl.classList.add("cal-inline-container");
const template = document.createElement("template");
template.innerHTML = `<cal-inline style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap;width:100%"></cal-inline><style>.cal-inline-container::-webkit-scrollbar{display:none}.cal-inline-container{scrollbar-width:none}</style>`;
this.cal.inlineEl = template.content.children[0];
this.cal.inlineEl.appendChild(iframe);
containerEl.appendChild(template.content);
}
floatingButton({
calLink,
buttonText = "Book my Cal",
hideButtonIcon = false,
attributes,
buttonPosition = "bottom-right",
buttonColor = "rgb(0, 0, 0)",
buttonTextColor = "rgb(255, 255, 255)",
calOrigin,
config,
}: {
calLink: string;
buttonText?: string;
attributes?: Record<"id", string> & Record<string | "id", string>;
hideButtonIcon?: boolean;
buttonPosition?: "bottom-left" | "bottom-right";
buttonColor?: string;
buttonTextColor?: string;
calOrigin?: string;
config?: PrefillAndIframeAttrsConfig;
}) {
// validate(arguments[0], {
// required: true,
// props: {
// calLink: {
// required: true,
// type: "string",
// },
// },
// });
let existingEl: HTMLElement | null = null;
if (attributes?.id) {
existingEl = document.getElementById(attributes.id);
}
let el: FloatingButton;
if (!existingEl) {
el = document.createElement("cal-floating-button") as FloatingButton;
// It makes it a target element that opens up embed modal on click
el.dataset.calLink = calLink;
el.dataset.calNamespace = this.cal.namespace;
el.dataset.calOrigin = calOrigin ?? "";
if (config) {
el.dataset.calConfig = JSON.stringify(config);
}
if (attributes?.id) {
el.id = attributes.id;
}
document.body.appendChild(el);
} else {
el = existingEl as FloatingButton;
}
const dataset = el.dataset;
dataset["buttonText"] = buttonText;
dataset["hideButtonIcon"] = `${hideButtonIcon}`;
dataset["buttonPosition"] = `${buttonPosition}`;
dataset["buttonColor"] = `${buttonColor}`;
dataset["buttonTextColor"] = `${buttonTextColor}`;
}
modal({
calLink,
config = {},
calOrigin,
__prerender = false,
}: {
calLink: string;
config?: PrefillAndIframeAttrsConfig;
calOrigin?: string;
__prerender?: boolean;
}) {
const uid = this.modalUid || this.preloadedModalUid || String(Date.now()) || "0";
const isConnectingToPreloadedModal = this.preloadedModalUid && !this.modalUid;
const containerEl = document.body;
this.cal.isPerendering = !!__prerender;
if (__prerender) {
// Add preload query param
config.prerender = "true";
}
const queryObject = withColorScheme(Cal.getQueryObject(config), containerEl);
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
if (existingModalEl) {
if (isConnectingToPreloadedModal) {
this.cal.doInIframe({
method: "connect",
arg: queryObject,
});
this.modalUid = uid;
existingModalEl.setAttribute("state", "loading");
return;
} else {
existingModalEl.setAttribute("state", "reopening");
return;
}
}
if (__prerender) {
this.preloadedModalUid = uid;
}
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
config.embedType = "modal";
let iframe = null;
if (!iframe) {
iframe = this.cal.createIframe({
calLink,
queryObject,
calOrigin: calOrigin || null,
});
}
iframe.style.borderRadius = "8px";
iframe.style.height = "100%";
iframe.style.width = "100%";
const template = document.createElement("template");
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
this.cal.modalBox = template.content.children[0];
this.cal.modalBox.appendChild(iframe);
if (__prerender) {
this.cal.modalBox.setAttribute("state", "prerendering");
}
this.handleClose();
containerEl.appendChild(template.content);
}
private handleClose() {
// A request, to close from the iframe, should close the modal
this.cal.actionManager.on("__closeIframe", () => {
this.cal.modalBox?.setAttribute("state", "closed");
});
}
on<T extends keyof EventDataMap>({
action,
callback,
}: {
action: T;
callback: (arg0: CustomEvent<EventData<T>>) => void;
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
action: {
required: true,
type: "string",
},
callback: {
required: true,
type: Function,
},
},
});
this.cal.actionManager.on(action, callback);
}
off<T extends keyof EventDataMap>({
action,
callback,
}: {
action: T;
callback: (arg0: CustomEvent<EventData<T>>) => void;
}) {
this.cal.actionManager.off(action, callback);
}
/**
*
* type is provided and prerenderIframe not set. We would assume prerenderIframe to be true
* type is provided and prerenderIframe set to false. We would ignore the type and preload assets only
* type is not provided and prerenderIframe set to true. We would throw error as we don't know what to prerender
* type is not provided and prerenderIframe set to false. We would preload assets only
*/
preload({
calLink,
type,
options = {},
}: {
calLink: string;
type?: "modal" | "floatingButton";
options?: {
prerenderIframe?: boolean;
};
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
calLink: {
type: "string",
required: true,
},
type: {
type: "string",
required: false,
},
options: {
type: Object,
required: false,
},
},
});
let api: GlobalCalWithoutNs = globalCal;
const namespace = this.cal.namespace;
if (namespace) {
api = globalCal.ns[namespace];
}
if (!api) {
throw new Error(`Namespace ${namespace} isn't defined`);
}
const config = this.cal.getConfig();
let prerenderIframe = options.prerenderIframe;
if (type && prerenderIframe === undefined) {
prerenderIframe = true;
}
if (!type && prerenderIframe) {
throw new Error("You should provide 'type'");
}
if (prerenderIframe) {
if (type === "modal" || type === "floatingButton") {
this.cal.isPerendering = true;
this.modal({
calLink,
calOrigin: config.calOrigin,
__prerender: true,
});
} else {
console.warn("Ignoring - full preload for inline embed and instead preloading assets only");
preloadAssetsForCalLink({ calLink, config });
}
} else {
preloadAssetsForCalLink({ calLink, config });
}
}
prerender({ calLink, type }: { calLink: string; type: "modal" | "floatingButton" }) {
this.preload({
calLink,
type,
});
}
ui(uiConfig: UiConfig) {
validate(uiConfig, {
required: true,
props: {
theme: {
required: false,
type: "string",
},
styles: {
required: false,
type: Object,
},
},
});
this.cal.doInIframe({ method: "ui", arg: uiConfig });
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Queue = any[];
// This is a full fledged Cal instance but doesn't have ns property because it would be nested inside an ns instance already
export interface GlobalCalWithoutNs {
<T extends keyof SingleInstructionMap>(methodName: T, ...arg: Rest<SingleInstructionMap[T]>): void;
/** Marks that the embed.js is loaded. Avoids re-downloading it. */
loaded?: boolean;
/** Maintains a queue till the time embed.js isn't loaded */
q: Queue;
/** If user registers multiple namespaces, those are available here */
instance?: Cal;
__css?: string;
fingerprint?: string;
__logQueue?: unknown[];
}
// Well Omit removes the Function Signature from a type if used. So, instead construct the types like this.
type GlobalCalWithNs = GlobalCalWithoutNs & {
ns: Record<string, GlobalCalWithoutNs>;
};
export type GlobalCal = GlobalCalWithNs;
declare global {
interface Window {
Cal: GlobalCal;
}
}
export interface CalWindow extends Window {
Cal: GlobalCal;
}
const DEFAULT_NAMESPACE = "";
globalCal.instance = new Cal(DEFAULT_NAMESPACE, globalCal.q);
// Namespaces created before embed.js executes are instantiated here for old Embed Snippets which don't use 'initNamespace' instruction
// Snippets that support 'initNamespace' instruction don't really need this but it is okay if it's done because it's idempotent
for (const [ns, api] of Object.entries(globalCal.ns)) {
api.instance = api.instance ?? new Cal(ns, api.q);
}
/**
* Intercepts all postmessages and fires action in corresponding actionManager
*/
window.addEventListener("message", (e) => {
const detail = e.data;
const fullType = detail.fullType;
const parsedAction = SdkActionManager.parseAction(fullType);
if (!parsedAction) {
return;
}
const actionManager = Cal.actionsManagers[parsedAction.ns];
globalCal.__logQueue = globalCal.__logQueue || [];
globalCal.__logQueue.push({ ...parsedAction, data: detail.data });
if (!actionManager) {
throw new Error(`Unhandled Action ${parsedAction}`);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
actionManager.fire(parsedAction.type, detail.data);
});
document.addEventListener("click", (e) => {
const targetEl = e.target;
const calLinkEl = getCalLinkEl(targetEl);
const path = calLinkEl?.dataset?.calLink;
if (!path) {
return;
}
const namespace = calLinkEl.dataset.calNamespace;
const configString = calLinkEl.dataset.calConfig || "";
const calOrigin = calLinkEl.dataset.calOrigin || "";
let config;
try {
config = JSON.parse(configString);
} catch (e) {
config = {};
}
let api: GlobalCalWithoutNs = globalCal;
if (namespace) {
api = globalCal.ns[namespace];
}
if (!api) {
throw new Error(`Namespace ${namespace} isn't defined`);
}
api("modal", {
calLink: path,
config,
calOrigin,
});
function getCalLinkEl(target: EventTarget | null) {
let calLinkEl;
if (!(target instanceof HTMLElement)) {
return null;
}
if (target?.dataset.calLink) {
calLinkEl = target;
} else {
// If the element clicked is a child of the cal-link element, then return the cal-link element
calLinkEl = Array.from(document.querySelectorAll("[data-cal-link]")).find((el) => el.contains(target));
}
if (!(calLinkEl instanceof HTMLElement)) {
return null;
}
return calLinkEl;
}
});
let currentColorScheme: string | null = null;
(function watchAndActOnColorSchemeChange() {
// TODO: Maybe find a better way to identify change in color-scheme, a mutation observer seems overkill for this. Settle with setInterval for now.
setInterval(() => {
const colorScheme = getColorScheme(document.body);
if (colorScheme && colorScheme !== currentColorScheme) {
currentColorScheme = colorScheme;
// Go through all the embeds on the same page and update all of them with this info
CalApi.initializedNamespaces.forEach((ns) => {
const api = getEmbedApiFn(ns);
api("ui", {
colorScheme: colorScheme,
});
});
}
}, 50);
})();
function getEmbedApiFn(ns: string) {
let api;
if (ns === DEFAULT_NAMESPACE) {
api = globalCal;
} else {
api = globalCal.ns[ns];
}
return api;
}
function preloadAssetsForCalLink({ config, calLink }: { config: Config; calLink: string }) {
const iframe = document.body.appendChild(document.createElement("iframe"));
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
urlInstance.searchParams.set("preload", "true");
iframe.src = urlInstance.toString();
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.display = "none";
}

View File

@@ -0,0 +1,65 @@
@keyframes loader {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(180deg);
}
50% {
transform: rotate(180deg);
}
75% {
transform: rotate(360deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes loader-inner {
0% {
height: 0%;
}
25% {
height: 0%;
}
50% {
height: 100%;
}
75% {
height: 100%;
}
100% {
height: 0%;
}
}
.loader-inner {
vertical-align: top;
display: inline-block;
width: 100%;
animation: loader-inner 2s infinite ease-in;
}
.loader {
display: block;
width: 30px;
height: 30px;
position: relative;
border-width: 4px;
border-style: solid;
-webkit-animation: loader 2s infinite ease;
animation: loader 2s infinite ease;
}
.loader.modal-loader {
margin: 60px auto;
}

View File

@@ -0,0 +1,126 @@
const searchParams = new URL(document.URL).searchParams;
const embedType = searchParams.get("embedType");
const calLink = searchParams.get("calLink");
const bookerUrl = searchParams.get("bookerUrl");
const embedLibUrl = searchParams.get("embedLibUrl");
if (!bookerUrl || !embedLibUrl) {
throw new Error('Can\'t Preview: Missing "bookerUrl" or "embedLibUrl" query parameter');
}
// TODO: Reuse the embed code snippet from the embed-snippet package - Not able to use it because of circular dependency
// Install Cal Embed Code Snippet
(function (C, A, L) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const p = function (a, ar) {
a.q.push(ar);
};
const d = C.document;
C.Cal =
C.Cal ||
function () {
const cal = C.Cal;
// eslint-disable-next-line prefer-rest-params
const 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 () {
// eslint-disable-next-line prefer-rest-params
p(api, arguments);
};
const namespace = ar[1];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
api.q = api.q || [];
if (typeof namespace === "string") {
// Make sure that even after re-execution of the snippet, the namespace is not overridden
cal.ns[namespace] = cal.ns[namespace] || api;
p(cal.ns[namespace], ar);
p(cal, ["initNamespace", namespace]);
} else p(cal, ar);
return;
}
p(cal, ar);
};
})(window, embedLibUrl, "init");
const previewWindow = window;
previewWindow.Cal.fingerprint = process.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string;
previewWindow.Cal("init", {
origin: bookerUrl,
});
if (!calLink) {
throw new Error('Missing "calLink" query parameter');
}
if (embedType === "inline") {
previewWindow.Cal("inline", {
elementOrSelector: "#my-embed",
calLink: calLink,
});
} else if (embedType === "floating-popup") {
previewWindow.Cal("floatingButton", {
calLink: calLink,
attributes: {
id: "my-floating-button",
},
});
} else if (embedType === "element-click") {
const button = document.createElement("button");
button.setAttribute("data-cal-link", calLink);
button.innerHTML = "I am a button that exists on your website";
document.body.appendChild(button);
}
previewWindow.addEventListener("message", (e) => {
const data = e.data;
if (data.mode !== "cal:preview") {
return;
}
const globalCal = window.Cal;
if (!globalCal) {
throw new Error("Cal is not defined yet");
}
if (data.type == "instruction") {
globalCal(data.instruction.name, data.instruction.arg);
}
if (data.type == "inlineEmbedDimensionUpdate") {
const inlineEl = document.querySelector<HTMLElement>("#my-embed");
if (inlineEl) {
inlineEl.style.width = data.data.width;
inlineEl.style.height = data.data.height;
}
}
});
function makePreviewPageUseSystemPreference() {
const colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
function handleColorSchemeChange(e: MediaQueryListEvent) {
if (e.matches) {
// Dark color scheme
document.body.classList.remove("light");
document.body.classList.add("dark");
} else {
// Light color scheme
document.body.classList.add("light");
document.body.classList.remove("dark");
}
}
colorSchemeQuery.addEventListener("change", handleColorSchemeChange);
// Initial check
handleColorSchemeChange(new MediaQueryListEvent("change", { matches: colorSchemeQuery.matches }));
}
// This makes preview page behave like a website that has system preference enabled. This provides a better experience of preview when user switch their system theme to dark
makePreviewPageUseSystemPreference();
export {};

View File

@@ -0,0 +1,161 @@
type Namespace = string;
type CustomEventDetail = Record<string, unknown>;
function _fireEvent(fullName: string, detail: CustomEventDetail) {
const event = new window.CustomEvent(fullName, {
detail: detail,
});
window.dispatchEvent(event);
}
export type EventDataMap = {
eventTypeSelected: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventType: any;
};
linkFailed: {
code: string;
msg: string;
data: {
url: string;
};
};
linkReady: Record<string, never>;
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;
eventType: unknown;
date: string;
duration: number | undefined;
organizer: {
name: string;
email: string;
timeZone: string;
};
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;
date: string;
duration: number | undefined;
organizer: {
name: string;
email: string;
timeZone: string;
};
confirmed: boolean;
};
bookingCancelled: {
booking: unknown;
organizer: {
name: string;
email: string;
timeZone?: string | undefined;
};
eventType: unknown;
};
routed: {
actionType: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl";
actionValue: string;
};
navigatedToBooker: Record<string, never>;
"*": Record<string, unknown>;
__routeChanged: Record<string, never>;
__windowLoadComplete: Record<string, never>;
__closeIframe: Record<string, never>;
__iframeReady: Record<string, never>;
__dimensionChanged: {
iframeHeight: number;
iframeWidth: number;
isFirstTime: boolean;
};
};
export type EventData<T extends keyof EventDataMap> = {
[K in T]: {
type: string;
namespace: string;
fullType: string;
data: EventDataMap[K];
};
}[T];
export class SdkActionManager {
namespace: Namespace;
static parseAction(fullType: string) {
if (!fullType) {
return null;
}
//FIXME: Ensure that any action if it has :, it is properly encoded.
const [cal, calNamespace, type] = fullType.split(":");
if (cal !== "CAL") {
return null;
}
return {
ns: calNamespace,
type,
};
}
getFullActionName(name: string) {
return this.namespace ? `CAL:${this.namespace}:${name}` : `CAL::${name}`;
}
fire<T extends keyof EventDataMap>(name: T, data: EventDataMap[T]) {
const fullName = this.getFullActionName(name);
const detail = {
type: name,
namespace: this.namespace,
fullType: fullName,
data,
};
_fireEvent(fullName, detail);
// Wildcard Event
_fireEvent(this.getFullActionName("*"), detail);
}
on<T extends keyof EventDataMap>(name: T, callback: (arg0: CustomEvent<EventData<T>>) => void) {
const fullName = this.getFullActionName(name);
window.addEventListener(fullName, callback as EventListener);
}
off<T extends keyof EventDataMap>(name: T, callback: (arg0: CustomEvent<EventData<T>>) => void) {
const fullName = this.getFullActionName(name);
window.removeEventListener(fullName, callback as EventListener);
}
constructor(ns: string | null) {
ns = ns || "";
this.namespace = ns;
}
}

View File

@@ -0,0 +1,13 @@
/**
* @file
* This module is supposed to instantiate the SDK with appropriate namespace
*/
import embedInit from "@calcom/embed-core/embed-iframe-init";
import { SdkActionManager } from "./sdk-action-manager";
export let sdkActionManager: SdkActionManager | null = null;
if (typeof window !== "undefined") {
embedInit();
sdkActionManager = new SdkActionManager(window.getEmbedNamespace());
}

View File

@@ -0,0 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Cal Sans";
src: url("https://cal.com/cal.ttf");
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Cal Sans";
font-weight: normal;
letter-spacing: normal;
}
html,
body,
:host {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue,
Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
}

View File

@@ -0,0 +1,45 @@
import type { CSSProperties } from "react";
type Theme = "dark" | "light";
export type EmbedThemeConfig = Theme | "auto";
export type BookerLayouts = "month_view" | "week_view" | "column_view";
// 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.
export interface EmbedStyles {
body?: Pick<CSSProperties, "background">;
eventTypeListItem?: Pick<CSSProperties, "background" | "color" | "backgroundColor">;
enabledDateButton?: Pick<CSSProperties, "background" | "color" | "backgroundColor">;
disabledDateButton?: Pick<CSSProperties, "background" | "color" | "backgroundColor">;
availabilityDatePicker?: Pick<CSSProperties, "background" | "color" | "backgroundColor">;
}
export interface EmbedNonStylesConfig {
/** Default would be center */
align?: "left";
branding?: {
brandColor?: string;
};
}
export type UiConfig = {
hideEventTypeDetails?: boolean;
// If theme not provided we would get null
theme?: EmbedThemeConfig | null;
styles?: EmbedStyles & EmbedNonStylesConfig;
//TODO: Extract from tailwind the list of all custom variables and support them in auto-completion as well as runtime validation. Followup with listing all variables in Embed Snippet Generator UI.
cssVarsPerTheme?: Record<Theme, Record<string, string>>;
layout?: BookerLayouts;
colorScheme?: string | null;
};
declare global {
interface Window {
CalComPageStatus: string;
isEmbed?: () => boolean;
getEmbedNamespace: () => string | null;
getEmbedTheme: () => EmbedThemeConfig | null;
}
}
export {};

View File

@@ -0,0 +1,21 @@
// this file is copied from '@calcom/lib/hooks/useCompatSearchParams.tsx'
import { ReadonlyURLSearchParams, useParams, useSearchParams } from "next/navigation";
export const useCompatSearchParams = () => {
const _searchParams = useSearchParams() ?? new URLSearchParams();
const params = useParams() ?? {};
const searchParams = new URLSearchParams(_searchParams.toString());
Object.getOwnPropertyNames(params).forEach((key) => {
searchParams.delete(key);
const param = params[key];
const paramArr = typeof param === "string" ? param.split("/") : param;
paramArr?.forEach((p) => {
searchParams.append(key, p);
});
});
return new ReadonlyURLSearchParams(searchParams);
};

View File

@@ -0,0 +1,7 @@
export const getErrorString = (errorCode: string | undefined) => {
if (errorCode === "404") {
return `Error Code: 404. Cal Link seems to be wrong.`;
} else {
return `Error Code: ${errorCode}. Something went wrong.`;
}
};

View File

@@ -0,0 +1,17 @@
const base = require("@calcom/config/tailwind-preset");
module.exports = {
...base,
content: ["**/*Html.ts"],
theme: {
...base.theme,
extend: {
...base.theme.extend,
colors: {
...base.theme.extend.colors,
// Set default as black
brand: "var(--cal-brand-color, black)",
},
},
},
};

View File

@@ -0,0 +1,17 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"jsx": "react",
"target": "ES2015",
"module": "esnext",
"moduleResolution": "Node",
"baseUrl": ".",
"outDir": "dist",
"paths": {
"@calcom/embed-react": ["../embed-react/src"],
"@calcom/embed-snippet": ["../embed-snippet/src"]
}
},
"include": ["src", "env.d.ts", "index.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@@ -0,0 +1,55 @@
import basicSsl from "@vitejs/plugin-basic-ssl";
import EnvironmentPlugin from "vite-plugin-environment";
import viteBaseConfig, { embedCoreEnvVars } from "../vite.config";
const path = require("path");
const { defineConfig } = require("vite");
module.exports = defineConfig((configEnv) => {
/** @type {import('vite').UserConfig} */
const config = {
...viteBaseConfig,
base: "/embed/",
plugins: [
EnvironmentPlugin({
EMBED_PUBLIC_EMBED_FINGER_PRINT: embedCoreEnvVars.EMBED_PUBLIC_EMBED_FINGER_PRINT,
EMBED_PUBLIC_VERCEL_URL: embedCoreEnvVars.EMBED_PUBLIC_VERCEL_URL,
EMBED_PUBLIC_WEBAPP_URL: embedCoreEnvVars.EMBED_PUBLIC_WEBAPP_URL,
}),
...(process.argv.includes("--https") ? [basicSsl()] : []),
],
build: {
emptyOutDir: true,
rollupOptions: {
input: {
preview: path.resolve(__dirname, "preview.html"),
embed: path.resolve(__dirname, "src/embed.ts"),
},
plugins: [
{
generateBundle: (code, bundle) => {
// Note: banner/footer doesn't work because it doesn't enclose the entire library code, some variables are still left out.
// Ideally IIFE mode should be used to solve this problem but it has 2 known problems
// 1. It doesn't work with rollupOptions.input.preview(as it is an app and app doesn't support it, only libraries)
// 2. Having IIFE mode somehow adds the CSS imported in embed, directly to the parent page. It is supposed to be used as a string and then that string is used as CSS in shadow dom
bundle["embed.js"].code = `!function(){${bundle["embed.js"].code}}()`;
},
},
],
output: {
entryFileNames: "[name].js",
//FIXME: Can't specify UMD as import because preview is an app which doesn't support `format` and this setting apply to both input
//format: "umd",
dir: "../../../apps/web/public/embed",
},
},
},
};
if (configEnv.mode === "development") {
config.build.watch = {
include: ["src/**"],
};
}
return config;
});

View File

@@ -0,0 +1,2 @@
.turbo
dist

View File

@@ -0,0 +1,91 @@
# @calcom/embed-react
## 1.5.0
### Minor Changes
- Added namespacing support throughout
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.5.0
- @calcom/embed-snippet@1.3.0
## 1.4.0
### Minor Changes
- Added a few more events
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.4.0
- @calcom/embed-snippet@1.2.0
## 1.3.0
### Minor Changes
- Fix module import of the embed-react package
## 1.2.2
### Patch Changes
- Improve UI instruction layout typings
- Updated dependencies
- @calcom/embed-snippet@1.1.2
- @calcom/embed-core@1.3.2
## 1.2.1
### Patch Changes
- layout type fix as zod-utils can't be used in npm package
- Updated dependencies
- @calcom/embed-snippet@1.1.1
- @calcom/embed-core@1.3.1
## 1.2.0
### Minor Changes
- Supports new booker layout
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.3.0
- @calcom/embed-snippet@1.1.0
## 1.1.1
### Patch Changes
- Fix the build for embed-react
- Updated dependencies
- @calcom/embed-snippet@1.0.9
- @calcom/embed-core@1.2.1
## 1.1.0
### Minor Changes
- Fix missing types for @calcom/embed-react. Also, release support for floatingButton config parameter. Though the support is available using embed.js already, for users using getCalApi the TypeScript types would report that config isn't supported.
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.2.0
- @calcom/embed-snippet@1.0.8
## 1.0.12
### Patch Changes
- Add changesets. Use prepack instead of prePublish and prepublish only as that works with both yarn and npm
- Updated dependencies
- @calcom/embed-snippet@1.0.7
- @calcom/embed-core@1.1.5

View File

@@ -0,0 +1,42 @@
The Cal.com Commercial License (EE) license (the “EE License”)
Copyright (c) 2020-present Cal.com, Inc
With regard to the Cal.com Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Cal.com Subscription Terms available
at https://cal.com/terms (the “EE Terms”), or other agreements governing
the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"),
and otherwise have a valid Cal.com Commercial License subscription ("EE Subscription")
for the correct number of hosts as defined in the EE Terms ("Hosts"). Subject to the foregoing sentence,
you are free to modify this Software and publish patches to the Software. You agree
that Cal.com and/or its licensors (as applicable) retain all right, title and interest in
and to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid EE Subscription for the correct number of hosts.
Notwithstanding the foregoing, you may copy and modify the Software for development
and testing purposes, without requiring a subscription. You agree that Cal.com and/or
its licensors (as applicable) retain all right, title and interest in and to all such
modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
This EE License applies only to the part of this Software that is not distributed under
the AGPLv3 license. Any part of this Software distributed under the AGPLv3 license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
in part, is copyrighted under the AGPLv3 license. The full text of this EE License shall
be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Cal.com Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@@ -0,0 +1,25 @@
# cal-react
Embed Cal Link as a React Component
To know how to use it, follow the steps at <https://developer.cal.com/embed/install-with-react>
## Development
Following command starts a hot reloading server
`yarn dev`
If you are working with embed on website, don't forget to do `yarn build` after every change.
## Running Tests
Runs tests and updates the snapshots. Right now we don't care about snapshots
`yarn embed-tests-quick --update-snapshots`
TODO
- Playwright tests.
- Need to what these tests should be as embed-core already have tests. We probably just need to verify that embed-core API is called appropriately.
- It would probably be better if Playwright tests exist at one place for all embeds.
- Distribution
- It would be better DX to serve the unbuilt version with JSX, instead of built version with React.createElement calls. But because of WebPack loaders not running on node_modules automatically, it doesn't work automatically.
- Right now if a typescript project uses the package, VSCode takes the user to .d.ts files instead of the functions definitions. How to solve it ?

View File

@@ -0,0 +1,10 @@
<html>
<head>
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
<script type="module" src="./element-click.tsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,31 @@
/**
* @fileoverview This file is an example file and tells how to use the element-click popup embed in a React application. This is also used by playwright e2e
*/
import { useEffect } from "react";
import * as React from "react";
import ReactDom from "react-dom";
import { getCalApi } from "./src/index";
const calNamespace = "element-click";
function App() {
useEffect(() => {
(async function () {
const cal = await getCalApi({
embedJsUrl: "http://localhost:3000/embed/embed.js",
namespace: calNamespace,
});
cal("ui", { styles: { branding: { brandColor: "#000000" } }, hideEventTypeDetails: false });
})();
}, []);
return (
<button
data-cal-namespace={calNamespace}
data-cal-link="pro"
data-cal-config='{"layout":"month_view", "theme":"dark"}'>
Click me
</button>
);
}
ReactDom.render(<App />, document.getElementById("root"));

View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly EMBED_PUBLIC_WEBAPP_URL: string;
readonly EMBED_PUBLIC_VERCEL_URL: string;
readonly EMBED_PUBLIC_EMBED_LIB_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,10 @@
<html>
<head>
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
<script type="module" src="./floating.tsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,30 @@
/**
* @fileoverview This file is an example file and tells how to use floating popup button in a React application. This is also used by playwright e2e
*/
import { useEffect } from "react";
import * as React from "react";
import ReactDom from "react-dom";
import { getCalApi } from "./src/index";
function App() {
useEffect(() => {
(async function () {
const cal = await getCalApi({
namespace: "floating",
embedJsUrl: "http://localhost:3000/embed/embed.js",
});
cal("floatingButton", {
calLink: "pro",
calOrigin: "http://localhost:3000",
config: {
theme: "dark",
},
});
cal("ui", { styles: { branding: { brandColor: "#000000" } }, hideEventTypeDetails: false });
})();
}, []);
return null;
}
ReactDom.render(<App />, document.getElementById("root"));

View File

@@ -0,0 +1,16 @@
<html>
<body>
<h1>Playground has following demos</h1>
<ul>
<li>
<a href="./inline.html">Inline</a>
</li>
<li>
<a href="./floating.html">Floating Button Popup</a>
</li>
<li>
<a href="./element-click.html">Element Click Popup</a>
</li>
</ul>
</body>
</html>

View File

@@ -0,0 +1,10 @@
<html>
<head>
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
<script type="module" src="./inline.tsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,77 @@
/**
* @fileoverview This file is an example file and tells how to use the Cal component in a React application. This is also used by playwright e2e
*/
import * as React from "react";
import { useEffect } from "react";
import { useState } from "react";
import ReactDom from "react-dom";
// Because we don't import from @calcom/embed-react, this file isn't able to test if the build is successful or not and thus npm package would work or not correctly.
// There are tests in test/built which verifiy that the types from built package are correctly generated and exported correctly.
import Cal, { getCalApi } from "./src/index";
const api = getCalApi({
namespace: "inline",
});
function App() {
const [, setLoaded] = useState(false);
useEffect(() => {
// Simulate state change causing config object to change, causing rerender of Cal
setTimeout(setLoaded.bind(true), 1000);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callback = (event: any) => {
console.log(event.detail);
};
api.then((api) => {
api("on", {
action: "*",
callback,
});
api("ui", {
cssVarsPerTheme: {
light: {
"cal-border-booker": "red",
"cal-border-booker-width": "20px",
},
dark: {
"cal-border-booker": "red",
"cal-border-booker-width": "5px",
},
},
});
});
return () => {
api.then((api) => {
api("off", {
action: "*",
callback,
});
});
};
}, []);
return (
<>
<h1>
There is <code>Cal</code> component below me
</h1>
<Cal
calOrigin="http://localhost:3000"
embedJsUrl="//localhost:3000/embed/embed.js"
namespace="inline"
style={{ width: "100%", height: "100%", overflow: "scroll" }}
calLink="pro"
config={{
name: "John Doe",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@gmail.com"],
theme: "dark",
}}
/>
</>
);
}
ReactDom.render(<App />, document.getElementById("root"));

View File

@@ -0,0 +1,60 @@
{
"name": "@calcom/embed-react",
"sideEffects": false,
"version": "1.5.0",
"description": "Embed Cal Link as a React Component",
"license": "SEE LICENSE IN LICENSE",
"repository": {
"type": "git",
"url": "https://github.com/calcom/cal.com",
"directory": "packages/embeds/embed-react"
},
"scripts": {
"dev": "vite --port=3101 --open",
"build": "rm -rf dist && vite build && cp ./dist/Cal.es.js ./dist/Cal.es.mjs && tsc --emitDeclarationOnly --declarationDir dist",
"preview": "vite preview",
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint --ext .ts,.js,.tsx,.jsx ./src",
"embed-tests": "yarn playwright test --config=./playwright/config/playwright.config.ts",
"embed-tests-quick": "QUICK=true yarn embed-tests",
"embed-tests-update-snapshots:ci": "yarn embed-tests-quick --update-snapshots",
"packaged:tests": "cd test/packaged && yarn tsc --noEmit && yarn run -T test -- --packaged-embed-tests-only",
"withEmbedPublishEnv": "NEXT_PUBLIC_EMBED_LIB_URL='https://app.cal.com/embed/embed.js' NEXT_PUBLIC_WEBAPP_URL='https://app.cal.com' yarn",
"prepack": "yarn ../../../ lint --filter='@calcom/embed-react' && yarn withEmbedPublishEnv build && yarn packaged:tests",
"embed-web-start": "yarn workspace @calcom/web start",
"embed-dev": "yarn workspace @calcom/embed-react dev",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"main": "./dist/Cal.umd.js",
"module": "./dist/Cal.es.mjs",
"types": "./dist/embed-react/src/index.d.ts",
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/embed-react/src/index.d.ts",
"import": "./dist/Cal.es.mjs",
"require": "./dist/Cal.umd.js"
}
},
"devDependencies": {
"@playwright/test": "^1.31.2",
"@types/react": "18.0.26",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^2.2.0",
"eslint": "^8.34.0",
"npm-run-all": "^4.1.5",
"typescript": "^4.9.4",
"vite": "^4.5.2"
},
"dependencies": {
"@calcom/embed-core": "workspace:*",
"@calcom/embed-snippet": "workspace:*"
}
}

View File

@@ -0,0 +1,68 @@
import { expect } from "@playwright/test";
import { getEmbedIframe } from "@calcom/embed-core/playwright/lib/testUtils";
// eslint-disable-next-line no-restricted-imports
import { test } from "@calcom/web/playwright/lib/fixtures";
test.describe("React Embed", () => {
test.describe("Inline", () => {
test("should verify that the iframe got created with correct URL - namespaced", async ({
page,
embeds,
}) => {
const calNamespace = "inline";
await embeds.gotoPlayground({ url: "/inline.html", calNamespace });
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink("", embeds.getActionFiredDetails, {
pathname: "/pro",
searchParams: {
theme: "dark",
},
});
// expect(await page.screenshot()).toMatchSnapshot("react-component-inline.png");
});
});
test.describe("Floating button Popup", () => {
test("should verify that the iframe got created with correct URL - namespaced", async ({
page,
embeds,
}) => {
const calNamespace = "floating";
await page.waitForLoadState();
await embeds.gotoPlayground({ url: "/floating.html", calNamespace });
await page.click("text=Book my Cal");
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: "/pro",
searchParams: {
theme: "dark",
},
});
});
});
// TODO: This test is extremely flaky and has been failing a lot, blocking many PRs. Fix this.
// eslint-disable-next-line playwright/no-skipped-test
test.describe.skip("Element Click Popup", () => {
test("should verify that the iframe got created with correct URL - namespaced", async ({
page,
embeds,
}) => {
const calNamespace = "element-click";
await embeds.gotoPlayground({ url: "/element-click.html", calNamespace });
await page.waitForLoadState();
await page.click("text=Click me");
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: "/pro",
searchParams: {
theme: "dark",
},
});
});
});
});

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect, useRef } from "react";
import type { PrefillAndIframeAttrsConfig } from "@calcom/embed-core";
import useEmbed from "./useEmbed";
type CalProps = {
calOrigin?: string;
calLink: string;
initConfig?: {
debug?: boolean;
uiDebug?: boolean;
};
namespace?: string;
config?: PrefillAndIframeAttrsConfig;
embedJsUrl?: string;
} & React.HTMLAttributes<HTMLDivElement>;
const Cal = function Cal(props: CalProps) {
const { calLink, calOrigin, namespace = "", config, initConfig = {}, embedJsUrl, ...restProps } = props;
if (!calLink) {
throw new Error("calLink is required");
}
const initializedRef = useRef(false);
const Cal = useEmbed(embedJsUrl);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!Cal || initializedRef.current || !ref.current) {
return;
}
initializedRef.current = true;
const element = ref.current;
if (namespace) {
Cal("init", namespace, {
...initConfig,
origin: calOrigin,
});
Cal.ns[namespace]("inline", {
elementOrSelector: element,
calLink,
config,
});
} else {
Cal("init", {
...initConfig,
origin: calOrigin,
});
Cal("inline", {
elementOrSelector: element,
calLink,
config,
});
}
}, [Cal, calLink, config, namespace, calOrigin, initConfig]);
if (!Cal) {
return null;
}
return <div ref={ref} {...restProps} />;
};
export default Cal;

View File

@@ -0,0 +1,40 @@
"use client";
import type { GlobalCal, GlobalCalWithoutNs } from "@calcom/embed-core";
import EmbedSnippet from "@calcom/embed-snippet";
import Cal from "./Cal";
export function getCalApi(options?: {
embedJsUrl?: string;
namespace?: string;
}): Promise<GlobalCal | GlobalCalWithoutNs>;
export function getCalApi(embedJsUrl: string): Promise<GlobalCal | GlobalCalWithoutNs>;
export function getCalApi(
optionsOrEmbedJsUrl?:
| {
embedJsUrl?: string;
namespace?: string;
}
| string
): Promise<GlobalCal | GlobalCalWithoutNs> {
const options =
typeof optionsOrEmbedJsUrl === "string" ? { embedJsUrl: optionsOrEmbedJsUrl } : optionsOrEmbedJsUrl ?? {};
const { namespace = "", embedJsUrl } = options;
return new Promise(function tryReadingFromWindow(resolve) {
const globalCal = EmbedSnippet(embedJsUrl);
globalCal("init", namespace);
const api = namespace ? globalCal.ns[namespace as keyof typeof globalCal.ns] : globalCal;
if (!api) {
setTimeout(() => {
tryReadingFromWindow(resolve);
}, 50);
return;
}
resolve(api);
});
}
export default Cal;

View File

@@ -0,0 +1,16 @@
"use client";
import { useEffect, useState } from "react";
import EmbedSnippet from "@calcom/embed-snippet";
export default function useEmbed(embedJsUrl?: string) {
const [globalCal, setGlobalCal] = useState<ReturnType<typeof EmbedSnippet>>();
useEffect(() => {
setGlobalCal(() => {
return EmbedSnippet(embedJsUrl);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return globalCal;
}

View File

@@ -0,0 +1,6 @@
# Packaged
The tests in this file are run on the packaged code that is published to npm. The packaged code is different from the source code in atleast the following ways
- Not all files go to packaged code.If package.json -> files field is specified then only the files that are specified there would be published. So, one might accidentally miss an important file that's available otherwise.
- The packaged code doesn't have .ts files. Those files are actually converted to .js files and .d.ts files are generated separately for TypeScript support. It allows the package to work in both TypeScript and non TypeScript environments.

View File

@@ -0,0 +1,25 @@
/**
* @fileoverview
* This file tests two things in 2 ways
* - It is a vitest test file and thus it tests if the code executes without any error. Thus, it tests that package.json->main/module fields are correctly defined. It obviously verifies the assertions as well.
* - It is also validates for it's types and thus verifies that @calcom/embed-react has correctly specified it's types in package.json->types field.
*/
import { expect, test } from "vitest";
// This import may show up as an error in your IDE, but it's fine because typings are available only after embed-react is built.
import { getCalApi } from "@calcom/embed-react";
const api = getCalApi();
test("Check that the API is available", async () => {
expect(api).toBeDefined();
const awaitedApi = await api;
awaitedApi("floatingButton", {
calLink: "free",
config: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error We are intentionaly testing invalid value
layout: "wrongview",
},
});
});

View File

@@ -0,0 +1,13 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ES2015",
"moduleResolution": "Node",
"baseUrl": ".",
"declaration": true,
"jsx": "preserve",
"outDir": "dist"
},
"include": ["**/*.ts"]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ES2015",
"moduleResolution": "Node",
"baseUrl": ".",
"declaration": true,
"jsx": "preserve",
"outDir": "dist",
"paths": {
"@calcom/embed-core": ["../embed-core/src"],
"@calcom/embed-snippet": ["../embed-snippet/src"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "env.d.ts"],
// Exclude "test" because that has `api.test.ts` which imports @calcom/embed-react which needs it to be built using this tsconfig.json first. Excluding it here prevents type-check from validating test folder
"exclude": ["node_modules", "test"]
}

View File

@@ -0,0 +1,32 @@
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
import viteBaseConfig from "../vite.config";
// https://vitejs.dev/config/
export default defineConfig({
...viteBaseConfig,
plugins: [react()],
build: {
lib: {
entry: path.resolve(__dirname, "src/index.ts"),
name: "Cal",
fileName: (format) => `Cal.${format}.js`,
},
rollupOptions: {
// make sure to externalize deps that shouldn't be bundled
// into your library
external: ["react", "react-dom"],
output: {
exports: "named",
// Provide global variables to use in the UMD build
// for externalized deps
globals: {
react: "React",
"react-dom": "ReactDOM",
},
},
},
},
});

View File

@@ -0,0 +1,73 @@
# @calcom/embed-snippet
## 1.3.0
### Minor Changes
- Added namespacing support throughout
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.5.0
## 1.2.0
### Minor Changes
- Added a few more events
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.4.0
## 1.1.2
### Patch Changes
- Improve UI instruction layout typings
- Updated dependencies
- @calcom/embed-core@1.3.2
## 1.1.1
### Patch Changes
- layout type fix as zod-utils can't be used in npm package
- Updated dependencies
- @calcom/embed-core@1.3.1
## 1.1.0
### Minor Changes
- Supports new booker layout
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.3.0
## 1.0.9
### Patch Changes
- Fix the build for embed-react
- Updated dependencies
- @calcom/embed-core@1.2.1
## 1.0.8
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.2.0
## 1.0.7
### Patch Changes
- Add changesets. Use prepack instead of prePublish and prepublish only as that works with both yarn and npm
- Updated dependencies
- @calcom/embed-core@1.1.5

View File

@@ -0,0 +1,42 @@
The Cal.com Commercial License (EE) license (the “EE License”)
Copyright (c) 2020-present Cal.com, Inc
With regard to the Cal.com Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Cal.com Subscription Terms available
at https://cal.com/terms (the “EE Terms”), or other agreements governing
the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"),
and otherwise have a valid Cal.com Commercial License subscription ("EE Subscription")
for the correct number of hosts as defined in the EE Terms ("Hosts"). Subject to the foregoing sentence,
you are free to modify this Software and publish patches to the Software. You agree
that Cal.com and/or its licensors (as applicable) retain all right, title and interest in
and to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid EE Subscription for the correct number of hosts.
Notwithstanding the foregoing, you may copy and modify the Software for development
and testing purposes, without requiring a subscription. You agree that Cal.com and/or
its licensors (as applicable) retain all right, title and interest in and to all such
modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
This EE License applies only to the part of this Software that is not distributed under
the AGPLv3 license. Any part of this Software distributed under the AGPLv3 license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
in part, is copyrighted under the AGPLv3 license. The full text of this EE License shall
be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Cal.com Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@@ -0,0 +1,10 @@
# embed-snippet
Vanilla JS embed snippet that is responsible to fetch @calcom/embed-core and thus show Cal Link as an embed on a page.
## Development
`yarn build` will generate dist/snippet.es.js. If you are going to test react embeds, make sure that you have built it so that they get the upto-date snippet
- which can be used as `<script type="module" src=...`
- You can also copy the appropriate portion of the code and install it directly as `<script>CODE_SUGGESTED_TO_BE_COPIED</script>`

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,34 @@
{
"name": "@calcom/embed-snippet",
"sideEffects": false,
"version": "1.3.0",
"main": "./dist/snippet.umd.js",
"module": "./dist/snippet.es.js",
"description": "Vanilla JS embed snippet that is responsible to fetch @calcom/embed-core and thus show Cal Link as an embed on a page.",
"license": "SEE LICENSE IN LICENSE",
"repository": {
"type": "git",
"url": "https://github.com/calcom/cal.com",
"directory": "packages/embeds/embed-snippet"
},
"scripts": {
"build": "rm -rf dist && vite build && tsc --emitDeclarationOnly --declarationDir dist",
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint --ext .ts,.js src",
"withEmbedPublishEnv": "NEXT_PUBLIC_EMBED_LIB_URL='https://app.cal.com/embed/embed.js' NEXT_PUBLIC_WEBAPP_URL='https://app.cal.com' yarn",
"prepack": "yarn ../../../ lint --filter='@calcom/embed-snippet' && yarn withEmbedPublishEnv build",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"files": [
"dist"
],
"types": "./dist/index.d.ts",
"devDependencies": {
"typescript": "^4.9.4",
"vite": "^4.1.2"
},
"dependencies": {
"@calcom/embed-core": "workspace:*"
}
}

View File

@@ -0,0 +1,82 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/ban-ts-comment,prefer-rest-params,prefer-const */
import type { GlobalCal, GlobalCalWithoutNs, Queue } from "@calcom/embed-core";
// FIXME: embed-snippet is a published package and shouldn't import from @calcom/types which is unpublished
// This isn't a problem at the moment because embed-snippet isn't directly imported and embed-react which uses it doesn't depend on this
// eslint-disable-next-line no-restricted-imports
import type { Optional } from "@calcom/types/utils";
/**
* As we want to keep control on the size of this snippet but we want some portion of it to be still readable.
* So, write the code that you need directly but keep it short.
*/
const WEBAPP_URL =
import.meta.env.EMBED_PUBLIC_WEBAPP_URL || `https://${import.meta.env.EMBED_PUBLIC_VERCEL_URL}`;
const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`;
type QueuePushArg = {
[k: number]: Queue[number];
};
/**
* When modifying this snippet, make sure to keep the snippets in following places in sync
* 1. EmbedTabs.tsx
* 2. embed-core/index.html
* 3. app-store/wordpress/plugin.php
*/
export default function EmbedSnippet(url = EMBED_LIB_URL) {
(function (C, A, L) {
let p = function (a: GlobalCalWithoutNs, ar: QueuePushArg) {
a.q.push(ar);
};
let d = C.document;
C.Cal =
C.Cal ||
function () {
let cal = C.Cal;
let ar = arguments;
if (!cal.loaded) {
// 'ns' and 'q' are now definitely set with the following 2 lines, so you can safely assert in TypeScript that it's GlobalCal now.
cal.ns = {};
cal.q = cal.q || [];
//@ts-ignore
d.head.appendChild(d.createElement("script")).src = A;
cal.loaded = true;
}
if (ar[0] === L) {
const api: GlobalCalWithoutNs = function () {
p(api, arguments);
};
const namespace = ar[1];
api.q = api.q || [];
if (typeof namespace === "string") {
// Make sure that even after re-execution of the snippet, the namespace is not overridden
cal.ns![namespace] = cal.ns![namespace] || api;
p(cal.ns![namespace], ar);
// Inform the default namespace queue to initialize this namespace
p(cal as GlobalCal, ["initNamespace", namespace]);
} else p(cal as GlobalCal, ar);
return;
}
p(cal as GlobalCal, ar);
};
})(
window as Omit<Window, "Cal"> & {
// Make 'ns' and 'q' optional as they are set through the snippet above
Cal: Optional<GlobalCal, "ns" | "q">;
},
//! Replace it with "https://cal.com/embed.js" or the URL where you have embed.js installed
url,
"init"
);
/*! Copying ends here. */
return window.Cal;
}
export const EmbedSnippetString = EmbedSnippet.toString();

View File

@@ -0,0 +1,17 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"jsx": "react",
"target": "ES2015",
"baseUrl": ".",
"module": "ESNext",
"declaration": true,
"outDir": "dist",
"paths": {
"@calcom/embed-core": ["../embed-core/src"],
"@calcom/embed-react": ["../embed-react/src"]
}
},
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}

View File

@@ -0,0 +1,15 @@
import viteBaseConfig from "../vite.config";
const path = require("path");
const { defineConfig } = require("vite");
module.exports = defineConfig({
...viteBaseConfig,
build: {
lib: {
entry: path.resolve(__dirname, "src", "index.ts"),
name: "snippet",
fileName: (format) => `snippet.${format}.js`,
},
},
});

View File

@@ -0,0 +1,39 @@
const path = require("path");
require("dotenv").config({ path: path.join(__dirname, "..", "..", ".env") });
process.env.EMBED_PUBLIC_VERCEL_URL = process.env.VERCEL_URL;
process.env.EMBED_PUBLIC_WEBAPP_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.EMBED_PUBLIC_EMBED_LIB_URL = process.env.NEXT_PUBLIC_EMBED_LIB_URL;
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.EMBED_PUBLIC_EMBED_FINGER_PRINT = process.env.NEXT_PUBLIC_EMBED_FINGER_PRINT;
// Problem: typeof process.env.EMBED_PUBLIC_EMBED_LIB_URL is "undefined"(truthy) if process.env.NEXT_PUBLIC_EMBED_LIB_URL is undefined(falsy)
// This is probably because environment variables are always string, so this weird automatic conversion to string happens
// HACKY Solution
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.EMBED_PUBLIC_EMBED_LIB_URL === "undefined") {
// eslint-disable-next-line turbo/no-undeclared-env-vars
delete process.env.EMBED_PUBLIC_EMBED_LIB_URL;
}
if (process.env.EMBED_PUBLIC_WEBAPP_URL === "undefined") {
delete process.env.EMBED_PUBLIC_WEBAPP_URL;
}
if (process.env.EMBED_PUBLIC_VERCEL_URL === "undefined") {
delete process.env.EMBED_PUBLIC_VERCEL_URL;
}
const viteBaseConfig = {
envPrefix: "EMBED_PUBLIC_",
};
export default viteBaseConfig;
export const embedCoreEnvVars = {
EMBED_PUBLIC_EMBED_FINGER_PRINT: process.env.EMBED_PUBLIC_EMBED_FINGER_PRINT ?? "",
EMBED_PUBLIC_VERCEL_URL: process.env.EMBED_PUBLIC_VERCEL_URL ?? "http://localhost:3000",
EMBED_PUBLIC_WEBAPP_URL:
process.env.EMBED_PUBLIC_WEBAPP_URL ??
(process.env.EMBED_PUBLIC_VERCEL_URL
? `https://${process.env.EMBED_PUBLIC_VERCEL_URL}`
: "http://localhost:3000"),
};