Embed Snippet Generator (#2597)

* Add support to dynamically change the theme

* Add Embed UI in app

* Update UI as per Figma

* Dynamicaly update Embed Code

* Get differnet modes working in preview

* Support Embed on EventType Edit, Team Link Fix and Mobile unsupported

* Fix auto theme switch in Embed Snippet generator

* Fix types

* Self Review fixes

* Remove Embed from App section

* Move get query after the middleware to let middleware work on it

* Add sandboxes in the document

* Add error handling for embed loading

* Fix types

* Update snapshots and fix bug identified by tests

* UI Fixes

* Add Embed Tests

* Respond in preview to width and height

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Hariom Balhara 2022-05-05 19:59:49 +05:30 committed by GitHub
parent 60146ed2c5
commit 174ed9f6d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 1593 additions and 153 deletions

View File

@ -0,0 +1,21 @@
function getAnchor(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9 ]/g, "")
.replace(/[ ]/g, "-")
.replace(/ /g, "%20");
}
export default function Anchor({ as, children }) {
const anchor = getAnchor(children);
const link = `#${anchor}`;
const Component = as || "div";
return (
<Component id={anchor}>
<a href={link} className="anchor-link">
§
</a>
{children}
</Component>
);
}

View File

@ -2,13 +2,16 @@
title: Embed
---
import Anchor from "../../components/Anchor"
# Embed
The Embed allows your website visitors to book a meeting with you directly from your website.
## Install on any website
- _Step-1._ Install the Vanilla JS Snippet
Install the following Vanilla JS Snippet to get embed to work on any website. After that you can <a href="#popular-ways-in-which-you-can-embed-on-your-website">choose any of the ways</a> to show your Cal Link embedded on your website.
```html
<script>
(function (C, A, L) {
@ -57,7 +60,7 @@ yarn add @calcom/embed-react
You can use Vanilla JS Snippet to install
## Popular ways in which you can embed on your website
<Anchor as="H2">Popular ways in which you can embed on your website</Anchor>
Assuming that you have followed the steps of installing and initializing the snippet, you can show the embed in following ways:
@ -82,8 +85,15 @@ Show the embed inline inside a container element. It would take the width and he
},
});
</script>
```
*Sample sandbox*
```
<iframe src="https://codesandbox.io/embed/vanilla-js-inline-embed-r27n67?fontsize=14&hidenavigation=1&theme=dark"
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
title="Cal Component - Embed Inline Demo[React][TypeScript]"
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
></iframe>
</details>
####
@ -108,6 +118,14 @@ const MyComponent = () => (
);
```
*Sample sandbox*
<iframe src="https://codesandbox.io/embed/cal-component-embed-inline-demo-react-typescript-d1zlcn?fontsize=14&hidenavigation=1&theme=dark"
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
title="Cal Component - Embed Inline Demo[React][TypeScript]"
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
></iframe>
</details>
### Popup on any existing element
@ -120,9 +138,16 @@ To show the embed as a popup on clicking an element, add `data-cal-link` attribu
To show the embed as a popup on clicking an element, simply add `data-cal-link` attribute to the element.
*Sample sandbox*
<iframe src="https://codesandbox.io/embed/popup-on-click-of-an-existing-element-y9lcuo?fontsize=14&hidenavigation=1&theme=dark"
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
title="Cal Component - Embed Inline Demo[React][TypeScript]"
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
></iframe>
<button data-cal-link="jane" data-cal-config="A valid config JSON"></button>
</details>
<details>
<summary>React</summary>
```jsx
@ -131,11 +156,37 @@ To show the embed as a popup on clicking an element, simply add `data-cal-link`
const MyComponent = ()=> {
return <button data-cal-link="jane" data-cal-config='A valid config JSON'></button>
}
```
````
*Sample sandbox*
<iframe src="https://codesandbox.io/embed/embed-popup-on-click-of-an-existing-element-demo-react-sc967e?fontsize=14&hidenavigation=1&theme=dark"
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
title="Cal Component - Embed Inline Demo[React][TypeScript]"
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
></iframe>
</details>
### Floating pop-up button
```html
<script>
Cal("floatingButton", {
// The link that you want to embed. It would open https://cal.com/jane in embed
calLink: "jane",
});
</script>
```
*Sample sandbox*
<iframe src="https://codesandbox.io/embed/embed-floating-button-popup-all-websites-cg7pru?fontsize=14&hidenavigation=1&theme=dark"
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
title="Cal Component - Embed Inline Demo[React][TypeScript]"
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
></iframe>
## Supported Instructions
Consider an instruction as a function with that name and that would be called with the given arguments.

View File

@ -0,0 +1,905 @@
import { CodeIcon, EyeIcon, SunIcon, ChevronRightIcon, ArrowLeftIcon } from "@heroicons/react/solid";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import classNames from "classnames";
import { useRouter } from "next/router";
import { useRef, useState } from "react";
import { components, ControlProps, SingleValue } from "react-select";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { EventType } from "@calcom/prisma/client";
import { Button, Switch } from "@calcom/ui";
import { Dialog, DialogContent, DialogClose } from "@calcom/ui/Dialog";
import { InputLeading, Label, TextArea, TextField } from "@calcom/ui/form/fields";
import { trpc } from "@lib/trpc";
import NavTabs from "@components/NavTabs";
import ColorPicker from "@components/ui/colorpicker";
import Select from "@components/ui/form/Select";
type EmbedType = "inline" | "floating-popup" | "element-click";
const queryParamsForDialog = ["embedType", "tabName", "eventTypeId"];
const embeds: {
illustration: React.ReactElement;
title: string;
subtitle: string;
type: EmbedType;
}[] = [
{
title: "Inline Embed",
subtitle: "Loads your Cal scheduling page directly inline with your other website content",
type: "inline",
illustration: (
<svg
width="100%"
height="100%"
className="rounded-md"
viewBox="0 0 308 265"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
fill="white"
/>
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
<rect x="24.5" y="51" width="139" height="163" rx="1.5" fill="#F8F8F8" />
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="2" fill="#E1E1E1" />
<rect x="48" y="86.5" width="48" height="4" rx="1" fill="#E1E1E1" />
<rect x="49" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="61" y="99.5" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="73" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="85" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="97" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="109" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="133" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="85" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="97" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="109" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="133" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="49" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="61" y="125.5" width="6" height="6" rx="1" fill="#3E3E3E" />
<path
d="M61 124.5H67V122.5H61V124.5ZM68 125.5V131.5H70V125.5H68ZM67 132.5H61V134.5H67V132.5ZM60 131.5V125.5H58V131.5H60ZM61 132.5C60.4477 132.5 60 132.052 60 131.5H58C58 133.157 59.3431 134.5 61 134.5V132.5ZM68 131.5C68 132.052 67.5523 132.5 67 132.5V134.5C68.6569 134.5 70 133.157 70 131.5H68ZM67 124.5C67.5523 124.5 68 124.948 68 125.5H70C70 123.843 68.6569 122.5 67 122.5V124.5ZM61 122.5C59.3431 122.5 58 123.843 58 125.5H60C60 124.948 60.4477 124.5 61 124.5V122.5Z"
fill="#3E3E3E"
/>
<rect x="73" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="85" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="97" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="109" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="133" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="49" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="61" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="73" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="85" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="97" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="109" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="121" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="133" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="49" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="61" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="73" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="85" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="97" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="109" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="121" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="133" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="49" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="61" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="73" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="85" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="97" y="161.5" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="109" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="24.5" y="51" width="139" height="163" rx="1.5" stroke="#292929" />
<rect x="176" y="50.5" width="108" height="164" rx="2" fill="#E1E1E1" />
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
{/* <path
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
fill="#CFCFCF"
/> */}
</svg>
),
},
{
title: "Floating pop-up button",
subtitle: "Adds a floating button on your site that launches Cal in a dialog.",
type: "floating-popup",
illustration: (
<svg
width="100%"
height="100%"
className="rounded-md"
viewBox="0 0 308 265"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
fill="white"
/>
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
<rect x="226" y="223.5" width="66" height="26" rx="2" fill="#292929" />
<rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" />
{/* <path
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
fill="#CFCFCF"
/> */}
</svg>
),
},
{
title: "Pop up via element click",
subtitle: "Open your Cal dialog when someone clicks an element.",
type: "element-click",
illustration: (
<svg
width="100%"
height="100%"
className="rounded-md"
viewBox="0 0 308 265"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
fill="white"
/>
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" fill="#F8F8F8" />
<rect opacity="0.8" x="108" y="85" width="80" height="8" rx="2" fill="#E1E1E1" />
<rect x="108" y="97" width="48" height="4" rx="1" fill="#E1E1E1" />
<rect x="109" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="110" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="133" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="169" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="181" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="193" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="169" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="181" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="193" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="109" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="136" width="6" height="6" rx="1" fill="#3E3E3E" />
<path
d="M121 135H127V133H121V135ZM128 136V142H130V136H128ZM127 143H121V145H127V143ZM120 142V136H118V142H120ZM121 143C120.448 143 120 142.552 120 142H118C118 143.657 119.343 145 121 145V143ZM128 142C128 142.552 127.552 143 127 143V145C128.657 145 130 143.657 130 142H128ZM127 135C127.552 135 128 135.448 128 136H130C130 134.343 128.657 133 127 133V135ZM121 133C119.343 133 118 134.343 118 136H120C120 135.448 120.448 135 121 135V133Z"
fill="#3E3E3E"
/>
<rect x="133" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="169" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="181" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="193" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="109" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="133" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="169" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="181" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="193" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="109" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="133" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="169" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="181" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="193" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="109" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="133" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="172" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="169" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" stroke="#292929" />
{/* <path
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
fill="#CFCFCF"
/> */}
</svg>
),
},
];
function getEmbedSnippetString() {
let embedJsUrl = "https://cal.com/embed.js";
let isLocal = false;
if (location.hostname === "localhost") {
embedJsUrl = "http://localhost:3100/dist/embed.umd.js";
isLocal = true;
}
// TODO: Import this string from @calcom/embed-snippet
return `
(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 || []; typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar); return; } p(cal, ar); }; })(window, "${embedJsUrl}", "init");
Cal("init"${isLocal ? ', {origin:"http://localhost:3000/"}' : ""});
`;
}
const EmbedNavBar = () => {
const { t } = useLocale();
const tabs = [
{
name: t("Embed"),
tabName: "embed-code",
icon: CodeIcon,
},
{
name: t("Preview"),
tabName: "embed-preview",
icon: EyeIcon,
},
];
return <NavTabs data-testid="embed-tabs" tabs={tabs} linkProps={{ shallow: true }} />;
};
const ThemeSelectControl = ({ children, ...props }: ControlProps<any, false>) => {
return (
<components.Control {...props}>
<SunIcon className="h-[32px] w-[32px] text-gray-500" />
{children}
</components.Control>
);
};
const ChooseEmbedTypesDialogContent = () => {
const { t } = useLocale();
const router = useRouter();
return (
<DialogContent size="lg">
<div className="mb-4">
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
{t("how_you_want_add_cal_site")}
</h3>
<div>
<p className="text-sm text-gray-500">{t("choose_ways_put_cal_site")}</p>
</div>
</div>
<div className="flex">
{embeds.map((embed, index) => (
<button
className="mr-2 w-1/3 p-3 text-left hover:rounded-md hover:border hover:bg-neutral-100"
key={index}
data-testid={embed.type}
onClick={() => {
router.push({
query: {
...router.query,
embedType: embed.type,
},
});
}}>
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white">
{embed.illustration}
</div>
<div className="mt-2 font-medium text-neutral-900">{embed.title}</div>
<p className="text-sm text-gray-500">{embed.subtitle}</p>
</button>
))}
</div>
</DialogContent>
);
};
const EmbedTypeCodeAndPreviewDialogContent = ({
eventTypeId,
embedType,
}: {
eventTypeId: EventType["id"];
embedType: EmbedType;
}) => {
const { t } = useLocale();
const router = useRouter();
const iframeRef = useRef<HTMLIFrameElement>(null);
const embedCode = useRef<HTMLTextAreaElement>(null);
const embed = embeds.find((embed) => embed.type === embedType);
const { data: eventType, isLoading } = trpc.useQuery([
"viewer.eventTypes.get",
{
id: +eventTypeId,
},
]);
const [isEmbedCustomizationOpen, setIsEmbedCustomizationOpen] = useState(true);
const [isBookingCustomizationOpen, setIsBookingCustomizationOpen] = useState(true);
const [previewState, setPreviewState] = useState({
inline: {
width: "100%",
height: "100%",
},
theme: "auto",
floatingPopup: {},
elementClick: {},
palette: {
brandColor: "#000000",
},
});
const close = () => {
const noPopupQuery = {
...router.query,
};
delete noPopupQuery.dialog;
queryParamsForDialog.forEach((queryParam) => {
delete noPopupQuery[queryParam];
});
router.push({
query: noPopupQuery,
});
};
// Use embed-code as default tab
if (!router.query.tabName) {
router.query.tabName = "embed-code";
router.push({
query: {
...router.query,
},
});
}
if (isLoading) {
return null;
}
if (!embed || !eventType) {
close();
return null;
}
const calLink = `${eventType.team ? `team/${eventType.team.slug}` : eventType.users[0].username}/${
eventType.slug
}`;
// TODO: Not sure how to make these template strings look better formatted.
// This exact formatting is required to make the code look nicely formatted together.
const getEmbedUIInstructionString = () =>
`Cal("ui", {
${getThemeForSnippet() ? 'theme: "' + previewState.theme + '",\n ' : ""}styles: {
branding: ${JSON.stringify(previewState.palette)}
}
})`;
const getEmbedTypeSpecificString = () => {
if (embedType === "inline") {
return `
Cal("inline", {
elementOrSelector:"#my-cal-inline",
calLink: "${calLink}"
});
${getEmbedUIInstructionString().trim()}`;
} else if (embedType === "floating-popup") {
let floatingButtonArg = {
calLink,
...previewState.floatingPopup,
};
return `
Cal("floatingButton", ${JSON.stringify(floatingButtonArg)});
${getEmbedUIInstructionString().trim()}`;
} else if (embedType === "element-click") {
return `//Important: Also, add data-cal-link="${calLink}" attribute to the element you want to open Cal on click
${getEmbedUIInstructionString().trim()}`;
}
return "";
};
const getThemeForSnippet = () => {
return previewState.theme !== "auto" ? previewState.theme : null;
};
const getDimension = (dimension: string) => {
if (dimension.match(/^\d+$/)) {
dimension = `${dimension}%`;
}
return dimension;
};
const addToPalette = (update: typeof previewState["palette"]) => {
setPreviewState((previewState) => {
return {
...previewState,
palette: {
...previewState.palette,
...update,
},
};
});
};
const previewInstruction = (instruction: { name: string; arg: any }) => {
iframeRef.current?.contentWindow?.postMessage(
{
mode: "cal:preview",
type: "instruction",
instruction,
},
"*"
);
};
const inlineEmbedDimensionUpdate = ({ width, height }: { width: string; height: string }) => {
iframeRef.current?.contentWindow?.postMessage(
{
mode: "cal:preview",
type: "inlineEmbedDimensionUpdate",
data: {
width: getDimension(width),
height: getDimension(height),
},
},
"*"
);
};
previewInstruction({
name: "ui",
arg: {
theme: previewState.theme,
styles: {
branding: {
...previewState.palette,
},
},
},
});
if (embedType === "floating-popup") {
previewInstruction({
name: "floatingButton",
arg: {
attributes: {
id: "my-floating-button",
},
...previewState.floatingPopup,
},
});
}
if (embedType === "inline") {
inlineEmbedDimensionUpdate({
width: previewState.inline.width,
height: previewState.inline.height,
});
}
const ThemeOptions = [
{ value: "auto", label: "Auto Theme" },
{ value: "dark", label: "Dark Theme" },
{ value: "light", label: "Light Theme" },
];
const FloatingPopupPositionOptions = [
{
value: "bottom-right",
label: "Bottom Right",
},
{
value: "bottom-left",
label: "Bottom Left",
},
];
return (
<DialogContent size="xl">
<div className="flex">
<div className="flex w-1/3 flex-col bg-white p-6">
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
<button
onClick={() => {
const newQuery = { ...router.query };
delete newQuery.embedType;
delete newQuery.tabName;
router.push({
query: {
...newQuery,
},
});
}}>
<ArrowLeftIcon className="mr-4 w-4"></ArrowLeftIcon>
</button>
{embed.title}
</h3>
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")}></hr>
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
<Collapsible
open={isEmbedCustomizationOpen}
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
<CollapsibleTrigger
type="button"
className="flex w-full items-center text-base font-medium text-neutral-900">
<div>
{embedType === "inline"
? "Inline Embed Customization"
: embedType === "floating-popup"
? "Floating Popup Customization"
: "Element Click Customization"}
</div>
<ChevronRightIcon
className={`${
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
} ml-auto h-5 w-5 text-neutral-500`}
/>
</CollapsibleTrigger>
<CollapsibleContent className="text-sm">
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
{/*TODO: Add Auto/Fixed toggle from Figma */}
<div className="text-sm">Embed Window Sizing</div>
<div className="justify-left flex items-center">
<TextField
name="width"
labelProps={{ className: "hidden" }}
required
value={previewState.inline.width}
onChange={(e) => {
setPreviewState((previewState) => {
let width = e.target.value || "100%";
return {
...previewState,
inline: {
...previewState.inline,
width,
},
};
});
}}
addOnLeading={<InputLeading>W</InputLeading>}
/>
<span className="p-2">x</span>
<TextField
labelProps={{ className: "hidden" }}
name="height"
value={previewState.inline.height}
required
onChange={(e) => {
const height = e.target.value || "100%";
setPreviewState((previewState) => {
return {
...previewState,
inline: {
...previewState.inline,
height,
},
};
});
}}
addOnLeading={<InputLeading>H</InputLeading>}
/>
</div>
</div>
<div
className={classNames(
"mt-4 items-center justify-between",
embedType === "floating-popup" ? "flex" : "hidden"
)}>
<div className="text-sm">Button Text</div>
{/* Default Values should come from preview iframe */}
<TextField
name="buttonText"
labelProps={{ className: "hidden" }}
onChange={(e) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
buttonText: e.target.value,
},
};
});
}}
defaultValue="Book my Cal"
required
/>
</div>
<div
className={classNames(
"mt-4 flex items-center justify-between",
embedType === "floating-popup" ? "flex" : "hidden"
)}>
<div className="text-sm">Display Calendar Icon Button</div>
<Switch
defaultChecked={true}
onCheckedChange={(checked) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
hideButtonIcon: !checked,
},
};
});
}}></Switch>
</div>
<div
className={classNames(
"mt-4 flex items-center justify-between",
embedType === "floating-popup" ? "flex" : "hidden"
)}>
<div>Position of Button</div>
<Select
onChange={(position) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
buttonPosition: position?.value,
},
};
});
}}
defaultValue={FloatingPopupPositionOptions[0]}
options={FloatingPopupPositionOptions}></Select>
</div>
<div
className={classNames(
"mt-4 flex items-center justify-between",
embedType === "floating-popup" ? "flex" : "hidden"
)}>
<div>Button Color</div>
<div className="w-36">
<ColorPicker
defaultValue="#000000"
onChange={(color) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
buttonColor: color,
},
};
});
}}></ColorPicker>
</div>
</div>
<div
className={classNames(
"mt-4 flex items-center justify-between",
embedType === "floating-popup" ? "flex" : "hidden"
)}>
<div>Text Color</div>
<div className="w-36">
<ColorPicker
defaultValue="#000000"
onChange={(color) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
buttonTextColor: color,
},
};
});
}}></ColorPicker>
</div>
</div>
{/* <div
className={classNames(
"mt-4 items-center justify-between",
embedType === "floating-popup" ? "flex" : "hidden"
)}>
<div>Button Color on Hover</div>
<div className="w-36">
<ColorPicker
defaultValue="#000000"
onChange={(color) => {
addToPalette({
"floating-popup-button-color-hover": color,
});
}}></ColorPicker>
</div>
</div> */}
</CollapsibleContent>
</Collapsible>
</div>
<hr className="mt-4"></hr>
<div className="mt-4 font-medium">
<Collapsible
open={isBookingCustomizationOpen}
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
<CollapsibleTrigger className="flex w-full" type="button">
<div className="text-base font-medium text-neutral-900">Cal Booking Customization</div>
<ChevronRightIcon
className={`${
isBookingCustomizationOpen ? "rotate-90 transform" : ""
} ml-auto h-5 w-5 text-neutral-500`}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-6 text-sm">
<Label className="flex items-center justify-between">
<div>Theme</div>
<Select
className="w-36"
defaultValue={ThemeOptions[0]}
components={{
Control: ThemeSelectControl,
}}
onChange={(option) => {
if (!option) {
return;
}
setPreviewState((previewState) => {
return {
...previewState,
theme: option.value,
};
});
}}
options={ThemeOptions}></Select>
</Label>
{[
{ name: "brandColor", title: "Brand Color" },
// { name: "lightColor", title: "Light Color" },
// { name: "lighterColor", title: "Lighter Color" },
// { name: "lightestColor", title: "Lightest Color" },
// { name: "highlightColor", title: "Highlight Color" },
// { name: "medianColor", title: "Median Color" },
].map((palette) => (
<Label key={palette.name} className="flex items-center justify-between">
<div>{palette.title}</div>
<div className="w-36">
<ColorPicker
defaultValue="#000000"
onChange={(color) => {
//@ts-ignore - How to support dynamic palette names?
addToPalette({
[palette.name]: color,
});
}}></ColorPicker>
</div>
</Label>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
<div className="w-2/3 bg-gray-50 p-6">
<EmbedNavBar />
<div>
<div
className={classNames(router.query.tabName === "embed-code" ? "block" : "hidden", "h-[75vh]")}>
<small className="flex py-4 text-neutral-500">{t("place_where_cal_widget_appear")}</small>
<TextArea
data-testid="embed-code"
ref={embedCode}
name="embed-code"
className="h-[36rem]"
readOnly
value={
`<!-- Cal ${embedType} embed code begins -->\n` +
(embedType === "inline"
? `<div style="width:${getDimension(previewState.inline.width)};height:${getDimension(
previewState.inline.height
)};overflow:scroll" id="my-cal-inline"></div>\n`
: "") +
`<script type="text/javascript">
${getEmbedSnippetString().trim()}
${getEmbedTypeSpecificString().trim()}
</script>
<!-- Cal ${embedType} embed code ends -->`
}></TextArea>
<p className="hidden text-sm text-gray-500">
{t(
"Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options."
)}
</p>
</div>
<div className={router.query.tabName == "embed-preview" ? "block" : "hidden"}>
<iframe
ref={iframeRef}
data-testid="embed-preview"
className="border-1 h-[75vh] border"
width="100%"
height="100%"
src={`http://localhost:3100/preview.html?embedType=${embedType}&calLink=${calLink}`}
/>
</div>
</div>
<div className="mt-8 flex flex-row-reverse gap-x-2">
<Button
type="submit"
onClick={() => {
if (!embedCode.current) {
return;
}
navigator.clipboard.writeText(embedCode.current.value);
showToast(t("code_copied"), "success");
}}>
{t("copy_code")}
</Button>
<DialogClose asChild>
<Button color="secondary">{t("Close")}</Button>
</DialogClose>
</div>
</div>
</div>
</DialogContent>
);
};
export const EmbedDialog = () => {
const router = useRouter();
const eventTypeId: EventType["id"] = +(router.query.eventTypeId as string);
return (
<Dialog name="embed" clearQueryParamsOnClose={queryParamsForDialog}>
{!router.query.embedType ? (
<ChooseEmbedTypesDialogContent />
) : (
<EmbedTypeCodeAndPreviewDialogContent
eventTypeId={eventTypeId}
embedType={router.query.embedType as EmbedType}
/>
)}
</Dialog>
);
};
export const EmbedButton = ({
eventTypeId,
className = "",
dark,
...props
}: {
eventTypeId: EventType["id"];
className: string;
dark?: boolean;
}) => {
const { t } = useLocale();
const router = useRouter();
className = classNames(className, "hidden lg:flex");
const openEmbedModal = () => {
const query = {
...router.query,
dialog: "embed",
eventTypeId,
};
router.push(
{
pathname: router.pathname,
query,
},
undefined,
{ shallow: true }
);
};
return (
<Button
type="button"
color="minimal"
size="sm"
className={className}
{...props}
data-test-eventtype-id={eventTypeId}
data-testid={"event-type-embed"}
onClick={() => openEmbedModal()}>
<CodeIcon
className={classNames("h-4 w-4 ltr:mr-2 rtl:ml-2", dark ? "" : "text-neutral-500")}></CodeIcon>
{t("Embed")}
</Button>
);
};

View File

@ -1,32 +1,62 @@
import { AdminRequired } from "components/ui/AdminRequired";
import Link, { LinkProps } from "next/link";
import { useRouter } from "next/router";
import React, { ElementType, FC, Fragment } from "react";
import React, { ElementType, FC, Fragment, MouseEventHandler } from "react";
import classNames from "@lib/classNames";
export interface NavTabProps {
tabs: {
name: string;
href: string;
/** If you want to change the path as per current tab */
href?: string;
/** If you want to change query param tabName as per current tab */
tabName?: string;
icon?: ElementType;
adminRequired?: boolean;
}[];
linkProps?: Omit<LinkProps, "href">;
}
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps }) => {
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
const router = useRouter();
return (
<>
<nav className="-mb-px flex space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse" aria-label="Tabs">
<nav
className="-mb-px flex space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse"
aria-label="Tabs"
{...props}>
{tabs.map((tab) => {
const isCurrent = router.asPath === tab.href;
let href: string;
let isCurrent;
if ((tab.tabName && tab.href) || (!tab.tabName && !tab.href)) {
throw new Error("Use either tabName or href");
}
if (tab.href) {
href = tab.href;
isCurrent = router.asPath === tab.href;
} else if (tab.tabName) {
href = "";
isCurrent = router.query.tabName === tab.tabName;
}
const onClick: MouseEventHandler = tab.tabName
? (e) => {
e.preventDefault();
router.push({
query: {
...router.query,
tabName: tab.tabName,
},
});
}
: () => {};
const Component = tab.adminRequired ? AdminRequired : Fragment;
return (
<Component key={tab.name}>
<Link href={tab.href} {...linkProps}>
<Link key={tab.name} href={href!} {...linkProps}>
<a
onClick={onClick}
className={classNames(
isCurrent
? "border-neutral-900 text-neutral-900"

View File

@ -40,7 +40,7 @@ export default function useTheme(theme?: Maybe<string>) {
// TODO: isReady doesn't seem required now. This is also impacting PSI Score for pages which are using isReady.
setIsReady(true);
setTheme(theme);
}, []);
}, [theme]);
function Theme() {
const code = applyThemeAndAddListener.toString();

View File

@ -9,6 +9,7 @@ const withTM = require("next-transpile-modules")([
"@calcom/stripe",
"@calcom/ui",
"@calcom/embed-core",
"@calcom/embed-snippet",
]);
const { i18n } = require("./next-i18next.config");

View File

@ -26,87 +26,6 @@ import IntegrationListItem from "@components/integrations/IntegrationListItem";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
import WebhookListContainer from "@components/webhook/WebhookListContainer";
function IframeEmbedContainer() {
const { t } = useLocale();
// doesn't need suspense as it should already be loaded
const user = trpc.useQuery(["viewer.me"]).data;
const iframeTemplate = `<iframe src="${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user?.username}" frameborder="0" allowfullscreen></iframe>`;
const htmlTemplate = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${t(
"schedule_a_meeting"
)}</title><style>body {margin: 0;}iframe {height: calc(100vh - 4px);width: calc(100vw - 4px);box-sizing: border-box;}</style></head><body>${iframeTemplate}</body></html>`;
return (
<>
<ShellSubHeading title={t("iframe_embed")} subtitle={t("embed_calcom")} className="mt-10" />
<div className="lg:col-span-9 lg:pb-8">
<List>
<ListItem className={classNames("flex-col")}>
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
<Image width={40} height={40} src="/apps/embed.svg" alt="Embed" />
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3">{t("standard_iframe")}</ListItemTitle>
<ListItemText component="p">{t("embed_your_calendar")}</ListItemText>
</div>
<div className="text-right">
<input
id="iframe"
className="px-2 py-1 text-sm text-gray-500 "
placeholder={t("loading")}
defaultValue={iframeTemplate}
readOnly
/>
<button
onClick={() => {
navigator.clipboard.writeText(iframeTemplate);
showToast("Copied to clipboard", "success");
}}>
<ClipboardIcon className="-mb-0.5 h-4 w-4 text-gray-800 ltr:mr-2 rtl:ml-2" />
</button>
</div>
</div>
</ListItem>
<ListItem className={classNames("flex-col")}>
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
<Image width={40} height={40} src="/apps/embed.svg" alt="Embed" />
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3">{t("responsive_fullscreen_iframe")}</ListItemTitle>
<ListItemText component="p">A fullscreen scheduling experience on your website</ListItemText>
</div>
<div>
<input
id="fullscreen"
className="px-2 py-1 text-sm text-gray-500 "
placeholder={t("loading")}
defaultValue={htmlTemplate}
readOnly
/>
<button
onClick={() => {
navigator.clipboard.writeText(htmlTemplate);
showToast("Copied to clipboard", "success");
}}>
<ClipboardIcon className="-mb-0.5 h-4 w-4 text-gray-800 ltr:mr-2 rtl:ml-2" />
</button>
</div>
</div>
</ListItem>
</List>
<div className="grid grid-cols-2 space-x-4 rtl:space-x-reverse">
<div>
<label htmlFor="iframe" className="block text-sm font-medium text-gray-700"></label>
<div className="mt-1"></div>
</div>
<div>
<label htmlFor="fullscreen" className="block text-sm font-medium text-gray-700"></label>
<div className="mt-1"></div>
</div>
</div>
</div>
</>
);
}
function ConnectOrDisconnectIntegrationButton(props: {
//
credentialIds: number[];
@ -342,7 +261,6 @@ export default function IntegrationsPage() {
<IntegrationsContainer />
<CalendarListContainer />
<WebhookListContainer title={t("webhooks")} subtitle={t("receive_cal_meeting_data")} />
<IframeEmbedContainer />
<Web3Container />
</ClientSuspense>
</AppsShell>

View File

@ -52,6 +52,7 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
import { ClientSuspense } from "@components/ClientSuspense";
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
import { EmbedButton, EmbedDialog } from "@components/Embed";
import Loader from "@components/Loader";
import Shell from "@components/Shell";
import { Tooltip } from "@components/Tooltip";
@ -1822,6 +1823,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<LinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
{t("copy_link")}
</button>
<EmbedButton
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900"
eventTypeId={eventType.id}
/>
<Dialog>
<DialogTrigger className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-red-500 hover:bg-gray-200">
<TrashIcon className="h-4 w-4 text-red-500 ltr:mr-2 rtl:ml-2" />
@ -1969,6 +1974,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
/>
)}
</ClientSuspense>
<EmbedDialog />
</Shell>
</div>
);

View File

@ -10,13 +10,14 @@ import {
ClipboardCopyIcon,
TrashIcon,
PencilIcon,
CodeIcon,
} from "@heroicons/react/solid";
import { UsersIcon } from "@heroicons/react/solid";
import { Trans } from "next-i18next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { Fragment, useEffect, useState } from "react";
import React, { Fragment, useEffect, useRef, useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -36,6 +37,7 @@ import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { EmbedButton, EmbedDialog } from "@components/Embed";
import EmptyScreen from "@components/EmptyScreen";
import Shell from "@components/Shell";
import { Tooltip } from "@components/Tooltip";
@ -299,6 +301,12 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
{t("duplicate")}
</Button>
</DropdownMenuItem>
<DropdownMenuItem>
<EmbedButton
dark
className="w-full rounded-none"
eventTypeId={type.id}></EmbedButton>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem>
<Dialog>
@ -519,9 +527,9 @@ const CTA = () => {
};
const WithQuery = withQuery(["viewer.eventTypes"]);
const EventTypesPage = () => {
const { t } = useLocale();
return (
<div>
<Head>
@ -574,6 +582,7 @@ const EventTypesPage = () => {
{data.eventTypeGroups.length === 0 && (
<CreateFirstEventTypeView profiles={data.profiles} canAddEvents={data.viewer.canAddEvents} />
)}
<EmbedDialog></EmbedDialog>
</>
)}
/>

View File

@ -0,0 +1,188 @@
import { expect, Page, test } from "@playwright/test";
function chooseEmbedType(page: Page, embedType: string) {
page.locator(`[data-testid=${embedType}]`).click();
}
async function gotToPreviewTab(page: Page) {
await page.locator("[data-testid=embed-tabs]").locator("text=Preview").click();
}
async function clickEmbedButton(page: Page) {
const embedButton = page.locator("[data-testid=event-type-embed]");
const eventTypeId = await embedButton.getAttribute("data-test-eventtype-id");
embedButton.click();
return eventTypeId;
}
async function clickFirstEventTypeEmbedButton(page: Page) {
const menu = page.locator("[data-testid*=event-type-options]").first();
await menu.click();
const eventTypeId = await clickEmbedButton(page);
return eventTypeId;
}
async function expectToBeNavigatingToEmbedTypesDialog(
page: Page,
{ eventTypeId, basePage }: { eventTypeId: string | null; basePage: string }
) {
if (!eventTypeId) {
throw new Error("Couldn't find eventTypeId");
}
await page.waitForNavigation({
url: (url) => {
return (
url.pathname === basePage &&
url.searchParams.get("dialog") === "embed" &&
url.searchParams.get("eventTypeId") === eventTypeId
);
},
});
}
async function expectToBeNavigatingToEmbedCodeAndPreviewDialog(
page: Page,
{ eventTypeId, embedType, basePage }: { eventTypeId: string | null; embedType: string; basePage: string }
) {
if (!eventTypeId) {
throw new Error("Couldn't find eventTypeId");
}
await page.waitForNavigation({
url: (url) => {
return (
url.pathname === basePage &&
url.searchParams.get("dialog") === "embed" &&
url.searchParams.get("eventTypeId") === eventTypeId &&
url.searchParams.get("embedType") === embedType &&
url.searchParams.get("tabName") === "embed-code"
);
},
});
}
async function expectToContainValidCode(page: Page, { embedType }: { embedType: string }) {
const embedCode = await page.locator("[data-testid=embed-code]").inputValue();
expect(embedCode.includes("(function (C, A, L)")).toBe(true);
expect(embedCode.includes(`Cal ${embedType} embed code begins`)).toBe(true);
return {
message: () => `passed`,
pass: true,
};
}
async function expectToContainValidPreviewIframe(
page: Page,
{ embedType, calLink }: { embedType: string; calLink: string }
) {
expect(await page.locator("[data-testid=embed-preview]").getAttribute("src")).toContain(
`/preview.html?embedType=${embedType}&calLink=${calLink}`
);
}
test.describe("Embed Code Generator Tests", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test.describe("Event Types Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/event-types");
});
test("open Embed Dialog and choose Inline for First Event Type", async ({ page }) => {
const eventTypeId = await clickFirstEventTypeEmbedButton(page);
await expectToBeNavigatingToEmbedTypesDialog(page, {
eventTypeId,
basePage: "/event-types",
});
chooseEmbedType(page, "inline");
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
eventTypeId,
embedType: "inline",
basePage: "/event-types",
});
await expectToContainValidCode(page, { embedType: "inline" });
await gotToPreviewTab(page);
await expectToContainValidPreviewIframe(page, { embedType: "inline", calLink: "pro/30min" });
});
test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page }) => {
const eventTypeId = await clickFirstEventTypeEmbedButton(page);
await expectToBeNavigatingToEmbedTypesDialog(page, {
eventTypeId,
basePage: "/event-types",
});
chooseEmbedType(page, "floating-popup");
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
eventTypeId,
embedType: "floating-popup",
basePage: "/event-types",
});
await expectToContainValidCode(page, { embedType: "floating-popup" });
await gotToPreviewTab(page);
await expectToContainValidPreviewIframe(page, { embedType: "floating-popup", calLink: "pro/30min" });
});
test("open Embed Dialog and choose element-click for First Event Type", async ({ page }) => {
const eventTypeId = await clickFirstEventTypeEmbedButton(page);
await expectToBeNavigatingToEmbedTypesDialog(page, {
eventTypeId,
basePage: "/event-types",
});
chooseEmbedType(page, "element-click");
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
eventTypeId,
embedType: "element-click",
basePage: "/event-types",
});
await expectToContainValidCode(page, { embedType: "element-click" });
await gotToPreviewTab(page);
await expectToContainValidPreviewIframe(page, { embedType: "element-click", calLink: "pro/30min" });
});
});
test.describe("Event Type Edit Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/event-types/3");
});
test("open Embed Dialog for the Event Type", async ({ page }) => {
const eventTypeId = await clickEmbedButton(page);
await expectToBeNavigatingToEmbedTypesDialog(page, {
eventTypeId,
basePage: "/event-types/3",
});
chooseEmbedType(page, "inline");
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
eventTypeId,
basePage: "/event-types/3",
embedType: "inline",
});
await expectToContainValidCode(page, {
embedType: "inline",
});
gotToPreviewTab(page);
await expectToContainValidPreviewIframe(page, {
embedType: "inline",
calLink: "pro/30min",
});
});
});
});

View File

@ -774,6 +774,11 @@
"impersonate_user_tip":"All uses of this feature is audited.",
"impersonating_user_warning":"Impersonating username \"{{user}}\".",
"impersonating_stop_instructions": "<0>Click Here to stop</0>.",
"place_where_cal_widget_appear": "Place this code in your HTML where you want your Cal widget to appear.",
"copy_code": "Copy Code",
"code_copied": "Code copied!",
"how_you_want_add_cal_site":"How do you want to add Cal to your site?",
"choose_ways_put_cal_site":"Choose one of the following ways to put Cal on your site.",
"setting_up_zapier": "Setting up your Zapier integration",
"generate_api_key": "Generate Api Key",
"your_unique_api_key": "Your unique API key",

View File

@ -211,6 +211,40 @@ export const eventTypesRouter = createProtectedRouter()
return next();
})
.query("get", {
input: z.object({
id: z.number(),
}),
async resolve({ ctx, input }) {
const user = await ctx.prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
id: true,
username: true,
name: true,
startTime: true,
endTime: true,
bufferTime: true,
avatar: true,
plan: true,
},
});
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
return await ctx.prisma.eventType.findUnique({
where: {
id: input.id,
},
include: {
team: true,
users: true,
},
});
},
})
.mutation("update", {
input: EventTypeUpdateInput.strict(),
async resolve({ ctx, input }) {

View File

@ -56,6 +56,7 @@ Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
- 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
@ -72,13 +73,8 @@ Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
- Dev Experience/Ease of Installation
- Do we need a one liner(like `window.dataLayer.push`) to inform SDK of something even if snippet is not yet on the page but would be there e.g. through GTM it would come late on the page ?
- Might be better to pass all configuration using a single base64encoded query param to booking page.
- Performance Improvements
- Custom written Tailwind CSS is sent multiple times for different custom elements.
- Embed Code Generator
- 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
@ -87,8 +83,7 @@ Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
- 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
- Theme switch dynamically - If user switches the theme on website, he should be able to do it on embed. Add a demo for the API. Also, test system theme handling.
- How would the user add on hover styles just using style attribute ?
- 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.

View File

@ -1,6 +1,7 @@
<html>
<head>
<!-- <link rel="prerender" href="http://localhost:3000/free"> -->
<!-- <script src="./src/embed.ts" type="module"></script> -->
<script>
if (!location.search.includes("nonResponsive")) {
document.write('<meta name="viewport" content="width=device-width"/>');

View File

@ -4,12 +4,13 @@
"description": "This is the vanilla JS core script that embeds Cal Link",
"main": "./index.ts",
"scripts": {
"build": "vite build",
"build": "NEXT_PUBLIC_EMBED_FINGER_PRINT=$(git rev-parse --short HEAD) vite build && cp dist/embed.umd.js ../../../apps/website/public/embed.js && echo 'You need to commit the newly generated embed.js in apps/website'",
"build:cal": "NEXT_PUBLIC_WEBSITE_URL='https://cal.com' yarn build",
"vite": "vite",
"tailwind": "yarn tailwindcss -i ./src/styles.css -o ./src/tailwind.generated.css --watch",
"tailwind": "yarn tailwindcss -i ./src/styles.css -o ./src/tailwind.generated.css",
"buildWatchAndServer": "run-p 'build --watch' 'vite --port 3100 --strict-port --open'",
"dev": "run-p 'tailwind' 'buildWatchAndServer'",
"dev": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer'",
"dev-real": "vite dev --port 3100",
"type-check": "tsc --pretty --noEmit",
"lint": "eslint --ext .ts,.js src",
"embed-tests": "yarn playwright test --config=playwright/config/playwright.config.ts",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 987 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 996 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@ -0,0 +1,80 @@
<html>
<head>
<script>
(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 || [];
typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar);
return;
}
p(cal, ar);
};
})(window, "//localhost:3100/dist/embed.umd.js", "init");
Cal("init", {
origin: "http://localhost:3000",
});
</script>
<style>
.row {
display:flex;
}
.cell-1 {
border-right:1px solid #ded9d9;
padding-right:10px;
}
.cell-2 {
margin:10px;
}
</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:100%;overflow:scroll"></div>
<script>
if (embedType === "inline") {
Cal("inline", {
elementOrSelector: "#my-embed",
calLink,
});
} else if (embedType === "floating-popup") {
Cal("floatingButton", {
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);
}
</script>
</body>
</html>

View File

@ -1,11 +1,73 @@
import { CalWindow } from "@calcom/embed-snippet";
import floatingButtonHtml from "./FloatingButtonHtml";
import getFloatingButtonHtml from "./FloatingButtonHtml";
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(" ");
}
//@ts-ignore
static get observedAttributes() {
return [
"data-button-text",
"data-hide-button-icon",
"data-button-position",
"data-button-color",
"data-button-text-color",
];
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (name === "data-button-text") {
const buttonEl = this.shadowRoot?.querySelector("#button");
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.innerHTML = newValue;
} else if (name === "data-hide-button-icon") {
const buttonIconEl = this.shadowRoot?.querySelector("#button-icon") as HTMLElement;
if (!buttonIconEl) {
throw new Error("Button not found");
}
buttonIconEl.style.display = newValue == "true" ? "none" : "block";
} else if (name === "data-button-position") {
const buttonEl = this.shadowRoot?.querySelector("button") as HTMLElement;
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.className = FloatingButton.updatedClassString(newValue, buttonEl.className);
} else if (name === "data-button-color") {
const buttonEl = this.shadowRoot?.querySelector("button") as HTMLElement;
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.style.backgroundColor = newValue;
} else if (name === "data-button-text-color") {
const buttonEl = this.shadowRoot?.querySelector("button") as HTMLElement;
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.style.color = newValue;
}
}
constructor() {
super();
const buttonHtml = `<style>${(window as CalWindow).Cal!.__css}</style> ${floatingButtonHtml}`;
const buttonText = this.dataset["buttonText"];
const buttonPosition = this.dataset["buttonPosition"];
const buttonColor = this.dataset["buttonColor"];
const buttonTextColor = this.dataset["buttonTextColor"];
//TODO: Logic is duplicated over HTML generation and attribute change, keep it at one place
const buttonHtml = `<style>${(window as CalWindow).Cal!.__css}</style> ${getFloatingButtonHtml({
buttonText: buttonText!,
buttonClasses: [FloatingButton.updatedClassString(buttonPosition!, "")],
buttonColor: buttonColor!,
buttonTextColor: buttonTextColor!,
})}`;
this.attachShadow({ mode: "open" });
this.shadowRoot!.innerHTML = buttonHtml;
}

View File

@ -1,8 +1,23 @@
const html = `<button class="fixed bottom-4 right-4 flex h-16 origin-center bg-red-50 transform cursor-pointer items-center
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="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 md:bottom-6 md:right-10"
style="background-color: rgb(255, 202, 0); color: rgb(20, 30, 47); z-index: 10001">
<div class="mr-3 flex items-center justify-center">
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"
@ -16,7 +31,8 @@ style="background-color: rgb(255, 202, 0); color: rgb(20, 30, 47); z-index: 1000
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 class="font-semibold leading-5 antialiased">Book my Cal</div>
<div id="button" class="font-semibold leading-5 antialiased">${buttonText}</div>
</button>`;
};
export default html;
export default getHtml;

View File

@ -1,6 +1,7 @@
import { CalWindow } from "@calcom/embed-snippet";
import loaderCss from "../loader.css";
import { getErrorString } from "../utils";
import inlineHtml from "./inlineHtml";
export class Inline extends HTMLElement {
@ -9,8 +10,16 @@ export class Inline extends HTMLElement {
return ["loading"];
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (name === "loading" && newValue == "done") {
(this.shadowRoot!.querySelector("#loader")! as HTMLElement).style.display = "none";
if (name === "loading") {
if (newValue == "done") {
(this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none";
} else if (newValue === "failed") {
(this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none";
(this.shadowRoot!.querySelector("#error")! as HTMLElement).style.display = "block";
(this.shadowRoot!.querySelector("slot")! as HTMLElement).style.visibility = "hidden";
const errorString = getErrorString(this.dataset.errorCode);
(this.shadowRoot!.querySelector("#error")! as HTMLElement).innerText = errorString;
}
}
}
constructor() {

View File

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

View File

@ -1,6 +1,7 @@
import { CalWindow } from "@calcom/embed-snippet";
import loaderCss from "../loader.css";
import { getErrorString } from "../utils";
import modalBoxHtml from "./ModalBoxHtml";
export class ModalBox extends HTMLElement {
@ -28,11 +29,16 @@ export class ModalBox extends HTMLElement {
}
if (newValue == "loaded") {
(this.shadowRoot!.querySelector("#loader")! as HTMLElement).style.display = "none";
(this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none";
} else if (newValue === "started") {
this.show(true);
} else if (newValue == "closed") {
this.show(false);
} else if (newValue === "failed") {
(this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none";
(this.shadowRoot!.querySelector("#error")! as HTMLElement).style.display = "inline-block";
const errorString = getErrorString(this.dataset.errorCode);
(this.shadowRoot!.querySelector("#error")! as HTMLElement).innerText = errorString;
}
}

View File

@ -59,11 +59,12 @@ const html = `<style>
</div>
<div class="modal-box">
<div class="body">
<div id="loader" class="z-[999999999999] absolute flex w-full items-center">
<div id="wrapper" class="z-[999999999999] absolute flex w-full items-center">
<div class="loader modal-loader border-brand dark:border-darkmodebrand">
<span class="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
</div>
</div>
<div id="error" class="hidden left-1/2 -translate-x-1/2 relative text-white"></div>
<slot></slot>
</div>
</div>

View File

@ -4,8 +4,8 @@ import { useState, useEffect, CSSProperties } from "react";
import { sdkActionManager } from "./sdk-event";
export interface UiConfig {
theme: string;
styles: EmbedStyles;
theme?: "dark" | "light" | "auto";
styles?: EmbedStyles;
}
const embedStore = {
@ -13,7 +13,6 @@ const embedStore = {
styles: {},
namespace: null,
embedType: undefined,
theme: null,
// Store all React State setters here.
reactStylesStateSetters: {},
parentInformedAboutContentHeight: false,
@ -21,11 +20,12 @@ const embedStore = {
} as {
styles: UiConfig["styles"];
namespace: string | null;
theme: string | null;
embedType: undefined | null | string;
reactStylesStateSetters: any;
parentInformedAboutContentHeight: boolean;
windowLoadEventFired: boolean;
theme?: UiConfig["theme"];
setTheme: (arg0: string) => void;
};
let isSafariBrowser = false;
@ -132,17 +132,14 @@ function isValidNamespace(ns: string | null | undefined) {
export const useEmbedTheme = () => {
const router = useRouter();
let [theme, setTheme] = useState(embedStore.theme || (router.query.theme as string));
useEffect(() => {
router.events.on("routeChangeComplete", () => {
sdkActionManager?.fire("__routeChanged", {});
});
}, [router.events]);
if (embedStore.theme) {
return embedStore.theme;
}
const theme = (embedStore.theme = router.query.theme as string);
return theme;
embedStore.setTheme = setTheme;
return theme === "auto" ? null : theme;
};
// 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
@ -271,11 +268,16 @@ export const methods = {
}
// 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) {
if (stylesConfig?.body?.background) {
document.body.style.background = stylesConfig.body.background as string;
}
setEmbedStyles(stylesConfig);
if (uiConfig.theme) {
embedStore.theme = uiConfig.theme as UiConfig["theme"];
embedStore.setTheme(uiConfig.theme);
}
setEmbedStyles(stylesConfig || {});
},
parentKnowsIframeReady: () => {
log("Method: `parentKnowsIframeReady` called");
@ -370,6 +372,7 @@ function keepParentInformedAboutDimensionChanges() {
if (isBrowser) {
const url = new URL(document.URL);
embedStore.theme = (url.searchParams.get("theme") || "auto") as UiConfig["theme"];
if (url.searchParams.get("prerender") !== "true" && isEmbed()) {
log("Initializing embed-iframe");
// HACK

View File

@ -23,6 +23,11 @@ const globalCal = (window as CalWindow).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 = import.meta.env.NEXT_PUBLIC_EMBED_FINGER_PRINT as string;
globalCal.__css = allCss;
document.head.appendChild(document.createElement("style")).innerHTML = css;
@ -30,6 +35,7 @@ function log(...args: any[]) {
console.log(...args);
}
/**
* //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
@ -258,19 +264,53 @@ export class Cal {
element.appendChild(template.content);
}
floatingButton({ calLink }: { calLink: string }) {
validate(arguments[0], {
required: true,
props: {
calLink: {
required: true,
type: "string",
},
},
});
const template = document.createElement("template");
template.innerHTML = `<cal-floating-button data-cal-namespace="${this.namespace}" data-cal-link="${calLink}"></cal-floating-button>`;
document.body.appendChild(template.content);
floatingButton({
calLink,
buttonText = "Book my Cal",
hideButtonIcon = false,
attributes,
buttonPosition = "bottom-right",
buttonColor = "rgb(255, 202, 0)",
buttonTextColor = "rgb(20, 30, 47)",
}: {
calLink: string;
buttonText?: string;
attributes?: Record<string, string>;
hideButtonIcon?: boolean;
buttonPosition?: "bottom-left" | "bottom-right";
buttonColor: string;
buttonTextColor: string;
}) {
// validate(arguments[0], {
// required: true,
// props: {
// calLink: {
// required: true,
// type: "string",
// },
// },
// });
let attributesString = "";
let existingEl = null;
if (attributes?.id) {
attributesString += ` id="${attributes.id}"`;
existingEl = document.getElementById(attributes.id);
}
let el = existingEl;
if (!existingEl) {
const template = document.createElement("template");
template.innerHTML = `<cal-floating-button ${attributesString} data-cal-namespace="${this.namespace}" data-cal-link="${calLink}"></cal-floating-button>`;
el = template.content.children[0] as HTMLElement;
document.body.appendChild(template.content);
}
if (buttonText) {
el!.setAttribute("data-button-text", buttonText);
}
el!.setAttribute("data-hide-button-icon", "" + hideButtonIcon);
el!.setAttribute("data-button-position", "" + buttonPosition);
el!.setAttribute("data-button-color", "" + buttonColor);
el!.setAttribute("data-button-text-color", "" + buttonTextColor);
}
modal({ calLink, config = {}, uid }: { calLink: string; config?: Record<string, string>; uid: number }) {
@ -437,8 +477,16 @@ export class Cal {
this.modalBox?.setAttribute("state", "loaded");
this.inlineEl?.setAttribute("loading", "done");
});
this.actionManager.on("linkFailed", (e) => {
this.iframe?.remove();
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");
});
}
}

View File

@ -0,0 +1,23 @@
import { CalWindow } from "@calcom/embed-snippet";
window.addEventListener("message", (e) => {
const data = e.data;
if (data.mode !== "cal:preview") {
return;
}
const globalCal = (window as CalWindow).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("#my-embed") as HTMLElement;
if (inlineEl) {
inlineEl.style.width = data.data.width;
inlineEl.style.height = data.data.height;
}
}
});

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

@ -6,6 +6,9 @@ module.exports = defineConfig({
envPrefix: "NEXT_PUBLIC_",
build: {
minify: "terser",
watch: {
include: ["src/**"],
},
terserOptions: {
format: {
comments: false,

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/embed-react",
"version": "1.0.1",
"version": "1.0.2",
"description": "Embed Cal Link as a React Component",
"scripts": {
"dev": "vite --port=3101 --open",

View File

@ -14,6 +14,7 @@ export interface GlobalCal {
ns?: Record<string, GlobalCal>;
instance?: CalClass;
__css?: string;
fingerprint?: string;
}
export interface CalWindow extends Window {
@ -21,7 +22,6 @@ export interface CalWindow extends Window {
}
export default function EmbedSnippet(url = "https://cal.com/embed.js") {
/*! Copy the code below and paste it in script tag of your website */
(function (C: CalWindow, A, L) {
let p = function (a: any, ar: any) {
a.q.push(ar);
@ -60,3 +60,5 @@ export default function EmbedSnippet(url = "https://cal.com/embed.js") {
return (window as CalWindow).Cal;
}
export const EmbedSnippetString = EmbedSnippet.toString();

View File

@ -8,5 +8,9 @@ module.exports = defineConfig({
name: "snippet",
fileName: (format) => `snippet.${format}.js`,
},
minify: "terser",
terserOptions: {
compress: true,
},
},
});

View File

@ -57,16 +57,24 @@ export function Dialog(props: DialogProps) {
</DialogPrimitive.Root>
);
}
type DialogContentProps = React.ComponentProps<typeof DialogPrimitive["Content"]>;
type DialogContentProps = React.ComponentProps<typeof DialogPrimitive["Content"]> & {
size?: "xl" | "lg";
};
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ children, ...props }, forwardedRef) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fadeIn fixed inset-0 z-40 bg-black bg-opacity-50 transition-opacity" />
<DialogPrimitive.Overlay className="fadeIn fixed inset-0 z-40 bg-gray-500 bg-opacity-75 transition-opacity" />
{/*zIndex one less than Toast */}
<DialogPrimitive.Content
{...props}
className={classNames(
"fadeIn fixed left-1/2 top-1/2 z-[9999999999] min-w-[360px] -translate-x-1/2 -translate-y-1/2 rounded bg-white p-6 text-left shadow-xl focus-visible:outline-none sm:w-full sm:max-w-[35rem] sm:align-middle",
"fadeIn fixed left-1/2 top-1/2 z-[9998] min-w-[360px] -translate-x-1/2 -translate-y-1/2 rounded bg-white text-left shadow-xl focus-visible:outline-none sm:w-full sm:align-middle",
props.size == "xl"
? "p-0.5 sm:max-w-[98vw]"
: props.size == "lg"
? "p-6 sm:max-w-[70rem]"
: "p-6 sm:max-w-[35rem]",
`${props.className}`
)}
ref={forwardedRef}>

View File

@ -5,7 +5,7 @@ import React from "react";
const Switch = (
props: React.ComponentProps<typeof PrimitiveSwitch.Root> & {
label: string;
label?: string;
}
) => {
const { label, ...primitiveProps } = props;