Embedded Routing Forms - Part1 (#3530)
* Support dark theme in routing form * Fix Embed detection * Add routing form embed example * Better rendering support in dark mode for react-select * Fix more theme issues * Added test for Routing Form Embed Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
parent
e871781079
commit
1be5510c5e
|
@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from "react";
|
|||
import ReactSelect, { components, GroupBase, Props, InputProps, SingleValue, MultiValue } from "react-select";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
|
||||
export type SelectProps<
|
||||
Option,
|
||||
|
@ -27,6 +28,54 @@ function Select<
|
|||
IsMulti extends boolean = false,
|
||||
Group extends GroupBase<Option> = GroupBase<Option>
|
||||
>({ className, ...props }: SelectProps<Option, IsMulti, Group>) {
|
||||
const { resolvedTheme, forcedTheme, isReady } = useTheme();
|
||||
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
|
||||
const darkThemeColors = {
|
||||
/** Dark Theme starts */
|
||||
//primary - Border when selected and Selected Option background
|
||||
primary: "rgb(41 41 41 / var(--tw-border-opacity))",
|
||||
|
||||
neutral0: "rgb(62 62 62 / var(--tw-bg-opacity))",
|
||||
// Down Arrow hover color
|
||||
neutral5: "white",
|
||||
|
||||
neutral10: "rgb(41 41 41 / var(--tw-border-opacity))",
|
||||
|
||||
// neutral20 - border color + down arrow default color
|
||||
neutral20: "rgb(41 41 41 / var(--tw-border-opacity))",
|
||||
|
||||
// neutral30 - hover border color
|
||||
neutral30: "rgb(41 41 41 / var(--tw-border-opacity))",
|
||||
|
||||
neutral40: "white",
|
||||
|
||||
danger: "white",
|
||||
|
||||
// Cross button in multiselect
|
||||
dangerLight: "rgb(41 41 41 / var(--tw-border-opacity))",
|
||||
|
||||
// neutral50 - MultiSelect - "Select Text" color
|
||||
neutral50: "white",
|
||||
|
||||
// neutral60 - Down Arrow color
|
||||
neutral60: "white",
|
||||
|
||||
neutral70: "red",
|
||||
|
||||
// neutral80 - Selected option
|
||||
neutral80: "white",
|
||||
|
||||
neutral90: "blue",
|
||||
|
||||
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
|
||||
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
|
||||
/** Dark Theme ends */
|
||||
};
|
||||
|
||||
// Till we know in JS the theme is ready, we can't render react-select as it would render with light theme instead
|
||||
if (!isReady) {
|
||||
return <input type="text" className={className} />;
|
||||
}
|
||||
return (
|
||||
<ReactSelect
|
||||
theme={(theme) => ({
|
||||
|
@ -34,10 +83,16 @@ function Select<
|
|||
borderRadius: 2,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary: "var(--brand-color)",
|
||||
...(hasDarkTheme
|
||||
? darkThemeColors
|
||||
: {
|
||||
/** Light Theme starts */
|
||||
primary: "var(--brand-color)",
|
||||
|
||||
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
|
||||
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
|
||||
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
|
||||
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
|
||||
/** Light Theme Ends */
|
||||
}),
|
||||
},
|
||||
})}
|
||||
styles={{
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { SessionProvider } from "next-auth/react";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
||||
import { NextRouter } from "next/router";
|
||||
import { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
@ -15,7 +16,10 @@ const I18nextAdapter = appWithTranslation<NextJsAppProps & { children: React.Rea
|
|||
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
export type AppProps = Omit<NextAppProps, "Component"> & {
|
||||
Component: NextAppProps["Component"] & { requiresLicense?: boolean; isThemeSupported?: boolean };
|
||||
Component: NextAppProps["Component"] & {
|
||||
requiresLicense?: boolean;
|
||||
isThemeSupported?: boolean | ((arg: { router: NextRouter }) => boolean);
|
||||
};
|
||||
/** Will be defined only is there was an error */
|
||||
err?: Error;
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useTheme as useNextTheme } from "next-themes";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useEmbedTheme } from "@calcom/embed-core/embed-iframe";
|
||||
import { Maybe } from "@calcom/trpc/server";
|
||||
|
@ -7,7 +7,8 @@ import { Maybe } from "@calcom/trpc/server";
|
|||
// makes sure the ui doesn't flash
|
||||
export default function useTheme(theme?: Maybe<string>) {
|
||||
theme = theme || "system";
|
||||
const { theme: currentTheme, setTheme } = useNextTheme();
|
||||
const { resolvedTheme, setTheme, forcedTheme } = useNextTheme();
|
||||
const [isReady, setIsReady] = useState<boolean>(false);
|
||||
const embedTheme = useEmbedTheme();
|
||||
// Embed UI configuration takes more precedence over App Configuration
|
||||
theme = embedTheme || theme;
|
||||
|
@ -16,10 +17,13 @@ export default function useTheme(theme?: Maybe<string>) {
|
|||
if (theme) {
|
||||
setTheme(theme);
|
||||
}
|
||||
setIsReady(true);
|
||||
}, [theme, setTheme]);
|
||||
|
||||
return {
|
||||
currentTheme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
isReady,
|
||||
forcedTheme,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -17,7 +17,6 @@ import LicenseRequired from "@ee/components/LicenseRequired";
|
|||
|
||||
import AppProviders, { AppProps } from "@lib/app-providers";
|
||||
import { seoConfig } from "@lib/config/next-seo.config";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
|
||||
import I18nLanguageHandler from "@components/I18nLanguageHandler";
|
||||
|
||||
|
@ -28,13 +27,28 @@ import "../styles/globals.css";
|
|||
function MyApp(props: AppProps) {
|
||||
const { Component, pageProps, err, router } = props;
|
||||
let pageStatus = "200";
|
||||
|
||||
if (router.pathname === "/404") {
|
||||
pageStatus = "404";
|
||||
} else if (router.pathname === "/500") {
|
||||
pageStatus = "500";
|
||||
}
|
||||
const forcedTheme = Component.isThemeSupported ? undefined : "light";
|
||||
|
||||
let isThemeSupported = null;
|
||||
|
||||
if (typeof Component.isThemeSupported === "function") {
|
||||
isThemeSupported = Component.isThemeSupported({ router });
|
||||
} else {
|
||||
isThemeSupported = Component.isThemeSupported;
|
||||
}
|
||||
|
||||
const forcedTheme = isThemeSupported ? undefined : "light";
|
||||
|
||||
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
|
||||
// One such example is our Embeds Demo and Testing page at http://localhost:3100
|
||||
// Having `getEmbedNamespace` defined on window before react initializes the app, ensures that embedNamespace is available on the first mount and can be used as part of storageKey
|
||||
const embedNamespace = typeof window !== "undefined" ? window.getEmbedNamespace() : null;
|
||||
const storageKey = typeof embedNamespace === "string" ? `embed-theme-${embedNamespace}` : "theme";
|
||||
|
||||
return (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<ContractsProvider>
|
||||
|
@ -46,7 +60,11 @@ function MyApp(props: AppProps) {
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
</Head>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
<ThemeProvider enableColorScheme={false} forcedTheme={forcedTheme} attribute="class">
|
||||
<ThemeProvider
|
||||
enableColorScheme={false}
|
||||
storageKey={storageKey}
|
||||
forcedTheme={forcedTheme}
|
||||
attribute="class">
|
||||
{Component.requiresLicense ? (
|
||||
<LicenseRequired>
|
||||
<Component {...pageProps} err={err} />
|
||||
|
|
|
@ -2,6 +2,24 @@ import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps
|
|||
|
||||
type Props = Record<string, unknown> & DocumentProps;
|
||||
|
||||
function toRunBeforeReactOnClient() {
|
||||
window.sessionStorage.setItem("calEmbedMode", String(location.search.includes("embed=")));
|
||||
window.isEmbed = () => {
|
||||
return window.sessionStorage.getItem("calEmbedMode") === "true";
|
||||
};
|
||||
|
||||
window.getEmbedTheme = () => {
|
||||
const url = new URL(document.URL);
|
||||
return url.searchParams.get("theme") as "dark" | "light";
|
||||
};
|
||||
|
||||
window.getEmbedNamespace = () => {
|
||||
const url = new URL(document.URL);
|
||||
const namespace = url.searchParams.get("embed");
|
||||
return namespace;
|
||||
};
|
||||
}
|
||||
|
||||
class MyDocument extends Document<Props> {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
|
@ -31,13 +49,12 @@ class MyDocument extends Document<Props> {
|
|||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link rel="preload" href="/fonts/cal.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
|
||||
{/* Define isEmbed here so that it can be shared with App(embed-iframe) as well as the following code to change background and hide body */}
|
||||
{/* Define isEmbed here so that it can be shared with App(embed-iframe) as well as the following code to change background and hide body
|
||||
Persist the embed mode in sessionStorage because query param might get lost during browsing.
|
||||
*/}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.isEmbed = ()=> {
|
||||
return location.search.includes("embed=")
|
||||
}`,
|
||||
__html: `(${toRunBeforeReactOnClient.toString()})()`,
|
||||
}}
|
||||
/>
|
||||
</Head>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { NextRouter, useRouter } from "next/router";
|
||||
|
||||
import RoutingFormsRoutingConfig from "@calcom/app-store/ee/routing_forms/pages/app-routing.config";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
@ -31,7 +31,7 @@ function getRoute(appName: string, pages: string[]) {
|
|||
notFound: false;
|
||||
// A component than can accept any properties
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Component: (props: any) => JSX.Element;
|
||||
Component: ((props: any) => JSX.Element) & { isThemeSupported?: boolean };
|
||||
getServerSideProps: AppGetServerSideProps;
|
||||
};
|
||||
if (!appPage) {
|
||||
|
@ -59,7 +59,13 @@ export default function AppPage(props: inferSSRProps<typeof getServerSideProps>)
|
|||
return <route.Component {...componentProps} />;
|
||||
}
|
||||
|
||||
AppPage.isThemeSupported = true;
|
||||
AppPage.isThemeSupported = ({ router }: { router: NextRouter }) => {
|
||||
const route = getRoute(router.query.slug as string, router.query.pages as string[]);
|
||||
if (route.notFound) {
|
||||
return false;
|
||||
}
|
||||
return route.Component.isThemeSupported;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
context: GetServerSidePropsContext<{
|
||||
|
|
|
@ -52,7 +52,7 @@ const TextWidget = (props: TextWidgetProps & { type?: string }) => {
|
|||
return (
|
||||
<input
|
||||
type={type}
|
||||
className="flex flex-grow border-gray-300 text-sm"
|
||||
className="flex flex-grow border-gray-300 text-sm dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500"
|
||||
value={textValue}
|
||||
placeholder={placeholder}
|
||||
disabled={readonly}
|
||||
|
@ -68,7 +68,7 @@ function NumberWidget({ value, setValue, ...remainingProps }: NumberWidgetProps)
|
|||
<Input
|
||||
name="query-builder"
|
||||
type="number"
|
||||
className="mt-0"
|
||||
className="mt-0 border-gray-300 text-sm dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
|
@ -102,7 +102,7 @@ const MultiSelectWidget = ({
|
|||
|
||||
return (
|
||||
<Select
|
||||
className="block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 sm:text-sm"
|
||||
className="block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm"
|
||||
menuPosition="fixed"
|
||||
onChange={(items) => {
|
||||
setValue(items?.map((item) => item.value));
|
||||
|
@ -136,7 +136,7 @@ function SelectWidget({
|
|||
|
||||
return (
|
||||
<Select
|
||||
className="data-testid-select block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 sm:text-sm"
|
||||
className="data-testid-select block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm"
|
||||
menuPosition="fixed"
|
||||
onChange={(item) => {
|
||||
if (!item) {
|
||||
|
|
|
@ -6,12 +6,17 @@ import { Utils as QbUtils } from "react-awesome-query-builder";
|
|||
import { Toaster } from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppGetServerSideProps";
|
||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
|
||||
import { getSerializableForm } from "../../utils";
|
||||
import { getQueryBuilderConfig } from "../route-builder/[...appPages]";
|
||||
|
||||
|
@ -27,9 +32,11 @@ type Form = inferSSRProps<typeof getServerSideProps>["form"];
|
|||
|
||||
type Route = NonNullable<Form["routes"]>[0];
|
||||
|
||||
function RoutingForm({ form }: inferSSRProps<typeof getServerSideProps>) {
|
||||
function RoutingForm({ form, profile }: inferSSRProps<typeof getServerSideProps>) {
|
||||
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
|
||||
const formFillerIdRef = useRef(uuidv4());
|
||||
const isEmbed = useIsEmbed();
|
||||
useTheme(profile.theme);
|
||||
|
||||
// TODO: We might want to prevent spam from a single user by having same formFillerId across pageviews
|
||||
// But technically, a user can fill form multiple times due to any number of reasons and we currently can't differentiate b/w that.
|
||||
|
@ -71,7 +78,7 @@ function RoutingForm({ form }: inferSSRProps<typeof getServerSideProps>) {
|
|||
} else if (decidedAction.type === "externalRedirectUrl") {
|
||||
window.location.href = decidedAction.value;
|
||||
}
|
||||
showToast("Form submitted successfully! Redirecting now ...", "success");
|
||||
// showToast("Form submitted successfully! Redirecting now ...", "success");
|
||||
},
|
||||
onError: (e) => {
|
||||
if (e?.message) {
|
||||
|
@ -80,7 +87,7 @@ function RoutingForm({ form }: inferSSRProps<typeof getServerSideProps>) {
|
|||
if (e?.data?.code === "CONFLICT") {
|
||||
return void showToast("Form already submitted", "error");
|
||||
}
|
||||
showToast("Something went wrong", "error");
|
||||
// showToast("Something went wrong", "error");
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -93,94 +100,104 @@ function RoutingForm({ form }: inferSSRProps<typeof getServerSideProps>) {
|
|||
onSubmit(response);
|
||||
};
|
||||
|
||||
return !customPageMessage ? (
|
||||
<>
|
||||
<Head>
|
||||
<title>{form.name} | Cal.com Forms</title>
|
||||
</Head>
|
||||
<div className="mx-auto my-0 max-w-3xl md:my-24">
|
||||
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
|
||||
<div className="mx-0 rounded-sm border border-neutral-200 bg-white p-4 py-6 sm:-mx-4 sm:px-8">
|
||||
<Toaster position="bottom-right" />
|
||||
return (
|
||||
<div>
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
|
||||
<form onSubmit={handleOnSubmit}>
|
||||
<div className="mb-8">
|
||||
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900">
|
||||
{form.name}
|
||||
</h1>
|
||||
{form.description ? (
|
||||
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{form.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{form.fields?.map((field) => {
|
||||
const widget = queryBuilderConfig.widgets[field.type];
|
||||
if (!("factory" in widget)) {
|
||||
return null;
|
||||
}
|
||||
const Component = widget.factory;
|
||||
<div>
|
||||
{!customPageMessage ? (
|
||||
<>
|
||||
<Head>
|
||||
<title>{form.name} | Cal.com Forms</title>
|
||||
</Head>
|
||||
<div className={classNames("mx-auto my-0 max-w-3xl", isEmbed ? "" : "md:my-24")}>
|
||||
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
|
||||
<div className="main border-bookinglightest mx-0 rounded-md bg-white p-4 py-6 dark:bg-gray-800 sm:-mx-4 sm:px-8 sm:dark:border-gray-600 md:border">
|
||||
<Toaster position="bottom-right" />
|
||||
|
||||
const optionValues = field.selectText?.trim().split("\n");
|
||||
const options = optionValues?.map((value) => {
|
||||
const title = value;
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div key={field.id} className="mb-4 block flex-col sm:flex ">
|
||||
<div className="min-w-48 mb-2 flex-grow">
|
||||
<label
|
||||
id="slug-label"
|
||||
htmlFor="slug"
|
||||
className="flex text-sm font-medium text-neutral-700">
|
||||
{field.label}
|
||||
</label>
|
||||
<form onSubmit={handleOnSubmit}>
|
||||
<div className="mb-8">
|
||||
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900 dark:text-white">
|
||||
{form.name}
|
||||
</h1>
|
||||
{form.description ? (
|
||||
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4 dark:text-white">
|
||||
{form.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex rounded-sm">
|
||||
<Component
|
||||
value={response[field.id]?.value}
|
||||
// required property isn't accepted by query-builder types
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
/* @ts-ignore */
|
||||
required={!!field.required}
|
||||
listValues={options}
|
||||
setValue={(value) => {
|
||||
setResponse((response) => {
|
||||
response = response || {};
|
||||
return {
|
||||
...response,
|
||||
[field.id]: {
|
||||
label: field.label,
|
||||
value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{form.fields?.map((field) => {
|
||||
const widget = queryBuilderConfig.widgets[field.type];
|
||||
if (!("factory" in widget)) {
|
||||
return null;
|
||||
}
|
||||
const Component = widget.factory;
|
||||
|
||||
const optionValues = field.selectText?.trim().split("\n");
|
||||
const options = optionValues?.map((value) => {
|
||||
const title = value;
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div key={field.id} className="mb-4 block flex-col sm:flex ">
|
||||
<div className="min-w-48 mb-2 flex-grow">
|
||||
<label
|
||||
id="slug-label"
|
||||
htmlFor="slug"
|
||||
className="flex text-sm font-medium text-neutral-700 dark:text-white">
|
||||
{field.label}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex rounded-sm">
|
||||
<Component
|
||||
value={response[field.id]?.value}
|
||||
// required property isn't accepted by query-builder types
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
/* @ts-ignore */
|
||||
required={!!field.required}
|
||||
listValues={options}
|
||||
setValue={(value) => {
|
||||
setResponse((response) => {
|
||||
response = response || {};
|
||||
return {
|
||||
...response,
|
||||
[field.id]: {
|
||||
label: field.label,
|
||||
value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
loading={responseMutation.isLoading}
|
||||
type="submit"
|
||||
className="dark:text-darkmodebrandcontrast text-brandcontrast bg-brand dark:bg-darkmodebrand relative inline-flex items-center rounded-sm border border-transparent px-3 py-2 text-sm font-medium hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
loading={responseMutation.isLoading}
|
||||
type="submit"
|
||||
className="dark:text-darkmodebrandcontrast text-brandcontrast bg-brand dark:bg-darkmodebrand relative inline-flex items-center rounded-sm border border-transparent px-3 py-2 text-sm font-medium hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mx-auto my-0 max-w-3xl md:my-24">
|
||||
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
|
||||
<div className="-mx-4 rounded-sm border border-neutral-200 bg-white p-4 py-6 sm:mx-0 sm:px-8">
|
||||
<div>{customPageMessage}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mx-auto my-0 max-w-3xl md:my-24">
|
||||
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
|
||||
<div className="-mx-4 rounded-sm border border-neutral-200 bg-white p-4 py-6 sm:mx-0 sm:px-8">
|
||||
<div>{customPageMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -216,7 +233,10 @@ function processRoute({ form, response }: { form: Form; response: Response }) {
|
|||
for (const [uuid, { value }] of Object.entries(response)) {
|
||||
responseValues[uuid] = value;
|
||||
}
|
||||
|
||||
if (logic) {
|
||||
// Leave the logs for easy debugging of routing form logic test.
|
||||
console.log("Checking logic with response", logic, responseValues);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
result = jsonLogic.apply(logic as any, responseValues);
|
||||
} else {
|
||||
|
@ -232,8 +252,8 @@ function processRoute({ form, response }: { form: Form; response: Response }) {
|
|||
return decidedAction;
|
||||
}
|
||||
|
||||
export default function RoutingLink({ form }: { form: Form }) {
|
||||
return <RoutingForm form={form} />;
|
||||
export default function RoutingLink({ form, profile }: inferSSRProps<typeof getServerSideProps>) {
|
||||
return <RoutingForm form={form} profile={profile} />;
|
||||
}
|
||||
|
||||
RoutingLink.isThemeSupported = true;
|
||||
|
@ -259,6 +279,15 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
where: {
|
||||
id: formId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!form || form.disabled) {
|
||||
|
@ -269,6 +298,11 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
|
||||
return {
|
||||
props: {
|
||||
profile: {
|
||||
theme: form.user.theme,
|
||||
brandColor: form.user.brandColor,
|
||||
darkBrandColor: form.user.darkBrandColor,
|
||||
},
|
||||
form: getSerializableForm(form),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -36,6 +36,12 @@ Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
|
|||
|
||||
- Hot reload doesn't work with CSS files in the way we use vite.
|
||||
|
||||
## Steps to make a page compatiable with Embed
|
||||
|
||||
- Define `main` class on the element that has the entire content of the page with no auto margins
|
||||
- Adding `main` class allows iframe height to adjust according to it, making sure that the content within `main` is visible without scroll as long as device dimensions permits it.
|
||||
- It also becomes the area beyond which if user clicks, modal-box would close.
|
||||
|
||||
## Known Bugs and Upcoming Improvements
|
||||
|
||||
- Unsupported Browsers and versions. Documenting them and gracefully handling that.
|
||||
|
|
|
@ -10,9 +10,9 @@
|
|||
const url = new URL(document.URL);
|
||||
// Only run the example specified by only=, avoids distraction and faster to test.
|
||||
const only = url.searchParams.get("only");
|
||||
const namespace = only ? only.replace("ns:",""): null
|
||||
if (namespace) {
|
||||
location.hash="#cal-booking-place-" + namespace + "-iframe"
|
||||
const elementIdentifier = only ? only.replace("ns:",""): null
|
||||
if (elementIdentifier) {
|
||||
location.hash="#cal-booking-place-" + elementIdentifier + "-iframe"
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
|
@ -89,6 +89,8 @@
|
|||
<button data-cal-namespace="popupTeamLinkDarkTheme" data-cal-config='{"theme":"dark"}' data-cal-link="team/seeded-team/collective-seeded-team-event">Book with Test Team[Dark Theme]</button>
|
||||
<button data-cal-namespace="popupTeamLinksList" data-cal-link="team/seeded-team/">See Team Links [Auto Theme]</button>
|
||||
<button data-cal-namespace="popupPaidEvent" data-cal-link="pro/paid">Book Paid Event [Auto Theme]</button>
|
||||
<button data-cal-namespace="routingFormAuto" data-cal-link="forms/948ae412-d995-4865-875a-48302588de03">Book through Routing Form [Auto Theme]</button>
|
||||
<button data-cal-namespace="routingFormDark" data-cal-config='{"theme":"dark"}' data-cal-link="forms/948ae412-d995-4865-875a-48302588de03">Book through Routing Form [Dark Theme]</button>
|
||||
<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>
|
||||
|
@ -189,6 +191,22 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="debug" id="cal-booking-place-inline-routing-form">
|
||||
<h2>Inline Routing Form</h2>
|
||||
<div>
|
||||
<i><a href="?only=inline-routing-form">Test in Zen Mode</a></i>
|
||||
</div>
|
||||
<i class="last-action">
|
||||
<i>You would see last Booking page action in my place</i>
|
||||
</i>
|
||||
<div style="display:flex;align-items: center;">
|
||||
<h2 style="width: 30%">
|
||||
On the right side you can book a team meeting =>
|
||||
</h2>
|
||||
<div style="width: 70%" class="place">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const callback = function (e) {
|
||||
|
@ -380,6 +398,28 @@
|
|||
calLink: "free",
|
||||
});
|
||||
}
|
||||
|
||||
if (!only || only === "inline-routing-form") {
|
||||
Cal('init', 'inline-routing-form', {
|
||||
debug: 1,
|
||||
origin: "http://localhost:3000",
|
||||
})
|
||||
Cal.ns['inline-routing-form'](
|
||||
[
|
||||
"inline",
|
||||
{
|
||||
elementOrSelector: "#cal-booking-place-inline-routing-form .place",
|
||||
calLink: "forms/9a7e8801-2f34-45ae-aa79-51859766a860",
|
||||
config: {
|
||||
iframeAttrs: {
|
||||
id: "cal-booking-place-inline-routing-form-iframe"
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Cal('init', 'popupDarkTheme', {
|
||||
debug: 1,
|
||||
origin: "http://localhost:3000",
|
||||
|
@ -416,11 +456,23 @@
|
|||
debug: 1,
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
if (!only || only == "ns:floatingButton") {
|
||||
Cal.ns.floatingButton("floatingButton", {
|
||||
calLink: "pro"
|
||||
|
||||
Cal('init', 'routingFormAuto', {
|
||||
debug: 1,
|
||||
origin: "http://localhost:3000",
|
||||
})
|
||||
}
|
||||
|
||||
Cal('init', 'routingFormDark', {
|
||||
debug: 1,
|
||||
origin: "http://localhost:3000",
|
||||
})
|
||||
|
||||
|
||||
if (!only || only == "ns:floatingButton") {
|
||||
Cal.ns.floatingButton("floatingButton", {
|
||||
calLink: "pro"
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<script></script>
|
||||
</body>
|
||||
|
|
|
@ -71,6 +71,7 @@ export type ExpectedUrlDetails = {
|
|||
};
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace PlaywrightTest {
|
||||
//FIXME: how to restrict it to Frame only
|
||||
interface Matchers<R> {
|
||||
|
@ -78,7 +79,7 @@ declare global {
|
|||
calNamespace: string,
|
||||
getActionFiredDetails: Function,
|
||||
expectedUrlDetails?: ExpectedUrlDetails
|
||||
): R;
|
||||
): Promise<R>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ test("should open embed iframe on click - Configured with light theme", async ({
|
|||
|
||||
embedIframe = await getEmbedIframe({ page, pathname: "/free" });
|
||||
|
||||
expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
|
||||
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
|
||||
pathname: "/free",
|
||||
});
|
||||
expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
|
||||
|
@ -39,3 +39,28 @@ todo("Floating Button Test with Dark Theme");
|
|||
todo("Floating Button Test with Light Theme");
|
||||
|
||||
todo("Add snapshot test for embed iframe");
|
||||
|
||||
test("should open Routing Forms embed on click", async ({
|
||||
page,
|
||||
addEmbedListeners,
|
||||
getActionFiredDetails,
|
||||
}) => {
|
||||
await deleteAllBookingsByEmail("embed-user@example.com");
|
||||
|
||||
const calNamespace = "routingFormAuto";
|
||||
await addEmbedListeners(calNamespace);
|
||||
await page.goto("/?only=prerender-test");
|
||||
let embedIframe = await getEmbedIframe({ page, pathname: "/forms/948ae412-d995-4865-875a-48302588de03" });
|
||||
expect(embedIframe).toBeFalsy();
|
||||
await page.click(
|
||||
`[data-cal-namespace=${calNamespace}][data-cal-link="forms/948ae412-d995-4865-875a-48302588de03"]`
|
||||
);
|
||||
embedIframe = await getEmbedIframe({ page, pathname: "/forms/948ae412-d995-4865-875a-48302588de03" });
|
||||
if (!embedIframe) {
|
||||
throw new Error("Routing Form embed iframe not found");
|
||||
}
|
||||
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
|
||||
pathname: "/forms/948ae412-d995-4865-875a-48302588de03",
|
||||
});
|
||||
await expect(embedIframe.locator("text=Seeded Form - Pro")).toBeVisible();
|
||||
});
|
||||
|
|
|
@ -16,6 +16,8 @@ declare global {
|
|||
CalComPageStatus: string;
|
||||
CalComPlan: string;
|
||||
isEmbed: () => boolean;
|
||||
getEmbedNamespace: () => string | null;
|
||||
getEmbedTheme: () => "dark" | "light" | null;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -197,8 +199,7 @@ function getNamespace() {
|
|||
return embedStore.namespace;
|
||||
}
|
||||
if (isBrowser) {
|
||||
const url = new URL(document.URL);
|
||||
const namespace = url.searchParams.get("embed");
|
||||
const namespace = window.getEmbedNamespace();
|
||||
embedStore.namespace = namespace;
|
||||
return namespace;
|
||||
}
|
||||
|
@ -219,7 +220,7 @@ 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<boolean | null>(null);
|
||||
const [isEmbed, setIsEmbed] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
const namespace = getNamespace();
|
||||
const _isValidNamespace = isValidNamespace(namespace);
|
||||
|
@ -230,7 +231,7 @@ export const useIsEmbed = () => {
|
|||
}
|
||||
setIsEmbed(window.isEmbed());
|
||||
}, []);
|
||||
return _isEmbed;
|
||||
return isEmbed;
|
||||
};
|
||||
|
||||
export const useEmbedType = () => {
|
||||
|
@ -369,7 +370,7 @@ function keepParentInformedAboutDimensionChanges() {
|
|||
|
||||
if (isBrowser) {
|
||||
const url = new URL(document.URL);
|
||||
embedStore.theme = (url.searchParams.get("theme") || "auto") as UiConfig["theme"];
|
||||
embedStore.theme = (window.getEmbedTheme() || "auto") as UiConfig["theme"];
|
||||
if (url.searchParams.get("prerender") !== "true" && window.isEmbed()) {
|
||||
log("Initializing embed-iframe");
|
||||
// HACK
|
||||
|
|
|
@ -1,11 +1,106 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import prisma from ".";
|
||||
|
||||
require("dotenv").config({ path: "../../.env.appStore" });
|
||||
|
||||
async function seedAppData() {
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: "948ae412-d995-4865-875a-48302588de03",
|
||||
},
|
||||
});
|
||||
if (form) {
|
||||
console.log(`Skipping Routing Form - Form Seed, "Seeded Form - Pro" already exists`);
|
||||
return;
|
||||
}
|
||||
await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
id: "948ae412-d995-4865-875a-48302588de03",
|
||||
routes: [
|
||||
{
|
||||
id: "8a898988-89ab-4cde-b012-31823f708642",
|
||||
action: { type: "eventTypeRedirectUrl", value: "pro/30min" },
|
||||
queryValue: {
|
||||
id: "8a898988-89ab-4cde-b012-31823f708642",
|
||||
type: "group",
|
||||
children1: {
|
||||
"8988bbb8-0123-4456-b89a-b1823f70c5ff": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "c4296635-9f12-47b1-8153-c3a854649182",
|
||||
value: ["event-routing"],
|
||||
operator: "equal",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
|
||||
action: { type: "customPageMessage", value: "Custom Page Result" },
|
||||
queryValue: {
|
||||
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b99b8a89-89ab-4cde-b012-31823f718ff5": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "c4296635-9f12-47b1-8153-c3a854649182",
|
||||
value: ["custom-page"],
|
||||
operator: "equal",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
action: { type: "externalRedirectUrl", value: "https://google.com" },
|
||||
queryValue: {
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
type: "group",
|
||||
children1: {
|
||||
"998b9b9a-0123-4456-b89a-b1823f7232b9": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "c4296635-9f12-47b1-8153-c3a854649182",
|
||||
value: ["external-redirect"],
|
||||
operator: "equal",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "898899aa-4567-489a-bcde-f1823f708646",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{ id: "c4296635-9f12-47b1-8153-c3a854649182", type: "text", label: "Test field", required: true },
|
||||
],
|
||||
user: {
|
||||
connect: {
|
||||
username: "pro",
|
||||
},
|
||||
},
|
||||
name: "Seeded Form - Pro",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createApp(
|
||||
/** The App identifier in the DB also used for public page in `/apps/[slug]` */
|
||||
slug: Prisma.AppCreateInput["slug"],
|
||||
|
@ -28,7 +123,7 @@ async function createApp(
|
|||
console.log(`📲 Upserted app: '${slug}'`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
export default async function main() {
|
||||
// Calendar apps
|
||||
await createApp("apple-calendar", "applecalendar", ["calendar"], "apple_calendar");
|
||||
await createApp("caldav-calendar", "caldavcalendar", ["calendar"], "caldav_calendar");
|
||||
|
@ -144,13 +239,6 @@ async function main() {
|
|||
const generatedApp = generatedApps[i];
|
||||
await createApp(generatedApp.slug, generatedApp.dirName, generatedApp.categories, generatedApp.type);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
await seedAppData();
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import { hashPassword } from "@calcom/lib/auth";
|
|||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
|
||||
import prisma from ".";
|
||||
import "./seed-app-store";
|
||||
import mainAppStore from "./seed-app-store";
|
||||
|
||||
require("dotenv").config({ path: "../../.env" });
|
||||
async function createUserAndEventType(opts: {
|
||||
|
@ -564,6 +564,7 @@ async function main() {
|
|||
}
|
||||
|
||||
main()
|
||||
.then(() => mainAppStore())
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
|
|
Loading…
Reference in New Issue
Block a user