diff --git a/apps/docs/components/Anchor.tsx b/apps/docs/components/Anchor.tsx new file mode 100644 index 00000000..b7322b6b --- /dev/null +++ b/apps/docs/components/Anchor.tsx @@ -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 ( + + + ยง + + {children} + + ); +} diff --git a/apps/docs/pages/integrations/embed.mdx b/apps/docs/pages/integrations/embed.mdx index 40cde9d9..7a876d8b 100644 --- a/apps/docs/pages/integrations/embed.mdx +++ b/apps/docs/pages/integrations/embed.mdx @@ -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 choose any of the ways to show your Cal Link embedded on your website. + ```html -``` +*Sample sandbox* +``` + #### @@ -108,6 +118,14 @@ const MyComponent = () => ( ); ``` +*Sample sandbox* + + ### 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* + + -
React ```jsx @@ -131,11 +156,37 @@ To show the embed as a popup on clicking an element, simply add `data-cal-link` const MyComponent = ()=> { return } +``` -```` +*Sample sandbox* +
+### Floating pop-up button + +```html + +``` + +*Sample sandbox* + + ## Supported Instructions Consider an instruction as a function with that name and that would be called with the given arguments. diff --git a/apps/web/components/Embed.tsx b/apps/web/components/Embed.tsx new file mode 100644 index 00000000..31ab4a39 --- /dev/null +++ b/apps/web/components/Embed.tsx @@ -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: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* */} + + ), + }, + { + title: "Floating pop-up button", + subtitle: "Adds a floating button on your site that launches Cal in a dialog.", + type: "floating-popup", + illustration: ( + + + + + + + + + + {/* */} + + ), + }, + { + title: "Pop up via element click", + subtitle: "Open your Cal dialog when someone clicks an element.", + type: "element-click", + illustration: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* */} + + ), + }, +]; + +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 ; +}; +const ThemeSelectControl = ({ children, ...props }: ControlProps) => { + return ( + + + {children} + + ); +}; + +const ChooseEmbedTypesDialogContent = () => { + const { t } = useLocale(); + const router = useRouter(); + return ( + +
+ +
+

{t("choose_ways_put_cal_site")}

+
+
+
+ {embeds.map((embed, index) => ( + + ))} +
+
+ ); +}; + +const EmbedTypeCodeAndPreviewDialogContent = ({ + eventTypeId, + embedType, +}: { + eventTypeId: EventType["id"]; + embedType: EmbedType; +}) => { + const { t } = useLocale(); + const router = useRouter(); + const iframeRef = useRef(null); + const embedCode = useRef(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 ( + +
+
+ +
+
+ setIsEmbedCustomizationOpen((val) => !val)}> + +
+ {embedType === "inline" + ? "Inline Embed Customization" + : embedType === "floating-popup" + ? "Floating Popup Customization" + : "Element Click Customization"} +
+ +
+ +
+ {/*TODO: Add Auto/Fixed toggle from Figma */} +
Embed Window Sizing
+
+ { + setPreviewState((previewState) => { + let width = e.target.value || "100%"; + + return { + ...previewState, + inline: { + ...previewState.inline, + width, + }, + }; + }); + }} + addOnLeading={W} + /> + x + { + const height = e.target.value || "100%"; + + setPreviewState((previewState) => { + return { + ...previewState, + inline: { + ...previewState.inline, + height, + }, + }; + }); + }} + addOnLeading={H} + /> +
+
+
+
Button Text
+ {/* Default Values should come from preview iframe */} + { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + buttonText: e.target.value, + }, + }; + }); + }} + defaultValue="Book my Cal" + required + /> +
+
+
Display Calendar Icon Button
+ { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + hideButtonIcon: !checked, + }, + }; + }); + }}> +
+
+
Position of Button
+ +
+
+
Button Color
+
+ { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + buttonColor: color, + }, + }; + }); + }}> +
+
+
+
Text Color
+
+ { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + buttonTextColor: color, + }, + }; + }); + }}> +
+
+ {/*
+
Button Color on Hover
+
+ { + addToPalette({ + "floating-popup-button-color-hover": color, + }); + }}> +
+
*/} +
+
+
+
+
+ setIsBookingCustomizationOpen((val) => !val)}> + +
Cal Booking Customization
+ +
+ +
+ + {[ + { 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) => ( + + ))} +
+
+
+
+
+
+ +
+
+ {t("place_where_cal_widget_appear")} + +

+ {t( + "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options." + )} +

+
+
+ `; - const htmlTemplate = `${t( - "schedule_a_meeting" - )}${iframeTemplate}`; - - return ( - <> - -
- - -
- Embed -
- {t("standard_iframe")} - {t("embed_your_calendar")} -
-
- - -
-
-
- -
- Embed -
- {t("responsive_fullscreen_iframe")} - A fullscreen scheduling experience on your website -
-
- - -
-
-
-
-
-
- -
-
-
- -
-
-
-
- - ); -} - function ConnectOrDisconnectIntegrationButton(props: { // credentialIds: number[]; @@ -342,7 +261,6 @@ export default function IntegrationsPage() { - diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index d02b82a9..398d90d9 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -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) => { {t("copy_link")} + @@ -1969,6 +1974,7 @@ const EventTypePage = (props: inferSSRProps) => { /> )} +
); diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 2550cb20..429a8131 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -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")} + + + @@ -519,9 +527,9 @@ const CTA = () => { }; const WithQuery = withQuery(["viewer.eventTypes"]); + const EventTypesPage = () => { const { t } = useLocale(); - return (
@@ -574,6 +582,7 @@ const EventTypesPage = () => { {data.eventTypeGroups.length === 0 && ( )} + )} /> diff --git a/apps/web/playwright/embed-code-generator.test.ts b/apps/web/playwright/embed-code-generator.test.ts new file mode 100644 index 00000000..8f8e442c --- /dev/null +++ b/apps/web/playwright/embed-code-generator.test.ts @@ -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", + }); + }); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 470d7425..02806885 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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.", + "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", diff --git a/apps/web/server/routers/viewer/eventTypes.tsx b/apps/web/server/routers/viewer/eventTypes.tsx index bbc2822a..738e68ea 100644 --- a/apps/web/server/routers/viewer/eventTypes.tsx +++ b/apps/web/server/routers/viewer/eventTypes.tsx @@ -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 }) { diff --git a/packages/embeds/embed-core/README.md b/packages/embeds/embed-core/README.md index 23795f3b..9b68c9a9 100644 --- a/packages/embeds/embed-core/README.md +++ b/packages/embeds/embed-core/README.md @@ -56,6 +56,7 @@ Make `dist/embed.umd.js` servable on URL - 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 - 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 - 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. diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index cf7842da..016f88a6 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -1,6 +1,7 @@ + + + + + + +
+ + + diff --git a/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts b/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts index 01d3e25b..fbba8459 100644 --- a/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts +++ b/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts @@ -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 = ` ${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 = ` ${getFloatingButtonHtml({ + buttonText: buttonText!, + buttonClasses: [FloatingButton.updatedClassString(buttonPosition!, "")], + buttonColor: buttonColor!, + buttonTextColor: buttonTextColor!, + })}`; this.attachShadow({ mode: "open" }); this.shadowRoot!.innerHTML = buttonHtml; } diff --git a/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts b/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts index 792032d8..e015f9c6 100644 --- a/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts +++ b/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts @@ -1,8 +1,23 @@ -const html = ``; +}; -export default html; +export default getHtml; diff --git a/packages/embeds/embed-core/src/Inline/inline.ts b/packages/embeds/embed-core/src/Inline/inline.ts index e17e9291..f0e101e6 100644 --- a/packages/embeds/embed-core/src/Inline/inline.ts +++ b/packages/embeds/embed-core/src/Inline/inline.ts @@ -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() { diff --git a/packages/embeds/embed-core/src/Inline/inlineHtml.ts b/packages/embeds/embed-core/src/Inline/inlineHtml.ts index 80a87b2a..44c4c7e3 100644 --- a/packages/embeds/embed-core/src/Inline/inlineHtml.ts +++ b/packages/embeds/embed-core/src/Inline/inlineHtml.ts @@ -1,7 +1,10 @@ -const html = `
+const html = `
+
`; export default html; diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts index a6e6a2b2..9a7b4dfa 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts @@ -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; } } diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts b/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts index 6222e91a..9e2178cd 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts @@ -59,11 +59,12 @@ const html = `