Merge branch 'main' into feat/api-keys
commit
bd9861e216
|
@ -17,6 +17,7 @@ import { useRouter } from "next/router";
|
|||
import React, { Fragment, ReactNode, useEffect } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
|
@ -135,6 +136,7 @@ export default function Shell(props: {
|
|||
flexChildrenContainer?: boolean;
|
||||
isPublic?: boolean;
|
||||
}) {
|
||||
const isEmbed = useIsEmbed();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { loading, session } = useRedirectToLoginIfUnauthenticated(props.isPublic);
|
||||
|
@ -231,7 +233,7 @@ export default function Shell(props: {
|
|||
className={classNames("flex h-screen overflow-hidden", props.large ? "bg-white" : "bg-gray-100")}
|
||||
data-testid="dashboard-shell">
|
||||
{status === "authenticated" && (
|
||||
<div className="hidden md:flex lg:flex-shrink-0">
|
||||
<div style={isEmbed ? { display: "none" } : {}} className="hidden md:flex lg:flex-shrink-0">
|
||||
<div className="flex w-14 flex-col lg:w-56">
|
||||
<div className="flex h-0 flex-1 flex-col border-r border-gray-200 bg-white">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pt-3 pb-4 lg:pt-5">
|
||||
|
@ -322,7 +324,9 @@ export default function Shell(props: {
|
|||
)}>
|
||||
{/* show top navigation for md and smaller (tablet and phones) */}
|
||||
{status === "authenticated" && (
|
||||
<nav className="flex items-center justify-between border-b border-gray-200 bg-white p-4 md:hidden">
|
||||
<nav
|
||||
style={isEmbed ? { display: "none" } : {}}
|
||||
className="flex items-center justify-between border-b border-gray-200 bg-white p-4 md:hidden">
|
||||
<Link href="/event-types">
|
||||
<a>
|
||||
<Logo />
|
||||
|
@ -382,7 +386,9 @@ export default function Shell(props: {
|
|||
</div>
|
||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||
{status === "authenticated" && (
|
||||
<nav className="bottom-nav fixed bottom-0 z-30 flex w-full bg-white shadow md:hidden">
|
||||
<nav
|
||||
style={isEmbed ? { display: "none" } : {}}
|
||||
className="bottom-nav fixed bottom-0 z-30 flex w-full bg-white shadow md:hidden">
|
||||
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
||||
{navigation.flatMap((item, itemIdx) =>
|
||||
item.href === "/settings/profile" ? (
|
||||
|
|
|
@ -16,7 +16,7 @@ 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 { useEmbedStyles, useIsEmbed, useIsBackgroundTransparent, sdkActionManager } from "@calcom/embed-core";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
|
@ -83,6 +83,10 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
return null;
|
||||
}, [router.query.date]);
|
||||
|
||||
if (selectedDate) {
|
||||
// Let iframe take the width available due to increase in max-width
|
||||
sdkActionManager?.fire("__refreshWidth", {});
|
||||
}
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
|
||||
|
@ -91,7 +95,12 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
useEffect(() => {
|
||||
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
|
||||
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
telemetryEventTypes.pageView,
|
||||
collectPageParameters("availability", { isTeamBooking: document.URL.includes("team/") })
|
||||
)
|
||||
);
|
||||
}, [telemetry]);
|
||||
|
||||
const changeDate = (newDate: Dayjs) => {
|
||||
|
@ -136,7 +145,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
<main
|
||||
className={
|
||||
isEmbed
|
||||
? ""
|
||||
? classNames("m-auto", selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
|
||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
}>
|
||||
|
|
|
@ -222,7 +222,10 @@ const BookingPage = ({
|
|||
|
||||
const bookEvent = (booking: BookingFormValues) => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
|
||||
jitsu.track(
|
||||
telemetryEventTypes.bookingConfirmed,
|
||||
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
||||
)
|
||||
);
|
||||
|
||||
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
|
||||
|
@ -290,9 +293,10 @@ const BookingPage = ({
|
|||
</Head>
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<main
|
||||
className={
|
||||
isEmbed ? "mx-auto" : "mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600"
|
||||
}>
|
||||
className={classNames(
|
||||
isEmbed ? "mx-auto" : "mx-auto my-0 rounded-sm sm:my-24",
|
||||
"max-w-3xl sm:border sm:dark:border-gray-600"
|
||||
)}>
|
||||
{isReady && (
|
||||
<div
|
||||
className={classNames(
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { SessionProvider } from "next-auth/react";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import type { AppProps as NextAppProps } from "next/app";
|
||||
import { ComponentProps, ReactNode } from "react";
|
||||
import { ComponentProps, ReactNode, useMemo } from "react";
|
||||
|
||||
import DynamicHelpscoutProvider from "@ee/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@ee/lib/intercom/providerDynamic";
|
||||
|
@ -48,9 +48,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
|||
<CustomI18nextProvider {...props}>{props.children}</CustomI18nextProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
|
||||
const telemetryClient = useMemo(createTelemetryClient, []);
|
||||
return (
|
||||
<TelemetryProvider value={createTelemetryClient()}>
|
||||
<TelemetryProvider value={telemetryClient}>
|
||||
{isPublicPage ? (
|
||||
RemainingProviders
|
||||
) : (
|
||||
|
|
|
@ -46,7 +46,7 @@ function isLocalhost(host: string) {
|
|||
* Collects page parameters and makes sure no sensitive data made it to telemetry
|
||||
* @param route current next.js route
|
||||
*/
|
||||
export function collectPageParameters(route?: string): any {
|
||||
export function collectPageParameters(route?: string, extraData: Record<string, any> = {}): any {
|
||||
const host = document.location.hostname;
|
||||
const maskedHost = isLocalhost(host) ? "localhost" : "masked";
|
||||
//starts with ''
|
||||
|
@ -60,6 +60,7 @@ export function collectPageParameters(route?: string): any {
|
|||
doc_search: "",
|
||||
doc_path: docPath,
|
||||
referer: "",
|
||||
...extraData,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import prisma from "@lib/prisma";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
|
@ -108,6 +109,13 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
|||
const nameOrUsername = user.name || user.username || "";
|
||||
const [evtsToVerify, setEvtsToVerify] = useState<EvtsToVerify>({});
|
||||
const isEmbed = useIsEmbed();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.pageView, collectPageParameters("/[user]"))
|
||||
);
|
||||
}, [telemetry]);
|
||||
return (
|
||||
<>
|
||||
<Theme />
|
||||
|
|
|
@ -3,7 +3,7 @@ import { UserPlan } from "@prisma/client";
|
|||
import classNames from "classnames";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
@ -15,6 +15,7 @@ import useTheme from "@lib/hooks/useTheme";
|
|||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
import { getTeamWithMembers } from "@lib/queries/teams";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||
|
@ -25,13 +26,24 @@ import AvatarGroup from "@components/ui/AvatarGroup";
|
|||
import Text from "@components/ui/Text";
|
||||
|
||||
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
function TeamPage({ team }: TeamPageProps) {
|
||||
const { isReady, Theme } = useTheme();
|
||||
const showMembers = useToggleQuery("members");
|
||||
const { t } = useLocale();
|
||||
useExposePlanGlobally("PRO");
|
||||
const isEmbed = useIsEmbed();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
telemetryEventTypes.pageView,
|
||||
collectPageParameters("/team/[slug]", {
|
||||
isTeamBooking: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
}, [telemetry]);
|
||||
const eventTypes = (
|
||||
<ul className="space-y-3">
|
||||
{team.eventTypes.map((type) => (
|
||||
|
|
|
@ -41,6 +41,17 @@ Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
|
|||
- 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 ?
|
||||
- Let user specify both dark and light theme colors. Right now the colors specified are for light theme.
|
||||
- Embed doesn't adapt to screen size without page refresh.
|
||||
- Try opening in portrait mode and then go to landscape mode.
|
||||
- In inline mode, due to changing height of iframe, the content goes beyond the fold. Automatic scroll needs to be implemented.
|
||||
- On Availability page, when selecting date, width doesn't increase. max-width is there but because of strict width restriction with iframe, it doesn't allow it to expand.
|
||||
|
||||
- Branding
|
||||
- Powered by Cal.com and 'Try it for free'. Should they be shown only for FREE account.
|
||||
- Branding at the bottom has been removed for UI improvements, need to see where to add it.
|
||||
|
||||
- API
|
||||
- Allow loader color customization using UI command itself too.
|
||||
|
||||
- Automation Tests
|
||||
- Run automation tests in CI
|
||||
|
@ -64,8 +75,18 @@ Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
|
|||
|
||||
- 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
|
||||
|
||||
- Release Issues
|
||||
- Compatibility Issue - When embed-iframe.js is updated in such a way that it is not compatible with embed.js, doing a release might break the embed for some time. e.g. iframeReady event let's say get's changed to something else
|
||||
- Best Case scenario - App and Website goes live at the same time. A website using embed loads the same updated and thus compatible versions of embed.js and embed-iframe.js
|
||||
- Worst case scenario - App goes live first, website PR isn't merged yet and thus a website using the embed would load updated version of embed-iframe but outdated version of embed.js possibly breaking the embed.
|
||||
- Ideal Solution: It would be to keep the libraries versioned and embed.js should instruct app within iframe to load a particular version. But if we push a security fix, it is possible that someone is still enforcing embed to load version with security issue. Need to handle this.
|
||||
- Quick Solution: Serve embed.js also from app, so that they go live together and there is only a slight chance of compatibility issues on going live. Note, that they can still occur as 2 different requests are sent at different times to fetch the libraries and deployments can go live in between,
|
||||
|
||||
- UI Config Features
|
||||
- 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 ?
|
||||
|
@ -73,22 +94,8 @@ Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
|
|||
- 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.
|
||||
|
||||
- `onClick` support with automatic preloading
|
||||
- Shadow DOM is currently in open state, which probably means that any styling change on website can possibly impact loader.
|
||||
|
||||
## Pending Documentation
|
||||
|
||||
|
|
|
@ -54,6 +54,10 @@
|
|||
.loader {
|
||||
color: green;
|
||||
}
|
||||
* {
|
||||
--cal-brand-border-color: blue;
|
||||
--cal-brand-background-color: blue;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
@ -61,10 +65,6 @@
|
|||
<h3>Pre-render test page available at <a href="?only=prerender-test">here</a></h3>
|
||||
<div>
|
||||
<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
|
||||
|
@ -73,6 +73,14 @@
|
|||
slow</i
|
||||
>
|
||||
</div>
|
||||
<h2>Other Popup Examples</h2>
|
||||
<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>
|
||||
<h2>Embed for Pages behind authentication</h2>
|
||||
<button data-cal-namespace="upcomingBookings" data-cal-config='{"theme":"dark"}' data-cal-link="bookings/upcoming">Show Upcoming Bookings</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="namespaces-test">
|
||||
<div class="debug" id="cal-booking-place-default">
|
||||
|
@ -83,11 +91,12 @@
|
|||
<i><a href="?only=ns:default">Test in Zen Mode</a></i>
|
||||
</div>
|
||||
<i class="last-action"> You would see last Booking page action in my place </i>
|
||||
<div style="max-height: 30vh; overflow: scroll" class="place">
|
||||
<div >
|
||||
<div>
|
||||
if you render booking embed in me, I would not let it be more than 30vh in height. So you would
|
||||
have to scroll to see the entire content
|
||||
</div>
|
||||
<div class="place" style="width:50%; max-height: 30vh; overflow: scroll"></div>
|
||||
<div class="loader" id="cal-booking-loader-">Loading .....</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -320,6 +329,21 @@
|
|||
debug: 1,
|
||||
origin: "http://localhost:3000",
|
||||
})
|
||||
|
||||
Cal('init', 'upcomingBookings', {
|
||||
debug: 1,
|
||||
origin: "http://localhost:3000",
|
||||
})
|
||||
|
||||
Cal("init", "floatingButton", {
|
||||
debug: 1,
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
if (!only || only == "ns:floatingButton") {
|
||||
Cal.ns.floatingButton("floatingButton", {
|
||||
calLink: "pro"
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<script></script>
|
||||
</body>
|
||||
|
|
|
@ -18,3 +18,9 @@ test("should open embed iframe on click", async ({ page, addEmbedListeners, getA
|
|||
pathname: "/free",
|
||||
});
|
||||
});
|
||||
|
||||
todo("Floating Button Test with Dark Theme");
|
||||
|
||||
todo("Floating Button Test with Light Theme");
|
||||
|
||||
todo("Add snapshot test for embed iframe");
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import tailwindCss from "./tailwind.css";
|
||||
|
||||
export class FloatingButton extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const buttonHtml = `
|
||||
<style>
|
||||
${tailwindCss}
|
||||
</style>
|
||||
<button
|
||||
class="fixed bottom-4 right-4 flex h-16 origin-center transform cursor-pointer items-center rounded-full py-4 px-6 text-base outline-none drop-shadow-md transition transition-all focus:outline-none focus:ring-4 focus:ring-gray-600 focus:ring-opacity-50 active:scale-95 md:bottom-6 md:right-10"
|
||||
style="background-color: rgb(255, 202, 0); color: rgb(20, 30, 47); z-index: 10001">
|
||||
<div class="mr-3 flex items-center justify-center">
|
||||
<svg
|
||||
class="h-7 w-7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold leading-5 antialiased">Book my Cal</div>
|
||||
</button>`;
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
this.shadowRoot!.innerHTML = buttonHtml;
|
||||
}
|
||||
}
|
|
@ -1,25 +1,39 @@
|
|||
import loaderCss from "./loader.css";
|
||||
import tailwindCss from "./tailwind.css";
|
||||
|
||||
export class ModalBox extends HTMLElement {
|
||||
static htmlOverflow: string;
|
||||
//@ts-ignore
|
||||
static get observedAttributes() {
|
||||
return ["loading"];
|
||||
return ["state"];
|
||||
}
|
||||
|
||||
show(show: boolean) {
|
||||
// We can't make it display none as that takes iframe width and height calculations to 0
|
||||
(this.shadowRoot!.host as unknown as any).style.visibility = show ? "visible" : "hidden";
|
||||
}
|
||||
|
||||
close() {
|
||||
this.shadowRoot!.host.remove();
|
||||
this.show(false);
|
||||
document.body.style.overflow = ModalBox.htmlOverflow;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
|
||||
if (name === "loading" && newValue == "done") {
|
||||
if (name !== "state") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue == "loaded") {
|
||||
(this.shadowRoot!.querySelector("#loader")! as HTMLElement).style.display = "none";
|
||||
} else if (newValue === "started") {
|
||||
this.show(true);
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const closeEl = this.shadowRoot!.querySelector(".close") as HTMLElement;
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const el = e.target as HTMLElement;
|
||||
this.shadowRoot!.host.addEventListener("click", (e) => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
|
@ -32,37 +46,7 @@ export class ModalBox extends HTMLElement {
|
|||
super();
|
||||
//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;
|
||||
}
|
||||
|
||||
<style> ${tailwindCss}
|
||||
.backdrop {
|
||||
position:fixed;
|
||||
width:100%;
|
||||
|
@ -73,10 +57,10 @@ export class ModalBox extends HTMLElement {
|
|||
display:block;
|
||||
background-color:rgb(5,5,5, 0.8)
|
||||
}
|
||||
|
||||
@media only screen and (min-width:600px) {
|
||||
.modal-box {
|
||||
margin:0 auto;
|
||||
border-radius: 8px;
|
||||
margin-top:20px;
|
||||
margin-bottom:20px;
|
||||
position:absolute;
|
||||
|
@ -112,69 +96,11 @@ 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;
|
||||
}
|
||||
--cal-brand-border-color: white;
|
||||
--cal-brand-background-color: white;
|
||||
}
|
||||
${loaderCss}
|
||||
</style>
|
||||
<div class="backdrop">
|
||||
<div class="header">
|
||||
|
|
|
@ -275,6 +275,7 @@ const messageParent = (data: any) => {
|
|||
|
||||
function keepParentInformedAboutDimensionChanges() {
|
||||
let knownIframeHeight: Number | null = null;
|
||||
let knownIframeWidth: Number | null = null;
|
||||
let numDimensionChanges = 0;
|
||||
let isFirstTime = true;
|
||||
let isWindowLoadComplete = false;
|
||||
|
@ -290,18 +291,20 @@ function keepParentInformedAboutDimensionChanges() {
|
|||
setTimeout(() => {
|
||||
isWindowLoadComplete = true;
|
||||
informAboutScroll();
|
||||
}, 10);
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
if (!embedStore.windowLoadEventFired) {
|
||||
sdkActionManager?.fire("__windowLoadComplete", {});
|
||||
}
|
||||
embedStore.windowLoadEventFired = true;
|
||||
|
||||
// Use the dimensions of main element as in most places there is max-width restriction on it and we just want to show the main content.
|
||||
// It avoids the unwanted padding outside main tag.
|
||||
const mainElement = document.getElementsByTagName("main")[0] || document.documentElement;
|
||||
const documentScrollHeight = document.documentElement.scrollHeight;
|
||||
const documentScrollWidth = document.documentElement.scrollWidth;
|
||||
const contentHeight = document.documentElement.offsetHeight;
|
||||
const contentWidth = document.documentElement.offsetWidth;
|
||||
const contentHeight = mainElement.offsetHeight;
|
||||
const contentWidth = mainElement.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.
|
||||
|
@ -309,9 +312,13 @@ function keepParentInformedAboutDimensionChanges() {
|
|||
let iframeHeight = isFirstTime ? documentScrollHeight : contentHeight;
|
||||
let iframeWidth = isFirstTime ? documentScrollWidth : contentWidth;
|
||||
embedStore.parentInformedAboutContentHeight = true;
|
||||
// TODO: Handle width as well.
|
||||
if (knownIframeHeight !== iframeHeight) {
|
||||
if (!iframeHeight || !iframeWidth) {
|
||||
runAsap(informAboutScroll);
|
||||
return;
|
||||
}
|
||||
if (knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth) {
|
||||
knownIframeHeight = iframeHeight;
|
||||
knownIframeWidth = iframeWidth;
|
||||
numDimensionChanges++;
|
||||
// FIXME: This event shouldn't be subscribable by the user. Only by the SDK.
|
||||
sdkActionManager?.fire("__dimensionChanged", {
|
||||
|
@ -350,6 +357,16 @@ if (isBrowser) {
|
|||
messageParent(detail);
|
||||
});
|
||||
|
||||
// This event should be fired whenever you want to let the content take automatic width which is available.
|
||||
// Because on cal-iframe we set explicty width to make it look inline and part of page, there is never space available for content to automatically expand
|
||||
// This is a HACK to quickly tell iframe to go full width and let iframe content adapt to that and set new width.
|
||||
sdkActionManager?.on("__refreshWidth", () => {
|
||||
sdkActionManager?.fire("__dimensionChanged", {
|
||||
iframeWidth: 100,
|
||||
__unit: "%",
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("message", (e) => {
|
||||
const data: Record<string, any> = e.data;
|
||||
if (!data) {
|
||||
|
|
|
@ -3,5 +3,5 @@
|
|||
*/
|
||||
.cal-embed {
|
||||
border: 0px;
|
||||
min-height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import type { CalWindow } from "@calcom/embed-snippet";
|
||||
|
||||
import { FloatingButton } from "./FloatingButton";
|
||||
import { ModalBox } from "./ModalBox";
|
||||
import { methods, UiConfig } from "./embed-iframe";
|
||||
import css from "./embed.css";
|
||||
import { Inline } from "./inline";
|
||||
import { SdkActionManager } from "./sdk-action-manager";
|
||||
|
||||
declare module "*.css";
|
||||
|
@ -77,6 +79,8 @@ export class Cal {
|
|||
|
||||
modalBox!: Element;
|
||||
|
||||
inlineEl!: Element;
|
||||
|
||||
namespace: string;
|
||||
|
||||
actionManager: SdkActionManager;
|
||||
|
@ -201,6 +205,7 @@ export class Cal {
|
|||
required: true,
|
||||
props: {
|
||||
calLink: {
|
||||
// TODO: Add a special type calLink for it and validate that it doesn't start with / or https?://
|
||||
required: true,
|
||||
type: "string",
|
||||
},
|
||||
|
@ -224,15 +229,41 @@ export class Cal {
|
|||
if (!element) {
|
||||
throw new Error("Element not found");
|
||||
}
|
||||
element.appendChild(iframe);
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = `<cal-inline style="max-height:inherit;height:inherit;min-height:inherit;display:block;position:relative"></cal-inline>`;
|
||||
this.inlineEl = template.content.children[0];
|
||||
this.inlineEl.appendChild(iframe);
|
||||
element.appendChild(template.content);
|
||||
}
|
||||
|
||||
modal({ calLink, config = {} }: { calLink: string; config?: Record<string, string> }) {
|
||||
floatingButton({ calLink }: { calLink: string }) {
|
||||
validate(arguments[0], {
|
||||
required: true,
|
||||
props: {
|
||||
calLink: {
|
||||
required: true,
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
});
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = `<cal-floating-button data-cal-namespace=${this.namespace} data-cal-link=${calLink}></cal-floating-button>`;
|
||||
document.body.appendChild(template.content);
|
||||
}
|
||||
|
||||
modal({ calLink, config = {}, uid }: { calLink: string; config?: Record<string, string>; uid: number }) {
|
||||
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
|
||||
if (existingModalEl) {
|
||||
existingModalEl.setAttribute("state", "started");
|
||||
return;
|
||||
}
|
||||
const iframe = this.createIframe({ calLink, queryObject: Cal.getQueryObject(config) });
|
||||
iframe.style.borderRadius = "8px";
|
||||
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.width = "100%";
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = `<cal-modal-box></cal-modal-box>`;
|
||||
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
|
||||
this.modalBox = template.content.children[0];
|
||||
this.modalBox.appendChild(iframe);
|
||||
document.body.appendChild(template.content);
|
||||
|
@ -338,12 +369,26 @@ export class Cal {
|
|||
// Iframe might be pre-rendering
|
||||
return;
|
||||
}
|
||||
iframe.style.height = data.iframeHeight + "px";
|
||||
iframe.style.width = data.iframeWidth + "px";
|
||||
let unit = "px";
|
||||
if (data.__unit) {
|
||||
unit = data.__unit;
|
||||
}
|
||||
if (data.iframeHeight) {
|
||||
iframe.style.height = data.iframeHeight + unit;
|
||||
}
|
||||
|
||||
if (data.iframeWidth) {
|
||||
iframe.style.width = data.iframeWidth + unit;
|
||||
}
|
||||
|
||||
if (this.modalBox) {
|
||||
// It ensures that if the iframe is so tall that it can't fit in the parent window without scroll. Then force the scroll by restricting the max-height to innerHeight
|
||||
// This case is reproducible when viewing in ModalBox on Mobile.
|
||||
iframe.style.maxHeight = window.innerHeight + "px";
|
||||
// Automatically setting the height of modal-box as per iframe creates problem in managing width of iframe.
|
||||
// if (iframe.style.width !== "100%") {
|
||||
// this.modalBox!.shadowRoot!.querySelector(".modal-box")!.style.width = iframe.style.width;
|
||||
// }
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -355,7 +400,8 @@ export class Cal {
|
|||
});
|
||||
});
|
||||
this.actionManager.on("linkReady", (e) => {
|
||||
this.modalBox?.setAttribute("loading", "done");
|
||||
this.modalBox?.setAttribute("state", "loaded");
|
||||
this.inlineEl?.setAttribute("loading", "done");
|
||||
});
|
||||
this.actionManager.on("linkFailed", (e) => {
|
||||
this.iframe?.remove();
|
||||
|
@ -395,6 +441,8 @@ document.addEventListener("click", (e) => {
|
|||
if (!path) {
|
||||
return;
|
||||
}
|
||||
const modalUniqueId = ((htmlElement as unknown as any).uniqueId =
|
||||
(htmlElement as unknown as any).uniqueId || Date.now());
|
||||
const namespace = htmlElement.dataset.calNamespace;
|
||||
const configString = htmlElement.dataset.calConfig || "";
|
||||
let config;
|
||||
|
@ -410,7 +458,10 @@ document.addEventListener("click", (e) => {
|
|||
api("modal", {
|
||||
calLink: path,
|
||||
config,
|
||||
uid: modalUniqueId,
|
||||
});
|
||||
});
|
||||
|
||||
customElements.define("cal-modal-box", ModalBox);
|
||||
customElements.define("cal-floating-button", FloatingButton);
|
||||
customElements.define("cal-inline", Inline);
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import loaderCss from "./loader.css";
|
||||
import tailwindCss from "./tailwind.css";
|
||||
|
||||
export class Inline extends HTMLElement {
|
||||
//@ts-ignore
|
||||
static get observedAttributes() {
|
||||
return ["loading"];
|
||||
}
|
||||
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
|
||||
if (name === "loading" && newValue == "done") {
|
||||
(this.shadowRoot!.querySelector("#loader")! as HTMLElement).style.display = "none";
|
||||
}
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.shadowRoot!.innerHTML = `
|
||||
<style> ${tailwindCss}${loaderCss}</style>
|
||||
<div id="loader" style="left:0;right:0" 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>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
@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;
|
||||
}
|
|
@ -0,0 +1,203 @@
|
|||
* {
|
||||
-tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-rotate: 0;
|
||||
--tw-skew-x: 0;
|
||||
--tw-skew-y: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-pan-x: ;
|
||||
--tw-pan-y: ;
|
||||
--tw-pinch-zoom: ;
|
||||
--tw-scroll-snap-strictness: proximity;
|
||||
--tw-ordinal: ;
|
||||
--tw-slashed-zero: ;
|
||||
--tw-numeric-figure: ;
|
||||
--tw-numeric-spacing: ;
|
||||
--tw-numeric-fraction: ;
|
||||
--tw-ring-inset: ;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-colored: 0 0 #0000;
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(248 248 248 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.antialiased {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.leading-5 {
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
.w-7 {
|
||||
width: 1.75rem;
|
||||
}
|
||||
|
||||
.h-7 {
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.antialiased {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.leading-5 {
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
.mr-3 {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.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:var(--cal-brand-border-color);
|
||||
}
|
||||
|
||||
.bg-brand {
|
||||
background-color: var(--cal-brand-background-color);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:right-10 {
|
||||
right: 2.5rem;
|
||||
}
|
||||
|
||||
.md\:bottom-6 {
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, fill, stroke, opacity, box-shadow, transform,
|
||||
filter, -webkit-text-decoration-color, -webkit-backdrop-filter;
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity,
|
||||
box-shadow, transform, filter, backdrop-filter;
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity,
|
||||
box-shadow, transform, filter, backdrop-filter, -webkit-text-decoration-color, -webkit-backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.drop-shadow-md {
|
||||
--tw-drop-shadow: drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06));
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate)
|
||||
var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
|
||||
.outline-none {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.px-6 {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.transform {
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate))
|
||||
skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
.origin-center {
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.h-16 {
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.right-4 {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.bottom-4 {
|
||||
bottom: 1rem;
|
||||
}
|
||||
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
|
@ -4,9 +4,11 @@ import useEmbed from "./useEmbed";
|
|||
|
||||
export default function Cal({
|
||||
calLink,
|
||||
calOrigin,
|
||||
config,
|
||||
embedJsUrl,
|
||||
}: {
|
||||
calOrigin?: string;
|
||||
calLink: string;
|
||||
config?: any;
|
||||
embedJsUrl?: string;
|
||||
|
@ -17,13 +19,21 @@ export default function Cal({
|
|||
if (!Cal) {
|
||||
return;
|
||||
}
|
||||
Cal("init");
|
||||
const element = ref.current;
|
||||
let initConfig = {};
|
||||
if (calOrigin) {
|
||||
(initConfig as any).origin = calOrigin;
|
||||
}
|
||||
Cal("init", initConfig);
|
||||
Cal("inline", {
|
||||
elementOrSelector: ref.current,
|
||||
elementOrSelector: element,
|
||||
calLink,
|
||||
config,
|
||||
});
|
||||
}, [Cal, calLink, config]);
|
||||
return () => {
|
||||
element?.querySelector(".cal-embed")?.remove();
|
||||
};
|
||||
}, [Cal, calLink, config, calOrigin]);
|
||||
|
||||
if (!Cal) {
|
||||
return <div>Loading {calLink}</div>;
|
||||
|
|
|
@ -9,6 +9,7 @@ function App() {
|
|||
There is <code>Cal</code> component below me
|
||||
</h1>
|
||||
<Cal
|
||||
calOrigin="http://localhost:3000"
|
||||
embedJsUrl="//localhost:3002/dist/embed.umd.js"
|
||||
calLink="pro"
|
||||
config={{
|
||||
|
|
Loading…
Reference in New Issue