Embed Improvements (#2365)

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Hariom Balhara 2022-04-08 11:03:24 +05:30 committed by GitHub
parent 96f6294542
commit c63d81719b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 776 additions and 218 deletions

View file

@ -1,8 +1,180 @@
---
title: Embed Snippet
title: Embed
---
# Embed Snippet
# Embed
The Embed Snippet allows your website visitors to book a meeting with you directly from your website. It works by you installing a small Javascript Snippet to your website.
[Mention possiblity of installation through tag managers as well]
The Embed allows your website visitors to book a meeting with you directly from your website.
## Install on any website
TODO: Mention possibility of installation through tag managers as well
- _Step-1._ Install the Vanilla JS Snippet
```javascript
(function (C, A, L) {
let p = function (a, ar) {
a.q.push(ar);
};
let d = C.document;
C.Cal =
C.Cal ||
function () {
let cal = C.Cal;
let ar = arguments;
if (!cal.loaded) {
cal.ns = {};
cal.q = cal.q || [];
d.head.appendChild(d.createElement("script")).src = A;
cal.loaded = true;
}
if (ar[0] === L) {
const api = function () {
p(api, arguments);
};
const namespace = ar[1];
api.q = api.q || [];
typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar);
return;
}
p(cal, ar);
};
})(window, "https://cal.com/embed.js", "init");
```
- _Step-2_. Initialize it
```javascript
Cal("init)
```
## Install with a Framework
### embed-react
It provides a react component `<Cal>` that can be used to show the embed inline at that place.
```bash
yarn add @calcom/embed-react
```
### Any XYZ Framework
You can use Vanilla JS Snippet to install
## Popular ways in which you can embed on your website
Assuming that you have followed the steps of installing and initializing the snippet, you can add show the embed in following ways:
### Inline
Show the embed inline inside a container element. It would take the width and height of the container element.
<details>
<summary>_Vanilla JS_</summary>
```javascript
Cal("inline", {
elementOrSelector: "Your Embed Container Selector Path", // You can also provide an element directly
calLink: "jane", // The link that you want to embed. It would open https://cal.com/jane in embed
config: {
name: "John Doe", // Prefill Name
email: "johndoe@gmail.com", // Prefill Email
notes: "Test Meeting", // Prefill Notes
guests: ["janedoe@gmail.com", "test@gmail.com"], // Prefill Guests
theme: "dark", // "dark" or "light" theme
},
});
```
</details>
####
<details>
<summary>_React_</summary>
```jsx
import Cal from "@calcom/embed-react";
const MyComponent = () => (
<Cal
calLink="pro"
config={{
name: "John Doe",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@gmail.com"],
theme: "dark",
}}
/>
);
```
</details>
### Popup on any existing element
To show the embed as a popup on clicking an element, add `data-cal-link` attribute to the element.
<details>
<summary>Vanilla JS</summary>
To show the embed as a popup on clicking an element, simply add `data-cal-link` attribute to the element.
<button data-cal-link="jane" data-cal-config="A valid config JSON"></button>
</details>
<details>
<summary>React</summary>
```jsx
import "@calcom/embed-react";
const MyComponent = ()=> {
return <button data-cal-link="jane" data-cal-config='A valid config JSON'></button>
}
````
</details>
### Full Screen
## Supported Instructions
Consider an instruction as a function with that name and that would be called with the given arguments.
### `inline`
Appends embed inline as the child of the element.
```javascript
Cal("inline", { elementOrSelector, calLink });
````
- `elementOrSelector` - Give it either a valid CSS selector or an HTMLElement instance directly
- `calLink` - Cal Link that you want to embed e.g. john. Just give the username. No need to give the full URL [https://cal.com/john](). It makes it easy to configure the calendar host once and use as many links you want with just usernames
### `ui`
Configure UI for embed. Make it look part of your webpage.
```javascript
Cal("inline", { styles });
```
- `styles` - It supports styling for `body` and `eventTypeListItem`. Right now we support just background on these two.
### preload
Usage:
If you want to open cal link on some action. Make it pop open instantly by preloading it.
```javascript
Cal("preload", { calLink });
```
- `calLink` - Cal Link that you want to embed e.g. john. Just give the username. No need to give the full URL [https://cal.com/john]()

View file

@ -1,5 +1,7 @@
import { useEffect } from "react";
import { useBrandColors } from "@calcom/embed-core";
const brandColor = "#292929";
const brandTextColor = "#ffffff";
const darkBrandColor = "#fafafa";
@ -220,6 +222,8 @@ const BrandColor = ({
lightVal: string | undefined | null;
darkVal: string | undefined | null;
}) => {
const embedBrandingColors = useBrandColors();
lightVal = embedBrandingColors.brandColor || lightVal;
// convert to 6 digit equivalent if 3 digit code is entered
lightVal = normalizeHexCode(lightVal, false);
darkVal = normalizeHexCode(darkVal, true);
@ -235,6 +239,34 @@ const BrandColor = ({
: "#" + darkVal
: fallBackHex(darkVal, true);
useEffect(() => {
document.documentElement.style.setProperty(
"--booking-highlight-color",
embedBrandingColors.highlightColor || "#10B981" // green--500
);
document.documentElement.style.setProperty(
"--booking-lightest-color",
embedBrandingColors.lightestColor || "#E1E1E1" // gray--200
);
document.documentElement.style.setProperty(
"--booking-lighter-color",
embedBrandingColors.lighterColor || "#ACACAC" // gray--400
);
document.documentElement.style.setProperty(
"--booking-light-color",
embedBrandingColors.lightColor || "#888888" // gray--500
);
document.documentElement.style.setProperty(
"--booking-median-color",
embedBrandingColors.medianColor || "#494949" // gray--600
);
document.documentElement.style.setProperty(
"--booking-dark-color",
embedBrandingColors.darkColor || "#313131" // gray--800
);
document.documentElement.style.setProperty(
"--booking-darker-color",
embedBrandingColors.darkerColor || "#292929" // gray--900
);
document.documentElement.style.setProperty("--brand-color", lightVal);
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(lightVal, true));
document.documentElement.style.setProperty("--brand-color-dark-mode", darkVal);

View file

@ -66,9 +66,9 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
return (
<div className="mt-8 flex flex-col text-center sm:mt-0 sm:w-1/3 sm:pl-4 md:-mb-5">
<div className="mb-4 text-left text-lg font-light text-gray-600">
<span className="w-1/2 text-gray-600 dark:text-white">
<span className="text-bookingdarker w-1/2 dark:text-white">
<strong>{nameOfDay(i18n.language, Number(date.format("d")))}</strong>
<span className="text-gray-500">
<span className="text-bookinglight">
{date.format(", D ")}
{date.toDate().toLocaleString(i18n.language, { month: "long" })}
</span>
@ -105,7 +105,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
<Link href={bookingUrl}>
<a
className={classNames(
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
"text-bookingdarker hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
)}
data-testid="time">

View file

@ -89,7 +89,6 @@ function DatePicker({
const [browsingDate, setBrowsingDate] = useState<Dayjs | null>(date);
const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton");
const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton");
const [month, setMonth] = useState<string>("");
const [year, setYear] = useState<string>("");
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(false);
@ -238,17 +237,17 @@ function DatePicker({
? "w-full sm:w-1/2 sm:border-r sm:pl-4 sm:pr-6 sm:dark:border-gray-700 md:w-1/3 "
: "w-full sm:pl-4")
}>
<div className="mb-4 flex text-xl font-light text-gray-600">
<span className="w-1/2 text-gray-600 dark:text-white">
<strong className="text-gray-900 dark:text-white">{month}</strong>{" "}
<span className="text-gray-500">{year}</span>
<div className="mb-4 flex text-xl font-light">
<span className="w-1/2 dark:text-white">
<strong className="text-bookingdarker dark:text-white">{month}</strong>{" "}
<span className="text-bookinglight">{year}</span>
</span>
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
<div className="w-1/2 text-right dark:text-gray-400">
<button
onClick={decrementMonth}
className={classNames(
"group p-1 ltr:mr-2 rtl:ml-2",
isFirstMonth && "text-gray-400 dark:text-gray-600"
isFirstMonth && "text-bookinglighter dark:text-gray-600"
)}
disabled={isFirstMonth}
data-testid="decrementMonth">
@ -259,9 +258,9 @@ function DatePicker({
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-4 border-t border-b text-center dark:border-gray-800 sm:border-0">
<div className="border-bookinglightest grid grid-cols-7 gap-4 border-t border-b text-center dark:border-gray-800 sm:border-0">
{weekdayNames(i18n.language, weekStart === "Sunday" ? 0 : 1, "short").map((weekDay) => (
<div key={weekDay} className="my-4 text-xs uppercase tracking-widest text-gray-500">
<div key={weekDay} className="text-bookinglight my-4 text-xs uppercase tracking-widest">
{weekDay}
</div>
))}
@ -286,7 +285,9 @@ function DatePicker({
className={classNames(
"absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm text-center",
"hover:border-brand hover:border dark:hover:border-white",
day.disabled ? "cursor-default font-light text-gray-400 hover:border-0" : "font-medium",
day.disabled
? "text-bookinglighter cursor-default font-light hover:border-0"
: "font-medium",
date && date.isSame(browsingDate.date(day.date), "day")
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
: !day.disabled

View file

@ -16,6 +16,9 @@ import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { useEmbedStyles, useIsEmbed, useIsBackgroundTransparent } from "@calcom/embed-core";
import classNames from "@calcom/lib/classNames";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { BASE_URL } from "@lib/config/constants";
@ -44,10 +47,13 @@ type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage }: Props) => {
const router = useRouter();
const isEmbed = useIsEmbed();
const { rescheduleUid } = router.query;
const { isReady, Theme } = useTheme(profile.theme);
const { t } = useLocale();
const { contracts } = useContracts();
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
let isBackgroundTransparent = useIsBackgroundTransparent();
useExposePlanGlobally(plan);
useEffect(() => {
if (eventType.metadata.smartContractAddress) {
@ -129,11 +135,19 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
<div>
<main
className={
"transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
(selectedDate ? "max-w-5xl" : "max-w-3xl")
isEmbed
? ""
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
(selectedDate ? "max-w-5xl" : "max-w-3xl")
}>
{isReady && (
<div className="rounded-sm border-gray-200 bg-white dark:bg-gray-800 sm:dark:border-gray-600 md:border">
<div
style={availabilityDatePickerEmbedStyles}
className={classNames(
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
"border-bookinglightest rounded-sm md:border",
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl"
)}>
{/* mobile: details */}
<div className="block p-4 sm:p-8 md:hidden">
<div className="block items-center sm:flex sm:space-x-4">
@ -156,7 +170,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
/>
<div className="mt-4 sm:-mt-2">
<p className="text-sm font-medium text-black dark:text-white">{profile.name}</p>
<div className="flex gap-2 text-xs font-medium text-gray-600 dark:text-gray-100">
<div className="text-bookingmedian flex gap-2 text-xs font-medium dark:text-gray-100">
{eventType.title}
<div>
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
@ -203,16 +217,16 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
size={10}
truncateAfter={3}
/>
<h2 className="mt-3 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
<h1 className="font-cal mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
<h2 className="dark:text-bookinglight mt-3 font-medium text-gray-500">{profile.name}</h2>
<h1 className="font-cal text-bookingdark mb-4 text-3xl font-semibold dark:text-white">
{eventType.title}
</h1>
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{eventType.length} {t("minutes")}
</p>
{eventType.price > 0 && (
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
@ -273,7 +287,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
</div>
</div>
)}
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && <PoweredByCal />}
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
</main>
</div>
</>
@ -282,7 +296,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
function TimezoneDropdown() {
return (
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Collapsible.Trigger className="min-w-32 mb-1 -ml-2 px-2 py-1 text-left text-gray-500">
<Collapsible.Trigger className="min-w-32 text-bookinglight mb-1 -ml-2 px-2 py-1 text-left">
<GlobeIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{timeZone()}
{isTimeOptionsOpen ? (

View file

@ -12,6 +12,8 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query";
import { useIsEmbed, useEmbedStyles, useIsBackgroundTransparent } from "@calcom/embed-core";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { createPaymentLink } from "@calcom/stripe/client";
@ -63,9 +65,12 @@ const BookingPage = ({
locationLabels,
}: BookingPageProps) => {
const { t, i18n } = useLocale();
const isEmbed = useIsEmbed();
const router = useRouter();
const { contracts } = useContracts();
const { data: session } = useSession();
const isBackgroundTransparent = useIsBackgroundTransparent();
useEffect(() => {
if (eventType.metadata.smartContractAddress) {
const eventOwner = eventType.users[0];
@ -283,9 +288,18 @@ const BookingPage = ({
<link rel="icon" href="/favicon.ico" />
</Head>
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
<main className="mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600">
<main
className={
isEmbed ? "mx-auto" : "mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600"
}>
{isReady && (
<div className="overflow-hidden border border-gray-200 bg-white dark:border-0 dark:bg-gray-800 sm:rounded-sm">
<div
className={classNames(
"overflow-hidden",
isEmbed ? "" : "border border-gray-200",
isBackgroundTransparent ? "" : "bg-white dark:border-0 dark:bg-gray-800",
"sm:rounded-sm"
)}>
<div className="px-4 py-5 sm:flex sm:p-4">
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-700">
<AvatarGroup
@ -300,16 +314,18 @@ const BookingPage = ({
}))
)}
/>
<h2 className="font-cal mt-2 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
<h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
<h2 className="font-cal text-bookinglight mt-2 font-medium dark:text-gray-300">
{profile.name}
</h2>
<h1 className="text-bookingdark mb-4 text-3xl font-semibold dark:text-white">
{eventType.title}
</h1>
<p className="mb-2 text-gray-500">
<p className="text-bookinglight mb-2">
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{eventType.length} {t("minutes")}
</p>
{eventType.price > 0 && (
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
@ -320,12 +336,12 @@ const BookingPage = ({
</IntlProvider>
</p>
)}
<p className="mb-4 text-green-500">
<p className="text-bookinghighlight mb-4">
<CalendarIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{parseDate(date)}
</p>
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
</p>
)}

View file

@ -1,13 +1,16 @@
import Link from "next/link";
import { useIsEmbed } from "@calcom/embed-core";
import { useLocale } from "@lib/hooks/useLocale";
const PoweredByCal = () => {
const { t } = useLocale();
const isEmbed = useIsEmbed();
return (
<div className="p-1 text-center text-xs sm:text-right">
<div className={"p-1 text-center text-xs sm:text-right" + (isEmbed ? " max-w-3xl" : "")}>
<Link href={`https://cal.com?utm_source=embed&utm_medium=powered-by-button`}>
<a target="_blank" className="text-gray-500 opacity-50 hover:opacity-100 dark:text-white">
<a target="_blank" className="text-bookinglight opacity-50 hover:opacity-100 dark:text-white">
{t("powered_by")}{" "}
<img
className="relative -mt-px inline h-[10px] w-auto dark:hidden"

View file

@ -60,7 +60,7 @@ export default function TeamAvailabilityTimes(props: Props) {
{times.map((time) => (
<div key={time.format()} className="flex flex-row items-center">
<a
className="min-w-48 border-brand text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 mr-3 block flex-grow rounded-sm border bg-white py-2 text-center font-medium dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black dark:hover:bg-black dark:hover:text-white"
className="min-w-48 border-brand text-bookingdarker hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 mr-3 block flex-grow rounded-sm border bg-white py-2 text-center font-medium dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black dark:hover:bg-black dark:hover:text-white"
data-testid="time">
{time.format("HH:mm")}
</a>

View file

@ -2,6 +2,9 @@ import { useEffect } from "react";
import { UserPlan } from "@calcom/prisma/client";
/**
* TODO: It should be exposed at a single place.
*/
export function useExposePlanGlobally(plan: UserPlan) {
// Don't wait for component to mount. Do it ASAP. Delaying it would delay UI Configuration.
if (typeof window !== "undefined") {

View file

@ -2,6 +2,8 @@ import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useEmbedTheme } from "@calcom/embed-core";
import { Maybe } from "@trpc/server";
// This method is stringified and executed only on client. So,
@ -28,10 +30,9 @@ function applyThemeAndAddListener(theme: string) {
// makes sure the ui doesn't flash
export default function useTheme(theme?: Maybe<string>) {
const [isReady, setIsReady] = useState(false);
const router = useRouter();
const embedTheme = useEmbedTheme();
// Embed UI configuration takes more precedence over App Configuration
theme = (router.query.theme as string | null) || theme;
theme = embedTheme || theme;
useEffect(() => {
// TODO: isReady doesn't seem required now. This is also impacting PSI Score for pages which are using isReady.

View file

@ -9,7 +9,7 @@ import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import { JSONObject } from "superjson/dist/types";
import { sdkActionManager, useEmbedStyles } from "@calcom/embed-core";
import { sdkActionManager, useEmbedStyles, useIsEmbed } from "@calcom/embed-core";
import defaultEvents, {
getDynamicEventDescription,
getUsernameList,
@ -107,6 +107,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
useExposePlanGlobally("PRO");
const nameOrUsername = user.name || user.username || "";
const [evtsToVerify, setEvtsToVerify] = useState<EvtsToVerify>({});
const isEmbed = useIsEmbed();
return (
<>
<Theme />
@ -119,7 +120,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
username={isDynamicGroup ? dynamicUsernames.join(", ") : (user.username as string) || ""}
// avatar={user.avatar || undefined}
/>
<div className="h-screen dark:bg-neutral-900">
<div className={"h-screen dark:bg-neutral-900" + isEmbed ? " bg:white m-auto max-w-3xl" : ""}>
<main className="mx-auto max-w-3xl px-4 py-24">
{isSingleUser && ( // When we deal with a single user, not dynamic group
<div className="mb-8 text-center">

View file

@ -1,5 +1,6 @@
import { DefaultSeo } from "next-seo";
import Head from "next/head";
import { useEffect } from "react";
// import { ReactQueryDevtools } from "react-query/devtools";
import superjson from "superjson";
@ -22,13 +23,20 @@ import "../styles/fonts.css";
import "../styles/globals.css";
function MyApp(props: AppProps) {
const { Component, pageProps, err } = props;
const { Component, pageProps, err, router } = props;
let pageStatus = "200";
if (router.pathname === "/404") {
pageStatus = "404";
} else if (router.pathname === "/500") {
pageStatus = "500";
}
return (
<ContractsProvider>
<AppProviders {...props}>
<DefaultSeo {...seoConfig.defaultNextSeo} />
<I18nLanguageHandler />
<Head>
<script dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}></script>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
</Head>
<Component {...pageProps} err={err} />

View file

@ -1,5 +1,6 @@
import { CheckIcon } from "@heroicons/react/outline";
import { ClockIcon, XIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
@ -10,6 +11,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState, useRef } from "react";
import { useIsEmbed, useEmbedStyles, useIsBackgroundTransparent } from "@calcom/embed-core";
import { sdkActionManager } from "@calcom/embed-core";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -136,6 +138,8 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
const { isReady, Theme } = useTheme(props.profile.theme);
const { eventType } = props;
const isBackgroundTransparent = useIsBackgroundTransparent();
const isEmbed = useIsEmbed();
const attendeeName = typeof name === "string" ? name : "Nameless";
const eventNameObject = {
@ -199,25 +203,34 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
return (
(isReady && (
<div className="h-screen bg-neutral-100 dark:bg-neutral-900" data-testid="success-page">
<div
className={isEmbed ? "" : "h-screen bg-neutral-100 dark:bg-neutral-900"}
data-testid="success-page">
<Theme />
<HeadSeo
title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
description={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
/>
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
<main className="mx-auto max-w-3xl py-24">
<div className="fixed inset-0 z-50 overflow-y-auto">
<main className={classNames("mx-auto", isEmbed ? "" : "max-w-3xl py-24")}>
<div className={classNames("overflow-y-auto", isEmbed ? "" : "fixed inset-0 z-50 ")}>
{isSuccessRedirectAvailable(eventType) && eventType.successRedirectUrl ? (
<RedirectionToast url={eventType.successRedirectUrl}></RedirectionToast>
) : null}{" "}
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<div
className={classNames("my-4 transition-opacity sm:my-0", isEmbed ? "" : "fixed inset-0")}
aria-hidden="true">
<span className="inline-block h-screen align-middle" aria-hidden="true">
&#8203;
</span>
<div
className="my-8 inline-block transform overflow-hidden rounded-sm border border-neutral-200 bg-white px-8 pt-5 pb-4 text-left align-middle transition-all dark:border-neutral-700 dark:bg-gray-800 sm:w-full sm:max-w-lg sm:py-6"
className={classNames(
"inline-block transform overflow-hidden rounded-sm",
isEmbed ? "" : "border sm:my-8 sm:max-w-lg ",
isBackgroundTransparent ? "" : "bg-white dark:border-neutral-700 dark:bg-gray-800",
"px-8 pt-5 pb-4 text-left align-bottom transition-all sm:w-full sm:py-6 sm:align-middle"
)}
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
@ -241,7 +254,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
: t("emailed_you_and_attendees")}
</p>
</div>
<div className="mt-4 grid grid-cols-3 border-t border-b py-4 text-left text-gray-700 dark:border-gray-900 dark:text-gray-300">
<div className="border-bookinglightest text-bookingdark mt-4 grid grid-cols-3 border-t border-b py-4 text-left dark:border-gray-900 dark:text-gray-300">
<div className="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6">{eventName}</div>
<div className="font-medium">{t("when")}</div>
@ -249,7 +262,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
{date.format("dddd, DD MMMM YYYY")}
<br />
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
<span className="text-gray-500">
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</div>
@ -271,7 +284,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
</div>
</div>
{!needsConfirmation && (
<div className="mt-5 flex border-b pt-2 pb-4 text-center dark:border-gray-900 sm:mt-0 sm:pt-4">
<div className="border-bookinglightest mt-5 flex border-b pt-2 pb-4 text-center dark:border-gray-900 sm:mt-0 sm:pt-4">
<span className="flex self-center font-medium text-gray-700 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
{t("add_to_calendar")}
</span>
@ -370,7 +383,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
</div>
)}
{!props.hideBranding && (
<div className="pt-4 text-center text-xs text-gray-400 dark:border-gray-900 dark:text-white">
<div className="border-bookinglightest text-booking-lighter pt-4 text-center text-xs dark:border-gray-900 dark:text-white">
<a href="https://cal.com/signup">{t("create_booking_link_with_calcom")}</a>
<form
@ -383,7 +396,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
name="email"
id="email"
defaultValue={router.query.email}
className="focus:border-brand mt-0 block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
className="focus:border-brand border-bookinglightest mt-0 block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
placeholder="rick.astley@cal.com"
/>
<Button size="lg" type="submit" className="min-w-max" color="primary">

View file

@ -1,9 +1,11 @@
import { ArrowRightIcon } from "@heroicons/react/solid";
import { UserPlan } from "@prisma/client";
import classNames from "classnames";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import React from "react";
import { useIsEmbed } from "@calcom/embed-core";
import Button from "@calcom/ui/Button";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
@ -29,12 +31,16 @@ function TeamPage({ team }: TeamPageProps) {
const showMembers = useToggleQuery("members");
const { t } = useLocale();
useExposePlanGlobally("PRO");
const isEmbed = useIsEmbed();
const eventTypes = (
<ul className="space-y-3">
{team.eventTypes.map((type) => (
<li
key={type.id}
className="hover:border-brand group relative rounded-sm border border-neutral-200 bg-white hover:bg-gray-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600">
className={classNames(
"hover:border-brand group relative rounded-sm border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600",
isEmbed ? "" : "bg-white hover:bg-gray-50"
)}>
<ArrowRightIcon className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
<Link href={`${team.slug}/${type.slug}`}>
<a className="flex justify-between px-6 py-4">

View file

@ -18,6 +18,13 @@ module.exports = {
brandcontrast: "var(--brand-text-color)",
darkmodebrand: "var(--brand-color-dark-mode)",
darkmodebrandcontrast: "var(--brand-text-color-dark-mode)",
bookinglightest: "var(--booking-lightest-color)",
bookinglighter: "var(--booking-lighter-color)",
bookinglight: "var(--booking-light-color)",
bookingmedian: "var(--booking-median-color)",
bookingdark: "var(--booking-dark-color)",
bookingdarker: "var(--booking-darker-color)",
bookinghighlight: "var(--booking-highlight-color)",
black: "#111111",
gray: {
50: "#F8F8F8",

View file

@ -1,83 +1,9 @@
# embed-core
See [index.html](index.html) to understand how it can be used.
## Features
- The Embed SDK can be added asynchronously
- You can add it through any tag manager like GTM if you like[Need to Test]
- Available configurations are
- `theme`
- Prefilling of
- `name`
- `email`
- `notes`
- `guests`
See [index.html](index.html) to see various examples
## How to use embed on any webpage no matter what framework
- _Step-1._ Install the snippet
```javascript
(function(C, A, L) {
let p = function(a, ar) {
a.q.push(ar);
};
let d = C.document;
C.Cal = C.Cal || function() {
let cal = C.Cal;
let ar = arguments;
if (!cal.loaded) {
cal.ns = {};
cal.q = cal.q || [];
d.head.appendChild(d.createElement("script")).src = A;
cal.loaded = true;
}
if (ar[0] === L) {
const api = function() {
p(api, arguments);
};
const namespace = ar[1];
api.q = api.q || [];
typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar);
return;
}
p(cal, ar);
};
})(window, "https://cal.com/embed.js", "init");
```
- _Step-2_. Give `init` instruction to it. It creates a queue so that even without embed.js being fetched, you can give instructions to embed.
```javascript
Cal("init) // Creates default instance. Give instruction to it as Cal("instruction")
```
**Optionally** if you want to install another instance of embed you can do
```javascript
Cal("init", "NAME_YOUR_OTHER_INSTANCE"); // Creates a named instance. Give instructions to it as Cal.ns.NAME_YOUR_OTHER_INSTANCE("instruction")
```
- Step-1 and Step-2 must be followed in same order. After that you can give various instructions to embed as you like.
## Supported Instructions
Consider an instruction as a function with that name and that would be called with the given argument.
`inline` - Appends embed inline as the child of the element.
- `elementOrSelector` - Give it either a valid CSS selector or an HTMLElement instance directly
- `calLink` - Cal Link that you want to embed e.g. john. Just give the username. No need to give the full URL <https://cal.com/john>. It makes it easy to configure the calendar host once and use as many links you want with just usernames
`ui` - Configure UI for embed. Make it look part of your webpage.
- `styles` - It supports styling for `body` and `eventTypeListItem`. Right now we support just background on these two.
`preload` - If you want to open cal link on some action. Make it pop open instantly by preloading it.
- `calLink` - Cal Link that you want to embed e.g. john. Just give the username. No need to give the full URL <https://cal.com/john>
See <https://docs.cal.com/integrations/embed>
## Development
@ -87,6 +13,14 @@ Run the following command and then you can test the embed in the automatically o
yarn dev
```
## Running Tests
Ensure that App is running on port 3000 already and then run following command:
```bash
yarn test-playwright
```
## Shipping to Production
```bash
@ -95,16 +29,23 @@ yarn build
Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
## Upcoming Improvements
## DX
- Hot reload doesn't work with CSS files in the way we use vite.
## Known Bugs and Upcoming Improvements
- Unsupported Browsers and versions. Documenting them and gracefully handling that.
- Accessibility and UI/UX Issues
- Loader on ModalBox/popup
- let user choose the loader for ModalBox
- If website owner links the booking page directly for an event, should the user be able to go to events-listing page using back button ?
- Automation Tests
- Run automation tests in CI
- Bundling Related
- Minify CSS in embed.js
- 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.
@ -112,32 +53,49 @@ Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
- Error Tracking for embed.js
- Know where exactly its failing if it does.
- Improved Demo
- Seeding might be done for team event so that such an example is also available readily in index.html
- Color Scheme
- Need to reduce the number of colors on booking page, so that UI configuration is simpler
- Dev Experience/Ease of Installation
- Improved Demo
- Seeding might be done for team event so that such an example is also available readily in index.html
- Do we need a one liner(like `window.dataLayer.push`) to inform SDK of something even if snippet is not yet on the page but would be there e.g. through GTM it would come late on the page ?
- Might be better to pass all configuration using a single base64encoded query param to booking page.
- Embed Code Generator
- UI Config Features
- Theme switch dynamically - If user switches the theme on website, he should be able to do it on embed.
- Text Color
- Brand color
- At some places Text is colored by using the color specific tailwind class. e.g. `text-gray-400` is the color of disabled date. He has 2 options, If user wants to customize that
- He can go and override the color on the class which doesnt make sense
- He can identify the element and change the color by directly adding style, which might cause consistency issues if certain elements are missed.
- Challenges
- How would the user add on hover styles just using style attribute ?
- 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 ?
- If just iframe refreshes due to some reason, embed script can't replay the applied instructions.
- React Component
- `onClick` support with preloading
Embed for authenticated pages
- Currently embed is properly supported for non authenticated pages like cal.com/john. It is supported for team links as well.
- For such pages, you can customize the colors of all the texts and give a common background to all pages under your cal link
- If we can support other pages, which are behind login, it can open possibilities for users to show "upcoming bookings", "availability" and other functionalities on their website itself.
- First of all we need more usecases for this.
- Think of it in this way. Cal.com is build with many different UI components that are put together to work seamlessly, what if the user can choose which component they need and which they don't
- The main problem with this is that, there are so many pages in the app. We would need to ensure that all the pages use the same text colors only that are available as embed UI configuration.
- We would need to hide certain UI components when opening a page. e.g. the navigation component wouldn't be there.
- User might want to change the text also for components, e.g. he might call "Event Type" as "Meeting Type" everywhere. common.json would be useful in this scenario.
- Login form shouldn't be visible in embed as auth would be taken care of separately. If due to cookies being expired, the component can't be shown then whatever auth flow is configured, can be triggered
- In most scenarios, user would have a website on which the visitors would be signing in already into their system(and thus they own the user table) and he would want to just link those users to cal.com - This would be allowed only with self hosted instance ?
- So, cal.com won't maintain the user details itself and would simply store a user id which it would provide to hosting website to retrieve user information whenever it needs it.
## 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 more looks like it is part of the website.
- docs.cal.com
- A complete document on how to use embed

View file

@ -38,6 +38,14 @@
</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%
);
}
.debug {
/* border: 1px solid black; */
margin-bottom: 5px;
@ -52,7 +60,11 @@
<h3>This page has a non responsive version accessible <a href="?nonResponsive">here</a></h3>
<h3>Pre-render test page available at <a href="?only=prerender-test">here</a></h3>
<div>
<button data-cal-link="free">Book with Free User</button>
<button data-cal-namespace="prerendertestLightTheme" data-cal-link="free?light&popup">Book with Free User[Light Theme]</button>
<button data-cal-namespace="popupDarkTheme" data-cal-config='{"theme":"dark"}' data-cal-link="free?dark&popup">Book with Free User[Dark Theme]</button>
<button data-cal-namespace="popupTeamLinkLightTheme" data-cal-config='{"theme":"light"}' data-cal-link="team/test-team?team&light&popup">Book with Test Team[Light Theme]</button>
<button data-cal-namespace="popupTeamLinkDarkTheme" data-cal-config='{"theme":"dark"}' data-cal-link="team/test-team?team&dark&popup">Book with Test Team[Dark Theme]</button>
<div>
<i
>Corresponding Cal Link is being preloaded. Assuming that it would take you some time to click this
@ -101,7 +113,7 @@
</div>
<div class="debug" id="cal-booking-place-third">
<h2>Namespace "third"(Cal.ns.third)[inline]</h2>
<h2>Namespace "third"(Cal.ns.third)[inline][Custom Styling - Transparent Background]</h2>
<div>
<i><a href="?only=ns:third">Test in Zen Mode</a></i>
</div>
@ -136,13 +148,13 @@
if (detail.type === "linkReady") {
document.querySelector(`#cal-booking-place-${namespace} .loader`).remove();
} else if (detail.type === "linkFailed") {
document.querySelector(`#cal-booking-place-${namespace} .loader`).remove();
}
document.querySelector(`#cal-booking-place-${namespace} .last-action`).innerHTML = JSON.stringify(
e.detail
);
};
const searchParams = new URL(document.URL).searchParams;
const only = searchParams.get("only");
if (!only || only === "ns:default") {
@ -215,6 +227,7 @@
debug: 1,
origin: "http://localhost:3000",
});
Cal.ns.third(
[
"inline",
@ -228,7 +241,15 @@
{
styles: {
body: {
background: "white",
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
},
},
@ -258,7 +279,15 @@
{
styles: {
body: {
background: "white",
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
},
},
@ -271,10 +300,26 @@
}
if (!only || only === "prerender-test") {
Cal("preload", {
Cal('init', 'prerendertestLightTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal.ns.prerendertestLightTheme("preload", {
calLink: "free",
});
}
Cal('init', 'popupDarkTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupTeamLinkLightTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupTeamLinkDarkTheme', {
debug: 1,
origin: "http://localhost:3000",
})
</script>
<script></script>
</body>

View file

@ -38,14 +38,16 @@ const config: PlaywrightTestConfig = {
testDir,
use: { ...devices["Desktop Chrome"] },
},
/* {
{
name: "firefox",
testDir,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testDir,
use: { ...devices["Desktop Safari"] },
}, */
},
],
};
export type ExpectedUrlDetails = {

View file

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

View file

@ -8,7 +8,7 @@ test("should open embed iframe on click", async ({ page }) => {
let embedIframe = await getEmbedIframe({ page, pathname: "/free" });
expect(embedIframe).toBeFalsy();
await page.click('[data-cal-link="free"]');
await page.click('[data-cal-link="free?light&popup"]');
embedIframe = await getEmbedIframe({ page, pathname: "/free" });
expect(embedIframe).toBeEmbedCalLink({

View file

@ -1,11 +1,11 @@
import { expect, Frame } from "@playwright/test";
import { test } from "../fixtures/fixtures";
import { todo } from "../lib/testUtils";
import { todo, getEmbedIframe } from "../lib/testUtils";
test("Inline Iframe - Configured with Dark Theme", async ({ page }) => {
await page.goto("/?only=ns:default");
const embedIframe = page.frame({ url: /.*pro.*/ });
const embedIframe = await getEmbedIframe({ page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink({
pathname: "/pro",
searchParams: {

View file

@ -1,8 +1,30 @@
export class ModalBox extends HTMLElement {
static htmlOverflow: string;
//@ts-ignore
static get observedAttributes() {
return ["loading"];
}
close() {
this.shadowRoot!.host.remove();
document.body.style.overflow = ModalBox.htmlOverflow;
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (name === "loading" && newValue == "done") {
(this.shadowRoot!.querySelector("#loader")! as HTMLElement).style.display = "none";
}
}
connectedCallback() {
const closeEl = this.shadowRoot!.querySelector(".close") as HTMLElement;
document.addEventListener("click", (e) => {
const el = e.target as HTMLElement;
this.close();
});
closeEl.onclick = () => {
this.shadowRoot!.host.remove();
this.close();
};
}
@ -11,6 +33,36 @@ export class ModalBox extends HTMLElement {
//FIXME: this styling goes as is as it's a JS string. That's a lot of unnecessary whitespaces over the wire.
const modalHtml = `
<style>
.bg-gray-50 {
--tw-bg-opacity: 1;
background-color: rgb(248 248 248 / var(--tw-bg-opacity));
}
.items-center {
align-items: center;
}
.w-full {
width: 100%;
}
.h-screen {
height: 100%;
}
.flex {
display: flex;
}
.z-highest {
z-index: 500000000;
}
.absolute {
position: absolute;
}
.border-brand {
border-color: white;
}
.bg-brand {
background-color: white;
}
.backdrop {
position:fixed;
width:100%;
@ -24,11 +76,11 @@ export class ModalBox extends HTMLElement {
@media only screen and (min-width:600px) {
.modal-box {
margin:0 auto;
border-radius: 8px;
margin-top:20px;
margin-bottom:20px;
position:absolute;
width:50%;
height: 80%;
top:50%;
left:50%;
transform: translateY(-50%) translateX(-50%);
@ -60,6 +112,69 @@ export class ModalBox extends HTMLElement {
color:white;
cursor: pointer;
}
@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;
margin: 60px auto;
position: relative;
border-width: 4px;
border-style: solid;
-webkit-animation: loader 2s infinite ease;
animation: loader 2s infinite ease;
}
</style>
<div class="backdrop">
<div class="header">
@ -67,12 +182,19 @@ export class ModalBox extends HTMLElement {
</div>
<div class="modal-box">
<div class="body">
<div id="loader" class="absolute z-highest flex h-screen w-full items-center">
<div class="loader border-brand dark:border-darkmodebrand">
<span class="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
</div>
<slot></slot>
</div>
</div>
</div>
`;
this.attachShadow({ mode: "open" });
ModalBox.htmlOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
this.shadowRoot!.innerHTML = modalHtml;
}
}

View file

@ -1,10 +1,35 @@
import { useRouter } from "next/router";
import { useState, useEffect, CSSProperties } from "react";
import { sdkActionManager } from "./sdk-event";
let isSafariBrowser = false;
export interface UiConfig {
theme: string;
styles: EmbedStyles;
}
if (typeof window !== "undefined") {
const embedStore = {
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {},
namespace: null,
theme: null,
// Store all React State setters here.
reactStylesStateSetters: {},
parentInformedAboutContentHeight: false,
windowLoadEventFired: false,
} as {
styles: UiConfig["styles"];
namespace: string | null;
theme: string | null;
reactStylesStateSetters: any;
parentInformedAboutContentHeight: boolean;
windowLoadEventFired: boolean;
};
let isSafariBrowser = false;
const isBrowser = typeof window !== "undefined";
if (isBrowser) {
const ua = navigator.userAgent.toLowerCase();
isSafariBrowser = ua.includes("safari") && !ua.includes("chrome");
if (isSafariBrowser) {
@ -12,32 +37,35 @@ if (typeof window !== "undefined") {
}
}
function keepRunningAsap(fn: (...arg: any) => void) {
function runAsap(fn: (...arg: any) => void) {
if (isSafariBrowser) {
// https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/
return setTimeout(fn, 50);
}
return requestAnimationFrame(fn);
}
declare global {
interface Window {
CalEmbed: {
__logQueue?: any[];
};
CalComPageStatus: string;
CalComPlan: string;
}
}
function log(...args: any[]) {
let namespace;
if (typeof window !== "undefined") {
if (isBrowser) {
const namespace = getNamespace();
const searchParams = new URL(document.URL).searchParams;
namespace = typeof searchParams.get("embed") !== "undefined" ? "" : "_unknown_";
//TODO: Send postMessage to parent to get all log messages in the same queue.
window.CalEmbed = window.CalEmbed || {};
const logQueue = (window.CalEmbed.__logQueue = window.CalEmbed.__logQueue || []);
args.push({
ns: namespace,
url: document.URL,
});
args.unshift("CAL:");
logQueue.push(args);
@ -50,32 +78,31 @@ function log(...args: any[]) {
// Only allow certain styles to be modified so that when we make any changes to HTML, we know what all embed styles might be impacted.
// Keep this list to minimum, only adding those styles which are really needed.
interface EmbedStyles {
body?: Pick<CSSProperties, "background" | "backgroundColor">;
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">;
}
interface EmbedStylesBranding {
branding?: {
brandColor?: string;
lightColor?: string;
lighterColor?: string;
lightestColor?: string;
highlightColor?: string;
darkColor?: string;
darkerColor?: string;
medianColor?: string;
};
}
type ElementName = keyof EmbedStyles;
type ReactEmbedStylesSetter = React.Dispatch<React.SetStateAction<EmbedStyles>>;
export interface UiConfig {
theme: string;
styles: EmbedStyles;
}
const embedStore = {
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {},
// Store all React State setters here.
reactStylesStateSetters: {} as Record<ElementName, ReactEmbedStylesSetter>,
};
type ReactEmbedStylesSetter = React.Dispatch<React.SetStateAction<EmbedStyles | EmbedStylesBranding>>;
const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => {
embedStore.styles = stylesConfig;
for (let [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
setEmbedStyle((styles) => {
(setEmbedStyle as any)((styles: any) => {
return {
...styles,
...stylesConfig,
@ -84,19 +111,32 @@ const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => {
}
};
const registerNewSetter = (elementName: ElementName, setStyles: ReactEmbedStylesSetter) => {
const registerNewSetter = (elementName: keyof EmbedStyles | keyof EmbedStylesBranding, setStyles: any) => {
embedStore.reactStylesStateSetters[elementName] = setStyles;
// 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
setStyles(embedStore.styles);
};
const removeFromEmbedStylesSetterMap = (elementName: ElementName) => {
const removeFromEmbedStylesSetterMap = (elementName: keyof EmbedStyles | keyof EmbedStylesBranding) => {
delete embedStore.reactStylesStateSetters[elementName];
};
function isValidNamespace(ns: string | null | undefined) {
return typeof ns !== "undefined" && ns !== null;
}
export const useEmbedTheme = () => {
const router = useRouter();
if (embedStore.theme) {
return embedStore.theme;
}
const theme = (embedStore.theme = router.query.theme as string);
return 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
export const useEmbedStyles = (elementName: ElementName) => {
export const useEmbedStyles = (elementName: keyof EmbedStyles) => {
const [styles, setStyles] = useState({} as EmbedStyles);
useEffect(() => {
@ -111,9 +151,71 @@ export const useEmbedStyles = (elementName: ElementName) => {
return styles[elementName] || {};
};
export const useEmbedBranding = (elementName: keyof EmbedStylesBranding) => {
const [styles, setStyles] = useState({} as EmbedStylesBranding);
useEffect(() => {
registerNewSetter(elementName, setStyles);
// It's important to have an element's embed style be required in only one component. If due to any reason it is required in multiple components, we would override state setter.
return () => {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap(elementName);
};
}, []);
return styles[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 = useEmbedBranding("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 url = new URL(document.URL);
const namespace = url.searchParams.get("embed");
embedStore.namespace = namespace;
return namespace;
}
}
const isEmbed = () => {
const namespace = getNamespace();
return isValidNamespace(namespace);
};
export const useIsEmbed = () => {
// We can't simply return isEmbed() from this method.
// isEmbed() returns different values on server and browser, which messes up the hydration.
// TODO: We can avoid using document.URL and instead use Router.
const [_isEmbed, setIsEmbed] = useState(false);
useEffect(() => {
setIsEmbed(isEmbed());
}, []);
return _isEmbed;
};
function unhideBody() {
document.body.style.display = "block";
}
// If you add a method here, give type safety to parent manually by adding it to embed.ts. Look for "parentKnowsIframeReady" in it
export const methods = {
ui: function style(uiConfig: UiConfig) {
@ -142,8 +244,16 @@ export const methods = {
},
parentKnowsIframeReady: () => {
log("Method: `parentKnowsIframeReady` called");
unhideBody();
sdkActionManager?.fire("linkReady", {});
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.
requestAnimationFrame(unhideBody);
sdkActionManager?.fire("linkReady", {});
});
},
};
@ -158,16 +268,14 @@ const messageParent = (data: any) => {
};
function keepParentInformedAboutDimensionChanges() {
console.log("keepParentInformedAboutDimensionChanges executed");
let knownIframeHeight: Number | null = null;
let numDimensionChanges = 0;
let isFirstTime = true;
let isWindowLoadComplete = false;
keepRunningAsap(function informAboutScroll() {
runAsap(function informAboutScroll() {
if (document.readyState !== "complete") {
// Wait for window to load to correctly calculate the initial scroll height.
keepRunningAsap(informAboutScroll);
runAsap(informAboutScroll);
return;
}
if (!isWindowLoadComplete) {
@ -179,13 +287,22 @@ function keepParentInformedAboutDimensionChanges() {
}, 10);
return;
}
if (!embedStore.windowLoadEventFired) {
sdkActionManager?.fire("windowLoadComplete", {});
}
embedStore.windowLoadEventFired = true;
const documentScrollHeight = document.documentElement.scrollHeight;
const documentScrollWidth = document.documentElement.scrollWidth;
const contentHeight = document.documentElement.offsetHeight;
const contentWidth = document.documentElement.offsetWidth;
// During first render let iframe tell parent that how much is the expected height to avoid scroll.
// Parent would set the same value as the height of iframe which would prevent scroll.
// On subsequent renders, consider html height as the height of the iframe. If we don't do this, then if iframe get's bigger in height, it would never shrink
let iframeHeight = isFirstTime ? documentScrollHeight : contentHeight;
isFirstTime = false;
let iframeWidth = isFirstTime ? documentScrollWidth : contentWidth;
embedStore.parentInformedAboutContentHeight = true;
// TODO: Handle width as well.
if (knownIframeHeight !== iframeHeight) {
knownIframeHeight = iframeHeight;
@ -193,8 +310,11 @@ function keepParentInformedAboutDimensionChanges() {
// FIXME: This event shouldn't be subscribable by the user. Only by the SDK.
sdkActionManager?.fire("dimension-changed", {
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.
@ -202,15 +322,16 @@ function keepParentInformedAboutDimensionChanges() {
console.warn("Too many dimension changes detected.");
return;
}
keepRunningAsap(informAboutScroll);
runAsap(informAboutScroll);
});
}
if (typeof window !== "undefined") {
if (isBrowser) {
const url = new URL(document.URL);
if (url.searchParams.get("prerender") !== "true" && typeof url.searchParams.get("embed") !== "undefined") {
if (url.searchParams.get("prerender") !== "true" && isEmbed()) {
log("Initializing embed-iframe");
// HACK
const pageStatus = window.CalComPageStatus;
// If embed link is opened in top, and not in iframe. Let the page be visible.
if (top === window) {
unhideBody();
@ -234,7 +355,16 @@ if (typeof window !== "undefined") {
}
});
keepParentInformedAboutDimensionChanges();
sdkActionManager?.fire("iframeReady", {});
if (!pageStatus || pageStatus == "200") {
keepParentInformedAboutDimensionChanges();
sdkActionManager?.fire("iframeReady", {});
} else
sdkActionManager?.fire("linkFailed", {
code: pageStatus,
msg: "Problem loading the link",
data: {
url: document.URL,
},
});
}
}

View file

@ -3,4 +3,5 @@
*/
.cal-embed {
border: 0px;
min-height: 100%;
}

View file

@ -75,6 +75,8 @@ export class Cal {
__config: any;
modalBox!: Element;
namespace: string;
actionManager: SdkActionManager;
@ -225,13 +227,14 @@ export class Cal {
element.appendChild(iframe);
}
modal({ calLink }: { calLink: string }) {
const iframe = this.createIframe({ calLink });
modal({ calLink, config = {} }: { calLink: string; config?: Record<string, string> }) {
const iframe = this.createIframe({ calLink, queryObject: Cal.getQueryObject(config) });
iframe.style.height = "100%";
iframe.style.width = "100%";
const template = document.createElement("template");
template.innerHTML = `<cal-modal-box></cal-modal-box>`;
template.content.children[0].appendChild(iframe);
this.modalBox = template.content.children[0];
this.modalBox.appendChild(iframe);
document.body.appendChild(template.content);
}
@ -335,11 +338,13 @@ export class Cal {
// Iframe might be pre-rendering
return;
}
let proposedHeightByIframeWebsite = data.iframeHeight;
iframe.style.height = proposedHeightByIframeWebsite + "px";
// It ensures that if the iframe is so tall that it can't fit in the parent window without scroll. Then force the scroll by restricting the max-height to innerHeight
// This case is reproducible when viewing in ModalBox on Mobile.
iframe.style.maxHeight = window.innerHeight + "px";
iframe.style.height = data.iframeHeight + "px";
iframe.style.width = data.iframeWidth + "px";
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.
iframe.style.maxHeight = window.innerHeight + "px";
}
});
this.actionManager.on("iframeReady", (e) => {
@ -349,6 +354,12 @@ export class Cal {
this.doInIframe({ method, arg });
});
});
this.actionManager.on("linkReady", (e) => {
this.modalBox?.setAttribute("loading", "done");
});
this.actionManager.on("linkFailed", (e) => {
this.iframe?.remove();
});
}
}
@ -384,9 +395,21 @@ document.addEventListener("click", (e) => {
if (!path) {
return;
}
// TODO: Add an option to check which cal instance should be used for this.
globalCal("modal", {
const namespace = htmlElement.dataset.calNamespace;
const configString = htmlElement.dataset.calConfig || "";
let config;
try {
config = JSON.parse(configString);
} catch (e) {
config = {};
}
let api = globalCal;
if (namespace) {
api = globalCal.ns![namespace];
}
api("modal", {
calLink: path,
config,
});
});

View file

@ -6,6 +6,12 @@ const { defineConfig } = require("vite");
module.exports = defineConfig({
envPrefix: "NEXT_PUBLIC_",
build: {
minify: "terser",
terserOptions: {
format: {
comments: false,
},
},
lib: {
entry: path.resolve(__dirname, "src/embed.ts"),
name: "embed",

View file

@ -4,11 +4,4 @@ Makes the embed available as a React component.
To add the embed on a webpage built using React. Follow the steps
```bash
yarn add @calcom/embed-react
```
```jsx
import Cal from "@calcom/embed-react"
<Cal></Cal>
```
See docs.cal.com/integrations/embed

View file

@ -18,6 +18,7 @@ function App() {
guests: ["janedoe@gmail.com"],
theme: "dark",
}}></Cal>
<button data-cal-link="pro">Popup</button>
</>
);
}